ADR-0029: Resequencer Continuation Boundary
Date: 2026-06-27 Status: Accepted (Phase 3) References: ADR-0022 (StepLifecycle), ADR-0024 (PipelineOutcome/CamelStop), ADR-0025 (Outcome-aware structural EIPs) Related: Phase 3 — Tasks 1a, 1b, 2, 3 (Resequencer EIP)
Context
The Resequencer EIP reorders incoming message streams back into their original sequence. It has two modes:
- Batch: buffer a window of messages (by size/timeout per correlation key), sort, then burst-emit in order.
- Stream: hold out-of-order messages in a priority queue, emit the contiguous run starting at the next-expected sequence number, with gap detection and capacity management.
The fundamental architectural challenge: a Resequencer receives ONE input exchange via the unary Tower Service<Exchange> contract, but may produce ZERO outputs (buffering), ONE output (normal), or MULTIPLE outputs (batch burst / stream gap drain). This conflicts with the unary pipeline model where call(input) -> Result<Exchange>.
Decision
Continuation-boundary design
The route compiler splits the flat step list at the top-level Resequence boundary into three partitions:
pre_steps → ResequencerService → [post-steps compiled as a BoxProcessor continuation]
precompiles normally into the main pipeline (before the resequencer).postcompiles viacompose_pipeline_with_contractsinto aBoxProcessorcontinuation owned by theResequencerService.- The resequencer is the LAST step of the main pipeline.
ResequencerService::call(input) sends the exchange into a bounded actor channel and returns an ack (Body::Empty + property CAMEL_RESEQUENCER_ACCEPTED=true). The actual reordered payloads flow asynchronously through a post-driver task that drives the continuation:
input → actor channel → policy.accept() → ready exchanges → post-driver → continuation.call(ex)
The whole route is ONE PipelineAssembly (no side-channel) — the resequencer's CompiledStep.lifecycle is Some(Arc<ResequencerService>), so the Phase 0 StepLifecycle drain mechanism reaches it for stop/hot-swap.
Why NOT the aggregator split-route
The Aggregator uses find_top_level_aggregate_requiring_split + two independent SharedPipelines + agg_service side-channel + a warn-and-proceeds hot-swap. The resequencer deliberately avoids this shape:
- The resequencer's single
PipelineAssemblyswaps atomically with full lifecycle drain (unlike the aggregator's two-pipeline shape that cannot drain viaCompiledStep.lifecycle). - No
agg_serviceside-channel — the resequencer IS a pipeline step, reachable by the standard lifecycle drain. - Hot-swap for lifecycle-bearing routes uses the Restart path (stop → drain → swap → start), not a warn-and-proceeds no-op.
Hot-swap drain semantics
On HotSwap: complete in-flight exchanges through the OLD continuation (ADR-0004 in-flight-finishes-old semantics — NOT discard), then quiesce. The StepLifecycle::shutdown ordering:
- Set shutdown flag; close input channel (actor sees EOF).
- Await actor
JoinHandle(bounded deadline). policy.flush()— emit remaining in order via post-driver.- Close post-driver channel sender.
- Await post-driver
JoinHandle(5s deadline). - Drain post-step lifecycles (Phase 3: post-steps with lifecycle are rejected at compile time; this step is a structural placeholder for future use).
Backpressure
The input channel is bounded (tokio::sync::mpsc with configurable capacity, default 1024). Service::call uses send().await — backpressure propagates into the consumer when the actor falls behind.
InOnly/ack semantic consequence
Request-reply final responses are NOT preserved through the unary Tower contract — the route is effectively InOnly past the resequencer. A runtime InOut guard in ResequencerService::call inspects exchange.pattern:
- If
InOutand notallow_inout: true:- Increment a durable metric counter (
resequencer_inout_warnings_total). - Emit a rate-limited
warn!(once per 30s per route, NOT per-exchange). - Set diagnostic property
CAMEL_RESEQUENCER_INOUT_WARN=trueon the ack.
- Increment a durable metric counter (
CamelStop interaction (Phase 2)
The post-driver checks camel_api::is_camel_stop(&ex) before calling the continuation — if true, the exchange is skipped (analogous to route_compiler.rs:345). This prevents downstream processing of stop-signaled exchanges past the resequencer boundary.
Compile-time rejection rules
- N2 (mutual exclusion):
assert_no_mixed_top_level_splitsrejects any route containing BOTH a top-level aggregate-requiring-split step AND a top-levelResequence. The predicate tests EVERY top-levelAggregateforhas_timeout_condition || force_completion_on_stop(NOTfind_top_level_aggregate_requiring_split, whose first-match-then-break under-detects). - N3: Reject more than one top-level
Resequence. - N4: Reject any
Resequencereached viacompile_children(nested inside Choice/Split/Loop/Filter). The step-compiler registry arm returnsRouteError("resequence must be a top-level step")unconditionally.
Post-ack continuation failure taxonomy
A continuation.call(ex) failure happens AFTER call() returned the ack, so the exchange has left the ADR-0019 pipeline loop — RouteErrorHandler is NEVER consulted. The post-driver:
- Logs at
warn!(ADR-0012 best-effort). - Increments
resequencer_post_ack_failures_total{route}metric. - Does NOT count against the route's error budget unless explicitly configured.
Policy trait
#[async_trait]
pub trait ResequencePolicy: Send + Sync + 'static {
async fn accept(&self, input: Exchange) -> Vec<Exchange>;
async fn flush(&self) -> Vec<Exchange>;
fn name(&self) -> &'static str;
fn set_timeout_tx(&self, _tx: mpsc::Sender<Exchange>) { /* default no-op */ }
}
set_timeout_tx has a default no-op implementation — BatchPolicy and StreamPolicy override it to store the driver channel for self-spawned timeout tasks. PassthroughPolicy inherits the no-op.
Stream capacity + gap failure policies
The stream policy's failure modes use honest naming (no false promises of dead-letter routing that isn't wired):
CapacityPolicy::LogAndDrop— logwarn!+ drop the incoming exchange (queue full).GapPolicy::DropAndLog— gap timer fired, drop held exchanges + log.
Future: wire a DLQ sink for both policies.
Scatter-Gather reconciliation (spec §5 correction)
Spec §5 described Scatter-Gather as "feeding a single aggregator with a correlation key." This is INCORRECT — canonical Hohpe/Woolf Scatter-Gather is the stateless form: parallel fan-out to N endpoints, combine N responses into ONE exchange. No correlation key. The stateful form (with correlation key) is the separate Aggregator EIP.
scatter_gather is a pure DSL alias that lowers to Multicast with parallel: true and the configured aggregation strategy (LastWins/CollectAll/Original). No new processor, no new runtime primitive.
Rejected Alternatives
- Pure
Processmode (inline reordering inrun_steps): impossible — the unary Tower contract cannot emit multiple outputs from onecall(). - Couple emit to input (return the burst from
call()): breaks unary semantics and cannot flush on shutdown (no input arrives). - Reopen the spec (change Tower contract to multi-output): loses hot-swap drain and breaks every existing processor.
- Aggregator split-route shape: two pipelines + side-channel — cannot drain via
CompiledStep.lifecycle, hot-swap is a warn-and-proceeds no-op.