- attempt_ledger.ensure: relax gap-3 outcome NOT NULL on Postgres (ALTER COLUMN outcome DROP NOT NULL, probe-gated); without it every resume write on a gap-3-migrated PG table fails with NotNullViolation. SQLite keeps NOT NULL (no such ALTER). - db.PgCursor: PRAGMA table_info fake rows had the notnull flag inverted (NO -> 0); now matches sqlite semantics (1 = NOT NULL). No caller read row[3] before, so this was latent. - ledger_onboarding: stale 'PG path unverified' notes updated to point at the real-PG proof. - tests: pragma_info notnull pin (fake driver) + _column_notnull probe pins (fresh + gap-3 SQLite). Proof: 28/28 checks on real Postgres 16.15 (see verification/ codex-task-attempts-pg-proof-20261106.md).
This commit is contained in:
1 parent
698398dafb
commit
2fcde375a4
5 files changed
+104
-16
No files matched your search
+47
-7
@@ -128,9 +128,16 @@ Persistence notes:
|
||||
endpoint treats a missing table exactly like "no ledger rows":
|
||||
unknown rates, not an error -- the same fail-soft shape already
|
||||
used for missing ``cost_records``. The Postgres upgrade path
|
||||
for a legacy-shaped table (fresh ``PG_DDL`` vs. adding the
|
||||
missing unified columns) has NOT been verified against a real
|
||||
Postgres instance in this batch -- see the batch summary.
|
||||
for a legacy-shaped table was verified against a real
|
||||
Postgres 16.15 instance on 2026-11-06 (fresh ``PG_DDL``,
|
||||
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.
|
||||
- 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`` /
|
||||
@@ -217,9 +224,12 @@ SQLITE_DDL = TASK_ATTEMPTS_DDL
|
||||
# endpoint. It is intentionally NOT part of POSTGRES_SCHEMA (see
|
||||
# the module docstring: migrations coverage must not widen silently).
|
||||
# For a legacy-shaped table already on Postgres, add whichever
|
||||
# unified columns are missing (see UNIFIED_COLUMN_TYPES);
|
||||
# that Postgres upgrade path is NOT yet verified against a real
|
||||
# Postgres instance -- see the batch summary.
|
||||
# unified columns are missing (see UNIFIED_COLUMN_TYPES) and
|
||||
# relax a NOT NULL outcome if the legacy shape declared one
|
||||
# (see ensure_task_attempts_table); that Postgres upgrade path
|
||||
# was verified against a real Postgres 16.15 instance on
|
||||
# 2026-11-06 -- see verification/
|
||||
# codex-task-attempts-pg-proof-20261106.md.
|
||||
PG_DDL = TASK_ATTEMPTS_DDL
|
||||
|
||||
# Unified column set, in DDL order. The migration in
|
||||
@@ -272,6 +282,17 @@ def _existing_columns(conn, table):
|
||||
return set()
|
||||
|
||||
|
||||
def _column_notnull(conn, table):
|
||||
"""列名 -> 是否 NOT NULL(PRAGMA table_info 的 row[3];
|
||||
SQLite 与 PG 适配层含义一致:1 = NOT NULL,0 = 可空;
|
||||
探针失败时回空 dict,调用方按“无需放宽”处理)。"""
|
||||
try:
|
||||
return {row[1]: bool(row[3]) for row in conn.execute(
|
||||
"PRAGMA table_info(%s)" % table).fetchall()}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def ensure_task_attempts_table(conn):
|
||||
"""Create the unified table if absent; migrate a legacy
|
||||
table to it if present. Idempotent; does not touch existing
|
||||
@@ -287,7 +308,12 @@ def ensure_task_attempts_table(conn):
|
||||
is migrated in place by adding whichever unified columns it
|
||||
lacks (``UNIFIED_COLUMN_TYPES``); added columns are always
|
||||
nullable or defaulted, so existing rows are never blocked,
|
||||
and no row is dropped or rewritten. This replaces the
|
||||
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 --
|
||||
without the relax every resume write on such a table fails.
|
||||
This replaces the
|
||||
rehearsal-era runtime column-completion workaround: the
|
||||
target here is the single canonical definition, and
|
||||
``cloud.attempts`` / ``cloud.db`` share that same string
|
||||
@@ -303,6 +329,20 @@ def ensure_task_attempts_table(conn):
|
||||
% (col, UNIFIED_COLUMN_TYPES[col]))
|
||||
except Exception:
|
||||
pass
|
||||
# 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 路径)。
|
||||
if _column_notnull(conn, "task_attempts").get("outcome"):
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE task_attempts "
|
||||
"ALTER COLUMN outcome DROP NOT NULL")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record_attempt(conn, task_id, outcome, is_defect=False,
|
||||
|
||||
Reference in new issue
Block a user