Skip to content

refactor(ingest): per-table batching + pipelined flushes; unblock per-table-per-test e2e isolation #191

Description

@EricAndrechek

Context

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-65
cons, err := js.CreateOrUpdateConsumer(ctx, mq.StreamName(), jetstream.ConsumerConfig{
    Durable:       BufferConsumerName,
    FilterSubject: "ingest.>",     // every ingest subject
    ...
})
// ...:146-147
var batch []jetstream.Msg          // one slice for all tables
// ...:165
if len(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
    • Some combination — needs measurement against Performance Benchmarks #39

3. E2E test isolation

  • 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:

Related but should stay separate PRs:

Acceptance criteria

  • Deterministic Go (or e2e) test that reproduces the cross-table contamination on main and passes after the refactor.
  • Per-table batching implemented; existing e2e suite passes without bumping waitForCondition timeouts.
  • Pipelined flushes per table with a documented bound on in-flight CH POSTs.
  • Decision recorded (in the PR description or an ADR) on how we avoid CH "too many parts" — adaptive batch, coalescing, or other.
  • e2e test files use per-test (or per-file) generated table names; singleFork: true removed; CI wall time recorded before/after.

Metadata

Metadata

Assignees

Labels

area/infraCI, build, deploy, Docker, releasearea/ingestIngest pipeline (Bento, batching, DLQ)area/sdkTypeScript SDK (clients/ts/)enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions