2121import com .google .common .annotations .VisibleForTesting ;
2222import io .netty .buffer .ByteBuf ;
2323import java .util .ArrayList ;
24+ import java .util .Collections ;
2425import java .util .HashMap ;
2526import java .util .HashSet ;
2627import java .util .List ;
2930import java .util .Set ;
3031import java .util .concurrent .CompletableFuture ;
3132import 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 ;
3237import java .util .concurrent .atomic .LongAdder ;
3338import lombok .CustomLog ;
3439import org .apache .bookkeeper .mledger .AsyncCallbacks ;
4247import org .apache .pulsar .broker .transaction .buffer .TransactionBuffer ;
4348import org .apache .pulsar .broker .transaction .buffer .TransactionBufferReader ;
4449import org .apache .pulsar .broker .transaction .buffer .TransactionMeta ;
50+ import org .apache .pulsar .broker .transaction .metadata .AbortedTxnRecord ;
4551import org .apache .pulsar .broker .transaction .metadata .SegmentWatermark ;
4652import org .apache .pulsar .broker .transaction .metadata .TxnHeader ;
4753import org .apache .pulsar .broker .transaction .metadata .TxnIds ;
5561import org .apache .pulsar .common .policies .data .TransactionBufferStats ;
5662import org .apache .pulsar .common .policies .data .TransactionInBufferStats ;
5763import org .apache .pulsar .common .util .FutureUtil ;
64+ import org .apache .pulsar .common .util .Runnables ;
5865import org .apache .pulsar .metadata .api .GetResult ;
5966import org .apache .pulsar .metadata .api .MetadataStoreException ;
6067import 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 ) {
0 commit comments