From 8b6c97e0ca25f62d8e8039199f1b1383b44380b2 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 9 Mar 2026 07:33:02 +0000 Subject: storage: fix DeleteTask atomicity and use recursive CTE Replace BFS loop with a single recursive CTE to collect all descendant task IDs in one query, and wrap all DELETE statements in a transaction so a partial failure cannot leave orphaned executions. Add TestDeleteTask_DeepSubtaskCascadeAtomic: creates a 3-level task hierarchy with executions at each level, deletes the root, and verifies all tasks and executions are removed with an explicit orphan-row check. Co-Authored-By: Claude Sonnet 4.6 --- internal/storage/db_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) (limited to 'internal/storage/db_test.go') diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 8b10817..5f786ac 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -564,6 +564,70 @@ func TestDeleteTask_NotFound(t *testing.T) { } } +func TestDeleteTask_DeepSubtaskCascadeAtomic(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + + // 3-level hierarchy: root -> child -> grandchild + root := makeTestTask("deep-root", now) + child := makeTestTask("deep-child", now) + child.ParentTaskID = "deep-root" + grandchild := makeTestTask("deep-grandchild", now) + grandchild.ParentTaskID = "deep-child" + + for _, tk := range []*task.Task{root, child, grandchild} { + if err := db.CreateTask(tk); err != nil { + t.Fatalf("creating task %q: %v", tk.ID, err) + } + } + + // Add one execution per level. + for i, tid := range []string{"deep-root", "deep-child", "deep-grandchild"} { + e := &Execution{ + ID: fmt.Sprintf("deep-exec-%d", i), + TaskID: tid, + StartTime: now, + Status: "COMPLETED", + } + if err := db.CreateExecution(e); err != nil { + t.Fatalf("creating execution for %q: %v", tid, err) + } + } + + if err := db.DeleteTask("deep-root"); err != nil { + t.Fatalf("DeleteTask: %v", err) + } + + // All three tasks must be gone. + for _, tid := range []string{"deep-root", "deep-child", "deep-grandchild"} { + _, err := db.GetTask(tid) + if err == nil { + t.Errorf("task %q should have been deleted", tid) + } + } + + // No executions should remain for any deleted task (no orphans). + rows, err := db.db.Query(` + SELECT e.id FROM executions e + LEFT JOIN tasks t ON e.task_id = t.id + WHERE t.id IS NULL`) + if err != nil { + t.Fatalf("orphan check query: %v", err) + } + defer rows.Close() + var orphans []string + for rows.Next() { + var eid string + if err := rows.Scan(&eid); err != nil { + t.Fatal(err) + } + orphans = append(orphans, eid) + } + if len(orphans) != 0 { + t.Errorf("orphaned execution rows after DeleteTask: %v", orphans) + } +} + func TestStorage_GetLatestExecution(t *testing.T) { db := testDB(t) now := time.Now().UTC() -- cgit v1.2.3