Skip to content

feat: DuckLake parse-on-read — materializer, query backend, consumer floor (dq side of din#2)#7

Merged
zer0stars merged 86 commits into
mainfrom
feat/duckdb-parse-on-read
Jun 22, 2026
Merged

feat: DuckLake parse-on-read — materializer, query backend, consumer floor (dq side of din#2)#7
zer0stars merged 86 commits into
mainfrom
feat/duckdb-parse-on-read

Conversation

@zer0stars

Copy link
Copy Markdown
Member

Summary

The dq counterpart to din#2. din writes the raw layer into a DuckLake raw_events table; this PR makes dq decode it and serve queries from the same lakehouse — attaching the same catalog (PostgreSQL in prod, a local file for dev/tests) that din's writer/maintainer manage.

Default-safe — this merges dark. QUERY_BACKEND defaults to clickhouse and the materializer is off (MATERIALIZER_ENABLED=false) unless explicitly enabled, so merging changes zero runtime behavior. The DuckLake path is opt-in per environment, and the existing bucket/ClickHouse paths stay intact as the fallback until the lake proves out in shadow/prod.

Depends on din#2. The DuckLake runtime path needs din's raw_events table and shared catalog to exist, and pins github.com/duckdb/duckdb-go/v2 to din's exact version (both attach the same catalog — the driver must move in lockstep). Merge + deploy din#2 first; this can merge dark before that.

Query backends (QUERY_BACKEND)

internal/app/backend.go selects the repositories.Backend implementation:

  • clickhouse (default) — unchanged, all queries from ClickHouse.
  • duckdb — DuckDB over hive-partitioned parquet in the bucket (the original parse-on-read approach).
  • shadow — serve from ClickHouse, mirror each query to DuckDB in the background, compare and log divergence (the parity harness for cutover).
  • ducklake — serve from the DuckLake catalog tables (lake.signals / lake.events).

DuckLake materializer (internal/materializer/ducklake.go)

Reads new rows from din's raw layer via ducklake_table_changes('lake','main','raw_events', cursor+1, head) filtered to change_type='insert', decodes via model-garage, writes lake.signals / lake.events, and advances a single snapshot-id cursor in lake.ingest_progressall in one catalog transaction. Exactly-once: concurrent materializers conflict at commit, the loser retries. No S3 LIST, no watermark file, no settle window. If the cursor falls below the catalog's oldest unexpired snapshot it resets to head, increments dq_materializer_cursor_resets_total, and logs loudly (the backstop if dq dies past din's staleness window).

DuckLake query backend (internal/service/duck/lake_latest.go)

duck.NewLakeQueries serves all 10 repositories.Backend methods from lake.signals / lake.events. latest / summary recompute from the base table (arg_max-by-ts, on-the-fly (0,0) location filter).

Consumer progress floor (the dq half of din's guarded retention)

After each materialized batch, dq attaches the catalog's side-DB as schema meta and upserts ('dq', snapshot_id, now()) into meta.din_consumer_progress. din's maintainer reads that floor and never expires a snapshot at or past the slowest live consumer's cursor — so dq can't have raw rows expired out from under it while it's still catching up. This is the documented SQL contract din#2 added; this PR is the producer side.

Deferred (not in this PR — sequenced after parity)

  • Retire the bucket / sharding / duckdb / shadow query code. Kept as the parity reference and fallback until the lake proves out in shadow/prod — deleting now removes the safety net.
  • Shadow run in prod to prove lake-vs-ClickHouse parity before flipping QUERY_BACKEND=ducklake.
  • voids_id is selected from raw_events but unused on the decode path (voiding is read-side; TODO in duck/raw.go).

Testing

  • File-catalog e2e (tests/ducklake_e2e_test.go) + query parity (tests/ducklake_query_test.go) — always run, no infra.
  • TestDuckLakePostgres (tests/ducklake_pg_test.go, gated on PG_CATALOG_DSN) — proves exactly-once across two concurrent materializers against a real Postgres catalog, plus the real consumer floor.
  • Full pipeline e2e (raw bundles → DuckDB answers), DIS GraphQL output parity on a real Ruptela payload, MinIO real-S3 suite, chaos harness, query/materializer perf gates.
  • go build ./..., go test ./internal/... ./tests/, golangci-lint run ./... — all green.

Config

DUCKLAKE_CATALOG_DSN (raw connection string — no postgres: prefix; code builds ducklake:postgres:<dsn> internally), DUCKLAKE_DATA_PATH (must match din's LAKE_DATA_PATH), MATERIALIZER_*, QUERY_BACKEND, and the DUCKDB_* engine knobs.

zer0stars added 30 commits June 10, 2026 00:30
Per-connection bootstrap (memory_limit/threads/spill/object_cache,
httpfs+aws+secret when S3 enabled), explicit per-day read_parquet glob
builders for raw and decoded datasets, shared HashBucket contract for
latest/summary buckets, and pre-baked duckdb extensions in the image.
Decodes raw cloudevents via model-garage (vendor modules wired:
AutoPi/Ruptela/Kaufmann/HashDog + default-module fallback with partial
salvage), ports dis pruning + coordinate merging, writes sorted/zstd/
bloom signal+event parquet, latest/summary buckets (lastSeen virtual
row, (0,0) location exclusion, footer batch-stamp idempotency), and a
crash-safe manifest+watermark commit protocol with crash-injection
test matrix.
ch.Service-compatible Queries over decoded parquet: epoch-math interval
bucketing (CH toStartOfInterval parity), FILTER-clause aggregations,
arg_min/arg_max FIRST/LAST, mode/string_agg string aggs, latest buckets
with lastSeen virtual row and (0,0)-exclusion via *_nonzero columns,
list_has_any tag filters, glob() pre-expansion for missing partitions.
Location polygon/circle filters TODO (spatial extension).
…ring

Backend interface split (signal/latest/summary/events vs CH-only
segments); duck.Queries satisfies it with compile-time assertions.
Shadow mode serves ClickHouse, async-compares DuckDB results (epsilon
floats, bounded concurrency, panic-safe) into dq_shadow_mismatch_total.
MATERIALIZER_ENABLED starts the post-fact decode loop with vendor
modules from new chain settings.
Replaces the ClickHouse cloud_event index + SeekToRow path: list/latest/
available-types queries scan raw/type=/date= partitions directly with
data inline, dedup on header key, week-chunked newest-first walk for
latest, glob() pre-expansion for missing partitions. Tombstone voiding
parity is a documented TODO. eventrepo facade (grpc.SearchOptions ->
RawFilter) is the follow-up wiring step.
Reader used bucket=%d while the materializer writes bucket=%03d — every
latest/summary lookup for buckets under 100 silently returned empty.
Regression test pins the padded format.
string_agg(DISTINCT) without ORDER BY is insertion-ordered, so shadow
comparisons against ClickHouse fired false mismatches on every UNIQUE
aggregation with 2+ values.
Upstream model-garage init() registers tesla.Module for TeslaSource;
the local registry mirror omitted it, so Tesla raw status fell through
to the default module — decoded tables silently missing an entire
oracle while live decodestream decoded it fine.
Within-batch event dedup on header Key() keeps at-least-once ingest
duplicates out of decoded rows and summary counts; dimo.status signal
decoding gated to vehicle-NFT subjects (dis/din parity — decoded tables
previously gained rows the ClickHouse path never had).
Writes raw cloudevent bundles byte-identical to din's sink output
(hive layout, ingest- naming, sorted/zstd/bloom), runs the materializer
post-fact, then asserts DuckDB results: hand-computed aggregation with
duplicate-event collapse, latest + lastSeen, available signals, the
vehicle gate (raw queryable, decoded excluded), verbatim raw payloads,
watermark publication, and incremental freshness on a follow-up poll.
The verbatim fullInputJSON device payload from model-garage's ruptela
conversion tests (the same payload dis's integration suite posts over
mTLS) flows raw-parquet -> materializer -> DuckDB, then a real gqlgen
execution of signalsLatest must return dis's hand-verified values
bit-for-bit: speed 31.24609375, powertrainType COMBUSTION, oil LOW,
tire pressure 262.00088, fuel 19.200000000000003, GPS 52.2721466 /
-0.9014316 hdop 0.6, plus lastSeen and per-signal timestamps.
One-vehicle 7-day hourly aggregation over 200 vehicles x 30 days x 3
signals/minute: 8ms cold, 6ms warm, 15ms full month (local NVMe; S3
adds GET latency). Gated behind -perf flag; plan targets were <1s warm
/ <3s cold — beaten by two orders of magnitude on engine cost.
- shadow: compare []*vss.Signal as canonically sorted copies; neither
  backend orders GROUP BY output, so positional diff fired
  dq_shadow_mismatch_total on nearly every latest-signals call
- duck: ORDER BY name on latest queries for deterministic results
- duck: wrap LatestCloudEvent miss in ErrNotFound so the eventrepo
  facade can tell absence from I/O failure
- materializer: synthesized location signal inherits header from the
  active triple, not signals[0], when a batch mixes sources
- raw_types_test: every cloudevent type constant through duck.Raw
  list/latest/available plus source/producer/id filters, tombstone
  voids_id, data_base64; new-constant drift guard
- segments_graphql_test: all six detection mechanisms + dailyActivity
  via real gqlgen execution over a fake SegmentsBackend with real
  DuckDB enrichment; validation, config plumb-through, limit/after,
  timezone handling
- duck: SET TimeZone='UTC' per connection — naive make_timestamp
  window literals resolved in host zone, shifting every sub-day
  window query on non-UTC hosts (found by the new tests)
- duck: scan voids_id now that cloudevent writes it
startMaterializer now accepts path-like parquet buckets and runs over
the local filesystem instead of rejecting them; duck already handled
local globs. Publishes are atomic (temp + fsync + rename) so din's
compactor never reads a torn watermark.

- internal/fsstore: production materializer.ObjectStore over a root
  dir; missing prefixes list empty (poll-before-first-write), sorted
  List, temp files hidden, ErrNotFound wrapped
- tests: pipeline/parity/segments suites now run on the production
  store; test-only fsStore deleted
- README: storage backend table + single-node quickstart
-perf gated. Raw din-layout bundles through one materializer pass;
reports events/s and signals/s, gates at >1k events/s.
…g metrics

First mass-scale roadmap item: the single-replica materializer wall.

- materializer: per-batch fan-out (Workers, default GOMAXPROCS) across
  raw fetch+decode, model-garage conversion, data-object writes, and
  bucket read-merge-writes; output stays deterministic (sequential
  dedup, writers re-sort). 29k -> 39k events/s on NVMe; bigger win on
  S3 where the fan-out hides GET/PUT latency
- decoded-layer compaction: merges closed date partitions into one
  file each (CompactInterval/CompactMinFiles). Aggregations don't
  dedup decoded rows, so the protocol never lets sources and target
  be visible together: stage outside query globs -> manifest ->
  delete sources -> publish. Crash matrix proves recovery converges
  from every interruption point; staged-object existence is the
  recovery discriminator (target-exists would lose late rows on
  recompaction crashes)
- metrics: dq_materializer_lag_seconds (decode-lag SLO), batches/
  rows/errors/compactions counters
- fsstore + s3ObjectStore gain DeleteObject (CompactStore)
- e2e: query results proven identical across compaction
- materializer: BatchMaxBytes (1GiB) cuts batches by aggregate raw size
  — the whole batch decodes in memory, file count alone let 64x128MB
  batches OOM; decoded[i] released as the dedup pass consumes it
- compaction: 2GiB partition size cap (skip+warn until chunked merge);
  recompaction threshold drops to 2 once compacted.parquet exists so
  late batches can't sit uncompacted below CompactMinFiles forever;
  S3 overwrite-visibility note on compactedName
- lag gauge reset unconditionally after a drained type pass (was
  guarded on len(batches)>0 — stale forever once caught up)
- latest+summary bucket updates run concurrently (disjoint key sets)
- s3 DeleteObject treats NoSuchKey as done (MinIO; recovery idempotence)
docker/docker alerts stay open: the patched v29.3.1 is not published
to the docker/docker Go module path, and the dependency is test-only
surface (testcontainers client for ClickHouse integration tests).
- fsstore: fsync parent directory after rename (watermark.json must
  never silently vanish on crash)
- duck: LatestCloudEvent scans the floor day on zero-width windows
  (loop boundary was exclusive)
- materializer settings wired: MATERIALIZER_WORKERS/BATCH_FILES/
  BATCH_BYTES, COMPACT_INTERVAL_SECONDS, COMPACT_MIN_FILES
- internal/audit: store-agnostic invariant checker (no duplicate
  decoded rows, decoded ⊆ raw, watermark cursor shape, no leftover
  compaction manifests) — built for chaos tests now, runnable against
  the production bucket before cutover
- tests/chaos: SIGKILLs the materializer process 15x at random points
  under live seeding, drains, then proves every seeded event decoded
  exactly once with zero violations (DQ_CHAOS=1 gated)
- tests/minio: full pipeline (s3 materializer store, DuckDB httpfs
  CREATE SECRET + s3:// globs, compaction visibility) against a real
  S3 API via local MinIO (skips when minio absent)
Scale-out for the two single-writer components, plus the deploy
artifacts that enforce single-writer until then.

- materializer sharding (MATERIALIZER_SHARD_INDEX/COUNT): shards own
  disjoint raw partitions by partition hash, write per-shard watermark
  files (_state/watermark-pNNNofMMM.json) and per-shard latest/summary
  namespaces (latest/shard=N/bucket=M) — no two shards ever write the
  same object. Decoded compaction shards by the same hash.
- duck: latest/summary reads glob both the single-replica and sharded
  namespaces; max/arg_max/sum aggregations merge shard files natively,
  so mixed layouts and migrations read correctly. Proven end-to-end:
  two shards, global latest correct, summary counts exactly-once,
  audit clean (sharding_test.go)
- settle window (90s default): the cursor never advances over a raw
  key younger than the slowest plausible sink flush — closes a window
  where a slow PUT could land below the cursor and never decode
- audit: merges sharded watermark files; flags split-brain (two
  cursors claiming one partition)
- charts/dq: standard chart with strategy=Recreate (single-writer
  guard during deploys) and PrometheusRule alerts for decode lag,
  stalled watermark, and shadow mismatches
Foundation + materializer write path for the DuckLake migration; the
bucket/query paths are untouched, so this is additive behind config.

- duck: DUCKLAKE_ENABLED attaches a DuckLake catalog as schema 'lake'
  per connection (postgres extension when the DSN is Postgres, else a
  file catalog for tests/single-node); reuses extension_directory +
  S3 secret handling
- materializer DuckLakeWriter: one catalog txn inserts the decoded
  batch and advances a per-partition cursor (lake.ingest_progress) —
  the transaction is the manifest+watermark, exactly-once by
  construction. Concurrent same-partition races abort at commit (no
  double-insert), proven in a phase-0 spike. Reuses decodeBatch +
  pendingBatches (settle window + ownership) verbatim
- projects the catalog cursor to decoded/v1/_state/watermark.json so
  din's raw compactor needs no catalog access
- e2e (file catalog): rows land in lake.signals, cursor advances,
  watermark projected, re-run is exactly-once

Remaining: query Backend over lake.signals/events (phase 3), wiring +
shadow parity (phase 4), Postgres-gated concurrency test, deploy +
bucket-path retirement (phase 5).
din's DuckLake PR standardized on github.com/duckdb/duckdb-go/v2; dq and
din attach the same catalog, so they must share a driver/DuckDB version.
Identical NewConnector(dsn, connInitFn) signature made it a drop-in swap
across duck.go, the installext prebake tool (now also bakes ducklake),
and the tools pin. dq is now a CGO build like din. Full suite + DuckLake
e2e + chaos green on the new driver.
Converges the dq decoded materializer onto din's DuckLake raw layer
(PR #2). Replaces the S3-hive read path with an incremental snapshot
diff over the shared catalog's lake.raw_events:

- DuckLakeMaterializer: per pass, read ducklake_table_changes(...,
  cursor+1, head) inserts, decode (model-garage), write lake.signals/
  events, advance a single snapshot-id cursor in lake.ingest_progress —
  all in one transaction. Exactly-once by construction; concurrent
  decoders conflict at commit and retry from the new cursor.
- No S3 LIST, no watermark.json, no settle window: a row is visible
  only after din committed its snapshot, so there is no pre-PUT race.
  Coordination with din is LAKE_SNAPSHOT_RETENTION (cursor must stay in
  the window), not a watermark file.
- decodeEvents: per-event type routing (status->signals, events->
  events) over a reconstructed delta, reusing convertSignals/Events.
- e2e (file catalog): seed raw_events as din's sink does -> decode ->
  lake.signals, exactly-once on re-run, incremental on new snapshot.

Retires the prior bucket-write DuckLakeWriter. Bucket query/retire and
QUERY_BACKEND=ducklake wiring follow.
zer0stars added 26 commits June 18, 2026 13:13
whereClauseQ emitted time >= ? for the After filter. ClickHouse
eventrepo uses strict time > ?, so events at exactly the After
timestamp were incorrectly included in lake results. Change to >.

The hive Raw path shares whereClauseQ and is being retired; CH
parity takes precedence.

Test added: TestAfterBoundaryIsStrict — asserts an event at exactly
the After timestamp is excluded while an event 1ms later is included.
filterFromAdvanced now mirrors eventrepo.AdvancedSearchOptionsToQueryMod
field-for-field. RawFilter and whereClauseQ are extended to match:

- Subject: multi-value IN (Subjects []string) instead of In[0] only.
- String fields (Type/Source/Producer/ID/DataVersion/Extras): NotIn
  (NOT IN) support alongside the existing In.
- Tags: ContainsAll (list_has_all), NotContainsAny (NOT list_has_any),
  NotContainsAll (NOT list_has_all) — all over extras.tags as VARCHAR[].
- Extras: IN / NOT IN on the raw extras text column.
- Or clauses: return errOrClauseUnsupported (not silently over-return).

Tests added: TestMultiSubjectIN, TestStringNotIn, TestTagsContainsAll,
TestTagsNotContainsAny, TestExtrasFilter, TestOrClauseReturnsError.
CreateGRPCServer now returns (*grpc.Server, func(), error). The cleanup
func releases the DuckDB connection and catalog attach after the server
stops serving. main.go defers it after runnerGroup.Wait() so it runs
only after GracefulStop completes.

Previously cleanup was dropped on the success path, leaking the
duck.Service for the process lifetime.
Replace the dead duckSvc == nil guard in the shadow arm of
newEventService with settings.DuckLakeCatalogDSN == "". duckSvc is
never nil for any non-clickhouse backend (newQueryBackend always
allocates a duck.Service), so the old guard never fired. Without a
catalog DSN the duck.Service has no lake catalog attached, and
constructing a LakeEventService over it would fail at query time on
lake.raw_events. The corrected guard falls back to a CH-only EventService
when no DSN is configured, matching the same condition used in
newQueryBackend to skip lake segment attachment.
Add white-box tests (package eventrepo) that use testutil.ToFloat64 to
read dq_fetch_shadow_mismatch_total by method label:
- TestShadowMismatchCounter_IncrementOnMismatch: counter goes up by 1
  when primary and secondary return different results.
- TestShadowMismatchCounter_NoIncrementOnMatch: counter stays flat when
  results are identical.

Both tests call shadow.Wait() before reading the counter to ensure the
shadow goroutine has finished. Deltas are taken against the per-label
value before the call to avoid cross-test pollution from the shared
global registry. The existing TestShadowEventService_MismatchIncrementsCounter
is updated to reference the new companion test.
Add tests/fetch_lake_parity_test.go with a top-of-file parity coverage
map (each CH eventrepo behaviour → where it is asserted in the suite)
plus four new tests for the gaps that existing files did not cover:

- TestBeforeBoundaryIsStrict: Before is exclusive upper bound (time < ?)
- TestSourceINFilter: Source IN narrows results
- TestProducerINFilter: Producer IN narrows results
- TestTagsNotContainsAll: NOT list_has_all excludes events with all tags

All eight parity bullets (ordering, strict After/Before, voiding, filter
narrowing, all four tag operators, type summaries, dedup) are now
demonstrably covered and cross-referenced.

go build ./..., go test ./internal/... ./tests/ ./pkg/... -count=1, and
golangci-lint run ./... all green. Only pre-existing pkg/eventrepo CH-
container failures remain (TestGetLatestIndexKey, TestGetEventWithAllHeaderFields,
TestGetData, TestListIndexesAdvanced — unchanged from branch base).
Add TimestampAsc bool to RawFilter, read opts.GetTimestampAsc().GetValue()
in filterFromAdvanced (matching CH's nil/false→DESC, true→ASC decision),
and thread the flag into queryLakeRaw's ORDER BY clause. GetLatestIndexAdvanced
forces TimestampAsc=false before querying, mirroring CH's explicit override so
"get latest" always returns the newest event regardless of the caller's flag.
Two new tests assert ASC/DESC list order and that GetLatest remains correct
with TimestampAsc=true.
Append a new section to the segments+fetch spec listing the 5 accepted
divergences between QUERY_BACKEND=ducklake and ClickHouse that an operator
must review before flipping the flag in production: Or-clause hard errors,
empty-result OK vs NotFound, blob payload gap (flagged highest priority),
>30-day ignition segment suppression, and app.New cleanup tidy-up.
Pre-cutover correctness + scale fixes on the DuckLake decoded layer, from
the senior-review backlog (PLAN-ch-deprecation-issues.md). Also commits the
already-validated Batch-1 fetch fixes (gRPC blob resolution, maxLakeQueryLimit
clamp, ignition NULL filter).

CHD-1 — partition + sort lake.signals / lake.events. Add a subject_bucket
column (HashBucket(subject), stamped at decode time) and ALTER the tables to
PARTITIONED BY (subject_bucket, day(timestamp)) + SORTED BY (subject,
timestamp), mirroring din's raw_events. Per-vehicle reads add an inlined
subject_bucket predicate (aggregations + latest/summary) so DuckLake prunes to
one partition instead of scanning the fleet. Tables are created with the layout
from the first write (decoded layer not yet in prod), so no re-materialization.

CHD-2 — dedup every lake.signals read. At-least-once ingest can store the same
(subject,name,timestamp) more than once with a different cloud_event_id;
aggregations/latest/summary read the bare table and over-counted
avg/count/sum/median. Centralize the segments-path QUALIFY ROW_NUMBER dedup
(canonical = lowest cloud_event_id) so all callers inherit it. Collapsing also
makes arg_max(value, timestamp) for latest unambiguous (tie-break).

CHD-7 — idempotent decoded writes. The commit INSERT now anti-joins on the
cloudevent identity (cloud_event_id, name, timestamp), pruned by subject_bucket,
so a cloudevent redelivered in a later snapshot is not decoded and stored twice.

CHD-8 — resolve blob payloads in the materializer. Payloads din externalizes to
S3 (data_index_key under BlobKeyPrefix, no inline data) were discarded at
decode, losing every >1 MiB payload. The materializer now downloads the blob
before decoding, mirroring the fetch path's resolvePayload.

Tests: local DuckLake file-catalog regression tests for each fix (blob decode,
read dedup over-count, subject_bucket population, cross-snapshot idempotency).
Build + lint clean; duck/materializer/tests suites green.
… sizing

Observability and operability fixes for the DuckLake target mode, from the
senior-review backlog (PLAN-ch-deprecation-issues.md).

CHD-9 — single-writer materializer + idempotent bootstrap. The cursor row is
now seeded once and every advance is a compare-and-swap UPDATE, so two
concurrent first-writers can no longer both do a guard-less INSERT and
double-decode the same range. (DuckLake has no PRIMARY KEY/UNIQUE, confirmed by
probe, so the seed + CAS is the enforcement.) A chart render guard refuses
MATERIALIZER_ENABLED=true with replicaCount>1 or autoscaling on — run decode as
a dedicated 1-replica release, scale the query fleet separately.

CHD-12 — instrument ducklake mode. The lake path emitted only cursor resets, so
the DecodeLag/Stalled alerts (which watch dq_materializer_lag_seconds /
batches_total) were dead in the target mode. It now emits decode lag, rows,
batches, and cursor/head snapshot-id gauges, and records the skipped snapshot
span on a cursor reset. New critical PrometheusRule on cursor resets (permanent
skipped-data loss had no alert).

CHD-13 — real readiness probe. /ready runs SELECT 1 FROM lake.signals LIMIT 0
(catalog reachable + extensions loaded + table present); the chart readiness
probe now targets it on the serving port instead of a static mon-http 200, so a
cold/catalog-down pod is pulled from the Service. Liveness stays static.

CHD-20 — DuckDB runtime config + pod sizing. Set DUCKDB_MEMORY_LIMIT (~75% of
pod, was unset → 80% of node RAM → OOM before spill), DUCKDB_THREADS (= CPU
limit, was 64 throttled to 1), and DUCKDB_TEMP_DIRECTORY backed by a sized
emptyDir spill volume. Prod query fleet sized for the ducklake target (4 CPU /
8 Gi) with HPA on CPU+memory.

Tests: observeLakeLag gauge, ReadyHandler 503-on-failure, bootstrap seed-once.
Build + lint clean; app/materializer/tests suites green; helm lint + template
verified for dev and prod.
CHD-21 — Postgres catalog connection resilience. The DuckLake catalog is
reached over a Postgres attach inside each DuckDB connection, but the pool set
no SetConnMaxLifetime/SetConnMaxIdleTime, so a connection whose attach was
poisoned by a PG blip stayed broken until pod restart. Add finite, configurable
connection lifetime + idle-time (defaults 30m / 5m) so poisoned connections are
recycled and re-bootstrap the attach. (HA Postgres itself — Patroni/RDS
Multi-AZ + PgBouncer — is infrastructure, tracked separately.)

CHD-22 — raise the gRPC message size limit. Cloudevent blob payloads run
4–50 MiB; the gRPC 4 MiB default silently truncated them once the fetch path
started serving blobs. Set MaxSendMsgSize/MaxRecvMsgSize to 50 MiB. (Empty-list
→ NotFound parity and an authz interceptor need a client-contract / threat-model
decision and are left as follow-ons.)

Test: connection-recycling defaults are non-zero. Build + lint clean.
Both shadow validators (query and fetch) folded comparisons dropped under
backpressure into the error counter, so a clean dq_shadow_mismatch_total could
mean "the validator was saturated and didn't look", not "the lake matched".
That makes the go/no-go cutover gate untrustworthy.

Add dq_shadow_dropped_total / dq_fetch_shadow_dropped_total and increment those
(not the error counters) when the concurrency semaphore is saturated. New
DQShadowDropped warning so any dropped comparison surfaces — a clean gate now
requires zero drops as well as zero mismatches.

Test: a saturated shadow call increments the dropped counter and leaves the
error counter untouched. Build + lint clean.

(The reconciliation harness — bulk CH↔lake count/checksum sampling as an
explicit pre-flip gate — is a larger piece left as a follow-on.)
A failed bootstrap statement was wrapped verbatim into the error, so CREATE
SECRET (inlined S3 KEY_ID/SECRET) and ATTACH (Postgres DSN with password) leaked
credentials into error messages and logs. Redact SECRET/ATTACH statements down
to their kind before logging, mirroring din's lake.redact.

Test: S3 keys and the Postgres password are masked; plain statements pass
through. Build + lint clean.
Map empty List results to codes.NotFound, matching ClickHouse — the lake backend
returns an empty slice with no error, which broke clients expecting NotFound.

Reject client-supplied index keys containing path-traversal in
ListCloudEventsFromIndex (defense-in-depth for the legacy CH/JSON fetch path,
which dereferences the key; the lake path re-queries by (subject,id) and ignores
it). Combined with the earlier MaxSend/RecvMsgSize bump this closes the gRPC
contract items. Per-caller authz stays a network-policy/ClusterIP control
(the FetchService is internal); a JWT interceptor is left opt-in to avoid
breaking existing unauthenticated internal callers.

Tests: empty list → NotFound; traversal key → InvalidArgument.
…CHD-34, CHD-33)

CHD-34 — bound an open-ended lake fetch with a default 400-day lookback (CH
eventrepo capped lookbacks; the lake path had none, so a filter without a time
bound scanned all of raw_events). A point lookup by id stays unbounded so old
events remain fetchable.

CHD-33 — lock DuckDB aggregate semantics on the lake backend against ClickHouse
golden vectors (median/empty-aggregate); the median↔median, mode↔topK,
string_agg-DISTINCT↔groupUniqArray mapping is documented in aggregations.go and
the bucket path exercises the same expressions in aggregations_test.go.

Tests: list excludes a 500-day event while id-lookup still finds it; median
golden vector; absent-signal aggregate is empty. (The materialized voiding
column is a din-side write-time change — tracked there; the current NOT EXISTS
self-join is correct.)
…riant (CHD-30/32/35)

CHD-30 — extract the tombstone-voiding NOT EXISTS predicate into voidingClause()
so the search (queryLakeRaw) and aggregate (type summaries) paths share one
definition instead of two copies that can drift.

CHD-32 — pin the decoded-table column contract with a test. The schema is
otherwise implicit in the first materializer write (SignalRow/EventRow parquet
template); a model-garage struct change would silently reshape the table and
break appends. The test fails on drift; the column lists are the explicit,
reviewed schema with a migration note.

CHD-35 — document the hard UTC invariant at the bootstrap that enforces it:
every pooled connection pins TimeZone='UTC' because raw_events."time" and the
decoded timestamps are TIMESTAMP WITH TIME ZONE and queries inline naive
make_timestamp literals.

Build + lint clean; duck + tests suites green.
getLatest/Summary/availableSignals were a full-history GROUP BY per request (the
migration dropped CH's precomputed latest tail). Add lake.signals_latest, a
per-(subject,name) latest+summary rollup the materializer refreshes per batch
(refreshRollup recomputes the touched subjects from the deduped base table in
the commit transaction, so it advances atomically). The query layer serves
GetAllLatestSignals / GetSignalSummaries / GetAvailableSignals from it when
there is no source filter — O(distinct-names) instead of O(history). A source
filter falls back to the full scan (still subject-pruned, CHD-1).

The rollup is computed by the same max/arg_max + (0,0)-loc FILTER aggregation as
getAllLatestSignalsLake, so it is a faithful materialized view (parity by
construction). Partitioned by subject_bucket like the base table.

Test: materialize three readings → latest/summary/available correct from the
rollup; a fourth newer reading updates it incrementally. Full duck/materializer/
tests suites green; lint clean.
…D-14)

The query path logged jwtSubject (the developer) but never the vehicle subject,
so a "vehicle X is wrong" report was near un-root-causable. Add the asset DID
(the vehicle being queried) to the request logger so every query-path log line
is keyed by subject.

Expose the decode backlog as a single number via a recording rule
(dq:pipeline_snapshot_backlog = head - cursor snapshot id, using the gauges
added in CHD-12); pair with din's head-age metric for end-to-end lag.

Full OTLP distributed tracing (span export across GraphQL/gRPC→DuckDB and
din→dq trace-context propagation) is the remaining infra piece; the
subject-keyed logs + correlation deliver the root-cause-ability the finding
called out.
…5, CHD-36)

Add internal/reconcile: a bulk pre-flip gate that compares per-signal summaries
(count, first/last seen) between the ClickHouse primary and the DuckLake
secondary across a sample of vehicles, returning every (subject,name)
disagreement. The migration previously had only organic shadow coverage; this
is the explicit, exhaustive check to require green before flipping
QUERY_BACKEND=ducklake.

Add a `make test-gated` target so CI/operators run the gated suites
(PG-concurrency, chaos, perf, MinIO) from one command — each skips cleanly when
its prerequisite is absent. docs/cutover-gate.md is the full go/no-go checklist
(reconcile clean + shadow clean incl. zero drops + gate suites + live
observability).

Tests: count mismatch and missing-name are flagged; identical summaries are
clean. (A GitHub Actions workflow to run this stack — CGO/duckdb + postgres +
minio services — is the CI-infra piece; the Makefile target is its entrypoint.)
…cy (CHD-38/37)

DuckLake snapshot expiry bounds history age, not data size, so lake.signals /
lake.events grow unbounded. Add an optional row-level TTL: LAKE_DECODED_RETENTION
(Go duration) drives PruneDecoded, which deletes decoded rows past the window
hourly from the materializer loop. Default off — deleting customer history is a
product decision. The latest rollup is never pruned (current state); din's
maintenance reclaims the files.

docs/cutover-gate.md now documents the retention levers, the ~2× dual-run
storage budget (don't shorten the bake to save storage), and notes that
per-signal privilege is enforced by the GraphQL auth directives. The NATS
repartitioning runbook + skew metric and MsgID sub-second precision (CHD-37) are
din-side and tracked on the din branch.

Test: a 30-day-old decoded row is pruned with a 7-day window; the recent row
stays. Build + lint clean.
SR-3: batch GetDailyActivity. It fired one GetAggregatedSignals + one
GetEventCounts per calendar day — up to 64 serialized round-trips for a 32-day
window, blowing the request timeout. Replace with a single
GetAggregatedSignalsForRanges + GetEventCountsForRanges pair (run concurrently),
scattered by day index — the same batched path GetSegments already uses.

SR-4: batch ListCloudEventsFromIndexes by subject (one raw_events query per
subject, not per index) and cap the gRPC ListCloudEventsFromIndex key count at
1000 so one call can't fan out into an unbounded number of fetches.

SR-5: serve GetLatestSignals from the lake.signals_latest rollup for the
no-source-filter, non-location case (O(distinct-names)), matching the existing
GetAllLatestSignals routing. Location names and source filters still take the
full deduped scan because the rollup lacks a separate nonzero-location
timestamp.

SR-11: dedup raw-event fetches in SQL on the cloudevent key
(subject, second-precision time, type, source, id) instead of fetching limit*2
and deduping in a Go map — the old form returned a short page when over half the
window was duplicates.

SR-12: default MAX_REQUEST_DURATION to 5m when unset instead of failing app
startup (and the readiness probe), which crash-looped the pod.

SR-16: guard GetLatestIndexAdvanced against an empty-without-error result so a
lake backend can't trigger an index-out-of-range panic.

SR-10: document why GetCloudEventTypeSummariesAdvanced must stay a full scan —
all-time first_seen/last_seen, matching ClickHouse's unbounded summary.
SR-6: prune the per-batch signals_latest rollup refresh to the batch's hash
buckets. The DELETE and the base recompute now carry "subject_bucket IN (...)",
and both signals_latest and lake.signals are PARTITIONED BY subject_bucket, so
the recompute reads only the touched buckets' files instead of the whole table.

SR-7: count latest/summary/available reads by serving path
(dq_lake_latest_served_total{path=rollup|scan}) so the rollup-coverage gap —
source-filtered and location queries falling back to the full scan — is visible
instead of silent.

SR-14: add failure metrics + alerts that were missing — prune errors,
compaction errors, fetch-shadow mismatch/error, and a rollup-refresh-duration
gauge with a slow-refresh alert (early warning of the SR-1 O(history) cost). The
din maintenance PrometheusRule landed separately.

SR-17: prove partition pruning, not just that the column is stamped — assert the
EXPLAIN plan pushes the subject_bucket predicate to the DuckLake scan.

SR-1 (full incremental rollup) is intentionally deferred: the base insert dedups
on cloud_event_id while reads dedup on (subject,name,timestamp), so an
incremental count must replicate the read-dedup exactly or it corrupts a
customer-facing aggregate. The full recompute is correct for the default
(no-prune) config; SR-6 bounds its cost and the new gauge makes it observable.
SR-9: stop building a second query backend for the gRPC server. main() runs
both app.New and CreateGRPCServer in one process, each of which opened its own
duck.Service (DuckDB pool + Postgres catalog attach) and S3 client. App now
exposes the eventService + buckets it already built, and CreateGRPCServer reuses
them — halving per-process DuckDB/Postgres/S3 footprint. The App owns the
backend lifecycle, so the gRPC path no longer returns a cleanup.

SR-13: plumb a logger into NewShadowEventService — fetch-shadow mismatches were
logged to a Nop logger and silently discarded, leaving only counters. Raise the
shadow concurrency default 4 -> 16 (both fetch and signal shadows): at 4 the
validator drops most comparisons under load, so a clean mismatch counter meant
"didn't look", undermining the cutover gate.

SR-18: collapse the two hand-synced HashBucket implementations — the
materializer's write-side bucketing now delegates to duck.HashBucket, so write
and read can never drift on the algorithm or the 256 bucket count.

SR-21: PanicRecoveryMiddleware logs through the structured request logger
instead of fmt.Fprintf to os.Stderr, so panics appear in the JSON log stream.

SR-22: guard the materializer object store's GetObject with a generous (2 GiB)
ContentLength ceiling so a corrupt/runaway object fails fast instead of being
read whole into memory.

SR-15 is covered without new dq code: the materializer single-writer render
guard already exists (CHD-9), and the din side gained the
ConsumerStaleness < SnapshotKeep boot check.
#3 events: dedup the decoded-event read path. lakeEventsDeduped collapses
at-rest duplicates (QUALIFY ROW_NUMBER over subject,timestamp,name,source —
ClickHouse's event ReplacingMergeTree key) so two cloud_event_ids that decode
to the same logical event count once, not twice. The materializer's INSERT
anti-join keeps them (its key includes cloud_event_id) and the signal read path
already deduped; the event read path did not, over-counting events vs CH.

#4 fetch: drop the 400-day lookback floor for subject-scoped fetches. It is a
DoS guard for subject-less, id-less scans only — ClickHouse imposes no floor
when given no `after`, and a subject prunes to one vehicle's files via the
(subject, time) sort, so latestCloudEvent / cloudEvents now reach arbitrarily
old events. A dormant vehicle whose newest event predates the window no longer
wrongly looks empty. The guard stays for genuinely unbounded searches.

#8 materializer: hard-error when MATERIALIZER_SHARD_COUNT>1 on the DuckLake
path. Sharding is not honored there (the global ingest_progress cursor allows
exactly one logical processor); refuse the config rather than silently run every
replica over the full stream and roll back the CAS losers.

#10 aggregations: implement inPolygon / inCircle location filters in pure SQL
(haversine great-circle distance; an even-odd ray cast unrolled over the
request's vertices) with no spatial-extension dependency, replacing the "not
supported on duckdb backend" rejection. Mirrors ClickHouse geoDistance /
pointInPolygon.
@zer0stars zer0stars merged commit cb7aafa into main Jun 22, 2026
@zer0stars zer0stars deleted the feat/duckdb-parse-on-read branch June 25, 2026 19:47
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).
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