Skip to content

Ledger engine: crash-safe, resumable drill_events migration — no Redis - #7

Open
ar2rsawseen wants to merge 42 commits into
mainfrom
poc/ledger-no-redis
Open

Ledger engine: crash-safe, resumable drill_events migration — no Redis#7
ar2rsawseen wants to merge 42 commits into
mainfrom
poc/ledger-no-redis

Conversation

@ar2rsawseen

@ar2rsawseen ar2rsawseen commented Aug 18, 2026

Copy link
Copy Markdown
Member

What this is

A ground-up replacement of the migration engine, built for the three problems that block large (10TB-class) drill_events migrations today: instability under bad data, throughput, and the operational risk of long ingestion downtime.

This is the complete solution, not a slimmed-down PoC — every improvement from the redesign is included (chunk ledger, DLQ with raw docs, verify-then-attach, poison-pill quarantine, circuit breaker, error classifier + bisection, sampled dry run, preflight incl. clock-skew check, index builds from the UI, multi-pod scaling, ledger rebuild, duplicate attribution, operator dashboard with embedded runbook, k8s manifests, CI). What the branch does NOT carry is the legacy engine: no Redis code paths, no dual-engine switches, no dead config. Reviewing against main is therefore a clean either/or comparison of two complete engines rather than a diff tangled through both.

Architecture in one paragraph

Work is cut into cd-bounded chunks tracked in a MongoDB ledger (mig_ranges) — ~50-100 tiny documents, claimed atomically with leases, safe for N pods with zero coordination infrastructure (Redis is gone entirely). Each chunk copies into its own ClickHouse staging table (sync inserts), is count-verified there, then promoted into the live table via verify-then-ATTACH per partition (INSERT SELECT fallback). Recovery never trusts the ledger: in-flight chunks are redone, promoted chunks are recounted, half-attached chunks are checked by staged (_id, cd) pairs. Unmigratable documents land in a DLQ with their full raw source (replay / waive from the UI); repeated crashers are auto-bisected down to the poison document; a circuit breaker pauses on systematic failure. A built-in dashboard (/) carries the whole runbook: preflight, index builds, sampled dry-run, live progress, verification gates, incident recovery — self-hosted customers can run this without us.

Key correctness properties (each has a pinning test)

  • Exactness: every chunk's live count must equal reads − skips − DLQ; global uniqExact check; continuous invariant monitor
  • Multi-collection scoping: all live-table window queries are scoped by the chunk's (a, e, n) identity — overlapping hashed collections cannot corrupt each other (purge/verify/recovery)
  • Provenance without schema changes: migrated vs live rows distinguished by cd construction + (_id, cd) pair matching; cross-cutover SDK retries are harmless; nothing added to the production table
  • Null-cd outliers: dedicated sweep, ordered strictly after regular chunks (regression-tested)
  • Ledger loss is recoverable: Rebuild ledger from data regenerates mig_ranges by recounting windows (Mongo vs scoped ClickHouse)
  • Duplicate attribution: verify classifies duplicate ids as live-ingestion artifact / cross-cutover retry / migration defect — only the last fails sign-off

Evidence

Check Result
Throughput (single pod, local) 39.4k docs/s vs the 25k/s ceiling on main
Kill drills (SIGKILL mid-run, single + 3-pod) exact counts, zero dups
Poison-pill drill (25 forced crashes) auto-quarantined to a 2-doc window, rest migrated
Chaos (mongod kill, CPU starve, CH outage via TCP proxy) exact after recovery
GKE smoke (dedicated cluster, production ClickHouse 26.4) 120,200 docs exact; all chunks promoted via real ATTACH; 2-pod run with SIGKILL mid-flight converged exact
Transform parity 74-assertion differential harness against the shared normalization spec goldens
Suite 93 tests, runs in CI against mongo:7 + clickhouse:26.4 service containers

What reviewers should look at first

  1. src/runtime/chunk-orchestrator.ts — the chunk lifecycle + every recovery path
  2. src/state/ledger-store.ts — claim/lease/transition semantics
  3. src/target/staging-manager.ts — staging lifecycle, verify-then-attach, scoped queries
  4. docs/RUNBOOK.md — the cutover choreography and incident table
  5. tests/integration/ — the correctness contract, in executable form

Not in this PR

Platform-side items tracked separately: countly-platform#1105 (cd passthrough, draft — interacts with EventDeduplicationJob), #722 (dedup job replay resilience). This tool depends on neither.

🤖 Generated with Claude Code

ar2rsawseen and others added 30 commits August 17, 2026 15:06
…A/B vs classic)

Adds a second migration engine behind MIGRATION_ENGINE=ledger for A/B testing
against the current architecture (classic remains the default and is untouched).

Ledger engine design:
- Progress state = one MongoDB ledger row per cd-bounded chunk
  (pending → in_progress → written → attaching → done). No Redis anywhere;
  MongoDB + ClickHouse are the only dependencies.
- Each chunk copies into its own staging table (clone of the live DDL),
  verified by read-tally vs exact ClickHouse count(), then promoted via
  verify-then-ATTACH PARTITION per partition (INSERT SELECT fallback),
  then dropped. The live table only ever receives whole verified chunks.
- Crash recovery never trusts the ledger: in_progress chunks are dropped
  and redone; written chunks are recounted; attaching chunks verify each
  partition against the live table before attaching (no double-attach).
- Synchronous inserts (errors surface; dedup token effective) + startup
  dedup canary that measures whether the token works on the target engine.
- Error classifier: permanent ClickHouse data errors fail immediately
  instead of burning the 8x retry backoff; transient errors keep retrying.
- Pipelined reads (prefetch) + bounded concurrent insert window.
- Fixes the page-boundary double-read (inclusive min() re-returns the
  previous page's last doc) — the classic engine exhibits this on main:
  measured 25 duplicate rows + 1 lost boundary doc per 250k clean run.

A/B harness in bench/: seed script, kill-drill (random SIGKILL until
convergence, verifies zero loss + zero duplicates), instructions.

Measured on the same 250k-doc dataset, same machine:
- classic: 44s, 250,024 rows / 249,999 unique (dups + loss on a clean run)
- ledger:  12s, 250,000 / 250,000 exact; 4x SIGKILL drill converges exact

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the Redis-fed viz for the ledger engine: data comes from the chunk
ledger (MongoDB) + in-process engine counters, polled every 2s. Brand tokens
sampled from countly.com (#21B566 green, #24292E ink, Plus Jakarta Sans +
Inter). Shows live counters, per-collection progress, a chunk map colored by
ledger status (newest-first), dedup-canary and engine badges, and failed
chunks with their errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ure, monitor, dry-run, report

Closes every gap the initial POC deliberately skipped, so the branch is a
complete solution rather than a proof of concept:

- Bisection → doc-level DLQ (mig_dlq_docs): permanent insert errors are
  halved-and-retried down to the exact offending documents, which are stored
  WITH their full raw source doc. Every unmigratable doc (invalid ts, missing
  fields, transform errors) is likewise captured — accounted for and
  replayable, never silently dropped.
- DLQ replay (POST /control/replay-dlq): re-transforms stored raw docs under
  the current TRANSFORM_VERSION and inserts into the live table; still-broken
  docs stay pending with updated errors. Never re-reads the source collection.
- Circuit breaker: pauses the engine when >LEDGER_BREAKER_PCT% of a chunk's
  docs fail (systematic bug) or after N consecutive failed chunks. Resume via
  POST /control/resume.
- ClickHouse backpressure: TTL-cached sampler (never 3 system queries per
  batch); waits out parts pressure between pages.
- Streaming reads (C3): one long-lived cursor per chunk instead of a fresh
  find() per page, reopened from the last position on cursor death; kills the
  per-page boundary re-read class entirely.
- Multi-pod lease reclaim tick: expired claims are recovered during the work
  loop, not only at collection start.
- Invariant monitor: background spot checks of done chunks against live-table
  counts; violation → pause + chunk flagged.
- Dry-run mode (DRY_RUN=1): ≤5% stratified sample against a Null-engine clone
  — full parse/type validation, nothing stored; DLQ + coercions become the
  pre-run report.
- Coercion policy (two-tier): Countly-owned c clamped to UInt32; customer
  sg/custom/cmp/up values that can't survive the numeric path stringified
  losslessly (zero-copy when clean). Every coercion counted with samples.
- GET /report: chunk status, skips by reason, coercions per key, DLQ summary.
- Tests: 12 new (classifier, coercions, ledger claims/leases/transitions,
  end-to-end pipeline with poisoned docs → DLQ with raw docs, replay).

Validated: 250k-doc run exact (250,000/250,000) with the full feature set at
identical speed to the POC; SIGKILL drill converges exact; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rely

The team compares branches (main vs this one), so this branch carries only
the new implementation. Removed: BatchRunner, collection/range orchestration,
Redis hot state, collection locks, global progress, async batch writer,
manifest batch store, coverage math, legacy ClickHouse writer, legacy HTTP
routes, GC controller, process metrics, their tests and helpers, the engine
switch, all classic-only config (Redis, rerun modes, range-parallel, GC,
async-write, lock tuning), and the ioredis dependency. Dependencies are now
MongoDB + ClickHouse, full stop.

Added the one capability only the legacy engine had: a null-cd sweep — a
dedicated chunk (sentinel bounds) pages by _id over documents without a cd
value, with id-based verify-then-attach (no cd window exists for them) and a
monitor mode that stays sound when null-cd rows land inside regular chunks'
cd windows.

Validated: 12/12 tests (incl. null-cd end-to-end), 250k straight run exact in
8s, SIGKILL drill (2 kills) converges exact — zero loss, zero duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Completion bug: the work loop treated "no pending chunks" as collection
  complete, silently skipping chunks still leased by a dead pod (SIGKILL
  orphan under an unexpired lease) — a run could report completed with a
  hole. Complete now means NO non-terminal chunks: single-pod recovers
  orphans immediately; multi-pod waits and reclaims on lease expiry
  (reclaim tick capped at 30s cadence).
- POST /control/retry-failed: resets failed chunks to pending and resumes;
  chunks that were already promoted (e.g. flagged by the invariant monitor)
  get their live cd window purged first so redo is clean.
- Circuit-breaker path now drops its staging table.
- bench/seed-failures.ts: seeds breaker-burst / scattered-DLQ / coercion
  scenarios for failure drills.

Drill verified end-to-end on 100k docs: SIGKILL → breaker trip (805 docs
DLQ'd with raw docs) → deliberate live-table corruption caught by the
invariant monitor in seconds → retry-failed purge+redo → orphan recovery →
final 100,003/100,003 exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… harness (D4, #6)

Vendors the differential harness from countly-platform (corpus.json 71
fixtures, goldens.json generated from the LIVE ingestion normalization,
decode/canonicalize, hash-tied sync contract) into tests/differential/, adds
the repo's first CI workflow (typecheck + harness, service-free), and adopts
the shared normalization spec in src/transform:

- normalize.ts/validators.ts rewritten to the spec (platform branch
  claude/jovial-shannon-b3dd29 is the source of truth): existing non-blank
  doc.n wins over sg-derived names (dedup identity with live rows),
  clampUInt32/clampDateTime64 for Countly-owned fields, sanitizeJsonValue
  for customer bags — stringify ONLY what JSON cannot carry (NaN/±Infinity,
  bigint, BSON Decimal128/Long). Notably this DROPS the earlier
  >2^53-stringify rule: live ingestion keeps finite large doubles numeric,
  and matching live is the whole point — the harness caught that divergence.
- CoercionCounter re-threaded as pure accounting (optional param, zero
  behavior change): clamp + stringify events counted per (rule, bag) with
  samples for the /report endpoint.

86/86 tests green (71 differential fixtures + engine suite); 100k end-to-end
run exact after the transform change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the 3-pod drill: concurrent pods probing a shared canary table name
race on CREATE/DROP and false-flag dedup as inert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A document that kills the process on every touch (OOM-class, not a clean
insert rejection) previously crash-looped until the whole multi-million-doc
chunk was quarantined. Now: after 3 crash-retries a splittable chunk is
bisected into 4 sub-chunks instead of retried — repeated splitting converges
on a <=1-minute window around the poison doc, quarantined as a tiny failed
chunk while everything else migrates. Originals become 'superseded'
(terminal); the null-cd sentinel and <=1-min windows quarantine directly.

Includes a gated chaos hook (LEDGER_TEST_CRASH_ID) and bench/poison-drill.ts.
Drill result: 20k docs + 1 poison -> converged in 25 restarts / 7 split
generations to a 0.5-min 2-doc window, 19,999/20,001 migrated with the
poison active, exact 20,001/20,001 after the operator fix + retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /control/waive-dlq (optionally {ids}): explicitly accept that pending
DLQ docs will not migrate. Waived is terminal but reversible; raw docs stay
in the DLQ permanently as the record of what was excluded. Sign-off requires
pending = 0 — every entry must end resolved (fixed+replayed) or waived.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard now covers the complete operator workflow, not just state:
- Action buttons wired to the /control endpoints (pause, resume, retry
  failed chunks, replay DLQ, waive pending DLQ) with confirmation prompts
  on the destructive ones and toast receipts that distinguish success from
  HTTP errors (an error response no longer masquerades as success).
- Dead-letter queue panel: pending/resolved/waived pills with the sign-off
  gate spelled out (pending must reach 0), top errors table, and expandable
  per-doc samples showing the stored raw source document.
- Coercions panel: per-(rule, field) counts with before→after samples.
- New GET /api/dlq feeding the panel.

Fixes from driving it in a real browser: POST fetches now send '{}' with the
JSON content-type (Fastify 400s an empty JSON body — the button clicks were
silently failing), and toasts report non-2xx responses as failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, verification

Turns the dashboard into a console a self-hosted customer can migrate with,
not just watch:

- Migration Guide tab: the runbook as a guided checklist. Automated phases
  report their own status (index coverage, dry-run state, live progress);
  manual phases (Prepare, Cutover) are persistent checkboxes (localStorage).
  Sign-off is three explicit gates: all chunks done, DLQ pending = 0, full
  verification passed.
- Preflight (GET /api/preflight + button): MongoDB reachability, per-
  collection {cd,_id} index coverage, doc estimates, ClickHouse target
  existence, dedup-canary verdict, dry-run status — read-only, run anytime.
- One-click verification (GET /api/verify + button): every completed chunk
  recounted against the live table, plus table totals and duplicate check.
  Exact; feeds the sign-off gate.
- Help & Recovery tab: the runbook's incident scenarios as expandable
  entries with the relevant action buttons inline (incl. a cross-tab jump
  to verification).
- Two-step confirmation replaces native confirm() dialogs: first click arms
  the button (auto-disarms after 4s), second click fires. Testable,
  consistent, no browser dialogs.

Every element driven and verified in a real browser: tabs, preflight,
checkbox persistence across reload, verify (6 chunks, 0 duplicates, gates
updating truthfully mid-incident), DLQ raw-doc expander, arm/disarm/fire on
waive (receipt {"waived":505}, gate flip to ready-for-sign-off), replay,
pause, resume receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-service & scaling:
- Pods panel (GET /api/pods): per-pod chunks done/active/last-seen with
  alive/gone pills; README gains a scaling guide (pods scale across
  machines — one pod is CPU-bound; find the ceiling by adding pods and
  watching per-pod docs/s).
- Preflight became actionable: POST /control/build-indexes builds missing
  {cd,_id} indexes server-side with live progress (GET /api/index-progress,
  incl. $currentOp build percentage); POST /control/dry-run runs the sampled
  rehearsal in-process with its own reader (guarded while migrating);
  both wired into the Guide tab.
- New preflight checks: replica-set detection with a secondaryPreferred
  suggestion (source is frozen after cutover — secondary reads are exact),
  MongoDB and ClickHouse disk headroom (the #1 preventable incident).

Chaos-verified (scratch containers + TCP chaos proxy; shared dev services
untouched), all with exact final counts:
- mongod hard-killed 8s mid-run (OOM/crash surface): self-healed, driver
  retry layer alone absorbed it
- mongod CPU-starved to 0.15 cores mid-run: slowed, completed exact
- ClickHouse unreachable 8s mid-run: insert retries rode it out
Disk-full stance documented in the classifier: capacity errors are
transient → retries → attempts → breaker pause → operator frees space →
retry-failed.

Found & fixed by the chaos run: estimatedDocumentCount resets after an
unclean mongod shutdown, which collapsed chunk sizing to one mega-chunk.
Chunk count now also floors by time span (LEDGER_MAX_CHUNK_DAYS, default 7)
so a bad estimate can never produce a whole-collection chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge, state location

GET /api/config surfaces the sizing knobs (chunk target, max chunk days,
page size, insert window, lease, breaker, read preference) with current vs
default values and guidance, plus where progress state physically lives
(mig_ranges / mig_dlq_docs in MANIFEST_DB) and the recovery stance. Rendered
as a Guide-tab card with 'changed' pills on non-default values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…by review)

Walking the state machine for 'can any crash point produce duplicates or
missing data' surfaced a real gap, no crash required: the null-cd sweep
chunk had the highest idx, so newest-first claiming ran it FIRST — and its
rows carry cd derived from ts, landing inside regular chunks' cd windows.
A regular chunk attaching afterwards saw rows in its window during
verify-then-attach and skipped attaching a never-attached partition:
silently missing data. (The invariant monitor would flag it, but the
retry purge would then also delete sweep rows in that window.)

Fixes:
- The sweep is now gated: claimNext excludes the sentinel until every
  regular chunk of the collection is terminal (multi-pod safe — the gate
  counts in-flight chunks too).
- retry-failed on a regular chunk of a collection whose sweep already ran
  also resets the sweep, purging its remaining rows precisely by id (it
  has no cd window of its own).
- Orphaned staging tables (crash between done and drop) are swept at
  collection completion.
- Regression test: a null-cd doc whose derived cd lands inside regular
  windows — exact totals now; would silently lose data before this fix.

Residual (documented): on dedup-inert targets only, an ack-lost crash
during DLQ REPLAY can duplicate one replay batch; the canary identifies
such targets and /api/verify's uniqExact check detects it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t, null-cd preflight count

- docker-compose.yml: the migrator only, connecting to YOUR Mongo/ClickHouse
  (previous file still shipped Redis and a bundled MongoDB from the legacy
  architecture — misleading for setup).
- .env.example: current variables only (required trio up top, common,
  scaling, sizing, rehearsal), replica-set read-preference note included.
- README 'Setup & run': prerequisites, two start paths, then hand over to
  the dashboard. Explicit split: README = everything BEFORE the dashboard
  exists (install, env, start, automation reference); the UI = everything
  after (guide, actions, troubleshooting, verification); RUNBOOK.md = the
  cross-system cutover procedure and incident tables.
- Preflight now counts null-cd outliers per collection (pass when zero,
  which is the expected case) and labels the doc-count estimate as
  span-guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No reason an operator should know a path fragment; the dashboard is the
product's front door. Docs updated to plain http://host:port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found while smoke-testing the new root route: pointing MONGO_DB at a
database without drill_events collections (or any orchestrator startup
error) hit process.exit(1) — taking the dashboard down with it, so a
fresh operator with a config typo saw a dead process instead of the UI.

Now the crash marks the run failed and the console stays up:
- red 'Engine stopped' banner on the dashboard with the actual error and
  'fix env + restart, state is untouched' guidance
- /healthz returns {status:'error', error} for orchestration/probes
- /stats carries fatalError; status badge shows FAILED

Verified live: bad MONGO_DB → console at / renders the banner, healthz
reports the error, run resumes normally once config is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-window queries

Two things, the second found while designing the first.

1) SCOPING FIX (latent multi-collection bug). Every test and drill so far
   used ONE source collection; production has many drill_events{hash}
   collections all overlapping in wall-clock time, landing in one CH table
   partitioned by month only. Four live-table cd-window queries were
   collection-agnostic, so on real deployments:
     - retry-failed's window purge DELETED sibling collections' rows
     - crash-during-attach recovery could see sibling rows in the same
       partition+window and skip a never-attached partition (silent loss)
     - the invariant monitor and verify would false-alarm (live > expected)
   Chunks now persist their ClickHouse row identity at creation
   (scope_a/scope_e/scope_n — custom events map to e='[CLY]_custom' with
   the name in n, so their scope is (a,e,n); internal events (a,e)):
     - countLiveInCdRange / deleteLiveCdRange take the scope
     - attach-recovery now checks staged row ids universally (precise for
       the chunk regardless of siblings; window-count check removed)
     - unresolvable collections in multi-collection runs purge by Mongo ids
       and are skipped by per-window equality checks (global totals still
       verify); single-collection runs keep exact unscoped semantics
   Regression suite: two hashed sibling collections over the same time
   range — exact migration, scoped verify clean, retry-failed leaves the
   sibling untouched.

2) LEDGER REBUILD (operator request): regenerate mig_ranges from the data
   itself when progress state is lost. Frozen source ⇒ chunk grid is
   re-derivable; per window, exact Mongo count vs scoped live CH count:
   equal→done, zero→pending, partial→failed (redo purges first). Post-
   cutover live ingestion is untouchable by construction (newer cd than
   every window); the tool's own null-cd sweep rows are attributed by id
   and subtracted per window. Guarded: not while copying, not with other
   pods active, force required to replace an existing ledger.
     - POST /control/rebuild-ledger {force} + GET /api/rebuild
     - Help & Recovery: 'Migration progress state lost' scenario with
       two-step Rebuild button, force-overwrite path, live progress and
       per-collection summary table (verified in-browser end to end)
     - tests: rebuild after wipe (all done, live rows ignored), partial
       window → failed → retry+resume heals exactly

Suite: 91 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine was already Kubernetes-shaped — pod identity defaults to the
hostname (= pod name), coordination is Mongo chunk leases with no shared
service, /healthz exists, and abrupt kills are the designed recovery path —
but the repo shipped no manifests, so only Docker had a concrete artifact.

- k8s/migration.yaml: ConfigMap + Secret + Deployment + Service. Pods stay
  up after completion so the dashboard remains available for verification
  and sign-off; any pod shows the whole run (state is in MongoDB).
- k8s/job.yaml: batch Job variant with EXIT_ON_COMPLETE=true (pods exit 0
  when every chunk is terminal), generous backoffLimit since crash-redo is
  normal operation.
- README: Kubernetes subsection in Setup & run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decision context: the countly-platform eventTransformer rewrite
(claude/jovial-shannon-b3dd29) will not merge for now. Audited every
divergence between this tool's transform and platform MAIN:

- The tool has no code dependency on platform — it writes ClickHouse
  directly. Ledger, DLQ, rebuild, verify, UI: all unaffected.
- Cross-query semantics already agree with main's live pipeline:
  custom events e='[CLY]_custom' + name in n (confirmed), uid_canon left
  to identity machinery on both sides, cd = history vs receive-time.
- Everything else in the spec (NaN/Decimal128/Long stringification, ts
  heuristics, clamps, skip rules) concerns BSON-only shapes that JSON SDK
  ingestion can never produce — divergence is unobservable.
- The rebuild's non-overlap assumption is GUARANTEED by main's behavior
  (cd always re-stamped to now for live rows).

One real hazard documented as a guardrail instead of a code change:
replaying historical drill docs through platform ingestion on main
re-stamps cd to insert time → history duplicated at today's date. Added
to RUNBOOK incident table and the DLQ Help scenario: replay only via the
tool's Replay DLQ.

normalize.ts header + differential README no longer claim a two-repo CI
lock; the goldens are this repo's frozen spec, the platform PR is
optional platform-side hardening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cd fix was split out of the shelved transformer-spec branch into its
own minimal PR — it affects LIVE rows (Kafka offset replay and connector
redelivery re-date events), not just doc replay. Guardrail wording fixed:
platform-side replay of already-migrated docs is off-limits regardless of
that fix, since the live table does not dedup by _id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live ingestion on platform main is at-least-once (connector
exactlyOnce=false); ordinary redelivery leaves a handful of duplicate
_ids until the platform's nightly EventDeduplicationJob cleans them.
Verify's global count-vs-uniqExact check surfaced those as bare
'duplicates: N', indistinguishable from a migration defect at sign-off.

Verify now samples duplicate groups with their cd spread and classifies
each against the EXACT migrated-data boundary (max chunk upper_cd from
the ledger): groups entirely above it are live at-least-once artifacts
(reported, do NOT fail verification — the nightly job cleans them);
any group reaching below it involves migrated data and fails
verification for investigation. UI verify panel shows the attribution.

Also the written record of the compatibility audit against platform
main-as-deployed: ingestor owns the [CLY]_custom/n mapping and cd
stamping, EventDeduplicationJob's 26h/7d cd window can never scan or
delete historical-cd migrated rows, and cross-cutover SDK-retry dups
resolve to the older (migrated) copy when the job sees both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arturs' question 'can new incoming data mix with migrated data in these
checks?' had a real yes: cd-window checks can't mix (live cd is always
newer than every migrated window), but ID-BASED checks could. An SDK
retry straddling cutover lands the same _id in both stacks, in the SAME
ts-month partition — so attach-recovery's staged-ids sample could see
the live retry copy, conclude 'partition already attached', and skip a
never-attached partition (silent loss). Duplicate attribution by cd
boundary was likewise heuristic at the edge.

Now provenance is a column, not an inference:
- connect() adds 'migrated Bool DEFAULT false' to the live table
  (metadata-only ALTER, instant at any size; live inserts default false)
- the INSERT layer stamps migrated=true on every row (staging + DLQ
  replay); the transform/goldens stay unaware — it's transport metadata
- every migration-side query filters on it: staged-ids attach recovery,
  window counts (verify/monitor/rebuild), window purges, by-id purges,
  null-cd sweep attribution
- verify's duplicate attribution is now exact with three verdicts:
  0 migrated copies = live at-least-once artifact (nightly platform job
  cleans), 1 = cross-cutover retry (benign, reported), 2+ = migration
  defect (fails sign-off)
- guard: resuming a run whose completed chunks predate the flag fails
  fast with the backfill recipe (checks would otherwise see zero rows)

New precision test pins the loss vector: staged-ids check returns 0 when
only a live retry copy of a staged _id exists, 1 once the migrated copy
is live. 93 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the migrated column

Team direction: no new column on the production drill_events table. The
column is gone entirely (no ALTER, nothing for future rows to inherit)
and the 100% migrated/live distinction is preserved by construction:

cd IS the provenance marker. Migrated rows carry historical cd from the
source; live rows are stamped at post-cutover insert time. A cross-
cutover SDK retry shares _id with its migrated twin but can never share
cd — so where an id alone is ambiguous, checks match (_id, cd) pairs:

- attach-recovery (the loss vector): staged (_id, cd) pairs vs live —
  the retry copy is invisible to it, the chunk's own promoted rows are
  matched exactly
- purges: deleteLiveByPairs (parallel arrays zipped server-side —
  Array(Tuple) params don't parse over HTTP); the null-cd sweep purge
  reconstructs its ts-derived cd values so even it is pair-exact
- verify's duplicate attribution: classified against the ledger's
  end-of-migrated-data boundary (max chunk upper_cd) — same three
  verdicts (live artifact / cross-cutover retry / migration defect)
- window counts need no pairs at all: the historical cd range excludes
  live rows and is minmax-index-accelerated (cheaper than any flag)

New preflight check guards the one assumption this rests on: 'Source
frozen & clocks sane' fails if the newest source cd is within 60s of
ClickHouse server time (source still ingesting, or skewed clocks would
blur the boundary).

Cost note: pair matching runs only on rare recovery/purge paths where
the partition scan dominates either way; the hot checks use the cd
minmax index. Nothing is stamped on future live rows, ever.

93 tests green (attach-recovery precision test now pins pair semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 26.4)

CI previously ran only typecheck + the pure differential harness; the
93-test suite (engine e2e, multi-collection scoping, rebuild, duplicate
attribution, pair-based recovery) now runs against service containers
pinned to the production ClickHouse version (26.4, per countly-platform
deploy/compose/images.standard.env). 26.4 requires a password for
non-localhost clients, so the tests accept TEST_CLICKHOUSE_URL/
TEST_CLICKHOUSE_PASSWORD (defaults unchanged for local runs).

Verified on GKE the same day: image runs on a dedicated cluster against
CH 26.4 — 120,200 docs exact, all chunks promoted via real ATTACH,
2-pod run with a SIGKILL mid-flight converged exact (zero loss, zero
duplicates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-review findings from Arturs walking the dashboard:

- docs/second kept 'declining' after completion — the elapsed clock never
  froze, so the card showed the run average decaying while the finished
  engine idled. finishedAt now freezes the clock (completed/stopped/
  fatal); the card shows the true run average with an 'avg' suffix, or a
  dash when this process copied nothing.
- 'Docs migrated' now prefers the durable ledger sum (done chunks'
  rows_expected) over process-local counters, so a restarted engine
  shows 80,001 — not 0 — for a completed run.
- DLQ panel now answers 'where do I run the update?': names the fix
  location (<manifestDb>.mig_dlq_docs — Replay re-transforms the STORED
  raw_doc, never the source) and each entry carries its source
  collection plus a copy-pasteable updateOne targeting its dlq _id.
- expanded DLQ entries survive the 2s re-render (open-state preserved
  by dlq _id).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings from Arturs (what happens at a billion DLQ docs; why is
the tool ASKING the operator to set read preference when it can decide):

Scale:
- /api/chunks now ships an O(collections) aggregation summary (status
  counts, docs done, remaining estimates per collection); full chunk
  details only under 2,000 chunks, else just pending/active/failed
  capped at 500 — a 10TB run no longer streams tens of thousands of
  chunk docs to the browser every 2s. Cards, bars, ETA and gates all
  compute from the summary; the chunk map notes when done-cells are
  summarized away.
- DLQ panel paginates (8 per page, stable _id order, Prev/Next with
  'x–y of N pending'); counts stay index-served aggregates.
- CoercionCounter caps distinct (rule, field) keys at 10k with an
  overflow bucket — totals stay exact under pathological field-name
  cardinality.

Self-driving checks:
- MONGO_READ_PREFERENCE defaults to 'auto': the engine probes hello at
  startup and picks secondaryPreferred on replica sets itself (frozen
  source ⇒ secondary reads exact). Explicit env still wins; preflight
  and the config card show '(auto-selected)'.
- New preflight check 'Old ingestion stopped (source frozen)': double
  probe of newest cd + estimated counts 4s apart — any advance fails
  the check and names the still-growing collections.
- New preflight check 'New ingestion flowing into ClickHouse': rows
  with cd in the last 15 min (pass with count, warn when zero — traffic
  may legitimately be zero). Both checks are topology-agnostic: they
  read only the source handle and the target handle, so new-cluster and
  same-cluster migrations behave identically.

96 tests (coercion cap, frozen-probe detection, DLQ pagination added).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, full DLQ drain

Second pass of the billion-document audit, this time below the UI:

- verifyMigration recounted every window SEQUENTIALLY inside one HTTP
  request — hours and a guaranteed timeout at tens of thousands of
  chunks. It now runs as a background task (POST /control/verify +
  GET /api/verify with {status, progress, result}), counts windows with
  bounded concurrency (8), and reports live progress; the UI button
  polls and shows 'checked X/Y · phase'.
- The global uniqExact(_id) + whole-table GROUP BY duplicate check can
  exhaust ClickHouse memory at billions of distinct ids. Replaced with
  duplicateStats(): partition-by-partition scans (external group-by
  enabled) — exact for every duplicate class we act on, because copies
  of the same document share their ts month (a retry RESENDS the same
  event ⇒ same ts ⇒ same partition), and memory-bounded per month.
  Dead countAndUniq/duplicateSample removed.
- replayDlq silently processed only the first 10,000 pending entries
  (listPending's default limit) — one click on a large DLQ reported
  success while replaying a fraction. Now a keyset drain (pages of 500
  by _id) processes the entire queue; still-failing entries stay
  pending but sort behind the advancing cursor, so it terminates.
  Batch dedup tokens are keyed by the page's first _id (stable across
  retries, unlike positional counters over a shifting list).
- Test fixture correction that validates the partition assumption: a
  cross-cutover retry shares the original event's ts (a retry resends
  the same event) — the earlier fixture gave the copy a fresh ts, which
  no real duplicate has.

96 tests green; async verify exercised live end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ar2rsawseen and others added 12 commits August 18, 2026 16:50
…ot three

Setup story now matches reality: MONGO_URI and CLICKHOUSE_URL are the
only variables an operator must set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gap flagged by Arturs: everything so far used STATIC post-cutover rows —
no test ever ran a continuous live writer against drill_events while the
migrator was copying, attaching, and monitoring.

New suite runs a 40-rows/30ms writer for the entire migration (starting
before, ending after), adversarially aimed: half the rows land in the
SAME (a,e,n) scope being migrated, and the source's newest slice carries
recent ts with historical cd so its chunk ATTACHes into the CURRENT
month partition — the exact partition live traffic is inserting into.
The invariant monitor runs hot (150ms) throughout.

Asserts: engine completes unpaused (no false invariant trip from live
rows), migrated counts exact (30k, zero dups), every live-written row
survives untouched, the hot partition really contains both populations,
and full verify passes with live data present.

97 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hen-replay dedup

Arturs: 'what happens if we have more problematic docs than 10k? at 10B
scale that is under 1% and we HAVE had those.' The 10k replay cap was
fixed earlier today; this closes what the question actually exposes:

- GLOBAL DLQ PAUSE GUARD (LEDGER_DLQ_PAUSE_THRESHOLD, default 1M, 0
  disables): the per-chunk breaker (5% of one chunk) never trips on
  evenly-spread failure — 1% of every chunk on a 10B run would silently
  accumulate ~100M raw docs into the manifest DB. The engine now checks
  total pending after each chunk (cheap in-process pre-filter) and at
  run start (so a resumed run inheriting a mass DLQ pauses immediately),
  and pauses for an operator decision.
- REPLAY IS A BACKGROUND TASK (POST /control/replay-dlq starts, GET
  /api/replay has {status, progress, result}) — millions of entries are
  not one HTTP request's work. DLQ panel shows live progress.
- REDO-THEN-REPLAY CANNOT DUPLICATE: replay now skips entries whose
  rows are already live as (_id, cd) pairs and marks them resolved
  ('already live — no insert'). This is the safe bulk path for mass
  DLQ: fix the transform, Retry failed chunks (redo re-reads the
  source), then Replay resolves the stale entries without inserting.
- DLQ counts/topErrors aggregations cached 15s in the route — the 2s UI
  poll stays harmless against a 100M-doc DLQ collection.

Runbook row added with the mass-DLQ playbook. 99 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 1M pause is a decision point — give the operator the numbers the
decision needs: the guard's pause log now includes the DLQ collection's
storage size and the manifest DB's disk-free %, and the DLQ panel shows
a storage pill (rides the 15s aggregate cache). Raising the threshold
is now an informed 'the disk can afford it', not a guess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replay became a background task; the button receipt now points at the
live progress line instead of claiming completion. Found in the full
UI button sweep (all 20+ interactions re-tested in-browser: tabs,
pause/resume, two-step retry/waive, background replay with progress,
DLQ pagination + storage pill, preflight with the new automated checks,
index build, dry run, async verify with gate, checkbox persistence
across reload, rebuild refuse→force flow, Help-tab cross-pane actions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The console can pause runs, purge windows, and rebuild the ledger —
it must not sit open on a reachable port at a customer site. One env
var enables HTTP Basic on everything except /healthz (constant-time
compare, any username, browser prompts natively — zero UI changes).
Unset = open, for localhost/port-forward setups.

Verified live: 401 without/with wrong password, 200 with correct,
/healthz stays open for probes.

Also probed 1,000-collection scale end to end (real deployments have
thousands of hashed collections; tests used two): exact migration,
1,000 chunks, dashboard summary query 29ms. Two quantified limits, no
code change needed yet: preflight is ~17s at 1k collections (sequential
per-collection probes — parallelize if 5k+ deployments appear) and
per-collection fixed overhead is ~140ms (irrelevant when collections
hold real data volumes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arturs' call: deployments run the console on non-reachable ports
(localhost / port-forward / cluster-internal), so DASHBOARD_PASSWORD
is complexity without a user. The 1,000-collection probe findings from
the reverted commit's message still stand (they were code-free).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified live: 2 containers via docker compose --scale against one
ledger — 16 chunks split 8/8 by container-hostname pod ids, 120,000
rows exact with zero duplicates, both containers exit 0 on completion
(EXIT_ON_COMPLETE). Recipe documents the two real-world caveats: scale
across machines (CPU-bound per container) and drop the fixed published
port when scaling on one host for tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mparison

Arturs' question: commit gates are count-based, not doc-per-doc — what
can that miss and how do we recover? The honest map has two blind spots
that deserved tooling, not documentation:

1. SELF-CONSISTENT UNDER-READ: a reader that silently loses cursor tail
   produces tally == staging count == live count — every existing check
   passes while docs are missing, because they all compare against the
   tally. 'Audit vs source' (rebuild machinery in checkOnly mode) is the
   defense: recount every window directly against MongoDB and report
   disagreeing windows WITHOUT touching the ledger. The source is the
   truth, not the tally.

2. RIGHT COUNT, WRONG CONTENT: a transform bug produces the correct
   number of corrupted rows — invisible to any count forever. 'Content
   sample audit' re-transforms random source docs (deterministic
   index-served probes, no $sample scan) and compares them
   field-by-field with their live rows: scalar columns exact, JSON
   columns by top-level key set (value-level JSON equality is the
   differential harness's job — ClickHouse normalizes encodings).

Both are background tasks with Guide-phase-6 buttons and result
rendering; recovery for anything flagged is the standard invariant —
purge the scoped window, redo from the frozen source.

Pinning test choreography proves complementarity: wrong-uid row (same
_id/cd/counts) → content audit catches with field attribution while
source audit stays green; deleted row → source audit flags the exact
window; restore → both green; checkOnly verifiably leaves the ledger
byte-identical. 100 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arturs asked whether the audits add load and whether they should run per
commit. Answering it properly surfaced two things:

1. PER-COMMIT UNDER-READ GUARD (LEDGER_SOURCE_COUNT_CHECK, default on):
   the audits stay on-demand, but the cheapest tally-independent check —
   'how many docs does the source say this window holds?' — costs one
   indexed countDocuments (~1% of chunk duration) and closes the
   self-consistent-under-read blind spot AT COMMIT TIME instead of at
   sign-off. Mismatch flags the chunk failed for standard purge+redo.

2. SCALE BUG: fetchRowsByIds / fetchLiveCdByIds / deleteLiveByPairs
   filtered ClickHouse by bare _id — not in the ORDER BY, so each call
   full-scanned the _id column (mass DLQ replay did that PER 500-DOC
   BATCH: days instead of minutes at 10B rows). Every caller knows its
   rows' cd values; all three now take cd min/max bounds and prune to
   the relevant partitions (replay batches, content-audit samples,
   rebuild sweep attribution via ts-derived cds, pair purges).

Two fixes found by the new test running against the changes: checkOnly
no longer reports pending (live=0) windows as disagreements, and
toString(cd) AS cd alias-shadowed the WHERE bound column in ClickHouse
(String vs DateTime64 type error) — aliases renamed. 100 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by live-clicking the new Audit-vs-source button (CI could not see
this — the demo state had WAIVED docs): a window whose only source-live
difference is its own DLQ'd docs was flagged as a disagreement, and the
render's advice (rebuild + retry) would just re-DLQ them — an operator
confusion loop.

DLQ entries now carry cd_ms (derived from raw_doc.cd, ts fallback,
null when unparseable) with an index; the source audit and rebuild
classification subtract known-unmigrated (non-resolved) docs per
window: live + unresolved == source → agreement/done. Entries written
before this field can't be attributed and count zero (documented).

The per-commit source-count guard needs no such adjustment — it
compares against docs READ, which includes later-skipped docs.

Verified in-browser: the previously-flagged demo window now passes;
new pinning test (waived doc absent from live → audit green, restore →
still green). 101 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI runner finished the migration faster than the local box, so the
writer landed 920 rows against a hardcoded >1000 threshold. Assert the
structural property instead: batches landed DURING the run (>100 rows
before completion) and the writer continued after — magnitude varies
with runner speed and proves nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant