Skip to content

feat(amber): wait out Lakekeeper's asynchronous purge when deleting a warehouse - #7744

Open
mengw15 wants to merge 6 commits into
apache:mainfrom
mengw15:fix/7742-warehouse-delete-purge-retry
Open

feat(amber): wait out Lakekeeper's asynchronous purge when deleting a warehouse#7744
mengw15 wants to merge 6 commits into
apache:mainfrom
mengw15:fix/7742-warehouse-delete-purge-retry

Conversation

@mengw15

@mengw15 mengw15 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this PR?

LakekeeperClient.deleteWarehouseEmptyFirst empties the warehouse by dropping every table with purgeRequested=true, then immediately deletes the warehouse entity. Lakekeeper purges the dropped tables' data files asynchronously (task queue tabular_purge) and refuses to delete the warehouse while any purge is pending — the tasks need the warehouse's storage profile to reach S3, so deleting it first would orphan them and leak the files. It answers 409 WarehouseHasUnfinishedTasks until the queue drains, so in practice the first delete of any warehouse that has ever stored execution results always failed; a retry seconds later succeeded (details in #7742).

  • Retry the final warehouse delete on exactly this conflict: 409 with error.type == "WarehouseHasUnfinishedTasks" is treated as transient and retried with exponential backoff — 200ms doubling up to a 5s cap over 7 retries (0.2+0.4+0.8+1.6+3.2+5+5s ≈ 16s bound). A purge that drains almost immediately, the common case, costs the caller ~200ms rather than a full fixed interval; a genuinely slow one issues fewer requests than a fixed pause would. Every other error — including any other 409, and a 409 whose body isn't the expected JSON envelope — still fails immediately, and 404 stays the idempotent goal state.
  • The bound and delays are constructor parameters with defaults, so production call sites are unchanged and the spec injects a zero initial delay — doubling zero stays zero, so the tests contain no real sleeps and stay deterministic.

Found while testing the flag-gated per-user warehouse feature (#6870); no deployment is affected because the flag defaults to off.

Any related issues, documentation, discussions?

Closes #7742. Part of #6870; deleteWarehouseEmptyFirst introduced in #7473. Verified against the local Lakekeeper (0.11.0) that its management API exposes no task-query endpoint or force-delete option, so waiting out the documented conflict type is the only client-side path.

How was this PR tested?

  • LakekeeperClientSpec (in-process HTTP stub, no external infra) gains four cases mirroring the issue's repro: the stub answers the warehouse delete with 409 WarehouseHasUnfinishedTasks twice and then 204 — verified failing before the fix (the first 409 threw) and passing after, with the stub asserting exactly 3 delete attempts; a warehouse whose queue never drains fails after the bounded 1+3 attempts; a 409 of any other type fails on the first attempt with no retry; and a 409 whose body is not JSON (a gateway error page) also fails on the first attempt rather than being waited out.
  • Full spec run locally: 8/8 passed; WorkflowExecutionService/scalafmtCheck (main + Test) passes.
  • The initial retry delay is injected as 0 in all tests — no real sleeps, deterministic across runs.

Was this PR authored or co-authored using generative AI tooling?

Generated-by: Claude Code (claude-opus-4-8)

@github-actions

Copy link
Copy Markdown
Contributor

Backport auto-label report

This fix: PR was checked against each actively-supported release branch. release/* labels drive the post-merge backport, so add or remove one to change where this fix lands.

Release branch Analysis
⚠️ release/v1.2 Not labeled automatically — none of the files this PR modifies exist on this branch (amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala, amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala). The fix may target code that isn't on this release, or the files were moved/renamed after the branch was cut. Please check and add release/v1.2 by hand if this fix should be backported here.

Auto-label run.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Automated Reviewer Suggestions

Based on the git blame history of the changed files, we recommend the following reviewers:

  • Contributors with relevant context: @aglinxinyuan
    You can notify them by mentioning @aglinxinyuan in a comment.

… warehouse

deleteWarehouseEmptyFirst drops every table with purgeRequested=true, then
immediately deletes the warehouse entity. Lakekeeper purges the dropped
tables' data files asynchronously (task queue `tabular_purge`) and refuses
to delete the warehouse while any purge is pending -- the tasks need the
warehouse's storage profile to reach S3, so deleting it first would orphan
them and leak the files. It answers 409 WarehouseHasUnfinishedTasks until
the queue drains, so the first delete of any warehouse that had ever stored
execution results always failed; a retry seconds later succeeded.

Retry the final warehouse delete on exactly that conflict -- 409 with
error.type WarehouseHasUnfinishedTasks -- with a bounded pause (default 10
retries x 2s; the queue normally drains within seconds). Every other error,
including any other 409, still fails immediately, and 404 stays the
idempotent goal state. The bound and delay are constructor parameters with
defaults, so production call sites are unchanged and the spec injects a
zero delay -- no real sleeps in the tests.

LakekeeperClientSpec covers the three outcomes against its in-process stub:
409-409-204 succeeds with exactly 3 attempts (failed before the fix on the
first 409), a never-draining queue fails after the bounded 1+3 attempts,
and a 409 of any other type fails on the first attempt with no retry.

Found while testing the flag-gated per-user warehouse feature; no
deployment is affected because the flag defaults to off.

Closes apache#7742.
@mengw15 mengw15 changed the title fix(amber): wait out Lakekeeper's asynchronous purge when deleting a warehouse feat(amber): wait out Lakekeeper's asynchronous purge when deleting a warehouse Aug 18, 2026
@mengw15
mengw15 force-pushed the fix/7742-warehouse-delete-purge-retry branch from e82ece1 to 6ce6cba Compare August 18, 2026 00:31
@mengw15 mengw15 removed the fix label Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

⚠️ Benchmark changes need a look

🟢 2 better · 🔴 2 worse · ⚪ 11 noise (<±5%) · 0 without baseline

Compared against main 60300e3 benchmarked on this same runner, so the delta is largely free of cross-runner hardware noise. The "7d avg" column still reflects the gh-pages dashboard. Treat <±5% as noise unless repeated.

Dashboard · Run

config throughput MB/s latency max Δ latest / 7d
🔴 bs=10 sw=10 sl=64 566 0.346 17,223/22,597/22,597 us 🔴 -6.9% / 🔴 +44.9%
bs=100 sw=10 sl=64 1,229 0.75 80,101/93,758/93,758 us ⚪ within ±5% / 🟢 +23.3%
bs=1000 sw=10 sl=64 1,394 0.851 721,079/750,023/750,023 us ⚪ within ±5% / 🟢 +34.4%
Baseline details

Latest main 60300e3 from same runner

config metric PR latest main 7d avg Δ latest Δ 7d
bs=10 sw=10 sl=64 throughput 566 tuples/sec 608 tuples/sec 771.02 tuples/sec -6.9% -26.6%
bs=10 sw=10 sl=64 MB/s 0.346 MB/s 0.371 MB/s 0.471 MB/s -6.7% -26.5%
bs=10 sw=10 sl=64 p50 17,223 us 16,677 us 12,749 us +3.3% +35.1%
bs=10 sw=10 sl=64 p95 22,597 us 24,171 us 15,594 us -6.5% +44.9%
bs=10 sw=10 sl=64 p99 22,597 us 24,171 us 19,320 us -6.5% +17.0%
bs=100 sw=10 sl=64 throughput 1,229 tuples/sec 1,260 tuples/sec 997.05 tuples/sec -2.5% +23.3%
bs=100 sw=10 sl=64 MB/s 0.75 MB/s 0.769 MB/s 0.609 MB/s -2.5% +23.2%
bs=100 sw=10 sl=64 p50 80,101 us 79,329 us 100,339 us +1.0% -20.2%
bs=100 sw=10 sl=64 p95 93,758 us 94,594 us 106,781 us -0.9% -12.2%
bs=100 sw=10 sl=64 p99 93,758 us 94,594 us 113,101 us -0.9% -17.1%
bs=1000 sw=10 sl=64 throughput 1,394 tuples/sec 1,382 tuples/sec 1,037 tuples/sec +0.9% +34.4%
bs=1000 sw=10 sl=64 MB/s 0.851 MB/s 0.843 MB/s 0.633 MB/s +0.9% +34.4%
bs=1000 sw=10 sl=64 p50 721,079 us 726,110 us 971,388 us -0.7% -25.8%
bs=1000 sw=10 sl=64 p95 750,023 us 773,648 us 1,013,249 us -3.1% -26.0%
bs=1000 sw=10 sl=64 p99 750,023 us 773,648 us 1,039,879 us -3.1% -27.9%
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,353.31,200,128000,566,0.346,17223.34,22596.74,22596.74
1,100,10,64,20,1626.79,2000,1280000,1229,0.750,80101.19,93758.24,93758.24
2,1000,10,64,20,14344.90,20000,12800000,1394,0.851,721079.44,750022.92,750022.92

Copilot AI 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.

Pull request overview

Adds bounded retry handling for Lakekeeper’s asynchronous warehouse purge conflicts.

Changes:

  • Retries only 409 WarehouseHasUnfinishedTasks.
  • Adds deterministic success, exhaustion, and unrelated-conflict tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
LakekeeperClient.scala Implements bounded warehouse-delete retries.
LakekeeperClientSpec.scala Tests retry and failure behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@codecov-commenter

codecov-commenter commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.86207% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.22%. Comparing base (60300e3) to head (525c9de).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
...g/apache/texera/web/service/LakekeeperClient.scala 81.81% 1 Missing and 3 partials ⚠️
...cala/org/apache/texera/common/util/RetryUtil.scala 57.14% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #7744      +/-   ##
============================================
+ Coverage     91.02%   91.22%   +0.19%     
- Complexity     4454     4472      +18     
============================================
  Files          1174     1171       -3     
  Lines         47146    47135      -11     
  Branches       5287     5287              
============================================
+ Hits          42916    42998      +82     
+ Misses         2550     2457      -93     
  Partials       1680     1680              
Flag Coverage Δ *Carryforward flag
access-control-service 81.00% <ø> (ø)
agent-service 98.62% <ø> (ø) Carriedforward from b45a778
amber 87.98% <75.86%> (+0.52%) ⬆️
computing-unit-managing-service 73.67% <ø> (ø)
config-service 86.73% <ø> (ø)
file-service 68.90% <ø> (ø)
frontend 92.59% <ø> (ø) Carriedforward from b45a778
notebook-migration-service 83.74% <ø> (ø)
pyamber 97.57% <ø> (ø) Carriedforward from b45a778
workflow-compiling-service 77.19% <ø> (ø)

*This pull request uses carry forward flags. Click here to find out more.

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

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The retry only fires for a 409 whose body carries the
WarehouseHasUnfinishedTasks type, and the type check parses that body --
so a 409 with a non-JSON body (a gateway error page, say) must read as
'not the purge conflict' and fail immediately rather than be waited out
as if it were transient. Assert it fails on the first attempt.
@github-actions github-actions Bot added the fix label Aug 18, 2026
…ueue

The wait was a fixed 2s x 10. That made the common case -- a purge that
drains almost immediately -- cost the caller a full 2s before the delete
was retried, on a request a user is waiting on, while still issuing 11
requests when the queue is genuinely slow.

Double the pause instead, from 200ms up to a 5s cap over 7 retries:
0.2+0.4+0.8+1.6+3.2+5+5s, so a fast purge returns in ~200ms, a slow one
issues fewer requests, and the overall bound drops from 20s to ~16s.
Tests still inject a zero initial delay -- doubling zero stays zero, so
they contain no real sleeps.
@mengw15
mengw15 requested a review from kunwp1 August 18, 2026 00:58
@mengw15 mengw15 removed the fix label Aug 18, 2026

@kunwp1 kunwp1 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.

Left some comments!

Comment thread amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala Outdated
Comment thread amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala Outdated
Comment thread amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala Outdated
Comment thread amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala Outdated
Comment thread amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala Outdated
- Bundle the three retry knobs into `LakekeeperClient.PurgeWaitPolicy`, so the
  constructor keeps two parameters and the defaults live in one place.
- Check the retry budget before parsing the response body: on the attempt that
  exhausts it, the parse was thrown away.
- Count the spec's delete attempts off the request log the stub already keeps,
  dropping the three counters and their per-test reset.
- Dispatch the stub's DELETE cases under a single method check.
@github-actions github-actions Bot added the fix label Aug 19, 2026
Review follow-up: rather than a hand-written retry loop, extend the shared
util with what the purge wait needs -- an optional retry predicate
(shouldRetry) and a per-wait delay cap (maxDelayMillis), both defaulted so
the existing call sites keep their behavior -- and have LakekeeperClient
tag the one retryable failure (409 WarehouseHasUnfinishedTasks) with a
private exception for the predicate to match.

@kunwp1 kunwp1 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.

The main functionality LGTM! Left some more comments to cleanup the codes.

* real sleeps — doubling 0 stays 0).
*/
final case class PurgeWaitPolicy(
retries: Int = 7,

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.

I would prefer to rename it to maxAttempts = 8 and drop the +1 in the code below when you use it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — maxAttempts = 8, the +1 is gone.

description = "delete warehouse",
maxAttempts = purgeWait.retries + 1,
initialDelayMillis = purgeWait.initialDelayMillis,
onRetry = attempt => logger.info(attempt.message),

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.

Use WARN for retries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment on lines +172 to +177
if (response.getStatus != 404) {
if (isUnfinishedTasksConflict(response.getStatus, response.getBody)) {
throw new UnfinishedTasksConflictException(response.getStatus, response.getBody)
}
failOn(response.getStatus, response.getBody, "delete warehouse")
}

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.

Can you refactor this code by flattening to a three-outcome match? It's too hard to read because it has two nesting levels. Then isUnfinishedTasksConflict can be much simpler.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — one flat match; the check is body-only now.

}

/** Tags the one retryable delete failure so the backoff predicate can single it out. */
private class UnfinishedTasksConflictException(status: Int, body: String)

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.

Drop status because this is only reachable by 409.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — the exception takes only the body.


/** Tags the one retryable delete failure so the backoff predicate can single it out. */
private class UnfinishedTasksConflictException(status: Int, body: String)
extends RuntimeException(s"Lakekeeper delete warehouse failed (HTTP $status): $body")

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.

I think "delete warehouse" string appears so many time. You can refactor this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — extracted DeleteWarehouseAction.

// Zero retry delay keeps the spec free of real sleeps (deterministic); 3
// retries keeps the exhaustion case cheap to assert.
private val retryClient = new LakekeeperClient(
s"http://localhost:${server.getAddress.getPort}/catalog",

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.

Refactor this line and line 158

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — extracted stubCatalogUri.

}
error.getMessage should include("409")
error.getMessage should include("WarehouseHasUnfinishedTasks")
// 1 initial attempt + 3 retries, then fail -- the wait is bounded.

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.

Might not need this line after the change above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped.

Comment on lines +234 to +250
it should "fail immediately on a 409 whose body is not the expected JSON envelope" in {
// The type check parses the body; a malformed one must read as "not the
// purge conflict" and fail rather than be retried as if it were transient.
val error = intercept[RuntimeException] {
retryClient.deleteWarehouseEmptyFirst(malformedConflictWarehouseId)
}
error.getMessage should include("409")
deleteAttempts(malformedConflictWarehouseId) shouldBe 1
}

it should "fail immediately on a 409 that is not WarehouseHasUnfinishedTasks" in {
val error = intercept[RuntimeException] {
retryClient.deleteWarehouseEmptyFirst(otherConflictWarehouseId)
}
error.getMessage should include("409")
deleteAttempts(otherConflictWarehouseId) shouldBe 1
}

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.

Refactor this code because there are duplicate codes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — shared assertFailsWithoutRetry.

Comment on lines 93 to 114
} else if (exchange.getRequestMethod == "DELETE") {
val path = exchange.getRequestURI.getPath
if (path.endsWith(racingWarehouseId.toString)) {
if (deleteAttempts(racingWarehouseId) <= 2) respond(exchange, 409, unfinishedTasksBody)
else respond(exchange, 204, "")
} else if (path.endsWith(alwaysBusyWarehouseId.toString)) {
respond(exchange, 409, unfinishedTasksBody)
} else if (path.endsWith(malformedConflictWarehouseId.toString)) {
// A 409 whose body isn't the JSON envelope the type check reads.
respond(exchange, 409, "<html>gateway conflict</html>")
} else if (path.endsWith(otherConflictWarehouseId.toString)) {
respond(
exchange,
409,
"""{"error":{"message":"warehouse is in use","type":"Conflict","code":409}}"""
)
} else {
respond(exchange, 200, "{}")
}
} else {
respond(exchange, 200, "{}")
}

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.

Too many branch depth. I think you can make it cleaner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — flattened to a single match on (method, path).

- PurgeWaitPolicy counts total maxAttempts (8) instead of retries, dropping
  the +1 at the call site.
- The delete outcome is one flat three-case match (gone / retryable purge
  conflict / fail), so the conflict check reads only the body and the marker
  exception no longer carries a status.
- Retries log at WARN, matching the other withBackoff callers.
- "delete warehouse" lives in one constant.
- Spec: the stub dispatch is one (method, path) match, the stub URI and the
  no-retry-409 assertions are shared, and the counted-attempts comment goes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BYO-S3] Warehouse deletion should wait out Lakekeeper's asynchronous purge instead of failing with 409

4 participants