Skip to content

materializer: replace per-pass signals_latest fold with a daily watermarked refresh #55

Description

@elffjs

Problem

The per-pass signals_latest upkeep (captureRollupDelta + foldSignalsRollup, run inside every span transaction) dominates materializer pass cost: ~300s at afternoon peak vs ~10s at dawn (dq_materializer_phase_seconds shows commit at ~1800–2200s/hour; decode is ~10s/hour). The fold is O(batch) in rows but O(day-partition files × 3) in I/O:

  • The recency/locrec CTEs re-derive latest-per-(subject, name) from lake.signals with a correlated bound (s.timestamp >= p.prev_ts), which cannot drive static partition pruning — no constant day = predicate exists in the query — so each pass re-opens roughly the affected buckets' current-day files.
  • The scan happens three times per pass (recency, locrec, and captureRollupDelta's NOT-EXISTS probe).
  • File opens are ~0.5s-class over httpfs, and the day partition's file count grows all day.

Result: the daily pass-duration sawtooth, decode ceiling below business-hours ingest (~255 snapshots/h), and the 2026-08-05/06 backlogs. Vehicle count is irrelevant to the cost — it's priced in file opens.

The fold's correctness machinery (recompute-equivalence, cloud_event_id tie-breaks against the base, QUALIFY dedup matching the read path, self-healing recency) exists to keep the table continuously exact — but the table stopped being a serving surface when the KV shipped: LATEST_KV_READ_MODE=serve in prod, negative path included. Its remaining jobs are KV bootstrap/reconcile source and fallback. We're paying the old system's ongoing cost for a backup copy.

Design

Remove the fold from the span transaction; refresh the rollup once daily from a watermark.

  1. Span commit keeps only: base inserts (signals/events) + KV publish + cursor advance. No rollup reads/writes.
  2. Daily refresh step (in the materializer loop — preserves the single-writer invariant; NOT a separate pod/cron):
    • Runs after the UTC-midnight partition rollover once the cursor has settled past it (~03:30–04:00 UTC — also this node's traffic trough).
    • Folds lake.signals WHERE timestamp >= <watermark> into the rollup with a constant watermark literal → static file pruning, one complete settled partition in steady state.
    • Watermark-driven and idempotent: a missed run folds two days next time; no operator memory needed. Watermark stored alongside the rollup (same transaction).
    • Simple arg_max/QUALIFY over (rollup ∪ tail) — the per-pass fold's tie-break/delta machinery can be dropped or reused; equivalence bar is unchanged (differential test stays).
  3. Coverage reconcile becomes watermark-aware — the sharp edge. It currently diffs KV against the rollup assuming freshness; against a daily rollup it must diff against (rollup ∪ signals-since-watermark), or bound comparisons at the watermark. Otherwise reconcile "repairs" the KV backwards up to a day. Same care as the CAS/multi-writer rules on the bucket.
  4. BootstrapFromRollup gains a tail replay: bootstrap from rollup (≤24h stale), then fold signals since watermark — constant predicate, 1–2 partitions, minutes, and stays bounded forever (vs O(history) pure-drop rebuild that grows with retention).

Cadence rationale (why daily, not finer/coarser)

The day partition is the cost quantum. Hourly refreshes rescan today-so-far repeatedly during business hours (recreating the problem, milder); weekly saves nothing per-day-covered and grows the recovery/reconcile tail 7x. Daily anchored to rollover folds exactly one complete settled partition. The rollup has no freshness SLA of its own anymore — its only consumer is disaster recovery, which is minutes-fast at any cadence.

Expected wins

  • Span passes lose their dominant, day-length-dependent term → sawtooth flattens, decode ceiling stops depending on time of day. MATERIALIZER_MAX_SNAPSHOT_SPAN=32 (dimo-node#123 stopgap) can return to 16.
  • signals_latest churn drops from ~288 rewrite cycles/day to 1 → the delete-churn fragmentation problem (din#13's rewrite_data_files motivation) essentially disappears at the source; din maintenance cycles get cheaper.

Out of scope / later

  • events_latest: same pattern, no KV equivalent yet — untouched here; measure its share and consider the same treatment (or a KV) separately.
  • Dropping the table entirely: viable phase 2 once the KV has solo-serving history; the daily job and lake_rollup.go fallback removal make the eventual drop trivial. Not now — the rollup-as-recovery-snapshot keeps KV rebuild O(1 day) instead of O(history).
  • LAKE_REBUILD_ROLLUP_ON_BOOT / RecomputeRollup stay as the deep-DR path (unchanged cost class).

Migration order (each step independently shippable)

  1. Add watermark column/row + daily refresh step (flag-gated), per-pass fold still on: differential-compare daily output vs incremental for a few days.
  2. Make coverage reconcile + bootstrap watermark-aware.
  3. Flip the flag: fold off, daily on. Watch dq_lake_latest_kv_* coverage metrics and span durations.
  4. Remove the per-pass fold code + MATERIALIZER_MAX_SNAPSHOT_SPAN back to default; retire lake_rollup.go fallback when confident.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions