Skip to content

perf(dq): batch location gap-fills, events rollup, re-decode tool, mem cap (load review)#15

Merged
zer0stars merged 4 commits into
mainfrom
fix/load-review-2026-07-07
Jul 8, 2026
Merged

perf(dq): batch location gap-fills, events rollup, re-decode tool, mem cap (load review)#15
zer0stars merged 4 commits into
mainfrom
fix/load-review-2026-07-07

Conversation

@zer0stars

Copy link
Copy Markdown
Member

Load/efficiency fixes (dq) — findings #1, #5, #7, #8

From the end-to-end din+dq+vt load review (REVIEW-din-dq-vt-load-2026-07-07.md). Two items are partial by design — the exactly-once-sensitive halves are deferred with code TODOs + designs (see bottom).

#8 — N+1 LocationAt gap-fills in segments (MEDIUM-HIGH)

Segment/dailyActivity enrichment issued a separate LocationAt reverse-scan per segment boundary (up to thousands of serial point queries on a sparse-GPS vehicle).

  • duck.Queries.LocationsAt: one ASOF LEFT JOIN resolves the nearest non-origin fix ≤ each boundary timestamp, index-aligned; (0,0)-skip + cloud_event_id tie-break make it exactly equal to the per-point LocationAt. Shared 90d lookback floor. GetSegments/GetDailyActivity collect fix-less boundaries and resolve them in one batched pass.

#7 — materializer pod runs a redundant 2nd DuckDB instance (MEDIUM)

app.New always builds the query backend + the materializer's own DuckDB; each honored DUCKDB_MEMORY_LIMIT=6GiB vs an 8Gi pod. The materializer release doesn't serve reads (ingress.enabled:false), but main.go serves query/probes duckSvc unconditionally, so skipping construction is entangled.

  • Config route: DUCKDB_QUERY_MEMORY_LIMIT caps the idle query instance only when MATERIALIZER_ENABLED (set to 1GiB → 6+1 < 8Gi); decode instance keeps the full budget; query pods ignore it.

#5 — events rollup (+ signals-rollup floor deferred)

  • events_latest rollup (done): GetEventSummaries full-history scanned lake.events per dataSummary (no events equivalent of signals_latest). Added lake.events_latest, a faithful mirror of the signals rollup (dirty-subject, bucket-chunked, off-commit, self-healing), with a first-create migration backfill for dormant subjects; GetEventSummaries serves from it with an existence fallback.

#1 — single materializer (re-decode tool + alert; pagination deferred)

  • Re-decode tool (done): dq -backfill-from <RFC3339> -backfill-to <RFC3339> re-decodes a raw_events time range from the base table (survives change-feed expiry) idempotently, without advancing the cursor — closes the silent-loss half of cursor-expiry (resetCursor skips undecoded spans; previously no recovery tool existed).
  • Alert (done): corrected the DQMaterializerCursorReset alert wording to name the backfill invocation.

Deferred (exactly-once-sensitive — need SIGKILL re-proving, not unit tests)

  • #5b signals-rollup timestamp floor — TODO(load-review #5b) in ducklake.go. A naive floor undercounts cumulative count/moves first_seen; needs a per-(subject,name) counted-through watermark with its own incremental==full-recompute-under-redelivery tests.
  • #1c per-pass byte budget / intra-snapshot pagination — TODO(load-review #1c). A single oversized snapshot can't drop below span=1; bounding it means paging one snapshot's rows and decoupling the atomic insert+cursor-advance the chaos-proven protocol relies on, so it must be re-proven under SIGKILL. Interim: size LAKE_SNAPSHOT_RETENTION + pod memory so one snapshot's working set fits.

Verification

go build/go vet clean; go test ./... (14 pkgs ok, 0 FAIL); golangci-lint run ./... = 0 issues. Correctness-critical tests run on real embedded DuckLake: LocationsAt≡LocationAt, batched-gap-fill O(1); events-rollup incremental (cross-cloud_event_id dup proves read-dedup) + full-rebuild parity + first-create backfill; backfill idempotent + cursor-untouched + recovers-skipped-range; materializer memory cap.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SAgMMjdGVCD7M1yLy7BUPq

zer0stars added 4 commits July 7, 2026 15:38
)

Segment and daily-activity enrichment gap-filled each boundary's location
with a separate LocationAt reverse-scan point query — O(2*segments) serial
queries per request (up to thousands on a sparse-GPS vehicle with the idling
candidate cap), each holding a pooled connection.

Add duck.Queries.LocationsAt: one ASOF LEFT JOIN resolves the nearest
non-origin fix at or before every requested boundary timestamp, index-aligned.
It is exactly equivalent to per-point LocationAt — the right side excludes
(0,0) fixes before deduping (subject,name,timestamp) to the lowest
cloud_event_id, so the as-of match is tie-free and deterministic — proven by
TestLakeQueries_LocationsAt_MatchesLocationAt across the (0,0)-skip,
tie-break, and lookback-floor cases.

Also add a 90d lookback floor to both LocationAt and LocationsAt so a
fix-less vehicle no longer full-reverse-scans its retained partition; the
floor is shared so the two paths stay equivalent.

