mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* fix(workspace): stop workspace delete from hanging on advisory lock 4246 (MUL-5983) Deleting a workspace from the latest client did nothing: the confirm dialog stayed on "Deleting…" forever and the workspace survived. The teardown transaction takes global advisory lock 4246 (added in #6230) so the task_usage_hourly rollup cannot write aggregates for a workspace being torn down, and it waited on that lock with no bound. Nothing above the handler has a deadline either — the API client's fetch has no timeout — so any holder of 4246 turns the delete into an infinite spinner. Two fixes: - Migration 272 makes the rollup's lock transaction-scoped. Migration 102 used the session-scoped pg_try_advisory_lock and released it from an `EXCEPTION WHEN OTHERS` handler, but plpgsql's OTHERS does not match query_canceled and a session-level advisory lock survives the rollback that follows. A cancelled tick therefore handed its pooled connection back to pgxpool still holding 4246 — permanently blocking every later tick (usage aggregation silently stops) and every workspace delete. - DeleteWorkspace sets SET LOCAL lock_timeout on its transaction and maps SQLSTATE 55P03 to 503. Lock 4246 is also held for whole runs by the backfill commands and for up to 25 min by a rollup tick, so contention has to surface as a retryable error instead of a request that never returns. The cap applies to lock waits only; the teardown work itself stays unbounded. Both paths are pinned by tests that fail without the fix: the scheduler test proves a cancelled tick leaks 4246 against the old function, and the handler test proves DeleteWorkspace blocks on a held 4246 without the lock timeout. Co-authored-by: multica-agent <[email protected]> * test(workspace): serialise the new 4246 tests with the rollup guard (MUL-5983) Review found that the two new handler tests take advisory lock 4246 by hand without joining the cross-binary guard the rollup family uses (42463980), so `go test` running internal/handler and internal/scheduler in parallel against one database made the scheduler's lock tests fail. Reproduced locally, then fixed by calling lockRollupSingleton in both. Stressing the combined run surfaced a second, older interference: every workspace teardown takes 4246 in production code (#6230), so any handler delete test can own the lock for a moment and leave the scheduler's rollup tests seeing "no work" — TestPgCronConcurrentNoDoubleWrite fails with winners=0, and this predates the guard fix above. Both rollup tests now treat a round lost to an outside holder as a retry rather than a verdict; the real invariants (no leaked lock, no double write) are unchanged. Also from review: correct the sqlc source comment that still described the rollup's lock as session-scoped (regenerated), and drop migration 272's claim that the TTL prune is bounded and only delays the next tick — it has no row cap and now also delays workspace deletes waiting on 4246. Co-authored-by: multica-agent <[email protected]> * test(workspace): make the advisory-lock holder release idempotent (MUL-5983) The 15 s deadline branch releases the 4246 holder early so the blocked handler can finish, and the defer then released it again. pgxpool's Release is idempotent but Exec on a returned connection dereferences a nil resource, so the second call panicked and buried the assertion failure it was supposed to report. sync.Once makes the cleanup safe from both paths. Verified by forcing the deadline branch (lock_timeout temporarily removed from DeleteWorkspace): the test now reports the assertion cleanly where it previously ended in a nil-pointer panic. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
178 lines
6.2 KiB
Go
178 lines
6.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// setWorkspaceDeleteLockTimeoutForTest is a package-private hook used only by
|
|
// the tests below to keep them fast.
|
|
func setWorkspaceDeleteLockTimeoutForTest(t *testing.T, v time.Duration) {
|
|
t.Helper()
|
|
workspaceDeleteLockTimeoutOverride = v
|
|
t.Cleanup(func() { workspaceDeleteLockTimeoutOverride = 0 })
|
|
}
|
|
|
|
// TestDeleteWorkspace_FailsFastWhenRollupLockHeld is the regression test for
|
|
// MUL-5983: "clicking delete workspace does nothing".
|
|
//
|
|
// The teardown transaction takes global advisory lock 4246 so the
|
|
// task_usage_hourly rollup cannot write aggregates for a workspace that is
|
|
// being torn down. That lock is batch-held — a rollup tick, a backfill run, or
|
|
// (before migration 272) a cancelled tick that leaked it — and nothing above
|
|
// this handler has a deadline: the desktop client's fetch has no timeout, so
|
|
// an unbounded wait leaves the delete dialog spinning on "Deleting…" until the
|
|
// app is force quit, and the workspace is never deleted.
|
|
//
|
|
// With SET LOCAL lock_timeout in place the same contention has to surface as a
|
|
// bounded, retryable 503 while the workspace stays intact.
|
|
func TestDeleteWorkspace_FailsFastWhenRollupLockHeld(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("DATABASE_URL not set; skipping live-Postgres workspace delete lock test")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Taking 4246 by hand is exactly what the rollup family's cross-binary
|
|
// guard exists to serialise: internal/scheduler runs in parallel against
|
|
// the same database, and its own 4246 tests read the lock's state (MUL-3980).
|
|
lockRollupSingleton(t)
|
|
|
|
setWorkspaceDeleteLockTimeoutForTest(t, 500*time.Millisecond)
|
|
|
|
// Hold 4246 on a pinned connection for the duration of the request, the
|
|
// way a running rollup tick (or a leaked one) holds it in production.
|
|
holder, err := testPool.Acquire(ctx)
|
|
if err != nil {
|
|
t.Fatalf("acquire advisory lock holder: %v", err)
|
|
}
|
|
if _, err := holder.Exec(ctx, `SELECT pg_advisory_lock(4246)`); err != nil {
|
|
holder.Release()
|
|
t.Fatalf("hold advisory lock 4246: %v", err)
|
|
}
|
|
// Idempotent: the deadline branch below releases the holder early to
|
|
// unblock the handler, and the defer still runs afterwards. A second
|
|
// Exec/Release on a returned pgxpool connection panics, which would
|
|
// replace the assertion failure with a stack trace.
|
|
var releaseOnce sync.Once
|
|
releaseHolder := func() {
|
|
releaseOnce.Do(func() {
|
|
_, _ = holder.Exec(context.Background(), `SELECT pg_advisory_unlock(4246)`)
|
|
holder.Release()
|
|
})
|
|
}
|
|
defer releaseHolder()
|
|
|
|
const slug = "handler-tests-delete-lock"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
|
|
var wsID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, "Handler Test Delete Lock", slug, "DeleteWorkspace lock timeout test").Scan(&wsID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
})
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role)
|
|
VALUES ($1, $2, 'owner')
|
|
`, wsID, testUserID); err != nil {
|
|
t.Fatalf("create owner member: %v", err)
|
|
}
|
|
|
|
// The handler runs on its own goroutine so a regression shows up as a
|
|
// failed deadline here instead of hanging the whole test binary.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+wsID, nil)
|
|
req = withURLParam(req, "id", wsID)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
testHandler.DeleteWorkspace(w, req)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(15 * time.Second):
|
|
// Unblock the handler so the test binary can still shut down cleanly.
|
|
releaseHolder()
|
|
<-done
|
|
t.Fatal("DeleteWorkspace blocked on advisory lock 4246 instead of timing out — this is the hang users see as 'delete workspace does nothing' (MUL-5983)")
|
|
}
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 while advisory lock 4246 is held, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var exists bool
|
|
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)`, wsID).Scan(&exists); err != nil {
|
|
t.Fatalf("verify workspace: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatal("workspace must survive a teardown that never got its locks")
|
|
}
|
|
}
|
|
|
|
// TestDeleteWorkspace_SucceedsWhenRollupLockIsFree is the counterpart: the
|
|
// lock timeout must not make an uncontended delete flaky.
|
|
func TestDeleteWorkspace_SucceedsWhenRollupLockIsFree(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("DATABASE_URL not set; skipping live-Postgres workspace delete lock test")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// The teardown itself waits on 4246 under a 500 ms budget, so a rollup
|
|
// test holding that lock in the parallel internal/scheduler binary would
|
|
// turn this "uncontended" delete into a 503. Same guard, same reason.
|
|
lockRollupSingleton(t)
|
|
|
|
setWorkspaceDeleteLockTimeoutForTest(t, 500*time.Millisecond)
|
|
|
|
const slug = "handler-tests-delete-lock-free"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
|
|
var wsID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, "Handler Test Delete Lock Free", slug, "DeleteWorkspace lock timeout test").Scan(&wsID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
})
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role)
|
|
VALUES ($1, $2, 'owner')
|
|
`, wsID, testUserID); err != nil {
|
|
t.Fatalf("create owner member: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+wsID, nil)
|
|
req = withURLParam(req, "id", wsID)
|
|
testHandler.DeleteWorkspace(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 from an uncontended delete, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var exists bool
|
|
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)`, wsID).Scan(&exists); err != nil {
|
|
t.Fatalf("verify workspace: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("workspace still exists after a successful delete")
|
|
}
|
|
}
|