This is an automated email from the ASF dual-hosted git repository.

frankgh pushed a commit to branch cep-45-mutation-tracking
in repository https://gitbox.apache.org/repos/asf/cassandra.git


The following commit(s) were added to refs/heads/cep-45-mutation-tracking by 
this push:
     new 6459184151 CEP-45: Prevent dropping journal segments referenced by 
partially reconciled sstables
6459184151 is described below

commit 6459184151a515e43a42a91830591c2112b699f3
Author: Francisco Guerrero <[email protected]>
AuthorDate: Thu Jun 25 14:23:39 2026 -0500

    CEP-45: Prevent dropping journal segments referenced by partially 
reconciled sstables
    
    Prevent dropping of any segment we might still need in
    the future to rebuild a partially reconciled sstable,
    even if every mutation in that segment has been fully
    reconciled.
    
    patch by Francisco Guerrero; reviewed by Aleksey Yeshchenko for 
CASSANDRA-21406
---
 CHANGES.txt                                        |   1 +
 .../cassandra/config/MutationTrackingSpec.java     |  20 +
 .../org/apache/cassandra/db/ColumnFamilyStore.java |   3 +
 src/java/org/apache/cassandra/db/Keyspace.java     |  68 +--
 .../db/tracked/TrackedKeyspaceWriteHandler.java    |   5 +-
 .../db/virtual/MutationTrackingTables.java         |  16 +-
 .../org/apache/cassandra/journal/Compactor.java    | 149 ++++--
 src/java/org/apache/cassandra/journal/Journal.java |  64 ++-
 .../apache/cassandra/journal/SegmentCompactor.java |  31 ++
 .../org/apache/cassandra/journal/Segments.java     |  24 +-
 .../cassandra/replication/CoordinatorLog.java      |  10 +-
 .../cassandra/replication/MutationJournal.java     | 135 ++++--
 .../MutationJournalSegmentCompactor.java           | 233 ++++++++++
 .../replication/MutationTrackingService.java       |  91 ++--
 .../replication/SegmentReferenceTracker.java       | 385 +++++++++++++++
 .../org/apache/cassandra/replication/Shard.java    |   3 +-
 .../cassandra/service/accord/AccordJournal.java    |   3 +-
 .../MutationJournalSegmentRefcountTest.java        | 318 +++++++++++++
 .../tracking/MutationTrackingLogPersisterTest.java |  15 +-
 .../db/virtual/MutationJournalTableTest.java       |   6 +-
 .../cassandra/replication/MutationJournalTest.java | 135 ++++--
 .../replication/SegmentReferenceTrackerTest.java   | 517 +++++++++++++++++++++
 22 files changed, 2031 insertions(+), 201 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index c79abe7f88..56b59b9a23 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
 cep-45-mutation-tracking
+ * CEP-45: Prevent dropping journal segments referenced by partially 
reconciled sstables (CASSANDRA-21406)
  * CEP-45: Fixes and improvements to mutation tracking migration
  * Add support for counters to mutation tracking (CASSANDRA-20959)
  * CEP-45: Query forwarding (CASSANDRA-20309)
diff --git a/src/java/org/apache/cassandra/config/MutationTrackingSpec.java 
b/src/java/org/apache/cassandra/config/MutationTrackingSpec.java
index 30f8acaef5..67def080d8 100644
--- a/src/java/org/apache/cassandra/config/MutationTrackingSpec.java
+++ b/src/java/org/apache/cassandra/config/MutationTrackingSpec.java
@@ -30,4 +30,24 @@ public class MutationTrackingSpec
      * The interval in which the backgroun reconciliation process runs
      */
     public volatile DurationSpec.LongMillisecondsBound 
background_reconciliation_interval = new 
DurationSpec.LongMillisecondsBound("1s");
+    /**
+     * Whether the mutation journal's segment-dropping/promotion compactor is 
enabled
+     */
+    public volatile boolean journal_compaction_enabled = true;
+    /**
+     * When the on-disk mutation journal grows beyond this size, out-of-band 
promotion of already
+     * durably-reconciled but still-unrepaired sstables to repaired is 
triggered so the journal segments they hold
+     * can be released and reclaimed (CASSANDRA-21406). {@code null} disables 
the mechanism. The default is 256MiB.
+     */
+    public volatile DataStorageSpec.LongBytesBound journal_promotion_threshold 
= new DataStorageSpec.LongBytesBound("256MiB");
+
+    /**
+     * @return the on-disk mutation journal size (in bytes) beyond which 
out-of-band promotion of durably-reconciled
+     * unrepaired sstables is triggered, or 0 if the mechanism is disabled.
+     */
+    public long getJournalPromotionThresholdBytes()
+    {
+        DataStorageSpec.LongBytesBound threshold = journal_promotion_threshold;
+        return threshold == null ? 0 : threshold.toBytes();
+    }
 }
diff --git a/src/java/org/apache/cassandra/db/ColumnFamilyStore.java 
b/src/java/org/apache/cassandra/db/ColumnFamilyStore.java
index 1626867245..288b419cd7 100644
--- a/src/java/org/apache/cassandra/db/ColumnFamilyStore.java
+++ b/src/java/org/apache/cassandra/db/ColumnFamilyStore.java
@@ -538,6 +538,9 @@ public class ColumnFamilyStore implements 
ColumnFamilyStoreMBean, Memtable.Owner
         data.subscribe(StorageService.instance.sstablesTracker);
         data.subscribe(SnapshotManager.instance);
 
+        if (DatabaseDescriptor.getMutationTrackingEnabled())
+            
data.subscribe(MutationJournal.instance().segmentReferenceTracker());
+
         Collection<SSTableReader> sstables = null;
         // scan for sstables corresponding to this cf and load them
         if (data.loadsstables)
diff --git a/src/java/org/apache/cassandra/db/Keyspace.java 
b/src/java/org/apache/cassandra/db/Keyspace.java
index 27072f60a6..0020306d79 100644
--- a/src/java/org/apache/cassandra/db/Keyspace.java
+++ b/src/java/org/apache/cassandra/db/Keyspace.java
@@ -57,7 +57,6 @@ import org.apache.cassandra.index.SecondaryIndexManager;
 import org.apache.cassandra.io.sstable.format.SSTableReader;
 import org.apache.cassandra.io.util.File;
 import org.apache.cassandra.locator.AbstractReplicationStrategy;
-import org.apache.cassandra.locator.RangesAtEndpoint;
 import org.apache.cassandra.metrics.KeyspaceMetrics;
 import org.apache.cassandra.repair.KeyspaceRepairManager;
 import org.apache.cassandra.replication.MutationTrackingService;
@@ -659,33 +658,19 @@ public class Keyspace
                         continue;
                     }
 
-                    // If this range is only witnessed then don't apply the 
update to the underlying column family store
-                    // We still want the mutation tracking log to see the 
update so that it can witness it
-                    // and participate in reconciliation of the mutation
-                    AbstractReplicationStrategy replicationStrategy = 
cfs.keyspace.getReplicationStrategy();
-                    if (replicationStrategy.hasTransientReplicas())
+                    // If this range is only witnessed then don't apply the 
update to the underlying column family
+                    // store. We still want the mutation tracking log to 
witness it (via startWriting above) so it can
+                    // participate in reconciliation of the mutation. (The 
journal write in beginWrite already skipped
+                    // marking the segment dirty for witnessed-only mutations; 
see MutationJournal.write.)
+                    if 
(!isFullReplicaFor(cfs.keyspace.getReplicationStrategy(), 
upd.partitionKey().getToken(), cm))
                     {
-                        RangesAtEndpoint localRanges = 
replicationStrategy.getLocalRanges(cm);
-                        Token token = upd.partitionKey().getToken();
-                        boolean foundMatch = false;
-                        for (Range<Token> r : localRanges.onlyFull().ranges())
-                        {
-                            if (r.contains(token))
-                            {
-                                foundMatch = true;
-                                break;
-                            }
-                        }
-                        if (!foundMatch)
-                        {
-                            // TODO 
checkState(!mutation.allowsPotentialTxnConflicts)
-                            // Basically if a transaction system thinks it is 
writing to a non-witness but is writing to a
-                            // witness then we are probably going to have 
issues.
-                            // This is problematic/racy in general because 
schema changes aren't really synced with
-                            // transaction systems yet when in reality they 
really should be completed by a transaction or some
-                            // other similar solution.
-                            continue;
-                        }
+                        // TODO 
checkState(!mutation.allowsPotentialTxnConflicts)
+                        // Basically if a transaction system thinks it is 
writing to a non-witness but is writing to a
+                        // witness then we are probably going to have issues.
+                        // This is problematic/racy in general because schema 
changes aren't really synced with
+                        // transaction systems yet when in reality they really 
should be completed by a transaction or
+                        // some other similar solution.
+                        continue;
                     }
 
                     cfs.getWriteHandler().write(mutation.id(), upd, ctx, true);
@@ -710,6 +695,35 @@ public class Keyspace
         return getMetadata().replicationStrategy;
     }
 
+    /**
+     * @param token the token to check for the given keyspace
+     * @param cm    the cluster metadata instance
+     * @return whether this node is a full replica for the given token in this 
keyspace. Keyspaces without
+     * transient replicas are always full replicas for tokens they own.
+     */
+    public boolean isFullReplicaFor(Token token, ClusterMetadata cm)
+    {
+        return isFullReplicaFor(getReplicationStrategy(), token, cm);
+    }
+
+    /**
+     * @param replicationStrategy the replication strategy for the keyspace
+     * @param token               the token to check for the given keyspace
+     * @param cm                  the cluster metadata instance
+     * @return whether this node is a full replica for the given token. 
Keyspaces without
+     * transient replicas are always full replicas for tokens they own.
+     */
+    static boolean isFullReplicaFor(AbstractReplicationStrategy 
replicationStrategy, Token token, ClusterMetadata cm)
+    {
+        if (!replicationStrategy.hasTransientReplicas())
+            return true;
+
+        for (Range<Token> range : 
replicationStrategy.getLocalRanges(cm).onlyFull().ranges())
+            if (range.contains(token))
+                return true;
+        return false;
+    }
+
     public List<Future<?>> flush(ColumnFamilyStore.FlushReason reason)
     {
         List<Future<?>> futures = new ArrayList<>(columnFamilyStores.size());
diff --git 
a/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java 
b/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
index 2be356538c..783b12d3fd 100644
--- a/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
+++ b/src/java/org/apache/cassandra/db/tracked/TrackedKeyspaceWriteHandler.java
@@ -26,6 +26,7 @@ import org.apache.cassandra.db.commitlog.CommitLogPosition;
 import org.apache.cassandra.exceptions.RequestExecutionException;
 import org.apache.cassandra.replication.MutationJournal;
 import org.apache.cassandra.service.replication.migration.MigrationRouter;
+import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.tracing.Tracing;
 import org.apache.cassandra.utils.concurrent.OpOrder;
 
@@ -45,7 +46,9 @@ public class TrackedKeyspaceWriteHandler implements 
KeyspaceWriteHandler
             if (makeDurable)
             {
                 Tracing.trace("Appending to mutation journal");
-                pointer = MutationJournal.instance().write(mutation.id(), 
mutation);
+                Keyspace keyspace = Keyspace.open(mutation.getKeyspaceName());
+                boolean isFullReplica = 
keyspace.isFullReplicaFor(mutation.key().getToken(), ClusterMetadata.current());
+                pointer = MutationJournal.instance().write(mutation.id(), 
mutation, isFullReplica);
             }
 
             return new CassandraWriteContext(group, pointer);
diff --git 
a/src/java/org/apache/cassandra/db/virtual/MutationTrackingTables.java 
b/src/java/org/apache/cassandra/db/virtual/MutationTrackingTables.java
index e0498028ee..4c3b801422 100644
--- a/src/java/org/apache/cassandra/db/virtual/MutationTrackingTables.java
+++ b/src/java/org/apache/cassandra/db/virtual/MutationTrackingTables.java
@@ -43,6 +43,7 @@ import org.apache.cassandra.replication.CoordinatorLog;
 import org.apache.cassandra.replication.CoordinatorLogId;
 import org.apache.cassandra.replication.MutationJournal;
 import org.apache.cassandra.replication.MutationTrackingService;
+import org.apache.cassandra.replication.SegmentReferenceTracker;
 import org.apache.cassandra.replication.Shard;
 import org.apache.cassandra.replication.ShortMutationId;
 import org.apache.cassandra.schema.TableId;
@@ -78,7 +79,8 @@ public class MutationTrackingTables
         private static final String FSYNCED_TO = "fsynced_to";
         private static final String NEEDS_REPLAY = "needs_replay";
         private static final String FILE_PATH = "file_path";
-    
+        private static final String REFERRING_SSTABLES = "referring_sstables";
+
         MutationJournalTable(String keyspace)
         {
             super(TableMetadata.builder(keyspace, MUTATION_JOURNAL)
@@ -93,16 +95,19 @@ public class MutationTrackingTables
                                .addRegularColumn(FSYNCED_TO, 
Int32Type.instance)
                                .addRegularColumn(NEEDS_REPLAY, 
BooleanType.instance)
                                .addRegularColumn(FILE_PATH, UTF8Type.instance)
+                               .addRegularColumn(REFERRING_SSTABLES, 
ListType.getInstance(UTF8Type.instance, false))
                                .build());
         }
-    
+
         @Override
         public DataSet data()
         {
             SimpleDataSet result = new SimpleDataSet(metadata());
-    
+            SegmentReferenceTracker referenceTracker = 
MutationJournal.instance().segmentReferenceTracker();
+
             for (Segment<ShortMutationId, Mutation> segment : 
MutationJournal.instance().getAllSegments())
             {
+                List<String> referringSstables = 
referenceTracker.referrerDescriptors(segment.id());
                 result.row(segment.id())
                       .column(IS_ACTIVE, segment instanceof ActiveSegment)
                       .column(BYTES_ON_DISK, segment.segmentSizeOnDisk())
@@ -110,9 +115,10 @@ public class MutationTrackingTables
                       .column(WRITTEN_TO, segment.writtenTo())
                       .column(FSYNCED_TO, segment.fsyncedTo())
                       .column(NEEDS_REPLAY, segment.metadata().needsReplay())
-                      .column(FILE_PATH, segment.filePath());
+                      .column(FILE_PATH, segment.filePath())
+                      .column(REFERRING_SSTABLES, referringSstables);
             }
-    
+
             return result;
         }
     }
diff --git a/src/java/org/apache/cassandra/journal/Compactor.java 
b/src/java/org/apache/cassandra/journal/Compactor.java
index b6749bc390..80e5cd98da 100644
--- a/src/java/org/apache/cassandra/journal/Compactor.java
+++ b/src/java/org/apache/cassandra/journal/Compactor.java
@@ -21,14 +21,20 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.google.common.base.Throwables;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.apache.cassandra.concurrent.ScheduledExecutorPlus;
 import org.apache.cassandra.concurrent.Shutdownable;
+import org.apache.cassandra.utils.NoSpamLogger;
 import org.apache.cassandra.utils.concurrent.WaitQueue;
 
 import static 
