Skip to content

Scalable Topics: pulsar::st stream consumer — controller-assigned ordered consumption with cumulative acks - #611

Open
merlimat wants to merge 5 commits into
apache:mainfrom
merlimat:scalable-topics-stream-consumer
Open

Scalable Topics: pulsar::st stream consumer — controller-assigned ordered consumption with cumulative acks#611
merlimat wants to merge 5 commits into
apache:mainfrom
merlimat:scalable-topics-stream-consumer

Conversation

@merlimat

Copy link
Copy Markdown
Contributor

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 / broker SubscriptionCoordinator): 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

  • Consumer assignment session — the controller-registration protocol the queue consumer never needed: Commands::newScalableTopicSubscribe, ClientConnection registries for pushed CommandScalableTopicAssignmentUpdate (by consumer id) and one-shot CommandScalableTopicSubscribeResponse callbacks (by request id), both drained on connection close — the same idiom as the DAG-watch session. ConsumerAssignmentSession resolves 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 mux ReceiveQueue, 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 one acknowledgeCumulativeAsync per segment). TopicTerminated closes 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::negativeAckRedeliveryDelay is deliberately not wired — a stream consumer has no negative-ack path.
  • receiveMultiReceiveQueue::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.
  • Seam fix — the readCompacted guard only accepted the persistent:// domain and would have rejected every segment:// subscribe; relaxed only on the internal allowSegmentTopic path.
  • e2e (runs under the existing st CI phase with no workflow changes) — three tests against a real broker:
    • ordered round-trip on one segment + one cumulative ack of the last message settles the stream (a reattached consumer receives nothing);
    • the DAG-replay scenario: produce → split → produce → attach Earliest; the initial assignment carries only the sealed parent, acking-as-we-go drains it, the broker's drain detection pushes the children, and every parent message arrives before any child message;
    • two initial segments drained via receiveMulti with no intermediate acks; acknowledging only the final message advances both cursors through its position vector.
  • Harness dedup — the admin REST helpers the three e2e suites each carried now live in tests/st/StE2EAdmin.h (the follow-up flagged in Scalable Topics: pulsar::st queue consumer — per-segment fan-in over a mux receive queue #605).

Deferred (fails loudly, not silently)

  • PIP-486 bucket-shared assignments (consumers outnumbering segments): the subscribe fails with ResultOperationNotSupported when 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.
  • Namespace-mode subscriptions, same as the queue consumer.

Testing

  • pulsar-st-tests: 105/105 — 94 broker-free unit tests (incl. new session + receiveMulti suites) and 11 e2e (5 producer + 3 queue + 3 stream) against a 5.0.0-M1 standalone.
  • clang-format-11 and clang-tidy (the Lint job's checks) clean on all new/changed translation units.

…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
merlimat requested review from lhotari and shibd and removed request for lhotari August 31, 2026 23:12
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.

1 participant