[ 
https://issues.apache.org/jira/browse/CASSANDRA-21019?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18108923#comment-18108923
 ] 

Dmitry Konstantinov commented on CASSANDRA-21019:
-------------------------------------------------

original deadlock mechanism in more details:
h2. 1. The problem the design has to solve, and the escape path

Writes consume memtable memory. Memtable memory is only released when a flush 
completes. A flush can only complete once the writes already in flight finish. 
So "write waits for memory" and "flush waits for writes" are mutually 
recursive, and without care that is a cycle.

Pre-patch, {{MemtableAllocator.SubAllocator.allocate}} resolved it with an 
{*}escape hatch{*}:
{code:java}
public void allocate(long size, OpOrder.Group opGroup)
{
    assert size >= 0;

    while (true)
    {
        if (parent.tryAllocate(size)) { acquired(size); return; }   // (1) room 
available -> take it
        if (opGroup.isBlocking())     { allocated(size); return; }  // (2) 
ESCAPE HATCH -> exceed the limit
        WaitQueue.Signal signal = 
parent.hasRoom().register(parent.blockedTimerContext(),
                                                           
Timer.Context::stop);   // (3) register for wake-up
        opGroup.notifyIfBlocking(signal);                           //     
...and for markBlocking
        boolean allocated = parent.tryAllocate(size);
        if (allocated) { signal.cancel(); acquired(size); return; }
        else
            signal.awaitThrowUncheckedOnInterrupt();                // (4) PARK
    }
}
{code}
Line (2) is the invariant that keeps the system live: {*}if a flush is waiting 
for my operation, I am allowed to blow through the memory limit so that the 
flush can proceed{*}.

A flush announces this by calling {{markBlocking()}} before {{{}await(){}}}:
{code:java}
// ColumnFamilyStore.Flush.run(), lines 1276-1286
public void run()
{
    // mark writes older than the barrier as blocking progress, permitting them 
to exceed our memory limit
    // if they are stuck waiting on it, then wait for them all to complete
    writeBarrier.markBlocking();
    writeBarrier.await();
}
{code}
Both interleavings are covered:
 * Marked *before* the writer parks - caught at (2).
 * Marked *after* the writer parks - {{notifyIfBlocking}} 
(OpOrder.java:324-331) added the signal to the group's list, and 
{{markBlocking()}} walks that list and signals it:

{code:java}
// OpOrder.Group, lines 324-339
public void notifyIfBlocking(WaitQueue.Signal signal)
{
    if (blocking == null)
        blockingUpdater.compareAndSet(this, null, new 
ConcurrentLinkedQueue<>());
    blocking.add(signal);
    if (isBlocking() && blocking.remove(signal))
        signal.signal();
}

private void markBlocking()
{
    isBlocking = true;
    ConcurrentLinkedQueue<WaitQueue.Signal> blocking = this.blocking;
    if (blocking != null)
        blocking.forEach(WaitQueue.Signal::signal);
}
{code}
So the guarantee is: *any thread parked in the allocator, whose op-group the 
flush awaits, will be released.*
h2. 2. Where the guarantee leaks

{{TrieMemtable.MemtableShard.put}} takes a {{ReentrantLock}} and then allocates 
*inside* it:
{code:java}
// TrieMemtable.java:550-593
boolean locked = writeLock.tryLock();
if (locked) { metrics.uncontendedPuts.inc(); }
else
{
    metrics.contendedPuts.inc();
    long lockStartTime = Clock.Global.nanoTime();
    writeLock.lock();                                                   // line 
562 -- W1 queues HERE
    metrics.contentionTime.addNano(Clock.Global.nanoTime() - lockStartTime);
}
try
{
    long onHeap  = data.sizeOnHeap();
    long offHeap = data.sizeOffHeap();
    data.putSingleton(key, update, updater::mergePartitions, ...);       // 
allocates via cloner + indexer callbacks
    allocator.offHeap().adjust(data.sizeOffHeap() - offHeap, opGroup);   // 
line 576 -+ these reach
    allocator.onHeap().adjust(data.sizeOnHeap()  - onHeap,  opGroup);    // 
line 577 -+ allocate()
}
finally { writeLock.unlock(); }
{code}
So a thread {*}can park on the memory pool while holding the shard lock{*}. The 
escape hatch understands the pool's {{{}WaitQueue{}}}; it understands nothing 
about {{{}ReentrantLock{}}}.

