In LocalLeaderboardScheduler, tournaments can occasionally miss either the end or reset callback when the tournament end (endActive) and reset/expiry (expiry) fall on the same Unix second. This is a race between two timers, not a deterministic bug.
opened 08:19AM - 27 Nov 25 UTC
Summary
-------
In `LocalLeaderboardScheduler`, tournaments can occasionally mis… s either the end or reset callback when the tournament end (`endActive`) and reset/expiry (`expiry`) fall on the same Unix second. This is a race between two timers, not a deterministic bug.
Regular leaderboards (non-tournament) are not affected.
Details
-------
For tournaments, `Update()` sets two timers:
ls.endActiveTimer = time.AfterFunc(endActiveDuration, func() {
ls.queueEndActiveElapse(time.Unix(earliestEndActive, 0).UTC(), endActiveLeaderboardIds)
})
ls.expiryTimer = time.AfterFunc(expiryDuration, func() {
ls.queueExpiryElapse(time.Unix(earliestExpiry, 0).UTC(), expiryLeaderboardIds)
})
Both callbacks then call `Update()`:
func (ls *LocalLeaderboardScheduler) queueEndActiveElapse(...) {
...
ls.Update()
}
func (ls *LocalLeaderboardScheduler) queueExpiryElapse(...) {
...
ls.Update()
}
`Update()` stops both timers:
if ls.endActiveTimer != nil { ls.endActiveTimer.Stop() }
if ls.expiryTimer != nil { ls.expiryTimer.Stop() }
If `endActive == expiry` and one timer’s callback runs first while the other has not yet fired, `Update()` can `Stop()` the other timer before its callback executes. Then only one of:
- the tournament end callback (`fnTournamentEnd`), or
- the tournament reset callback (`fnTournamentReset` / reset logic)
runs for that second. If both timers have already fired, both callbacks run and there is no issue, so the behavior is intermittent.
Impact
------
If a tournament’s end time is configured to land exactly on a reset boundary (e.g. cron `* * * * *` plus matching `end_time`), the final reset or final end callback may sometimes be skipped, depending on timing.
Suggestions
-----------
Possible fixes:
- Ensure `endActive` and `expiry` are never equal (e.g. offset one by 1 second), or
- Avoid stopping the “other” timer in `Update()` when called from a timer callback, or
- Use a single timer and process all due actions (end + reset) when it fires.