feat(tenant): supersede unknown publish tasks on manual retry

Replace the in-place requeue of submit-uncertain unknown publish tasks
with an explicit supersede-and-create-new flow: manual retries mark the
old unknown task failed (manual_retry_superseded) and create a fresh task
carrying manual_retry / superseded_unknown_task_ids in its payload.

Also fold title-length validation into definitivePublishFailurePattern and
check the pattern before the publish_submit_uncertain guard so those
failures are released from the dedup set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:32:06 +08:00
parent 9dd26145b3
commit aecdb87fa3
3 changed files with 207 additions and 142 deletions
@@ -155,62 +155,6 @@ func TestPublishTargetPlatformsSortsAndDedupes(t *testing.T) {
}
}
func TestShouldRequeuePublishTaskInsteadOfCreatingNewOnlyForUnknownPublish(t *testing.T) {
t.Parallel()
if !shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
Kind: "publish",
Status: "unknown",
Error: []byte(`{"publish_submit_uncertain":true,"message":"network interrupted after submit"}`),
}) {
t.Fatal("submit-uncertain unknown publish tasks should be retried by requeueing the existing task")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
Kind: "publish",
Status: "unknown",
Error: []byte(`{"error_code":403,"error_msg":"CSRF token invalid"}`),
}) {
t.Fatal("definitive failed unknown publish tasks should create a new retry record")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
Kind: "publish",
Status: "failed",
}) {
t.Fatal("failed publish tasks should keep create-new retry semantics")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{
Kind: "monitor",
Status: "unknown",
}) {
t.Fatal("monitor retry semantics are owned by desktop task reconcile")
}
if shouldRequeuePublishTaskInsteadOfCreatingNew(nil) {
t.Fatal("nil tasks should not be requeued")
}
}
func TestCreatePublishJobResponseForRequeuedTaskReportsRequeuedTaskID(t *testing.T) {
t.Parallel()
taskID := uuid.MustParse("55555555-5555-5555-5555-555555555555")
got := createPublishJobResponseForRequeuedTask(&repository.DesktopTask{DesktopID: taskID})
if got.JobID != "" {
t.Fatalf("JobID = %q, want empty for requeued task", got.JobID)
}
if len(got.TaskIDs) != 1 || got.TaskIDs[0] != taskID.String() {
t.Fatalf("TaskIDs = %v, want [%s]", got.TaskIDs, taskID)
}
if len(got.CreatedTaskIDs) != 0 {
t.Fatalf("CreatedTaskIDs = %v, want empty", got.CreatedTaskIDs)
}
if len(got.ExistingTaskIDs) != 0 {
t.Fatalf("ExistingTaskIDs = %v, want empty", got.ExistingTaskIDs)
}
if len(got.RequeuedTaskIDs) != 1 || got.RequeuedTaskIDs[0] != taskID.String() {
t.Fatalf("RequeuedTaskIDs = %v, want [%s]", got.RequeuedTaskIDs, taskID)
}
}
func TestPublishUnknownTaskHasDefinitiveFailureReleasesCSRF403(t *testing.T) {
t.Parallel()
@@ -237,6 +181,19 @@ func TestPublishUnknownTaskHasDefinitiveFailureKeepsSubmitUncertain(t *testing.T
}
}
func TestPublishUnknownTaskHasDefinitiveFailureReleasesTitleLengthValidation(t *testing.T) {
t.Parallel()
task := &repository.DesktopTask{
Kind: "publish",
Status: "unknown",
Error: []byte(`{"publish_submit_uncertain":true,"code":"toutiaohao_publish_failed","message":"标题长度应该在2-30字之间"}`),
}
if !publishUnknownTaskHasDefinitiveFailure(task) {
t.Fatal("title length validation should be treated as definitive failed")
}
}
func TestLoadExistingPublishRecordTargetsDedupeUnknownTasks(t *testing.T) {
t.Parallel()
@@ -298,6 +255,53 @@ func TestLockDesktopPublishDedupKeysSkipsEmptyList(t *testing.T) {
}
}
func TestReleaseManuallySupersededUnknownPublishTasksLocksUnknownDedupSet(t *testing.T) {
t.Parallel()
tx := &capturePublishDedupLookupTx{rows: emptyPublishDedupRows{}}
outcomes, superseded, err := releaseManuallySupersededUnknownPublishTasks(
context.Background(),
tx,
1,
2,
[]string{"dedup-a", "dedup-b"},
)
if err != nil {
t.Fatalf("releaseManuallySupersededUnknownPublishTasks() error = %v", err)
}
if len(outcomes) != 0 {
t.Fatalf("outcomes = %v, want empty", outcomes)
}
if len(superseded) != 0 {
t.Fatalf("superseded = %v, want empty", superseded)
}
normalizedSQL := normalizeSQLWhitespace(tx.sql)
for _, fragment := range []string{
"FROM desktop_tasks",
"tenant_id = $1",
"workspace_id = $2",
"kind = 'publish'",
"status = 'unknown'",
"dedup_key = ANY($3::text[])",
"FOR UPDATE",
} {
if !strings.Contains(normalizedSQL, fragment) {
t.Fatalf("manual supersede lookup missing %q: %s", fragment, tx.sql)
}
}
if !strings.Contains(normalizedSQL, "COALESCE(dedup_key, '')") {
t.Fatalf("manual supersede lookup should return dedup keys for new-task payload: %s", tx.sql)
}
if len(tx.args) != 3 || tx.args[0] != int64(1) || tx.args[1] != int64(2) {
t.Fatalf("manual supersede lookup args = %#v, want tenant/workspace/dedup keys", tx.args)
}
gotKeys, ok := tx.args[2].([]string)
if !ok || len(gotKeys) != 2 || gotKeys[0] != "dedup-a" || gotKeys[1] != "dedup-b" {
t.Fatalf("manual supersede lookup dedup keys = %#v, want [dedup-a dedup-b]", tx.args[2])
}
}
func TestIsPostgresUniqueViolationMatchesConstraint(t *testing.T) {
t.Parallel()