Skip to content

Adding Mutual exclusion logic for SAI index rebuilding and ZCS strea… - #5071

Open
pranavshenoy wants to merge 1 commit into
apache:trunkfrom
pranavshenoy:zcs_sstable_rebuild_bug
Open

Adding Mutual exclusion logic for SAI index rebuilding and ZCS strea…#5071
pranavshenoy wants to merge 1 commit into
apache:trunkfrom
pranavshenoy:zcs_sstable_rebuild_bug

Conversation

@pranavshenoy

Copy link
Copy Markdown
Contributor

…ming

Thanks for sending a pull request! Here are some tips if you're new here:

  • Ensure you have added or run the appropriate tests for your PR.
  • Be sure to keep the PR description updated to reflect all changes.
  • Write your PR title to summarize what this PR proposes.
  • If possible, provide a concise example to reproduce the issue for a faster review.
  • Read our contributor guidelines
  • If you're making a documentation change, see our guide to documentation contribution

Commit messages should follow the following format:

<One sentence description, usually Jira title or CHANGES.txt summary>

<Optional lengthier description (context on patch)>

patch by <Authors>; reviewed by <Reviewers> for CASSANDRA-#####

Co-authored-by: Name1 <email1>
Co-authored-by: Name2 <email2>

The Cassandra Jira

reserved.forEach(s -> s.streamRebuildState().endRebuild());
throw new RuntimeException(String.format(
"Cannot build SAI index on %s while entire-sstable (zero-copy) streaming is in progress.",
sstable.descriptor));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: If we move forward with this, it might be nice to have a full listing of the SSTables that cannot be locked for rebuild. More of an operator concern than a correctness problem. It might also be good to cap it at a certain number, like 16 or 32 so we don't have a huge error message.

