From a9ebb0e0e4448b9757bbe2576c878af2c8d5c103 Mon Sep 17 00:00:00 2001 From: Miguel Tremblay Date: Tue, 27 Jan 2026 22:31:21 -0500 Subject: [PATCH] Add benchmarking project for performance testing - Create RecurringThings.Benchmarks project using BenchmarkDotNet - Add QueryBenchmarks, CreateBenchmarks, UpdateBenchmarks, DeleteBenchmarks - Use varied RRule patterns (DAILY, WEEKLY, MONTHLY, YEARLY) for realistic tests - Include progress output during setup/cleanup phases - Run migrations before benchmarks to exclude migration time from results - Add ScottPlotExporter for PNG chart generation - Add benchmarking section to README.md - Fix PostgreSQL package author metadata Co-Authored-By: Claude Opus 4.5 --- README.md | 18 ++ .../Benchmarks/CreateBenchmarks.cs | 128 ++++++++++++ .../Benchmarks/DeleteBenchmarks.cs | 135 +++++++++++++ .../Benchmarks/QueryBenchmarks.cs | 122 +++++++++++ .../Benchmarks/UpdateBenchmarks.cs | 131 ++++++++++++ .../Infrastructure/BenchmarkConfig.cs | 47 +++++ .../Infrastructure/DataSeeder.cs | 189 ++++++++++++++++++ .../Infrastructure/ProviderFactory.cs | 141 +++++++++++++ .../RecurringThings.Benchmarks/Program.cs | 93 +++++++++ .../RecurringThings.Benchmarks.csproj | 23 +++ .../RecurringThings.PostgreSQL.csproj | 2 +- 11 files changed, 1028 insertions(+), 1 deletion(-) create mode 100644 benchmarks/RecurringThings.Benchmarks/Benchmarks/CreateBenchmarks.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/Benchmarks/DeleteBenchmarks.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/Benchmarks/QueryBenchmarks.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/Benchmarks/UpdateBenchmarks.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/Infrastructure/BenchmarkConfig.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/Infrastructure/DataSeeder.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/Infrastructure/ProviderFactory.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/Program.cs create mode 100644 benchmarks/RecurringThings.Benchmarks/RecurringThings.Benchmarks.csproj diff --git a/README.md b/README.md index 328f12d..a31a6ce 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,24 @@ await foreach (var entry in engine.GetRecurrencesAsync("tenant1", "user123/calen | MongoDB | [RecurringThings.MongoDB](src/RecurringThings.MongoDB/README.md) | | PostgreSQL | [RecurringThings.PostgreSQL](src/RecurringThings.PostgreSQL/README.md) | +## Benchmarking + +Run benchmarks locally against MongoDB and/or PostgreSQL: + +```bash +# Set connection strings (PowerShell) +$env:MONGODB_CONNECTION_STRING = "mongodb://localhost:27017" +$env:POSTGRES_CONNECTION_STRING = "Host=localhost;Database=postgres;Username=postgres;Password=password" + +# Run all benchmarks +dotnet run -c Release --project benchmarks/RecurringThings.Benchmarks + +# Run specific benchmark class +dotnet run -c Release --project benchmarks/RecurringThings.Benchmarks -- --filter *QueryBenchmarks* +``` + +Results are generated in `./BenchmarkResults/` including HTML reports and PNG charts. + ## License Apache 2.0 - see [LICENSE](LICENSE) diff --git a/benchmarks/RecurringThings.Benchmarks/Benchmarks/CreateBenchmarks.cs b/benchmarks/RecurringThings.Benchmarks/Benchmarks/CreateBenchmarks.cs new file mode 100644 index 0000000..28c2fee --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Benchmarks/CreateBenchmarks.cs @@ -0,0 +1,128 @@ +namespace RecurringThings.Benchmarks.Benchmarks; + +using BenchmarkDotNet.Attributes; +using RecurringThings.Benchmarks.Infrastructure; +using RecurringThings.Engine; +using RecurringThings.Models; + +/// +/// Benchmarks for create operations. +/// +[Config(typeof(BenchmarkConfig))] +public class CreateBenchmarks +{ + private IRecurrenceEngine _engine = null!; + private List _createdEntries = null!; + private int _createIndex; + + /// + /// Gets or sets the database provider to benchmark. + /// + [Params(BenchmarkProvider.MongoDB, BenchmarkProvider.PostgreSQL)] + public BenchmarkProvider Provider { get; set; } + + private const string TimeZone = "America/New_York"; + + /// + /// Setup that runs once before all benchmark iterations for each parameter combination. + /// + [GlobalSetup] + public void GlobalSetup() + { + Console.WriteLine(); + Console.WriteLine($"[CreateBenchmarks] GlobalSetup: Provider={Provider}"); + + _engine = ProviderFactory.CreateEngine(Provider); + _createdEntries = new List(); + _createIndex = 0; + + Console.WriteLine($"[CreateBenchmarks] Setup complete. Ready to benchmark."); + } + + /// + /// Benchmarks creating a recurrence pattern. + /// + [Benchmark(Description = "Create recurrence pattern")] + public async Task CreateRecurrenceAsync() + { + var index = Interlocked.Increment(ref _createIndex); + var startTime = DateTime.UtcNow.Date.AddHours(9); + var rrule = $"FREQ=DAILY;UNTIL={DateTime.UtcNow.AddMonths(6):yyyyMMdd}T235959Z"; + + var entry = await _engine.CreateRecurrenceAsync( + DataSeeder.Organization, + DataSeeder.ResourcePath, + $"benchmark-type-{index}", + startTime, + TimeSpan.FromHours(1), + rrule, + TimeZone); + + lock (_createdEntries) + { + _createdEntries.Add(entry); + } + + return entry; + } + + /// + /// Benchmarks creating a standalone occurrence. + /// + [Benchmark(Description = "Create standalone occurrence")] + public async Task CreateOccurrenceAsync() + { + var index = Interlocked.Increment(ref _createIndex); + var startTime = DateTime.UtcNow.Date.AddDays(index).AddHours(10); + + var entry = await _engine.CreateOccurrenceAsync( + DataSeeder.Organization, + DataSeeder.ResourcePath, + $"appointment-{index}", + startTime, + TimeSpan.FromMinutes(45), + TimeZone); + + lock (_createdEntries) + { + _createdEntries.Add(entry); + } + + return entry; + } + + /// + /// Cleanup that runs once after all benchmark iterations for each parameter combination. + /// + [GlobalCleanup] + public async Task GlobalCleanup() + { + Console.WriteLine(); + Console.WriteLine($"[CreateBenchmarks] GlobalCleanup: Deleting {_createdEntries.Count} entries..."); + + var deleted = 0; + foreach (var entry in _createdEntries) + { + try + { + if (entry.RecurrenceId.HasValue && entry.EntryType == CalendarEntryType.Recurrence) + { + await _engine.DeleteRecurrenceAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, entry.RecurrenceId.Value); + deleted++; + } + else if (entry.OccurrenceId.HasValue) + { + await _engine.DeleteOccurrenceAsync(entry); + deleted++; + } + } + catch + { + // Ignore cleanup errors + } + } + + Console.WriteLine($"[CreateBenchmarks] Cleanup complete: {deleted} entries deleted."); + } +} diff --git a/benchmarks/RecurringThings.Benchmarks/Benchmarks/DeleteBenchmarks.cs b/benchmarks/RecurringThings.Benchmarks/Benchmarks/DeleteBenchmarks.cs new file mode 100644 index 0000000..bc365a1 --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Benchmarks/DeleteBenchmarks.cs @@ -0,0 +1,135 @@ +namespace RecurringThings.Benchmarks.Benchmarks; + +using BenchmarkDotNet.Attributes; +using RecurringThings.Benchmarks.Infrastructure; +using RecurringThings.Engine; +using RecurringThings.Models; + +/// +/// Benchmarks for delete operations. +/// +[Config(typeof(BenchmarkConfig))] +public class DeleteBenchmarks +{ + private IRecurrenceEngine _engine = null!; + private Queue _occurrencesToDelete = null!; + private Queue _recurrencesToDelete = null!; + + /// + /// Gets or sets the database provider to benchmark. + /// + [Params(BenchmarkProvider.MongoDB, BenchmarkProvider.PostgreSQL)] + public BenchmarkProvider Provider { get; set; } + + private const string TimeZone = "America/New_York"; + + /// + /// Setup that runs once before all benchmark iterations for each parameter combination. + /// + [GlobalSetup] + public void GlobalSetup() + { + Console.WriteLine(); + Console.WriteLine($"[DeleteBenchmarks] GlobalSetup: Provider={Provider}"); + _engine = ProviderFactory.CreateEngine(Provider); + Console.WriteLine($"[DeleteBenchmarks] Setup complete."); + } + + /// + /// Setup that runs before each benchmark iteration. + /// Pre-creates entries to be deleted during the benchmark. + /// + [IterationSetup] + public void IterationSetup() + { + _occurrencesToDelete = new Queue(); + _recurrencesToDelete = new Queue(); + + // Pre-create entries to delete during benchmark iteration + for (int i = 0; i < 10; i++) + { + var occurrence = _engine.CreateOccurrenceAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, "delete-test", + DateTime.UtcNow.AddDays(i), TimeSpan.FromHours(1), TimeZone) + .GetAwaiter().GetResult(); + _occurrencesToDelete.Enqueue(occurrence); + + var rrule = $"FREQ=DAILY;UNTIL={DateTime.UtcNow.AddMonths(1):yyyyMMdd}T235959Z"; + var recurrence = _engine.CreateRecurrenceAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, "delete-test", + DateTime.UtcNow.AddDays(i), TimeSpan.FromHours(1), rrule, TimeZone) + .GetAwaiter().GetResult(); + _recurrencesToDelete.Enqueue(recurrence.RecurrenceId!.Value); + } + } + + /// + /// Benchmarks deleting a standalone occurrence. + /// + [Benchmark(Description = "Delete standalone occurrence")] + public async Task DeleteOccurrenceAsync() + { + if (_occurrencesToDelete.TryDequeue(out var entry)) + { + await _engine.DeleteOccurrenceAsync(entry); + } + } + + /// + /// Benchmarks deleting a recurrence with cascade delete. + /// + [Benchmark(Description = "Delete recurrence (cascade)")] + public async Task DeleteRecurrenceAsync() + { + if (_recurrencesToDelete.TryDequeue(out var recurrenceId)) + { + await _engine.DeleteRecurrenceAsync(DataSeeder.Organization, DataSeeder.ResourcePath, recurrenceId); + } + } + + /// + /// Cleanup that runs after each benchmark iteration. + /// Removes any entries that weren't deleted during the benchmark. + /// + [IterationCleanup] + public void IterationCleanup() + { + // Clean up any entries that weren't deleted during the benchmark + while (_occurrencesToDelete.TryDequeue(out var entry)) + { + try + { + _engine.DeleteOccurrenceAsync(entry).GetAwaiter().GetResult(); + } + catch + { + // Ignore cleanup errors + } + } + + while (_recurrencesToDelete.TryDequeue(out var id)) + { + try + { + _engine.DeleteRecurrenceAsync(DataSeeder.Organization, DataSeeder.ResourcePath, id) + .GetAwaiter().GetResult(); + } + catch + { + // Ignore cleanup errors + } + } + } + + /// + /// Cleanup that runs once after all benchmark iterations for each parameter combination. + /// + [GlobalCleanup] + public async Task GlobalCleanup() + { + Console.WriteLine(); + Console.WriteLine($"[DeleteBenchmarks] GlobalCleanup: Ensuring all test data is removed..."); + await DataSeeder.CleanupAllAsync(_engine); + Console.WriteLine($"[DeleteBenchmarks] Cleanup complete."); + } +} diff --git a/benchmarks/RecurringThings.Benchmarks/Benchmarks/QueryBenchmarks.cs b/benchmarks/RecurringThings.Benchmarks/Benchmarks/QueryBenchmarks.cs new file mode 100644 index 0000000..52cc625 --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Benchmarks/QueryBenchmarks.cs @@ -0,0 +1,122 @@ +namespace RecurringThings.Benchmarks.Benchmarks; + +using BenchmarkDotNet.Attributes; +using RecurringThings.Benchmarks.Infrastructure; +using RecurringThings.Engine; +using RecurringThings.Models; + +/// +/// Benchmarks for query operations. +/// +[Config(typeof(BenchmarkConfig))] +public class QueryBenchmarks +{ + private IRecurrenceEngine _engine = null!; + private List _seededRecurrences = null!; + + /// + /// Gets or sets the database provider to benchmark. + /// + [Params(BenchmarkProvider.MongoDB, BenchmarkProvider.PostgreSQL)] + public BenchmarkProvider Provider { get; set; } + + /// + /// Gets or sets the number of recurrences to seed. + /// + [Params(100, 1000, 10000)] + public int RecurrenceCount { get; set; } + + private DateTime _queryStart; + private DateTime _queryEnd; + + /// + /// Setup that runs once before all benchmark iterations for each parameter combination. + /// + [GlobalSetup] + public async Task GlobalSetup() + { + Console.WriteLine(); + Console.WriteLine($"[QueryBenchmarks] GlobalSetup: Provider={Provider}, RecurrenceCount={RecurrenceCount}"); + + _engine = ProviderFactory.CreateEngine(Provider); + _seededRecurrences = await DataSeeder.SeedRecurrencesAsync(_engine, RecurrenceCount); + + // Query range spans 3 months of virtualized occurrences + _queryStart = DateTime.UtcNow.Date; + _queryEnd = _queryStart.AddMonths(3); + + Console.WriteLine($"[QueryBenchmarks] Setup complete. Ready to benchmark."); + } + + /// + /// Benchmarks querying virtualized occurrences over a 3-month period. + /// + [Benchmark(Description = "Query virtualized occurrences (3 months)")] + public async Task GetOccurrencesAsync() + { + var count = 0; + await foreach (var entry in _engine.GetOccurrencesAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, _queryStart, _queryEnd)) + { + count++; + } + + return count; + } + + /// + /// Benchmarks querying recurrence patterns. + /// + [Benchmark(Description = "Query recurrence patterns")] + public async Task GetRecurrencesAsync() + { + var count = 0; + await foreach (var entry in _engine.GetRecurrencesAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, _queryStart, _queryEnd)) + { + count++; + } + + return count; + } + + /// + /// Benchmarks querying with type filter. + /// + [Benchmark(Description = "Query with type filter")] + public async Task GetOccurrencesWithTypeFilterAsync() + { + var count = 0; + await foreach (var entry in _engine.GetOccurrencesAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, _queryStart, _queryEnd, + types: ["meeting-type-0", "meeting-type-1"])) + { + count++; + } + + return count; + } + + /// + /// Cleanup that runs once after all benchmark iterations for each parameter combination. + /// + [GlobalCleanup] + public async Task GlobalCleanup() + { + Console.WriteLine(); + Console.WriteLine($"[QueryBenchmarks] GlobalCleanup: Deleting {_seededRecurrences.Count} recurrences..."); + + var deleted = 0; + foreach (var entry in _seededRecurrences) + { + if (entry.RecurrenceId.HasValue) + { + await _engine.DeleteRecurrenceAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, entry.RecurrenceId.Value); + deleted++; + } + } + + Console.WriteLine($"[QueryBenchmarks] Cleanup complete: {deleted} recurrences deleted."); + } +} diff --git a/benchmarks/RecurringThings.Benchmarks/Benchmarks/UpdateBenchmarks.cs b/benchmarks/RecurringThings.Benchmarks/Benchmarks/UpdateBenchmarks.cs new file mode 100644 index 0000000..7d464af --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Benchmarks/UpdateBenchmarks.cs @@ -0,0 +1,131 @@ +namespace RecurringThings.Benchmarks.Benchmarks; + +using BenchmarkDotNet.Attributes; +using RecurringThings.Benchmarks.Infrastructure; +using RecurringThings.Engine; +using RecurringThings.Models; + +/// +/// Benchmarks for update operations. +/// +[Config(typeof(BenchmarkConfig))] +public class UpdateBenchmarks +{ + private IRecurrenceEngine _engine = null!; + private List _standaloneOccurrences = null!; + private List _virtualizedOccurrences = null!; + private CalendarEntry? _recurrenceEntry; + private int _updateIndex; + + /// + /// Gets or sets the database provider to benchmark. + /// + [Params(BenchmarkProvider.MongoDB, BenchmarkProvider.PostgreSQL)] + public BenchmarkProvider Provider { get; set; } + + /// + /// Gets or sets the number of occurrences to seed. + /// + [Params(100)] + public int OccurrenceCount { get; set; } + + private DateTime _queryStart; + private DateTime _queryEnd; + + /// + /// Setup that runs once before all benchmark iterations for each parameter combination. + /// + [GlobalSetup] + public async Task GlobalSetup() + { + Console.WriteLine(); + Console.WriteLine($"[UpdateBenchmarks] GlobalSetup: Provider={Provider}, OccurrenceCount={OccurrenceCount}"); + + _engine = ProviderFactory.CreateEngine(Provider); + _updateIndex = 0; + + // Seed standalone occurrences + _standaloneOccurrences = await DataSeeder.SeedOccurrencesAsync(_engine, OccurrenceCount); + + // Seed one recurrence and get virtualized occurrences + var recurrences = await DataSeeder.SeedRecurrencesAsync(_engine, 1); + _recurrenceEntry = recurrences.First(); + + _queryStart = DateTime.UtcNow.Date; + _queryEnd = _queryStart.AddMonths(1); + + Console.WriteLine(" Fetching virtualized occurrences for update tests..."); + _virtualizedOccurrences = new List(); + await foreach (var entry in _engine.GetOccurrencesAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, _queryStart, _queryEnd)) + { + if (entry.EntryType == CalendarEntryType.Virtualized) + { + _virtualizedOccurrences.Add(entry); + if (_virtualizedOccurrences.Count >= 20) + { + break; + } + } + } + + Console.WriteLine($"[UpdateBenchmarks] Setup complete. {_virtualizedOccurrences.Count} virtualized occurrences ready."); + } + + /// + /// Benchmarks updating a standalone occurrence. + /// + [Benchmark(Description = "Update standalone occurrence")] + public async Task UpdateStandaloneOccurrenceAsync() + { + var index = Interlocked.Increment(ref _updateIndex) % _standaloneOccurrences.Count; + var entry = _standaloneOccurrences[index]; + entry.Duration = TimeSpan.FromMinutes(60 + (index % 30)); + return await _engine.UpdateOccurrenceAsync(entry); + } + + /// + /// Benchmarks updating a virtualized occurrence (creates an override). + /// + [Benchmark(Description = "Update virtualized occurrence (creates override)")] + public async Task UpdateVirtualizedOccurrenceAsync() + { + var index = Interlocked.Increment(ref _updateIndex) % _virtualizedOccurrences.Count; + var entry = _virtualizedOccurrences[index]; + entry.Duration = TimeSpan.FromMinutes(90); + return await _engine.UpdateOccurrenceAsync(entry); + } + + /// + /// Cleanup that runs once after all benchmark iterations for each parameter combination. + /// + [GlobalCleanup] + public async Task GlobalCleanup() + { + Console.WriteLine(); + Console.WriteLine($"[UpdateBenchmarks] GlobalCleanup: Deleting test data..."); + + var deleted = 0; + foreach (var entry in _standaloneOccurrences) + { + try + { + await _engine.DeleteOccurrenceAsync(entry); + deleted++; + } + catch + { + // Ignore cleanup errors + } + } + + if (_recurrenceEntry?.RecurrenceId.HasValue == true) + { + await _engine.DeleteRecurrenceAsync( + DataSeeder.Organization, DataSeeder.ResourcePath, _recurrenceEntry.RecurrenceId.Value); + deleted++; + } + + Console.WriteLine($"[UpdateBenchmarks] Cleanup complete: {deleted} entries deleted."); + } +} diff --git a/benchmarks/RecurringThings.Benchmarks/Infrastructure/BenchmarkConfig.cs b/benchmarks/RecurringThings.Benchmarks/Infrastructure/BenchmarkConfig.cs new file mode 100644 index 0000000..21406de --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Infrastructure/BenchmarkConfig.cs @@ -0,0 +1,47 @@ +namespace RecurringThings.Benchmarks.Infrastructure; + +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Exporters.Csv; +using BenchmarkDotNet.Exporters.Plotting; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; + +/// +/// Custom BenchmarkDotNet configuration for RecurringThings benchmarks. +/// +public class BenchmarkConfig : ManualConfig +{ + /// + /// Initializes a new instance of the class. + /// + public BenchmarkConfig() + { + // Job configuration - fewer iterations for database benchmarks + AddJob(Job.Default + .WithWarmupCount(2) + .WithIterationCount(5) + .WithInvocationCount(1) + .WithUnrollFactor(1)); + + // Diagnosers + AddDiagnoser(MemoryDiagnoser.Default); + + // Columns + AddColumnProvider(DefaultColumnProviders.Instance); + + // Exporters for graphical output + AddExporter(HtmlExporter.Default); + AddExporter(CsvExporter.Default); + AddExporter(MarkdownExporter.GitHub); + AddExporter(new ScottPlotExporter(1920, 1080)); + + // Logger + AddLogger(ConsoleLogger.Default); + + // Output folder + WithArtifactsPath("./BenchmarkResults"); + } +} diff --git a/benchmarks/RecurringThings.Benchmarks/Infrastructure/DataSeeder.cs b/benchmarks/RecurringThings.Benchmarks/Infrastructure/DataSeeder.cs new file mode 100644 index 0000000..5df3c5a --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Infrastructure/DataSeeder.cs @@ -0,0 +1,189 @@ +namespace RecurringThings.Benchmarks.Infrastructure; + +using RecurringThings.Engine; +using RecurringThings.Models; + +/// +/// Seeds test data for benchmarks with varied recurrence patterns. +/// +public static class DataSeeder +{ + /// + /// Organization used for all benchmark data. + /// + public const string Organization = "benchmark-org"; + + /// + /// Resource path used for all benchmark data. + /// + public const string ResourcePath = "benchmark/calendar"; + + private const string TimeZone = "America/New_York"; + + // Varied recurrence patterns with different computational complexities + private static readonly string[] RRulePatterns = + [ + // Heavy: Multiple days per week (generates many occurrences) + "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "FREQ=WEEKLY;BYDAY=MO,WE,FR", + "FREQ=DAILY", + // Medium: Weekly patterns + "FREQ=WEEKLY;BYDAY=MO", + "FREQ=WEEKLY;BYDAY=TU,TH", + "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", + // Light: Monthly and yearly patterns (fewer occurrences) + "FREQ=MONTHLY;BYMONTHDAY=1", + "FREQ=MONTHLY;BYMONTHDAY=15", + "FREQ=MONTHLY;BYDAY=1MO", + "FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1" + ]; + + /// + /// Seeds recurrences with varied patterns. + /// + public static async Task> SeedRecurrencesAsync( + IRecurrenceEngine engine, + int count, + CancellationToken cancellationToken = default) + { + var entries = new List(count); + var baseDate = DateTime.UtcNow.Date; + var untilDate = baseDate.AddYears(1); + + Console.WriteLine($" Seeding {count} recurrences with varied patterns..."); + var lastProgress = 0; + + for (int i = 0; i < count; i++) + { + // Report progress every 10% + var progress = (i + 1) * 100 / count; + if (progress >= lastProgress + 10) + { + Console.WriteLine($" Progress: {progress}% ({i + 1}/{count})"); + lastProgress = progress; + } + + var startTime = baseDate.AddDays(i % 365).AddHours(9); + + // Select varied pattern based on index + var patternIndex = i % RRulePatterns.Length; + var baseRRule = RRulePatterns[patternIndex]; + var rrule = $"{baseRRule};UNTIL={untilDate:yyyyMMdd}T235959Z"; + + var entry = await engine.CreateRecurrenceAsync( + Organization, + ResourcePath, + $"meeting-type-{i % 10}", + startTime, + TimeSpan.FromHours(1), + rrule, + TimeZone, + new Dictionary + { + ["index"] = i.ToString(), + ["pattern"] = patternIndex.ToString() + }, + cancellationToken: cancellationToken); + + entries.Add(entry); + } + + Console.WriteLine($" Seeding complete: {count} recurrences created"); + return entries; + } + + /// + /// Seeds standalone occurrences. + /// + public static async Task> SeedOccurrencesAsync( + IRecurrenceEngine engine, + int count, + CancellationToken cancellationToken = default) + { + var entries = new List(count); + var baseDate = DateTime.UtcNow.Date; + + Console.WriteLine($" Seeding {count} standalone occurrences..."); + var lastProgress = 0; + + for (int i = 0; i < count; i++) + { + var progress = (i + 1) * 100 / count; + if (progress >= lastProgress + 10) + { + Console.WriteLine($" Progress: {progress}% ({i + 1}/{count})"); + lastProgress = progress; + } + + var startTime = baseDate.AddDays(i % 365).AddHours(10 + (i % 8)); + + var entry = await engine.CreateOccurrenceAsync( + Organization, + ResourcePath, + $"appointment-type-{i % 5}", + startTime, + TimeSpan.FromMinutes(30 + (i % 60)), + TimeZone, + new Dictionary { ["index"] = i.ToString() }, + cancellationToken: cancellationToken); + + entries.Add(entry); + } + + Console.WriteLine($" Seeding complete: {count} occurrences created"); + return entries; + } + + /// + /// Cleans up all benchmark data from the database. + /// + public static async Task CleanupAllAsync(IRecurrenceEngine engine, CancellationToken cancellationToken = default) + { + Console.WriteLine(" Cleaning up benchmark data..."); + + // Query all recurrences and delete them (cascades to exceptions/overrides) + var start = DateTime.UtcNow.AddYears(-5); + var end = DateTime.UtcNow.AddYears(5); + + var recurrenceIds = new List(); + await foreach (var entry in engine.GetRecurrencesAsync( + Organization, ResourcePath, start, end, cancellationToken: cancellationToken)) + { + if (entry.RecurrenceId.HasValue) + { + recurrenceIds.Add(entry.RecurrenceId.Value); + } + } + + if (recurrenceIds.Count > 0) + { + Console.WriteLine($" Deleting {recurrenceIds.Count} recurrences..."); + foreach (var id in recurrenceIds) + { + await engine.DeleteRecurrenceAsync(Organization, ResourcePath, id, cancellationToken: cancellationToken); + } + } + + // Query and delete standalone occurrences + var occurrences = new List(); + await foreach (var entry in engine.GetOccurrencesAsync( + Organization, ResourcePath, start, end, cancellationToken: cancellationToken)) + { + if (entry.EntryType == CalendarEntryType.Standalone) + { + occurrences.Add(entry); + } + } + + if (occurrences.Count > 0) + { + Console.WriteLine($" Deleting {occurrences.Count} standalone occurrences..."); + foreach (var entry in occurrences) + { + await engine.DeleteOccurrenceAsync(entry, cancellationToken: cancellationToken); + } + } + + Console.WriteLine(" Cleanup complete"); + } +} diff --git a/benchmarks/RecurringThings.Benchmarks/Infrastructure/ProviderFactory.cs b/benchmarks/RecurringThings.Benchmarks/Infrastructure/ProviderFactory.cs new file mode 100644 index 0000000..87533a4 --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Infrastructure/ProviderFactory.cs @@ -0,0 +1,141 @@ +namespace RecurringThings.Benchmarks.Infrastructure; + +using Microsoft.Extensions.DependencyInjection; +using RecurringThings.Configuration; +using RecurringThings.Engine; +using RecurringThings.MongoDB.Configuration; +using RecurringThings.PostgreSQL.Configuration; + +/// +/// Database provider types supported by benchmarks. +/// +public enum BenchmarkProvider +{ + /// MongoDB provider. + MongoDB, + + /// PostgreSQL provider. + PostgreSQL +} + +/// +/// Factory for creating IRecurrenceEngine instances for each provider. +/// +public static class ProviderFactory +{ + // Static database names to ensure same DB is used across benchmark runs + private static readonly string MongoDatabaseName = $"rt_bench_{DateTime.UtcNow:yyyyMMdd}"; + + private static bool _mongoInitialized; + private static bool _postgresInitialized; + + /// + /// Initializes the provider by running migrations/creating indexes. + /// Called once at startup before any benchmarks run. + /// + public static async Task InitializeProviderAsync(BenchmarkProvider provider) + { + Console.WriteLine($"Initializing {provider} provider (migrations/indexes)..."); + + switch (provider) + { + case BenchmarkProvider.MongoDB: + if (!_mongoInitialized) + { + // MongoDB index creation happens automatically on first use via IndexManager + // Force it now by creating a temporary engine and making a query + var engine = CreateEngine(provider); + // Trigger index creation by making a simple query + await foreach (var _ in engine.GetRecurrencesAsync( + "init", "init", DateTime.UtcNow, DateTime.UtcNow.AddDays(1))) + { + break; + } + + _mongoInitialized = true; + Console.WriteLine($" MongoDB database: {MongoDatabaseName}"); + } + + break; + + case BenchmarkProvider.PostgreSQL: + if (!_postgresInitialized) + { + // PostgreSQL migrations run via EF Core on first DbContext use + var engine = CreateEngine(provider); + // Trigger migrations by making a simple query + await foreach (var _ in engine.GetRecurrencesAsync( + "init", "init", DateTime.UtcNow, DateTime.UtcNow.AddDays(1))) + { + break; + } + + _postgresInitialized = true; + Console.WriteLine(" PostgreSQL database ready"); + } + + break; + } + + Console.WriteLine($"{provider} initialization complete"); + } + + /// + /// Creates a new IRecurrenceEngine instance for the specified provider. + /// + public static IRecurrenceEngine CreateEngine(BenchmarkProvider provider) + { + var services = new ServiceCollection(); + + services.AddRecurringThings(builder => + { + switch (provider) + { + case BenchmarkProvider.MongoDB: + builder.UseMongoDb(options => + { + options.ConnectionString = GetMongoConnectionString(); + options.DatabaseName = MongoDatabaseName; + options.CollectionName = "recurring_things"; + }); + break; + + case BenchmarkProvider.PostgreSQL: + builder.UsePostgreSql(options => + { + options.ConnectionString = GetPostgresConnectionString(); + }); + break; + } + }); + + var serviceProvider = services.BuildServiceProvider(); + return serviceProvider.GetRequiredService(); + } + + /// + /// Gets the MongoDB connection string from environment variable. + /// + public static string GetMongoConnectionString() => + Environment.GetEnvironmentVariable("MONGODB_CONNECTION_STRING") + ?? throw new InvalidOperationException("MONGODB_CONNECTION_STRING environment variable not set"); + + /// + /// Gets the PostgreSQL connection string from environment variable. + /// + public static string GetPostgresConnectionString() => + Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING") + ?? throw new InvalidOperationException("POSTGRES_CONNECTION_STRING environment variable not set"); + + /// + /// Checks if MongoDB is available (environment variable set). + /// + public static bool IsMongoAvailable() => + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MONGODB_CONNECTION_STRING")); + + /// + /// Checks if PostgreSQL is available (environment variable set). + /// + public static bool IsPostgresAvailable() => + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")); +} diff --git a/benchmarks/RecurringThings.Benchmarks/Program.cs b/benchmarks/RecurringThings.Benchmarks/Program.cs new file mode 100644 index 0000000..ffa98c2 --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/Program.cs @@ -0,0 +1,93 @@ +using BenchmarkDotNet.Running; +using RecurringThings.Benchmarks.Benchmarks; +using RecurringThings.Benchmarks.Infrastructure; + +Console.WriteLine("=== RecurringThings Benchmarks ==="); +Console.WriteLine(); + +// Check environment variables +var mongoAvailable = ProviderFactory.IsMongoAvailable(); +var postgresAvailable = ProviderFactory.IsPostgresAvailable(); + +Console.WriteLine("Provider availability:"); +Console.WriteLine($" MongoDB: {(mongoAvailable ? "Available" : "NOT AVAILABLE (set MONGODB_CONNECTION_STRING)")}"); +Console.WriteLine($" PostgreSQL: {(postgresAvailable ? "Available" : "NOT AVAILABLE (set POSTGRES_CONNECTION_STRING)")}"); +Console.WriteLine(); + +if (!mongoAvailable && !postgresAvailable) +{ + Console.WriteLine("ERROR: No database providers available. Set at least one connection string."); + return 1; +} + +// Initialize providers BEFORE benchmarks (migrations/indexes don't count in benchmark time) +Console.WriteLine("=== Initializing Providers (migrations/indexes) ==="); +if (mongoAvailable) +{ + await ProviderFactory.InitializeProviderAsync(BenchmarkProvider.MongoDB); +} + +if (postgresAvailable) +{ + await ProviderFactory.InitializeProviderAsync(BenchmarkProvider.PostgreSQL); +} + +Console.WriteLine(); + +// Clean up any leftover data from previous runs +Console.WriteLine("=== Cleaning up previous benchmark data ==="); +if (mongoAvailable) +{ + Console.WriteLine("Cleaning MongoDB..."); + var mongoEngine = ProviderFactory.CreateEngine(BenchmarkProvider.MongoDB); + await DataSeeder.CleanupAllAsync(mongoEngine); +} + +if (postgresAvailable) +{ + Console.WriteLine("Cleaning PostgreSQL..."); + var pgEngine = ProviderFactory.CreateEngine(BenchmarkProvider.PostgreSQL); + await DataSeeder.CleanupAllAsync(pgEngine); +} + +Console.WriteLine(); + +Console.WriteLine("=== Starting Benchmarks ==="); +Console.WriteLine("BenchmarkDotNet will now run. Progress will be shown for each benchmark."); +Console.WriteLine(); + +// Run benchmarks +if (args.Length == 0) +{ + BenchmarkRunner.Run(new BenchmarkConfig()); + BenchmarkRunner.Run(new BenchmarkConfig()); + BenchmarkRunner.Run(new BenchmarkConfig()); + BenchmarkRunner.Run(new BenchmarkConfig()); +} +else +{ + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, new BenchmarkConfig()); +} + +// Final cleanup +Console.WriteLine(); +Console.WriteLine("=== Final Cleanup ==="); +if (mongoAvailable) +{ + Console.WriteLine("Cleaning MongoDB..."); + var mongoEngine = ProviderFactory.CreateEngine(BenchmarkProvider.MongoDB); + await DataSeeder.CleanupAllAsync(mongoEngine); +} + +if (postgresAvailable) +{ + Console.WriteLine("Cleaning PostgreSQL..."); + var pgEngine = ProviderFactory.CreateEngine(BenchmarkProvider.PostgreSQL); + await DataSeeder.CleanupAllAsync(pgEngine); +} + +Console.WriteLine(); +Console.WriteLine("=== Benchmarks Complete ==="); +Console.WriteLine("Results available in ./BenchmarkResults/"); + +return 0; diff --git a/benchmarks/RecurringThings.Benchmarks/RecurringThings.Benchmarks.csproj b/benchmarks/RecurringThings.Benchmarks/RecurringThings.Benchmarks.csproj new file mode 100644 index 0000000..d4d5cd3 --- /dev/null +++ b/benchmarks/RecurringThings.Benchmarks/RecurringThings.Benchmarks.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + + + + + + diff --git a/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj b/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj index d50367e..07e393f 100644 --- a/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj +++ b/src/RecurringThings.PostgreSQL/RecurringThings.PostgreSQL.csproj @@ -14,7 +14,7 @@ RecurringThings.PostgreSQL - RecurringThings Contributors + Miguel Tremblay PostgreSQL persistence provider for RecurringThings library. https://github.com/ChuckNovice/RecurringThings Apache-2.0