Skip to content

Commit 93f2cf3

Browse files
authored
perf(dq): batch location gap-fills, events rollup, re-decode tool, mem 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).
1 parent c7d36fc commit 93f2cf3

21 files changed

Lines changed: 1526 additions & 131 deletions

charts/dq/templates/prometheusrule.yaml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,14 @@ spec:
104104
annotations:
105105
summary: DuckLake materializer skipped an un-decoded snapshot range
106106
description: >-
107-
The decode cursor lagged past LAKE_SNAPSHOT_RETENTION and was reset
108-
to head, permanently skipping un-decoded raw events
109-
({{ "{{ $value }}" }} resets; span in
110-
dq_materializer_last_cursor_reset_gap_snapshots). Backfill the
111-
skipped range, then raise retention or decode throughput.
107+
The decode cursor lagged past LAKE_SNAPSHOT_RETENTION; the unretained
108+
prefix was skipped WITHOUT decoding, so those raw events never reached
109+
lake.signals/events ({{ "{{ $value }}" }} resets; skipped span in
110+
dq_materializer_last_cursor_reset_gap_snapshots). RECOVER the gap with
111+
the backfill tool — run the dq image with
112+
`-backfill-from <RFC3339> -backfill-to <RFC3339>` covering the skipped
113+
window (idempotent; safe while the materializer is up) — then raise
114+
LAKE_SNAPSHOT_RETENTION or decode throughput so it can't recur.
112115
# Decode-position spread as a single number (CHD-14). NOT an exact
113116
# backlog: head is the catalog-global max snapshot id, which includes
114117
# this decoder's own signals/events/rollup snapshots and din's

charts/dq/values-materializer.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,18 @@ env:
4949
# duration; ~1y). Empty lets them grow unbounded.
5050
LAKE_DECODED_RETENTION: 8760h
5151
# Decode + rollup recompute are memory-heavy (one pod). Size memory_limit to
52-
# ~75% of the pod memory limit; threads to the CPU limit.
52+
# ~75% of the pod memory limit; threads to the CPU limit. This is the DECODE
53+
# instance's budget.
5354
DUCKDB_MEMORY_LIMIT: 6GiB
5455
DUCKDB_THREADS: 4
56+
# This release always ALSO builds a query DuckDB instance (main.go serves the
57+
# query HTTP/gRPC unconditionally), but it receives no traffic here: no ingress,
58+
# and the query fleet is a separate release with its own Service. Without this it
59+
# would honor the 6GiB DUCKDB_MEMORY_LIMIT too, so decode(6) + query(6) = 12GiB
60+
# can OOM the 8Gi pod (finding #7). Cap the idle query instance at 1GiB so the two
61+
# limits sum to 7GiB < 8Gi; only a query pod (MATERIALIZER_ENABLED=false) ignores
62+
# this and keeps the full budget.
63+
DUCKDB_QUERY_MEMORY_LIMIT: 1GiB
5564

5665
resources:
5766
limits:

