Skip to content

test(dq): pin presigned-blob-link + tombstone-voiding parity#8

Closed
zer0stars wants to merge 1 commit into
mainfrom
test/presign-tombstone-parity
Closed

test(dq): pin presigned-blob-link + tombstone-voiding parity#8
zer0stars wants to merge 1 commit into
mainfrom
test/presign-tombstone-parity

Conversation

@zer0stars

Copy link
Copy Markdown
Member

Verification tests for the functionality of PRs #3 (presigned blob links) and #4 (tombstone voiding), which were superseded by the DuckLake-native reimplementation on main (the BLOB_BUCKET refactor). Those PRs target the removed ClickHouse eventrepo; rebasing them would resurrect clickhouse-go/sqlboiler. These tests lock in that the behavior survives natively so #3/#4 can be closed safely.

Added (internal/service/duck/pr3_pr4_parity_test.go):

  • TestLakeEventService_PresignBlobURL — blob key → presigned S3 GET URL for the configured blob bucket; nil presigner / empty bucket / presigner error all fail loudly. Complements existing TestLakeEventService_BlobIndexKey (blob-key routing into the resolver's HasPrefix(key, BlobKeyPrefix) presign branch).
  • TestLakeEventService_VoidingTombstoneBeforeEvent — a tombstone whose timestamp precedes the event it voids still hides it (anti-join keys on voids_id == id, not time). Fills the reverse-order gap a review of Hide events based on new tombstone rows #4 flagged. Complements existing TestLakeEventService_VoidingExcludes.

Scope (unchanged from #4): voiding hides raw CloudEvents (cloudEvents/latestCloudEvent); decoded signals/events are not voided (materializer keeps _ = voidsID).

go build ./... && go vet && go test ./internal/service/duck/ ./internal/graph/ ./pkg/eventrepo/ all green.

Lock in the two behaviors ported from the legacy ClickHouse eventrepo
(superseded PRs #3, #4), now served natively from the DuckLake layer:

- PresignBlobURL: a blob key becomes a presigned S3 GET URL for the
  configured blob bucket; nil presigner, empty bucket, and presigner
  failure all error loudly rather than panic or silently inline.
  Complements TestLakeEventService_BlobIndexKey (blob-key routing into
  the resolver's HasPrefix(key, BlobKeyPrefix) presign branch).
- Voiding under reverse ingest order: a tombstone whose timestamp
  precedes the event it voids still hides that event (the anti-join keys
  on voids_id == id, not on time). Complements
  TestLakeEventService_VoidingExcludes (tombstone-after).
@zer0stars

Copy link
Copy Markdown
Member Author

Landed directly on main instead. Commit 0aa63b5 carries the parity tests (TestLakeEventService_PresignBlobURL, TestLakeEventService_VoidingTombstoneBeforeEvent); commit 19f0a57 adds the two review fixes from #3dataUrl now serializes null (not "") for inline events, and PresignBlobURL rejects non-BlobKeyPrefix keys — plus the guard test case. Closing; deleting the branch.

@zer0stars zer0stars closed this Jun 25, 2026
@zer0stars zer0stars deleted the test/presign-tombstone-parity branch June 25, 2026 18:34
zer0stars added a commit that referenced this pull request Jul 7, 2026
…nts serving

From a 4-lens adversarial regression review of the load-review PRs:

- #5b count: captureRollupDelta inherited the anti-join's 30d probe clamp, so an
  ancient (>30d) redelivery counted as new and signals_latest.count drifted +1 vs
  RecomputeRollup. The exact-timestamp match is already partition-pruned; dropped the
  redundant BETWEEN so an ancient redelivery finds its existing row (count stays exact).
- #8 location floor: the 90d gap-fill lookback returned (0,0) for a vehicle whose last
  GPS fix was 90d-1y old but still within LAKE_DECODED_RETENTION — a behavior regression
  vs the pre-#8 unbounded LocationAt. Widened past any realistic retention (the base is
  retention-pruned, so it costs nothing) to restore the prior semantics.
- #5a events_latest serving: GetEventSummaries served the rollup as soon as the table
  EXISTED, so during the first-create migration rebuild (or for a brand-new subject
  before the next flush) it returned zero events. Now falls back to the live base scan
  when the rollup is empty for the subject.
- #5a events_latest migration (Q3b): the first-create rebuild used an in-memory flag set
  from pre-DDL existence, so a crash mid-rebuild silently stranded dormant subjects on
  restart. Re-derived from DB state (events non-empty AND rollup empty) each boot, crash-
  safe like the loc_ts marker.

Tests: ancient-redelivery count parity, empty-rollup fallback, crash-mid-migration
restart rebuild; location equivalence updated. Full suite + PG concurrency green.
zer0stars added a commit that referenced this pull request Jul 8, 2026
…m cap (load review) (#15)

* perf(dq): batch segment/daily location gap-fills into one as-of join (#8)

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.

* perf(dq): cap the idle query DuckDB on a materializer pod (#7)

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.

* feat(dq): events_latest rollup for GetEventSummaries; #5b floor deferred (#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.

* feat(dq): re-decode backfill tool + cursor-reset alert; #1c deferred (#1)

(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 added a commit that referenced this pull request Jul 8, 2026
…nts serving

From a 4-lens adversarial regression review of the load-review PRs:

- #5b count: captureRollupDelta inherited the anti-join's 30d probe clamp, so an
  ancient (>30d) redelivery counted as new and signals_latest.count drifted +1 vs
  RecomputeRollup. The exact-timestamp match is already partition-pruned; dropped the
  redundant BETWEEN so an ancient redelivery finds its existing row (count stays exact).
- #8 location floor: the 90d gap-fill lookback returned (0,0) for a vehicle whose last
  GPS fix was 90d-1y old but still within LAKE_DECODED_RETENTION — a behavior regression
  vs the pre-#8 unbounded LocationAt. Widened past any realistic retention (the base is
  retention-pruned, so it costs nothing) to restore the prior semantics.
- #5a events_latest serving: GetEventSummaries served the rollup as soon as the table
  EXISTED, so during the first-create migration rebuild (or for a brand-new subject
  before the next flush) it returned zero events. Now falls back to the live base scan
  when the rollup is empty for the subject.
- #5a events_latest migration (Q3b): the first-create rebuild used an in-memory flag set
  from pre-DDL existence, so a crash mid-rebuild silently stranded dormant subjects on
  restart. Re-derived from DB state (events non-empty AND rollup empty) each boot, crash-
  safe like the loc_ts marker.

Tests: ancient-redelivery count parity, empty-rollup fallback, crash-mid-migration
restart rebuild; location equivalence updated. Full suite + PG concurrency green.
zer0stars added a commit that referenced this pull request Jul 8, 2026
…ven) (#16)

* perf(dq): paginate a single oversized snapshot into byte/row-bounded windows (#1c)

A single fat snapshot (span can't drop below 1) with a large blob fan-out
materialized its whole readDelta slice + resolveBlobs payloads at once and could
OOM the single writer, which then crash-looped on the identical delta. The
(cursor, to] insert delta is now read+decoded+written in row-key-ordered windows
bounded by DUCKDB window byte budget (64 MiB) / row cap (100k): intermediate
windows are written idempotently WITHOUT advancing the cursor, and only the final
window's transaction advances it — so "cursor advanced <=> every window durable"
still holds. A crash mid-span leaves the cursor un-advanced; restart re-reads the
whole span and the cloud_event_id anti-join collapses windows that already landed.

Proven: crash-mid-span exactly-once recovery (embedded), paginated fat snapshot
under two concurrent materializers on a real Postgres catalog (exactly-once, no
dupes), and small-snapshot single-window (common path unchanged).

* perf(dq): maintain signals_latest incrementally at commit time (#5b)

The steady-state rollup recomputed each dirty subject's signals_latest over its
ENTIRE retained history every flush (O(history) x active fleet). It is now folded
INCREMENTALLY inside the decode-commit transaction (O(batch)):

  - recency (timestamp/value_*/loc_*/loc_ts) is recomputed from the base but BOUNDED
    to `timestamp >= the row's prior latest`, so it prunes every older day-partition
    yet stays exact and self-healing (the new latest is in the batch or the prior row
    still qualifies; deduped via ORDER BY timestamp DESC, cloud_event_id ASC).
  - count = prev.count + a NOT-EXISTS delta over DISTINCT (subject,name,timestamp),
    captured BEFORE the base insert so a redelivery, a same-(s,n,ts) collision, and a
    replayed window each contribute 0 (idempotent). first_seen min-folds.

Composes with the #1c windows (each window folds in its own tx) and with concurrent
writers (temp scratch tables are per-connection; the fold rides the atomic commit).
The full-history rollupSelectSQL is retained only for the boot/DR rebuild and the
deferred bulk-backfill catch-up. events_latest still uses the recompute (far lower
volume); mirroring the fold to it is a follow-up.

Proven: differential test (incremental == RecomputeRollup) across redelivery,
same-timestamp collision, out-of-order, multi-window, dormant re-report; crash-replay
idempotency; a 25-round randomized fuzz; and exactness under two concurrent paginated
materializers on a real Postgres catalog.

* test(dq): 10-loop adversarial verification campaign for #1c/#5b exactly-once

* fix(dq): adversarial-review regressions — count clamp, loc floor, events serving

From a 4-lens adversarial regression review of the load-review PRs:

- #5b count: captureRollupDelta inherited the anti-join's 30d probe clamp, so an
  ancient (>30d) redelivery counted as new and signals_latest.count drifted +1 vs
  RecomputeRollup. The exact-timestamp match is already partition-pruned; dropped the
  redundant BETWEEN so an ancient redelivery finds its existing row (count stays exact).
- #8 location floor: the 90d gap-fill lookback returned (0,0) for a vehicle whose last
  GPS fix was 90d-1y old but still within LAKE_DECODED_RETENTION — a behavior regression
  vs the pre-#8 unbounded LocationAt. Widened past any realistic retention (the base is
  retention-pruned, so it costs nothing) to restore the prior semantics.
- #5a events_latest serving: GetEventSummaries served the rollup as soon as the table
  EXISTED, so during the first-create migration rebuild (or for a brand-new subject
  before the next flush) it returned zero events. Now falls back to the live base scan
  when the rollup is empty for the subject.
- #5a events_latest migration (Q3b): the first-create rebuild used an in-memory flag set
  from pre-DDL existence, so a crash mid-rebuild silently stranded dormant subjects on
  restart. Re-derived from DB state (events non-empty AND rollup empty) each boot, crash-
  safe like the loc_ts marker.

Tests: ancient-redelivery count parity, empty-rollup fallback, crash-mid-migration
restart rebuild; location equivalence updated. Full suite + PG concurrency green.
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