org.apache.cassandra.concurrent.ExecutorFactory.Global.executorFactory;
@@ -36,11 +42,13 @@ import static 
org.apache.cassandra.concurrent.ExecutorFactory.Global.executorFac
 public final class Compactor<K, V> implements Runnable, Shutdownable
 {
     private static final Logger logger = 
LoggerFactory.getLogger(Compactor.class);
+    private static final NoSpamLogger noSpamLogger = 
NoSpamLogger.getLogger(logger, 5L, TimeUnit.MINUTES);
 
     private final Journal<K, V> journal;
     private final SegmentCompactor<K, V> segmentCompactor;
     private final ScheduledExecutorPlus executor;
     private Future<?> scheduled;
+    private final AtomicBoolean triggerPending = new AtomicBoolean();
     public final WaitQueue compacted = WaitQueue.newWaitQueue();
 
     Compactor(Journal<K, V> journal, SegmentCompactor<K, V> segmentCompactor)
@@ -61,51 +69,131 @@ public final class Compactor<K, V> implements Runnable, 
Shutdownable
         scheduled = executor.scheduleWithFixedDelay(this, period, period, 
units);
     }
 
-    public synchronized void updateCompactionPeriod(int period, TimeUnit units)
+    public synchronized void updateCompactionPeriod(long period, TimeUnit 
units)
     {
-        if (!journal.params.enableCompaction())
-            return;
-
-        if (scheduled != null)
-            scheduled.cancel(false);
+        cancelPeriodic();
 
-        schedule(period, units);
+        if (journal.params.enableCompaction() && !executor.isShutdown())
+            schedule(period, units);
     }
 
     @Override
     public void run()
     {
-        List<StaticSegment<K, V>> toCompact = new ArrayList<>();
-        journal.segments().selectStatic(toCompact);
-        if (toCompact.isEmpty())
-            return;
+        runInternal(true);
+    }
 
-        int limit = journal.params.compactMaxSegments();
-        if (toCompact.size() > limit)
+    private void runInternal(boolean runPostCompaction)
+    {
+        triggerPending.set(false);
+        try
         {
-            toCompact.sort(StaticSegment::compareTo);
-            toCompact.subList(limit, toCompact.size()).clear();
+            List<StaticSegment<K, V>> candidates = new ArrayList<>();
+            journal.segments().selectStatic(candidates);
+            if (candidates.isEmpty())
+                return;
+
+            List<StaticSegment<K, V>> toCompact = 
maybeWrapArrayList(segmentCompactor.select(candidates));
+            if (toCompact.isEmpty())
+                return;
+
+            int limit = journal.params.compactMaxSegments();
+            if (limit < 0)
+            {
+                noSpamLogger.warn("Misconfigured Journal's compaction max 
segments (\"{}\") for journal {}. " +
+                                  "Compacting all segments ({})",
+                                  limit, journal.name, toCompact.size());
+            }
+            else if (toCompact.size() > limit)
+            {
+                toCompact.sort(StaticSegment::compareTo);
+                toCompact.subList(limit, toCompact.size()).clear();
+            }
+
+            try
+            {
+                Collection<StaticSegment<K, V>> newSegments = 
segmentCompactor.compact(toCompact);
+
+                for (StaticSegment<K, V> segment : newSegments)
+                    toCompact.remove(segment);
+
+                journal.replaceCompactedSegments(toCompact, newSegments);
+                for (StaticSegment<K, V> segment : toCompact)
+                    segment.discard(journal);
+            }
+            catch (IOException e)
+            {
+                throw new RuntimeException("Could not compact segments: " + 
toCompact, e);
+            }
         }
-
-        try
+        finally
         {
-            Collection<StaticSegment<K, V>> newSegments = 
segmentCompactor.compact(toCompact);
+            compacted.signalAll();
+            if (runPostCompaction)
+                segmentCompactor.onCompacted();
+        }
+    }
 
-            for (StaticSegment<K, V> segment : newSegments)
-                toCompact.remove(segment);
+    /**
+     * Runs a compaction pass ahead of the next scheduled tick, without 
introducing any additional concurrency.
+     */
+    public void triggerNow()
+    {
+        if (journal.params.enableCompaction() && !executor.isShutdown() && 
triggerPending.compareAndSet(false, true))
+            executor.execute(this);
+    }
 
-            journal.replaceCompactedSegments(toCompact, newSegments);
-            for (StaticSegment<K, V> segment : toCompact)
-                segment.discard(journal);
+    /**
+     * Like {@link #triggerNow()}, but runs on the same dedicated executor and 
blocks until the pass completes.
+     * Skip when the executor is already shutdown
+     */
+    public void runNowBlocking()
+    {
+        submitBlocking(this);
+    }
 
-            compacted.signalAll();
+    public void drainBlocking()
+    {
+        cancelPeriodic();
+        submitBlocking(() -> runInternal(false));
+    }
+
+    private void submitBlocking(Runnable compactorTask)
+    {
+        // Must not be called from the compactor executor thread: 
submit(...).get() would self-deadlock
+        if (executor.isShutdown())
+            return;
+
+        try
+        {
+            executor.submit(compactorTask).get();
+        }
+        catch (InterruptedException e)
+        {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+        catch (ExecutionException e)
+        {
+            Throwable cause = e.getCause();
+            Throwables.throwIfUnchecked(cause);
+            throw new RuntimeException(cause);
         }
-        catch (IOException e)
+        catch (CancellationException ignored)
         {
-            throw new RuntimeException("Could not compact segments: " + 
toCompact);
+            // ignore when it's too late to schedule
         }
     }
 
+    private synchronized void cancelPeriodic()
+    {
+        if (scheduled == null)
+            return;
+
+        scheduled.cancel(false);
+        scheduled = null;
+    }
+
     @Override
     public boolean isTerminated()
     {
@@ -115,7 +203,7 @@ public final class Compactor<K, V> implements Runnable, 
Shutdownable
     @Override
     public void shutdown()
     {
-        logger.debug("Shutting down " + executor);
+        logger.debug("Shutting down {}", executor);
         executor.shutdown();
     }
 
@@ -130,4 +218,11 @@ public final class Compactor<K, V> implements Runnable, 
Shutdownable
     {
         return executor.awaitTermination(timeout, units);
     }
+
+    private List<StaticSegment<K, V>> 
maybeWrapArrayList(Collection<StaticSegment<K, V>> collection)
+    {
+        if (collection instanceof ArrayList<?>)
+            return (List<StaticSegment<K, V>>) collection;
+        return new ArrayList<>(collection);
+    }
 }
diff --git a/src/java/org/apache/cassandra/journal/Journal.java 
b/src/java/org/apache/cassandra/journal/Journal.java
index 2519b58b26..f9569747f2 100644
--- a/src/java/org/apache/cassandra/journal/Journal.java
+++ b/src/java/org/apache/cassandra/journal/Journal.java
@@ -28,6 +28,7 @@ import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
@@ -304,6 +305,8 @@ public class Journal<K, V> implements Shutdownable
                               "Unexpected journal state during initialization: 
%s", state);
         metrics.register(flusher);
 
+        ensureJournalDirectoryExists();
+
         deleteTmpFiles();
 
         List<Descriptor> descriptors = Descriptor.list(directory);
@@ -323,14 +326,19 @@ public class Journal<K, V> implements Shutdownable
                               "Unexpected journal state after initialization", 
state);
         flusher.start();
         compactor.start();
+    }
+
+    public void waitForCompaction(int maxSegments)
+    {
+        if (!params.enableCompaction())
+            return;
 
-        final int maxSegments = 100;
-        if (segments.get().count(Segment::isStatic) > maxSegments)
+        if (countStaticSegments() > maxSegments)
         {
             while (true)
             {
                 WaitQueue.Signal signal = compactor.compacted.register();
-                int count = segments.get().count(Segment::isStatic);
+                int count = countStaticSegments();
                 if (count <= maxSegments)
                 {
                     signal.cancel();
@@ -351,6 +359,11 @@ public class Journal<K, V> implements Shutdownable
         return compactor;
     }
 
+    private void ensureJournalDirectoryExists()
+    {
+        directory.createDirectoriesIfNotExists();
+    }
+
     /**
      * Cleans up unfinished component files from previous run (metadata and 
index)
      */
@@ -961,11 +974,6 @@ public class Journal<K, V> implements Shutdownable
         swapSegments(current -> current.withoutEmptySegment(activeSegment));
     }
 
-    private void removeStaticSegments(Collection<StaticSegment<K, V>> 
staticSegments)
-    {
-        swapSegments(current -> current.withoutStaticSegments(staticSegments));
-    }
-
     private void replaceCompletedSegment(ActiveSegment<K, V> activeSegment, 
StaticSegment<K, V> staticSegment)
     {
         swapSegments(current -> current.withCompletedSegment(activeSegment, 
staticSegment));
@@ -1095,18 +1103,6 @@ public class Journal<K, V> implements Shutdownable
         closer.execute(new CloseActiveSegmentRunnable(activeSegment, onDone));
     }
 
-    public int dropStaticSegments(Predicate<StaticSegment<K, V>> dropIf)
-    {
-        Set<StaticSegment<K, V>> toDrop = new HashSet<>();
-        segments().selectStatic(dropIf, toDrop);
-        if (toDrop.isEmpty())
-            return 0;
-        removeStaticSegments(toDrop);
-        for (StaticSegment<K, V> segment : toDrop)
-            segment.discard(this);
-        return toDrop.size();
-    }
-
     /*
      * Error handling
      */
@@ -1417,7 +1413,7 @@ public class Journal<K, V> implements Shutdownable
     @VisibleForTesting
     public void runCompactorForTesting()
     {
-        compactor.run();
+        compactor.runNowBlocking();
     }
 
     @VisibleForTesting
@@ -1431,6 +1427,19 @@ public class Journal<K, V> implements Shutdownable
         {
             LockSupport.parkNanos(1000);
         }
+        try
+        {
+            closer.submit(() -> {}).get();
+        }
+        catch (InterruptedException e)
+        {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+        catch (ExecutionException e)
+        {
+            throw new RuntimeException(e);
+        }
     }
 
     @VisibleForTesting
@@ -1441,9 +1450,20 @@ public class Journal<K, V> implements Shutdownable
         toReset.forEach(s -> s.metadata().clearNeedsReplay());
     }
 
+    public int countStaticSegments()
+    {
+        return segments.get().count(Segment::isStatic);
+    }
+
     @VisibleForTesting
     public int countStaticSegmentsForTesting()
     {
-        return segments.get().count(Segment::isStatic);
+        return countStaticSegments();
+    }
+
+    @VisibleForTesting
+    public int countStaticSegmentsForTesting(Predicate<StaticSegment<K, V>> 
matches)
+    {
+        return segments().countStatic(matches);
     }
 }
diff --git a/src/java/org/apache/cassandra/journal/SegmentCompactor.java 
b/src/java/org/apache/cassandra/journal/SegmentCompactor.java
index 7b84ea82e1..aa15313944 100644
--- a/src/java/org/apache/cassandra/journal/SegmentCompactor.java
+++ b/src/java/org/apache/cassandra/journal/SegmentCompactor.java
@@ -20,8 +20,13 @@ package org.apache.cassandra.journal;
 import java.io.IOException;
 import java.util.Collection;
 
+/**
+ * Decides which static segments of a {@link Journal} are compacted, and how. 
Used by {@link Compactor}
+ * to run periodic or on-demand compaction passes.
+ */
 public interface SegmentCompactor<K, V>
 {
+    /** A no-op compactor */
     SegmentCompactor<?, ?> NOOP = (SegmentCompactor<Object, Object>) 
(segments) -> segments;
 
     static <K, V> SegmentCompactor<K, V> noop()
@@ -30,5 +35,31 @@ public interface SegmentCompactor<K, V>
         return (SegmentCompactor<K, V>) NOOP;
     }
 
+    /**
+     * Picks which of the given candidate segments should be compacted this 
pass. Defaults to selecting
+     * all of them.
+     *
+     * @param candidates all static segments currently eligible for compaction
+     * @return the subset of {@code candidates} to pass to {@link 
#compact(Collection)}
+     */
+    default Collection<StaticSegment<K, V>> select(Collection<StaticSegment<K, 
V>> candidates)
+    {
+        return candidates;
+    }
+
+    /**
+     * Compacts the given {@code segments}, previously chosen by {@link 
#select(Collection)}.
+     *
+     * @param segments the segments to compact
+     * @return the new segments that replace them; empty when the segments are 
dropped
+     */
     Collection<StaticSegment<K, V>> compact(Collection<StaticSegment<K, V>> 
segments) throws IOException;
+
+    /**
+     * Invoked at the end of every {@link Compactor} run, whether or not any 
segments were selected or
+     * compacted this pass, and whether or not the pass failed.
+     */
+    default void onCompacted()
+    {
+    }
 }
diff --git a/src/java/org/apache/cassandra/journal/Segments.java 
b/src/java/org/apache/cassandra/journal/Segments.java
index 8c7db1f3cd..b7314b7faf 100644
--- a/src/java/org/apache/cassandra/journal/Segments.java
+++ b/src/java/org/apache/cassandra/journal/Segments.java
@@ -23,6 +23,8 @@ import java.util.function.Predicate;
 
 import javax.annotation.Nullable;
 
+import com.google.common.annotations.VisibleForTesting;
+
 import org.agrona.collections.Long2ObjectHashMap;
 
 import accord.utils.Invariants;
@@ -75,21 +77,6 @@ public class Segments<K, V>
         return new Segments<>(newSegments);
     }
 
-    Segments<K, V> withoutStaticSegments(Collection<StaticSegment<K, V>> 
removeSegments)
-    {
-        if (removeSegments.isEmpty())
-            return this;
-
-        Long2ObjectHashMap<Segment<K, V>> newSegments = new 
Long2ObjectHashMap<>(segments);
-        for (StaticSegment<K, V> segment : removeSegments)
-        {
-            Segment<K, V> oldValue = 
newSegments.remove(segment.descriptor.timestamp);
-            if (oldValue != null)
-                Invariants.require(oldValue.isStatic());
-        }
-        return new Segments<>(newSegments);
-    }
-
     Segments<K, V> withCompletedSegment(ActiveSegment<K, V> activeSegment, 
StaticSegment<K, V> staticSegment)
     {
         
Invariants.requireArgument(activeSegment.descriptor.equals(staticSegment.descriptor));
@@ -214,11 +201,14 @@ public class Segments<K, V>
                 into.add(segment.asStatic());
     }
 
-    void selectStatic(Predicate<StaticSegment<K, V>> filter, 
Collection<StaticSegment<K, V>> into)
+    @VisibleForTesting
+    int countStatic(Predicate<StaticSegment<K, V>> filter)
     {
+        int count = 0;
         for (Segment<K, V> segment : segments.values())
             if (segment.isStatic() && filter.test(segment.asStatic()))
-                into.add(segment.asStatic());
+                count++;
+        return count;
     }
 
     /**
diff --git a/src/java/org/apache/cassandra/replication/CoordinatorLog.java 
b/src/java/org/apache/cassandra/replication/CoordinatorLog.java
index 317a3e6eea..61835e40ad 100644
--- a/src/java/org/apache/cassandra/replication/CoordinatorLog.java
+++ b/src/java/org/apache/cassandra/replication/CoordinatorLog.java
@@ -595,7 +595,15 @@ public abstract class CoordinatorLog
 
     void collectDurablyReconciledOffsets(Log2OffsetsMap.Mutable into)
     {
-        into.add(reconciledPersistedOffsets);
+        lock.readLock().lock();
+        try
+        {
+            into.add(reconciledPersistedOffsets);
+        }
+        finally
+        {
+            lock.readLock().unlock();
+        }
     }
 
     boolean isDurablyReconciled(ShortMutationId id)
diff --git a/src/java/org/apache/cassandra/replication/MutationJournal.java 
b/src/java/org/apache/cassandra/replication/MutationJournal.java
index 1fa4909804..fa93fc7a54 100644
--- a/src/java/org/apache/cassandra/replication/MutationJournal.java
+++ b/src/java/org/apache/cassandra/replication/MutationJournal.java
@@ -27,6 +27,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.BiConsumer;
+import java.util.function.Supplier;
 import java.util.zip.CRC32;
 
 import javax.annotation.Nullable;
@@ -46,6 +47,7 @@ import accord.utils.Invariants;
 import org.apache.cassandra.concurrent.Stage;
 import org.apache.cassandra.config.Config;
 import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.config.DurationSpec;
 import org.apache.cassandra.db.Keyspace;
 import org.apache.cassandra.db.Mutation;
 import org.apache.cassandra.db.TypeSizes;
@@ -69,12 +71,12 @@ import org.apache.cassandra.journal.Params;
 import org.apache.cassandra.journal.RecordConsumer;
 import org.apache.cassandra.journal.RecordPointer;
 import org.apache.cassandra.journal.Segment;
-import org.apache.cassandra.journal.SegmentCompactor;
 import org.apache.cassandra.journal.StaticSegment;
 import org.apache.cassandra.journal.ValueSerializer;
 import org.apache.cassandra.net.MessagingService;
 import org.apache.cassandra.schema.Schema;
 import org.apache.cassandra.schema.TableId;
+import org.apache.cassandra.tcm.ClusterMetadata;
 import org.apache.cassandra.utils.Crc;
 import org.apache.cassandra.utils.concurrent.OpOrder;
 import org.apache.cassandra.utils.concurrent.Semaphore;
@@ -101,7 +103,10 @@ public class MutationJournal
     private static final MutationJournal instance = 
DatabaseDescriptor.getMutationTrackingEnabled() ? new MutationJournal() : null;
 
     private final Journal<ShortMutationId, Mutation> journal;
+    @VisibleForTesting
+    final MutationJournalSegmentCompactor segmentCompactor;
     private final Map<Long, SegmentStateTracker> segmentStateTrackers;
+    private final SegmentReferenceTracker segmentReferenceTracker;
 
     // Static segments awaiting durable cleanup of their needsReplay=false 
metadata.
     private final Set<Long> pendingClearReplay = ConcurrentHashMap.newKeySet();
@@ -169,14 +174,22 @@ public class MutationJournal
     @VisibleForTesting
     MutationJournal(File directory, Params params)
     {
-        journal =
+        this(directory, params, null);
+    }
+
+    @VisibleForTesting
+    MutationJournal(File directory, Params params, Supplier<Log2OffsetsMap<?>> 
durablyReconciledOffsetsSupplier)
+    {
+        segmentStateTrackers = new NonBlockingHashMapLong<>();
+        segmentCompactor = new MutationJournalSegmentCompactor(this, 
durablyReconciledOffsetsSupplier);
+        Journal<ShortMutationId, Mutation> journal =
             new Journal<>("MutationJournal",
                           directory,
                           params,
                           MutationIdSupport.INSTANCE,
                           MutationSerializer.INSTANCE,
                           OffsetRangesFactory.INSTANCE,
-                          SegmentCompactor.noop(),
+                          segmentCompactor,
                           new OpOrder())
                           {
                               // TODO (expected): a cleaner way to override 
it; pass a Callbacks object with sanctioned callbacks?
@@ -189,7 +202,17 @@ public class MutationJournal
                                   });
                               }
                           };
-        segmentStateTrackers = new NonBlockingHashMapLong<>();
+        this.journal = journal;
+        segmentReferenceTracker = new SegmentReferenceTracker(() -> 
journal.compactor().triggerNow());
+    }
+
+    /**
+     * Triggers the journal's compactor to initiate a segment-dropping + 
promotion pass
+     * (see {@link MutationJournalSegmentCompactor}
+     */
+    private void triggerSegmentCompaction()
+    {
+        journal.compactor().triggerNow();
     }
 
     public CommitLogPosition getCurrentPosition()
@@ -207,7 +230,7 @@ public class MutationJournal
     {
         Invariants.require(segment.isStatic());
         SegmentStateTracker tracker = segmentStateTrackers.get(segment.id());
-        if (tracker != null && tracker.removeCleanFromDirty())
+        if (tracker == null || tracker.removeCleanFromDirty())
             pendingClearReplay.add(segment.id());
     }
 
@@ -226,6 +249,7 @@ public class MutationJournal
      */
     public void drainCleanup(PendingClearReplay toDrain)
     {
+        boolean anyCleared = false;
         for (long segId : toDrain.segments)
         {
             List<Segment<ShortMutationId, Mutation>> found = 
journal.getSegments(segId, segId);
@@ -241,6 +265,7 @@ public class MutationJournal
                 segment.metadata().clearNeedsReplay();
                 segment.persistMetadata();
                 pendingClearReplay.remove(segId);
+                anyCleared = true;
             }
             catch (Throwable t)
             {
@@ -248,6 +273,9 @@ public class MutationJournal
                 // leave in live queue
             }
         }
+
+        if (anyCleared)
+            triggerSegmentCompaction();
     }
 
     @VisibleForTesting
@@ -273,23 +301,40 @@ public class MutationJournal
         journal.shutdown();
     }
 
+    @VisibleForTesting
     public RecordPointer write(ShortMutationId id, Mutation mutation)
+    {
+        return write(id, mutation, true);
+    }
+
+    /**
+     * Append a mutation to the journal.
+     *
+     * @param id            the short mutation id
+     * @param mutation      the mutation to be applied to the journal
+     * @param isFullReplica whether this node is a full replica for the 
mutation's token
+     * @return the record pointer to the journal
+     */
+    public RecordPointer write(ShortMutationId id, Mutation mutation, boolean 
isFullReplica)
     {
         // TODO (required): why are we using blocking write here? We 
can/should wait for completion on `close` of WriteContext.
         RecordPointer ptr = journal.blockingWrite(id, mutation);
 
         // IMPORTANT: there should be no way for mutation to be applied to 
memtable before we mark it as dirty here,
         // since this will introduce a race between marking as dirty and 
marking as clean.
-        for (TableId tableId : mutation.getTableIds())
+        if (isFullReplica)
         {
-            SegmentStateTracker tracker = lastSegmentTracker;
-            if (tracker == null || tracker.segmentId() != ptr.segmentId)
+            for (TableId tableId : mutation.getTableIds())
             {
-                tracker = segmentStateTrackers.computeIfAbsent(ptr.segmentId, 
SegmentStateTracker::new);
-                lastSegmentTracker = tracker;
-            }
+                SegmentStateTracker tracker = lastSegmentTracker;
+                if (tracker == null || tracker.segmentId() != ptr.segmentId)
+                {
+                    tracker = 
segmentStateTrackers.computeIfAbsent(ptr.segmentId, SegmentStateTracker::new);
+                    lastSegmentTracker = tracker;
+                }
 
-            tracker.markDirty(tableId, ptr);
+                tracker.markDirty(tableId, ptr);
+            }
         }
 
         return ptr;
@@ -378,6 +423,7 @@ public class MutationJournal
                     return;
                 // TODO: if (commitLogReplayer.pointInTimeExceeded(mutation))
                 final Keyspace keyspace = 
Keyspace.open(value.getKeyspaceName());
+                final boolean isFullReplica = 
keyspace.isFullReplicaFor(value.key().getToken(), ClusterMetadata.current());
 
                 Mutation.PartitionUpdateCollector newPUCollector = null;
                 // TODO (required): replayFilter
@@ -389,9 +435,9 @@ public class MutationJournal
                         continue; // dropped
                     TableId tableId = e.getKey();
 
-                    // Start segment state tracking
-                    segmentStateTrackers.computeIfAbsent(segmentId, 
SegmentStateTracker::new)
-                                        .markDirty(tableId, segmentId, 
position);
+                    if (isFullReplica)
+                        segmentStateTrackers.computeIfAbsent(segmentId, 
SegmentStateTracker::new)
+                                            .markDirty(tableId, segmentId, 
position);
                     // TODO (required): shouldReplay
                     if (newPUCollector == null)
                         newPUCollector = new 
Mutation.PartitionUpdateCollector(value.id(), value.getKeyspaceName(), 
value.key());
@@ -440,13 +486,25 @@ public class MutationJournal
         }
     }
 
-    @VisibleForTesting
-    public int dropReconciledSegments(Log2OffsetsMap<?> reconciledOffsets)
+    void runCompactionBlocking()
+    {
+        journal.compactor().runNowBlocking();
+    }
+
+    /**
+     * Reclaim droppable segments without sstable promotion
+     */
+    void drainBlocking()
+    {
+        journal.compactor().drainBlocking();
+    }
+
+    /**
+     * Listener tracking how many unrepaired sstables of tracked tables 
reference each static segment.
+     */
+    public SegmentReferenceTracker segmentReferenceTracker()
     {
-        return journal.dropStaticSegments((segment) -> {
-            StaticOffsetRanges ranges = (StaticOffsetRanges) 
segment.keyStats();
-            return ranges.isFullyCovered(reconciledOffsets) && 
!segment.metadata().needsReplay();
-        });
+        return segmentReferenceTracker;
     }
 
     public void readAll(RecordConsumer<ShortMutationId> consumer)
@@ -456,6 +514,9 @@ public class MutationJournal
 
     static class JournalParams implements Params
     {
+        /** for now match {@link 
MutationTrackingService.LogStatePersister#PERSIST_INTERVAL_MILLIS} **/
+        public DurationSpec.IntMillisecondsBound compactionPeriod = new 
DurationSpec.IntMillisecondsBound("1s");
+
         @Override
         public int segmentSize()
         {
@@ -488,7 +549,7 @@ public class MutationJournal
         @Override
         public int compactMaxSegments()
         {
-            return 0;
+            return Integer.MAX_VALUE;
         }
 
         @Override
@@ -500,13 +561,13 @@ public class MutationJournal
         @Override
         public boolean enableCompaction()
         {
-            return false;
+            return 
DatabaseDescriptor.getMutationTrackingConfig().journal_compaction_enabled;
         }
 
         @Override
-        public long compactionPeriod(TimeUnit units)
+        public long compactionPeriod(TimeUnit unit)
         {
-            return 0;
+            return compactionPeriod.to(unit);
         }
 
         @Override
@@ -804,8 +865,11 @@ public class MutationJournal
         }
 
         /**
-         * @return whether all keys in the segment are fully covered by the 
specified (durably reconciled) offsets map
+         * @return whether every key range in this segment is fully covered by 
the given (durably reconciled)
+         * offsets — i.e. every mutation id the segment holds has been durably 
reconciled across peers. Used to
+         * decide when a segment holding witnessed-only data may be dropped.
          */
+        @SuppressWarnings("unchecked")
         boolean isFullyCovered(Log2OffsetsMap<?> durablyReconciled)
         {
             Long2ObjectHashMap<Offsets> reconciledMap = 
((Log2OffsetsMap<Offsets>) durablyReconciled).asMap();
@@ -819,9 +883,8 @@ public class MutationJournal
                 int max = maxOffset(range);
 
                 Offsets offsets = reconciledMap.get(logId);
-                if (offsets == null)
-                    return false;
-                if (!offsets.containsRange(min, max))
+                if (offsets == null
+                    || !offsets.containsRange(min, max))
                     return false;
             }
             return true;
@@ -884,7 +947,19 @@ public class MutationJournal
     @VisibleForTesting
     public int countStaticSegmentsForTesting()
     {
-        return journal.countStaticSegmentsForTesting();
+        return journal.countStaticSegments();
+    }
+
+    /**
+     *  Lets tests wait for reconciliation to converge without having to first 
release the sstable references
+     *  that gate (R).
+     * @return the number of static segments not yet fully covered by the 
given durably-reconciled offsets.
+     */
+    @VisibleForTesting
+    public int 
countStaticSegmentsPendingReconciliationForTesting(Log2OffsetsMap<?> 
durablyReconciled)
+    {
+        return journal.countStaticSegmentsForTesting(
+        segment -> !((StaticOffsetRanges) 
segment.keyStats()).isFullyCovered(durablyReconciled));
     }
 
     public long getDiskSpaceUsed()
diff --git 
a/src/java/org/apache/cassandra/replication/MutationJournalSegmentCompactor.java
 
b/src/java/org/apache/cassandra/replication/MutationJournalSegmentCompactor.java
new file mode 100644
index 0000000000..bc16343571
--- /dev/null
+++ 
b/src/java/org/apache/cassandra/replication/MutationJournalSegmentCompactor.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.replication;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.Mutation;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.sstable.metadata.StatsMetadata;
+import org.apache.cassandra.journal.SegmentCompactor;
+import org.apache.cassandra.journal.StaticSegment;
+import org.apache.cassandra.schema.ReplicationType;
+import org.apache.cassandra.service.ActiveRepairService;
+import 
org.apache.cassandra.service.replication.migration.KeyspaceMigrationInfo;
+import 
org.apache.cassandra.service.replication.migration.MutationTrackingMigrationState;
+import org.apache.cassandra.tcm.ClusterMetadata;
+import org.apache.cassandra.utils.Clock;
+import org.apache.cassandra.utils.JVMStabilityInspector;
+import org.apache.cassandra.utils.NoSpamLogger;
+
+/**
+ * Segment compactor: takes static segments, selects the ones that are safe to 
drop, and drops them
+ */
+class MutationJournalSegmentCompactor implements 
SegmentCompactor<ShortMutationId, Mutation>
+{
+    private static final Logger logger = 
LoggerFactory.getLogger(MutationJournalSegmentCompactor.class);
+
+    private static final NoSpamLogger noSpamLogger = 
NoSpamLogger.getLogger(logger, 1L, TimeUnit.MINUTES);
+
+    private final MutationJournal journal;
+
+    // for testing only
+    private final Supplier<Log2OffsetsMap<?>> durablyReconciledOffsetsSupplier;
+
+    @VisibleForTesting
+    MutationJournalSegmentCompactor(MutationJournal journal, 
Supplier<Log2OffsetsMap<?>> durablyReconciledOffsetsSupplier)
+    {
+        this.journal = journal;
+        this.durablyReconciledOffsetsSupplier = 
durablyReconciledOffsetsSupplier;
+    }
+
+    /**
+     * Returns the list of segments that do not need to be replayed, and that 
are not referenced by a tracked
+     * SSTable, and that are durably reconciled over the full {@link 
#durablyReconciledOffsets durably reconciled offsets}.
+     *
+     * @param candidates the list of potential candidate segments
+     * @return a subset of {@code candidates} that are safe to drop
+     */
+    @Override
+    public Collection<StaticSegment<ShortMutationId, Mutation>> 
select(Collection<StaticSegment<ShortMutationId, Mutation>> candidates)
+    {
+        Log2OffsetsMap<?> durablyReconciled = durablyReconciledOffsets();
+        List<StaticSegment<ShortMutationId, Mutation>> selected = new 
ArrayList<>();
+        for (StaticSegment<ShortMutationId, Mutation> segment : candidates)
+        {
+            if (!segment.metadata().needsReplay()
+                && 
!journal.segmentReferenceTracker().isReferenced(segment.id())
+                && ((MutationJournal.StaticOffsetRanges) 
segment.keyStats()).isFullyCovered(durablyReconciled))
+                selected.add(segment);
+        }
+        return selected;
+    }
+
+    /**
+     * We drop every segment, since already know that the selected segments 
are safe to be discarded
+     */
+    @Override
+    public Collection<StaticSegment<ShortMutationId, Mutation>> 
compact(Collection<StaticSegment<ShortMutationId, Mutation>> segments)
+    {
+        return Collections.emptyList();
+    }
+
+    @Override
+    public void onCompacted()
+    {
+        try
+        {
+            maybePromoteReconciledSSTables();
+        }
+        catch (Throwable t)
+        {
+            JVMStabilityInspector.inspectThrowable(t);
+            noSpamLogger.warn("Failed to promote reconciled sstables to 
repaired; will retry on a later pass", t);
+        }
+    }
+
+    Log2OffsetsMap<?> durablyReconciledOffsets()
+    {
+        if (durablyReconciledOffsetsSupplier == null) // 
durablyReconciledOffsetsSupplier is only used for testing
+        {
+            Log2OffsetsMap.Mutable durablyReconciled = new 
Log2OffsetsMap.Mutable();
+            if (MutationTrackingService.isEnabled())
+                
MutationTrackingService.instance().collectDurablyReconciledOffsets(durablyReconciled);
+            return durablyReconciled;
+        }
+        return durablyReconciledOffsetsSupplier.get();
+    }
+
+    /**
+     * Out-of-band promotion of already durably-reconciled but 
still-unrepaired sstables to repaired, attempted at
+     * the end of every compaction pass while the on-disk mutation journal is 
over the
+     * {@code mutation_tracking.journal_promotion_threshold}.
+     *
+     * <p>Best-effort: an sstable we cannot make a decision about is skipped, 
and a failure to flip
+     * one table's sstables is logged and retried on a later pass.
+     */
+    @VisibleForTesting
+    void maybePromoteReconciledSSTables()
+    {
+        long threshold = 
DatabaseDescriptor.getMutationTrackingConfig().getJournalPromotionThresholdBytes();
+        if (threshold <= 0)
+            return;
+
+        ClusterMetadata metadata = ClusterMetadata.currentNullable();
+        if (metadata == null)
+            return;
+
+        Set<SSTableReader> trackedSSTables = 
journal.segmentReferenceTracker().trackedSSTables();
+        if (trackedSSTables.isEmpty() || journal.getDiskSpaceUsed() <= 
threshold)
+            return;
+
+        MutationTrackingMigrationState mutationTrackingMigrationState = 
metadata.mutationTrackingMigrationState;
+        long repairedAt = Clock.Global.currentTimeMillis();
+        Map<ColumnFamilyStore, List<SSTableReader>> toPromoteByTable = new 
HashMap<>();
+        for (SSTableReader sstable : trackedSSTables)
+        {
+            ColumnFamilyStore cfs;
+            try
+            {
+                if 
(!isReconciliationPromotable(mutationTrackingMigrationState, sstable))
+                    continue;
+
+                cfs = ColumnFamilyStore.getIfExists(sstable.metadata().id);
+            }
+            catch (Throwable t)
+            {
+                JVMStabilityInspector.inspectThrowable(t);
+                logger.debug("Skipping sstable ({}) for reconciliation 
promotion; could not determine eligibility",
+                             sstable.getId(), t);
+                continue;
+            }
+
+            if (cfs == null)
+                continue;
+
+            toPromoteByTable.computeIfAbsent(cfs, ignore -> new 
ArrayList<>()).add(sstable);
+        }
+
+        if (toPromoteByTable.isEmpty())
+            return;
+
+        for (Map.Entry<ColumnFamilyStore, List<SSTableReader>> entry : 
toPromoteByTable.entrySet())
+        {
+            ColumnFamilyStore cfs = entry.getKey();
+            List<SSTableReader> toPromote = entry.getValue();
+            try
+            {
+                cfs.getCompactionStrategyManager().mutateRepaired(toPromote, 
repairedAt, ActiveRepairService.NO_PENDING_REPAIR);
+                logger.debug("Promoted {} reconciled sstables of {}.{} to 
repaired to release journal segments",
+                             toPromote.size(), cfs.getKeyspaceName(), 
cfs.getTableName());
+            }
+            catch (Throwable t)
+            {
+                JVMStabilityInspector.inspectThrowable(t);
+                noSpamLogger.warn("Failed to promote reconciled sstables of 
{}.{} to repaired; will retry",
+                                  cfs.getKeyspaceName(), cfs.getTableName(), 
t);
+            }
+        }
+    }
+
+    /**
+     * Whether the {@code sstable} is eligible for reconciliation-based 
promotion to repaired.
+     *
+     * <p>We replicate the guard in {@link 
org.apache.cassandra.io.sstable.format.SSTableWriter#finalizeMetadata()}
+     * to exclude sstables that have a pending migration.
+     *
+     * <p>During {@code MIGRATE_TO} writes are tracked for all tokens while 
reads are not. So we exclude
+     * sstables already owned by a pending repair session.
+     *
+     * @param state   the mutation tracking migration state
+     * @param sstable the sstable to test
+     * @return true if the sstable should be considered for promotion to 
repaired
+     */
+    private boolean isReconciliationPromotable(MutationTrackingMigrationState 
state, SSTableReader sstable)
+    {
+        StatsMetadata stats = sstable.getSSTableMetadata();
+        if (stats.repairedAt != ActiveRepairService.UNREPAIRED_SSTABLE || 
stats.pendingRepair != ActiveRepairService.NO_PENDING_REPAIR)
+            return false;
+
+        ReplicationType replicationType = sstable.metadata().replicationType();
+        if (replicationType == null || !replicationType.isTracked())
+            return false;
+
+        KeyspaceMigrationInfo migrationInfo = 
state.getKeyspaceInfo(sstable.metadata().keyspace);
+        boolean inMigrationPendingRange = migrationInfo != null && 
migrationInfo.isRangeInPendingMigration(sstable.metadata().id,
+                                                                               
                            sstable.getFirst().getToken(),
+                                                                               
                            sstable.getLast().getToken());
+        if (inMigrationPendingRange)
+            return false;
+
+        return 
MutationTrackingService.instance().isDurablyReconciled(stats.coordinatorLogOffsets);
+    }
+}
diff --git 
a/src/java/org/apache/cassandra/replication/MutationTrackingService.java 
b/src/java/org/apache/cassandra/replication/MutationTrackingService.java
index db87a88f01..9f6d4e395a 100644
--- a/src/java/org/apache/cassandra/replication/MutationTrackingService.java
+++ b/src/java/org/apache/cassandra/replication/MutationTrackingService.java
@@ -53,6 +53,7 @@ import org.apache.cassandra.config.DatabaseDescriptor;
 import org.apache.cassandra.config.DurationSpec;
 import org.apache.cassandra.config.MutationTrackingSpec;
 import org.apache.cassandra.cql3.UntypedResultSet;
+import org.apache.cassandra.db.ColumnFamilyStore;
 import org.apache.cassandra.db.ConsistencyLevel;
 import org.apache.cassandra.db.DecoratedKey;
 import org.apache.cassandra.db.Keyspace;
@@ -252,8 +253,14 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
 
         onNewClusterMetadata(null, metadata);
 
-        if (!keyspaceShards.isEmpty() && 
!config.background_reconciliation_enabled)
-            
logBackgroundReconciliationDisabledWarning(keyspaceShards.keySet());
+        if (!keyspaceShards.isEmpty())
+        {
+            if (!config.background_reconciliation_enabled)
+                
logBackgroundReconciliationDisabledWarning(keyspaceShards.keySet());
+
+            if (!config.journal_compaction_enabled)
+                logJournalCompactionDisabledWarning(keyspaceShards.keySet());
+        }
 
         offsetsBroadcaster.start();
         offsetsPersister.start();
@@ -339,10 +346,14 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         if (!executor.awaitTermination(1, TimeUnit.MINUTES))
             logger.warn("Mutation tracking executor did not terminate within 1 
minute; forcing shutdown");
 
-        // attempt to persist offsets and mark segments as
-        // not needing replay one last time before shutdown
+        // attempt to persist offsets and mark segments as not needing replay 
one last time before shutdown, then
+        // reclaim any now-droppable segments synchronously (the executor is 
stopped, so scheduled attempts from
+        // drainCleanup would never run).
         if (isStarted())
-            offsetsPersister.run(true);
+        {
+            offsetsPersister.persistAndDrain();
+            MutationJournal.instance().drainBlocking();
+        }
         ExpiredStatePurger.instance.shutdownBlocking();
     }
 
@@ -1005,6 +1016,7 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
             {
                 case MIGRATE_FROM:
                     // TODO (expected): Implement shard deletion for tracked → 
untracked migration completion (CASSANDRA-20955)
+                    evictSegmentReferences(keyspace);
                 case NONE:
                     if (current != null)
                         updated.put(keyspace, current);
@@ -1041,6 +1053,20 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         return updated;
     }
 
+    private static void evictSegmentReferences(String keyspace)
+    {
+        if (!DatabaseDescriptor.getMutationTrackingEnabled())
+            return;
+
+        Keyspace ks = Schema.instance.getKeyspaceInstance(keyspace);
+        if (ks == null)
+            return;
+
+        SegmentReferenceTracker tracker = 
MutationJournal.instance().segmentReferenceTracker();
+        for (ColumnFamilyStore cfs : ks.getColumnFamilyStores())
+            tracker.evict(cfs.getLiveSSTables());
+    }
+
     // TODO (expected): when topology and state truncation is implemented, 
implement cleanup of this map as well
     private void onNewLog(Shard shard, CoordinatorLog log)
     {
@@ -1055,21 +1081,26 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         }
     }
 
-    private void truncateMutationJournal()
+    void collectDurablyReconciledOffsets(Log2OffsetsMap.Mutable into)
     {
-        Log2OffsetsMap.Mutable reconciledOffsets = new 
Log2OffsetsMap.Mutable();
-        collectDurablyReconciledOffsets(reconciledOffsets);
-        MutationJournal.instance().dropReconciledSegments(reconciledOffsets);
+        forEachKeyspace(keyspace -> 
keyspace.collectDurablyReconciledOffsets(into));
     }
 
     /**
-     * Collect every log's durably reconciled offsets. Every mutation covered
-     * by these offsets can be compacted away by the journal, assuming that all
-     * relevant memtables had been flushed to disk.
+     * @return true once every static journal segment is fully covered by the 
durably-reconciled offsets
      */
-    private void collectDurablyReconciledOffsets(Log2OffsetsMap.Mutable into)
+    @VisibleForTesting
+    public boolean allStaticSegmentsDurablyReconciledForTesting()
     {
-        forEachKeyspace(keyspace -> 
keyspace.collectDurablyReconciledOffsets(into));
+        Log2OffsetsMap.Mutable durablyReconciled = new 
Log2OffsetsMap.Mutable();
+        collectDurablyReconciledOffsets(durablyReconciled);
+        return 
MutationJournal.instance().countStaticSegmentsPendingReconciliationForTesting(durablyReconciled)
 == 0;
+    }
+
+    @VisibleForTesting
+    public void maybePromoteReconciledSSTablesForTesting()
+    {
+        
MutationJournal.instance().segmentCompactor.maybePromoteReconciledSSTables();
     }
 
     public SyncTasks alignToShardBoundaries(Keyspace keyspace, List<SyncTask> 
tasks)
@@ -1138,6 +1169,14 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
                     "Unreconciled mutations will not be automatically repaired 
in the background.", keyspaces);
     }
 
+    private void logJournalCompactionDisabledWarning(Set<String> keyspaces)
+    {
+        logger.warn("Mutation journal compaction is disabled but mutation 
tracking keyspaces exist: {}. " +
+                    "Journal segments will not be reclaimed and 
durably-reconciled sstables will not be promoted " +
+                    "to repaired 
(mutation_tracking.journal_promotion_threshold has no effect while compaction 
is " +
+                    "disabled); the on-disk journal will grow without bound.", 
keyspaces);
+    }
+
     public static class KeyspaceShards
     {
         private enum UpdateDecision
@@ -1380,17 +1419,17 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
             });
         }
 
-        void collectDurablyReconciledOffsets(Log2OffsetsMap.Mutable into)
-        {
-            forEachShard(shard -> shard.collectDurablyReconciledOffsets(into));
-        }
-
         void forEachShard(Consumer<Shard> consumer)
         {
             for (Shard shard : shards.values())
                 consumer.accept(shard);
         }
 
+        void collectDurablyReconciledOffsets(Log2OffsetsMap.Mutable into)
+        {
+            forEachShard(shard -> shard.collectDurablyReconciledOffsets(into));
+        }
+
         Shard lookUp(Mutation mutation)
         {
             return lookUp(mutation.key());
@@ -1657,12 +1696,11 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
         {
             if (isPaused)
                 return;
-            run(true);
+            persistAndDrain();
         }
 
-        private void run(boolean dropSegments)
+        void persistAndDrain()
         {
-
             MutationJournal.PendingClearReplay toDrain = 
MutationJournal.instance().snapshotPendingClearReplay();
 
             boolean writesOk;
@@ -1679,9 +1717,6 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
 
             if (writesOk)
                 MutationJournal.instance().drainCleanup(toDrain);
-
-            if (dropSegments)
-                MutationTrackingService.instance().truncateMutationJournal();
         }
 
         private void run(KeyspaceShards shards)
@@ -1698,13 +1733,15 @@ public class MutationTrackingService implements 
MutationTrackingServiceMBean
     @VisibleForTesting
     public void persistLogStateForTesting()
     {
-        offsetsPersister.run();
+        persistLogStateForTesting(true);
     }
 
     @VisibleForTesting
     public void persistLogStateForTesting(boolean dropSegments)
     {
-        offsetsPersister.run(dropSegments);
+        offsetsPersister.persistAndDrain();
+        if (dropSegments)
+            MutationJournal.instance().runCompactionBlocking();
     }
 
     @VisibleForTesting
diff --git 
a/src/java/org/apache/cassandra/replication/SegmentReferenceTracker.java 
b/src/java/org/apache/cassandra/replication/SegmentReferenceTracker.java
new file mode 100644
index 0000000000..45c9f95517
--- /dev/null
+++ b/src/java/org/apache/cassandra/replication/SegmentReferenceTracker.java
@@ -0,0 +1,385 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.replication;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.LongConsumer;
+import java.util.function.Supplier;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.agrona.collections.Long2ObjectHashMap;
+
+import org.apache.cassandra.db.commitlog.CommitLogPosition;
+import org.apache.cassandra.db.commitlog.IntervalSet;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.notifications.INotification;
+import org.apache.cassandra.notifications.INotificationConsumer;
+import org.apache.cassandra.notifications.InitialSSTableAddedNotification;
+import org.apache.cassandra.notifications.SSTableAddedNotification;
+import org.apache.cassandra.notifications.SSTableListChangedNotification;
+import org.apache.cassandra.notifications.SSTableRepairStatusChanged;
+import org.apache.cassandra.schema.ReplicationType;
+import org.apache.cassandra.service.StorageService;
+
+/**
+ * Tracks, for each mutation journal segment, the set of local unrepaired 
sstables of tracked tables that
+ * reference it.
+ *
+ * <p>A sstable references a segment iff the sstable's {@code 
StatsMetadata.commitLogIntervals} —
+ * which for tracked tables stores mutation journal positions, not commit log 
positions — covers
+ * any position within that segment. The granularity is coarse: the referenced 
range is the union
+ * of the memtable lower/upper bounds for the sstable's lineage. This matches 
existing commit log
+ * retention semantics; it can include a segment with no actual data from this 
sstable, which only
+ * defers (never incorrectly enables) segment dropping.
+ *
+ * <p>Used by {@link MutationJournal} to decide when a static segment may be 
dropped: a segment may be
+ * dropped only once it has no unrepaired references and {@code !needsReplay}. 
This guarantees the
+ * journal can rebuild any unrepaired sstable from the journal if minority 
writes need to be filtered
+ * out (CASSANDRA-21407).
+ *
+ * <p>A per-segment <em>set</em> of referrers (rather than a bare refcount) is 
kept so that individual
+ * referrers can be located and dropped from a segment's set — needed to 
reason about keyspaces migrating
+ * to and from tracked replication (CASSANDRA-21406) and to surface which 
sstables hold a segment.
+ */
+public class SegmentReferenceTracker implements INotificationConsumer
+{
+    // Guards both referrersBySegment and trackedSstables to keep transitions 
atomic across notifications.
+    private final ReentrantLock lock = new ReentrantLock();
+
+    // segment id -> set of local unrepaired sstables referencing it
+    private final Long2ObjectHashMap<Set<SSTableReader>> referrersBySegment = 
new Long2ObjectHashMap<>();
+
+    // Sstables we currently hold refs for.
+    // Required so SSTableRepairStatusChanged can transition an sstable in/out 
without the notification
+    // having to carry the previous repair state.
+    private final Set<SSTableReader> trackedSSTables = new HashSet<>();
+
+    // Invoked whenever a notification drives at least one segment's reference 
count to zero,
+    // so the journal can attempt to drop the now-unreferenced segment(s).
+    private final Runnable onSegmentsUnreferenced;
+
+    // Resolves the local host id. May return null very early in startup, in 
which case no sstable is
+    // treated as locally originated.
+    private final Supplier<UUID> localHostIdSupplier;
+
+    public SegmentReferenceTracker(Runnable onSegmentsUnreferenced)
+    {
+        this(onSegmentsUnreferenced, 
StorageService.instance::getLocalHostUUID);
+    }
+
+    @VisibleForTesting
+    SegmentReferenceTracker(Runnable onSegmentsUnreferenced, Supplier<UUID> 
localHostIdSupplier)
+    {
+        this.onSegmentsUnreferenced = onSegmentsUnreferenced;
+        this.localHostIdSupplier = localHostIdSupplier;
+    }
+
+    @Override
+    public void handleNotification(INotification notification, Object sender)
+    {
+        if (notification instanceof SSTableAddedNotification)
+            onAdded(((SSTableAddedNotification) notification).added);
+        else if (notification instanceof InitialSSTableAddedNotification)
+            onAdded(((InitialSSTableAddedNotification) notification).added);
+        else if (notification instanceof SSTableListChangedNotification)
+            onListChanged((SSTableListChangedNotification) notification);
+        else if (notification instanceof SSTableRepairStatusChanged)
+            onRepairStatusChanged(((SSTableRepairStatusChanged) 
notification).sstables);
+    }
+
+    /**
+     * @param segmentId the identifier for the segment
+     * @return whether any unrepaired local sstable references the given 
segment id
+     */
+    boolean isReferenced(long segmentId)
+    {
+        lock.lock();
+        try
+        {
+            return referrersBySegment.containsKey(segmentId);
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+
+    private void onAdded(Iterable<SSTableReader> added)
+    {
+        lock.lock();
+        try
+        {
+            for (SSTableReader sstable : added)
+                acquireIfTracked(sstable);
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+
+    private void onListChanged(SSTableListChangedNotification notification)
+    {
+        boolean anyReleased = false;
+        lock.lock();
+        try
+        {
+            // Process additions before removals so refcounts are never 
observed briefly empty
+            // between a compaction's input drop and output add when both span 
the same segment.
+            for (SSTableReader sstable : notification.added)
+                acquireIfTracked(sstable);
+            for (SSTableReader sstable : notification.removed)
+                anyReleased |= releaseIfTracked(sstable);
+        }
+        finally
+        {
+            lock.unlock();
+        }
+        if (anyReleased)
+            onSegmentsUnreferenced.run();
+    }
+
+    private void onRepairStatusChanged(Collection<SSTableReader> changed)
+    {
+        boolean anyReleased = false;
+        lock.lock();
+        try
+        {
+            for (SSTableReader sstable : changed)
+            {
+                if (shouldTrack(sstable))
+                    acquireIfTracked(sstable);
+                else
+                    anyReleased |= releaseIfTracked(sstable);
+            }
+        }
+        finally
+        {
+            lock.unlock();
+        }
+        if (anyReleased)
+            onSegmentsUnreferenced.run();
+    }
+
+    /**
+     * Release every reference currently held for the given sstables, dropping 
each from every segment's referrer
+     * set.
+     */
+    public void evict(Iterable<SSTableReader> sstables)
+    {
+        boolean anyReleased = false;
+        lock.lock();
+        try
+        {
+            for (SSTableReader sstable : sstables)
+                anyReleased |= releaseIfTracked(sstable);
+        }
+        finally
+        {
+            lock.unlock();
+        }
+        if (anyReleased)
+            onSegmentsUnreferenced.run();
+    }
+
+    /**
+     * @return an immutable snapshot of the currently tracked SSTables
+     */
+    public Set<SSTableReader> trackedSSTables()
+    {
+        lock.lock();
+        try
+        {
+            if (trackedSSTables.isEmpty())
+                return Set.of();
+            return Set.copyOf(trackedSSTables);
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+
+    /**
+     * @return true when all these conditions are true, the sstable is locally 
originated, unrepaired, carries
+     * tracked mutations, and it belongs to a tracked table
+     */
+    boolean shouldTrack(SSTableReader sstable)
+    {
+        return isLocallyOriginated(sstable)
+               && !sstable.isRepaired()
+               && !sstable.getCoordinatorLogOffsets().isEmpty()
+               && isTrackedTable(sstable);
+    }
+
+    private static boolean isTrackedTable(SSTableReader sstable)
+    {
+        ReplicationType replicationType = sstable.metadata().replicationType();
+        return replicationType != null && replicationType.isTracked();
+    }
+
+    private boolean isLocallyOriginated(SSTableReader sstable)
+    {
+        UUID originatingHostId = 
sstable.getSSTableMetadata().originatingHostId;
+        UUID localHostId = localHostIdSupplier.get();
+        return originatingHostId != null && 
originatingHostId.equals(localHostId);
+    }
+
+    private void acquireIfTracked(SSTableReader sstable)
+    {
+        if (shouldTrack(sstable) && trackedSSTables.add(sstable))
+            forEachSegment(sstable, segmentId ->
+                                    
referrersBySegment.computeIfAbsent(segmentId, k -> new 
HashSet<>()).add(sstable));
+    }
+
+    /**
+     * @return true if releasing this sstable emptied at least one segment's 
referrer set
+     */
+    private boolean releaseIfTracked(SSTableReader sstable)
+    {
+        if (!trackedSSTables.remove(sstable))
+            return false;
+        boolean[] anyEmptied = { false };
+        forEachSegment(sstable, segmentId -> {
+            Set<SSTableReader> referrers = referrersBySegment.get(segmentId);
+            if (referrers != null && referrers.remove(sstable) && 
referrers.isEmpty())
+            {
+                referrersBySegment.remove(segmentId);
+                anyEmptied[0] = true;
+            }
+        });
+        return anyEmptied[0];
+    }
+
+    private static void forEachSegment(SSTableReader sstable, LongConsumer 
consumer)
+    {
+        IntervalSet<CommitLogPosition> intervals = 
sstable.getSSTableMetadata().commitLogIntervals;
+        if (intervals.isEmpty())
+            return;
+
+        // IntervalSet guarantees starts and ends are returned in matching 
order.
+        Iterator<CommitLogPosition> startIt = intervals.starts().iterator();
+        Iterator<CommitLogPosition> endIt = intervals.ends().iterator();
+        while (startIt.hasNext())
+        {
+            CommitLogPosition start = startIt.next();
+            CommitLogPosition end = endIt.next();
+            for (long s = start.segmentId; s <= end.segmentId; s++)
+                consumer.accept(s);
+        }
+    }
+
+    /**
+     * Number of unrepaired local sstables currently holding the given segment 
(for diagnostics / the vtable).
+     */
+    int referenceCount(long segmentId)
+    {
+        lock.lock();
+        try
+        {
+            Set<SSTableReader> referrers = referrersBySegment.get(segmentId);
+            return referrers == null ? 0 : referrers.size();
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+
+    /**
+     * Sorted base filenames of the sstables currently holding the given 
segment (for diagnostics / the vtable).
+     */
+    public List<String> referrerDescriptors(long segmentId)
+    {
+        lock.lock();
+        try
+        {
+            Set<SSTableReader> referrers = referrersBySegment.get(segmentId);
+            if (referrers == null || referrers.isEmpty())
+                return Collections.emptyList();
+            List<String> names = new ArrayList<>(referrers.size());
+            for (SSTableReader sstable : referrers)
+                names.add(sstable.descriptor.baseFile().name());
+            Collections.sort(names);
+            return names;
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+
+    @VisibleForTesting
+    long referenceCountForTesting(long segmentId)
+    {
+        return referenceCount(segmentId);
+    }
+
+    @VisibleForTesting
+    int trackedSstableCountForTesting()
+    {
+        lock.lock();
+        try
+        {
+            return trackedSSTables.size();
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+
+    @VisibleForTesting
+    void addReferenceForTesting(long segmentId, SSTableReader referrer)
+    {
+        lock.lock();
+        try
+        {
+            referrersBySegment.computeIfAbsent(segmentId, k -> new 
HashSet<>()).add(referrer);
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+
+    @VisibleForTesting
+    void removeReferenceForTesting(long segmentId, SSTableReader referrer)
+    {
+        lock.lock();
+        try
+        {
+            Set<SSTableReader> referrers = referrersBySegment.get(segmentId);
+            if (referrers != null && referrers.remove(referrer) && 
referrers.isEmpty())
+                referrersBySegment.remove(segmentId);
+        }
+        finally
+        {
+            lock.unlock();
+        }
+    }
+}
diff --git a/src/java/org/apache/cassandra/replication/Shard.java 
b/src/java/org/apache/cassandra/replication/Shard.java
index dd4e349023..e8bd73c2e9 100644
--- a/src/java/org/apache/cassandra/replication/Shard.java
+++ b/src/java/org/apache/cassandra/replication/Shard.java
@@ -317,7 +317,8 @@ public class Shard
 
     void collectDurablyReconciledOffsets(Log2OffsetsMap.Mutable into)
     {
-        logs.values().forEach(log -> 
log.collectDurablyReconciledOffsets(into));
+        for (CoordinatorLog log : logs.values())
+            log.collectDurablyReconciledOffsets(into);
     }
 
     private CoordinatorLog getOrCreate(Mutation mutation)
diff --git a/src/java/org/apache/cassandra/service/accord/AccordJournal.java 
b/src/java/org/apache/cassandra/service/accord/AccordJournal.java
index a255a890fc..5f13593aed 100644
--- a/src/java/org/apache/cassandra/service/accord/AccordJournal.java
+++ b/src/java/org/apache/cassandra/service/accord/AccordJournal.java
@@ -202,6 +202,7 @@ public class AccordJournal implements accord.api.Journal, 
RangeSearcher.Supplier
         // start table first to scrub directories before compactor starts
         journalTable.start();
         journal.start();
+        journal.waitForCompaction(100);
     }
 
     public boolean started()
@@ -1253,4 +1254,4 @@ public class AccordJournal implements accord.api.Journal, 
RangeSearcher.Supplier
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationJournalSegmentRefcountTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationJournalSegmentRefcountTest.java
new file mode 100644
index 0000000000..b21f3bddcd
--- /dev/null
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationJournalSegmentRefcountTest.java
@@ -0,0 +1,318 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.distributed.test.tracking;
+
+import org.junit.Test;
+
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.ConsistencyLevel;
+import org.apache.cassandra.distributed.api.Feature;
+import org.apache.cassandra.distributed.test.TestBaseImpl;
+import org.apache.cassandra.net.Verb;
+import org.apache.cassandra.replication.MutationJournal;
+import org.apache.cassandra.replication.MutationTrackingService;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * End-to-end coverage of CASSANDRA-21406: a static journal segment must be 
retained while any
+ * unrepaired sstable references it, and must be droppable once every 
referencing sstable has
+ * been promoted to repaired (e.g. by compaction once mutations are durably 
reconciled).
+ */
+public class MutationJournalSegmentRefcountTest extends TestBaseImpl
+{
+    private static final String CREATE_KEYSPACE =
+    "CREATE KEYSPACE %s WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': 3} " +
+    "AND replication_type = 'tracked'";
+
+    private static final String CREATE_TABLE = "CREATE TABLE %s.tbl (pk int 
PRIMARY KEY, val text)";
+
+    @Test(timeout = 120_000)
+    public void testSegmentRetainedUntilSSTableRepaired() throws Throwable
+    {
+        try (Cluster cluster = Cluster.build(3)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace(CREATE_KEYSPACE));
+            cluster.schemaChange(String.format(CREATE_TABLE, KEYSPACE));
+
+            // Disable autocompaction so flushed sstables stay until we 
explicitly compact.
+            cluster.forEach(i -> i.nodetoolResult("disableautocompaction", 
KEYSPACE, "tbl").asserts().success());
+
+            // Block offset broadcasts: each node only sees its own witnesses, 
so isDurablyReconciled is
+            // false everywhere and SSTableWriter cannot auto-mark the flushed 
sstables as repaired.
+            cluster.filters().verbs(Verb.MT_BROADCAST_LOG_OFFSETS.id).drop();
+
+            for (int i = 0; i < 50; i++)
+            {
+                cluster.coordinator(1)
+                       .execute(withKeyspace("INSERT INTO %s.tbl (pk, val) 
VALUES (?, ?)"),
+                                ConsistencyLevel.QUORUM, i, "v" + i);
+            }
+
+            // Flush and force the active journal segment to roll so we have a 
static segment to inspect.
+            cluster.forEach(i -> i.nodetoolResult("flush", 
KEYSPACE).asserts().success());
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationJournal.instance().closeCurrentSegmentForTestingIfNonEmpty()));
+
+            // Confirm there is a static segment that the new refcount is 
keeping alive, then try to drop:
+            // the dropping pass must be a no-op because every flushed sstable 
is unrepaired.
+            cluster.forEach(i -> i.runOnInstance(() -> {
+                int before = 
MutationJournal.instance().countStaticSegmentsForTesting();
+                assertTrue("Expected at least one static segment after 
flush+segment close, got " + before, before > 0);
+                MutationTrackingService.instance().persistLogStateForTesting();
+                int after = 
MutationJournal.instance().countStaticSegmentsForTesting();
+                assertEquals("Static segments must not be dropped while 
unrepaired sstables reference them",
+                             before, after);
+            }));
+
+            // Restore broadcast, exchange witnesses, and persist so 
isDurablyReconciled is now true everywhere.
+            cluster.filters().reset();
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().broadcastOffsetsForTesting()));
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting()));
+
+            // Major-compact the table. Compaction rewrites the sstable 
through SSTableWriter.finalizeMetadata,
+            // which detects that all mutations are durably reconciled and 
stamps repairedAt on the output.
+            // SSTableListChangedNotification then releases refs from the 
(unrepaired) inputs without acquiring
+            // any from the (repaired) output -> refcount drops to zero.
+            cluster.forEach(i -> i.nodetoolResult("compact", KEYSPACE, 
"tbl").asserts().success());
+
+            // Now the persister can drop the static segments.
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting()));
+            cluster.forEach(i -> i.runOnInstance(() -> {
+                int remaining = 
MutationJournal.instance().countStaticSegmentsForTesting();
+                assertEquals("Static segments must be dropped once their 
sstables are promoted to repaired",
+                             0, remaining);
+            }));
+        }
+    }
+
+    /**
+     * CASSANDRA-21406 (item 1, tracked -> untracked migration): a keyspace's 
unrepaired sstables hold journal
+     * segments while it is tracked, but once it migrates away from tracked 
those sstables are never promoted to
+     * repaired (that path is gated on the table being tracked). Their 
references must therefore be evicted on
+     * MIGRATE_FROM so the segments can be reclaimed rather than pinned 
forever.
+     */
+    @Test(timeout = 120_000)
+    public void testSegmentsReleasedWhenKeyspaceMigratesFromTracked() throws 
Throwable
+    {
+        try (Cluster cluster = Cluster.build(3)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace(CREATE_KEYSPACE));
+            cluster.schemaChange(String.format(CREATE_TABLE, KEYSPACE));
+
+            cluster.forEach(i -> i.nodetoolResult("disableautocompaction", 
KEYSPACE, "tbl").asserts().success());
+
+            for (int i = 0; i < 50; i++)
+            {
+                cluster.coordinator(1)
+                       .execute(withKeyspace("INSERT INTO %s.tbl (pk, val) 
VALUES (?, ?)"),
+                                ConsistencyLevel.QUORUM, i, "v" + i);
+            }
+
+            cluster.forEach(i -> i.nodetoolResult("flush", 
KEYSPACE).asserts().success());
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationJournal.instance().closeCurrentSegmentForTestingIfNonEmpty()));
+
+            // Durably reconcile every offset (but keep the flushed sstables 
unrepaired, since we never compact),
+            // driving the cross-node offset exchange to convergence. This 
satisfies the reconciliation gate (W) so
+            // the *only* thing still pinning the segments is the unrepaired 
sstable references (R) -- making the
+            // eviction below the sole reason they can finally drop.
+            boolean reconciled = false;
+            for (int round = 0; round < 30 && !reconciled; round++)
+            {
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting());
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().broadcastOffsetsForTesting());
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting());
+
+                reconciled = true;
+                for (int n = 1; n <= cluster.size(); n++)
+                    reconciled &= cluster.get(n).callOnInstance(() ->
+                        
MutationTrackingService.instance().allStaticSegmentsDurablyReconciledForTesting());
+            }
+            assertTrue("Reconciliation must converge so only the sstable 
references still pin the segments", reconciled);
+
+            // While tracked, the (fully reconciled but) unrepaired sstables 
still hold their segments: a drop pass
+            // is a no-op.
+            cluster.forEach(i -> i.runOnInstance(() -> {
+                int before = 
MutationJournal.instance().countStaticSegmentsForTesting();
+                assertTrue("Expected a static segment held by the unrepaired 
sstables, got " + before, before > 0);
+                MutationTrackingService.instance().persistLogStateForTesting();
+                assertEquals("Segments must be retained while unrepaired 
tracked sstables reference them",
+                             before, 
MutationJournal.instance().countStaticSegmentsForTesting());
+            }));
+
+            // Migrate the keyspace away from tracked. tracked->untracked is 
instant; MIGRATE_FROM evicts the
+            // keyspace's sstable references from the segment tracker on every 
node.
+            cluster.schemaChange(withKeyspace("ALTER KEYSPACE %s WITH 
replication_type = 'untracked'"));
+
+            // The eviction released the references; a drop pass now reclaims 
the segments.
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting()));
+            cluster.forEach(i -> i.runOnInstance(() -> {
+                int remaining = 
MutationJournal.instance().countStaticSegmentsForTesting();
+                assertEquals("Static segments must be dropped after the 
keyspace migrates away from tracked",
+                             0, remaining);
+            }));
+        }
+    }
+
+    /**
+     * CASSANDRA-21406 (item 4): under normal operation an unrepaired sstable 
can hold its journal segments for an
+     * unbounded time (compaction, where reconciled sstables are born 
repaired, may not run for cold tables). When
+     * the journal grows past mutation_tracking.journal_promotion_threshold, 
already durably-reconciled unrepaired
+     * sstables are promoted to repaired out of band, releasing their segments 
even without a compaction.
+     */
+    @Test(timeout = 120_000)
+    public void testSegmentsReleasedBySizeTriggeredPromotion() throws Throwable
+    {
+        try (Cluster cluster = Cluster.build(3)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP)
+                                                            
.set("mutation_tracking.journal_promotion_threshold", "1KiB"))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace(CREATE_KEYSPACE));
+            cluster.schemaChange(String.format(CREATE_TABLE, KEYSPACE));
+
+            // Never let compaction promote the sstables to repaired; only the 
size-triggered promotion can.
+            cluster.forEach(i -> i.nodetoolResult("disableautocompaction", 
KEYSPACE, "tbl").asserts().success());
+
+            for (int i = 0; i < 50; i++)
+            {
+                cluster.coordinator(1)
+                       .execute(withKeyspace("INSERT INTO %s.tbl (pk, val) 
VALUES (?, ?)"),
+                                ConsistencyLevel.QUORUM, i, "v" + i);
+            }
+
+            cluster.forEach(i -> i.nodetoolResult("flush", 
KEYSPACE).asserts().success());
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationJournal.instance().closeCurrentSegmentForTestingIfNonEmpty()));
+
+            // The journal exceeds the tiny threshold and the (unrepaired) 
flushed sstables are holding segments.
+            cluster.forEach(i -> i.runOnInstance(() -> {
+                long threshold = 
DatabaseDescriptor.getMutationTrackingConfig().getJournalPromotionThresholdBytes();
+                assertTrue("promotion threshold must be configured (>0), was " 
+ threshold, threshold > 0);
+                assertTrue("journal size must exceed the threshold",
+                           MutationJournal.instance().getDiskSpaceUsed() > 
threshold);
+                assertTrue("segments must be held by the unrepaired sstables",
+                           
MutationJournal.instance().countStaticSegmentsForTesting() > 0);
+            }));
+
+            // Reconcile (persist then exchange offsets) and trigger 
size-based promotion until the reconciled
+            // sstables are flipped to repaired and their segments reclaimed. 
Promotion only flips sstables that are
+            // already durably reconciled, which requires the offset exchange 
to have propagated.
+            boolean converged = false;
+            for (int round = 0; round < 30 && !converged; round++)
+            {
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting());
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().broadcastOffsetsForTesting());
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> {
+                        
MutationTrackingService.instance().maybePromoteReconciledSSTablesForTesting();
+                        
MutationTrackingService.instance().persistLogStateForTesting();
+                    });
+
+                converged = true;
+                for (int n = 1; n <= cluster.size(); n++)
+                    converged &= cluster.get(n).callOnInstance(() -> 
MutationJournal.instance().countStaticSegmentsForTesting() == 0);
+            }
+
+            cluster.forEach(i -> i.runOnInstance(() -> {
+                int remaining = 
MutationJournal.instance().countStaticSegmentsForTesting();
+                assertEquals("Static segments must be dropped after 
size-triggered promotion of reconciled sstables",
+                             0, remaining);
+            }));
+        }
+    }
+
+    /**
+     * A transient (witness) replica journals witnessed writes but never 
applies them to a memtable, so it never
+     * flushes an sstable for them. Such witness-only journal data must not 
pin {@code needsReplay} (only a flush
+     * clears it, so it would otherwise stay set forever) and cannot be 
reclaimed by the reference path (there
+     * is no sstable to promote). Its journal segments are instead governed 
purely by the reconciliation gate.
+     * This exercises the full transient-replication path end to end on a 
{@code '3/1'} keyspace where every
+     * node is a full replica for some ranges and a witness for others: once 
every offset is durably
+     * reconciled and the full-replica sstables are compacted to repaired, 
every node reclaims all of its static
+     * segments, including those holding only witnessed data.
+     */
+    @Test(timeout = 120_000)
+    public void testWitnessSegmentsReleasedOnceReconciled() throws Throwable
+    {
+        try (Cluster cluster = Cluster.build(3)
+                                      .withConfig(cfg -> 
cfg.with(Feature.NETWORK).with(Feature.GOSSIP)
+                                                            
.set("transient_replication_enabled", "true"))
+                                      .start())
+        {
+            cluster.schemaChange(withKeyspace(
+                "CREATE KEYSPACE %s WITH replication = {'class': 
'SimpleStrategy', 'replication_factor': '3/1'} " +
+                "AND replication_type = 'tracked'"));
+            cluster.schemaChange(String.format(CREATE_TABLE, KEYSPACE));
+
+            cluster.forEach(i -> i.nodetoolResult("disableautocompaction", 
KEYSPACE, "tbl").asserts().success());
+
+            for (int i = 0; i < 50; i++)
+            {
+                cluster.coordinator(1)
+                       .execute(withKeyspace("INSERT INTO %s.tbl (pk, val) 
VALUES (?, ?)"),
+                                ConsistencyLevel.QUORUM, i, "v" + i);
+            }
+
+            cluster.forEach(i -> i.nodetoolResult("flush", 
KEYSPACE).asserts().success());
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationJournal.instance().closeCurrentSegmentForTestingIfNonEmpty()));
+
+            cluster.forEach(i -> i.runOnInstance(() ->
+                assertTrue("Expected static segments after flush+close",
+                           
MutationJournal.instance().countStaticSegmentsForTesting() > 0)));
+
+            // Reconcile to convergence across full replicas and witnesses.
+            boolean reconciled = false;
+            for (int round = 0; round < 30 && !reconciled; round++)
+            {
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting());
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().broadcastOffsetsForTesting());
+                for (int n = 1; n <= cluster.size(); n++)
+                    cluster.get(n).runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting());
+
+                reconciled = true;
+                for (int n = 1; n <= cluster.size(); n++)
+                    reconciled &= cluster.get(n).callOnInstance(() ->
+                        
MutationTrackingService.instance().allStaticSegmentsDurablyReconciledForTesting());
+            }
+            assertTrue("Reconciliation must converge across all replicas and 
witnesses", reconciled);
+
+            // Compact so the full-replica sstables are promoted to repaired 
and release their segment references.
+            // Witness-only segments have no sstable and no pinned 
needsReplay, so the reconciliation above already
+            // made them reclaimable.
+            cluster.forEach(i -> i.nodetoolResult("compact", KEYSPACE, 
"tbl").asserts().success());
+
+            cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting()));
+            cluster.forEach(i -> i.runOnInstance(() ->
+                assertEquals("All static segments (including witness-only 
ranges) must be reclaimed once reconciled",
+                             0, 
MutationJournal.instance().countStaticSegmentsForTesting())));
+        }
+    }
+}
diff --git 
a/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingLogPersisterTest.java
 
