Skip to content

refactor(keyenc): one key escaping, keep '-', DLQ counts per table - #655

Merged
EricAndrechek merged 8 commits into
mainfrom
refactor/keyenc
Sep 26, 2026
Merged

EricAndrechek merged 8 commits into
mainfrom
refactor/keyenc

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Part of #613.

One shared escaping for the composite keys WaveHouse builds, in the new internal/keyenc package. NATS subjects use it now, the cache's namespace tokens use it through query.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 with keyenc.Join/AppendJoin, so no field can reach a key unescaped.

What changes

  • internal/keyenc (new):
    • Escape/AppendEscape keep [A-Za-z0-9_-] and write every other byte as %XX in 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).
    • Unescape is url.PathUnescape: %XX in either case decodes, and any other byte reads as itself.
    • Join/AppendJoin escape each field and put a separator between them; Split reverses 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.
  • NATS subjects (internal/mq): the private encodeToken/decodeToken are gone. Topic.key() writes the tenant verbatim, then AppendJoins the table and scope; parseTopicKey reads the tenant token verbatim (as keyTenant does) and Splits the rest.
  • Dead-letter counts (internal/mq/deadletter.go): GET /v1/ops/dlq/stats counts every scope of a table under the table itself, and ?table= keeps all of its scopes. A scoped message used to count under table.scope, a name a dotted table could share. Scope is always empty today, so the response is unchanged.
  • Cache namespace tokens: query.SafeEncodeToken is a one-line delegate to keyenc.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, and FuzzEscapeRoundTrip against 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_DLQ streams 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: %2D decodes to -, and a dead-letter count merges both forms. One path notices: a /v1/stream client resuming across such an upgrade (Last-Event-ID or since) 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):

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 %2D form 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 %2D and - subjects merge.

Checks

🤖 Generated with Claude Code

https://claude.ai/code/session_01B1tJWUp6oaDoH1usLwGtLF

EricAndrechek and others added 3 commits September 25, 2026 13:04
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
@coderabbitai

coderabbitai Bot commented Sep 25, 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: 0c7139f8-4091-4b67-b8bb-073035cd638c

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

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/query Structured query AST, SQL builder area/docs Documentation, site/, README labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

📚 Docs preview is live → https://8993701f-wavehouse-docs.wave-rf.workers.dev

  • Commit — 16dce69: Merge remote-tracking branch 'origin/main' into refactor/keyenc
  • Author — @EricAndrechek
  • Committed — 2026-09-25 21:06 (UTC-04:00)
  • Deployed — 2026-09-25 21:16 EDT

@github-code-quality

github-code-quality Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall line coverage in commit 16dce69 in the refactor/keyenc branch remains at 94%, unchanged from commit 73c75ee in the main branch.

Show a line coverage summary of the most impacted files.
File main 73c75ee refactor/keyenc 16dce69 +/-
internal/mq/subject.go 100% 100% 0%
internal/query/ident.go 100% 100% 0%
internal/mq/embedded.go 88% 89% +1%
internal/ingest/worker.go 97% 98% +1%
internal/keyenc/keyenc.go 0% 100% +100%
internal/mq/deadletter.go 0% 100% +100%

Updated September 26, 2026 01:16 UTC

EricAndrechek and others added 4 commits September 25, 2026 19:27
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
@EricAndrechek EricAndrechek changed the title refactor: one escaped key format for subjects, cache and dedupe keys refactor(keyenc): one key escaping, keep '-', DLQ counts per table Sep 25, 2026
@EricAndrechek
EricAndrechek marked this pull request as ready for review September 26, 2026 01:12
@EricAndrechek
EricAndrechek requested review from a team and taitelee September 26, 2026 01:12
@EricAndrechek
EricAndrechek merged commit 77cf4a7 into main Sep 26, 2026
26 of 32 checks passed
@EricAndrechek
EricAndrechek deleted the refactor/keyenc branch September 26, 2026 01:22
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
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>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
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>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Brings in #618 (squash-merged parent), #619, #632, #616, #655 and #647.
Conflicts resolved by keeping main's content plus this branch's coord
changes; the AGENTS.md package count is now twenty (keyenc + coord).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
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>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
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>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
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
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/query Structured query AST, SQL builder documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant