fix(reindex): stop falsely abandoning healthy reindex jobs that hold a live lock - #29547
Conversation
…a live lock A distributed search reindex could be marked FAILED with "Job abandoned due to server crash or shutdown" while it was actually still running. Orphan detection treated a job whose progress heartbeat (updatedAt) had gone stale as abandoned even when its coordinator was alive and actively refreshing the reindex lock — most visible on large catalogs whose reindex runs for a long time. Treat a still-unexpired reindex lock as proof the coordinator is alive, so the job is not considered orphaned regardless of heartbeat lag or total age. This is safe by construction: the lock TTL (5 min) is shorter than the staleness threshold (10 min), so a crashed coordinator's lock always expires before updatedAt can reach the threshold — a still-valid lock can only mean a live coordinator whose updatedAt write lagged. A genuine crash (expired lock) is still correctly detected and failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
❌ PR checklist incompleteThis PR cannot be merged until the following are addressed on its linked issue:
The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically. Maintainers can bypass this check by adding the |
|
🟡 Playwright Results — all passed (46 flaky)✅ 4430 passed · ❌ 0 failed · 🟡 46 flaky · ⏭️ 38 skipped
🟡 46 flaky test(s) (passed on retry)
How to debug locally# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip # view trace |
|
@mohityadav766 should this go into 1.13 or 1.12 branch? |
…a live lock (#29547) A distributed search reindex could be marked FAILED with "Job abandoned due to server crash or shutdown" while it was actually still running. Orphan detection treated a job whose progress heartbeat (updatedAt) had gone stale as abandoned even when its coordinator was alive and actively refreshing the reindex lock — most visible on large catalogs whose reindex runs for a long time. Treat a still-unexpired reindex lock as proof the coordinator is alive, so the job is not considered orphaned regardless of heartbeat lag or total age. This is safe by construction: the lock TTL (5 min) is shorter than the staleness threshold (10 min), so a crashed coordinator's lock always expires before updatedAt can reach the threshold — a still-valid lock can only mean a live coordinator whose updatedAt write lagged. A genuine crash (expired lock) is still correctly detected and failed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 187ac0f)
Code Review 👍 Approved with suggestions 0 resolved / 1 findingsPrevents healthy reindex jobs from being falsely abandoned by verifying live coordinator locks before failing stale jobs. Ensure the relationship between ABANDONED_LOCK_THRESHOLD_MS and LOCK_TIMEOUT_MS remains explicitly documented to maintain this invariant. 💡 Quality: Orphan-detection correctness depends on cross-class constant coupling📄 openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/distributed/JobRecoveryManager.java:45 📄 openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/distributed/JobRecoveryManager.java:276-282
Suggested: add a guard that ties the two together so a violation fails fast — e.g. a static assertion/test that Fail fast if the invariant the orphan-detection logic relies on is ever broken.🤖 Prompt for agentsOptionsDisplay: compact → Showing less information. Comment with these commands to change:
Was this helpful? React with 👍 / 👎 | Gitar |
…a live lock (#29547), cherry pick



Problem
A distributed search reindex could be marked FAILED with
Job abandoned due to server crash or shutdownwhile it was actually still running and healthy. On large catalogs (long-running reindexes) this aborted the reindex partway and forced a re-run; the app run showed a job-level failure with an empty failed-entities list.Fixes #29546
Root cause
Orphaned-job detection (
JobRecoveryManager.isJobOrphaned) marks a job abandoned when its progress heartbeat (job.updatedAt) is stale (≥ 10 min). When the heartbeat was stale but a valid reindex lock still existed, the old code also treated the job as orphaned ("coordinator may have crashed while holding the lock"), and the periodicOrphanJobMonitorthen failed it with the message above.That branch is wrong. The reindex lock TTL (
LOCK_TIMEOUT_MS= 5 min) is shorter than the staleness threshold (ABANDONED_LOCK_THRESHOLD_MS= 10 min), so a crashed coordinator's lock always expires beforeupdatedAtcan reach the 10-min threshold. "Valid lock + ≥10-min-stale heartbeat" can therefore only occur when the coordinator is alive and refreshing the lock (every 60 s) while itsupdatedAtwrite lagged — e.g. the throttledtouchJobwrite failing, or a single large partition running a long time between completions.Fix
Treat a still-unexpired reindex lock as proof the coordinator is alive: when
updatedAtis stale but a valid lock for the job exists, the job is not orphaned — regardless of heartbeat lag or total job age. A genuine crash/restart expires the lock, so those jobs are still correctly detected and failed.Extracted a
hasLiveCoordinatorLock()helper and replaced the repeated lock-key literal with a constant.Tests
JobRecoveryOrphanDetectionTest: staleupdatedAt+ live (unexpired) lock → not orphaned (previously asserted the opposite).JobRecoveryManagerTest: repaired the stale→orphaned case to use a realistic expired lock; added a regression test for a ~2 h reindex with a stale heartbeat but a live lock → not failed.All green:
JobRecoveryManagerTest+JobRecoveryOrphanDetectionTest31/31.mvn spotless:applyrun.🤖 Generated with Claude Code
Greptile Summary
Inverts the orphan-detection branch for "stale
updatedAt+ valid lock": a still-unexpired reindex lock is now treated as proof the coordinator is alive rather than evidence it crashed. The fix exploits the invariant that the lock TTL (5 min) is shorter than the staleness threshold (10 min), so a genuinely crashed coordinator's lock always expires beforeupdatedAtcan become this stale.JobRecoveryManager.isJobOrphaned: replaced the old "valid lock + stale heartbeat → orphaned" branch with ahasLiveCoordinatorLock()helper that returnsfalse(not orphaned) when the lock is unexpired, and extracted the magic"SEARCH_REINDEX_LOCK"string to a constantREINDEX_LOCK_KEY.JobRecoveryOrphanDetectionTest: renamed and inverted the assertion of the stale-heartbeat/live-lock test case to match the fixed behaviour.JobRecoveryManagerTest: updated the stale-orphaned test to use a realistically expired lock, and added a regression test covering a 2-hour reindex with a 15-minute-stale heartbeat but a lock refreshed 30 seconds ago.Confidence Score: 5/5
Safe to merge — the change is tightly scoped to a single private method, the invariant it relies on (lock TTL < staleness threshold) is well-established by the surrounding constants, and the fix is validated by both the targeted unit tests and the new integration-level regression test.
The logic inversion is correct: because LOCK_TIMEOUT_MS (5 min) is strictly shorter than ABANDONED_LOCK_THRESHOLD_MS (10 min), a coordinator that crashed cannot still hold a valid lock by the time updatedAt reaches the stale threshold. All five observable scenarios (fresh heartbeat, stale+live-lock, stale+expired-lock, stale+no-lock, stale+different-job-lock) now produce the right outcome, and each is covered by a test.
No files require special attention.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[isJobOrphaned called] --> B{updatedAt fresh?\nupdatedAt >= now - 10min} B -- Yes --> C[orphaned = false\ncoordinator alive via heartbeat] B -- No --> D[hasLiveCoordinatorLock?] D --> E{Lock exists AND\nlockInfo.jobId == job.id AND\nlockInfo.expiresAt >= now?} E -- Yes --> F["orphaned = false\n(lock TTL 5min < threshold 10min:\ncrashed lock would have expired\nbefore updatedAt went this stale)"] E -- No --> G[orphaned = true\nno heartbeat AND no live lock] C --> H[return false] F --> H G --> I[return true → processOrphanedJob] style C fill:#90EE90 style F fill:#90EE90 style G fill:#FFB6C1 style I fill:#FFB6C1%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[isJobOrphaned called] --> B{updatedAt fresh?\nupdatedAt >= now - 10min} B -- Yes --> C[orphaned = false\ncoordinator alive via heartbeat] B -- No --> D[hasLiveCoordinatorLock?] D --> E{Lock exists AND\nlockInfo.jobId == job.id AND\nlockInfo.expiresAt >= now?} E -- Yes --> F["orphaned = false\n(lock TTL 5min < threshold 10min:\ncrashed lock would have expired\nbefore updatedAt went this stale)"] E -- No --> G[orphaned = true\nno heartbeat AND no live lock] C --> H[return false] F --> H G --> I[return true → processOrphanedJob] style C fill:#90EE90 style F fill:#90EE90 style G fill:#FFB6C1 style I fill:#FFB6C1Reviews (1): Last reviewed commit: "Merge branch 'main' into richmond" | Re-trigger Greptile