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 (Leaderboard, LeaderboardCategory), the scoring scales (PointsCalculator), and the aggregation strategies (LeaderboardType / LeaderboardCalculator) are all defined there and are in production use today via manual aggregation. This page describes only what is added to make that recalculation automatic: the change event, debouncing, the ResultSet state machine, manual-recalc API, and failure handling.

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

ResultSetChangedEvent.java

Single event published on any result change.

LeaderboardSynchronizationService.java

Main sync service with debouncing.

LeaderboardResource.java

REST API for manual recalculation.

LeaderboardFailureNotificationService.java

Failure notification handling.

LeaderboardFailureEmailTask.java

Scheduled task to email admins on failures.

Modified to participate in sync:

File Change

ResultSet.java

leaderboard relationship, errorMessage field.

ResultSetState.java

FAILED('F') state.

RaceResultServiceEx.java

Publish ResultSetChangedEvent on save/delete.

ResultImportXLS.java / ResultSetImportSheets.java

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 ResultSetChangedEvent is 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

P

Upsert freely; displayed publicly, marked provisional.

APPROVED

A

Immutable — clone to a new DRAFT to change.

REPLACED

R

Superseded version; replaces links the new version.

DELETED

D

Immutable — clone to a new DRAFT to change.

FAILED

F

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

POST /api/leaderboards/{id}/recalculate

Recalculate one leaderboard immediately.

POST /api/leaderboards/recalculate-all?seriesId={id}

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:

  1. The ResultSet state is set to FAILED and the error stored in result_set.error_message.

  2. A UI notification is sent via the existing mechanism.

  3. 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

result_set.leaderboard_id FK

Directly link a ResultSet to the leaderboard it materialises.

result_set.error_message

FAILED-state details.

9. Deferred / open design

  • A general GROUP / CUSTOM_LIST LeaderboardType (or a second-CustomList override on SCHOOL) to model Club/Team grouping and the COALESCE(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.