diff --git a/cloud/attempt_ledger.py b/cloud/attempt_ledger.py index 24765fe..6930b7a 100644 --- a/cloud/attempt_ledger.py +++ b/cloud/attempt_ledger.py @@ -133,11 +133,12 @@ Persistence notes: three legacy shapes migrated in place, both writers, PK, idempotent ensure; see ``verification/codex-task-attempts-pg-proof-20261106.md``). - One gap-3-only step is Postgres-only by nature: a gap-3 - table's ``outcome`` was created NOT NULL, and ``ensure`` - relaxes it with ``ALTER COLUMN outcome DROP NOT NULL`` - (see ``ensure_task_attempts_table``); on SQLite that - statement is not valid and the column stays NOT NULL. + One gap-3-only step relaxes a gap-3 table's ``outcome`` + from NOT NULL with ``ALTER COLUMN outcome DROP NOT NULL`` + (see ``ensure_task_attempts_table``); the statement works + on Postgres and on SQLite 3.53.0+, and is swallowed by + ``try/except`` on older SQLite, where the column stays + NOT NULL. - The read path (``GET /metrics-report``) never creates the table: a read-only endpoint must not mutate schema. Table creation happens only on the write path, inside ``record_attempt`` / @@ -310,8 +311,9 @@ def ensure_task_attempts_table(conn): nullable or defaulted, so existing rows are never blocked, and no row is dropped or rewritten. A gap-3 table's ``outcome`` was created NOT NULL; ensure also relaxes it to - nullable on Postgres (``ALTER COLUMN outcome DROP NOT - NULL``), because the resume writer inserts outcome NULL -- + nullable (``ALTER COLUMN outcome DROP NOT NULL`` -- works + on Postgres and on SQLite 3.53.0+, swallowed on older + SQLite), because the resume writer inserts outcome NULL -- without the relax every resume write on such a table fails. This replaces the rehearsal-era runtime column-completion workaround: the @@ -332,10 +334,11 @@ def ensure_task_attempts_table(conn): # gap-3 形态的 outcome 是 NOT NULL,而统一 DDL 要求它可空 # (恢复写路不报 outcome,NOT NULL 会使它在 PG 上每次写 # 行都报 NotNullViolation)。只在探针报 NOT NULL 时放宽: - # Postgres 上是 ALTER COLUMN DROP NOT NULL(已是可空时 - # 不会走到这里);SQLite 上该语句不合法,try/except 吞 - # 掉——SQLite 的 gap-3 表仍保留 NOT NULL(放宽需重建表, - # 与“迁移不做重建式类型收紧”的边界一致,本批只修 PG 路径)。 + # ALTER COLUMN outcome DROP NOT NULL 在 Postgres 上生效; + # SQLite 3.53.0 起也支持该语法,在此之前不合法、被 + # try/except 吞掉,gap-3 表仍保留 NOT NULL(恢复写路 + # 会被 INSERT OR IGNORE 静默丢弃——版本相关边界,见 + # test_column_notnull_probe_sees_gap3_outcome)。 if _column_notnull(conn, "task_attempts").get("outcome"): try: conn.execute( diff --git a/cloud/tests/test_task_attempts_unified_schema.py b/cloud/tests/test_task_attempts_unified_schema.py index 78dba25..cb8721b 100644 --- a/cloud/tests/test_task_attempts_unified_schema.py +++ b/cloud/tests/test_task_attempts_unified_schema.py @@ -150,14 +150,31 @@ class TestFreshAndMigrations(unittest.TestCase): self.conn.execute(GAP3_DDL) flags = _column_notnull(self.conn, "task_attempts") self.assertTrue(flags["outcome"]) - # SQLite 上放宽语句不合法、被 ensure 吞掉:迁移本身 - # 不抛错、列集仍齐,但 outcome 保持 NOT NULL(需重建 - # 表才能改,见批总结诚实边界;本批只修 PG 路径)。 + # 迁移本身不抛错、列集仍齐;真正的不变量是 ensure + # 之后恢复写路与实际的 schema 状态一致,而不是 + # outcome 的 NOT NULL 位:outcome 放宽了(PG 上 + # ALTER 生效;SQLite 3.53.0 起也支持 ALTER COLUMN + # DROP NOT NULL),resume 风格写行必须能写入并读回 + # outcome 为 NULL 的行;outcome 仍 NOT NULL 时(更 + # 早的 SQLite:ALTER 不合法、被 try/except 吞掉), + # 该行会被 INSERT OR IGNORE 静默丢弃——版本相关 + # 边界,不是迁移失败。 ensure_task_attempts_table(self.conn) self.assertEqual(set(_columns(self.conn)), set(UNIFIED_COLUMNS)) - self.assertTrue( - _column_notnull(self.conn, "task_attempts")["outcome"]) + relaxed = not _column_notnull( + self.conn, "task_attempts")["outcome"] + resume_record(self.conn, "t-gap", 2, "platform_fresh", + "worker_shutdown", -1, "second", None) + self.conn.commit() + row = _row_by_key(self.conn, "t-gap", 2) + if relaxed: + self.assertIsNotNone(row) + self.assertIsNone(row["outcome"]) + latest = latest_attempt(self.conn, "t-gap") + self.assertEqual(latest["attempt_no"], 2) + else: + self.assertIsNone(row) def test_old_shape_migrates_with_rows_intact(self): self.conn.execute(OLD_DDL)