Repository navigation
perf(inkless:control-plane): index and rewrite list_offsets_v1 timestamp lookups [KC-537] - #799
Conversation
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
🔵 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 computesMIN(base_offset)by scanning every qualifying timestamp-index entry, and this benchmark keeps 10% of an increasingly deep partition qualifying. Onlypast-the-endshould stay flat; interpretat-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
74e9009 to
df7e816
Compare
df7e816 to
dc46724
Compare
|
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
dc46724 to
154c130
Compare
|
@viktorsomogyi fixed, let's wait for ci to retry. thanks! |
…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
…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
…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
The concrete-timestamp branch of
list_offsets_v1(theoffsetsForTimespath) picks its batch withORDER BY batch_id LIMIT 1.batches_pkeyyields that order, so the planner can walk the primary key across the wholebatchestable 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 largebatchesbacklog, every ListOffsets then runs intostatement_timeout. MAX_TIMESTAMP has a related problem: no index coversbatch_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: createsbatches_by_timestamp_idxon(topic_id, partition, batch_timestamp(...)) INCLUDE (base_offset), then runsANALYZE batches. Flyway runs it in a transaction, so the build holds aSHARElock onbatchesand blockscommit_fileuntil 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 theCREATE INDEX CONCURRENTLY IF NOT EXISTSstatement from the migration header before upgrading. The migration then only runsANALYZE.batch_timestampmust stayLANGUAGE 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 twobatcheslookups becomeMIN(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 readslogs.deleted_at, so it cannot be hand-applied to a schema older than V28.V30: new index
V31:
list_offsets_v1changesOnly the two
batcheslookups change (MAX_TIMESTAMP and the concrete timestamp). Thelogs-only branches are untouched. The diff is against the V28 definition, the last one before this PR.Diff of
list_offsets_v1, V28 to V31Testing
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 throughPostgresControlPlaneTest.ListOffsetsJobTest.maxTimestampTieReturnsFirstBatchByOffset,timestampBeforeOldestBatchReturnsOldestBatch, andtimestampLookupIsClampedToLogStartOffset: pin the tie case, both branches of the oldest-batch check, and thelog_start_offsetclamp.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
icarries timestampbase + i, statistics refreshed after each seed step, local Postgres 17 container. Microseconds perlist_offsets_v1call throughListOffsetsJobat 400k retained batches.ts@startmatches the first row,ts@90%matches the last 10%,ts@end+1matches nothing.batches_pkeyfor the lookup.ts@startis fast only because the first row matches.batches_pkey, so the two production-shaped cases barely move.ts@90%reads 40k entries andts@end+1reads none.ts@startwould 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.
batchessizes in MB, and the index adds 59 bytes per row (22.5 MB at 400k rows, about 4.1 GB at 70M rows).batches_pkeybatches_by_filebatches_by_last_offset_covering_idxbatches_by_timestamp_idxThe new index is 46% of the existing index total (49.1 MB) and 23% of the whole relation.
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.