Skip to content

Commit 5f8ec4a

Browse files
authored
Merge pull request #131 from graphql-java/ticker_mode_on_scheduled_registry
Ticker mode on ScheduledDataLoaderRegistry
2 parents 442edf4 + 46ce173 commit 5f8ec4a

File tree

5 files changed

+291
-30
lines changed

5 files changed

+291
-30
lines changed

README.md

+89
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,95 @@ since it was last dispatched".
583583
The above acts as a kind of minimum batch depth, with a time overload. It won't dispatch if the loader depth is less
584584
than or equal to 10 but if 200ms pass it will dispatch.
585585

586+
## Chaining DataLoader calls
587+
588+
It's natural to want to have chained `DataLoader` calls.
589+
590+
```java
591+
CompletableFuture<Object> chainedCalls = dataLoaderA.load("user1")
592+
.thenCompose(userAsKey -> dataLoaderB.load(userAsKey));
593+
```
594+
595+
However, the challenge here is how to be efficient in batching terms.
596+
597+
This is discussed in detail in the https://github.com/graphql-java/java-dataloader/issues/54 issue.
598+
599+
Since CompletableFuture's are async and can complete at some time in the future, when is the best time to call
600+
`dispatch` again when a load call has completed to maximize batching?
601+
602+
The most naive approach is to immediately dispatch the second chained call as follows :
603+
604+
```java
605+
CompletableFuture<Object> chainedWithImmediateDispatch = dataLoaderA.load("user1")
606+
.thenCompose(userAsKey -> {
607+
CompletableFuture<Object> loadB = dataLoaderB.load(userAsKey);
608+
dataLoaderB.dispatch();
609+
return loadB;
610+
});
611+
```
612+
613+
The above will work however the window of batching together multiple calls to `dataLoaderB` will be very small and since
614+
it will likely result in batch sizes of 1.
615+
616+
This is a very difficult problem to solve because you have to balance two competing design ideals which is to maximize the
617+
batching window of secondary calls in a small window of time so you customer requests don't take longer than necessary.
618+
619+
* If the batching window is wide you will maximize the number of keys presented to a `BatchLoader` but your request latency will increase.
620+
621+
* If the batching window is narrow you will reduce your request latency, but also you will reduce the number of keys presented to a `BatchLoader`.
622+
623+
624+
### ScheduledDataLoaderRegistry ticker mode
625+
626+
The `ScheduledDataLoaderRegistry` offers one solution to this called "ticker mode" where it will continually reschedule secondary
627+
`DataLoader` calls after the initial `dispatch()` call is made.
628+
629+
The batch window of time is controlled by the schedule duration setup at when the `ScheduledDataLoaderRegistry` is created.
630+
631+
```java
632+
ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
633+
634+
ScheduledDataLoaderRegistry registry = ScheduledDataLoaderRegistry.newScheduledRegistry()
635+
.register("a", dataLoaderA)
636+
.register("b", dataLoaderB)
637+
.scheduledExecutorService(executorService)
638+
.schedule(Duration.ofMillis(10))
639+
.tickerMode(true) // ticker mode is on
640+
.build();
641+
642+
CompletableFuture<Object> chainedCalls = dataLoaderA.load("user1")
643+
.thenCompose(userAsKey -> dataLoaderB.load(userAsKey));
644+
645+
```
646+
When ticker mode is on the chained dataloader calls will complete but the batching window size will depend on how quickly
647+
the first level of `DataLoader` calls returned compared to the `schedule` of the `ScheduledDataLoaderRegistry`.
648+
649+
If you use ticker mode, then you MUST `registry.close()` on the `ScheduledDataLoaderRegistry` at the end of the request (say) otherwise
650+
it will continue to reschedule tasks to the `ScheduledExecutorService` associated with the registry.
651+
652+
You will want to look at sharing the `ScheduledExecutorService` in some way between requests when creating the `ScheduledDataLoaderRegistry`
653+
otherwise you will be creating a thread per `ScheduledDataLoaderRegistry` instance created and with enough concurrent requests
654+
you may create too many threads.
655+
656+
### ScheduledDataLoaderRegistry dispatching algorithm
657+
658+
When ticker mode is **false** the `ScheduledDataLoaderRegistry` algorithm is as follows :
659+
660+
* Nothing starts scheduled - some code must call `registry.dispatchAll()` a first time
661+
* Then for every `DataLoader` in the registry
662+
* The `DispatchPredicate` is called to test if the data loader should be dispatched
663+
* if it returns **false** then a task is scheduled to re-evaluate this specific dataloader in the near future
664+
* If it returns **true**, then `dataLoader.dispatch()` is called and the dataloader is not rescheduled again
665+
* The re-evaluation tasks are run periodically according to the `registry.getScheduleDuration()`
666+
667+
When ticker mode is **true** the `ScheduledDataLoaderRegistry` algorithm is as follows:
668+
669+
* Nothing starts scheduled - some code must call `registry.dispatchAll()` a first time
670+
* Then for every `DataLoader` in the registry
671+
* The `DispatchPredicate` is called to test if the data loader should be dispatched
672+
* if it returns **false** then a task is scheduled to re-evaluate this specific dataloader in the near future
673+
* If it returns **true**, then `dataLoader.dispatch()` is called **and** a task is scheduled to re-evaluate this specific dataloader in the near future
674+
* The re-evaluation tasks are run periodically according to the `registry.getScheduleDuration()`
586675

