Skip to content

Commit 84ecbb5

Browse files
committed
fix(SCHEMA_VERSION 5->6): one-time billable_requests backfill, plus a real destructive-wipe landmine
Closes the boot-time double/under-billing bug in busbarAI core's governance::state::hydrate_budgets: that function used a value-based heuristic (billable_requests==0 && requests>0 => "legacy pre-split row, re-seed it") to detect data needing a one-time backfill - but that exact counter shape is ALSO what a bucket looks like when every request in the window was legitimately refunded (refund_bucket decrements billable_requests but never requests, by design). The heuristic ran on every boot and could not tell the two cases apart, silently re-billing correctly-refunded fees on restart. Fix: do the backfill ONCE, durably, gated on this store's own schema-version crossing (which only ever happens once, ever, in a store's lifetime) instead of on every boot. This is safe as an unconditional blanket UPDATE only because 1.5.0/SCHEMA_VERSION 5 has never shipped to a real customer - there is no genuine "currently, legitimately refunded to zero" row in existence yet that this could incorrectly re-bill. After this lands in busbarAI core, hydrate_budgets drops the heuristic entirely and trusts billable_requests unconditionally, forever - v6+ rows are written correctly from birth. Found and fixed a second, more serious pre-existing bug while implementing this: migrate()'s legacy-drop detection checked for 'keys'/'store_meta' among its "needs a destructive wipe" signals - but those are the CURRENT (v5+) table names, not just old ones, so a real v5 database legitimately has both. Naively bumping SCHEMA_VERSION to 6 under the existing `version < SCHEMA_VERSION` gate would have made every real v5 store's OWN live data register as "legacy" on the v5->v6 crossing and wiped it - the exact opposite of what this backfill is supposed to do. Fixed by gating the destructive path on the actual pre-v5 boundary (`version < 5`, the boundary that was ALWAYS destructive by design) instead of on SCHEMA_VERSION (which moves every bump), and gating the new additive backfill separately on `version < 6`. Both fixes proven red-before-green by hand: reverting the has_legacy gate back to `< SCHEMA_VERSION` reproduces real data loss in migrate_v5_to_v6_backfills_billable_requests_without_wiping_data; reverting has_legacy's table list to exclude 'keys'/'store_meta' breaks the PRE-EXISTING migrate_drops_and_recreates_a_genuinely_older_schema test (a real, older schema no longer gets detected/dropped). Full gate: fmt clean, clippy clean, cargo test --workspace (49 lib + 3 e2e) all green.
1 parent 8d0ae0c commit 84ecbb5

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

store-sqlite/src/lib.rs

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,15 @@ impl<T> IntoStoreResult<T> for Result<T, rusqlite::Error> {
3939
/// retention sweep); `usage_metering` leads with `bucket` for the same write-locality reasoning, and
4040
/// gains `key_group_at_use`/`pricing_version`/`billable_requests`, and renames
4141
/// `tokens_cache_creation` -> `tokens_cache_write` to match `TierTokens`'s own naming (a drift fixed
42-
/// core-side in this same redesign). 1.5.0 is UNRELEASED, so each bump is destructive (drop +
43-
/// recreate), never a migration: a pre-v5 dev database is recreated empty on open.
44-
const SCHEMA_VERSION: i64 = 5;
42+
/// core-side in this same redesign). 1.5.0 is UNRELEASED, so each bump up to and including v5 was
43+
/// destructive (drop + recreate), never a migration: a pre-v5 dev database was recreated empty on
44+
/// open.
45+
///
46+
/// v6: the FIRST real, additive (non-destructive) migration this store has ever needed — a
47+
/// one-time backfill of `billable_requests` for any row where a v5-era write left it at 0 despite
48+
/// a nonzero `requests` (see the `version < 6` block in `migrate`, and
49+
/// `governance::state::hydrate_budgets` in busbarAI core for the boot-time bug this closes).
50+
const SCHEMA_VERSION: i64 = 6;
4551

4652
const SCHEMA: &str = "
4753
CREATE TABLE IF NOT EXISTS store_meta (
@@ -387,11 +393,25 @@ impl SqliteStore {
387393
let tx = conn
388394
.transaction_with_behavior(TransactionBehavior::Immediate)
389395
.store()?;
390-
if version < SCHEMA_VERSION {
396+
// Gated on the actual pre-v5 boundary (`< 5`), NOT on `SCHEMA_VERSION` (which moves every
397+
// bump): every bump up to and including v5 was destructive by design (1.5.0 was
398+
// unreleased, so a pre-v5 dev database is simply wiped and recreated). v6+ are ADDITIVE,
399+
// non-destructive migrations (see the `version < 6` backfill just below) — they must
400+
// never fall into this drop-and-recreate path, even though `keys`/`store_meta` are named
401+
// in the drop list below (the list has to cover every table this schema has EVER used,
402+
// including its OWN current names, since a version<5 db could theoretically already carry
403+
// a same-named-but-incompatibly-shaped table from an even older generation — see
404+
// `migrate_drops_and_recreates_a_genuinely_older_schema`). Gating on `< SCHEMA_VERSION`
405+
// instead of `< 5` here was tried first and is WRONG: it makes a real v5 database's own
406+
// (correctly-shaped, live) `keys`/`store_meta` tables register as "has_legacy" on the
407+
// v5->v6 crossing and wipes them — confirmed by hand, reverting this gate to
408+
// `< SCHEMA_VERSION` reproduces exactly that data loss in
409+
// `migrate_v5_to_v6_backfills_billable_requests_without_wiping_data`.
410+
if version < 5 {
391411
let has_legacy: bool = tx
392412
.query_row(
393413
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name IN \
394-
('usage_counters','virtual_keys','keys','store_meta'))",
414+
('usage_counters','virtual_keys','aws_credentials','usage_ledger','keys','store_meta'))",
395415
[],
396416
|r| r.get(0),
397417
)
@@ -424,6 +444,35 @@ impl SqliteStore {
424444
[],
425445
)
426446
.store()?;
447+
// v5 -> v6, ONE-TIME durable backfill (never repeated, gated on the version crossing —
448+
// see governance::state::hydrate_budgets in busbarAI core for the bug this closes). A
449+
// pre-v6 row can have `billable_requests=0` for either of two reasons that look
450+
// IDENTICAL in the data: (a) it was written by v5 code that never split billable_requests
451+
// out from requests (a real legacy gap — v5 added the column but not every code path
452+
// populated it correctly from day one), or (b) every request in that window was
453+
// legitimately refunded (refund_bucket decrements billable_requests but never requests,
454+
// by design). Those two cases are NOT distinguishable from the stored values alone, which
455+
// is exactly why this must NOT be a per-boot heuristic (hydrate_budgets no longer applies
456+
// one after this migration ships) — it is safe to run this AS A BLANKET, UNCONDITIONAL
457+
// backfill exactly once, right now, only because 1.5.0 has never shipped to a real
458+
// customer: there is no genuine "currently, legitimately refunded to zero" row in
459+
// existence yet that this could incorrectly re-bill. Re-running this same UPDATE
460+
// unconditionally at ANY later point (once real refund data exists) would reintroduce the
461+
// exact bug it closes — that is why it is gated on `version < 6`, never repeated.
462+
if version < 6 {
463+
tx.execute(
464+
"UPDATE usage_windows SET billable_requests = requests \
465+
WHERE model = '' AND billable_requests = 0 AND requests > 0",
466+
[],
467+
)
468+
.store()?;
469+
tx.execute(
470+
"UPDATE usage_metering SET billable_requests = requests \
471+
WHERE billable_requests = 0 AND requests > 0",
472+
[],
473+
)
474+
.store()?;
475+
}
427476
tx.pragma_update(None, "user_version", SCHEMA_VERSION)
428477
.store()?;
429478
tx.commit().store()?;

store-sqlite/src/tests.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,82 @@ fn migrate_drops_and_recreates_a_genuinely_older_schema() {
929929
let _ = std::fs::remove_dir_all(&dir);
930930
}
931931

932+
#[test]
933+
fn migrate_v5_to_v6_backfills_billable_requests_without_wiping_data() {
934+
// The v5->v6 crossing is the FIRST non-destructive migration this store has ever needed — a
935+
// real regression risk `migrate_rerun_at_current_schema_version_does_not_wipe_data`'s own
936+
// comment already flags but doesn't itself cover: the legacy-drop block's table-name list
937+
// ('keys','store_meta', etc) includes the CURRENT schema's own names, so a naive
938+
// `version < SCHEMA_VERSION` bump (5 < 6) would find a real v5 database's OWN 'keys' table and
939+
// wipe it, unless the has_legacy check is scoped to pre-v5-ONLY names. Hand-build a real v5
940+
// database (current table shapes, PRAGMA user_version=5) with a live key AND a usage_windows
941+
// row shaped exactly like the boot-time bug this migration exists to close (billable_requests
942+
// stuck at 0 with a real nonzero requests count), then open it through the real store
943+
// (triggering migrate()) and assert BOTH that the key survived AND the row was backfilled.
944+
let dir = tempdir();
945+
let file = dir.join("v5.db");
946+
{
947+
let conn = Connection::open(&file).unwrap();
948+
conn.execute_batch(SCHEMA).unwrap();
949+
conn.execute(
950+
"INSERT INTO keys (id, name, key_group, allowed_pools, labels, enabled, \
951+
generation_hash, created_at, updated_at, expires_at, deleted_at, revision) \
952+
VALUES ('vk_v5', 'n', NULL, NULL, '{}', 1, 'g1', 0, 0, NULL, NULL, 0)",
953+
[],
954+
)
955+
.unwrap();
956+
conn.execute(
957+
"INSERT INTO usage_windows (window_start, bucket_id, model, requests, billable_requests) \
958+
VALUES (100, 'vk_v5', '', 7, 0)",
959+
[],
960+
)
961+
.unwrap();
962+
conn.pragma_update(None, "user_version", 5i64).unwrap();
963+
}
964+
let s = SqliteStore::open(file.to_str().unwrap(), 5000)
965+
.expect("open() must migrate a real v5 database additively, not fail or wipe it");
966+
assert!(
967+
s.get_key("vk_v5").unwrap().is_some(),
968+
"a real v5 key must survive the v5->v6 migration, not be wiped by the legacy-drop path"
969+
);
970+
let ledger = s.get_usage("vk_v5", 100).unwrap();
971+
assert_eq!(ledger.requests, 7, "requests must be untouched");
972+
assert_eq!(
973+
ledger.billable_requests, 7,
974+
"a v5-era row stuck at billable_requests=0 with real requests must be backfilled exactly \
975+
once during the v5->v6 crossing"
976+
);
977+
let _ = std::fs::remove_dir_all(&dir);
978+
}
979+
980+
#[test]
981+
fn migrate_v5_to_v6_does_not_touch_an_already_nonzero_billable_requests_row() {
982+
// The backfill's WHERE clause (`billable_requests = 0 AND requests > 0`) must not touch a row
983+
// that already carries a real, independently-tracked billable_requests value — only the
984+
// ambiguous zero-with-nonzero-requests shape is a backfill candidate.
985+
let dir = tempdir();
986+
let file = dir.join("v5_ok.db");
987+
{
988+
let conn = Connection::open(&file).unwrap();
989+
conn.execute_batch(SCHEMA).unwrap();
990+
conn.execute(
991+
"INSERT INTO usage_windows (window_start, bucket_id, model, requests, billable_requests) \
992+
VALUES (200, 'vk_v5b', '', 10, 3)",
993+
[],
994+
)
995+
.unwrap();
996+
conn.pragma_update(None, "user_version", 5i64).unwrap();
997+
}
998+
let s = SqliteStore::open(file.to_str().unwrap(), 5000).unwrap();
999+
let ledger = s.get_usage("vk_v5b", 200).unwrap();
1000+
assert_eq!(ledger.requests, 10);
1001+
assert_eq!(
1002+
ledger.billable_requests, 3,
1003+
"a row with a real, already-nonzero billable_requests must be left exactly as-is"
1004+
);
1005+
let _ = std::fs::remove_dir_all(&dir);
1006+
}
1007+
9321008
#[test]
9331009
fn secret_form_from_str_round_trips_every_named_form() {
9341010
assert_eq!(secret_form_from_str("recoverable"), SecretForm::Recoverable);

0 commit comments

Comments
 (0)