Repository navigation
fix(inkless:consume): stop a slow ListOffsets from hanging the broker [KC-537] - #798
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
Broker-wide concurrency and backpressure changes merit final human validation despite focused tests.
Pull request overview
Prevents slow control-plane offset queries from exhausting broker request threads.
Changes:
- Removes broker-wide
ListOffsetssynchronization while preserving in-memory locking. - Adds bounded fetch-offset and delete-records executors with rejection handling.
- Adds pool configuration, rejection metrics, documentation, and focused tests.
File summaries
| File | Description |
|---|---|
storage/inkless/src/test/java/io/aiven/inkless/delete/DeleteRecordsInterceptorTest.java |
Tests rejected delete submissions. |
storage/inkless/src/test/java/io/aiven/inkless/control_plane/AbstractControlPlaneListOffsetsConcurrencyTest.java |
Verifies concurrent offset queries. |
storage/inkless/src/test/java/io/aiven/inkless/consume/FetchOffsetHandlerTest.java |
Tests rejected offset jobs. |
storage/inkless/src/test/java/io/aiven/inkless/config/InklessConfigTest.java |
Tests the new configuration. |
storage/inkless/src/main/java/io/aiven/inkless/delete/DeleteRecordsInterceptor.java |
Bounds delete-records execution. |
storage/inkless/src/main/java/io/aiven/inkless/control_plane/InMemoryControlPlane.java |
Retains required local synchronization. |
storage/inkless/src/main/java/io/aiven/inkless/control_plane/AbstractControlPlane.java |
Removes global offset serialization. |
storage/inkless/src/main/java/io/aiven/inkless/consume/InklessFetchOffsetMetrics.java |
Records rejected offset requests. |
storage/inkless/src/main/java/io/aiven/inkless/consume/FetchOffsetHandler.java |
Adds bounded execution and rejection handling. |
storage/inkless/src/main/java/io/aiven/inkless/config/InklessConfig.java |
Defines the pool-size setting. |
docs/inkless/metrics.rst |
Documents the rejection metric. |
docs/inkless/configs.rst |
Documents the pool-size setting. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
jeqo
force-pushed
the
jeqo/fix-list-offsets
branch
from
October 9, 2026 13:29
93b8a29 to
e6a5ad6
Compare
jeqo
marked this pull request as ready for review
October 9, 2026 13:32
jeqo
force-pushed
the
jeqo/fix-list-offsets
branch
from
October 11, 2026 19:12
e6a5ad6 to
18718cb
Compare
AbstractControlPlane.listOffsets was synchronized, so every ListOffsets on the broker waited for the slowest single control-plane query. The lock only existed for InMemoryControlPlane, whose listOffset was the one implementation without its own synchronization. Move the lock there, matching findBatchesForExistingPartition, so PostgresControlPlane runs queries concurrently.
jeqo
force-pushed
the
jeqo/fix-list-offsets
branch
from
October 11, 2026 19:47
18718cb to
586864d
Compare
FetchOffsetHandler and DeleteRecordsInterceptor used cached thread pools, which create one thread per submission when no worker is idle. A slow control plane turned every arriving request into a parked OS thread with no upper bound. Replace both with fixed pools and a bounded queue. A submission that finds the queue full fails on the caller with RejectedExecutionException, which the handler turns into a retriable REQUEST_TIMED_OUT response instead of letting the exception escape to the request handler. The fetch offset pool size is set by the new fetch.offset.thread.pool.size config (default 8), with a queue of 100 slots per thread. Rejections are counted by the new FetchOffsetRejectedRate metric. DeleteRecords is an admin path with a low arrival rate and uses a fixed pool of 2 threads with a queue of 200 slots. Both pools report queue depth and active threads through ThreadPoolMonitor. A task that purgatory cancels while it is still queued keeps its queue slot until a worker reaches it and skips it. This is the same behavior as the remote log reader pool in Kafka. During a control-plane stall, cancelled requests can therefore still fill the queue, and new submissions are rejected until the workers catch up. Assisted-by: Claude Opus 5.5
jeqo
force-pushed
the
jeqo/fix-list-offsets
branch
from
October 12, 2026 06:57
586864d to
227e247
Compare
viktorsomogyi
approved these changes
Oct 12, 2026
jeqo
added a commit
that referenced
this pull request
Oct 19, 2026
… [KC-537] (#798) * fix(inkless:control-plane): don't serialize listOffsets across callers AbstractControlPlane.listOffsets was synchronized, so every ListOffsets on the broker waited for the slowest single control-plane query. The lock only existed for InMemoryControlPlane, whose listOffset was the one implementation without its own synchronization. Move the lock there, matching findBatchesForExistingPartition, so PostgresControlPlane runs queries concurrently. * fix(inkless:consume): bound the fetch offset and delete records pools FetchOffsetHandler and DeleteRecordsInterceptor used cached thread pools, which create one thread per submission when no worker is idle. A slow control plane turned every arriving request into a parked OS thread with no upper bound. Replace both with fixed pools and a bounded queue. A submission that finds the queue full fails on the caller with RejectedExecutionException, which the handler turns into a retriable REQUEST_TIMED_OUT response instead of letting the exception escape to the request handler. The fetch offset pool size is set by the new fetch.offset.thread.pool.size config (default 8), with a queue of 100 slots per thread. Rejections are counted by the new FetchOffsetRejectedRate metric. DeleteRecords is an admin path with a low arrival rate and uses a fixed pool of 2 threads with a queue of 200 slots. Both pools report queue depth and active threads through ThreadPoolMonitor. A task that purgatory cancels while it is still queued keeps its queue slot until a worker reaches it and skips it. This is the same behavior as the remote log reader pool in Kafka. During a control-plane stall, cancelled requests can therefore still fill the queue, and new submissions are rejected until the workers catch up. Assisted-by: Claude Opus 5.5
jeqo
added a commit
that referenced
this pull request
Oct 19, 2026
… [KC-537] (#798) * fix(inkless:control-plane): don't serialize listOffsets across callers AbstractControlPlane.listOffsets was synchronized, so every ListOffsets on the broker waited for the slowest single control-plane query. The lock only existed for InMemoryControlPlane, whose listOffset was the one implementation without its own synchronization. Move the lock there, matching findBatchesForExistingPartition, so PostgresControlPlane runs queries concurrently. * fix(inkless:consume): bound the fetch offset and delete records pools FetchOffsetHandler and DeleteRecordsInterceptor used cached thread pools, which create one thread per submission when no worker is idle. A slow control plane turned every arriving request into a parked OS thread with no upper bound. Replace both with fixed pools and a bounded queue. A submission that finds the queue full fails on the caller with RejectedExecutionException, which the handler turns into a retriable REQUEST_TIMED_OUT response instead of letting the exception escape to the request handler. The fetch offset pool size is set by the new fetch.offset.thread.pool.size config (default 8), with a queue of 100 slots per thread. Rejections are counted by the new FetchOffsetRejectedRate metric. DeleteRecords is an admin path with a low arrival rate and uses a fixed pool of 2 threads with a queue of 200 slots. Both pools report queue depth and active threads through ThreadPoolMonitor. A task that purgatory cancels while it is still queued keeps its queue slot until a worker reaches it and skips it. This is the same behavior as the remote log reader pool in Kafka. During a control-plane stall, cancelled requests can therefore still fill the queue, and new submissions are rejected until the workers catch up. Assisted-by: Claude Opus 5.5
jeqo
added a commit
that referenced
this pull request
Oct 19, 2026
… [KC-537] (#798) * fix(inkless:control-plane): don't serialize listOffsets across callers AbstractControlPlane.listOffsets was synchronized, so every ListOffsets on the broker waited for the slowest single control-plane query. The lock only existed for InMemoryControlPlane, whose listOffset was the one implementation without its own synchronization. Move the lock there, matching findBatchesForExistingPartition, so PostgresControlPlane runs queries concurrently. * fix(inkless:consume): bound the fetch offset and delete records pools FetchOffsetHandler and DeleteRecordsInterceptor used cached thread pools, which create one thread per submission when no worker is idle. A slow control plane turned every arriving request into a parked OS thread with no upper bound. Replace both with fixed pools and a bounded queue. A submission that finds the queue full fails on the caller with RejectedExecutionException, which the handler turns into a retriable REQUEST_TIMED_OUT response instead of letting the exception escape to the request handler. The fetch offset pool size is set by the new fetch.offset.thread.pool.size config (default 8), with a queue of 100 slots per thread. Rejections are counted by the new FetchOffsetRejectedRate metric. DeleteRecords is an admin path with a low arrival rate and uses a fixed pool of 2 threads with a queue of 200 slots. Both pools report queue depth and active threads through ThreadPoolMonitor. A task that purgatory cancels while it is still queued keeps its queue slot until a worker reaches it and skips it. This is the same behavior as the remote log reader pool in Kafka. During a control-plane stall, cancelled requests can therefore still fill the queue, and new submissions are rejected until the workers catch up. Assisted-by: Claude Opus 5.5
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.
A slow
list_offsets_v1call stalled every ListOffsets on the broker and left one parked thread per arriving request. Two broker-side properties combined:AbstractControlPlane.listOffsetswassynchronized, so every call waited on the slowest one, andFetchOffsetHandlerused a cached thread pool, so every waiting request became an OS thread. Neither makes the query fast. Together they let one slow query block the broker after the query itself has finished. Each commit removes one property and stands on its own. See the commit messages for the reasoning behind each step.DeleteRecordsInterceptorhad the same cached pool and the same unbounded thread growth when the control plane is slow. It never had the lock, so only the pool change applies to it. The pool is fixed at 2 threads with a queue of 200 and has no config. With a slow control plane, deletes now queue behind each other, and a full queue returnsREQUEST_TIMED_OUTinstead of starting another thread.Commits
fix(inkless:control-plane): don't serialize listOffsets across callers: moves the lock from the abstract base intoInMemoryControlPlane, the only implementation that needed it.fix(inkless:consume): bound the fetch offset and delete records pools: fixed pools with a bounded queue; a full queue rejects and the request gets an error response instead of a parked thread.Config
fetch.offset.thread.pool.size(default 8). Queue capacity is 100 per thread; a ListOffsets that arrives when the queue is full fails immediately withREQUEST_TIMED_OUT. Clients treat it as retriable, and the admin client retries it, unlikeUNKNOWN_SERVER_ERROR.Metrics
FetchOffsetRejectedRateunderInklessFetchOffsetMetrics. Rejections are also counted inFetchOffsetErrorRate.ThreadPoolMonitorgroupsinkless-fetch-offset-metadataandinkless-delete-recordsnow report pool size, active threads, and queue depth.Testing
FetchOffsetHandlerTest.rejectedSubmissionFailsAllFuturesandDeleteRecordsInterceptorTest.rejectedSubmissionRespondsWithError: a rejected submission completes every partition withREQUEST_TIMED_OUTinstead of escaping to the request handler.FetchOffsetHandlerTest.fullQueueRejectsOnlyTheOverflowingJobandDeleteRecordsInterceptorTest.fullQueueRejectsOnlyTheOverflowingRequest: run the real bounded pools with a blocked control plane. Exactly the submission past the queue capacity is rejected, the rejected meter is marked once, and every admitted request completes after the control plane unblocks.Follow-ups
timeout.ms(theTODO use purgatoryinDeleteRecordsInterceptor). The bounded pool caps admitted work; purgatory would cap how long a request waits. Separate change.Reader.createBoundedThreadPoolhas the same pool shape inline. Left as is until there is a reason to standardize the reject policy and queue depth.Related
The control-plane query fix (V30 index and V31 lookup rewrite) is #799. Neither depends on the other.