Skip to content

Commit

Permalink
Refactor Tourney classes
Browse files Browse the repository at this point in the history
- Move recently created complex logic into its own object
- Add a deterministic tourney stagger method
- Add stagger to Plan objects, rather than Tournaments to
  be more easily testable.
  • Loading branch information
isaacl committed Sep 22, 2024
1 parent 2cc706c commit d49e666
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 117 deletions.
181 changes: 181 additions & 0 deletions modules/tournament/src/main/PlanBuilder.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package lila.tournament

import scala.collection.mutable
import scala.collection.SortedSet
// For comparing Instants
import scala.math.Ordering.Implicits.infixOrderingOps

import Schedule.Plan

object PlanBuilder:
/** Max window for daily or better schedules to be considered overlapping (i.e. if one starts within X hrs
* of the other ending). Using 11.5 hrs here ensures that at least one daily is always cancelled for the
* more important event. But, if a higher importance tourney is scheduled nearly opposite of the daily
* (i.e. 11:00 for a monthly and 00:00 for its daily), two dailies will be cancelled... so don't do this!
*/
private[tournament] val SCHEDULE_DAILY_OVERLAP_MINS = 690 // 11.5 hours

private[tournament] val MAX_STAGGER_MS = 40_000

private[tournament] abstract class ScheduleWithInterval:
def schedule: Schedule
def duration: java.time.Duration
def startsAt: Instant

val endsAt = startsAt.plus(duration)

def interval = TimeInterval(startsAt, endsAt)

def overlaps(other: ScheduleWithInterval) = interval.overlaps(other.interval)

// Note: must be kept in sync with [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]]
// In particular, pruneConflictsFailOnUsurp filters tourneys that couldn't possibly conflict based
// on their hours -- so the same logic (overlaps and daily windows) must be used here.
def conflictsWith(si2: ScheduleWithInterval) =
val s1 = schedule
val s2 = si2.schedule
s1.variant == s2.variant && (
// prevent daily && weekly within X hours of each other
if s1.freq.isDailyOrBetter && s2.freq.isDailyOrBetter && s1.sameSpeed(s2) then
si2.startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(endsAt) &&
startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(si2.endsAt)
else
(
s1.variant.exotic || // overlapping exotic variant
s1.hasMaxRating || // overlapping same rating limit
s1.similarSpeed(s2) // overlapping similar
) && s1.similarConditions(s2) && overlaps(si2)
)

/** Kept in sync with [[conflictsWithFailOnUsurp]].
*/
def conflictsWith(scheds: Iterable[ScheduleWithInterval]): Boolean =
scheds.exists(conflictsWith)

/** Kept in sync with [[conflictsWith]].
*
* Raises an exception if a tourney is incorrectly usurped.
*/
@throws[IllegalStateException]("if a tourney is incorrectly usurped")
def conflictsWithFailOnUsurp(scheds: Iterable[ScheduleWithInterval]) =
val conflicts = scheds.filter(conflictsWith).toSeq
val okConflicts = conflicts.filter(_.schedule.freq >= schedule.freq)
if conflicts.nonEmpty && okConflicts.isEmpty then
throw new IllegalStateException(s"Schedule [$schedule] usurped by ${conflicts}")
conflicts.nonEmpty

private final case class ConcreteSchedule(
val tourney: Tournament,
val schedule: Schedule,
val startsAt: Instant,
val duration: java.time.Duration
) extends ScheduleWithInterval

/** Given a plan, return an adjusted start time that minimizes conflicts with existing events.
*
* This method assumes that no event starts immediately before plan or after the max stagger, in terms of
* impacting server performance by concurrent events.
*/
private[tournament] def getAdjustedStart(
plan: Plan,
existingTourneyMap: SortedSet[Instant]
): Instant =

val unstaggeredStart = plan.startsAt
val unstaggeredStartMs = unstaggeredStart.toEpochMilli
val maxConflictAt = unstaggeredStart.plusMillis(MAX_STAGGER_MS)

// Find all events that start at a similar time to the plan.
val offsetsWithSimilarStart = existingTourneyMap
.iteratorFrom(unstaggeredStart)
.takeWhile(_ <= maxConflictAt)
// Move the range to start at 0, to avoid loss of precision
.map(s => (s.toEpochMilli - unstaggeredStartMs).toFloat)
.toSeq