Wire GetSegments and GetDailyActivity to collect every fix-less boundary and
resolve them in a single batched pass (BatchLocationAtSource), falling back
to the (0,0) sentinel unchanged. TestGetSegments_BatchesLocationGapFill and
TestGetDailyActivity_BatchesLocationGapFill prove O(1) location queries (one
batched call, zero point calls) and correct scatter-by-index.
A materializer release always builds the query DuckDB backend (main.go serves
the query HTTP/gRPC unconditionally) even though it serves no reads: the
overlay disables ingress and the query fleet is a separate release with its
own Service selector, so no query/fetch traffic is routed to it. That idle
instance still honored DUCKDB_MEMORY_LIMIT=6GiB, so decode(6) + query(6) could
reach 12GiB on an 8Gi pod and OOM.

Fully skipping the query backend is entangled (main.go serves the query
HTTP/gRPC and probes duckSvc for readiness unconditionally) and higher-risk,
so take the lower-risk config route: add DUCKDB_QUERY_MEMORY_LIMIT, applied
via queryDuckConfig ONLY when MATERIALIZER_ENABLED, to cap the idle query
instance; the decode instance keeps the full DUCKDB_MEMORY_LIMIT. Set it to
1GiB in values-materializer.yaml so the two limits sum to 7GiB < 8Gi. A query
pod (MATERIALIZER_ENABLED=false) ignores the override entirely.

TestQueryDuckConfig_MaterializerMemoryCap pins all three cases.
…red (#5)

(a) GetEventSummaries full-history-scanned lake.events per dataSummary, with no
rollup — asymmetric with the now-cheap signal side. Add lake.events_latest, a
per-(subject,name) count + first/last-seen rollup maintained by the materializer
exactly like signals_latest: a dirtyEventSubjects set marked post-commit,
FlushEventRollup recomputing only dirty subjects bucket-chunked off the decode
commit, RecomputeEventRollup for the disaster-recovery / first-create backfill,
and an orphan-prune in PruneDecoded. eventRollupSelectSQL mirrors
GetEventSummaries' (subject,timestamp,name,source) dedup + GROUP BY name, so the
rollup is a materialized view by construction. GetEventSummaries serves from it,
falling back to the base scan until the table exists (self-healing, guards a
rollout where a query pod predates the materializer creating the new table).

ensureSchema escalates the FIRST FlushEventRollup to a full rebuild when
events_latest is created over a pre-existing lake.events (the migration case) so
dormant vehicles are backfilled and never read an empty summary; a fresh catalog
skips it (no snapshot churn). LAKE_REBUILD_ROLLUP_ON_BOOT rebuilds both rollups.

Proven by tests/ducklake_event_rollup_test.go: end-to-end incremental via the
real decode path (with a cross-cloud_event_id duplicate to prove read-dedup),
full-rebuild == per-batch parity, and first-create dormant-subject backfill.

(b) The signals rollup recompute timestamp floor is DEFERRED as
TODO(load-review #5b): a naive floor undercounts count/first_seen (full-history
aggregates) and an incremental count fold can't stay exact because the write
anti-join keys on cloud_event_id, so a different-id duplicate is stored and only
the read QUALIFY dedup collapses it — an incremental += would double-count it,
breaking the rollup-exactness invariant the tests assert. The code carries a
precise split-recency/cumulative-column design for a proven follow-up.

The commit/cursor-advance exactly-once path is untouched; both rollups are
materialized views maintained off the commit.
)

(a) When the decode cursor lags past LAKE_SNAPSHOT_RETENTION, maybeRecoverExpired
skips the unretained prefix WITHOUT decoding it and only cursorResetsTotal records
the loss — no tool existed to recover the gap. Add BackfillTimeRange: it reads
raw_events in [from, to) DIRECTLY from the base table (not the expired change feed;
the rows survive din's separate row retention), re-decodes via the existing decode
path, and idempotent-inserts into lake.signals/events WITHOUT touching the
ingest_progress cursor (out-of-band repair). Idempotency uses the same
cloud_event_id anti-join but with the UNCLAMPED [min,max] timestamp window
(minMaxTime, factored out of timeRange) so re-decoding arbitrarily old data still
finds and skips existing rows — the steady-state 30d probe-floor clamp would miss
old duplicates and double-insert. Exposed as `dq -backfill-from <RFC3339>
-backfill-to <RFC3339>` (runs once and exits), which flushes both rollups after.
Proven idempotent + cursor-untouched + skipped-range-recovery by
tests/ducklake_backfill_test.go.

(b) The DQMaterializerCursorReset alert already existed; corrected its stale
"reset to head" wording (the code skips only the unretained prefix) and made it
actionable — it now names the backfill invocation to recover the gap.

(c) The per-pass byte budget is DEFERRED as TODO(load-review #1c): the real OOM is
one oversized snapshot (span can't drop below 1), which only intra-snapshot
row-key-window pagination can bound — and that decouples the atomic
insert+cursor-advance the chaos-proven exactly-once protocol relies on, so it must
be re-proven under SIGKILL, not just unit-tested. The code carries the precise
pagination design (page by (subject,timestamp,cloud_event_id), idempotent
intermediate windows, cursor coupled only to the final window's insert).

The commit/cursor-advance exactly-once path is untouched; readDelta only gained a
shared scan helper (readRawByTime reuses it).
@zer0stars zer0stars merged commit 93f2cf3 into main Jul 8, 2026
6 checks passed
@zer0stars zer0stars deleted the fix/load-review-2026-07-07 branch July 8, 2026 21:24
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