Skip to content

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

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

Ledger engine: crash-safe, resumable drill_events migration — no Redis#7
ar2rsawseen wants to merge 69 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 8 commits August 22, 2026 19:22
Two field crashes from devops' first dry run, same root pair:

1. socketTimeoutMS (maxTimeMs+30s ≈ 90s) killed the createIndex await on
   any real-sized collection. Removed: every read op already carries its
   own maxTimeMS, so the client-wide socket timeout protected nothing
   and murdered legitimately long awaits (index builds, cold window
   counts). Index builds now run as long as they need.

2. The crash left a half-built index, and on restart hasRequiredIndex
   counted it as present (listIndexes lists in-progress builds), so the
   engine skipped building and hinted an unfinished index:
   'hint provided does not correspond to an existing index'.
   startIndexCreation is replaced by ensureIndex with join semantics —
   ALWAYS called per collection: instant no-op when ready, JOINS an
   in-progress identical build and waits for readiness, builds when
   missing. hasRequiredIndex remains for display/log purposes only;
   correctness no longer depends on it.

Corrects the claim I gave devops earlier: 'no timeout on createIndex'
was true of the command but false of the client socket config.

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

The 4 docs in devops' dry-run DLQ, made migratable. Mechanics: JS
serializes integer-valued doubles below 1e21 in FIXED notation
(5.26e19 → '52601586211929000000'), so ClickHouse's JSON parser infers
an integer type and overflows UInt64 — while values ≥1e21 serialize in
exponent notation and land as Float64 (the 9.2e25 golden). The
unmigratable band is exactly [2^64, 1e21) and (-1e21, -2^63).

Values in that band are now stringified losslessly (as they would have
serialized) and counted under 'stringify_int64_overflow' — the same
spec philosophy as NaN/Infinity/bigint/Decimal128/Long: values the
target cannot carry numerically become strings, never nulls, never
dropped rows. No live-parity divergence: live ingestion ERRORS on such
payloads (same CH rejection), so there is no live row whose identity a
migrated row must match.

TRANSFORM_VERSION default bumped to v2 — devops can simply pull and
press Replay DLQ: the 4 entries re-transform under v2, insert, and
resolve with resolved_by_version=v2. No golden fixture sits in the
window (differential suite unchanged and green).

Boundary unit tests + e2e proof (overflow value lands in real CH as the
expected string). Integration validated on CI — local Docker Desktop is
currently down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
countly-platform's WhereClauseConverter accesses sg fields numerically
via toFloat64OrNull(CAST(field, 'String')) — string-cast first,
precisely because JSON paths hold mixed Dynamic types. Through that
pattern a stringified overflow value behaves identically to the number,
so product filters/aggregations are unaffected by the coercion. Pinned
with an executable assertion against real ClickHouse.

(Also the answer to 'is mixed-type under one path a problem': the
platform's standard access pattern was built for exactly that; only
hand-written typed-subcolumn SQL differs, and the fix there is the same
cast the platform uses.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field bug (devops): pressing Replay DLQ during a dry run inserted the
replayed rows into the REAL drill_events table — the replay path had no
dry-run awareness. Consequence beyond surprise: the actual run then
migrates those same docs from source, guaranteeing duplicate (_id, cd)
pairs that verify flags as migration defects.

Replay now honors dry-run mode: rows rehearse against the Null-engine
table (full parse/type validation — the dry-run contract), the
already-live pair check is skipped (meaningless against a Null target),
and the Null table is ensured non-destructively so replay can run
beside the main dry loop. Regression test pins: dry replay reports
replayed=1, live table stays untouched, dry DLQ entry resolves.

Operational note for the field: rows a PRE-FIX dry-run replay already
wrote to live must be removed before the real run (TRUNCATE if live is
otherwise empty pre-cutover, else delete by (_id, cd) pairs).

Also in this push: LedgerStore gains claimNextGlobal / claimById /
listPendingSentinels and optional-collection filters (groundwork for
cross-collection pod scheduling, orchestrator switch lands separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field shape from the first customer dry run: ~2,400 mostly-single-chunk
collections at 12k docs/s on one pod — and the old scheduler could not
use more pods on it: every pod walked the same collection list and
waited for the current collection to finish before advancing (convoy).

Arturs' design, implemented: map EVERY collection's chunk grid upfront
(index ensure + bounds + initChunks per collection, idempotent — resume
unchanged), then claim globally: next pending chunk anywhere, sorted
(collection asc, idx desc), so pods drain a collection together and
spill into the next one the instant nothing is claimable. Reservation
stays a single atomic findOneAndUpdate with a lease, exactly as before.

The one per-collection ordering constraint survives as its own phase:
each null-cd sentinel runs only after ITS collection's regulars are
terminal (gate re-checked per sentinel; guarded claimById so racing
pods can't double-run a sweep). Completion = zero non-terminal chunks
anywhere; recovery/lease-reclaim are now run-scoped.

New in-process two-pod test: 8 single-chunk collections, both pods run
concurrently — exact counts, zero duplicates, both pods complete ≥2
collections each (the convoy would have let one pod monopolize).
All 104 tests green, including the sweep-ordering regression and every
resume/heal choreography on the new control flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arturs' question on the map-upfront design: what about data still coming
into OLD ingestion? Old ingestion assigns cd at write time, so post-map
docs land strictly BEYOND every mapped window — existing chunks and all
window checks stay valid; the data just needs chunks of its own.

The run is now map → drain → re-map → ... until a pass finds nothing
new: re-mapping re-discovers collections (new event types included) and
appends delta chunks after each collection's high-water mark (idx
continues, so delta = newest data claims first). Frozen source costs
one extra cheap pass; a live source is legitimately chased, which makes
bulk-before-cutover + final-drain a supported flow (runbook row added):
migrate history while the old stack still serves, freeze, and the last
pass drains minutes of delta instead of hours of backfill.

Backward compatible in both directions: mapping is idempotent, ledger
schema is unchanged (collection was always a first-class field), and an
upgraded pod resumes an old ledger exactly as the soak's mid-run image
swap already proved. Dry runs skip top-up (sampled rehearsal).

Test: run to completion, insert late-cd docs into two collections
(simulated old-ingestion tail), resume — delta chunks appended with
continuing idx, drained exactly, zero duplicates. 105 tests.

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

Mixed shape = a data-bearing base drill_events (new format, a/e/n in
docs) ALONGSIDE hashed collections. verifyMigration already skips
per-window equality for the unscopable base collection in
multi-collection runs — but rebuild/audit lacked the same guard: an
unscoped window count includes sibling collections' rows, so rebuild
would have misclassified base windows (and the source audit would have
false-flagged them). Now: audit reports nothing for them; rebuild marks
them pending — redo is idempotent (promotion pair-checks staged rows
before every attach), so recopying an already-migrated window skips
attaches instead of duplicating.

Live two-pod mixed-shape run (docker compose scale=2, 20 hashed
collections + base with new-format docs, 530k docs): cross-collection
parallelism observed mid-run (both pods in different collections in 6
sampled moments; 17 distinct collections each), exit 0 both, 530,000
rows / 530,000 unique, chunks split 21/21, base mapping exact
(checkout_new → [CLY]_custom/n, [CLY]_view kept, explicit doc.n
preserved), verify ok with exactly the 2 base windows unscoped-skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l_events (new format)

The transform was always written against the UNION of both shapes
(doc.a/e/n win, collection-hash defaults fill absence, ts unit
heuristic, old-only helper fields dropped like live's whitelist) and
the mixed-shape live run proved it empirically — but the golden corpus
turned out to cover it barely (1 of 71 fixtures without embedded a,
none exercising collection defaults). Six unit cases now pin the
schema-difference matrix explicitly: defaults identity for old-shape
custom + internal events, embedded-wins for new shape, seconds-unit ts,
skip-not-garbage when identity is absent everywhere, and helper-field
dropping.

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

Copy link
Copy Markdown
Member Author

@codex review

@ar2rsawseen

Copy link
Copy Markdown
Member Author

Review findings

I found two blocking multi-pod correctness issues at head 7b1de12.

[P1] Reclaimed leases are not fenced from the previous worker

LedgerStore.heartbeat() checks pod_id, but transition() and recordAttached() only guard on chunk id/status. Recovery can reset an expired chunk and drop its deterministic staging table while the original worker is merely stalled rather than dead. If that worker resumes after another pod claims the chunk, both workers can recreate/write/drop the same staging table, and the stale worker can transition the new owner's claim.

Please add a claim-generation/fencing token to every worker-owned ledger mutation (including attachment bookkeeping), include it in the staging-table identity, and abort processing whenever heartbeat or a guarded transition no longer matches the claim.

Relevant code: src/state/ledger-store.ts:331-362, src/runtime/chunk-orchestrator.ts:524-547, and src/runtime/chunk-orchestrator.ts:585-620.

[P1] Top-up completion has no distributed barrier before null-cd sweeps

Each pod independently decides that newChunks === 0 and advances to the sweep phase. Pod A can observe a stable pass, see zero regular non-terminals, and claim a sentinel while pod B is still mapping and subsequently appends delta chunks for that collection. The sentinel's ts-derived rows then overlap those new regular windows, violating the required sweep-after-regular ordering and potentially causing verification failures or skipped promotion.

Please persist a shared mapping/top-up generation and prevent sentinel claims until every active mapper has completed the same stable generation.

Relevant code: src/runtime/chunk-orchestrator.ts:219-244 and src/runtime/chunk-orchestrator.ts:450-480.

Related appendChunks race

Concurrent pods independently derive startIdx and bounds, use unordered insertMany, swallow duplicate-key errors, and return docs.length even for rejected documents. If pods observed different source upper bounds, MongoDB can retain a mixture of their proposed grids. The shared top-up coordinator should serialize or atomically reserve each appended range and return the number actually inserted.

Relevant code: src/state/ledger-store.ts:199-251 and src/runtime/chunk-orchestrator.ts:386-398.

Validation performed:

  • npm run typecheck passed.
  • All 74 non-integration tests passed locally.
  • GitHub's MongoDB/ClickHouse integration check is green.
  • Existing multi-pod tests cover simultaneous claiming, but top-up is tested with one pod and lease recovery does not exercise a stalled worker resuming after reclamation.

… barrier-lite

Codex review on PR #7 found two P1s and a race at head 7b1de12; all
three addressed, each with a pinning test.

FENCING (P1): a worker stalled past its lease could resume after
reclamation sharing the new owner's deterministic staging table and
moving its claim (transitions guarded only by id+status). attempts —
already atomically incremented per claim — is now the fencing token:
- staging tables are generation-suffixed (…_g<attempts>): a stale
  worker writes only its own table, orphan sweep collects it
- every worker-path transition and recordAttached carries an optimistic
  (pod_id, attempts) fence; recovery passes the CURRENT doc snapshot so
  legitimate takeover still works, while a stale ex-owner's outdated
  snapshot is rejected
- heartbeat reports ownership; loss aborts the chunk at the next page
  (ClaimLostError → abandon quietly, no transitions, drop own staging)

APPEND RESERVATION: racing top-up pods that observed different source
maxima could interleave two grids into OVERLAPPING windows (docs
migrated twice) — unordered insertMany swallowed the duplicate keys.
The first delta chunk is now the reservation: exactly one insertOne
wins startIdx, the loser returns 0 and re-probes. The new test
reproduced the original bug (a=2 b=3 both 'succeeded') before the fix.

SWEEP BARRIER-LITE (P1): a pod entering the sweep phase re-probes the
sentinel's collection for delta right before claiming — a racing
mapper's append sends it back to regular draining. Note the invariant
has softened since pair-matched promotion: a lost race degrades to
idempotent redo (attach pair-checks staged rows), not data loss — the
probe is ordering defense-in-depth, so no distributed barrier needed.

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

Copy link
Copy Markdown
Member Author

All three findings addressed in 974fee5 (113 tests, CI green) — thank you, two of these were real and one reproduced exactly as described.

[P1] Lease fencing — fixed as suggested. attempts (already atomically incremented on every claim) is the claim-generation token:

  • staging tables are generation-suffixed (…_g<attempts>), so a stalled ex-owner writes only its own table (orphan-swept later), never the new owner's;
  • every worker-path transition/recordAttached now carries an optimistic (pod_id, attempts) fence — recovery passes the current doc snapshot so legitimate takeover still works, while a stale ex-owner's outdated snapshot is rejected;
  • heartbeat reports ownership; loss aborts the chunk at the next page boundary (abandon quietly, no transitions, drop own staging).
    Pinned by a stalled-worker-resumes test: after reclamation, the stale pod's heartbeat and fenced transitions are rejected, the new owner's succeed.

appendChunks race — confirmed and fixed. The new regression test reproduced your exact scenario pre-fix (both racing appends "succeeded", mixed grid). The first delta chunk is now the reservation: exactly one insertOne wins startIdx; the loser returns 0 and re-probes next pass. Test asserts one winner and a contiguous, non-overlapping grid.

[P1] Sweep/top-up barrier — mitigated with a lighter mechanism, and honestly re-scoped. The strict sweep-after-regulars invariant dates from when attach-recovery was window-count-based; since promotion became (_id, cd) pair-checked, a sentinel racing a late append degrades to idempotent redo (staged rows already live are skipped, never duplicated), not loss or skipped promotion. So instead of a distributed mapping-generation barrier, each pod re-probes the sentinel's collection for delta immediately before claiming it — a racing mapper's append (or its reservation) sends the pod back to regular draining. If you see a hole in that reasoning under pair-checked promotion, very interested to hear it.

🤖 Generated with Claude Code

ar2rsawseen and others added 18 commits August 23, 2026 16:40
… sweep

Concurrent INITIAL mapping had the same interleaving bug the review round
fixed for top-up appends: two pods that probed a live source at different
instants compute different grids, and unordered insertMany with swallowed
duplicate keys interleaved them into overlapping/gapping windows. The first
document (idx 0, same _id for every racer) is now the reservation — exactly
one grid stands; the loser returns 0 and heals any genuine delta through the
top-up path. Remainder inserts are ordered so a crash mid-init leaves a
contiguous prefix the top-up pass can extend.

New deterministic race tests (the divergent-grids one reproduces the original
bug when run against the unfixed store):
- initChunks divergent grids: exactly one coherent grid stands
- initChunks identical grids (frozen source): collapse to one
- sweep sentinel double-claim: one winner
- retryFailed double-fire: each failed chunk retried exactly once
- zombie owner mid-chunk: processChunk abandon path drops only its own
  staging generation, leaves the reclaimed chunk untouched

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

The new chaos harness (real worker processes SIGKILLed at random moments,
plus a forced kill between ATTACH and recordAttached) caught a 400-row
duplication on its first run: recovery of a lease-expired 'attaching' chunk
was not atomic, so TWO surviving pods could both pick it up from
findRecoverable and both run check-then-ATTACH on the same staging
partition — double-attaching it. No zombie required; pod death mid-attach
plus two recoverers is an ordinary multi-pod production scenario.

Fixes:
- LedgerStore.reclaim: atomic single-winner takeover of a recoverable chunk
  (status + expired-lease filtered findOneAndUpdate, attempts bump = new
  claim generation). recoverOne reclaims FIRST for all three states; losers
  walk away, zombies are fenced out of every subsequent ledger mutation.
- finishAttaching: exact pair accounting per partition (live-matching count
  vs staged count) replaces the sampled any>0 check — equal means already
  attached, zero means attach, anything else self-heals (fenced-touch, then
  delete matched pairs + attach fresh), including a post-attach re-count for
  the concurrent window. Partial promotions now converge immediately
  instead of silently under-recording.
- Heartbeat interval is capped at lease/2 so small leases keep the
  heartbeat-outlives-lease contract (the 10s floor broke it below 30s).
- staging: countPartitionRows, countLiveMatchingStaged (exact), and
  provenance-exact deleteLiveMatchingStaged.

New tests: pod-chaos harness (phase A forced torn commit, phase B random
2-pod kill cycles, phase C undisturbed drain + quarantine heal, end-state
exactness + verify + both audits + zero debris), reclaim single-winner
race, and the double-attach heal pinned deterministically. Full suite 121
tests; chaos run green 5x consecutively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion under random pod kills

The chaos harness now runs the complete cutover-first situation: a Mongo
writer keeps appending to the old source through the kill phases (top-up
mapping under chaos), and a ClickHouse writer pours live rows into the
target the whole time — half into the same (a,e,n) scope being migrated —
through verification. End-state asserts use dynamic source counts, exclude
live rows from migration exactness, and require every live-ingested row to
survive untouched. CHAOS_PODS/CHAOS_CYCLES knobs; campaign green at 2, 3,
and 4 pods.

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

Devops running 4 pods saw the dashboard report ~10k docs/s while the real
aggregate was ~39k: docsPerSecond was computed from this pod's in-memory
counters only, so with N pods the stat undercounted ~N-fold (same flaw the
previous migration tool had).

The rate now comes from the shared ledger: LedgerStore.clusterRate sums
docs_read of chunks that finished in the last 120s across ALL pods. /stats
carries it as  {docsPerSecond, pods, windowSec}; the dashboard
card shows the cluster rate with a pod count ('21,750 · 2 pods') while
running, and the ETA uses the same effective rate. Single-pod display and
the completed-run average are unchanged.

Verified live: real engine + a simulated second pod's ledger entries —
/stats cluster math exact, card renders the pod-count label mid-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two operator-safety flaws around multi-pod runs (the setup devops is on
right now):

- The rebuild guard counted a CRASHED pod's leftover claims as "active",
  refusing rebuild after a crash-storm — the exact scenario rebuild exists
  for. It now checks live leases only (activeClaims: non-terminal chunks
  with unexpired lease_until): dead pods' claims expire and stop blocking;
  genuinely working pods still block.

- The two audit buttons had NO cross-pod guard at all — clicking
  "Audit vs source" on any pod's dashboard while other pods migrate would
  report a wall of false mismatches. Both audits now refuse while other
  pods hold live leases, with a message saying why.

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

Chunks are cut on cd (server insert time) but drill_events partitions by
toYYYYMM(ts) (device event time). Historical data carries garbage device
clocks — 1970s epochs, far-future dates — so one cd-week of documents can
span more than 100 distinct ts-months, and ClickHouse rejects such an
INSERT block at the default max_partitions_per_insert_block=100. Hit in
the field on two chunks of a real migration (both parked as failed after
3 attempts, exactly as designed — no data damage).

Unlimited is correct for a one-off bulk load: the partitions exist in the
data regardless of how the insert batches are shaped, and the setting is
client-level so the staging copy path, the DLQ replay path, and the
INSERT SELECT fallback are all covered.

Regression test inserts 120 rows across 120 ts-months within one cd
window — reproduces the exact field error on the unfixed client settings.

Operator path for the parked chunks: pull this image, click Retry failed
chunks — the windows redo cleanly (nothing was attached, so the purge is
a no-op).

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

CI typecheck: the client types UInt64 settings as strings. And instead of
unlimited (0), use 4800 — every month of the DateTime64 range (1900..2299)
the transform clamps ts into. Functionally identical for legitimate data
(no block can exceed the theoretical month count), but keeps the tripwire:
the default 100 exists to catch misconfigured partition keys, not to cap
table partitions — those come from the data regardless of batch shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unk #110)

The insert regression covered writing >100 ts-months into staging; this
extends it through the full promote path — recovery of an 'attaching'
chunk with 120 partitions: per-partition pair-checked ATTACH, per-partition
recordAttached bookkeeping, chunk done, exactly one live copy per doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New outage-chaos suite (CHAOS_OUTAGE=1, dedicated throwaway containers —
never the shared dev stack) SIGKILLs nothing: it restarts CLICKHOUSE
mid-copy (connections reset) and freezes MONGODB for 6s (connections hang)
under two live workers. First run surfaced a real liveness gap: chunks
failing during the outage tripped the consecutive-failure circuit breaker,
which paused BOTH pods — and a paused headless pod never resumes. Workers
sat alive-but-idle with 24 pending chunks for 90s+ (data was never at
risk; the end state healed via kill+retry, but a 3am network blip would
have parked a 20h migration until an operator woke up).

Fix — classify the pause instead of weakening the breaker:
- pause(reason): 'operator' | 'breaker-transient' | 'breaker-data'.
  Streaks containing any KNOWN-permanent failure (classifier verdict at
  the failure site, source-count mismatch, fail-rate breaker, invariant
  trip, mass-DLQ guard) pause as breaker-data: operator-owned, exactly as
  before. Streaks of transient/unknown failures pause as breaker-transient.
- breaker-transient arms a 15s backend probe (ClickHouse + Mongo); two
  consecutive healthy probes re-queue the failed chunks (retryFailed) and
  resume. Poison data that slipped through re-fails via the classified
  path and re-pauses as breaker-data — converges to operator-owned.
- resume() resets the failure streak (one stray failure after resume must
  not instantly re-trip), stats expose pauseReason, and the dashboard
  status shows 'auto-resume armed' vs 'needs operator (data)'.
- chaos worker now uses the config-driven retry policy (prod-like backoff
  absorbs multi-second blips; pod-chaos pins fast retries via env).

Verified: outage suite green 4x consecutively with workers finishing on
their own (exit 0, wedge watchdog never fires — asserted); deterministic
test pins the probe state machine (2-probe resume + failed-chunk re-queue,
and that breaker-data/operator pauses never auto-resume).

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

Second real finding from the outage chaos round: an ECONNREFUSED thrown by
a recovery-path ClickHouse call (recoverOne dropping/promoting a reclaimed
chunk during a target outage) propagated up through reclaimExpiredLeases
into run() and CRASHED the pod (observed live: worker exit 1). In k8s that
means pods crash-looping for the length of any CH outage. recoverOne now
absorbs non-fence errors: the chunk stays claimed, the lease expires, and
a later tick retries recovery — the pod never dies for a failed attempt.

The outage suite now forces and proves the full self-healing loop LIVE
(previously only unit-verified): tight retry budget + 90s ClickHouse stop
deterministically parks chunks and trips the breaker on both pods; ledger
telemetry during the outage shows them correctly frozen while probes fail;
strict asserts demand 'Circuit breaker' AND 'auto-resumed' in the worker
logs, exit 0, wedge watchdog silent, exact end state. Measured on the way:
with PROD backoff the engine absorbed a 150s hard stop with zero failed
chunks — the patience is real, which is why the test needs tight knobs to
reach the breaker at all.

Also: worker staging client timeout is config-driven now, and the harness
captures stdout AND stderr (pino logs to stdout — the breaker evidence was
invisible in stderr-only capture).

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

Field round 4. All source-count-mismatch failures sat in windows exactly
one retention period old (2025-08-23..30, one year before run date): the
Mongo TTL reaper deletes at the retention horizon while the migration
passes it, so read > source by the reaper's progress during the chunk.
Failing those chunks flaps forever (retry chases a moving target).

- sourceCountGuard is now directional: source < read (shrinkage: TTL,
  GDPR) logs + counts (stats.sourceShrankChunks) and the chunk stays done
  — the migration is a snapshot at read time. source > read (UNDER-read,
  the true data-loss signal) still fails the chunk. Deterministic test
  pins both directions.
- Source audit classifies live > source windows as deletionDriftWindows
  (reported separately), never as mismatches; live < source remains a
  defect. Test: delete a migrated source doc -> drift 1 / mismatches 0.

Dashboard fixes (field screenshots, 73k chunks):
- Per-collection bars were built from the TRUNCATED chunk list (500 active
  rows once total > 2000): a collection whose only listed chunk was failed
  rendered as 0/1 = 100% failed while its done chunks were invisible. Bars
  now come from the server-side summary — accurate at any scale, with an
  explicit 'N failed' note instead of a red takeover.
- Chunk map states what it shows when truncated (active/failed window;
  done chunks omitted) instead of presenting a sea of grey as stalled.
- ETA used a GLOBAL avg-docs-per-done-chunk; the done set is dominated by
  big collections while the pending tail is thousands of tiny ones — field
  showed 8630 min for a ~1-day remainder. Now per-collection averages with
  global fallback.
- Failed-chunks stat card reads the ledger (cluster truth), not this pod's
  in-memory counter.
- Long error text wraps inside its table cell; coercion samples quote
  strings so stringify coercions no longer display as no-ops.

All verified live against a 2,668-chunk truncated-mode run.

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

Field requirement: customers who need approval before switching keep the
OLD architecture authoritative, the new one receives a live copy, and
history is backfilled up to the moment mirroring began.

Mirror mode (MIRROR_MODE=true, its own pod, same image):
- ONE database-level change stream filtered to the drill prefix; inserts
  are transformed with the SAME transform v2 as the bulk migration (cd
  preserved), pair-checked against live (_id, cd), and batch-inserted.
  Identical transform on both halves of the timeline means verify and the
  source audit cover migrated + mirrored data as one consistent set.
- Checkpoint T0 is captured AFTER the stream opens and persisted once
  ($setOnInsert): overlap around the boundary is delivered by BOTH sides
  and converges via the pair-check — loss is structurally impossible,
  restarts never move the boundary.
- Crash-safe: resume token saved per flushed batch; redelivery converges
  (pinned by a deliberate token-REWIND test). A ClickHouse outage makes
  the mirror lag, never drop (retry-until-landed). Oplog-rolled tokens are
  a loud fatal with gap-recovery instructions, not a silent gap.
- Collections created after startup trigger a resolver refresh; non-insert
  ops (uid merges, GDPR, TTL) are counted and surfaced — the runbook's
  reconciliation step owns them (v1 mirrors inserts).
- Idle heartbeat marks the stream at-head so dashboards distinguish
  "caught up" from "mirror died" during quiet hours.

LEDGER_CD_UPPER_BOUND (the other half):
- Mapper clamps windows to the bound; collections born after it are
  skipped; top-up is disabled (post-checkpoint data belongs to the
  mirror); clamped spans re-estimate doc counts with an exact indexed
  count. Preflight becomes bound-aware: "source keeps growing" is the
  expected state, and the sanity check moves to the bound itself.

UI: Mirror card on every pod's dashboard (live/caught-up/lag, docs
mirrored, non-insert ops, checkpoint with the exact LEDGER_CD_UPPER_BOUND
value to copy, and a mismatch warning if a pod runs with a different or
missing bound); "bounded · cd < ..." header badge. Verified live.

CI: MongoDB service replaced with a step-launched single-node replica set
(change streams). Tests: mirror identity/restart/rewind suite + THE
SCENARIO — mirror running, source still growing, bulk migration bounded
at the checkpoint → exact whole-timeline convergence with verify + audit
green over the combined table. RUNBOOK: mirror-first playbook section.

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

Arturs' review of the mirror-first design surfaced two decisive flaws in
the change-stream mirror: (1) not all deployments run replica-set MongoDB,
and (2) copying drill DOCUMENTS is the wrong layer — the new architecture
re-ingests with different logic, so a doc-level mirror fills drill_events
while sessions/aggregations/profiles stay empty, making side-by-side
validation meaningless. The real mirror is the nginx-level REQUEST tee
that already exists: same SDK traffic re-ingested natively by both stacks.

Removed: MirrorEngine, MirrorStore, MIRROR_MODE config, /api/mirror,
dashboard Mirror card, mirror tests, and the CI replica-set plumbing
(the tool is back to running on any MongoDB).

Kept and now load-bearing: LEDGER_CD_UPPER_BOUND. With a request tee the
same event exists in both systems under DIFFERENT identities (new _id,
new cd), so (_id, cd) convergence cannot deduplicate across the seam —
any post-flip doc the bulk migration copies is an UNDETECTABLE duplicate.
The bound is the only protection: mapper never crosses it, top-up is
disabled, born-after collections are skipped, preflight expects a growing
source, and the header badge shows the bound on every pod.

New test (no change streams needed): history + post-flip slice + a live
writer growing the source THROUGH the run → exactly the pre-bound docs
land (zero leaked, max cd < bound), the born-after collection is never
mapped, a second full run appends nothing, verify + source audit stay
green against the growing source.

RUNBOOK: tee-mirror playbook — flip the tee inside a ~60s old-ingestion
pause (SDKs retry; the pause creates a sharp loss-free/dup-free boundary),
record the pause timestamp as the bound; fallback margin rule when a
pause is impossible; GDPR/merge re-apply and retention-drift notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Can we auto-detect the cut-off from incoming data in both stores?" — yes,
and without per-event identity (teed events carry different _id/cd on each
side, and device ts is server-stamped independently when requests omit it,
so matching keys are out — Arturs' catch). Rate SHAPES only:

- Anchor: min(cd) in ClickHouse — the store that starts cold in BOTH tee
  directions (old→new and new→old), so one detector covers both. Refused
  when the run already mapped chunks (migrated rows poison the anchor) or
  when CH already holds historical data.
- Per-minute count curves from both stores around the anchor. The
  recommended flip-inside-an-ingestion-pause leaves a zero-traffic minute
  on BOTH curves: the detector finds the gap, suggests a bound from inside
  it, and the seam is provably exact. Without a gap it suggests the anchor
  and QUANTIFIES the stake — how many old-side docs sit within +-2 min —
  so the dup/loss trade is a number the operator decides on, never hidden.
- Sync parity (valid before AND after migration): hourly count parity
  between the stores from the boundary onward. nginx mirror is
  fire-and-forget — a secondary outage silently drops mirrored requests
  and only count parity reveals the hole; each flagged hour is a bounded
  backfill window. Sub-100-doc hours are below the noise floor; the
  still-filling current hour is never flagged.

Runs as a background task (the Mongo scan spans thousands of collections);
dashboard card shows the suggestion with the exact LEDGER_CD_UPPER_BOUND
line to copy, the minute table around the seam (gap highlighted), and the
parity verdict with flagged hours.

Tests: pause-gap detection lands inside the simulated pause; no-gap mode
quantifies ambiguity; mapped-run refusal (sync still reports); the
simulated tee-outage hour is flagged precisely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without LEDGER_CD_UPPER_BOUND the migration deliberately chases newly
arriving old-cluster data via top-up (correct when nothing mirrors traffic
to ClickHouse — CH's existing rows play no role in mapping). The bound is
only for tee setups where post-flip old-cluster data is the tee's copy.
The detector suggests, never applies — stated on the card and as a mode
table in the runbook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The detected boundary can now be adopted from the dashboard: the Apply
button (double-click confirm) stores the bound in the run's shared config
(mig_run_config) and prunes the shared ledger — pending chunks fully past
the bound are deleted, a straddler is clamped to end AT the bound. Because
both the store and the grid are shared, ONE click covers every pod:
nothing beyond the bound is claimable the moment it lands, and each pod
adopts the bound itself at its next map pass (top-up disables) — no
ConfigMap edit, no rollout restart.

Safety rails:
- env stays king: with LEDGER_CD_UPPER_BOUND set, the route refuses and
  points at the deployment config (one source of truth). A pod booted with
  a CONFLICTING env value fails loudly ("bound conflict"), never guesses.
- refuses when any EXECUTED chunk reaches past the bound (that data may
  already be live — purge/retry first, then apply).
- adoption re-prunes: a pod whose unbounded map pass raced the click may
  append post-bound chunks after the apply-time prune; every adopting pod
  prunes again, so stragglers die within one pass cycle.
- bound must be >60s in the past; dry runs refuse.

Tests: prune semantics (delete/clamp/refuse-on-executed), a fresh pod
adopting the stored bound end-to-end (migrates only below it, stats show
adoption), and the env-conflict fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four supported scenarios, now selectable on the Guide tab (persisted)
with per-scenario checklists, and as a decision matrix in the RUNBOOK:

1. Two clusters, no mirroring (plain switch)      -> bound UNSET; top-up
   chases new old-cluster data until the final drain.
2. Two clusters, mirror old->new (old primary)    -> bound SET at the tee
   flip; detect + one-click apply; badge on every pod; sync parity during
   validation; GDPR/merge re-apply before sign-off.
3. Two clusters, mirror new->old (rollback net)   -> same machinery as 2;
   the detector is direction-agnostic (ClickHouse starts cold either way).
4. Single cluster, in-place upgrade               -> bound UNSET; the
   upgrade froze mongo drill collections; live-parallel path with
   backpressure protecting the production ClickHouse.

The picker states the one decision that changes configuration — tee or no
tee — because a wrong bound setting is the one mistake the tool cannot
detect afterwards (unset with a tee = undetectable duplicates; set without
one = orphaned new data).

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

Field report at 1.2B rows live: docs/s collapsed to 20-200 in the tail.
The tail is the many-single-chunk-collections regime (41% of chunks held
the last 20% of docs), where per-chunk FIXED costs dominate — and the
dominant fixed cost was the pair check: countLiveMatchingStaged filtered
(_id, cd) IN (staged) against the live PARTITION with no pruning — a
partition column scan over billions of rows, paid twice per chunk
(pre-attach check + post-attach double-attach guard), identical cost for a
300-doc chunk and a 50k-doc one.

Fix: the chunk's (a, e, n) scope plus the staged rows' ts min/max are
exactly the live table's primary key (a, e, n, ts) — the pair check and
the heal DELETE now carry those predicates, turning the partition scan
into a keyed range read. Scopeless chunks (unresolvable collections) fall
back to the old plan — correct, just slower.

Also: the per-chunk statusCounts refresh aggregated over ALL chunk docs
after EVERY chunk — O(n^2) across a 73k-chunk run; now throttled to once
per 2s (the UI reads a snapshot; 2s staleness is free).

Correctness unchanged — every attach-path test (heal, torn-commit,
120-partition, chaos, scenario) runs through the pruned queries.

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