From d1eb1f7ba7efa33c68f193114157e002912c779b Mon Sep 17 00:00:00 2001 From: axunonb Date: Fri, 19 Jan 2024 20:05:23 +0100 Subject: [PATCH] Improve performance for creating fixtures with MatchScheduler * Planned matches of a tournament are cached for faster checks of available dates * Extend debug logging * Update tenant settings for development to include RefereeRuleSet Update MatchScheduler --- .../Tenant.Default.Development.config | 5 + .../Tenant.Default.Production.config | 5 + .../Tenant.OtherOrg.Development.config | 5 + .../Tenant.OtherOrg.Production.config | 5 + .../Tenant.TestOrg.Development.config | 5 + .../Tenant.TestOrg.Production.config | 5 + .../Data/AvailableMatchDateRepository.cs | 41 ++++++ .../Data/ExcludedMatchDateRepository.cs | 5 +- .../Data/GenericRepository.cs | 4 +- .../MultiTenancy/ITournamentContext.cs | 7 +- .../MultiTenancy/TournamentContext.cs | 8 +- .../Plan/AvailableMatchDates.cs | 72 ++++----- .../TournamentManager/Plan/MatchCreator.cs | 4 +- .../TournamentManager/Plan/MatchScheduler.cs | 138 +++++++++--------- .../TournamentManager/TournamentCreator.cs | 22 +-- 15 files changed, 202 insertions(+), 129 deletions(-) diff --git a/League.Demo/Configuration/Tenant.Default.Development.config b/League.Demo/Configuration/Tenant.Default.Development.config index ecd1e38a..e3813c5f 100644 --- a/League.Demo/Configuration/Tenant.Default.Development.config +++ b/League.Demo/Configuration/Tenant.Default.Development.config @@ -169,5 +169,10 @@ True + + + + None + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.Default.Production.config b/League.Demo/Configuration/Tenant.Default.Production.config index d55ae5a7..94236eae 100644 --- a/League.Demo/Configuration/Tenant.Default.Production.config +++ b/League.Demo/Configuration/Tenant.Default.Production.config @@ -168,5 +168,10 @@ True + + + + None + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.OtherOrg.Development.config b/League.Demo/Configuration/Tenant.OtherOrg.Development.config index e9b3b1d5..25b639f4 100644 --- a/League.Demo/Configuration/Tenant.OtherOrg.Development.config +++ b/League.Demo/Configuration/Tenant.OtherOrg.Development.config @@ -169,5 +169,10 @@ True + + + + Home + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.OtherOrg.Production.config b/League.Demo/Configuration/Tenant.OtherOrg.Production.config index 923bf351..6218a9a4 100644 --- a/League.Demo/Configuration/Tenant.OtherOrg.Production.config +++ b/League.Demo/Configuration/Tenant.OtherOrg.Production.config @@ -168,5 +168,10 @@ True + + + + Home + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.TestOrg.Development.config b/League.Demo/Configuration/Tenant.TestOrg.Development.config index fc4e95ce..d0997c12 100644 --- a/League.Demo/Configuration/Tenant.TestOrg.Development.config +++ b/League.Demo/Configuration/Tenant.TestOrg.Development.config @@ -169,5 +169,10 @@ True + + + + Home + \ No newline at end of file diff --git a/League.Demo/Configuration/Tenant.TestOrg.Production.config b/League.Demo/Configuration/Tenant.TestOrg.Production.config index 0a499251..f962800b 100644 --- a/League.Demo/Configuration/Tenant.TestOrg.Production.config +++ b/League.Demo/Configuration/Tenant.TestOrg.Production.config @@ -169,5 +169,10 @@ True + + + + Home + \ No newline at end of file diff --git a/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs b/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs index e2697e69..a8c2826c 100644 --- a/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs +++ b/TournamentManager/TournamentManager/Data/AvailableMatchDateRepository.cs @@ -2,6 +2,7 @@ using SD.LLBLGen.Pro.ORMSupportClasses; using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.HelperClasses; +using TournamentManager.Plan; namespace TournamentManager.Data; @@ -35,6 +36,46 @@ public async Task> GetAvailableMatchD await da.FetchEntityCollectionAsync(qp, cancellationToken); da.CloseConnection(); + _logger.LogDebug("Fetched {count} available match dates for tournament {tournamentId}.", available.Count, tournamentId); + return available; } + + /// + /// Removes entries in AvailableMatchDates database table. + /// + /// The tournament ID. + /// Which entries to delete for the tournament. + /// + /// Returns the number of deleted records. + public async Task ClearAsync(long tournamentId, MatchDateClearOption clear, CancellationToken cancellationToken) + { + var deleted = 0; + + // tournament is always in the filter + var filterAvailable = new RelationPredicateBucket(); + filterAvailable.PredicateExpression.Add(AvailableMatchDateFields.TournamentId == tournamentId); + + if ((clear & MatchDateClearOption.All) == MatchDateClearOption.All) + { + deleted = await _dbContext.AppDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), + null, cancellationToken); + } + else if ((clear & MatchDateClearOption.OnlyAutoGenerated) == MatchDateClearOption.OnlyAutoGenerated) + { + filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == true); + deleted = await _dbContext.AppDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), + filterAvailable, cancellationToken); + } + else if ((clear & MatchDateClearOption.OnlyManual) == MatchDateClearOption.OnlyManual) + { + filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == false); + deleted = await _dbContext.AppDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), + filterAvailable, cancellationToken); + } + + _logger.LogDebug("Deleted {deleted} available match dates for tournament {tournamentId}.", deleted, tournamentId); + + return deleted; + } } diff --git a/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs b/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs index 8aa8170d..9f7f6e8e 100644 --- a/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs +++ b/TournamentManager/TournamentManager/Data/ExcludedMatchDateRepository.cs @@ -49,7 +49,8 @@ public async Task> GetExcludedMatchDate /// but only for the team or round. /// /// - /// Same behavior as with . + /// Same conditions as with + /// which uses a cached list of s. /// /// The where RoundId and TeamId are taken. /// The TournamentId to filter the result. @@ -58,7 +59,7 @@ public async Task> GetExcludedMatchDate public virtual async Task GetExcludedMatchDateAsync(MatchEntity match, long tournamentId, CancellationToken cancellationToken) { - if (!(match.PlannedStart.HasValue && match.PlannedEnd.HasValue)) return null; + if (match is not { PlannedStart: not null, PlannedEnd: not null }) return null; using var da = _dbContext.GetNewAdapter(); var tournamentFilter = new PredicateExpression( diff --git a/TournamentManager/TournamentManager/Data/GenericRepository.cs b/TournamentManager/TournamentManager/Data/GenericRepository.cs index c83521e8..5bdfbdf3 100644 --- a/TournamentManager/TournamentManager/Data/GenericRepository.cs +++ b/TournamentManager/TournamentManager/Data/GenericRepository.cs @@ -77,7 +77,7 @@ public virtual async Task DeleteEntitiesAsync(T entitiesToDelete, Cancellatio var count = await da.DeleteEntityCollectionAsync(entitiesToDelete, cancellationToken); } - public virtual async Task DeleteEntitiesDirectlyAsync(Type entityType, IRelationPredicateBucket filterBucket, CancellationToken cancellationToken) + public virtual async Task DeleteEntitiesDirectlyAsync(Type entityType, IRelationPredicateBucket? filterBucket, CancellationToken cancellationToken) { using var da = _dbContext.GetNewAdapter(); return await da.DeleteEntitiesDirectlyAsync(entityType, filterBucket, cancellationToken); @@ -97,4 +97,4 @@ public virtual async Task DeleteEntitiesUsingConstraintAsync(IPredicateE var bucket = new RelationPredicateBucket(uniqueConstraintFilter); return await da.DeleteEntitiesDirectlyAsync(typeof(T), bucket, cancellationToken); } -} \ No newline at end of file +} diff --git a/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs b/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs index edb83358..8035a238 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/ITournamentContext.cs @@ -59,4 +59,9 @@ public interface ITournamentContext /// Rules for team master data /// TeamRules TeamRuleSet { get; set; } -} \ No newline at end of file + + /// + /// Rules for referee master data + /// + RefereeRules RefereeRuleSet { get; set; } +} diff --git a/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs b/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs index b59cac09..3bc5ecc0 100644 --- a/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs +++ b/TournamentManager/TournamentManager/MultiTenancy/TournamentContext.cs @@ -75,9 +75,9 @@ public class TournamentContext : ITournamentContext public TeamRules TeamRuleSet { get; set; } = new(); /// - /// Rules for organizing referees. + /// Rules for referee master data. /// - [YAXLib.Attributes.YAXComment("Rules for organizing referees")] + [YAXLib.Attributes.YAXComment("Rules for referee master data")] public RefereeRules RefereeRuleSet { get; set; } = new(); } @@ -166,14 +166,14 @@ public class TeamRules } /// -/// Rules for organizing referees. +/// Rules for referee master data. /// public class RefereeRules { /// /// Rules for teams' home match time /// - [YAXLib.Attributes.YAXComment("Rules for organizing referees")] + [YAXLib.Attributes.YAXComment("Rule for organizing referees")] public Plan.RefereeType RefereeType { get; set; } = Plan.RefereeType.None; } diff --git a/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs b/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs index 47de06f4..071283a2 100644 --- a/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs +++ b/TournamentManager/TournamentManager/Plan/AvailableMatchDates.cs @@ -1,6 +1,5 @@ using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.HelperClasses; -using SD.LLBLGen.Pro.ORMSupportClasses; using Microsoft.Extensions.Logging; using TournamentManager.Data; using TournamentManager.MultiTenancy; @@ -62,39 +61,22 @@ await _appDb.AvailableMatchDateRepository.GetAvailableMatchDatesAsync( /// - /// Removes entries in AvailableMatchDates database table. + /// Removes entries in database table and internal cache. /// /// Which entries to delete for the tournament. /// /// Returns the number of deleted records. public async Task ClearAsync(MatchDateClearOption clear, CancellationToken cancellationToken) { - var deleted = 0; + var deleted = + await _appDb.AvailableMatchDateRepository.ClearAsync(_tenantContext.TournamentContext.MatchPlanTournamentId, + clear, cancellationToken); - // tournament is always in the filter - var filterAvailable = new RelationPredicateBucket(); - filterAvailable.PredicateExpression.Add(AvailableMatchDateFields.TournamentId == - _tenantContext.TournamentContext.MatchPlanTournamentId); - - if (clear == MatchDateClearOption.All) - { - deleted = await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), - null!, cancellationToken); - _generatedAvailableMatchDateEntities.Clear(); - } - else if (clear == MatchDateClearOption.OnlyAutoGenerated) - { - filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == true); - deleted = await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), - filterAvailable, cancellationToken); + if((clear & MatchDateClearOption.OnlyAutoGenerated) == MatchDateClearOption.OnlyAutoGenerated) _generatedAvailableMatchDateEntities.Clear(); - } - else if (clear == MatchDateClearOption.OnlyManual) - { - filterAvailable.PredicateExpression.AddWithAnd(AvailableMatchDateFields.IsGenerated == false); - deleted = await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(AvailableMatchDateEntity), - filterAvailable, cancellationToken); - } + + if ((clear & MatchDateClearOption.OnlyManual) == MatchDateClearOption.OnlyManual) + _availableMatchDateEntities.Clear(); return deleted; } @@ -112,16 +94,19 @@ private static bool IsVenueAndDateDefined(TeamEntity team) /// Verifies, that the given is within the date /// boundaries, and it is not excluded, and the venue is not occupied by another match. /// - private async Task IsDateUsable(DateTime matchDateTimeUtc, RoundLegEntity roundLeg, TeamEntity team, CancellationToken cancellationToken) + private bool IsDateUsable(DateTime matchDateTimeUtc, RoundLegEntity roundLeg, IEnumerable tournamentMatches, TeamEntity team) { var plannedDuration = _tenantContext.TournamentContext.FixtureRuleSet.PlannedDurationOfMatch; - // Todo: This code creates heavy load on the database - return IsDateWithinRoundLegDateTime(roundLeg, matchDateTimeUtc) + var isAvailable = IsDateWithinRoundLegDateTime(roundLeg, matchDateTimeUtc) && !IsExcludedDate(matchDateTimeUtc, roundLeg.RoundId, team.Id) - && !await IsVenueOccupiedByMatchAsync( + && !IsVenueOccupiedByMatch( new DateTimePeriod(matchDateTimeUtc, matchDateTimeUtc.Add(plannedDuration)), - team.VenueId!.Value, cancellationToken); + team.VenueId!.Value, tournamentMatches); + + _logger.LogDebug("Venue '{venueId}' is available for '{matchDateTimeUtc}': {isAvailable}", team.VenueId, matchDateTimeUtc, isAvailable); + + return isAvailable; } @@ -131,9 +116,10 @@ private async Task IsDateUsable(DateTime matchDateTimeUtc, RoundLegEntity /// are not . /// /// + /// /// /// - public async Task GenerateNewAsync(RoundEntity round, CancellationToken cancellationToken) + public async Task GenerateNewAsync(RoundEntity round, EntityCollection tournamentMatches, CancellationToken cancellationToken) { await Initialize(cancellationToken); @@ -165,7 +151,7 @@ public async Task GenerateNewAsync(RoundEntity round, CancellationToken cancella // check whether the calculated date // is within the borders of round legs (if any) and is not marked as excluded - if (await IsDateUsable(matchDateTimeUtc, roundLeg, team, cancellationToken)) + if (IsDateUsable(matchDateTimeUtc, roundLeg, tournamentMatches, team)) { var av = new AvailableMatchDateEntity { @@ -195,8 +181,7 @@ public async Task GenerateNewAsync(RoundEntity round, CancellationToken cancella _logger.LogDebug("Generated {Count} UTC dates for HomeTeams:", _generatedAvailableMatchDateEntities.Count); _logger.LogDebug("{Generated}\n", _generatedAvailableMatchDateEntities.Select(gen => (gen.HomeTeamId, gen.MatchStartTime))); - // save to the persistent storage - // await _appDb.GenericRepository.SaveEntitiesAsync(_generatedAvailableMatchDateEntities, true, false, cancellationToken); + // Note: Generated dates are not saved to the database, but only used for the current run. } /// @@ -222,11 +207,13 @@ private List> GetListOfTeamsWithSameVenue(RoundEnti return listTeamsWithSameVenue; } - private async Task IsVenueOccupiedByMatchAsync(DateTimePeriod matchTime, long venueId, - CancellationToken cancellationToken) + private static bool IsVenueOccupiedByMatch(DateTimePeriod matchTime, long venueId, + IEnumerable tournamentMatches) { - return (await _appDb.VenueRepository.GetOccupyingMatchesAsync(venueId, matchTime, - _tenantContext.TournamentContext.MatchPlanTournamentId, cancellationToken)).Count == 0; + return tournamentMatches.Any(m => m.VenueId == venueId && + m is { IsComplete: false, PlannedStart: not null, PlannedEnd: not null } + && (m.PlannedStart <= matchTime.End && + matchTime.Start <= m.PlannedEnd)); // overlapping periods } private static bool IsDateWithinRoundLegDateTime(RoundLegEntity leg, DateTime queryDate) @@ -249,7 +236,8 @@ private static DateTime IncrementDateUntilDayOfWeek(DateTime date, DayOfWeek day /// but only for the team or round. /// /// - /// Same behavior as with . + /// Same conditions as with + /// which gets a s from the database, if one exists, or . /// /// Date to test, whether it is excluded. /// OR excluded on the round level. If , there is no round restriction. @@ -278,7 +266,7 @@ private bool IsExcludedDate(DateTime queryDate, long? roundId, long? teamId) ; } - public List GetGeneratedAndManualAvailableMatchDateDays(RoundLegEntity leg) + internal List GetGeneratedAndManualAvailableMatchDateDays(RoundLegEntity leg) { var result = _generatedAvailableMatchDateEntities.Union(_availableMatchDateEntities) .Where(gen => gen.TournamentId == _tenantContext.TournamentContext.MatchPlanTournamentId @@ -291,7 +279,7 @@ public List GetGeneratedAndManualAvailableMatchDateDays(RoundLegEntity return result; } - public List GetGeneratedAndManualAvailableMatchDates(long homeTeamId, + internal List GetGeneratedAndManualAvailableMatchDates(long homeTeamId, DateTimePeriod datePeriod, List? excludedDates) { if (datePeriod is not { Start: not null, End: not null }) throw new ArgumentNullException(nameof(datePeriod)); diff --git a/TournamentManager/TournamentManager/Plan/MatchCreator.cs b/TournamentManager/TournamentManager/Plan/MatchCreator.cs index 6c0c19a6..2cb8a56d 100644 --- a/TournamentManager/TournamentManager/Plan/MatchCreator.cs +++ b/TournamentManager/TournamentManager/Plan/MatchCreator.cs @@ -7,7 +7,7 @@ namespace TournamentManager.Plan; /// /// Class to create matches for a group of participants. -/// The round robin system is applied, i.e. all participants in the group play each other. +/// The round-robin system is applied, i.e. all participants in the group play each other. /// /// The participant type. /// The referee type. @@ -118,7 +118,7 @@ public ParticipantCombinations GetCombinations(LegType legType) { CreateCombinations(_tenantContext.TournamentContext.RefereeRuleSet.RefereeType); - return ((legType == LegType.First) ? _participantCombinationsFirstLeg : _participantCombinationsReturnLeg); + return (legType == LegType.First) ? _participantCombinationsFirstLeg : _participantCombinationsReturnLeg; } /// diff --git a/TournamentManager/TournamentManager/Plan/MatchScheduler.cs b/TournamentManager/TournamentManager/Plan/MatchScheduler.cs index 449ffd78..42fa5a49 100644 --- a/TournamentManager/TournamentManager/Plan/MatchScheduler.cs +++ b/TournamentManager/TournamentManager/Plan/MatchScheduler.cs @@ -46,11 +46,11 @@ private async Task LoadEntitiesAsync(CancellationToken cancellationToken) } internal async Task GenerateAvailableMatchDatesAsync(MatchDateClearOption clearMatchDates, RoundEntity round, - CancellationToken cancellationToken) + EntityCollection tournamentMatches, CancellationToken cancellationToken) { await LoadEntitiesAsync(cancellationToken); _ = await _availableMatchDates.ClearAsync(clearMatchDates, cancellationToken); - await _availableMatchDates.GenerateNewAsync(round, cancellationToken); + await _availableMatchDates.GenerateNewAsync(round, tournamentMatches, cancellationToken); } /// @@ -70,7 +70,7 @@ public async Task ScheduleFixturesForTournament(bool keepExisting, CancellationT } /// - /// Generates round match combinations for the Round Robin system, + /// Generates round match combinations for the round-robin system, /// assigns optimized match dates and stores the matches to /// the persistent storage. /// @@ -82,9 +82,10 @@ public async Task ScheduleFixturesForRound(RoundEntity round, bool keepExisting, if (_appDb.MatchRepository.AnyCompleteMatchesExist(round)) throw new InvalidOperationException($"Completed matches exist for round '{round.Id}'. Generating fixtures aborted."); - var roundMatches = await GetOrCreateRoundMatches(round, keepExisting, cancellationToken); + // We load all tournament matches, so that we can check for venues occupied by existing matches in memory. + var tournamentMatches = await GetOrCreateTournamentMatches(round, keepExisting, cancellationToken); - await GenerateAvailableMatchDatesAsync(MatchDateClearOption.OnlyAutoGenerated, round, cancellationToken); + await GenerateAvailableMatchDatesAsync(MatchDateClearOption.OnlyAutoGenerated, round, tournamentMatches, cancellationToken); var teams = new Collection(round.TeamCollectionViaTeamInRound.Select(t => t.Id).ToList()); @@ -105,12 +106,12 @@ public async Task ScheduleFixturesForRound(RoundEntity round, bool keepExisting, // We assign the match dates based on the turn combinations' TurnDateTimePeriods foreach (var turn in combinations.GetTurns()) { - SetMatchDates(round, combinations, turn, roundLeg, roundMatches); + SetMatchDates(round, roundLeg, turn, combinations, tournamentMatches); } } // save the matches for the group - await _appDb.GenericRepository.SaveEntitiesAsync(roundMatches, true, false, cancellationToken); + await _appDb.GenericRepository.SaveEntitiesAsync(tournamentMatches, true, false, cancellationToken); await _availableMatchDates.ClearAsync(MatchDateClearOption.OnlyAutoGenerated, cancellationToken); } @@ -119,24 +120,27 @@ public async Task ScheduleFixturesForRound(RoundEntity round, bool keepExisting, /// Sets the match dates for the given in and . /// /// - /// - /// /// - /// - private void SetMatchDates(RoundEntity round, ParticipantCombinations combinations, int turn, RoundLegEntity roundLeg, - EntityCollection roundMatches) + /// + /// + /// + private void SetMatchDates(RoundEntity round, RoundLegEntity roundLeg, + int turn, ParticipantCombinations combinations, + EntityCollection tournamentMatches) { + // Get the selected turn combinations for the given turn. var selectedTurnCombinations = combinations.GetCombinations(turn).ToList(); - // Get match dates for every combination of a group. - // Matches in the same turnCombinations can even take at the same time. - var datesFound = GetMatchDatesForTurn(turn, combinations, roundMatches); + // Get match dates for every combination of a turn. + // Matches in the same turnCombinations can even take place at the same time. + var datesFound = GetMatchDatesForTurn(turn, combinations, tournamentMatches); _logger.LogDebug("Found dates for combination: {dates}", string.Join(", ", - datesFound.OrderBy(bd => bd?.MatchStartTime).Select(bd => bd?.MatchStartTime.ToShortDateString())) - .TrimEnd(',', ' ')); + datesFound.OrderBy(bd => bd?.MatchStartTime) + .Select(bd => bd?.MatchStartTime.ToShortDateString() ?? "(null)")) + .Trim(',', ' ')); - // MatchDates contains calculated dates in the same order as turn combinations, + // datesFound contains calculated dates in the same sequence as turn combinations, // so the index can be used for both. for (var index = 0; index < selectedTurnCombinations.Count; index++) { @@ -144,7 +148,7 @@ private void SetMatchDates(RoundEntity round, ParticipantCombinations + if (tournamentMatches.Any(rm => (rm.HomeTeamId == combination.Home && rm.GuestTeamId == combination.Guest && rm.LegSequenceNo == roundLeg.SequenceNo) || (rm.GuestTeamId == combination.Home && rm.HomeTeamId == combination.Guest && @@ -173,7 +177,7 @@ private void SetMatchDates(RoundEntity round, ParticipantCombinations CreateCombinations(Collection { // build up match combinations for the teams of round var matchCreator = new MatchCreator(_tenantContext, _loggerFactory.CreateLogger>()); - // TODO: RefereeType should be configurable via ITenantContext var combinations = matchCreator.SetParticipants(teams).GetCombinations( roundLeg.SequenceNo % 2 == 1 ? LegType.First : LegType.Return); @@ -197,34 +200,28 @@ private ParticipantCombinations CreateCombinations(Collection } /// - /// Gets the existing matches for the given . + /// Gets the existing matches for the given of a . /// If is true, the existing matches are returned. - /// If is false, the existing matches are deleted and an empty collection is returned. + /// If is false, the existing matches of the round are deleted before returning tournament matches. /// /// /// /// - /// The existing matches for the given , depending on . - private async Task> GetOrCreateRoundMatches(RoundEntity round, bool keepExistingMatches, CancellationToken cancellationToken) + /// The existing matches for the given , depending on . + private async Task> GetOrCreateTournamentMatches(RoundEntity round, bool keepExistingMatches, CancellationToken cancellationToken) { - EntityCollection roundMatches; - - if (keepExistingMatches) + if (!keepExistingMatches) { - // load existing matches from storage - roundMatches = _appDb.MatchRepository.GetMatches(round); - } - else - { - roundMatches = new EntityCollection(); - // delete existing matches from storage + // delete existing matches of the round from storage + // before load tournament matches var bucket = new RelationPredicateBucket(new PredicateExpression( new FieldCompareRangePredicate(MatchFields.RoundId, null, false, new[] { round.Id }))); await _appDb.GenericRepository.DeleteEntitiesDirectlyAsync(typeof(MatchEntity), bucket, cancellationToken); } - return roundMatches; + var tournamentMatches = _appDb.MatchRepository.GetMatches(round.TournamentId!.Value); + return tournamentMatches; } /// @@ -280,6 +277,8 @@ private static List GetOccupiedMatchDates(ParticipantCombination>(); for (var index = 0; index < turnCombinations.Count; index++) @@ -291,6 +290,7 @@ private static List GetOccupiedMatchDates(ParticipantCombination amd.MinTimeDiff = TimeSpan.MaxValue); +#if DEBUG // Get the last match for at least one of the teams, if any var lastMatchOfCombination = roundMatches.OrderBy(gm => gm.PlannedStart).LastOrDefault(gm => gm.HomeTeamId == combination.Home || gm.GuestTeamId == combination.Guest); @@ -302,44 +302,44 @@ private static List GetOccupiedMatchDates(ParticipantCombination(); - - // we can't proceed without any match dates found - if (matchDates.Count == 0) return matchDatesPerCombination; - // only 1 match date found, so optimization is not possible - // and the following "i-loop" will be skipped - if (matchDates.Count == 1) - { - matchDatesPerCombination.Add(matchDates[0][0]); - return matchDatesPerCombination; + // We have to add the list of available dates even if it is empty. + matchDates.Add(availableDatesForCombination); } - FindBestDate(matchDates, matchDatesPerCombination); - - return matchDatesPerCombination; + return matchDates.Count switch { + // We can't proceed without any match dates found + 0 => new List(), + // Only 1 match date found, so optimization is not possible + // and FindBestDate() would throw an exception + 1 => new List { matchDates[0][0] }, + _ => FindBestDates(matchDates) + }; } /// /// Finds the best match dates from a list of available match dates for each combination. + /// The best match is the date that has the smallest distance to the smallest date in the other turn(s). + /// If no match dates could be determined for a team, bestDate will be set to null. + /// + /// This method is optimizing across all combinations of all turns. /// /// - /// - private static void FindBestDate(List> availableMatchDates, List bestMatchDatesPerCombination) + private static List FindBestDates(List> availableMatchDates) { - // Cross-compute the number of dates between between matches of turn. - // Goal: found match dates should be as close together as possible + var bestMatchDatesPerCombination = new List(); + + // Cross-compute the number of dates between matches of a turn. + // Goal: Found match dates are as close to each other as possible // start with 1st dates, end with last but one dates for (var i = 0; i < availableMatchDates.Count - 1; i++) @@ -365,10 +365,12 @@ private static void FindBestDate(List> availableM } // end dates1 } // end j - // Get the date that has least distance to smallest date in other turn(s) + // Get the date that has the smallest distance to the smallest date in the other turn(s). // Note: If no match dates could be determined for a team, bestDate will be null. - var bestDate = availableMatchDates[i].Where(md => md.MinTimeDiff == availableMatchDates[i].Min(d => d.MinTimeDiff)) - .OrderBy(md => md.MinTimeDiff).FirstOrDefault(); + var bestDate = availableMatchDates[i] + .Where(md => md.MinTimeDiff == availableMatchDates[i] + .Min(d => d.MinTimeDiff)) + .MinBy(md => md.MinTimeDiff); bestMatchDatesPerCombination.Add(bestDate); // process the last combination @@ -377,12 +379,18 @@ private static void FindBestDate(List> availableM // now the "j-loop" group is not processed yet: if (i + 1 >= availableMatchDates.Count - 1) { - bestDate = availableMatchDates[^1].Where(md => md.MinTimeDiff == availableMatchDates[^1].Min(d => d.MinTimeDiff)) + bestDate = availableMatchDates[^1] + .Where(md => md.MinTimeDiff == availableMatchDates[^1]. + Min(d => d.MinTimeDiff)) .MinBy(md => md.MinTimeDiff); // the last "j-increment" is always the same as "matchDates[^1]" (loop condition) bestMatchDatesPerCombination.Add(bestDate); } } // end i + + // returns the best match date found per combination, + // so the number of elements is the same as the number of combinations + return bestMatchDatesPerCombination; } /// diff --git a/TournamentManager/TournamentManager/TournamentCreator.cs b/TournamentManager/TournamentManager/TournamentCreator.cs index 35ee7eeb..8da3ecc6 100644 --- a/TournamentManager/TournamentManager/TournamentCreator.cs +++ b/TournamentManager/TournamentManager/TournamentCreator.cs @@ -45,9 +45,11 @@ public static TournamentCreator Instance(IAppDb appDb) public async Task CopyTournament (long fromTournamentId) { var now = DateTime.UtcNow; - var tournament = await _appDb.TournamentRepository.GetTournamentAsync(new PredicateExpression(TournamentFields.Id == fromTournamentId), CancellationToken.None); - if (tournament is null) throw new NullReferenceException($"'{fromTournamentId}' not found."); - + var tournament = + await _appDb.TournamentRepository.GetTournamentAsync( + new PredicateExpression(TournamentFields.Id == fromTournamentId), CancellationToken.None) + ?? throw new InvalidOperationException($"'{fromTournamentId}' not found."); + var newTournament = new TournamentEntity { IsPlanningMode = true, @@ -77,13 +79,13 @@ public async Task CopyTournament (long fromTournamentId) /// /// Existing source tournament id. /// Existing target tournament id. - /// List of round id's to be excluded (may be null for none) + /// List of round id's to be excluded (empty list for 'none') /// True, if creation was successful, false otherwise. - public bool CopyRound(long fromTournamentId, long toTournamentId, IEnumerable excludeRoundId) + public bool CopyRound(long fromTournamentId, long toTournamentId, IList excludeRoundId) { const string transactionName = "CloneRounds"; var now = DateTime.UtcNow; - + // get the rounds of SOURCE tournament var roundIds = _appDb.TournamentRepository.GetTournamentRounds(fromTournamentId).Select(r => r.Id).ToList(); @@ -134,7 +136,7 @@ public bool CopyRound(long fromTournamentId, long toTournamentId, IEnumerable rounds , int sequenceNo, DateTi var roundEntities = (rounds as RoundEntity[] ?? rounds.ToArray()).ToList(); - if (!roundEntities.Any()) + if (roundEntities.Count == 0) return false; var roundIds = roundEntities.Select(r => r.Id).ToList(); @@ -204,9 +206,7 @@ public async Task SetTournamentCompleted(long tournamentId) throw new ArgumentException($"Tournament {tournamentId} contains incomplete matches."); } - var tournament = await new TournamentRepository(_appDb.DbContext).GetTournamentWithRoundsAsync(tournamentId, CancellationToken.None); - if (tournament == null) throw new InvalidOperationException($"Tournament with Id '{tournamentId}' not found."); - + var tournament = await new TournamentRepository(_appDb.DbContext).GetTournamentWithRoundsAsync(tournamentId, CancellationToken.None) ?? throw new InvalidOperationException($"Tournament with Id '{tournamentId}' not found."); var now = DateTime.UtcNow; foreach (var round in tournament.Rounds)