Repository navigation
feat(mq): external nats broker with sharded, pinned ingest workers - #624
Merged
Merged
Conversation
mq.backend, cache.backend, dedupe.backend and coord.backend select each layer's implementation; only today's in-process one exists per layer and it is the default. Validate refuses an unknown value, internal/app picks the implementation in one switch per layer, data_dir is probed only when a selected backend keeps state there, and boot logs Config.Warnings. Part of #613. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
New internal/coord: Coordinator/TryAcquire/Term with a fencing Token, Done/Err and Resign; RunElected for leader loops; Local, the in-process implementation; and coordtest.Conformance, the suite every backend runs. The sweeper now runs through RunElected under the "sweeper" lease, over a Local coordinator that wireCoord opens until coord.backend lands. Part of #613. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
A handoff overlap cannot lose ClickHouse data (every sweep stops at the ack floor) but can trim SSE replay history when the holders' settings views differ. Also lists coord/ in development.md's package tree. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
…ENTS.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
mqtest.Run states the mq.Broker contract as behavior, through the interfaces alone, so the external-NATS backend (#613) runs the same cases as the embedded one; mqtest.Caps covers the places where their semantics legitimately differ. The embedded broker passes it. The suite found that a durable deleted on several tenants' queues could report on failed more than once; fixed. The interface comments now allow a partition as the delivery unit, a CreateConsumer that finds rather than creates, an operator-owned retention, and zero dead-letter counts without a per-tenant queue. mq.ErrUnavailable is new, and the ingest handler answers it with 503 and Retry-After: 5. Part of #613. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
internal/mq's unit tests already take ~10s of their 15s budget under load, and the suite pushed them over. The embedded run moves to mqtest/embedded_test.go and ends delivery by closing the broker, so it needs no hook into mq's internals; the exactly-once failed report gets a deterministic test in internal/mq. The api.md rows for ErrUnavailable say that no backend returns it yet. Part of #613. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
The JetStream layout an external NATS must provide for mq.backend: nats (epic #613, D2): N interest-retention ingest partitions each with the wh-ingest durable, a limits history stream sourcing them, and one DLQ. A verifier checks a live server against the spec and returns every finding at once; await retries it while the operator's CRs roll out. `wavehouse mq manifests` renders the same spec as nack CRs, and deployments/nats ships its N=4 output (golden-tested) plus Helm values whose wavehouse user permissions are test-pinned and used verbatim by the fixture the verifier tests run against. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Deletes the durable on one tenant's queue, drains the report, then on the next: the real path, rather than calling fail by hand. The replay polls in mqtest pause between attempts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
…itions Review round 1: max_msgs_per_subject without discard_new_per_subject evicts unwritten rows, so it is required; the history's sources must be attached (active >= 0); two partitions may not share a stream; the durable must not be headers-only and must replay instantly. The manifest header now states the one ordering hazard that holds, and the S1 file comment records the measured AckFlowControl source consumer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
A durable deleted before its pull reaches the server ends nothing the client sees, so the exactly-once tests waited out their 5s and failed. Both wait for a delivery on each tenant first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
wireCoord becomes a switch on coord.backend like the other layers, and New refuses a Config that names no coordinator. Docs stop calling the key reserved. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
A halted unit renewed its pin for ack_wait from the halt, while a handover waits ack_wait from when the halt had handed its rows on, so a slow drain could end the renewals before the release. The renewals now run until ack_wait and one renewal after the drain. A renewal row at the cap also counts against the prefetch share of rows waiting for the handler. AGENTS.md names the new mq surface. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Also says, wherever the halted renewal bound is stated, that it runs ack_wait and one renewal from when the halt handed its rows on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Conflicts: - internal/mq/external.go: NewNATS's ExternalNATS literal keeps apiPrefix and activeWithin (#696) and the permission watch (#695). - CHANGELOG.md: the topology entry keeps #696's pinned TTL wording and #695's new manifests flags; both Fixed entries kept. Adds TestExternalNATS_PinRenewalUnderAJSDomain: a halted shard's raw pin renewals, sent under $JS.<domain>.API., keep the pin as the restricted user on a server in the domain and through a leafnode outside it, with no refusal recorded. The deployment docs say the domain prefix covers those pulls. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
The per-shard floor lets a process with more than ten shards hold more than 10,000 rows; only a configured shard durable that is gone ends the worker; a clean stop releases after writing what it holds; the probe sends a pull and an unpin. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
The test took the first moment every unit had one owner as settled, which under a slow scheduler comes while processes are still joining, and counted a handover's release that follows its earlier stop as a move. Wait until every process holds a share, and allow the release of a unit stopped before settling. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
EricAndrechek
marked this pull request as ready for review
September 30, 2026 15:39
This was referenced Sep 30, 2026
This was referenced Sep 30, 2026
Open
This was referenced Oct 6, 2026
This was referenced Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
WaveHouse can now run on a NATS JetStream cluster the operator owns, instead of only on its embedded single-process broker. With
mq.backend: natsandcoord.backend: nats, any number of WaveHouse processes share one queue: severalapi,ingestreplicas, or API and ingest processes deployed separately (withcache.backend: redis).wavehouse mq manifestsprints the whole topology as nack resources, sized to fit the servers' file store, andwavehouse mq permissionsprints the restrictedwavehouseuser's NATS permissions.deployments/nats/ships both, for four partitions of eight shards, with a Helm values snippet for a three-node cluster whose volume the shipped streams fit.ack_wait.Topology
The operator creates all of this, and
wavehouse mq manifestsgenerates it:<prefix>.ingest.<p>.>. A row's subject is<prefix>.ingest.<p>.<s>.<tenant>.<table>. The partitionpand shardscome from independent jump consistent hashes of tenant and table, so changing N or V moves only about 1/N or 1/V of the tables, and a tenant's tables spread over the partitions. Partitions use work-queue retention: a row stays until the ingest worker acks it, even while no consumer covers it, and is deleted then. One tenant's unwritten rows never hold back another's reclaim. A partition has no age limit, and usesdiscard: newwith a byte limit, plus a per-subject cap that refuses one table's flood before it fills the partition.wh-ingest-<s>, each filtering its shard's subjects. They use thepinned_clientpriority policy in the groupwavehouse, with a pinned TTL of at least 10 seconds, twice the 5 seconds between the pulls that keep the pin of a shard at its share of held rows, or stopping. The server delivers a shard to one pinned puller at a time. Names are not zero-padded, so a durable keeps its name when V grows.<prefix>.hist.>, with no sources. Every partition republishes each row it stores to<prefix>.hist.<tenant>.<table>, which drops the partition and shard. SSE replay and every API process's live events read from it through short-lived consumers of their own, the only objects WaveHouse creates. The generatedmax_ageis 15m, the seed's gap window. The history is best effort: a row republished while the history is unavailable reaches ClickHouse but not SSE, and a full or missing history never holds up ingest.<prefix>.dlq.>, shared by every tenant.<prefix>_coord, a KV bucket that holds the ingest processes' leases.The
wavehouseuser can publish to the ingest and dead-letter subjects. It can read stream and consumer info, and pull from, unpin and reset each shard durable by name (a NATS wildcard matches a whole token only, sowh-ingest-*names nothing). It can ack only for the shard durables, under both ack subject layouts ($JS.ACK.*.<durable>.>, and$JS.ACK.*.*.*.<durable>.>for thejs_ack_fc_v2flag). It can read and write lease keys. It cannot create, change, purge or delete a stream or bucket, create a durable on a partition, ack for another consumer, or publish to the history.js_domain. Under a JetStream domain nats.go sends every API request and KV write under$JS.<domain>.API.. A server in the domain maps that to$JS.API.before checking permissions, while a leafnode outside it checks it as sent.wavehouse mq permissions --js-domain <d>allows and denies both forms, which covers the raw pulls that renew a busy or stopping shard's pin.requiredfinding, with the fullwavehouse mq permissionscommand that regenerates the set. Permissions generated for fewer shards than the topology used to boot and leave the extra shards unpulled. A consumer API request refused after boot setswavehouse_mq_topology_okto 0 at once.Sizing
Each server reserves every stream replica's
max_bytesagainst itsmax_file_store, which the NATS Helm chart sets to the JetStream PVC size (100Gi shipped).wavehouse mq manifests --file-store(default100Gi) sizes each partition at 15% of the store, the history at 10% and the dead-letter stream at 5%, so the shipped four partitions reserve 75 GiB. A partition's size does not follow N, so lowering N never needs more store while the removed partitions drain. The generator refuses streams that together exceed--file-store, and--partition-max-bytesresizes the partitions (from six partitions at the defaults). The test fixture now takesmax_file_storefrom the shipped values the way the chart does.wavehouse mq manifestsalso takes--ingest-consumer,--history-streamand--publish-timeout(the partitions' duplicate window follows it), matching the config keys.Worker model
lease.ingest.m<j>, up to 64 slots) in the lease bucket. Every process computes the same owner for every shard from the live members, by rendezvous hashing capped at an even share. When a process joins or leaves, roughly one to two times its share of the shards move while the rounded-up share holds, and never more than half of them. Durables left over after N or V is lowered are assigned apart from the configured ones and drained.ack_wait. A per-shardmax_ack_pendingof 10,000/V was not used: at V=32 it is below the worker's batch of 500, so a busy table's batch could never fill.ack_waitof such rows past its share (12 at the generated 1-minuteack_wait), then with pulls that deliver nothing.Unownedand the takeover reset also require that the durable delivered nothing and had no ack for the pinned TTL, so a missing pin alone no longer reads as a dead owner.ack_waita durable may have), then releases it, and the new owner receives at once.failed, which ends the process so the next boot names what is missing. A bind whose durable is gone or no longer fits does the same.ack_wait. Delivery is at least once. Measured:ack_wait60s)ack_wait. No acked row was replayed.kill -9of one of the real binary's ingest processes (15s lease)ErrUnitHeld.failedfired 5s after the delete.These were measured on a single nats-server 2.14.6, not a 3-replica cluster. The 3-node measurements are tracked in #694.
Configuration
mq.backend: natswith themq.nats.*block (WH_MQ_NATS_*) configures:urlscreds_file,nkey_seed_file, oruser+password_filetls.*, including mutual TLSjs_domainsubject_prefix(defaultwh)partitionsandshards(default 32, 1–256), which must match the topology's metadataingest_consumer(defaultwh-ingest) andhistory_stream(default<PREFIX>_HISTORY)connect_timeout,publish_timeoutandtopology_wait(5s, 5s and 60s)coord.backend: natswithcoord.nats.bucket(WH_COORD_NATS_BUCKET, default<prefix>_coord) configures the lease bucket. It rides themq.natsconnection.Boot rules. Boot refuses:
coord.backend: natswithoutmq.backend: natsingestprocess onmq.backend: natswithcoord.backend: localapiwithoutingest, or the reverse, over a local cachesweeperundernats, because there is no sweeper there and the streams' retention does its workduplicate_windowdoes not cover every publish attempt anddedupe.leasepersist_mode: asyncon a partitionRecommended findings, such as fewer than 3 replicas, are logged and do not stop boot. A publish retried after a lost answer reuses its
Nats-Msg-Id, so it is stored once.Durability comes from replicas. An ingest
200follows the partition's ack, and at 3 replicas that ack comes once a Raft quorum has stored the event. WaveHouse does not requiresync_always.Gauges:
wavehouse_mq_connected,wavehouse_mq_topology_ok,wavehouse_mq_history_behind_seconds, and thewavehouse_ingest_shard*/wavehouse_ingest_rows_heldfamily for ownership, unowned shards, held rows and handovers.Compatibility
mq.backend: embeddedremains the default: one process, per-tenant streams, the fsync-per-event200, and the Active Sweeper under its lease. A config that sets none of the new keys runs as before.Manual pre-production checks
The automated tests run against a single nats-server. The deployment guide's "Before production: manual checks" asks the operator to check two things once on their own cluster:
pinned_client, the group and the pinned TTL. WaveHouse's boot check reports any field that did not arrive.--replicas 3.Tests
Multi-process roles test (
tests/integration/roles_test.go). The real binary runs as tworoles=apiand tworoles=ingestprocesses over an operator-provisioned NATS (4 partitions × 8 shards), a Redis cache and a dynamodb-local dedupe table. It checks:SIGKILLed process's shards are taken over about one lease later, and every batch is still written once.A second test drives the boot rules through the binary.
Conformance suite.
mqtest, the onemq.Brokercontract suite, passes against the external broker connected as the shipped restrictedwavehouseuser. That also proves the user's permissions are enough to publish, consume and check.coordtest.Conformancepasses against the NATS KV leases.More tests:
js_domainboot, consume, ack and lease on a server in the domain and through a leafnode, and a shard's pin renewal as the restricted user in bothinternal/mq/natsspike: work-queue retention with republish, pinned clients, unpin, reset, and the whole-token permission wildcardmake cipasses locally on the final head.Follow-ups
Retry-AfterDuplicateWindow()and the retention warningmq.natsdefaults, name grammar, and the sweeper role undernatsinternal/mqintegration tests selected by name patternUnownedbatching, the crash-restart wait, allocations, the--coord-bucketdocs pointer, per-tenant budgetsPart of #613.
Fixes #264.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq