Cut database egress 189x by deferring the prompt snapshot
The free-tier 5 GB/month network transfer allowance ran out, which blocks connections outright. The database is only ~55 MB, so 5 GB meant the whole thing was being pulled roughly 90 times over. Cause: actions is 39 MB of that 55 MB -- 541 rows at ~74 KB each, almost entirely context_snapshot, which stores the whole assembled prompt for a turn. Every adventure load and every turn fetched all of it in order to read two small things out of it: the world-change chips under an AI message (Action.world_changes) and the emit block re-attached when replaying history to the model (_history_text). The Insights viewer is the only consumer that wants the whole snapshot, and it asks for one action at a time. Lifts that slice into its own small actions.world_delta column (migration 36) and marks context_snapshot, state_before and world_state_before deferred, so they load only when something touches the attribute -- Insights, undo and retry, all single-action paths. The backfill runs server-side, dialect-specific (json_extract on SQLite, #> on Postgres), because pulling 39 MB of snapshots into Python to rewrite a slice of each would defeat the purpose. Measured at production shape (541 actions, 72 KB snapshots), one adventure load goes from 38.46 MB to 0.20 MB. The traffic that consumed 5 GB would now be about 27 MB. Deliberately not included: limiting the history query to recent actions, and removing the redundant db.refresh(adventure) calls. Both were sized against the old numbers; against a 0.20 MB load they would take ~27 MB a month down to ~10 MB, which is not worth the complexity. tests/test_egress.py hooks before_cursor_execute and asserts the emitted SQL never names the deferred columns during a bulk load, so this cannot regress silently. 123 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
co-authored by
Claude Opus 5
parent
7c538c8235
commit
f1bd099ec8
@@ -110,10 +110,49 @@ MIGRATIONS: list[tuple[int, str]] = [
|
||||
# predates this column.
|
||||
(34, "ALTER TABLE actions ADD COLUMN variants JSON"),
|
||||
(35, "ALTER TABLE actions ADD COLUMN variant_index INTEGER NOT NULL DEFAULT 0"),
|
||||
# Egress: context_snapshot holds the whole assembled prompt (~74 KB/row) and
|
||||
# was being loaded in bulk for two tiny things — the world-change chips and
|
||||
# the emit block replayed into history. Lift just that slice into its own
|
||||
# column so the snapshot can be deferred. Backfilled by _backfill_world_delta.
|
||||
(36, "ALTER TABLE actions ADD COLUMN world_delta JSON"),
|
||||
]
|
||||
|
||||
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
||||
|
||||
# Migrations that need a data pass after their DDL, keyed by version.
|
||||
WORLD_DELTA_VERSION = 36
|
||||
|
||||
|
||||
def _backfill_world_delta(conn) -> None:
|
||||
"""Populate actions.world_delta from the existing context_snapshot.
|
||||
|
||||
Runs entirely server-side: the snapshots are the reason this change exists,
|
||||
so pulling ~40 MB of them into Python to rewrite a slice would defeat the
|
||||
point. Dialect-specific because SQLite and Postgres spell JSON access
|
||||
differently, and both have to work (SQLite locally and in tests).
|
||||
"""
|
||||
if conn.dialect.name == "sqlite":
|
||||
sql = """
|
||||
UPDATE actions SET world_delta = json_object(
|
||||
'delta', json_extract(context_snapshot, '$.world_state.delta'),
|
||||
'applied', json_extract(context_snapshot, '$.world_state.report.applied')
|
||||
)
|
||||
WHERE world_delta IS NULL
|
||||
AND context_snapshot IS NOT NULL
|
||||
AND json_extract(context_snapshot, '$.world_state') IS NOT NULL
|
||||
"""
|
||||
else:
|
||||
sql = """
|
||||
UPDATE actions SET world_delta = jsonb_build_object(
|
||||
'delta', context_snapshot::jsonb #> '{world_state,delta}',
|
||||
'applied', context_snapshot::jsonb #> '{world_state,report,applied}'
|
||||
)
|
||||
WHERE world_delta IS NULL
|
||||
AND context_snapshot IS NOT NULL
|
||||
AND jsonb_exists(context_snapshot::jsonb, 'world_state')
|
||||
"""
|
||||
conn.execute(text(sql))
|
||||
|
||||
|
||||
def _get_version(conn) -> int:
|
||||
if conn.dialect.name == "sqlite":
|
||||
@@ -153,6 +192,8 @@ def bootstrap(engine: Engine) -> None:
|
||||
for version, sql in MIGRATIONS:
|
||||
if version > current:
|
||||
conn.execute(text(sql))
|
||||
if version == WORLD_DELTA_VERSION:
|
||||
_backfill_world_delta(conn)
|
||||
current = version
|
||||
_set_version(conn, current)
|
||||
_encrypt_plaintext_api_keys(conn)
|
||||
|
||||
Reference in New Issue
Block a user