Skip to content

Commit 94de2b6

Browse files
authored
[improve][txn] PIP-473: GC aborted-transaction records on ML trim and segment drop (#25975)
1 parent 36270cc commit 94de2b6

6 files changed

Lines changed: 253 additions & 2 deletions

File tree

pulsar-broker/src/main/java/org/apache/pulsar/broker/service/scalable/ScalableTopicService.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.apache.pulsar.broker.resources.ScalableTopicMetadata;
2828
import org.apache.pulsar.broker.resources.ScalableTopicResources;
2929
import org.apache.pulsar.broker.service.BrokerService;
30+
import org.apache.pulsar.broker.transaction.metadata.TxnMetadataStore;
3031
import org.apache.pulsar.common.api.proto.ScalableConsumerType;
3132
import org.apache.pulsar.common.naming.TopicDomain;
3233
import org.apache.pulsar.common.naming.TopicName;
@@ -232,23 +233,42 @@ public CompletableFuture<ScalableTopicStats> getStats(TopicName topic) {
232233
* Delete a scalable topic and all its segment topics.
233234
*/
234235
public CompletableFuture<Void> deleteScalableTopic(TopicName topic) {
236+
// When transactions are enabled, the segments carry durable /txn/segment-state records
237+
// (watermark + aborted-txn records). Delete them alongside the segment topics so they don't
238+
// outlive the data.
239+
TxnMetadataStore txnStore =
240+
brokerService.getPulsar().getConfiguration().isTransactionCoordinatorEnabled()
241+
? new TxnMetadataStore(brokerService.getPulsar().getLocalMetadataStore())
242+
: null;
235243
return releaseController(topic)
236244
.thenCompose(__ -> resources.getScalableTopicMetadataAsync(topic))
237245
.thenCompose(optMd -> {
238246
if (optMd.isEmpty()) {
239247
return CompletableFuture.completedFuture(null);
240248
}
241249
ScalableTopicMetadata metadata = optMd.get();
242-
// Delete all underlying segment topics
250+
// Delete all underlying segment topics, then their durable transaction state.
243251
return FutureUtil.waitForAll(
244252
metadata.getSegments().values().stream()
245-
.map(segment -> deleteUnderlyingSegmentTopic(topic, segment))
253+
.map(segment -> deleteUnderlyingSegmentTopic(topic, segment)
254+
.thenCompose(__ -> cleanupSegmentTxnState(txnStore, topic, segment)))
246255
.toList()
247256
);
248257
})
249258
.thenCompose(__ -> resources.deleteScalableTopicAsync(topic));
250259
}
251260

261+
/** Delete the durable {@code /txn/segment-state} records for a segment being dropped. */
262+
private CompletableFuture<Void> cleanupSegmentTxnState(TxnMetadataStore txnStore,
263+
TopicName parentTopic, SegmentInfo segment) {
264+
if (txnStore == null) {
265+
return CompletableFuture.completedFuture(null);
266+
}
267+
String segmentName = SegmentTopicName.fromParent(
268+
parentTopic, segment.hashRange(), segment.segmentId()).toString();
269+
return txnStore.deleteAllSegmentState(segmentName);
270+
}
271+
252272
/**
253273
* Register a scalable consumer with the controller leader for {@code topic}.
254274
* Persists a durable session and returns the consumer's segment assignment.

pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/MetadataTransactionBuffer.java

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.google.common.annotations.VisibleForTesting;
2222
import io.netty.buffer.ByteBuf;
2323
import java.util.ArrayList;
24+
import java.util.Collections;
2425
import java.util.HashMap;
2526
import java.util.HashSet;
2627
import java.util.List;
@@ -29,6 +30,10 @@
2930
import java.util.Set;
3031
import java.util.concurrent.CompletableFuture;
3132
import java.util.concurrent.ConcurrentHashMap;
33+
import java.util.concurrent.ScheduledExecutorService;
34+
import java.util.concurrent.ScheduledFuture;
35+
import java.util.concurrent.TimeUnit;
36+
import java.util.concurrent.atomic.AtomicBoolean;
3237
import java.util.concurrent.atomic.LongAdder;
3338
import lombok.CustomLog;
3439
import org.apache.bookkeeper.mledger.AsyncCallbacks;
@@ -42,6 +47,7 @@
4247
import org.apache.pulsar.broker.transaction.buffer.TransactionBuffer;
4348
import org.apache.pulsar.broker.transaction.buffer.TransactionBufferReader;
4449
import org.apache.pulsar.broker.transaction.buffer.TransactionMeta;
50+
import org.apache.pulsar.broker.transaction.metadata.AbortedTxnRecord;
4551
import org.apache.pulsar.broker.transaction.metadata.SegmentWatermark;
4652
import org.apache.pulsar.broker.transaction.metadata.TxnHeader;
4753
import org.apache.pulsar.broker.transaction.metadata.TxnIds;
@@ -55,6 +61,7 @@
5561
import org.apache.pulsar.common.policies.data.TransactionBufferStats;
5662
import org.apache.pulsar.common.policies.data.TransactionInBufferStats;
5763
import org.apache.pulsar.common.util.FutureUtil;
64+
import org.apache.pulsar.common.util.Runnables;
5865
import org.apache.pulsar.metadata.api.GetResult;
5966
import org.apache.pulsar.metadata.api.MetadataStoreException;
6067
import org.apache.pulsar.metadata.api.ScanConsumer;
@@ -132,6 +139,11 @@ public class MetadataTransactionBuffer implements TransactionBuffer {
132139
private final LongAdder committedCount = new LongAdder();
133140
private final LongAdder abortedCount = new LongAdder();
134141

142+
/** Periodic task that range-deletes aborted-txn records once the segment ML trims past them. */
143+
private final ScheduledFuture<?> abortedGcTask;
144+
/** Guards against a new GC cycle starting while the previous async one is still in flight. */
145+
private final AtomicBoolean gcRunning = new AtomicBoolean(false);
146+
135147
public MetadataTransactionBuffer(PersistentTopic topic, TxnMetadataStore txnStore) {
136148
this.topic = topic;
137149
this.ledger = topic.getManagedLedger();
@@ -140,6 +152,55 @@ public MetadataTransactionBuffer(PersistentTopic topic, TxnMetadataStore txnStor
140152
this.maxReadPositionCallBack = topic.getMaxReadPositionCallBack();
141153
this.maxReadPosition = ledger.getLastConfirmedEntry();
142154
recover();
155+
this.abortedGcTask = scheduleAbortedGc();
156+
}
157+
158+
/**
159+
* Schedule the periodic aborted-record GC on the broker executor. Returns {@code null} when no
160+
* executor is reachable (e.g. a unit test with a mocked topic); such callers drive
161+
* {@link #pruneTrimmedAbortedTxns()} directly.
162+
*/
163+
private ScheduledFuture<?> scheduleAbortedGc() {
164+
ScheduledExecutorService executor = brokerExecutor();
165+
if (executor == null) {
166+
return null;
167+
}
168+
long intervalSeconds = Math.max(1, topic.getBrokerService().getPulsar().getConfiguration()
169+
.getTransactionCoordinatorScalableTopicsGcIntervalSeconds());
170+
long intervalMs = TimeUnit.SECONDS.toMillis(intervalSeconds);
171+
// Wrap in catchingAndLoggingThrowables so an unexpected RuntimeException doesn't cancel the
172+
// fixed-delay schedule. The gcRunning guard skips a cycle while the previous async sweep is
173+
// still in flight (slow metadata store) rather than overlapping sweeps.
174+
return executor.scheduleWithFixedDelay(Runnables.catchingAndLoggingThrowables(() -> {
175+
if (closed || !gcRunning.compareAndSet(false, true)) {
176+
return;
177+
}
178+
CompletableFuture<Void> sweep;
179+
try {
180+
sweep = pruneTrimmedAbortedTxns();
181+
} catch (Throwable t) {
182+
gcRunning.set(false);
183+
throw t;
184+
}
185+
sweep.whenComplete((__, ex) -> {
186+
gcRunning.set(false);
187+
if (ex != null) {
188+
log.warn().attr("segment", segmentName).exception(ex)
189+
.log("Aborted-txn GC sweep failed; will retry next cycle");
190+
}
191+
});
192+
}), intervalMs, intervalMs, TimeUnit.MILLISECONDS);
193+
}
194+
195+
private ScheduledExecutorService brokerExecutor() {
196+
try {
197+
if (topic.getBrokerService() != null && topic.getBrokerService().getPulsar() != null) {
198+
return topic.getBrokerService().getPulsar().getExecutor();
199+
}
200+
} catch (Throwable t) {
201+
// Mocked topic in unit tests — no broker executor; GC is driven directly.
202+
}
203+
return null;
143204
}
144205

145206
// ---- Recovery ----------------------------------------------------------
@@ -625,10 +686,73 @@ public CompletableFuture<Void> checkIfTBRecoverCompletely() {
625686
@Override
626687
public CompletableFuture<Void> closeAsync() {
627688
closed = true;
689+
if (abortedGcTask != null) {
690+
abortedGcTask.cancel(false);
691+
}
628692
closeSubscriptionQuietly();
629693
return CompletableFuture.completedFuture(null);
630694
}
631695

696+
/**
697+
* Range-delete aborted-txn records — and drop their in-memory {@link #abortedTxns} entries —
698+
* whose highest position in this segment is below the ML's first still-valid position, i.e. whose
699+
* data has been fully trimmed. Safe because a trimmed position is never dispatched, so its abort
700+
* filtering is no longer needed; records for still-readable data (max position at or above the
701+
* first valid position) are retained. Without this the durable aborted set and the heap set grow
702+
* for the segment's whole lifetime even as the underlying data is trimmed away.
703+
*/
704+
@VisibleForTesting
705+
CompletableFuture<Void> pruneTrimmedAbortedTxns() {
706+
if (closed) {
707+
return CompletableFuture.completedFuture(null);
708+
}
709+
Position firstValid = ledger.getFirstPosition();
710+
if (firstValid == null) {
711+
return CompletableFuture.completedFuture(null);
712+
}
713+
List<String> toPrune = Collections.synchronizedList(new ArrayList<>());
714+
return txnStore.scanAbortedTxns(segmentName,
715+
TxnPaths.abortedByPositionSegmentLowerBound(segmentName),
716+
TxnPaths.abortedByPositionSegmentUpperBound(segmentName),
717+
new ScanConsumer() {
718+
@Override
719+
public void onNext(GetResult r) {
720+
String txnIdKey = TxnPaths.txnIdFromAbortedPath(r.getStat().getPath());
721+
if (txnIdKey == null) {
722+
return;
723+
}
724+
AbortedTxnRecord rec = TxnMetadataStore.fromJson(r.getValue(), AbortedTxnRecord.class);
725+
Position maxPos = PositionFactory.create(rec.maxLedgerId(), rec.maxEntryId());
726+
// Strictly below the first valid position → fully trimmed (conservative).
727+
if (maxPos.compareTo(firstValid) < 0) {
728+
toPrune.add(txnIdKey);
729+
}
730+
}
731+
732+
@Override
733+
public void onError(Throwable throwable) {
734+
log.warn().attr("segment", segmentName).exception(throwable)
735+
.log("Aborted-txn GC scan errored");
736+
}
737+
738+
@Override
739+
public void onCompleted() {
740+
}
741+
}).thenCompose(__ -> {
742+
if (toPrune.isEmpty()) {
743+
return CompletableFuture.completedFuture(null);
744+
}
745+
synchronized (lock) {
746+
toPrune.forEach(abortedTxns::remove);
747+
}
748+
List<CompletableFuture<Void>> deletes = new ArrayList<>(toPrune.size());
749+
for (String txnIdKey : toPrune) {
750+
deletes.add(txnStore.deleteAbortedTxn(segmentName, txnIdKey));
751+
}
752+
return FutureUtil.waitForAll(deletes);
753+
});
754+
}
755+
632756
private void closeSubscriptionQuietly() {
633757
AutoCloseable handle = subscription;
634758
if (handle == null) {

pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/metadata/TxnMetadataStore.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
import com.fasterxml.jackson.core.JsonProcessingException;
2222
import java.io.IOException;
23+
import java.util.ArrayList;
24+
import java.util.Collections;
2325
import java.util.List;
2426
import java.util.Optional;
2527
import java.util.Set;
@@ -29,6 +31,7 @@
2931
import lombok.CustomLog;
3032
import org.apache.pulsar.common.util.FutureUtil;
3133
import org.apache.pulsar.common.util.ObjectMapperFactory;
34+
import org.apache.pulsar.metadata.api.GetResult;
3235
import org.apache.pulsar.metadata.api.MetadataStore;
3336
import org.apache.pulsar.metadata.api.MetadataStoreException;
3437
import org.apache.pulsar.metadata.api.Option;
@@ -398,6 +401,43 @@ public CompletableFuture<Void> deleteSegmentWatermark(String segment) {
398401
return store.deleteIfExists(TxnPaths.segmentWatermarkPath(segment), Optional.empty(), opts);
399402
}
400403

404+
/**
405+
* Delete all durable per-segment transaction state — every aborted-txn record and the watermark —
406+
* when a segment is dropped (e.g. the scalable topic is deleted), so the {@code /txn/segment-state}
407+
* records don't outlive the segment's data. Idempotent: missing records are no-ops.
408+
*/
409+
public CompletableFuture<Void> deleteAllSegmentState(String segment) {
410+
List<String> abortedKeys = Collections.synchronizedList(new ArrayList<>());
411+
return scanAbortedTxns(segment,
412+
TxnPaths.abortedByPositionSegmentLowerBound(segment),
413+
TxnPaths.abortedByPositionSegmentUpperBound(segment),
414+
new ScanConsumer() {
415+
@Override
416+
public void onNext(GetResult r) {
417+
String txnIdKey = TxnPaths.txnIdFromAbortedPath(r.getStat().getPath());
418+
if (txnIdKey != null) {
419+
abortedKeys.add(txnIdKey);
420+
}
421+
}
422+
423+
@Override
424+
public void onError(Throwable throwable) {
425+
log.warn().attr("segment", segment).exception(throwable)
426+
.log("Segment-state cleanup scan errored");
427+
}
428+
429+
@Override
430+
public void onCompleted() {
431+
}
432+
}).thenCompose(__ -> {
433+
List<CompletableFuture<Void>> deletes = new ArrayList<>(abortedKeys.size());
434+
for (String txnIdKey : abortedKeys) {
435+
deletes.add(deleteAbortedTxn(segment, txnIdKey));
436+
}
437+
return FutureUtil.waitForAll(deletes);
438+
}).thenCompose(__ -> deleteSegmentWatermark(segment));
439+
}
440+
401441
// ---- TC sequence counter ----------------------------------------------
402442

403443
/**

pulsar-broker/src/test/java/org/apache/pulsar/broker/service/scalable/ScalableTopicServiceTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import java.util.concurrent.Executors;
3838
import java.util.concurrent.ScheduledExecutorService;
3939
import org.apache.pulsar.broker.PulsarService;
40+
import org.apache.pulsar.broker.ServiceConfiguration;
4041
import org.apache.pulsar.broker.resources.ScalableTopicMetadata;
4142
import org.apache.pulsar.broker.resources.ScalableTopicResources;
4243
import org.apache.pulsar.broker.resources.SubscriptionType;
@@ -94,6 +95,7 @@ public void setUp() throws Exception {
9495
scalableTopicsAdmin = mock(ScalableTopics.class);
9596

9697
when(brokerService.getPulsar()).thenReturn(pulsar);
98+
when(pulsar.getConfiguration()).thenReturn(new ServiceConfiguration());
9799
when(brokerService.getTopicIfExists(anyString()))
98100
.thenReturn(CompletableFuture.completedFuture(Optional.empty()));
99101
when(pulsar.getBrokerId()).thenReturn(BROKER_ID);

pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/buffer/impl/MetadataTransactionBufferTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,36 @@ public void restartAfterAbort_abortedTxnStillFiltered() throws Exception {
279279
assertThat(tb.isTxnAborted(oldAbortedTxn, PositionFactory.create(3, 5))).isTrue();
280280
}
281281

282+
@Test
283+
public void pruneTrimmedAborted_dropsBelowFirstValid_retainsAbove() throws Exception {
284+
// An aborted txn whose data the ML has fully trimmed (max position below the first valid
285+
// position) is dropped from both the durable aborted records and the in-memory set; an
286+
// aborted txn whose data is still readable is retained.
287+
TxnID trimmedTxn = new TxnID(1, 100); // max position on ledger 1 — will be trimmed away
288+
TxnID liveTxn = new TxnID(1, 200); // max position on ledger 10 — still readable
289+
txnStore.putAbortedTxn(SEGMENT, TxnIds.toKey(trimmedTxn), 1L, 5L).get();
290+
txnStore.putAbortedTxn(SEGMENT, TxnIds.toKey(liveTxn), 10L, 5L).get();
291+
292+
MetadataTransactionBuffer tb = new MetadataTransactionBuffer(topic, txnStore);
293+
tb.checkIfTBRecoverCompletely().get();
294+
assertThat(tb.isTxnAborted(trimmedTxn, PositionFactory.create(1, 5))).isTrue();
295+
assertThat(tb.isTxnAborted(liveTxn, PositionFactory.create(10, 5))).isTrue();
296+
297+
// The ML has trimmed everything below ledger 5.
298+
when(ledger.getFirstPosition()).thenReturn(PositionFactory.create(5, 0));
299+
tb.pruneTrimmedAbortedTxns().get();
300+
301+
// In-memory: trimmed dropped, live retained.
302+
assertThat(tb.isTxnAborted(trimmedTxn, PositionFactory.create(1, 5))).isFalse();
303+
assertThat(tb.isTxnAborted(liveTxn, PositionFactory.create(10, 5))).isTrue();
304+
305+
// Durable record also deleted: a fresh TB recovers only the live txn.
306+
MetadataTransactionBuffer tb2 = new MetadataTransactionBuffer(topic, txnStore);
307+
tb2.checkIfTBRecoverCompletely().get();
308+
assertThat(tb2.isTxnAborted(trimmedTxn, PositionFactory.create(1, 5))).isFalse();
309+
assertThat(tb2.isTxnAborted(liveTxn, PositionFactory.create(10, 5))).isTrue();
310+
}
311+
282312
@Test
283313
public void recoveryDiscoveredOpenTxn_pinsAtWatermark() throws Exception {
284314
// /txn/op records exist for an open txn (broker was processing publishes for txn T;

pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/metadata/TxnMetadataStoreTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.time.Instant;
2525
import java.util.ArrayList;
2626
import java.util.List;
27+
import java.util.Optional;
2728
import java.util.concurrent.ConcurrentLinkedQueue;
2829
import lombok.Cleanup;
2930
import org.apache.pulsar.metadata.api.GetResult;
@@ -219,6 +220,40 @@ public void publishAndSubscribeSubscriptionEvents() throws Exception {
219220
assertThat(received).isNotEmpty().last().asString().isEqualTo(s.getPath()));
220221
}
221222

223+
@Test
224+
public void deleteAllSegmentState_removesAbortedRecordsAndWatermark() throws Exception {
225+
@Cleanup MetadataStore store = newMemoryStore();
226+
TxnMetadataStore txn = new TxnMetadataStore(store);
227+
String segment = "segment://public/default/topic/0000-ffff-0";
228+
229+
txn.putAbortedTxn(segment, "t1", 1L, 5L).get();
230+
txn.putAbortedTxn(segment, "t2", 2L, 7L).get();
231+
txn.casSegmentWatermark(segment, new SegmentWatermark(3, 0), Optional.empty()).get();
232+
233+
txn.deleteAllSegmentState(segment).get();
234+
235+
List<String> remaining = new ArrayList<>();
236+
txn.scanAbortedTxns(segment,
237+
TxnPaths.abortedByPositionSegmentLowerBound(segment),
238+
TxnPaths.abortedByPositionSegmentUpperBound(segment),
239+
new ScanConsumer() {
240+
@Override
241+
public void onNext(GetResult r) {
242+
remaining.add(r.getStat().getPath());
243+
}
244+
245+
@Override
246+
public void onError(Throwable throwable) {
247+
}
248+
249+
@Override
250+
public void onCompleted() {
251+
}
252+
}).get();
253+
assertThat(remaining).isEmpty();
254+
assertThat(txn.getSegmentWatermark(segment).get()).isEmpty();
255+
}
256+
222257
// ---- helpers -----------------------------------------------------------
223258

224259
private static ScanConsumer collectHeaders(List<TxnHeader> out) {

0 commit comments

Comments
 (0)