Skip to content

Broker Phases 6–10: clustering, durability, transactions, auth, management - #18

Merged
mack42 merged 39 commits into
mainfrom
feat/phase6-forwarding-fabric
Jul 7, 2026
Merged

Broker Phases 6–10: clustering, durability, transactions, auth, management#18
mack42 merged 39 commits into
mainfrom
feat/phase6-forwarding-fabric

Conversation

@mack42

@mack42 mack42 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Completes the remaining broker.md implementation work: Phases 6–9 in full, plus Phase 10's in-process legs. 11 commits, each a self-contained sub-phase with its own tests and docs.

Phase 6 — leader routing + forwarding fabric (clustering complete)

  • cluster/fabric.rs — one multiplexed TCP connection per peer pair carrying every Raft group's traffic (shared-transport half of the multi-raft manager) plus the forwarded data plane. Correlation-id RPC (pipelined, no head-of-line blocking, cancellation-safe), bincode Raft payloads, message bodies as raw frame tails (never serde'd), batched single-flush writes.
  • cluster/node.rs — catalog-driven quorum declaration with rendezvous placement recorded in the catalog, StartGroup fanout + lazy member healing, MetaWrite leader forwarding, leader-side pub/sub bridging onto unmodified queue actors.
  • proxy.rs — leader-following local actor speaking the queue-mailbox protocol (connection driver untouched); re-resolves on failover, migrates subscriptions re-arming outstanding demand, epoch-guarded publish retries.
  • Kill-the-leader mid-stream, zero accepted-message loss: proven at group, fabric, and client-e2e scope. Bench: 3-node quorum p50 288µs leader-local / 427µs via follower, 140–165k msg/s (vs RabbitMQ quorum 2234µs / 39k; caveats in bench-compare/README).

Phase 7 — durability & deep-queue scaling

  • /durable/<name> queues on redb (store-redb feature, --data-dir): group-commit fsync is the publish confirm; full restart recovery.
  • Paged state machine for deep quorum queues: bodies spill to segment files past a resident budget; p50 flat from empty to 1M deep; ~3× less RSS at 4 KiB bodies; snapshot blobs parked on disk.
  • TTL / max-length (reject or drop-head) / dead-letter / delivery-limit policies across all three queue kinds.
  • On-disk Raft log (write-through RaftLogSink): quorum queues and the metadata catalog survive full broker restarts.

Phase 8 — transactions

  • The amqp:coordinator target (spec part 4): declare/discharge, transactional enqueues + settlements staged per connection, commit through each queue's own durability confirm (cluster-aware by construction), rollback + implicit rollback on disconnect. Client gains Consumer::settle_in_txn.

Phase 9 — auth, limits, management

  • SCRAM-SHA-1/-256/-512 server flow (full RFC 5802 exchange) against verifier-based storage; StaticScram built-in.
  • Per-address authorize(identity, vhost, address, operation) at every attach, before queue resolution; link-level refusals.
  • Vhosts: open.hostname = vhost:<name> namespaces queues, storage, catalog, policies, permissions.
  • Dependency-free management endpoint: GET /metrics (Prometheus) + GET /queues (JSON), stats pulled from actors off the hot path.

Phase 10 — interop, faults, runtime decision (in-process legs)

  • fe2o3-amqp (independent AMQP 1.0 implementation) interop tests pass against our broker.
  • Fault injection: rolling leader kills to the availability boundary (quorum lost → clean refusals, never silent acceptance, never a hang) and follower-loss transparency.
  • §3.3 runtime decision recorded: stay on tokio work-stealing; revisit triggers documented.

Notable bugs found & fixed (each with a regression test)

  1. tokio coop-budget trap: the close-time settlement drain polled oneshots with an exhausted cooperative budget, silently requeueing hundreds of acked messages as duplicates per busy close.
  2. Zombie member resurrection: a dying node's still-open fabric connections lazily recreated empty Raft members whose conflict replies panicked openraft ("follower log reversion").
  3. Proxy rebind pinning: proxies on a stopping node spun their full 60s rebind window, holding the node's store lock past restart.

Remaining (tracked in broker.md §11)

Qpid/proton + JMS interop legs, a unified conformance harness, process-level partition/split-brain injection, tuned-incumbent benchmark re-runs; standing follow-ups: incremental snapshots, spill-segment shipping for follower snapshot catch-up, queue delete/admin API.

Versions: ramqp-broker 0.1.10 → 0.8.1, ramqp-core 0.2.1 → 0.2.3 (additive), ramqp client 0.8.0 (one additive feature-gated method). All tests green under default and --all-features; clippy/fmt/rustdoc clean.

mack42 added 11 commits July 6, 2026 21:00
…ode serves any queue

Phase 6 lands multi-node quorum queues (broker.md §8):

- cluster/fabric.rs: one multiplexed TCP connection per peer pair carrying
  every Raft group's traffic (the shared-transport half of the multi-raft
  manager) plus the forwarded data plane. Correlation-id RPC (pipelined, no
  head-of-line blocking, cancellation-safe by construction), bincode Raft
  payloads, message bodies as the raw frame tail (never serde'd, sliced as
  refcounted Bytes), batched single-flush writes.
- cluster/node.rs: catalog-driven quorum declaration with deterministic
  rendezvous placement recorded in the catalog; StartGroup fanout with lazy
  member healing; MetaWrite leader forwarding; the leader-side bridge that
  lands forwarded publish/subscribe traffic on unmodified queue actors (a
  per-connection ordered forwarder preserves producer FIFO and keeps the
  frame reader unblockable).
- proxy.rs: the origin-side leader-following proxy — speaks the queue-actor
  mailbox protocol so the connection driver is untouched; re-resolves on
  failover, migrates subscriptions re-arming outstanding demand, retries
  in-flight publishes with epoch-guarded rebinds (one rebind per failover,
  not one per publish).
