Skip to content

fix(mq): keep one owner per shard under load, handover and stop - #696

Merged
EricAndrechek merged 11 commits into
feat/mq-nats-topologyfrom
fix/nats-ownership
Sep 30, 2026
Merged

EricAndrechek merged 11 commits into
feat/mq-nats-topologyfrom
fix/nats-ownership

Conversation

@EricAndrechek

Copy link
Copy Markdown
Member

Part of #613. Stacked on #624 (feat/mq-nats-topology); merge into it, not into main.

This fixes four problems with shard ownership under mq.backend: nats. Each one broke the promise that a table has one writer and keeps its order under load, across handovers and across rolling deploys.

What changes

1. A busy owner keeps its pin

The pull consumer ran the worker's handler on its delivery goroutine and only pulled again once the handler returned. The server renews a pin only when it gets a pull. So a process that sat at its held-rows cap for longer than the pinned TTL (10s) lost its pins. wavehouse_ingest_shards_unowned then counted its shards, and a peer that took the process for dead reset them.

  • Each unit now has two goroutines (internal/mq/external_consumer.go, moved out of external.go):
    • A puller that never runs the handler. It fetches in 1s pulls, sized to what the unit's cap leaves room for.
    • A deliverer that hands rows to the handler in order.
  • At the cap, the puller renews the pin every 5s with a one-row fetch, up to one ack_wait of such rows past the cap (12 at the generated 1-minute ack_wait).
  • Past that, and once halted, it renews with a pull of max_bytes: 1. The server holds the row back, so that pull delivers nothing.
  • The one-row fetch at the cap is there for order. A held-back redelivery goes to the back of the server's redelivery queue: with max_bytes-1 renewals at the cap, rows NAKed as 0, 1, 2, 3 came back as 2, 3, 0, 1 (measured).
  • ResetOrphaned and Unowned now also require that the durable delivered nothing and had no ack for its pinned TTL. A missing pin alone no longer means the owner is dead.

2. Each shard gets its own budget

The process-wide cap of 10,000 held rows let one stuck table take every slot, which stalled every shard the process owned.

  • Each shard now holds an even share of the budget over the shards its process holds, and never less than 1,000 (two batches).
  • The share goes through ConsumerConfig.MaxHeld and is read before every fetch.
  • wavehouse_ingest_rows_held_waits_total is removed, because nothing waits at the cap any more.

3. A clean stop and a handover keep order

Before:

  • A stop cancelled the worker while its shards kept pulling. Rows delivered during the final flush were dropped, and they only came back after ack_wait, behind newer rows.
  • A handover released its shard after at most 15 seconds, even if its rows had not been written yet.

Now:

  • A clean stop halts every shard first (mq.Halter) and keeps the pins. The worker writes what it holds plus whatever the shards still deliver. Only then does the stop release the shards.
  • A handover keeps the pin until its rows are written, bounded by the worker's 60s ack wait.
  • A row the worker drops while stopping is NAKed before the release.

4. A deleted durable ends the worker

  • A durable deleted while no pull of it was waiting stalled silently until the five-minute topology check. Two pulls in a row with no responders, or one renewal with none, now look the durable up. If the durable or its stream is gone, delivery ends through failed.
  • A bind whose durable or stream is gone, or whose durable no longer fits (mq.ErrConsumerMismatch), now ends the worker.
  • A bind failure that lasts a minute now logs an error.

The release check-then-unpin race is documented, not changed:

  • The unpin names no pin id, so a check that the pin is still ours comes first.
  • A halted unit renews its pin until the unpin returns, so the pin cannot lapse between the check and the unpin.
  • Only a consumer leader change in that window could move the pin.

Measurements

These were run against an in-process single-node nats-server v2.14.6.

  • Blocked owner. Its insert hung for 13s, longer than the 10s pinned TTL. It kept one pin id throughout, took 2 rows past its share, and a peer's ResetOrphaned answered ErrUnitHeld.
  • Throughput of one unit. 160k to 192k rows/s, against 158k to 172k before, with 20,000 rows of 256 bytes, run three times.
  • Handover and clean stop under continuous publishing. 16 tables, with the first process's ClickHouse taking 6s per insert. Every table's rows arrived exactly once and in order. The next owner wrote its first rows about 2s after the stop. With the halt removed that was 9s, while the dropped rows were waited out.
  • Deleted durable. failed fired 5s after the delete, with the handler busy.

Tests

Each new test was checked by removing its fix and confirming the test then fails:

  • TestShardClaims_BlockedOwnerKeepsItsShard
  • TestShardClaims_StuckShardDoesNotStallTheOthers
  • TestShardClaims_TableOrderAcrossHandoverAndStop
  • TestExternalNATS_DurableDeletedWhileBusyEndsDelivery
  • TestExternalNATS_RedeliveryOrderKeptAtTheCap
  • TestExternalNATS_BlockedHandlerTakesABoundedOvershoot
  • TestExternalNATS_BlockedHandlerOvershootBoundedByTheShare
  • TestExternalNATS_HaltKeepsThePinPastASlowDrain
  • TestIngestWorker_StopHaltsBeforeTheFlushAndStopsAfter
  • TestIngestWorker_StopNaksWhatArrivesAfterItStopsReading
  • TestClaims_UnitShareOfMaxHeld
  • the fatal-bind cases in TestClaims_MissingDurableEndsDelivery

make ci passes locally.

Left for later

  • Rows redelivered across a handover or stop. They come back ahead of newer rows, but not always in order among themselves. While the next owner's pull waits for the pin, the server requeues a due redelivery behind the others. The normal handover and stop hand nothing back, so this only affects rows already NAKed or past ack_wait. It is documented.
  • Cost of the change. An idle shard now costs one pull per second (1s fetches bound how long a halt takes). A stuck shard can hold up to 12 rows past its share.
  • Deploy and tooling fixes to the manifests, the permissions check and the mq CLI are in a separate PR.
  • The other follow-ups found in validation: mixed-version rollouts, the history's edge cases, and the storage-limit status codes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq

EricAndrechek and others added 11 commits September 30, 2026 08:13
A shard owner's pull consumer ran the handler on its delivery goroutine
and pulled again only once the handler returned. At the held-rows cap
the handler blocked, the server saw no pull for longer than pinned_ttl
and dropped the pin, and Unowned and ResetOrphaned then read a live
owner as gone.

Each unit now has a puller that never runs the handler and a deliverer
that does. The puller fetches only what the unit's cap leaves room for;
at the cap, and once halted, it sends pulls with max_bytes 1 that renew
the pin and deliver nothing. Unowned and ResetOrphaned also require no
delivery or ack within the durable's pinned_ttl.

The held-rows cap is per unit: an even share of MaxHeld over the units
the process owns, never below two batches, so one stuck table cannot
stall the process's other shards.

A clean stop halts every unit first, routes what they still deliver,
flushes, and only then releases; a handover releases once its rows have
settled (bounded by ack_wait). A row the worker drops while stopping is
NAKed before the release.

A durable deleted while no pull waits answers the next pull with no
responders; the unit then looks the durable up and ends delivery with
failed. A bind whose durable or stream is gone, or whose durable no
longer fits, is fatal; a bind failure that lasts logs an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
…ivery

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
The order test's first process now writes slower than a batch window,
so a handover that released before its rows were written, or a stop
that left rows unsettled, fails it. A delivery a consumer that cannot
halt makes after the worker stopped reading is NAKed before and after
the consumer's stop, not stranded until ack_wait.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
The deployment, pipeline and architecture pages said a stuck live
owner keeps its pins, and that a process caps its held rows at 10,000
across its shards; neither held. They now describe the per-shard share,
the renewing pulls, the halt before a clean stop's flush, the activity
test before a takeover reset, and which bind failures end the worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
A renewal pull with max_bytes 1 holds any row back, and the server puts
a redelivery it held back at the tail of its redelivery queue, so rows
NAKed as 0, 1, 2, 3 came back as 2, 3, 0, 1 after two renewals. At the
cap the renewal is now a one-row fetch, and they come back in order; a
halted unit still renews without taking a row. The docs now describe
that, the 1-second fetch against the 5 seconds between renewals, and
the one-minute handover bound, and say rows redelivered across a
handover may come back out of order among themselves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Renewing with a one-row fetch at the cap took a row every 5 seconds
however long the handler stayed blocked, and rows queued for a blocked
handler never expire, so a stuck shard's held rows grew without end. A
unit now takes a row to renew only while nothing waits for its handler
and it is less than one ack_wait of renewals past its cap; otherwise it
renews with a pull that delivers nothing. The docs say the cap is
exceeded by at most that dozen rows, and that every pull waits a second
at most.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Refusing a renewal row whenever rows waited for the handler sent a
backed-up shard straight to the renewal that reorders due redeliveries,
while the count bound alone already stops the growth: rows queued for
the handler stay counted. A shard now takes renewal rows until it is one
ack_wait of renewals past its cap, whatever the handler does, and a test
with a shortened ack_wait holds it there. The docs state the condition
and the 12 at the generated one-minute ack_wait.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
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
@coderabbitai

coderabbitai Bot commented Sep 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b45c4644-48bb-4f43-8421-4973ee04f1db

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/ingest Ingest pipeline (Bento, batching, DLQ) area/docs Documentation, site/, README labels Sep 30, 2026
EricAndrechek added a commit that referenced this pull request Sep 30, 2026
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
@EricAndrechek
EricAndrechek merged commit e94605e into feat/mq-nats-topology Sep 30, 2026
1 check passed
@EricAndrechek
EricAndrechek deleted the fix/nats-ownership branch September 30, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation, site/, README area/ingest Ingest pipeline (Bento, batching, DLQ) documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

1 participant