Skip to content

Fix peer recovery activity tracking - #20178

Open
maxlepikhin wants to merge 4 commits into
opensearch-project:mainfrom
maxlepikhin:bugfix/peer-recovery-timeout
Open

Fix peer recovery activity tracking#20178
maxlepikhin wants to merge 4 commits into
opensearch-project:mainfrom
maxlepikhin:bugfix/peer-recovery-timeout

Conversation

@maxlepikhin

@maxlepikhin maxlepikhin commented Dec 6, 2025

Copy link
Copy Markdown

Description

Prevent peer/segment recovery timeouts from being reset by mere status polling. Previously, every call to ReplicationCollection#get() bumped the recovery’s lastAccessTime, so a hung recovery could sit in INITIALIZING indefinitely. This change removes that implicit update and explicitly touches the timestamp only when meaningful work happens (start, file transfer, clean‑files, translog, finalize, etc.), ensuring indices.recovery.recovery_activity_timeout actually fails stuck recoveries.

Ensure recovery/replication touch lastAccessTime only when handling real work: centralize the update in ReplicationTarget.createOrFinishListener (not on polling) and have explicit touches for the few non-listener entry points.

Also fix routing diff test to supply a fresh blob stream per read to avoid checksum/footer corruption.”

Related Issues

Resolves #20177

Check List

  • Functionality includes testing. (./gradlew :server:test --tests org.opensearch.recovery.ReplicationCollectionTests)
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

Summary by CodeRabbit

  • Bug Fixes

    • Recovery and replication flows now refresh targets' last-access timestamps when actively used; passive collection lookups no longer update last-access to prevent misleading activity or timeout resets.
    • Fixes hung peer recovery that could permanently block replica allocation.
  • Tests

    • Added tests verifying last-access update behavior and that polling doesn't reset recovery/replication timeouts.
    • Updated routing-table tests to create serialized inputs lazily during reads.

✏️ Tip: You can customize this high-level summary in your review settings.

@maxlepikhin
maxlepikhin requested a review from a team as a code owner December 6, 2025 22:43
@github-actions github-actions Bot added _No response_ bug Something isn't working labels Dec 6, 2025
@coderabbitai

coderabbitai Bot commented Dec 6, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Added explicit setLastAccessTime() calls at multiple recovery and replication entry points, removed an implicit touch from ReplicationRef constructor, adjusted tests (last-access polling behavior and lazy blob stream creation), and updated the changelog.

Changes

Cohort / File(s) Summary
Peer recovery access-time updates
server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java
Inserted recoveryTarget.setLastAccessTime() in doRecovery and in request handlers (HandoffPrimaryContextRequestHandler, TranslogOperationsRequestHandler) so targets are touched when recovery RPCs are processed.
Replication target touches
server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java, server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java
Call setLastAccessTime() after obtaining replication targets, in handleFileChunk, and when createOrFinishListener yields a listener to update last-access timestamps on replication interactions.
Removed implicit ref touch
server/src/main/java/org/opensearch/indices/replication/common/ReplicationCollection.java
Removed status.setLastAccessTime() from ReplicationCollection.ReplicationRef constructor so last-access is updated only at explicit entry points.
Tests: last-access behavior and timeout polling
server/src/test/java/org/opensearch/recovery/ReplicationCollectionTests.java
Added testRecoveryTimeoutNotResetByPolling() and updated testLastAccessTimeUpdate() to assert that plain get() does not update lastAccessTime and that explicit setLastAccessTime() does.
Tests: lazy blob stream creation
server/src/test/java/org/opensearch/cluster/routing/remote/RemoteRoutingTableServiceTests.java
Replaced static blobContainer.readBlob(...) stubs with lazy thenAnswer(...) that produces the input stream at invocation time in two tests.
Changelog
CHANGELOG.md
Added entry: Fixed hung peer recovery permanently blocks replica allocation ([#20177]).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Attention points:
    • Concurrency/ownership effects of added setLastAccessTime() calls.
    • Behavioral impact from removing the implicit touch in ReplicationRef.
    • Timing-sensitive tests in ReplicationCollectionTests.java for flakiness.
    • Ensure lazy thenAnswer(...) test changes preserve semantics.

Suggested reviewers

  • mch2
  • dbwiddis
  • msfroh

Poem

🐰 I hop to every recovery gate,
I tap the clock—record the state.
When chunks arrive or listeners call,
I mark the time to guard them all.
A tiny hop, a timestamp small.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix peer recovery activity tracking' clearly and concisely describes the main change of the PR, which is to fix how peer recovery activity is tracked and prevent hung recoveries from being reset by polling.
Description check ✅ Passed The PR description fully covers the change objectives, links to the related issue #20177, explains the fix, and includes test confirmation, though API/documentation changes are marked as not applicable.
Linked Issues check ✅ Passed The PR successfully addresses the primary objective from #20177 by preventing recovery timeouts from being reset by polling and explicitly updating lastAccessTime only during meaningful work, enabling indices.recovery.recovery_activity_timeout to fail stuck recoveries.
Out of Scope Changes check ✅ Passed The PR includes changes to RemoteRoutingTableServiceTests for blob stream handling and CHANGELOG updates, which are narrowly scoped to support the main recovery timeout fix without introducing unrelated functionality.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 87f8373 and 1d46b8b.

📒 Files selected for processing (6)
  • CHANGELOG.md (1 hunks)
  • server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java (3 hunks)
  • server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java (1 hunks)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationCollection.java (0 hunks)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java (2 hunks)
  • server/src/test/java/org/opensearch/cluster/routing/remote/RemoteRoutingTableServiceTests.java (2 hunks)
💤 Files with no reviewable changes (1)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationCollection.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (20)
  • GitHub Check: gradle-check
  • GitHub Check: detect-breaking-change
  • GitHub Check: precommit (25, macos-15-intel)
  • GitHub Check: precommit (25, macos-15)
  • GitHub Check: precommit (25, ubuntu-24.04-arm)
  • GitHub Check: precommit (21, windows-2025, true)
  • GitHub Check: precommit (25, ubuntu-latest)
  • GitHub Check: precommit (21, ubuntu-24.04-arm)
  • GitHub Check: precommit (25, windows-latest)
  • GitHub Check: precommit (21, macos-15-intel)
  • GitHub Check: precommit (21, windows-latest)
  • GitHub Check: precommit (21, ubuntu-latest)
  • GitHub Check: precommit (21, macos-15)
  • GitHub Check: Analyze (java)
  • GitHub Check: assemble (21, ubuntu-latest)
  • GitHub Check: assemble (25, ubuntu-24.04-arm)
  • GitHub Check: assemble (25, windows-latest)
  • GitHub Check: assemble (25, ubuntu-latest)
  • GitHub Check: assemble (21, ubuntu-24.04-arm)
  • GitHub Check: assemble (21, windows-latest)
🔇 Additional comments (9)
CHANGELOG.md (1)

18-18: LGTM! Changelog entry correctly documents the fix.

The entry appropriately documents the resolution of issue #20177 regarding hung peer recovery blocking replica allocation.

server/src/test/java/org/opensearch/cluster/routing/remote/RemoteRoutingTableServiceTests.java (2)

576-582: LGTM! Test fix properly creates fresh streams.

The change from thenReturn to thenAnswer ensures a fresh InputStream is created on each readBlob invocation, preventing checksum/footer corruption from stream reuse.


614-616: LGTM! Consistent test fix for stream creation.

This change applies the same fix as in testGetAsyncIndexRoutingReadAction, ensuring fresh streams are created on each invocation.

server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java (1)

332-332: LGTM! Correct placement of lastAccessTime update.

The setLastAccessTime() call is appropriately placed right before replication starts, ensuring the activity timeout tracks meaningful work rather than status polling.

server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java (3)

240-240: LGTM! Explicit timestamp update needed in doRecovery.

This explicit setLastAccessTime() is necessary since doRecovery does not call createOrFinishListener. The recovery initiation qualifies as meaningful work that should reset the activity timeout.


433-435: LGTM! Explicit timestamp update needed for primary context handoff.

This explicit setLastAccessTime() is necessary since HandoffPrimaryContextRequestHandler does not use createOrFinishListener. The primary context handoff represents meaningful recovery work.


469-469: LGTM! Timestamp update needed for retry scenarios.

While createOrFinishListener (line 449) already updates lastAccessTime, this explicit call at line 469 is necessary for the retry logic. When performTranslogOps is invoked recursively due to mapping exceptions (lines 481-490), a fresh recoveryRef is obtained and the timestamp must be updated again to track the continued activity.

server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java (2)

227-229: LGTM! Conditional timestamp update prevents timeout extension on no-ops.

The conditional update ensures lastAccessTime is only modified when a non-null listener indicates the request will actually be processed, not for duplicate replays or no-ops. This aligns with the PR objective to track meaningful work.


249-252: LGTM! Timestamp update correctly placed after null check.

The setLastAccessTime() call is now properly placed after the null check, ensuring the timestamp is updated only when the file chunk will actually be processed. This addresses the inconsistency flagged in previous reviews and aligns with the pattern established in createOrFinishListener.


Comment @coderabbitai help to get the list of available commands and usage tips.

@maxlepikhin
maxlepikhin force-pushed the bugfix/peer-recovery-timeout branch from afeebd6 to 4263de5 Compare December 6, 2025 22:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java (1)

239-247: Explicit activity bump on each received file chunk

Updating replicationTarget.setLastAccessTime() at the top of handleFileChunk makes chunk traffic reliably count as recovery activity, independent of status polling. Note that FileChunkTransportRequestHandler also calls setLastAccessTime() before invoking this method, so you now have two updates per chunk; that’s harmless but could be consolidated in one place later if you want to avoid duplication.

server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java (1)

229-241: Good separation between “real recovery work” and status polling

The new recoveryTarget.setLastAccessTime() calls in doRecovery and all the recovery request handlers ensure that only meaningful events (start/restart, files info, file chunks, clean files, prepare/finalize, handoff primary context, and translog ops, including mapping‑retry runs) advance the activity timestamp. This matches the goal of letting idle/hung recoveries time out even while status is being polled via ReplicationCollection.get().

There is a bit of benign duplication (e.g., for translog ops and file chunks you now bump lastAccessTime in both the transport handler and the downstream helper), so if you later want to tighten things up you could centralize the update in a single layer per operation type.

Also applies to: 399-408, 414-425, 431-438, 447-457, 471-473, 535-552, 559-573, 585-595

server/src/test/java/org/opensearch/recovery/ReplicationCollectionTests.java (1)

96-127: Solid regression test; consider tightening latch assertion

testRecoveryTimeoutNotResetByPolling does a good job simulating frequent status polling and asserting that the recovery still times out (via the custom listener and failed flag), which directly protects against the original bug.

For extra clarity in failure cases, you might also assert that latch.await(30, TimeUnit.SECONDS) itself returns true before checking failed.get(), so a stuck test reports “recovery did not complete” rather than only “failed to timeout.”

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0449ce8 and afeebd6.

📒 Files selected for processing (5)
  • server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java (9 hunks)
  • server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java (1 hunks)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationCollection.java (0 hunks)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java (1 hunks)
  • server/src/test/java/org/opensearch/recovery/ReplicationCollectionTests.java (2 hunks)
💤 Files with no reviewable changes (1)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationCollection.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: gradle-check
🔇 Additional comments (2)
server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java (1)

320-333: Aligns segment replication with explicit activity tracking

Calling target.setLastAccessTime() immediately after obtaining the ReplicationRef cleanly reintroduces the “replication just (re)started” activity bump that was previously hidden in ReplicationRef’s constructor. This keeps timeout behavior correct after the implicit update removal.

server/src/test/java/org/opensearch/recovery/ReplicationCollectionTests.java (1)

72-88: Tests now correctly enforce explicit lastAccessTime updates

The extra assertBusy before calling setLastAccessTime() is a nice regression guard: it proves collection.get(recoveryId) alone no longer mutates lastAccessTime, and that only an explicit setLastAccessTime() changes what subsequent observers see. This matches the new ReplicationRef behavior.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java (1)

399-402: Preparing for translog phase now properly refreshes activity timestamp

Updating lastAccessTime right after resolving the RecoveryTarget for PREPARE_TRANSLOG ties the timeout to an actual protocol step rather than to generic collection access, which matches the stated goal.

You repeat the getSafe(...) → recoveryRef.get() → setLastAccessTime() pattern in several handlers; consider a small helper to centralize this so future handlers can’t forget the activity update.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between afeebd6 and 4263de5.

📒 Files selected for processing (5)
  • server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java (9 hunks)
  • server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java (1 hunks)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationCollection.java (0 hunks)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java (1 hunks)
  • server/src/test/java/org/opensearch/recovery/ReplicationCollectionTests.java (2 hunks)
💤 Files with no reviewable changes (1)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationCollection.java
✅ Files skipped from review due to trivial changes (1)
  • server/src/test/java/org/opensearch/recovery/ReplicationCollectionTests.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java
  • server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: gradle-check
🔇 Additional comments (8)
server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java (8)

239-241: Explicit activity bump at start/re-establish of recovery looks correct

Calling recoveryTarget.setLastAccessTime() immediately after acquiring the RecoveryTarget ensures long-running local prep (e.g., pre-recovery, remote segment sync) is treated as activity and prevents premature timeout, while still decoupling from bare status polling.


416-419: Finalize phase correctly counted as recovery activity

Bumping lastAccessTime before finalizeRecovery(...) ensures that a recovery doing only finalize/refresh work is not timed out as idle, which is consistent with “meaningful work” semantics.


435-438: Primary-context handoff now tracked as active recovery work

Setting lastAccessTime before handoffPrimaryContext(...) brings this path in line with the other phases; the handoff is a critical step and should indeed reset the inactivity timer.


449-452: Translog-ops request correctly refreshes activity timestamp

Updating lastAccessTime upon receipt of a TRANSLOG_OPS request ensures that inbound translog batches keep the recovery alive and avoids timeouts being reset by unrelated collection lookups.


471-473: Extra activity bump in performTranslogOps is justified for retry path

Calling setLastAccessTime() again inside performTranslogOps covers internal retries triggered after mapping updates (which re-enter via performTranslogOps without a new transport message), so those retries won’t be misclassified as idle; the double bump for the initial call is harmless.


536-539: Files-info phase now participates in recovery timeout semantics

Marking activity when handling FILES_INFO aligns this early phase with the rest of the recovery pipeline and prevents a recovery from timing out while the source is legitimately sending file metadata.


560-563: Clean-files step correctly refreshes last access time

Treating CLEAN_FILES as activity makes sense, since it can be non-trivial work and may be the only thing happening for a period; this fits well with the intent of recovery_activity_timeout.


585-588: File-chunk handling updates lastAccessTime at the right place

Updating lastAccessTime right before handleFileChunk(...) ties the timer to actual segment-bytes transfer, which is central to the “no bytes transferred within the interval” heuristic and complements the additional updates in the replication layer.

@github-actions

github-actions Bot commented Dec 6, 2025

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 4263de5: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@andrross

Copy link
Copy Markdown
Member

@mch2 Can you take a look at this?

@andrross andrross added Indexing:Replication Issues and PRs related to core replication framework eg segrep and removed _No response_ labels Dec 15, 2025
@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 4263de5: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for dfe2559: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@maxlepikhin
maxlepikhin force-pushed the bugfix/peer-recovery-timeout branch from dfe2559 to 71bd51f Compare December 16, 2025 00:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dfe2559 and 71bd51f.

📒 Files selected for processing (2)
  • server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java (2 hunks)
  • server/src/test/java/org/opensearch/cluster/routing/remote/RemoteRoutingTableServiceTests.java (2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: gradle-check
🔇 Additional comments (3)
server/src/test/java/org/opensearch/cluster/routing/remote/RemoteRoutingTableServiceTests.java (2)

576-580: LGTM: Lazy stream creation prevents reuse corruption.

The change from thenReturn to thenAnswer ensures a fresh InputStream is created on each invocation, preventing checksum or footer corruption when the mock is called multiple times.


612-614: LGTM: Consistent lazy stream creation.

Same pattern as the previous test method—ensures fresh stream creation per invocation.

server/src/main/java/org/opensearch/indices/replication/common/ReplicationTarget.java (1)

227-230: LGTM: Correctly prevents duplicate requests from resetting timeout.

The conditional update ensures lastAccessTime is only touched when a listener is created (i.e., not a duplicate request), aligning with the PR objective to prevent status polling from resetting recovery timeouts.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 71bd51f: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@maxlepikhin
maxlepikhin force-pushed the bugfix/peer-recovery-timeout branch from 71bd51f to c57ea79 Compare December 16, 2025 02:05
@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for c57ea79: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@maxlepikhin
maxlepikhin requested a review from andrross December 16, 2025 02:48
@andrross

Copy link
Copy Markdown
Member

@maxlepikhin You've got a code formatting error:

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':server:spotlessJavaCheck'.
> The following files had format violations:
      src/test/java/org/opensearch/cluster/routing/remote/RemoteRoutingTableServiceTests.java
          @@ -573,11 +573,13 @@
           ············compressor,
           ············Version.CURRENT
           ········);
          -········when(blobContainer.readBlob(indexName)).thenAnswer(invocation·->·remoteIndexRoutingTable.indexRoutingTableFormat.serialize(
          -············clusterState.getRoutingTable().getIndicesRouting().get(indexName),
          -············uploadedFileName,
          -············compressor
          -········).streamInput());
          +········when(blobContainer.readBlob(indexName)).thenAnswer(
          +············invocation·->·remoteIndexRoutingTable.indexRoutingTableFormat.serialize(
          +················clusterState.getRoutingTable().getIndicesRouting().get(indexName),
          +················uploadedFileName,
          +················compressor
          +············).streamInput()
          +········);
           ········TestCapturingListener<IndexRoutingTable>·listener·=·new·TestCapturingListener<>();
           ········CountDownLatch·latch·=·new·CountDownLatch(1);
           
  Run './gradlew spotlessApply' to fix all violations.

@maxlepikhin
maxlepikhin force-pushed the bugfix/peer-recovery-timeout branch from c57ea79 to 8201444 Compare December 16, 2025 23:40
@maxlepikhin

Copy link
Copy Markdown
Author

@maxlepikhin You've got a code formatting error:

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':server:spotlessJavaCheck'.
> The following files had format violations:
      src/test/java/org/opensearch/cluster/routing/remote/RemoteRoutingTableServiceTests.java
          @@ -573,11 +573,13 @@
           ············compressor,
           ············Version.CURRENT
           ········);
          -········when(blobContainer.readBlob(indexName)).thenAnswer(invocation·->·remoteIndexRoutingTable.indexRoutingTableFormat.serialize(
          -············clusterState.getRoutingTable().getIndicesRouting().get(indexName),
          -············uploadedFileName,
          -············compressor
          -········).streamInput());
          +········when(blobContainer.readBlob(indexName)).thenAnswer(
          +············invocation·->·remoteIndexRoutingTable.indexRoutingTableFormat.serialize(
          +················clusterState.getRoutingTable().getIndicesRouting().get(indexName),
          +················uploadedFileName,
          +················compressor
          +············).streamInput()
          +········);
           ········TestCapturingListener<IndexRoutingTable>·listener·=·new·TestCapturingListener<>();
           ········CountDownLatch·latch·=·new·CountDownLatch(1);
           
  Run './gradlew spotlessApply' to fix all violations.

Thanks, updated.

@mch2 mch2 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your logic makes sense, though I'm curious what is polling/resetting the last access time here. The internal monitor is calling get on ConcurrentHashMap here - so that shouldn't be resetting the timestamp.

api calls to cat recovery also wouldn't reset this time as its fetching and reading recoverystate.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for b2fc04c: SUCCESS

@codecov

codecov Bot commented Dec 17, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 73.30%. Comparing base (d47931e) to head (87f8373).
⚠️ Report is 36 commits behind head on main.

Files with missing lines Patch % Lines
.../indices/replication/common/ReplicationTarget.java 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##               main   #20178   +/-   ##
=========================================
  Coverage     73.30%   73.30%           
- Complexity    71732    71770   +38     
=========================================
  Files          5793     5793           
  Lines        328056   328117   +61     
  Branches      47245    47257   +12     
=========================================
+ Hits         240476   240532   +56     
- Misses        68264    68316   +52     
+ Partials      19316    19269   -47     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@maxlepikhin
maxlepikhin force-pushed the bugfix/peer-recovery-timeout branch from b2fc04c to 87f8373 Compare December 17, 2025 20:44
@maxlepikhin
maxlepikhin requested a review from mch2 December 17, 2025 20:49
@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 87f8373: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 87f8373: SUCCESS

maxlepikhin and others added 4 commits December 18, 2025 11:54
Signed-off-by: Max Lepikhin <mlepikhin@gmail.com>
Signed-off-by: Max Lepikhin <mlepikhin@gmail.com>
Signed-off-by: Max Lepikhin <mlepikhin@gmail.com>
Signed-off-by: Andrew Ross <andrross@amazon.com>
@andrross
andrross force-pushed the bugfix/peer-recovery-timeout branch from 87f8373 to 1d46b8b Compare December 18, 2025 19:56
@andrross

Copy link
Copy Markdown
Member

I rebased and added a CHANGELOG entry

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 1d46b8b: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@maxlepikhin

Copy link
Copy Markdown
Author

I rebased and added a CHANGELOG entry

@andrross looks like the CI failed, ownership = DCO or something else?

@andrross

Copy link
Copy Markdown
Member

@maxlepikhin DCO is fine, looks like some sort of connection failure on test setup. I'll retry the tests.

Did you answer @mch2's question above?

Your logic makes sense, though I'm curious what is polling/resetting the last access time here. The internal monitor is calling get on ConcurrentHashMap here - so that shouldn't be resetting the timestamp.

api calls to cat recovery also wouldn't reset this time as its fetching and reading recoverystate.

Did you confirm that this fixes the problem you discovered?

@maxlepikhin

maxlepikhin commented Dec 18, 2025

Copy link
Copy Markdown
Author

Your logic makes sense, though I'm curious what is polling/resetting the last access time here. The internal monitor is calling get on ConcurrentHashMap here - so that shouldn't be resetting the timestamp.

api calls to cat recovery also wouldn't reset this time as its fetching and reading recoverystate.

@mch2
Here is Codex' reply (I asked it to find concrete examples of the call sites). It makes sense to me.

• You’re right that the monitor and cat recovery don’t mutate it. The resets were coming from the ReplicationRef constructor calling setLastAccessTime(), so every lookup bumped the clock—
  even if no work followed. Examples:

  - PeerRecoveryTargetService handlers (e.g., file chunk at server/src/main/java/org/opensearch/indices/recovery/PeerRecoveryTargetService.java:578-582, translog ops at :447-459) did
    getSafe(...) before checking for duplicates; that lookup alone refreshed the timestamp.
  - Segment replication runner (server/src/main/java/org/opensearch/indices/replication/SegmentReplicator.java:322-333) bumped it just by fetching the ref.

  We removed that implicit bump and now update lastAccessTime only when we actually handle the request (createOrFinishListener when it returns non-null, plus the few non-transport paths like
  doRecovery). Polling via get/getSafe no longer resets the timer, which fixes the observed timeout issue.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 1d46b8b: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@maxlepikhin

Copy link
Copy Markdown
Author

@maxlepikhin DCO is fine, looks like some sort of connection failure on test setup. I'll retry the tests.

Did you answer @mch2's question above?

Your logic makes sense, though I'm curious what is polling/resetting the last access time here. The internal monitor is calling get on ConcurrentHashMap here - so that shouldn't be resetting the timestamp.
api calls to cat recovery also wouldn't reset this time as its fetching and reading recoverystate.

Did you confirm that this fixes the problem you discovered?

  1. Replied to @mch2 , missed that comment, apologies.
  2. Re repro, spent some time trying to get a cluster in minikube into that condition with network policy or iptables unsuccessfully. So no repro to test the fix.

@andrross

andrross commented Jan 5, 2026

Copy link
Copy Markdown
Member

@mch2 Can you take another look here?

@mch2

mch2 commented Jan 6, 2026

Copy link
Copy Markdown
Member

I'm hesitant with this change if we don't have a concrete test showing this fixes stuck replications, not simply that the time has/hasn't incremented. The change to createOrFinishListener would theoretically prevent the last access time from being bumped on requests the target has already received. However, the source would eventually hit a retry limit if that were the case. Will see if i can try a quick test to repro.

A few other questions:

  1. are you using segrep in your cluster?
  2. have you changed any of the retry settings? ie indices.recovery.retry_delay_network indices.recovery.internal_action_timeout?
  3. Any other exceptions in your logs?

@andrross

andrross commented Jan 6, 2026

Copy link
Copy Markdown
Member

I'm hesitant with this change if we don't have a concrete test showing this fixes stuck replications

I tend to agree. This change is a pretty low-level change in behavior where there's a risk of an unintended regression. I would prefer to not to make a change like this until we have a clear reproduction of the problem and verification of this fix.

@maxlepikhin

Copy link
Copy Markdown
Author

I'm hesitant with this change if we don't have a concrete test showing this fixes stuck replications, not simply that the time has/hasn't incremented. The change to createOrFinishListener would theoretically prevent the last access time from being bumped on requests the target has already received. However, the source would eventually hit a retry limit if that were the case. Will see if i can try a quick test to repro.

A few other questions:

  1. are you using segrep in your cluster?
    [ML] No.
  2. have you changed any of the retry settings? ie indices.recovery.retry_delay_network indices.recovery.internal_action_timeout?
    [ML] No special retry settings.
  3. Any other exceptions in your logs?
    [ML] There was a failure to join, not any other that I can find from internal slack or other sources.

Logically the change makes sense to me - only update the timestamp when there is recovery work progress, if no progress fail the recovery so it can be retried. The instance of stuck recovery in December of 2025 was not the only one.

@maxlepikhin

Copy link
Copy Markdown
Author

@mch2 @andrross see the issue, I've added new evidence, let me know if you have any suggestions for what diagnostics to run, I will keep the cluster running for today 02/10/26 only. Now, there are logs from the 3 replicas which can be shared (perhaps in more private way).

@maxlepikhin

Copy link
Copy Markdown
Author

@mch2 @andrross see the issue, I've added new evidence, let me know if you have any suggestions for what diagnostics to run, I will keep the cluster running for today 02/10/26 only. Now, there are logs from the 3 replicas which can be shared (perhaps in more private way).

@mch2 @andrross ping

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working Indexing:Replication Issues and PRs related to core replication framework eg segrep

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Hung peer recovery permanently blocks replica allocation.

3 participants