- quorum actors now exit on demotion (a follower must never dispatch);
  proxies detect the closed mailbox and follow the new leader.
- Clustering is opt-in: BrokerConfig::cluster / ramqp-brokerd --node-id
  --cluster-listen --seed. Standalone brokers are byte-for-byte unchanged.
- supersedes cluster/tcp.rs + cluster/bootstrap.rs (folded into the fabric).

Proven kill-the-leader-mid-stream at three levels: single-replica group,
3-node fabric (proxies only), and client-facing e2e with the unmodified
ramqp client — zero accepted-message loss in all three.

ramqp-broker 0.1.10 -> 0.2.0.
…ssages requeued at close

Repeated bench runs against one broker degraded 95µs → 1.7ms → 5ms p50.
Root cause chain, found while standing up the quorum-queue bench:

1. The connection driver's close-time settlement drain used
   FuturesUnordered::next().now_or_never(). The settlement futures await
   tokio oneshots, whose polls consult the task's COOPERATIVE BUDGET —
   exhausted right after a busy close — so every poll reported Pending
   regardless of actual readiness and the drain forwarded NOTHING.
2. The dropped settlements left their messages unacked at the queue; the
   teardown unsubscribe then requeued them — hundreds of already-accepted
   messages redelivered to the next consumer as duplicates, every busy
   close.

The drain now runs under tokio::task::unconstrained (bypassing the budget)
with a per-item timeout so a peer that really left deliveries unsettled
cannot stall teardown (those still requeue — at-least-once, as before).
Also forwards resolved settlements in batches during steady state instead
of one per wakeup.

Regression test: blast + ranged accepts + close, then assert a fresh
consumer receives nothing. Diagnostics that found it stay: core gains
Session::sender_backlog() / UnsettledMap::id_range(), and bench-compare
gains a drain bin.

ramqp-core 0.2.1 -> 0.2.2 (additive diagnostics); ramqp-broker 0.2.0 -> 0.2.1.
…lete

bench-compare/README gains the quorum matrix (256 B, same harness/machine):
p50 288µs leader-local / 427µs via follower (one fabric hop ≈ +140µs),
p99.9 < 1ms and p99.9/p50 ≈ 2x on every leg, 140-165k msg/s — vs RabbitMQ
4.3.1 quorum at 2234µs p50 / 39k msg/s. Stated caveats: our Raft log is
in-memory vs RabbitMQ's fsync (durability parity re-run lands with Phase 7's
on-disk log), and our leg replicated x3 while RabbitMQ ran a single member.

broker.md: Phase 6 checked off in full.
…up-commit fsync

Phase 7 slice 1 (broker.md):

- store.rs (behind the new store-redb feature): one redb database per node;
  every mutation rides a single group-commit writer thread that drains a
  burst, applies one write transaction, and fsyncs once — one fsync
  amortizes every publish in flight across all durable queues (§3.2
  batching). Publishes are confirmed only after their batch commits, so the
  accepted disposition is a real on-disk durability confirm. Reads
  (recovery scans, dispatch-time body fetches) use MVCC read transactions
  and never block the writer.
- durable.rs: the queue actor over the store — same mailbox contract as the
  transient/quorum actors, pipelined commits, ready-set dispatch seeded by
  a recovery scan (restart recovery = the seed; at-least-once).
- /durable/<name> addressing; BrokerConfig::data_dir; brokerd --data-dir /
  RAMQP_DATA_DIR. Without the feature or a data dir the attach is refused
  (link-level not-found; the session survives). A failed store open (e.g. a
  stale file lock) is retried on the next attach, not cached.
- Client-facing tests: round trip, FULL BROKER RESTART recovery (unsettled
  messages recovered in FIFO order, settled ones never resurrect),
  kind-coexistence, and the no-data-dir refusal.

ramqp-broker 0.2.1 -> 0.3.0.
…o disk

Phase 7 slice 2, the broker.md §8 #1 risk (deep queues must not live in RAM):

- cluster/paging.rs: per-queue append-only segment files. Bodies beyond a
  resident budget (BrokerConfig::resident_bytes_max, default 64 MiB) spill
  at apply time — the index stays resident, the bytes do not. Segments
  reclaim when everything in them is settled; snapshot builders pin
  segments so concurrent settles cannot delete one mid-read.
- QueueState is now paging-aware: StoredBody::{Resident,Spilled}, spill on
  enqueue past the budget, release on settle, dispatch reads spilled bodies
  back outside the store lock (BodyFetch).
- ReplicatedState grows snapshot hooks (prepare/snapshot_bytes/restore) so
  a paged queue's snapshot keeps spilled bodies EXTERNAL (refs — a
  million-deep queue's snapshot no longer materializes its payload) and
  SharedStore parks snapshot blobs on disk when a data_dir exists (a deep
  snapshot must not double RSS). Cross-node install of an external-body
  snapshot fails loudly: follower catch-up via snapshot on deep paged
  queues is the documented follow-up (log-replay catch-up works).
- Quorum snapshot cadence 5k -> 50k applies: each build clones+serializes
  the full index, so at depth the old cadence was 10x the stalls and write
  amplification. Incremental snapshots are the standing follow-up.
- New depth bench (bench-compare/depth) + results in the README:
  p50/p99 FLAT from empty to 1M deep (117->125µs / ~250µs); at 4 KiB bodies
  paging cuts RSS ~3x (878 vs 2461 MiB) at identical p50; drain-out from
  disk 248k msg/s.

ramqp-broker 0.3.0 -> 0.4.0.
…ad-lettering

Phase 7 slice 3 (broker.md):

