What this is: The pipeline between a player clicking Sync and the server knowing whether they received an Accepted verdict. Everything downstream depends on this being correct and race-safe.
Owner: Someone comfortable with async queues, HTTP API integration, and writing tests for edge cases.
Tasks
-
POST /api/contests/sync endpoint
- Check
ratelimit:sync:<userId> in Redis. If present: return HTTP 429.
- Set
ratelimit:sync:<userId> with 60s TTL.
- Enqueue a
cf_sync job on cf_sync_queue: { roomId, userId, teamId, cfHandle, problemId }.
- Set
sync:<roomId>:<userId> Hash: { status: 'queued', position: N, createdAt: now }.
- Return HTTP 202:
{ queued: true }.
- Publish
sync.queued to events:user:<userId>: { position: N }.
-
cf_sync_queue worker (full implementation)
- Dequeue job.
- Call
cf.user.status for the handle (last 20 submissions).
- Run the Validation Matrix (all 5 checks — verdict, lower timestamp, upper timestamp, problem ID, handle match).
- On AC: emit the internal
sync.detected event (consumed by the Room engine in Stage 3): { roomId, userId, teamId, problemId, cfSubmissionId, cfTimestamp, verdict: 'OK', pointsAwarded: null }. Also publish sync.detected to events:user:<userId>.
- On non-AC or validation failure: publish
sync.failed to events:user:<userId>: { verdict: 'WA' | 'invalid' }. No Redis state is touched.
- Circuit breaker: if CF returns HTTP 429, pause the queue for 30s.
-
BullMQ retry behaviour
- Verify exponential backoff on failure: 3 attempts, 5s → 10s → 20s.
- On permanent failure (3 retries exhausted): publish
sync.failed to events:user:<userId>: { reason: 'cf_unavailable' }.
-
CF API adapter layer
- Thin wrapper in
lib/cf-api.ts. All CF API calls go through this file — no ad-hoc CF fetches elsewhere.
- Parse errors trigger an alert log.
- Reuses the existing fetch pattern from
src/lib/potd/submit.ts.
-
Cooldown UI contract
- The 60s cooldown is server-enforced via
ratelimit:sync:<userId>. The client may mirror it in the UI, but the server is always authoritative. Document this contract clearly in STAGE1_DONE.md.
Testing Gate
Handoff Contract
What this is: The pipeline between a player clicking Sync and the server knowing whether they received an Accepted verdict. Everything downstream depends on this being correct and race-safe.
Owner: Someone comfortable with async queues, HTTP API integration, and writing tests for edge cases.
Tasks
POST /api/contests/syncendpointratelimit:sync:<userId>in Redis. If present: return HTTP 429.ratelimit:sync:<userId>with 60s TTL.cf_syncjob oncf_sync_queue:{ roomId, userId, teamId, cfHandle, problemId }.sync:<roomId>:<userId>Hash:{ status: 'queued', position: N, createdAt: now }.{ queued: true }.sync.queuedtoevents:user:<userId>:{ position: N }.cf_sync_queueworker (full implementation)cf.user.statusfor the handle (last 20 submissions).sync.detectedevent (consumed by the Room engine in Stage 3):{ roomId, userId, teamId, problemId, cfSubmissionId, cfTimestamp, verdict: 'OK', pointsAwarded: null }. Also publishsync.detectedtoevents:user:<userId>.sync.failedtoevents:user:<userId>:{ verdict: 'WA' | 'invalid' }. No Redis state is touched.BullMQ retry behaviour
sync.failedtoevents:user:<userId>:{ reason: 'cf_unavailable' }.CF API adapter layer
lib/cf-api.ts. All CF API calls go through this file — no ad-hoc CF fetches elsewhere.src/lib/potd/submit.ts.Cooldown UI contract
ratelimit:sync:<userId>. The client may mirror it in the UI, but the server is always authoritative. Document this contract clearly inSTAGE1_DONE.md.Testing Gate
POST /api/contests/syncreturns 202,sync.queuedSSE event is received on the user stream, within ~2ssync.detectedis received with correct data.sync.failedreceived with{ verdict: 'WA' }. No Redis state updated.revealedAtto 1 hour in the future. Older AC submission → Validation Matrix rejects it.sync.failedto user stream.Handoff Contract
cf_sync_queueworker is running as a BullMQ worker process.sync.detectedinternal event shape is documented:{ roomId, userId, teamId, problemId, cfSubmissionId, cfTimestamp, verdict: 'OK', pointsAwarded: null }.pointsAwardedis null here; Stage 3 fills it.