Skip to content

Keep the DuckDB spatial index loaded instead of re-reading it - #1057

Open
DarkChromaMC wants to merge 1 commit into
PlayPro:masterfrom
DarkChromaMC:fix/duckdb-spatial-index-reload
Open

DarkChromaMC wants to merge 1 commit into
PlayPro:masterfrom
DarkChromaMC:fix/duckdb-spatial-index-reload

Conversation

@DarkChromaMC

Copy link
Copy Markdown
Contributor

Fixes #1056.

On a large DuckDB server a single lookup could re-read the whole spatial index in a loop and allocate native memory until the process was killed. Details and measurements are in the issue.

What changes (DuckDBSpatialIndex only)

  • The loaded index is kept. When a consumer batch completes segments, publish() appends them to the loaded index instead of dropping it. The catch-up scan in begin() does the same with the segments it writes. A batch that wrote no index rows, including a rolled back one, leaves the loaded index alone.
  • A load is no longer discarded because the consumer published. A separate counter tracks the changes that really invalidate a loaded index (reset(), an external Builder write, a segment that doesn't follow the loaded ones). Segments committed while a load is running are collected and added to its result.
  • The load is cheaper. The index is read on a duplicated connection with jdbc_stream_results on and without the ORDER BY. The segments are sorted in Java. The duplicated connection also means a lookup that is reading an older snapshot can't cache a stale index.
  • One lookup loads at a time. Others wait for it and use its result.

Lookups read the index snapshots without a lock, so appending builds new snapshot objects and swaps them in.

Not changed

  • The index table, its format and the writer state handling.
  • SQLite, MySQL and ClickHouse.

Testing

  • 7 JUnit tests against a real DuckDB file. They're on this branch rather than in the PR, since src/test is ignored here. That branch also adds two package-private test hooks.
  • Replay on a copy of a 23.8 GB production database, numbers in the issue.
  • Running on that production server since 2026-11-11. The inspector click that preceded the last kill now costs one 0.68 GB step for the first lookup after a start and nothing for later ones.

Open points

  • The load lock is global and not interruptible. A lookup on the main thread waits for a load in progress (about 2 s for this index, once per start).
  • The first lookup after a start still reads the whole index. Loading it at startup, or keeping only the newest segments in memory, would remove that step. I left it out to keep this change small.

🤖 Generated with Claude Code

A lookup that found no loaded index read the whole index table, and read
it again whenever the consumer published a batch during the read. On a
busy server with a large index the read never finished and native memory
grew until the process was killed. The loaded index was also dropped
every time any table completed a segment.

The index is now streamed without a sort on its own connection, one
lookup loads it at a time, committed segments are appended to it, and
only a reset or rebuild makes a lookup read it again.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@netlify

netlify Bot commented Nov 11, 2026

Copy link
Copy Markdown

👷 Deploy request for coreprotect pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit 3e3487e

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T13:36:25.542882Z 3e3487e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e3487e0a2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

List<PendingSegment> batch = new ArrayList<>();
if (state.writeCompleted(connection, prefix, batch)) {
connection.commit();
written.addAll(batch);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop retaining every catch-up checkpoint

When begin() must build or substantially catch up a large index, every committed checkpoint is appended here to the method-wide written list and remains reachable until all sources finish. This defeats the INDEX_CHECKPOINT_SEGMENTS memory bound: for a fresh index it retains the entire index's 4/8-KiB Bloom arrays in heap (potentially hundreds of megabytes) instead of allowing each committed batch to be collected, so startup can OOM on the large databases this code targets. Publish each committed batch to an active cache/load as it commits, or retain it only while that is actually necessary.

Useful? React with 👍 / 👎.


WriterState loaded = loadWriterState(connection, prefix);
List<PendingSegment> written = new ArrayList<>();
WriterState loaded = loadWriterState(connection, prefix, written);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Publish checkpoints even when catch-up later fails

If this catch-up has already committed checkpoint batches and a later source/page throws, the call exits before written is passed to addCommittedSegments. A retry skips those batches because indexedThrough() now sees them, and successful initialization no longer clears the cache; therefore, when a lookup cached the older snapshot before or during the catch-up, that cache permanently retains a gap and future lookups scan the entire affected row-ID range. Publish committed checkpoints as they happen or invalidate the cache on this exception path.

Useful? React with 👍 / 👎.

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.

DuckDB: a lookup can re-read the whole spatial index in a loop and exhaust native memory

1 participant