- BrokerConfig::policies: (name-prefix, QueuePolicy) pairs, first match
  wins. QueuePolicy: message_ttl, max_length + overflow behavior
  (RejectPublish | DropHead), dead_letter target address,
  max_delivery_attempts.
- Enforced in all three queue actors (transient, durable, quorum):
  - Lazy head-of-queue TTL expiry (RabbitMQ-classic semantics). Quorum
    queues stamp the enqueue time INTO the log entry so every replica
    agrees; durable queues persist it (store schema now carries
    enqueued_ms); requeues keep the original clock.
  - DropHead displaces the oldest ready message (dead-lettering it) to
    admit the new publish; RejectPublish keeps the existing refuse path.
  - A message failing delivery max_delivery_attempts times dead-letters
    instead of redelivering forever (poison-message guard).
- One broker-wide dead-letter router: actors emit (target, body); the
  router resolves through the registry and republishes pre-settled,
  best-effort. It holds the registry WEAKLY — a strong ref kept a
  shut-down broker's redb store (and its file lock) alive, breaking
  restart recovery; caught by the durable restart test.
- Client-facing tests: TTL->DLX, drop-head ordering, delivery-limit->DLX,
  and quorum TTL through the replicated log.

ramqp-broker 0.4.0 -> 0.5.0.
…e restarts

Phase 7 complete (broker.md):

- cluster/store.rs: SharedStore gains write-through persistence via a new
  RaftLogSink trait — log appends, votes, conflict truncations, purges,
  and snapshot pointers are durable BEFORE the Raft storage call returns
  (the Raft safety requirement). RaftPersistFactory opens per-group sinks
  + recovery.
- store.rs (store-redb): the sink implementation on the same group-commit
  writer thread — raft appends from every queue group AND the metadata
  group share one fsync with durable-queue publishes. New tables:
  raft_groups / raft_log / raft_meta.
- Recovery: groups rebuild from persisted vote+log+snapshot at creation;
  openraft replays committed entries; the quorum actor now WAITS for
  replay to finish before seeding its ready-set (else recovered messages
  strand). Spill segments survive restarts (open_preserving) with live
  counts recomputed from the restored state; unreferenced segments
  reclaim.
- Guard rails: with a data dir, an unopenable store (e.g. a lingering
  file lock) REFUSES the quorum attach / fails the clustered bind loudly
  instead of silently starting empty over persisted state — both were
  real silent-fallback bugs the restart tests caught.
- Client-facing tests: standalone quorum restart and single-node-cluster
  (metadata catalog) restart — unsettled messages recover in FIFO order,
  settled ones never resurrect, recovered queues accept new work.

ramqp-broker 0.5.0 -> 0.6.0. Phase 7 (durability & deep-queue scaling)
is complete.
Phase 8 complete (broker.md):

- Coordinator links: a sender attach targeting Coordinator binds a
  control link; declare/discharge control messages (amqp-value sections)
  are decoded and answered per spec — declare settles with the
  declared{txn-id} outcome, discharge of an unknown txn is rejected.
- Transactional enqueues: a transfer carrying transactional-state stages
  its message under the txn (settled back with the provisional
  transactional outcome) — nothing reaches a queue before discharge.
- Transactional settlements: a consumer disposition carrying
  transactional-state stages its ack; the settlement pipeline gained a
  SettleAction (apply-now vs stage) split. Coordinator controls drain
  ready settlements first so a pipelined discharge cannot take an
  incomplete transaction.
- Commit publishes every staged enqueue through its queue's own confirm
  (Raft commit for quorum queues, fsync for durable — the coordinator is
  cluster-aware by construction), then applies staged settlements;
  rollback drops enqueues and requeues settlements. Connection close is
  an implicit rollback (local-transactions scope). Staging is bounded
  (64 txns/conn, 10k ops/txn).
- Client: Consumer::settle_in_txn (behind the transaction feature);
  core: declared_state/txn_state helpers.
- Six client-facing tests incl. atomic visibility, both discharge paths,
  quorum-queue commits, and implicit rollback.

ramqp-core 0.2.2 -> 0.2.3; ramqp-broker 0.6.0 -> 0.7.0.
…cs endpoint

Phase 9 complete (broker.md):

- SCRAM-SHA-1/-256/-512 server flow (full RFC 5802 exchange, server-final
  signature in the outcome's additional-data — mutual auth against the
  unmodified client). Authenticator grows scram_verifier() backed by
  verifier storage (salted+iterated, no plaintext at rest); StaticScram is
  the built-in store.
- Per-address authorization: authorize(identity, vhost, address, operation)
  is consulted at every link attach BEFORE queue resolution — an
  unauthorized attach cannot auto-declare; refusals are link-level
  (unauthorized-access; the session survives). The txn coordinator
  authorizes as $coordinator.
- Vhosts: open.hostname = vhost:<name> namespaces every queue this
  connection touches as <vhost>/<name> — names, storage, catalog,
  policies, and permissions all scope; the same address in two vhosts is
  two queues (tested). Dead-letter targets compose across the scheme.
- Management endpoint (BrokerConfig::management_listen / brokerd
  --management-listen): a dependency-free HTTP server exposing GET /metrics
  (Prometheus text: connections, RSS, per-queue ready/unacked/consumers)
  and GET /queues (JSON). Stats are pulled from queue actors at scrape
  time via a Stats mailbox message — nothing rides the hot path.
- Tests: SCRAM round trip (right/wrong password, unknown user, PLAIN
  refused), authorization gating with session survival, vhost isolation,
  metrics + inspection.

ramqp-broker 0.7.0 -> 0.8.0.
… — Phase 10 core

Phase 10 (broker.md), the in-process legs:

