Leaderboard Synchronization
1. Overview
Leaderboard Synchronization is the automated recalculation layer: when race results change, it recomputes the affected leaderboards' standings without manual intervention. It is an event-driven replacement for the current manual SQL aggregation process.
|
This layer builds on the Leaderboard Model & Result
Classification. The schema ( Status: deferred. Standings are aggregated manually today. This design is the target for the automation iteration. |
2. Problem Statement
Leaderboards (Top Schools, All Stars, Individual Rider, and the WP Cycling boards) are currently
populated by manual aggregation that reads RaceResult.points. When results change there is no
automatic recalculation — an operator must re-run the aggregation. This layer makes the
recalculation fire automatically and safely on any result change.
3. Source Code
These are the components this layer adds on top of the model. (The model’s own files —
Leaderboard, LeaderboardCategory, LeaderboardType, the PointsCalculator /
LeaderboardCalculator families — are listed in the model doc.)
| File | Purpose |
|---|---|
|
Single event published on any result change. |
|
Main sync service with debouncing. |
|
REST API for manual recalculation. |
|
Failure notification handling. |
|
Scheduled task to email admins on failures. |
Modified to participate in sync:
| File | Change |
|---|---|
|
|
|
|
|
Publish |
|
Publish event after import. |
4. Architecture
4.1. Event Flow
Result Change (API / Import)
│
▼
ResultSetChangedEvent
│
▼
Debounce (60s)
│
▼
Find Affected Leaderboards
│
┌────────┼────────┐
▼ ▼ ▼
School TopSchools Individual ← LeaderboardCalculator strategies (see model doc)
└────────┼────────┘
▼
Upsert Results (state-aware)
4.2. Rate Limiting
A 60-second debounce absorbs bursts:
-
A
ResultSetChangedEventis queued on arrival. -
A scheduled task processes pending changes older than 60 seconds.
-
This prevents rapid recalculations during bulk imports or rapid edits.
-
Manual recalculation via the API bypasses the debounce.
4.3. ResultSet State Machine
Recalculation writes standings into a ResultSet, whose state governs whether it may be
modified in place.
| State | Code | Behaviour |
|---|---|---|
DRAFT |
|
Upsert freely; not displayed publicly. |
PROVISIONAL |
|
Upsert freely; displayed publicly, marked provisional. |
APPROVED |
|
Immutable — clone to a new DRAFT to change. |
REPLACED |
|
Superseded version; |
DELETED |
|
Immutable — clone to a new DRAFT to change. |
FAILED |
|
Recalculation failed — triggers notifications, can retry. |
When upserting standings:
-
DRAFT or PROVISIONAL → update directly.
-
APPROVED or DELETED → clone the ResultSet, set the original to REPLACED, update the clone.
-
On failure → set state to FAILED, store the error message.
5. Manual Recalculation API
| Endpoint | Purpose |
|---|---|
|
Recalculate one leaderboard immediately. |
|
Recalculate all active leaderboards for a series. |
Manual triggers bypass the 60-second debounce and return job status.
6. Upsert Strategy
Standings are upserted into the ResultSet keyed by the grouping CustomListValue (school/club/ team id) so that re-runs update existing rows rather than duplicating them, and rows that drop out of the standings are removed:
public void upsertLeaderboardResults(ResultSet resultSet, List<LeaderboardEntry> entries) {
Map<Long, RaceResult> existingByCustom = resultSet.getResults().stream()
.filter(r -> r.getCustom1() != null)
.collect(Collectors.toMap(r -> r.getCustom1().getId(), r -> r));
Set<Long> processedIds = new HashSet<>();
for (LeaderboardEntry entry : entries) {
RaceResult existing = existingByCustom.get(entry.getCustomListValueId());
if (existing != null) {
existing.setPosition(entry.getPosition());
existing.setPoints(entry.getPoints());
existing.setSeq(entry.getSeq());
raceResultRepository.save(existing);
processedIds.add(existing.getId());
} else {
raceResultRepository.save(new RaceResult()
.resultSet(resultSet)
.custom1(customListValueRepository.getReferenceById(entry.getCustomListValueId()))
.position(entry.getPosition()).points(entry.getPoints()).seq(entry.getSeq()));
}
}
resultSet.getResults().stream()
.filter(r -> !processedIds.contains(r.getId()))
.forEach(raceResultRepository::delete);
}
The seq field (see the model) gives
deterministic ordering when positions tie, and a stable secondary key for this upsert.
7. Failure Handling
When recalculation fails:
-
The ResultSet state is set to FAILED and the error stored in
result_set.error_message. -
A UI notification is sent via the existing mechanism.
-
A scheduled task polls for FAILED ResultSets and emails administrators (leaderboard name, event name, error message, retry link).
Administrators retry via the manual recalculation API; on success the state returns to DRAFT/PROVISIONAL.
8. Schema additions
This layer adds only the sync-specific columns; the leaderboard tables themselves are in the model:
| Change | Description |
|---|---|
|
Directly link a ResultSet to the leaderboard it materialises. |
|
FAILED-state details. |
9. Deferred / open design
-
A general
GROUP/CUSTOM_LISTLeaderboardType(or a second-CustomList override on SCHOOL) to model Club/Team grouping and theCOALESCE(team, club)overlay without reusing SCHOOL — see WP Cycling procedure. -
A first-class per-leaderboard event set (flag on event / join table) to replace the hardcoded event-ID lists used by the current manual "all events vs starred events only" aggregation.
10. Related Documentation
-
Leaderboard Model & Result Classification — the foundation this layer automates.
-
Results Import — the change source that triggers sync.