b/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingLogPersisterTest.java
index 0437aa7514..4fa1fbfb70 100644
--- 
a/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingLogPersisterTest.java
+++ 
b/test/distributed/org/apache/cassandra/distributed/test/tracking/MutationTrackingLogPersisterTest.java
@@ -74,6 +74,10 @@ public class MutationTrackingLogPersisterTest extends 
FuzzTestBase
                                                                                
           .build(schema, hb, cluster)));
                 }
 
+                // Keep background compaction from promoting sstables to 
repaired before we explicitly compact
+                // below, so the segment-drop timing is deterministic.
+                cluster.forEach(i -> i.nodetoolResult("disableautocompaction", 
KEYSPACE).asserts().success());
+
                 int counter = 0;
                 for (int pk = 0; pk < pks; pk++)
                 {
@@ -86,13 +90,22 @@ public class MutationTrackingLogPersisterTest extends 
FuzzTestBase
                 }
 
                 cluster.forEach(i -> i.nodetoolResult("flush", 
KEYSPACE).asserts().success());
+
                 cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting()));
                 cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().broadcastOffsetsForTesting()));
                 cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting()));
 
+                // The flushed sstables are unrepaired and therefore still 
reference their journal segments
+                // (CASSANDRA-21406). Now that their mutations are durably 
reconciled, compaction rewrites them into
+                // repaired outputs (SSTableWriter.finalizeMetadata), 
releasing the references so the persister can
+                // finally drop the static segments.
+                cluster.forEach(i -> i.nodetoolResult("compact", 
KEYSPACE).asserts().success());
+                cluster.forEach(i -> i.runOnInstance(() -> 
MutationTrackingService.instance().persistLogStateForTesting()));
+
                 cluster.forEach(i -> i.runOnInstance(() -> {
                     int staticSegments = 
MutationJournal.instance().countStaticSegmentsForTesting();
-                    Assert.assertEquals("Expected no static segments after log 
persister runs", 0, staticSegments);
+                    Assert.assertEquals("Expected no static segments once 
reconciled sstables are compacted to repaired",
+                                        0, staticSegments);
                 }));
             });
         }
diff --git 
a/test/unit/org/apache/cassandra/db/virtual/MutationJournalTableTest.java 
b/test/unit/org/apache/cassandra/db/virtual/MutationJournalTableTest.java
index 9bfd68e2b9..c9b75a52d7 100644
--- a/test/unit/org/apache/cassandra/db/virtual/MutationJournalTableTest.java
+++ b/test/unit/org/apache/cassandra/db/virtual/MutationJournalTableTest.java
@@ -81,7 +81,8 @@ public class MutationJournalTableTest extends CQLTester
                 "written_to",
                 "fsynced_to",
                 "needs_replay",
-                "file_path"
+                "file_path",
+                "referring_sstables"
             ));
 
         boolean foundSegments = false;
@@ -99,7 +100,8 @@ public class MutationJournalTableTest extends CQLTester
             int writtenTo = r.getInt("written_to");
             int fsyncedTo = r.getInt("fsynced_to");
             r.getBool("needs_replay"); // Just verify it's accessible
-            String filePath = r.getString("file_path");
+            r.getString("file_path");  // Just verify it's accessible
+            r.getList("referring_sstables", String.class);// Just verify it's 
accessible
 
             assertThat(segmentId).isGreaterThan(0L);
 
