Abstract
Every routed application event enters the core as one Message, and the whole system turns on one design decision: the transport is dumb, the domain lives behind ports, and what happens to a message is decided by composing a fixed chain of tiny stages — never by branching business logic through the socket. The core is a two-chambered heart: an inbound router that turns a frame into durable state plus an acknowledgement, and an outbound router that turns a durable row into a live push. The entrance is a pure resolver that reads a message's type and returns a route — the name of a bead-string of stages. From there the router makes one promise and keeps it totally: the message traverses every stage to a terminal FinalStage, its outcomes packed into a shared context, faults absorbed at each seam rather than thrown. A stage is a two-line interface that can map, filter, mediate, authorize, persist, cache, deliver, acknowledge, audit, group, coalesce, or fire a separate async process — assembled like beads. And the slow, bursty, dangerous work is pushed out to vessels: back-pressured publishers and coalescing batchers that live in a separate high-load module, are configured apart, carry their own metrics, and self-tune in production — shedding at the valves and slowing the poller's pulse under load, with a durable log underneath so slowing down never means losing. If the router is the heart, the publishers are the valves and vessels; this paper is the anatomy of both.
The router is the core
A messenger is a crowd of message kinds — chat, signaling, typing, presence, read receipts, system notices, group fan-out. The naïve design branches all of them through the transport. This one does the opposite.
- The transport is dumb. The WebSocket handler validates a frame and stamps the authentic sender — and stops. It holds no business logic.
- The domain is behind ports. Durability, delivery, idempotency, the watermark, the send-guard — every one is a port the stages call, never an adapter they import.
- Capability is composed, not branched. A new message kind is a new route declaration — one line in the routing map — plus a chain of (mostly existing) stages. You add a bead to a string; you don't add an
ifto the socket.
The router does not dispatch a message to a handler. It shepherds the message, in a context, through a fixed itinerary — to a definite end.
Two routers, one anatomy
There is exactly one router per direction, and each is fed not by a direct call but by a bounded, back-pressured publisher (§05). They have the same anatomy — resolve a route, run the message through a fixed chain of stages to FinalStage, packing every outcome into a shared context. The only difference is which beads are on the string.
The inbound chamber carries one hot-path detail worth naming, because throughput lives here: the ingest chain runs application-scoped stages on reactive clients and needs no propagated thread context, so it turns per-message context capture off entirely.
// MessageInboundRouter.routeIn — a saturated JFR showed ~30% of CPU in per-message SmallRye // context capture (SlowCapturedContextState); disabling it is pure ingest headroom. @CurrentThreadContext(propagated = {}, unchanged = ThreadContext.ALL_REMAINING) public Uni<MessageEnvelope<Message>> routeIn(Message message) { … }
A resolver reads the type and picks the route
A message carries a type, not logic. The entrance to the heart is a pure resolver that reads the type and returns a route — a PipelineType, which is nothing but the name of a fixed string of stages. No DB, no content branching, no business rules: an EnumMap lookup, and SKIP straight to Final for anything malformed.
// MessagePipelineResolver — a pure EnumMap<MessageType, PipelineType>. CHAT_IN → INBOUND_PERSISTENT SIGNAL_IN → INBOUND_CACHED READ_IN → READ CHAT_ACK → ACK_PERSISTENT TYPING_IN → INBOUND_CACHED CHAT_OUT → OUTBOUND_CACHED SYSTEM_OUT → OUTBOUND_TRANSIENT PRESENT_* → INBOUND_DIRECT ? / null → SKIP
There is exactly one thing the pure table cannot decide — DIRECT vs GROUP — because that needs a lookup. So the router refines the route at runtime through a neutral ConversationResolver port (one cached UNION over the direct and group tables): a persistent or typing frame into a group promotes to the group pipeline. The lookup runs only for the two group-capable types; everything else short-circuits.
Every route runs to Final, reacting to errors, packing the message in a context
Once a route is chosen, the router's promise is simple and total: the message traverses every stage of that route and reaches FinalStage — whatever happens on the way. It folds the message into a RouterContext and threads it through the stages, checking for an error at each seam and short-circuiting to Final on rejection rather than throwing.
// MessageInboundRouter.run — the DIRECT chat route, assembled as beads. case INBOUND_PERSISTENT -> authorizationStage.apply(context) .onItem().transformToUni(ctx -> ctx.hasError() ? finalStage.apply(ctx) // reject → straight to Final : outboxStage.apply(ctx) // persist (history + outbox) .onItem().transformToUni(ackStage::apply) // ack SENT to the sender .onItem().transformToUni(asyncHandoffStage::apply) // hand delivery off (fly away) .onItem().transformToUni(finalStage::apply)); // done
The RouterContext is the bag that travels the string of beads — the payload plus four per-concern outcomes, each a StageResult. FinalStage reads the one that defines done for the strategy and emits a terminal MessageEnvelope.
| Outcome | Set by | Means |
|---|---|---|
| persisted | Outbox / GroupOutbox | written to history + outbox |
| delivered | Delivery | pushed to the recipient's live session |
| cached | Cache / CleanCache | present in the idempotency / redelivery cache |
| acknowledged | Ack / TetrisAck | SENT-ack sent · recipient ACK processed |
Two invariants make the guarantee real. No stage failure escapes the pipeline boundary. A stage may fail internally — a DB error, a dead socket — but every stage ends .onFailure().invoke(setError).recoverWithItem(ctx), so the failure is caught, recorded on the context as error, and the same context flows on; it is converted into state, never thrown out of the chain. And the route always terminates at FinalStage — error or success, group or 1:1, every arm ends there, so every message produces a definite processed = true/false and nothing dangles.
a rule for the context, so it never becomes a god-object
The RouterContext holds only route-local execution state and the four stage outcomes — never domain state. A stage keeps its working data private unless a later stage genuinely needs it; a new stage does not get to bolt an arbitrary field onto the shared bag. That is what keeps "one context down the chain" from silently growing into a dumping ground.
A two-line interface that can be anything
Every bead on every string implements one interface. That is the entire extension surface of the system:
public interface PipelineStage<T> {
Uni<T> apply(T ctx); // do ONE thing · record the outcome on the context · failures stay inside the pipeline
}
A stage is a CDI bean the router injects and threads into chains. Behind that two-line contract, a stage can be anything a step in a message's life needs to be — and the catalogue already spans the lot:
| A stage can… | …realised by |
|---|---|
| map / normalise | InboundPrototype — fresh server ULID, IN→OUT flip, server timestamp |
| filter / mediate / authorize | AuthorizationStage, GroupAuthorizeStage — membership + not-blocked; re-stamps the authentic recipient (client value never trusted) |
| persist | OutboxStage (atomic history+outbox); GroupOutboxStage (the multiplier: 1 log + N outbox) |
| deliver / send | DeliveryStage (live WS push with backoff); AckStage (build + send the sender's SENT ack) |
| idempotency / cache | CacheStage (guard double-delivery); CleanCacheStage (evict on ACK) |
| audit / mark / watermark | TetrisSentStage / TetrisAckStage (pin & advance the GC watermark); RouterLogger (structured in/out audit) |
| group / fan-out | GroupOutboxStage, GroupHandoffStage, GroupTypingStage — N recipients behind a GroupSend port |
| fire a separate async process | AsyncHandoffStage (delivery flies off); OfflineCallPushStage (offline call push) |
| read-model side-effect | ReadStage — mark READ + push READ_OUT |
You compose capability like beads on a string — chat is authorize → persist → ack → hand-off; signaling is authorize → deliver → cache; a recipient ACK is tetris-ack → clean-cache. The router never grows business logic; it grows a switch arm. A concrete bead is exactly as small as the contract promises:
// AsyncHandoffStage — "fly the delivery off the inbound chain." Two lines of body. @ApplicationScoped @RequiredArgsConstructor public class AsyncHandoffStage implements PipelineStage<RouterContext<Message>> { private final HandoffSubPipeline handoff; public Uni<RouterContext<Message>> apply(RouterContext<Message> ctx) { return handoff.handoff(ctx); } }
Everything a stage touches — durability, idempotency, the watermark, delivery — is a port, so the beads stay pure and the substrate stays swappable. An ArchUnit rule fails the build if a router ever depends on a business port or the delivery publisher, so this stays honest.
The valves and vessels — a separate high-load kernel
A stage does its one thing and returns. But some of that "one thing" is slow, bursty, or fire-and-forget: delivering to a laggy socket, writing N rows to Postgres, pushing a Kafka notification to an offline user. The router refuses to pay those costs on the thread keeping ingest fast — so the heavy work is handed to vessels, and the vessels live in their own module, org.hormigas:messenger-hl, wired in at a thin edge. The core and domain never see Micrometer, Quarkus, or the kernel internals; the vessels are configured apart, carry their own metrics, and self-tune.
If the router is the heart, the publishers are the valves and vessels — pumping work into the chambers, carrying side-work off them, and clamping down when the pressure rises.
Every vessel is one of two primitives. The first is the back-pressured publisher — a wheel: a bounded queue on one side and, on the other, a fixed number of parallel continuations pulling from it (transformToUni(sink).merge(concurrency)). Producers call publish(item); when the queue is full it returns false — the caller sheds rather than blocking.
the second primitive — the coalescing vessel
A batcher (GroupCommitBatcher, CoalescingBatcher) accumulates items into a buffer that closes on size or time (max-size / linger-ms), then processes the buffer as one unit — a single transaction, or a single keep-latest merge — across max-concurrent-batches, and drain(timeout) on shutdown. Group-commit turns a per-message cost into a per-batch cost; coalescing turns a storm of updates for one key into one. The persist path uses it so a flood of chats commits as a few large saveBatch transactions, and a poisoned batch is retried row by row so one bad row can't fail its 255 neighbours.
Intake, offload, coalesce — the standard models
Vessels serve exactly three purposes: intake (give a chamber its inflow), offload (carry an async side-job off a stage so the pipeline flies on), and coalesce (buffer a storm into few commits). Here is the real set.
| Vessel | Role | Primitive | Feeds / does |
|---|---|---|---|
| InboundPublisher | intake | wheel | WS frames → routeIn |
| RoutingBackpressurePublisher | intake | wheel | durable outbox rows (poller) → routeOut — the one vessel that drives the reflex (§07) |
| FastDeliveryPublisher | offload | wheel | AsyncHandoff → routeOut on its own subscription — live delivery decoupled from persist |
| InboundPersistBatcher | coalesce | group-commit | persist stage → one saveBatch tx of N; poison rows retried individually |
| OfflinePusherBatcher → KafkaOfflineEventPublisher | offload + coalesce | coalescing → Kafka | undelivered-to-offline → one Kafka push per (recipient,conversation,call) → topic chat.offline.notify |
| ReadReceiptBatcher | coalesce | coalescing | write-behind receipts (keep MAX readId) → 1 UPDATE + 1 coalesced READ_OUT |
| AttachmentReclaimPublisher | offload | wheel | drain PENDING_DELETE off the delete hot-path → delete object + mark ORPHANED |
| RosterEventFanout | offload | reuses FastDelivery | group roster → ephemeral SERVICE_OUT per member |
Read the anatomy across the table: the two intake wheels are the heart's inflow valves; FastDelivery is the artery the inbound chamber pumps into and then lets go — the message flies off on the wheel's own subscription while inbound returns for the next frame; the Kafka offline-push vessel is the capillary carrying a notification to a user who isn't even connected; and the coalescing batchers turn a flood into a steady drip, so Postgres and Redis see few, large operations instead of many small ones.
What a shed means — and where "no loss" begins
Every wheel sheds the same way — publish() returns false — but the consequence is not the same, and being sloppy about it would overclaim the guarantee. It turns entirely on one line: durable acceptance. A shed before the outbox write is an admission decision; a shed after it is only a delivery decision.
| Vessel | A shed means | Because |
|---|---|---|
| InboundPublisher · intake | admission rejected — the frame is refused and the sender gets a rate-limited overload notice | before the boundary — nothing is durable yet; this is the one shed that can drop a not-yet-accepted message |
| RoutingBackpressure · poller | the live attempt is deferred — the row stays in the outbox and is re-polled | after the boundary — the row is already durable |
| FastDelivery · fast-path | the live attempt is deferred to the catch-up poller | after the boundary — persisted before the hand-off |
Shedding a delivery is a delivery decision, not a durability decision. The no-loss guarantee begins at durable acceptance; before it, admission may be refused under overload — and the design says so out loud.
done and nothing accepted is lost.How the vessels self-tune under load
The vessels don't just shed; the outbound path regulates its own pulse. A closed loop — an autonomic reflex — watches the outbound wheel and slows the poller when delivery backs up, then eases it back as things clear. It is the one genuinely self-tuning mechanism in the engine, and it is worth being precise about, including what it does not do.
- Signal. Only the outbound wheel carries an event provider. On a shed it bumps a
dropSplashand fires a health event; on each clean delivery it decrements the splash and fires an ease-back event — but only while a splash is outstanding (hysteresis: the interval eases back the same number of notches it grew). The fast-path and inbound wheels shed silently — they tune nothing. - Regulate. An
@ObservesAsyncobserver feeds aFeedbackRegulator. Dropped →interval = min(max, interval × 2.0); clean →interval = max(base, interval × 0.5). Base 1s, cap 30s. - Actuate. The
OutboxPollerdelays each fetch by the current interval. So the fixed 500ms tick is augmented by an adaptive extra delay: under sustained sheds the poller backs off — 1s up to 30s — and stops refilling a queue that is already draining slowly.
There is a second, cruder lever on the same poller: skip-while-busy. Before fetching, the poller checks whether the outbound wheel still has work and skips the fetch entirely if so — so peak in-flight is bounded near one outbound.batch-size regardless of cadence. The system's answer to a load spike is therefore never to fall over and never to buffer unboundedly: it is to shed at the valves and slow the pulse at the poller until the pressure clears — pure back-pressure, with a durable log underneath so slowing down never means losing.
Why a drop is the control signal
The regulator watches drops, not queue depth, delivery latency, or in-flight count — and that is a deliberate choice, not an omission. A full bounded queue is the cheapest direct evidence that a vessel has hit capacity: a shed is a binary "I cannot accept more," already computed at the exact instant of saturation, needing no sampling, no percentile, no clock. Drop-frequency is a saturation proxy that is free to observe. That is its strength — and, honestly, its ceiling: it reacts to refusal, not to a queue that is deep-but-still-moving or a downstream that is slow-but-not-yet-shedding. It is a back-pressure reflex, not a full latency-aware load controller — which is exactly why it only moves cadence, and why the durable outbox, not the regulator, is what actually guarantees no loss.
honest boundaries — so no one over-reads the reflex
It tunes poll cadence only, never concurrency — delivery-concurrency / merge(n) are static config; the reflex changes how often the heart refills, not how hard it pumps. Only the outbound / poller vessel drives the loop; the fast-path does not self-throttle — when full it sheds silently and the durable outbox + poller are its safety net (the row is already committed, so a shed live delivery is re-delivered later). And the adaptive poller is prod-profile only.
The properties that fall out of the shape
Nothing above is bespoke cleverness; it is the same few moves — resolve a route, run beads to Final, offload to a vessel, let the vessel shed and self-tune — applied consistently. What the shape buys, each on a concrete mechanism:
Throughput ⟂ latency.
Inbound persist is decoupled from delivery (the hand-off); per-message DB cost is decoupled from arrival rate (group-commit). A slow recipient can't slow the sender's ack.
No loss after durable acceptance.
Past the persistence boundary every shed is only deferred delivery — outbox, catch-up poller, reconnect re-push, idempotency cache. Before it, admission may be refused under overload. The guarantee is honest about where it starts.
Failure isolation, top to bottom.
Stages recover to their context; wheels recover per item and re-subscribe with backoff; a poisoned persist batch retries row-by-row. No single message can down a shared stream.
Self-regulation.
The reflex trades latency for stability under sustained load automatically, with hysteresis, and hands the operator the whole story in metrics on the vessels.
Capability by composition.
A new kind is a resolver row + a chain of mostly-existing stages; fan-out lives behind a port. An ArchUnit rule fails the build if a router depends on a business port or the publisher.
The failure taxonomy — one row per thing that can go wrong
Because failures are converted to state (§03) and sheds are bounded by durability (§06), every failure mode has a named recovery — there is no "and then it crashes" row.
| Failure | Recovery |
|---|---|
| authorization rejected | terminal — short-circuit to Final, processed=false |
| persist transaction transient error | the wheel / batcher retries with backoff |
| one poisoned row in a persist batch | row-level individual retry — its 255 neighbours still commit |
| recipient socket unavailable (offline) | not delivered live → stays in outbox → poller / reconnect re-push |
| a vessel queue is full | shed — admission refused (intake) or deferred to the outbox (delivery) |
| a single poison message on a wheel | recoverWithNull() — dropped from that turn, stream lives |
| terminal stream error on a wheel | re-subscribe with backoff — the wheel is immortal |
Stated plainly: one Message, one resolver, two routers, a string of stages, and a family of vessels. The heart stays small and clean; the vessels do the bursty, slow, dangerous work off to the side and tell you how they're coping. That division — a composable core, and a self-tuning high-load kernel around it — is the whole design. The delivery substrate the outbound chamber feeds (outbox, poller, watermark GC, and its Kafka-portable offset model) is the companion delivery paper; the end-to-end encryption laid over the same transport is the encryption paper.