Running it · Database

Database and the read-only copy

How Postgres stays fast as data piles up, and how a read-only copy serves fans without ever serving an old score.

Design section
Section 10, agreed 7 Oct 2026
Main code
core/db.py, api/app.py, stats/partitions.py, alembic/env.py
Main tables
integration_row, stat_change, stat_snapshot, replica_heartbeat (new)
Read time
about 20 minutes

In one minute

Omnium has one main database (Postgres) for every write. The agreed design adds one read-only copy for fans, apps and reports. Every write, and every read that must be exact, stays on the main database. That includes the delivery-worker when it builds live scoring files: a live file is always built from the main database.

We measure how far behind the copy is with our own heartbeat: one row on the main database, updated every second. If the copy is more than 5 s behind, reads move to the main database through a small fixed pool. A live match's file is served from Redis first, and on a Redis miss from the main database, never from the copy.

Tables that only grow are split by month, and old months are dropped whole. Dropping a month took 59 ms on a laptop; deleting the same rows took 9.3 s and sent 371 MB of changes to the copy.

Today none of this is built. There is one database and no copy. The public API can already send all its reads to a copy, and that is a trap: the feed builder reads through the public API, so a copy switched on today would make client files out of date.

0.9 ms
median copy lag at 5,700 writes a second, laptop
5 s
lag at which reads move to the main database (design)
59 ms
drop one month, vs 9.3 s to delete it, laptop
38%
stored import rows that changed nothing, laptop

What this part does

This part answers one big question: how does the database stay fast and correct as data piles up, event after event? It splits into eight smaller questions.

#QuestionWhy it matters
1Which reads may use the read-only copy, and which must use the main database?The copy is a few milliseconds to seconds behind. A score, a lock check or a client file built from old data is wrong.
2What happens when the copy falls behind, or is lost?Readers must not quietly get old data. The main database must not be flooded the moment the copy goes away.
3How big does each table get, and when does old data leave?Today some tables grow with every import and are never cleaned.
4Which tables are split into parts by time?Dropping a month is instant. Deleting millions of rows is slow and bloats the table.
5How do rows updated on every ball stay cheap?The match row's counter changes on every ball.
6How many connections does each service get?The 30 Sep plan cut pools from 117 to 56. A copy adds a second set.
7How do we change the schema during an event?One long lock on a big table stops scoring.
8What do backups and restore look like?A bad import or a bad deploy can damage data. We need a safe way back.

How it works

The rule is simple: code that writes, locks or needs exact data uses the main database. Code that can be a few seconds old uses the copy. Each piece of code says in code which kind of session it wants. A URL setting never decides it.

A sketch titled Who reads where. In the middle left is a large cylinder, Main database; in the middle right a smaller cylinder, Read-only copy, with an arrow from main to copy labelled copy of every change. On the far left, four yellow boxes all point into the main database: Commands, Delivery-worker (live files), Staff and scorer screens, and Alerts and stats queue. On the far right, three white boxes point into the copy: Fans and apps (public API), Reports and exports, and History screens and dashboards. A dashed arrow runs from the right side over the top into the main database, labelled fallback when copy is over 5 s behind. At the bottom, pull clients read Redis first, and the main database second, for a live match on a miss.
The left side writes or needs exact data and always uses the main database. The right side may be a few seconds old and uses the copy, with a fallback to the main database.
  1. A command saves on the main database. Scorer taps, console fixes and import rows all become commands. A command takes a lock, writes and commits on the main database. Only the main database can do this.
  2. The main database streams every change to the copy. Postgres sends its change log (the WAL, see the note) to the copy, which replays it. On a laptop this took 0.3 ms when idle.
  3. The delivery-worker builds live files from the main database. It must see the change it was woken for, so it never reads the copy. It writes the file to Redis for pull clients and sends it to push clients.
  4. Fans and apps read the copy. The public API, reports, exports, history screens and dashboards may see data a few seconds old. They read the copy, so they add no load to the main database during a match.
  5. Pull clients read Redis first. A live match's file comes from Redis. On a Redis miss, a live match goes to the main database, never the copy. A finished or scheduled match may fall back to the copy.
  6. A heartbeat measures the lag. One row on the main database is updated every second. Each API server reads that row on the copy every second. Now minus the row's time is the lag.
  7. If the copy is over 5 s behind, reads move. Reads that set a freshness limit move to the main database through a small fixed pool, at most 4 connections per server. If the lag stays over 10 s for 60 s, an alert fires.
  8. Old data leaves by the month. A daily job creates partitions 3 months ahead and drops months whose rows are all past their keep time. Nothing deletes millions of rows one by one.

What happens when the copy lags

Lag is normal. It is milliseconds most of the time. The design says exactly what happens at each level, so nobody has to decide in the middle of a match.

A sketch titled How far behind is the copy? A long arrow labelled lag runs left to right. Under it are six boxes in order: Milliseconds, reads use the copy (green); Under 5 s, reads still use the copy (green); Over 5 s, reads move to main (yellow), with a note above it saying max 4 main connections per server; 10 s for 60 s, alert fires (red); Copy lost, all reads on main (red); Catches up, reads go back to copy (green). A curved arrow from the last box goes back to the first, labelled back to normal.
Six levels of lag and what the system does at each one.
Lag levelWhat readers getWhat the system doesStatus
Milliseconds (normal)Fans and reports read the copy and see the latest data, a few ms late. Live files, staff screens and alerts read the main database, as always.Nothing. The heartbeat keeps measuring every second.Agreed, to build
Under 5 sread() still uses the copy. A fan may see a score up to that many seconds old. Each answer carries its data age.Nothing changes in routing. The health page shows the lag.Agreed, to build
Over 5 sReads with a freshness limit move to the main database. Reads without one stay on the copy and carry their data age.Each API server sees it within 1 s. The fallback uses a fixed pool of 4 connections per server, so the main database is never flooded.Agreed, to build
Over 5 s, imports and bulk writesLive scoring carries on as normal.Imports and other bulk writes pause until the copy is back under 5 s, so they stop making the lag worse (GitHub's freno idea).Proposed
Over 10 s for 60 sSame as over 5 s.An alert fires (the copy-lag rule in the alert service).Agreed, to build
Copy lost (no answer)All read() calls use the main database through the fallback pool. Pull clients barely notice, because they read Redis first.The lag watcher reports "infinity", so reads fall back. An alert fires.Agreed, to build
Catches upReads go back to the copy.Agreed design: back to the copy as soon as lag is at or under 5 s. Proposed: switch back only once lag is under 2 s, so reads do not flip back and forth around 5 s.Proposed

How much lag is normal?

This was asked during the design review. The answer, from measurements on one laptop (one machine, no network, 7 Oct):

SituationMedian99th percentileWorst
Idle0.32 ms0.69 msnot recorded
Under 5,700 writes a second0.90 ms6.40 ms22.8 ms

On AWS: not measured. AWS publishes no typical number. Lag grows with heavy writes, long locks and a smaller copy. This is why routing uses our own heartbeat with a 5 s limit, and why we measure real AWS lag once the copy exists (Section 14, parked).

How others do it

These teams faced the same problem. Each page below was opened and read on 7 Oct.

WhoWhat they doWhat we take
GitHub, frenoA heartbeat writes a timestamp on the main database every 100 ms, so lag is measured, not guessed. Bulk writes go in batches of 50 or 100 rows and pause while lag is too high. They aim for under one second of lag.Our own heartbeat (agreed, every 1 s). Pausing imports while lag is over 5 s (proposed).
Rails, multiple databasesPOST, PUT, DELETE and PATCH go to the main database. A GET right after the same user's write also goes to the main database for a "delay" of 2 seconds by default. Rails says it guarantees "read your own write".The same idea, made stronger: staff and scorers never read the copy at all, so they always see their own change.
Shopify, read consistencyWith several copies at different lags, one reader can see a newer value and then an older one. Shopify hashes an id sent with each query, so a series of reads always goes to the same copy ("monotonic reads").Proposed: each API server sticks to one copy, so one server never shows a score going backwards.
PostgreSQL, hot standbyA copy allows SELECT only: no writes, no SELECT FOR UPDATE, no nextval, no LISTEN or NOTIFY. Reads on it can be cancelled when replay conflicts.Only plain reads go to the copy. A cancelled read is retried once on the main database.

A worked example

Example · A football final while a big import makes the copy fall behind

All times are on one Saturday. The match is live. Lag values in this example are made up to show the rules; they are not measurements.

15:02:00.000. The scorer taps "goal". The command takes the match lock on the main database, writes the goal, bumps the match counter to seq 41 and commits. The copy has not seen it yet.

15:02:00.001. The copy replays the change (about 1 ms is normal, see the table above).

15:02:00.050. The delivery-worker is woken. It reads the match from the main database, builds the live file at seq 41, writes it to Redis, and sends it to push clients. It does not wait for the copy and never asks it.

15:02:00.200. A fan's app asks the public API for the score. The API server's read() checks its lag value: 0.4 s, under 5 s. The query runs on the copy, which already has the goal.

15:10. A large import starts: 12,725 rows (the largest import run seen locally). Say it pushes the copy 7 s behind.

15:10:07. The heartbeat row on the copy now shows a time 7 s old. Within 1 s every API server knows. Fan reads with a freshness limit move to the main database through the fallback pool. Each server uses at most 4 connections for this, so the main database is not flooded. (Proposed: the import itself pauses until lag is back under 5 s.)

15:10:08. Google pulls the live match file. Redis has it, so it is served from Redis. Had Redis missed, the file would come from the main database, because the match is live. A finished match from yesterday could come from the copy.

15:10:30. The import ends. The copy catches up. Lag drops to 1.5 s. Under the agreed design, reads go back to the copy now, because 1.5 s is under 5 s. (Proposed: back only once lag is under 2 s, which 1.5 s also meets.)

What nobody saw. The lag never stayed over 10 s for 60 s, so no alert fired. The scorer, the delivery-worker and Google always had the latest score. Some fans saw the score up to 5 s late, as designed.

Low-level design

Today: one engine factory Built today

Every service builds its database engine through one function. Here it is, as it is today.

# packages/core/src/omnium_core/db.py:18-37
def build_engine(settings: Settings, *, url: str | None = None) -> AsyncEngine:
    """Create an async engine from settings (no connection is opened here). ..."""
    return create_async_engine(
        url or settings.database_url,
        echo=settings.echo_sql,
        pool_pre_ping=True,
        pool_size=settings.db_pool_size,
        max_overflow=settings.db_max_overflow,
        pool_timeout=settings.db_pool_timeout,
        pool_recycle=settings.db_pool_recycle,
        connect_args={
            "server_settings": {"statement_timeout": str(settings.db_statement_timeout_ms)}
        },
    )
# packages/core/src/omnium_core/settings.py:270-275
db_pool_size: int = 10  # persistent connections per engine
db_max_overflow: int = 5  # extra connections allowed under burst
db_pool_timeout: float = 10.0  # seconds to wait for a pooled connection
db_pool_recycle: int = 1_800  # recycle connections older than this (seconds)
# Server-side statement timeout (ms): a pathological query can't pile up.
db_statement_timeout_ms: int = 15_000

What this means:

  • Each engine keeps 10 connections and may open 5 more under load.
  • Every query stops after 15 s (statement_timeout). That is the only server setting.
  • There is no lock_timeout (how long to wait for a lock), no idle_in_transaction_session_timeout (how long a transaction may sit open and idle) and no application_name (a name that tells you which service owns a connection).
  • With one copy of each service, the design doc counts about 117 connections at most: api 15, admin 32, scheduler 34, stats 21, delivery 15 (from the 30 Sep plan; not re-counted for this page).

Today: the public API sends all reads to the copy Built today

# packages/api/src/omnium_api/app.py:50-60
engine = build_engine(settings)
# GraphQL is read-only → route its sessions to the replica when one is
# configured; otherwise reuse the primary engine (no redundant pool).
read_engine = (
    build_engine(settings, url=settings.replica_database_url)
    if (settings.replica_database_url)
    else engine
)
app.state.engine = engine
app.state.read_engine = read_engine
app.state.session_factory = build_serial_session_factory(read_engine)

If REPLICA_DATABASE_URL is set, the public API makes a second engine and gives every request a session on it. That covers GraphQL and also the document GET routes, which read published_document through the same factory (packages/api/src/omnium_api/deps.py:10-21, routers/documents.py). REPLICA_DATABASE_URL is set nowhere in the repo. On 21 Sep, staging's replica URL pointed at the main instance itself, so no real copy has ever been tested.

Today: why a copy would make client files out of date Built today

The feed builder does not read the database directly. It runs a saved GraphQL query over HTTP against the public API:

# packages/core/src/omnium_core/publishing/feed.py:158-194
class HttpQueryExecutor:
    """Run persisted queries over HTTP against omnium's public GraphQL API. ..."""

    async def execute(self, query: str, variables: dict[str, Any], root: str) -> Any:
        try:
            async with httpx.AsyncClient(timeout=self._timeout) as client:
                response = await client.post(
                    self._endpoint, json={"query": query, "variables": variables}
                )
        ...
        return (payload.get("data") or {}).get(root)


def default_executor() -> QueryExecutor:
    """The process query executor: HTTP against ``api_url`` ``/graphql``."""
    base = get_settings().api_url
    return HttpQueryExecutor(f"{base}/graphql")

Put the two snippets together. The file builder is woken by a new goal. It asks the public API. The public API reads the copy. The copy may not have the goal yet. The file goes out without the goal, and nothing rebuilds it until the next change. That is why the agreed order is: first the delivery-worker builds from the main database directly (Section 9, D1), then a copy is switched on (DB5).

Agreed: sessions in code Agreed, to build

Agreed design, not in the code yet. One class becomes the only way code reaches the database. Each route asks for the kind it needs.

# core/src/omnium_core/db.py  (agreed design, not in the code yet)
class Sessions:
    """The only way code reaches the database. Each route says which kind it needs."""

    def write(self) -> AsyncSession:
        """Main database: commands, locks, and every read that must be exact.
        An ordinary SQLAlchemy session: ORM, raw SQL, savepoints, advisory locks and bulk inserts all work."""

    def raw(self) -> AsyncContextManager[asyncpg.Connection]:
        """A driver connection on the main database for what a session does badly: COPY, LISTEN (Section 8)."""

    def read(self, max_age_s: float = 5.0) -> AsyncSession:
        """The copy, if it is at most max_age_s behind; else the main database through a small fixed pool."""
        if self._copy is None or self._lag.seconds() > max_age_s:
            return self._fallback()          # at most 4 connections per server on the main database
        return self._copy_session()          # read-only, 5 s statement limit

What each part does:

  • write() gives an ordinary SQLAlchemy session on the main database. Anything SQLAlchemy can do works: the ORM, raw SQL, savepoints, advisory locks, bulk inserts. The user asked for this to stay flexible, and it does (DE1).
  • raw() gives a plain asyncpg connection on the main database, for the two things a session does badly: COPY (fast bulk load) and LISTEN (wake-ups, Section 8).
  • read(max_age_s) returns a copy session if the copy is at most max_age_s behind. Otherwise it returns a session from the small fallback pool on the main database. With no copy set, it uses the main database, so nothing changes until a copy exists.
  • On the copy, every query has a 5 s limit.

A copy can cancel a read when replay needs to remove rows the read still uses. Postgres reports this as error code 40001. The agreed helper retries such a read once on the main database:

# core/src/omnium_core/db.py  (agreed design, not in the code yet)
async def read_with_retry(sessions: Sessions, work: Callable[[AsyncSession], Awaitable[T]]) -> T:
    try:
        async with sessions.read() as s:
            return await work(s)
    except DBAPIError as exc:
        if sqlstate(exc) != "40001":       # measured: the copy cancels with 40001 (conflict with recovery)
            raise
        async with sessions.fallback() as s:  # once, on the main database
            return await work(s)

In our read-only queries, 40001 can only mean the copy cancelled the query. So one retry on the main database is safe. Any other error is raised as normal.

Agreed: who reads where Agreed, to build

ReaderSessionWhy
Commands and the reads inside themwrite()They lock and write. A copy cannot do either.
Delivery-worker (builds live files)write()It must see the change it was woken for (Section 9, D1).
Pull API, live match, on a Redis missmain databaseA live file must never be older than Redis already was. Agreed in chat on 7 Oct.
Pull API, finished or scheduled match, on a Redis missread()Old data is fine. The fallback rule still applies.
Staff and scorer screens, and their live watcheswrite() / raw()Staff must see their own change at once (Section 8).
Import comparisonswrite()An import compares against the latest row (Section 3).
Alert checks and the stats queuewrite()Alerts must judge the latest state (Section 11).
Public API (fans and apps)read()A few seconds old is fine.
Reports, exports, admin history screens, dashboardsread()Long reads belong on the copy. They page through data in short queries of under 5 s each.

Agreed: the heartbeat Agreed, to build

Agreed design, not in the code yet. A new table with exactly one row:

-- new table (agreed design, not in the code yet)
CREATE TABLE replica_heartbeat (id int PRIMARY KEY CHECK (id = 1), beat_at timestamptz NOT NULL);

-- On the main database, every second, by the alert service (one working copy, Section 11):
UPDATE replica_heartbeat SET beat_at = now() WHERE id = 1;

-- On the copy, every second, by each API server:
SELECT extract(epoch FROM now() - beat_at) FROM replica_heartbeat;

Does this table fill up? No. This was asked in review. The CHECK (id = 1) allows only one row, and the beat updates it in place. Measured on a laptop after 10,000 beats: still 1 row in one 8 KB page, and 100% of updates were HOT (see the note). Each beat writes 115 bytes of WAL, about 9.7 MB a day.

Why not use AWS's own lag number? AWS measures lag as "now minus the last replayed change". On a quiet database nothing changes, so the copy looks minutes behind (AWS's read replica docs, as cited in the design doc, say "up to five minutes"). One write a second keeps the measure true. AWS's number is shown on the health page but never used to route.

What if the alert service stops? The beats stop. The copy looks behind, and reads move to the main database. That is the safe direction, and the alert service's own silence is alerted anyway. Clocks on the two servers are kept in sync by AWS; any clock error is far below 5 s (not measured).

# core/src/omnium_core/db_lag.py  (new; agreed design, not in the code yet)
class LagWatcher:
    """One per API server. Reads the heartbeat age on the copy every second."""
    def seconds(self) -> float:
        """The last measured lag. If the copy did not answer, infinity, so reads fall back."""

Today: partitions Partly built

A partition is one piece of a table that holds one range of dates. Postgres treats all pieces as one table for queries. You can remove one piece in one quick statement.

Three stats tables are already split:

# packages/contract/src/omnium_contract/models/stats_engine.py:495-502
#: The partitioned tables and their partition column. ``omnium_stats.partitions``
#: creates partitions ahead of time from this list.
PARTITIONED: dict[str, tuple[str, str]] = {
    # table: (partition column, grain)
    "stat_value": ("occurred_on", "year"),
    "stat_change": ("changed_on", "month"),
    "stat_snapshot": ("taken_on", "month"),
}

New partitions are created by ensure():

# packages/stats/src/omnium_stats/partitions.py:51-71
async def ensure(conn: AsyncConnection, *, today: date | None = None, ahead: int = 2) -> list[str]:
    """Create the missing partitions. Returns the names it created."""
    ...
    for table, name, start, end in plan(today, ahead=ahead):
        if name in existing:
            continue
        await conn.execute(
            sa.text(
                f"CREATE TABLE IF NOT EXISTS {name} PARTITION OF {table} "
                f"FOR VALUES FROM ('{start.isoformat()}') TO ('{end.isoformat()}')"
            )
        )
# packages/stats/src/omnium_stats/worker.py:216-217
async with proc.engine.begin() as conn:
    created = await partitions.ensure(conn)

What this means:

  • ensure() creates today's partition and the next 2 periods.
  • The only caller found is the stats worker at start-up (worker.py:217). The module's docstring says it also runs "once a day", but no daily caller was found in the code.
  • So a stats worker that keeps running across a month boundary can send rows to the default partition. Nothing is lost, but nothing alerts on it either.
  • Nothing drops old partitions.

Today: tables that grow forever Built today

integration_row (one row per row of every import run) is not partitioned (packages/contract/src/omnium_contract/models/integrations.py:138-142). On the laptop database it was 82% of everything:

What (laptop database, imports from 16 to 28 Sep, not prod)Value
Whole database655 MB
integration_row312,058 rows, 535 MB, 82% of the database
Average stored import row1,375 bytes (raw and mapped JSON)
Import rows that changed nothing, stored anyway118,520 (38%)
Rows per import runmedian 388, 90th percentile 5,835, largest 12,725

Other tables that are never cleaned today: timeline_item, stat_change, stat_snapshot, stat_period, delivery_deadletter, raw_ingested_result, and finished stats-queue jobs. The outbox has a prune function, but no caller was found:

# packages/core/src/omnium_core/jobs_outbox.py:75-80
async def prune(session: AsyncSession, *, older_than: timedelta = PRUNE_AFTER) -> int:
    """Clear events past any conceivable replay. Returns how many went."""
    result = await session.execute(
        sa.delete(DomainEvent).where(DomainEvent.occurred_at < sa.func.now() - older_than)
    )
    return int(getattr(result, "rowcount", 0) or 0)

Delivery attempts are the one table pruned today (packages/worker/src/omnium_worker/delivery.py:379).

Agreed: monthly partitions and retention Agreed, to build

The rule (DB7): a table that can pass 10 million rows or 10 GB a year is split by month, and old data leaves by dropping whole months. Smaller logs get a nightly delete in batches.

A sketch titled Removing one old month, in two panels. Left panel, DELETE the rows: a table integration_row with a red block of rows marked September; an arrow to a red box saying 9.3 s and 371 MB of WAL; an arrow to a Read-only copy cylinder with a red note, falls behind. Right panel, DROP the partition: a table made of month blocks Aug, Sep, Oct, Nov with Sep crossed out; an arrow to a green box saying 59 ms and 61 kB of WAL; an arrow to a Read-only copy cylinder with a green note, stays current. A line at the bottom says measured on a laptop, 7 Oct.
Deleting a month's rows makes the copy replay hundreds of megabytes; dropping its partition sends almost nothing. Measured on a test month of 300,000 rows (343 MB) on a laptop.

Agreed design, not in the code yet. integration_row gets a monthly twin. The partition column must be part of the primary key:

-- integration_row, split by month (new table; agreed design, not in the code yet)
CREATE TABLE integration_row_v2 (
  id           bigint GENERATED ALWAYS AS IDENTITY,
  created_at   timestamptz NOT NULL DEFAULT now(),
  run_id       uuid NOT NULL, integration_id uuid NOT NULL, position integer NOT NULL,
  subject_key  varchar(256), subject_kind varchar(16), subject_id uuid, row_hash varchar(64),
  raw jsonb, mapped jsonb, outcome varchar(16) NOT NULL, message text, timeline_item_id bigint,
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX ON integration_row_v2 (run_id, position);
CREATE INDEX ON integration_row_v2 (integration_id, subject_key, id);

-- Daily job: create 3 months ahead.
CREATE TABLE IF NOT EXISTS integration_row_v2_2027_01 PARTITION OF integration_row_v2
  FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');

-- Daily job: drop a month once its newest row is past retention, unless a hold says no.
ALTER TABLE integration_row_v2 DETACH PARTITION integration_row_v2_2026_09 CONCURRENTLY;
DROP TABLE integration_row_v2_2026_09;

This SQL was also run on Postgres 15.19, because our docs say prod may be on 15.

How "keep 30 days" works with months. A month is dropped only when its newest row is past 30 days. So each row is kept at least 30 days and at most about 60. Example: the September partition holds rows up to 30 Sep. Those reach 30 days old on 30 Oct. The daily job drops September on its first run after that.

Moving a big table without a long lock. We never copy the old table into the new one:

  1. Create the partitioned table under a new name.
  2. Release code that writes new rows only to it.
  3. History screens read both tables through one view for 30 days.
  4. After 30 days the old table holds only expired rows. Drop it, which is one quick statement.

The keep rules, as agreed (DB8):

# core/src/omnium_core/retention/rules.py  (new; agreed design, not in the code yet)
RULES = [
    Rule("integration_row", by="month", column="created_at", keep=days(30)),
    Rule("domain_event",    by="month", column="occurred_at", keep=days(30)),  # its real time column
    Rule("command",         by="month", column="created_at", keep=days(30)),     # Section 4
    Rule("stat_change",     by="month", column="changed_on", keep=days(90)),     # already monthly
    Rule("stat_snapshot",   by="month", column="taken_on",   keep=days(90)),     # already monthly
    Rule("procrastinate_jobs", by="delete", keep=days(7),  where="status = 'succeeded'"),
    Rule("procrastinate_jobs", by="delete", keep=days(30), where="status = 'failed'"),
    Rule("delivery_attempt",   by="delete", keep=days(14)),                       # as today
    Rule("alert_signal",       by="delete", keep=days(7)),                        # Section 11
]
# Kept, never in RULES: timeline_item (the match log), stat_value, stat_period.

# core/src/omnium_core/retention/job.py  (new; agreed design, not in the code yet)
async def run_daily(sessions: Sessions, today: date) -> RetentionReport:
    """Create partitions 3 months ahead; detach and drop months past retention unless a hold
    covers them; delete small tables in batches of 10,000; write every action to retention_log."""

Raw import payloads are kept 30 days and import run summaries 1 year. A person can hold any rule, for example during a dispute. A hold is a recorded command (ops.hold_retention, ops.release_hold), and every drop is written to the new retention_log table.

Store only what changed (DB9, DE6). An import row identical to last time is counted on the run, not stored again. Only new or changed rows, and rows with a problem, keep their payload. Locally that removes 38% of stored import rows.

Agreed: hot rows stay cheap Agreed, to build

The match row's counter changes on every ball. That stays cheap only while updates are HOT. Measured on a laptop: 488,714 updates on 200 hot rows were 99.9% HOT, and the table grew only from 3.6 to 4.3 MB. A lower fill factor (70) did not help (99.8% HOT and a bigger table). So the rules are: no index on a column that changes on every ball, the default fill factor stays, and a test enforces both (DB11, DE10).

Today and agreed: migrations Partly built

Today each migration upgrade runs in one transaction, with the 15 s statement limit and no lock limit:

# packages/core/alembic/env.py:70-80
def do_run_migrations(connection: Connection) -> None:
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        compare_type=True,
        compare_server_default=True,
        include_object=include_object,
    )

    with context.begin_transaction():
        context.run_migrations()

Migration 059 built an index on the biggest table with the time limit switched off, and not CONCURRENTLY:

# packages/core/alembic/versions/20260921_059_integration_row_latest_index.py:39-47
def upgrade() -> None:
    op.execute("SET LOCAL statement_timeout = 0")
    op.create_index(
        INDEX,
        "integration_row",
        ["integration_id", "subject_key", sa.text("id DESC")],
        postgresql_include=["row_hash"],
        postgresql_where=sa.text(HELD),
    )

A plain CREATE INDEX blocks writes to the table until it ends. On a big table during a match, that stops scoring.

Agreed (DB12, DE7): the migration runner sets a 2 s lock limit with 5 retries. Index steps use CONCURRENTLY in their own autocommit block (CONCURRENTLY builds the index without blocking writes, but cannot run inside a transaction). A new CI check, scripts/check_migrations.py, refuses a plain index or a type change on a big table. Heavy changes wait for a gap between sessions.

Agreed: connections Agreed, to build

DB13 and DE8: one database user per service, each with its own connection limit (30 Sep plan). Every connection sets application_name, so the health page can group connections by service. A transaction left open and idle ends after 60 s. The copy gets its own small pools: 8 connections per API server. The fallback pool on the main database is 4 per server.

Settings

SettingDefaultWhat it doesStatus
REPLICA_DATABASE_URLnoneThe copyBuilt today exists today
DB_POOL_SIZE, DB_MAX_OVERFLOW10, 5Pool per engineBuilt today
DB_STATEMENT_TIMEOUT_MS15000Limit on any one query, main databaseBuilt today
READ_MAX_AGE_S5Lag above which reads fall back to the main databaseAgreed, to build
READ_FALLBACK_POOL4Main-database connections per server for the fallbackAgreed, to build
REPLICA_POOL_SIZE8Copy connections per API serverAgreed, to build
REPLICA_STATEMENT_TIMEOUT_MS5000Limit on every query on the copyAgreed, to build
RETENTION_AHEAD_MONTHS3Partitions created aheadAgreed, to build
MIGRATION_LOCK_TIMEOUT_MS, MIGRATION_RETRIES2000, 5The migration runner's lock limit and retriesAgreed, to build
IDLE_IN_TRANSACTION_TIMEOUT_MS60000Ends a transaction left open and idleAgreed, to build

The DB_* names are the environment form of the fields in packages/core/src/omnium_core/settings.py:270-275.

The database health page Agreed, to build

One page in the admin panel shows what matters. It reads system views only. Its heavy queries run on the copy; the lag and lock parts run on the main database.

PartWhat a person seesReads
Copy lagLag now and the worst in the last hour. Red over 5 sreplica_heartbeat on the copy
DiskUsed, growth per day over 7 days, days leftpg_database_size, the new daily table db_size_daily
ConnectionsUsed against the limit, per servicepg_stat_activity by user and application_name
TablesSize, rows, growth, keep rule, next month to drop, months created ahead. A person can hold retentionpg_class, pg_stat_user_tables, the partition list, retention_rule
Hot rowsHOT share of updates. Red under 90%pg_stat_user_tables
Slow queriesThe 10 queries using the most total timepg_stat_statements (whether it is on in our instances: not checked)
LocksWho waits for whom right nowpg_locks, pg_blocking_pids

Order of work

  1. Quick wins: application_name and the idle limit, the daily partition job, a daily call to the outbox prune that already exists, cleanup of finished stats-queue jobs, dropping the duplicate index.
  2. Sessions in code, every route choosing read or write. With no copy set, read() uses the main database, so behaviour does not change.
  3. Store only changed import rows.
  4. integration_row and domain_event move to monthly partitions (new table plus view).
  5. The migration runner limits and the CI check.
  6. The health page and daily size snapshots.
  7. The delivery-worker builds from the main database directly (Section 9, D1).
  8. The heartbeat, the lag watcher and the fallback, tested against a real copy in Docker.
  9. The copy itself on AWS, with Section 14 (parked). No AWS step without asking first.

When things go wrong

The rule for every case: a reader that needs exact data never gets old data, and the main database is never flooded because the copy went away.

What goes wrongWhat happens
The copy falls more than 5 s behindEach API server sees it within 1 s. Reads with a freshness limit move to the main database through 4 connections per server. Other reads stay on the copy and carry their data age. An alert fires if lag stays over 10 s for 60 s.
The copy is lostThe same fallback, plus an alert. Pull clients barely notice: they read Redis first, and a live match on a Redis miss already reads the main database.
A long read on the copy is cancelledMeasured locally: the copy cancels with 40001 when cleanup on the main database removes rows the read still needs. A short read is retried once on the main database. Long jobs page through in queries of under 5 s, so they never hold a long snapshot.
A fan reads just after a changeThe fan may see the old value while the copy is behind, within the 5 s limit. Staff, scorers, the delivery-worker and alerts never read the copy.
A live match file misses in RedisIt is built or read from the main database, never the copy.
The main database failsCommands get "try again" (Section 4). Senders resend with the same key, so nothing is applied twice. With semi-synchronous standbys no committed score is lost. A plain async replica could lose the last commits on promotion, which is why it is not the failover plan.
A big DELETE floods the copyMeasured: deleting one month (300,000 rows) wrote 371 MB of WAL for the copy to replay. Dropping the partition wrote 61 kB. Retention drops partitions.
Next month's partition is missingRows land in the default partition and nothing is lost. The daily job creates 3 months ahead, and rows in a default partition raise an alert.
The disk fillsAlerts at 70% and 85% used. The health page shows days left.
A migration takes a long lock during a match2 s lock limit with retries, CONCURRENTLY indexes, no rewrite of a big table. Risky changes wait for a gap between sessions.
Rows updated on every ball bloat the table99.9% HOT measured. A test fails if any such column gets an index.
Too many connections after a deployOne database user per service with its own limit; an alarm at 70% of the limit.
Code that needs the main database runs on the copyWrites, row locks, nextval and LISTEN fail on a copy. The session type decides the route, and a test runs every read-only route against a real copy.
Imports keep the copy behindProposed: imports and bulk writes pause while lag is over 5 s, then carry on.
Lag hovers around 5 sAgreed design switches back at 5 s, so reads could flip often. Proposed: move off at 5 s, come back under 2 s.
Data is damaged by a bad import or a bugScoring data is rewound through the command log (Sections 4 and 5). Anything else: point-in-time recovery into a new instance, and only the damaged rows are copied back. The runbook is practised.

Decisions

All fifteen DB decisions and ten DE decisions were agreed on 7 Oct. The last four rows were added in chat after the doc.

#DecisionIn plain words
DB1Two kinds of session in code: write (main database) and read (the copy). Each route chooses one, replacing the public API's "every read to the copy" rule.The code, not a URL, decides who may see slightly old data.
DB2Only the public API, the pull API on a Redis miss, reports and exports, admin history screens and dashboards read the copy. Everything else reads the main database. Refined in chat: on a Redis miss, a live match reads the main database; only finished or scheduled data may use the copy.Scores, locks, client files, staff screens and alerts always see the latest.
DB3Our own heartbeat every second. Over 5 s, reads with a freshness limit move to the main database through a small fixed pool. Alert at 10 s for 60 s. AWS's lag number never decides routing.A lagging or lost copy never serves old scores and never floods the main database.
DB4Every query on the copy has a 5 s limit; long jobs page through; hot_standby_feedback stays off.The copy cannot cancel our work or bloat the main database.
DB5A real copy is switched on only after the delivery-worker builds from the main database directly and every read route has passed against a real copy.We never find a stale-file bug in production.
DB6Recommended shape: a Multi-AZ DB cluster, one writer and two readable standbys with semi-synchronous commits. The code works the same with one replica. Cost and setup are Section 14's.A read-only copy and a failover that loses no committed score, in one setup.
DB7A table that can pass 10 million rows or 10 GB a year is split by month; retention drops whole months. Smaller logs get a nightly delete in batches.Old data leaves in milliseconds, not hours of deletes.
DB8Keep: raw import rows 30 days, run summaries 1 year; commands and outbox 30 days; stat_change and stat_snapshot 90 days; finished stats-queue jobs 7 days; delivery attempts 14 days. Match log, stat values and stat periods are kept. A recorded hold can pause any rule.Clear rules for what stays and what goes.
DB9An import row identical to last time is counted, not stored.38% of today's stored import rows disappear.
DB10A daily job creates partitions 3 months ahead, instead of the stats worker at start-up. Rows in a default partition raise an alert.A new month never lands in the wrong place.
DB11No index on a column that changes on every ball. Default fill factor stays. A test enforces both.The match row stays cheap.
DB12Migrations: 2 s lock limit with retries, CONCURRENTLY outside the transaction, never rewrite a big table, heavy work between sessions.A deploy never freezes a live match.
DB13One database user per service with its own limit, application_name everywhere, 60 s idle-transaction limit. The copy gets its own small pools.We see who uses what; no service starves the others.
DB14Point-in-time recovery with 14 days kept (prod's current setting not checked), a practised restore runbook, scoring rewound through the command log.When data is damaged, we know how to get it back.
DB15One Postgres major version in CI, staging and prod, matched to prod once checked.Tests prove the version we actually run.
DE1Sessions.write() and Sessions.read(max_age_s) in core/db.py are the only ways to the database. Both are ordinary SQLAlchemy sessions, so any database operation works. Sessions.raw() gives a driver connection for COPY and LISTEN.One place decides every route, and it stays flexible.
DE2A one-row replica_heartbeat updated every second by the alert service; API servers read its age on the copy every second.Lag is measured by us, cheaply, and fails safe.
DE3Copy pools: read-only, 5 s statement limit, 8 connections per API server; a 40001 is retried once on the main database.Cancelled reads are invisible to users.
DE4integration_row, domain_event and the command table become monthly partitioned tables, moved by "new table plus view".Retention by dropping months, with no long lock.
DE5A daily retention job in the stats-worker (it was "in the scheduler"; the scheduler container was removed on 7 Oct, and its timed jobs moved to the stats-worker): create 3 months ahead, drop aged-out months unless held, batched deletes for small tables, every action in retention_log.Growth is handled by a machine, and every drop is recorded.
DE6Imports store a row only when its hash changed or it has a problem.Import storage drops by about a third.
DE7Migration runner: lock_timeout 2 s with 5 retries; CONCURRENTLY steps in their own autocommit block; a CI check.Safe migrations are the default.
DE8Pool server settings: application_name, 60 s idle-transaction limit, the Section 4 limits per pool.Every connection is named and none can hang forever.
DE9A daily size snapshot per table (db_size_daily).We see growth coming weeks ahead.
DE10Drop the duplicate index on raw_ingested_result; a test fails if any every-ball column gets an index.Less write cost now, and HOT updates protected.
Chat, 7 OctLive scoring files are always built from the main database. A live match's file is served from Redis first and, on a Redis miss, from the main database, never the copy. Finished or scheduled data may fall back to the copy. Agreed, to buildA live file is never older than the latest score.
ProposedMove off the copy at 5 s, come back only under 2 s. ProposedReads do not flip back and forth.
ProposedEach API server sticks to one copy. ProposedOne server never shows a score going backwards (Shopify's problem).
ProposedImports and bulk writes pause while the copy is over 5 s behind. ProposedImports stop making the lag worse (GitHub's freno idea).

Built today, or still to build

PieceTodayAgreed designStatus
Engine factoryOne build_engine, pool 10 + 5, 15 s statement limit only (packages/core/src/omnium_core/db.py:18-37, settings.py:270-275)Adds application_name and a 60 s idle limit; one user per servicePartly built
RoutingAll public API reads go to the copy when REPLICA_DATABASE_URL is set (packages/api/src/omnium_api/app.py:50-60)Sessions.write() / read() / raw(), chosen per routeAgreed, to build
File builds readThrough the public API over HTTP (packages/core/src/omnium_core/publishing/feed.py:158-194)Reads the main database directly (Section 9, D1)Agreed, to build
Pull documents/v1/fixtures/... reads published_document through the API's read session (packages/api/src/omnium_api/routers/documents.py:49-52); no Redis on this pathRedis first; live match on a miss from the main database; finished or scheduled may use the copyAgreed, to build
Lag measureNone (replica_heartbeat not found in code)Heartbeat every 1 s, LagWatcher, 5 s fallback, alert at 10 s for 60 sAgreed, to build
Copy query limit and retryNone5 s limit, one retry on 40001Agreed, to build
Stats partitionsstat_value by year, stat_change and stat_snapshot by month (packages/contract/src/omnium_contract/models/stats_engine.py:497-502); created only at stats-worker start, 2 periods ahead (packages/stats/src/omnium_stats/worker.py:217, partitions.py:51)A daily job, 3 months ahead, alert on default-partition rowsPartly built
integration_rowOne plain table, never cleaned (packages/contract/src/omnium_contract/models/integrations.py:138-142)Monthly integration_row_v2 plus a view, 30 days keptAgreed, to build
RetentionOutbox prune exists, no caller found (packages/core/src/omnium_core/jobs_outbox.py:75); delivery attempts pruned (packages/worker/src/omnium_worker/delivery.py:379)retention/ rules and daily job, holds, retention_logPartly built
Unchanged import rowsStored in full (38% locally)Counted, not storedAgreed, to build
MigrationsOne transaction, no lock limit (packages/core/alembic/env.py:79-80); 059 with statement_timeout = 0 and a plain index (versions/20260921_059_integration_row_latest_index.py:40-47)2 s lock limit, 5 retries, CONCURRENTLY, CI checkAgreed, to build
Duplicate indexraw_ingested_result has a unique constraint and a plain index on the same six columns (packages/contract/src/omnium_contract/models/ingestion.py:129-148)Drop the plain indexAgreed, to build
Health pageNoneadmin/.../routers/db_health.py, db_size_dailyAgreed, to build
BackupsNo restore runbook found; prod's point-in-time recovery not checked14 days PITR, practised runbookAgreed, to build
Postgres versionDocs say 15.7, deploy example 17, CI and laptops 17, staging 18.3 (21 Sep); prod not checkedOne major version everywhereAgreed, to build
Proposed extrasNoneSwitch back under 2 s; one copy per server; pause imports over 5 sProposed

Numbers

NumberWhatHow and where measured
0.32 ms median, 0.69 ms p99A change reaching the copy, idleLaptop, one machine, no network, 7 Oct
0.90 ms median, 6.40 ms p99, 22.8 ms worstA change reaching the copy under 5,700 writes a secondSame laptop setup, 7 Oct
not measuredCopy lag on AWSAWS publishes no typical number
1 row, 8 KB, 100% HOTreplica_heartbeat after 10,000 beatsLaptop, 7 Oct
115 bytes of WAL per beat, about 9.7 MB a dayHeartbeat costLaptop, 7 Oct
99.9% HOT, 0.33 ms average, about 24,000 updates a second488,714 updates on 200 live match rows, 8 writers, 20 s; table grew 3.6 to 4.3 MBLaptop, 7 Oct
99.8% HOTSame, with fill factor 70, and a bigger tableLaptop, 7 Oct
40001 after 1 sA long read on the copy while the main database cleans upLaptop, with the delay set to 1 s (Postgres default is 30 s)
9.3 s, 371 MB of WALDeleting one month: 300,000 rows, 343 MBLaptop, 7 Oct
59 ms, 61 kB of WALDropping that month's partitionLaptop, 7 Oct
655 MB; 312,058 rows, 535 MB (82%)Whole database; integration_rowLaptop database, imports 16 to 28 Sep, not prod
1,375 bytesAverage stored import rowSame laptop database
118,520 (38%)Import rows stored that changed nothingSame laptop database
median 388, p90 5,835, largest 12,725Rows per import runSame laptop database
about 117Maximum connections, one copy of each serviceFrom the design doc (30 Sep plan); not re-counted here
10 + 5, 15 sPool per engine; statement limitFrom the code, settings.py:270-275
59MigrationsFrom the code, packages/core/alembic/versions/
  • Feeds and delivery: the delivery-worker, which must build from the main database, and how files reach clients.
  • Monitoring: the alert service that writes the heartbeat and fires the copy-lag alert.
  • Commands: why every write takes the main database, and how a retried command is never applied twice.