Skip to content

perf(inkless:control-plane): index and rewrite list_offsets_v1 timestamp lookups [KC-537] - #799

Merged
jeqo merged 1 commit into
mainfrom
jeqo/list-offsets-timestamp-index
Oct 12, 2026
Merged

jeqo merged 1 commit into
mainfrom
jeqo/list-offsets-timestamp-index

Conversation

@jeqo

@jeqo jeqo commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

The concrete-timestamp branch of list_offsets_v1 (the offsetsForTimes path) picks its batch with ORDER BY batch_id LIMIT 1. batches_pkey yields that order, so the planner can walk the primary key across the whole batches table with the partition and timestamp as filters, and inside plpgsql it does so on every call because the generic plan cannot see the timestamp's selectivity. With a large batches backlog, every ListOffsets then runs into statement_timeout. MAX_TIMESTAMP has a related problem: no index covers batch_timestamp(...), so it evaluates the function for every retained batch. Two Flyway migrations fix this and must ship together: V30 adds the index, and V31 rewrites the lookups so the planner has no ordered-walk plan left to pick. See the commit message for the planner reasoning.

Flyway migrations

  • V30__Batches_by_timestamp_index.sql: creates batches_by_timestamp_idx on (topic_id, partition, batch_timestamp(...)) INCLUDE (base_offset), then runs ANALYZE batches. Flyway runs it in a transaction, so the build holds a SHARE lock on batches and blocks commit_file until it finishes, during broker startup. Measured build rate is about 200k rows/s single-threaded: about 6 minutes at 70M rows and over an hour at the 1e9 rows V24 designs for. At that scale, run the CREATE INDEX CONCURRENTLY IF NOT EXISTS statement from the migration header before upgrading. The migration then only runs ANALYZE. batch_timestamp must stay LANGUAGE plpgsql, otherwise queries stop matching the stored index expression and the index goes unused. The index adds about 4 GB per 70M rows and a plpgsql call per inserted batch.
  • V31__List_offsets_min_offset_lookup.sql: CREATE OR REPLACE FUNCTION list_offsets_v1, no table lock. The two batches lookups become MIN(base_offset) over the timestamp predicate, followed by a point fetch by offset. A concrete-timestamp request first checks the oldest retained batch and skips the scan when it already qualifies. The function body is the V28 one and reads logs.deleted_at, so it cannot be hand-applied to a schema older than V28.

V30: new index

CREATE INDEX IF NOT EXISTS batches_by_timestamp_idx
    ON batches (topic_id, partition, batch_timestamp(timestamp_type, batch_max_timestamp, log_append_timestamp))
    INCLUDE (base_offset);

ANALYZE batches;

V31: list_offsets_v1 changes

Only the two batches lookups change (MAX_TIMESTAMP and the concrete timestamp). The logs-only branches are untouched. The diff is against the V28 definition, the last one before this PR.

Diff of list_offsets_v1, V28 to V31
--- V28 list_offsets_v1
+++ V31 list_offsets_v1
@@ -6,6 +6,7 @@
     l_request RECORD;
     l_log RECORD;
     l_max_timestamp BIGINT = NULL;
+    l_min_base_offset BIGINT = NULL;
     l_found_timestamp BIGINT = NULL;
     l_found_timestamp_offset BIGINT = NULL;
 BEGIN
@@ -56,13 +57,20 @@
             WHERE topic_id = l_request.topic_id
                 AND partition = l_request.partition;
 
+            SELECT MIN(base_offset)
+            INTO l_min_base_offset
+            FROM batches
+            WHERE topic_id = l_request.topic_id
+                AND partition = l_request.partition
+                AND batch_timestamp(timestamp_type, batch_max_timestamp, log_append_timestamp) = l_max_timestamp;
+
             SELECT last_offset
             INTO l_found_timestamp_offset
             FROM batches
             WHERE topic_id = l_request.topic_id
                 AND partition = l_request.partition
