Start here · Architecture
The big picture
Every part of omnium on one page, and the one road a change takes through it.
In one minute
Omnium has one road in and many roads out. Every change, from any source, becomes a command and is checked and saved by one workflow engine into Postgres. Apps and the console send their commands through one door, the bridge; Scout imports turn changed rows into commands in the command service, in an import process of its own. Nothing writes to the scoring tables any other way.
After the save, a wake-up tells everyone who cares: live screens, the delivery-worker that builds and sends client files, the stats engine and the alert service. Clients get files pushed to them, or pull them from Redis. Fans read through the public API.
This page names every part and shows how they connect. Each later page zooms into one part.

What omnium is
Omnium keeps the live scores, results and standings of 25 and more long-tail sports in one shared data model, and serves them through one API and one set of client files.
The hard part is not storing a score. It is keeping it right when many hands touch it at once: a scorer on a phone, a staff member fixing a mistake in the console, and a provider import arriving every minute. And then getting the right version to every client, fast, even when one of them is slow or down.
Every sport fits one of two shapes:
| Shape | What it means | Example |
|---|---|---|
HEAD_TO_HEAD | Two sides play each other; the result is a score | A football match, a boxing bout, a volleyball game |
RANKED_FIELD | Many competitors; the result is a ranking | A 100 m final, a swimming heat, a golf round |
A third piece, AGGREGATE_CLASSIFICATION, sits on top of both for tables built from many results: league standings, group tables, the medal table. The page The data model explains the tables.
The one road in
Three kinds of source change data, and all three take the same road.
| Source | Who or what | How it arrives |
|---|---|---|
| Scorer apps | A person scoring a live match, often on a phone | Through the bridge, as commands |
| The console | Staff fixing data, running an event's operations | Through the bridge, as commands |
| Scout imports | Rows from a provider website, fetched by our Scout service | A no-code integration turns each changed row into a command, in the command service's import process |
The road, in five steps:
- A command arrives with a code (what to do, like
results.edit_side), an input (the values) and a key (a unique id the sender chooses, so a resend is never applied twice). - The engine locks the record it changes (one match, one event), so two changes to the same match never mix.
- It checks the command: is the input valid, is the record still at the version the sender saw, does the sport's rule allow it?
- It applies the change and saves it, with a row in the outbox, in one transaction. Either all of it is saved, or none of it.
- It answers the sender with one of five answers: accepted, duplicate (this key was already applied), refused (with a reason), conflict (someone changed it first) or try again.
This road is the same for every sport and every source. That is what makes omnium a product: a new sport or a new client is added with settings and a plugin, not with changes to the road.
The parts, and where they live in the code
The code is one Python workspace (si-build-omnium) with these packages. Services import core, never each other, and frontends talk to services over HTTP only. make imports enforces this rule.
| Package | What it holds |
|---|---|
packages/core | The shared heart: data model, database, workflow engine, publishing, delivery, integrations |
packages/contract | The plugin contract: the shapes a sport plugin must follow, and the registry that finds plugins |
packages/flows | The flows plugin: each sport's rules and stats, plus games/ (event operations) and results/ (any fixture in any sport) |
packages/admin | The admin service: the bridge, imports, and everything the console calls |
packages/api | The public API: GraphQL and REST reads, client pull, and the live stream |
packages/worker | The delivery-worker, which builds and sends client files, plus seeding |
packages/scheduler | Today's timed jobs and import queue. Removed in the plan: timed jobs move to the stats-worker, imports to the command service |
packages/stats | The stats engine: the queue, the runner and the definitions table |
packages-ts/omnium-bridge | The app and admin panel protocol, published as @fanos/omnium-bridge |
The services
In the plan, omnium runs as six services. Each has one job, so a slow or broken part never holds up scoring. Three support containers run beside them, unchanged.

| Service | Its job | Copies | Decided in |
|---|---|---|---|
| command service (new) | Runs every command from scorers and the console: lock, check, apply, save, answer, inside the HTTP request. Sport code runs in small worker processes inside it, only for events-mode matches. Imports run in a process of their own beside it, at lower CPU priority: it takes the next job from the import queue, fetches the Scout run, compares, and sends commands in batches of 25 matches through the same engine | 2 | Section 4, L15 (imports since 8 Oct) and L10; Section 5; Section 3 |
| admin-api | Console screens, admin reads and settings, the live stream for screens. When Scout says a run is ready, admin-api only puts an import job on the queue | 1 | Sections 3, 7 and 8 |
| publishing-api | Public reads for fans and apps, and client pull. Client files and match documents come from Redis first, and from Postgres only when Redis has nothing | 1 | Section 9, D11 to D15; Section 10 |
| stats-worker | Two loops. Stats: counts the stats a change calls for. Timed jobs: the stats catch-up, the 15-minute check, the daily clean-up and next month's partitions; one copy at a time runs them, by a database lock | 1 | Section 9; Section 10, DE5 |
| delivery-worker | Two loops. Build: woken by each change, it builds only the client files the change touches, from the main database (Section 9 calls this step "the publisher"). Send: each file to each destination, one queue per destination, with proof of delivery. It also writes everything that is pulled into Redis: client files, and since 8 Oct the public match documents | 1 | Section 9: D1, D12, E2 to E6, E8 |
| alert service (new) | Turns problems into one alert each, and sends it to Slack, email and the console. One working copy at a time, by a database lock | 1 | Section 11, N3 |
Support containers, unchanged by the plan: web (the public website), prometheus (metrics) and grafana (dashboards). The admin panel's web app is not a container: it is served from S3 and CloudFront.
Each loop writes its own heartbeat (Section 11), so a stuck import is caught even while scoring in the same container is fine. The load test must show no slowdown for scorers during a big import; if it does, imports move to their own container, with the same code.
What it costs. About $227 a month on AWS Fargate for the ten running copies (2 for the command service, 1 for every other service and support container). That is $81 more than today's $146.
| Change from today | Per month |
|---|---|
| Add the command service: 2 copies of 1 vCPU, 2 GB (it also runs imports) | +$72 |
| Add the alert service: 0.25 vCPU, 0.5 GB | +$9 |
| Switch the stats-worker on: 1 vCPU, 2 GB | +$36 |
| Remove the scheduler: 1 vCPU, 2 GB | −$36 |
| Total | +$81 |
Sizes of today's services come from the prod Terraform (si-build-iac, commit 0e32bf2, 1 Oct 2026), not checked in AWS. The sizes of the two new services are estimates; the command service was sized up from 0.5 vCPU and 1 GB on 8 Oct, when imports moved into it. Prices are the Fargate list prices for us-east-1, $0.0405 per vCPU-hour and $0.00444 per GB-hour, checked 30 Sep 2026.
How today's deploy differs. On prod today, per the same Terraform, seven containers run: publishing-api, admin-api, the scheduler, the delivery-worker, web, prometheus and grafana. The stats-worker is defined but set to 0 copies. admin-api runs every command and also works the import queue; the scheduler runs the timed jobs and works the same queue; the delivery-worker and admin-api both build files, and the delivery-worker sends them. The build moves to the plan step by step; see Build order.
Outside omnium sit Scout (our separate service that fetches and reads provider websites, and hands back rows), the console (its own repo, shown inside the admin panel), and the clients (NDTV, News18, DailyHunt, Google and others).
The many roads out
Once a change is saved, four kinds of reader need to know.
| Reader | What it needs | Page |
|---|---|---|
| Live screens | The second scorer and the console see the change in under a second | Bridge and live updates |
| The delivery-worker | Rebuilds only the client files the change touches, and sends each to every client that wants it | Stats, feeds and delivery |
| The stats engine | Updates the totals, tables and careers the change affects | Stats, feeds and delivery |
| The alert service | Notices when something is wrong, and tells one person once | Monitoring, logs and alerts |
How the news travels is the outbox: in the same transaction as the change, the engine writes one outbox row. A wake-up signal (Postgres NOTIFY) tells listeners to read new outbox rows. Because the row is saved together with the change, no reader can miss a change, and none can see a change that was rolled back.
Clients get data two ways:
- Push: the delivery-worker sends a file to the client's FTP, SFTP, S3 or webhook. Each client destination has its own queue, so one slow host never delays the others.
- Pull: the client calls our API. The answer comes from Redis first, and from Postgres only when Redis has nothing. Google pulls 2 to 3 million times during one cricket match.
What the rebuild changes
The code that ran the Asian Games 2026 works, but the Games showed where it breaks. Between 29 Sep and 7 Oct 2026 we agreed a redesign, section by section. The biggest changes:

| Area | Today Built today | Agreed design Agreed, to build | Page |
|---|---|---|---|
| Hand edits vs imports | An import can overwrite a staff fix | Fields have owners; pins protect hand edits | Truth and ownership |
| Writing a change | Engine works; several write paths and rules | One road: key, lock, version check, five answers | Commands |
| Live scoring | The whole match log is re-read on every command | Saved state; a small step per event; scorecards set directly | The live scoring engine |
| Client files | Every rebuild resends all files to everyone | Only changed files, one queue per destination, proof of delivery | Stats, feeds and delivery |
| Pull clients | Every hit reads Postgres; the cache is a 3 s timer | Redis first, written by the delivery-worker | Stats, feeds and delivery |
| Live screens | Each screen polls the database every second | One wake-up, shared watches that push values | Bridge and live updates |
| Alerts | Alert rules exist, but reach nobody | One alert service: one alert per problem, to Slack, email and console | Monitoring, logs and alerts |
| Database | One database; tables grow forever | A read-only copy for fans; monthly partitions; retention | Database and the read-only copy |
The section pages say exactly which parts are built and which are still design.
Three rules that hold everything together
- Every write is a command. Apps, the console and imports all use the same road. There is no side door into the scoring tables.
- Postgres is the truth. Writes and every read that must be exact use the main database. Redis and the read-only copy only ever hold copies, and can be rebuilt from Postgres.
- Fast, but correct first. Targets are in milliseconds, and every design was measured. But no speed-up may risk losing, doubling or reordering a change.
Read next
- Life of a score: one goal, followed through every part on this page.
- Commands and the workflow engine: the one road, in full detail.
- Writing a workflow in code: commands, validations and actions in the real code.
- The data model: the tables everything hangs on.