Files
multica/server/pkg/db/queries/project_resource.sql
T
小七 f9b3065ae1 fix(handler): serialize project resource read-modify-write with a row lock (#9240)
UpdateProjectResource derives every column it writes back from the row
read at the top of the request, but the read and the write ran outside
any transaction. Two concurrent partial updates could interleave as
read-read-write-write, and the second writer's stale snapshot silently
reverted the first writer's change: a label-only PUT reverted a racing
ref update to an old Git ref, and a label-only PUT on a local_directory
restored a stale execution mode (reported in #9238 with a deterministic
interleaving that fails 15/15 under -race -count=3).

Serialize the flow with SELECT ... FOR UPDATE inside a short transaction
(GetProjectResourceInWorkspaceForUpdate + Begin/WithTx, same shape as
CreateAgent). Competing updates block on the row until the write lands,
then re-read fresh. This keeps the local_directory legacy label-mirror
writeback intact, which a presence-aware column rewrite alone would have
broken for mirror-triggered ref updates.

Review follow-ups:

- The regression test no longer leaves the race to the scheduler. Before
  resuming the paused label request, it waits until the ref request has
  either completed outright (the only exit on the pre-fix code, which
  never blocks) or is blocked waiting for the label request's row lock
  (the only exit with the fix, seen as wait_event_type='Lock' on the
  backend running the FOR UPDATE query, checked via pg_stat_activity).
  The two signals are mutually exclusive, so the test fails
  deterministically without the lock and passes with it.
- findLocalDirectoryConflict and requireWorktreeCapableDaemon now read
  through caller-provided queries instead of the handler's pool: on the
  update path they run inside the transaction, so a locked request no
  longer borrows a second pooled connection per request.
- Added a local_directory concurrency case: a label-only update racing
  an execution_mode update through the label-mirror ref rewrite.

The regression tests fail on the pre-fix code ("stored ref = main, want
the concurrent ref update (release)" / "stored execution_mode = worktree,
want the concurrent mode update (cleared = in_place)") and pass with the
lock, 20/20 runs each way.

Fixes #9238

Generated-by: GLM-5.3-Flash (ZCode)
2026-10-26 15:20:52 +08:00

61 lines
1.9 KiB
SQL

-- name: ListProjectResources :many
SELECT * FROM project_resource
WHERE project_id = $1
ORDER BY position ASC, created_at ASC;
-- name: ListProjectResourcesInWorkspace :many
-- Workspace-scoped read for the daemon claim path. project_resource carries its
-- own workspace_id, so a corrupt project reference cannot pull another tenant's
-- repository URLs or local paths into a claim response.
SELECT * FROM project_resource
WHERE project_id = $1 AND workspace_id = $2
ORDER BY position ASC, created_at ASC;
-- name: ListProjectResourcesForProjects :many
SELECT * FROM project_resource
WHERE project_id = ANY(sqlc.arg('project_ids')::uuid[])
ORDER BY project_id, position ASC, created_at ASC;
-- name: GetProjectResource :one
SELECT * FROM project_resource
WHERE id = $1;
-- name: GetProjectResourceInWorkspace :one
SELECT * FROM project_resource
WHERE id = $1 AND workspace_id = $2;
-- name: GetProjectResourceInWorkspaceForUpdate :one
-- Same row as GetProjectResourceInWorkspace, but takes a row lock held until
-- the caller's transaction ends, serializing the read-decide-write flow in
-- UpdateProjectResource against competing partial updates (#9238).
SELECT * FROM project_resource
WHERE id = $1 AND workspace_id = $2
FOR UPDATE;
-- name: CreateProjectResource :one
INSERT INTO project_resource (
project_id, workspace_id, resource_type, resource_ref, label, position, created_by
) VALUES (
$1, $2, $3, $4, $5, $6, $7
) RETURNING *;
-- name: UpdateProjectResource :one
UPDATE project_resource
SET resource_ref = $2,
label = $3,
position = $4
WHERE id = $1
RETURNING *;
-- name: DeleteProjectResource :exec
DELETE FROM project_resource WHERE id = $1;
-- name: CountProjectResources :one
SELECT count(*) FROM project_resource WHERE project_id = $1;
-- name: GetProjectResourceCounts :many
SELECT project_id, count(*)::bigint AS resource_count
FROM project_resource
WHERE project_id = ANY(sqlc.arg('project_ids')::uuid[])
GROUP BY project_id;