From 57713fac9474277d63ee22cbb2ea1758bb46e3b0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 9 Mar 2026 13:00:26 -0500 Subject: [PATCH] Fix full table scans in GetNextRemindersAsync and GetRemindersOverviewAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetNextRemindersAsync had two performance bugs introduced during the provider package split (0ce1026): 1. Called GetRemindersOverviewAsync and discarded the result, loading all rows and deserializing all payloads for nothing. 2. Opened a second connection to load ALL rows (including completed) into memory, just to compute COUNT and MIN(WhenUtc) of remaining reminders. GetRemindersOverviewAsync itself had the same issue — loading all rows to compute two scalar values. Replace GetOverviewSql (SELECT * FROM table) with: - GetOverviewAggregateSql: SELECT COUNT(*), MIN(when_utc) - GetNextReminderTimeSql: SELECT when_utc ... OFFSET @Skip LIMIT 1 This eliminates all full table scans and payload deserialization from the overview/summary code paths across all three SQL providers (SqlServer, PostgreSQL, SQLite). Also adds XML doc comments to IReminderStorage.GetRemindersOverviewAsync clarifying it is for diagnostics/testing/monitoring only and must not be called from the scheduling hot path. --- .../Internal/ISqlDialect.cs | 3 +- .../Internal/PostgreSqlDialect.cs | 20 ++++- .../PostgreSqlReminderStorage.cs | 80 +++++++++-------- .../Internal/ISqlDialect.cs | 3 +- .../Internal/SqlServerDialect.cs | 20 ++++- .../SqlServerReminderStorage.cs | 80 +++++++++-------- .../Internal/ISqlDialect.cs | 3 +- .../Internal/SqliteDialect.cs | 20 ++++- .../SqliteReminderStorage.cs | 88 ++++++++++--------- .../Storage/IReminderStorage.cs | 11 ++- 10 files changed, 194 insertions(+), 134 deletions(-) diff --git a/src/Akka.Reminders.PostgreSql/Internal/ISqlDialect.cs b/src/Akka.Reminders.PostgreSql/Internal/ISqlDialect.cs index 52947be..79c330a 100644 --- a/src/Akka.Reminders.PostgreSql/Internal/ISqlDialect.cs +++ b/src/Akka.Reminders.PostgreSql/Internal/ISqlDialect.cs @@ -10,7 +10,8 @@ internal interface ISqlDialect string GetMarkCompletedSql(string schemaName, string tableName); string GetBatchMarkCompletedSql(string schemaName, string tableName, int count); string GetCleanupSql(string schemaName, string tableName); - string GetOverviewSql(string schemaName, string tableName); + string GetOverviewAggregateSql(string schemaName, string tableName); + string GetNextReminderTimeSql(string schemaName, string tableName); string GetCancelReminderSql(string schemaName, string tableName); string GetCancelAllRemindersSql(string schemaName, string tableName); string GetFetchRemindersSql(string schemaName, string tableName); diff --git a/src/Akka.Reminders.PostgreSql/Internal/PostgreSqlDialect.cs b/src/Akka.Reminders.PostgreSql/Internal/PostgreSqlDialect.cs index 520cb7f..c6cb8e6 100644 --- a/src/Akka.Reminders.PostgreSql/Internal/PostgreSqlDialect.cs +++ b/src/Akka.Reminders.PostgreSql/Internal/PostgreSqlDialect.cs @@ -140,15 +140,27 @@ DELETE FROM {fullTableName} """; } - public string GetOverviewSql(string schemaName, string tableName) + public string GetOverviewAggregateSql(string schemaName, string tableName) { var fullTableName = $"\"{schemaName}\".\"{tableName}\""; return $""" - SELECT shard_region_name, entity_id, reminder_key, when_utc, repeat_interval_ticks, - serializer_id, manifest, payload, attempt_count, last_failure_reason, is_completed + SELECT COUNT(*) AS total_count, MIN(when_utc) AS next_when_utc FROM {fullTableName} - ORDER BY when_utc ASC; + WHERE is_completed = FALSE; + """; + } + + public string GetNextReminderTimeSql(string schemaName, string tableName) + { + var fullTableName = $"\"{schemaName}\".\"{tableName}\""; + + return $""" + SELECT when_utc + FROM {fullTableName} + WHERE is_completed = FALSE + ORDER BY when_utc ASC + LIMIT 1 OFFSET @Skip; """; } diff --git a/src/Akka.Reminders.PostgreSql/PostgreSqlReminderStorage.cs b/src/Akka.Reminders.PostgreSql/PostgreSqlReminderStorage.cs index 5e66a1f..79a97b1 100644 --- a/src/Akka.Reminders.PostgreSql/PostgreSqlReminderStorage.cs +++ b/src/Akka.Reminders.PostgreSql/PostgreSqlReminderStorage.cs @@ -108,40 +108,43 @@ public async Task GetNextRemindersAsync( reminders.Add(reminder); } - var overview = await GetRemindersOverviewAsync(now, cancellationToken); - - var fetchedKeys = new HashSet<(string, string, string)>( - reminders.Select(r => (r.Entity.ShardRegionName, r.Entity.EntityId, r.Key.Name))); - + // Get overview of remaining reminders using aggregate queries await using var conn2 = _dialect.CreateConnection(_settings.ConnectionString); await conn2.OpenAsync(cancellationToken); - await using var cmd2 = conn2.CreateCommand(); - cmd2.CommandText = _dialect.GetOverviewSql(_settings.SchemaName, _settings.TableName); - cmd2.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; - await using var reader2 = await cmd2.ExecuteReaderAsync(cancellationToken); - var remainingReminders = new List(); - while (await reader2.ReadAsync(cancellationToken)) + long totalPending = 0; + await using (var cmd2 = conn2.CreateCommand()) { - var isCompleted = reader2.GetBoolean(reader2.GetOrdinal("is_completed")); - if (!isCompleted) + cmd2.CommandText = _dialect.GetOverviewAggregateSql(_settings.SchemaName, _settings.TableName); + cmd2.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; + await using var reader2 = await cmd2.ExecuteReaderAsync(cancellationToken); + if (await reader2.ReadAsync(cancellationToken)) { - var reminder = ReadReminderFromReader(reader2); - var key = (reminder.Entity.ShardRegionName, reminder.Entity.EntityId, reminder.Key.Name); - if (!fetchedKeys.Contains(key)) - { - remainingReminders.Add(reminder); - } + totalPending = reader2.GetInt64(reader2.GetOrdinal("total_count")); } } - var nextReminder = remainingReminders.OrderBy(r => r.When).FirstOrDefault(); - var timeUntilNext = nextReminder != null ? nextReminder.When - now : TimeSpan.MaxValue; + var remainingCount = totalPending - reminders.Count; + var timeUntilNext = TimeSpan.MaxValue; + + if (remainingCount > 0) + { + await using var cmd3 = conn2.CreateCommand(); + cmd3.CommandText = _dialect.GetNextReminderTimeSql(_settings.SchemaName, _settings.TableName); + cmd3.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; + _dialect.AddParameter(cmd3, "@Skip", reminders.Count); + + var result = await cmd3.ExecuteScalarAsync(cancellationToken); + if (result is DateTime nextWhenUtc) + { + timeUntilNext = new DateTimeOffset(DateTime.SpecifyKind(nextWhenUtc, DateTimeKind.Utc)) - now; + } + } var adjustedOverview = new ReminderOverview { TimeUntilNext = timeUntilNext, - TotalPendingReminders = remainingReminders.Count + TotalPendingReminders = remainingCount }; return new PendingRemindersWithSummary(reminders, adjustedOverview); @@ -201,42 +204,41 @@ public async Task MarkRemindersAsCompletedAsync( } } + /// public async Task GetRemindersOverviewAsync( DateTimeOffset now, CancellationToken cancellationToken = default) { await EnsureInitializedAsync(cancellationToken); - var allReminders = new List(); - await using var connection = _dialect.CreateConnection(_settings.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); - command.CommandText = _dialect.GetOverviewSql(_settings.SchemaName, _settings.TableName); + command.CommandText = _dialect.GetOverviewAggregateSql(_settings.SchemaName, _settings.TableName); command.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; await using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) + if (await reader.ReadAsync(cancellationToken)) { - var isCompleted = reader.GetBoolean(reader.GetOrdinal("is_completed")); + var totalCount = reader.GetInt64(reader.GetOrdinal("total_count")); + var nextWhenUtcOrdinal = reader.GetOrdinal("next_when_utc"); + + if (totalCount == 0 || reader.IsDBNull(nextWhenUtcOrdinal)) + return ReminderOverview.Empty; - if (!isCompleted) + var nextWhenUtc = reader.GetDateTime(nextWhenUtcOrdinal); + var timeUntilNext = new DateTimeOffset(DateTime.SpecifyKind(nextWhenUtc, DateTimeKind.Utc)) - now; + + return new ReminderOverview { - var reminder = ReadReminderFromReader(reader); - allReminders.Add(reminder); - } + TotalPendingReminders = totalCount, + TimeUntilNext = timeUntilNext + }; } - var nextReminder = allReminders.OrderBy(r => r.When).FirstOrDefault(); - var timeUntilNext = nextReminder != null ? nextReminder.When - now : TimeSpan.MaxValue; - - return new ReminderOverview - { - TimeUntilNext = timeUntilNext, - TotalPendingReminders = allReminders.Count - }; + return ReminderOverview.Empty; } public async Task CleanUpCompletedRemindersAsync( diff --git a/src/Akka.Reminders.SqlServer/Internal/ISqlDialect.cs b/src/Akka.Reminders.SqlServer/Internal/ISqlDialect.cs index 5c29f69..b3a035a 100644 --- a/src/Akka.Reminders.SqlServer/Internal/ISqlDialect.cs +++ b/src/Akka.Reminders.SqlServer/Internal/ISqlDialect.cs @@ -10,7 +10,8 @@ internal interface ISqlDialect string GetMarkCompletedSql(string schemaName, string tableName); string GetBatchMarkCompletedSql(string schemaName, string tableName, int count); string GetCleanupSql(string schemaName, string tableName); - string GetOverviewSql(string schemaName, string tableName); + string GetOverviewAggregateSql(string schemaName, string tableName); + string GetNextReminderTimeSql(string schemaName, string tableName); string GetCancelReminderSql(string schemaName, string tableName); string GetCancelAllRemindersSql(string schemaName, string tableName); string GetFetchRemindersSql(string schemaName, string tableName); diff --git a/src/Akka.Reminders.SqlServer/Internal/SqlServerDialect.cs b/src/Akka.Reminders.SqlServer/Internal/SqlServerDialect.cs index afc66e7..8be8713 100644 --- a/src/Akka.Reminders.SqlServer/Internal/SqlServerDialect.cs +++ b/src/Akka.Reminders.SqlServer/Internal/SqlServerDialect.cs @@ -162,15 +162,27 @@ DELETE FROM {fullTableName} """; } - public string GetOverviewSql(string schemaName, string tableName) + public string GetOverviewAggregateSql(string schemaName, string tableName) { var fullTableName = $"[{schemaName}].[{tableName}]"; return $""" - SELECT ShardRegionName, EntityId, ReminderKey, WhenUtc, RepeatIntervalTicks, - SerializerId, Manifest, Payload, AttemptCount, LastFailureReason, IsCompleted + SELECT COUNT(*) AS TotalCount, MIN(WhenUtc) AS NextWhenUtc FROM {fullTableName} - ORDER BY WhenUtc ASC; + WHERE IsCompleted = 0; + """; + } + + public string GetNextReminderTimeSql(string schemaName, string tableName) + { + var fullTableName = $"[{schemaName}].[{tableName}]"; + + return $""" + SELECT WhenUtc + FROM {fullTableName} + WHERE IsCompleted = 0 + ORDER BY WhenUtc ASC + OFFSET @Skip ROWS FETCH NEXT 1 ROW ONLY; """; } diff --git a/src/Akka.Reminders.SqlServer/SqlServerReminderStorage.cs b/src/Akka.Reminders.SqlServer/SqlServerReminderStorage.cs index 4f06297..feb9541 100644 --- a/src/Akka.Reminders.SqlServer/SqlServerReminderStorage.cs +++ b/src/Akka.Reminders.SqlServer/SqlServerReminderStorage.cs @@ -99,40 +99,43 @@ public async Task GetNextRemindersAsync( reminders.Add(reminder); } - var overview = await GetRemindersOverviewAsync(now, cancellationToken); - - var fetchedKeys = new HashSet<(string, string, string)>( - reminders.Select(r => (r.Entity.ShardRegionName, r.Entity.EntityId, r.Key.Name))); - + // Get overview of remaining reminders using aggregate queries await using var conn2 = _dialect.CreateConnection(_settings.ConnectionString); await conn2.OpenAsync(cancellationToken); - await using var cmd2 = conn2.CreateCommand(); - cmd2.CommandText = _dialect.GetOverviewSql(_settings.SchemaName, _settings.TableName); - cmd2.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; - await using var reader2 = await cmd2.ExecuteReaderAsync(cancellationToken); - var remainingReminders = new List(); - while (await reader2.ReadAsync(cancellationToken)) + long totalPending = 0; + await using (var cmd2 = conn2.CreateCommand()) { - var isCompleted = reader2.GetBoolean(reader2.GetOrdinal("IsCompleted")); - if (!isCompleted) + cmd2.CommandText = _dialect.GetOverviewAggregateSql(_settings.SchemaName, _settings.TableName); + cmd2.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; + await using var reader2 = await cmd2.ExecuteReaderAsync(cancellationToken); + if (await reader2.ReadAsync(cancellationToken)) { - var reminder = ReadReminderFromReader(reader2); - var key = (reminder.Entity.ShardRegionName, reminder.Entity.EntityId, reminder.Key.Name); - if (!fetchedKeys.Contains(key)) - { - remainingReminders.Add(reminder); - } + totalPending = reader2.GetInt32(reader2.GetOrdinal("TotalCount")); } } - var nextReminder = remainingReminders.OrderBy(r => r.When).FirstOrDefault(); - var timeUntilNext = nextReminder != null ? nextReminder.When - now : TimeSpan.MaxValue; + var remainingCount = totalPending - reminders.Count; + var timeUntilNext = TimeSpan.MaxValue; + + if (remainingCount > 0) + { + await using var cmd3 = conn2.CreateCommand(); + cmd3.CommandText = _dialect.GetNextReminderTimeSql(_settings.SchemaName, _settings.TableName); + cmd3.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; + _dialect.AddParameter(cmd3, "@Skip", reminders.Count); + + var result = await cmd3.ExecuteScalarAsync(cancellationToken); + if (result is DateTime nextWhenUtc) + { + timeUntilNext = new DateTimeOffset(DateTime.SpecifyKind(nextWhenUtc, DateTimeKind.Utc)) - now; + } + } var adjustedOverview = new ReminderOverview { TimeUntilNext = timeUntilNext, - TotalPendingReminders = remainingReminders.Count + TotalPendingReminders = remainingCount }; return new PendingRemindersWithSummary(reminders, adjustedOverview); @@ -192,42 +195,41 @@ public async Task MarkRemindersAsCompletedAsync( } } + /// public async Task GetRemindersOverviewAsync( DateTimeOffset now, CancellationToken cancellationToken = default) { await EnsureInitializedAsync(cancellationToken); - var allReminders = new List(); - await using var connection = _dialect.CreateConnection(_settings.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); - command.CommandText = _dialect.GetOverviewSql(_settings.SchemaName, _settings.TableName); + command.CommandText = _dialect.GetOverviewAggregateSql(_settings.SchemaName, _settings.TableName); command.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; await using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) + if (await reader.ReadAsync(cancellationToken)) { - var isCompleted = reader.GetBoolean(reader.GetOrdinal("IsCompleted")); + var totalCount = reader.GetInt32(reader.GetOrdinal("TotalCount")); + var nextWhenUtcOrdinal = reader.GetOrdinal("NextWhenUtc"); + + if (totalCount == 0 || reader.IsDBNull(nextWhenUtcOrdinal)) + return ReminderOverview.Empty; - if (!isCompleted) + var nextWhenUtc = reader.GetDateTime(nextWhenUtcOrdinal); + var timeUntilNext = new DateTimeOffset(DateTime.SpecifyKind(nextWhenUtc, DateTimeKind.Utc)) - now; + + return new ReminderOverview { - var reminder = ReadReminderFromReader(reader); - allReminders.Add(reminder); - } + TotalPendingReminders = totalCount, + TimeUntilNext = timeUntilNext + }; } - var nextReminder = allReminders.OrderBy(r => r.When).FirstOrDefault(); - var timeUntilNext = nextReminder != null ? nextReminder.When - now : TimeSpan.MaxValue; - - return new ReminderOverview - { - TimeUntilNext = timeUntilNext, - TotalPendingReminders = allReminders.Count - }; + return ReminderOverview.Empty; } public async Task CleanUpCompletedRemindersAsync( diff --git a/src/Akka.Reminders.Sqlite/Internal/ISqlDialect.cs b/src/Akka.Reminders.Sqlite/Internal/ISqlDialect.cs index 6a92f10..945e481 100644 --- a/src/Akka.Reminders.Sqlite/Internal/ISqlDialect.cs +++ b/src/Akka.Reminders.Sqlite/Internal/ISqlDialect.cs @@ -10,7 +10,8 @@ internal interface ISqlDialect string GetMarkCompletedSql(string tableName); string GetBatchMarkCompletedSql(string tableName, int count); string GetCleanupSql(string tableName); - string GetOverviewSql(string tableName); + string GetOverviewAggregateSql(string tableName); + string GetNextReminderTimeSql(string tableName); string GetCancelReminderSql(string tableName); string GetCancelAllRemindersSql(string tableName); string GetFetchRemindersSql(string tableName); diff --git a/src/Akka.Reminders.Sqlite/Internal/SqliteDialect.cs b/src/Akka.Reminders.Sqlite/Internal/SqliteDialect.cs index 46dc706..87608f5 100644 --- a/src/Akka.Reminders.Sqlite/Internal/SqliteDialect.cs +++ b/src/Akka.Reminders.Sqlite/Internal/SqliteDialect.cs @@ -132,15 +132,27 @@ DELETE FROM {fullTableName} """; } - public string GetOverviewSql(string tableName) + public string GetOverviewAggregateSql(string tableName) { var fullTableName = $"\"{tableName}\""; return $""" - SELECT shard_region_name, entity_id, reminder_key, when_utc, repeat_interval_ticks, - serializer_id, manifest, payload, attempt_count, last_failure_reason, is_completed + SELECT COUNT(*) AS total_count, MIN(when_utc) AS next_when_utc FROM {fullTableName} - ORDER BY when_utc ASC; + WHERE is_completed = 0; + """; + } + + public string GetNextReminderTimeSql(string tableName) + { + var fullTableName = $"\"{tableName}\""; + + return $""" + SELECT when_utc + FROM {fullTableName} + WHERE is_completed = 0 + ORDER BY when_utc ASC + LIMIT 1 OFFSET @Skip; """; } diff --git a/src/Akka.Reminders.Sqlite/SqliteReminderStorage.cs b/src/Akka.Reminders.Sqlite/SqliteReminderStorage.cs index c4d13ef..b14a255 100644 --- a/src/Akka.Reminders.Sqlite/SqliteReminderStorage.cs +++ b/src/Akka.Reminders.Sqlite/SqliteReminderStorage.cs @@ -97,40 +97,45 @@ public async Task GetNextRemindersAsync( reminders.Add(reminder); } - var overview = await GetRemindersOverviewAsync(now, cancellationToken); - - var fetchedKeys = new HashSet<(string, string, string)>( - reminders.Select(r => (r.Entity.ShardRegionName, r.Entity.EntityId, r.Key.Name))); - + // Get overview of remaining reminders using aggregate queries await using var conn2 = _dialect.CreateConnection(_settings.ConnectionString); await conn2.OpenAsync(cancellationToken); - await using var cmd2 = conn2.CreateCommand(); - cmd2.CommandText = _dialect.GetOverviewSql(_settings.TableName); - cmd2.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; - await using var reader2 = await cmd2.ExecuteReaderAsync(cancellationToken); - var remainingReminders = new List(); - while (await reader2.ReadAsync(cancellationToken)) + long totalPending = 0; + await using (var cmd2 = conn2.CreateCommand()) { - var isCompleted = ReadBoolean(reader2, "is_completed"); - if (!isCompleted) + cmd2.CommandText = _dialect.GetOverviewAggregateSql(_settings.TableName); + cmd2.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; + await using var reader2 = await cmd2.ExecuteReaderAsync(cancellationToken); + if (await reader2.ReadAsync(cancellationToken)) { - var reminder = ReadReminderFromReader(reader2); - var key = (reminder.Entity.ShardRegionName, reminder.Entity.EntityId, reminder.Key.Name); - if (!fetchedKeys.Contains(key)) - { - remainingReminders.Add(reminder); - } + totalPending = Convert.ToInt64(reader2.GetValue(reader2.GetOrdinal("total_count")), + CultureInfo.InvariantCulture); } } - var nextReminder = remainingReminders.OrderBy(r => r.When).FirstOrDefault(); - var timeUntilNext = nextReminder != null ? nextReminder.When - now : TimeSpan.MaxValue; + var remainingCount = totalPending - reminders.Count; + var timeUntilNext = TimeSpan.MaxValue; + + if (remainingCount > 0) + { + await using var cmd3 = conn2.CreateCommand(); + cmd3.CommandText = _dialect.GetNextReminderTimeSql(_settings.TableName); + cmd3.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; + _dialect.AddParameter(cmd3, "@Skip", reminders.Count); + + var result = await cmd3.ExecuteScalarAsync(cancellationToken); + if (result != null && result != DBNull.Value) + { + var nextWhenUtc = ParseDateTimeOffset(result); + timeUntilNext = nextWhenUtc - now; + } + } var adjustedOverview = new ReminderOverview { TimeUntilNext = timeUntilNext, - TotalPendingReminders = remainingReminders.Count + TotalPendingReminders = remainingCount }; return new PendingRemindersWithSummary(reminders, adjustedOverview); @@ -197,36 +202,35 @@ public async Task GetRemindersOverviewAsync( { await EnsureInitializedAsync(cancellationToken); - var allReminders = new List(); - await using var connection = _dialect.CreateConnection(_settings.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); - command.CommandText = _dialect.GetOverviewSql(_settings.TableName); + command.CommandText = _dialect.GetOverviewAggregateSql(_settings.TableName); command.CommandTimeout = (int)_settings.CommandTimeout.TotalSeconds; await using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) + if (await reader.ReadAsync(cancellationToken)) { - var isCompleted = ReadBoolean(reader, "is_completed"); + var totalCount = Convert.ToInt64(reader.GetValue(reader.GetOrdinal("total_count")), + CultureInfo.InvariantCulture); + var nextWhenUtcOrdinal = reader.GetOrdinal("next_when_utc"); + + if (totalCount == 0 || reader.IsDBNull(nextWhenUtcOrdinal)) + return ReminderOverview.Empty; + + var nextWhenUtc = ParseDateTimeOffset(reader.GetValue(nextWhenUtcOrdinal)); + var timeUntilNext = nextWhenUtc - now; - if (!isCompleted) + return new ReminderOverview { - var reminder = ReadReminderFromReader(reader); - allReminders.Add(reminder); - } + TotalPendingReminders = totalCount, + TimeUntilNext = timeUntilNext + }; } - var nextReminder = allReminders.OrderBy(r => r.When).FirstOrDefault(); - var timeUntilNext = nextReminder != null ? nextReminder.When - now : TimeSpan.MaxValue; - - return new ReminderOverview - { - TimeUntilNext = timeUntilNext, - TotalPendingReminders = allReminders.Count - }; + return ReminderOverview.Empty; } public async Task CleanUpCompletedRemindersAsync( @@ -455,13 +459,17 @@ private static DateTimeOffset ReadDateTimeOffset(IDataReader reader, string colu { var ordinal = reader.GetOrdinal(columnName); var value = reader.GetValue(ordinal); + return ParseDateTimeOffset(value); + } + private static DateTimeOffset ParseDateTimeOffset(object value) + { return value switch { DateTimeOffset dto => dto, DateTime dt => new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc)), string s => DateTimeOffset.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), - _ => throw new InvalidOperationException($"Unexpected datetime value type '{value.GetType()}' for column '{columnName}'.") + _ => throw new InvalidOperationException($"Unexpected datetime value type '{value.GetType()}'.") }; } diff --git a/src/Akka.Reminders/Storage/IReminderStorage.cs b/src/Akka.Reminders/Storage/IReminderStorage.cs index 2fc577a..bbca75e 100644 --- a/src/Akka.Reminders/Storage/IReminderStorage.cs +++ b/src/Akka.Reminders/Storage/IReminderStorage.cs @@ -98,8 +98,17 @@ public interface IReminderStorage Task CancelAllRemindersForEntityAsync(ReminderEntity entity, CancellationToken ct = default); /// - /// Fetches a summary of pending reminders. + /// Fetches a summary of pending reminders for diagnostics, testing, and ad-hoc queries. /// + /// + /// This method is intended for external use cases: diagnostic tooling, integration tests, + /// health check endpoints, and monitoring dashboards. + /// + /// It MUST NOT be called from the scheduling hot path (e.g., inside ). + /// The scheduling actor computes its own overview from efficient aggregate queries as part of + /// — calling this method from there would introduce + /// redundant database round-trips. + /// /// Current time from scheduler /// Cancellation token Task GetRemindersOverviewAsync(DateTimeOffset now, CancellationToken ct = default);