val staggerMs = findMinimalGoodSlot(0f, MAX_STAGGER_MS, offsetsWithSimilarStart)
unstaggeredStart.plusMillis(staggerMs.toLong)

private[tournament] def filterAndStaggerPlans(
existingTourneys: Iterable[Tournament],
plans: Iterable[Plan]
): List[Plan] =
// Prune plans using the unstaggered scheduled start time.
val existing = existingTourneys.flatMap { t =>
// Ignore tournaments with schedule=None - they never conflict.
t.schedule.map { s => ConcreteSchedule(t, s, s.atInstant, t.duration) }
}

val prunedPlans = pruneConflicts(existing, plans)

// Determine stagger using the actual (staggered) start time of existing and new tourneys.
val allTourneyStarts = mutable.TreeSet.from(existing.map(_.tourney.startsAt))

prunedPlans
.foldLeft(Nil): (allAdjustedPlans, plan) =>
val adjustedStart = getAdjustedStart(plan, allTourneyStarts)
allTourneyStarts += adjustedStart
plan.copy(startsAt = adjustedStart) :: allAdjustedPlans
.reverse

/** Given a list of existing schedules and a list of possible new plans, returns a subset of the possible
* plans that do not conflict with either the existing schedules or with themselves. Intended to produce
* identical output to [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]], but this
* variant is more readable and has lower potential for bugs.
*/
private[tournament] def pruneConflicts[A <: ScheduleWithInterval](
existingSchedules: Iterable[ScheduleWithInterval],
possibleNewPlans: Iterable[A]
): List[A] =
var allPlannedSchedules = existingSchedules.toList
possibleNewPlans
.foldLeft(Nil): (newPlans, p) =>
if p.conflictsWith(allPlannedSchedules) then newPlans
else
allPlannedSchedules ::= p
p :: newPlans
.reverse

/** This method is used find a good stagger value for a new tournament. We want stagger as low as possible,
* because that means tourneys start sooner, but we also want tournaments to be spaced out to avoid server
* DDOS.
*
* The method uses Floats. Assuming the original plans use whole numbers (of seconds), successive staggers
* will be whole multiples of a negative power of 2, and so are be exactly representable as a Float. Neat!
*
* Behavior is only loosely defined when the length of [low, hi] approaches or exceeds [[Float.MaxValue]]
* or when the range is centered around a large number and loses precision. Neither of these scenarios is
* how the function is expected to be used in practice.
*
* @param hi
* must be >= low
* @param sortedExisting
* must be sorted and contain only values in [low, hi]
*
* @return
* the lowest value in [low, hi] with maximal distance to elts of sortedExisting
*/
private[tournament] def findMinimalGoodSlot(low: Float, hi: Float, sortedExisting: Iterable[Float]): Float =
// Computations use doubles to avoid loss of precision and rounding errors.
if sortedExisting.isEmpty then low
else
val iter = sortedExisting.iterator
var prevElt = iter.next.toDouble
// Fake gap low to check later (i.e. maxGapLow < low)
var maxGapLow = Double.NegativeInfinity
// nothing is at low element so gap is equiv to 2x
var maxGapLen = (prevElt - low) * 2.0
while iter.hasNext do
val elt = iter.next.toDouble
if elt - prevElt > maxGapLen then
maxGapLow = prevElt
maxGapLen = elt - prevElt
prevElt = elt
// Use hi if it's strictly better than all other gaps. Since nothing is at hi, gap is equiv
// to 2x maxGapLen.
if (hi - prevElt) * 2.0 > maxGapLen then hi
// Else, use the first best slot, whose first candidate is low. Using a special case for low
// guarantees we always return in the interval [low, high], and don't have to be quite as
// vigilant with floating point rounding errors.
else if maxGapLow < low then low
else (maxGapLow + maxGapLen * 0.5).toFloat
84 changes: 5 additions & 79 deletions modules/tournament/src/main/Schedule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ case class Schedule(

def perfKey: PerfKey = PerfKey.byVariant(variant) | Schedule.Speed.toPerfKey(speed)

def plan = Schedule.Plan(this, None)
def plan(build: Tournament => Tournament) = Schedule.Plan(this, build.some)
def plan = Schedule.Plan(this, atInstant, None)
def plan(build: Tournament => Tournament) = Schedule.Plan(this, atInstant, build.some)

override def toString =
s"${atInstant} $freq ${variant.key} ${speed.key}(${Schedule.clockFor(this)}) $conditions $position"
Expand All @@ -144,73 +144,17 @@ object Schedule:
at = tour.startsAt.dateTime
)

/** Max window for daily or better schedules to be considered overlapping (i.e. if one starts within X hrs
* of the other ending). Using 11.5 hrs here ensures that at least one daily is always cancelled for the
* more important event. But, if a higher importance tourney is scheduled nearly opposite of the daily
* (i.e. 11:00 for a monthly and 00:00 for its daily), two dailies will be cancelled... so don't do this!
*/
private[tournament] val SCHEDULE_DAILY_OVERLAP_MINS = 690 // 11.5 * 60

private[tournament] trait ScheduleWithInterval:
def schedule: Schedule
def startsAt: Instant
def duration: java.time.Duration

def endsAt = startsAt.plus(duration)

def interval = TimeInterval(startsAt, duration)

def overlaps(other: ScheduleWithInterval) = interval.overlaps(other.interval)

// Note: must be kept in sync with [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]]
// In particular, pruneConflictsFailOnUsurp filters tourneys that couldn't possibly conflict based
// on their hours -- so the same logic (overlaps and daily windows) must be used here.
def conflictsWith(si2: ScheduleWithInterval) =
val s1 = schedule
val s2 = si2.schedule
s1.variant == s2.variant && (
// prevent daily && weekly within X hours of each other
if s1.freq.isDailyOrBetter && s2.freq.isDailyOrBetter && s1.sameSpeed(s2) then
si2.startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(endsAt) &&
startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(si2.endsAt)
else
(
s1.variant.exotic || // overlapping exotic variant
s1.hasMaxRating || // overlapping same rating limit
s1.similarSpeed(s2) // overlapping similar
) && s1.similarConditions(s2) && overlaps(si2)
)

/** Kept in sync with [[conflictsWithFailOnUsurp]].
*/
def conflictsWith(scheds: Iterable[ScheduleWithInterval]): Boolean =
scheds.exists(conflictsWith)

/** Kept in sync with [[conflictsWith]].
*
* Raises an exception if a tourney is incorrectly usurped.
*/
@throws[IllegalStateException]("if a tourney is incorrectly usurped")
def conflictsWithFailOnUsurp(scheds: Iterable[ScheduleWithInterval]) =
val conflicts = scheds.filter(conflictsWith).toSeq
val okConflicts = conflicts.filter(_.schedule.freq >= schedule.freq)
if conflicts.nonEmpty && okConflicts.isEmpty then
throw new IllegalStateException(s"Schedule [$schedule] usurped by ${conflicts}")
conflicts.nonEmpty

case class Plan(schedule: Schedule, buildFunc: Option[Tournament => Tournament])
extends ScheduleWithInterval:
case class Plan(schedule: Schedule, startsAt: Instant, buildFunc: Option[Tournament => Tournament])
extends PlanBuilder.ScheduleWithInterval:

def build(using Translate): Tournament =
val t = Tournament.scheduleAs(withConditions(schedule), minutes)
val t = Tournament.scheduleAs(withConditions(schedule), startsAt, minutes)
buildFunc.fold(t) { _(t) }

def map(f: Tournament => Tournament) = copy(
buildFunc = buildFunc.fold(f)(f.compose).some
)

override def startsAt = schedule.atInstant

def minutes = durationFor(schedule)

override def duration = java.time.Duration.ofMinutes(minutes)
Expand Down Expand Up @@ -425,21 +369,3 @@ object Schedule:
accountAge = none,
allowList = none
)

/** Given a list of existing schedules and a list of possible new plans, returns a subset of the possible
* plans that do not conflict with either the existing schedules or with themselves. Intended to produce
* identical output to [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]], but this
* variant is more readable and has lower potential for bugs.
*/
private[tournament] def pruneConflicts[A <: ScheduleWithInterval](
existingSchedules: Iterable[ScheduleWithInterval],
possibleNewPlans: Iterable[A]
): List[A] =
var allPlannedSchedules = existingSchedules.toList
possibleNewPlans
.foldLeft(Nil): (newPlans, p) =>
if p.conflictsWith(allPlannedSchedules) then newPlans
else
allPlannedSchedules ::= p
p :: newPlans
.reverse
5 changes: 2 additions & 3 deletions modules/tournament/src/main/Tournament.scala
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ case class Tournament(
case class EnterableTournaments(tours: List[Tournament], scheduled: List[Tournament])

object Tournament:

val minPlayers = 2

def fromSetup(setup: TournamentSetup)(using me: Me) =
Expand Down Expand Up @@ -186,7 +185,7 @@ object Tournament:
hasChat = setup.hasChat | true
)

def scheduleAs(sched: Schedule, minutes: Int)(using Translate) =
def scheduleAs(sched: Schedule, startsAt: Instant, minutes: Int)(using Translate) =
Tournament(
id = makeId,
name = sched.name(full = false),
Expand All @@ -201,7 +200,7 @@ object Tournament:
mode = Mode.Rated,
conditions = sched.conditions,
schedule = sched.some,
startsAt = sched.atInstant.plusSeconds(ThreadLocalRandom.nextInt(60))
startsAt = startsAt
)

def tournamentUrl(tourId: TourId): String = s"https://lichess.org/tournament/$tourId"
Expand Down
35 changes: 3 additions & 32 deletions modules/tournament/src/main/TournamentScheduler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,6 @@ import lila.common.LilaScheduler
import lila.core.i18n.Translator
import lila.gathering.Condition

/** This case class (and underlying trait) exists to ensure conflicts are checked against a tournament's true
* interval, rather than the interval which could be inferred from the tournament's schedule parameter via
* [[Schedule.durationFor]]
*
* Such a mismatch could occur if durationFor is modified and existing tournaments are rehydrated from db.
* Another source of mismatch is that tourney actual start is tweaked from scheduled start by a random number
* of seconds (see [[Tournament.scheduleAs]])
*/
private[tournament] case class ConcreteSchedule(
schedule: Schedule,
startsAt: Instant,
duration: java.time.Duration
) extends Schedule.ScheduleWithInterval

private[tournament] case class ConcreteTourney(
tournament: Tournament,
schedule: Schedule,
startsAt: Instant,
duration: java.time.Duration
) extends Schedule.ScheduleWithInterval

final private class TournamentScheduler(tournamentRepo: TournamentRepo)(using
Executor,
Scheduler,
Expand All @@ -43,18 +22,10 @@ final private class TournamentScheduler(tournamentRepo: TournamentRepo)(using
tournamentRepo.scheduledUnfinished.flatMap: dbScheds =>
try
val plans = TournamentScheduler.allWithConflicts()
val possibleTourneys = plans.map(p => (p.schedule, p.build)).map { case (s, t) =>
ConcreteTourney(t, s, t.startsAt, t.duration)
}

val existingSchedules = dbScheds.flatMap { t =>
// Ignore tournaments with schedule=None - they never conflict.
t.schedule.map { ConcreteSchedule(_, t.startsAt, t.duration) }
}

val prunedTourneys = Schedule.pruneConflicts(existingSchedules, possibleTourneys)
val filteredPlans = PlanBuilder.filterAndStaggerPlans(dbScheds, plans)

tournamentRepo.insert(prunedTourneys.map(_.tournament)).logFailure(logger)
tournamentRepo.insert(filteredPlans.map(_.build)).logFailure(logger)
catch
case e: Exception =>
logger.error(s"failed to schedule all: ${e.getMessage}")
Expand Down Expand Up @@ -492,7 +463,7 @@ Thank you all, you rock!""".some,
).plan
)
}
).flatten.filter(_.schedule.atInstant.isAfter(rightNow))
).flatten.filter(_.startsAt.isAfter(rightNow))

private def atTopOfHour(rightNow: Instant, hourDelta: Int): LocalDateTime =
rightNow.plusHours(hourDelta).dateTime.withMinute(0)
Expand Down
Loading

0 comments on commit d49e666

Please sign in to comment.