- fe2o3-amqp interop (bench-compare/tests/fe2o3_interop.rs): an
  INDEPENDENT AMQP 1.0 implementation against ramqp-broker — handshake,
  links, transfers, dispositions incl. release/redelivery, and quorum
  queues. All pass unmodified.
- Fault injection: rolling leader kills to the availability boundary
  (2/3 alive: recovers and accepts; 1/3: quorum lost, publishes cleanly
  REFUSED — never silently accepted, never hung) and follower-loss
  transparency (zero refusals, zero loss).
- Two real bugs the fault tests flushed out, both fixed:
  1. A dying node's still-open fabric connections could lazily resurrect
     an EMPTY group member whose conflict replies below the leader's
     matched index panic openraft ("follower log reversion"). Nodes now
     refuse member creation once stopping.
  2. With zombie resurrection gone, proxies on a stopping node spun their
     full 60s rebind window, pinning the node (and its store lock) past
     restart. Proxies now abort rebinding when their node is stopping.
- Runtime-model decision (§3.3) recorded in broker.md: STAY on tokio
  work-stealing — targets met without escalation (p99.9/p50 ≈ 3-5x vs
  the ≤10x bar; tails flat to 1M deep); revisit triggers documented; the
  dispatch layer stays shard-partitioned so escalation remains cheap.
- broker.md Phase-10 checkboxes updated with the honest remainder:
  Qpid/JMS interop legs, a unified conformance harness, process-level
  partition testing, tuned-incumbent benchmark re-runs.

ramqp-broker 0.8.0 -> 0.8.1.
@mack42 mack42 self-assigned this Jul 7, 2026
…t-Phases-0-10

- Phase 8 header/checkbox marked complete (the earlier edit missed on a
  text mismatch).
- §14 risks retagged by landed state: deep-queue latency, runtime-model
  fork, leader-routing fabric, semver, persistence engine, and driver
  de-dup are retired; the no-GC gate (tuned-incumbent re-run), multi-raft
  batched ticks at scale, and Jepsen-grade partition testing stay live.
- New follow-up items recorded: incremental snapshots, spill-segment
  shipping for follower snapshot catch-up, fabric authentication, and a
  richer management/admin surface.
@mack42

mack42 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Automated six-agent review of this branch is in #19 — 4 Critical, 10 High, 18 Medium, 17 Low, deduplicated across cluster/consensus, durability (×2), transactions, security, and policy reviewers. Several findings were reported independently by two agents (flagged ⭐). Net: the consensus ack path and the core protocol paths verified sound; the weight is in recovery/restart paths (silent data-loss windows) and a few DoS / tenant-isolation judgment calls. No fixes applied — triage backlog with stable finding IDs for follow-up PRs.

mack42 added 15 commits July 7, 2026 07:41
…#19)

Commit previously published staged enqueues sequentially and broke on the
first refusal: enqueues that already landed were never withdrawn while the
client was told 'rolled back' — a retry duplicated them. Nothing validated
at stage time protected commit time, and a connection dying mid-commit
dropped the commit future between publishes.

- Two-phase commit: QueueMsg::{Reserve, Unreserve, PublishReserved} across
  the transient, durable, quorum, and proxy actors (plus matching fabric
  RequestKinds routed to the leader actor). Every deterministic refusal —
  full queue, dead/deleted actor — now aborts before a single message lands.
- Discharge execution is detached (tokio::spawn): a connection dying
  mid-commit no longer strands a half-applied transaction.
- Non-deterministic mid-apply failures (fsync error, Raft leadership loss)
  release the remaining reservations and report DischargeOutcome::Partial —
  the client is told how many enqueues committed instead of a false
  'rolled back'.

