Start here · Architecture

The big picture

Every part of omnium on one page, and the one road a change takes through it.

Covers
All parts, at the top level
Main code
si-build-omnium, the packages/ folder
Based on
Design sections 1 to 11, and the code on 7 Oct 2026
Read time
about 12 minutes

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.

A sketch titled Omnium: one road in, many roads out. Scorer apps and console staff send changes into the Bridge. Scout sends rows into Imports, which run in the command service, in a process of their own. Both the Bridge and Imports feed the workflow engine (lock, check, apply), which takes its rules from the sport plugins and saves to Postgres (main): commands, match state and the outbox. A wake-up from Postgres reaches live screens, the delivery-worker (build and send), stats and the alert service. The delivery-worker writes to Redis for pull clients such as Google and apps, and sends client files by FTP, SFTP, S3 and webhook. A dashed copy arrow leads to a read-only copy that serves fans through the public API.
The agreed design. One road in (left), the database in the middle, many roads out (right).

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:

ShapeWhat it meansExample
HEAD_TO_HEADTwo sides play each other; the result is a scoreA football match, a boxing bout, a volleyball game
RANKED_FIELDMany competitors; the result is a rankingA 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.

SourceWho or whatHow it arrives
Scorer appsA person scoring a live match, often on a phoneThrough the bridge, as commands
The consoleStaff fixing data, running an event's operationsThrough the bridge, as commands
Scout importsRows from a provider website, fetched by our Scout serviceA no-code integration turns each changed row into a command, in the command service's import process

The road, in five steps:

  1. 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).
  2. The engine locks the record it changes (one match, one event), so two changes to the same match never mix.
  3. 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?
  4. 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.
  5. 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.

PackageWhat it holds
packages/coreThe shared heart: data model, database, workflow engine, publishing, delivery, integrations
packages/contractThe plugin contract: the shapes a sport plugin must follow, and the registry that finds plugins
packages/flowsThe flows plugin: each sport's rules and stats, plus games/ (event operations) and results/ (any fixture in any sport)
packages/adminThe admin service: the bridge, imports, and everything the console calls
packages/apiThe public API: GraphQL and REST reads, client pull, and the live stream
packages/workerThe delivery-worker, which builds and sends client files, plus seeding
packages/schedulerToday's timed jobs and import queue. Removed in the plan: timed jobs move to the stats-worker, imports to the command service
packages/statsThe stats engine: the queue, the runner and the definitions table
packages-ts/omnium-bridgeThe 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.

A sketch titled The services in the plan. Top row, three outside parties: Scorers and console, Scout, Clients and fans. Middle row, six yellow services: command service (every change, imports, 2 copies), admin-api (console screens), stats-worker (stats, timed jobs), delivery-worker (builds and sends files), publishing-api (public reads, pull) and alert service (one alert per problem). Scorers and console send commands to the command service, and Scout sends it rows; Scout also rings admin-api with a dashed doorbell. The delivery-worker sends files to clients, and clients and fans read from publishing-api. The command service writes to Postgres (main), and the stats-worker writes stats there. The delivery-worker puts all pull data into Redis; publishing-api reads Redis and, dashed, the read-only copy. Bottom left, a crossed-out red box: scheduler, removed 7 Oct. Along the bottom: also running: web, prometheus, grafana.
The six services. Every service also reads Postgres; the arrows show only the main paths.
ServiceIts jobCopiesDecided 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 engine2Section 4, L15 (imports since 8 Oct) and L10; Section 5; Section 3
admin-apiConsole 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 queue1Sections 3, 7 and 8
publishing-apiPublic reads for fans and apps, and client pull. Client files and match documents come from Redis first, and from Postgres only when Redis has nothing1Section 9, D11 to D15; Section 10
stats-workerTwo 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 lock1Section 9; Section 10, DE5
delivery-workerTwo 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 documents1Section 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 lock1Section 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 todayPer 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.

ReaderWhat it needsPage
Live screensThe second scorer and the console see the change in under a secondBridge and live updates
The delivery-workerRebuilds only the client files the change touches, and sends each to every client that wants itStats, feeds and delivery
The stats engineUpdates the totals, tables and careers the change affectsStats, feeds and delivery
The alert serviceNotices when something is wrong, and tells one person onceMonitoring, 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:

A sketch with two halves. On the left, under Today, five red problems: imports overwrite hand edits; every rebuild resends all files; one slow host stalls everyone; problems found by people looking; whole match replayed every ball. On the right, under Agreed design, five green fixes, each across from its problem: pins protect hand edits; send only files that changed; one queue per destination; one alert per problem; saved state, small steps.
Five problems from the Games, and the agreed fix for each.
AreaToday Built todayAgreed design Agreed, to buildPage
Hand edits vs importsAn import can overwrite a staff fixFields have owners; pins protect hand editsTruth and ownership
Writing a changeEngine works; several write paths and rulesOne road: key, lock, version check, five answersCommands
Live scoringThe whole match log is re-read on every commandSaved state; a small step per event; scorecards set directlyThe live scoring engine
Client filesEvery rebuild resends all files to everyoneOnly changed files, one queue per destination, proof of deliveryStats, feeds and delivery
Pull clientsEvery hit reads Postgres; the cache is a 3 s timerRedis first, written by the delivery-workerStats, feeds and delivery
Live screensEach screen polls the database every secondOne wake-up, shared watches that push valuesBridge and live updates
AlertsAlert rules exist, but reach nobodyOne alert service: one alert per problem, to Slack, email and consoleMonitoring, logs and alerts
DatabaseOne database; tables grow foreverA read-only copy for fans; monthly partitions; retentionDatabase 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

  1. Every write is a command. Apps, the console and imports all use the same road. There is no side door into the scoring tables.
  2. 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.
  3. 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.