6.5 KiB
Leader/Follower Tournament Sync
Backend supports one-way tournament replication:
- leader tournament stays writable
- follower tournament stays read only
- leader pushes full snapshots asynchronously
- follower never pulls
- follower never needs to reach leader
This fits live topology:
- leader = local laptop / Raspberry Pi / LAN host without public IP
- follower = internet VM with public HTTPS endpoint
- only leader needs outbound access to follower
Live Setup
1. Create follower tournament on remote backend
Create empty tournament with:
read_only_mode: truesync_auth_token: <shared secret>
Do not send teams payload for follower. Backend allows empty read only follower tournament creation.
The token is write-only. Backend responses never return it; owner responses only expose sync_auth_configured.
2. Create normal leader tournament on local backend
Create tournament as usual. This remains normal writable tournament.
3. Configure leader to push to follower
Update leader tournament with:
sync_target_url: https://remote.example/tournaments/:id/sync_statesync_auth_token: <same shared secret as follower>
Leader becomes push source. Follower accepts only authenticated snapshot pushes.
4. Run tournament normally on leader
These leader-side actions enqueue async snapshot push:
- tournament updates
- timer updates
- team renames
- match state changes
- match score changes
- stage finish / playoff generation
Queue retries with backoff until follower accepts snapshot.
Local Manual Testing
Backend now has local manual setup target:
inv docker-blackbox-follow-manual-up
This starts:
- leader production image on
http://127.0.0.1:3002 - follower production image on
http://127.0.0.1:3003 - separate leader and follower Postgres containers
- bootstrapped owner users on both backends
Teardown:
inv docker-blackbox-follow-down
Local topology
Compose setup now separates DB traffic:
source-postgresonly onsource-dbinternal networkfollower-postgresonly onfollower-dbinternal network- both apps share only
syncnetwork
This gives:
- separate production app containers
- separate production DB containers
- DB isolation between leader and follower
Important limit:
- Docker Compose does not enforce true one-way HTTP reachability here
- local setup still allows follower container to reach leader container over shared
syncnetwork - real production trust model still comes from backend design:
- leader pushes
- follower never pulls
- follower has no code path that contacts leader
So local setup is good for frontend/manual testing of behavior, not strict network-policy simulation.
Manual frontend testing flow
- Run
inv docker-blackbox-follow-manual-up - Open leader backend at
http://127.0.0.1:3002 - Open follower backend at
http://127.0.0.1:3003 - Log in with:
- email:
e2e@example.com - password:
password123
- email:
- On follower backend, create empty read-only follower tournament
- On leader backend, create normal tournament
- On leader tournament, set:
sync_target_urlto followerPATCH /tournaments/:id/sync_statesync_auth_tokento same shared token used on follower
- Point local frontend leader instance to
http://127.0.0.1:3002 - Point local frontend follower/remote instance to
http://127.0.0.1:3003 - Progress tournament on leader, verify follower mirrors state and stays read only
Frontend team can use this setup to build:
- owner sync configuration UI
- follower read-only UX
- status display for
sync_last_pushed_at - status display for
sync_last_push_error - takeover flow by disabling
read_only_mode
Takeover
If remote must become writable after sync phase:
- disable
read_only_modeon follower - backend clears follower token when no longer needed
- follower becomes normal standalone tournament again
After takeover, leader should no longer push to old follower URL.
API Contract
Leader-side writable config
Tournament push mode active when:
sync_target_urlpresentsync_auth_tokenpresentread_only_modefalse
Follower-side push receiver
Tournament accepts push when:
read_only_modetruesync_auth_tokenpresent
Push endpoint:
PATCH /tournaments/:id/sync_state
Auth:
Authorization: Bearer <token>
Payload:
{ snapshot: ... }
Snapshot import replaces tournament graph using sync_source_id mapping, not local DB ids.
Frontend Integration Notes
Frontend does not need separate "follower UI mode" for normal viewers. Remote tournament should look same for non-owner users.
Owner-facing frontend can expose:
read_only_modetogglesync_target_urlinputsync_auth_tokeninput- sync health display:
sync_last_pushed_atsync_last_push_error
Recommended owner UX:
- generate a shared token outside the backend
- create remote follower with that
sync_auth_token - paste same token and remote
sync_stateURL into leader - save leader sync config
- show last push time / error state
- allow explicit follower takeover by disabling
read_only_mode
Frontend should block owner edits on follower when read_only_mode true. Backend already enforces this with 423 Locked.
Future Feature Rule
Any new persisted tournament state must be handled in three places:
- leader write path
- snapshot export/import path
- follower/read-only behavior
Guardrail now exists:
TournamentSyncSchemalists every synced and ignored model column- spec fails when new DB column appears without explicit sync decision
This does not auto-implement sync for new features. It makes missing sync work fail loudly in tests instead of silently drifting.
For every new tournament feature, add:
- schema decision in
TournamentSyncSchema - snapshot builder/importer support if state must replicate
- one follower lifecycle test or roundtrip spec that proves behavior survives sync
Coverage Today
Current backend coverage includes:
- async push queue and retry behavior
- follower auth and read-only lock
- full follower lifecycle HTTP E2E with unrelated tournaments and mismatched ids
- team renames
- multiple decider matches
- playoff generation blocked before deciders finish
- playoff generation after deciders finish
- follower takeover
- large roundtrip graph coverage for 32 groups, 4 teams each, 64-team playoffs
Still not magic:
- if new feature adds new state and nobody updates sync contract, tests will fail
- if new feature changes behavior but not schema, add behavior test too