* Rewrite comments in Google developer documentation style Rewrite the comments and docstrings across the backend core modules so they read plainly. The previous prose was accurate but dense and figurative, which made it slow to skim. Applies the Google developer documentation style guide: short sentences, active voice, present tense, American spelling, and no metaphors, idioms, or rhetorical asides. Replaces em-dash chains with separate sentences.
71 lines
2.7 KiB
Python
71 lines
2.7 KiB
Python
"""Storing a JSON column compressed.
|
|
|
|
`actions.context_snapshot` holds the entire assembled prompt for a turn. It is
|
|
89% of the database, which is 150.8 MB of JSON across 944 actions in production
|
|
and 232 kB per row on the longest adventure, and the free tier this deploys to
|
|
allows 512 MB. Reads are not the problem. The column is deferred, so a page load
|
|
never touches it and exactly one endpoint fetches one row of it at a time.
|
|
Storage is the problem, and storage has a hard limit.
|
|
|
|
Postgres already compresses the column. TOAST brings 150.8 MB down to about
|
|
89 MB, a factor of 1.7. Postgres chooses pglz for decompression speed on data a
|
|
query might filter on, and no query filters on this column. A prompt is written
|
|
once and read whole, occasionally, by one screen. zlib at the application layer
|
|
reaches three to four times on the same text, and the cost is one decompression
|
|
on a request that already makes an LLM call.
|
|
|
|
Doing it as a TypeDecorator rather than a second column keeps every call site
|
|
writing `action.context_snapshot = {...}` and reading a dict back, and keeps
|
|
`deferred=True`, `undefer()` and `load_only()` naming the same attribute they
|
|
named before. The storage format changes; nothing else does.
|
|
|
|
Level 6 is zlib's default and the knee of the curve here: 9 spends noticeably
|
|
more CPU on prompt text for about a percent more space.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import zlib
|
|
|
|
from sqlalchemy import LargeBinary
|
|
from sqlalchemy.types import TypeDecorator
|
|
|
|
LEVEL = 6
|
|
|
|
|
|
def pack(value) -> bytes:
|
|
"""A JSON-able value as compressed UTF-8."""
|
|
raw = json.dumps(value, separators=(",", ":"), default=str).encode("utf-8")
|
|
return zlib.compress(raw, LEVEL)
|
|
|
|
|
|
def unpack(blob: bytes) -> object:
|
|
"""The value `pack` was given."""
|
|
return json.loads(zlib.decompress(bytes(blob)).decode("utf-8"))
|
|
|
|
|
|
class CompressedJSON(TypeDecorator):
|
|
"""A JSON column stored as zlib-compressed UTF-8 in a BLOB/BYTEA.
|
|
|
|
`cache_ok = True`: the type carries no per-instance configuration, so
|
|
SQLAlchemy may reuse a compiled statement across instances of it.
|
|
"""
|
|
|
|
impl = LargeBinary
|
|
cache_ok = True
|
|
|
|
def process_bind_param(self, value, dialect):
|
|
return None if value is None else pack(value)
|
|
|
|
def process_result_value(self, value, dialect):
|
|
# Tolerate a row the backfill has not reached yet, or one written
|
|
# before the conversion: a snapshot that cannot be read back is worth
|
|
# less than the screen that shows it, and never worth a 500 on the
|
|
# turn that happens to load it.
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return unpack(value)
|
|
except (zlib.error, UnicodeDecodeError, ValueError):
|
|
return None
|