Siyao Meng created HDDS-16744:
---------------------------------

             Summary: A client retry that arrives before the first attempt 
reaches Ratis runs preExecute twice and leaks inFlightSnapshotCount
                 Key: HDDS-16744
                 URL: https://issues.apache.org/jira/browse/HDDS-16744
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-4-wait-for-earlier-attempt-before-retry-cache-check.patch

h3. Mechanism
The retry cache check, {{preExecute}} and the Ratis submit are three separate 
steps with nothing tying them together.

{{OzoneManagerProtocolServerSideTranslatorPB.java:204}} calls 
{{checkRetryCache()}}, which is a read only {{getIfPresent}} 
({{OzoneManagerRatisServer.java:548}}) and creates nothing. The record it looks 
for is created by Ratis only when it accepts the request, inside 
{{submitClientRequestAsync}} ({{OzoneManagerRatisServer.java:319}}). 
{{OMExecutionFlow}} reaches that at {{:80}}, after it has already run 
{{preExecute}} at {{:67}}.

A second attempt of the same call, carrying the same {{(ClientId, callId)}}, 
that arrives while the first is still in {{preExecute}} therefore misses the 
retry cache, runs {{preExecute}} again, and is only then absorbed by Ratis as a 
duplicate. At most once apply still holds, but whatever {{preExecute}} did 
outside the Raft log has now been done twice.

For snapshot create that side effect is a counter. 
{{OMSnapshotCreateRequest.java:125}} calls {{snapshotLimitCheck()}}, which 
increments {{inFlightSnapshotCount}} ({{OmSnapshotManager.java:991}}). The only 
decrement is in {{validateAndUpdateCache}} 
({{OMSnapshotCreateRequest.java:202}}). The absorbed duplicate never applies 
and its reply reports success, so {{handleRequestFailure}} is not reached 
either, and the second increment is never taken back.

h3. Trigger
An ordinary client retry after an RPC timeout or a connection loss, while the 
first attempt is still being served. Hadoop RPC reuses the call ID across 
retries, which is what makes the retry cache work at all. The window is the 
whole of the first attempt's {{preExecute}}: RocksDB reads, bucket link 
resolution and ACL checks for snapshot create, and a synchronous SCM 
{{allocateBlock}} RPC for key create and allocate block.

h3. Impact
{{inFlightSnapshotCount}} leaks by one per occurrence, and 
{{snapshotLimitCheck()}} counts it against {{ozone.om.fs.snapshot.max.limit}}, 
so {{createSnapshot}} starts failing with {{TOO_MANY_SNAPSHOTS}} below the 
configured limit. Reproduced with the limit set to 8: the eighth create was 
refused with "Current snapshots: 7, In-flight creations: 1" and nothing in 
flight.

The leak stays until a leadership change, since {{notifyLeaderReady}} 
({{OzoneManagerStateMachine.java:199}}) is the only thing that resets the 
counter.

Only the snapshot counter consequence was demonstrated. The same ordering 
applies to every other {{preExecute}} side effect, such as a second SCM block 
allocation, but those were not exercised.

h3. Reproduction
PASS. Two attempts of one {{CreateSnapshot}} call with the same client ID and 
call ID, released together from a latch against a mini cluster, with no product 
code modified. The duplicate landed in the window on the first round in both 
runs. A sequential retry of the same call is absorbed by the retry cache as 
designed, which is the control. Artifacts under {{confirmation/MC-4/}} and 
{{repro/}}, driven from the counterexample {{spec/output/hunt_s1_bfs2.out}} 
against invariant {{NoOrphanedSideEffect}}.

h3. Suggested fix
Close the window instead of compensating for each side effect: make a later 
attempt of a call wait for an earlier attempt that is past the retry cache 
check, then check the retry cache itself. By then the earlier attempt's record 
exists, so the later one is answered from the cache and never reaches 
{{preExecute}}.

h3. Patch
[^MC-4-wait-for-earlier-attempt-before-retry-cache-check.patch], against 
20e6a1c0f6039248373efd3eb1d7f2afcf7f1535.

{{OzoneManagerRatisServer}} gains {{startAttempt()}}, which registers the 
current call's {{ClientInvocationId}} in a map and first waits for any attempt 
already registered under it. {{OzoneManagerProtocolServerSideTranslatorPB}} 
holds that registration across the existing retry cache check and the submit. 
An attempt with no concurrent duplicate pays one map insert and one remove. 
Calls on the gRPC transport carry a fresh client ID per request, so they never 
wait.

Covered by {{testSnapshotInFlightCountWithConcurrentRetry}} in the existing 
{{TestOzoneManagerHASnapshot}}: ten rounds of two concurrent attempts of one 
{{CreateSnapshot}} call, asserting both succeed with the same snapshot ID and 
that the in flight count returns to 0. Without the change it fails with the 
count at 1. The OM Ratis server unit suites and the existing 
{{TestOzoneManagerHASnapshot}}, {{TestOzoneManagerHAWithAllRunning}} and 
{{TestOzoneManagerHAWithStoppedNodes}} pass. Checkstyle clean.

Found by TLA+ model checking and code review of the OM client retry cache and 
at most once mutation paths under HDDS-15926, on commit 
20e6a1c0f6039248373efd3eb1d7f2afcf7f1535. Checked against open HDDS issues for 
duplicates before filing, in addition to the repository's merged git history. 
Generated with Specula (Claude Opus 5).



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