Files
geo/server/internal/tenant/repository/queries/desktop_task.sql
T
root fa51a3455f
Desktop Client Build / Resolve Build Metadata (push) Successful in 41s
Backend CI / Backend (push) Failing after 7m14s
Desktop Client Build / Build Desktop Client (push) Successful in 23m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 56s
fix(publish): prevent stuck publish queue and duplicate posting
The desktop publish queue could stall for a long time and end users assumed
the software was broken. Root cause: a hung adapter held its execution slot
with no wall-clock timeout while auto-renewing its lease forever, so the
server never reclaimed it and every queued task behind it stayed 等待发布.
Auto-recovery also risked silently re-posting a non-idempotent article.

Client (Electron):
- per-task wall-clock deadline + abort; progress-gated lease renewal that
  stops and aborts a stalled task instead of renewing it forever
- decouple the concurrency cap from CDP-induced CPU/memory pressure
  (admission gate instead of self-throttling collapse); 15s watchdog pump
- all adapter network I/O now has fetch timeouts and honors context.signal;
  bounded image-upload concurrency with per-image timeout
- surface live adapter progress, elapsed time, queue position and a
  working-vs-queued distinction in the publish view

Server (tenant-api):
- publish lease-recovery worker (every 3m) + supporting index: reclaim
  expired in_progress publish leases, requeue (<3 attempts) or terminal-fail
- 3-minute lease TTL with client-presence-gated extension; max 3 attempts

Idempotency (production-grade core):
- durable publish_submit_started_at marker, set before the irreversible
  platform submit POST; recovery and abort route a maybe-submitted task to
  unknown (manual reconcile, kept in the dedup set) instead of re-posting
- desktop UI requires explicit confirmation before retrying a possibly-
  already-published task

Verified: go build/vet/test (incl. resolvePublishRecoveryOutcome), vue-tsc,
vitest 141/141, gofmt all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:52:17 +08:00

240 lines
6.2 KiB
SQL

