Scalable Topics: pulsar::st stream consumer — controller-assigned ordered consumption with cumulative acks - #611
Open
merlimat wants to merge 5 commits into
Open
Conversation
…consumers The stream consumer is told what to consume, unlike the queue consumer which watches the DAG and attaches to every segment itself: ordering across splits and merges is enforced broker-side by the SubscriptionCoordinator, which withholds a child segment from the assignment until every parent has drained. The client's job is to register with the controller and subscribe to exactly the assigned segments. This adds the controller session (a port of the Java v5 ScalableConsumerClient): - Commands::newScalableTopicSubscribe and the ScalableConsumerType constants. - ClientConnection: a consumer-session registry dispatching pushed CommandScalableTopicAssignmentUpdate by consumer id, one-shot callbacks for CommandScalableTopicSubscribeResponse correlated by request id, and close notification for both — the same idiom as the DAG-watch session registry. - ConsumerAssignmentSession: resolve the controller leader through a one-shot DAG-watch lookup (TLS-aware; behind a proxy or before leader election it falls back to the regular lookup path, where any broker forwards the subscribe), register + subscribe, apply epoch-gated assignments, replay the current assignment on listener registration, reconnect with backoff after the initial assignment and fail fast before it. close() drops only the local registration; the broker reaps the registration through its grace timer (Java parity).
…mulative acks StreamConsumerImpl, the Java v5 ScalableStreamConsumer port. The client enforces no DAG ordering itself — the broker's subscription coordinator withholds child segments from the assignment until every parent drains — so the consumer subscribes Exclusive to exactly the assigned segments (ConsumerAssignmentSession), runs one receive loop per segment into the shared mux ReceiveQueue, and stamps each delivered message with a position vector: a snapshot of every segment's latest-delivered id taken at delivery time, so one cumulative acknowledgment advances all cursors (fanned out as one acknowledgeCumulativeAsync per segment). - Assignment reconcile: released segments close immediately (the shared cursor redelivers unacked messages to the next owner); newly assigned segments retry only rebalance collisions (ConsumerBusy / ConsumerAssignError — the previous Exclusive owner has not released yet) with bounded backoff, and fail fast on anything else. - TopicTerminated closes the segment immediately and drops its bookkeeping; a late cumulative ack for it is a no-op (Java parity — the queue consumer's outstanding-count deferral does not transfer to cumulative acks). - ReceiveQueue::receiveMultiAsync: batch receive collecting until full or deadline (possibly short, including empty on a quiet timeout), with the same executor-hop guard against inline recursion, plus broker-free unit tests. - MessageIdFactory grows the position-vector overload; readCompacted is now accepted for segment topics on the subscribe seam (they are persistent in all but the scheme); AckPolicy::negativeAckRedeliveryDelay is deliberately not wired (a stream consumer has no negative-ack path). - PIP-486 bucket-shared assignments are not supported yet: the subscribe fails loudly when the initial assignment carries bucket ranges. Wired through StreamConsumerCore and StClientImpl::subscribeStreamAsync.
Three end-to-end tests against a real scalable-topics broker: - testOrderedRoundTripAndCumulativeAck: one segment, one Exclusive consumer — delivery is exactly the publish order, and one cumulative ack of the last message settles the whole stream (a reattached consumer receives nothing). - testDrainsSealedParentBeforeChildren: the DAG-replay scenario. Everything produced before a split sits in the sealed parent; the broker withholds the children from the assignment until the parent is drained for this subscription, so acking as we go is what unblocks them — and every parent message must arrive before any child message. Exercises the controller registration, the initial assignment, the drain detection, and the pushed assignment update end to end. - testCumulativeAckCoversAllSegments: two initial segments drained through receiveMulti without intermediate acks; acknowledging only the final message advances both cursors through its position vector. The admin REST helpers the three e2e suites had each carried are extracted into tests/st/StE2EAdmin.h (the dedup flagged in apache#605).
…and mux queue clang-tidy performance-unnecessary-value-param (the Lint job's config): the connectAndSubscribe/subscribeOn/collectMulti parameters are shared-state handles only read (and copied into continuations) by the bodies, so take them by const reference instead of by value.
merlimat
requested review from
lhotari and
shibd
and removed request for
lhotari
August 31, 2026 23:12
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.
Motivation
Next consumer in the
pulsar::st(scalable-topics) SDK after the queue consumer (#605): the stream consumer — ordered consumption over the segment DAG.The design headline, from the Java v5 reference (
ScalableStreamConsumer/ScalableConsumerClient/ brokerSubscriptionCoordinator): the client enforces no DAG ordering itself. The broker's subscription coordinator withholds an active child segment from the consumer's assignment until every parent has drained for this subscription (per-subscription backlog reaching zero; merges fall out of the same rule). The client's job is therefore to register with the controller, subscribe Exclusive to exactly the assigned segments, and mux them — per-key order across splits and merges holds as long as the application keeps acknowledging. That makes acking-as-you-go a liveness requirement: a consumer that defers all acks to the end keeps the children withheld forever (documented on the API and encoded in the e2e).What's in this PR
Commands::newScalableTopicSubscribe,ClientConnectionregistries for pushedCommandScalableTopicAssignmentUpdate(by consumer id) and one-shotCommandScalableTopicSubscribeResponsecallbacks (by request id), both drained on connection close — the same idiom as the DAG-watch session.ConsumerAssignmentSessionresolves the controller leader through a one-shot DAG-watch lookup (TLS-aware; behind a proxy or before leader election it falls back to the regular lookup path, where any broker forwards), registers + subscribes, applies epoch-gated assignments, replays the current assignment on listener registration, reconnects with backoff after the initial assignment and fails fast before it.close()drops only the local registration; the broker reaps it through its grace timer (Java parity).StreamConsumerImpl— one Exclusive classic consumer per assigned segment, receive loops fanning into the shared muxReceiveQueue, and every delivered message stamped with a position vector: a snapshot of all segments' latest-delivered ids taken at delivery time, so one cumulative acknowledgment advances every cursor (fanned out as oneacknowledgeCumulativeAsyncper segment).TopicTerminatedcloses the segment immediately and late acks no-op — a deliberate divergence from the queue consumer's deferred close, which doesn't transfer because one cumulative ack settles an unbounded prefix. Newly assigned segments retry only rebalance collisions (ConsumerBusy/ConsumerAssignError, the previous Exclusive owner not having released yet) with bounded backoff, and fail fast on anything else.AckPolicy::negativeAckRedeliveryDelayis deliberately not wired — a stream consumer has no negative-ack path.receiveMulti—ReceiveQueue::receiveMultiAsync(greedy drain, then wait with the remaining deadline until full or timeout; a short or empty batch is a normal result), with broker-free unit tests. This also closes G1 from the API review for the stream side.readCompactedguard only accepted thepersistent://domain and would have rejected everysegment://subscribe; relaxed only on the internalallowSegmentTopicpath.receiveMultiwith no intermediate acks; acknowledging only the final message advances both cursors through its position vector.tests/st/StE2EAdmin.h(the follow-up flagged in Scalable Topics:pulsar::stqueue consumer — per-segment fan-in over a mux receive queue #605).Deferred (fails loudly, not silently)
ResultOperationNotSupportedwhen the initial assignment carries bucket ranges. The whole-segment Exclusive path covers every assignment the controller produces otherwise, and the deferral avoids porting the drain-release machinery blind.Testing
pulsar-st-tests: 105/105 — 94 broker-free unit tests (incl. new session +receiveMultisuites) and 11 e2e (5 producer + 3 queue + 3 stream) against a5.0.0-M1standalone.