Leaderboard Synchronization
1. Overview
The Leaderboard Synchronization system automatically recalculates leaderboard standings when race results change. It replaces the current manual SQL-based process with an event-driven architecture that supports multiple leaderboard types.
2. Problem Statement
Currently, leaderboards (Top Schools, All Stars, Individual Rider) are created via manual SQL scripts with hardcoded series category IDs. When race results change, there’s no automatic recalculation of affected leaderboards.
3. Leaderboard Types
| Type | Description |
|---|---|
SCHOOL |
Groups points by School ( |
TOP_SCHOOLS |
Top N schools by aggregate points (per gender, per PS/HS level) |
ALL_STARS |
Cross-gender aggregate of top schools |
INDIVIDUAL_RIDER |
Aggregates Person points across ALL events in a series |
4. Source Code
4.1. New Files
| File | Purpose |
|---|---|
|
Leaderboard configuration entity |
|
Category mapping entity (replaces hardcoded series_category_id lists) |
|
Enum for leaderboard types |
|
Single event for all result changes |
|
Main sync service with debouncing |
|
Calculator interface |
|
School points aggregation |
|
Top N schools calculation |
|
Cross-gender aggregate |
|
Person points across series |
|
REST API for manual recalculation |
|
Failure notification handling |
|
Scheduled task to email admins on failures |
Location: admin-service/src/main/java/za/co/idealogic/event/admin/service/
4.2. Modified Files
| File | Purpose |
|---|---|
|
Add |
|
Add |
|
Add |
|
Publish |
|
Publish event after import |
|
Publish event after import |
|
Implement |
5. Architecture
5.1. Event Flow
┌─────────────────────┐
│ Result Change │
│ (API/Import) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ ResultSetChangedEvent│
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Debounce (60s) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Find Affected │
│ Leaderboards │
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ School │ │Top Schools│ │Individual│
│Calculator│ │Calculator │ │ Rider │
└────┬─────┘ └─────┬─────┘ └────┬─────┘
│ │ │
└────────────────┬┴───────────────┘
│
▼
┌─────────────────────┐
│ Upsert Results │
│ (state-aware) │
└─────────────────────┘
5.2. Rate Limiting
The system uses a 60-second debounce mechanism:
-
When a
ResultSetChangedEventis received, it’s queued -
A scheduled task checks for pending changes older than 60 seconds
-
This prevents rapid recalculations during bulk imports or rapid edits
-
Manual recalculation via API bypasses the debounce
5.3. ResultSet State Machine
| State | Code | Behavior |
|---|---|---|
DRAFT |
|
Can upsert freely, not displayed publicly |
PROVISIONAL |
|
Can upsert freely, displayed publicly but marked as provisional |
APPROVED |
|
Cannot modify - must clone to new DRAFT ResultSet |
REPLACED |
|
Previous version, |
DELETED |
|
Cannot modify - must clone to new DRAFT ResultSet |
FAILED |
|
Recalculation failed - triggers notifications, can retry |
5.4. State Transitions
States: DRAFT → PROVISIONAL → APPROVED
↓ ↓ ↓
(upsert) (upsert) (clone to DRAFT)
↓ ↓ ↓
FAILED FAILED REPLACED (original)
When upserting leaderboard results:
-
If state is DRAFT or PROVISIONAL: update directly
-
If state is APPROVED or DELETED: clone ResultSet, set original to REPLACED, update clone
-
On failure: set state to FAILED, store error message
6. Domain Entities
6.1. Leaderboard Entity
@Entity
public class Leaderboard {
Long id;
String name;
LeaderboardType type; // TOP_SCHOOLS, SCHOOL, ALL_STARS, INDIVIDUAL_RIDER
Gender gender; // NULL for gender-neutral
Integer topCount; // Number of top results to aggregate (e.g., 5 for boys, 3 for girls)
Integer resultLimit; // Max entries in leaderboard
Boolean active;
Series series;
CustomListValue levelFilter; // PS or HS CustomListValue
CustomList groupByList; // List to group by (e.g., Schools)
String pointsCalculatorClass; // Pluggable points calculator
Set<LeaderboardCategory> categories;
}
6.2. LeaderboardCategory Entity
Maps leaderboards to source series categories, replacing hardcoded ID lists.
@Entity
public class LeaderboardCategory {
Long id;
Leaderboard leaderboard;
EventCategory seriesCategory; // Series-level category
BigDecimal pointsMultiplier; // Optional points multiplier
}
7. Calculator Framework
7.1. Interface
public interface LeaderboardCalculator {
LeaderboardType getType();
List<LeaderboardEntry> calculate(Leaderboard leaderboard, Long eventId);
}
7.2. Points Calculator Interface
public interface PointsCalculator {
int getPoints(Integer position);
}
The existing SASchoolCyclingSeriesPointsCalculator will implement this interface, allowing pluggable points calculators per leaderboard.
7.3. School Leaderboard Calculator
Groups points by school using EventParticipant.custom_3_id:
// 1. Get series category IDs from LeaderboardCategory
Set<Long> seriesCategoryIds = leaderboard.getCategories().stream()
.map(lc -> lc.getSeriesCategory().getId())
.collect(Collectors.toSet());
// 2. Query results matching criteria
List<RaceResult> results = raceResultRepository.findForLeaderboard(
eventId, gender, seriesCategoryIds, levelFilterId);
// 3. Group by school (custom_3)
Map<Long, List<RaceResult>> bySchool = results.stream()
.filter(r -> r.getEventParticipant().getCustom3() != null)
.collect(Collectors.groupingBy(
r -> r.getEventParticipant().getCustom3().getId()));
// 4. Sum points per school
List<LeaderboardEntry> entries = bySchool.entrySet().stream()
.map(e -> new LeaderboardEntry(e.getKey(),
e.getValue().stream().mapToInt(RaceResult::getPoints).sum()))
.sorted(Comparator.comparingInt(LeaderboardEntry::getPoints).reversed())
.collect(Collectors.toList());
7.4. Top Schools Calculator
Similar to School calculator but takes only top N results per school:
// 4. For each school, take top N and sum
int totalPoints = entry.getValue().stream()
.sorted(Comparator.comparingInt(RaceResult::getPoints).reversed())
.limit(leaderboard.getTopCount())
.mapToInt(RaceResult::getPoints)
.sum();
7.5. Individual Rider Calculator
Aggregates Person points across ALL events in a series (not just one event):
// Query all results for series (not just one event)
List<RaceResult> results = raceResultRepository.findBySeriesAndCategories(
seriesId, seriesCategoryIds, gender, levelFilterId);
// Group by Person ID
Map<Long, List<RaceResult>> byPerson = results.stream()
.filter(r -> r.getPerson() != null)
.collect(Collectors.groupingBy(r -> r.getPerson().getId()));
8. REST API
8.1. Manual Recalculation
| Endpoint | Purpose |
|---|---|
|
Trigger immediate recalculation for specific leaderboard |
|
Recalculate all active leaderboards for a series |
Manual triggers bypass the 60-second debounce and return job status.
9. Failure Handling
9.1. FAILED State
When recalculation fails:
-
ResultSet state is set to FAILED
-
Error message stored in
error_messagecolumn -
UI notification sent (via existing notification mechanism)
-
Scheduled task polls for FAILED ResultSets and emails administrators
10. Database Changes
| Change | Description |
|---|---|
Add |
|
Create |
Configuration entity for leaderboards |
Create |
Maps leaderboards to series categories |
Add |
|
Add |
|
11. Upsert Strategy
public void upsertLeaderboardResults(ResultSet resultSet, List<LeaderboardEntry> entries) {
// Build lookup map by custom field (school ID)
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) {
// Update existing
existing.setPosition(entry.getPosition());
existing.setPoints(entry.getPoints());
existing.setSeq(entry.getSeq());
raceResultRepository.save(existing);
processedIds.add(existing.getId());
} else {
// Create new
RaceResult newResult = new RaceResult()
.resultSet(resultSet)
.custom1(customListValueRepository.getReferenceById(entry.getCustomListValueId()))
.position(entry.getPosition())
.points(entry.getPoints())
.seq(entry.getSeq());
raceResultRepository.save(newResult);
}
}
// Remove results no longer in leaderboard
resultSet.getResults().stream()
.filter(r -> !processedIds.contains(r.getId()))
.forEach(raceResultRepository::delete);
}
12. PS/HS Level Filtering
Previously, PS (Primary School) vs HS (High School) was determined by hardcoded series_category_id lists in SQL:
-- Old approach (hardcoded)
ec.series_category_id IN (2163,2164,2165,2166,2167,2168,2169,2170)
With the new Leaderboard entity:
-
levelFilterfield references the PS or HSCustomListValue -
LeaderboardCategorylinks to specific series categories -
No more hardcoded ID lists