diff --git 
a/test/unit/org/apache/cassandra/replication/MutationJournalTest.java 
b/test/unit/org/apache/cassandra/replication/MutationJournalTest.java
index 54f69d34f0..b9805b01df 100644
--- a/test/unit/org/apache/cassandra/replication/MutationJournalTest.java
+++ b/test/unit/org/apache/cassandra/replication/MutationJournalTest.java
@@ -22,15 +22,18 @@ import java.nio.ByteBuffer;
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.Supplier;
 
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
+import org.mockito.Mockito;
 
 import org.apache.cassandra.SchemaLoader;
 import org.apache.cassandra.db.Mutation;
 import org.apache.cassandra.db.RowUpdateBuilder;
 import org.apache.cassandra.db.marshal.UTF8Type;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
 import org.apache.cassandra.io.util.DataOutputBuffer;
 import org.apache.cassandra.io.util.File;
 import org.apache.cassandra.journal.Descriptor;
@@ -56,6 +59,7 @@ public class MutationJournalTest
     private static final String KEYSPACE = "mjtks";
     private static final String TABLE = "mjtt";
 
+    private static TestDurablyReconciledOffsetsSupplier 
durablyReconciledOffsetsSupplier;
     private static MutationJournal journal;
     private static File directory;
 
@@ -73,7 +77,9 @@ public class MutationJournalTest
         directory = new 
File(Files.createTempDirectory("mutation-journal-test-simple"));
         directory.deleteRecursiveOnExit();
 
-        journal = new MutationJournal(directory, TestParams.MUTATION_JOURNAL);
+        durablyReconciledOffsetsSupplier = new 
TestDurablyReconciledOffsetsSupplier();
+
+        journal = new MutationJournal(directory, TestParams.MUTATION_JOURNAL, 
durablyReconciledOffsetsSupplier);
         journal.startInternal();
     }
 
@@ -181,19 +187,25 @@ public class MutationJournalTest
     }
 
     @Test
-    public void testDropReconcileSegments()
+    public void testDropSegments()
     {
-        ShortMutationId id1 = id(100L, 0);
-        ShortMutationId id2 = id(100L, 1);
-        ShortMutationId id3 = id(200L, 2);
-        ShortMutationId id4 = id(200L, 3);
+        // Distinct logIds from the read tests (which use logId 100) so writes 
to the shared journal don't collide.
+        ShortMutationId id1 = id(1000L, 0);
+        ShortMutationId id2 = id(1000L, 1);
+        ShortMutationId id3 = id(2000L, 2);
+        ShortMutationId id4 = id(2000L, 3);
 
         Mutation mutation1 = mutation("key1", "ck1", "value1");
         Mutation mutation2 = mutation("key2", "ck2", "value2");
         Mutation mutation3 = mutation("key3", "ck3", "value3");
         Mutation mutation4 = mutation("key4", "ck4", "value4");
 
+        SegmentReferenceTracker refs = journal.segmentReferenceTracker();
+
+        int baseline = journal.countStaticSegmentsForTesting();
+
         // write two mutations to the first segment and flush it to make static
+        long firstSegment = journal.getCurrentPosition().segmentId;
         journal.write(id1, mutation1);
         journal.write(id2, mutation2);
         journal.closeCurrentSegmentForTestingIfNonEmpty();
@@ -203,51 +215,96 @@ public class MutationJournalTest
         journal.write(id4, mutation4);
         journal.closeCurrentSegmentForTestingIfNonEmpty();
 
+        // durably-reconciled offsets covering every mutation written above 
(logId 1000 -> {0,1}, 2000 -> {2,3})
+        Log2OffsetsMap.Mutable allReconciled = new Log2OffsetsMap.Mutable();
+        for (ShortMutationId id : List.of(id1, id2, id3, id4))
+            allReconciled.add(id);
+
         {
-            // call dropReconciledSegments() with a log2offsets map that 
covers both segments fully
-            // *BUT* with the segments still marked as needing replay nothing 
should be dropped
-            Log2OffsetsMap.Immutable.Builder builder = new 
Log2OffsetsMap.Immutable.Builder();
-            builder.add(id1);
-            builder.add(id2);
-            builder.add(id3);
-            builder.add(id4);
-            assertEquals(0, journal.dropReconciledSegments(builder.build()));
-            // confirm that no static segments have been dropped
-            assertEquals(2, journal.countStaticSegmentsForTesting());
+            // Both segments still need replay; even fully reconciled and 
unreferenced they must be retained.
+            
durablyReconciledOffsetsSupplier.setDurablyReconciledOffsetsSupplierForTesting(()
 -> allReconciled);
+            journal.runCompactionBlocking();
+            assertEquals(baseline + 2, 
journal.countStaticSegmentsForTesting());
         }
 
-        // mark both segments as not needing replay
+        // mark both segments as not needing replay (simulate their memtables 
having been flushed)
         journal.clearNeedsReplayForTesting();
 
         {
-            // call dropReconciledSegments() with a log2offsets map that 
doesn't cover any segments fully
-            Log2OffsetsMap.Immutable.Builder builder = new 
Log2OffsetsMap.Immutable.Builder();
-            builder.add(id1);
-            assertEquals(0, journal.dropReconciledSegments(builder.build()));
-            // confirm that no static segments got dropped
-            assertEquals(2, journal.countStaticSegmentsForTesting());
+            // Not reconciled -> retained even when !needsReplay and 
unreferenced (the witness gate).
+            
durablyReconciledOffsetsSupplier.setDurablyReconciledOffsetsSupplierForTesting(Log2OffsetsMap.Mutable::new);
+            journal.runCompactionBlocking();
+            assertEquals(baseline + 2, 
journal.countStaticSegmentsForTesting());
+        }
+
+        
durablyReconciledOffsetsSupplier.setDurablyReconciledOffsetsSupplierForTesting(()
 -> allReconciled);
+
+        {
+            // An unrepaired sstable referencing the first segment retains it; 
the second (unreferenced,
+            // reconciled, !needsReplay) is dropped.
+            SSTableReader referrer = Mockito.mock(SSTableReader.class);
+            refs.addReferenceForTesting(firstSegment, referrer);
+            journal.runCompactionBlocking();
+            assertEquals(baseline + 1, 
journal.countStaticSegmentsForTesting());
+
+            // Releasing the last reference allows the first segment to drop 
too.
+            refs.removeReferenceForTesting(firstSegment, referrer);
+            journal.runCompactionBlocking();
+            assertEquals(baseline, journal.countStaticSegmentsForTesting());
         }
+    }
+
+    @Test
+    public void testWitnessOnlyWritesDoNotPinNeedsReplay()
+    {
+        // Isolate a fresh segment holding only the witness-only writes below.
+        journal.closeCurrentSegmentForTestingIfNonEmpty();
+        long witnessSegment = journal.getCurrentPosition().segmentId;
+
+        // Witnessed-only writes (fullReplica=false): journaled but never 
applied to a memtable, so never marked
+        // dirty. A segment holding only such data has nothing to flush.
+        journal.write(id(300L, 0), mutation("wk1", "ck", "v1"), false);
+        journal.write(id(300L, 1), mutation("wk2", "ck", "v2"), false);
+        journal.closeCurrentSegmentForTestingIfNonEmpty();
+
+        assertTrue("witness-only segment should be eligible to clear 
needsReplay without a flush to prevent " +
+                   "the journal to grow unbounded on witness-only nodes",
+                   
journal.pendingCleanupForTesting().contains(witnessSegment));
+    }
+
+    @Test
+    public void testFullReplicaWritesPinNeedsReplayUntilFlushed()
+    {
+        // Isolate a fresh segment holding only the full-replica write below.
+        journal.closeCurrentSegmentForTestingIfNonEmpty();
+        long fullSegment = journal.getCurrentPosition().segmentId;
+
+        // Full-replica write marks the segment dirty; without a flush it 
still needs replay.
+        journal.write(id(400L, 0), mutation("fk1", "ck", "v1"), /* fullReplica 
= */ true);
+        journal.closeCurrentSegmentForTestingIfNonEmpty();
+
+        assertFalse("full-replica segment must not clear needsReplay before 
its memtable is flushed",
+                    journal.pendingCleanupForTesting().contains(fullSegment));
+    }
+
+    private static class TestDurablyReconciledOffsetsSupplier implements 
Supplier<Log2OffsetsMap<?>>
+    {
+        private Supplier<Log2OffsetsMap<?>> nonDefaultSupplier;
 
+        @Override
+        public Log2OffsetsMap<?> get()
         {
-            // call dropReconciledSegments() with a log2offsets map that 
covers only the first segment fully
-            Log2OffsetsMap.Immutable.Builder builder = new 
Log2OffsetsMap.Immutable.Builder();
-            builder.add(id1);
-            builder.add(id2);
-            assertEquals(1, journal.dropReconciledSegments(builder.build()));
-            // confirm that only one static segment got dropped
-            assertEquals(1, journal.countStaticSegmentsForTesting());
+            if (nonDefaultSupplier != null)
+                return nonDefaultSupplier.get();
+            Log2OffsetsMap.Mutable durablyReconciled = new 
Log2OffsetsMap.Mutable();
+            if (MutationTrackingService.isEnabled())
+                
MutationTrackingService.instance().collectDurablyReconciledOffsets(durablyReconciled);
+            return durablyReconciled;
         }
 
+        public void 
setDurablyReconciledOffsetsSupplierForTesting(Supplier<Log2OffsetsMap<?>> 
nonDefaultSupplier)
         {
-            // call dropReconciledSegments() with a log2offsets map that 
covers both segments fully
-            Log2OffsetsMap.Immutable.Builder builder = new 
Log2OffsetsMap.Immutable.Builder();
-            builder.add(id1);
-            builder.add(id2);
-            builder.add(id3);
-            builder.add(id4);
-            assertEquals(1, journal.dropReconciledSegments(builder.build()));
-            // confirm that all static segments have now been dropped
-            assertEquals(0, journal.countStaticSegmentsForTesting());
+            this.nonDefaultSupplier = nonDefaultSupplier;
         }
     }
 