-                AND batch_timestamp(timestamp_type, batch_max_timestamp, log_append_timestamp) = l_max_timestamp
-            ORDER BY batch_id
+                AND last_offset >= l_min_base_offset
+            ORDER BY last_offset
             LIMIT 1;
 
             IF l_found_timestamp_offset IS NULL THEN
@@ -88,15 +96,34 @@
             CONTINUE;
         END IF;
 
+        -- The oldest retained batch has the smallest offset, so if it qualifies it is the answer and the
+        -- scan below is skipped.
         SELECT batch_timestamp(timestamp_type, batch_max_timestamp, log_append_timestamp), base_offset
         INTO l_found_timestamp, l_found_timestamp_offset
         FROM batches
         WHERE topic_id = l_request.topic_id
             AND partition = l_request.partition
-            AND batch_timestamp(timestamp_type, batch_max_timestamp, log_append_timestamp) >= l_request.timestamp
-        ORDER BY batch_id
+        ORDER BY last_offset
         LIMIT 1;
 
+        IF l_found_timestamp_offset IS NOT NULL AND l_found_timestamp < l_request.timestamp THEN
+            SELECT MIN(base_offset)
+            INTO l_min_base_offset
+            FROM batches
+            WHERE topic_id = l_request.topic_id
+                AND partition = l_request.partition
+                AND batch_timestamp(timestamp_type, batch_max_timestamp, log_append_timestamp) >= l_request.timestamp;
+
+            SELECT batch_timestamp(timestamp_type, batch_max_timestamp, log_append_timestamp), base_offset
+            INTO l_found_timestamp, l_found_timestamp_offset
+            FROM batches
+            WHERE topic_id = l_request.topic_id
+                AND partition = l_request.partition
+                AND last_offset >= l_min_base_offset
+            ORDER BY last_offset
+            LIMIT 1;
+        END IF;
+
         IF l_found_timestamp_offset IS NULL THEN
             -- -1 = org.apache.kafka.common.record.RecordBatch.NO_TIMESTAMP
             RETURN NEXT (l_request.topic_id, l_request.partition, -1, -1, 'none')::list_offsets_response_v1;

Testing

  • ListOffsetsJobTest.timestampLookupFollowsOffsetOrderNotTimestampOrder: out-of-order timestamps across three batches. It pins that the lookup returns the first batch by offset, not the smallest qualifying timestamp. The existing MAX_TIMESTAMP and concrete-timestamp contract tests run against V31 through PostgresControlPlaneTest.
  • ListOffsetsJobTest.maxTimestampTieReturnsFirstBatchByOffset, timestampBeforeOldestBatchReturnsOldestBatch, and timestampLookupIsClampedToLogStartOffset: pin the tie case, both branches of the oldest-batch check, and the log_start_offset clamp.
  • ListOffsetsTimestampScanBenchmarkTest (@Tag("benchmark"), not run in CI): runs one seeded workload against the baseline schema (V29), V30, and V31, each in its own database through a Flyway target version. It prints per-call latency, write cost, index sizes, and EXPLAIN output.

How to read the benchmark: each schema prints three tables, one row per depth (25k to 400k batches in one partition). Compare schemas at the same depth, and look at how a column changes with depth more than at any single figure. In the read table, a flat column means the query ignores partition size, and a column that doubles when the depth doubles means it reads the whole partition. About 500 to 1,000 microseconds is the fixed cost of the job and the round trip, so values near that floor are "constant", not "fast". Compare V29 with V30 to price the index, since V30 and V31 share a write path. Run-to-run noise is about 10% on most figures and up to 30% on a single one. Plans come from standalone SQL and timings go through the plpgsql function, so the two can differ.

Benchmark results

One partition, batch i carries timestamp base + i, statistics refreshed after each seed step, local Postgres 17 container. Microseconds per list_offsets_v1 call through ListOffsetsJob at 400k retained batches. ts@start matches the first row, ts@90% matches the last 10%, ts@end+1 matches nothing.

