The repository DELETE's hard-delete phase waits on the row lock without bound

The wait

The repository DELETE runs in two phases. The first attempts a hard delete through RepositoryStore.Delete, which takes a row-exclusive lock on the repositories row. The second, reached only when the first reports the repository non-empty and destructive=true, opens the tombstone transaction under lifecycle.TombstoneTimeout.

armTombstoneResponseDeadline arms the connection's write deadline for the second phase. It covers the tombstone phase and nothing before it.

So the first phase's wait on the row lock has no bound of its own. While a concurrent accepted delete's tombstone holds that row, or any other writer holds it, the hard-delete attempt parks. The service sets no lock_timeout and no statement_timeout, internal/server/server.go sets only ReadTimeout, WriteTimeout and IdleTimeout — none of which cancels a request context — and there is no http.TimeoutHandler and no per-request deadline middleware.

What that costs

Two things, and they are separable.

The park itself is unbounded, so a client can wait on a lock for as long as the holder holds it.

And the phase's own 409 or 204 can be lost the way a parked acceptance would have been, because the connection's write deadline is armed when the request headers finish being read rather than when the response is written.

The masking half belongs to an existing item

Whether a deadline that fires on this route is classified as a server-side timeout or as a client disconnect is not this item's question. Work item #252 owns it, and it records the current behavior as deliberate for this route:

Out of scope

  • The !824 (merged) repository handlers, which follow the existing convention on purpose.

!824 (merged) is the management API repository update and delete handlers. Anyone reopening the classification reads #252 first, and a decision there changes what this item's fix has to do about reporting.

The canonical form the fix takes

RepositoryStore.SoftDelete's doc already carries the sentence this route's first phase needs, for the second phase's statement:

The write waits on the row lock without bound, because the service sets no lock_timeout and no statement_timeout. A caller on a request path owes this call a context deadline.

RepositoryStore.Delete's doc carries no equivalent. Closing this item means the caller-side deadline that sentence says is owed, applied to the first phase, and the same statement of the obligation somewhere a reader of the delete path will find it.

Neighbours that are not this

  • #648 bounds the parent gate's FOR SHARE wait when a request path first calls a remote create. It names RepositoryStore.Delete as a holder of the row lock, never as a waiter on it, so it does not reach this.
  • #776 (closed) covers a route losing its status line to the header-time write deadline, on the Maven upload. Same mechanism as the second cost above, different route.

Where the record sits

The spec's ## Error Cases in docs/specs/S20-a-lifecycle-closed-beta.md carries the row describing the unbounded first wait, added by its own docs(specs) merge request rather than by a step MR.

The Go surface carries no acknowledgment of it today. lifecycle.TombstoneTimeout's doc scopes its bound to the tombstone transaction correctly and says nothing about the phase ahead of it; that doc block sits far above its comment cap, so any sentence added there arrives with a compression of the block. Whoever closes this item decides whether the sentence lands there, on RepositoryStore.Delete beside the SoftDelete precedent, or on armTombstoneResponseDeadline where the scope limit is.

Provenance

Raised by @mkhalifa3 in review of !1885 (merged), note 3735057589. The mechanism was verified at that merge request's head and every part of it held. The decision to record rather than close it in that step was the operator's on that thread.