This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch cassandra-5.0
in repository https://gitbox.apache.org/repos/asf/cassandra.git
The following commit(s) were added to refs/heads/cassandra-5.0 by this push:
new 93b5aec70d Isolate SAI index rebuilds from ZCS
93b5aec70d is described below
commit 93b5aec70d161b97069bf396f6a1c470ce09fa68
Author: Pranav Shenoy <[email protected]>
AuthorDate: Wed Aug 26 21:13:20 2026 -0700
Isolate SAI index rebuilds from ZCS
patch by Pranav Shenoy; reviewed by Caleb Rackliffe and David Capwell for
CASSANDRA-21520
---
CHANGES.txt | 1 +
.../cassandra/db/compaction/CompactionManager.java | 8 +-
.../db/streaming/CassandraOutgoingFile.java | 41 ++-
.../db/streaming/CassandraStreamManager.java | 13 +-
.../cassandra/db/streaming/ComponentContext.java | 9 +-
.../cassandra/index/SecondaryIndexBuilder.java | 8 +
.../index/sai/StorageAttachedIndexBuilder.java | 70 ++++-
.../sai/StorageAttachedIndexBuildingSupport.java | 66 ++++-
.../cassandra/io/sstable/format/SSTableReader.java | 10 +
.../sstable/format/SSTableStreamRebuildState.java | 96 +++++++
.../apache/cassandra/streaming/OutgoingStream.java | 7 +
...ndexDeleteDuringEntireSSTableStreamingTest.java | 234 +++++++++++++++++
...dexRebuildDuringEntireSSTableStreamingTest.java | 243 ++++++++++++++++++
.../test/sai/StreamingDuringIndexRebuildTest.java | 155 +++++++++++
.../CassandraStreamManagerDoubleReleaseTest.java | 271 ++++++++++++++++++++
.../db/streaming/ComponentContextTest.java | 121 +++++++++
.../db/streaming/TableStreamManagerTest.java | 258 +++++++++++++++++++
.../index/sai/StreamRebuildCoordinationTest.java | 190 ++++++++++++++
.../format/SSTableStreamRebuildStateTest.java | 285 +++++++++++++++++++++
19 files changed, 2061 insertions(+), 25 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 1cdbf799c1..5ce20dd5f0 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
5.0.10
+ * Isolate SAI index rebuilds from ZCS (CASSANDRA-21520)
* Bound and chunk the BTI partition index preload (CASSANDRA-21632)
* Restore volatile on CommitLogSegment.cdcState dropped in merge
(CASSANDRA-21665)
* Fix compressed scan read-ahead buffer Block leak and chunk-cache pollution
by scans (CASSANDRA-21671)
diff --git a/src/java/org/apache/cassandra/db/compaction/CompactionManager.java
b/src/java/org/apache/cassandra/db/compaction/CompactionManager.java
index a20dc32a6e..830c483ecf 100644
--- a/src/java/org/apache/cassandra/db/compaction/CompactionManager.java
+++ b/src/java/org/apache/cassandra/db/compaction/CompactionManager.java
@@ -1917,7 +1917,13 @@ public class CompactionManager implements
CompactionManagerMBean, ICompactionMan
}
};
- return secondaryIndexExecutor.submitIfRunning(runnable, "index build");
+ Future<?> future = secondaryIndexExecutor.submitIfRunning(runnable,
"index build");
+ if (future.isCancelled())
+ {
+ // Let the builder release any resources it reserved during
construction.
+ builder.onNotExecuted();
+ }
+ return future;
}
/**
diff --git
a/src/java/org/apache/cassandra/db/streaming/CassandraOutgoingFile.java
b/src/java/org/apache/cassandra/db/streaming/CassandraOutgoingFile.java
index 9fc04e7e0d..8950eddf1a 100644
--- a/src/java/org/apache/cassandra/db/streaming/CassandraOutgoingFile.java
+++ b/src/java/org/apache/cassandra/db/streaming/CassandraOutgoingFile.java
@@ -22,6 +22,8 @@ import java.io.IOException;
import java.util.List;
import java.util.Objects;
+import javax.annotation.concurrent.NotThreadSafe;
+
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
@@ -40,6 +42,7 @@ import org.apache.cassandra.utils.concurrent.Ref;
/**
* used to transfer the part(or whole) of a SSTable data file
*/
+@NotThreadSafe
public class CassandraOutgoingFile implements OutgoingStream
{
private final Ref<SSTableReader> ref;
@@ -50,6 +53,9 @@ public class CassandraOutgoingFile implements OutgoingStream
private final StreamOperation operation;
private final CassandraStreamHeader header;
+ // guards against releasing the SSTable streaming status more than once
+ private boolean streamRebuildStatusReleased = false;
+
public CassandraOutgoingFile(StreamOperation operation, Ref<SSTableReader>
ref,
List<SSTableReader.PartitionPositionBounds>
sections, List<Range<Token>> normalizedRanges,
long estimatedKeys)
@@ -64,9 +70,22 @@ public class CassandraOutgoingFile implements OutgoingStream
SSTableReader sstable = ref.get();
this.filename = sstable.getFilename();
- this.shouldStreamEntireSSTable = computeShouldStreamEntireSSTables();
- ComponentManifest manifest = ComponentManifest.create(sstable);
- this.header = makeHeader(sstable, operation, sections, estimatedKeys,
shouldStreamEntireSSTable, manifest);
+
+ // If the SSTable is currently being rebuilt, fall back to legacy
streaming.
+ boolean entire = computeShouldStreamEntireSSTables() &&
sstable.streamRebuildState().tryBeginStreaming();
+ this.shouldStreamEntireSSTable = entire;
+ try
+ {
+ ComponentManifest manifest = ComponentManifest.create(sstable);
+ this.header = makeHeader(sstable, operation, sections,
estimatedKeys, entire, manifest);
+ }
+ catch (RuntimeException | Error e)
+ {
+ // Release the status we just acquired if construction fails.
+ if (entire)
+ sstable.streamRebuildState().endStreaming();
+ throw e;
+ }
}
private static CassandraStreamHeader makeHeader(SSTableReader sstable,
@@ -204,9 +223,25 @@ public class CassandraOutgoingFile implements
OutgoingStream
@Override
public void finish()
{
+ releaseStreamRebuildStatus();
ref.release();
}
+ /**
+ * Releases the entire-sstable streaming status this stream reserved at
construction, exactly once, without
+ * releasing the SSTable reference. Used both by {@link #finish()} and by
callers that must clean up a
+ * constructed-but-never-transferred stream.
+ */
+ @Override
+ public void releaseStreamRebuildStatus()
+ {
+ if (shouldStreamEntireSSTable && !streamRebuildStatusReleased)
+ {
+ streamRebuildStatusReleased = true;
+ ref.get().streamRebuildState().endStreaming();
+ }
+ }
+
public boolean equals(Object o)
{
if (this == o) return true;
diff --git
a/src/java/org/apache/cassandra/db/streaming/CassandraStreamManager.java
b/src/java/org/apache/cassandra/db/streaming/CassandraStreamManager.java
index 6940f11b57..dcee46b7e4 100644
--- a/src/java/org/apache/cassandra/db/streaming/CassandraStreamManager.java
+++ b/src/java/org/apache/cassandra/db/streaming/CassandraStreamManager.java
@@ -90,6 +90,9 @@ public class CassandraStreamManager implements
TableStreamManager
public Collection<OutgoingStream> createOutgoingStreams(StreamSession
session, RangesAtEndpoint replicas, TimeUUID pendingRepair, PreviewKind
previewKind)
{
Refs<SSTableReader> refs = new Refs<>();
+ // Declared outside the try so the catch can release the SSTable
streaming status reserved by any
+ // stream already constructed before a failure.
+ List<OutgoingStream> streams = new ArrayList<>();
try
{
final List<Range<PartitionPosition>> keyRanges = new
ArrayList<>(replicas.size());
@@ -141,18 +144,17 @@ public class CassandraStreamManager implements
TableStreamManager
List<Range<Token>> normalizedFullRanges =
Range.normalize(replicas.onlyFull().ranges());
List<Range<Token>> normalizedAllRanges =
Range.normalize(replicas.ranges());
//Create outgoing file streams for ranges possibly skipping
repaired ranges in sstables
- List<OutgoingStream> streams = new ArrayList<>(refs.size());
- for (SSTableReader sstable : refs)
+ for (SSTableReader sstable : new ArrayList<>(refs))
{
List<Range<Token>> ranges = sstable.isRepaired() ?
normalizedFullRanges : normalizedAllRanges;
List<SSTableReader.PartitionPositionBounds> sections =
sstable.getPositionsForRanges(ranges);
- Ref<SSTableReader> ref = refs.get(sstable);
if (sections.isEmpty())
{
- ref.release();
+ refs.release(sstable);
continue;
}
+ Ref<SSTableReader> ref = refs.get(sstable);
streams.add(new
CassandraOutgoingFile(session.getStreamOperation(), ref, sections, ranges,
sstable.estimatedKeysForRanges(ranges)));
}
@@ -161,6 +163,9 @@ public class CassandraStreamManager implements
TableStreamManager
}
catch (Throwable t)
{
+ // Release the SSTable streaming status held by any
already-constructed stream, so a planning failure cannot leak it.
+ for (OutgoingStream stream : streams)
+ stream.releaseStreamRebuildStatus();
refs.release();
throw t;
}
diff --git a/src/java/org/apache/cassandra/db/streaming/ComponentContext.java
b/src/java/org/apache/cassandra/db/streaming/ComponentContext.java
index c03e7b4c34..2743743b23 100644
--- a/src/java/org/apache/cassandra/db/streaming/ComponentContext.java
+++ b/src/java/org/apache/cassandra/db/streaming/ComponentContext.java
@@ -78,8 +78,13 @@ public class ComponentContext implements AutoCloseable
@SuppressWarnings("resource") // file channel will be closed by Caller
FileChannel channel = toTransfer.newReadChannel();
- assert size == channel.size() : String.format("Entire sstable
streaming expects %s file size to be %s but got %s.",
- component, size,
channel.size());
+ if (size != channel.size())
+ {
+ long actual = channel.size();
+ FileUtils.closeQuietly(channel);
+ throw new IllegalStateException(String.format("Entire SSTable
streaming expects %s file size to be %s but got %s.",
+ component, size,
actual));
+ }
return channel;
}
diff --git a/src/java/org/apache/cassandra/index/SecondaryIndexBuilder.java
b/src/java/org/apache/cassandra/index/SecondaryIndexBuilder.java
index 73dc3345a2..e963648e82 100644
--- a/src/java/org/apache/cassandra/index/SecondaryIndexBuilder.java
+++ b/src/java/org/apache/cassandra/index/SecondaryIndexBuilder.java
@@ -30,4 +30,12 @@ public abstract class SecondaryIndexBuilder extends
CompactionInfo.Holder
{
return false;
}
+
+ /**
+ * Invoked when this builder was created but its {@link #build()} will
never run (e.g. because an executor
+ * rejects the submission during shutdown). Implementations must release
any resources they reserved during construction.
+ */
+ public void onNotExecuted()
+ {
+ }
}
diff --git
a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuilder.java
b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuilder.java
index c7ee124063..03a1d40118 100644
--- a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuilder.java
+++ b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuilder.java
@@ -77,6 +77,9 @@ public class StorageAttachedIndexBuilder extends
SecondaryIndexBuilder
private final boolean isFullRebuild;
private final boolean isInitialBuild;
+ // The reserved SSTables whose rebuild status this builder still owns and
must release.
+ private final Set<SSTableReader> outstandingReservations;
+
private final SortedMap<SSTableReader, Set<StorageAttachedIndex>> sstables;
private long bytesProcessed = 0;
@@ -86,6 +89,15 @@ public class StorageAttachedIndexBuilder extends
SecondaryIndexBuilder
SortedMap<SSTableReader,
Set<StorageAttachedIndex>> sstables,
boolean isFullRebuild,
boolean isInitialBuild)
+ {
+ this(group, sstables, isFullRebuild, isInitialBuild, false);
+ }
+
+ StorageAttachedIndexBuilder(StorageAttachedIndexGroup group,
+ SortedMap<SSTableReader,
Set<StorageAttachedIndex>> sstables,
+ boolean isFullRebuild,
+ boolean isInitialBuild,
+ boolean ownsStreamRebuildStatus)
{
this.group = group;
this.metadata = group.metadata();
@@ -93,6 +105,7 @@ public class StorageAttachedIndexBuilder extends
SecondaryIndexBuilder
this.tracker = group.table().getTracker();
this.isFullRebuild = isFullRebuild;
this.isInitialBuild = isInitialBuild;
+ this.outstandingReservations = ownsStreamRebuildStatus ? new
HashSet<>(sstables.keySet()) : Collections.emptySet();
this.totalSizeInBytes =
sstables.keySet().stream().mapToLong(SSTableReader::uncompressedLength).sum();
}
@@ -103,21 +116,56 @@ public class StorageAttachedIndexBuilder extends
SecondaryIndexBuilder
isInitialBuild ? "initial" :
"non-initial",
isFullRebuild ? "full" :
"partial")));
- for (Map.Entry<SSTableReader, Set<StorageAttachedIndex>> e :
sstables.entrySet())
+ try
{
- SSTableReader sstable = e.getKey();
- Set<StorageAttachedIndex> indexes = e.getValue();
-
- Set<StorageAttachedIndex> existing = validateIndexes(indexes,
sstable.descriptor);
- if (existing.isEmpty())
+ for (Map.Entry<SSTableReader, Set<StorageAttachedIndex>> e :
sstables.entrySet())
{
- logger.debug(logMessage("{} dropped during index build"),
indexes);
- continue;
- }
+ SSTableReader sstable = e.getKey();
+ Set<StorageAttachedIndex> indexes = e.getValue();
- if (indexSSTable(sstable, existing))
- return;
+ Set<StorageAttachedIndex> existing = validateIndexes(indexes,
sstable.descriptor);
+ if (existing.isEmpty())
+ {
+ logger.debug(logMessage("{} dropped during index build"),
indexes);
+ // Nothing to build for this sstable, so release its
reserved status right away.
+ releaseStreamRebuildStatus(sstable);
+ continue;
+ }
+
+ if (indexSSTable(sstable, existing))
+ return;
+
+ // This SSTable's index build completed, so release its
streaming status immediately, which allows
+ // other streaming operations to operate on it.
+ releaseStreamRebuildStatus(sstable);
+ }
}
+ finally
+ {
+ // Release the rebuild status for any sstables not already
released above.
+ releaseStreamRebuildStatus();
+ }
+ }
+
+ @Override
+ public void onNotExecuted()
+ {
+ // If build() will never run, release here to avoid leaving the
reserved sstables stuck in the REBUILDING state.
+ releaseStreamRebuildStatus();
+ }
+
+ private void releaseStreamRebuildStatus()
+ {
+ outstandingReservations.forEach(sstable ->
sstable.streamRebuildState().endRebuild());
+ outstandingReservations.clear();
+ }
+
+ private void releaseStreamRebuildStatus(SSTableReader sstable)
+ {
+ // Only release a status this builder actually still owns, so we never
clear a reservation that was
+ // released earlier and possibly re-taken by another rebuild or stream.
+ if (outstandingReservations.remove(sstable))
+ sstable.streamRebuildState().endRebuild();
}
private String logMessage(String message)
diff --git
a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuildingSupport.java
b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuildingSupport.java
index 7a13b4b186..0a816c9c71 100644
---
a/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuildingSupport.java
+++
b/src/java/org/apache/cassandra/index/sai/StorageAttachedIndexBuildingSupport.java
@@ -21,6 +21,9 @@ package org.apache.cassandra.index.sai;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.TreeMap;
@@ -32,9 +35,13 @@ import org.apache.cassandra.index.SecondaryIndexBuilder;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.io.sstable.SSTableIdFactory;
import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.sstable.format.SSTableStreamRebuildState;
class StorageAttachedIndexBuildingSupport implements Index.IndexBuildingSupport
{
+ /** Maximum number of blocked sstable names to log when a rebuild is
rejected, to keep the message bounded. */
+ private static final int MAX_LOGGED_SSTABLES = 16;
+
@Override
public SecondaryIndexBuilder getIndexBuildTask(ColumnFamilyStore cfs,
Set<Index> indexes,
@@ -46,6 +53,8 @@ class StorageAttachedIndexBuildingSupport implements
Index.IndexBuildingSupport
assert group != null : "Index group does not exist for table " +
cfs.keyspace + '.' + cfs.name;
+ // First resolve, without mutating anything, which SSTables each index
will rebuild.
+ Map<StorageAttachedIndex, Collection<SSTableReader>> targets = new
LinkedHashMap<>();
indexes.stream()
.filter((i) -> i instanceof StorageAttachedIndex)
.forEach((i) ->
@@ -62,11 +71,60 @@ class StorageAttachedIndexBuildingSupport implements
Index.IndexBuildingSupport
.collect(Collectors.toList());
}
- group.dropIndexSSTables(ss, sai);
-
- ss.forEach(sstable ->
sstables.computeIfAbsent(sstable, ignore -> new HashSet<>()).add(sai));
+ targets.put(sai, ss);
});
- return new StorageAttachedIndexBuilder(group, sstables, isFullRebuild,
false);
+ // Reserve the per-sstable rebuild status for every unique target
sstable BEFORE deleting any index
+ // components. A 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. The returned builder owns these reservations and releases
them when it finishes.
+ Set<SSTableReader> reserved = new LinkedHashSet<>();
+ for (Collection<SSTableReader> ss : targets.values())
+ {
+ for (SSTableReader sstable : ss)
+ {
+ if (reserved.contains(sstable))
+ continue;
+ if (sstable.streamRebuildState().tryBeginRebuild())
+ {
+ reserved.add(sstable);
+ }
+ else
+ {
+ reserved.forEach(s -> s.streamRebuildState().endRebuild());
+
+ // For debugging, list the SSTable that are blocking this
rebuild because they are not in a NORMAL
+ // state (a zero-copy stream is in flight). Cap the list
so the message stays bounded even when a
+ // large number of sstables are affected.
+ String blockedSSTables = targets.values().stream()
+
.flatMap(Collection::stream)
+ .distinct()
+ .filter(s ->
s.streamRebuildState().state() != SSTableStreamRebuildState.State.NORMAL)
+ .map(s ->
s.descriptor.toString())
+
.limit(MAX_LOGGED_SSTABLES)
+
.collect(Collectors.joining(", "));
+ throw new RuntimeException(String.format(
+ "Cannot build SAI index while entire SSTable streaming
is in progress. " +
+ "Blocked sstable(s) (up to %d shown): %s",
MAX_LOGGED_SSTABLES, blockedSSTables));
+ }
+ }
+ }
+
+ // Now it is safe to drop existing components and assemble the build
map.
+ try
+ {
+ targets.forEach((sai, ss) ->
+ {
+ group.dropIndexSSTables(ss, sai);
+ ss.forEach(sstable ->
sstables.computeIfAbsent(sstable, ignore -> new HashSet<>()).add(sai));
+ });
+ }
+ catch (RuntimeException | Error e)
+ {
+ reserved.forEach(s -> s.streamRebuildState().endRebuild());
+ throw e;
+ }
+
+ return new StorageAttachedIndexBuilder(group, sstables, isFullRebuild,
false, true);
}
}
diff --git a/src/java/org/apache/cassandra/io/sstable/format/SSTableReader.java
b/src/java/org/apache/cassandra/io/sstable/format/SSTableReader.java
index 854a1b486e..9561e9b2db 100644
--- a/src/java/org/apache/cassandra/io/sstable/format/SSTableReader.java
+++ b/src/java/org/apache/cassandra/io/sstable/format/SSTableReader.java
@@ -588,6 +588,14 @@ public abstract class SSTableReader extends SSTable
implements UnfilteredSource,
}
}
+ /**
+ * @return the thread-safe, per-descriptor coordination status between SAI
index rebuilds and entire-SSTable streaming
+ */
+ public SSTableStreamRebuildState streamRebuildState()
+ {
+ return tidy.global.streamRebuildState;
+ }
+
public void setReplaced()
{
synchronized (tidy.global)
@@ -1629,6 +1637,8 @@ public abstract class SSTableReader extends SSTable
implements UnfilteredSource,
private WeakReference<ScheduledFuture<?>> readMeterSyncFuture = NULL;
// shared state managing if the logical sstable has been compacted;
this is used in cleanup
private volatile Runnable obsoletion;
+ // Coordination state for SAI index rebuilds and ZCS for this SSTable.
+ final SSTableStreamRebuildState streamRebuildState = new
SSTableStreamRebuildState();
GlobalTidy(final SSTableReader reader)
{
diff --git
a/src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java
b/src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java
new file mode 100644
index 0000000000..f7c81c46ae
--- /dev/null
+++
b/src/java/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildState.java
@@ -0,0 +1,96 @@
+/*
+ * 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.io.sstable.format;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Coordination state between ZCS and SAI index rebuilds for a single logical
SSTable.
+ *
+ * <p>Multiple entire-SSTable streams of the same SSTable can run concurrently
({@code ZCS_STREAMING} with a
+ * reference count), but a rebuild is exclusive ({@code REBUILDING}) and
mutually exclusive with streaming.
+ */
+@ThreadSafe
+public class SSTableStreamRebuildState
+{
+ public enum State
+ {
+ NORMAL,
+ REBUILDING,
+ ZCS_STREAMING
+ }
+
+ private State state = State.NORMAL;
+ private int zcsStreamCount = 0;
+
+ /**
+ * Attempt to begin an entire-SSTable stream.
+ *
+ * @return true if streaming may proceed (caller must later call {@link
#endStreaming()})
+ * false if a rebuild is active and the caller should fall back to
legacy streaming
+ */
+ public synchronized boolean tryBeginStreaming()
+ {
+ if (state == State.REBUILDING)
+ return false;
+ state = State.ZCS_STREAMING;
+ zcsStreamCount++;
+ return true;
+ }
+
+ public synchronized void endStreaming()
+ {
+ // Return to NORMAL when the last stream ends
+ if (zcsStreamCount > 0 && --zcsStreamCount == 0)
+ state = State.NORMAL;
+ }
+
+ /**
+ * Attempt to begin a rebuild.
+ *
+ * @return true if the rebuild may proceed (caller must later call {@link
#endRebuild()})
+ * false if any stream is in progress or another rebuild is
already running
+ */
+ public synchronized boolean tryBeginRebuild()
+ {
+ if (state != State.NORMAL)
+ return false;
+ state = State.REBUILDING;
+ return true;
+ }
+
+ public synchronized void endRebuild()
+ {
+ if (state == State.REBUILDING)
+ state = State.NORMAL;
+ }
+
+ public synchronized State state()
+ {
+ return state;
+ }
+
+ @VisibleForTesting
+ public synchronized int zcsStreamCount()
+ {
+ return zcsStreamCount;
+ }
+}
diff --git a/src/java/org/apache/cassandra/streaming/OutgoingStream.java
b/src/java/org/apache/cassandra/streaming/OutgoingStream.java
index cc42ab6b82..30c902b5f1 100644
--- a/src/java/org/apache/cassandra/streaming/OutgoingStream.java
+++ b/src/java/org/apache/cassandra/streaming/OutgoingStream.java
@@ -42,6 +42,13 @@ public interface OutgoingStream
*/
void finish();
+ /**
+ * Release any coordination status this stream reserved at construction.
+ */
+ default void releaseStreamRebuildStatus()
+ {
+ }
+
long getRepairedAt();
TimeUUID getPendingRepair();
diff --git
a/test/distributed/org/apache/cassandra/distributed/test/sai/IndexDeleteDuringEntireSSTableStreamingTest.java
b/test/distributed/org/apache/cassandra/distributed/test/sai/IndexDeleteDuringEntireSSTableStreamingTest.java
new file mode 100644
index 0000000000..6cb8b864f5
--- /dev/null
+++
b/test/distributed/org/apache/cassandra/distributed/test/sai/IndexDeleteDuringEntireSSTableStreamingTest.java
@@ -0,0 +1,234 @@
+/*
+ * 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.sai;
+
+import java.nio.channels.FileChannel;
+import java.util.StringJoiner;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
+import net.bytebuddy.implementation.MethodDelegation;
+import net.bytebuddy.implementation.bind.annotation.AllArguments;
+import net.bytebuddy.implementation.bind.annotation.FieldValue;
+import net.bytebuddy.implementation.bind.annotation.SuperCall;
+
+import org.junit.Test;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.streaming.CassandraEntireSSTableStreamWriter;
+import org.apache.cassandra.db.streaming.ComponentContext;
+import org.apache.cassandra.db.streaming.ComponentManifest;
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.IInvokableInstance;
+import org.apache.cassandra.distributed.api.NodeToolResult;
+import org.apache.cassandra.distributed.test.TestBaseImpl;
+import org.apache.cassandra.io.sstable.Component;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.util.File;
+import org.apache.cassandra.utils.concurrent.CountDownLatch;
+
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+import static org.apache.cassandra.distributed.api.Feature.GOSSIP;
+import static org.apache.cassandra.distributed.api.Feature.NETWORK;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that if a streamed SAI component changes size underneath an
in-flight ZCS stream, the sender must fail the
+ * stream rather than ship bytes that disagree with the already-sent {@link
ComponentManifest}. The failed streaming
+ * transaction must be rolled back on the receiver, which must not expose any
partial or size-mismatched data.
+ */
+public class IndexDeleteDuringEntireSSTableStreamingTest extends TestBaseImpl
+{
+ private static final String TABLE = "tbl";
+ private static final String INDEX = "sai_idx";
+ private static final int ROWS = 200;
+
+ @Test
+ public void testIndexDeleteWhileEntireSSTableStreaming() throws Exception
+ {
+ try (Cluster cluster = init(Cluster.build(2)
+ .withDataDirCount(1)
+ .withConfig(c -> c.with(NETWORK,
GOSSIP)
+
.set("stream_entire_sstables", true)
+
.set("autocompaction_on_startup_enabled", false))
+ // Only the sender (node1) needs
the streaming pause installed.
+ .withInstanceInitializer((cl, num)
-> {
+ if (num == 1)
+ BBHelper.install(cl);
+ })
+ .start()))
+ {
+ cluster.disableAutoCompaction(KEYSPACE);
+ cluster.schemaChange(withKeyspace("CREATE TABLE %s." + TABLE + "
(pk int PRIMARY KEY, v text)"));
+ cluster.schemaChange(withKeyspace("CREATE INDEX " + INDEX + " ON
%s." + TABLE + "(v) USING 'sai'"));
+ SAIUtil.waitForIndexQueryable(cluster, KEYSPACE, INDEX);
+
+ IInvokableInstance node1 = cluster.get(1); // sender
+ IInvokableInstance node2 = cluster.get(2); // receiver
+
+ // Populate the sender only and flush to a single SSTable that
carries the SAI components.
+ // node2 owns the same range but has no data yet.
+ for (int i = 0; i < ROWS; i++)
+ node1.executeInternal(withKeyspace("INSERT INTO %s." + TABLE +
"(pk, v) VALUES (?, ?)"), i, "v" + i);
+ node1.flush(KEYSPACE);
+
+ // Arm the sender-side pause so only the upcoming ZCS stream is
intercepted.
+ node1.runOnInstance(() -> BBHelper.armed.set(true));
+
+ // Kick off the streaming asynchronously so node2 rebuilds its
data from node1. The call blocks until
+ // streaming completes, and streaming is about to block on the
sender, so run it off-thread.
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try
+ {
+ Future<NodeToolResult> streaming =
+ executor.submit(() -> node2.nodetoolResult("rebuild",
"--keyspace", KEYSPACE));
+
+ // Wait until the sender has serialized and flushed the
manifest and is paused before opening the
+ // outgoing component channels.
+ node1.runOnInstance(() ->
BBHelper.manifestSent.awaitUninterruptibly(2, TimeUnit.MINUTES));
+
+ // While the stream is paused mid-flight (status is already
ZCS_STREAMING for this SSTable), a
+ // concurrent SAI rebuild must fail fast rather than mutate
components underneath the in-flight
+ // stream. Directly truncate one of the streamed SAI component
files on the sender. SAI components are
+ // not part of mutableComponents(), so they are not
hard-linked and are streamed from the live file.
+ // When the writer opens the channel the on-disk 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.
+
node1.runOnInstance(IndexDeleteDuringEntireSSTableStreamingTest::truncateStreamedSaiComponent);
+
+ // Let the paused stream continue now that a component has
been mutated underneath it.
+ node1.runOnInstance(() -> BBHelper.proceed.decrement());
+
+ NodeToolResult result = streaming.get(3, TimeUnit.MINUTES);
+ result.asserts().failure();
+ assertThat(node1.logs()
+ .grep("Entire SSTable streaming expects .*
file size to be .* but got")
+ .getResult())
+ .describedAs("Expected the ComponentContext size check to
fail the stream")
+ .isNotEmpty();
+ }
+ finally
+ {
+ executor.shutdownNow();
+ }
+
+ for (int i = 0; i < ROWS; i++)
+ {
+ assertThat(node2.executeInternal(withKeyspace("SELECT pk FROM
%s." + TABLE + " WHERE pk = ?"), i))
+ .describedAs("Receiver must not retain data from the
failed stream (pk=%d)", i)
+ .isEmpty();
+ }
+ }
+ }
+
+ private static void truncateStreamedSaiComponent()
+ {
+ ColumnFamilyStore cfs =
Keyspace.open(KEYSPACE).getColumnFamilyStore(TABLE);
+ SSTableReader sstable = cfs.getLiveSSTables().iterator().next();
+ for (Component component : sstable.getStreamingComponents())
+ {
+ // Only mutate a component that is streamed from the live file
(not a hard-linked mutable component and
+ // not a primary component), i.e. a SAI index component, so the
size divergence hits ComponentContext.
+ if
(sstable.descriptor.getFormat().primaryComponents().contains(component))
+ continue;
+ if
(sstable.descriptor.getFormat().mutableComponents().contains(component))
+ continue;
+ File file = sstable.descriptor.fileFor(component);
+ if (!file.exists() || file.length() == 0)
+ continue;
+ try (FileChannel channel = file.newReadWriteChannel())
+ {
+ channel.truncate(Math.max(0, channel.size() - 1));
+ }
+ catch (Exception e)
+ {
+ throw new RuntimeException(e);
+ }
+ return;
+ }
+ throw new IllegalStateException("No streamed SAI component available
to mutate");
+ }
+
+ public static class BBHelper
+ {
+ // Per-instance state, resolved in the sender's classloader.
+ static final AtomicBoolean armed = new AtomicBoolean(false);
+ static final AtomicBoolean paused = new AtomicBoolean(false);
+ static final CountDownLatch manifestSent =
CountDownLatch.newCountDownLatch(1);
+ static final CountDownLatch proceed =
CountDownLatch.newCountDownLatch(1);
+
+ @SuppressWarnings("unused")
+ public static void write(@FieldValue("manifest") ComponentManifest
manifest, @SuperCall Callable<Void> zuper) throws Exception
+ {
+ if (armed.get() && paused.compareAndSet(false, true))
+ {
+ StringJoiner sj = new StringJoiner(", ", "{", "}");
+ for (Component component : manifest.components())
+ sj.add(component.name + '=' + manifest.sizeOf(component));
+ System.out.println("[ZCS-VERIFY] manifest sent
(component=advertisedSize): " + sj);
+
+ manifestSent.decrement();
+ proceed.awaitUninterruptibly(2, TimeUnit.MINUTES);
+ }
+ zuper.call();
+ }
+
+ @SuppressWarnings("unused")
+ public static FileChannel channel(@AllArguments Object[] args,
@SuperCall Callable<FileChannel> zuper) throws Exception
+ {
+ Component component = (Component) args[1];
+ long advertised = (Long) args[2];
+ try
+ {
+ FileChannel channel = zuper.call();
+ System.out.println("[ZCS-VERIFY] streaming component " +
component.name +
+ ": advertisedSize=" + advertised + ",
actualSize=" + channel.size());
+ return channel;
+ }
+ catch (Throwable t)
+ {
+ System.out.println("[ZCS-VERIFY] streaming component " +
component.name +
+ ": advertisedSize=" + advertised + " ->
FAILED TO OPEN: " + t);
+ throw t;
+ }
+ }
+
+ public static void install(ClassLoader classLoader)
+ {
+ new ByteBuddy().rebase(CassandraEntireSSTableStreamWriter.class)
+ .method(named("write").and(takesArguments(1)))
+ .intercept(MethodDelegation.to(BBHelper.class))
+ .make()
+ .load(classLoader,
ClassLoadingStrategy.Default.INJECTION);
+ new ByteBuddy().rebase(ComponentContext.class)
+ .method(named("channel").and(takesArguments(3)))
+ .intercept(MethodDelegation.to(BBHelper.class))
+ .make()
+ .load(classLoader,
ClassLoadingStrategy.Default.INJECTION);
+ }
+ }
+}
diff --git
a/test/distributed/org/apache/cassandra/distributed/test/sai/IndexRebuildDuringEntireSSTableStreamingTest.java
b/test/distributed/org/apache/cassandra/distributed/test/sai/IndexRebuildDuringEntireSSTableStreamingTest.java
new file mode 100644
index 0000000000..a143721b8d
--- /dev/null
+++
b/test/distributed/org/apache/cassandra/distributed/test/sai/IndexRebuildDuringEntireSSTableStreamingTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.sai;
+
+import java.nio.channels.FileChannel;
+import java.util.Collections;
+import java.util.StringJoiner;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
+import net.bytebuddy.implementation.MethodDelegation;
+import net.bytebuddy.implementation.bind.annotation.AllArguments;
+import net.bytebuddy.implementation.bind.annotation.FieldValue;
+import net.bytebuddy.implementation.bind.annotation.SuperCall;
+
+import org.junit.Test;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.streaming.CassandraEntireSSTableStreamWriter;
+import org.apache.cassandra.db.streaming.ComponentContext;
+import org.apache.cassandra.db.streaming.ComponentManifest;
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.IInvokableInstance;
+import org.apache.cassandra.distributed.api.NodeToolResult;
+import org.apache.cassandra.distributed.test.TestBaseImpl;
+import org.apache.cassandra.io.sstable.Component;
+import org.apache.cassandra.utils.concurrent.CountDownLatch;
+
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+import static org.apache.cassandra.distributed.api.Feature.GOSSIP;
+import static org.apache.cassandra.distributed.api.Feature.NETWORK;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that a concurrent SAI rebuild fails fast while an entire-SSTable
(ZCS) stream is in flight, and that the
+ * stream completes with correct data on the receiver.
+ */
+public class IndexRebuildDuringEntireSSTableStreamingTest extends TestBaseImpl
+{
+ private static final String TABLE = "tbl";
+ private static final String INDEX = "sai_idx";
+ private static final int ROWS = 200;
+
+ @Test
+ public void testIndexRebuildWhileEntireSSTableStreaming() throws Exception
+ {
+ try (Cluster cluster = init(Cluster.build(2)
+ .withDataDirCount(1)
+ .withConfig(c -> c.with(NETWORK,
GOSSIP)
+
.set("stream_entire_sstables", true)
+
.set("autocompaction_on_startup_enabled", false))
+ // Only the sender (node1) needs
the streaming pause installed.
+ .withInstanceInitializer((cl, num)
-> {
+ if (num == 1)
+ BBHelper.install(cl);
+ })
+ .start()))
+ {
+ cluster.disableAutoCompaction(KEYSPACE);
+ cluster.schemaChange(withKeyspace("CREATE TABLE %s." + TABLE + "
(pk int PRIMARY KEY, v text)"));
+ cluster.schemaChange(withKeyspace("CREATE INDEX " + INDEX + " ON
%s." + TABLE + "(v) USING 'sai'"));
+ SAIUtil.waitForIndexQueryable(cluster, KEYSPACE, INDEX);
+
+ IInvokableInstance node1 = cluster.get(1); // sender
+ IInvokableInstance node2 = cluster.get(2); // receiver
+
+ // Populate the sender only and flush to a single SSTable that
carries the SAI components.
+ // node2 owns the same range (RF == cluster size) but has no data
yet.
+ for (int i = 0; i < ROWS; i++)
+ node1.executeInternal(withKeyspace("INSERT INTO %s." + TABLE +
"(pk, v) VALUES (?, ?)"), i, "v" + i);
+ node1.flush(KEYSPACE);
+
+ // Arm the sender-side pause so only the upcoming ZCS stream is
intercepted.
+ node1.runOnInstance(() -> BBHelper.armed.set(true));
+
+ // Kick off the streaming asynchronously so node2 rebuilds its
data from node1. The call blocks until
+ // streaming completes, and streaming is about to block on the
sender, so run it off-thread.
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try
+ {
+ Future<NodeToolResult> streaming =
+ executor.submit(() -> node2.nodetoolResult("rebuild",
"--keyspace", KEYSPACE));
+
+ // Wait until the sender has serialized and flushed the
manifest and is paused before opening the
+ // outgoing component channels.
+ node1.runOnInstance(() ->
BBHelper.manifestSent.awaitUninterruptibly(2, TimeUnit.MINUTES));
+
+ // While the stream is paused mid-flight (status is already
ZCS_STREAMING for this sstable), a
+ // concurrent SAI rebuild must fail fast rather than mutate
components underneath the in-flight stream.
+ boolean rebuildRejected =
node1.callOnInstance(IndexRebuildDuringEntireSSTableStreamingTest::rebuildAndReturnRejected);
+ assertThat(rebuildRejected)
+ .describedAs("SAI rebuild must fail fast while ZCS is in
progress")
+ .isTrue();
+ assertThat(node1.logs()
+ .grep("while entire SSTable streaming is in
progress")
+ .getResult())
+ .describedAs("Expected the rebuild to be rejected due to
active streaming")
+ .isNotEmpty();
+
+ // Let the paused stream continue; its components were never
mutated, so it completes cleanly.
+ node1.runOnInstance(() -> BBHelper.proceed.decrement());
+
+ NodeToolResult result = streaming.get(3, TimeUnit.MINUTES);
+ result.asserts().success();
+ }
+ finally
+ {
+ executor.shutdownNow();
+ }
+
+ for (int i = 0; i < ROWS; i++)
+ {
+ Object[][] byPrimaryKey =
node2.executeInternal(withKeyspace("SELECT pk FROM %s." + TABLE + " WHERE pk =
?"), i);
+ assertThat(byPrimaryKey.length)
+ .describedAs("Receiver must retain streamed data (pk=%d)",
i)
+ .isEqualTo(1);
+ Object[][] byIndex =
node2.executeInternal(withKeyspace("SELECT pk FROM %s." + TABLE + " WHERE v =
?"), "v" + i);
+ assertThat(byIndex.length)
+ .describedAs("Receiver SAI index must return streamed row
(v%d)", i)
+ .isEqualTo(1);
+ assertThat(byIndex[0][0]).isEqualTo(i);
+ }
+
+ // A rejected full rebuild leaves the index marked for rebuild.
The sender's data is intact, and once
+ // streaming has completed and released the status a fresh rebuild
succeeds.
+ for (int i = 0; i < ROWS; i++)
+ {
+ Object[][] onSender =
node1.executeInternal(withKeyspace("SELECT pk FROM %s." + TABLE + " WHERE pk =
?"), i);
+ assertThat(onSender.length).describedAs("Sender data must be
intact (pk=%d)", i).isEqualTo(1);
+ }
+
+ node1.runOnInstance(() -> {
+ ColumnFamilyStore cfs =
Keyspace.open(KEYSPACE).getColumnFamilyStore(TABLE);
+
cfs.indexManager.rebuildIndexesBlocking(Collections.singleton(INDEX));
+ });
+ SAIUtil.waitForIndexQueryable(cluster, KEYSPACE, INDEX);
+
+ for (int i = 0; i < ROWS; i++)
+ {
+ Object[][] onSender =
node1.executeInternal(withKeyspace("SELECT pk FROM %s." + TABLE + " WHERE v =
?"), "v" + i);
+ assertThat(onSender.length).describedAs("Sender index must be
queryable after re-rebuild (v%d)", i).isEqualTo(1);
+ assertThat(onSender[0][0]).isEqualTo(i);
+ }
+ }
+ }
+
+ private static boolean rebuildAndReturnRejected()
+ {
+ try
+ {
+ ColumnFamilyStore cfs =
Keyspace.open(KEYSPACE).getColumnFamilyStore(TABLE);
+
cfs.indexManager.rebuildIndexesBlocking(Collections.singleton(INDEX));
+ return false;
+ }
+ catch (Throwable t)
+ {
+
org.slf4j.LoggerFactory.getLogger(IndexRebuildDuringEntireSSTableStreamingTest.class).error(t.getMessage(),
t);
+ return true;
+ }
+ }
+
+ public static class BBHelper
+ {
+ static final AtomicBoolean armed = new AtomicBoolean(false);
+ static final AtomicBoolean paused = new AtomicBoolean(false);
+ static final CountDownLatch manifestSent =
CountDownLatch.newCountDownLatch(1);
+ static final CountDownLatch proceed =
CountDownLatch.newCountDownLatch(1);
+
+ @SuppressWarnings("unused")
+ public static void write(@FieldValue("manifest") ComponentManifest
manifest, @SuperCall Callable<Void> zuper) throws Exception
+ {
+ if (armed.get() && paused.compareAndSet(false, true))
+ {
+ StringJoiner sj = new StringJoiner(", ", "{", "}");
+ for (Component component : manifest.components())
+ sj.add(component.name + '=' + manifest.sizeOf(component));
+ System.out.println("[ZCS-VERIFY] manifest sent
(component=advertisedSize): " + sj);
+
+ manifestSent.decrement();
+ proceed.awaitUninterruptibly(2, TimeUnit.MINUTES);
+ }
+ zuper.call();
+ }
+
+ @SuppressWarnings("unused")
+ public static FileChannel channel(@AllArguments Object[] args,
@SuperCall Callable<FileChannel> zuper) throws Exception
+ {
+ Component component = (Component) args[1];
+ long advertised = (Long) args[2];
+ try
+ {
+ FileChannel channel = zuper.call();
+ System.out.println("[ZCS-VERIFY] streaming component " +
component.name +
+ ": advertisedSize=" + advertised + ",
actualSize=" + channel.size());
+ return channel;
+ }
+ catch (Throwable t)
+ {
+ System.out.println("[ZCS-VERIFY] streaming component " +
component.name +
+ ": advertisedSize=" + advertised + " ->
MISMATCH: " + t.getMessage());
+ throw t;
+ }
+ }
+
+ public static void install(ClassLoader classLoader)
+ {
+ new ByteBuddy().rebase(CassandraEntireSSTableStreamWriter.class)
+ .method(named("write").and(takesArguments(1)))
+ .intercept(MethodDelegation.to(BBHelper.class))
+ .make()
+ .load(classLoader,
ClassLoadingStrategy.Default.INJECTION);
+ new ByteBuddy().rebase(ComponentContext.class)
+ .method(named("channel").and(takesArguments(3)))
+ .intercept(MethodDelegation.to(BBHelper.class))
+ .make()
+ .load(classLoader,
ClassLoadingStrategy.Default.INJECTION);
+ }
+ }
+}
diff --git
a/test/distributed/org/apache/cassandra/distributed/test/sai/StreamingDuringIndexRebuildTest.java
b/test/distributed/org/apache/cassandra/distributed/test/sai/StreamingDuringIndexRebuildTest.java
new file mode 100644
index 0000000000..61b8688a8c
--- /dev/null
+++
b/test/distributed/org/apache/cassandra/distributed/test/sai/StreamingDuringIndexRebuildTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.sai;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
+import net.bytebuddy.implementation.MethodDelegation;
+import net.bytebuddy.implementation.bind.annotation.SuperCall;
+
+import org.junit.Test;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.streaming.CassandraEntireSSTableStreamWriter;
+import org.apache.cassandra.distributed.Cluster;
+import org.apache.cassandra.distributed.api.IInvokableInstance;
+import org.apache.cassandra.distributed.api.NodeToolResult;
+import org.apache.cassandra.distributed.test.TestBaseImpl;
+
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+import static org.apache.cassandra.distributed.api.Feature.GOSSIP;
+import static org.apache.cassandra.distributed.api.Feature.NETWORK;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * When an SAI index rebuild is in progress on the sender, a new
entire-SSTable (ZCS) stream of the same SSTable must
+ * degrade to legacy streaming, so the rebuild and the stream never
mutate/ship the SAI components concurrently.
+ */
+public class StreamingDuringIndexRebuildTest extends TestBaseImpl
+{
+ private static final String TABLE = "tbl";
+ private static final String INDEX = "sai_idx";
+ private static final int ROWS = 200;
+
+ @Test
+ public void testStreamingFallsBackToLegacyDuringRebuild() throws Exception
+ {
+ try (Cluster cluster = init(Cluster.build(2)
+ .withDataDirCount(1)
+ .withConfig(c -> c.with(NETWORK,
GOSSIP)
+
.set("stream_entire_sstables", true)
+
.set("autocompaction_on_startup_enabled", false))
+ // Only the sender (node1) needs
the entire-SSTable writer counter installed.
+ .withInstanceInitializer((cl, num)
-> {
+ if (num == 1)
+ BBHelper.install(cl);
+ })
+ .start()))
+ {
+ cluster.disableAutoCompaction(KEYSPACE);
+ cluster.schemaChange(withKeyspace("CREATE TABLE %s." + TABLE + "
(pk int PRIMARY KEY, v text)"));
+ cluster.schemaChange(withKeyspace("CREATE INDEX " + INDEX + " ON
%s." + TABLE + "(v) USING 'sai'"));
+ SAIUtil.waitForIndexQueryable(cluster, KEYSPACE, INDEX);
+
+ IInvokableInstance node1 = cluster.get(1); // sender
+ IInvokableInstance node2 = cluster.get(2); // receiver
+
+ for (int i = 0; i < ROWS; i++)
+ node1.executeInternal(withKeyspace("INSERT INTO %s." + TABLE +
"(pk, v) VALUES (?, ?)"), i, "v" + i);
+ node1.flush(KEYSPACE);
+
+ // Model an in-progress SAI rebuild on the sender by reserving the
per-SSTable rebuild status for every
+ // live SSTable. While this is held, an entire-SSTable stream of
the same sstable must fall back to legacy.
+ node1.runOnInstance(() -> {
+ ColumnFamilyStore cfs =
Keyspace.open(KEYSPACE).getColumnFamilyStore(TABLE);
+ cfs.getLiveSSTables().forEach(sstable -> {
+ if (!sstable.streamRebuildState().tryBeginRebuild())
+ throw new IllegalStateException("Could not reserve
rebuild status for " + sstable.descriptor);
+ });
+ });
+
+ node1.runOnInstance(() -> BBHelper.armed.set(true));
+
+ try
+ {
+ // node2 rebuilds its data from node1. The sender must NOT use
ZCS while a rebuild holds the SSTable, so
+ // it degrades to legacy streaming.
+ NodeToolResult result = node2.nodetoolResult("rebuild",
"--keyspace", KEYSPACE);
+ result.asserts().success();
+
+ int entireWrites = node1.callOnInstance(() ->
BBHelper.entireSSTableWrites.get());
+ assertThat(entireWrites)
+ .describedAs("ZCS must not run while a rebuild holds the
sstable")
+ .isZero();
+ }
+ finally
+ {
+ // Release the reserved status so the sender returns to normal.
+ node1.runOnInstance(() -> {
+ ColumnFamilyStore cfs =
Keyspace.open(KEYSPACE).getColumnFamilyStore(TABLE);
+ cfs.getLiveSSTables().forEach(sstable ->
sstable.streamRebuildState().endRebuild());
+ });
+ }
+
+ // The receiver built its SAI index locally from the legacy stream
and must expose the streamed rows.
+ SAIUtil.waitForIndexQueryable(cluster, KEYSPACE, INDEX);
+ for (int i = 0; i < ROWS; i++)
+ {
+ Object[][] byPrimaryKey =
node2.executeInternal(withKeyspace("SELECT pk FROM %s." + TABLE + " WHERE pk =
?"), i);
+ assertThat(byPrimaryKey.length)
+ .describedAs("Receiver must retain streamed data (pk=%d)",
i)
+ .isEqualTo(1);
+ Object[][] byIndex =
node2.executeInternal(withKeyspace("SELECT pk FROM %s." + TABLE + " WHERE v =
?"), "v" + i);
+ assertThat(byIndex.length)
+ .describedAs("Receiver SAI index must return streamed row
(v%d)", i)
+ .isEqualTo(1);
+ assertThat(byIndex[0][0]).isEqualTo(i);
+ }
+ }
+ }
+
+ public static class BBHelper
+ {
+ static final AtomicBoolean armed = new AtomicBoolean(false);
+ static final AtomicInteger entireSSTableWrites = new AtomicInteger(0);
+
+ @SuppressWarnings("unused")
+ public static void write(@SuperCall Callable<Void> zuper) throws
Exception
+ {
+ if (armed.get())
+ entireSSTableWrites.incrementAndGet();
+ zuper.call();
+ }
+
+ public static void install(ClassLoader classLoader)
+ {
+ new ByteBuddy().rebase(CassandraEntireSSTableStreamWriter.class)
+ .method(named("write").and(takesArguments(1)))
+ .intercept(MethodDelegation.to(BBHelper.class))
+ .make()
+ .load(classLoader,
ClassLoadingStrategy.Default.INJECTION);
+ }
+ }
+}
diff --git
a/test/unit/org/apache/cassandra/db/streaming/CassandraStreamManagerDoubleReleaseTest.java
b/test/unit/org/apache/cassandra/db/streaming/CassandraStreamManagerDoubleReleaseTest.java
new file mode 100644
index 0000000000..643abc6355
--- /dev/null
+++
b/test/unit/org/apache/cassandra/db/streaming/CassandraStreamManagerDoubleReleaseTest.java
@@ -0,0 +1,271 @@
+/*
+ * 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.db.streaming;
+
+import java.io.IOException;
+import java.net.UnknownHostException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+
+import org.jboss.byteman.contrib.bmunit.BMRule;
+import org.jboss.byteman.contrib.bmunit.BMRules;
+import org.jboss.byteman.contrib.bmunit.BMUnitRunner;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import org.apache.cassandra.SchemaLoader;
+import org.apache.cassandra.Util;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.cql3.QueryProcessor;
+import org.apache.cassandra.cql3.statements.schema.CreateTableStatement;
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.dht.IPartitioner;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.sstable.Descriptor;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.RangesAtEndpoint;
+import org.apache.cassandra.locator.Replica;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.schema.CompactionParams;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.schema.Schema;
+import org.apache.cassandra.schema.TableMetadata;
+import org.apache.cassandra.streaming.OutgoingStream;
+import org.apache.cassandra.streaming.PreviewKind;
+import org.apache.cassandra.streaming.StreamOperation;
+import org.apache.cassandra.streaming.StreamSession;
+import org.apache.cassandra.streaming.StreamingChannel;
+import org.apache.cassandra.streaming.async.NettyStreamingConnectionFactory;
+import org.apache.cassandra.utils.TimeUUID;
+import org.apache.cassandra.utils.concurrent.Ref;
+
+import static
org.apache.cassandra.service.ActiveRepairService.NO_PENDING_REPAIR;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests that an empty-sections SSTable reference is not double-released if a
subsequent stream
+ * construction failure occurs in {@link
CassandraStreamManager#createOutgoingStreams}.
+ *
+ * <p>When an SSTable has no sections for the requested ranges, its reference
must be released and
+ * removed from {@code refs}. If it is only released without removal, a
failure when creating a later
+ * stream causes the catch block's {@code refs.release()} to release it a
second time, throwing an
+ * {@link IllegalStateException}.
+ */
+@RunWith(BMUnitRunner.class)
+public class CassandraStreamManagerDoubleReleaseTest
+{
+ private static final String table = "tbl";
+ private static final StreamingChannel.Factory connectionFactory = new
NettyStreamingConnectionFactory();
+
+ private static final int N = 1;
+ private static final int RETRY_BUDGET = 20;
+
+ private static final String DOUBLE_RELEASE_MSG = "Attempted to release a
reference that has already been released";
+ private static final String INJECTED_MSG = "injected component manifest
failure";
+
+ // Set once getPositionsForRanges returns empty, ensuring failure is
injected only after an empty-sections ref was released.
+ private static final AtomicBoolean emptySeen = new AtomicBoolean(false);
+
+ // Byteman callbacks
+ public static void markEmptySeen() { emptySeen.set(true); }
+ public static boolean shouldThrow() { return emptySeen.get(); }
+
+ private String keyspace;
+ private TableMetadata tbm;
+ private ColumnFamilyStore cfs;
+
+ @BeforeClass
+ public static void setupClass() throws Exception
+ {
+ SchemaLoader.prepareServer();
+ }
+
+ @Before
+ public void createKeyspace()
+ {
+ keyspace = String.format("ks_%s", System.currentTimeMillis());
+ tbm = CreateTableStatement.parse(String.format("CREATE TABLE %s (k INT
PRIMARY KEY, v INT)", table), keyspace)
+
.compaction(CompactionParams.stcs(Collections.emptyMap()))
+ .build();
+ SchemaLoader.createKeyspace(keyspace, KeyspaceParams.simple(1), tbm);
+ cfs = Schema.instance.getColumnFamilyStoreInstance(tbm.id);
+ }
+
+ @Test
+ @BMRules(rules = {
+ @BMRule(name = "record empty getPositionsForRanges result",
+ targetClass =
"org.apache.cassandra.io.sstable.format.SSTableReader",
+ targetMethod = "getPositionsForRanges",
+ targetLocation = "AT EXIT",
+ condition = "$!.isEmpty()",
+ action =
"org.apache.cassandra.db.streaming.CassandraStreamManagerDoubleReleaseTest.markEmptySeen()"),
+ @BMRule(name = "throw from ComponentManifest.create once an
empty-sections ref was released",
+ targetClass =
"org.apache.cassandra.db.streaming.ComponentManifest",
+ targetMethod = "create",
+ targetLocation = "AT ENTRY",
+ condition =
"org.apache.cassandra.db.streaming.CassandraStreamManagerDoubleReleaseTest.shouldThrow()",
+ action = "throw new java.lang.RuntimeException(\"injected
component manifest failure\")")
+ })
+ public void emptySectionsRefIsNotDoubleReleasedOnExceptionPath() throws
Exception
+ {
+ // Refs iteration order is determined by HashMap bucket layout.
Alternating sstable creation order
+ // ensures an empty-sections sstable is processed before a failing
sstable within two attempts.
+ for (int attempt = 0; attempt < RETRY_BUDGET; attempt++)
+ {
+ RangesAtEndpoint replicas = freshSSTables(attempt);
+ emptySeen.set(false);
+
+ Collection<OutgoingStream> streams = null;
+ try
+ {
+ streams =
cfs.getStreamManager().createOutgoingStreams(session(NO_PENDING_REPAIR),
+
replicas,
+
NO_PENDING_REPAIR,
+
PreviewKind.NONE);
+ // The failing sstable was processed before the empty one, so
no exception was injected; retry.
+ releaseStreams(streams);
+ }
+ catch (IllegalStateException e)
+ {
+ if (e.getMessage() != null &&
e.getMessage().contains(DOUBLE_RELEASE_MSG))
+ fail("Empty-sections Ref was double-released on exception
path: " + e);
+ throw e;
+ }
+ catch (RuntimeException e)
+ {
+ // Injected failure propagated cleanly without
double-releasing any refs.
+ if (e.getMessage() != null &&
e.getMessage().contains(INJECTED_MSG))
+ return;
+ throw e;
+ }
+ }
+
+ fail("Could not process an empty-sections sstable before a failing
sstable within " + RETRY_BUDGET + " attempts");
+ }
+
+ /**
+ * Rebuilds the CFS with N repaired (empty-sections) sstables and N
unrepaired (non-empty)
+ * sstables, and returns a transient-only replica set. A repaired sstable
is streamed against
+ * {@code replicas.onlyFull()} (empty here) so its sections are empty; an
unrepaired sstable is
+ * streamed against {@code replicas.ranges()} (the whole ring) so its
sections are non-empty.
+ */
+ private RangesAtEndpoint freshSSTables(int attempt) throws Exception
+ {
+ cfs.truncateBlocking();
+
+ long repairedAt = System.currentTimeMillis();
+ Runnable makeRepaired = () -> {
+ for (int i = 0; i < N; i++)
+ {
+ final int k = 100 + i;
+ SSTableReader repaired = createSSTable(() ->
QueryProcessor.executeInternal(
+ String.format("INSERT INTO %s.%s (k, v) VALUES (%d, %d)",
keyspace, table, k, k)));
+ try
+ {
+ mutateRepaired(repaired, repairedAt);
+ }
+ catch (IOException e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+ };
+ Runnable makeUnrepaired = () -> {
+ for (int i = 0; i < N; i++)
+ {
+ final int k = 200 + i;
+ createSSTable(() -> QueryProcessor.executeInternal(
+ String.format("INSERT INTO %s.%s (k, v) VALUES (%d, %d)",
keyspace, table, k, k)));
+ }
+ };
+
+ if (attempt % 2 == 0)
+ {
+ makeRepaired.run();
+ makeUnrepaired.run();
+ }
+ else
+ {
+ makeUnrepaired.run();
+ makeRepaired.run();
+ }
+
+ IPartitioner partitioner = DatabaseDescriptor.getPartitioner();
+ Range<Token> wholeRing = new Range<>(partitioner.getMinimumToken(),
partitioner.getMinimumToken());
+ InetAddressAndPort local = InetAddressAndPort.getByName("127.0.0.1");
+ // transient replica (full=false): onlyFull() is empty => repaired
sstables get empty sections
+ return RangesAtEndpoint.of(new Replica(local, wholeRing, false));
+ }
+
+ private SSTableReader createSSTable(Runnable queryable)
+ {
+ Set<SSTableReader> before = cfs.getLiveSSTables();
+ queryable.run();
+ Util.flush(cfs);
+ Set<SSTableReader> after = cfs.getLiveSSTables();
+ return Iterables.getOnlyElement(Sets.difference(after, before));
+ }
+
+ private static void mutateRepaired(SSTableReader sstable, long repairedAt)
throws IOException
+ {
+ Descriptor descriptor = sstable.descriptor;
+ descriptor.getMetadataSerializer().mutateRepairMetadata(descriptor,
repairedAt, NO_PENDING_REPAIR, false);
+ sstable.reloadSSTableMetadata();
+ }
+
+ private static void releaseStreams(Collection<OutgoingStream> streams)
+ {
+ if (streams == null)
+ return;
+ for (OutgoingStream stream : streams)
+ {
+ Ref<SSTableReader> ref =
CassandraOutgoingFile.fromStream(stream).getRef();
+ ref.release();
+ }
+ }
+
+ private static StreamSession session(TimeUUID pendingRepair)
+ {
+ try
+ {
+ return new StreamSession(StreamOperation.REPAIR,
+ InetAddressAndPort.getByName("127.0.0.1"),
+ connectionFactory,
+ null,
+ MessagingService.current_version,
+ false,
+ 0,
+ pendingRepair,
+ PreviewKind.NONE);
+ }
+ catch (UnknownHostException e)
+ {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git
a/test/unit/org/apache/cassandra/db/streaming/ComponentContextTest.java
b/test/unit/org/apache/cassandra/db/streaming/ComponentContextTest.java
new file mode 100644
index 0000000000..e081b1eaad
--- /dev/null
+++ b/test/unit/org/apache/cassandra/db/streaming/ComponentContextTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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.db.streaming;
+
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.SchemaLoader;
+import org.apache.cassandra.Util;
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.RowUpdateBuilder;
+import org.apache.cassandra.db.compaction.CompactionManager;
+import org.apache.cassandra.io.sstable.Component;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.utils.ByteBufferUtil;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class ComponentContextTest
+{
+ private static final String KEYSPACE = "ComponentContextTest";
+ private static final String CF_STANDARD = "Standard1";
+
+ private static SSTableReader sstable;
+
+ @BeforeClass
+ public static void defineSchemaAndPrepareSSTable()
+ {
+ SchemaLoader.prepareServer();
+ SchemaLoader.createKeyspace(KEYSPACE,
+ KeyspaceParams.simple(1),
+ SchemaLoader.standardCFMD(KEYSPACE,
CF_STANDARD));
+
+ ColumnFamilyStore store =
Keyspace.open(KEYSPACE).getColumnFamilyStore(CF_STANDARD);
+ CompactionManager.instance.disableAutoCompaction();
+ for (int j = 0; j < 10; j++)
+ {
+ new RowUpdateBuilder(store.metadata(), j, String.valueOf(j))
+ .clustering("0")
+ .add("val", ByteBufferUtil.EMPTY_BYTE_BUFFER)
+ .build()
+ .applyUnsafe();
+ }
+ Util.flush(store);
+ CompactionManager.instance.performMaximal(store, false);
+
+ sstable = store.getLiveSSTables().iterator().next();
+ }
+
+ @Test
+ public void matchingSizeReturnsChannel() throws IOException
+ {
+ try (ComponentContext context = ComponentContext.create(sstable))
+ {
+ Component component = firstStreamableComponent(context);
+ long advertised = context.manifest().sizeOf(component);
+ try (FileChannel channel = context.channel(sstable.descriptor,
component, advertised))
+ {
+ assertEquals("advertised size must match the streamed channel
size", advertised, channel.size());
+ }
+ }
+ }
+
+ @Test
+ public void sizeMismatchThrowsIllegalStateExceptionNotAssertionError()
throws IOException
+ {
+ try (ComponentContext context = ComponentContext.create(sstable))
+ {
+ Component component = firstStreamableComponent(context);
+ long advertised = context.manifest().sizeOf(component);
+ long wrongSize = advertised + 1; // pretend the on-disk file no
longer matches the advertised manifest
+
+ try
+ {
+ context.channel(sstable.descriptor, component, wrongSize);
+ fail("Expected an IllegalStateException when the advertised
size does not match the on-disk size");
+ }
+ catch (AssertionError e)
+ {
+ // The whole point of the hardening: this must NOT be an
AssertionError, since assertions are
+ // disabled in production and the corrupt bytes would
otherwise be shipped silently.
+ throw new AssertionError("size mismatch must fail via
IllegalStateException, not AssertionError", e);
+ }
+ catch (IllegalStateException e)
+ {
+ assertTrue("exception message should describe the size
mismatch",
+ e.getMessage().contains("file size to be"));
+ assertTrue("exception message should include the actual
on-disk size",
+
e.getMessage().contains(String.valueOf(advertised)));
+ }
+ }
+ }
+
+ private static Component firstStreamableComponent(ComponentContext context)
+ {
+ return context.manifest().components().iterator().next();
+ }
+}
diff --git
a/test/unit/org/apache/cassandra/db/streaming/TableStreamManagerTest.java
b/test/unit/org/apache/cassandra/db/streaming/TableStreamManagerTest.java
new file mode 100644
index 0000000000..263c95f157
--- /dev/null
+++ b/test/unit/org/apache/cassandra/db/streaming/TableStreamManagerTest.java
@@ -0,0 +1,258 @@
+/*
+ * 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.db.streaming;
+
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import org.apache.cassandra.SchemaLoader;
+import org.apache.cassandra.Util;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.db.Keyspace;
+import org.apache.cassandra.db.RowUpdateBuilder;
+import org.apache.cassandra.db.compaction.CompactionManager;
+import org.apache.cassandra.dht.Range;
+import org.apache.cassandra.dht.Token;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.sstable.format.SSTableStreamRebuildState.State;
+import org.apache.cassandra.locator.InetAddressAndPort;
+import org.apache.cassandra.locator.RangesAtEndpoint;
+import org.apache.cassandra.net.MessagingService;
+import org.apache.cassandra.schema.KeyspaceParams;
+import org.apache.cassandra.streaming.OutgoingStream;
+import org.apache.cassandra.streaming.PreviewKind;
+import org.apache.cassandra.streaming.StreamOperation;
+import org.apache.cassandra.streaming.StreamSession;
+import org.apache.cassandra.streaming.StreamingChannel;
+import org.apache.cassandra.streaming.async.NettyStreamingConnectionFactory;
+import org.apache.cassandra.utils.ByteBufferUtil;
+
+import static
org.apache.cassandra.service.ActiveRepairService.NO_PENDING_REPAIR;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class TableStreamManagerTest
+{
+ private static final String KEYSPACE = "TableStreamManagerTest";
+ private static final String CF = "Standard1";
+ private static final StreamingChannel.Factory connectionFactory = new
NettyStreamingConnectionFactory();
+
+ private static ColumnFamilyStore store;
+ private boolean previousStreamEntireSSTables;
+
+ @BeforeClass
+ public static void defineSchema()
+ {
+ SchemaLoader.prepareServer();
+ SchemaLoader.createKeyspace(KEYSPACE,
+ KeyspaceParams.simple(1),
+ SchemaLoader.standardCFMD(KEYSPACE, CF));
+ store = Keyspace.open(KEYSPACE).getColumnFamilyStore(CF);
+ CompactionManager.instance.disableAutoCompaction();
+ }
+
+ @Before
+ public void setUp()
+ {
+ previousStreamEntireSSTables =
DatabaseDescriptor.streamEntireSSTables();
+ DatabaseDescriptor.setStreamEntireSSTables(true);
+ store.disableAutoCompaction();
+ store.truncateBlocking();
+ }
+
+ @After
+ public void tearDown()
+ {
+
DatabaseDescriptor.setStreamEntireSSTables(previousStreamEntireSSTables);
+ store.truncateBlocking();
+ }
+
+ private static StreamSession session()
+ {
+ try
+ {
+ return new StreamSession(StreamOperation.BOOTSTRAP,
+ InetAddressAndPort.getByName("127.0.0.1"),
+ connectionFactory,
+ null,
+ MessagingService.current_version,
+ false,
+ 0,
+ null,
+ PreviewKind.NONE);
+ }
+ catch (UnknownHostException e)
+ {
+ throw new AssertionError(e);
+ }
+ }
+
+ private RangesAtEndpoint fullRange()
+ {
+ Token min = store.getPartitioner().getMinimumToken();
+ return RangesAtEndpoint.toDummyList(Collections.singleton(new
Range<>(min, min)));
+ }
+
+ private static void insertAndFlush(int from, int to)
+ {
+ for (int j = from; j < to; j++)
+ {
+ new RowUpdateBuilder(store.metadata(), j, String.valueOf(j))
+ .clustering("0")
+ .add("val", ByteBufferUtil.EMPTY_BYTE_BUFFER)
+ .build()
+ .applyUnsafe();
+ }
+ Util.flush(store);
+ }
+
+ @Test
+ public void entireStreamReservesAndFinishReleasesStatus()
+ {
+ insertAndFlush(0, 10);
+ assertEquals(1, store.getLiveSSTables().size());
+
+ Collection<OutgoingStream> streams = store.getStreamManager()
+
.createOutgoingStreams(session(), fullRange(), NO_PENDING_REPAIR,
PreviewKind.NONE);
+ try
+ {
+ assertEquals(1, streams.size());
+ CassandraOutgoingFile cof =
CassandraOutgoingFile.fromStream(streams.iterator().next());
+
+ // Entire-sstable streaming must have engaged (multiple files
advertised) and reserved the ZCS status.
+ assertTrue("expected entire-sstable streaming to engage for a
full-range stream", cof.getNumFiles() > 1);
+ SSTableReader sstable = cof.getRef().get();
+ assertEquals(State.ZCS_STREAMING,
sstable.streamRebuildState().state());
+ assertEquals(1, sstable.streamRebuildState().zcsStreamCount());
+
+ // finish() releases the ZCS status exactly once and the sstable
returns to NORMAL.
+ cof.finish();
+ assertEquals(State.NORMAL, sstable.streamRebuildState().state());
+ assertEquals(0, sstable.streamRebuildState().zcsStreamCount());
+
+ // releaseStreamRebuildStatus() is idempotent - a second call must
not over-release.
+ cof.releaseStreamRebuildStatus();
+ assertEquals(State.NORMAL, sstable.streamRebuildState().state());
+ assertEquals(0, sstable.streamRebuildState().zcsStreamCount());
+ }
+ finally
+ {
+ releaseAll(streams);
+ }
+ }
+
+ @Test
+ public void planningFailurePartwayReleasesAlreadyReservedStreams()
+ {
+ // Two sstables so the manager's construction loop runs more than once.
+ insertAndFlush(0, 10);
+ insertAndFlush(10, 20);
+ assertEquals(2, store.getLiveSSTables().size());
+
+ // Spy the session so the FIRST outgoing stream is built normally
(reserving its ZCS status), then the
+ // SECOND construction fails - getStreamOperation() is only called
from inside the construction loop, once
+ // per sstable, so this deterministically injects a partway planning
failure.
+ StreamSession spy = Mockito.spy(session());
+ RuntimeException boom = new RuntimeException("injected planning
failure");
+ Mockito.doReturn(StreamOperation.BOOTSTRAP)
+ .doThrow(boom)
+ .when(spy).getStreamOperation();
+
+ try
+ {
+ store.getStreamManager().createOutgoingStreams(spy, fullRange(),
NO_PENDING_REPAIR, PreviewKind.NONE);
+ fail("Expected the injected planning failure to propagate");
+ }
+ catch (RuntimeException e)
+ {
+ assertSame(boom, e);
+ }
+
+ // The catch block must have released the ZCS status reserved by the
already-constructed first stream, so
+ // no live sstable is left stuck in ZCS_STREAMING.
+ for (SSTableReader sstable : store.getLiveSSTables())
+ {
+ assertEquals("planning failure must not leak the ZCS streaming
status for " + sstable.descriptor,
+ State.NORMAL, sstable.streamRebuildState().state());
+ assertEquals(0, sstable.streamRebuildState().zcsStreamCount());
+ }
+ }
+
+ @Test
+ public void streamingFallsBackToLegacyWhenRebuildInProgress()
+ {
+ insertAndFlush(0, 10);
+ assertEquals(1, store.getLiveSSTables().size());
+ SSTableReader sstable = store.getLiveSSTables().iterator().next();
+
+ // Model an in-progress SAI rebuild by reserving the rebuild status.
The stream must degrade to legacy and
+ // must NOT reserve (or later release) any streaming status.
+ assertTrue(sstable.streamRebuildState().tryBeginRebuild());
+
+ Collection<OutgoingStream> streams = store.getStreamManager()
+
.createOutgoingStreams(session(), fullRange(), NO_PENDING_REPAIR,
PreviewKind.NONE);
+ try
+ {
+ assertEquals(1, streams.size());
+ CassandraOutgoingFile cof =
CassandraOutgoingFile.fromStream(streams.iterator().next());
+ assertEquals("stream must fall back to legacy (single file) while
a rebuild holds the sstable",
+ 1, cof.getNumFiles());
+ // Status is still owned by the rebuild, untouched by the stream.
+ assertEquals(State.REBUILDING,
sstable.streamRebuildState().state());
+
+ cof.finish(); // must be a no-op for the rebuild status
+ assertEquals(State.REBUILDING,
sstable.streamRebuildState().state());
+ }
+ finally
+ {
+ releaseAll(streams);
+ sstable.streamRebuildState().endRebuild();
+ }
+ }
+
+ private static void releaseAll(Collection<OutgoingStream> streams)
+ {
+ List<OutgoingStream> list = new ArrayList<>(streams);
+ for (OutgoingStream stream : list)
+ {
+ CassandraOutgoingFile cof =
CassandraOutgoingFile.fromStream(stream);
+ cof.releaseStreamRebuildStatus();
+ try
+ {
+ cof.getRef().release();
+ }
+ catch (Throwable ignore)
+ {
+ // ref may already be released via finish() in the test body
+ }
+ }
+ }
+}
diff --git
a/test/unit/org/apache/cassandra/index/sai/StreamRebuildCoordinationTest.java
b/test/unit/org/apache/cassandra/index/sai/StreamRebuildCoordinationTest.java
new file mode 100644
index 0000000000..f68c5773b3
--- /dev/null
+++
b/test/unit/org/apache/cassandra/index/sai/StreamRebuildCoordinationTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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.index.sai;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.Test;
+
+import org.apache.cassandra.db.ColumnFamilyStore;
+import org.apache.cassandra.index.SecondaryIndexBuilder;
+import org.apache.cassandra.io.sstable.format.SSTableReader;
+import org.apache.cassandra.io.sstable.format.SSTableStreamRebuildState.State;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class StreamRebuildCoordinationTest extends SAITester
+{
+ private StorageAttachedIndex createIndexedTableWithSSTables(int
sstableCount)
+ {
+ createTable("CREATE TABLE %s (pk int PRIMARY KEY, v text)");
+ String indexName = createIndex("CREATE INDEX ON %s(v) USING 'sai'");
+ ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
+ cfs.disableAutoCompaction();
+
+ int pk = 0;
+ for (int s = 0; s < sstableCount; s++)
+ {
+ for (int i = 0; i < 20; i++, pk++)
+ execute("INSERT INTO %s (pk, v) VALUES (?, ?)", pk, "v" + pk);
+ flush();
+ }
+ waitForTableIndexesQueryable();
+
+ assertEquals("expected one sstable per flush", sstableCount,
cfs.getLiveSSTables().size());
+ return (StorageAttachedIndex)
cfs.indexManager.getIndexByName(indexName);
+ }
+
+ @Test
+ public void onNotExecutedReleasesReservedRebuildStatus()
+ {
+ StorageAttachedIndex sai = createIndexedTableWithSSTables(2);
+ ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
+ List<SSTableReader> sstables = new ArrayList<>(cfs.getLiveSSTables());
+
+ // Real rebuild path: reserves the per-sstable rebuild status for
every target sstable before returning.
+ SecondaryIndexBuilder builder = sai.getBuildTaskSupport()
+ .getIndexBuildTask(cfs,
Collections.singleton(sai), sstables, true);
+
+ for (SSTableReader sstable : sstables)
+ assertEquals("rebuild status must be reserved for " +
sstable.descriptor,
+ State.REBUILDING,
sstable.streamRebuildState().state());
+
+ // build() will never run (executor rejected the submission).
onNotExecuted must release everything, so the
+ // sstables do not stay stuck in REBUILDING and block future
entire-sstable streaming.
+ builder.onNotExecuted();
+
+ for (SSTableReader sstable : sstables)
+ {
+ assertEquals("onNotExecuted must release the reserved rebuild
status for " + sstable.descriptor,
+ State.NORMAL, sstable.streamRebuildState().state());
+ // And a stream may now proceed.
+ assertTrue(sstable.streamRebuildState().tryBeginStreaming());
+ sstable.streamRebuildState().endStreaming();
+ }
+ }
+
+ @Test
+ public void secondRebuildRejectedWhileFirstRebuildInFlight()
+ {
+ StorageAttachedIndex sai = createIndexedTableWithSSTables(2);
+ ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
+ List<SSTableReader> sstables = new ArrayList<>(cfs.getLiveSSTables());
+
+ // First rebuild is "in flight": it reserved the per-sstable rebuild
status for every target sstable but has
+ // not run/released yet (the returned builder still owns the
reservations).
+ SecondaryIndexBuilder first = sai.getBuildTaskSupport()
+ .getIndexBuildTask(cfs,
Collections.singleton(sai), sstables, true);
+ for (SSTableReader sstable : sstables)
+ assertEquals(State.REBUILDING,
sstable.streamRebuildState().state());
+
+ try
+ {
+ // A second rebuild of the same sstables must fail fast
(tryBeginRebuild returns false because they are
+ // already REBUILDING) rather than mutating index components
underneath the in-flight rebuild.
+ assertThatThrownBy(() -> sai.getBuildTaskSupport()
+ .getIndexBuildTask(cfs,
Collections.singleton(sai), sstables, true))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Cannot build SAI index");
+
+ // The failed second attempt must not disturb the first rebuild's
reservations: every sstable is still
+ // REBUILDING and owned by the first builder (the second attempt
rolled back only what it reserved).
+ for (SSTableReader sstable : sstables)
+ {
+ assertEquals("first rebuild's reservation must be intact for "
+ sstable.descriptor,
+ State.REBUILDING,
sstable.streamRebuildState().state());
+ assertFalse("no third rebuild may begin while the first is in
flight",
+ sstable.streamRebuildState().tryBeginRebuild());
+ assertFalse("streaming must remain blocked while the first
rebuild is in flight",
+ sstable.streamRebuildState().tryBeginStreaming());
+ }
+ }
+ finally
+ {
+ first.onNotExecuted();
+ }
+
+ // Once the first rebuild releases, a fresh rebuild can reserve
everything cleanly again.
+ for (SSTableReader sstable : sstables)
+ assertEquals(State.NORMAL, sstable.streamRebuildState().state());
+ SecondaryIndexBuilder third = sai.getBuildTaskSupport()
+ .getIndexBuildTask(cfs,
Collections.singleton(sai), sstables, true);
+ for (SSTableReader sstable : sstables)
+ assertEquals(State.REBUILDING,
sstable.streamRebuildState().state());
+ third.onNotExecuted();
+ }
+
+ @Test
+ public void partialReservationRollbackWhenAnSSTableIsBeingStreamed()
+ {
+ StorageAttachedIndex sai = createIndexedTableWithSSTables(3);
+ ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
+
+ List<SSTableReader> live = new ArrayList<>(cfs.getLiveSSTables());
+ assertEquals(3, live.size());
+
+ // Simulate an in-flight entire-sstable stream of ONE sstable, and
make sure it is reserved LAST so the
+ // rebuild reserves the other two first and must then roll them back
when it hits the streamed one.
+ SSTableReader streamed = live.get(0);
+ List<SSTableReader> rollbackCandidates = new
ArrayList<>(live.subList(1, live.size()));
+ assertThat(rollbackCandidates).hasSize(2);
+ assertTrue(streamed.streamRebuildState().tryBeginStreaming());
+
+ List<SSTableReader> ordered = new ArrayList<>(rollbackCandidates);
+ ordered.add(streamed); // streamed sstable reserved last
+
+ try
+ {
+ assertThatThrownBy(() -> sai.getBuildTaskSupport()
+ .getIndexBuildTask(cfs,
Collections.singleton(sai), ordered, true))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Cannot build SAI index while entire
SSTable streaming is in progress");
+
+ // The two sstables reserved before the failure must be fully
rolled back to NORMAL...
+ for (SSTableReader sstable : rollbackCandidates)
+ assertEquals("reservation must be rolled back for " +
sstable.descriptor,
+ State.NORMAL,
sstable.streamRebuildState().state());
+
+ // ...and the streamed sstable must be untouched (still
streaming), not corrupted into REBUILDING.
+ assertEquals(State.ZCS_STREAMING,
streamed.streamRebuildState().state());
+ assertEquals(1, streamed.streamRebuildState().zcsStreamCount());
+ }
+ finally
+ {
+ streamed.streamRebuildState().endStreaming();
+ }
+
+ // With the stream released, a rebuild can now reserve every sstable
cleanly - proving the earlier partial
+ // rollback left no lingering reservations.
+ List<SSTableReader> all = new ArrayList<>(cfs.getLiveSSTables());
+ SecondaryIndexBuilder builder = sai.getBuildTaskSupport()
+ .getIndexBuildTask(cfs,
Collections.singleton(sai), all, true);
+ for (SSTableReader sstable : all)
+ assertEquals(State.REBUILDING,
sstable.streamRebuildState().state());
+ builder.onNotExecuted();
+ for (SSTableReader sstable : all)
+ assertEquals(State.NORMAL, sstable.streamRebuildState().state());
+ }
+}
diff --git
a/test/unit/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildStateTest.java
b/test/unit/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildStateTest.java
new file mode 100644
index 0000000000..9717bffcf3
--- /dev/null
+++
b/test/unit/org/apache/cassandra/io/sstable/format/SSTableStreamRebuildStateTest.java
@@ -0,0 +1,285 @@
+/*
+ * 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.io.sstable.format;
+
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Test;
+
+import org.apache.cassandra.io.sstable.format.SSTableStreamRebuildState.State;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class SSTableStreamRebuildStateTest
+{
+ @Test
+ public void startsNormal()
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ assertEquals(State.NORMAL, s.state());
+ assertEquals(0, s.zcsStreamCount());
+ }
+
+ @Test
+ public void multipleStreamsAllowedAndCounted()
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ assertTrue(s.tryBeginStreaming());
+ assertTrue(s.tryBeginStreaming());
+ assertEquals(State.ZCS_STREAMING, s.state());
+ assertEquals(2, s.zcsStreamCount());
+
+ s.endStreaming();
+ assertEquals(State.ZCS_STREAMING, s.state());
+ assertEquals(1, s.zcsStreamCount());
+
+ s.endStreaming();
+ assertEquals(State.NORMAL, s.state());
+ assertEquals(0, s.zcsStreamCount());
+ }
+
+ @Test
+ public void rebuildBlockedWhileStreaming()
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ assertTrue(s.tryBeginStreaming());
+ assertFalse(s.tryBeginRebuild());
+ assertEquals(State.ZCS_STREAMING, s.state());
+ }
+
+ @Test
+ public void streamingBlockedWhileRebuilding()
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ assertTrue(s.tryBeginRebuild());
+ assertFalse(s.tryBeginStreaming());
+ assertEquals(State.REBUILDING, s.state());
+ assertEquals(0, s.zcsStreamCount());
+ }
+
+ @Test
+ public void rebuildExcludesSecondRebuild()
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ assertTrue(s.tryBeginRebuild());
+ assertFalse(s.tryBeginRebuild());
+ }
+
+ @Test
+ public void rebuildResetsToNormal()
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ assertTrue(s.tryBeginRebuild());
+ s.endRebuild();
+ assertEquals(State.NORMAL, s.state());
+ assertTrue(s.tryBeginStreaming());
+ }
+
+ @Test
+ public void endIsDefensiveAgainstOverRelease()
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ s.endStreaming(); // no-op, must not go negative
+ s.endRebuild(); // no-op
+ assertEquals(State.NORMAL, s.state());
+ assertEquals(0, s.zcsStreamCount());
+ }
+
+ /**
+ * Under contention many threads race to begin a rebuild of the same
sstable. Because a rebuild is exclusive,
+ * exactly one attempt must win; every other attempt must observe the
in-flight rebuild and fail. Covers the
+ * "second concurrent rebuild attempt fails while one is in flight"
scenario under real contention rather than
+ * the sequential check in {@link #rebuildExcludesSecondRebuild()}.
+ */
+ @Test
+ public void concurrentRebuildAttemptsOnlyOneWins() throws Exception
+ {
+ for (int round = 0; round < 200; round++)
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ int threads = 8;
+ CyclicBarrier start = new CyclicBarrier(threads);
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try
+ {
+ AtomicInteger winners = new AtomicInteger();
+ Future<?>[] futures = new Future<?>[threads];
+ for (int i = 0; i < threads; i++)
+ {
+ futures[i] = pool.submit(() -> {
+ awaitBarrier(start);
+ if (s.tryBeginRebuild())
+ winners.incrementAndGet();
+ });
+ }
+ for (Future<?> f : futures)
+ f.get(30, TimeUnit.SECONDS);
+
+ assertEquals("exactly one rebuild attempt must win the race",
1, winners.get());
+ assertEquals(State.REBUILDING, s.state());
+ assertFalse("streaming must be blocked while the winning
rebuild is in flight", s.tryBeginStreaming());
+
+ s.endRebuild();
+ assertEquals(State.NORMAL, s.state());
+ }
+ finally
+ {
+ pool.shutdownNow();
+ }
+ }
+ }
+
+ /**
+ * Concurrent entire-sstable streams of the same sstable must all be
admitted and reference counted correctly,
+ * so that no update is lost under contention (the count must equal the
number of successful begins, and only
+ * the final release returns the sstable to {@code NORMAL}).
+ */
+ @Test
+ public void concurrentStreamsAreRefCountedCorrectly() throws Exception
+ {
+ for (int round = 0; round < 200; round++)
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ int threads = 8;
+ CyclicBarrier start = new CyclicBarrier(threads);
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try
+ {
+ AtomicInteger admitted = new AtomicInteger();
+ Future<?>[] futures = new Future<?>[threads];
+ for (int i = 0; i < threads; i++)
+ {
+ futures[i] = pool.submit(() -> {
+ awaitBarrier(start);
+ if (s.tryBeginStreaming())
+ admitted.incrementAndGet();
+ });
+ }
+ for (Future<?> f : futures)
+ f.get(30, TimeUnit.SECONDS);
+
+ assertEquals("all concurrent streams must be admitted
(streaming is not exclusive)", threads, admitted.get());
+ assertEquals(State.ZCS_STREAMING, s.state());
+ assertEquals(threads, s.zcsStreamCount());
+ assertFalse("a rebuild must be blocked while any stream is in
flight", s.tryBeginRebuild());
+
+ for (int i = 0; i < threads; i++)
+ s.endStreaming();
+
+ assertEquals(State.NORMAL, s.state());
+ assertEquals(0, s.zcsStreamCount());
+ }
+ finally
+ {
+ pool.shutdownNow();
+ }
+ }
+ }
+
+ /**
+ * Stress test hammering {@code tryBeginRebuild}/{@code tryBeginStreaming}
from many threads to assert the core
+ * mutual-exclusion invariant never breaks under contention: a rebuild and
any stream are never both active at
+ * once, and at most one rebuild is ever active. This complements the
sequential state-transition tests, which
+ * cannot detect races in the check-and-set logic.
+ */
+ @Test
+ public void mutualExclusionHoldsUnderStress() throws Exception
+ {
+ SSTableStreamRebuildState s = new SSTableStreamRebuildState();
+ int threads = 12;
+ int iterationsPerThread = 20_000;
+
+ AtomicInteger activeRebuilds = new AtomicInteger();
+ AtomicInteger activeStreams = new AtomicInteger();
+ AtomicReference<String> violation = new AtomicReference<>();
+
+ CyclicBarrier start = new CyclicBarrier(threads);
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try
+ {
+ Future<?>[] futures = new Future<?>[threads];
+ for (int t = 0; t < threads; t++)
+ {
+ final int seed = t;
+ futures[t] = pool.submit(() -> {
+ awaitBarrier(start);
+ java.util.Random rnd = new java.util.Random(seed);
+ for (int i = 0; i < iterationsPerThread && violation.get()
== null; i++)
+ {
+ if (rnd.nextBoolean())
+ {
+ if (s.tryBeginRebuild())
+ {
+ int r = activeRebuilds.incrementAndGet();
+ if (r != 1)
+ violation.compareAndSet(null, "two
rebuilds active simultaneously: " + r);
+ if (activeStreams.get() != 0)
+ violation.compareAndSet(null, "stream
active during rebuild");
+ activeRebuilds.decrementAndGet();
+ s.endRebuild();
+ }
+ }
+ else
+ {
+ if (s.tryBeginStreaming())
+ {
+ activeStreams.incrementAndGet();
+ if (activeRebuilds.get() != 0)
+ violation.compareAndSet(null, "rebuild
active during stream");
+ activeStreams.decrementAndGet();
+ s.endStreaming();
+ }
+ }
+ }
+ });
+ }
+ for (Future<?> f : futures)
+ f.get(60, TimeUnit.SECONDS);
+
+ assertNull(violation.get(), violation.get());
+ assertEquals("all operations released, must return to NORMAL",
State.NORMAL, s.state());
+ assertEquals(0, s.zcsStreamCount());
+ }
+ finally
+ {
+ pool.shutdownNow();
+ }
+ }
+
+ private static void awaitBarrier(CyclicBarrier barrier)
+ {
+ try
+ {
+ barrier.await(30, TimeUnit.SECONDS);
+ }
+ catch (Exception e)
+ {
+ throw new RuntimeException(e);
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]