mengw15 commented on code in PR #7744:
URL: https://github.com/apache/texera/pull/7744#discussion_r3816571544


##########
amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala:
##########
@@ -20,15 +20,37 @@
 package org.apache.texera.web.service
 
 import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
+import com.typesafe.scalalogging.LazyLogging
 import kong.unirest.Unirest
 import org.apache.texera.common.config.StorageConfig
+import org.apache.texera.common.util.RetryUtil
+import org.apache.texera.web.service.LakekeeperClient.PurgeWaitPolicy
 
 import java.net.URLEncoder
 import java.nio.charset.StandardCharsets
 import java.util.UUID
 import scala.collection.mutable.ListBuffer
 import scala.jdk.CollectionConverters.IteratorHasAsScala
 
+object LakekeeperClient {
+
+  /**
+    * How long the final warehouse delete waits out Lakekeeper's asynchronous 
purge of the
+    * dropped tables' data files, which it reports as 409 
WarehouseHasUnfinishedTasks while
+    * still draining (#7742). The pause starts at `initialDelayMillis` and 
doubles up to
+    * `maxDelayMillis`: starting small keeps a fast purge (the common case) 
from costing the
+    * caller a full fixed interval, while the growth keeps a slow one from 
hammering
+    * Lakekeeper. With the defaults the `retries` retries wait 
0.2+0.4+0.8+1.6+3.2+5+5s
+    * ≈ 16s in total. Overridable for tests (a 0 initial delay keeps the spec 
free of
+    * real sleeps — doubling 0 stays 0).
+    */
+  final case class PurgeWaitPolicy(
+      retries: Int = 7,

Review Comment:
   Done — `maxAttempts = 8`, the `+1` is gone.



##########
amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala:
##########
@@ -126,12 +153,43 @@ class LakekeeperClient(catalogUri: String = 
StorageConfig.icebergRESTCatalogUri)
         failOn(response.getStatus, response.getBody, s"drop namespace 
'$namespace'")
       }
     }
-    val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
-    if (response.getStatus != 404) {
-      failOn(response.getStatus, response.getBody, "delete warehouse")
+    // The drops above purge each table's data files asynchronously 
(Lakekeeper task
+    // queue `tabular_purge`), and Lakekeeper 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 (normally within 
seconds),
+    // so ride that out with a bounded retry; every other error, including any 
other
+    // 409, still fails immediately. (#7742)
+    RetryUtil.withBackoff(
+      description = "delete warehouse",
+      maxAttempts = purgeWait.retries + 1,
+      initialDelayMillis = purgeWait.initialDelayMillis,
+      onRetry = attempt => logger.info(attempt.message),

Review Comment:
   Done.



##########
amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala:
##########
@@ -126,12 +153,43 @@ class LakekeeperClient(catalogUri: String = 
StorageConfig.icebergRESTCatalogUri)
         failOn(response.getStatus, response.getBody, s"drop namespace 
'$namespace'")
       }
     }
-    val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
-    if (response.getStatus != 404) {
-      failOn(response.getStatus, response.getBody, "delete warehouse")
+    // The drops above purge each table's data files asynchronously 
(Lakekeeper task
+    // queue `tabular_purge`), and Lakekeeper 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 (normally within 
seconds),
+    // so ride that out with a bounded retry; every other error, including any 
other
+    // 409, still fails immediately. (#7742)
+    RetryUtil.withBackoff(
+      description = "delete warehouse",
+      maxAttempts = purgeWait.retries + 1,
+      initialDelayMillis = purgeWait.initialDelayMillis,
+      onRetry = attempt => logger.info(attempt.message),
+      maxDelayMillis = purgeWait.maxDelayMillis,
+      shouldRetry = _.isInstanceOf[UnfinishedTasksConflictException]
+    ) {
+      val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
+      if (response.getStatus != 404) {
+        if (isUnfinishedTasksConflict(response.getStatus, response.getBody)) {
+          throw new UnfinishedTasksConflictException(response.getStatus, 
response.getBody)
+        }
+        failOn(response.getStatus, response.getBody, "delete warehouse")
+      }

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



##########
amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala:
##########
@@ -126,12 +153,43 @@ class LakekeeperClient(catalogUri: String = 
StorageConfig.icebergRESTCatalogUri)
         failOn(response.getStatus, response.getBody, s"drop namespace 
'$namespace'")
       }
     }
-    val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
-    if (response.getStatus != 404) {
-      failOn(response.getStatus, response.getBody, "delete warehouse")
+    // The drops above purge each table's data files asynchronously 
(Lakekeeper task
+    // queue `tabular_purge`), and Lakekeeper 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 (normally within 
seconds),
+    // so ride that out with a bounded retry; every other error, including any 
other
+    // 409, still fails immediately. (#7742)
+    RetryUtil.withBackoff(
+      description = "delete warehouse",
+      maxAttempts = purgeWait.retries + 1,
+      initialDelayMillis = purgeWait.initialDelayMillis,
+      onRetry = attempt => logger.info(attempt.message),
+      maxDelayMillis = purgeWait.maxDelayMillis,
+      shouldRetry = _.isInstanceOf[UnfinishedTasksConflictException]
+    ) {
+      val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
+      if (response.getStatus != 404) {
+        if (isUnfinishedTasksConflict(response.getStatus, response.getBody)) {
+          throw new UnfinishedTasksConflictException(response.getStatus, 
response.getBody)
+        }
+        failOn(response.getStatus, response.getBody, "delete warehouse")
+      }
     }
   }
 
+  /** Tags the one retryable delete failure so the backoff predicate can 
single it out. */
+  private class UnfinishedTasksConflictException(status: Int, body: String)

Review Comment:
   Done — the exception takes only the body.



##########
amber/src/main/scala/org/apache/texera/web/service/LakekeeperClient.scala:
##########
@@ -126,12 +153,43 @@ class LakekeeperClient(catalogUri: String = 
StorageConfig.icebergRESTCatalogUri)
         failOn(response.getStatus, response.getBody, s"drop namespace 
'$namespace'")
       }
     }
-    val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
-    if (response.getStatus != 404) {
-      failOn(response.getStatus, response.getBody, "delete warehouse")
+    // The drops above purge each table's data files asynchronously 
(Lakekeeper task
+    // queue `tabular_purge`), and Lakekeeper 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 (normally within 
seconds),
+    // so ride that out with a bounded retry; every other error, including any 
other
+    // 409, still fails immediately. (#7742)
+    RetryUtil.withBackoff(
+      description = "delete warehouse",
+      maxAttempts = purgeWait.retries + 1,
+      initialDelayMillis = purgeWait.initialDelayMillis,
+      onRetry = attempt => logger.info(attempt.message),
+      maxDelayMillis = purgeWait.maxDelayMillis,
+      shouldRetry = _.isInstanceOf[UnfinishedTasksConflictException]
+    ) {
+      val response = 
Unirest.delete(s"$managementBase/warehouse/$warehouseId").asString()
+      if (response.getStatus != 404) {
+        if (isUnfinishedTasksConflict(response.getStatus, response.getBody)) {
+          throw new UnfinishedTasksConflictException(response.getStatus, 
response.getBody)
+        }
+        failOn(response.getStatus, response.getBody, "delete warehouse")
+      }
     }
   }
 
+  /** 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")

Review Comment:
   Done — extracted `DeleteWarehouseAction`.



##########
amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala:
##########
@@ -121,6 +158,13 @@ class LakekeeperClientSpec
     s"http://localhost:${server.getAddress.getPort}/catalog";
   )
 
+  // 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";,

Review Comment:
   Done — extracted `stubCatalogUri`.



##########
amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala:
##########
@@ -168,4 +212,40 @@ class LakekeeperClientSpec
     error.getMessage should include("Lakekeeper")
     error.getMessage should include("500")
   }
+
+  it should "wait out 409 WarehouseHasUnfinishedTasks from the asynchronous 
purge (#7742)" in {
+    // Lakekeeper purges dropped tables asynchronously; the stub answers the
+    // warehouse delete with 409 WarehouseHasUnfinishedTasks twice before the
+    // queue "drains" and it returns 204. The delete must ride that out.
+    noException should be thrownBy 
retryClient.deleteWarehouseEmptyFirst(racingWarehouseId)
+    deleteAttempts(racingWarehouseId) shouldBe 3
+  }
+
+  it should "give up once the purge-wait retries are exhausted" in {
+    val error = intercept[RuntimeException] {
+      retryClient.deleteWarehouseEmptyFirst(alwaysBusyWarehouseId)
+    }
+    error.getMessage should include("409")
+    error.getMessage should include("WarehouseHasUnfinishedTasks")
+    // 1 initial attempt + 3 retries, then fail -- the wait is bounded.

Review Comment:
   Dropped.



##########
amber/src/test/scala/org/apache/texera/web/service/LakekeeperClientSpec.scala:
##########
@@ -168,4 +212,40 @@ class LakekeeperClientSpec
     error.getMessage should include("Lakekeeper")
     error.getMessage should include("500")
   }
+
+  it should "wait out 409 WarehouseHasUnfinishedTasks from the asynchronous 
purge (#7742)" in {
+    // Lakekeeper purges dropped tables asynchronously; the stub answers the
+    // warehouse delete with 409 WarehouseHasUnfinishedTasks twice before the
+    // queue "drains" and it returns 204. The delete must ride that out.
+    noException should be thrownBy 
retryClient.deleteWarehouseEmptyFirst(racingWarehouseId)
+    deleteAttempts(racingWarehouseId) shouldBe 3
+  }
+
+  it should "give up once the purge-wait retries are exhausted" in {
+    val error = intercept[RuntimeException] {
+      retryClient.deleteWarehouseEmptyFirst(alwaysBusyWarehouseId)
+    }
+    error.getMessage should include("409")
+    error.getMessage should include("WarehouseHasUnfinishedTasks")
+    // 1 initial attempt + 3 retries, then fail -- the wait is bounded.
+    deleteAttempts(alwaysBusyWarehouseId) shouldBe 4
+  }
+
+  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
+  }

Review Comment:
   Done — shared `assertFailsWithoutRetry`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to