{{ShardedSkipListMemtable.Locking}} has the same shape with {{synchronized 
(shard)}} at line 523.
h2. 3. The deadlock, step by step
{noformat}
Keyspace.writeOrder -- ONE static OpOrder for the whole node (Keyspace.java:102)

T0  W1 starts:  g1 = writeOrder.start()                  g1 is the current group
                W1 is a write to TABLE B

T1  TABLE A flushes.   new Flush(false)                  
ColumnFamilyStore.java:1080
       writeBarrier = Keyspace.writeOrder.newBarrier()   line 1247
       writeBarrier.issue()                              line 1271
         -> orderOnOrBefore = g1
         -> new current group = g2
       flushExecutor.execute(flush)                      line 1081

T2  W2 starts:  g2 = writeOrder.start()                  g2 is POST-barrier
                W2 is also a write to TABLE B, same shard

T3  Table B is NOT flushing  ->  M_B.writeBarrier == null
      ->  accepts(g1) = accepts(g2) = true
    Both W1 and W2 are routed to M_B: same MemtableShard, same writeLock.

T4  W2 wins the lock (tryLock succeeds).
    Inside the lock it reaches adjust() -> allocate():
       tryAllocate fails, pool is at its limit          (1) FAIL
       g2.isBlocking() == false                         (2) FAIL   <-- 
post-barrier, never marked
       registers a signal, parks                        (4) PARKED, HOLDING THE 
LOCK

T5  W1 calls put(), tryLock fails, blocks at writeLock.lock()   
TrieMemtable.java:562
    W1 has NOT reached the allocator, so it registered NO signal.

T6  Flush A's run() executes on flushExecutor:
       writeBarrier.markBlocking()
         -> walks orderOnOrBefore(g1) and its prev chain      
OpOrder.java:405-413
         -> marks g1:  isBlocking = true; blocking.forEach(signal)   <-- list 
is EMPTY
         -> does NOT mark g2  (g2 is after the barrier)
       writeBarrier.await()  ->  waits for g1 to close

T7  Nothing can move.
{noformat}
h2. 4. Why every rescue fails - and the symmetry is the point
||Thread||Would the escape hatch help?||Why it does not fire||
|*W2* - holds the lock, parked in the allocator|It is sitting exactly where the 
hatch is checked|Its group {{g2}} is {*}post-barrier{*}, so {{markBlocking()}} 
never marks it. {{isBlocking()}} stays false forever.|
|*W1* - queued on the lock|Its group {{g1}} *is* marked blocking, so the hatch 
would let it straight through|It never reaches the allocator. It is parked on a 
{{{}ReentrantLock{}}}, which {{markBlocking()}} cannot signal. Its signal list 
is empty.|

Neither thread can be rescued, for *opposite* reasons: W1 qualifies for the 
hatch but cannot reach it; W2 can reach it but does not qualify.

W2's wait is indefinite because a meaningful amount of pool memory is only 
returned by {{SubAllocator.releaseAll()}} -> {{SubPool.released()}} -> 
{{{}hasRoom.signalAll(){}}}, and {{releaseAll()}} runs from {{setDiscarded()}} 
<- {{memtable.discard()}} <- {{Flush.reclaim}} - that is, only after some flush 
has got *past* {{{}writeBarrier.await(){}}}.
{noformat}
      Flush A ---awaits---> g1 ---held open by---> W1
         ^                                          |
         |                                     blocked on
         |                                          v
         |                            MemtableShard.writeLock
         |                                          |
         |                                       held by
         |                                          v
         +---- pool room <---only freed by---- W2 (parked in allocate())
                (requires a COMPLETED flush)
{noformat}
h2. 5. Is it permanent?

Not automatically. A *later* flush's {{markBlocking()}} would cover {{g2}} - 
any barrier issued after {{g2}} started has {{orderOnOrBefore >= g2}} - and 
would release W2.

But {{markBlocking()}} lives inside {{{}Flush.run(){}}}, which executes on a 
*static, node-wide* pool:
{code:java}
// ColumnFamilyStore.java:199-201
private static final ExecutorPlus flushExecutor = 
DatabaseDescriptor.isDaemonInitialized()
        ? executorFactory().withJmxInternal().pooled("MemtableFlushWriter", 
getFlushWriters())
        : null;
{code}
sized by {{{}memtable_flush_writers{}}}:
{code:java}
// DatabaseDescriptor.java:929-931
if (conf.memtable_flush_writers == 0)
{
    conf.memtable_flush_writers = conf.data_file_directories.length == 1 ? 2 : 
1;
}
{code}
The stuck flush occupies one of those threads for the entire duration of 
{{{}await(){}}}. So:
 * *Multiple data directories* (default 1 writer): no thread is left to run any 