cmd/dq/main.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,23 @@ func main() {
4545
runnerGroup, runnerCtx := errgroup.WithContext(mainCtx)
4646

4747
settingsFile := flag.String("settings", "settings.yaml", "settings file")
48+
// Backfill mode (finding #1a): a one-shot re-decode of a raw_events time range into
49+
// the decoded lake tables, for a range the decode loop permanently skipped on cursor
50+
// expiry (DQMaterializerCursorReset). Both flags RFC3339; setting either runs the
51+
// backfill and exits instead of starting the servers/decode loop. Idempotent.
52+
backfillFrom := flag.String("backfill-from", "", "RFC3339 start (inclusive) of a raw_events range to re-decode, then exit")
53+
backfillTo := flag.String("backfill-to", "", "RFC3339 end (exclusive) of a raw_events range to re-decode, then exit")
4854
flag.Parse()
4955

5056
cfg, err := settings.LoadConfig[config.Settings](*settingsFile)
5157
if err != nil {
5258
logger.Fatal().Err(err).Msg("Couldn't load settings.")
5359
}
60+
61+
if *backfillFrom != "" || *backfillTo != "" {
62+
runBackfillAndExit(cfg, *backfillFrom, *backfillTo, logger)
63+
return
64+
}
5465
// The shared env loader silently leaves a field zero on a malformed value (it swallows
5566
// per-field parse errors), so boot-critical numerics never fail LoadConfig — validate
5667
// them loud here (a zero port black-holes traffic; a zero chain id mis-decodes DIDs).
@@ -103,6 +114,25 @@ func main() {
103114
logger.Info().Msg("Server shut down.")
104115
}
105116

117+
// runBackfillAndExit parses the RFC3339 backfill window, runs the one-shot re-decode
118+
// (finding #1a), and exits. Both bounds are required and from must precede to.
119+
func runBackfillAndExit(cfg config.Settings, fromStr, toStr string, logger zerolog.Logger) {
120+
if fromStr == "" || toStr == "" {
121+
logger.Fatal().Msg("backfill requires both -backfill-from and -backfill-to (RFC3339)")
122+
}
123+
from, err := time.Parse(time.RFC3339, fromStr)
124+
if err != nil {
125+
logger.Fatal().Err(err).Str("backfill-from", fromStr).Msg("invalid -backfill-from (want RFC3339)")
126+
}
127+
to, err := time.Parse(time.RFC3339, toStr)
128+
if err != nil {
129+
logger.Fatal().Err(err).Str("backfill-to", toStr).Msg("invalid -backfill-to (want RFC3339)")
130+
}
131+
if err := app.RunBackfill(cfg, from, to, logger); err != nil {
132+
logger.Fatal().Err(err).Msg("backfill failed")
133+
}
134+
}
135+
106136
// maxHTTPBodyBytes caps a request body on the public query surface. GraphQL
107137
// queries/variables are small; this blunts a single oversized POST.
108138
const maxHTTPBodyBytes = 4 << 20 // 4 MiB

internal/app/backend.go

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ func duckConfigFromSettings(settings *config.Settings) duck.Config {
6161
}
6262
}
6363

64+
// queryDuckConfig is the DuckDB config for the QUERY backend. It is
65+
// duckConfigFromSettings with one materializer-pod adjustment: on a materializer
66+
// release (MATERIALIZER_ENABLED) the query backend is always built but serves no
67+
// traffic (no ingress; the query fleet is a separate release), yet still honors
68+
// DUCKDB_MEMORY_LIMIT — so two same-limit DuckDB instances can co-reside on one pod
69+
// and OOM (finding #7). When DUCKDB_QUERY_MEMORY_LIMIT is set on such a pod, cap this
70+
// idle query instance with it so it plus the decode instance sum under the pod limit;
71+
// the decode instance (startDuckLakeMaterializer) keeps the full DUCKDB_MEMORY_LIMIT.
72+
// On a query pod the override is inert, so the query fleet is unchanged.
73+
func queryDuckConfig(settings *config.Settings) duck.Config {
74+
cfg := duckConfigFromSettings(settings)
75+
if settings.MaterializerEnabled && settings.DuckDBQueryMemoryLimit != "" {
76+
cfg.DuckDBMemoryLimit = settings.DuckDBQueryMemoryLimit
77+
}
78+
return cfg
79+
}
80+
6481
// isLocalBucket reports whether the parquet bucket points at the local
6582
// filesystem (file:// URL or absolute path) instead of S3, mirroring how
6683
// duck.Service interprets its Bucket setting.
@@ -79,7 +96,7 @@ func newQueryBackend(settings *config.Settings, logger zerolog.Logger) (reposito
7996
if settings.DuckLakeCatalogDSN == "" {
8097
return nil, nil, nil, fmt.Errorf("DUCKLAKE_CATALOG_DSN is empty: the DuckLake catalog is required for the query backend")
8198
}
82-
duckSvc, err := duck.NewService(duckConfigFromSettings(settings))
99+
duckSvc, err := duck.NewService(queryDuckConfig(settings))
83100
if err != nil {
84101
return nil, nil, nil, fmt.Errorf("couldn't create DuckDB service: %w", err)
85102
}
@@ -144,13 +161,38 @@ func startMaterializer(settings *config.Settings, logger zerolog.Logger) (func()
144161
// owns its own DuckDB service (catalog attached) for the lifetime of the
145162
// loop; the query backend opens a separate one.
146163
func startDuckLakeMaterializer(settings *config.Settings, pollInterval time.Duration, logger zerolog.Logger) (func(), error) {
164+
duckSvc, mat, runner, err := buildDuckLakeMaterializer(settings, pollInterval, logger)
165+
if err != nil {
166+
return nil, err
167+
}
168+
169+
// rebuildRollup is the opt-in disaster-recovery rebuild (LAKE_REBUILD_ROLLUP_ON_BOOT):
170+
// the per-batch recompute only touches a batch's subjects, so a dropped/truncated
171+
// rollup leaves dormant vehicles missing until rebuilt from the full base. It runs
172+
// in the loop goroutine BEFORE processing (never concurrently with it) — not
173+
// synchronously at boot — so this O(history) scan can't outlast the liveness probe
174+
// and CrashLoop the pod; a failure is logged and the loop proceeds (the per-batch
175+
// recompute still heals active vehicles).
176+
stop := runMaterializerLoop(runner, mat, settings.LakeRebuildRollupOnBoot, logger)
177+
return func() {
178+
stop()
179+
_ = duckSvc.Close()
180+
}, nil
181+
}
182+
183+
// buildDuckLakeMaterializer constructs the materializer's DuckDB service, the
184+
// DuckLakeMaterializer (blob store + cipher + temp dir), and the Runner (the decode
185+
// surface) — the shared setup behind both the live decode loop (startDuckLakeMaterializer)
186+
// and the one-shot re-decode tool (RunBackfill, finding #1a). On error it closes the
187+
// service so the caller has nothing to clean up; on success the caller owns duckSvc.
188+
func buildDuckLakeMaterializer(settings *config.Settings, pollInterval time.Duration, logger zerolog.Logger) (*duck.Service, *materializer.DuckLakeMaterializer, *materializer.Runner, error) {
147189
// Sharding is not honored on the DuckLake path — readDelta reads the whole
148190
// raw_events delta and the single global ingest_progress cursor makes exactly
149191
// one logical processor (extra replicas just lose the cursor CAS and roll
150192
// back). Refuse the config rather than silently ignore it: run the
151193
// materializer as a single replicaCount=1 release (SR review #8).
152194
if settings.MaterializerShardCount > 1 {
153-
return nil, fmt.Errorf(
195+
return nil, nil, nil, fmt.Errorf(
154196
"MATERIALIZER_SHARD_COUNT=%d is not supported on the DuckLake path: the global ingest_progress cursor allows only one materializer; run a single replicaCount=1 release",
155197
settings.MaterializerShardCount)
156198
}
@@ -162,20 +204,20 @@ func startDuckLakeMaterializer(settings *config.Settings, pollInterval time.Dura
162204
cfg.MetricsPoolLabel = "materializer" // separate dq_db_pool_* series from the query pool (H4)
163205
duckSvc, err := duck.NewService(cfg)
164206
if err != nil {
165-
return nil, fmt.Errorf("creating DuckLake service: %w", err)
207+
return nil, nil, nil, fmt.Errorf("creating DuckLake service: %w", err)
166208
}
167209
mat, err := materializer.NewDuckLakeMaterializer(context.Background(), duckSvc.DB(), logger)
168210
if err != nil {
169211
_ = duckSvc.Close()
170-
return nil, fmt.Errorf("creating DuckLake materializer: %w", err)
212+
return nil, nil, nil, fmt.Errorf("creating DuckLake materializer: %w", err)
171213
}
172214
// Resolve externalized blob payloads from the same bucket the fetch path
173215
// presigns/downloads (settings.BlobBucket): din writes payloads larger
174216
// than the inline threshold to a blob and leaves only the key on the row.
175217
blobCipher, err := blobcrypt.NewCipher(settings.BlobEncryptionKey)
176218
if err != nil {
177219
_ = duckSvc.Close()
178-
return nil, fmt.Errorf("blob cipher: %w", err)
220+
return nil, nil, nil, fmt.Errorf("blob cipher: %w", err)
179221
}
180222
mat = mat.WithBlobStore(s3ClientFromSettings(settings), settings.BlobBucket).
181223
WithBlobCipher(blobCipher).
@@ -186,15 +228,15 @@ func startDuckLakeMaterializer(settings *config.Settings, pollInterval time.Dura
186228
decodedRetention, err = time.ParseDuration(settings.LakeDecodedRetention)
187229
if err != nil {
188230
_ = duckSvc.Close()
189-
return nil, fmt.Errorf("invalid LAKE_DECODED_RETENTION %q: %w", settings.LakeDecodedRetention, err)
231+
return nil, nil, nil, fmt.Errorf("invalid LAKE_DECODED_RETENTION %q: %w", settings.LakeDecodedRetention, err)
190232
}
191233
}
192234
var rollupInterval time.Duration
193235
if settings.MaterializerRollupInterval != "" {
194236
rollupInterval, err = time.ParseDuration(settings.MaterializerRollupInterval)
195237
if err != nil {
196238
_ = duckSvc.Close()
197-
return nil, fmt.Errorf("invalid MATERIALIZER_ROLLUP_INTERVAL %q: %w", settings.MaterializerRollupInterval, err)
239+
return nil, nil, nil, fmt.Errorf("invalid MATERIALIZER_ROLLUP_INTERVAL %q: %w", settings.MaterializerRollupInterval, err)
198240
}
199241
}
200242
runner := materializer.New(materializer.Config{
@@ -206,19 +248,46 @@ func startDuckLakeMaterializer(settings *config.Settings, pollInterval time.Dura
206248
DecodedRetention: decodedRetention,
207249
BackfillMode: settings.MaterializerBackfillMode,
208250
}, logger).WithDuckLake(mat)
251+
return duckSvc, mat, runner, nil
252+
}
209253

210-
// rebuildRollup is the opt-in disaster-recovery rebuild (LAKE_REBUILD_ROLLUP_ON_BOOT):
211-
// the per-batch recompute only touches a batch's subjects, so a dropped/truncated
212-
// rollup leaves dormant vehicles missing until rebuilt from the full base. It runs
213-
// in the loop goroutine BEFORE processing (never concurrently with it) — not
214-
// synchronously at boot — so this O(history) scan can't outlast the liveness probe
215-
// and CrashLoop the pod; a failure is logged and the loop proceeds (the per-batch
216-
// recompute still heals active vehicles).
217-
stop := runMaterializerLoop(runner, mat, settings.LakeRebuildRollupOnBoot, logger)
218-
return func() {
219-
stop()
220-
_ = duckSvc.Close()
221-
}, nil
254+
// RunBackfill re-decodes raw_events in [from, to) into the decoded lake tables and
255+
// exits — the operator-run repair for a range the decode loop permanently skipped on
256+
// cursor expiry (finding #1a; alerted by DQMaterializerCursorReset). It is idempotent
257+
// (the same cloud_event_id anti-join), so it is safe to re-run and safe to run while
258+
// the live materializer is up. It registers the vendor decode modules, decodes the
259+
// range, then flushes both rollups so latest/summary reflect the backfilled rows.
260+
func RunBackfill(settings config.Settings, from, to time.Time, logger zerolog.Logger) error {
261+
if settings.DuckLakeCatalogDSN == "" {
262+
return fmt.Errorf("DUCKLAKE_CATALOG_DSN is empty: the DuckLake catalog is required for backfill")
263+
}
264+
materializer.RegisterVendorModules(materializer.VendorConfig{
265+
ChainID: settings.DIMORegistryChainID,
266+
VehicleNFTAddress: common.HexToAddress(settings.VehicleNFTAddress),
267+
AftermarketNFTAddress: common.HexToAddress(settings.AftermarketNFTAddress),
268+
SyntheticNFTAddress: common.HexToAddress(settings.SyntheticNFTAddress),
269+
})
270+
duckSvc, mat, runner, err := buildDuckLakeMaterializer(&settings, 0, logger)
271+
if err != nil {
272+
return err
273+
}
274+
defer func() { _ = duckSvc.Close() }()
275+
276+
ctx := context.Background()
277+
logger.Info().Time("from", from).Time("to", to).Msg("backfill: re-decoding raw_events range")
278+
n, err := mat.BackfillTimeRange(ctx, runner, from, to)
279+
if err != nil {
280+
return fmt.Errorf("backfill decode: %w", err)
281+
}
282+
// Refresh the rollups for the subjects the backfill touched (dirtied above).
283+
if ferr := runner.FlushRollup(ctx); ferr != nil {
284+
logger.Error().Err(ferr).Msg("backfill: signals_latest flush failed; rerun with LAKE_REBUILD_ROLLUP_ON_BOOT if latest/summary looks stale")
285+
}
286+
if ferr := runner.FlushEventRollup(ctx); ferr != nil {
287+
logger.Error().Err(ferr).Msg("backfill: events_latest flush failed; rerun with LAKE_REBUILD_ROLLUP_ON_BOOT if event summaries look stale")
288+
}
289+
logger.Info().Int("raw_events", n).Msg("backfill complete")
290+
return nil
222291
}
223292

224293
// runMaterializerLoop runs runner.Run in a goroutine and returns a stop
@@ -229,14 +298,21 @@ func runMaterializerLoop(runner *materializer.Runner, mat *materializer.DuckLake
229298
go func() {
230299
defer close(done)
231300
if rebuildRollup {
232-
logger.Info().Msg("LAKE_REBUILD_ROLLUP_ON_BOOT set: rebuilding signals_latest from full base (may take a while on deep history)")
301+
logger.Info().Msg("LAKE_REBUILD_ROLLUP_ON_BOOT set: rebuilding signals_latest + events_latest from full base (may take a while on deep history)")
233302
if err := mat.RecomputeRollup(ctx); err != nil {
234303
// Non-fatal: log and proceed — crashing here would CrashLoop the
235304
// pod; the per-batch recompute still heals active vehicles.
236305
logger.Error().Err(err).Msg("signals_latest rebuild failed; continuing with the normal loop")
237306
} else {
238307
logger.Info().Msg("signals_latest rebuild complete")
239308
}
309+
// Rebuild the events rollup too (finding #5a); independent of the signals
310+
// rebuild so one failing doesn't skip the other.
311+
if err := mat.RecomputeEventRollup(ctx); err != nil {
312+
logger.Error().Err(err).Msg("events_latest rebuild failed; continuing with the normal loop")
313+
} else {
314+
logger.Info().Msg("events_latest rebuild complete")
315+
}
240316
}
241317
if err := runner.Run(ctx); err != nil {
242318
// Run returns an error only when the decode loop is durably broken (the

internal/app/backend_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package app
2+
3+
import (
4+
"testing"
5+
6+
"github.com/DIMO-Network/dq/internal/config"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
// TestQueryDuckConfig_MaterializerMemoryCap proves finding #7's fix: on a
11+
// materializer pod the always-built (but idle) query DuckDB instance is capped by
12+
// DUCKDB_QUERY_MEMORY_LIMIT so it plus the decode instance sum under the pod limit,
13+
// while a query pod is unaffected.
14+
func TestQueryDuckConfig_MaterializerMemoryCap(t *testing.T) {
15+
t.Parallel()
16+
base := config.Settings{
17+
DuckLakeCatalogDSN: "postgres://x", // non-empty so this models a real config
18+
DuckDBMemoryLimit: "6GiB",
19+
}
20+
21+
t.Run("query pod ignores the override", func(t *testing.T) {
22+
s := base
23+
s.MaterializerEnabled = false
24+
s.DuckDBQueryMemoryLimit = "1GiB"
25+
assert.Equal(t, "6GiB", queryDuckConfig(&s).DuckDBMemoryLimit,
26+
"a query pod keeps the full DUCKDB_MEMORY_LIMIT")
27+
})
28+
29+
t.Run("materializer pod caps the idle query instance", func(t *testing.T) {
30+
s := base
31+
s.MaterializerEnabled = true
32+
s.DuckDBQueryMemoryLimit = "1GiB"
33+
assert.Equal(t, "1GiB", queryDuckConfig(&s).DuckDBMemoryLimit,
34+
"the idle query instance on a materializer pod must use the lower cap")
35+
// The decode instance keeps the full budget (it uses duckConfigFromSettings
36+
// directly, not queryDuckConfig).
37+
assert.Equal(t, "6GiB", duckConfigFromSettings(&s).DuckDBMemoryLimit,
38+
"the decode instance keeps DUCKDB_MEMORY_LIMIT")
39+
})
40+
41+
t.Run("materializer pod without an override keeps the full budget", func(t *testing.T) {
42+
s := base
43+
s.MaterializerEnabled = true
44+
s.DuckDBQueryMemoryLimit = ""
45+
assert.Equal(t, "6GiB", queryDuckConfig(&s).DuckDBMemoryLimit,
46+
"no override set → unchanged (opt-in)")
47+
})
48+
}

internal/config/settings.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,17 @@ type Settings struct {
7878
// read-only reader role. Empty reads the primary DuckLakeCatalogDSN.
7979
DuckLakeCatalogReadDSN string `yaml:"DUCKLAKE_CATALOG_READ_DSN"`
8080
// DuckDB parse-on-read query engine (maps into duck.Config).
81-
DuckDBMemoryLimit string `yaml:"DUCKDB_MEMORY_LIMIT"`
82-
DuckDBThreads int `yaml:"DUCKDB_THREADS"`
81+
DuckDBMemoryLimit string `yaml:"DUCKDB_MEMORY_LIMIT"`
82+
// DuckDBQueryMemoryLimit, when set, overrides DuckDBMemoryLimit for the QUERY
83+
// DuckDB instance ONLY ON A MATERIALIZER POD (MATERIALIZER_ENABLED). The
84+
// materializer release serves no query traffic (no ingress; the query fleet is a
85+
// separate release), so its always-constructed query backend is idle — but it
86+
// still honors DUCKDB_MEMORY_LIMIT, so two 6GiB DuckDB instances can co-reside on
87+
// an 8Gi pod and OOM (finding #7). Lowering just the idle query instance makes the
88+
// two limits sum under the pod limit while the decode instance keeps its full
89+
// budget. Ignored on a query pod (MATERIALIZER_ENABLED=false).
90+
DuckDBQueryMemoryLimit string `yaml:"DUCKDB_QUERY_MEMORY_LIMIT"`
91+
DuckDBThreads int `yaml:"DUCKDB_THREADS"`
8392
DuckDBExtensionDir string `yaml:"DUCKDB_EXTENSION_DIR"`
8493
DuckDBTempDirectory string `yaml:"DUCKDB_TEMP_DIRECTORY"`
8594
DuckDBMaxConns int `yaml:"DUCKDB_MAX_CONNS"`

0 commit comments

Comments
 (0)