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.
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.
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.
| # | Question | Why it matters |
|---|---|---|
| 1 | Which 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. |
| 2 | What 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. |
| 3 | How big does each table get, and when does old data leave? | Today some tables grow with every import and are never cleaned. |
| 4 | Which tables are split into parts by time? | Dropping a month is instant. Deleting millions of rows is slow and bloats the table. |
| 5 | How do rows updated on every ball stay cheap? | The match row's counter changes on every ball. |
| 6 | How many connections does each service get? | The 30 Sep plan cut pools from 117 to 56. A copy adds a second set. |
| 7 | How do we change the schema during an event? | One long lock on a big table stops scoring. |
| 8 | What 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 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

| Lag level | What readers get | What the system does | Status |
|---|---|---|---|
| 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 s | read() 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 s | Reads 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 writes | Live 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 s | Same 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 up | Reads 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):
| Situation | Median | 99th percentile | Worst |
|---|---|---|---|
| Idle | 0.32 ms | 0.69 ms | not recorded |
| Under 5,700 writes a second | 0.90 ms | 6.40 ms | 22.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.
| Who | What they do | What we take |
|---|---|---|
| GitHub, freno | A 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 databases | POST, 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 consistency | With 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 standby | A 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), noidle_in_transaction_session_timeout(how long a transaction may sit open and idle) and noapplication_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) andLISTEN(wake-ups, Section 8).read(max_age_s)returns a copy session if the copy is at mostmax_age_sbehind. 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
| Reader | Session | Why |
|---|---|---|
| Commands and the reads inside them | write() | 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 miss | main database | A 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 miss | read() | Old data is fine. The fallback rule still applies. |
| Staff and scorer screens, and their live watches | write() / raw() | Staff must see their own change at once (Section 8). |
| Import comparisons | write() | An import compares against the latest row (Section 3). |
| Alert checks and the stats queue | write() | 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, dashboards | read() | 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 database | 655 MB |
integration_row | 312,058 rows, 535 MB, 82% of the database |
| Average stored import row | 1,375 bytes (raw and mapped JSON) |
| Import rows that changed nothing, stored anyway | 118,520 (38%) |
| Rows per import run | median 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.

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:
- Create the partitioned table under a new name.
- Release code that writes new rows only to it.
- History screens read both tables through one view for 30 days.
- 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
| Setting | Default | What it does | Status |
|---|---|---|---|
REPLICA_DATABASE_URL | none | The copy | Built today exists today |
DB_POOL_SIZE, DB_MAX_OVERFLOW | 10, 5 | Pool per engine | Built today |
DB_STATEMENT_TIMEOUT_MS | 15000 | Limit on any one query, main database | Built today |
READ_MAX_AGE_S | 5 | Lag above which reads fall back to the main database | Agreed, to build |
READ_FALLBACK_POOL | 4 | Main-database connections per server for the fallback | Agreed, to build |
REPLICA_POOL_SIZE | 8 | Copy connections per API server | Agreed, to build |
REPLICA_STATEMENT_TIMEOUT_MS | 5000 | Limit on every query on the copy | Agreed, to build |
RETENTION_AHEAD_MONTHS | 3 | Partitions created ahead | Agreed, to build |
MIGRATION_LOCK_TIMEOUT_MS, MIGRATION_RETRIES | 2000, 5 | The migration runner's lock limit and retries | Agreed, to build |
IDLE_IN_TRANSACTION_TIMEOUT_MS | 60000 | Ends a transaction left open and idle | Agreed, 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.
| Part | What a person sees | Reads |
|---|---|---|
| Copy lag | Lag now and the worst in the last hour. Red over 5 s | replica_heartbeat on the copy |
| Disk | Used, growth per day over 7 days, days left | pg_database_size, the new daily table db_size_daily |
| Connections | Used against the limit, per service | pg_stat_activity by user and application_name |
| Tables | Size, rows, growth, keep rule, next month to drop, months created ahead. A person can hold retention | pg_class, pg_stat_user_tables, the partition list, retention_rule |
| Hot rows | HOT share of updates. Red under 90% | pg_stat_user_tables |
| Slow queries | The 10 queries using the most total time | pg_stat_statements (whether it is on in our instances: not checked) |
| Locks | Who waits for whom right now | pg_locks, pg_blocking_pids |
Order of work
- Quick wins:
application_nameand 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. - Sessions in code, every route choosing read or write. With no copy set,
read()uses the main database, so behaviour does not change. - Store only changed import rows.
integration_rowanddomain_eventmove to monthly partitions (new table plus view).- The migration runner limits and the CI check.
- The health page and daily size snapshots.
- The delivery-worker builds from the main database directly (Section 9, D1).
- The heartbeat, the lag watcher and the fallback, tested against a real copy in Docker.
- 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 wrong | What happens |
|---|---|
| The copy falls more than 5 s behind | Each 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 lost | The 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 cancelled | Measured 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 change | The 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 Redis | It is built or read from the main database, never the copy. |
| The main database fails | Commands 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 copy | Measured: 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 missing | Rows 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 fills | Alerts at 70% and 85% used. The health page shows days left. |
| A migration takes a long lock during a match | 2 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 table | 99.9% HOT measured. A test fails if any such column gets an index. |
| Too many connections after a deploy | One database user per service with its own limit; an alarm at 70% of the limit. |
| Code that needs the main database runs on the copy | Writes, 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 behind | Proposed: imports and bulk writes pause while lag is over 5 s, then carry on. |
| Lag hovers around 5 s | Agreed 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 bug | Scoring 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.
| # | Decision | In plain words |
|---|---|---|
| DB1 | Two 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. |
| DB2 | Only 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. |
| DB3 | Our 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. |
| DB4 | Every 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. |
| DB5 | A 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. |
| DB6 | Recommended 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. |
| DB7 | A 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. |
| DB8 | Keep: 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. |
| DB9 | An import row identical to last time is counted, not stored. | 38% of today's stored import rows disappear. |
| DB10 | A 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. |
| DB11 | No index on a column that changes on every ball. Default fill factor stays. A test enforces both. | The match row stays cheap. |
| DB12 | Migrations: 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. |
| DB13 | One 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. |
| DB14 | Point-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. |
| DB15 | One Postgres major version in CI, staging and prod, matched to prod once checked. | Tests prove the version we actually run. |
| DE1 | Sessions.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. |
| DE2 | A 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. |
| DE3 | Copy 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. |
| DE4 | integration_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. |
| DE5 | A 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. |
| DE6 | Imports store a row only when its hash changed or it has a problem. | Import storage drops by about a third. |
| DE7 | Migration runner: lock_timeout 2 s with 5 retries; CONCURRENTLY steps in their own autocommit block; a CI check. | Safe migrations are the default. |
| DE8 | Pool server settings: application_name, 60 s idle-transaction limit, the Section 4 limits per pool. | Every connection is named and none can hang forever. |
| DE9 | A daily size snapshot per table (db_size_daily). | We see growth coming weeks ahead. |
| DE10 | Drop 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 Oct | Live 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 build | A live file is never older than the latest score. |
| Proposed | Move off the copy at 5 s, come back only under 2 s. Proposed | Reads do not flip back and forth. |
| Proposed | Each API server sticks to one copy. Proposed | One server never shows a score going backwards (Shopify's problem). |
| Proposed | Imports and bulk writes pause while the copy is over 5 s behind. Proposed | Imports stop making the lag worse (GitHub's freno idea). |
Built today, or still to build
| Piece | Today | Agreed design | Status |
|---|---|---|---|
| Engine factory | One 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 service | Partly built |
| Routing | All 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 route | Agreed, to build |
| File builds read | Through 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 path | Redis first; live match on a miss from the main database; finished or scheduled may use the copy | Agreed, to build |
| Lag measure | None (replica_heartbeat not found in code) | Heartbeat every 1 s, LagWatcher, 5 s fallback, alert at 10 s for 60 s | Agreed, to build |
| Copy query limit and retry | None | 5 s limit, one retry on 40001 | Agreed, to build |
| Stats partitions | stat_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 rows | Partly built |
integration_row | One plain table, never cleaned (packages/contract/src/omnium_contract/models/integrations.py:138-142) | Monthly integration_row_v2 plus a view, 30 days kept | Agreed, to build |
| Retention | Outbox 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_log | Partly built |
| Unchanged import rows | Stored in full (38% locally) | Counted, not stored | Agreed, to build |
| Migrations | One 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 check | Agreed, to build |
| Duplicate index | raw_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 index | Agreed, to build |
| Health page | None | admin/.../routers/db_health.py, db_size_daily | Agreed, to build |
| Backups | No restore runbook found; prod's point-in-time recovery not checked | 14 days PITR, practised runbook | Agreed, to build |
| Postgres version | Docs say 15.7, deploy example 17, CI and laptops 17, staging 18.3 (21 Sep); prod not checked | One major version everywhere | Agreed, to build |
| Proposed extras | None | Switch back under 2 s; one copy per server; pause imports over 5 s | Proposed |
Numbers
| Number | What | How and where measured |
|---|---|---|
| 0.32 ms median, 0.69 ms p99 | A change reaching the copy, idle | Laptop, one machine, no network, 7 Oct |
| 0.90 ms median, 6.40 ms p99, 22.8 ms worst | A change reaching the copy under 5,700 writes a second | Same laptop setup, 7 Oct |
| not measured | Copy lag on AWS | AWS publishes no typical number |
| 1 row, 8 KB, 100% HOT | replica_heartbeat after 10,000 beats | Laptop, 7 Oct |
| 115 bytes of WAL per beat, about 9.7 MB a day | Heartbeat cost | Laptop, 7 Oct |
| 99.9% HOT, 0.33 ms average, about 24,000 updates a second | 488,714 updates on 200 live match rows, 8 writers, 20 s; table grew 3.6 to 4.3 MB | Laptop, 7 Oct |
| 99.8% HOT | Same, with fill factor 70, and a bigger table | Laptop, 7 Oct |
40001 after 1 s | A long read on the copy while the main database cleans up | Laptop, with the delay set to 1 s (Postgres default is 30 s) |
| 9.3 s, 371 MB of WAL | Deleting one month: 300,000 rows, 343 MB | Laptop, 7 Oct |
| 59 ms, 61 kB of WAL | Dropping that month's partition | Laptop, 7 Oct |
| 655 MB; 312,058 rows, 535 MB (82%) | Whole database; integration_row | Laptop database, imports 16 to 28 Sep, not prod |
| 1,375 bytes | Average stored import row | Same laptop database |
| 118,520 (38%) | Import rows stored that changed nothing | Same laptop database |
| median 388, p90 5,835, largest 12,725 | Rows per import run | Same laptop database |
| about 117 | Maximum connections, one copy of each service | From the design doc (30 Sep plan); not re-counted here |
| 10 + 5, 15 s | Pool per engine; statement limit | From the code, settings.py:270-275 |
| 59 | Migrations | From the code, packages/core/alembic/versions/ |
Read next
- 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.