Skip to content

fix(inkless:consume): stop a slow ListOffsets from hanging the broker [KC-537] - #798

Merged
viktorsomogyi merged 2 commits into
mainfrom
jeqo/fix-list-offsets
Oct 12, 2026
Merged

viktorsomogyi merged 2 commits into
mainfrom
jeqo/fix-list-offsets

Conversation

@jeqo

@jeqo jeqo commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

A slow list_offsets_v1 call stalled every ListOffsets on the broker and left one parked thread per arriving request. Two broker-side properties combined: AbstractControlPlane.listOffsets was synchronized, so every call waited on the slowest one, and FetchOffsetHandler used 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.

DeleteRecordsInterceptor had 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 returns REQUEST_TIMED_OUT instead of starting another thread.

Commits

  • fix(inkless:control-plane): don't serialize listOffsets across callers: moves the lock from the abstract base into InMemoryControlPlane, 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

  • New fetch.offset.thread.pool.size (default 8). Queue capacity is 100 per thread; a ListOffsets that arrives when the queue is full fails immediately with REQUEST_TIMED_OUT. Clients treat it as retriable, and the admin client retries it, unlike UNKNOWN_SERVER_ERROR.

Metrics

  • New FetchOffsetRejectedRate under InklessFetchOffsetMetrics. Rejections are also counted in FetchOffsetErrorRate.
  • ThreadPoolMonitor groups inkless-fetch-offset-metadata and inkless-delete-records now report pool size, active threads, and queue depth.

Testing

  • FetchOffsetHandlerTest.rejectedSubmissionFailsAllFutures and DeleteRecordsInterceptorTest.rejectedSubmissionRespondsWithError: a rejected submission completes every partition with REQUEST_TIMED_OUT instead of escaping to the request handler.
  • FetchOffsetHandlerTest.fullQueueRejectsOnlyTheOverflowingJob and DeleteRecordsInterceptorTest.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

  • A task that purgatory cancels while it is still queued keeps its queue slot until a worker skips it, so cancelled requests can still fill the queue during a stall. Kafka's remote log reader pool behaves the same way.
  • The diskless DeleteRecords leg does not apply the request's timeout.ms (the TODO use purgatory in DeleteRecordsInterceptor). The bounded pool caps admitted work; purgatory would cap how long a request waits. Separate change.
  • Reader.createBoundedThreadPool has 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.

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

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 ListOffsets synchronization 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
jeqo force-pushed the jeqo/fix-list-offsets branch from 93b8a29 to e6a5ad6 Compare October 9, 2026 13:29
@jeqo
jeqo marked this pull request as ready for review October 9, 2026 13:32
@jeqo jeqo changed the title fix(inkless:consume): stop a slow ListOffsets from wedging the broker [KC-537] fix(inkless:consume): stop a slow ListOffsets from hanging the broker [KC-537] Oct 9, 2026
@jeqo
jeqo force-pushed the jeqo/fix-list-offsets branch from e6a5ad6 to 18718cb Compare October 11, 2026 19:12
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
jeqo force-pushed the jeqo/fix-list-offsets branch from 18718cb to 586864d Compare October 11, 2026 19:47
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
@viktorsomogyi
viktorsomogyi merged commit 9c98296 into main Oct 12, 2026
6 checks passed
@viktorsomogyi
viktorsomogyi deleted the jeqo/fix-list-offsets branch October 12, 2026 14:23
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
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