schema MAX_TIMESTAMP ts@start ts@90% ts@end+1
baseline 209,704 781 90,562 101,981
V30 592 590 95,251 103,372
V31 681 643 3,999 469
  • The baseline scans the partition twice for MAX and walks batches_pkey for the lookup. ts@start is fast only because the first row matches.
  • V30 fixes MAX. Inside the function, the generic plan still walks batches_pkey, so the two production-shaped cases barely move.
  • V31 has one plan for the lookup, a range scan of the new index. Cost follows the batches at or after the target: ts@90% reads 40k entries and ts@end+1 reads none. ts@start would read all 400k entries, so V31 first checks the oldest retained batch and skips the scan when it already qualifies.

Storage and write cost

Same benchmark, 400k batches in one partition, local Postgres 17 container, one run per schema. batches sizes in MB, and the index adds 59 bytes per row (22.5 MB at 400k rows, about 4.1 GB at 70M rows).

index or table baseline (V29) V30 / V31
heap 48.1 48.1
batches_pkey 8.6 8.6
batches_by_file 3.8 3.8
batches_by_last_offset_covering_idx 36.7 36.7
batches_by_timestamp_idx - 22.5

The new index is 46% of the existing index total (49.1 MB) and 23% of the whole relation.

write path baseline (V29) V30 / V31
bulk commit, microseconds per batch 45-51 44-45
bulk commit, WAL bytes per batch 456 559
10-batch commit, WAL bytes 24,575 25,586
10-batch commit, milliseconds 1.7-2.3 1.5-2.3
  • A bulk commit is one 10 MB file of 833 batches of 12 kB, and a small commit is 10 batches. WAL per batch rises by about 100 bytes (23%). Commit latency shows no change beyond run-to-run noise (about 20% on the small commits).
  • The benchmark inserts timestamps in increasing order into one partition, so every index insert lands on the right edge of the btree. A control plane with many partitions writes to many places in the index, which needs more of it cached. The benchmark does not cover that case.
  • Production sizes follow from per-row costs, not from this run. Check pg_indexes_size('batches') before and after on a staging copy.

Related

Broker-side hardening for slow control-plane queries (lock removal and bounded pools) is #798. Neither PR depends on the other.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The benchmark always installs V29 and V30, so it cannot produce the claimed baseline comparison.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Optimizes PostgreSQL timestamp-based ListOffsets queries to avoid full batches table walks.

Changes:

  • Adds a covering timestamp-expression index.
  • Rewrites timestamp lookups using MIN(base_offset) and indexed point fetches.
  • Adds correctness and performance tests and regenerates jOOQ metadata for schema version 30.