Tests: reserve-protocol unit tests (transient actor), commit-atomicity unit
tests (full queue / dead actor / happy path), and a client-facing
regression test proving a commit spanning a full queue applies nothing.
…a catalog survives restart (#19)

The metadata group ran with the default snapshot policy (LogsSinceLast
5000) and snapshot_dir = None: after enough catalog writes openraft built
a Memory-blob snapshot — which save_snapshot skipped (File blobs only) —
then DURABLY purged the log behind it. On restart, recovery found the
purge marker and log tail but no snapshot: the replicated queue catalog
silently reverted or vanished, stranding queue data whose placement could
no longer be resolved.

- RaftLogSink::save_snapshot now takes SnapshotPersist::{Inline, File}:
  memory-held blobs (meta catalog, unpaged queues) are stored inside redb,
  atomic with the snapshot pointer; paged-queue file blobs keep recording
  their path.
- recover_raft returns the inline blob (snap_blob key) or the path
  (snap_path); exactly one survives per save, atomically.
- new_persistent restores from either form.

Regression test: 200 catalog writes under an aggressive snapshot policy,
wait for the durable purge, restart the store + raft, assert the full
catalog (including the purged prefix) recovers.
…ability ordering (#19)

Four interlocking holes let a crash or power cut turn a durably-purged log
into silent state loss or corruption:

- CRIT-4: snapshot blobs were written with std::fs::write (no fsync, no
  directory fsync) and spill segments with bare write_all, while the
  snapshot pointer and log purge WERE durable. After power loss the pointer
  and purge survive but the blob may be missing/short/zero-filled — silent
  loss or corrupt bodies. Blobs now go through write_blob_durably (write +
  file fsync + dir fsync); paged snapshot builds fsync every spill segment
  (Spill::sync_all) before the snapshot pointer persists, since External
  refs make those bytes the only copy once the log purges.
- HIGH-8: install_snapshot deleted the OLD blob before durably recording
  the new pointer; a crash between the two left recovery pointing at a
  deleted file. Deletion now waits until the new pointer is durable, and a
  failed blob write degrades to inline persistence (old file untouched)
  instead of deleting the old blob with nothing persisted.
- HIGH-9: recover_raft silently degraded a missing blob file to 'no
  snapshot', recovering an empty state below the purge marker. It now
  refuses to start, loudly, for a dangling pointer or a pointer with no
  blob at all.
- LOW-10: the snapshot-id counter reset to 0 on restart, so a post-restart
  build at the same applied index truncate-overwrote the live blob in
  place. The counter now resumes from the recovered snapshot id.
…te, keeping paging (#19)

install_snapshot rebuilt the state machine from QueueState::default()
(paging: None):

- A deep-queue snapshot with PortableBody::External refs made the follower's
  restore fail ('no spill') → StorageError → the member's Raft core went
  Fatal, permanently (snapshot-based catch-up structurally broken for deep
  queues).
- An all-inline snapshot installed 'successfully' but silently replaced the
  paged state with an unpaged one: the replica never spilled again
  (unbounded RSS) and the old state's spilled segments leaked without
  release().

The restore now happens in place on the existing state (as new_persistent
already did), which preserves the node-local paging config and releases old
bodies as they are replaced. Additionally, each spill store now carries a
persistent identity (spill.id) stamped into snapshots next to their
External refs: restoring a snapshot built against ANOTHER replica's spill —
where the same (segment, offset) names different bytes — is rejected loudly
instead of silently serving corrupt bodies. Deep-queue snapshot installs
remain node-local by design (the standing incremental-snapshot follow-up);
what changes is that every misuse now fails loudly instead of corrupting.

Tests: install preserves paging (post-install enqueues still spill within
the resident budget); foreign-spill snapshots are rejected with a
cross-replica error.
… subscriptions (#19)

rebind() cleared its channel maps, dropped its event receivers, and bumped
the binding epoch — but never sent Unsubscribe (local) or CloseSub (remote)
for the old bindings. A rebind landing on the SAME still-alive leader (the
common case: a forwarded publish exceeding CALL_TIMEOUT under backpressure
returns Retry and triggers a rebind that resolves the same node) left the
old leader-side subs in the round-robin ring: dispatches to them flowed
into dropped channels, and the leader marked those messages in-flight under
a subscriber nobody owned — stranded until the fabric connection or actor
died. Up to ~half the stream could strand on an otherwise-healthy cluster.

- rebind() now closes every current downstream binding first: local subs
  unsubscribe from the leader-local actor (requeueing their in-flights),
  remote subs close their fabric channels (the leader unsubscribes on
  receipt).
- try_bind's per-sub loop closes the subs the failed attempt had already
  opened before returning, so bind retries no longer accumulate abandoned
  leader-side subs.

Regression test: a same-leader rebind leaves the actor with exactly one
subscriber (previously two — the leaked old binding plus the new one).
…se (#19)

The TxnDone commit disposition carried only (channel, handle, delivery_id)
with no session identity — unlike Deliver/SettleIncoming, which carry
binding_gen for exactly this reason. A client could discharge on channel L,
end the session while the commit was still fsyncing/Raft-committing, begin
a new session that the allocator hands the same channel L, and start
publishing: the late TxnDone would find the NEW session at L and emit a
settled disposition for a delivery id that now names an unrelated transfer
— a publish spuriously confirmed (or rejected) that its queue never saw.

TxnDone now records the SessionId the discharge arrived on (session ids
are monotonic per connection, never reused), and the outcome is delivered
only if the session at that channel is still the same one; otherwise it is
dropped with a debug log, exactly like a stale queue command.

The positive path is covered by the existing transaction suite (every
discharge disposition now passes the id check); the reuse race itself has
no deterministic client-level repro without fault injection.
…f stranding (#19)

When stage_settle failed — the transaction already discharged (a
disposition racing the discharge frame, trivial with a multi-task client)
or the 10,001st staged op hitting the cap — the settle was dropped with
only a warn log. The client could not observe it (its disposition was
fire-and-forget settled:true), the subsequent commit still answered
Accepted, and the message sat in the queue's unacked map with no
redelivery timer: invisible to every consumer until the connection tore
down, which the 'requeued by teardown' comment wrongly assumed would
happen.

- A refused settle is handed back and the message requeues immediately
  (at-least-once: a duplicate is recoverable, an invisible message is not).
- A cap refusal also marks the transaction rollback-only: its staged work
  is incomplete, so discharge now rolls back and answers Rejected instead
  of committing silently partial work.

Tests: unit — the cap refusal poisons the txn and hands the settle back;
unknown-txn refusals hand it back too. Integration — a settle arriving
after its transaction's discharge requeues and redelivers the message.
… after the copy's fate (#19)

Dead-lettering a durable message committed the source's Remove to disk
while the dead-letter copy still rode an in-memory mpsc to the router: a
crash after the Remove batch fsynced and before the DL target durably
stored the copy lost a previously-confirmed durable message from both the
source and the DLQ.

DeadLetter can now carry a confirm resolved once the copy's fate is known
(durably stored by the target, refused, or dropped — the router publishes
confirmed dead letters with an ack and resolves the confirm off-task so a
slow DLQ never stalls broker-wide dead-lettering). The durable actor's
three DL sites (drop-head, delivery-limit, TTL expiry) submit their Remove
only after that resolution: a crash in the window now redelivers (and
re-dead-letters) the message instead of silently losing it — at-least-once,
never loss. Sources with no DL target keep the immediate inline Remove.

Test: durable → durable TTL dead-lettering end to end over the
confirm-ordered path.
…taged-bytes bound (#19)

CRIT-2 (decision: accept + document + warn; fabric auth/TLS is a
deliberate deferral): the inter-node fabric port speaks unauthenticated,
unencrypted frames — any host with TCP reach can drive every queue this
node leads, rewrite the replicated catalog, and inject Raft RPCs (a forged
high-term Vote is a cluster-wide liveness DoS). The AMQP port warned about
this class of exposure; the fabric port did not. Now:
- ClusterNode::bootstrap logs a loud warning when the fabric binds a
  non-loopback address (covers brokerd and embedded users);
- ClusterMemberConfig carries a Security section documenting the
  isolated-trusted-network requirement; the brokerd docs point at it.

HIGH-4: MAX_STAGED bounded op COUNT only — 64 txns × 10k ops × 16 MiB
messages let one authorized client pin hundreds of GiB of staged bodies
(and staging replenishes producer credit, so the producer never stalls).
Staged publish bytes are now bounded per connection (MAX_STAGED_BYTES,
64 MiB): over-budget stagings are rejected via disposition as before, the
transaction is poisoned rollback-only (its work is incomplete), and
discharge returns the bytes to the budget. Unit-tested.
… key collisions (#19)

The storage key collapsed (vhost, name) into '<vhost>/<name>' with no
validation on either side: a default-vhost attach to
/queues/tenantA/secret landed on the same key as queue 'secret' in vhost
tenantA, and vhost 'a' + queue 'b/c' ≡ vhost 'a/b' + queue 'c' — cross-
tenant read/write/delete below the authz layer, which sees only the raw
(vhost, address) pair and cannot defend against the collapse.

- Vhosts are validated when the open arrives (before our open goes out):
  '/' and control characters refuse the connection.
- Client address resolution (resolve_in) refuses names containing '/' or
  control characters, so no client-chosen name can cross the key
  separator.
- The broker-INTERNAL path (resolve: dead-letter router, management)
  still accepts qualified names — the documented per-vhost DLX
  composition ('/queues/<vhost>/dead') keeps working; it is now reachable
  only from operator-configured policies, not from client addresses.

Tests: registry unit (cross-separator/control-char refusals + internal
qualified resolution reaching the tenant queue) and client-facing (cross-
tenant attach refused, '/'-vhost open fails, tenant's data untouched).
…ost grants on static authenticators (#19)

HIGH-7: a clustered node without on-disk Raft persistence restarts with an
empty log AND empty vote, so it can re-grant a vote in a term it already
voted in — a candidate missing committed entries can win, silently losing
acknowledged publishes. The config is kept (tests and ephemeral clusters
use it) but is no longer silent: ClusterNode::bootstrap warns loudly, and
BrokerConfig::data_dir documents the durability consequence.

HIGH-10: no shipped authenticator overrode authorize(), whose default
allows everything — an authenticated user (or with AllowAll, an anonymous
one) could select ANY vhost and ANY queue; tenant isolation required
writing a custom Authenticator. StaticPlain and StaticScram now accept
per-user vhost grants (with_user_vhosts): a granted user may only attach
within its vhosts, ungrated users keep the old any-vhost behavior, and the
trait docs state the sharp edge explicitly.

Tests: unit (grants gate authorize for both authenticators) and
client-facing (a SCRAM user bound to tenant-a authenticates fine but is
refused attaches inside tenant-b).
…ngth_bytes) (#19)

The defaults admitted ~16 TiB per queue (max_queue_depth 1M ×
max_message_size 16 MiB) with no byte bound anywhere — transient and
in-memory quorum queues live in RAM, so one anonymous producer streaming
large messages could OOM the broker while inside every configured bound.

- BrokerConfig::max_queue_bytes (default 1 GiB; 0 disables) and
  QueuePolicy::max_length_bytes (per-queue override), resolved into
  EffectivePolicy::max_bytes.
- Transient actor: tracks held body bytes (ready + unacked); over-bytes
  publishes are refused, and drop-head evicts as many of the oldest ready
  messages as the new body needs.
- Durable actor: same accounting (sizes recovered via the scan, which now
  returns body lengths) — bounds the shared disk, not just RAM.
- Quorum actor: resident bytes (spilled bodies excluded — paged queues are
  disk-bounded by design) plus in-flight proposal bytes; over-bytes
  refuses (no synchronous drop-head: a removal must commit through Raft
  before resident bytes fall).
- Reserved (transaction) publishes bypass the residual check as with the
  depth bound; their overshoot is capped by the 64 MiB per-connection
  staging budget.

Tests: byte-bound refusal despite depth room; drop-head evicting multiple
messages to fit; store scan length round-trip.
MED-17: each management socket spawned a task looping on read with no
timeout and no concurrency cap — a slow-loris client parked tasks forever
and a connection flood spawned unbounded tasks (the AMQP path has both
guards). Requests now run under a 10s whole-request deadline and a
64-connection semaphore; excess sockets are dropped at accept.

MED-18: escape_label/escape_json escaped only backslash and double-quote —
a queue name containing a newline injected arbitrary Prometheus sample
lines into /metrics (alert spoofing) and produced invalid JSON from
/queues. Labels now escape per the text exposition format and drop other
control characters; JSON escapes all control characters per RFC 8259.
(Defense in depth: HIGH-6 already rejects control characters in
client-chosen names at resolution.)

Unit test: a hostile name with quotes, newline, CR, and a control byte is
neutralized in both formats.
The quorum actor read the failure count from APPLIED Raft state, added
one, and proposed a pipelined Settle{requeue:true} while re-readying the
message immediately. Under a fast nack loop the read stayed stale (the
increments were still in flight), so the delivery limit fired late — or
never, when increments persistently failed to commit (only logged).
Transient and durable queues count exactly; quorum diverged.

The actor now keeps a leader-local failure map, seeded from applied state
on first sight of a message and incremented exactly per nack (it is the
only proposer of increments while it leads). The replicated increment
still rides the log so counts survive failovers approximately
(at-least-once, as before); Ack/Drop and dead-lettering clear the entry.

Test: a fast nack loop against a /quorum/ queue dead-letters after
EXACTLY max_delivery_attempts — no third delivery.
…e guard (#19)

MED-2: when a queue's initializer failed, it evicted the init cell from
the registry map — but a caller already WAITING on that cell could then
run its own init attempt, succeed, and set the now-orphaned cell. The next
attach created a fresh cell and a SECOND live actor for the same queue:
duplicate delivery over one queue_id for durable queues (and concurrent
Raft instances over one persisted group), split-brain for transient. The
init closure now verifies, under the map lock, that its cell is still the
mapped one before doing any side-effectful work: either it observes the
eviction (aborts as Orphaned and retries on a fresh cell) or its success
blocks the eviction via the existing get().is_none() guard — both orders
are safe, and no duplicate actor is ever spawned.

MED-3: the documented catch-all '' policy prefix matches the dead-letter
queue itself, so messages expiring in it re-published into it with a
fresh timestamp — unbounded retention, or a 100%-CPU router↔actor
livelock with drop-head at capacity. A queue whose resolved dead-letter
target is itself now has dead-lettering disabled (warned once at
declaration); longer operator-configured cycles remain documented as the
operator's responsibility. Unit-tested.
mack42 added 12 commits July 7, 2026 09:44
… TTL clock semantics (#19)

MED-4: a clustered quorum queue dead-letters through whichever node leads
when the message dies — the policy and target resolve against that node's
local registry, so a transient/durable DLQ scatters across nodes after
failovers and a consumer on one node silently misses the rest. A /quorum/
target is leader-routed and therefore cluster-wide. The leader actor now
warns loudly when a clustered queue's dead-letter target is node-local,
and NodeSettings::policies documents that per-node policies must be kept
identical (they are node-local configuration; replicated policies arrive
with the management API).

MED-5: TTL runs on the wall clock — the only clock that survives restarts,
which durable/quorum TTLs require (RabbitMQ-classic semantics). The
consequences (a forward clock step expiring the affected backlog at once;
inter-node skew shifting expiry on clusters) are now documented on
QueuePolicy::message_ttl with the operational guidance (slew, don't step).
…sumer recovery (#19)

MED-7: a demote→re-elect pair coalescing into one watch notification read
as 'still leader', so the actor kept its pre-demotion ready set while the
interim leader's enqueues sat in the shared state machine, undeliverable
until the next restart. The actor now records the term it started leading
under and exits when the term changes even if it looks like the leader —
the respawn re-seeds the ready set from applied state.

MED-9: dispatch dropped a subscriber whose connection channel died without
a clean Unsubscribe, but requeued only the message whose send failed — its
other in-flights stayed owned by a ghost on a live actor forever. All
three actors (quorum, transient, durable) now requeue everything the dead
subscriber held, exactly like Unsubscribe.

MED-11: a 30s replay-wait timeout logged an error and SERVED with a
partially-seeded ready set — messages applied after the seed were stranded
until the next restart. The actor now exits instead; the proxy/registry
evicts the dead mailbox and respawns, retrying the wait.

Test: a dead consumer channel (no Unsubscribe) has ALL its in-flight
messages redelivered to a fresh consumer.
…outside the lock (#19)

MED-8: start_local_member checked the stopping flag once at entry, then
did seconds of async work before inserting into the groups map. An inbound
Raft RPC lazily healing a member concurrently with stop() could insert a
fresh EMPTY member after the drain — a resurrected member on a dying node
whose conflict replies below its previously-acked matched index panic the
LEADER ('follower log reversion', the exact failure the flag exists to
prevent). The flag is now re-checked under the same lock stop() drains
under, so every interleaving either observes the stop or is drained by it.

MED-10: PeerClient::conn() dialed with no timeout while holding the
reconnect mutex — a blackholed peer (SYN drop, the classic failover mode)
hung for the kernel's ~2-minute TCP timeout with the mutex held, stalling
resolve_queue_leader's WhoLeads sweep and proxy binds behind it, sailing
past the 60s REBIND_DEADLINE and 10s meta-write deadline (both checked
only between attempts). Dials are now bounded at 5s and happen OUTSIDE the
mutex (racing dialers keep the first winner).
…h, discharge starvation, SCRAM oracle (#19)

MED-13: drain_ready_settlements looped with a 20ms per-call timeout, so
every coordinator control message stalled the whole connection ~20ms
(~40ms per transaction, all other links frozen). The coordinator path now
uses a zero-wait drain (unconstrained poll bypasses the tokio-coop budget
so ready oneshots aren't misread as pending) — the client's pipelined
transactional dispositions were already processed synchronously in an
earlier frame, so they are ready now or never. The 20ms variant stays for
cleanup, where futures may be genuinely mid-flight.

MED-14: a coordinator link detach removed only its binding, leaking every
open transaction — staged settles pinned messages invisibly and staged
publish bytes stayed resident until CONNECTION close, and the MAX_TXNS=64
slots leaked (a client re-attaching its coordinator on error exhausted the
table). Detach now rolls back all open transactions (TxnManager::take_all),
reclaiming slots, staged bytes, and requeueing staged settles.

MED-15: txn_results could starve under the biased select. Commit EXECUTION
already runs detached (CRIT-1), so only outcome reporting rides the arm;
that arm now self-drains every ready outcome per wakeup like settlements.

MED-16: SCRAM was a username-existence oracle — an unknown user got an
immediate failure (no server-first challenge) while a known user with a
wrong password got a challenge then failed. Unknown users now get a
deterministic decoy verifier (stable salt per username from a per-process
secret) so the exchange is identical until client-final fails (RFC 5802
§7).

Tests: take_all drains all txns and frees the budget; the decoy verifier
is deterministic per username and differs between usernames.
…vergence fixes (#19)

LOW-1: the quorum actor re-readied a message into the dispatch set whenever
its remove-commit failed — including expiry/delivery-limit/drop-head
removals whose body already went to the DLX, so the message was consumed
AND dead-lettered. Committed::Remove now carries readd_on_failure: an ack
removal re-readies (still replicated, at-least-once), a dead-letter removal
does not (matches the durable actor, which never reinserts after DLX).

LOW-2: documented that quorum FIFO leans on FuturesUnordered insertion-order
first-polling — a fragility under openraft core-channel backpressure, not a
contractual guarantee (the durable actor submits inline and avoids it).

LOW-3: open_sub's deserialize-error path returned without removing the sub
or closing the leader-side subscription, leaking both; it now close_sub's
before bailing.

LOW-6: a spill segment that drained to zero live entries while still the
current segment was never deleted (release skips the current segment), so
it leaked until process exit under steady traffic just above the resident
cap. append now reclaims the outgoing current segment as it rolls.
…wn race, snapshot pin leak (#19)

LOW-4: the meta bootstrap initialize loop lacked a Fatal arm, so after
stop() shut the meta Raft down it fell into the generic 250ms retry and
spun forever. Added the arm (the queue-group loop already had it).

LOW-5: an OpenSub spawned task could insert a leader-side subscription into
the connection's subs map AFTER the reader-loop teardown had drained it,
leaking an actor-side subscriber and its delivery-pump task until the actor
exited. The subs map now carries a closed flag set at teardown; a late
OpenSub observing it undoes its actor-side subscribe instead of inserting.

LOW-16: build_snapshot pinned the spill under the lock (prepare_snapshot)
and unpinned inside the spawn_blocking closure (snapshot_bytes) — if the
blocking task was cancelled before running (runtime shutdown), the unpin
never happened and deferred segment deletions were withheld until process
exit. Unpin moved to a dedicated finish_snapshot hook the builder ALWAYS
calls after the join (the clone shares the spill Arc with the live state,
so unpinning through the live state balances the pin on every path).
…h, declare slot leak (#19)

LOW-7: a failed rebind in the proxy's Subscribe arm was swallowed (let _ =
self.rebind()), so after a >60s leaderless window the proxy stayed alive
but permanently unbound — no select arm could re-trigger a bind, and
attached consumers starved silently. handle_msg now returns whether to keep
running; a rebind that exhausts its deadline closes the proxy so the
registry evicts and re-declares it.

LOW-8: if the queue actor died between the Subscribe send and its reply,
accept_attach returned AFTER accept_peer_attach had already answered the
client — a zombie link that ignored flow/drain while the consumer waited
forever. It now ends the session with an error so the client learns the
link is dead.

LOW-14: handle_txn_control's Declare allocated a MAX_TXNS slot via
txns.declare() BEFORE checking the session existed; a declare on a
since-gone session leaked the slot until connection close. The session is
now confirmed live first.
…#19)

The coordinator conflated transaction errors onto generic conditions: an
unknown-txn discharge answered amqp:not-found and unknown-txn/cap staging
both answered amqp:resource-limit-exceeded, and a declare carrying a
global-id (a distributed-transaction request) was silently accepted as a
local declare.

- ramqp-core: added the amqp:transaction: error domain (TransactionError:
  unknown-id / rollback / timeout) and wired it into ErrorCondition
  (decode, as_str, From). Bumped to 0.2.4 (additive).
- Unknown-txn discharge and unknown-txn staging now answer
  amqp:transaction:unknown-id; stage_publish distinguishes UnknownTxn from
  Capped so a cap refusal keeps amqp:resource-limit-exceeded.
- A declare with global-id is rejected with amqp:not-implemented
  (distributed transactions unsupported) instead of becoming a local txn.

Tested: stage_publish returns UnknownTxn vs Capped vs Staged.
…ore-abort log, PLAIN docs (#19)

LOW-9: per-queue spill/snapshot directories were named by a 64-bit FNV-1a
of the queue name. FNV is not collision-resistant, so a client that can
declare queues could construct a name colliding with an existing durable
queue's tag and share its directories — and Spill::open's remove_dir_all
would then destroy the victim's segments (a chosen-input data-destruction
vector). Directory tags are now a SHA-256 of the name (via ramqp-core's
already-linked scram hash), making a chosen collision infeasible.
(rendezvous placement keeps FNV — it is a scoring function, not a security
boundary.)

LOW-11: Remove/Fail store ops carry no completion channel, so a batch-commit
abort silently un-persisted a settle → the message redelivered after restart
(at-least-once) with no trace. The writer now logs a warning naming the
count of unsignalled removals/fails when their batch fails.

LOW-17: documented that StaticPlain is a dev/testing helper — plaintext at
rest, and its constant-time compare still leaks password length via the
length-mismatch short-circuit; production should use StaticScram or a
hash-backed authenticator.
…gression guard (#19)

LOW-12: max_queues was a single broker-wide pool, so one tenant could
exhaust it and starve every other vhost's new-queue attaches (and the DLX
router's auto-declares). Added BrokerConfig::max_queues_per_vhost (default
10k; 0 disables), enforced per non-default vhost at declaration. Tenant A
hitting its cap no longer affects tenant B.

LOW-13: documented the invariant on the knows_link attach branch — it
routes to handle_link_frame, the one attach path that skips authorize(),
and stays safe only because a known link is already bound (no new binding
or queue resolution). Added a regression test: re-attaching a link whose
name was already used for a denied attach stays denied.
@mack42
mack42 merged commit cf4f7ba into main Jul 7, 2026
8 checks passed
@mack42
mack42 deleted the feat/phase6-forwarding-fabric branch July 11, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant