fix: heal session_model_usage PK unconditionally to restore token/cost accounting (#73823) - #73838
fix: heal session_model_usage PK unconditionally to restore token/cost accounting (#73823)#73838RelaxJonh wants to merge 1 commit into
Conversation
Code Review: PR #73838 — heal session_model_usage PK unconditionallyBug Analysis - Correctly IdentifiedThe Fix Assessment
Merge RecommendationApprove — correct, safe, and follows established patterns. |
|
Whoa, that was fast — thank you! 🙌 And the shape is spot on: unconditional healer modeled on One thing I hit though, and I'd much rather flag it before this merges than after 👇 Foreign keys need to be off for the copy —
|
| result | |
|---|---|
| FK enforcement on | copy raised, table left unhealed ❌ |
| FK-off window | both rows survived, 6-column PK in place, both indexes recreated, no _legacy_pk residue ✅ |
And enforcement really was restored afterwards — a follow-up orphan insert correctly raised IntegrityError.
Two smaller notes
Call site ordering. Worth double-checking it lands after _reconcile_columns(), since that's what re-ADDs the bare nullable task column. Healer running first on an unreconciled DB might be keying on a column that isn't there yet.
Detection has to be cheap. On installs where the PK is already correct, the detect path is the whole story — it needs to be a genuine no-op, not a rebuild. On a multi-GB state.db an accidental rebuild-every-open would be extremely noticeable 😬
Happy to test your branch against a database that's already in the broken state if that helps — that's the annoying case to synthesize and I've got one sitting right here.
Nice work getting this out so quickly 👏
…t accounting (NousResearch#73823) Installs whose state.db reached schema_version >= 22 before the ``task`` dimension was added carry a 5-column PRIMARY KEY on session_model_usage. The reconciler ADDs the bare nullable column, but SQLite cannot ALTER a primary key, so the 6-column composite PK never lands. The existing migration is gated behind ``if current_version < 22:`` which is unreachable once the version bumps past 22. Every subsequent upsert in _record_model_usage() fails with "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint", aborting the enclosing write transaction and silently zeroing all token/cost accounting. Add an idempotent ``_heal_session_model_usage_pk()`` method modeled on the existing ``_heal_gateway_routing_pk()`` pattern. It runs unconditionally on every database open, detects the legacy 5-column PK via PRAGMA, and rebuilds the table with the correct composite key. On healthy databases it is a no-op. Uses INSERT OR IGNORE to avoid IntegrityError on any theoretical row collision during the rebuild. Fixes NousResearch#73823
ac12284 to
0fe8602
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating a real stale-schema path. Current main still gates the v22 table rebuild at hermes_state_schema.py:503, while the live writer uses a six-column conflict target at hermes_state.py:4353-4361.
Problems
- The copy at PR
hermes_state.py:3646usesINSERT OR IGNORE, but SessionDB enables foreign keys before_init_schema()(hermes_state.py:1916). SQLite does not applyOR IGNOREto foreign-key violations. The PR has no FK-off/restore window and catches onlyOperationalErrorat PRhermes_state.py:3669, so an orphaned legacy row can abort the rebuild as noted in the existing discussion. - The schema code moved to
hermes_state_schema.pyin21c7ae8563; this patch needs a thoughtful port toSessionSchemaMixinrather than the old monolith location. - No regression test covers the v22+-marked stale-PK state. Existing coverage sets the version to 21 (
tests/hermes_state/test_aux_usage_accounting.py:101-139).
Suggested changes
- Port the healer into
hermes_state_schema.py, make the rebuild atomic while temporarily managing FK enforcement, and add stale-v22+, orphan-row, healthy no-op, and repeat-open regressions.
Automated hermes-sweeper review.
| )""" | ||
| ) | ||
| cursor.execute( | ||
| """INSERT OR IGNORE INTO session_model_usage ( |
There was a problem hiding this comment.
INSERT OR IGNORE does not suppress FOREIGN KEY violations. SessionDB enables PRAGMA foreign_keys=ON before _init_schema() on current main, so an orphaned legacy session_id aborts this copy; disable and restore FK enforcement outside an atomic rebuild transaction, and add an orphan-row regression.
…ken/cost accounting Installs whose state.db reached schema_version >= 22 before the task dimension was added carry a 5-column PRIMARY KEY on session_model_usage. The column reconciler ADDs task as a bare nullable, but SQLite cannot ALTER a primary key, and the version-gated v22 rebuild is unreachable (current_version < 22 already false), so the composite 6-column key never lands. Every upsert in _record_model_usage then fails with 'ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint', aborting the enclosing write transaction — token/cost accounting permanently dead (#73823). Add an idempotent _heal_session_model_usage_pk() modeled on _heal_gateway_routing_pk(), run unconditionally from _init_schema on every open. Salvaged from #73838 with fix-ups: - ported to SessionSchemaMixin in hermes_state_schema.py (the schema code moved out of hermes_state.py in 21c7ae8; the PR targeted the old location) - rebuild wrapped in a PRAGMA foreign_keys=OFF/ON window: the connection enables FKs before _init_schema and OR IGNORE does NOT suppress FK violations, so a single orphaned usage row (session pruned while accounting was broken) would have aborted the heal - COALESCE('') on the nullable reconciler-added task column (and the billing columns) during the copy - stale-v22+ regression tests: rebuilt PK + restored upsert, orphan rows survive the FK window, healthy-DB no-op, no legacy leftover Fixes #73823
…ken/cost accounting Installs whose state.db reached schema_version >= 22 before the task dimension was added carry a 5-column PRIMARY KEY on session_model_usage. The column reconciler ADDs task as a bare nullable, but SQLite cannot ALTER a primary key, and the version-gated v22 rebuild is unreachable (current_version < 22 already false), so the composite 6-column key never lands. Every upsert in _record_model_usage then fails with 'ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint', aborting the enclosing write transaction — token/cost accounting permanently dead (#73823). Add an idempotent _heal_session_model_usage_pk() modeled on _heal_gateway_routing_pk(), run unconditionally from _init_schema on every open. Salvaged from #73838 with fix-ups: - ported to SessionSchemaMixin in hermes_state_schema.py (the schema code moved out of hermes_state.py in 21c7ae8; the PR targeted the old location) - rebuild wrapped in a PRAGMA foreign_keys=OFF/ON window: the connection enables FKs before _init_schema and OR IGNORE does NOT suppress FK violations, so a single orphaned usage row (session pruned while accounting was broken) would have aborted the heal - COALESCE('') on the nullable reconciler-added task column (and the billing columns) during the copy - stale-v22+ regression tests: rebuilt PK + restored upsert, orphan rows survive the FK window, healthy-DB no-op, no legacy leftover Fixes #73823
…ken/cost accounting Installs whose state.db reached schema_version >= 22 before the task dimension was added carry a 5-column PRIMARY KEY on session_model_usage. The column reconciler ADDs task as a bare nullable, but SQLite cannot ALTER a primary key, and the version-gated v22 rebuild is unreachable (current_version < 22 already false), so the composite 6-column key never lands. Every upsert in _record_model_usage then fails with 'ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint', aborting the enclosing write transaction — token/cost accounting permanently dead (#73823). Add an idempotent _heal_session_model_usage_pk() modeled on _heal_gateway_routing_pk(), run unconditionally from _init_schema on every open. Salvaged from #73838 with fix-ups: - ported to SessionSchemaMixin in hermes_state_schema.py (the schema code moved out of hermes_state.py in 21c7ae8; the PR targeted the old location) - rebuild wrapped in a PRAGMA foreign_keys=OFF/ON window: the connection enables FKs before _init_schema and OR IGNORE does NOT suppress FK violations, so a single orphaned usage row (session pruned while accounting was broken) would have aborted the heal - COALESCE('') on the nullable reconciler-added task column (and the billing columns) during the copy - stale-v22+ regression tests: rebuilt PK + restored upsert, orphan rows survive the FK window, healthy-DB no-op, no legacy leftover Fixes #73823
|
Merged via salvage PR #75883 (#75883) — your unconditional _heal_session_model_usage_pk() approach landed, authored under your name, ported to the current SessionSchemaMixin location with an FK-off/restore window around the rebuild and stale-v22+ regression tests. Fixes #73823 — token/cost accounting now heals on installs stuck at schema v22+. Thanks! |
…ken/cost accounting Installs whose state.db reached schema_version >= 22 before the task dimension was added carry a 5-column PRIMARY KEY on session_model_usage. The column reconciler ADDs task as a bare nullable, but SQLite cannot ALTER a primary key, and the version-gated v22 rebuild is unreachable (current_version < 22 already false), so the composite 6-column key never lands. Every upsert in _record_model_usage then fails with 'ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint', aborting the enclosing write transaction — token/cost accounting permanently dead (NousResearch#73823). Add an idempotent _heal_session_model_usage_pk() modeled on _heal_gateway_routing_pk(), run unconditionally from _init_schema on every open. Salvaged from NousResearch#73838 with fix-ups: - ported to SessionSchemaMixin in hermes_state_schema.py (the schema code moved out of hermes_state.py in 951ee23; the PR targeted the old location) - rebuild wrapped in a PRAGMA foreign_keys=OFF/ON window: the connection enables FKs before _init_schema and OR IGNORE does NOT suppress FK violations, so a single orphaned usage row (session pruned while accounting was broken) would have aborted the heal - COALESCE('') on the nullable reconciler-added task column (and the billing columns) during the copy - stale-v22+ regression tests: rebuilt PK + restored upsert, orphan rows survive the FK window, healthy-DB no-op, no legacy leftover Fixes NousResearch#73823
Summary
Fixes #73823
Installs whose
state.dbreachedschema_version >= 22before thetaskdimension was added tosession_model_usagecarry a 5-column PRIMARY KEY. The existing migration is gated behindif current_version < 22:which is unreachable once the version bumps — so the PK rebuild never runs, and every token/cost write fails permanently and silently.Root cause
SCHEMA_SQLdeclares the 6-column PK(session_id, model, billing_provider, billing_base_url, billing_mode, task)— only applied to new databases.if current_version < 22:._reconcile_columns()ADDs a bare nullabletaskcolumn via ALTER TABLE, but SQLite cannot ALTER a primary key.taskcolumn exists but is NOT in the PK. Every upsert in_record_model_usage()fails with "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint", aborting the enclosing write transaction and zeroing all token/cost data.Fix
Add an idempotent
_heal_session_model_usage_pk()method modeled on the existing_heal_gateway_routing_pk()pattern. It:PRAGMA table_info(checks iftaskis in the PK)INSERT OR IGNOREto avoid IntegrityError on any theoretical row collisiontaskis already in the PKChanges
hermes_state.py: Added_heal_session_model_usage_pk()method + call in_init_schema()Test plan