Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
134 commits
Select commit Hold shift + click to select a range
ef93154
feat(mq): give every tenant a queue of its own
taitelee Sep 24, 2026
8ad9865
fix(mq): reopen detached from the request, and the upgrade runbook swept
taitelee Sep 24, 2026
650a28e
fix(app): boot opens queues under New's context; docs review fixes
taitelee Sep 24, 2026
999c1db
fix(mq): boot re-applies a budget to a split queue pair; review fixes
taitelee Sep 24, 2026
db2d20a
fix(mq): a failed resize restores the ingest stream's own cap; review…
taitelee Sep 25, 2026
07c6a91
fix(app): a rejected tenant keeps its replay history; review fixes
taitelee Sep 25, 2026
04aad9f
fix(mq): share the hub bridge's fetch-ahead across tenants; review fixes
taitelee Sep 25, 2026
57870c4
fix(mq): publish only into a queue the broker recorded open; review f…
taitelee Sep 25, 2026
7cdb794
docs(mq): a consumer that cannot join a queue opened at runtime; revi…
taitelee Sep 25, 2026
e199a03
fix(mq): pace publish-side retries of a queue that cannot open; revie…
taitelee Sep 25, 2026
55137d8
test(mq): one tenant's failed purge stops no other; review fixes
taitelee Sep 25, 2026
060ca9e
docs(mq): size a dead-letter stream the shrink guard kept; review fixes
taitelee Sep 25, 2026
3022b92
feat(config): choose each layer's implementation at boot
EricAndrechek Sep 25, 2026
77ef65d
test(mq): pin interest partitions with a sourced history (S1)
EricAndrechek Sep 25, 2026
fde17ba
docs(config): say coord.backend is reserved; sync the boot-config lists
EricAndrechek Sep 25, 2026
13422da
feat(coord): leases, in-process implementation
EricAndrechek Sep 25, 2026
a82f74b
docs(coord): state what an unfenced sweeper overlap can cost
EricAndrechek Sep 25, 2026
f129d57
docs(config): no backend has a sub-block yet; index backends.go in AG…
EricAndrechek Sep 25, 2026
bb027da
test(mq): one conformance suite for every Broker
EricAndrechek Sep 25, 2026
d913a18
docs(coord): name ErrClosed as RunElected's other exit; changelog files
EricAndrechek Sep 25, 2026
1adc286
test(mq): run the embedded conformance in a test binary of its own
EricAndrechek Sep 25, 2026
80d6c22
docs(mq): keep the per-tenant no-queue case in ErrQueueFull's contract
EricAndrechek Sep 25, 2026
735fd78
feat(mq): topology spec and verifier for operator-owned NATS
EricAndrechek Sep 25, 2026
bf11ecc
test(mq): pin the exactly-once failed report through durable deletion
EricAndrechek Sep 25, 2026
ef4153c
docs(api): list the unavailable broker among the request aborts
EricAndrechek Sep 25, 2026
9344f1b
fix(mq): pace a park's reopen, and warn only on a missing consumer; r…
taitelee Sep 25, 2026
68b74ff
fix(mq): refuse per-subject eviction, unattached sources, shared part…
EricAndrechek Sep 25, 2026
8792d89
test(mq): end delivery only once the pulls are live
EricAndrechek Sep 25, 2026
5a9d1f1
Merge origin/feat/boot-backends into feat/coord-leases
EricAndrechek Sep 25, 2026
e0705f2
feat(coord): coord.backend selects the coordinator
EricAndrechek Sep 25, 2026
3425243
Merge remote-tracking branch 'origin/mq-tenant-streams' into feat/mq-…
EricAndrechek Sep 25, 2026
beab0fd
feat(app): process roles
EricAndrechek Sep 25, 2026
e2434d6
fix(mq): a replay whose connection closed is not caught up
EricAndrechek Sep 25, 2026
e78ea2a
test(mq): keep the unit suite inside its per-package timeout
EricAndrechek Sep 25, 2026
a6fdcfd
docs(changelog): leave the other entries' spelling as it was
EricAndrechek Sep 25, 2026
5b5f988
Merge remote-tracking branch 'origin/mq-tenant-streams' into feat/mq-…
EricAndrechek Sep 25, 2026
5de4fd0
docs(app): scope instance_id and sweeper claims to what ships today
EricAndrechek Sep 25, 2026
0fb7637
test(mq): give the S1 tests a package of their own
EricAndrechek Sep 25, 2026
c63ec9a
ci(cov): keep the external-NATS topology out of the e2e suite's gate
EricAndrechek Sep 25, 2026
aa9a655
Merge remote-tracking branch 'origin/feat/mq-conformance' into feat/m…
EricAndrechek Sep 25, 2026
0aaeddb
feat(mq): a Broker over an external NATS cluster
EricAndrechek Sep 25, 2026
c452cb6
fix(mq): count a replay down, and size the duplicate window to retries
EricAndrechek Sep 25, 2026
c8ad3f0
fix(mq): keep a replay consumer through a slow client's batch
EricAndrechek Sep 25, 2026
2757e64
fix(test): retry removing an embedded broker store in testutil
EricAndrechek Sep 25, 2026
dbd4661
Merge remote-tracking branch 'origin/feat/process-roles' into feat/mq…
EricAndrechek Sep 25, 2026
83ef8d0
feat(app): mq.backend selects embedded or external NATS
EricAndrechek Sep 25, 2026
2bfc9ee
fix(config): refuse credentials in mq.nats.urls; review fixes to docs
EricAndrechek Sep 25, 2026
18d47de
fix(mq): drain partitions a lower N leaves; quiet close
EricAndrechek Sep 25, 2026
6d42f5a
fix(mq): cover a publishing old-N process; name the stream delete
EricAndrechek Sep 25, 2026
d4f7450
feat(mq): coord leases on a NATS KV bucket
EricAndrechek Sep 25, 2026
71fdc07
fix(mq): time lease step-down from the renewal sent; review fixes
EricAndrechek Sep 25, 2026
b29929e
fix(mq): a per-term id in the lease value; lease tests off the unit b…
EricAndrechek Sep 25, 2026
8a9af4b
test(mq): the unit verifier cases skip the lease bucket they do not c…
EricAndrechek Sep 25, 2026
8e8cc73
Merge remote-tracking branch 'origin/main' into feat/mq-nats-topology
EricAndrechek Sep 25, 2026
5570296
Merge remote-tracking branch 'origin/main' into feat/mq-conformance
EricAndrechek Sep 25, 2026
6f2ddb6
Merge remote-tracking branch 'origin/main' into feat/boot-backends
EricAndrechek Sep 25, 2026
184864e
fix(mq): refuse an embedded store it cannot create at once
EricAndrechek Sep 25, 2026
a3381d2
test(mq): run the embedded broker's tests in parallel, without fsync
EricAndrechek Sep 25, 2026
c566f55
test(app): boot without the embedded broker's fsync per write
EricAndrechek Sep 25, 2026
a48ce1f
test(app): a 1ms topology_wait where the outcome cannot change
EricAndrechek Sep 25, 2026
61b643f
fix(mq): create the embedded store at 0700; changelog the fail-fast
EricAndrechek Sep 25, 2026
0e04e3e
docs(changelog): scope the fail-fast store entry to what mkdir catches
EricAndrechek Sep 25, 2026
5ba2bc1
test(mq): keep the streams directory occupied through a failed open
taitelee Sep 25, 2026
5de3e98
docs(mq): an external NATS ack rests on replicas, not an fsync
EricAndrechek Sep 25, 2026
9ed7119
fix(mq): refuse async persist mode on an ingest partition
EricAndrechek Sep 25, 2026
f00b0f4
feat(mq): generate a 15-minute history stream
EricAndrechek Sep 25, 2026
85d5a17
fix(mq): do not warn on a gap window equal to the history's max_age
EricAndrechek Sep 25, 2026
d563fa9
docs(changelog): an equal gap window read as longer, not short
EricAndrechek Sep 25, 2026
b4bd984
Merge origin/feat/boot-backends into feat/coord-leases
EricAndrechek Sep 25, 2026
eaeb52f
Merge origin/feat/coord-leases into feat/process-roles
EricAndrechek Sep 25, 2026
7847c14
fix(mq): count dead letters per table on both brokers
EricAndrechek Sep 25, 2026
50a1170
docs(changelog): list deadletter.go in the external-broker entry
EricAndrechek Sep 26, 2026
22d30b0
Merge remote-tracking branch 'origin/main' into feat/mq-conformance
EricAndrechek Sep 26, 2026
6ebfc6f
test(mq): nothing parked is an empty Tables map, never nil
EricAndrechek Sep 26, 2026
5329eb1
Merge origin/main into feat/coord-leases
EricAndrechek Sep 26, 2026
b643ba1
Merge feat/coord-leases (synced with main) into feat/process-roles
EricAndrechek Sep 26, 2026
01cd262
docs(app): a bearer token on a worker's ops listener gets 401
EricAndrechek Sep 26, 2026
1c26d11
Merge origin/main into feat/process-roles
EricAndrechek Sep 26, 2026
dafea4c
fix(app): ops-auth warning names the nested case; own store in test
EricAndrechek Sep 26, 2026
23ac730
Merge origin/main into feat/process-roles
EricAndrechek Sep 26, 2026
ada4bdd
test(integration): name the roles in TestQueryErrors_ClickHouseDown
EricAndrechek Sep 26, 2026
ff67ab5
Merge remote-tracking branch 'origin/main' into feat/mq-conformance
EricAndrechek Sep 26, 2026
704176f
Merge feat/coord-nats-kv and fix/mq-external-close-and-shrink into fe…
EricAndrechek Sep 29, 2026
f91ab6f
Merge feat/mq-external-broker into feat/mq-nats-wiring
EricAndrechek Sep 29, 2026
bd1113f
Merge feat/mq-external-broker into feat/mq-nats-topology
EricAndrechek Sep 29, 2026
eab1830
Merge the final head of #622 (feat/process-roles) into feat/mq-nats-t…
EricAndrechek Sep 29, 2026
e395743
Merge the final head of #623 (feat/mq-conformance) into feat/mq-nats-…
EricAndrechek Sep 29, 2026
873c39f
Merge origin/main into feat/mq-nats-topology
EricAndrechek Sep 29, 2026
d896450
fix(config): give the mq.nats defaults to defaults()
EricAndrechek Sep 29, 2026
492fde8
fix(mq): ExternalNATS honours the idempotency key
EricAndrechek Sep 29, 2026
cbd7464
feat(mq): nats duplicate_window covers dedupe.lease; warn on short re…
EricAndrechek Sep 29, 2026
9229f15
docs: the combined backends' claims main's merge left stale
EricAndrechek Sep 29, 2026
5bb6f2d
fix(mq): manifests cover dedupe.lease; review fixes to docs
EricAndrechek Sep 29, 2026
c7311e5
docs(changelog): list the manifests files in the mq.backend: nats entry
EricAndrechek Sep 29, 2026
a058c6c
test(integration): give the NATS tests' hand-built configs a dedupe l…
EricAndrechek Sep 29, 2026
9fefe7c
refactor(config): move the nats blocks and rules into mq_nats.go
EricAndrechek Sep 29, 2026
7fa5e46
refactor(app): move the ops-only listener's wiring into wire_ops.go
EricAndrechek Sep 29, 2026
019b968
feat(app): wire no sweeper under mq.backend: nats
EricAndrechek Sep 29, 2026
aa9c8fb
feat(mq): work-queue partitions and a republished history
EricAndrechek Sep 29, 2026
f0b1a43
feat(mq): split each partition into shards owned by a pin
EricAndrechek Sep 29, 2026
103a485
feat(ingest): balance shards over workers by membership
EricAndrechek Sep 29, 2026
fd0a4be
test(integration): C2 on shards: ownership and handover, no sweeper
EricAndrechek Sep 29, 2026
f7f82ad
ci(cov): keep the shard claims out of the e2e coverage gate
EricAndrechek Sep 29, 2026
8e56f5e
fix(ingest): keep extras from moving configured shards; cap membership
EricAndrechek Sep 29, 2026
4f3ba59
fix(ingest): a shard durable gone at bind time ends delivery
EricAndrechek Sep 29, 2026
064f16b
docs(mq): generate the permissions with the shards; exact jump-hash m…
EricAndrechek Sep 29, 2026
61a67a2
fix(ingest): retry a shard bind the broker did not answer
EricAndrechek Sep 29, 2026
2389199
test(ingest): wait for settles and releases; docs for the new files
EricAndrechek Sep 29, 2026
cc3fb0e
fix(ingest): report the owned shards of the tick just run
EricAndrechek Sep 29, 2026
c8e89b8
fix(mq): require NATS 2.14, the first with consumer reset
EricAndrechek Sep 29, 2026
3a4b99a
docs(mq): lowering V deletes the nack Consumer before the durable
EricAndrechek Sep 29, 2026
f0a3895
docs(mq): only a configured shard durable deleted ends the worker
EricAndrechek Sep 29, 2026
9a4b3fe
docs(changelog): describe the external-NATS stack as it ships
EricAndrechek Sep 29, 2026
b59a1ba
docs(mq): scope embedded-only queue behaviour; shrink drains durables…
EricAndrechek Sep 29, 2026
1257f94
test(integration): give the integration package 480s
EricAndrechek Sep 29, 2026
2436e04
test(integration): say which clock the 480s timeout is for
EricAndrechek Sep 29, 2026
cea9ca9
fix(mq): fit nats sizes, probe shard permissions, fix nats tooling
EricAndrechek Sep 30, 2026
fd27505
docs(nats): file store sizing, permission probe, js_domain and flags
EricAndrechek Sep 30, 2026
5adbf27
fix(mq): match domain refusals, bound the watch, full remedy hint
EricAndrechek Sep 30, 2026
c5f2cc0
fix(mq): keep a shard's pin while its owner is busy or stopping
EricAndrechek Sep 30, 2026
d10d3a1
test(mq): a durable that no longer fits, or lost its stream, ends del…
EricAndrechek Sep 30, 2026
4e20dd5
test(ingest): pin the stop order: halt, flush, NAK what came late, stop
EricAndrechek Sep 30, 2026
82dcb16
docs(mq): say how a shard owner keeps its pin, shares, stops and fails
EricAndrechek Sep 30, 2026
279c313
fix(mq): renew a shard's pin at its cap by taking a row
EricAndrechek Sep 30, 2026
1c2e669
fix(mq): bound the rows a shard takes past its cap to renew its pin
EricAndrechek Sep 30, 2026
d9a327a
fix(mq): bound a shard's renewal rows by count alone, and test the bound
EricAndrechek Sep 30, 2026
1cbda6a
fix(mq): keep a halted shard's pin for ack_wait after its drain
EricAndrechek Sep 30, 2026
c706345
test(mq): a blocked unit's renewal rows stop past its prefetch share too
EricAndrechek Sep 30, 2026
370cbb9
test(mq): pin the share-capped overshoot at exactly its bound
EricAndrechek Sep 30, 2026
62d27b0
test(mq): sample the MaxHeld overshoot well after its last renewal row
EricAndrechek Sep 30, 2026
e94605e
merge: fix/nats-ownership (#696) into feat/mq-nats-topology
EricAndrechek Sep 30, 2026
9ded702
merge: fix/nats-deploy-tooling (#695) into feat/mq-nats-topology
EricAndrechek Sep 30, 2026
6f87832
docs(mq): reconcile the merged nats ownership and tooling docs
EricAndrechek Sep 30, 2026
e99d86f
test(ingest): settle the differing-extras test on the full split
EricAndrechek Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .testcoverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ exclude:
# internal/mq/mqtest/ is the Broker conformance suite: test code that
# lives outside *_test.go only so each backend's tests can import it.
- ^internal/mq/mqtest/
# internal/mq/natstest/ stands up NATS as an operator deploys it, for
# tests outside internal/mq; test code, like mqtest.
- ^internal/mq/natstest/
- ^tests/
# scripts/ holds Go helpers (cov, orchestrator) that drive the build but
# aren't part of the shipped binary; they show up in `-coverpkg=./...`
Expand Down Expand Up @@ -101,3 +104,36 @@ exclude:
# outage. The unit and integration suites cover both.
- ^internal/config/cache_redis\.go$
- ^internal/cache/pending\.go$
# The external-NATS topology spec, verifier and manifest generator
# (and the `mq manifests` CLI) run against an operator's NATS, which
# the e2e stack (embedded broker) never has: unit territory, covered
# there by a fixture server. Excluding them keeps e2e at ~61%. The
# external broker is the same, covered by the integration suite.
- ^internal/mq/nats_topology\.go$
- ^internal/mq/nats_manifests\.go$
- ^internal/mq/nats_permprobe\.go$
- ^internal/mq/subject_nats\.go$
- ^internal/mq/external(_consumer)?\.go$
- ^internal/mq/lease\.go$
- ^cmd/wavehouse/mq\.go$
# Their wiring, apart from wire.go for this: the NATS queue and lease
# wiring and the retention warning under it.
- ^internal/app/wire_nats\.go$
# The ops-only listener of a process without the api role, moved
# out of wire.go the same way: the e2e binary runs every role.
- ^internal/app/wire_ops\.go$
# The mq.nats block's checks, as cache_redis.go above: boot adopts the
# embedded fixture, so e2e never reads it.
- ^internal/config/mq_nats\.go$
# The ingest processes' shard claims, which run only over the external
# broker's shards: the unit suite covers them with a fake queue, and
# the integration suite against NATS.
- ^internal/ingest/(claims|assign)\.go$
- ^internal/mq/settle\.go$
unit:
# The external NATS broker's tests start a server per case, which the
# unit suite's 15s per package cannot hold: they are integration-tagged
# (make test-integration), and the merged total counts them.
- ^internal/mq/external(_consumer)?\.go$
# The NATS KV leases, the same way.
- ^internal/mq/lease\.go$
19 changes: 10 additions & 9 deletions AGENTS.md

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Open a [feature request issue](https://github.com/Wave-RF/WaveHouse/issues/new?t

The pre-push hook (installed by `make tools`) blocks a push until the tree has been validated locally: a code change needs `make ci`, a docs/prose-only change needs only `make verify` (the same split CI makes). `make lint` / `make test` / `make build` are fast inner-loop subsets.

2. Write tests for new functionality. Unit tests go alongside the code in `internal/`. Integration tests carry the `//go:build integration` tag and go in `tests/integration/`, or beside the package when they test one package against its own external server (e.g. `internal/cache/redis_integration_test.go`), with the package added to the `test-integration` target.
2. Write tests for new functionality. Unit tests go alongside the code in `internal/`. Integration tests carry the `//go:build integration` tag and go in `tests/integration/`, or beside the package when they test one package against its own external server (e.g. `internal/cache/redis_integration_test.go`), with the package added to the `test-integration` target. A test that must import NATS, which only `internal/mq` may do, goes in `internal/mq/natsspike`, or in `internal/mq` itself with the `integration` tag when it needs the package's internals (the external NATS broker's tests, which `make test-integration` selects by name).

3. Update documentation if your change affects:
- API endpoints → update `docs/src/content/docs/api.md`
Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -762,9 +762,18 @@ test: test-unit
test-integration: go-mod-download ## Run Go integration tests + render coverage + gate threshold (requires Docker)
@printf "$(CYAN)==> Running Integration Tests...$(RESET)\n"
@rm -rf $(COV_INT)/data && mkdir -p $(COV_INT)/data
@# 480s: the go command kills the package at -timeout + 1m, counting
@# TestMain's wavehouse binary build before m.Run, and the multi-process
@# roles test runs its handover steps in sequence; at 240s CI killed it.
@GOCOVERDIR="$(CURDIR)/$(COV_INT)/data" go tool gotestsum --format $(GOTESTSUM_FMT) -- \
-tags="integration $(TAGS)" -timeout 480s -coverpkg=./... -race -count=1 \
./tests/integration/... ./internal/mq/natsspike/... ./internal/cache/... $(ARGS) \
-args -test.gocoverdir="$(CURDIR)/$(COV_INT)/data"
@# internal/mq's integration-tagged tests (the external NATS broker) run
@# alone: its untagged tests are the unit suite's.
@GOCOVERDIR="$(CURDIR)/$(COV_INT)/data" go tool gotestsum --format $(GOTESTSUM_FMT) -- \
-tags="integration $(TAGS)" -timeout 240s -coverpkg=./... -race -count=1 \
./tests/integration/... ./internal/cache/... $(ARGS) \
-run '^Test(ExternalNATS|NewNATS|NATSPermissions_Refuse|Leases)' ./internal/mq $(ARGS) \
-args -test.gocoverdir="$(CURDIR)/$(COV_INT)/data"
@if [ -z "$(COV_DEFER)" ]; then go run ./scripts/cov render integration; fi

Expand Down
4 changes: 4 additions & 0 deletions cmd/wavehouse/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ func main() {
os.Exit(runValidate(os.Args[2:]))
case "bootstrap":
os.Exit(runBootstrap(os.Args[2:]))
case "mq":
os.Exit(runMQ(os.Args[2:], os.Stdout, os.Stderr))
case "version", "--version", "-v":
fmt.Printf("wavehouse %s (commit %s, built %s)\n", Version, GitCommit, BuildTime)
os.Exit(0)
Expand Down Expand Up @@ -138,6 +140,8 @@ func printUsage(w io.Writer) {
wavehouse start the server
wavehouse validate [dir] validate a settings directory (dir falls back to %[1]s)
wavehouse bootstrap [dir] write a starter settings directory, every key at its default (dir falls back to %[1]s)
wavehouse mq manifests print the nack resources for an external NATS JetStream
wavehouse mq permissions print the wavehouse NATS user's permissions for it
wavehouse health liveness self-probe against the local server (container HEALTHCHECK)
wavehouse version print version, commit, and build time
wavehouse help show this help
Expand Down
182 changes: 182 additions & 0 deletions cmd/wavehouse/mq.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package main

import (
"errors"
"flag"
"fmt"
"io"

"github.com/Wave-RF/WaveHouse/internal/dedupe"
"github.com/Wave-RF/WaveHouse/internal/mq"
)

// runMQ implements `wavehouse mq <command>`: tooling for the message queue.
// Exit codes: 0 ok, 1 failed, 2 usage.
func runMQ(args []string, stdout, stderr io.Writer) int {
usage := func(w io.Writer) {
_, _ = fmt.Fprint(w, `usage: wavehouse mq <command>

commands:
manifests print the nack resources for an external NATS JetStream
permissions print the wavehouse NATS user's permissions for it
`)
}
if len(args) == 0 {
usage(stderr)
return 2
}
switch args[0] {
case "manifests":
return runMQManifests(args[1:], stdout, stderr)
case "permissions":
return runMQPermissions(args[1:], stdout, stderr)
case "help", "-h", "--help":
usage(stdout)
return 0
default:
_, _ = fmt.Fprintf(stderr, "wavehouse mq: unknown command %q\n\n", args[0])
usage(stderr)
return 2
}
}

// runMQManifests implements `wavehouse mq manifests`: print the nack
// Stream, Consumer and KeyValue resources for the topology WaveHouse checks at boot
// under mq.backend: nats, for the operator to apply.
func runMQManifests(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("mq manifests", flag.ContinueOnError)
fs.SetOutput(stderr)
partitions := fs.Int("partitions", mq.DefaultNATSPartitions, "number of ingest partition streams (mq.nats.partitions)")
shards := fs.Int("shards", mq.DefaultNATSShards, "shards of every partition, one durable each (mq.nats.shards)")
prefix := fs.String("prefix", mq.DefaultNATSSubjectPrefix, "subject prefix (mq.nats.subject_prefix)")
replicas := fs.Int("replicas", 3, "replicas for every stream and the lease bucket")
consumer := fs.String("ingest-consumer", mq.DefaultNATSIngestConsumer, "the shard durables' name prefix (mq.nats.ingest_consumer)")
history := fs.String("history-stream", "", "the history stream (mq.nats.history_stream); empty is <PREFIX>_HISTORY")
publishTimeout := fs.Duration("publish-timeout", mq.DefaultNATSPublishTimeout, "one publish attempt's bound (mq.nats.publish_timeout); the partitions' duplicate window covers every attempt")
bucket := fs.String("coord-bucket", "", "the lease KV bucket (coord.nats.bucket); empty is <prefix>_coord")
lease := fs.Duration("dedupe-lease", dedupe.DefaultLease, "the dedupe.lease the partitions' duplicate window must cover")
fileStore := fs.String("file-store", mq.FormatStoreSize(mq.DefaultNATSFileStore), "every server's JetStream max_file_store (the Helm chart's fileStore.maxSize, else its PVC size), which the streams' maxBytes must fit in")
partitionBytes := fs.String("partition-max-bytes", "", "each ingest partition's maxBytes; empty is 15% of --file-store")
fs.Usage = func() {
_, _ = fmt.Fprint(fs.Output(), `usage: wavehouse mq manifests [--partitions N] [--shards V] [--prefix wh] [--replicas 3] [--ingest-consumer wh-ingest] [--history-stream S] [--publish-timeout 5s] [--coord-bucket B] [--dedupe-lease 30s] [--file-store 100Gi] [--partition-max-bytes 15Gi]

Print the nack (jetstream.nats.io/v1beta2) Stream, Consumer and KeyValue
resources for the JetStream topology WaveHouse needs under mq.backend: nats
and coord.backend: nats, as YAML for kubectl apply. WaveHouse never creates these itself; it checks them at boot.
The streams' maxBytes are sized from --file-store: each partition 15%, the
history 10% and the DLQ 5%, so four partitions leave a quarter of it spare.
It refuses streams that together reserve more than --file-store.

`)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() > 0 {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: unexpected argument %q\n", fs.Arg(0))
fs.Usage()
return 2
}
if *lease < 0 {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: --dedupe-lease must not be negative\n")
return 2
}
if *shards < 1 {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: --shards must be at least 1\n")
return 2
}
if *replicas < 1 {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: --replicas must be at least 1\n")
return 2
}
if *publishTimeout <= 0 {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: --publish-timeout must be positive\n")
return 2
}
store, err := positiveSize(*fileStore)
if err != nil {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: --file-store: %v\n", err)
return 2
}
var partitionMax int64
if *partitionBytes != "" {
if partitionMax, err = positiveSize(*partitionBytes); err != nil {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: --partition-max-bytes: %v\n", err)
return 2
}
}
err = mq.WriteNATSManifests(stdout, mq.NATSManifestOptions{
Topology: mq.NATSTopology{
Prefix: *prefix, Partitions: *partitions, Shards: *shards, IngestConsumer: *consumer, HistoryStream: *history,
PublishTimeout: *publishTimeout, CoordBucket: *bucket, DedupeLease: *lease,
},
Replicas: *replicas,
FileStore: store,
PartitionMaxBytes: partitionMax,
})
if err != nil {
_, _ = fmt.Fprintf(stderr, "wavehouse mq manifests: %v\n", err)
return 1
}
return 0
}

// runMQPermissions implements `wavehouse mq permissions`: print the
// wavehouse NATS user's permissions for the topology, which name every shard
// durable, so an operator regenerates them with the shard count.
func runMQPermissions(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("mq permissions", flag.ContinueOnError)
fs.SetOutput(stderr)
shards := fs.Int("shards", mq.DefaultNATSShards, "shards of every partition (mq.nats.shards); while a lower count's durables drain, the higher")
prefix := fs.String("prefix", mq.DefaultNATSSubjectPrefix, "subject prefix (mq.nats.subject_prefix)")
consumer := fs.String("ingest-consumer", mq.DefaultNATSIngestConsumer, "the shard durables' name prefix (mq.nats.ingest_consumer)")
history := fs.String("history-stream", "", "the history stream (mq.nats.history_stream); empty is <PREFIX>_HISTORY")
bucket := fs.String("coord-bucket", "", "the lease KV bucket (coord.nats.bucket); empty is <prefix>_coord")
domain := fs.String("js-domain", "", "the JetStream domain WaveHouse connects to (mq.nats.js_domain); its API requests go to $JS.<domain>.API.>")
fs.Usage = func() {
_, _ = fmt.Fprint(fs.Output(), `usage: wavehouse mq permissions [--shards V] [--prefix wh] [--ingest-consumer wh-ingest] [--history-stream S] [--coord-bucket B] [--js-domain D]

Print the permissions block for the wavehouse user of a NATS Helm values
file (config.merge.accounts.<account>.users[]), for the topology that
wavehouse mq manifests prints with the same flags.

`)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() > 0 {
_, _ = fmt.Fprintf(stderr, "wavehouse mq permissions: unexpected argument %q\n", fs.Arg(0))
fs.Usage()
return 2
}
if *shards < 1 {
_, _ = fmt.Fprintf(stderr, "wavehouse mq permissions: --shards must be at least 1\n")
return 2
}
err := mq.WriteNATSPermissions(stdout, mq.NATSTopology{
Prefix: *prefix, Shards: *shards, IngestConsumer: *consumer, HistoryStream: *history, CoordBucket: *bucket,
}, *domain)
if err != nil {
_, _ = fmt.Fprintf(stderr, "wavehouse mq permissions: %v\n", err)
return 1
}
return 0
}

// positiveSize reads a byte count of at least one byte (mq.ParseStoreSize).
func positiveSize(s string) (int64, error) {
n, err := mq.ParseStoreSize(s)
if err == nil && n == 0 {
err = errors.New("must be positive")
}
return n, err
}
116 changes: 116 additions & 0 deletions cmd/wavehouse/mq_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"bytes"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The shipped manifests are the generator's output for four partitions of eight shards.
// Regenerate with: go run ./cmd/wavehouse mq manifests --partitions 4 --shards 8 > deployments/nats/jetstream.yaml
// (the file store defaults to the shipped values' 100Gi PVC).
func TestRunMQManifests_MatchesShipped(t *testing.T) {
want, err := os.ReadFile("../../deployments/nats/jetstream.yaml")
require.NoError(t, err)
var out, errOut bytes.Buffer
require.Equal(t, 0, runMQ([]string{"manifests", "--partitions", "4", "--shards", "8"}, &out, &errOut), errOut.String())
assert.Equal(t, string(want), out.String(), "deployments/nats/jetstream.yaml is stale; regenerate it")
}

func TestRunMQ_ExitCodes(t *testing.T) {
cases := map[string]struct {
args []string
code int
}{
"no command": {nil, 2},
"unknown command": {[]string{"frobnicate"}, 2},
"help": {[]string{"help"}, 0},
"manifests help": {[]string{"manifests", "-h"}, 0},
"stray argument": {[]string{"manifests", "extra"}, 2},
"unknown flag": {[]string{"manifests", "--nope"}, 2},
"zero replicas": {[]string{"manifests", "--replicas", "0"}, 2},
"bad prefix": {[]string{"manifests", "--prefix", "a.b"}, 1},
"bad partitions": {[]string{"manifests", "--partitions", "-1"}, 1},
"bad coord bucket": {[]string{"manifests", "--coord-bucket", "a.b"}, 1},
"negative lease": {[]string{"manifests", "--dedupe-lease", "-1s"}, 2},
"defaults generate": {[]string{"manifests"}, 0},
"bad file store": {[]string{"manifests", "--file-store", "100GB"}, 2},
"zero file store": {[]string{"manifests", "--file-store", "0"}, 2},
"bad partition cap": {[]string{"manifests", "--partition-max-bytes", "lots"}, 2},
"over the store": {[]string{"manifests", "--partitions", "6"}, 1},
"resized to fit": {[]string{"manifests", "--partitions", "6", "--partition-max-bytes", "10Gi"}, 0},
"zero timeout": {[]string{"manifests", "--publish-timeout", "0s"}, 2},
"bad consumer": {[]string{"manifests", "--ingest-consumer", "a.b"}, 1},
"bad history": {[]string{"manifests", "--history-stream", "A B"}, 1},
"bad js domain": {[]string{"permissions", "--js-domain", "a.b"}, 1},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
var out, errOut bytes.Buffer
assert.Equal(t, tc.code, runMQ(tc.args, &out, &errOut), errOut.String())
})
}
}

func TestRunMQManifests_NamesTheLeaseBucket(t *testing.T) {
var out, errOut bytes.Buffer
require.Equal(t, 0, runMQ([]string{"manifests", "--prefix", "acme", "--coord-bucket", "acme_leases"}, &out, &errOut), errOut.String())
assert.Contains(t, out.String(), "kind: KeyValue\nmetadata:\n name: acme-coord\nspec:\n bucket: acme_leases\n")
}

// A lease past the shipped 2m window's reach widens every partition's window
// to cover it (90s + 90s + 1s), so the manifests pass the boot check.
func TestRunMQManifests_CoversTheDedupeLease(t *testing.T) {
var out, errOut bytes.Buffer
require.Equal(t, 0, runMQ([]string{"manifests", "--partitions", "1", "--dedupe-lease", "90s"}, &out, &errOut), errOut.String())
assert.Contains(t, out.String(), "duplicateWindow: 3m1s\n")
}

// The permissions name every shard's durable, and match what the verifier's
// test holds the shipped values to.
func TestRunMQPermissions(t *testing.T) {
var out, errOut bytes.Buffer
require.Equal(t, 0, runMQ([]string{"permissions", "--shards", "3", "--prefix", "acme"}, &out, &errOut), errOut.String())
for _, want := range []string{"$JS.API.CONSUMER.MSG.NEXT.*.wh-ingest-2", "$JS.API.CONSUMER.UNPIN.*.wh-ingest-0", "$JS.API.CONSUMER.RESET.*.wh-ingest-1", "acme.hist.>", "_INBOX_acme.>"} {
assert.Contains(t, out.String(), want)
}
assert.NotContains(t, out.String(), "wh-ingest-3")
out.Reset()
errOut.Reset()
assert.Equal(t, 2, runMQ([]string{"permissions", "--shards", "0"}, &out, &errOut))
assert.Contains(t, errOut.String(), "--shards must be at least 1")
errOut.Reset()
assert.Equal(t, 1, runMQ([]string{"permissions", "--shards", "257"}, &out, &errOut))
assert.Contains(t, errOut.String(), "shards must be from 1 to 256")
}

// The names and publish timeout the config takes are flags too, and the
// partitions' duplicate window covers every attempt at that timeout
// (3 × 1m + 2 × 250ms).
func TestRunMQManifests_NamesAndPublishTimeout(t *testing.T) {
var out, errOut bytes.Buffer
require.Equal(t, 0, runMQ([]string{"manifests", "--partitions", "1", "--shards", "1", "--ingest-consumer", "acme-ingest", "--history-stream", "ACME_REPLAY", "--publish-timeout", "1m"}, &out, &errOut), errOut.String())
for _, want := range []string{
"durableName: acme-ingest-0\n", "name: ACME_REPLAY\n", "duplicateWindow: 3m0.5s\n",
"--ingest-consumer acme-ingest --history-stream ACME_REPLAY --publish-timeout 1m0s",
} {
assert.Contains(t, out.String(), want)
}
}

// Under a JetStream domain the API and KV subjects come in the domain's form
// as well as the plain one.
func TestRunMQPermissions_JSDomain(t *testing.T) {
var out, errOut bytes.Buffer
require.Equal(t, 0, runMQ([]string{"permissions", "--shards", "1", "--js-domain", "hub"}, &out, &errOut), errOut.String())
for _, want := range []string{
"$JS.hub.API.CONSUMER.MSG.NEXT.*.wh-ingest-0", "$JS.API.CONSUMER.MSG.NEXT.*.wh-ingest-0",
"$JS.hub.API.$KV.wh_coord.lease.>", "$JS.hub.API.DIRECT.GET.KV_wh_coord.$KV.wh_coord.lease.>", "$JS.hub.API.STREAM.CREATE.>",
"JetStream domain hub",
} {
assert.Contains(t, out.String(), want)
}
}
Loading
Loading