Skip to content

Commit 403ee7f

Browse files
authored
[fix][client] Scalable topics: stream consumer must not acknowledge on close or topic-removal (#26172)
1 parent 3529aba commit 403ee7f

2 files changed

Lines changed: 90 additions & 14 deletions

File tree

pulsar-broker/src/test/java/org/apache/pulsar/client/api/v5/V5MultiTopicStreamConsumerTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import lombok.Cleanup;
3030
import org.apache.pulsar.client.api.v5.config.SubscriptionInitialPosition;
3131
import org.apache.pulsar.client.api.v5.schema.Schema;
32+
import org.awaitility.Awaitility;
3233
import org.testng.annotations.Test;
3334

3435
/**
@@ -176,6 +177,80 @@ public void cumulativeAckCoversEveryTopicSeenSoFar() throws Exception {
176177
+ (stale != null ? stale.value() : ""));
177178
}
178179

180+
@Test
181+
public void closeWithoutAckDoesNotAcknowledgeAnything() throws Exception {
182+
// A stream consumer acknowledges only via explicit acknowledgeCumulative. Closing
183+
// the consumer (or, equivalently, a crash) must NOT acknowledge anything on the
184+
// application's behalf. Regression test for a close-time flush that used to ack each
185+
// per-topic consumer up to its prefetch frontier — silently acking messages the
186+
// application had buffered but never received. We assert it at the broker: after
187+
// receiving every message but acknowledging none, the subscription backlog must
188+
// still cover every message. (With the old flush this dropped to 0.)
189+
String topicA = topicName("a");
190+
String topicB = topicName("b");
191+
admin.scalableTopics().createScalableTopic(topicA, 1);
192+
admin.scalableTopics().createScalableTopic(topicB, 1);
193+
194+
@Cleanup
195+
Producer<String> pa = v5Client.newProducer(Schema.string()).topic(topicA).create();
196+
@Cleanup
197+
Producer<String> pb = v5Client.newProducer(Schema.string()).topic(topicB).create();
198+
199+
String subscription = "multi-stream-close-no-ack";
200+
StreamConsumer<String> first = v5Client.newStreamConsumer(Schema.string())
201+
.namespace(getNamespace())
202+
.subscriptionName(subscription)
203+
.subscriptionInitialPosition(SubscriptionInitialPosition.EARLIEST)
204+
.subscribe();
205+
206+
int n = 5;
207+
for (int i = 0; i < n; i++) {
208+
pa.newMessage().value("a-" + i).send();
209+
pb.newMessage().value("b-" + i).send();
210+
}
211+
212+
// Receive every message — advancing each per-topic prefetch frontier — but ack
213+
// NOTHING. If close flushed that frontier, all 2n would be acknowledged here.
214+
Set<String> receivedFirst = new HashSet<>();
215+
long deadline = System.currentTimeMillis() + 20_000L;
216+
while (receivedFirst.size() < 2 * n && System.currentTimeMillis() < deadline) {
217+
Message<String> msg = first.receive(Duration.ofSeconds(1));
218+
if (msg != null) {
219+
receivedFirst.add(msg.value());
220+
}
221+
}
222+
assertEquals(receivedFirst.size(), 2 * n, "first consumer should receive every message");
223+
first.close();
224+
225+
// At the broker, every message must still be in the backlog: close acked nothing.
226+
Awaitility.await().untilAsserted(() ->
227+
assertEquals(subscriptionBacklog(subscription, topicA, topicB), 2L * n,
228+
"close must not acknowledge anything; full backlog must remain"));
229+
}
230+
231+
/**
232+
* Total delivered-but-unacked backlog for {@code subscription} across every segment of
233+
* the given scalable topics. Reads the broker's Topic reference directly — the topics
234+
* REST admin does not serve the {@code segment://} domain.
235+
*/
236+
private long subscriptionBacklog(String subscription, String... scalableTopics) throws Exception {
237+
long total = 0;
238+
for (String topic : scalableTopics) {
239+
var stats = admin.scalableTopics().getStats(topic);
240+
for (var seg : stats.getSegments().values()) {
241+
var ref = getTopicReference(seg.name());
242+
if (ref.isEmpty()) {
243+
continue;
244+
}
245+
var sub = ref.get().getSubscription(subscription);
246+
if (sub != null) {
247+
total += sub.getNumberOfEntriesInBacklog(true);
248+
}
249+
}
250+
}
251+
return total;
252+
}
253+
179254
@Test
180255
public void filtersByPropertySoOnlyMatchingTopicsAttach() throws Exception {
181256
String aliceTopic = topicName("alice");

pulsar-client-v5/src/main/java/org/apache/pulsar/client/impl/v5/MultiTopicStreamConsumer.java

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@
5757
* per-topic consumer with the right segment vector — same semantics as the
5858
* single-topic case, just lifted one level.
5959
*
60-
* <p>For Removed-mid-stream topics we flush acks up to {@code latestDelivered}
61-
* for that topic before closing the per-topic consumer, so the user's
62-
* processing-acked invariant is preserved if the topic is later re-added.
60+
* <p>Acknowledgment is always explicit: neither {@code close()} nor a topic
61+
* leaving the matching set acks anything on the application's behalf. A removed
62+
* topic's per-topic consumer is simply detached; anything delivered-but-unacked
63+
* is redelivered if the topic is later re-added (at-least-once).
6364
*/
6465
final class MultiTopicStreamConsumer<T> implements StreamConsumer<T> {
6566

@@ -247,10 +248,13 @@ private ConsumerConfigurationData<T> perTopicConf(String topicName) {
247248
}
248249

249250
/**
250-
* Close per-topic consumer, flushing pending cumulative acks up to whatever was
251-
* last delivered for that topic. If the topic later re-appears (re-Added), a
252-
* fresh consumer subscribes and resumes from the broker-side cursor — already
253-
* advanced past the messages we've delivered to the user.
251+
* Detach the per-topic consumer and drop our delivery tracking for it. Runs both on
252+
* {@link #closeAsync()} and when a topic leaves the matching set. We deliberately do
253+
* <em>not</em> acknowledge anything here: acks on a stream consumer are cumulative and
254+
* always explicit, so closing (or a topic removal) must never advance a cursor past
255+
* what the application itself acked. Whatever was delivered-but-unacked is redelivered
256+
* on the next attach (at-least-once). If the topic later re-appears, a fresh consumer
257+
* subscribes and resumes from the broker-side cursor.
254258
*/
255259
private CompletableFuture<Void> closeTopic(String topicName) {
256260
retryDelays.remove(topicName);
@@ -264,12 +268,8 @@ private CompletableFuture<Void> closeTopic(String topicName) {
264268
if (state == null) {
265269
return CompletableFuture.completedFuture(null);
266270
}
267-
// Flush: ack everything we delivered for this topic.
268-
Map<Long, org.apache.pulsar.client.api.MessageId> latest =
269-
latestDeliveredPerTopicSegment.remove(topicName);
270-
if (latest != null && !latest.isEmpty()) {
271-
state.consumer.ackUpToVector(latest);
272-
}
271+
// Stop tracking this topic's delivery positions. No ack flush — see javadoc.
272+
latestDeliveredPerTopicSegment.remove(topicName);
273273
return state.consumer.closeAsync()
274274
.thenRun(() -> log.info().attr("topic", topicName)
275275
.log("Per-topic stream consumer detached"));
@@ -337,7 +337,8 @@ private void fanOutCumulativeAck(MessageId messageId,
337337
for (var entry : vector.entrySet()) {
338338
PerTopic<T> state = perTopic.get(entry.getKey());
339339
if (state == null) {
340-
// Topic was Removed since enqueue; closeTopic already flushed.
340+
// Topic left the matching set since this message was enqueued: we've
341+
// detached it and no longer ack removed topics, so skip its slice.
341342
continue;
342343
}
343344
action.accept(state.consumer, entry.getValue());

0 commit comments

Comments
 (0)