Hormigas · Messenger · the routing engine

Composable pipelines · bounded vessels · adaptive back-pressure

The heart that routes every message

One core, two chambers — an inbound router that turns a frame into durable truth, an outbound router that pumps it out as a live delivery — and the vessels around them that shed under pressure and slow the pulse when the load spikes.

Hormigas Messenger· two routers · stages & ports· a self-tuning high-load kernel· companion to “The message you can afford to drop”· and the encryption paper

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.

00 — the brief

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 router does not dispatch a message to a handler. It shepherds the message, in a context, through a fixed itinerary — to a definite end.

01 — the heart

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.

WS ingress InboundPublisher INTAKE outbox poller RoutingBackpressure INTAKE InboundRouter .routeIn frame → durable truth + a SENT ack — the chamber fills OutboundRouter .routeOut durable row → live push idempotent — the chamber pumps FastDeliveryPublisher the fly-off artery PORTS — THE ORGANS OutboxManager DeliveryChannel IdempotencyManager TetrisMarker DeadLetterStore OfflineEventPublisher ReadReceipts the stages call these — never an adapter publish poll hand off via ports only
Fig 1 — two chambers, one shape. Three vessels feed the heart; both chambers reach the domain only through ports.

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) { … }
02 — the entrance

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.

Message carries a type, not logic resolve EnumMap: type → PipelineType refine ConversationResolver DIRECT vs GROUP (cached) the route a fixed string of stages type group-capable types only malformed / null SKIP → straight to Final
Fig 2 — choosing what happens to a message is a table lookup, not a branch tree.
03 — the guarantee

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.

The four outcomes packed into the context — who sets each, and what it means.
OutcomeSet byMeans
persistedOutbox / GroupOutboxwritten to history + outbox
deliveredDeliverypushed to the recipient's live session
cachedCache / CleanCachepresent in the idempotency / redelivery cache
acknowledgedAck / TetrisAckSENT-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.

Authorize member · not-blocked Outbox persisted ✔ Ack acknowledged ✔ AsyncHandoff fly delivery off Final done = persisted ctx.hasError() → recover to the same context → short-circuit to Final
Fig 3 — the context walks the beads; a fault is packed in and routed to Final, not thrown.
04 — the stage

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:

What a stage can do — and the bead that already does it. You compose capability by stringing them.
A stage can……realised by
map / normaliseInboundPrototype — fresh server ULID, IN→OUT flip, server timestamp
filter / mediate / authorizeAuthorizationStage, GroupAuthorizeStage — membership + not-blocked; re-stamps the authentic recipient (client value never trusted)
persistOutboxStage (atomic history+outbox); GroupOutboxStage (the multiplier: 1 log + N outbox)
deliver / sendDeliveryStage (live WS push with backoff); AckStage (build + send the sender's SENT ack)
idempotency / cacheCacheStage (guard double-delivery); CleanCacheStage (evict on ACK)
audit / mark / watermarkTetrisSentStage / TetrisAckStage (pin & advance the GC watermark); RouterLogger (structured in/out audit)
group / fan-outGroupOutboxStage, GroupHandoffStage, GroupTypingStage — N recipients behind a GroupSend port
fire a separate async processAsyncHandoffStage (delivery flies off); OfflineCallPushStage (offline call push)
read-model side-effectReadStage — 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.

05 — the vessels

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.

shed — publish() = false producer publish(item) bounded queue merge (concurrency) sink → routeIn / routeOut recoverWithNull() per item sink → … one continuation of N sink → …N side-effect publish queue full → false terminal error → re-subscribe (backoff) — immortal
Fig 4 — the wheel: shed, don't block; isolate per item; never permanently die.

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.

06 — the catalogue

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.

The vessels — the two intake valves, the fly-off artery, the coalescing buffers, and the Kafka capillary that reaches an offline user. Knobs under processing.*.
VesselRolePrimitiveFeeds / does
InboundPublisherintakewheelWS frames → routeIn
RoutingBackpressurePublisherintakewheeldurable outbox rows (poller) → routeOut — the one vessel that drives the reflex (§07)
FastDeliveryPublisheroffloadwheelAsyncHandoff → routeOut on its own subscription — live delivery decoupled from persist
InboundPersistBatchercoalescegroup-commitpersist stage → one saveBatch tx of N; poison rows retried individually
OfflinePusherBatcher → KafkaOfflineEventPublisheroffload + coalescecoalescing → Kafkaundelivered-to-offline → one Kafka push per (recipient,conversation,call) → topic chat.offline.notify
ReadReceiptBatchercoalescecoalescingwrite-behind receipts (keep MAX readId) → 1 UPDATE + 1 coalesced READ_OUT
AttachmentReclaimPublisheroffloadwheeldrain PENDING_DELETE off the delete hot-path → delete object + mark ORPHANED
RosterEventFanoutoffloadreuses FastDeliverygroup 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.

The same publish()==false, three different meanings — split by whether the message is durable yet.
VesselA shed meansBecause
InboundPublisher · intakeadmission rejected — the frame is refused and the sender gets a rate-limited overload noticebefore the boundary — nothing is durable yet; this is the one shed that can drop a not-yet-accepted message
RoutingBackpressure · pollerthe live attempt is deferred — the row stays in the outbox and is re-polledafter the boundary — the row is already durable
FastDelivery · fast-paththe live attempt is deferred to the catch-up pollerafter 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.

DURABLE ACCEPTANCE admission may shed NO-LOSS CLAIM HOLDS → Message resolve +router · stages admission shed → refuse queue full persist outbox + historydurable truth sent → ACKwatermark advances shed → re-pollstays in outbox done live attempt re-poll
Fig 6 — one message's life. To the left of the boundary, admission may shed; to the right, every path reaches done and nothing accepted is lost.
07 — the reflex

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.

outbound wheel shed → dropSplash++ clean delivery → dropSplash-- OutgoingHealthEvent dropped=true · ease-back hysteresis: ease back what it grew FeedbackRegulator drop: interval ×2 → max 30s clean: interval ×0.5 → min 1s OutboxPoller delay each fetch by the interval + skip-while-busy shed / clean → fire event @ObservesAsync grow / decay the interval refill slower / faster
Fig 5 — the reflex: shed at the valve, slow the pulse at the poller, ease back with hysteresis.

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.

08 — what holds

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:

✓ by design
R1

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.

✓ durable
R2

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.

✓ isolated
R3

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.

✓ adaptive
R4

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.

✓ composed
R5

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.

Fig 7 — the payoff. A composable core, and a self-tuning high-load kernel around it.

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.

What each failure does — the whole point of the design is that this table is short and every recovery is definite.
FailureRecovery
authorization rejectedterminal — short-circuit to Final, processed=false
persist transaction transient errorthe wheel / batcher retries with backoff
one poisoned row in a persist batchrow-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 fullshed — admission refused (intake) or deferred to the outbox (delivery)
a single poison message on a wheelrecoverWithNull() — dropped from that turn, stream lives
terminal stream error on a wheelre-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.