mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
* perf(workspace): make teardown task discovery index-driven
Workspace teardown found a workspace's tasks with a three-way
`OR agent_id IN (…) OR issue_id IN (…) OR runtime_id IN (…)`, three times
per delete, and swept task_token with the same shape. PostgreSQL costs
that as a Seq Scan on agent_task_queue — the largest table in the
database (5.08M rows / 8.3 GB in production) — so deleting even a tiny
workspace paid for the whole table, inside the transaction that holds the
workspace row lock and global advisory lock 4246.
Resolve the task set one owner key at a time instead, in the handler, and
reuse that snapshot for every task-keyed step. Measured on a 1.2M-row
synthetic copy of the production shape (937 agents / 109 issues / 12
runtimes): the OR form and a 937-element `= ANY(array)` form both Seq
Scan; single-key equality is a Bitmap Index Scan. A UNION of parent-key
joins is worse than the OR (three hash joins, each seq scanning the
table), which is why that earlier suggestion was dropped.
All three ownership paths are preserved: nothing enforces that a task's
agent, runtime and issue share a workspace, so dropping a path would turn
a performance fix into a tenant-cleanup bug.
task_token needs no loop. Its workspace_id has been NOT NULL since
migration 108, so the workspace key alone covers this workspace's rows,
and the two extra OR arms only ever matched tokens owned by another
workspace whose task or agent lives here — both NOT NULL FKs with
ON DELETE CASCADE, so the later task/agent deletes remove exactly those.
Two indexes make the new shapes index-eligible:
- 273 agent_task_queue(runtime_id): every existing runtime_id index is
partial, so all-status consumers had none. This also covers the FK's
own ON DELETE CASCADE probe, which runs per deleted agent_runtime row
on the ordinary delete path.
- 274 task_token(workspace_id): the column has never been indexed.
Measured block usage for the same workspace, before → after:
agent_task_queue discovery 25,665 → 14,485 blocks; task_token sweep
4,086 → 3 blocks. The point is not the ratio at this size but that the
cost now scales with the workspace being deleted instead of with the
global table.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): fence, bound and un-cascade the teardown task sweep
Addresses all four blocking findings from the MUL-5999 review.
1. Concurrency (correctness / tenant isolation). The task set is no longer
a lock-free snapshot reused across steps. Teardown now locks every row
through which a task can belong to the workspace — its agents, issues
and runtimes — FOR UPDATE, and holds them for the rest of the
transaction. Inserting an agent_task_queue row takes FOR KEY SHARE on
each referenced owner, which conflicts with FOR UPDATE, so a concurrent
enqueue (or a reassignment onto one of these runtimes) can no longer
slip a task in behind the sweep. Locking the tasks alone could not do
that: a row that does not exist yet cannot be locked. This is the same
technique the handler already uses on the workspace row to keep
CreateChatSession out of the delete window.
The per-batch page query also takes FOR UPDATE, so under READ COMMITTED
PostgreSQL re-checks ownership after each lock: a task a concurrent
commit already moved elsewhere is skipped rather than deleted on a
stale claim, and a task we locked cannot move before we delete it.
Deadlock (40P01) joins lock_timeout (55P03) as a retryable 503, because
the owner locks can be taken in the opposite order by a concurrent task
update.
2. Cleanup ownership (no cascade reliance). task_token keeps all three
explicit paths, split into separate index-driven statements instead of
one OR: workspace_id in DeleteWorkspaceLeafData, task_id in
DeleteTaskBatch, agent_id in DeleteTaskTokensByAgent. Nothing in the
teardown graph now depends on ON DELETE CASCADE to finish the job.
3. Bounded memory. The whole-workspace task array is gone. The sweep pages
through one owner key at a time with LIMIT 1000 and deletes exactly the
page it read, so process memory, the array parameter and each DELETE's
row count are bounded by the batch size rather than by a workspace's
lifetime task count. The task-keyed arms moved out of the leaf-data
statement into DeleteTaskBatch, which runs per page, and the separate
whole-workspace task delete is gone.
4. Migration retryability. The concurrent-index cleanup hooks are now
derived from a version -> index-name map that covers 257, 261 and
273-277, so an interrupted CREATE INDEX CONCURRENTLY cannot be mistaken
for success on retry.
Three more indexes, each an unindexed FK referencing column that the
per-batch statements need and that the FK's own ON DELETE action already
scanned per row on ordinary deletes: task_token(agent_id),
chat_draft_restore(task_id), autopilot_run(task_id).
Plans re-verified on a 1.2M-row synthetic copy of the prod shape with the
child tables populated: page query is a Bitmap Index Scan under LockRows;
task_usage, chat_draft_restore, task_token and autopilot_run arms are all
index scans.
Tests: the ownership-path test now asserts the sweep is self-sufficient
inside the transaction, before any owner row is deleted, so a cascade
cannot stand in for teardown. New tests cover the enqueue / reassignment
fence (blocked writers, unrelated writer unaffected), multi-page sweeps
and the page bound, plus a migration-retry repair test and a check that
every cleanup hook names the index its migration builds.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): keyset-page the teardown sweep and fail closed on leaks
Addresses the second review round on MUL-5999.
1. Bounded scan, not just bounded result. The page query was
`owner = $1 ORDER BY id LIMIT n` against indexes that cannot produce id
order, so the executor had to read the owner's whole task set before
applying LIMIT — quadratic in pages for a busy owner, which is the case
this issue is about. Measured on a 550k-row probe: the old shape spent
11,630 buffers per page (planner chose a full pkey index scan; on other
statistics it chose an explicit Sort), the keyset shape spends ~2,000
and a later page costs the same as the first.
Pages are now `owner = $1 AND id > $cursor ORDER BY id LIMIT n` against
composite indexes: 273 becomes (runtime_id, id), 278 adds (agent_id, id),
279 adds (issue_id, id) and 280 drops the single-column issue_id index it
supersedes so the hot table does not carry both.
2. Owner enumeration is bounded too. The three LockWorkspaceTaskOwner*
statements no longer return ids at all — they are a set-based write fence
and now cost the API process nothing. Owners are walked separately in
keyset pages of 500 (281-283 add the (workspace_id, id) indexes those
pages need), so a workspace with a huge agent or issue set never has its
whole owner list held in memory.
The keyset column is id rather than a natural key on purpose: a cursor
column must be immutable, and renaming an owner mid-teardown would
otherwise let it sort behind the cursor and escape the sweep.
3. The fence is no longer the only guarantee. Blocking a concurrent enqueue
still relies on an INSERT taking FOR KEY SHARE on the owner rows it
references, which is a property of the legacy FKs rather than a protocol
every enqueue path opts into. Two things now backstop it:
- every owner is re-swept until a pass finds nothing, and teardown fails
closed (rolling the whole transaction back) if tasks keep arriving,
instead of committing a partially cleaned tenant;
- a schema-contract test asserts the three owner FKs still exist, so a
migration that removes them turns the missing fence into a red build
rather than a silent hole.
An explicit deleting-state protocol shared with all seven enqueue paths
remains the right long-term fix and is out of scope here.
End-to-end teardown, real handler, identical synthetic data, main vs this
branch (1.2M-row agent_task_queue, 200k task_token, 300k task_usage):
small workspace, 5k tasks: 29,247 ms -> 1,013 ms (4.38M -> 0.27M blocks)
busy workspace, 60k tasks: 243,863 ms -> 1,724 ms (5.76M -> 2.23M blocks)
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): fence task writes in-transaction, independent of the FKs
Addresses the third review round on MUL-5999.
1. The re-scan did not close the window, and the reviewer is right that it
cannot: a pass that comes back empty returns success, and a task
inserted after that final scan but before COMMIT is not caught by any
number of passes. The only thing that closed that window was the
FOR KEY SHARE an INSERT takes on the agent / issue / runtime rows it
references — a side effect of foreign keys that workspace_delete.sql
itself documents as an expand-phase net a later contract removes.
Migration 284 replaces it with an explicit fence: a BEFORE INSERT OR
UPDATE OF (agent_id, issue_id, runtime_id) trigger on agent_task_queue
that takes FOR KEY SHARE on the workspace row behind every owner the row
references, and raises if one is gone. That conflicts with the FOR UPDATE
LockWorkspaceForDelete already holds for the whole teardown transaction,
so a task write against a workspace being torn down blocks until teardown
finishes and then either proceeds (rolled back) or fails closed
(committed).
A trigger rather than a per-call-site check because the fence has to be
in the same transaction as the write: there are seven INSERTs and several
ownership-changing UPDATEs across agent.sql, autopilot.sql, chat.sql and
runtime.sql, several of them single autocommit statements, so a separate
"take the fence" statement from the caller would be its own transaction
and protect nothing. In-transaction by construction, covers every current
path, and cannot be forgotten by a path added later.
TestTaskWriteFence_BlocksOnWorkspaceLockAlone isolates the fence from the
FKs: the holder locks ONLY the workspace row — no agent, issue or runtime
row — so no FK on agent_task_queue has anything to block on, since none of
them references workspace. Inserts through all three ownership paths and a
reassignment all block; an unrelated workspace's insert and a status-only
update still pass. Dropping the trigger fails that test.
Cost, measured with 5,000 bare inserts: 147 ms without the fence,
238 ms with it — about 18 us per inserted row for up to three indexed
lookups and a shared row lock. It does not fire on the hot claim /
dispatch / complete path, which updates status and timestamps rather than
ownership.
The verification passes stay as a self-check, no longer described as the
concurrency guarantee.
2. Migration 280 is withdrawn. Its down path rebuilt the superseded index
with CREATE INDEX CONCURRENTLY, and the runner only runs INVALID-index
cleanup hooks in the up direction, so an interrupted rollback would leave
an invalid index that a later `down` skips — and rolling back 279 too
would then leave no usable issue_id index at all. The redundant
single-column index stays for now; dropping it needs its own migration
with a direction-aware cleanup. This also settles the stale comment in
task_usage.sql, which still names the old index and remains correct.
3. Keyset cursors no longer start from the all-zero uuid, which is itself a
valid uuid and would have made a row with that id invisible forever.
Every walk now has an explicit first-page query with no cursor predicate.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(workspace): move the task-write fence into the write statements
Addresses the fourth review round on MUL-5999.
1. The trigger is gone. Migration 284 now defines
lock_task_owner_workspaces(agent_id, issue_id, runtime_id), a predicate
that every statement writing agent_task_queue ownership calls in its own
WHERE clause:
INSERT INTO agent_task_queue (...)
SELECT ... WHERE lock_task_owner_workspaces($1, $3, $2)
Nine statements now carry it: the seven task INSERTs (agent.sql x5,
autopilot.sql, chat.sql), LinkTaskToIssue, and ReassignTasksToRuntime —
the last through a MATERIALIZED CTE so a bulk UPDATE evaluates the
VOLATILE predicate once instead of per row. Clearing runtime_id is
exempt: it cannot move a task into another workspace.
Calling it from the writing statement keeps the lock in the writer's own
transaction, which a separate caller-issued statement could not do for
the autocommit enqueue paths. TestTaskOwnershipWritesCallTheFence is the
trade for dropping the trigger: it scans the query files and fails the
build if a statement writes ownership without the fence.
2. The fence no longer defers to the foreign keys. The trigger version
counted only owner references that still resolved, so a deleted owner
made itself invisible: with all three gone it returned early, and with
one of three gone the count still matched. The predicate now compares
resolved owners against the number of owner arguments supplied and
returns false on any mismatch, so a stale write is rejected on its own
terms. TestLockTaskOwnerWorkspaces_RejectsUnresolvableOwner covers each
shape, including the one-of-three case.
3. Deterministic lock order. The workspace rows are locked with
ORDER BY w.id, so mirrored cross-workspace writes cannot take the same
pair in opposite orders. Honest note on the test: 25 mirrored concurrent
pairs never deadlock either way, because the current plan happens to
return the rows in a stable order — the ORDER BY is what makes that a
guarantee rather than an accident of the plan, and the test is a
regression test for the symptom.
4. The fail-closed test now tests what its name says. The previous one only
inserted a neighbour-owned task and could not have failed. The
replacement runs the actual race: a writer calls the real
CreateAgentTask, blocks on the workspace lock, teardown deletes the
workspace and commits, and the writer must come back with pgx.ErrNoRows
— the fence's own verdict. An FK doing the work instead surfaces as
SQLSTATE 23503, which the assertion rejects explicitly.
Cost: 5,000 predicate calls in 53.7 ms, about 11 us per fenced write, for
up to three indexed lookups and a shared row lock. Status, dispatch and
completion updates do not call it.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): stop the legacy merge when the task fence refuses
Addresses the P1 from the fifth review round on MUL-5999.
ReassignTasksToRuntime returned only a row count, so a fenced refusal was
indistinguishable from "the old runtime had no tasks". mergeLegacyRuntimes
read both as success and went on to DeleteAgentRuntime(old), where
agent_task_queue's ON DELETE CASCADE silently took the history the
reassignment exists to preserve — and once that FK goes away, the same path
would instead leave tasks pointing at a deleted runtime. Either way the
merge was leaning on the cascade, which is what this PR set out to stop.
The query now returns the fence verdict alongside the count:
SELECT (SELECT ok FROM fence) AS fence_ok,
(SELECT count(*) FROM reassigned) AS reassigned_tasks
and the merge is a single helper, mergeLegacyRuntime, that aborts with
errRuntimeMergeFenced before touching anything else when fence_ok is false.
Tasks now move FIRST, because that is the step carrying the fence: nothing
else in the merge — agent reassignment, legacy daemon_id bookkeeping, the
old runtime delete — runs unless the fence agreed. The next daemon
registration re-runs the merge, by which time the workspace is either gone
or its teardown rolled back.
TestMergeLegacyRuntime_KeepsOldRuntimeWhenFenceRefuses covers it: with a
vanished merge target the old runtime and its task both survive and the task
stays on the old runtime, while a merge onto a live runtime still completes.
Removing the abort fails that test — and fails it with SQLSTATE 23503 from
agent_runtime_id_fkey, which is precisely the "an FK is doing the work"
shape the review objected to.
Also replaced the fixed 300 ms sleep in the queued-writer race with a poll
on pg_stat_activity for a backend actually parked on a lock, so the test
cannot pass without the race being set up (it now fails on timeout instead).
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): make the legacy runtime merge a single transaction
Addresses the P1 from the sixth review round on MUL-5999.
The merge ran four autocommit statements, so the fence's workspace lock
ended with the statement that took it. Two ways that left a tenant
inconsistent while daemon registration still reported success:
* a transient failure on the agent reassignment committed the task move
and nothing else, leaving tasks on the new runtime and their agents on
the old one until some later registration happened to retry;
* in the gap after the task commit, a workspace teardown could lock and
delete the target runtime — and its UnbindTasksFromRuntime step clears
runtime_id on exactly the tasks that had just moved. The next merge
looks tasks up by the OLD runtime id, so that history was detached for
good.
mergeLegacyRuntime now opens a transaction and runs the whole merge on one
qtx: fenced task reassignment, agent reassignment, the legacy daemon_id
audit write, and the old-runtime delete, then commits. The fence stays the
first statement, so the workspace lock it takes is held until COMMIT — a
teardown cannot start deleting the target mid-merge — and the lock order
against teardown is unchanged. Any failure returns an error and rolls the
whole thing back.
The audit write is no longer best-effort: inside a transaction a failed
statement poisons the rest, so it either lands with the merge or the merge
is abandoned.
TestMergeLegacyRuntime_RollsBackWhenAStepFails injects the failure in the
database rather than through a code seam — a trigger makes the agent move
raise — and asserts the task, the agent, both runtime rows and the audit
column are all exactly as they were, then that the same merge completes
once the injection is removed. Reverting to autocommit fails it with
"task moved to <new runtime>; the task reassignment was not rolled back".
Also took the non-blocking suggestion: the queued-writer race now has the
writer report its own pg_backend_pid() over a channel and waits for that
specific backend to park on a lock, instead of matching statement text
against every backend on a shared test database.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* fix(runtime): fence late writes against the runtime a merge is collapsing
Addresses the P1 from the seventh review round on MUL-5999, which the
reviewer reproduced rather than inferred.
The fence locked the workspace row and nothing else. A task write takes
FOR KEY SHARE there, and so did the legacy runtime merge — and two KEY SHARE
locks do not conflict. So an enqueue against the OLD runtime went straight
through after the merge had scanned its tasks, and DeleteAgentRuntime then
removed that brand-new task through agent_task_queue's ON DELETE CASCADE.
Making the merge one transaction widened the window rather than creating it:
the agent's new runtime_id is invisible until COMMIT, so concurrent writers
keep resolving the old runtime for the whole merge.
Both sides of the contract now lock the owner rows too, in one order:
workspace rows (by id), then agent, issue, runtime.
* lock_task_owner_rows (migration 284, renamed from
lock_task_owner_workspaces) takes FOR KEY SHARE on each owner row after
the workspace rows, and re-checks that they still exist. That is what
conflicts with a delete.
* The merge takes FOR KEY SHARE on the workspace and then FOR UPDATE on
both runtime rows in id order, before the task scan, and holds them to
COMMIT. FOR UPDATE is the level that conflicts with a writer's KEY
SHARE; both runtimes in one ordered statement so two merges running in
opposite directions cannot take the pair in opposite orders. Locking
fewer rows than asked for means one disappeared, and the merge is
abandoned.
A late writer therefore either commits before the merge takes the lock — in
which case the scan that follows sees its task and moves it — or waits, and
then finds the runtime gone and writes no row.
TestRuntimeMerge_LateWriteToOldRuntimeIsFencedNotCascaded drives the merge
statement by statement so it can stop where the window was (after the agent
update, before the old runtime is deleted), enqueues against the old runtime
through the generated query, and requires that the writer is parked on a
lock, that it comes back as pgx.ErrNoRows, and that the merge's own history
ends up on the target. It explicitly rejects both 23503 and a row that
lands and then vanishes.
Both halves are load-bearing: dropping the runtime lock from the fence, or
the FOR UPDATE from the merge, fails that test — the latter with "the late
write was rejected by a foreign key (23503), not by the fence".
Cost: 5,000 predicate calls in 74.4 ms, ~15 us per fenced write (was ~11 us
with the workspace lock alone).
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
* docs(workspace): correct the fence comment and drop a trailing newline
Two review nits, no behaviour change.
lockWorkspaceTaskOwners still described migration 284 as a BEFORE trigger on
agent_task_queue. It became a called predicate, lock_task_owner_rows, in
9fc1d79, and this comment is the place someone reads to understand how the
fence works — so it now says what it is: a predicate every ownership-writing
statement calls in its own WHERE clause, taking FOR KEY SHARE on the
workspace rows and then the owner rows.
Also removed the blank line at EOF in the migration, which git diff --check
was flagging.
Refs MUL-5999
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
1076 lines
35 KiB
Go
1076 lines
35 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: workspace_delete.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const deleteTaskBatch = `-- name: DeleteTaskBatch :exec
|
|
WITH
|
|
batch AS MATERIALIZED (
|
|
SELECT id FROM unnest($1::uuid[]) AS t(id)
|
|
),
|
|
deleted_task_usage AS (
|
|
DELETE FROM task_usage WHERE task_id IN (SELECT id FROM batch)
|
|
),
|
|
deleted_task_messages AS (
|
|
DELETE FROM task_message WHERE task_id IN (SELECT id FROM batch)
|
|
),
|
|
deleted_task_tokens AS (
|
|
DELETE FROM task_token WHERE task_id IN (SELECT id FROM batch)
|
|
),
|
|
deleted_channel_outbound_cards AS (
|
|
DELETE FROM channel_outbound_card_message WHERE task_id IN (SELECT id FROM batch)
|
|
),
|
|
deleted_lark_outbound_cards AS (
|
|
DELETE FROM lark_outbound_card_message WHERE task_id IN (SELECT id FROM batch)
|
|
),
|
|
deleted_draft_restores AS (
|
|
DELETE FROM chat_draft_restore WHERE task_id IN (SELECT id FROM batch)
|
|
)
|
|
DELETE FROM agent_task_queue WHERE id IN (SELECT id FROM batch)
|
|
`
|
|
|
|
// Deletes one bounded batch of tasks together with everything that hangs off
|
|
// them, every arm keyed by task_id against an existing index, then the task rows
|
|
// by primary key. The legacy FK cascades stay a safety net only: this statement
|
|
// is what actually removes the rows, so teardown keeps working when they go.
|
|
func (q *Queries) DeleteTaskBatch(ctx context.Context, taskIds []pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteTaskBatch, taskIds)
|
|
return err
|
|
}
|
|
|
|
const deleteTaskTokensByAgent = `-- name: DeleteTaskTokensByAgent :exec
|
|
DELETE FROM task_token WHERE agent_id = $1
|
|
`
|
|
|
|
// The third explicit task_token path. workspace_id (in DeleteWorkspaceLeafData)
|
|
// and task_id (in DeleteTaskBatch) do not cover a token whose own workspace_id
|
|
// points at a neighbour while its agent lives here, and teardown must not fall
|
|
// back on the agent_id cascade for it. Uses idx_task_token_agent_id
|
|
// (migration 275); one agent key per call for the same planner reason as
|
|
// ListTaskIDsByAgentBatch.
|
|
func (q *Queries) DeleteTaskTokensByAgent(ctx context.Context, agentID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteTaskTokensByAgent, agentID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceAdministration = `-- name: DeleteWorkspaceAdministration :exec
|
|
WITH
|
|
deleted_members AS (
|
|
DELETE FROM member WHERE member.workspace_id = $1
|
|
),
|
|
deleted_notification_preferences AS (
|
|
DELETE FROM notification_preference
|
|
WHERE notification_preference.workspace_id = $1
|
|
),
|
|
deleted_pins AS (
|
|
DELETE FROM pinned_item WHERE pinned_item.workspace_id = $1
|
|
),
|
|
deleted_daemon_tokens AS (
|
|
DELETE FROM daemon_token WHERE daemon_token.workspace_id = $1
|
|
),
|
|
detached_feedback AS (
|
|
UPDATE feedback
|
|
SET workspace_id = NULL
|
|
WHERE feedback.workspace_id = $1
|
|
),
|
|
detached_client_usage AS (
|
|
UPDATE client_usage_daily
|
|
SET workspace_id = NULL
|
|
WHERE client_usage_daily.workspace_id = $1
|
|
)
|
|
DELETE FROM workspace_invitation
|
|
WHERE workspace_invitation.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceAdministration(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceAdministration, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceAgents = `-- name: DeleteWorkspaceAgents :exec
|
|
DELETE FROM agent WHERE agent.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceAgents(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceAgents, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceAutopilotChildren = `-- name: DeleteWorkspaceAutopilotChildren :exec
|
|
WITH
|
|
deleted_triggers AS (
|
|
DELETE FROM autopilot_trigger
|
|
WHERE autopilot_id IN (
|
|
SELECT id FROM autopilot WHERE autopilot.workspace_id = $1
|
|
)
|
|
)
|
|
DELETE FROM autopilot_rule_version
|
|
WHERE autopilot_rule_version.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceAutopilotChildren(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilotChildren, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceAutopilotRuns = `-- name: DeleteWorkspaceAutopilotRuns :exec
|
|
DELETE FROM autopilot_run
|
|
WHERE autopilot_id IN (
|
|
SELECT id FROM autopilot WHERE autopilot.workspace_id = $1
|
|
)
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceAutopilotRuns(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilotRuns, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceAutopilots = `-- name: DeleteWorkspaceAutopilots :exec
|
|
DELETE FROM autopilot WHERE autopilot.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceAutopilots(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceAutopilots, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceChatMessages = `-- name: DeleteWorkspaceChatMessages :exec
|
|
DELETE FROM chat_message
|
|
WHERE chat_session_id IN (
|
|
SELECT id FROM chat_session WHERE chat_session.workspace_id = $1
|
|
)
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceChatMessages(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceChatMessages, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceComments = `-- name: DeleteWorkspaceComments :exec
|
|
DELETE FROM comment WHERE comment.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceComments(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceComments, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceCommunicationRoots = `-- name: DeleteWorkspaceCommunicationRoots :exec
|
|
WITH
|
|
deleted_sessions AS (
|
|
DELETE FROM chat_session WHERE chat_session.workspace_id = $1
|
|
),
|
|
deleted_channel_installations AS (
|
|
DELETE FROM channel_installation
|
|
WHERE channel_installation.workspace_id = $1
|
|
)
|
|
DELETE FROM lark_installation WHERE lark_installation.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceCommunicationRoots(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceCommunicationRoots, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceConnections = `-- name: DeleteWorkspaceConnections :exec
|
|
WITH deleted_github_installations AS (
|
|
DELETE FROM github_installation
|
|
WHERE github_installation.workspace_id = $1
|
|
)
|
|
DELETE FROM vcs_connection WHERE vcs_connection.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceConnections(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceConnections, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceIssueRoots = `-- name: DeleteWorkspaceIssueRoots :exec
|
|
WITH
|
|
deleted_issues AS (
|
|
DELETE FROM issue WHERE issue.workspace_id = $1
|
|
),
|
|
deleted_labels AS (
|
|
DELETE FROM issue_label WHERE issue_label.workspace_id = $1
|
|
),
|
|
deleted_properties AS (
|
|
DELETE FROM issue_property WHERE issue_property.workspace_id = $1
|
|
),
|
|
deleted_issue_views AS (
|
|
DELETE FROM issue_view WHERE issue_view.workspace_id = $1
|
|
),
|
|
deleted_issue_view_preferences AS (
|
|
DELETE FROM issue_view_preference
|
|
WHERE issue_view_preference.workspace_id = $1
|
|
)
|
|
DELETE FROM quick_action WHERE quick_action.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceIssueRoots(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceIssueRoots, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceLeafData = `-- name: DeleteWorkspaceLeafData :exec
|
|
WITH
|
|
ws_agents AS MATERIALIZED (
|
|
SELECT id FROM agent WHERE workspace_id = $1
|
|
),
|
|
ws_issues AS MATERIALIZED (
|
|
SELECT id FROM issue WHERE workspace_id = $1
|
|
),
|
|
ws_labels AS MATERIALIZED (
|
|
SELECT id FROM issue_label WHERE workspace_id = $1
|
|
),
|
|
ws_skills AS MATERIALIZED (
|
|
SELECT id FROM skill WHERE workspace_id = $1
|
|
),
|
|
ws_squads AS MATERIALIZED (
|
|
SELECT id FROM squad WHERE workspace_id = $1
|
|
),
|
|
ws_sessions AS MATERIALIZED (
|
|
SELECT id FROM chat_session WHERE workspace_id = $1
|
|
),
|
|
ws_autopilots AS MATERIALIZED (
|
|
SELECT id FROM autopilot WHERE workspace_id = $1
|
|
),
|
|
ws_github_prs AS MATERIALIZED (
|
|
SELECT id FROM github_pull_request WHERE workspace_id = $1
|
|
),
|
|
ws_vcs_prs AS MATERIALIZED (
|
|
SELECT id FROM vcs_pull_request WHERE workspace_id = $1
|
|
),
|
|
ws_vcs_connections AS MATERIALIZED (
|
|
SELECT id FROM vcs_connection WHERE workspace_id = $1
|
|
),
|
|
ws_channel_installations AS MATERIALIZED (
|
|
SELECT id FROM channel_installation WHERE workspace_id = $1
|
|
),
|
|
ws_lark_installations AS MATERIALIZED (
|
|
SELECT id FROM lark_installation WHERE workspace_id = $1
|
|
),
|
|
deleted_task_tokens AS (
|
|
DELETE FROM task_token
|
|
WHERE workspace_id = $1
|
|
),
|
|
deleted_hourly_dirty AS (
|
|
DELETE FROM task_usage_hourly_dirty WHERE workspace_id = $1
|
|
),
|
|
deleted_hourly AS (
|
|
DELETE FROM task_usage_hourly WHERE workspace_id = $1
|
|
),
|
|
deleted_attachments AS (
|
|
DELETE FROM attachment WHERE workspace_id = $1
|
|
),
|
|
deleted_channel_outbound_cards AS (
|
|
DELETE FROM channel_outbound_card_message
|
|
WHERE chat_session_id IN (SELECT id FROM ws_sessions)
|
|
),
|
|
deleted_lark_outbound_cards AS (
|
|
DELETE FROM lark_outbound_card_message
|
|
WHERE chat_session_id IN (SELECT id FROM ws_sessions)
|
|
),
|
|
deleted_draft_restores AS (
|
|
DELETE FROM chat_draft_restore
|
|
WHERE chat_session_id IN (SELECT id FROM ws_sessions)
|
|
),
|
|
deleted_agent_builder_drafts AS (
|
|
DELETE FROM agent_builder_draft WHERE workspace_id = $1
|
|
),
|
|
deleted_comment_reactions AS (
|
|
DELETE FROM comment_reaction WHERE workspace_id = $1
|
|
),
|
|
deleted_issue_reactions AS (
|
|
DELETE FROM issue_reaction WHERE workspace_id = $1
|
|
),
|
|
deleted_activity AS (
|
|
DELETE FROM activity_log WHERE workspace_id = $1
|
|
),
|
|
deleted_inbox AS (
|
|
DELETE FROM inbox_item WHERE workspace_id = $1
|
|
),
|
|
deleted_issue_dependencies AS (
|
|
DELETE FROM issue_dependency
|
|
WHERE issue_id IN (SELECT id FROM ws_issues)
|
|
OR depends_on_issue_id IN (SELECT id FROM ws_issues)
|
|
),
|
|
deleted_issue_subscribers AS (
|
|
DELETE FROM issue_subscriber
|
|
WHERE issue_id IN (SELECT id FROM ws_issues)
|
|
),
|
|
deleted_issue_labels AS (
|
|
DELETE FROM issue_to_label
|
|
WHERE issue_id IN (SELECT id FROM ws_issues)
|
|
OR label_id IN (SELECT id FROM ws_labels)
|
|
),
|
|
deleted_agent_labels AS (
|
|
DELETE FROM agent_to_label
|
|
WHERE agent_id IN (SELECT id FROM ws_agents)
|
|
OR label_id IN (SELECT id FROM ws_labels)
|
|
),
|
|
deleted_skill_labels AS (
|
|
DELETE FROM skill_to_label
|
|
WHERE skill_id IN (SELECT id FROM ws_skills)
|
|
OR label_id IN (SELECT id FROM ws_labels)
|
|
),
|
|
deleted_issue_github_links AS (
|
|
DELETE FROM issue_pull_request
|
|
WHERE issue_id IN (SELECT id FROM ws_issues)
|
|
OR pull_request_id IN (SELECT id FROM ws_github_prs)
|
|
),
|
|
deleted_issue_vcs_links AS (
|
|
DELETE FROM issue_vcs_pull_request
|
|
WHERE issue_id IN (SELECT id FROM ws_issues)
|
|
OR pull_request_id IN (SELECT id FROM ws_vcs_prs)
|
|
),
|
|
deleted_agent_invocation_targets AS (
|
|
DELETE FROM agent_invocation_target
|
|
WHERE agent_id IN (SELECT id FROM ws_agents)
|
|
),
|
|
deleted_agent_skills AS (
|
|
DELETE FROM agent_skill
|
|
WHERE agent_id IN (SELECT id FROM ws_agents)
|
|
OR skill_id IN (SELECT id FROM ws_skills)
|
|
),
|
|
deleted_skill_files AS (
|
|
DELETE FROM skill_file
|
|
WHERE skill_id IN (SELECT id FROM ws_skills)
|
|
),
|
|
deleted_daemon_connections AS (
|
|
DELETE FROM daemon_connection
|
|
WHERE agent_id IN (SELECT id FROM ws_agents)
|
|
),
|
|
deleted_squad_members AS (
|
|
DELETE FROM squad_member
|
|
WHERE squad_id IN (SELECT id FROM ws_squads)
|
|
),
|
|
deleted_project_resources AS (
|
|
DELETE FROM project_resource WHERE workspace_id = $1
|
|
),
|
|
deleted_autopilot_collaborators AS (
|
|
DELETE FROM autopilot_collaborator
|
|
WHERE autopilot_id IN (SELECT id FROM ws_autopilots)
|
|
),
|
|
deleted_autopilot_subscribers AS (
|
|
DELETE FROM autopilot_subscriber
|
|
WHERE autopilot_id IN (SELECT id FROM ws_autopilots)
|
|
),
|
|
deleted_webhook_deliveries AS (
|
|
DELETE FROM webhook_delivery WHERE workspace_id = $1
|
|
),
|
|
deleted_github_check_runs AS (
|
|
DELETE FROM github_pull_request_check_run
|
|
WHERE pr_id IN (SELECT id FROM ws_github_prs)
|
|
),
|
|
deleted_github_check_suites AS (
|
|
DELETE FROM github_pull_request_check_suite
|
|
WHERE pr_id IN (SELECT id FROM ws_github_prs)
|
|
),
|
|
deleted_pending_github_suites AS (
|
|
DELETE FROM github_pending_check_suite WHERE workspace_id = $1
|
|
),
|
|
deleted_vcs_commit_statuses AS (
|
|
DELETE FROM vcs_commit_status
|
|
WHERE connection_id IN (SELECT id FROM ws_vcs_connections)
|
|
),
|
|
deleted_channel_chat_bindings AS (
|
|
DELETE FROM channel_chat_session_binding
|
|
WHERE installation_id IN (SELECT id FROM ws_channel_installations)
|
|
OR chat_session_id IN (SELECT id FROM ws_sessions)
|
|
),
|
|
deleted_channel_inbound_dedup AS (
|
|
DELETE FROM channel_inbound_message_dedup
|
|
WHERE installation_id IN (SELECT id FROM ws_channel_installations)
|
|
),
|
|
deleted_channel_inbound_audit AS (
|
|
DELETE FROM channel_inbound_audit
|
|
WHERE installation_id IN (SELECT id FROM ws_channel_installations)
|
|
),
|
|
deleted_channel_user_bindings AS (
|
|
DELETE FROM channel_user_binding WHERE workspace_id = $1
|
|
),
|
|
deleted_channel_binding_tokens AS (
|
|
DELETE FROM channel_binding_token WHERE workspace_id = $1
|
|
),
|
|
deleted_lark_chat_bindings AS (
|
|
DELETE FROM lark_chat_session_binding
|
|
WHERE installation_id IN (SELECT id FROM ws_lark_installations)
|
|
OR chat_session_id IN (SELECT id FROM ws_sessions)
|
|
),
|
|
deleted_lark_inbound_dedup AS (
|
|
DELETE FROM lark_inbound_message_dedup
|
|
WHERE installation_id IN (SELECT id FROM ws_lark_installations)
|
|
),
|
|
deleted_lark_inbound_audit AS (
|
|
DELETE FROM lark_inbound_audit
|
|
WHERE installation_id IN (SELECT id FROM ws_lark_installations)
|
|
),
|
|
deleted_lark_user_bindings AS (
|
|
DELETE FROM lark_user_binding WHERE workspace_id = $1
|
|
),
|
|
deleted_lark_binding_tokens AS (
|
|
DELETE FROM lark_binding_token WHERE workspace_id = $1
|
|
)
|
|
UPDATE channel_media_pending_object
|
|
SET state = CASE
|
|
WHEN state = 'tombstoned' THEN 'tombstoned'
|
|
ELSE 'deleting'
|
|
END,
|
|
lease_token = NULL,
|
|
lease_expires_at = NULL,
|
|
next_attempt_at = now(),
|
|
last_error = NULL
|
|
WHERE channel_media_pending_object.workspace_id = $1
|
|
`
|
|
|
|
// Everything task-keyed moved to DeleteTaskBatch, which runs in bounded batches
|
|
// before this step; what is left is keyed by the workspace or by one of the
|
|
// workspace-scoped id sets below.
|
|
// One of three explicit task_token paths. This is the workspace-keyed one;
|
|
// DeleteTaskBatch covers task_id and DeleteTaskTokensByAgent covers agent_id, so
|
|
// a token whose workspace_id points at a neighbour while its task or agent lives
|
|
// here is still removed by this teardown rather than by the FK cascade. The
|
|
// former single statement combined all three with OR, which cost a full scan of
|
|
// task_token (MUL-5999); split, each path is an index scan.
|
|
// Same no-FK chore as chat_draft_restore above. Matched on workspace_id rather
|
|
// than the session set because that column exists precisely so this statement
|
|
// does not have to join through chat_session, which it deletes in this same CTE.
|
|
// Keep the two-system cleanup ledger until object storage has been settled.
|
|
// Moving every row out of pending also prevents a concurrent media bind from
|
|
// attaching an object after the workspace teardown commits. The reconciler
|
|
// performs the idempotent object delete and clears the row afterwards.
|
|
func (q *Queries) DeleteWorkspaceLeafData(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceLeafData, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspacePullRequests = `-- name: DeleteWorkspacePullRequests :exec
|
|
WITH deleted_github_prs AS (
|
|
DELETE FROM github_pull_request
|
|
WHERE github_pull_request.workspace_id = $1
|
|
)
|
|
DELETE FROM vcs_pull_request WHERE vcs_pull_request.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspacePullRequests(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspacePullRequests, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceRuntimesAndProjects = `-- name: DeleteWorkspaceRuntimesAndProjects :exec
|
|
WITH
|
|
deleted_runtimes AS (
|
|
DELETE FROM agent_runtime WHERE agent_runtime.workspace_id = $1
|
|
),
|
|
deleted_profiles AS (
|
|
DELETE FROM runtime_profile WHERE runtime_profile.workspace_id = $1
|
|
)
|
|
DELETE FROM project WHERE project.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceRuntimesAndProjects(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceRuntimesAndProjects, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const deleteWorkspaceSquadsAndSkills = `-- name: DeleteWorkspaceSquadsAndSkills :exec
|
|
WITH deleted_squads AS (
|
|
DELETE FROM squad WHERE squad.workspace_id = $1
|
|
)
|
|
DELETE FROM skill WHERE skill.workspace_id = $1
|
|
`
|
|
|
|
func (q *Queries) DeleteWorkspaceSquadsAndSkills(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteWorkspaceSquadsAndSkills, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const detachTaskBatchReferences = `-- name: DetachTaskBatchReferences :exec
|
|
WITH detached_runs AS (
|
|
UPDATE autopilot_run
|
|
SET task_id = NULL
|
|
WHERE task_id = ANY($1::uuid[])
|
|
)
|
|
UPDATE agent_task_queue
|
|
SET parent_task_id = NULL
|
|
WHERE parent_task_id = ANY($1::uuid[])
|
|
`
|
|
|
|
// Break inbound references to a task batch before deleting it, in the
|
|
// application layer rather than through the FKs' ON DELETE SET NULL. Both
|
|
// referencing columns are indexed (idx_agent_task_queue_parent from migration
|
|
// 055, idx_autopilot_run_task_id from migration 277) so this stays a per-batch
|
|
// index scan instead of the per-row full scan the FK action would do.
|
|
//
|
|
// Kept separate from DeleteTaskBatch: a task can be both a member of the batch
|
|
// and the parent of another member, and updating plus deleting the same row
|
|
// inside one statement is not well defined.
|
|
func (q *Queries) DetachTaskBatchReferences(ctx context.Context, taskIds []pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, detachTaskBatchReferences, taskIds)
|
|
return err
|
|
}
|
|
|
|
const listTaskIDsByAgentFirstPage = `-- name: ListTaskIDsByAgentFirstPage :many
|
|
SELECT id FROM agent_task_queue
|
|
WHERE agent_id = $1
|
|
ORDER BY id
|
|
LIMIT $2
|
|
FOR UPDATE
|
|
`
|
|
|
|
type ListTaskIDsByAgentFirstPageParams struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// First page of the same walk; see ListWorkspaceAgentIDFirstPage for why this is
|
|
// a separate query rather than a zero-uuid cursor, and ListTaskIDsByAgentPage for
|
|
// the single-key / keyset / FOR UPDATE rationale.
|
|
func (q *Queries) ListTaskIDsByAgentFirstPage(ctx context.Context, arg ListTaskIDsByAgentFirstPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listTaskIDsByAgentFirstPage, arg.AgentID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listTaskIDsByAgentPage = `-- name: ListTaskIDsByAgentPage :many
|
|
SELECT id FROM agent_task_queue
|
|
WHERE agent_id = $1 AND id > $2
|
|
ORDER BY id
|
|
LIMIT $3
|
|
FOR UPDATE
|
|
`
|
|
|
|
type ListTaskIDsByAgentPageParams struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
ID pgtype.UUID `json:"id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// One owner key per call, one keyset-paged chunk per call.
|
|
//
|
|
// Single-key equality is deliberate. All three ownership paths must stay (nothing
|
|
// enforces that a task's agent/runtime/issue share a workspace), but expressing
|
|
// them as `OR agent_id IN (subquery) OR …` — or as a UNION of joins, or as
|
|
// `= ANY($1::uuid[])` over a whole workspace's agents — makes the planner
|
|
// estimate a proportional share of the global table and fall back to a Seq Scan
|
|
// on agent_task_queue. A single-key equality is the only form whose row estimate
|
|
// stays small enough to be index-driven regardless of how the workspace's tasks
|
|
// are distributed (MUL-5999).
|
|
//
|
|
// `id > $2 ORDER BY id LIMIT $3` against idx_agent_task_queue_agent_id_keyset
|
|
// (migration 278) bounds the SCAN, not just the result: each page is an index
|
|
// range scan that starts where the previous one stopped. Bounding only the result
|
|
// — `ORDER BY id LIMIT n` against the (agent_id, status) index — puts a Sort over
|
|
// the agent's entire task set in front of every page, so a busy agent costs
|
|
// roughly O(N² / page) for the sweep.
|
|
//
|
|
// FOR UPDATE is load-bearing for correctness, not throughput. It locks the rows
|
|
// this page is about to delete, and under READ COMMITTED PostgreSQL re-checks the
|
|
// WHERE clause after acquiring each lock: a task a concurrent committed UPDATE
|
|
// has already moved to another owner no longer matches and is skipped, and one we
|
|
// did lock cannot be moved away before we delete it.
|
|
func (q *Queries) ListTaskIDsByAgentPage(ctx context.Context, arg ListTaskIDsByAgentPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listTaskIDsByAgentPage, arg.AgentID, arg.ID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listTaskIDsByIssueFirstPage = `-- name: ListTaskIDsByIssueFirstPage :many
|
|
SELECT id FROM agent_task_queue
|
|
WHERE issue_id = $1
|
|
ORDER BY id
|
|
LIMIT $2
|
|
FOR UPDATE
|
|
`
|
|
|
|
type ListTaskIDsByIssueFirstPageParams struct {
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// First page of the same walk; see ListWorkspaceAgentIDFirstPage for why this is
|
|
// a separate query rather than a zero-uuid cursor, and ListTaskIDsByAgentPage for
|
|
// the single-key / keyset / FOR UPDATE rationale.
|
|
func (q *Queries) ListTaskIDsByIssueFirstPage(ctx context.Context, arg ListTaskIDsByIssueFirstPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listTaskIDsByIssueFirstPage, arg.IssueID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listTaskIDsByIssuePage = `-- name: ListTaskIDsByIssuePage :many
|
|
SELECT id FROM agent_task_queue
|
|
WHERE issue_id = $1 AND id > $2
|
|
ORDER BY id
|
|
LIMIT $3
|
|
FOR UPDATE
|
|
`
|
|
|
|
type ListTaskIDsByIssuePageParams struct {
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
ID pgtype.UUID `json:"id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// Uses idx_agent_task_queue_issue_id_keyset (migration 279). See
|
|
// ListTaskIDsByAgentPage for the single-key / keyset / FOR UPDATE rationale.
|
|
func (q *Queries) ListTaskIDsByIssuePage(ctx context.Context, arg ListTaskIDsByIssuePageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listTaskIDsByIssuePage, arg.IssueID, arg.ID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listTaskIDsByRuntimeFirstPage = `-- name: ListTaskIDsByRuntimeFirstPage :many
|
|
SELECT id FROM agent_task_queue
|
|
WHERE runtime_id = $1
|
|
ORDER BY id
|
|
LIMIT $2
|
|
FOR UPDATE
|
|
`
|
|
|
|
type ListTaskIDsByRuntimeFirstPageParams struct {
|
|
RuntimeID pgtype.UUID `json:"runtime_id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// First page of the same walk; see ListWorkspaceAgentIDFirstPage for why this is
|
|
// a separate query rather than a zero-uuid cursor, and ListTaskIDsByAgentPage for
|
|
// the single-key / keyset / FOR UPDATE rationale.
|
|
func (q *Queries) ListTaskIDsByRuntimeFirstPage(ctx context.Context, arg ListTaskIDsByRuntimeFirstPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listTaskIDsByRuntimeFirstPage, arg.RuntimeID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listTaskIDsByRuntimePage = `-- name: ListTaskIDsByRuntimePage :many
|
|
SELECT id FROM agent_task_queue
|
|
WHERE runtime_id = $1 AND id > $2
|
|
ORDER BY id
|
|
LIMIT $3
|
|
FOR UPDATE
|
|
`
|
|
|
|
type ListTaskIDsByRuntimePageParams struct {
|
|
RuntimeID pgtype.UUID `json:"runtime_id"`
|
|
ID pgtype.UUID `json:"id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// Uses idx_agent_task_queue_runtime_id (migration 273, (runtime_id, id)); before
|
|
// it every runtime_id index was partial, so this path had none at all. See
|
|
// ListTaskIDsByAgentPage for the single-key / keyset / FOR UPDATE rationale.
|
|
func (q *Queries) ListTaskIDsByRuntimePage(ctx context.Context, arg ListTaskIDsByRuntimePageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listTaskIDsByRuntimePage, arg.RuntimeID, arg.ID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspaceAgentIDFirstPage = `-- name: ListWorkspaceAgentIDFirstPage :many
|
|
SELECT id FROM agent
|
|
WHERE agent.workspace_id = $1
|
|
ORDER BY id
|
|
LIMIT $2
|
|
`
|
|
|
|
type ListWorkspaceAgentIDFirstPageParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// First page of the same walk. Split from the keyset query rather than seeded
|
|
// with the all-zero uuid, because that value is itself a valid uuid: a row whose
|
|
// id happened to be all zeros would be skipped forever by `id > $2`.
|
|
func (q *Queries) ListWorkspaceAgentIDFirstPage(ctx context.Context, arg ListWorkspaceAgentIDFirstPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaceAgentIDFirstPage, arg.WorkspaceID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspaceAgentIDPage = `-- name: ListWorkspaceAgentIDPage :many
|
|
SELECT id FROM agent
|
|
WHERE agent.workspace_id = $1 AND id > $2
|
|
ORDER BY id
|
|
LIMIT $3
|
|
`
|
|
|
|
type ListWorkspaceAgentIDPageParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
ID pgtype.UUID `json:"id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// Owner enumeration in bounded, keyset-paged chunks. The rows are already locked
|
|
// by LockWorkspaceTaskOwnerAgents above; this only walks them.
|
|
//
|
|
// Keyset on id, not on a natural key: the cursor column must be immutable, and
|
|
// renaming an agent mid-teardown would otherwise let it sort behind the cursor
|
|
// and escape the sweep. Uses idx_agent_workspace_id_keyset (migration 281), which
|
|
// exists so this page does not sort the whole owner set.
|
|
func (q *Queries) ListWorkspaceAgentIDPage(ctx context.Context, arg ListWorkspaceAgentIDPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaceAgentIDPage, arg.WorkspaceID, arg.ID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspaceIssueIDFirstPage = `-- name: ListWorkspaceIssueIDFirstPage :many
|
|
SELECT id FROM issue
|
|
WHERE issue.workspace_id = $1
|
|
ORDER BY id
|
|
LIMIT $2
|
|
`
|
|
|
|
type ListWorkspaceIssueIDFirstPageParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// First page of the same walk. Split from the keyset query rather than seeded
|
|
// with the all-zero uuid, because that value is itself a valid uuid: a row whose
|
|
// id happened to be all zeros would be skipped forever by `id > $2`.
|
|
func (q *Queries) ListWorkspaceIssueIDFirstPage(ctx context.Context, arg ListWorkspaceIssueIDFirstPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaceIssueIDFirstPage, arg.WorkspaceID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspaceIssueIDPage = `-- name: ListWorkspaceIssueIDPage :many
|
|
SELECT id FROM issue
|
|
WHERE issue.workspace_id = $1 AND id > $2
|
|
ORDER BY id
|
|
LIMIT $3
|
|
`
|
|
|
|
type ListWorkspaceIssueIDPageParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
ID pgtype.UUID `json:"id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// Uses idx_issue_workspace_id_keyset (migration 282).
|
|
func (q *Queries) ListWorkspaceIssueIDPage(ctx context.Context, arg ListWorkspaceIssueIDPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaceIssueIDPage, arg.WorkspaceID, arg.ID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspaceRuntimeIDFirstPage = `-- name: ListWorkspaceRuntimeIDFirstPage :many
|
|
SELECT id FROM agent_runtime
|
|
WHERE agent_runtime.workspace_id = $1
|
|
ORDER BY id
|
|
LIMIT $2
|
|
`
|
|
|
|
type ListWorkspaceRuntimeIDFirstPageParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// First page of the same walk. Split from the keyset query rather than seeded
|
|
// with the all-zero uuid, because that value is itself a valid uuid: a row whose
|
|
// id happened to be all zeros would be skipped forever by `id > $2`.
|
|
func (q *Queries) ListWorkspaceRuntimeIDFirstPage(ctx context.Context, arg ListWorkspaceRuntimeIDFirstPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaceRuntimeIDFirstPage, arg.WorkspaceID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listWorkspaceRuntimeIDPage = `-- name: ListWorkspaceRuntimeIDPage :many
|
|
SELECT id FROM agent_runtime
|
|
WHERE agent_runtime.workspace_id = $1 AND id > $2
|
|
ORDER BY id
|
|
LIMIT $3
|
|
`
|
|
|
|
type ListWorkspaceRuntimeIDPageParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
ID pgtype.UUID `json:"id"`
|
|
Limit int32 `json:"limit"`
|
|
}
|
|
|
|
// Uses idx_agent_runtime_workspace_id_keyset (migration 283).
|
|
func (q *Queries) ListWorkspaceRuntimeIDPage(ctx context.Context, arg ListWorkspaceRuntimeIDPageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listWorkspaceRuntimeIDPage, arg.WorkspaceID, arg.ID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const lockTaskUsageRollupForWorkspaceDelete = `-- name: LockTaskUsageRollupForWorkspaceDelete :exec
|
|
SELECT pg_advisory_xact_lock(4246)
|
|
`
|
|
|
|
// Advisory lock 4246 is the hourly rollup family's key: the rollup function
|
|
// takes it per transaction (migration 272) and the backfill commands take it
|
|
// per session. An in-flight rollup finishes first, then no new rollup can
|
|
// write the workspace's aggregates until this delete commits. The caller
|
|
// bounds this wait with SET LOCAL lock_timeout — batch jobs hold 4246 for
|
|
// minutes, and an unbounded wait here hangs the delete request (MUL-5983).
|
|
func (q *Queries) LockTaskUsageRollupForWorkspaceDelete(ctx context.Context) error {
|
|
_, err := q.db.Exec(ctx, lockTaskUsageRollupForWorkspaceDelete)
|
|
return err
|
|
}
|
|
|
|
const lockWorkspaceTaskOwnerAgents = `-- name: LockWorkspaceTaskOwnerAgents :exec
|
|
SELECT 1 FROM agent WHERE agent.workspace_id = $1 FOR UPDATE
|
|
`
|
|
|
|
// The three LockWorkspaceTaskOwner* statements are a write fence, not a lookup —
|
|
// they deliberately return nothing, so a workspace with a huge owner set costs
|
|
// the API process no memory at all.
|
|
//
|
|
// agent_task_queue has no workspace_id, so a task becomes this workspace's
|
|
// problem through agent_id, issue_id or runtime_id — and inserting such a task
|
|
// requires FOR KEY SHARE on the referenced owner row, which conflicts with
|
|
// FOR UPDATE. Holding these locks for the rest of the teardown transaction
|
|
// therefore blocks any enqueue (or reassignment) that would add a task to this
|
|
// workspace after we have swept it, which row locks on the tasks alone cannot
|
|
// do. Same technique the handler already uses on the workspace row to keep
|
|
// CreateChatSession out of the delete window.
|
|
//
|
|
// This fence is defence in depth, not the only guarantee: deleteWorkspaceTasks
|
|
// re-verifies every owner after sweeping it and fails closed if tasks keep
|
|
// appearing, so teardown does not silently leak rows even if the fence is ever
|
|
// weakened (for example by removing the legacy FKs this one leans on).
|
|
func (q *Queries) LockWorkspaceTaskOwnerAgents(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, lockWorkspaceTaskOwnerAgents, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const lockWorkspaceTaskOwnerIssues = `-- name: LockWorkspaceTaskOwnerIssues :exec
|
|
SELECT 1 FROM issue WHERE issue.workspace_id = $1 FOR UPDATE
|
|
`
|
|
|
|
func (q *Queries) LockWorkspaceTaskOwnerIssues(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, lockWorkspaceTaskOwnerIssues, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const lockWorkspaceTaskOwnerRuntimes = `-- name: LockWorkspaceTaskOwnerRuntimes :exec
|
|
SELECT 1 FROM agent_runtime WHERE agent_runtime.workspace_id = $1 FOR UPDATE
|
|
`
|
|
|
|
func (q *Queries) LockWorkspaceTaskOwnerRuntimes(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, lockWorkspaceTaskOwnerRuntimes, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const prepareWorkspaceDeletionLinks = `-- name: PrepareWorkspaceDeletionLinks :exec
|
|
WITH
|
|
detached_comments AS (
|
|
UPDATE comment
|
|
SET parent_id = NULL
|
|
WHERE comment.workspace_id = $1
|
|
AND parent_id IS NOT NULL
|
|
),
|
|
detached_issues AS (
|
|
UPDATE issue
|
|
SET parent_issue_id = NULL
|
|
WHERE issue.workspace_id = $1
|
|
AND parent_issue_id IS NOT NULL
|
|
)
|
|
UPDATE webhook_delivery
|
|
SET replayed_from_delivery_id = NULL
|
|
WHERE webhook_delivery.workspace_id = $1
|
|
AND replayed_from_delivery_id IS NOT NULL
|
|
`
|
|
|
|
// Break self-references and the task/run cycle explicitly before deleting
|
|
// either side. This keeps their ordering in the application-owned graph
|
|
// instead of relying on ON DELETE SET NULL actions.
|
|
//
|
|
// The task half of this step now lives in DetachTaskBatchReferences, which runs
|
|
// once per bounded batch instead of once for a whole workspace's task set.
|
|
func (q *Queries) PrepareWorkspaceDeletionLinks(ctx context.Context, workspaceID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, prepareWorkspaceDeletionLinks, workspaceID)
|
|
return err
|
|
}
|
|
|
|
const setWorkspaceTeardownMode = `-- name: SetWorkspaceTeardownMode :exec
|
|
|
|
SELECT set_config('multica.workspace_teardown', 'on', true)
|
|
`
|
|
|
|
// Workspace deletion is application-owned. These statements form a fixed,
|
|
// bottom-up deletion plan; the legacy foreign keys remain only as a
|
|
// compatibility safety net during the expand phase.
|
|
// The setting is transaction-local. Migration 242 makes only the three DELETE
|
|
// dirty triggers skip their per-row work while this flag is on.
|
|
func (q *Queries) SetWorkspaceTeardownMode(ctx context.Context) error {
|
|
_, err := q.db.Exec(ctx, setWorkspaceTeardownMode)
|
|
return err
|
|
}
|