Commit 947ed31d84d257054d70cf914ebce74d21f2e51a
1 parent
d10a05b0
fix: make migrate_drop_work_graph_run_org_node handle real duplicate-active-run data
Real self-hosting DB (2026-08-13) has 10 task_ids with multiple non-terminal work_graph_run rows each (legacy per-org_node-row leftovers from before the merged-graph architecture) -- the migration's final CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id) crashed against this real data (IntegrityError), and because executescript() commits each DDL statement individually, the crash left the live DB in a partially-migrated state: org_node column dropped, but the index never created, with the old idempotency check (`if "org_node" not in cols: return`) then permanently skipping any further attempt to create it. Adds _reconcile_duplicate_active_work_graph_runs(), which marks all but the most-recently-created non-terminal run per task_id as 'failed' (not 'blocked' -- the index's WHERE clause treats 'blocked' as still-active, which was the first fix attempt and still failed) before the index is created. Recency is ranked by rowid, not run_id: real data has started_at=NULL on every legacy row, so a run_id tiebreak would pick among duplicates by UUID string value, unrelated to actual insertion order. Also splits the idempotency check into column-drop and index-creation halves so a database already in the partially-migrated state (like the live one) gets the missing index created on the next run instead of being skipped. Dry-run verified against a copy of the live 36-row production DB before applying to the real ~/.graphlemon/graphlemon.db (backend_server was down for this, no concurrent writers). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
169 additions
and
3 deletions
src/schema.py
| ... | ... | @@ -847,15 +847,39 @@ def migrate_drop_work_graph_run_org_node(conn): |
| 847 | 847 | (each embeds the full column/constraint set valid as of ITS place in the |
| 848 | 848 | sequence, not retroactively edited by later migrations). |
| 849 | 849 | |
| 850 | - Idempotent: skipped if work_graph_run no longer has an org_node column, | |
| 851 | - so a fresh create_schema()'d database (already on this final shape) is a | |
| 852 | - no-op, same convention as every other migrate_* function here.""" | |
| 850 | + Idempotent, and re-entrant against a database left PARTWAY through a | |
| 851 | + previous, interrupted attempt (real 2026-08-13 self-hosting incident: | |
| 852 | + executescript() does not wrap the whole script in one transaction, so a | |
| 853 | + prior run of this function got as far as dropping the org_node column | |
| 854 | + and renaming the table in, then died on the final CREATE UNIQUE INDEX | |
| 855 | + step -- see the _reconcile_duplicate_active_work_graph_runs() docstring | |
| 856 | + for why that step can fail against real data). Column-drop and index- | |
| 857 | + creation are therefore checked and completed independently rather than | |
| 858 | + as one all-or-nothing gate, so re-running this function against that | |
| 859 | + exact half-migrated state finishes the job instead of silently | |
| 860 | + no-op'ing (the OLD idempotency check here was just `if "org_node" not | |
| 861 | + in cols: return`, which would have skipped forever on that database, | |
| 862 | + permanently leaving ux_work_graph_run_active missing).""" | |
| 853 | 863 | migrate_relax_org_node_constraints(conn) |
| 854 | 864 | conn.commit() |
| 855 | 865 | |
| 866 | + _reconcile_duplicate_active_work_graph_runs(conn) | |
| 867 | + conn.commit() | |
| 868 | + | |
| 856 | 869 | cols = {row[1] for row in conn.execute("PRAGMA table_info(work_graph_run)").fetchall()} |
| 870 | + has_index = conn.execute( | |
| 871 | + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'ux_work_graph_run_active'" | |
| 872 | + ).fetchone() is not None | |
| 873 | + | |
| 857 | 874 | if "org_node" not in cols: |
| 875 | + if not has_index: | |
| 876 | + conn.execute( | |
| 877 | + "CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id) " | |
| 878 | + "WHERE status NOT IN ('done', 'failed')" | |
| 879 | + ) | |
| 880 | + conn.commit() | |
| 858 | 881 | return |
| 882 | + | |
| 859 | 883 | conn.execute("PRAGMA foreign_keys = OFF") |
| 860 | 884 | try: |
| 861 | 885 | conn.executescript( |
| ... | ... | @@ -897,3 +921,61 @@ def migrate_drop_work_graph_run_org_node(conn): |
| 897 | 921 | ) |
| 898 | 922 | finally: |
| 899 | 923 | conn.execute("PRAGMA foreign_keys = ON") |
| 924 | + | |
| 925 | + | |
| 926 | +def _reconcile_duplicate_active_work_graph_runs(conn): | |
| 927 | + """Real production data (2026-08-13 incident) can have MULTIPLE | |
| 928 | + non-terminal work_graph_run rows for the same task_id -- a legitimate | |
| 929 | + leftover of the pre-Phase-2c architecture where each org_node got its | |
| 930 | + own row instead of sharing one merged run. ux_work_graph_run_active's | |
| 931 | + UNIQUE(task_id) WHERE status NOT IN ('done', 'failed') can't be created | |
| 932 | + while such duplicates exist (10 affected task_ids found against the | |
| 933 | + live self-hosting database, e.g. one task had 8 concurrently-'open' | |
| 934 | + rows from the old per-org_node model). | |
| 935 | + | |
| 936 | + Keeps the most-recently-started non-terminal row per task_id as the | |
| 937 | + active one and marks every other non-terminal row for that task_id as | |
| 938 | + 'failed' -- 'blocked' was considered but rejected: ux_work_graph_run_active's | |
| 939 | + WHERE clause is `status NOT IN ('done', 'failed')`, so a 'blocked' row | |
| 940 | + still counts as active and would keep tripping the same UNIQUE | |
| 941 | + violation this function exists to prevent (confirmed by re-running this | |
| 942 | + against real duplicate data -- the index creation still failed with | |
| 943 | + 'blocked'). 'failed' is the only terminal-compatible status that | |
| 944 | + doesn't misrepresent these rows as successfully completed ('done' | |
| 945 | + would); it's an honest description too, since these are abandoned runs | |
| 946 | + from before the merged-graph architecture -- nothing will ever resume | |
| 947 | + against them directly again (Coder/Validator now only ever append into | |
| 948 | + the newest run for a task). Plain Python grouping rather than a SQL | |
| 949 | + window function, matching this file's existing style (no window | |
| 950 | + functions used elsewhere in this module) and avoiding any SQLite- | |
| 951 | + version assumption. | |
| 952 | + | |
| 953 | + Safe to call unconditionally and repeatedly: a database with no | |
| 954 | + duplicates (the common case) does zero UPDATEs. | |
| 955 | + | |
| 956 | + Ranks candidates by (started_at, rowid), NOT (started_at, run_id): | |
| 957 | + real data confirmed started_at is NULL on every one of these legacy | |
| 958 | + rows (never backfilled -- they predate this column being populated), | |
| 959 | + which would make a run_id tiebreak pick among them by UUID string | |
| 960 | + value, unrelated to actual chronology. rowid reflects true insertion | |
| 961 | + order (SQLite assigns it monotonically increasing unless the table is | |
| 962 | + declared WITHOUT ROWID, which this one isn't), so it's the only | |
| 963 | + honest proxy available for "most recently created" when started_at | |
| 964 | + can't answer that.""" | |
| 965 | + rows = conn.execute( | |
| 966 | + "SELECT rowid, run_id, task_id, started_at FROM work_graph_run " | |
| 967 | + "WHERE status NOT IN ('done', 'failed')" | |
| 968 | + ).fetchall() | |
| 969 | + by_task = {} | |
| 970 | + for rowid, run_id, task_id, started_at in rows: | |
| 971 | + by_task.setdefault(task_id, []).append((started_at or "", rowid, run_id)) | |
| 972 | + | |
| 973 | + to_block = [] | |
| 974 | + for task_id, entries in by_task.items(): | |
| 975 | + if len(entries) <= 1: | |
| 976 | + continue | |
| 977 | + entries.sort() | |
| 978 | + to_block.extend(run_id for _, _, run_id in entries[:-1]) | |
| 979 | + | |
| 980 | + for run_id in to_block: | |
| 981 | + conn.execute("UPDATE work_graph_run SET status = 'failed' WHERE run_id = ?", (run_id,)) | ... | ... |
src/test_schema.py
| ... | ... | @@ -915,6 +915,90 @@ class MigrateDropWorkGraphRunOrgNodeTest(unittest.TestCase): |
| 915 | 915 | finally: |
| 916 | 916 | fresh.close() |
| 917 | 917 | |
| 918 | + def test_duplicate_non_terminal_rows_per_task_reconciled_before_index(self): | |
| 919 | + # Real 2026-08-13 incident: pre-Phase-2c databases can have several | |
| 920 | + # non-terminal work_graph_run rows for the same task_id (one per | |
| 921 | + # org_node, before runs were merged). The narrowed unique index | |
| 922 | + # can't be created until only one non-terminal row per task_id | |
| 923 | + # remains -- this must not raise. | |
| 924 | + self.conn.execute( | |
| 925 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at) " | |
| 926 | + "VALUES ('run-2', 'task-1', 'coder', '{}', 'running', '2026-08-01T00:00:00')" | |
| 927 | + ) | |
| 928 | + self.conn.execute( | |
| 929 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at) " | |
| 930 | + "VALUES ('run-3', 'task-1', 'validator', '{}', 'planning', '2026-08-02T00:00:00')" | |
| 931 | + ) | |
| 932 | + self.conn.commit() | |
| 933 | + | |
| 934 | + schema.migrate_drop_work_graph_run_org_node(self.conn) | |
| 935 | + | |
| 936 | + rows = self.conn.execute( | |
| 937 | + "SELECT run_id, status FROM work_graph_run WHERE task_id = 'task-1' AND run_id != 'run-1'" | |
| 938 | + ).fetchall() | |
| 939 | + statuses = dict(rows) | |
| 940 | + # run-3 started latest among the non-terminal duplicates -> stays active. | |
| 941 | + self.assertEqual(statuses["run-3"], "planning") | |
| 942 | + self.assertEqual(statuses["run-2"], "failed") | |
| 943 | + # the narrowed index must have actually been created and be enforced. | |
| 944 | + with self.assertRaises(sqlite3.IntegrityError): | |
| 945 | + self.conn.execute( | |
| 946 | + "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) " | |
| 947 | + "VALUES ('run-4', 'task-1', '{}', 'running')" | |
| 948 | + ) | |
| 949 | + | |
| 950 | + def test_duplicate_reconciliation_uses_insertion_order_not_uuid_when_started_at_null(self): | |
| 951 | + # Real 2026-08-13 incident: every legacy duplicate row had | |
| 952 | + # started_at = NULL. A run_id-string tiebreak would pick among | |
| 953 | + # them by UUID value, unrelated to which one was actually created | |
| 954 | + # last -- e.g. 'aaaa...' sorts after 'zzzz...' alphabetically but | |
| 955 | + # was inserted first. rowid (SQLite's true insertion-order | |
| 956 | + # counter) must be used instead, so the row inserted LAST (run-2, | |
| 957 | + # despite its run_id sorting before run-3's alphabetically) is the | |
| 958 | + # one kept active. | |
| 959 | + self.conn.execute( | |
| 960 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 961 | + "VALUES ('aaa-run-3', 'task-1', 'validator', '{}', 'planning')" | |
| 962 | + ) | |
| 963 | + self.conn.execute( | |
| 964 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 965 | + "VALUES ('zzz-run-2', 'task-1', 'coder', '{}', 'running')" | |
| 966 | + ) | |
| 967 | + self.conn.commit() | |
| 968 | + | |
| 969 | + schema.migrate_drop_work_graph_run_org_node(self.conn) | |
| 970 | + | |
| 971 | + statuses = dict( | |
| 972 | + self.conn.execute( | |
| 973 | + "SELECT run_id, status FROM work_graph_run WHERE run_id IN ('aaa-run-3', 'zzz-run-2')" | |
| 974 | + ).fetchall() | |
| 975 | + ) | |
| 976 | + self.assertEqual(statuses["aaa-run-3"], "failed") | |
| 977 | + self.assertEqual(statuses["zzz-run-2"], "running") | |
| 978 | + | |
| 979 | + def test_reentrant_after_partial_migration_missing_index_only(self): | |
| 980 | + # Real 2026-08-13 incident: executescript() commits each DDL | |
| 981 | + # statement individually, so a process crash between ALTER TABLE | |
| 982 | + # ... RENAME and CREATE UNIQUE INDEX leaves a database with the | |
| 983 | + # org_node column already gone but ux_work_graph_run_active never | |
| 984 | + # created. Re-running the migration against that exact state must | |
| 985 | + # finish the job, not silently no-op because "org_node" is already | |
| 986 | + # absent. | |
| 987 | + schema.migrate_drop_work_graph_run_org_node(self.conn) | |
| 988 | + self.conn.execute("DROP INDEX ux_work_graph_run_active") | |
| 989 | + self.conn.commit() | |
| 990 | + has_index = self.conn.execute( | |
| 991 | + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'ux_work_graph_run_active'" | |
| 992 | + ).fetchone() | |
| 993 | + self.assertIsNone(has_index) # sanity: simulated partial state matches the real incident | |
| 994 | + | |
| 995 | + schema.migrate_drop_work_graph_run_org_node(self.conn) | |
| 996 | + | |
| 997 | + has_index = self.conn.execute( | |
| 998 | + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'ux_work_graph_run_active'" | |
| 999 | + ).fetchone() | |
| 1000 | + self.assertIsNotNone(has_index) | |
| 1001 | + | |
| 918 | 1002 | |
| 919 | 1003 | class MigrateAddWorkGraphLoopLimitConfigTest(unittest.TestCase): |
| 920 | 1004 | """2026-08-08: real upgrade path for an already-populated database that | ... | ... |