Jeremiah Jordan created CASSANDRA-21644:
-------------------------------------------

             Summary: Concurrent SAI vector inserts fail with "Number of 
outstanding pooled objects has gone beyond the limit" since CASSANDRA-21160
                 Key: CASSANDRA-21644
                 URL: https://issues.apache.org/jira/browse/CASSANDRA-21644
             Project: Apache Cassandra
          Issue Type: Bug
          Components: Feature/Vector Search
            Reporter: Jeremiah Jordan


Since CASSANDRA-21160 removed {{synchronized}} from 
{{{}VectorMemoryIndex.add(){}}}, concurrent inserts into a table with an SAI 
vector index fail once more than {{Runtime.availableProcessors() + 1}} threads 
are inside {{GraphIndexBuilder.addGraphNode()}} on the same memtable graph. 
jvector 1.0.2 caps the pooled scratch objects per {{GraphIndexBuilder}} at that 
number and throws {{IllegalStateException}} rather than waiting. The mutation 
fails with {{{}WriteFailureException{}}}. Before CASSANDRA-21160 all inserts 
into a memtable graph were serialized on the {{VectorMemoryIndex}} monitor, so 
the cap was unreachable.

Reproduces with the default configuration ({{{}SkipListMemtable{}}}, 
{{{}concurrent_writes: 32{}}}) on JDK 11 and JDK 25.
h3. Symptom

Client:
{code:java}
WriteFailureException: Operation failed - received 0 responses and 1 failures: 
UNKNOWN
{code}
Server (captured from a unit test; everything from {{StorageProxy.mutate}} down 
is the production write path):
{code:java}
ERROR [pool-2-thread-2] StorageProxy.java:2044 - Failed to apply mutation 
locally :
java.lang.RuntimeException: Number of outstanding pooled objects has gone 
beyond the limit of 3 for ks: cql_test_keyspace, table: table_testsiftsmall_00
        at 
org.apache.cassandra.db.ColumnFamilyStore.apply(ColumnFamilyStore.java:1552)
        at 
org.apache.cassandra.db.CassandraTableWriteHandler.write(CassandraTableWriteHandler.java:38)
        at org.apache.cassandra.db.Keyspace.applyInternal(Keyspace.java:579)
        at org.apache.cassandra.db.Keyspace.apply(Keyspace.java:434)
        at org.apache.cassandra.db.Mutation.apply(Mutation.java:297)
        at 
org.apache.cassandra.service.StorageProxy$4.runMayThrow(StorageProxy.java:2037)
        at 
org.apache.cassandra.service.StorageProxy$LocalMutationRunnable.run(StorageProxy.java:3209)
        at 
org.apache.cassandra.concurrent.SEPExecutor.maybeExecuteImmediately(SEPExecutor.java:218)
        at 
org.apache.cassandra.concurrent.Stage.maybeExecuteImmediately(Stage.java:130)
        at 
org.apache.cassandra.service.StorageProxy.performLocally(StorageProxy.java:2026)
        at 
org.apache.cassandra.service.StorageProxy.sendToHintedReplicas(StorageProxy.java:1919)
        at 
org.apache.cassandra.service.StorageProxy.performWrite(StorageProxy.java:1752)
        at 
org.apache.cassandra.service.StorageProxy.mutate(StorageProxy.java:993)
        ...
Caused by: java.lang.IllegalStateException: Number of outstanding pooled 
objects has gone beyond the limit of 3
        at 
io.github.jbellis.jvector.util.PoolingSupport$ThreadPooling.get(PoolingSupport.java:120)
        at 
io.github.jbellis.jvector.graph.GraphIndexBuilder.addGraphNode(GraphIndexBuilder.java:162)
        at 
org.apache.cassandra.index.sai.disk.v1.vector.OnHeapGraph.add(OnHeapGraph.java:191)
{code}
The limit is 3 because unit tests run with {{{}-XX:ActiveProcessorCount=2{}}}.
h3. Cause
 * jvector 1.0.2 {{PoolingSupport$ThreadPooling}} is constructed with {{{}limit 
= Runtime.getRuntime().availableProcessors() + 1{}}}; {{get()}} throws when the 
queue is empty and {{limit}} objects are already outstanding. Each 
{{GraphIndexBuilder}} owns three such pools ({{{}graphSearcher{}}}, 
{{{}naturalScratch{}}}, {{{}concurrentScratch{}}}). {{addGraphNode()}} takes 
one object from each and holds it for the duration of the call, so at most 
{{availableProcessors + 1}} threads can be inside {{addGraphNode()}} per 
builder. The limit is not configurable: the builder uses the no-argument 
factory and keeps the pools in private fields. {{OnHeapGraph}} creates one 
builder per memtable index.
 * Commit 448d98ce31 (CASSANDRA-21160) removed {{synchronized}} from 
{{{}VectorMemoryIndex.add(){}}}. Only {{updateKeyBounds()}} remains 
synchronized, and it runs after {{{}graph.add(){}}}. {{MemtableIndex}} and 
{{MemtableIndexManager}} hold no lock.
 * {{SkipListMemtable.put()}} locks per partition, so inserts to different 