other {{{}Flush.run(){}}}, so no later {{markBlocking()}} ever executes. The 
deadlock is {*}unrecoverable{*}.
 * *Single data directory* (default 2 writers): a second flush can run and 
rescue W2 - unless the remaining slot is also consumed by a flush that gets 
stuck the same way.

h2. 6. What the patch changes

The wait moves out of {{allocate()}} and up into 
{{{}AbstractAllocatorMemtable.put{}}}, *before* {{performPut}} and therefore 
before {{writeLock}} is ever touched:
{code:java}
// AbstractAllocatorMemtable.java:138-143
@Override
public final long put(PartitionUpdate update, UpdateTransaction indexer, 
OpOrder.Group opGroup, boolean assumeMissing)
{
    allocator.awaitRoomToStart(opGroup);
    return performPut(update, indexer, opGroup, assumeMissing);
}
{code}
Step T4 becomes: W2 parks in {{awaitRoom}} {*}holding nothing{*}. The shard 
lock stays free, W1 acquires it, completes, {{g1}} closes, and 
{{writeBarrier.await()}} returns.

The set of locks held at the blocking point is now a strict subset of what it 
was before, so the change cannot introduce a new lock-order cycle.

Nested writes (legacy 2i, which re-enters {{ColumnFamilyStore.apply}} from an 
indexer callback *inside* the base memtable's lock) must skip the gate for the 
same reason - hence {{{}putNested{}}}.
 

> Memtable allocator: separate memory usage tracking and limit checking
> ---------------------------------------------------------------------
>
>                 Key: CASSANDRA-21019
>                 URL: https://issues.apache.org/jira/browse/CASSANDRA-21019
>             Project: Apache Cassandra
>          Issue Type: Improvement
>          Components: Local/Memtable
>            Reporter: Dmitry Konstantinov
>            Assignee: Michael Semb Wever
>            Priority: High
>             Fix For: 4.0.x, 4.1.x, 5.0.x, 7.x
>
>         Attachments: ci_summary_thelastpickle_mck-21019-5.0_175.html, 
> ci_summary_thelastpickle_mck-21019-6.0_101.html, 
> ci_summary_thelastpickle_mck-21019-trunk_653.html, 
> results_details_thelastpickle_mck-21019-5.0_175.tar.xz, 
> results_details_thelastpickle_mck-21019-6.0_101.tar.xz, 
> results_details_thelastpickle_mck-21019-trunk_653.tar.xz
>
>          Time Spent: 4h
>  Remaining Estimate: 0h
>
> The following optimization idea has been suggested by @blambov in 
> CASSANDRA-20226:
> {quote}
> There's another option to consider here. The allocation mechanism does not 
> need to check the limit for individual cell writes. We could just as well 
> track the usage of a mutation in a single {{allocate}} call after it 
> completes, or track the allocations with a {{LongAdder}} without checking if 
> the limit is hit, and check if we need to wait for room before starting to 
> apply a mutation.
> We use the {{allocate}} code to decide:
>  - whether to initiate a flush, when the chosen memory limit is filled to 
> some ratio
>  - whether to pause accepting writes, when the chosen memory limit has been 
> exhausted
> For the former use there is absolutely no benefit to make these decisions at 
> the individual allocation level, as we will wait for the mutation to complete 
> anyway before flushing anything. For the latter, I'd argue that the 
> allocation-level tracking is actually hurting us. The reason for this is that 
> we can have the limit be hit at any time during the application of a 
> mutation, holding multiple locks (which necessitates the complexity of the 
> {{isBlocking}} mutation signal), a partial copy of the mutation already 
> written to the memtable structures, and a likely expanded version of the 
> mutation to be applied on heap, keeping hold of more total memory than we 
> would if we allowed the operation to continue.
> If, instead, we check the allocation limits _before starting_ a mutation and, 
> once started, allow it to fully progress to completion, we can avoid this 
> situation at the cost of being somewhat late to notice that the limit has 
> been reached. This means that the limit will be breached, but this also 
> happens as it stands now because we will permit operations to run to 
> completion if the memtable they have been marked for is scheduled for a flush 
> – which is effectively the same thing as not having noticed the memory limit 
> would be breached by this mutation at the time when we decided to start it.
> {quote}



--
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