refactor(keyenc): one key escaping, keep '-', DLQ counts per table - #655
Conversation
NATS subject tokens (internal/mq) and cache namespace tokens (query.SafeEncodeToken) each carried a copy of the same encoder. Both now call internal/keyenc: Escape keeps [A-Za-z0-9_] and writes every other byte as %XX, Unescape decodes as url.PathUnescape did, and Join/Split join escaped fields with a separator the escaping never emits. Output is byte-identical, pinned by golden subject tests and against v0.1.0's encoder for every byte value. 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
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
📚 Docs preview is live → https://8993701f-wavehouse-docs.wave-rf.workers.dev
|
Code Coverage OverviewLanguages: Go GoThe overall line coverage in commit 16dce69 in the Show a line coverage summary of the most impacted files.
Updated |
Escape now keeps '-' alongside [A-Za-z0-9_], exactly the tenant-id grammar, so a tenant id is its own escaped form and dashed names read as themselves. Unescape is url.PathUnescape, so v0.1.0's %2D still decodes. Join/AppendJoin/Split are fixed: Split converted a separator of 0x80+ as a rune, and Join of no fields could not be told from one empty field; both now refuse those inputs. Topic keys are built with AppendJoin and parsed with Split, the tenant token still verbatim. Dead-letter counts are keyed by table, every scope of a table summed under it, through one deadLetterTables function; the ?table= filter keeps all of a table's scopes. A scoped message used to count under "table.scope", which a dotted table name could share. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B1tJWUp6oaDoH1usLwGtLF
A v0.1.0 queue is deleted at boot, so the lenient %2D decoding and the stream-resume caveat only concern queues an unreleased build since #612 wrote; say so in the changelog and the comments. List keyenc in the development.md tree and AGENTS.md's file structure, and correct the stale "L2" cache line beside it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B1tJWUp6oaDoH1usLwGtLF
With '-' kept, "b-c" is what Escape writes, so the case no longer exercised a partly unescaped token; "b~c" does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B1tJWUp6oaDoH1usLwGtLF
Brings in #647, #632, #616 and #655. One conflict, in config.go: main dropped the Cache struct's env-default tag while this branch moved Cache into backends.go; kept the move. #632 removed every env-default tag. The four *.backend fields and cache.l1_max_cost this branch declares in backends.go still carried theirs, so an explicit `backend: ""` would have become the default. Their defaults now live in defaults(); an empty backend, which Validate refuses, is pinned next to server.port's zero as a refusal; the docs test converts a documented default to a named string type such as MQBackend. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Takes #655's per-table dead-letter counts: the conformance cases now expect every scope of a table counted under the table itself, a table filter keeping all of its scopes, and a dotted table name counted apart from a table + scope pair. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in main via feat/coord-leases: #618's squash, #619, #632, #616, #655 and #647. Per #632, the roles default moves from its env-default tag into defaults(): an explicit `roles: []` now reaches Validate (a refusedZeros entry pins it), and the doc-defaults test parses the roles cell as a comma-separated list. instance_id's documented default is *(empty)*, the value in defaults(); Load resolves it to <hostname>-<8 hex>. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Part of #613. This is PR **D1** of the external-NATS workstream. It is based on `main` (#612, which it was stacked on, has merged). ## What - **`internal/mq/mqtest`** (new) is the conformance suite for `mq.Broker`. `mqtest.Run(t, Harness{New, EndDelivery, Fill, Caps})` states the contract as behavior and uses the interfaces only, with no stream, subject or partition names. It covers: - round trips with names that need encoding; - that a topic without a tenant is refused; - trace context reaching `Subscribe`, and `Subscribe` seeing every tenant; - per-tenant order; - `Nak` and `AckWait` redelivery; - that `DeadLetter` keeps the topic and does not ack; - per-tenant, per-table dead-letter counts, every scope of a table counted under the table itself (#655), and an empty (never nil) `Tables` when nothing is parked; - replay bounds and isolation, ctx cancellation, and that a failed pull is an error; - exactly one `failed` report, and none after `stop`; - `MaxBytes`, `Stats`, `ErrQueueFull`, and `PurgeAcked` semantics. Each case runs as a parallel subtest on a fresh broker. - **`mqtest.Caps`** flags the four places where the external backend legitimately differs: - `PerTenantBudget` - `PurgesAcked` - `UnbudgetedNotFound` (DLQ counts of a tenant never given a budget) - `ConfiguresDurables` (whether `CreateConsumer` applies `AckWait` or only finds an operator-made durable) - **The embedded broker passes the suite.** Its run is `internal/mq/mqtest/embedded_test.go`. - **`mq.go` contract wording** is updated as the design specifies: - The delivery unit is "a tenant's queue, or the partition that holds it". - `ErrQueueFull` is a byte limit, and the per-tenant no-queue case applies only to an implementation that opens queues per tenant. - `DeadLetterCounts` may return zero counts in place of `ErrNoDeadLetterQueue`. - `CreateConsumer` may find rather than create. - `PurgeAcked` may remove nothing. - `Subscribe` guarantees delivery only for events published after it returns. - **`mq.ErrUnavailable`** (new) is mapped by the ingest handler to `503` + `Retry-After: 5`. Before, it would have been the `500` "publish failed". No backend returns it yet; D3's will. `api.md` says so. - **Two embedded bugs found by the suite are fixed:** - A durable deleted on several tenants' queues could report on `failed` more than once. A CAS now allows one report, and it is pinned by a test that deletes the durable on real queues one after another. - `ReplaySince` read a pull that raced the connection closing as "caught up". It is now an error unless the connection is open. ## Deviations from the design doc - **The embedded run lives in `internal/mq/mqtest/embedded_test.go`, not `internal/mq/embedded_conformance_test.go`.** `internal/mq`'s unit binary already takes about 10s of its 15s `-race` budget when the machine is idle, and 21–34s under heavy load on the base branch alone (measured). The suite in that binary pushed it over. In its own binary it takes about 2.5s. It also no longer needs an `export_test.go` hook into mq's internals. - **`Harness.DeleteIngestDurable` became `Harness.EndDelivery`,** which ends delivery under a running consumer. The embedded harness closes the broker; D3 should delete the durable. The durable-deletion path for embedded is covered in `internal/mq`'s own tests. - **`Caps.NeverParkedNotFound` became `UnbudgetedNotFound`.** Embedded returns zero counts for a budgeted tenant that has parked nothing. Only a tenant with no budget gets `ErrNoDeadLetterQueue`. - **New cap `ConfiguresDurables`.** The AckWait-redelivery case cannot pass against an operator-made durable with a 60s `ack_wait`. - **The suite uses a fixed `mqtest.Durable = "buffer-consumer"`.** It is the worker's name, so a backend that maps durable names has one to find. - **`Run` sets the global W3C propagator for its duration.** The trace case needs it, so `Run` must not be called from a parallel test. ## Left to later PRs - **D2:** topology spec and verifier, manifests, S1. - **D3:** `ExternalNATS` plus `external_conformance_test.go`, which runs `mqtest.Run` with its own `Caps`. - **D5:** the `Sharded` cap and the shard-subset case. `ConsumerConfig.Shards` does not exist yet, so neither is here. - **D4:** docs for the nats backend. ## Evidence - `make ci`: green on 6ebfc6f, after the merge of `main` with #655 (all coverage gates passed; Go total 94.4%). - The suite passed 40/40 under `-race -count=20 -cpu 1,4` (before the #655 merge). - Pre-push reviewers: `pre-push-reviewer` and `docs-reviewer` both returned `ship_it` before the #655 merge, after 6 and 4 rounds. After it, `pre-push-reviewer` returned `ship_it` on 6ebfc6f (one round of review fixes: the nil-map assertion); the merge left the PR's own docs unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL --------- Co-authored-by: taitelee <taitelee@umich.edu> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main now carries #612 and #618 as squashes, plus #632, #622, #627, #615, #619, #647, #616, #655 and #623. The merge was resolved against the pre-squash #618 head (f129d57) as its base, so main's version wins for everything this stack does not own and only the cache stack's changes (#614, #621, #626 as merged here, and this PR) are re-applied on top. Warnings keeps main's api-role gate for the cache.redis warnings too: a split's Deployments differ only in roles, so the API's cover the others'. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
Part of #613.
One shared escaping for the composite keys WaveHouse builds, in the new
internal/keyencpackage. NATS subjects use it now, the cache's namespace tokens use it throughquery.SafeEncodeToken, and the dedupe keys adopt it in #625. The house rule it sets: a package that builds a composite key takes raw names and builds the key withkeyenc.Join/AppendJoin, so no field can reach a key unescaped.What changes
internal/keyenc(new):Escape/AppendEscapekeep[A-Za-z0-9_-]and write every other byte as%XXin uppercase hex, so.*>, whitespace,%,/,:,|,#,{}, NUL and every non-ASCII byte are escaped. The kept bytes are exactly the tenant-id grammar, so a tenant id is its own escaped form. A%in a name is itself escaped, so names that look escaped (b%2Dc) never share a key with the name they resemble (b-c).Unescapeisurl.PathUnescape:%XXin either case decodes, and any other byte reads as itself.Join/AppendJoinescape each field and put a separator between them;Splitreverses them. They panic on no fields (a key of no fields could not be told from one empty field) and on a separator the escaping could write,%, or a byte outside ASCII.internal/mq): the privateencodeToken/decodeTokenare gone.Topic.key()writes the tenant verbatim, thenAppendJoins the table and scope;parseTopicKeyreads the tenant token verbatim (askeyTenantdoes) andSplits the rest.internal/mq/deadletter.go):GET /v1/ops/dlq/statscounts every scope of a table under the table itself, and?table=keeps all of its scopes. A scoped message used to count undertable.scope, a name a dotted table could share. Scope is always empty today, so the response is unchanged.query.SafeEncodeTokenis a one-line delegate tokeyenc.Escape; feat(cache): shared redis cache, snapshot keys, uncached write pipes #614 moves the escaping into the cache itself and deletes it.What is deliberately not byte-identical
-is kept rather than escaped as%2D, so dashed table names, scopes and (in #625) ids read as themselves. Every other byte escapes exactly as v0.1.0's encoder did (TestEscape_MatchesV010ButDash, all 256 byte values, andFuzzEscapeRoundTripagainst a verbatim copy of it).Why this is safe to change now: no released queue survives into this build. v0.1.0 queued under the shared
WAVEHOUSE/WAVEHOUSE_DLQstreams with subjects that carry no tenant, and #612 deletes both at boot. What remains is a queue an unreleased build since #612 wrote. It still reads, because decoding is unchanged:%2Ddecodes to-, and a dead-letter count merges both forms. One path notices: a/v1/streamclient resuming across such an upgrade (Last-Event-IDorsince) on a table whose name holds-misses that table's events queued before it, since the replay filters on the table's exact subject. The in-process cache starts empty on restart, so its keys changing costs nothing.Work that follows in other PRs
Each of these owes a change once it takes this branch (a note is on each PR):
deadLetterKeepsTheTopicAndDoesNotAckexpects a scoped topic under{"t.s": 1}anddeadLetterCountscountst1andt1.sapart; both must expect every scope under its table ({"t": 1},t1summed). The fold is not theBrokercontract any more — restoring it would undo this PR.AppendJoinand pinsevt-123rather thanevt%2D123.cache.Namespacecarries raw names and the cache escapes its own keys withJoin;query.SafeEncodeTokenis deleted.AppendJoinand hashes escaped fields.deadLetterTables, and its copy of the conformance suite flips as test(mq): one conformance suite for every Broker #623's does.Tests
TestSubject_Golden: full subjects on the ingest and DLQ prefixes for dotted, wildcard, whitespace,-,%,/, brace, NUL,\xff, 2- and 3-byte UTF-8, empty-table and scopeless topics.TestParseTopicKey_LenientTokens: a lowercase escape, a byte left unescaped (~), and an earlier build's%2Dform read as the same topic.TestParseTopicKey_ForeignTailKeepsItself: tails this package could not have written fall back to a topic of no tenant; an escaped tenant token (a%2Db) is refused, since the tenant is read verbatim.TestEscape_KeepsExactlyTheTenantGrammar,TestEscape_LookalikesStayDistinct,TestSeparators(every byte value as a separator: refused, or round-trips),TestJoin_RefusesZeroFields.TestDeadLetterTables: a dotted table and a table + scope pair count apart, scopes sum under their table, the filter keeps every scope, and%2Dand-subjects merge.Checks
make cipasses locally at 79ab364.pre-push-revieweranddocs-reviewership_it at 79ab364, after two iterate rounds with no code defects: the changelog tied the%2Dcompatibility to v0.1.0 (only queues since feat(mq): give every tenant a queue of its own #612 are affected), two package listings missedkeyenc, a stale cache line, the follow-up list missed test(mq): one conformance suite for every Broker #623, and the lenient-token test no longer exercised an unescaped byte.🤖 Generated with Claude Code
https://claude.ai/code/session_01B1tJWUp6oaDoH1usLwGtLF