partitions reach {{OnHeapGraph.add()}} concurrently. {{concurrent_writes}} 
defaults to 32, and {{Stage.maybeExecuteImmediately}} runs local mutations on 
the calling thread, so the number of threads in the builder is not bounded by 
{{concurrent_writes}} either. An 8-core node fails with 10 concurrent 
inserters. {{TrieMemtable}} bounds it (shards = 
{{{}getAvailableProcessors(){}}}, indexing under the shard write lock) but is 
not the default.

 
h3. Why CI does not catch it
 * {{VectorMemoryIndexTest}} (added by CASSANDRA-21160) uses exactly 
{{availableProcessors}} writer threads.
 * {{VectorSiftSmallTest}} on trunk uses {{{}IntStream.parallel(){}}}: common 
pool parallelism {{procs - 1}} plus the calling thread = {{{}procs{}}}.
 * Unit tests run with {{{}-XX:ActiveProcessorCount=2{}}}, so the limit is 3 
and no test uses more than 2 or 3 writers.

h3. Reproduction

Insert into a vector-indexed table from {{availableProcessors + 2}} or more 
threads concurrently. On the CASSANDRA-21171 branch, {{VectorSiftSmallTest}} 
with a 4-thread fixed pool fails on every run on JDK 11 and JDK 25. The 
following test, in the style of the existing concurrent tests in 
{{{}VectorMemoryIndexTest{}}}, reproduces it deterministically:
{code:java}
@Test
public void testConcurrentAddsExceedingJVectorPoolCap() throws Exception
{
    // jvector's limit is availableProcessors + 1; use twice that many writers
    int numThreads = 2 * (Runtime.getRuntime().availableProcessors() + 1);
    int vectorsPerThread = 500;

    Memtable memtable = Mockito.mock(Memtable.class);
    VectorMemoryIndex memtableIndex = new VectorMemoryIndex(index, memtable);

    ExecutorService executor = Executors.newFixedThreadPool(numThreads);
    CyclicBarrier barrier = new CyclicBarrier(numThreads);
    List<Future<?>> futures = new ArrayList<>();

    for (int t = 0; t < numThreads; t++)
    {
        final int threadId = t;
        futures.add(executor.submit(() -> {
            barrier.await();
            for (int i = 0; i < vectorsPerThread; i++)
            {
                int pk = threadId * vectorsPerThread + i;
                DecoratedKey key = 
cfs.metadata().partitioner.decorateKey(Int32Type.instance.decompose(pk));
                memtableIndex.add(key, Clustering.EMPTY, 
randomVector(dimensionCount));
            }
            return null;
        }));
    }

    executor.shutdown();
    assertTrue(executor.awaitTermination(60, TimeUnit.SECONDS));

    for (Future<?> f : futures)
    {
        try
        {
            f.get();
        }
        catch (ExecutionException e)
        {
            // IllegalStateException: Number of outstanding pooled objects has 
gone beyond the limit of N
            fail("Worker thread threw during concurrent add(): " + 
e.getCause());
        }
    }

    // then search the full ring and check the inserted keys come back with 
finite, positive scores
}
{code}
h3. Possible fixes
 * Bound the number of threads inside {{builder.addGraphNode()}} in 
{{OnHeapGraph}} to {{{}availableProcessors + 1{}}}, for example with a 
semaphore, so that additional writers wait instead of failing. Graph insertion 
is CPU-bound, so this should keep the throughput gain from CASSANDRA-21160, 
which came from going from one writer to one per core. Everything else in 
{{VectorMemoryIndex}} and {{OnHeapGraph}} would stay lock-free.
 * Restore {{synchronized}} on {{{}VectorMemoryIndex.add(){}}}. This reverts 
CASSANDRA-21160 and its measured throughput gain.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to