public static async Task CheckForTimedUnlocksAsync(
            PuzzleServerContext context,
            Event eventObj,
            Team team)
        {
            DateTime expiry;

            lock (TimedUnlockExpiryCache)
            {
                // throttle this by an expiry interval before we do anything even remotely expensive
                if (TimedUnlockExpiryCache.TryGetValue(team.ID, out expiry) && expiry >= DateTime.UtcNow)
                {
                    return;
                }
            }

            DateTime now = DateTime.UtcNow;

            // do the unlocks in a loop.
            // The loop will catch cascading unlocks, e.g. if someone does not hit the site between 11:59 and 12:31, catch up to the 12:30 unlocks immediately.
            while (true)
            {
                var puzzlesToSolveByTime = await PuzzleStateHelper.GetSparseQuery(context, eventObj, null, team)
                                           .Where(state => state.SolvedTime == null && state.UnlockedTime != null && state.Puzzle.MinutesToAutomaticallySolve != null && state.UnlockedTime.Value + TimeSpan.FromMinutes(state.Puzzle.MinutesToAutomaticallySolve.Value) <= now)
                                           .Select((state) => new { Puzzle = state.Puzzle, UnlockedTime = state.UnlockedTime.Value })
                                           .ToListAsync();

                foreach (var state in puzzlesToSolveByTime)
                {
                    // mark solve time as when the puzzle was supposed to complete, so cascading unlocks work properly.
                    await PuzzleStateHelper.SetSolveStateAsync(context, eventObj, state.Puzzle, team, state.UnlockedTime + TimeSpan.FromMinutes(state.Puzzle.MinutesToAutomaticallySolve.Value));
                }

                // get out of the loop if we did nothing
                if (puzzlesToSolveByTime.Count == 0)
                {
                    break;
                }
            }

            lock (TimedUnlockExpiryCache)
            {
                // effectively, expiry = Math.Max(DateTime.UtcNow, LastGlobalExpiry) + ClosestExpirySpacing - if you could use Math.Max on DateTime
                expiry = DateTime.UtcNow;
                if (expiry < LastGlobalExpiry)
                {
                    expiry = LastGlobalExpiry;
                }
                expiry += ClosestExpirySpacing;

                TimedUnlockExpiryCache[team.ID] = expiry;
                LastGlobalExpiry = expiry;
            }
        }
        public static async Task CheckForTimedUnlocksAsync(
            PuzzleServerContext context,
            Event eventObj,
            Team team)
        {
            DateTime expiry;

            lock (TimedUnlockExpiryCache)
            {
                // throttle this by an expiry interval before we do anything even remotely expensive
                if (TimedUnlockExpiryCache.TryGetValue(team.ID, out expiry) && expiry >= DateTime.UtcNow)
                {
                    return;
                }
            }

            DateTime now = DateTime.UtcNow;
            var      puzzlesToSolveByTime = await PuzzleStateHelper.GetSparseQuery(context, eventObj, null, team)
                                            .Where(state => state.SolvedTime == null && state.UnlockedTime != null && state.Puzzle.MinutesToAutomaticallySolve != null && state.UnlockedTime.Value + TimeSpan.FromMinutes(state.Puzzle.MinutesToAutomaticallySolve.Value) <= now)
                                            .Select(state => state.Puzzle)
                                            .ToListAsync();

            foreach (Puzzle puzzle in puzzlesToSolveByTime)
            {
                await PuzzleStateHelper.SetSolveStateAsync(context, eventObj, puzzle, team, DateTime.UtcNow);
            }

            lock (TimedUnlockExpiryCache)
            {
                // effectively, expiry = Math.Max(DateTime.UtcNow, LastGlobalExpiry) + ClosestExpirySpacing - if you could use Math.Max on DateTime
                expiry = DateTime.UtcNow;
                if (expiry < LastGlobalExpiry)
                {
                    expiry = LastGlobalExpiry;
                }
                expiry += ClosestExpirySpacing;

                TimedUnlockExpiryCache[team.ID] = expiry;
                LastGlobalExpiry = expiry;
            }
        }