587676
## Other information sources
588677

src/main/java/org/dataloader/registries/ScheduledDataLoaderRegistry.java

+74-28
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,31 @@
2525
* This will continue to loop (test false and reschedule) until such time as the predicate returns true, in which case
2626
* no rescheduling will occur, and you will need to call dispatch again to restart the process.
2727
* <p>
28+
* In the default mode, when {@link #tickerMode} is false, the registry will continue to loop (test false and reschedule) until such time as the predicate returns true, in which case
29+
* no rescheduling will occur, and you will need to call dispatch again to restart the process.
30+
* <p>
31+
* However, when {@link #tickerMode} is true, the registry will always reschedule continuously after the first ever call to {@link #dispatchAll()}.
32+
* <p>
33+
* This will allow you to chain together {@link DataLoader} load calls like this :
34+
* <pre>{@code
35+
* CompletableFuture<String> future = dataLoaderA.load("A")
36+
* .thenCompose(value -> dataLoaderB.load(value));
37+
* }</pre>
38+
* <p>
39+
* However, it may mean your batching will not be as efficient as it might be. In environments
40+
* like graphql this might mean you are too eager in fetching. The {@link DispatchPredicate} still runs to decide if
41+
* dispatch should happen however in ticker mode it will be continuously rescheduled.
42+
* <p>
43+
* When {@link #tickerMode} is true, you really SHOULD close the registry say at the end of a request otherwise you will leave a job
44+
* on the {@link ScheduledExecutorService} that is continuously dispatching.
45+
* <p>
2846
* If you wanted to create a ScheduledDataLoaderRegistry that started a rescheduling immediately, just create one and
2947
* call {@link #rescheduleNow()}.
3048
* <p>
49+
* By default, it uses a {@link Executors#newSingleThreadScheduledExecutor()}} to schedule the tasks. However, if you
50+
* are creating a {@link ScheduledDataLoaderRegistry} per request you will want to look at sharing this {@link ScheduledExecutorService}
51+
* to avoid creating a new thread per registry created.
52+
* <p>
3153
* This code is currently marked as {@link ExperimentalApi}
3254
*/
3355
@ExperimentalApi
@@ -37,13 +59,15 @@ public class ScheduledDataLoaderRegistry extends DataLoaderRegistry implements A
3759
private final DispatchPredicate dispatchPredicate;
3860
private final ScheduledExecutorService scheduledExecutorService;
3961
private final Duration schedule;
62+
private final boolean tickerMode;
4063
private volatile boolean closed;
4164

4265
private ScheduledDataLoaderRegistry(Builder builder) {
4366
super();
4467
this.dataLoaders.putAll(builder.dataLoaders);
4568
this.scheduledExecutorService = builder.scheduledExecutorService;
4669
this.schedule = builder.schedule;
70+
this.tickerMode = builder.tickerMode;
4771
this.closed = false;
4872
this.dispatchPredicate = builder.dispatchPredicate;
4973
this.dataLoaderPredicates.putAll(builder.dataLoaderPredicates);
@@ -64,6 +88,13 @@ public Duration getScheduleDuration() {
6488
return schedule;
6589
}
6690

91+
/**
92+
* @return true of the registry is in ticker mode or false otherwise
93+
*/
94+
public boolean isTickerMode() {
95+
return tickerMode;
96+
}
97+
6798
/**
6899
* This will combine all the current data loaders in this registry and all the data loaders from the specified registry
69100
* and return a new combined registry
@@ -127,25 +158,6 @@ public ScheduledDataLoaderRegistry register(String key, DataLoader<?, ?> dataLoa
127158
return this;
128159
}
129160

130-
/**
131-
* Returns true if the dataloader has a predicate which returned true, OR the overall
132-
* registry predicate returned true.
133-
*
134-
* @param dataLoaderKey the key in the dataloader map
135-
* @param dataLoader the dataloader
136-
*
137-
* @return true if it should dispatch
138-
*/
139-
private boolean shouldDispatch(String dataLoaderKey, DataLoader<?, ?> dataLoader) {
140-
DispatchPredicate dispatchPredicate = dataLoaderPredicates.get(dataLoader);
141-
if (dispatchPredicate != null) {
142-
if (dispatchPredicate.test(dataLoaderKey, dataLoader)) {
143-
return true;
144-
}
145-
}
146-
return this.dispatchPredicate.test(dataLoaderKey, dataLoader);
147-
}
148-
149161
@Override
150162
public void dispatchAll() {
151163
dispatchAllWithCount();
@@ -157,11 +169,7 @@ public int dispatchAllWithCount() {
157169
for (Map.Entry<String, DataLoader<?, ?>> entry : dataLoaders.entrySet()) {
158170
DataLoader<?, ?> dataLoader = entry.getValue();
159171
String key = entry.getKey();
160-
if (shouldDispatch(key, dataLoader)) {
161-
sum += dataLoader.dispatchWithCounts().getKeysCount();
162-
} else {
163-
reschedule(key, dataLoader);
164-
}
172+
sum += dispatchOrReschedule(key, dataLoader);
165173
}
166174
return sum;
167175
}
@@ -196,19 +204,42 @@ public void rescheduleNow() {
196204
dataLoaders.forEach(this::reschedule);
197205
}
198206

207+
/**
208+
* Returns true if the dataloader has a predicate which returned true, OR the overall
209+
* registry predicate returned true.
210+
*
211+
* @param dataLoaderKey the key in the dataloader map
212+
* @param dataLoader the dataloader
213+
*
214+
* @return true if it should dispatch
215+
*/
216+
private boolean shouldDispatch(String dataLoaderKey, DataLoader<?, ?> dataLoader) {
217+
DispatchPredicate dispatchPredicate = dataLoaderPredicates.get(dataLoader);
218+
if (dispatchPredicate != null) {
219+
if (dispatchPredicate.test(dataLoaderKey, dataLoader)) {
220+
return true;
221+
}
222+
}
223+
return this.dispatchPredicate.test(dataLoaderKey, dataLoader);
224+
}
225+
199226
private void reschedule(String key, DataLoader<?, ?> dataLoader) {
200227
if (!closed) {
201228
Runnable runThis = () -> dispatchOrReschedule(key, dataLoader);
202229
scheduledExecutorService.schedule(runThis, schedule.toMillis(), TimeUnit.MILLISECONDS);
203230
}
204231
}
205232

206-
private void dispatchOrReschedule(String key, DataLoader<?, ?> dataLoader) {
207-
if (shouldDispatch(key, dataLoader)) {
208-
dataLoader.dispatch();
209-
} else {
233+
private int dispatchOrReschedule(String key, DataLoader<?, ?> dataLoader) {
234+
int sum = 0;
235+
boolean shouldDispatch = shouldDispatch(key, dataLoader);
236+
if (shouldDispatch) {
237+
sum = dataLoader.dispatchWithCounts().getKeysCount();
238+
}
239+
if (tickerMode || !shouldDispatch) {
210240
reschedule(key, dataLoader);
211241
}
242+
return sum;
212243
}
213244

214245
/**
@@ -228,6 +259,7 @@ public static class Builder {
228259
private DispatchPredicate dispatchPredicate = DispatchPredicate.DISPATCH_ALWAYS;
229260
private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
230261
private Duration schedule = Duration.ofMillis(10);
262+
private boolean tickerMode = false;
231263

232264
public Builder scheduledExecutorService(ScheduledExecutorService executorService) {
233265
this.scheduledExecutorService = nonNull(executorService);
@@ -298,6 +330,20 @@ public Builder dispatchPredicate(DispatchPredicate dispatchPredicate) {
298330
return this;
299331
}
300332

333+
/**
334+
* This sets ticker mode on the registry. When ticker mode is true the registry will
335+
* continuously reschedule the data loaders for possible dispatching after the first call
336+
* to dispatchAll.
337+
*
338+
* @param tickerMode true or false
339+
*
340+
* @return this builder for a fluent pattern
341+
*/
342+
public Builder tickerMode(boolean tickerMode) {
343+
this.tickerMode = tickerMode;
344+
return this;
345+
}
346+
301347
/**
302348
* @return the newly built {@link ScheduledDataLoaderRegistry}
303349
*/

src/test/java/ReadmeExamples.java

+35-1
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@
1919
import java.time.Duration;
2020
import java.util.ArrayList;
2121
import java.util.Collection;
22+
import java.util.Collections;
2223
import java.util.List;
2324
import java.util.Map;
2425
import java.util.Set;
2526
import java.util.concurrent.CompletableFuture;
2627
import java.util.concurrent.CompletionStage;
28+
import java.util.concurrent.Executors;
29+
import java.util.concurrent.ScheduledExecutorService;
2730
import java.util.function.Function;
2831
import java.util.stream.Collectors;
2932

@@ -304,7 +307,7 @@ public <K, V> CompletionStage<Map<K, V>> scheduleMappedBatchLoader(ScheduledMapp
304307
};
305308
}
306309

307-
private void ScheduledDispatche() {
310+
private void ScheduledDispatcher() {
308311
DispatchPredicate depthOrTimePredicate = DispatchPredicate.dispatchIfDepthGreaterThan(10)
309312
.or(DispatchPredicate.dispatchIfLongerThan(Duration.ofMillis(200)));
310313

@@ -314,4 +317,35 @@ private void ScheduledDispatche() {
314317
.register("users", userDataLoader)
315318
.build();
316319
}
320+
321+
322+
DataLoader<String, User> dataLoaderA = DataLoaderFactory.newDataLoader(userBatchLoader);
323+
DataLoader<User, Object> dataLoaderB = DataLoaderFactory.newDataLoader(keys -> {
324+
return CompletableFuture.completedFuture(Collections.singletonList(1L));
325+
});
326+
327+
private void ScheduledDispatcherChained() {
328+
CompletableFuture<Object> chainedCalls = dataLoaderA.load("user1")
329+
.thenCompose(userAsKey -> dataLoaderB.load(userAsKey));
330+
331+
332+
CompletableFuture<Object> chainedWithImmediateDispatch = dataLoaderA.load("user1")
333+
.thenCompose(userAsKey -> {
334+
CompletableFuture<Object> loadB = dataLoaderB.load(userAsKey);
335+
dataLoaderB.dispatch();
336+
return loadB;
337+
});
338+
339+
340+
ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
341+
342+
ScheduledDataLoaderRegistry registry = ScheduledDataLoaderRegistry.newScheduledRegistry()
343+
.register("a", dataLoaderA)
344+
.register("b", dataLoaderB)
345+
.scheduledExecutorService(executorService)
346+
.schedule(Duration.ofMillis(10))
347+
.tickerMode(true) // ticker mode is on
348+
.build();
349+
350+
}
317351
}

src/test/java/org/dataloader/fixtures/TestKit.java

+27-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.dataloader.MappedBatchLoader;
99
import org.dataloader.MappedBatchLoaderWithContext;
1010

11+
import java.time.Duration;
1112
import java.util.ArrayList;
1213
import java.util.Arrays;
1314
import java.util.Collection;
@@ -61,6 +62,23 @@ public static <K, V> BatchLoader<K, V> keysAsValues(List<List<K>> loadCalls) {
6162
};
6263
}
6364

65+
public static <K, V> BatchLoader<K, V> keysAsValuesAsync(Duration delay) {
66+
return keysAsValuesAsync(new ArrayList<>(), delay);
67+
}
68+
69+
public static <K, V> BatchLoader<K, V> keysAsValuesAsync(List<List<K>> loadCalls, Duration delay) {
70+
return keys -> CompletableFuture.supplyAsync(() -> {
71+
snooze(delay.toMillis());
72+
List<K> ks = new ArrayList<>(keys);
73+
loadCalls.add(ks);
74+
@SuppressWarnings("unchecked")
75+
List<V> values = keys.stream()
76+
.map(k -> (V) k)
77+
.collect(toList());
78+
return values;
79+
});
80+
}
81+
6482
public static <K, V> DataLoader<K, V> idLoader() {
6583
return idLoader(null, new ArrayList<>());
6684
}
@@ -73,6 +91,14 @@ public static <K, V> DataLoader<K, V> idLoader(DataLoaderOptions options, List<L
7391
return DataLoaderFactory.newDataLoader(keysAsValues(loadCalls), options);
7492
}
7593

94+
public static <K, V> DataLoader<K, V> idLoaderAsync(Duration delay) {
95+
return idLoaderAsync(null, new ArrayList<>(), delay);
96+
}
97+
98+
public static <K, V> DataLoader<K, V> idLoaderAsync(DataLoaderOptions options, List<List<K>> loadCalls, Duration delay) {
99+
return DataLoaderFactory.newDataLoader(keysAsValuesAsync(loadCalls, delay), options);
100+
}
101+
76102
public static Collection<Integer> listFrom(int i, int max) {
77103
List<Integer> ints = new ArrayList<>();
78104
for (int j = i; j < max; j++) {
@@ -85,7 +111,7 @@ public static <V> CompletableFuture<V> futureError() {
85111
return failedFuture(new IllegalStateException("Error"));
86112
}
87113

88-
public static void snooze(int millis) {
114+
public static void snooze(long millis) {
89115
try {
90116
Thread.sleep(millis);
91117
} catch (InterruptedException e) {

0 commit comments

Comments
 (0)