Skip to content

fix(lock): name the cause on FAILED_TO_RELEASE in StorageBasedLockProvider - #19574

Open
pkgajulapalli wants to merge 1 commit into
apache:masterfrom
pkgajulapalli:lock-release-failure-cause-v2
Open

fix(lock): name the cause on FAILED_TO_RELEASE in StorageBasedLockProvider#19574
pkgajulapalli wants to merge 1 commit into
apache:masterfrom
pkgajulapalli:lock-release-failure-cause-v2

Conversation

@pkgajulapalli

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

StorageBasedLockProvider#unlock() has three distinct failure paths that all throw a
byte-identical HoodieLockException message:

throw new HoodieLockException(generateLockStateMessage(FAILED_TO_RELEASE));

All three also share a single updateLockReleaseFailureMetric counter. So when a lock
release fails in production, neither the exception nor the metric tells you which path
produced it — and the causes call for completely different fixes (stop the writer's GC
pressure, back off from a storage rate limit, or fix clock skew between nodes).

The individual storage outcomes are already logged inside tryExpireCurrentLock. What
is missing is any way to attribute the thrown exception to a cause, and any signal at all
on two of the three paths.

This matters because a failed release leaves the lock file in storage without its
expired: true flag — i.e. a dangling lock that blocks every other writer on that table
until the lease elapses.

Summary and Changelog

Each FAILED_TO_RELEASE throw now names its cause via a new
generateLockStateMessage(LockState, String cause) overload, and each logs the context
needed to act on it.

Cause Meaning
HEARTBEAT_STOP_FAILED The heartbeat task would not stop, so the lock is deliberately left un-expired (the task could still renew it after we return). Logs the interrupted flag to separate the two sub-cases in LockProviderHeartbeatManager#stopHeartbeat.
INTERRUPTED_DURING_THROTTLE_BACKOFF Interrupted mid-backoff. Now also passes the InterruptedException so the stack trace survives.
THROTTLE_RETRIES_EXHAUSTED The retry budget was exhausted against a storage rate limit (e.g. the GCS 1-write/sec per-object limit).
EXPIRE_WRITE_FAILED A terminal UNKNOWN_ERROR / ACQUIRED_BY_OTHERS outcome.

Additionally, on ACQUIRED_BY_OTHERS we now log how long ago our lease should have ended:

  • positive → we overran our own lease, pointing at a starved heartbeat (long GC, thread-pool starvation);
  • negative → the lease had not elapsed by our clock, pointing at clock skew between nodes.

Those two are indistinguishable today and need different fixes. Every new message also
carries lockFilePath, so a lock left dangling in storage can be joined back to the
writer that failed to release it.

Detailed changes:

  • StorageBasedLockProvider: added the generateLockStateMessage(state, cause) overload; added a logger.error at each of the three throw sites with the cause-specific context; passed ie into the interrupted-path log so the stack trace is retained; added the lease-overrun delta log on ACQUIRED_BY_OTHERS.
  • TestStorageBasedLockProvider: added testUnlockThrowsExceptionWhenInterruptedDuringThrottleBackoff (this path previously had no coverage); tightened the three existing FAILED_TO_RELEASE assertions to also pin the specific cause label, so a future refactor that collapses them fails the build.

No code was copied.

Impact

None on behaviour. Control flow is untouched — every edit either adds a logger.error
call or appends , cause <LABEL> to an existing exception message. No public API, config,
or metric change (updateLockReleaseFailureMetric deliberately remains a single counter;
splitting it per cause would change the metrics surface and is left for a separate
change).

Anything parsing these exception strings verbatim would see the appended
, cause <LABEL> suffix. The existing FAILED_TO_RELEASE substring is preserved.

Risk Level

none — logging and exception-message text only.

Documentation Update

none — no new configs, no user-facing feature change.

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

Verified locally on JDK 17: TestStorageBasedLockProvider48 tests, 0 failures, 0 errors.

…vider

Three distinct failures in StorageBasedLockProvider#unlock() threw a
byte-identical HoodieLockException message, so logs could not tell them
apart. All three also share a single updateLockReleaseFailureMetric
counter, leaving no way to attribute a release failure to a cause.

Each throw now names its cause, and each logs the context needed to act
on it:

- HEARTBEAT_STOP_FAILED: the heartbeat task would not stop, so the lock
  is deliberately left un-expired (the task could still renew it after
  we return). Logs the interrupted flag to separate the two sub-cases in
  LockProviderHeartbeatManager#stopHeartbeat.
- INTERRUPTED_DURING_THROTTLE_BACKOFF: interrupted mid-backoff. Now also
  passes the InterruptedException so the stack trace survives.
- THROTTLE_RETRIES_EXHAUSTED vs EXPIRE_WRITE_FAILED: distinguishes an
  exhausted retry budget against a storage rate limit (e.g. the GCS
  1-write/sec per-object limit) from a terminal UNKNOWN_ERROR /
  ACQUIRED_BY_OTHERS outcome.

The four cause strings are declared as constants next to the other lock
tunables, so the full set is visible in one place and both the call sites
and the test assertions reference them rather than raw literals.

On ACQUIRED_BY_OTHERS, also log how long ago our lease should have
ended. A positive value means we overran our own lease, pointing at a
starved heartbeat (long GC, thread-pool starvation); a negative value
means the lease had not elapsed by our clock, pointing at clock skew
between nodes instead. Those two causes are indistinguishable today and
call for different fixes.

Every new message carries lockFilePath, so a lock left dangling in
storage can be joined back to the writer that failed to release it.

Behaviour is unchanged: control flow is untouched, and each edit either
adds a logger.error call or appends ", cause <LABEL>" to an existing
exception message. Metrics are unchanged.

Adds a test for the interrupted-during-backoff path, which had no
coverage, and tightens the three existing FAILED_TO_RELEASE assertions
to pin the specific cause label.
@github-actions github-actions Bot added the size:S PR with lines of changes in (10, 100] label Aug 10, 2026

@hudi-agent hudi-agent 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.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for the contribution! This PR disambiguates the three distinct FAILED_TO_RELEASE failure paths in StorageBasedLockProvider#unlock() by naming each cause in the thrown HoodieLockException and adding targeted error logging. No correctness issues found. A few style/readability suggestions in the inline comments. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. One small logging readability nit; otherwise clean.

cc @yihua

// THROTTLED here means the retries above were exhausted; FAILED means tryExpireCurrentLock
// already logged the specific storage outcome (UNKNOWN_ERROR vs ACQUIRED_BY_OTHERS).
String cause = expireResult == ExpireLockResult.THROTTLED
? CAUSE_THROTTLE_RETRIES_EXHAUSTED

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.

🤖 nit: cause is computed just above but then omitted from the log message — only expireResult (the enum) appears. Could you add cause as a placeholder so the cause string (e.g. THROTTLE_RETRIES_EXHAUSTED) is greppable directly in logs, without having to correlate with the exception? e.g. "... ended as {} (cause={}) after ..." with args expireResult, cause.

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

@codecov-commenter

codecov-commenter commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.10%. Comparing base (4853b57) to head (ec0ea3b).

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19574      +/-   ##
============================================
+ Coverage     77.09%   77.10%   +0.01%     
- Complexity    32490    32500      +10     
============================================
  Files          2522     2522              
  Lines        139112   139126      +14     
  Branches      16714    16715       +1     
============================================
+ Hits         107243   107276      +33     
+ Misses        24291    24278      -13     
+ Partials       7578     7572       -6     
Components Coverage Δ
hudi-common 83.28% <ø> (+0.03%) ⬆️
hudi-client 82.74% <100.00%> (+0.02%) ⬆️
hudi-flink 85.35% <ø> (ø)
hudi-spark-datasource 70.60% <ø> (ø)
hudi-utilities 73.67% <ø> (+0.04%) ⬆️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 63.49% <ø> (-0.02%) ⬇️
hudi-sync 75.11% <ø> (ø)
hudi-io 79.46% <ø> (ø)
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 50.74% <100.00%> (+0.01%) ⬆️
flink-integration-tests 49.07% <0.00%> (-0.02%) ⬇️
hadoop-mr-java-client 43.73% <0.00%> (+<0.01%) ⬆️
integration-tests 13.57% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 49.61% <0.00%> (-0.01%) ⬇️
spark-java-tests 51.62% <0.00%> (+0.01%) ⬆️
spark-scala-tests 45.97% <0.00%> (-0.01%) ⬇️
utilities 36.58% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ent/transaction/lock/StorageBasedLockProvider.java 90.27% <100.00%> (+1.95%) ⬆️

... and 20 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

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

Labels

size:S PR with lines of changes in (10, 100]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants