Window tables: a leak loop, and examples that append and sum instead of an indexed upsert - #277
Merged
Conversation
…ndow footgun The Bluesky demo grew from 100 MB to 450 MB in a day. Five eight-hour soaks localized it to the window table, and each question after that cost another two hours. This drives the real StructuredBatch handler and the real tumbling manager over one DuckDB connection for 1,000 batches in the -short pass, about three seconds each, and SQLFLOW_LEAK_BATCHES scales the same test to millions of messages. Three results, all from DuckDB's own accounting (duckdb_memory and pragma_storage_info): - Indexed window table, in memory: 85 row groups holding 20,400 rows for 120 live after 500,000 messages. The pattern every shipped tumbling example uses. - The same on a file-backed database: identical. pipeline.state.path is not a way out, which a Python loop that never checkpointed had suggested. - No index, plain insert, collect SQL sums: one row group, flat, measured to 15 million messages and 30,000 polls. The two indexed tests assert the leak on purpose. If a DuckDB upgrade makes them fail, the leak is fixed upstream and #268, the examples, and the README need updating. Resident memory is logged, not asserted. On macOS it is peak RSS, and the no-index run's peak rose 48, 97, then 117 MiB at 1k, 10k and 30k batches: a ceiling, not a slope. A threshold on it failed a healthy run. What breaks if this is wrong: the no-index workaround ships to users as the fix while it still leaks. The row-group assertion is what would catch that.
…upsert Every tumbling example and the README taught a UNIQUE INDEX on the window table with an ON CONFLICT upsert. DuckDB never frees rows deleted from an indexed table once a checkpoint writes them, and the manager deletes every closed window, so the pattern leaks without bound, in memory and with a state path. The Bluesky demo grew from 100 MB to 450 MB in a day on it (#268). Each window table now has no index. The handler appends a batch's counts, and the collect query sums them with GROUP BY ALL and casts the sum back to the column type. Changed: tumbling.window.yml, kafka.stateful.window.yml, bluesky.kafka.windowed.yml, bluesky.postgres.windowed.yml, the slow-soak config, and the README's Tumbling windows section, which now shows the handler SQL and warns against the index. GROUP BY ALL, not GROUP BY bucket: two examples alias a reformatted bucket AS bucket, and DuckDB resolves GROUP BY bucket to the table column, which splits one output window into several rows. Measured on DuckDB 1.5.2. benchmark.stateful.mem.yml keeps its index. Its manager deletes nothing, and an index with upserts and no deletes held one row group over 40,000 batches. Removing the index there would grow the table forever and invalidate the durable-state cost numbers. Evidence: - The origin/main and branch SQL of all five configs, over the same 400 batches with late events and an idle close, published identical rows in identical column types. The Postgres example's sink upserts produced identical keyed tables. The same harness fails on a config missing the sum. - tumbling.window.yml end to end through the engine against Kafka, old config and new, same 3,000 events: 27 identical rows, one per key, 2,433 total. - sqlflow validate passes all five. internal/cli example tests pass. The release test and the slow-soak sampler already read sum(count), so they are unaffected. What breaks if this is wrong: a window publishes split or partial counts, or a sink upsert sees a duplicate key. The A/B would show both.
turbolytics
added a commit
that referenced
this pull request
Sep 13, 2026
#275 squash-merged this branch's first two commits, so both sides added docs/superpowers/specs/2026-09-12-serve-design.md. main's copy is byte-identical to ac97dbc. This branch's copy is that plus 40ec437 and 00432fe, which record what implementation changed and the 500 body that no longer carries DuckDB's error, so the conflict resolves to this branch's copy. #277's README, example and manager changes merged cleanly. The cli, config, schema, validate, managers and serve packages pass under -race after the merge.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
The Bluesky demo grew from 100 MB to 450 MB in a day on a 512 MB plan. The cause is the window pattern every tumbling example and the README taught: a
UNIQUE INDEXon the window table with anON CONFLICTupsert. DuckDB never frees rows deleted from an indexed table once a checkpoint writes them, and the manager deletes every closed window, so the table grows without bound. That happens in memory and with a state path alike. See #268.Two commits:
internal/managers/window_leak_test.godrives the realStructuredBatchhandler and the real tumbling manager over one DuckDB connection and reads DuckDB's own accounting. It runs 1,000 batches in the-shortpass, about three seconds a test, andSQLFLOW_LEAK_BATCHESscales it to millions of messages.GROUP BY ALL, casting the sum back to the column type.Changed configs:
tumbling.window.yml,kafka.stateful.window.yml,bluesky/bluesky.kafka.windowed.yml,bluesky/bluesky.postgres.windowed.yml, and.claude/skills/slow-soak/slow.yml. The README's Tumbling windows section now shows the handler SQL and warns against the index.What breaks if this is wrong: a window publishes split or partial counts, or a sink upsert sees a duplicate key. The A/B below shows neither.
Verification
The leak loop, 500,000 messages per test:
The no-index table held one row group through 15 million messages and 30,000 polls.
The examples, old SQL from
origin/mainagainst the branch:tumbling.window.yml. Old and new configs ran against Kafka on the same 3,000 events and published the same 27 rows, one per key, 2,433 total.sqlflow validate: all five valid.The template checks:
go test -short -race ./...: pass, no data races.uv run --locked pytest tests/tooling -q: 205 passed.make coverage-page && git status --short docs/coverage: clean.uv run --locked pytest tests/release -q -k "state_durability or config_validation_accepts": 2 passed. The durability test runs the changedkafka.stateful.window.ymlthrough a restart. Run againstturbolytics/sql-flow:v1.2.0, because the engine is unchanged here and the suite mountsdev/configfrom the checkout.make soak: not run. No engine code path changes.Notes for the reviewer
GROUP BY ALL, notGROUP BY bucket. Two examples output a reformatted bucketAS bucket. DuckDB resolvesGROUP BY bucketto the table's column and splits one output window into several rows. Measured on DuckDB 1.5.2.benchmark.stateful.mem.ymlkeeps its index. Its manager deletes nothing, and an index with upserts and no deletes held one row group over 40,000 batches. Removing the index there would grow the table forever and invalidate the durable-state cost numbers.vacuum_rebuild_indexeslets a checkpoint compact an indexed table and rebuild its ART index. It is off by default and startup-only, socommands:cannot set it. Passed through sqlflow's ADBC database options, it made the indexed upsert flat through the real handler and manager: one row group, 240 rows for 120 live, over 500,000 messages, in memory and on disk. Exposing it is a separate engine change.Related: #268, #247, #276.