Skip to content

Scalable Topics: pulsar::st queue consumer — per-segment fan-in over a mux receive queue - #605

Merged
merlimat merged 14 commits into
apache:mainfrom
merlimat:scalable-topics-queue-consumer
Aug 29, 2026
Merged

Scalable Topics: pulsar::st queue consumer — per-segment fan-in over a mux receive queue#605
merlimat merged 14 commits into
apache:mainfrom
merlimat:scalable-topics-queue-consumer

Conversation

@merlimat

Copy link
Copy Markdown
Contributor

Motivation

Next step in the pulsar::st (scalable-topics) SDK after the producer (#601) and its e2e tests (#603): the queue consumer for a single scalable topic.

A scalable topic is a DAG of per-segment backing topics. A queue consumer runs one DagWatchSession and a Shared-subscription classic pulsar::Consumer per segment — active and sealed (a split seals the parent without migrating its backlog, so its messages are only drainable through the sealed segment). Each segment's receive loop stamps the segment id onto every message and fans it into a shared, bounded mux receive queue; the user receives from that queue. Acks route back to the owning segment by the message id's segment id. Layout changes add consumers for new segments and close ones that leave the DAG.

Scope here is the single-topic core (mirrors the Java v5 ScalableQueueConsumer). Dead-letter and namespace-subscription modes are deferred to follow-ups.

What's in this PR

  • Received-message plumbinglib/st/MessageImpl + MessageCore accessors (wrap a classic Message + the segment-qualified MessageId).
  • Classic consumer segment seamClientImpl::subscribeSegmentAsync (a single-topic subscribe that accepts a segment:// topic), guarded so the public path is unchanged.
  • Queue consumer corelib/st/ReceiveQueue (bounded fan-in mux with back-pressure) and lib/st/QueueConsumerImpl (per-segment consumers, ack/nack routing by segment id, layout reconcile, sealed-segment drain-and-close), wired through QueueConsumerCore and StClientImpl::subscribeQueueAsync.
  • Classic terminated-topic handling (see below) — ClientConnection + ConsumerImpl.
  • End-to-end teststests/st/StQueueConsumerE2ETest.cc: a produce→consume round-trip and a cross-segment fan-in test over a split topic, each self-contained (creates and splits its own topic via the admin REST API, same as the producer e2e).

Note: a necessary classic-client change

The classic C++ client never handled CommandReachedEndOfTopic (BaseCommand type 27) — it fell through to default: and closed the connection as an "invalid message from server". A queue consumer that subscribes to sealed segments would therefore churn its connection (close → reconnect → re-subscribe → end-of-topic again) instead of learning the segment drained.

This PR handles it: dispatch the command to the target consumer (mirroring handleActiveConsumerChange), and have ConsumerImpl surface ResultTopicTerminated on the async receive path once the prefetch queue drains — matching the Java client, whose consumers close a drained sealed segment on TopicTerminated. The broker only sends the command once the consumer's read position reaches the terminate marker, so buffered messages always drain first. The change is additive (a flag that is only ever set on a terminated topic), and it's covered by a new ConsumerTest.testReceiveAsyncAfterTopicTerminated.

Testing

  • pulsar-st-tests: 95/95 green (88 broker-free unit + 5 producer e2e + 2 consumer e2e) against a 5.0.0-M1 standalone.
  • Classic ConsumerTest 34/34 and ProducerTest terminated tests 2/2 green (no regression from the terminated-topic change).
  • clang-format-11 and clang-tidy (analyzer + performance) clean on all changed files.

Follow-ups

merlimat added 8 commits July 16, 2026 11:41
The consumer receive path needs the impl side of detail::MessageCore, which was
declared but never defined (like ProducerCore was before the producer landed):

- lib/st/MessageImpl.h: pulsar::st::MessageImpl, a thin view over a classic
  pulsar::Message (owns payload + metadata) plus the segment-qualified st
  MessageId minted on receive, with an optional topic override for namespace mode.
- lib/st/MessageCore.cc: the out-of-line MessageCore accessors, forwarding to it.

All accessors map to public classic Message getters except sequenceId(), which the
classic public API does not expose; it returns -1 for now (a TODO to revisit with a
classic accessor when the Stream consumer needs it, rather than touch the classic
API here). Shared by all three consumer types.
The scalable-topics queue/stream consumers attach a Shared consumer per active
segment, on the segment's segment:// backing topic — which the public subscribe
path rejects. Add ClientImpl::subscribeSegmentAsync, mirroring the producer's
createSegmentProducerAsync: the private single-topic subscribeToTopicsAsyncV2
gains an allowSegmentTopic flag (default false; the segment-domain rejection
becomes isSegment() && !allowSegmentTopic), and the new public method calls it
with true. A segment is a non-partitioned persistent topic, so it lands in the
single-ConsumerImpl branch of handleSubscribe unchanged. No broker pin (the Java
consumer path does not pin; the DAG-provided owner resolves via segment:// lookup).
Wrap two over-length lines that clang-format-11 (the CI style) breaks but
clang-format-18 leaves on one line: the subscribeToTopicsAsyncV2 call in
ClientImpl::subscribeSegmentAsync and the MessageImpl constructor signature.
Formatting only, no behavior change.
Implement the single-topic scalable-topics queue consumer (a port of the Java
v5 ScalableQueueConsumer). A Shared subscription is fanned across every segment
of the topic — active AND sealed, since a sealed segment may still hold
undrained messages — with one classic Shared-subscription pulsar::Consumer per
segment created through the ClientImpl::subscribeSegmentAsync seam.

- ReceiveQueue: a bounded fan-in mux. Per-segment receive loops offer() messages;
  the user receiveAsync()es them in FIFO order. offer() returns a future that
  completes only when the queue has room, so a slow consumer back-pressures the
  underlying segment consumers' flow control rather than buffering unboundedly.
  Timed receives fail with ResultTimeout; close() fails every waiter.
- QueueConsumerImpl: owns one DagWatchSession and the per-segment consumers.
  Each segment loop stamps the segment id onto every message (MessageIdFactory)
  and fans it into the shared queue. Acks/nacks route back to the owning
  segment's consumer via the message id's segment id. Layout changes add
  consumers for new segments and close ones that left the DAG; a segment that
  reports TopicTerminated (a drained sealed segment) is closed and dropped.
- QueueConsumerCore: thin forwarders mapping MessageImplPtr to MessageCore.
- Wire ClientImpl::subscribeQueueAsync (was notImplementedYet), mirroring
  createProducerAsync: build the impl, start(), then mint the public core.

Transactional acknowledge is not implemented yet (logged and dropped); the
dead-letter and namespace-subscription paths are deferred to later slices.
The classic client never handled BaseCommand::REACHED_END_OF_TOPIC (type 27):
handleIncomingCommand fell through to default: and closed the whole connection
as an "invalid message from server". Any consumer of a terminated topic — and
every scalable-topics queue consumer, which subscribes to sealed segments to
drain their backlog — would therefore churn its connection (close, reconnect,
re-subscribe, reach end of topic again) instead of learning the topic ended.

Handle it: dispatch REACHED_END_OF_TOPIC to the target consumer (mirroring
handleActiveConsumerChange), and have ConsumerImpl surface ResultTopicTerminated
on the async receive path once the prefetch queue drains — matching the Java
client, whose consumers close a drained sealed segment on TopicTerminated. The
broker only sends the command once the consumer's read position reaches the
terminate marker, so buffered messages always drain before termination.

Scope is the async receiveAsync path (what the scalable consumer uses); the
blocking sync receive() is unchanged (it would need to interrupt a parked
pop(), and a terminated topic there already behaves as "no more messages").

Adds ConsumerTest.testReceiveAsyncAfterTopicTerminated.
End-to-end coverage for the scalable-topics queue consumer against a real
broker, gated on PULSAR_ST_E2E (the broker-free unit run skips it):

- testProduceThenConsumeRoundTrip: produce 25 keyed messages, receive and ack
  all of them through a Shared subscription, assert the payloads round-trip and
  every received id carries a real segment id.
- testConsumeAcrossSplitSegments: over a topic pre-split into two active
  segments, produce 60 keyed messages and assert they fan in from both segments
  through the mux receive queue — the multi-segment path the queue consumer
  exists for, and the case that exercises draining the sealed parent segment.

Both pass against apachepulsar/pulsar:5.0.0-M1. The CI wiring (docker-compose +
run-unit-tests.sh) that runs these lands with the producer-e2e harness.
Mirror the producer e2e (apache#603): each queue-consumer e2e test now creates its
own fresh-named scalable topic — and, for the fan-in test, splits it — through
the admin REST API, instead of consuming harness-pre-created, CLI-pre-split
fixed topics (st-e2e-queue / st-e2e-queue-split). The tests are now
self-contained and keep working under the REST-driven harness, where nothing
is pre-arranged for them.

Links HttpHelper.cc into pulsar-st-tests for the makePut/makePostRequest calls.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds the scalable-topics (pulsar::st) queue consumer implementation with per-segment fan-in over a bounded mux receive queue, plus classic-client support for terminated topics and new tests.

Changes:

  • Implemented QueueConsumerImpl with per-segment classic consumers and a bounded ReceiveQueue for fan-in + back-pressure.
  • Added classic handling for CommandReachedEndOfTopic and surfaced ResultTopicTerminated on async receive after backlog drain.
  • Added E2E tests for ST queue consumer and a unit test for terminated-topic async receive behavior.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/st/StQueueConsumerE2ETest.cc New broker-backed E2E tests for queue consumer round-trip and split-segment fan-in
tests/ConsumerTest.cc Adds regression test ensuring async receive returns ResultTopicTerminated after terminate + drain
lib/st/StClientImpl.cc Wires subscribeQueueAsync to the new QueueConsumerImpl
lib/st/ReceiveQueue.h Declares bounded mux receive queue API used for per-segment fan-in
lib/st/ReceiveQueue.cc Implements bounded offer/receive semantics with timeout support
lib/st/QueueConsumerImpl.h Declares scalable-topics queue consumer core implementation
lib/st/QueueConsumerImpl.cc Implements per-segment subscribe, receive loops, ack routing, and layout reconciliation
lib/st/QueueConsumerCore.cc Adapts internal QueueConsumerImpl to public QueueConsumerCore API
lib/st/MessageImpl.h Introduces internal received-message wrapper (classic Message + segment-qualified MessageId)
lib/st/MessageCore.cc Forwards MessageCore accessors to MessageImpl
lib/ConsumerImpl.h Adds reachedEndOfTopic() hook and termination flag
lib/ConsumerImpl.cc Implements termination handling and surfaces ResultTopicTerminated on async receive
lib/ClientImpl.h Adds subscribeSegmentAsync and an internal allow-segment flag for subscription
lib/ClientImpl.cc Implements subscribeSegmentAsync and guards segment-topic rejection behind a flag
lib/ClientConnection.h Declares handler for CommandReachedEndOfTopic
lib/ClientConnection.cc Dispatches REACHED_END_OF_TOPIC and routes it to the target consumer
include/pulsar/st/detail/QueueConsumerCore.h Allows st::ClientImpl to mint QueueConsumerCore

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/st/QueueConsumerImpl.cc
Comment thread lib/st/ReceiveQueue.cc
Comment thread lib/st/ReceiveQueue.cc
Comment thread tests/st/StQueueConsumerE2ETest.cc
Comment thread tests/st/StQueueConsumerE2ETest.cc
Comment thread lib/ConsumerImpl.cc
…subscribe

- The queue-consumer subscribe callback applied std::move to a pulsar::Consumer,
  whose virtual destructor suppresses the move constructor, so the move bound to
  the copy constructor: clang-tidy performance-move-const-arg, which failed the
  Lint job. Copy the handle directly (a shared-impl copy), matching StProducerImpl.

- On ResultTopicTerminated the drained segment's consumer was erased, but the
  sealed segment stays in the DAG, so the next layout reconcile re-subscribed it
  and the broker redelivered its still-unacked messages as duplicates. Track
  drained segments and skip re-subscribing them; prune the set when a segment
  leaves the DAG.

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The fan-in design here is the right shape: offer() gating the segment re-arm gives real back-pressure rather than an unbounded buffer, and subscribing sealed segments as well as active ones is exactly the subtlety a split introduces. The subscribeSegmentAsync seam is properly confined too — allowSegmentTopic defaults to false, and only the st queue consumer passes true, so the public subscribe path is genuinely unchanged.

My concerns cluster on the sealed-segment drain path, which is both the most intricate new logic and the least exercised by the tests.

The drain path closes a segment's consumer as soon as the broker reports end-of-topic, but end-of-topic only means the classic prefetch queue drained — up to 1000 messages from that segment may still be sitting in the mux queue or in the application's hands. Their acks are then silently dropped, and drainedSegments_ guarantees the segment is never reattached. This is the half of the earlier Copilot thread that the drainedSegments_ fix did not cover: it cures the duplicate redelivery, but the acks are still lost.

Separately, the segment receive loop recurses rather than iterates. ConsumerImpl::receiveAsync calls back inline when a message is already buffered, offer() returns an already-completed future whenever the queue has room, and SharedState::addListener runs listeners inline — so each message adds stack frames instead of unwinding. An actively-receiving application makes this worse, not better, because offer() then hands messages straight to the parked receiver and the buffer never fills to apply the brake.

On testing: neither e2e test ever drains a sealed segment. testConsumeAcrossSplitSegments splits before producing, so the sealed parent is empty — the scenario the PR description leads with ("a split seals the parent without migrating its backlog") has no coverage. That is where I would put the next test, and it would likely surface the first finding directly.

Comment thread lib/st/QueueConsumerImpl.cc
Comment thread lib/st/QueueConsumerImpl.cc
Comment thread lib/ConsumerImpl.cc
Comment thread lib/ConsumerImpl.cc
Comment thread lib/st/QueueConsumerImpl.cc
Comment thread lib/st/QueueConsumerImpl.cc Outdated
Comment thread lib/st/QueueConsumerImpl.cc
Comment thread tests/st/StQueueConsumerE2ETest.cc
Comment thread lib/st/ReceiveQueue.cc
Two gaps in the CommandReachedEndOfTopic handling, from apache#605 review:

- hasReachedEndOfTopic_ was never cleared, so after a reconnect (which clears
  the prefetch queue and re-sends flow permits) a receiveAsync landing before
  redelivery arrived would report a stale ResultTopicTerminated — and the
  scalable queue consumer would then drop the segment permanently. Termination
  stops new publications, not redelivery of unacked messages: clear the flag on
  each new broker session; the broker re-sends the command once the re-created
  consumer's read position reaches the terminate marker again.

- The sync receive paths ignored the flag entirely: the untimed receive()
  blocked forever on a drained terminated topic and the timed one returned
  ResultTimeout. Both now fail fast with ResultTopicTerminated when the flag is
  set and the queue is empty, agreeing with the async path. (A receive already
  parked in pop() when the command arrives still waits — waking it would need
  an interruptible queue.)

Extends ConsumerTest.testReceiveAsyncAfterTopicTerminated to assert both sync
overloads.
… review

- Ack loss on drained sealed segments: end-of-topic only means the classic
  prefetch queue drained — messages already fanned into the mux queue or held
  by the application still need the segment consumer to route their acks.
  Track outstanding (fanned-in minus acked/nacked) messages per segment and
  defer the drain-close until the count reaches zero; the consumer stays in
  the map for ack routing meanwhile.

- Receive-loop recursion: receiveAsync completes inline when a message is
  prefetched and offer()'s future is already complete while the queue has
  room, so the re-arm chain grew the stack once per message. Hop the re-arm
  through the IO executor so the chain is a loop again.

- A segment subscribe that failed off the first-layout path was only retried
  on the next DAG push, which may not come for hours: back it with a bounded
  backoff retry (10 attempts, 100->500ms, the producer's constants), skipping
  segments that left the DAG or drained.

- Message::topic() reported the internal segment:// backing topic; pass the
  scalable topic as the override so the public contract holds.

- A configured deadLetterPolicy was silently ignored; it now fails the
  subscribe with ResultOperationNotSupported, and the API docs say so, until
  dead-lettering lands.

- ReceiveQueue timed receives never cancelled their timer when a message won
  the race, accumulating live timers proportional to receive rate x timeout;
  park {promise, timer} together and cancel on delivery and on close.
The scenario the queue consumer exists for — a split seals the parent WITHOUT
migrating its backlog — had no coverage: both existing e2e tests split before
producing, so the sealed parent was always empty and no end-of-topic was ever
delivered. Produce 1200 messages (more than the classic prefetch queue and the
mux capacity), split, then consume: every pre-split message must arrive through
the sealed parent, and reattaching a second consumer on the same subscription
must receive nothing — proving the acks routed through the drain-deferred
close instead of being dropped. Fails on the pre-fix code.
…s policy

Since mid-August every new PR-validation run on this repo fails at workflow
startup (startup_failure, 0s, "workflow file issue") with no workflow change
on main — the same repo-wide pattern as the docker/build-push-action break
fixed by apache#602. The ASF GitHub Actions policy requires external actions to be
pinned to a specific git hash, and jidicula/clang-format-action@v4.11.0 was
the one remaining tag-pinned external action after apache#602 pinned the docker
ones. Pin it to the commit the v4.11.0 tag points to (f62da5e, unchanged
behavior).

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks — all nine points are addressed, and several of the fixes are better than what I asked for.

The drain path (QueueConsumerImpl.cc:262-290, 308-333) is now correct. The outstanding_ refcount is incremented in onMessageFannedIn before the message is offered to the mux queue and decremented only when the application settles it, so an end-of-topic arriving with messages still in flight parks the segment in terminatedSegments_ and keeps its consumer in segmentConsumers_ for ack routing; the close runs from onMessageSettled once the count reaches zero. Settling after the ack is enqueued rather than before is the right ordering, and takeDrainedSegmentLocked handing the future back to be closed outside the lock is a nice touch.

The receive loop (QueueConsumerImpl.cc:294-297) now hops through executor_->postWork, so it is a loop rather than recursion. That was the crash-class one.

The terminated-topic flag is cleared on each new broker session (ConsumerImpl.cc:344), right alongside incomingMessages_.clear() — exactly where it needed to go, and the comment explains why termination does not cancel redelivery.

Sync receive now checks the flag in both receiveHelper overloads plus after a timeout wake-up (ConsumerImpl.cc:1254, 1288, 1301), and ConsumerTest asserts both sync paths agree with the async one.

Also fixed: the bounded subscribe retry with backoff and a still-wanted check (subscribeSegmentWithRetry), topic() now reporting the scalable topic, dead-lettering failing loudly with ResultOperationNotSupported instead of silently ignoring the policy, and the timed-receive timer now cancelled on delivery and on close.

testDrainSealedSegmentBacklog is the test I was hoping for. Producing before the split so the backlog is stranded in the sealed parent, 1200 messages to overrun both the prefetch and mux queues, asserting every message carries segment id 0, and then reattaching a second consumer to prove the acks stuck — that last assertion is what makes it a real regression test for the ack-loss bug rather than a happy-path walk.

Two non-blocking notes left in the threads rather than held against the PR: the per-call (rather than per-message) decrement in onMessageSettled, and the parked-pop() residual you documented in receiveHelper. Both are follow-up material at most.

I also checked whether closing a terminated segment on nack strands the redelivery, and I do not think it does: the receive loop has already returned by then, so an attached consumer would not deliver it either — the broker releasing it back to the subscription is the better outcome. Your inline comment gets this right.

One merge-readiness item that is not about your code: the PR validation workflow hit a startup_failure on 60c763b (run 33213225434, "This run likely failed because of a workflow file issue"), so only CodeQL and Analyze(cpp) actually ran on this head — the build and unit tests did not. It passed on the previous head, and nothing here touches workflow files, so it looks like infrastructure. Worth a re-run before merge so the classic ConsumerTest changes are actually exercised in CI.

Rebasing onto main would also pick up #604, which landed after this branch.

@lhotari

lhotari commented Aug 28, 2026

Copy link
Copy Markdown
Member

Following up on the CI note in my review — I dug into why PR validation never ran on 60c763b, and it is not something you did.

Two Docker actions in our workflows were pinned to SHAs that are not on the ASF org-wide allowlist:

  • docker/setup-buildx-action@4d04d5d9
  • docker/setup-qemu-action@ce360397

When a workflow references a non-allowlisted action, the run fails with a bare "Startup failure"per ASF infra, "no logs, no notifications, and the PR may appear green because no checks ran." That is exactly what happened here: CodeQL and Analyze (cpp) passed, so the PR looked green, while the build and the unit tests never executed. So the classic ConsumerTest changes in this PR are still unverified by CI.

#610 fixes it repo-wide, bumping both to the latest approved revisions from apache/infrastructure-actions (and updating the GitHub-owned actions, which the allowlist implicitly trusts, to their latest majors). Its own PR validation run is currently in_progress rather than failing at startup, which is the confirmation that the allowlist was the blocker.

Once #610 is merged, a rebase here should give this PR a real CI run. My approval stands — I would just want to see the build and tests actually go green on the rebased head before it lands.

merlimat pushed a commit that referenced this pull request Aug 29, 2026
…ions (#610)

Two Docker actions were pinned to SHAs that are not on the ASF
org-wide allowlist:

  docker/setup-buildx-action@4d04d5d9
  docker/setup-qemu-action@ce360397

A workflow referencing a non-allowlisted action fails with a bare
"Startup failure": no logs, no notifications, and the PR can look
green because no checks ran. PR #605 hit exactly this — its
"PR validation" run never started, so the build and unit tests did
not execute while CodeQL and Analyze(cpp) still reported success.

Bump both to the latest approved revisions from
apache/infrastructure-actions, and record the tag in a trailing
comment so Dependabot can track them:

  docker/setup-buildx-action  -> 37fe6310 # v4.3.0
  docker/setup-qemu-action    -> 96fe6ef7 # v4.2.0
  docker/build-push-action       53b7df96 # v7.3.0 (already latest)

Also update the GitHub-owned actions, which the ASF allowlist treats
as implicitly trusted, to their latest majors:

  actions/checkout         v3, v4  -> v7
  actions/cache            v3, v4  -> v6
  actions/upload-artifact  master  -> v7
  github/codeql-action     v3      -> v4
  jidicula/clang-format-action v4.11.0 -> v4.18.0 (allowlisted via *)

actions/upload-artifact was tracking @master, an unpinned moving
target. All four call sites already use unique artifact names, so
the v4+ one-artifact-per-name rule is satisfied.
…-consumer

# Conflicts:
#	.github/workflows/ci-pr-validation.yaml
@merlimat
merlimat merged commit 582c407 into apache:main Aug 29, 2026
14 checks passed
@merlimat
merlimat deleted the scalable-topics-queue-consumer branch August 31, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants