Skip to content

Commit

Permalink
Use a faster and less CPU- and memory-intensive algorithm for compari…
Browse files Browse the repository at this point in the history
…ng whether two calendars are equal, to reduce GC pressure. Takes advantage of the run lists being presorted.
  • Loading branch information
Aldaviva committed Jul 4, 2024
1 parent 8824663 commit d1977eb
Show file tree
Hide file tree
Showing 9 changed files with 109 additions and 35 deletions.
6 changes: 3 additions & 3 deletions GamesDoneQuickCalendarFactory/Data/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ public class Configuration {

public TimeSpan cacheDuration { get; init; } = TimeSpan.FromMinutes(1);

public string? googleServiceAccountEmailAddress { get; init; }
public string? googleCalendarId { get; init; }
public string? googleServiceAccountPrivateKey { get; init; }
public string? googleServiceAccountEmailAddress { get; init; } = null;
public string? googleCalendarId { get; init; } = null;
public string? googleServiceAccountPrivateKey { get; init; } = null;

}
61 changes: 61 additions & 0 deletions GamesDoneQuickCalendarFactory/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using Google.Apis.Calendar.v3.Data;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Proxies;
using Ical.Net.Serialization;
using NodaTime;
using System.Diagnostics.Contracts;
using System.Reflection;
using System.Text;
using Calendar = Ical.Net.Calendar;

namespace GamesDoneQuickCalendarFactory;

Expand All @@ -14,10 +17,13 @@ public static class Extensions {
private static readonly MethodInfo ENCODINGSTACK_PUSH = ENCODINGSTACK_TYPE.GetMethod("Push", [typeof(Encoding)])!;
private static readonly MethodInfo ENCODINGSTACK_POP = ENCODINGSTACK_TYPE.GetMethod("Pop")!;

[Pure]
public static IDateTime toIcsDateTimeUtc(this OffsetDateTime input) => new CalDateTime(input.ToInstant().ToDateTimeUtc(), DateTimeZone.Utc.Id);

[Pure]
public static EventDateTime toGoogleEventDateTime(this IDateTime dateTime) => new() { DateTimeDateTimeOffset = dateTime.AsDateTimeOffset, TimeZone = dateTime.TimeZoneName };

[Pure]
public static Event toGoogleEvent(this CalendarEvent calendarEvent) => new() {
ICalUID = calendarEvent.Uid,
Start = calendarEvent.Start.toGoogleEventDateTime(),
Expand All @@ -29,6 +35,7 @@ public static class Extensions {
Transparency = "transparent" // show me as available
};

[Pure]
public static string joinHumanized(this IEnumerable<object> enumerable, string comma = ",", string conjunction = "and", bool oxfordComma = true) {
using IEnumerator<object> enumerator = enumerable.GetEnumerator();

Expand Down Expand Up @@ -87,4 +94,58 @@ public static async Task serializeAsync(this SerializerBase serializer, object d
serializer.SerializationContext.Pop();
}

/// <summary>
/// Checks if two calendars have equal lists of events. Both calendars' event lists must already be sorted the same (such as ascending start time) for vastly improved CPU and memory usage compared to <see cref="Calendar.Equals(Ical.Net.Calendar)"/>.
/// </summary>
/// <param name="a">a <see cref="Calendar"/></param>
/// <param name="b">another <see cref="Calendar"/></param>
/// <returns><c>true</c> if <paramref name="a"/> and <paramref name="b"/> have the same events in the same order, or <c>false</c> otherwise</returns>
[Pure]
public static bool EqualsPresorted(this Calendar a, Calendar? b) {
if (b is null) {
return false;
}

IUniqueComponentList<CalendarEvent> eventsA = a.Events;
IUniqueComponentList<CalendarEvent> eventsB = b.Events;

if (eventsA.Count == eventsB.Count) {
return !eventsA.Where((eventA, i) => !eventA.EqualsFast(eventsB[i])).Any();
} else {
return false;
}
}

[Pure]
public static bool EqualsFast(this CalendarEvent a, CalendarEvent? b) =>
b != null &&
a.Uid == b.Uid &&
a.Summary == b.Summary &&
a.Location == b.Location &&
a.Description == b.Description &&
a.DtStart.Equals(b.DtStart) &&
a.DtEnd.Equals(b.DtEnd);

/// <summary>
/// Is this time before another?
/// </summary>
/// <param name="time">a time</param>
/// <param name="other">another time</param>
/// <returns><c>true</c> if this <paramref name="time"/> happens before <paramref name="other"/>, or <c>false</c> if it happens on or after <paramref name="other"/>.</returns>
[Pure]
public static bool IsBefore(this OffsetDateTime time, OffsetDateTime other) {
return time.ToInstant() < other.ToInstant();
}

/// <summary>
/// Is this time after another?
/// </summary>
/// <param name="time">a time</param>
/// <param name="other">another time</param>
/// <returns><c>true</c> if this <paramref name="time"/> happens after <paramref name="other"/>, or <c>false</c> if it happens on or before <paramref name="other"/>.</returns>
[Pure]
public static bool IsAfter(this OffsetDateTime time, OffsetDateTime other) {
return time.ToInstant() > other.ToInstant();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64</RuntimeIdentifiers>
<Version>2.7.1</Version>
<Version>2.7.2</Version>
<Product>Games Done Quick Calendar Factory</Product>
<AssemblyTitle>Games Done Quick Calendar Factory</AssemblyTitle>
<Company>Ben Hutchison</Company>
Expand Down
15 changes: 8 additions & 7 deletions GamesDoneQuickCalendarFactory/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using NodaTime;
using System.Text;
using System.Text.RegularExpressions;

const string CACHE_POLICY = "Cache policy";
Encoding calendarEncoding = Encoding.UTF8;
MediaTypeHeaderValue icalendarContentType = new("text/calendar") { Charset = calendarEncoding.WebName };

Expand All @@ -27,8 +27,8 @@

// GZIP response compression is handled by Apache httpd, not Kestrel, per https://learn.microsoft.com/en-us/aspnet/core/performance/response-compression?view=aspnetcore-8.0#when-to-use-response-compression-middleware
builder.Services
.Configure<Configuration>(builder.Configuration)
.AddOutputCache(options => options.AddPolicy(CACHE_POLICY, policyBuilder => policyBuilder.Expire(builder.Configuration.Get<Configuration>()!.cacheDuration)))
// .Configure<Configuration>(builder.Configuration) // uncommenting this causes missing JSON properties to be returned as "" instead of null
.AddOutputCache()
.AddSingleton<ICalendarGenerator, CalendarGenerator>()
.AddSingleton<IEventDownloader, EventDownloader>()
.AddSingleton<IGdqClient, GdqClient>()
Expand All @@ -48,25 +48,26 @@
await next();
});

webApp.MapGet("/", async Task ([FromServices] ICalendarPoller calendarPoller, HttpResponse response) => {
webApp.MapGet("/", [OutputCache] async Task ([FromServices] ICalendarPoller calendarPoller, HttpResponse response) => {
CalendarResponse? mostRecentlyPolledCalendar = calendarPoller.mostRecentlyPolledCalendar;
if (mostRecentlyPolledCalendar != null) {
ResponseHeaders responseHeaders = response.GetTypedHeaders();
responseHeaders.ContentType = icalendarContentType;
responseHeaders.ETag = new EntityTagHeaderValue(mostRecentlyPolledCalendar.etag);
responseHeaders.LastModified = mostRecentlyPolledCalendar.dateModified;
responseHeaders.CacheControl = new CacheControlHeaderValue { Public = true, MaxAge = calendarPoller.getPollingInterval() };
await new CalendarSerializer().serializeAsync(mostRecentlyPolledCalendar.calendar, response.Body, calendarEncoding);
}
}).CacheOutput(CACHE_POLICY);
});

webApp.MapGet("/badge.json", async ([FromServices] IEventDownloader eventDownloader) =>
webApp.MapGet("/badge.json", [OutputCache] async ([FromServices] IEventDownloader eventDownloader) =>
await eventDownloader.downloadSchedule() is { } schedule
? new ShieldsBadgeResponse(
label: Regex.Replace(schedule.shortTitle, @"(?<=\D)(?=\d)|(?<=[a-z])(?=[A-Z])", " ").ToLower(), // add spaces to abbreviation
message: $"{schedule.runs.Count} {(schedule.runs.Count == 1 ? "run" : "runs")}",
color: "success",
logoSvg: Resources.gdqDpadBadgeLogo)
: new ShieldsBadgeResponse("gdq", "no event now", "important", false, Resources.gdqDpadBadgeLogo)).CacheOutput(CACHE_POLICY);
: new ShieldsBadgeResponse("gdq", "no event now", "important", false, Resources.gdqDpadBadgeLogo));

await webApp.Services.GetRequiredService<IGoogleCalendarSynchronizer>().start();

Expand Down
12 changes: 2 additions & 10 deletions GamesDoneQuickCalendarFactory/Services/CalendarGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,26 @@
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using System.Collections.Frozen;

namespace GamesDoneQuickCalendarFactory.Services;

public interface ICalendarGenerator {

Task<Calendar> generateCalendar(bool includeAnnoyingPeople = true);
Task<Calendar> generateCalendar();

}

public sealed class CalendarGenerator(IEventDownloader eventDownloader, ILogger<CalendarGenerator> logger): ICalendarGenerator {

private static readonly Uri TWITCH_STREAM_URL = new("https://www.twitch.tv/gamesdonequick");

private static readonly IReadOnlySet<int> ANNOYING_PERSON_BLACKLIST = new HashSet<int> {
60 // Spike Vegeta
}.ToFrozenSet();

public async Task<Calendar> generateCalendar(bool includeAnnoyingPeople = true) {
public async Task<Calendar> generateCalendar() {
logger.LogTrace("Downloading schedule from Games Done Quick website");
Event? gdqEvent = await eventDownloader.downloadSchedule();
Calendar calendar = new() { Method = CalendarMethods.Publish };

if (gdqEvent != null) {
calendar.Events.AddRange(gdqEvent.runs
.Where(run => includeAnnoyingPeople || !containsAnnoyingPerson(run))
.Select((run, runIndex) => new CalendarEvent {
Uid = $"aldaviva.com/{gdqEvent.shortTitle}/{run.name}",
// UTC works better than trying to coerce the OffsetDateTime into a ZonedDateTime, because NodaTime will pick a zone like UTC-5 instead of America/New_York (which makes sense), but Vivaldi doesn't apply zones like UTC-5 correctly and render the times as if they were local time, leading to events starting 3 hours too early for subscribers in America/Los_Angeles. Alternatively, we could map offsets and dates to more well-known zones like America/New_York, or use the zone specified in the GdqEvent.timezone property except I don't know if Vivaldi handles US/Eastern
Expand Down Expand Up @@ -64,6 +58,4 @@ public async Task<Calendar> generateCalendar(bool includeAnnoyingPeople = true)

private static string getName(Person person) => person.name;

private static bool containsAnnoyingPerson(GameRun run) => run.runners.Concat(run.commentators).Concat(run.hosts).IntersectBy(ANNOYING_PERSON_BLACKLIST, person => person.id).Any();

}
17 changes: 12 additions & 5 deletions GamesDoneQuickCalendarFactory/Services/CalendarPoller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ namespace GamesDoneQuickCalendarFactory.Services;
public interface ICalendarPoller: IDisposable, IAsyncDisposable {

CalendarResponse? mostRecentlyPolledCalendar { get; }
TimeSpan getPollingInterval();

event EventHandler<Calendar>? calendarChanged;

Task pollCalendar();

}

public class CalendarPoller: ICalendarPoller {
Expand All @@ -33,17 +36,17 @@ public CalendarPoller(ICalendarGenerator calendarGenerator, IOptions<Configurati
this.config = config;
this.logger = logger;

pollingTimer = new Timer(pollCalendar, null, TimeSpan.Zero, OUT_OF_EVENT_POLLING_INTERVAL);
pollingTimer = new Timer(async _ => await pollCalendar(), null, OUT_OF_EVENT_POLLING_INTERVAL, OUT_OF_EVENT_POLLING_INTERVAL);
}

private async void pollCalendar(object? state = null) {
public async Task pollCalendar() {
if (await pollingLock.WaitAsync(0)) { // don't allow parallel polls, and if one is already running, skip the new iteration
try {
logger.LogDebug("Polling GDQ schedule");
Calendar calendar = await calendarGenerator.generateCalendar();
DateTimeOffset generatedDate = DateTimeOffset.UtcNow;

if (!calendar.Equals(mostRecentlyPolledCalendar?.calendar)) {
if (!calendar.EqualsPresorted(mostRecentlyPolledCalendar?.calendar)) {
mostRecentlyPolledCalendar = new CalendarResponse(calendar, generatedDate);
logger.LogInformation("GDQ schedule changed, new etag is {etag}", mostRecentlyPolledCalendar.etag);
calendarChanged?.Invoke(this, calendar);
Expand All @@ -56,9 +59,9 @@ private async void pollCalendar(object? state = null) {
generatedDate < calendar.Events.Max(run => run.Start)!.AsDateTimeOffset;

if (wasEventRunning != isEventRunning) {
TimeSpan desiredPollingInterval = isEventRunning ? config.Value.cacheDuration : OUT_OF_EVENT_POLLING_INTERVAL;
pollingTimer.Change(desiredPollingInterval, desiredPollingInterval);
wasEventRunning = isEventRunning;
TimeSpan desiredPollingInterval = getPollingInterval();
pollingTimer.Change(desiredPollingInterval, desiredPollingInterval);
}
} catch (Exception e) when (e is not OutOfMemoryException) {
logger.LogError(e, "Failed to poll GDQ schedule, trying again later");
Expand All @@ -68,6 +71,10 @@ private async void pollCalendar(object? state = null) {
}
}

public TimeSpan getPollingInterval() {
return wasEventRunning ? config.Value.cacheDuration : OUT_OF_EVENT_POLLING_INTERVAL;
}

public void Dispose() {
pollingTimer.Dispose();
GC.SuppressFinalize(this);
Expand Down
16 changes: 13 additions & 3 deletions GamesDoneQuickCalendarFactory/Services/GdqClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using GamesDoneQuickCalendarFactory.Data.GDQ;
using GamesDoneQuickCalendarFactory.Data.Marshal;
using jaytwo.FluentUri;
using NodaTime;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -55,20 +56,29 @@ public async Task<IEnumerable<GameRun>> getEventRuns(int eventId) {
IList<GameRun>? runs = null;
Uri runsUrl = EVENTS_API_URL.WithPath(eventId.ToString()).WithPath("runs");
var resultsCount = new ValueHolderStruct<int>();
OffsetDateTime? tailStart = null;
bool sorted = true;

await foreach (GdqRun run in downloadAllPages<GdqRun>(runsUrl, resultsCount)) {
runs ??= new List<GameRun>(resultsCount.value!.Value);
runs.Add(new GameRun(
GameRun gameRun = new(
start: run.startTime,
duration: run.endTime - run.startTime,
name: run.gameName,
description: $"{run.category} \u2014 {run.console}",
runners: run.runners.Select(getPerson),
commentators: run.commentators.Select(getPerson),
hosts: run.hosts.Select(getPerson)));
hosts: run.hosts.Select(getPerson));
runs.Add(gameRun);

// The API returns runs sorted in ascending start time order, but guarantee it here so the faster equality check in CalendarPoller is correct
if (sorted) {
sorted = !tailStart?.IsAfter(gameRun.start) ?? sorted;
tailStart = gameRun.start;
}
}

return runs?.AsReadOnly() ?? Enumerable.Empty<GameRun>();
return ((sorted ? runs?.AsEnumerable() : runs?.OrderBy(run => run.start)) ?? []).ToList().AsReadOnly();
}

private static Person getPerson(GdqPerson person) => new(person.id, person.name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@ public class GoogleCalendarSynchronizer: IGoogleCalendarSynchronizer {

private const int MAX_EVENTS_PER_PAGE = 2500; // week-long GDQ events usually comprise about 150 runs

private readonly CalendarService? calendarService;
private readonly ICalendarPoller calendarPoller;
private readonly IOptions<Configuration> configuration;

private readonly CalendarService? calendarService;
private readonly ICalendarPoller calendarPoller;
private readonly IOptions<Configuration> configuration;
private readonly ILogger<GoogleCalendarSynchronizer> logger;

private IDictionary<string, Event> existingGoogleEventsByIcalUid = null!;
Expand All @@ -46,22 +45,26 @@ public GoogleCalendarSynchronizer(ICalendarPoller calendarPoller, IOptions<Confi
public async Task start() {
if (calendarService != null) {
string googleCalendarId = configuration.Value.googleCalendarId!;

logger.LogDebug("Downloading existing events from Google Calendar {calendarId}", googleCalendarId);
EventsResource.ListRequest listRequest = calendarService.Events.List(googleCalendarId);
listRequest.MaxResults = MAX_EVENTS_PER_PAGE;
Events googleCalendarEvents = await listRequest.ExecuteAsync();

existingGoogleEventsByIcalUid = googleCalendarEvents.Items.ToDictionary(googleEvent => googleEvent.ICalUID);
logger.LogDebug("Found {count:N0} existing events in Google Calendar", existingGoogleEventsByIcalUid.Values.Count);

calendarPoller.calendarChanged += sync;
}

await calendarPoller.pollCalendar();
}

private async void sync(object? sender, Calendar newCalendar) {
string googleCalendarId = configuration.Value.googleCalendarId!;

IEnumerable<Event> eventsToDelete = existingGoogleEventsByIcalUid.ExceptBy(newCalendar.Events.Select(icsEvent => icsEvent.Uid), gcalEvent => gcalEvent.Key).Select(pair => pair.Value).ToList();
IEnumerable<CalendarEvent> eventsToCreate = newCalendar.Events.ExceptBy(existingGoogleEventsByIcalUid.Keys, run => run.Uid).ToList();
IEnumerable<CalendarEvent> eventsToCreate = newCalendar.Events.ExceptBy(existingGoogleEventsByIcalUid.Keys, icsEvent => icsEvent.Uid).ToList();
IEnumerable<CalendarEvent> eventsToUpdate = newCalendar.Events.Except(eventsToCreate).Where(icsEvent => {
Event googleEvent = existingGoogleEventsByIcalUid[icsEvent.Uid];
return icsEvent.Summary != googleEvent.Summary ||
Expand Down
2 changes: 1 addition & 1 deletion GamesDoneQuickCalendarFactory/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"cacheDuration": "0:03:00",
"cacheDuration": "0:01:00",
"googleServiceAccountEmailAddress": null,
"googleCalendarId": null,
"googleServiceAccountPrivateKey": null,
Expand Down

0 comments on commit d1977eb

Please sign in to comment.