fix(diagnostics): honor MaxRecentLogQuerySize for relational Take (#8147)

* fix(diagnostics): honor MaxRecentLogQuerySize for relational Take

Relational recent-log queries used a hard-coded null→100 default and
1000 clamp, so enabling Sqlite silently dropped the page size from the
InMemory contract of StructuredLogsOptions.MaxRecentLogQuerySize.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(diagnostics): disambiguate Options.Create in SQL builder tests

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(diagnostics): normalize negative MaxRecentLogQuerySize before Clamp

Math.Clamp throws when the configured ceiling is negative. Both
InMemory and Relational now share ClampRecentLogQueryTake, which treats
a negative max as zero so query construction cannot fail.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sipke Schoorstra 2026-09-14 08:07:17 +02:00 committed by GitHub
parent fec3d561d9
commit 55e3e4091d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 68 additions and 6 deletions

View file

@ -140,6 +140,8 @@ services.AddElsa(elsa =>
The write buffer uses a bounded queue. If the queue is full, newest events are dropped and the dropped-write count is reported through storage diagnostics.
Recent-log queries honor `StructuredLogsOptions.MaxRecentLogQuerySize` for both the default `Take` and the upper clamp, matching the in-memory store.
## SQLite Provider Boundary
[AddSqliteStructuredLogPersistence](../../src/modules/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite/Extensions/SqliteStructuredLogsModuleExtensions.cs) supplies provider-specific services:

View file

@ -1,4 +1,5 @@
using Elsa.Diagnostics.StructuredLogs.Contracts;
using Elsa.Diagnostics.StructuredLogs.Options;
using Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Contracts;
using Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Options;
using Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Services;
@ -17,6 +18,7 @@ public static class RelationalStructuredLogsServiceCollectionExtensions
services.Configure(configureOptions);
services.AddOptions<RelationalStructuredLogOptions>();
services.AddOptions<StructuredLogsOptions>();
services.TryAddSingleton<RelationalStructuredLogMapper>();
services.TryAddSingleton<RelationalStructuredLogSqlBuilder>();
services.TryAddSingleton<RelationalStructuredLogStore>();

View file

@ -1,9 +1,11 @@
using Elsa.Diagnostics.StructuredLogs.Models;
using Elsa.Diagnostics.StructuredLogs.Options;
using Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Contracts;
using Microsoft.Extensions.Options;
namespace Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Services;
public class RelationalStructuredLogSqlBuilder(IRelationalStructuredLogDialect dialect)
public class RelationalStructuredLogSqlBuilder(IRelationalStructuredLogDialect dialect, IOptions<StructuredLogsOptions> options)
{
private readonly string _table = dialect.QuoteIdentifier("StructuredLogEvents");
@ -20,7 +22,7 @@ public class RelationalStructuredLogSqlBuilder(IRelationalStructuredLogDialect d
var parameters = new Dictionary<string, object?>();
var predicates = BuildFilterPredicates(filter, parameters);
var where = predicates.Count == 0 ? "" : $" WHERE {string.Join(" AND ", predicates)}";
var limit = Math.Clamp(filter.Take ?? 100, 0, 1000);
var limit = options.Value.ClampRecentLogQueryTake(filter.Take);
var sql = $"SELECT {string.Join(", ", Columns.Select(dialect.QuoteIdentifier))} FROM {_table}{where} ORDER BY {dialect.QuoteIdentifier("Timestamp")} DESC, {dialect.QuoteIdentifier("ReceivedAt")} DESC, {dialect.QuoteIdentifier("Sequence")} DESC, {dialect.QuoteIdentifier("Id")} DESC";
sql = dialect.ApplyLimit(sql, limit);
return new(sql, parameters);

View file

@ -4,6 +4,10 @@ public class StructuredLogsOptions
{
public int RecentLogCapacity { get; set; } = 5_000;
public int SubscriberChannelCapacity { get; set; } = 1_000;
/// <summary>
/// Default <c>Take</c> and upper clamp for recent-log queries on every <c>IStructuredLogStore</c> implementation.
/// Negative values are treated as zero so query construction never throws.
/// </summary>
public int MaxRecentLogQuerySize { get; set; } = 1_000;
public TimeSpan SourceHeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(30);
public bool IncludeStructuredLogsInternalLogs { get; set; }
@ -27,4 +31,10 @@ public class StructuredLogsOptions
"(?i)(password|secret|token|api[-_]?key)\\s*[=:]\\s*[^\\s,;]+",
"(?i)(AccountKey|SharedAccessKey)=([^;\\s]+)"
];
public int ClampRecentLogQueryTake(int? take)
{
var maxTake = Math.Max(0, MaxRecentLogQuerySize);
return Math.Clamp(take ?? maxTake, 0, maxTake);
}
}

View file

@ -28,7 +28,7 @@ public class InMemoryStructuredLogStore : IStructuredLogStore
public ValueTask<RecentStructuredLogsResult> QueryAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default)
{
var take = Math.Clamp(filter.Take ?? _options.MaxRecentLogQuerySize, 0, _options.MaxRecentLogQuerySize);
var take = _options.ClampRecentLogQueryTake(filter.Take);
var items = _recentLogs
.Snapshot()
.Where(x => StructuredLogFilterEvaluator.Matches(x, filter))

View file

@ -1,12 +1,14 @@
using Elsa.Diagnostics.StructuredLogs.Models;
using Elsa.Diagnostics.StructuredLogs.Options;
using Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Contracts;
using Elsa.Diagnostics.StructuredLogs.Persistence.Relational.Services;
using MicrosoftOptions = Microsoft.Extensions.Options.Options;
namespace Elsa.Diagnostics.StructuredLogs.Persistence.Relational.UnitTests;
public class RelationalStructuredLogSqlBuilderTests
{
private readonly RelationalStructuredLogSqlBuilder _builder = new(new FakeDialect());
private readonly RelationalStructuredLogSqlBuilder _builder = CreateBuilder();
[Fact]
public void BuildInsert_UsesDialectQuotingAndParameters()
@ -61,18 +63,42 @@ public class RelationalStructuredLogSqlBuilderTests
}
[Theory]
[InlineData(null, "FETCH 100")]
[InlineData(null, "FETCH 1000")]
[InlineData(-5, "FETCH 0")]
[InlineData(-1, "FETCH 0")]
[InlineData(2000, "FETCH 1000")]
[InlineData(5000, "FETCH 1000")]
public void BuildQuery_ClampsTakeToSupportedRange(int? take, string expectedLimit)
public void BuildQuery_ClampsTakeToMaxRecentLogQuerySize(int? take, string expectedLimit)
{
var query = _builder.BuildQuery(new() { Take = take });
Assert.Contains(expectedLimit, query.Sql, StringComparison.Ordinal);
}
[Theory]
[InlineData(null, "FETCH 50")]
[InlineData(25, "FETCH 25")]
[InlineData(200, "FETCH 50")]
public void BuildQuery_UsesConfiguredMaxRecentLogQuerySize(int? take, string expectedLimit)
{
var builder = CreateBuilder(maxRecentLogQuerySize: 50);
var query = builder.BuildQuery(new() { Take = take });
Assert.Contains(expectedLimit, query.Sql, StringComparison.Ordinal);
}
[Theory]
[InlineData(null)]
[InlineData(-5)]
[InlineData(25)]
public void BuildQuery_WhenMaxRecentLogQuerySizeIsNegative_UsesZeroLimit(int? take)
{
var builder = CreateBuilder(maxRecentLogQuerySize: -10);
var query = builder.BuildQuery(new() { Take = take });
Assert.Contains("FETCH 0", query.Sql, StringComparison.Ordinal);
}
[Fact]
public void BuildListSources_GroupsBySourceAndOrdersBySource()
{
@ -118,6 +144,15 @@ public class RelationalStructuredLogSqlBuilderTests
Assert.DoesNotContain("LIMIT -1", query.Sql, StringComparison.Ordinal);
}
private static RelationalStructuredLogSqlBuilder CreateBuilder(int? maxRecentLogQuerySize = null)
{
var options = new StructuredLogsOptions();
if (maxRecentLogQuerySize is { } maxTake)
options.MaxRecentLogQuerySize = maxTake;
return new(new FakeDialect(), MicrosoftOptions.Create(options));
}
private class FakeDialect : IRelationalStructuredLogDialect
{
public string ProviderName => "Fake";

View file

@ -43,6 +43,17 @@ public class InMemoryStructuredLogProviderTests
Assert.Equal([2, 4], result.Items.Select(x => x.Sequence));
}
[Fact]
public async Task GetRecentAsync_WhenMaxRecentLogQuerySizeIsNegative_ReturnsEmptyWithoutThrowing()
{
_options.MaxRecentLogQuerySize = -10;
await _provider.PublishAsync(CreateLog(1, StructuredLogLevel.Information));
var result = await _provider.GetRecentAsync(new() { Take = 25 });
Assert.Empty(result.Items);
}
[Fact]
public async Task SubscribeAsync_YieldsOnlyMatchingLiveEvents()
{