feat(publish): cancel stale queued publish tasks after queue timeout
Add CancelStaleQueuedPublishTasks to abort publish tasks that sit in the queue past a configurable timeout (default 3 days) without being claimed by a desktop client, so they no longer linger indefinitely as 'queued'. The lease recovery worker runs the cleanup each cycle and reports a cancelled_queued count; the timeout is configurable via job run config. Aborted tasks carry a publish_queue_timeout error payload with a readable Chinese duration, and the admin-web publish summary surfaces it as an auto-cancelled queue-timeout message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
@@ -53,6 +54,71 @@ func TestDesktopTaskRecoverySelectQueryLeaseExpiryFiltersExpiredLeases(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleQueuedPublishTasksCandidateSQLUsesExecutableQueueAge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query := normalizeSQLForDesktopTaskTest(staleQueuedPublishTasksCandidateSQL())
|
||||
for _, fragment := range []string{
|
||||
"FROM desktop_tasks AS t",
|
||||
"JOIN desktop_publish_jobs AS j",
|
||||
"t.kind = 'publish'",
|
||||
"t.status = 'queued'",
|
||||
"GREATEST( COALESCE(t.enqueued_at, t.created_at), COALESCE(j.scheduled_at, t.created_at) ) < $1",
|
||||
"LIMIT $2",
|
||||
"FOR UPDATE OF t SKIP LOCKED",
|
||||
} {
|
||||
if !strings.Contains(query, fragment) {
|
||||
t.Fatalf("stale queued publish query missing %q: %s", fragment, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishQueueTimeoutErrorPayloadIncludesConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := publishQueueTimeoutErrorPayload(3 * 24 * time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("publishQueueTimeoutErrorPayload() error = %v", err)
|
||||
}
|
||||
text := string(payload)
|
||||
for _, fragment := range []string{
|
||||
`"code":"publish_queue_timeout"`,
|
||||
`发布任务在等待通道中超过 3 天未被桌面端领取`,
|
||||
`请确认对应桌面端在线、账号登录正常且任务通道在线后,再重新发布。`,
|
||||
`"source":"publish_queue_timeout"`,
|
||||
`"queued_timeout_seconds":259200`,
|
||||
} {
|
||||
if !strings.Contains(text, fragment) {
|
||||
t.Fatalf("queue timeout payload missing %q: %s", fragment, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishQueueTimeoutDisplayDurationUsesReadableChinese(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
want string
|
||||
}{
|
||||
{name: "days", timeout: 7 * 24 * time.Hour, want: "7 天"},
|
||||
{name: "hours", timeout: 36 * time.Hour, want: "36 小时"},
|
||||
{name: "hours and minutes", timeout: 90 * time.Minute, want: "1 小时 30 分钟"},
|
||||
{name: "minutes", timeout: 45 * time.Minute, want: "45 分钟"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := publishQueueTimeoutDisplayDuration(tc.timeout); got != tc.want {
|
||||
t.Fatalf("publishQueueTimeoutDisplayDuration(%s) = %q, want %q", tc.timeout, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRetryPublishesTaskAvailableForAllDesktopTaskKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user