File summaries
File Description
db/migration/V29__Batches_by_timestamp_index.sql Adds and analyzes the timestamp index.
db/migration/V30__List_offsets_min_offset_lookup.sql Rewrites timestamp lookups.
test/.../ListOffsetsJobTest.java Tests offset-order semantics.
test/.../ListOffsetsTimestampScanBenchmarkTest.java Adds the scan benchmark.
main/jooq/.../DefaultSchema.java Updates generated schema metadata.
main/jooq/.../Domains.java Updates generated schema metadata.
main/jooq/.../Indexes.java Updates generated schema metadata.
main/jooq/.../Keys.java Updates generated schema metadata.
main/jooq/.../Routines.java Updates generated schema metadata.
main/jooq/.../Tables.java Updates generated schema metadata.
main/jooq/.../UDTs.java Updates generated schema metadata.
main/jooq/.../routines/BatchTimestamp.java Updates generated schema metadata.
main/jooq/.../routines/DeleteBatchV1.java Updates generated schema metadata.
main/jooq/.../routines/DeleteFilesV1.java Updates generated schema metadata.
main/jooq/.../routines/DeleteTopicV1.java Updates generated schema metadata.
main/jooq/.../routines/FlushCommitRunV2.java Updates generated schema metadata.
main/jooq/.../routines/MarkFileToDeleteV1.java Updates generated schema metadata.
main/jooq/.../enums/* Updates ten generated enum files.
main/jooq/.../tables/* Updates 17 generated table files.
main/jooq/.../tables/records/* Updates 17 generated table-record files.
main/jooq/.../udt/* Updates 22 generated UDT files.
main/jooq/.../udt/paths/* Updates 22 generated UDT path files.
main/jooq/.../udt/records/* Updates 22 generated UDT record files.
Review details
  • Files reviewed: 4/127 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The production migration can block commits for an extended index build, so its rollout safeguards require final human validation.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

storage/inkless/src/test/java/io/aiven/inkless/control_plane/postgres/ListOffsetsTimestampScanBenchmarkTest.java:65

  • The at-90% timing cannot stay flat as depth grows. V30 computes MIN(base_offset) by scanning every qualifying timestamp-index entry, and this benchmark keeps 10% of an increasingly deep partition qualifying. Only past-the-end should stay flat; interpret at-90% as scaling with the qualifying tail rather than the rejected prefix or whole table.
  • Files reviewed: 5/128 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@jeqo
jeqo force-pushed the jeqo/list-offsets-timestamp-index branch 4 times, most recently from 74e9009 to df7e816 Compare October 12, 2026 07:27
@jeqo
jeqo marked this pull request as ready for review October 12, 2026 07:28
@jeqo
jeqo marked this pull request as draft October 12, 2026 09:32
@jeqo
jeqo force-pushed the jeqo/list-offsets-timestamp-index branch from df7e816 to dc46724 Compare October 12, 2026 09:34
@jeqo jeqo changed the title perf(inkless:control-plane): stop list_offsets_v1 from walking the batches table [KC-537] perf(inkless:control-plane): index and rewrite list_offsets_v1 timestamp lookups [KC-537] Oct 12, 2026
@jeqo
jeqo marked this pull request as ready for review October 12, 2026 09:37
@jeqo
jeqo requested a review from viktorsomogyi October 12, 2026 13:18
@viktorsomogyi

Copy link
Copy Markdown
Contributor

Please sign your commit and rebase, then it can be merged :)

…amp lookups

MAX_TIMESTAMP and the concrete-timestamp branch of list_offsets_v1
filter and aggregate on batch_timestamp(timestamp_type,
batch_max_timestamp, log_append_timestamp). No index covered that
expression, so both walked every retained batch of the partition and
evaluated the function per row. Cost grew with retention, and a
partition with a large backlog pushed one ListOffsets past
statement_timeout.

V30 adds an expression index on (topic_id, partition,
batch_timestamp(...)) INCLUDE (base_offset). MAX becomes a backward
range scan. The migration runs ANALYZE so the expression gets
statistics without waiting for autovacuum on a table where 10% churn
can take days.

The index stores the expression as a function call, so batch_timestamp
must stay non-inlinable (LANGUAGE plpgsql): a LANGUAGE sql rewrite
inlines every query to a bare CASE that no longer matches and the
index goes unused silently. The migration header records that, the
measured build rate (about 200k rows/s single-threaded), and that
pre-creating the index CONCURRENTLY before the upgrade is required at
the table sizes V24 designs for.

The index alone does not fix the concrete-timestamp lookup. It picked
one batch with ORDER BY batch_id LIMIT 1, and batches_pkey yields that
order, so the planner could walk the primary key from the smallest
batch_id in the table with the partition and timestamp as filters. It
chose that walk whenever it expected an early hit, and it expects one
in the wrong cases: plpgsql runs the query as a generic plan with a
fixed selectivity for the timestamp bound, and even a custom plan
assumes matches are spread evenly along the walk while timestamps grow
with offset. A consumer seeking to a recent time then scanned nearly
the whole partition, across other partitions' rows.

V31 rewrites each lookup as MIN(base_offset) over the timestamp
predicate, then fetches that batch by offset through the covering
index. No index orders base_offset, so the aggregate has one plan: a
range scan of batches_by_timestamp_idx costing one entry per batch at
or after the target. The result is unchanged because within a
partition batch_id order is offset order; commit_file assigns offsets
and inserts batches under the same logs row lock.

A timestamp at or before the oldest retained batch would make that scan
read every index entry of the partition. V31 therefore reads the oldest
batch first, which has the smallest offset, and skips the scan when it
already qualifies.

Testing

ListOffsetsJobTest gains a case with out-of-order timestamps across
batches, pinning that the lookup returns the first batch by offset and
not the smallest qualifying timestamp. Further cases pin a tie on the
maximum timestamp, a target below the oldest batch, and the clamp to
the log start offset.

ListOffsetsTimestampScanBenchmarkTest (tagged benchmark) measures the
lookups against the retained batch count. The test container can now
stop Flyway at a target version, so the same workload runs against
V29, V30, and V31 in separate databases, with the EXPLAIN shape
following the variant. The harness runs ANALYZE in a committed
transaction: the test pool has autocommit off, and a bare execute is
rolled back on connection return.

The same runs report the cost of the new index. batches_by_timestamp_idx
takes about 59 bytes per row, roughly 46% on top of the existing
indexes of batches. Commit_file writes about 100 bytes more WAL per
batch. Commit latency shows no change beyond run-to-run noise, but the
benchmark inserts one partition in timestamp order, so it does not cover
inserts spread across many partitions.

Assisted-by: Claude Fable 5.1
@jeqo
jeqo force-pushed the jeqo/list-offsets-timestamp-index branch from dc46724 to 154c130 Compare October 12, 2026 15:22
@jeqo

jeqo commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

@viktorsomogyi fixed, let's wait for ci to retry. thanks!

@jeqo
jeqo merged commit b888104 into main Oct 12, 2026
5 checks passed
@jeqo
jeqo deleted the jeqo/list-offsets-timestamp-index branch October 12, 2026 15:56
jeqo added a commit that referenced this pull request Oct 19, 2026
…amp lookups (#799)

MAX_TIMESTAMP and the concrete-timestamp branch of list_offsets_v1
filter and aggregate on batch_timestamp(timestamp_type,
batch_max_timestamp, log_append_timestamp). No index covered that
expression, so both walked every retained batch of the partition and
evaluated the function per row. Cost grew with retention, and a
partition with a large backlog pushed one ListOffsets past
statement_timeout.

V30 adds an expression index on (topic_id, partition,
batch_timestamp(...)) INCLUDE (base_offset). MAX becomes a backward
range scan. The migration runs ANALYZE so the expression gets
statistics without waiting for autovacuum on a table where 10% churn
can take days.

The index stores the expression as a function call, so batch_timestamp
must stay non-inlinable (LANGUAGE plpgsql): a LANGUAGE sql rewrite
inlines every query to a bare CASE that no longer matches and the
index goes unused silently. The migration header records that, the
measured build rate (about 200k rows/s single-threaded), and that
pre-creating the index CONCURRENTLY before the upgrade is required at
the table sizes V24 designs for.

The index alone does not fix the concrete-timestamp lookup. It picked
one batch with ORDER BY batch_id LIMIT 1, and batches_pkey yields that
order, so the planner could walk the primary key from the smallest
batch_id in the table with the partition and timestamp as filters. It
chose that walk whenever it expected an early hit, and it expects one
in the wrong cases: plpgsql runs the query as a generic plan with a
fixed selectivity for the timestamp bound, and even a custom plan
assumes matches are spread evenly along the walk while timestamps grow
with offset. A consumer seeking to a recent time then scanned nearly
the whole partition, across other partitions' rows.

V31 rewrites each lookup as MIN(base_offset) over the timestamp
predicate, then fetches that batch by offset through the covering
index. No index orders base_offset, so the aggregate has one plan: a
range scan of batches_by_timestamp_idx costing one entry per batch at
or after the target. The result is unchanged because within a
partition batch_id order is offset order; commit_file assigns offsets
and inserts batches under the same logs row lock.

A timestamp at or before the oldest retained batch would make that scan
read every index entry of the partition. V31 therefore reads the oldest
batch first, which has the smallest offset, and skips the scan when it
already qualifies.

Testing

ListOffsetsJobTest gains a case with out-of-order timestamps across
batches, pinning that the lookup returns the first batch by offset and
not the smallest qualifying timestamp. Further cases pin a tie on the
maximum timestamp, a target below the oldest batch, and the clamp to
the log start offset.

ListOffsetsTimestampScanBenchmarkTest (tagged benchmark) measures the
lookups against the retained batch count. The test container can now
stop Flyway at a target version, so the same workload runs against
V29, V30, and V31 in separate databases, with the EXPLAIN shape
following the variant. The harness runs ANALYZE in a committed
transaction: the test pool has autocommit off, and a bare execute is
rolled back on connection return.

The same runs report the cost of the new index. batches_by_timestamp_idx
takes about 59 bytes per row, roughly 46% on top of the existing
indexes of batches. Commit_file writes about 100 bytes more WAL per
batch. Commit latency shows no change beyond run-to-run noise, but the
benchmark inserts one partition in timestamp order, so it does not cover
inserts spread across many partitions.

Assisted-by: Claude Fable 5.1
jeqo added a commit that referenced this pull request Oct 19, 2026
…amp lookups (#799)

MAX_TIMESTAMP and the concrete-timestamp branch of list_offsets_v1
filter and aggregate on batch_timestamp(timestamp_type,
batch_max_timestamp, log_append_timestamp). No index covered that
expression, so both walked every retained batch of the partition and
evaluated the function per row. Cost grew with retention, and a
partition with a large backlog pushed one ListOffsets past
statement_timeout.

V30 adds an expression index on (topic_id, partition,
batch_timestamp(...)) INCLUDE (base_offset). MAX becomes a backward
range scan. The migration runs ANALYZE so the expression gets
statistics without waiting for autovacuum on a table where 10% churn
can take days.

The index stores the expression as a function call, so batch_timestamp
must stay non-inlinable (LANGUAGE plpgsql): a LANGUAGE sql rewrite
inlines every query to a bare CASE that no longer matches and the
index goes unused silently. The migration header records that, the
measured build rate (about 200k rows/s single-threaded), and that
pre-creating the index CONCURRENTLY before the upgrade is required at
the table sizes V24 designs for.

The index alone does not fix the concrete-timestamp lookup. It picked
one batch with ORDER BY batch_id LIMIT 1, and batches_pkey yields that
order, so the planner could walk the primary key from the smallest
batch_id in the table with the partition and timestamp as filters. It
chose that walk whenever it expected an early hit, and it expects one
in the wrong cases: plpgsql runs the query as a generic plan with a
fixed selectivity for the timestamp bound, and even a custom plan
assumes matches are spread evenly along the walk while timestamps grow
with offset. A consumer seeking to a recent time then scanned nearly
the whole partition, across other partitions' rows.

V31 rewrites each lookup as MIN(base_offset) over the timestamp
predicate, then fetches that batch by offset through the covering
index. No index orders base_offset, so the aggregate has one plan: a
range scan of batches_by_timestamp_idx costing one entry per batch at
or after the target. The result is unchanged because within a
partition batch_id order is offset order; commit_file assigns offsets
and inserts batches under the same logs row lock.

A timestamp at or before the oldest retained batch would make that scan
read every index entry of the partition. V31 therefore reads the oldest
batch first, which has the smallest offset, and skips the scan when it
already qualifies.

Testing

ListOffsetsJobTest gains a case with out-of-order timestamps across
batches, pinning that the lookup returns the first batch by offset and
not the smallest qualifying timestamp. Further cases pin a tie on the
maximum timestamp, a target below the oldest batch, and the clamp to
the log start offset.

ListOffsetsTimestampScanBenchmarkTest (tagged benchmark) measures the
lookups against the retained batch count. The test container can now
stop Flyway at a target version, so the same workload runs against
V29, V30, and V31 in separate databases, with the EXPLAIN shape
following the variant. The harness runs ANALYZE in a committed
transaction: the test pool has autocommit off, and a bare execute is
rolled back on connection return.

The same runs report the cost of the new index. batches_by_timestamp_idx
takes about 59 bytes per row, roughly 46% on top of the existing
indexes of batches. Commit_file writes about 100 bytes more WAL per
batch. Commit latency shows no change beyond run-to-run noise, but the
benchmark inserts one partition in timestamp order, so it does not cover
inserts spread across many partitions.

Assisted-by: Claude Fable 5.1
jeqo added a commit that referenced this pull request Oct 19, 2026
…amp lookups (#799)

MAX_TIMESTAMP and the concrete-timestamp branch of list_offsets_v1
filter and aggregate on batch_timestamp(timestamp_type,
batch_max_timestamp, log_append_timestamp). No index covered that
expression, so both walked every retained batch of the partition and
evaluated the function per row. Cost grew with retention, and a
partition with a large backlog pushed one ListOffsets past
statement_timeout.

V30 adds an expression index on (topic_id, partition,
batch_timestamp(...)) INCLUDE (base_offset). MAX becomes a backward
range scan. The migration runs ANALYZE so the expression gets
statistics without waiting for autovacuum on a table where 10% churn
can take days.

The index stores the expression as a function call, so batch_timestamp
must stay non-inlinable (LANGUAGE plpgsql): a LANGUAGE sql rewrite
inlines every query to a bare CASE that no longer matches and the
index goes unused silently. The migration header records that, the
measured build rate (about 200k rows/s single-threaded), and that
pre-creating the index CONCURRENTLY before the upgrade is required at
the table sizes V24 designs for.

The index alone does not fix the concrete-timestamp lookup. It picked
one batch with ORDER BY batch_id LIMIT 1, and batches_pkey yields that
order, so the planner could walk the primary key from the smallest
batch_id in the table with the partition and timestamp as filters. It
chose that walk whenever it expected an early hit, and it expects one
in the wrong cases: plpgsql runs the query as a generic plan with a
fixed selectivity for the timestamp bound, and even a custom plan
assumes matches are spread evenly along the walk while timestamps grow
with offset. A consumer seeking to a recent time then scanned nearly
the whole partition, across other partitions' rows.

V31 rewrites each lookup as MIN(base_offset) over the timestamp
predicate, then fetches that batch by offset through the covering
index. No index orders base_offset, so the aggregate has one plan: a
range scan of batches_by_timestamp_idx costing one entry per batch at
or after the target. The result is unchanged because within a
partition batch_id order is offset order; commit_file assigns offsets
and inserts batches under the same logs row lock.

A timestamp at or before the oldest retained batch would make that scan
read every index entry of the partition. V31 therefore reads the oldest
batch first, which has the smallest offset, and skips the scan when it
already qualifies.

Testing

ListOffsetsJobTest gains a case with out-of-order timestamps across
batches, pinning that the lookup returns the first batch by offset and
not the smallest qualifying timestamp. Further cases pin a tie on the
maximum timestamp, a target below the oldest batch, and the clamp to
the log start offset.

ListOffsetsTimestampScanBenchmarkTest (tagged benchmark) measures the
lookups against the retained batch count. The test container can now
stop Flyway at a target version, so the same workload runs against
V29, V30, and V31 in separate databases, with the EXPLAIN shape
following the variant. The harness runs ANALYZE in a committed
transaction: the test pool has autocommit off, and a bare execute is
rolled back on connection return.

The same runs report the cost of the new index. batches_by_timestamp_idx
takes about 59 bytes per row, roughly 46% on top of the existing
indexes of batches. Commit_file writes about 100 bytes more WAL per
batch. Commit latency shows no change beyond run-to-run noise, but the
benchmark inserts one partition in timestamp order, so it does not cover
inserts spread across many partitions.

Assisted-by: Claude Fable 5.1
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.

3 participants