You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #190 (sdk-updates) surfaced a CI flake in tests/e2e/sdk/batching.test.ts — "flushes immediately when hitting the 500-item batch limit". Investigation on the throwaway ci-flake-dbg branch (instrumented worker + uploaded wavehouse-cov.log artifacts on runs 26578745441 and 26580071857) showed the batcher itself is healthy — the size trigger fires correctly at batch_len=500 in ~2.9s. The flake is caused by the worker batching across all tables:
// internal/ingest/worker.go:60-65cons, err:=js.CreateOrUpdateConsumer(ctx, mq.StreamName(), jetstream.ConsumerConfig{
Durable: BufferConsumerName,
FilterSubject: "ingest.>", // every ingest subject...
})
// ...:146-147varbatch []jetstream.Msg// one slice for all tables// ...:165iflen(batch) >=maxBatch { // size trigger ignores table
On CI, streaming.test.ts publishes 2 events to clicks ~340ms before batching.test.ts starts. They open batch 1 first, so the size trigger flushes 2 streaming + 498 batching, leaving the last 2 batching events in batch 2. Batch 2 fires by maxWait=5s timer — but the test's waitForCondition budget is also 5s. The test sees count=498 and times out a few ms before batch 2 lands. (Attempt 2 on the original failing run passed at 4996ms — 4 ms of luck.) Switching streaming.test.ts to a different table does NOT fix it: the maxBatch check counts msgs regardless of table.
Goals
A single PR that addresses the root cause, in this order:
1. Deterministic repro test (do this first)
Before touching worker.go, land a test that always reproduces the contamination. Publish N events to table A, then immediately N events to table B (where A+B totals ≥ maxBatch); assert table B's count reaches N within maxWait − epsilon. With today's code this fails deterministically; with the per-table fix it passes.
This protects us against regressions and gives every commit in the refactor a green-vs-red signal.
2. Refactor the batcher
Several optimizations on the table — design discussion welcome:
Per-table batches. Each table gets its own batch, batchOpenedAt, and timer. Low-volume tables (events, users, dlq) stop blocking high-volume ones (clicks). Smallest correctness win.
Pipelined flushes per table. Today flush() blocks runLoop until CH POST returns. Under sustained load on a single hot table, the next batch's accumulation pauses for the CH RTT. Allow a flush goroutine to run while the next batch fills — bounded (e.g., max 2 in-flight per table) to avoid unbounded memory growth.
ClickHouse "too many parts" guardrail. Naively pipelining = lots of small parts when a hot table hits maxBatch repeatedly per second. Options worth prototyping + benchmarking:
Adaptive maxBatch based on observed rate per table (bigger batches when hot)
Min interval between flushes per table (the user notes this feels janky — probably not the right primitive)
Coalesce the in-flight batch with the next batch if a flush is already running
Each e2e test (or test file) publishes to a unique table name generated at setup. Fixture SQL becomes templated (clicks_${suite} etc.), policy bootstrap covers the generated names. Cross-test contamination becomes structurally impossible.
Once tables are isolated, drop singleFork: true from tests/e2e/sdk/vitest.config.ts and let test files run in parallel. One WH + one CH still serves everything; concurrent files just write to different tables. Expected wall-time drop on CI is significant.
Related issues
Could potentially fold into this PR — debatable:
NATS Robustness & Backoff #49 NATS robustness & backoff — reconnect strategy. Touches the same consumer setup; worth doing alongside if the batcher refactor moves consumer config around anyway.
Performance Benchmarks #39 Performance benchmarks — useful for measuring this refactor's effect, but the benchmark harness itself should land independently.
Context
PR #190 (
sdk-updates) surfaced a CI flake intests/e2e/sdk/batching.test.ts— "flushes immediately when hitting the 500-item batch limit". Investigation on the throwawayci-flake-dbgbranch (instrumented worker + uploadedwavehouse-cov.logartifacts on runs 26578745441 and 26580071857) showed the batcher itself is healthy — the size trigger fires correctly atbatch_len=500in ~2.9s. The flake is caused by the worker batching across all tables:On CI,
streaming.test.tspublishes 2 events toclicks~340ms beforebatching.test.tsstarts. They open batch 1 first, so the size trigger flushes2 streaming + 498 batching, leaving the last 2 batching events in batch 2. Batch 2 fires bymaxWait=5stimer — but the test'swaitForConditionbudget is also 5s. The test seescount=498and times out a few ms before batch 2 lands. (Attempt 2 on the original failing run passed at 4996ms — 4 ms of luck.) Switchingstreaming.test.tsto a different table does NOT fix it: themaxBatchcheck counts msgs regardless of table.Goals
A single PR that addresses the root cause, in this order:
1. Deterministic repro test (do this first)
Before touching
worker.go, land a test that always reproduces the contamination. Publish N events to table A, then immediately N events to table B (where A+B totals ≥maxBatch); assert table B's count reaches N withinmaxWait − epsilon. With today's code this fails deterministically; with the per-table fix it passes.This protects us against regressions and gives every commit in the refactor a green-vs-red signal.
2. Refactor the batcher
Several optimizations on the table — design discussion welcome:
batch,batchOpenedAt, and timer. Low-volume tables (events, users, dlq) stop blocking high-volume ones (clicks). Smallest correctness win.flush()blocksrunLoopuntil CH POST returns. Under sustained load on a single hot table, the next batch's accumulation pauses for the CH RTT. Allow a flush goroutine to run while the next batch fills — bounded (e.g., max 2 in-flight per table) to avoid unbounded memory growth.maxBatchrepeatedly per second. Options worth prototyping + benchmarking:maxBatchbased on observed rate per table (bigger batches when hot)3. E2E test isolation
clicks_${suite}etc.), policy bootstrap covers the generated names. Cross-test contamination becomes structurally impossible.singleFork: truefromtests/e2e/sdk/vitest.config.tsand let test files run in parallel. One WH + one CH still serves everything; concurrent files just write to different tables. Expected wall-time drop on CI is significant.Related issues
Could potentially fold into this PR — debatable:
Related but should stay separate PRs:
SyncAlwaysdurability vs throughput docs — different layer (durability config).WH_MQ_MAX_BYTES_GBupper bound — config validation, unrelated layer.Acceptance criteria
mainand passes after the refactor.waitForConditiontimeouts.singleFork: trueremoved; CI wall time recorded before/after.