Store context_snapshot compressed

One column is 89% of the database and the free tier allows 512 MB. Reads were
already solved -- the column is deferred, so a page load never touches it and
one screen fetches one row at a time -- but nothing had costed storage, and
storage is the constraint with a cliff: 99.6 MB used, ~94 kB of disk per
action, so the ceiling arrives around 5,400 actions and 944 are stored.

Postgres already compresses it and only gets 1.7x. pglz is tuned for fast
decompression of data a query might filter on, and nothing has ever filtered
on an assembled prompt -- it is written once and read whole, rarely, by the
Insights viewer. zlib gets 3.5x on the same text for a decompress on a request
that already made an LLM call.

Done as a TypeDecorator rather than a second column, so every call site still
writes a dict and reads a dict back, and deferred/undefer/load_only keep
naming the same attribute. Only the storage format moves.

Migrations 43-45: add the bytea, convert into it, drop the original, rename.
The backfill is the one destructive step in the file -- 44 removes the only
other copy -- so it decompresses every row and compares it against what went
in, and a row that fails aborts the run. The whole loop is one transaction, so
an abort rolls the DROP back and the prompts are still there.

Verified on real Postgres, replaying 43-45 from a pre-43 schema on a throwaway
Neon database: 720,864 B of JSON became 204,293 B of bytea, 3.53x, the column
came out named context_snapshot, every snapshot compared equal and the one
NULL stayed NULL.

Postgres does not return the disk by itself: DROP COLUMN only marks the column
gone and the backfill leaves a dead tuple per row, so the table peaks near
twice its size before settling. The deploy needs one VACUUM FULL to collect
it; the migration comment says so.

The egress fixture's snapshots are prose now rather than "x" * 20_000, and the
prose generator moved to tools/fakeprose.py so the harness and the tests share
one definition. A repeated character compresses a thousandfold: against the
old fixture a compressed column looked free and the byte ceilings would have
been guarding nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-17 14:08:23 +05:30
co-authored by Claude Opus 5
parent a6cb49293c
commit ae6e5af6c7
7 changed files with 512 additions and 46 deletions
+13 -5
View File
@@ -6,6 +6,7 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .compression import CompressedJSON
from .database import Base
@@ -220,12 +221,19 @@ class Action(Base):
# Reasoning-model "thinking" that preceded the text (AI actions only).
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
# The full assembled prompt for this turn, for the Insights viewer. By far
# the biggest column in the database (~74 KB/row in production), and needed
# by exactly one endpoint, one action at a time — so it is deferred: never
# loaded unless something actually touches the attribute. Bulk readers must
# NOT touch it; that is what `world_delta` below exists for.
# the biggest column in the database — 163 KB a row averaged over
# production and 232 KB on the longest adventure, 89% of everything stored
# — and needed by exactly one endpoint, one action at a time.
#
# Two separate defences, because it is expensive in two separate ways.
# `deferred=True` is the read defence: never loaded unless something
# touches the attribute, so a page load pays nothing for it. Bulk readers
# must NOT touch it; that is what `world_delta` below exists for.
# CompressedJSON is the *storage* defence: this is the column that decides
# when the free tier's 512 MB runs out. Still a dict either way — see
# compression.py.
context_snapshot: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
CompressedJSON, nullable=True, deferred=True
)
# The small slice of the snapshot that IS needed in bulk: this turn's RPG
# state changes, for the inline chips under an AI message (world_changes)