Lab L03 · Java + Go
Implementing a concurrent request aggregator
Build a helper that runs a set of named calls concurrently — at most concurrency at a time — waits for all of them, and returns one outcome per name: either the value it returned or the exception it threw. A failing call must not cancel or hide its siblings (“all-settled”, as opposed to fail-fast).
The task
A trip-details screen needs data from four services: driver profile, vehicle, ETA and fare. If the fare service is down you still want to render the other three and show “fare unavailable”, rather than failing the whole page. So the backend fans out the four calls in parallel, waits for every one to settle, and hands the caller a per-call result it can inspect. The aggregator is the reusable piece: it bounds parallelism, validates input up front, and reports success or failure per name.
fare call is recorded, not propagated.What you implement
public final class RequestAggregator {
public record Outcome<V>(V value, Throwable failure) {} // failure == null means success
public static <V> Map<String, Outcome<V>> run(
Map<String, Callable<V>> tasks, // name -> call
int concurrency, // max calls running at once
int maxTasks, // max size of 'tasks'
BooleanSupplier cancelled) // checked before each call starts
throws InterruptedException;
}
Rules
concurrency <= 0,maxTasks <= 0ortasks.size() > maxTasks→IllegalArgumentExceptionbefore anything runs. Null names or callables →NullPointerException.- The result has exactly one entry per input name; an empty input returns an empty map. The returned map is unmodifiable and independent of the input map.
- Success →
Outcome(value, null). A call that throws →Outcome(null, e)whereeis the exception the call threw — not anExecutionExceptionwrapper. - Never more than
concurrencycalls execute simultaneously. - If
cancelledreturns true when a call is about to start, that call is not invoked and its outcome is aCancellationExceptionfailure. Calls already running are not interrupted. runreturns only after every call has settled.
Worked example
- Input:
profile→ returns"Ana",fare→ throwsIOException,eta→ returns4;concurrency = 2,maxTasks = 3. - Result:
eta → (4, null),fare → (null, IOException),profile → ("Ana", null). At no moment were three calls running. - Same input with
maxTasks = 2→IllegalArgumentException; no call ran.
Files and commands
Read the tests first, then replace each TODO in the starter until they pass. Java starter: RequestAggregator.java · tests: RequestAggregatorTest.java. Go starter: aggregate.go · tests: aggregate_test.go.
cd java
./gradlew test --tests 'labs.fanout.*'
cd go
go test -race -count=1 ./fanout
Reveal the reference solutions
Java: RequestAggregator.java
Go: aggregate.go
When to use
Use this lab when a response depends on several independently useful calls and partial success is acceptable. Each named task yields a value or a failure. The aggregator accepts only a configured maximum number of tasks, limits concurrent execution, and returns one outcome per input task. It uses an all-settled policy: a failed dependency does not automatically cancel healthy siblings. Input task names are unique by the map contract.
The examples are local orchestration, not network clients. They do not configure HTTP timeouts, retries, per-host connection pools, or circuit breakers. Tasks must be safe to execute concurrently, and callers must not mutate the input map during validation and copying. The result collection is independent of the input but returned values may still alias caller-owned mutable data.
Steps
- Choose failure policy first. All-settled aggregation preserves useful outcomes from healthy dependencies. If a product instead requires all dependencies, a fail-fast policy may be preferable, but it must still own cancellation and completion of siblings. Do not silently mix these two contracts.
- Bound input and execution. Validate positive limits and reject a task count beyond the configured maximum before starting any work. Go dispatches a copied finite set through a fixed worker population. Java creates a fixed executor and submits only the validated finite task set; its internal queue is bounded by that per-call input limit, not a service-wide quota.
- Carry cancellation deliberately. Go passes the caller context to running tasks and marks unstarted tasks cancelled if it is already done. Java polls a caller-supplied cancellation signal before dispatch; an already-running callable needs its own cancellation or deadline mechanism. Do not assume the Boolean signal interrupts IO. Neither API returns while its owned workers remain unfinished.
- Observe every outcome. Go converts recovered task panics into failures; Java futures expose exceptions. Collect results under a lock or through owned futures and wait for all tasks. The API error describes invalid orchestration input; individual dependency failures belong in the named outcome collection.
- Test partial success. Include a successful dependency and one that fails and require both outcomes. With a cancelled input, require that unstarted task bodies are not invoked. Instrument active Go calls to verify the concurrency cap. Add latch-controlled tests for sustained overlap if throughput behavior is consequential.
- State deadline limits. A timeout signal is not a hard return guarantee. A dependency that ignores its context, interruption, or transport deadline can stall aggregation indefinitely. Java executor closing joins owned tasks, including after interruption. If a hard response deadline is required, isolate execution resources and define who owns work that outlives the response rather than leaking it accidentally.
Checklist
Recall and transport variation
Rebuild all-settled collection before reading Go or Java. The paired httpio packages supply bounded-body clients and controlled local-server tests for deadlines and chunked responses. Variation: one dependency exceeds its body limit while another succeeds. Acceptance: named outcomes distinguish the failure and successful value, and owned calls finish before dependencies are released.
- Partial-result policy is explicitly all-settled or fail-fast
- The number of tasks and concurrent calls have separate bounds
- Failure collection includes worker exceptions
- Running dependencies receive an effective cancellation/deadline mechanism
- Caller completion is not confused with a cancellation request
Signals
Working: one failed dependency does not erase useful results and cancelled unstarted calls do not run.
Going wrong: an executor's unbounded queue is fed an unbounded request set, a future is never inspected, or a hard timeout is promised while arbitrary dependency code can still block forever. Per-call bounds also multiply across concurrent aggregations; add a shared bulkhead when the service needs a global limit.
Related
- Implementing a Bounded Worker Pool, for service-level admission and execution limits.
- Implementing Per-Key Request Coalescing, because shared loads have different cancellation ownership.