6.3 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.
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:
- create remote follower first
- copy remote
sync_auth_token - paste 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