improvement(postgres-storage): Improve bucket data query performance - #721
Merged
Conversation
🦋 Changeset detectedLatest commit: 3e75ba7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
rkistner
reviewed
Jul 22, 2026
rkistner
left a comment
Contributor
There was a problem hiding this comment.
This looks great! What is still needed to get this merged?
Collaborator
Author
|
stevensJourney
marked this pull request as ready for review
July 22, 2026 13:29
rkistner
approved these changes
Jul 22, 2026
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.
Context
Bucket data is read while a client drains a checkpoint, making this query a potentially hot part of SQL-backed sync storage. Recent performance measurements from the storage benchmark PR made the cost particularly visible as the number of rows and resolved buckets increased. This PR focuses on the Postgres query improvement discovered through that work; the benchmark harness itself is covered separately.
These results are for storage v1 and come entirely from changing how existing bucket rows are read. They do not yet rely on storage v3 batching changes or potential checksum improvements. This makes the improvement comparatively cheap and low risk: it does not require a storage migration, change persisted data, or alter the sync protocol, and it leaves further gains from v3 batching and checksum work available separately.
Existing query
The previous Postgres implementation built one statement containing an
ORbranch for every requested bucket. Each branch paired a bucket name with that bucket's starting operation ID, after which the complete result was ordered by bucket name and operation ID and limited to the requested batch size.This keeps the read to one database round trip, but the statement grows with the bucket count and asks PostgreSQL to plan a large disjunction containing different range bounds. Its performance also became nonlinear as buckets grew denser. The original
10k-todos-1k-bucketsmeasurement took approximately 3 seconds to drain, while100k-todos-1k-bucketstook approximately 86 seconds. Increasing the operations by 10× therefore increased the drain time by roughly 29×.The benchmark's initial drain starts every bucket at operation zero, which can make the original query look healthier than it is.PostgreSQL can collapse those equivalent range conditions into a single
bucket_name = ANY (...)index condition. Once buckets have advanced to different operation IDs, however, their range conditions are no longer interchangeable. An explanation of the tenth batch in the 10k/200 scenario showed PostgreSQL scanning all 20,000 operations up to the checkpoint, applying the long per-bucketORexpression as a filter, materializing 11,000 matching rows, and top-N sorting those rows to return a batch of 1,000. Of the scanned rows, 9,000 were removed by the filter before the sort.The important cost is not just the 44 ms for this individual query, but that the scan, filter, and sort are repeated for every batch. As the checkpoint contains more operations and the bucket cursors diverge, the amount of work grows with the stored range rather than only with the next batch being returned.
Alternatives considered
An experimental SQLite implementation used during performance testing sorted the requested buckets and executed one indexed
(group_id, bucket_name, op_id)range scan per bucket while carrying the remaining batch limit across those scans. This was useful as an alternative query shape to investigate, rather than established production behavior to copy directly. Porting the experiment to Postgres improved dense workloads, reducing the 100k/1k case from approximately 86 seconds to 27 seconds. It also exposed an important difference between an embedded database and a database server: SQLite pays almost no round-trip cost for another statement, while Postgres does. Sparse workloads regressed because hundreds or thousands of queries could return only a few rows, or no rows at all. The 1k/1k case increased from approximately 149 ms to 657 ms.A density-based hybrid was considered, but bucket count alone is not enough to choose a strategy. The same 1,000-bucket request behaved very differently depending on whether it contained 2,000 or 200,000 operations, and the read API does not have a reliable estimate of future rows per bucket. A threshold would therefore encode a workload assumption rather than fix the underlying query shape. A JSONB request-table join had also been explored previously, but expanding and joining the JSON input was substantially slower.
The more promising approach was to preserve the per-bucket index scans while moving them back into a single database query. Bucket requests can be represented as a
VALUEStable and passed through a correlatedLATERALsubquery. This gives each bucket its own parameterized index range scan without incurring one network round trip per bucket.The first LATERAL query
The initial LATERAL implementation assigned each bucket a deterministic ordinal and ordered the combined output by that ordinal and
op_id. A globalop_idorder was deliberately avoided because the storage index is ordered by(group_id, bucket_name, op_id)and rows need to remain contiguous per bucket for chunk construction.This version performed well for sparse and medium workloads, but the million-row measurements revealed another nonlinear path. The combined output was ordered and limited only after every LATERAL branch had produced its candidates.
EXPLAIN (ANALYZE, BUFFERS)showed that a request returning 1,000 rows performed all 1,000 index scans, produced 360,000 candidate rows, and then used a top-N sort to discard 359,000 of them.The same work was repeated for every returned batch. As buckets became denser, each batch rescanned large portions of all requested buckets before discarding nearly every candidate. This explains why this intermediate version reached approximately 755 seconds for one million todos across 1,000 buckets.
Preserving bucket order for early termination
The final query orders the bucket request table inside a non-flattened subquery before joining it to the per-bucket range scan.
OFFSET 0acts as an optimization barrier that preserves the ordered outer path for PostgreSQL's planner. The final ordering can then usebucket_orderas a presorted key and incrementally sort each bucket's operations. Most importantly, the outer limit can stop the nested loop as soon as the requested number of rows has been produced.The repeated ordering and limits are intentional. The request ordering establishes the presorted bucket path, while the LATERAL ordering keeps each bucket's index range scan in operation order. The inner limit bounds that per-bucket scan and prevents it from being flattened into a broader join; because it equals the outer limit, it cannot exclude a row that the batch could return. The final ordering guarantees contiguous bucket chunks, and the outer limit applies the batch size across all buckets and stops the nested loop early.
For the same 1,000-row request, the resulting plan consumes only six bucket requests and 1,001 candidate rows. The global sort is replaced by an incremental sort, shared-buffer hits fall from more than 356,000 to approximately 1,000, and execution time for the explained query falls from roughly 278 ms to 1.1 ms.
Performance
The table compares the original implementation with the final ordered LATERAL query. The million-todo baseline scenarios were intentionally skipped because the original query was already taking approximately 86 seconds at 100k todos and 1,000 buckets, making the larger baseline runs prohibitively slow. After the query fix, both million-todo scenarios complete in approximately 62–63 seconds. Lower drain time and higher read throughput are better.
AI Disclosure. The original storage benchmark was generated with Codex 5.5 assistance. I guided Codex through the process of testing various query improvements to reach this end result.