Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions dashboard/backend/database_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,27 +226,6 @@ def _init_schema(self) -> None:
"""
)

cur.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_runs_session "
"ON agent_runs(session_id)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_runs_session_mode "
"ON agent_runs(session_id, mode)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_run_timestamp "
"ON equity_timeseries(run_id, timestamp)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_trades_run "
"ON trades(run_id, timestamp)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_decisions_run "
"ON backtest_decisions(run_id, step_index)"
)

# ADDING A COLUMN LATER? It must go in an `ALTER TABLE ... ADD COLUMN IF
# NOT EXISTS` below, *not* only in the CREATE above. CREATE TABLE IF NOT
# EXISTS silently no-ops once the table exists, so an existing deployment
Expand Down Expand Up @@ -426,6 +405,38 @@ def _init_schema(self) -> None:
"market_rule_closing_gate_effective BOOLEAN"
)

# Indexes sit below every ADD COLUMN on purpose. agent_runs.session_id
# arrives by ALTER on a table that predates it, and an index created
# above that ALTER raises UndefinedColumn there before the column
# exists -- the #432 Render boot crash, in the credits twin.
# test_store_twin_parity.py pins this order for every twin --
# by *source* position, which is a proxy for execution order and
# holds here because this method runs its DDL top to bottom. It
# is not a universal property (users_postgres.py executes a
# hoisted constant after later inline ALTERs), so keep these
# CREATE INDEX calls physically below the ALTERs rather than
# relying on the guard to notice if they move.
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_runs_session "
"ON agent_runs(session_id)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_runs_session_mode "
"ON agent_runs(session_id, mode)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_run_timestamp "
"ON equity_timeseries(run_id, timestamp)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_trades_run "
"ON trades(run_id, timestamp)"
)
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_decisions_run "
"ON backtest_decisions(run_id, step_index)"
)

# Postgres counterpart of SQLite's
# _ensure_equity_timeseries_uniqueness/_apply_equity_timeseries_uniqueness:
# the natural key that makes a rerun replace rather than duplicate, and
Expand Down
88 changes: 81 additions & 7 deletions dashboard/backend/domain/credits/repository_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,18 @@ def _evidence_identity_json(evidence_json: str) -> str:
"""


# ADDING A COLUMN LATER? It must go in an `ALTER TABLE ... ADD COLUMN IF NOT
# EXISTS` here, *not* only in CREDITS_POSTGRES_DDL above: CREATE TABLE IF NOT
# EXISTS silently no-ops once the table exists, so a deployed table would never
# gain the column and every query naming it would raise UndefinedColumn. Any
# index over that column must be created here too, *below* its ADD COLUMN --
# never in the base DDL, which runs first. #432 killed the Render boot exactly
# that way, and credits_store is built at import, so the crash took the whole
# app down rather than one surface. Nothing catches either mistake first:
# SQLite is the default test tier and CI's Postgres container is empty on
# every run, so only the CREATE path is ever exercised there.
# test_store_twin_parity.py pins both rules statically; see
# domain/agents/repository_postgres.py for the worked example.
CREDITS_POSTGRES_GRANT_MIGRATION_DDL = """
ALTER TABLE credit_ledger_entries
ADD COLUMN IF NOT EXISTS bucket TEXT;
Expand Down Expand Up @@ -366,8 +378,6 @@ def _evidence_identity_json(evidence_json: str) -> str:
ALTER TABLE credit_llm_reservations
ADD CONSTRAINT credit_llm_reservations_attempt_index_check
CHECK (attempt_index >= 0);
CREATE INDEX IF NOT EXISTS idx_credit_llm_reservations_run_status
ON credit_llm_reservations(run_id, status, call_index, attempt_index);
ALTER TABLE credit_accounts
ADD COLUMN IF NOT EXISTS restriction_reason TEXT;
UPDATE credit_llm_reservations
Expand Down Expand Up @@ -422,11 +432,75 @@ def _evidence_identity_json(evidence_json: str) -> str:
END LOOP;
END
$$;
ALTER TABLE credit_llm_reservations
DROP CONSTRAINT IF EXISTS credit_llm_reservations_logical_attempt_key;
ALTER TABLE credit_llm_reservations
ADD CONSTRAINT credit_llm_reservations_logical_attempt_key
UNIQUE (user_id, run_id, call_index, attempt_index);
DO $$
BEGIN
-- Converging, for the same reason as the index repair below: this DDL
-- runs on every boot and ADD CONSTRAINT ... UNIQUE builds a full index
-- under ACCESS EXCLUSIVE, so skip both statements once the constraint is
-- already the four-column one. Column identity comes from conkey rather
-- than a pg_get_constraintdef text match -- the same idiom as the legacy
-- sweep above, and it cannot drift with Postgres's deparsing. Any
-- mismatch simply falls back to drop+add, i.e. the previous behaviour.
IF NOT EXISTS (
SELECT 1
FROM pg_constraint AS con
WHERE con.conrelid = 'credit_llm_reservations'::regclass
AND con.conname = 'credit_llm_reservations_logical_attempt_key'
AND con.contype = 'u'
AND (
SELECT array_agg(att.attname ORDER BY key.ord)
FROM unnest(con.conkey) WITH ORDINALITY AS key(attnum, ord)
JOIN pg_attribute AS att
ON att.attrelid = con.conrelid
AND att.attnum = key.attnum
) = ARRAY['user_id', 'run_id', 'call_index', 'attempt_index']::name[]
) THEN
ALTER TABLE credit_llm_reservations
DROP CONSTRAINT IF EXISTS credit_llm_reservations_logical_attempt_key;
ALTER TABLE credit_llm_reservations
ADD CONSTRAINT credit_llm_reservations_logical_attempt_key
UNIQUE (user_id, run_id, call_index, attempt_index);
END IF;
END
$$;
DO $$
BEGIN
-- Prod carried this index name over (run_id, status, call_index) before
-- #432 added attempt_index. CREATE INDEX IF NOT EXISTS matches by name
-- alone and would keep that stale definition forever, so drop it -- but
-- only while it is stale: this DDL runs on every boot (credits_store is
-- built at import), and an unconditional DROP+CREATE would rebuild the
-- index under ACCESS EXCLUSIVE on every deploy instead of converging.
--
-- Scope, so the next reader does not over-read this: converging is a
-- property of this repair and of the UNIQUE above, NOT of the migration
-- as a whole. Every CHECK and the FOREIGN KEY on credit_llm_reservations
-- and credit_ledger_entries is still dropped and re-added unconditionally,
-- each costing a validating full-table scan per boot. That is deliberate:
-- recognising an existing CHECK means comparing pg_get_constraintdef text,
-- which drifts with Postgres's deparsing, and a mismatch there converges
-- to nothing while adding a way to skip a constraint that ought to be
-- rewritten. Both tables sit behind a disabled billing flag today; revisit
-- if either grows.
IF EXISTS (
SELECT 1
FROM pg_index AS idx
JOIN pg_class AS rel ON rel.oid = idx.indexrelid
WHERE idx.indrelid = 'credit_llm_reservations'::regclass
AND rel.relname = 'idx_credit_llm_reservations_run_status'
AND split_part(pg_get_indexdef(idx.indexrelid), ' USING btree ', 2)
<> '(run_id, status, call_index, attempt_index)'
) THEN
-- IF EXISTS, not bare: the predicate above is evaluated before the
-- lock is taken, so two concurrent boots can both reach this line and
-- the loser would raise "index does not exist", aborting _init_schema
-- -- fatal, because credits_store is built at import.
DROP INDEX IF EXISTS idx_credit_llm_reservations_run_status;
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS idx_credit_llm_reservations_run_status
ON credit_llm_reservations(run_id, status, call_index, attempt_index);

ALTER TABLE credit_grant_pool_ledger_entries
ADD COLUMN IF NOT EXISTS pool_name_snapshot TEXT;
Expand Down
Loading
Loading