diff --git 
a/test/unit/org/apache/cassandra/replication/SegmentReferenceTrackerTest.java 
b/test/unit/org/apache/cassandra/replication/SegmentReferenceTrackerTest.java
new file mode 100644
index 0000000000..c1504ae205
--- /dev/null
+++ 
b/test/unit/org/apache/cassandra/replication/SegmentReferenceTrackerTest.java
@@ -0,0 +1,517 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.cassandra.replication;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import org.apache.cassandra.db.Slice;
+import org.apache.cassandra.db.commitlog.CommitLogPosition;
+import org.apache.cassandra.db.commitlog.IntervalSet;
+import org.apache.cassandra.db.compaction.OperationType;
+import org.apache.cassandra.db.rows.Cell;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.sstable.metadata.StatsMetadata;
+import org.apache.cassandra.notifications.InitialSSTableAddedNotification;
+import org.apache.cassandra.notifications.SSTableAddedNotification;
+import org.apache.cassandra.notifications.SSTableListChangedNotification;
+import org.apache.cassandra.notifications.SSTableRepairStatusChanged;
+import org.apache.cassandra.schema.ReplicationType;
+import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.service.ActiveRepairService;
+import org.apache.cassandra.utils.ByteBufferUtil;
+import org.apache.cassandra.utils.EstimatedHistogram;
+import org.apache.cassandra.utils.streamhist.TombstoneHistogram;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class SegmentReferenceTrackerTest
+{
+    private static final UUID LOCAL_HOST = UUID.randomUUID();
+    private static final UUID REMOTE_HOST = UUID.randomUUID();
+
+    @Test
+    public void testInitialAddRefsEverySegmentInTheInterval()
+    {
+        int startSegment = 5;
+        int endSegment = 7;
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader sstable = unrepaired(intervals(startSegment, 0, 
endSegment, 100));
+
+        tracker.handleNotification(new 
InitialSSTableAddedNotification(List.of(sstable)), null);
+
+        // Coarse range covers segments 5..7 inclusive.
+        for (long segment = startSegment; segment <= endSegment; segment++)
+            assertEquals("segment " + segment, 1L, 
tracker.referenceCountForTesting(segment));
+        assertFalse(tracker.isReferenced(4));
+        assertFalse(tracker.isReferenced(8));
+        assertEquals(1, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testAddedRepairedSSTableHoldsNoRefs()
+    {
+        int startSegment = 5;
+        int endSegment = 7;
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader sstable = repaired(intervals(startSegment, 0, 
endSegment, 100));
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+
+        for (long segment = startSegment; segment <= endSegment; segment++)
+            assertFalse("segment " + segment, tracker.isReferenced(segment));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testUnrepairedSSTableWithoutCoordinatorLogOffsetsHoldsNoRefs()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        // Unrepaired, but carries no tracked mutations (empty 
coordinatorLogOffsets) -> not tracked. This is the
+        // untracked / pre-migration case: such an sstable's 
commitLogIntervals reference the commit log, not the
+        // mutation journal, so it must never hold a segment reference.
+        SSTableReader sstable = sstable(intervals(5, 0, 7, 100), () -> false, 
coordinatorLogOffsets(false));
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+
+        for (long segment = 5; segment <= 7; segment++)
+            assertFalse("segment " + segment, tracker.isReferenced(segment));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testStreamedSSTableHoldsNoRefs()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        // Unrepaired with tracked mutations, but streamed from another host: 
its commitLogIntervals reference that
+        // host's journal segments, not ours, so it must never hold a local 
segment reference (CASSANDRA-21406).
+        SSTableReader sstable = sstable(intervals(5, 0, 7, 100), () -> false, 
coordinatorLogOffsets(true), REMOTE_HOST);
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+
+        for (long segment = 5; segment <= 7; segment++)
+            assertFalse("segment " + segment, tracker.isReferenced(segment));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testUntrackedTableSSTableHoldsNoRefs()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        // Local + unrepaired + non-empty coordinatorLogOffsets, but its table 
has migrated away from tracked. Such
+        // an sstable (or a compaction output that inherits its offsets) is 
never promoted to repaired, so it must
+        // not be counted or its segments would be pinned forever 
(CASSANDRA-21406).
+        SSTableReader sstable = sstable(intervals(5, 0, 7, 100), () -> false, 
coordinatorLogOffsets(true),
+                                        LOCAL_HOST, ReplicationType.untracked);
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+
+        for (long segment = 5; segment <= 7; segment++)
+            assertFalse("segment " + segment, tracker.isReferenced(segment));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testEvictReleasesReferencesAndFiresCallback()
+    {
+        int[] calls = { 0 };
+        SegmentReferenceTracker tracker = newTracker(() -> calls[0]++);
+        SSTableReader sstable = unrepaired(intervals(5, 0, 7, 0));
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+        for (long segment = 5; segment <= 7; segment++)
+            assertEquals(1L, tracker.referenceCountForTesting(segment));
+
+        // Simulates a keyspace migrating away from tracked: evict its 
sstables' references directly.
+        tracker.evict(List.of(sstable));
+
+        for (long segment = 5; segment <= 7; segment++)
+            assertFalse("segment " + segment, tracker.isReferenced(segment));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+        assertEquals("evicting the last referrer fires the unreferenced 
callback", 1, calls[0]);
+    }
+
+    @Test
+    public void testEvictOnlyReleasesGivenSstables()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader a = unrepaired(intervals(5, 0, 5, 100));
+        SSTableReader b = unrepaired(intervals(5, 0, 5, 200));
+
+        tracker.handleNotification(new SSTableAddedNotification(List.of(a, b), 
null), null);
+        assertEquals(2L, tracker.referenceCountForTesting(5));
+
+        // Evicting only 'a' leaves segment 5 referenced by 'b'; evicting an 
untracked sstable is a no-op.
+        tracker.evict(List.of(a));
+        assertTrue(tracker.isReferenced(5));
+        assertEquals(1L, tracker.referenceCountForTesting(5));
+
+        tracker.evict(List.of(b));
+        assertFalse(tracker.isReferenced(5));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testAddIsIdempotent()
+    {
+        int startSegment = 5;
+        int endSegment = 7;
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader sstable = unrepaired(intervals(startSegment, 0, 
endSegment, 100));
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+
+        assertEquals(1L, tracker.referenceCountForTesting(5));
+        assertEquals(1, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testMultipleDisjointIntervalsRefEachContainedSegment()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        // Two disjoint intervals: [3:0..3:100] and [9:0..10:50].
+        IntervalSet.Builder<CommitLogPosition> builder = new 
IntervalSet.Builder<>();
+        builder.add(new CommitLogPosition(3, 0), new CommitLogPosition(3, 
100));
+        builder.add(new CommitLogPosition(9, 0), new CommitLogPosition(10, 
50));
+        SSTableReader sstable = unrepaired(builder.build());
+
+        tracker.handleNotification(new 
InitialSSTableAddedNotification(List.of(sstable)), null);
+
+        assertEquals(1L, tracker.referenceCountForTesting(3));
+        assertEquals(1L, tracker.referenceCountForTesting(9));
+        assertEquals(1L, tracker.referenceCountForTesting(10));
+        // Gap between disjoint intervals is not referenced.
+        for (long gap = 4; gap <= 8; gap++)
+            assertFalse("segment " + gap, tracker.isReferenced(gap));
+    }
+
+    @Test
+    public void testMultipleIntervalsInSameSegmentCountedOnce()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        // Two disjoint intervals within the SAME segment (different position 
ranges): forEachSegment emits
+        // segment 5 twice for this sstable, but the referrer set de-dupes so 
it is counted exactly once.
+        IntervalSet.Builder<CommitLogPosition> builder = new 
IntervalSet.Builder<>();
+        builder.add(new CommitLogPosition(5, 0), new CommitLogPosition(5, 
100));
+        builder.add(new CommitLogPosition(5, 200), new CommitLogPosition(5, 
300));
+        SSTableReader sstable = unrepaired(builder.build());
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+
+        assertEquals("a segment spanned by multiple intervals of one sstable 
is referenced once, not n times",
+                     1L, tracker.referenceCountForTesting(5));
+        assertEquals(1, tracker.trackedSstableCountForTesting());
+
+        // Releasing clears the single reference (no residual double-count).
+        tracker.handleNotification(
+        new SSTableListChangedNotification(List.of(), List.of(sstable), 
OperationType.COMPACTION),
+        null);
+        assertFalse(tracker.isReferenced(5));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testEmptyIntervalsHoldNoRefs()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader sstable = unrepaired(IntervalSet.empty());
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+
+        // Tracked sstable but no segments to ref.
+        assertEquals(1, tracker.trackedSstableCountForTesting());
+        assertFalse(tracker.isReferenced(0));
+    }
+
+    @Test
+    public void 
testCompactionPreservesRefsWhenInputAndOutputOverlapSameSegment()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader input = unrepaired(intervals(5, 0, 5, 100));
+        SSTableReader output = unrepaired(intervals(5, 0, 5, 200));
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(input), null), null);
+        assertEquals(1L, tracker.referenceCountForTesting(5));
+
+        // Compaction emits the SSTableListChangedNotification with added + 
removed atomically.
+        tracker.handleNotification(
+        new SSTableListChangedNotification(List.of(output),
+                                           List.of(input),
+                                           OperationType.COMPACTION),
+        null);
+
+        // Net: still one unrepaired sstable referencing segment 5.
+        assertEquals(1L, tracker.referenceCountForTesting(5));
+        assertEquals(1, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testCompactionToRepairedOutputReleasesRefs()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader input = unrepaired(intervals(5, 0, 5, 100));
+        SSTableReader output = repaired(intervals(5, 0, 5, 200));
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(input), null), null);
+        assertEquals(1L, tracker.referenceCountForTesting(5));
+
+        tracker.handleNotification(
+        new SSTableListChangedNotification(List.of(output),
+                                           List.of(input),
+                                           OperationType.COMPACTION),
+        null);
+
+        assertFalse(tracker.isReferenced(5));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testRepairPromotionReleasesRefs()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        AtomicReference<Boolean> repaired = new AtomicReference<>(false);
+        SSTableReader sstable = sstableWithRepairSupplier(intervals(5, 0, 7, 
0), repaired::get);
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+        for (long segment = 5; segment <= 7; segment++)
+            assertEquals(1L, tracker.referenceCountForTesting(segment));
+
+        // Promote to repaired; repair-status-changed delivers the transition.
+        repaired.set(true);
+        tracker.handleNotification(new 
SSTableRepairStatusChanged(List.of(sstable)), null);
+
+        for (long segment = 5; segment <= 7; segment++)
+            assertFalse("segment " + segment, tracker.isReferenced(segment));
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testRepairStatusFlippingBackToUnrepairedReAcquires()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        AtomicReference<Boolean> repaired = new AtomicReference<>(true);
+        SSTableReader sstable = sstableWithRepairSupplier(intervals(5, 0, 5, 
100), repaired::get);
+
+        // Starts repaired -> add is a no-op.
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+        assertEquals(0, tracker.trackedSstableCountForTesting());
+
+        // Flip back to unrepaired (e.g. failed repair session) and deliver 
repair-status-changed.
+        repaired.set(false);
+        tracker.handleNotification(new 
SSTableRepairStatusChanged(List.of(sstable)), null);
+
+        assertEquals(1L, tracker.referenceCountForTesting(5));
+        assertEquals(1, tracker.trackedSstableCountForTesting());
+    }
+
+    @Test
+    public void testMultipleSstablesAccumulateRefsOnSharedSegments()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader a = unrepaired(intervals(5, 0, 6, 0));
+        SSTableReader b = unrepaired(intervals(6, 0, 7, 0));
+
+        tracker.handleNotification(new SSTableAddedNotification(List.of(a, b), 
null), null);
+
+        assertEquals(1L, tracker.referenceCountForTesting(5));
+        assertEquals(2L, tracker.referenceCountForTesting(6));
+        assertEquals(1L, tracker.referenceCountForTesting(7));
+
+        tracker.handleNotification(
+        new SSTableListChangedNotification(List.of(),
+                                           List.of(a),
+                                           OperationType.COMPACTION),
+        null);
+
+        assertFalse(tracker.isReferenced(5));
+        assertEquals("there's still a reference from the shared segment from 
b", 1L, tracker.referenceCountForTesting(6));
+        assertEquals(1L, tracker.referenceCountForTesting(7));
+    }
+
+    @Test
+    public void testReleaseOfUntrackedSstableIsNoOp()
+    {
+        SegmentReferenceTracker tracker = newTracker();
+        SSTableReader sstable = unrepaired(intervals(5, 0, 5, 100));
+
+        assertFalse(tracker.isReferenced(5));
+
+        tracker.handleNotification(
+        new SSTableListChangedNotification(List.of(),
+                                           List.of(sstable),
+                                           OperationType.COMPACTION),
+        null);
+
+        assertFalse("ensure a never added sstable does not underflow during 
removal", tracker.isReferenced(5));
+    }
+
+    @Test
+    public void testCallbackFiredWhenLastReferenceReleased()
+    {
+        int[] calls = { 0 };
+        SegmentReferenceTracker tracker = newTracker(() -> calls[0]++);
+        SSTableReader sstable = unrepaired(intervals(5, 0, 7, 0));
+
+        tracker.handleNotification(new 
SSTableAddedNotification(List.of(sstable), null), null);
+        assertEquals("adding never fires the unreferenced callback", 0, 
calls[0]);
+
+        // Releasing the only referrer drives segments 5..7 to zero -> 
callback fires (once per notification).
+        tracker.handleNotification(
+        new SSTableListChangedNotification(List.of(), List.of(sstable), 
OperationType.COMPACTION),
+        null);
+
+        assertFalse(tracker.isReferenced(5));
+        assertEquals("releasing the last reference fires the unreferenced 
callback", 1, calls[0]);
+    }
+
+    @Test
+    public void testCallbackNotFiredWhileSegmentStillReferenced()
+    {
+        int[] calls = { 0 };
+        SegmentReferenceTracker tracker = newTracker(() -> calls[0]++);
+        SSTableReader a = unrepaired(intervals(5, 0, 5, 100));
+        SSTableReader b = unrepaired(intervals(5, 0, 5, 200));
+
+        tracker.handleNotification(new SSTableAddedNotification(List.of(a, b), 
null), null);
+        assertEquals(2L, tracker.referenceCountForTesting(5));
+
+        // Releasing one of two referrers leaves segment 5 still referenced -> 
no callback.
+        tracker.handleNotification(
+        new SSTableListChangedNotification(List.of(), List.of(a), 
OperationType.COMPACTION),
+        null);
+
+        assertTrue(tracker.isReferenced(5));
+        assertEquals("callback must not fire while the segment is still 
referenced", 0, calls[0]);
+    }
+
+    // -- helpers ---------------------------------------------------------
+
+    private static SegmentReferenceTracker newTracker()
+    {
+        return newTracker(() -> {});
+    }
+
+    private static SegmentReferenceTracker newTracker(Runnable 
onSegmentsUnreferenced)
+    {
+        // Fixed local host id so the mock sstables (originating from 
LOCAL_HOST) are treated as locally originated.
+        return new SegmentReferenceTracker(onSegmentsUnreferenced, () -> 
LOCAL_HOST);
+    }
+
+    private static IntervalSet<CommitLogPosition> intervals(long startSegment, 
int startPosition, long endSegment, int endPosition)
+    {
+        assertTrue("startSegment " + startSegment + " is less than or equal to 
endSegment " + endSegment, startSegment <= endSegment);
+        assertTrue("startPosition " + startPosition + " is less than or equal 
to endPosition " + endPosition, startPosition <= endPosition);
+        return new IntervalSet<>(new CommitLogPosition(startSegment, 
startPosition),
+                                 new CommitLogPosition(endSegment, 
endPosition));
+    }
+
+    private static SSTableReader unrepaired(IntervalSet<CommitLogPosition> 
intervals)
+    {
+        return stub(intervals, false);
+    }
+
+    private static SSTableReader repaired(IntervalSet<CommitLogPosition> 
intervals)
+    {
+        return stub(intervals, true);
+    }
+
+    private static SSTableReader stub(IntervalSet<CommitLogPosition> 
intervals, boolean isRepaired)
+    {
+        return sstableWithRepairSupplier(intervals, () -> isRepaired);
+    }
+
+    private static SSTableReader 
sstableWithRepairSupplier(IntervalSet<CommitLogPosition> intervals,
+                                                           BooleanSupplier 
isRepairedSupplier)
+    {
+        return sstable(intervals, isRepairedSupplier, 
coordinatorLogOffsets(true));
+    }
+
+    private static SSTableReader sstable(IntervalSet<CommitLogPosition> 
intervals,
+                                         BooleanSupplier isRepairedSupplier,
+                                         ImmutableCoordinatorLogOffsets 
coordinatorLogOffsets)
+    {
+        return sstable(intervals, isRepairedSupplier, coordinatorLogOffsets, 
LOCAL_HOST);
+    }
+
+    private static SSTableReader sstable(IntervalSet<CommitLogPosition> 
intervals,
+                                         BooleanSupplier isRepairedSupplier,
+                                         ImmutableCoordinatorLogOffsets 
coordinatorLogOffsets,
+                                         UUID originatingHostId)
+    {
+        return sstable(intervals, isRepairedSupplier, coordinatorLogOffsets, 
originatingHostId, ReplicationType.tracked);
+    }
+
+    private static SSTableReader sstable(IntervalSet<CommitLogPosition> 
intervals,
+                                         BooleanSupplier isRepairedSupplier,
+                                         ImmutableCoordinatorLogOffsets 
coordinatorLogOffsets,
+                                         UUID originatingHostId,
+                                         ReplicationType replicationType)
+    {
+        SSTableReader reader = Mockito.mock(SSTableReader.class);
+        Mockito.when(reader.isRepaired()).thenAnswer(ref -> 
isRepairedSupplier.getAsBoolean());
+        Mockito.when(reader.getSSTableMetadata()).thenReturn(stats(intervals, 
originatingHostId));
+        
Mockito.when(reader.getCoordinatorLogOffsets()).thenReturn(coordinatorLogOffsets);
+        TableMetadata tableMetadata = Mockito.mock(TableMetadata.class);
+        
Mockito.when(tableMetadata.replicationType()).thenReturn(replicationType);
+        Mockito.when(reader.metadata()).thenReturn(tableMetadata);
+        return reader;
+    }
+
+    private static ImmutableCoordinatorLogOffsets 
coordinatorLogOffsets(boolean nonEmpty)
+    {
+        // A bare mock reports isEmpty()==false (Mockito's default boolean), 
which is all the tracker inspects.
+        return nonEmpty ? Mockito.mock(ImmutableCoordinatorLogOffsets.class) : 
ImmutableCoordinatorLogOffsets.NONE;
+    }
+
+    private static StatsMetadata stats(IntervalSet<CommitLogPosition> 
intervals, UUID originatingHostId)
+    {
+        return new StatsMetadata(new EstimatedHistogram(155),                  
   // estimatedPartitionSize
+                                 new EstimatedHistogram(118),                  
   // estimatedCellPerPartitionCount
+                                 intervals,                                    
   // commitLogIntervals
+                                 0L,                                           
   // minTimestamp
+                                 0L,                                           
   // maxTimestamp
+                                 Cell.NO_DELETION_TIME,                        
   // minLocalDeletionTime
+                                 Cell.NO_DELETION_TIME,                        
   // maxLocalDeletionTime
+                                 Cell.NO_TTL,                                  
   // minTTL
+                                 Cell.NO_TTL,                                  
   // maxTTL
+                                 -1.0,                                         
   // compressionRatio
+                                 TombstoneHistogram.createDefault(),
+                                 0,                                            
   // sstableLevel
+                                 List.of(),                                    
   // clusteringTypes
+                                 Slice.ALL,                                    
   // coveredClustering
+                                 false,                                        
   // hasLegacyCounterShards
+                                 ActiveRepairService.UNREPAIRED_SSTABLE,
+                                 0L,                                           
   // totalColumnsSet
+                                 0L,                                           
   // totalRows
+                                 Double.NaN,                                   
   // tokenSpaceCoverage
+                                 originatingHostId,
+                                 ActiveRepairService.NO_PENDING_REPAIR,
+                                 false,                                        
   // hasPartitionLevelDeletions
+                                 ImmutableCoordinatorLogOffsets.NONE,
+                                 ByteBufferUtil.EMPTY_BYTE_BUFFER,
+                                 ByteBufferUtil.EMPTY_BYTE_BUFFER);
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to