{
if (ownsStreamRebuildStatus)
sstables.keySet().forEach(sstable -> sstable.streamRebuildState().endRebuild());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to just release the SSTables one by one as they complete? That would narrow the window where ZCS wouldn't be possible...

// components. An entire-sstable (zero-copy) stream reserves the same status when its outgoing file is
// constructed, so this ensures a rebuild cannot delete/rewrite SAI components underneath an in-flight
// stream (and vice versa the stream degrades to legacy). See CASSANDRA-21520. The returned builder owns
// these reservations and releases them when it finishes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The big design question for this patch is whether or not we have to do this reservation for the entire set of SSTables. I think we do, and it's because there is no partial full index rebuild. If we have 100 SSTables, and 2 of them are being ZCS streamed, we can't just build 98 and wait until those are done with streaming, delaying the rebuild for an arbitrary window. Similarly, we can't lazily check SSTables as we delete and rebuild them, because ZCS streaming on a particular SSTable might start immediately before we attempt to rebuild it.

More tactically, we have group.dropIndexSSTables() below, which will actually delete column indexes on disk if there aren't queries in flight referencing them. Any attempt to change the design to per-SSTable locking would have to account for that.

@maedhroz

maedhroz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Possible testing gaps worth looking at:

  • No test for the onNotExecuted() / executor-rejection path (the shutdown-time cleanup added to CompactionManager).

  • No test for CassandraStreamManager's new catch-block that releases already-reserved streams when planning fails partway through a multi-sstable batch.

  • No test for partial reservation rollback in StorageAttachedIndexBuildingSupport — reserve sstable A and B, fail on C, assert A and B get released.

  • No concurrency/stress test hammering tryBeginRebuild/tryBeginStreaming from multiple threads — the existing unit test likely checks state transitions sequentially, not under contention.

  • No test combining this with a second concurrent rebuild attempt on the same sstable (verifying the second tryBeginRebuild correctly fails while one is in flight).

  • The ComponentContext assert→IOException change looks like a separate hardening fix riding along in this commit. No test exercises the file-size-mismatch path to confirm it now fails cleanly instead of via AssertionError.

// size no longer matches the size advertised in the manifest, and the production size check in
// ComponentContext.channel() must fail the stream rather than ship corrupt bytes. This check must be
// effective even with assertions disabled (it is a real exception, not an assert). CASSANDRA-21520.
node1.runOnInstance(IndexDeleteDuringEntireSSTableStreamingTest::truncateStreamedSaiComponent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is basically codifying the current behavior for us calling DROP INDEX? If we want to try to make that safe, we could try something like:

Index: src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java b/src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java
--- a/src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java	(revision 5e7b788fa0098cc7fee821f4da8e4693d28b0907)
+++ b/src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java	(date 1788392191269)
@@ -18,6 +18,9 @@
 
 package org.apache.cassandra.io.sstable.format;
 
+import java.util.ArrayList;
+import java.util.List;
+
 import com.google.common.annotations.VisibleForTesting;
 
 /**
@@ -41,6 +44,11 @@
 
     private State state = State.NORMAL;
     private int zcsStreamCount = 0;
+    // One-shot cleanup tasks (e.g. deleting a dropped/corrupt column index's components) that lost the race
+    // against an in-progress stream and are waiting for it to end. Drained back through
+    // deleteOrDeferUntilStreamingEnds() once the last stream finishes, so a task that loses the race again
+    // (e.g. to a rebuild that started in the meantime) simply re-queues instead of being lost.
+    private List<Runnable> pendingCleanup;
 
     /**
      * Attempt to begin an entire-sstable stream. Fails only if a rebuild is in progress.
@@ -60,11 +68,28 @@
     /**
      * Release one entire-sstable stream. Returns to {@code NORMAL} when the last stream ends. Defensive against
      * over-release so cleanup paths cannot corrupt the state.
+     * <p>
+     * When the last stream ends, any cleanup deferred by {@link #deleteOrDeferUntilStreamingEnds} is retried.
      */
-    public synchronized void endStreaming()
+    public void endStreaming()
     {
-        if (zcsStreamCount > 0 && --zcsStreamCount == 0)
-            state = State.NORMAL;
+        List<Runnable> toRetry = null;
+        synchronized (this)
+        {
+            if (zcsStreamCount > 0 && --zcsStreamCount == 0)
+            {
+                state = State.NORMAL;
+                if (pendingCleanup != null)
+                {
+                    toRetry = pendingCleanup;
+                    pendingCleanup = null;
+                }
+            }
+        }
+        // Re-submit rather than run directly: something else (a new stream, a new rebuild) may have claimed
+        // NORMAL before we get here, in which case this simply re-queues instead of racing it.
+        if (toRetry != null)
+            toRetry.forEach(this::deleteOrDeferUntilStreamingEnds);
     }
 
     /**
@@ -89,6 +114,39 @@
             state = State.NORMAL;
     }
 
+    /**
+     * Runs a one-shot component cleanup (e.g. deleting a dropped or corrupt column index's files) with the same
+     * exclusivity a rebuild would use, so it cannot race a concurrent entire-sstable stream. There is no caller
+     * here to report failure to (this is invoked from arbitrary release() completions, e.g. a query finishing),
+     * so losing the race is not an error: {@code cleanup} is queued and retried once the active stream(s) end.
+     */
+    public void deleteOrDeferUntilStreamingEnds(Runnable cleanup)
+    {
+        boolean runNow;
+        synchronized (this)
+        {
+            runNow = tryBeginRebuild();
+            if (!runNow)
+            {
+                if (pendingCleanup == null)
+                    pendingCleanup = new ArrayList<>();
+                pendingCleanup.add(cleanup);
+            }
+        }
+
+        if (runNow)
+        {
+            try
+            {
+                cleanup.run();
+            }
+            finally
+            {
+                endRebuild();
+            }
+        }
+    }
+
     @VisibleForTesting
     public synchronized State state()
     {
Index: src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java
--- a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java	(revision 5e7b788fa0098cc7fee821f4da8e4693d28b0907)
+++ b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexGroup.java	(date 1788392260481)
@@ -323,14 +323,16 @@
         if (!results.right.isEmpty())
         {
             results.right.forEach(sstable -> {
-                IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
-                indexDescriptor.deletePerSSTableIndexComponents();
-                // Column indexes are invalid if their SSTable-level components are corrupted so delete
-                // their associated index files and mark them non-queryable.
-                indexes.forEach(index -> {
-                    indexDescriptor.deleteColumnIndex(index.termType(), index.identifier());
-                    index.makeIndexNonQueryable();
+                // Deleting these files must not race a concurrent entire-sstable stream of the same sstable
+                // (CASSANDRA-21520).
+                sstable.streamRebuildState().deleteOrDeferUntilStreamingEnds(() -> {
+                    IndexDescriptor indexDescriptor = IndexDescriptor.create(sstable);
+                    indexDescriptor.deletePerSSTableIndexComponents();
+                    // Column indexes are invalid if their SSTable-level components are corrupted so delete
+                    // their associated index files and mark them non-queryable.
+                    indexes.forEach(index -> indexDescriptor.deleteColumnIndex(index.termType(), index.identifier()));
                 });
+                indexes.forEach(StorageAttachedIndex::makeIndexNonQueryable);
             });
             return indexes;
         }
@@ -344,8 +346,10 @@
             if (!invalid.isEmpty())
             {
                 // Delete the index files and mark the index non-queryable, as its view may be compromised,
-                // and incomplete, for our callers:
-                invalid.forEach(context -> context.indexDescriptor.deleteColumnIndex(index.termType(), index.identifier()));
+                // and incomplete, for our callers. Deletion must not race a concurrent entire-sstable stream
+                // of the same sstable (CASSANDRA-21520).
+                invalid.forEach(context -> context.sstable.streamRebuildState()
+                                                   .deleteOrDeferUntilStreamingEnds(() -> context.indexDescriptor.deleteColumnIndex(index.termType(), index.identifier())));
                 index.makeIndexNonQueryable();
                 incomplete.add(index);
             }
Index: src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java b/src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java
--- a/src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java	(revision 5e7b788fa0098cc7fee821f4da8e4693d28b0907)
+++ b/src/java/org/apache/cassandra/index/sai/disk/SSTableIndex.java	(date 1788392207795)
@@ -232,10 +232,15 @@
             /*
              * When SSTable is removed, storage-attached index components will be automatically removed by LogTransaction.
              * We only remove index components explicitly in case of index corruption or index rebuild.
+             *
+             * Deleting these files must not race a concurrent entire-sstable (zero-copy) stream of the same
+             * sstable, which reads them directly off disk (CASSANDRA-21520). getSSTable() is still valid here:
+             * sstableContext.close() above releases its own closeables, not the underlying SSTableReader.
              */
             if (obsolete.get())
             {
-                sstableContext.indexDescriptor.deleteColumnIndex(indexTermType, indexIdentifier);
+                getSSTable().streamRebuildState()
+                            .deleteOrDeferUntilStreamingEnds(() -> sstableContext.indexDescriptor.deleteColumnIndex(indexTermType, indexIdentifier));
             }
         }
     }

@pranavshenoy
pranavshenoy marked this pull request as ready for review September 4, 2026 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants