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 bf06dbdd9a Fix concurrent SAI vector inserts failing once jvector's
per-graph pool limit is exceeded
bf06dbdd9a is described below
commit bf06dbdd9aafb6b1d4ef17f1e06ec2b720d40d2b
Author: Jeremiah Jordan <[email protected]>
AuthorDate: Tue Sep 8 15:25:54 2026 -0500
Fix concurrent SAI vector inserts failing once jvector's per-graph pool
limit is exceeded
CASSANDRA-21160 removed the synchronized keyword from
VectorMemoryIndex.add(). Concurrent inserts into a table with a
Storage-Attached Index (SAI) vector index now reach jvector's
GraphIndexBuilder.addGraphNode() in parallel.
jvector 1.0.2 pools the scratch objects that addGraphNode() uses. Each
pool holds at most Runtime.availableProcessors() + 1 objects. When more
threads than that are inside addGraphNode() at the same time, the pool
throws IllegalStateException. The mutation fails with
WriteFailureException. The failed insert also leaves a node with no
neighbors in the graph. A retry of the same row finds the existing
postings entry and does not link the node.
The default configuration reaches this limit. SkipListMemtable locks
each partition only, and concurrent_writes defaults to 32.
Bound the number of threads inside addGraphNode() in OnHeapGraph with a
semaphore of availableProcessors() + 1 permits. Writers beyond the limit
wait for a permit. Graph insertion is processor bound, so the throughput
gain from CASSANDRA-21160 stays.
Add Memtable.limitsConcurrentWritesTo(). TrieMemtable and the locking
ShardedSkipListMemtable serialize writes per shard. They return true
when their shard count is within the limit, and OnHeapGraph then creates
no semaphore.
Add a regression test that inserts from more writers than the limit. Add
a test for the memtable implementations.
patch by Jeremiah Jordan; reviewed by Caleb Rackliffe for CASSANDRA-21644
---
CHANGES.txt | 1 +
.../org/apache/cassandra/db/memtable/Memtable.java | 11 +++
.../apache/cassandra/db/memtable/Memtable_API.md | 4 +
.../db/memtable/ShardedSkipListMemtable.java | 5 +
.../apache/cassandra/db/memtable/TrieMemtable.java | 7 ++
.../index/sai/disk/v1/vector/OnHeapGraph.java | 31 ++++++-
.../memtable/MemtableConcurrentWriteLimitTest.java | 102 +++++++++++++++++++++
.../index/sai/memory/VectorMemoryIndexTest.java | 54 +++++++----
8 files changed, 197 insertions(+), 18 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 83979af9a4..8b9c00f041 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,4 +1,5 @@
5.0.10
+ * Fix concurrent SAI vector inserts failing once jvector's per-graph pool
limit is exceeded (CASSANDRA-21644)
* Let the commit log allocator observe the shutdown state (CASSANDRA-21616)
* Unwrap LongType properly when calculating min/max terms in V1SSTableIndex
(CASSANDRA-21635)
* Force repair should ignore min_repair_interval (CASSANDRA-21552)
diff --git a/src/java/org/apache/cassandra/db/memtable/Memtable.java
b/src/java/org/apache/cassandra/db/memtable/Memtable.java
index d4f0dedb99..dac1cd5ccd 100644
--- a/src/java/org/apache/cassandra/db/memtable/Memtable.java
+++ b/src/java/org/apache/cassandra/db/memtable/Memtable.java
@@ -196,6 +196,17 @@ public interface Memtable extends Comparable<Memtable>,
UnfilteredSource, CellSo
*/
long put(PartitionUpdate update, UpdateTransaction indexer, OpOrder.Group
opGroup);
+ /**
+ * Whether this memtable guarantees that no more than {@code maxWriters}
threads are inside {@link #put} at once,
+ * and therefore that no more than that many threads concurrently apply
the update's effect to the {@code indexer}.
+ * Memtables that serialize writes per shard can return true when they
have at most {@code maxWriters} shards.
+ * Indexes whose implementation has a limit on concurrent writers can skip
their own bounding when this is true.
+ */
+ default boolean limitsConcurrentWritesTo(int maxWriters)
+ {
+ return false;
+ }
+
// Read operations are provided by the UnfilteredSource interface.
// Statistics
diff --git a/src/java/org/apache/cassandra/db/memtable/Memtable_API.md
b/src/java/org/apache/cassandra/db/memtable/Memtable_API.md
index 70f8f0b605..ae1eda1ca7 100644
--- a/src/java/org/apache/cassandra/db/memtable/Memtable_API.md
+++ b/src/java/org/apache/cassandra/db/memtable/Memtable_API.md
@@ -135,6 +135,10 @@ implementations) are provided as the `AbstractMemtable`
(statistics tracking), `
commit log span tracking) and `AbstractAllocatorMemtable` (adds memory
management via the `Allocator` class, together
with flush triggering on memory use and time interval expiration).
+A memtable that serializes writes, for example per shard, can say so through
`limitsConcurrentWritesTo()`. Secondary
+indexes whose implementation limits the number of concurrent writers use this
to skip their own bounding when the
+memtable already provides it. The default answer is false.
+
The memtable API also gives the memtable some control over flushing and the
functioning of the commit log. The former
is there to permit memtables that operate long-term and/or can handle some
events internally, without a need to flush.
The latter enables memtables that have an internal durability mechanism, such
as ones using persistent memory or a
diff --git
a/src/java/org/apache/cassandra/db/memtable/ShardedSkipListMemtable.java
b/src/java/org/apache/cassandra/db/memtable/ShardedSkipListMemtable.java
index 92cdbbad9f..955e6b0d52 100644
--- a/src/java/org/apache/cassandra/db/memtable/ShardedSkipListMemtable.java
+++ b/src/java/org/apache/cassandra/db/memtable/ShardedSkipListMemtable.java
@@ -494,6 +494,11 @@ public class ShardedSkipListMemtable extends
AbstractShardedMemtable
}
}
+ @Override
+ public boolean limitsConcurrentWritesTo(int maxWriters)
+ {
+ return boundaries.shardCount() <= maxWriters;
+ }
}
public static Factory factory(Map<String, String> optionsCopy)
diff --git a/src/java/org/apache/cassandra/db/memtable/TrieMemtable.java
b/src/java/org/apache/cassandra/db/memtable/TrieMemtable.java
index 83b02db06a..f652c4675f 100644
--- a/src/java/org/apache/cassandra/db/memtable/TrieMemtable.java
+++ b/src/java/org/apache/cassandra/db/memtable/TrieMemtable.java
@@ -174,6 +174,13 @@ public class TrieMemtable extends AbstractShardedMemtable
}
}
+ @Override
+ public boolean limitsConcurrentWritesTo(int maxWriters)
+ {
+ // each shard applies one update at a time under its write lock
+ return boundaries.shardCount() <= maxWriters;
+ }
+
/**
* Should only be called by ColumnFamilyStore.apply via Keyspace.apply,
which supplies the appropriate
* OpOrdering.
diff --git
a/src/java/org/apache/cassandra/index/sai/disk/v1/vector/OnHeapGraph.java
b/src/java/org/apache/cassandra/index/sai/disk/v1/vector/OnHeapGraph.java
index a823943a4e..62b7c4dfa4 100644
--- a/src/java/org/apache/cassandra/index/sai/disk/v1/vector/OnHeapGraph.java
+++ b/src/java/org/apache/cassandra/index/sai/disk/v1/vector/OnHeapGraph.java
@@ -31,6 +31,10 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.IntStream;
+import javax.annotation.Nullable;
+
+import com.google.common.annotations.VisibleForTesting;
+
import org.cliffc.high_scale_lib.NonBlockingHashMap;
import org.cliffc.high_scale_lib.NonBlockingHashMapLong;
import org.slf4j.Logger;
@@ -63,6 +67,7 @@ import
org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.io.util.SequentialWriter;
import org.apache.cassandra.utils.ByteBufferUtil;
import org.apache.cassandra.utils.CloseableIterator;
+import org.apache.cassandra.utils.concurrent.Semaphore;
import org.apache.lucene.util.StringHelper;
public class OnHeapGraph<T>
@@ -71,6 +76,13 @@ public class OnHeapGraph<T>
public static final int MIN_PQ_ROWS = 1024;
+ /**
+ * jvector's {@link GraphIndexBuilder#addGraphNode} throws if more than
this many threads are inside it at once
+ * (see {@code PoolingSupport}). Uses {@link Runtime} rather than {@code
DatabaseDescriptor} because that is what jvector reads.
+ */
+ @VisibleForTesting
+ public static final int MAX_CONCURRENT_GRAPH_INSERTS =
Runtime.getRuntime().availableProcessors() + 1;
+
private final RamAwareVectorValues vectorValues;
private final GraphIndexBuilder<float[]> builder;
private final VectorType<?> vectorType;
@@ -79,6 +91,10 @@ public class OnHeapGraph<T>
private final NonBlockingHashMapLong<VectorPostings<T>> postingsByOrdinal;
private final NonBlockingHashMap<T, float[]> vectorsByKey;
private final AtomicInteger nextOrdinal = new AtomicInteger();
+ // memtable inserts are concurrent (CASSANDRA-21160); excess writers wait
here rather than fail in jvector.
+ // Null when the memtable itself already limits writers to that many, e.g.
a TrieMemtable with few enough shards.
+ @Nullable
+ private final Semaphore graphInsertPermits;
private volatile boolean hasDeletions;
private String source;
@@ -107,6 +123,9 @@ public class OnHeapGraph<T>
postingsMap = new ConcurrentSkipListMap<>(Arrays::compare);
postingsByOrdinal = new NonBlockingHashMapLong<>();
vectorsByKey = memtable != null ? new NonBlockingHashMap<>() : null;
+ graphInsertPermits = memtable != null &&
memtable.limitsConcurrentWritesTo(MAX_CONCURRENT_GRAPH_INSERTS)
+ ? null
+ :
Semaphore.newSemaphore(MAX_CONCURRENT_GRAPH_INSERTS);
builder = new GraphIndexBuilder<>(vectorValues,
VectorEncoding.FLOAT32,
@@ -187,7 +206,17 @@ public class OnHeapGraph<T>
: ((CompactionVectorValues)
vectorValues).add(ordinal, term);
bytesUsed += VectorPostings.emptyBytesUsed() +
VectorPostings.bytesPerPosting();
postingsByOrdinal.put(ordinal, postings);
- bytesUsed += builder.addGraphNode(ordinal, vectorValues);
+ if (graphInsertPermits != null)
+ graphInsertPermits.acquireThrowUncheckedOnInterrupt(1);
+ try
+ {
+ bytesUsed += builder.addGraphNode(ordinal, vectorValues);
+ }
+ finally
+ {
+ if (graphInsertPermits != null)
+ graphInsertPermits.release(1);
+ }
return bytesUsed;
}
else
diff --git
a/test/unit/org/apache/cassandra/db/memtable/MemtableConcurrentWriteLimitTest.java
b/test/unit/org/apache/cassandra/db/memtable/MemtableConcurrentWriteLimitTest.java
new file mode 100644
index 0000000000..76ce3d01e1
--- /dev/null
+++
b/test/unit/org/apache/cassandra/db/memtable/MemtableConcurrentWriteLimitTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.memtable;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import org.apache.cassandra.config.Config;
+import org.apache.cassandra.config.DatabaseDescriptor;
+import org.apache.cassandra.config.InheritingClass;
+import org.apache.cassandra.cql3.CQLTester;
+import org.apache.cassandra.db.ColumnFamilyStore;
+
+import static
org.apache.cassandra.db.memtable.AbstractShardedMemtable.SHARDS_OPTION;
+import static
org.apache.cassandra.db.memtable.ShardedSkipListMemtable.LOCKING_OPTION;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * {@link Memtable#limitsConcurrentWritesTo} must be true only for memtables
that serialize writes per shard, and
+ * only when the shard count is within the limit asked about.
+ */
+public class MemtableConcurrentWriteLimitTest extends CQLTester
+{
+ private static final int SHARDS = 4;
+
+ // Overrides CQLTester.setUpClass so the memtable configurations are
registered before the server is prepared
+ @BeforeClass
+ public static void setUpClass()
+ {
+ prePrepareServer();
+
+ LinkedHashMap<String, InheritingClass> memtableConfig = new
LinkedHashMap<>();
+ memtableConfig.put("skiplist", new InheritingClass(null,
SkipListMemtable.class.getName(), Map.of()));
+ memtableConfig.put("trie", new InheritingClass(null,
TrieMemtable.class.getName(), Map.of(SHARDS_OPTION, String.valueOf(SHARDS))));
+ memtableConfig.put("sharded", new InheritingClass(null,
ShardedSkipListMemtable.class.getName(), Map.of(SHARDS_OPTION,
String.valueOf(SHARDS))));
+ memtableConfig.put("sharded_locking", new InheritingClass(null,
ShardedSkipListMemtable.class.getName(), Map.of(SHARDS_OPTION,
String.valueOf(SHARDS), LOCKING_OPTION, "true")));
+ DatabaseDescriptor.getRawConfig().memtable = new
Config.MemtableOptions();
+ DatabaseDescriptor.getRawConfig().memtable.configurations =
memtableConfig;
+
+ prepareServer();
+ }
+
+ @Test
+ public void unshardedMemtableDoesNotLimitWriters()
+ {
+
assertFalse(memtableFor("skiplist").limitsConcurrentWritesTo(Integer.MAX_VALUE));
+ }
+
+ @Test
+ public void shardedSkipListWithoutLockingDoesNotLimitWriters()
+ {
+
assertFalse(memtableFor("sharded").limitsConcurrentWritesTo(Integer.MAX_VALUE));
+ }
+
+ @Test
+ public void trieMemtableLimitsWritersToShardCount()
+ {
+ assertLimitsToShardCount(memtableFor("trie"));
+ }
+
+ @Test
+ public void lockingShardedSkipListLimitsWritersToShardCount()
+ {
+ assertLimitsToShardCount(memtableFor("sharded_locking"));
+ }
+
+ private void assertLimitsToShardCount(Memtable memtable)
+ {
+ // the memtable may get fewer shards than requested, e.g. if local
ranges cannot be split that finely
+ int shards =
getCurrentColumnFamilyStore().localRangeSplits(SHARDS).shardCount();
+ assertTrue(memtable.limitsConcurrentWritesTo(shards));
+ assertTrue(memtable.limitsConcurrentWritesTo(shards + 1));
+ assertFalse(memtable.limitsConcurrentWritesTo(shards - 1));
+ }
+
+ private Memtable memtableFor(String memtableConfig)
+ {
+ createTable("CREATE TABLE %s (pk int PRIMARY KEY, v int) WITH memtable
= '" + memtableConfig + '\'');
+ ColumnFamilyStore cfs = getCurrentColumnFamilyStore();
+ return cfs.getTracker().getView().getCurrentMemtable();
+ }
+}
diff --git
a/test/unit/org/apache/cassandra/index/sai/memory/VectorMemoryIndexTest.java
b/test/unit/org/apache/cassandra/index/sai/memory/VectorMemoryIndexTest.java
index 3aa10bdd6b..91ea00ad75 100644
--- a/test/unit/org/apache/cassandra/index/sai/memory/VectorMemoryIndexTest.java
+++ b/test/unit/org/apache/cassandra/index/sai/memory/VectorMemoryIndexTest.java
@@ -74,6 +74,7 @@ import org.apache.cassandra.dht.Range;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.SAITester;
import org.apache.cassandra.index.sai.StorageAttachedIndex;
+import org.apache.cassandra.index.sai.disk.v1.vector.OnHeapGraph;
import org.apache.cassandra.index.sai.disk.v1.vector.PrimaryKeyWithScore;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
@@ -102,6 +103,7 @@ public class VectorMemoryIndexTest extends SAITester
private static final double RECALL_THRESHOLD = 0.9;
private static final int VECTORS_PER_THREAD = 2000;
+ private static final int VECTORS_PER_THREAD_BEYOND_POOL_CAP = 500; // more
writers, so fewer vectors each
private ColumnFamilyStore cfs;
private StorageAttachedIndex index;
@@ -138,8 +140,7 @@ public class VectorMemoryIndexTest extends SAITester
public void randomQueryTest() throws Exception
{
// A non-null memtable tells it to track the mapping from primary key
to vector, needed for brute force search
- Memtable memtable = Mockito.mock(Memtable.class);
- memtableIndex = new VectorMemoryIndex(index, memtable);
+ memtableIndex = new VectorMemoryIndex(index, mockMemtable(1));
for (int row = 0; row < getRandom().nextIntBetween(1000, 5000); row++)
{
@@ -242,13 +243,26 @@ public class VectorMemoryIndexTest extends SAITester
@Test
public void testConcurrentAddsWithRandomVectors() throws Exception
{
- testConcurrentAddsAreEventuallyConsistent((threadId, i) ->
randomVectorFromThreadLocal());
+
testConcurrentAddsAreEventuallyConsistent(Runtime.getRuntime().availableProcessors(),
VECTORS_PER_THREAD, (threadId, i) -> randomVectorFromThreadLocal());
}
@Test
public void testConcurrentAddsWithSharedVectors() throws Exception
{
- testConcurrentAddsAreEventuallyConsistent((threadId, i) ->
makeSharedVector(i));
+
testConcurrentAddsAreEventuallyConsistent(Runtime.getRuntime().availableProcessors(),
VECTORS_PER_THREAD, (threadId, i) -> makeSharedVector(i));
+ }
+
+ /**
+ * More writers than jvector's GraphIndexBuilder can serve at once must
wait, not fail the insert. The other
+ * concurrent tests use exactly availableProcessors writers, within
jvector's limit, so their memtable reports that
+ * it bounds writers and OnHeapGraph skips its semaphore; this test's
memtable cannot make that promise, so
+ * OnHeapGraph bounds the writers itself.
+ */
+ @Test
+ public void testConcurrentAddsExceedingJVectorPoolCap() throws Exception
+ {
+ int numThreads = 2 * OnHeapGraph.MAX_CONCURRENT_GRAPH_INSERTS;
+ testConcurrentAddsAreEventuallyConsistent(numThreads,
VECTORS_PER_THREAD_BEYOND_POOL_CAP, (threadId, i) ->
randomVectorFromThreadLocal());
}
/**
@@ -262,13 +276,11 @@ public class VectorMemoryIndexTest extends SAITester
* After all writers complete, a full-ring search must return the vast
majority of
* inserted keys with valid scores, confirming no data was lost or
corrupted.
*/
- private void testConcurrentAddsAreEventuallyConsistent(BiFunction<Integer,
Integer, ByteBuffer> vectorFactory) throws Exception
+ private void testConcurrentAddsAreEventuallyConsistent(int numThreads, int
vectorsPerThread, BiFunction<Integer, Integer, ByteBuffer> vectorFactory)
throws Exception
{
- Memtable memtable = Mockito.mock(Memtable.class);
- memtableIndex = new VectorMemoryIndex(index, memtable);
+ memtableIndex = new VectorMemoryIndex(index, mockMemtable(numThreads));
- int numThreads = Runtime.getRuntime().availableProcessors();
- int totalInserted = numThreads * VECTORS_PER_THREAD;
+ int totalInserted = numThreads * vectorsPerThread;
ExecutorService executor = Executors.newFixedThreadPool(numThreads);
@@ -284,9 +296,9 @@ public class VectorMemoryIndexTest extends SAITester
try
{
barrier.await();
- for (int i = 0; i < VECTORS_PER_THREAD; i++)
+ for (int i = 0; i < vectorsPerThread; i++)
{
- int pk = threadId * VECTORS_PER_THREAD + i;
+ int pk = threadId * vectorsPerThread + i;
addRow(pk, vectorFactory.apply(threadId, i));
}
}
@@ -376,11 +388,9 @@ public class VectorMemoryIndexTest extends SAITester
*/
public void testConcurrentAddsAndOrderByNeverThrow(BiFunction<Integer,
Integer, ByteBuffer> vectorFactory) throws Exception
{
- Memtable memtable = Mockito.mock(Memtable.class);
- memtableIndex = new VectorMemoryIndex(index, memtable);
-
int numWriterThreads = Runtime.getRuntime().availableProcessors();
int numReaderThreads = Runtime.getRuntime().availableProcessors();
+ memtableIndex = new VectorMemoryIndex(index,
mockMemtable(numWriterThreads));
int totalInserted = numWriterThreads * VECTORS_PER_THREAD;
// Pre-seed enough rows that orderBy() always has a non-empty graph to
search,
@@ -540,11 +550,9 @@ public class VectorMemoryIndexTest extends SAITester
*/
private void
testConcurrentAddsAndOrderResultsByNeverThrow(BiFunction<Integer, Integer,
ByteBuffer> vectorFactory) throws Exception
{
- Memtable memtable = Mockito.mock(Memtable.class);
- memtableIndex = new VectorMemoryIndex(index, memtable);
-
int numWriterThreads = Runtime.getRuntime().availableProcessors();
int numReaderThreads = Runtime.getRuntime().availableProcessors();
+ memtableIndex = new VectorMemoryIndex(index,
mockMemtable(numWriterThreads));
int totalInserted = numWriterThreads * VECTORS_PER_THREAD;
// Pre-seed rows so orderResultsBy() always has a non-empty
[minimumKey, maximumKey]
@@ -757,6 +765,18 @@ public class VectorMemoryIndexTest extends SAITester
keyMap.put(key, pk);
}
+ /**
+ * A memtable that answers {@link Memtable#limitsConcurrentWritesTo}
truthfully for the number of writer threads the
+ * test will use. With that many writers at or under {@link
OnHeapGraph#MAX_CONCURRENT_GRAPH_INSERTS}, OnHeapGraph
+ * relies on the memtable and creates no semaphore; with more, it bounds
the writers itself.
+ */
+ private static Memtable mockMemtable(int writers)
+ {
+ Memtable memtable = Mockito.mock(Memtable.class);
+
Mockito.when(memtable.limitsConcurrentWritesTo(Mockito.anyInt())).thenAnswer(invocation
-> writers <= (int) invocation.getArgument(0));
+ return memtable;
+ }
+
private DecoratedKey makeKey(TableMetadata table, Integer partitionKey)
{
ByteBuffer key =
table.partitionKeyType.fromString(partitionKey.toString());
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]