Skip to content

Commit 582c407

Browse files
authored
Scalable Topics: pulsar::st queue consumer — per-segment fan-in over a mux receive queue (#605)
* st: received-message plumbing — MessageImpl + MessageCore accessors 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. * st: classic consumer segment seam — subscribeSegmentAsync 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). * st: clang-format-11 line wrapping in segment seam + MessageImpl ctor 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. * st: queue consumer core — per-segment fan-in over a mux receive queue 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. * Handle CommandReachedEndOfTopic on the consumer receive path 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. * st: queue consumer produce->consume e2e test 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. * st: drive queue-consumer e2e split through the admin REST API Mirror the producer e2e (#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. * st: address #605 review — clang-tidy move + drained-segment re-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. * Terminated-topic completeness on the consumer: reconnect + sync receive Two gaps in the CommandReachedEndOfTopic handling, from #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. * st: queue consumer drain, robustness, and honesty fixes from #605 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. * st: e2e for draining a sealed segment's backlog with sticking acks 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. * Pin jidicula/clang-format-action to its commit SHA for the ASF actions 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 #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 #602 pinned the docker ones. Pin it to the commit the v4.11.0 tag points to (f62da5e, unchanged behavior).
1 parent 4468ce4 commit 582c407

18 files changed

Lines changed: 1496 additions & 7 deletions

‎include/pulsar/st/QueueConsumer.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ struct QueueConsumerConfig {
8181
* redelivery delay). Default-constructed `AckPolicy` when unset. */
8282
AckPolicy ackPolicy;
8383
/** Optional dead-letter policy: route messages to a dead-letter topic after
84-
* repeated redelivery. Default unset (no dead-lettering). */
84+
* repeated redelivery. Default unset (no dead-lettering). Not implemented yet:
85+
* setting it fails the subscribe with `ResultOperationNotSupported`. */
8586
std::optional<DeadLetterPolicy> deadLetterPolicy;
8687
/** Arbitrary client-side consumer properties (reported in topic stats). Default empty. */
8788
Properties properties;
@@ -328,6 +329,9 @@ class QueueConsumerBuilder {
328329
* Route messages to a dead-letter topic after repeated redelivery (spec §7.2).
329330
* QueueConsumer only.
330331
*
332+
* Not implemented yet: setting a policy currently fails the subscribe with
333+
* `ResultOperationNotSupported` rather than silently ignoring it.
334+
*
331335
* @param policy the dead-letter policy (max redeliveries, DLQ topic name, etc.).
332336
* Default unset (no dead-lettering).
333337
* @return `*this` for chaining.

‎include/pulsar/st/detail/QueueConsumerCore.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ namespace pulsar::st {
3333
class QueueConsumerImpl;
3434
using QueueConsumerImplPtr = std::shared_ptr<QueueConsumerImpl>;
3535
class Transaction;
36+
class ClientImpl; // lib/st — mints consumer cores from subscribeQueueAsync
3637

3738
namespace detail {
3839

@@ -60,6 +61,7 @@ class PULSAR_PUBLIC QueueConsumerCore {
6061

6162
private:
6263
friend class ClientCore;
64+
friend class ::pulsar::st::ClientImpl;
6365
explicit QueueConsumerCore(QueueConsumerImplPtr impl) : impl_(std::move(impl)) {}
6466

6567
QueueConsumerImplPtr impl_;

‎lib/ClientConnection.cc‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,27 @@ void ClientConnection::handleActiveConsumerChange(const proto::CommandActiveCons
853853
}
854854
}
855855

856+
void ClientConnection::handleReachedEndOfTopic(const proto::CommandReachedEndOfTopic& reachedEndOfTopic) {
857+
LOG_DEBUG(cnxString() << "Received reached-end-of-topic, consumer_id: "
858+
<< reachedEndOfTopic.consumer_id());
859+
Lock lock(mutex_);
860+
ConsumersMap::iterator it = consumers_.find(reachedEndOfTopic.consumer_id());
861+
if (it != consumers_.end()) {
862+
ConsumerImplPtr consumer = it->second.lock();
863+
if (consumer) {
864+
lock.unlock();
865+
consumer->reachedEndOfTopic();
866+
} else {
867+
consumers_.erase(reachedEndOfTopic.consumer_id());
868+
LOG_DEBUG(cnxString() << "Ignoring reached-end-of-topic for already destroyed consumer "
869+
<< reachedEndOfTopic.consumer_id());
870+
}
871+
} else {
872+
LOG_DEBUG(cnxString() << "Got invalid consumer Id in reached-end-of-topic "
873+
<< reachedEndOfTopic.consumer_id());
874+
}
875+
}
876+
856877
void ClientConnection::handleIncomingMessage(const proto::CommandMessage& msg, bool isChecksumValid,
857878
proto::BrokerEntryMetadata& brokerEntryMetadata,
858879
proto::MessageMetadata& msgMetadata, SharedBuffer& payload) {
@@ -997,6 +1018,10 @@ void ClientConnection::handleIncomingCommand(BaseCommand& incomingCmd) {
9971018
handleScalableTopicUpdate(incomingCmd.scalabletopicupdate());
9981019
break;
9991020

1021+
case BaseCommand::REACHED_END_OF_TOPIC:
1022+
handleReachedEndOfTopic(incomingCmd.reachedendoftopic());
1023+
break;
1024+
10001025
default:
10011026
LOG_WARN(cnxString() << "Received invalid message from server");
10021027
close(Error{ResultDisconnected, cnxString() + "Received invalid message from server"});

‎lib/ClientConnection.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class CommandGetLastMessageIdResponse;
105105
class CommandLookupTopicResponse;
106106
class CommandPartitionedTopicMetadataResponse;
107107
class CommandProducerSuccess;
108+
class CommandReachedEndOfTopic;
108109
class CommandScalableTopicUpdate;
109110
class CommandSendReceipt;
110111
class CommandSendError;
@@ -265,6 +266,7 @@ class PULSAR_PUBLIC ClientConnection : public std::enable_shared_from_this<Clien
265266
proto::BaseCommand& incomingCmd);
266267

267268
void handleActiveConsumerChange(const proto::CommandActiveConsumerChange& change);
269+
void handleReachedEndOfTopic(const proto::CommandReachedEndOfTopic& reachedEndOfTopic);
268270
void handleIncomingCommand(proto::BaseCommand& incomingCmd);
269271
void handleIncomingMessage(const proto::CommandMessage& msg, bool isChecksumValid,
270272
proto::BrokerEntryMetadata& brokerEntryMetadata,

‎lib/ClientImpl.cc‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -604,8 +604,15 @@ void ClientImpl::subscribeAsync(const std::string& topic, const std::string& sub
604604
[callback](const auto& value) { invokeLegacyCallback<Consumer>(callback, value); });
605605
}
606606

607+
void ClientImpl::subscribeSegmentAsync(const std::string& topic, const std::string& subscriptionName,
608+
const ConsumerConfiguration& conf, SubscribeV2Callback callback) {
609+
subscribeToTopicsAsyncV2(topic, subscriptionName, conf, std::move(callback),
610+
/* allowSegmentTopic */ true);
611+
}
612+
607613
void ClientImpl::subscribeToTopicsAsyncV2(const std::string& topic, const std::string& subscriptionName,
608-
const ConsumerConfiguration& conf, SubscribeV2Callback callback) {
614+
const ConsumerConfiguration& conf, SubscribeV2Callback callback,
615+
bool allowSegmentTopic) {
609616
LOG_INFO("Subscribing on Topic :" << topic);
610617
TopicNamePtr topicName;
611618
{
@@ -627,7 +634,7 @@ void ClientImpl::subscribeToTopicsAsyncV2(const std::string& topic, const std::s
627634
}
628635
}
629636

630-
if (topicName->isSegment()) {
637+
if (topicName->isSegment() && !allowSegmentTopic) {
631638
callback(segmentTopicRejected(topic));
632639
return;
633640
}

‎lib/ClientImpl.h‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ class ClientImpl : public std::enable_shared_from_this<ClientImpl> {
103103
CreateProducerV2Callback callback,
104104
const std::optional<std::string>& assignedBrokerUrl = std::nullopt);
105105

106+
/**
107+
* Subscribe a consumer to a single `segment://` scalable-topic segment, bypassing the
108+
* segment-domain rejection applied to the public subscribe path. The scalable-topics
109+
* queue/stream consumers use this to attach a per-segment consumer.
110+
*/
111+
void subscribeSegmentAsync(const std::string& topic, const std::string& subscriptionName,
112+
const ConsumerConfiguration& conf, SubscribeV2Callback callback);
113+
106114
void subscribeAsync(const std::string& topic, const std::string& subscriptionName,
107115
const ConsumerConfiguration& conf, const SubscribeCallback& callback);
108116

@@ -203,7 +211,8 @@ class ClientImpl : public std::enable_shared_from_this<ClientImpl> {
203211
ConsumerConfiguration conf, SubscribeV2Callback callback);
204212

205213
void subscribeToTopicsAsyncV2(const std::string& topic, const std::string& subscriptionName,
206-
const ConsumerConfiguration& conf, SubscribeV2Callback callback);
214+
const ConsumerConfiguration& conf, SubscribeV2Callback callback,
215+
bool allowSegmentTopic = false);
207216

208217
void subscribeToTopicsAsyncV2(const std::vector<std::string>& topics, const std::string& subscriptionName,
209218
const ConsumerConfiguration& conf, SubscribeV2Callback callback);

‎lib/ConsumerImpl.cc‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,11 @@ Result ConsumerImpl::handleCreateConsumer(const ClientConnectionPtr& cnx, Result
337337
incomingMessages_.clear();
338338
possibleSendToDeadLetterTopicMessages_.clear();
339339
backoff_.reset();
340+
// Re-derive end-of-topic from the new session: termination stops new publications, not
341+
// redelivery of unacked messages, so a stale flag would report ResultTopicTerminated in
342+
// the window before redeliveries arrive. The broker re-sends CommandReachedEndOfTopic
343+
// once this consumer's read position reaches the terminate marker again.
344+
hasReachedEndOfTopic_ = false;
340345
if (!messageListener_ && config_.getReceiverQueueSize() == 0) {
341346
// Complicated logic since we don't have a isLocked() function for mutex
342347
if (waitingForZeroQueueSizeMessage) {
@@ -823,6 +828,24 @@ void ConsumerImpl::activeConsumerChanged(bool isActive) {
823828
}
824829
}
825830

831+
void ConsumerImpl::reachedEndOfTopic() {
832+
hasReachedEndOfTopic_ = true;
833+
// If nothing is buffered there is nothing left to deliver, so complete any waiting async
834+
// receives with ResultTopicTerminated now. When messages are still buffered they drain through
835+
// the normal path first, and the next receive observes the flag (see receiveAsync).
836+
Lock lock(pendingReceiveMutex_);
837+
if (incomingMessages_.empty()) {
838+
Message msg;
839+
while (!pendingReceives_.empty()) {
840+
ReceiveCallback callback = pendingReceives_.front();
841+
pendingReceives_.pop();
842+
listenerExecutor_->postWork(std::bind(&ConsumerImpl::notifyPendingReceivedCallback,
843+
get_shared_this_ptr(), ResultTopicTerminated, msg,
844+
callback));
845+
}
846+
}
847+
}
848+
826849
void ConsumerImpl::internalConsumerChangeListener(bool isActive) {
827850
try {
828851
if (isActive) {
@@ -1189,6 +1212,14 @@ void ConsumerImpl::receiveAsync(const ReceiveCallback& callback) {
11891212
messageProcessed(msg);
11901213
msg = interceptors_->beforeConsume(Consumer(shared_from_this()), msg);
11911214
callback(ResultOk, msg);
1215+
} else if (hasReachedEndOfTopic_) {
1216+
// Terminated topic with nothing left buffered: fail the receive rather than parking it
1217+
// forever waiting for a message that will never arrive.
1218+
pendingReceiveMutexLock.unlock();
1219+
if (config_.getReceiverQueueSize() == 0) {
1220+
mutexlock.unlock();
1221+
}
1222+
callback(ResultTopicTerminated, msg);
11921223
} else if (config_.getReceiverQueueSize() == 0) {
11931224
pendingReceives_.push(callback);
11941225
// If connection_ is nullptr, sendFlowPermitsToBroker does nothing.
@@ -1217,6 +1248,13 @@ Result ConsumerImpl::receiveHelper(Message& msg) {
12171248
return fetchSingleMessageFromBroker(msg);
12181249
}
12191250

1251+
// A drained terminated topic has nothing left to deliver: fail fast instead of blocking
1252+
// forever, matching the async path. (A receive already parked in pop() when end-of-topic
1253+
// arrives still waits — the queue only wakes on a message or on close.)
1254+
if (hasReachedEndOfTopic_ && incomingMessages_.empty()) {
1255+
return ResultTopicTerminated;
1256+
}
1257+
12201258
if (!incomingMessages_.pop(msg)) {
12211259
return ResultInterrupted;
12221260
}
@@ -1247,6 +1285,10 @@ Result ConsumerImpl::receiveHelper(Message& msg, int timeout) {
12471285
return ResultInvalidConfiguration;
12481286
}
12491287

1288+
if (hasReachedEndOfTopic_ && incomingMessages_.empty()) {
1289+
return ResultTopicTerminated;
1290+
}
1291+
12501292
if (incomingMessages_.pop(msg, std::chrono::milliseconds(timeout))) {
12511293
messageProcessed(msg);
12521294
msg = interceptors_->beforeConsume(Consumer(shared_from_this()), msg);
@@ -1255,6 +1297,10 @@ Result ConsumerImpl::receiveHelper(Message& msg, int timeout) {
12551297
if (state_ != Ready) {
12561298
return ResultAlreadyClosed;
12571299
}
1300+
// Waking up empty on a terminated topic means drained, not merely idle.
1301+
if (hasReachedEndOfTopic_ && incomingMessages_.empty()) {
1302+
return ResultTopicTerminated;
1303+
}
12581304
return ResultTimeout;
12591305
}
12601306
}

‎lib/ConsumerImpl.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ class ConsumerImpl : public ConsumerImplBase {
103103
proto::MessageMetadata& msgMetadata, SharedBuffer& payload);
104104
void messageProcessed(Message& msg, bool track = true);
105105
void activeConsumerChanged(bool isActive);
106+
// The broker signalled that this (terminated) topic has no more messages beyond what has already
107+
// been delivered. Surface ResultTopicTerminated to receivers once the prefetch queue drains.
108+
void reachedEndOfTopic();
106109
inline CommandSubscribe_SubType getSubType();
107110
inline CommandSubscribe_InitialPosition getInitialPosition();
108111

@@ -185,6 +188,10 @@ class ConsumerImpl : public ConsumerImplBase {
185188

186189
private:
187190
std::atomic_bool waitingForZeroQueueSizeMessage;
191+
// Set when the broker sends CommandReachedEndOfTopic and cleared again on each new broker
192+
// session (termination does not cancel redelivery of unacked messages); a drained receive
193+
// then yields ResultTopicTerminated instead of parking forever.
194+
std::atomic_bool hasReachedEndOfTopic_{false};
188195
std::shared_ptr<ConsumerImpl> get_shared_this_ptr();
189196
bool uncompressMessageIfNeeded(const ClientConnectionPtr& cnx, const proto::MessageIdData& messageIdData,
190197
const proto::MessageMetadata& metadata, SharedBuffer& payload,

‎lib/st/MessageCore.cc‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
#include <pulsar/st/detail/MessageCore.h>
20+
21+
#include "MessageImpl.h"
22+
23+
namespace pulsar::st::detail {
24+
25+
// Thin forwarders to the hidden MessageImpl (see ProducerCore.cc for the same pattern).
26+
std::span<const std::byte> MessageCore::data() const { return impl_->data(); }
27+
MessageId MessageCore::id() const { return impl_->id(); }
28+
std::optional<std::string_view> MessageCore::key() const { return impl_->key(); }
29+
const Properties& MessageCore::properties() const { return impl_->properties(); }
30+
Timestamp MessageCore::publishTime() const { return impl_->publishTime(); }
31+
std::optional<Timestamp> MessageCore::eventTime() const { return impl_->eventTime(); }
32+
int64_t MessageCore::sequenceId() const { return impl_->sequenceId(); }
33+
std::optional<std::string_view> MessageCore::producerName() const { return impl_->producerName(); }
34+
std::string_view MessageCore::topic() const { return impl_->topic(); }
35+
int MessageCore::redeliveryCount() const { return impl_->redeliveryCount(); }
36+
std::optional<std::string_view> MessageCore::replicatedFrom() const { return impl_->replicatedFrom(); }
37+
38+
} // namespace pulsar::st::detail

‎lib/st/MessageImpl.h‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
#pragma once
20+
21+
#include <pulsar/Message.h>
22+
#include <pulsar/st/MessageId.h>
23+
#include <pulsar/st/detail/MessageCore.h>
24+
25+
#include <chrono>
26+
#include <cstddef>
27+
#include <cstdint>
28+
#include <optional>
29+
#include <span>
30+
#include <string>
31+
#include <string_view>
32+
#include <utility>
33+
34+
namespace pulsar::st {
35+
36+
/**
37+
* INTERNAL — the received message behind `detail::MessageCore`.
38+
*
39+
* A thin view over a classic `pulsar::Message` (which owns the payload and metadata)
40+
* plus the segment-qualified `pulsar::st::MessageId` minted on the receive path. An
41+
* optional `topicOverride` carries the scalable topic identity in namespace mode
42+
* (a plain segment consumer reports the segment backing topic otherwise).
43+
*/
44+
class MessageImpl {
45+
public:
46+
MessageImpl(pulsar::Message message, MessageId id,
47+
std::optional<std::string> topicOverride = std::nullopt)
48+
: classic_(std::move(message)), id_(std::move(id)), topicOverride_(std::move(topicOverride)) {}
49+
50+
std::span<const std::byte> data() const {
51+
return {static_cast<const std::byte*>(classic_.getData()), classic_.getLength()};
52+
}
53+
const MessageId& id() const { return id_; }
54+
std::optional<std::string_view> key() const {
55+
if (!classic_.hasPartitionKey()) return std::nullopt;
56+
return std::string_view(classic_.getPartitionKey());
57+
}
58+
const Properties& properties() const { return classic_.getProperties(); }
59+
Timestamp publishTime() const { return fromMillis(classic_.getPublishTimestamp()); }
60+
std::optional<Timestamp> eventTime() const {
61+
const uint64_t millis = classic_.getEventTimestamp();
62+
return millis != 0 ? std::optional<Timestamp>(fromMillis(millis)) : std::nullopt;
63+
}
64+
// The classic public Message API does not expose the message's sequence id; populating it
65+
// would require reaching into pulsar::MessageImpl's metadata, i.e. touching the classic API.
66+
// TODO: revisit when the Stream consumer needs it (a classic Message::getSequenceId() accessor).
67+
int64_t sequenceId() const { return -1; }
68+
std::optional<std::string_view> producerName() const {
69+
const std::string& name = classic_.getProducerName();
70+
return name.empty() ? std::nullopt : std::optional<std::string_view>(name);
71+
}
72+
std::string_view topic() const {
73+
return topicOverride_ ? std::string_view(*topicOverride_) : std::string_view(classic_.getTopicName());
74+
}
75+
int redeliveryCount() const { return classic_.getRedeliveryCount(); }
76+
std::optional<std::string_view> replicatedFrom() const {
77+
const std::optional<const std::string*> from = classic_.getReplicatedFrom();
78+
if (!from || *from == nullptr) return std::nullopt;
79+
return std::string_view(**from);
80+
}
81+
82+
private:
83+
static Timestamp fromMillis(uint64_t millis) {
84+
return Timestamp(std::chrono::milliseconds(static_cast<std::int64_t>(millis)));
85+
}
86+
87+
pulsar::Message classic_;
88+
MessageId id_;
89+
std::optional<std::string> topicOverride_;
90+
};
91+
92+
} // namespace pulsar::st

0 commit comments

Comments
 (0)