Skip to content

Window tables: a leak loop, and examples that append and sum instead of an indexed upsert - #277

Merged
turbolytics merged 2 commits into
mainfrom
feat/leak-loop
Sep 13, 2026
Merged

Window tables: a leak loop, and examples that append and sum instead of an indexed upsert#277
turbolytics merged 2 commits into
mainfrom
feat/leak-loop

Conversation

@turbolytics

@turbolytics turbolytics commented Sep 13, 2026

Copy link
Copy Markdown
Owner

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 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 table grows without bound. That happens in memory and with a state path alike. See #268.

Two commits:

  1. A leak loop. internal/managers/window_leak_test.go drives the real StructuredBatch handler and the real tumbling manager over one DuckDB connection and reads DuckDB's own accounting. It runs 1,000 batches in the -short pass, about three seconds a test, and SQLFLOW_LEAK_BATCHES scales it to millions of messages.
  2. Examples and README that no longer leak. Each window table drops its index. The handler appends a batch's counts, and the collect query sums them with 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:

Window table Row groups Rows held Live rows DuckDB table memory
Unique index, upsert, in memory 2 → 85 20,400 120 1 → 63 MiB
Unique index, upsert, file-backed 2 → 85 20,400 120 1 → 64 MiB
No index, plain insert, collect sums 1 → 1 960 480 0 → 0 MiB

The no-index table held one row group through 15 million messages and 30,000 polls.

The examples, old SQL from origin/main against the branch:

  • SQL A/B, all five configs. 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 harness fails at batch 9 on a config missing the sum.
  • Engine A/B, 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 changed kafka.stateful.window.yml through a restart. Run against turbolytics/sql-flow:v1.2.0, because the engine is unchanged here and the suite mounts dev/config from the checkout.
  • make soak: not run. No engine code path changes.

Notes for the reviewer

  • GROUP BY ALL, not GROUP BY bucket. Two examples output a reformatted bucket AS bucket. DuckDB resolves GROUP BY bucket to the table's column and 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.
  • Resident memory is logged, not asserted. On macOS it is peak RSS and only rises. The Linux memory soak stays the gate for native growth.
  • DuckDB has an experimental fix, not exposed by sqlflow. vacuum_rebuild_indexes lets a checkpoint compact an indexed table and rebuild its ART index. It is off by default and startup-only, so commands: 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.
  • Found along the way: StructuredBatch with a state path dies after its first batch: CHECKPOINT inside the state transaction #276, and an unfiled crash where a state path plus a window sink writing to an attached database aborts on the first closed window.
  • The demo's config fix is pipeline: drop the window table's unique index, which leaked every deleted row sql-flow-bluesky-demo#3.

Related: #268, #247, #276.

…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 turbolytics changed the title managers: a leak loop through the real handler and manager for the window footgun Window tables: a leak loop, and examples that append and sum instead of an indexed upsert Sep 13, 2026
@turbolytics
turbolytics merged commit 29cd4c0 into main Sep 13, 2026
5 checks passed
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.
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