Skip to content

Commit 4af262d

Browse files
authored
Remove DRPC per-function request queues once they are empty (#8985)
1 parent c1c3338 commit 4af262d

2 files changed

Lines changed: 140 additions & 13 deletions

File tree

  • storm-server/src

storm-server/src/main/java/org/apache/storm/daemon/drpc/DRPC.java

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.concurrent.ConcurrentHashMap;
2929
import java.util.concurrent.ConcurrentLinkedQueue;
3030
import java.util.concurrent.atomic.AtomicLong;
31+
import java.util.concurrent.atomic.AtomicReference;
3132
import org.apache.storm.DaemonConfig;
3233
import org.apache.storm.daemon.StormCommon;
3334
import org.apache.storm.generated.AuthorizationException;
@@ -145,8 +146,15 @@ private void checkAuthorizationNoLog(String operation, String function) throws A
145146

146147
private void cleanup(String id) {
147148
OutstandingRequest req = requests.remove(id);
148-
if (req != null && !req.wasFetched()) {
149-
queues.get(req.getFunction()).remove(req);
149+
if (req != null) {
150+
queues.computeIfPresent(req.getFunction(), (function, queue) -> {
151+
if (!req.wasFetched()) {
152+
queue.remove(req);
153+
}
154+
//Drop the queue itself once nothing is waiting in it, otherwise the map keeps an
155+
// entry for every function name a client has ever asked about.
156+
return queue.isEmpty() ? null : queue;
157+
});
150158
}
151159
}
152160

@@ -165,16 +173,15 @@ private String nextId() {
165173
return String.valueOf(ctr.incrementAndGet());
166174
}
167175

168-
private ConcurrentLinkedQueue<OutstandingRequest> getQueue(String function) {
176+
private static void checkFunctionName(String function) {
169177
if (function == null) {
170178
throw new IllegalArgumentException("The function for a request cannot be null");
171179
}
172-
ConcurrentLinkedQueue<OutstandingRequest> queue = queues.get(function);
173-
if (queue == null) {
174-
queues.putIfAbsent(function, new ConcurrentLinkedQueue<>());
175-
queue = queues.get(function);
176-
}
177-
return queue;
180+
}
181+
182+
@VisibleForTesting
183+
int getNumTrackedFunctions() {
184+
return queues.size();
178185
}
179186

180187
public void returnResult(String id, String result) throws AuthorizationException {
@@ -190,8 +197,17 @@ public void returnResult(String id, String result) throws AuthorizationException
190197
public DRPCRequest fetchRequest(String functionName) throws AuthorizationException {
191198
meterFetchRequestCalls.mark();
192199
checkAuthorizationNoLog("fetchRequest", functionName);
193-
ConcurrentLinkedQueue<OutstandingRequest> q = getQueue(functionName);
194-
OutstandingRequest req = q.poll();
200+
checkFunctionName(functionName);
201+
//Never create a queue here. A function name comes from the client, so a queue that no one
202+
// ever puts a request into would stay in the map forever. Poll and drop an emptied queue
203+
// under the same lock execute() adds under, so a request can never be left in a queue that
204+
// was just removed from the map.
205+
AtomicReference<OutstandingRequest> polled = new AtomicReference<>();
206+
queues.computeIfPresent(functionName, (function, queue) -> {
207+
polled.set(queue.poll());
208+
return queue.isEmpty() ? null : queue;
209+
});
210+
OutstandingRequest req = polled.get();
195211
if (req != null) {
196212
//Only log accesses that fetched something
197213
logAccess("fetchRequest", functionName);
@@ -219,12 +235,18 @@ public <T extends OutstandingRequest> T execute(String functionName, String func
219235
AuthorizationException {
220236
meterExecuteCalls.mark();
221237
checkAuthorization("execute", functionName);
238+
checkFunctionName(functionName);
222239
String id = nextId();
223240
LOG.debug("Execute {} {}", functionName, funcArgs);
224241
T req = factory.mkRequest(functionName, new DRPCRequest(funcArgs, id));
225242
requests.put(id, req);
226-
ConcurrentLinkedQueue<OutstandingRequest> q = getQueue(functionName);
227-
q.add(req);
243+
queues.compute(functionName, (function, queue) -> {
244+
if (queue == null) {
245+
queue = new ConcurrentLinkedQueue<>();
246+
}
247+
queue.add(req);
248+
return queue;
249+
});
228250
return req;
229251
}
230252

storm-server/src/test/java/org/apache/storm/daemon/drpc/DRPCTest.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@
1818

1919
package org.apache.storm.daemon.drpc;
2020

21+
import java.util.ArrayList;
2122
import java.util.Collections;
2223
import java.util.HashMap;
24+
import java.util.HashSet;
25+
import java.util.List;
2326
import java.util.Map;
27+
import java.util.Set;
2428
import java.util.concurrent.ExecutionException;
2529
import java.util.concurrent.ExecutorService;
2630
import java.util.concurrent.Executors;
@@ -140,6 +144,107 @@ public void testDequeueAfterTimeout() throws Exception {
140144
}
141145
}
142146

147+
@Test
148+
public void testQueuesAreRemovedWhenEmpty() throws Exception {
149+
try (DRPC server = new DRPC(new StormMetricsRegistry(), null, 1000)) {
150+
//Fetching for a function nothing was ever submitted for must not leave state behind
151+
DRPCRequest nothing = server.fetchRequest("never-registered");
152+
assertNotNull(nothing);
153+
assertEquals("", nothing.get_request_id());
154+
assertEquals(0, server.getNumTrackedFunctions());
155+
156+
//A registered function is still served repeatedly, and is not left behind once idle
157+
for (int i = 0; i < 3; i++) {
158+
Future<String> found = exec.submit(() -> server.executeBlocking("testing", "test"));
159+
DRPCRequest request = getNextAvailableRequest(server, "testing");
160+
assertNotNull(request);
161+
server.returnResult(request.get_request_id(), "tested");
162+
assertEquals("tested", found.get(10, TimeUnit.MILLISECONDS));
163+
}
164+
assertEquals(0, server.getNumTrackedFunctions());
165+
166+
//Nor is a function whose only request timed out. The timer thread fails the request
167+
// before it drops the queue, so the caller can return first; wait for the drop instead
168+
// of racing it, with a hard timeout so a real leak still fails the test.
169+
try {
170+
server.executeBlocking("timing-out", "test");
171+
fail("Should have timed out....");
172+
} catch (DRPCExecutionException e) {
173+
assertEquals(DRPCExceptionType.SERVER_TIMEOUT, e.get_type());
174+
}
175+
Awaitility.await("DRPC queue for timing-out to be dropped")
176+
.atMost(5, TimeUnit.SECONDS)
177+
.pollInterval(1, TimeUnit.MILLISECONDS)
178+
.until(() -> server.getNumTrackedFunctions() == 0);
179+
}
180+
}
181+
182+
@Test
183+
public void testConcurrentExecuteAndFetchLosesNoRequests() throws Exception {
184+
//A bounded pool of 16 threads is what keeps this cheap: executeBlocking() parks its caller,
185+
// so an unbounded pool would need one live thread per outstanding request. The request
186+
// count costs no threads at all, and is what gives the stress test its power. Measured
187+
// against a fetchRequest() whose poll/remove escapes the per-function compute lock, the
188+
// lost request was caught 1 run in 25 at 200 requests, 3 in 10 at 2000 and 9 in 10 at 5000,
189+
// while a correct server still serves all 5000 in about a second with every core busy.
190+
final int numRequests = 5000;
191+
final int numThreads = 16;
192+
final long deadlineMs = 30_000;
193+
//A timeout far beyond the test deadline, so the cleanup timer never reaps a live request.
194+
try (DRPC server = new DRPC(new StormMetricsRegistry(), null, 300_000)) {
195+
ExecutorService submitters = Executors.newFixedThreadPool(numThreads);
196+
try {
197+
List<Future<String>> futures = new ArrayList<>(numRequests);
198+
for (int i = 0; i < numRequests; i++) {
199+
final String args = "test-" + i;
200+
futures.add(submitters.submit(() -> server.executeBlocking("testing", args)));
201+
}
202+
203+
Set<String> servedIds = new HashSet<>();
204+
long deadline = Time.currentTimeMillis() + deadlineMs;
205+
int emptyFetches = 0;
206+
while (servedIds.size() < numRequests) {
207+
if (Time.currentTimeMillis() > deadline) {
208+
fail("Only served " + servedIds.size() + " of " + numRequests
209+
+ " requests within " + deadlineMs + "ms, a request was lost");
210+
}
211+
DRPCRequest req = server.fetchRequest("testing");
212+
assertNotNull(req);
213+
String id = req.get_request_id();
214+
if (id.isEmpty()) {
215+
//Nothing to serve right now. Spin at first, so fetches keep interleaving
216+
// tightly with the submitting threads, and only back off if this goes on
217+
// for a long time (a regression, which the deadline above then fails).
218+
if (++emptyFetches > 10_000) {
219+
TimeUnit.MILLISECONDS.sleep(1);
220+
} else {
221+
Thread.onSpinWait();
222+
}
223+
continue;
224+
}
225+
emptyFetches = 0;
226+
assertTrue(servedIds.add(id), "Request " + id + " was fetched more than once");
227+
server.returnResult(id, "tested-" + id);
228+
}
229+
230+
Set<String> results = new HashSet<>();
231+
for (Future<String> f : futures) {
232+
long left = deadline - Time.currentTimeMillis();
233+
assertTrue(left > 0, "Ran out of time waiting for the blocked callers");
234+
assertTrue(results.add(f.get(left, TimeUnit.MILLISECONDS)), "Duplicate result returned");
235+
}
236+
assertEquals(numRequests, results.size());
237+
for (String id : servedIds) {
238+
assertTrue(results.contains("tested-" + id), "No caller got the result for " + id);
239+
}
240+
//Nothing is waiting any more, so no per-function queue may be left behind
241+
assertEquals(0, server.getNumTrackedFunctions());
242+
} finally {
243+
submitters.shutdownNow();
244+
}
245+
}
246+
}
247+
143248
@Test
144249
public void testDeny() {
145250
try (DRPC server = new DRPC(new StormMetricsRegistry(), new DenyAuthorizer(), 100)) {

0 commit comments

Comments
 (0)