-- name: CreateDesktopPublishJob :one
INSERT INTO desktop_publish_jobs (
desktop_id,
tenant_id,
workspace_id,
created_by_user_id,
title,
content_ref,
scheduled_at,
article_id,
article_version_id
)
VALUES (
sqlc.arg(desktop_id),
sqlc.arg(tenant_id),
sqlc.arg(workspace_id),
sqlc.arg(created_by_user_id),
sqlc.arg(title),
sqlc.arg(content_ref),
sqlc.narg(scheduled_at),
sqlc.narg(article_id),
sqlc.narg(article_version_id)
)
RETURNING *;
-- name: CreateDesktopTask :one
INSERT INTO desktop_tasks (
desktop_id,
job_id,
tenant_id,
workspace_id,
target_account_id,
target_client_id,
platform_id,
kind,
payload,
status,
dedup_key
)
VALUES (
sqlc.arg(desktop_id),
sqlc.arg(job_id),
sqlc.arg(tenant_id),
sqlc.arg(workspace_id),
sqlc.arg(target_account_id),
sqlc.arg(target_client_id),
sqlc.arg(platform_id),
sqlc.arg(kind),
sqlc.arg(payload),
sqlc.arg(status),
sqlc.narg(dedup_key)
)
RETURNING *;
-- name: GetDesktopTaskByDesktopID :one
SELECT *
FROM desktop_tasks
WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id)
LIMIT 1;
-- name: LeaseNextQueuedDesktopTask :one
WITH candidate AS (
SELECT dt.desktop_id
FROM desktop_tasks AS dt
WHERE dt.target_client_id = sqlc.arg(client_id)
AND dt.status = 'queued'
AND (
sqlc.narg(kind)::text IS NULL
OR dt.kind = sqlc.narg(kind)::text
)
AND (
dt.kind <> 'publish'
OR dt.attempts < 3
)
AND (
dt.kind <> 'publish'
OR NOT EXISTS (
SELECT 1
FROM desktop_publish_jobs AS j
WHERE j.desktop_id = dt.job_id
AND j.status <> 'queued'
)
)
ORDER BY dt.lane_weight DESC,
dt.priority DESC,
COALESCE(dt.enqueued_at, dt.created_at) ASC,
dt.created_at ASC,
dt.desktop_id ASC
LIMIT 1
FOR UPDATE OF dt SKIP LOCKED
)
UPDATE desktop_tasks AS t
SET active_attempt_id = sqlc.arg(attempt_id),
lease_token_hash = sqlc.arg(lease_token_hash),
lease_expires_at = now() + CASE WHEN t.kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
status = 'in_progress',
attempts = t.attempts + 1,
started_at = COALESCE(t.started_at, now()),
updated_at = now()
FROM candidate
WHERE t.desktop_id = candidate.desktop_id
RETURNING t.*;
-- name: LeaseQueuedDesktopTaskByDesktopID :one
UPDATE desktop_tasks
SET active_attempt_id = sqlc.arg(attempt_id),
lease_token_hash = sqlc.arg(lease_token_hash),
lease_expires_at = now() + CASE WHEN kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
status = 'in_progress',
attempts = attempts + 1,
started_at = COALESCE(started_at, now()),
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND target_client_id = sqlc.arg(client_id)
AND status = 'queued'
AND (
kind <> 'publish'
OR attempts < 3
)
RETURNING *;
-- name: ExtendDesktopTaskLease :one
UPDATE desktop_tasks
SET lease_expires_at = now() + CASE WHEN kind = 'publish' THEN interval '3 minutes' ELSE interval '10 minutes' END,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND lease_token_hash = sqlc.arg(lease_token_hash)
AND status = 'in_progress'
RETURNING *;
-- name: CompleteDesktopTask :one
UPDATE desktop_tasks
SET status = sqlc.arg(status),
result = sqlc.narg(result),
error = sqlc.narg(error),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND lease_token_hash = sqlc.arg(lease_token_hash)
AND status = 'in_progress'
RETURNING *;
-- name: CancelDesktopTaskByLease :one
UPDATE desktop_tasks
SET status = 'aborted',
error = sqlc.narg(error),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND lease_token_hash = sqlc.arg(lease_token_hash)
AND status = 'in_progress'
RETURNING *;
-- name: CancelDesktopTaskByClient :one
UPDATE desktop_tasks
SET status = 'aborted',
error = sqlc.narg(error),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND target_client_id = sqlc.arg(client_id)
AND status = 'queued'
RETURNING *;
-- name: TenantCancelDesktopTask :one
UPDATE desktop_tasks
SET status = 'aborted',
error = sqlc.narg(error),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id)
AND status = 'queued'
RETURNING *;
-- name: ReconcileDesktopTask :one
UPDATE desktop_tasks
SET status = CASE
WHEN sqlc.arg(status)::text = 'retry' THEN 'queued'
ELSE sqlc.arg(status)::text
END,
result = CASE
WHEN sqlc.arg(status)::text = 'retry' THEN NULL
ELSE sqlc.narg(result)
END,
error = sqlc.narg(error),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
attempts = CASE WHEN sqlc.arg(status)::text = 'retry' THEN 0 ELSE attempts END,
updated_at = now()
WHERE desktop_id = sqlc.arg(desktop_id)
AND workspace_id = sqlc.arg(workspace_id)
AND status = 'unknown'
RETURNING *;
-- name: MarkDesktopTasksUnknownByClient :execrows
UPDATE desktop_tasks
SET status = 'unknown',
error = sqlc.narg(error),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = now()
WHERE target_client_id = sqlc.arg(client_id)
AND workspace_id = sqlc.arg(workspace_id)
AND status = 'in_progress';
-- name: CreateDesktopTaskAttempt :one
INSERT INTO desktop_task_attempts (
desktop_id,
task_id,
client_id,
lease_token_hash
)
VALUES (
sqlc.arg(desktop_id),
sqlc.arg(task_id),
sqlc.arg(client_id),
sqlc.arg(lease_token_hash)
)
RETURNING *;
-- name: FinishDesktopTaskAttempt :exec
UPDATE desktop_task_attempts
SET ended_at = now(),
final_status = sqlc.narg(final_status),
error = sqlc.narg(error)
WHERE task_id = sqlc.arg(task_id)
AND desktop_id = sqlc.arg(attempt_id);