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