Skip to content

Commit 3e78ebb

Browse files
committed
Fix InfluxDB readback duration and superseded benchmark runs
Read the full InfluxDB corpus through one ordered native JSONL query instead of thousands of repeated sorted pages. Keep bounded row buffering, the frozen record ceiling, cancellation, full-body/digest verification and joined cleanup. Use the vendor-recommended 70% query pool within the existing physical memory budget so deduplication can execute. Add a real million-record InfluxDB control with update, mid-stream cancellation and healthy continuation. Preserve ClickHouse paging through the common reader contract and handle the last partial native write batch. Newer Benchmarks runs cancel the previous run of their branch, including across full/contracts modes. Validation: complete Debug solution build with warnings as errors; full formatter verification; repository governance; actionlint; real InfluxDB million-record and ClickHouse million-record controls with physical container/volume cleanup. Full session run40502233284 was cancelled at the owner request and is not complete qualification.
1 parent 3173da3 commit 3e78ebb

13 files changed

Lines changed: 231 additions & 32 deletions

File tree

‎.github/workflows/benchmarks.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ on:
1111
default: full
1212
permissions: {contents: read, actions: read}
1313
concurrency:
14-
group: keyload-benchmarks-${{ github.ref }}${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'contracts' && '-contracts' || '' }}
15-
cancel-in-progress: false
14+
group: keyload-benchmarks-${{ github.ref }}
15+
cancel-in-progress: true
1616
env:
1717
NUGET_PACKAGES: ${{ github.workspace }}/artifacts/benchmark-runtime/packages
1818
jobs:
@@ -110,7 +110,7 @@ jobs:
110110
compression-level: 0
111111
retention-days: 90
112112
- name: Test external database bulk operations and joined cleanup
113-
run: dotnet benchmarks/KeyLoad.ComparisonHost/bin/Release/net10.0/KeyLoad.ComparisonHost.dll --output Detailed --maximum-parallel-tests 1 --minimum-expected-tests 7 --treenode-filter '/*/*/NativeDatabaseTests/*' --results-directory "$GITHUB_WORKSPACE/benchmarks/KeyLoad.ComparisonHost/bin/Release/net10.0/TestResults/NativeAdapters" --report-trx --timeout 40m
113+
run: dotnet benchmarks/KeyLoad.ComparisonHost/bin/Release/net10.0/KeyLoad.ComparisonHost.dll --output Detailed --maximum-parallel-tests 1 --minimum-expected-tests 8 --treenode-filter '/*/*/NativeDatabaseTests/*' --results-directory "$GITHUB_WORKSPACE/benchmarks/KeyLoad.ComparisonHost/bin/Release/net10.0/TestResults/NativeAdapters" --report-trx --timeout 40m
114114
- name: Test bounded terminal execution diagnostics
115115
run: dotnet benchmarks/KeyLoad.ComparisonHost/bin/Release/net10.0/KeyLoad.ComparisonHost.dll --output Detailed --minimum-expected-tests 1 --treenode-filter '/*/*/ComparisonHostCaptureTests/*' --results-directory "$GITHUB_WORKSPACE/benchmarks/KeyLoad.ComparisonHost/bin/Release/net10.0/TestResults/TerminalDiagnostics" --report-trx
116116
- name: Save original preparation diagnostics
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using KeyLoad.Comparisons;
2+
3+
namespace KeyLoad.ComparisonHost.BenchmarkComparisons;
4+
5+
internal static class InfluxDocumentReadback
6+
{
7+
private const int Empty = 0, Clients = 16, Records = 1_000_000, Single = 1, Updated = 1;
8+
9+
internal static async Task VerifyAsync(IComparisonTarget target, CancellationToken token)
10+
{
11+
var documents = (IDocumentComparisonTarget)target;
12+
var corpus = new DocumentComparisonCorpus(Empty, Clients);
13+
await documents.InitializeDocumentsAsync(new(corpus, false, Records + Single), token);
14+
await NativeDatabaseTests.CreateBatchesAsync(documents, corpus, Records, token);
15+
await using var session = await documents.OpenDocumentSessionAsync(token);
16+
await session.ExecuteAsync(Scenario.DocumentUpdate, corpus.Replacement(Updated), token);
17+
await using var independent = await documents.OpenDocumentSessionAsync(token);
18+
var expected = Enumerable.Range(Empty, Records)
19+
.Select(index => index == Updated ? corpus.Replacement(index) : corpus.CreateDocument(index));
20+
var observed = await DocumentComparisonOracle.VerifyAsync(independent.ReadDocumentsAsync(token), expected, token);
21+
await Assert.That(observed.Records).IsEqualTo(Records);
22+
using var cancelled = CancellationTokenSource.CreateLinkedTokenSource(token);
23+
await using (var cursor = independent.ReadDocumentsAsync(cancelled.Token).GetAsyncEnumerator(cancelled.Token))
24+
{
25+
await Assert.That(await cursor.MoveNextAsync()).IsTrue();
26+
await cancelled.CancelAsync();
27+
await Assert.ThrowsAsync<OperationCanceledException>(() => cursor.MoveNextAsync().AsTask());
28+
}
29+
await session.CreateDocumentsAsync([corpus.CreateDocument(Records)], token);
30+
await Assert.That(BenchmarkDataset.SameDocument((await independent.ReadAsync(corpus.CreateDocument(Records), token)).Value,
31+
corpus.CreateDocument(Records))).IsTrue();
32+
}
33+
}

‎benchmarks/KeyLoad.ComparisonHost/DatabaseAdapters/Cases/NativeDatabaseTests.cs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ public Task NativeBulkPreservesBodiesAndIndependentClients(string target, int no
1919
public Task MongoReplicaSetReadinessPrecedesIndependentBulkClients()
2020
=> AdditionalDatabaseResources.RunAsync(MongoDb, VerifyAsync, TestContext.Current!.Execution.CancellationToken, ReplicaNodes);
2121

22+
[Test]
23+
public Task InfluxBulkReadbackPreservesCompleteBodiesAndHealthyContinuation()
24+
=> AdditionalDatabaseResources.RunAsync(BenchmarkAdditionalTargets.InfluxDb, InfluxDocumentReadback.VerifyAsync,
25+
TestContext.Current!.Execution.CancellationToken);
26+
2227
private static async Task VerifyAsync(IComparisonTarget target, CancellationToken token)
2328
{
2429
var documents = (IDocumentComparisonTarget)target;
@@ -60,7 +65,7 @@ await Assert.That(BenchmarkDataset.SameDocument((await independent.ReadAsync(cor
6065
corpus.CreateDocument(count))).IsTrue();
6166
}
6267

63-
private static async Task CreateBatchesAsync(IDocumentComparisonTarget target, DocumentComparisonCorpus corpus,
68+
internal static async Task CreateBatchesAsync(IDocumentComparisonTarget target, DocumentComparisonCorpus corpus,
6469
int count, CancellationToken token)
6570
{
6671
var sessions = new List<IDocumentComparisonSession>();
@@ -79,7 +84,7 @@ private static async Task CreateBatchesAsync(IDocumentComparisonTarget target, D
7984
for (var first = worker * batch; first < count; first += Clients * batch)
8085
{
8186
using var deadline = ComparisonDeadline.Create(BenchmarkSessionLimits.TimeoutSeconds, TimeProvider.System, token);
82-
await session.CreateDocumentsAsync([.. Enumerable.Range(first, batch).Select(corpus.CreateDocument)], deadline.Token);
87+
await session.CreateDocumentsAsync([.. Enumerable.Range(first, Math.Min(batch, count - first)).Select(corpus.CreateDocument)], deadline.Token);
8388
}
8489
}).ToArray();
8590
start.SetResult();

‎benchmarks/KeyLoad.Comparisons/Targets/ClickHouse/Execution/ClickHouseDocumentStore.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ internal override Task<JsonDocument> ReadAsync(string id, int? number, Cancellat
9999
return QueryAsync(Select + Table + Predicate + (number.HasValue ? NumericPredicate : string.Empty) + Limit + JsonFormat, parameters, token);
100100
}
101101

102-
internal override Task<JsonDocument> PageAsync(string after, CancellationToken token)
102+
internal override IAsyncEnumerable<FoundDocument> ReadDocumentsAsync(CancellationToken token) => ReadPagesAsync(PageAsync, token);
103+
104+
private Task<JsonDocument> PageAsync(string after, CancellationToken token)
103105
=> QueryAsync(Select + Table + Page + Policy.ReadbackBatchCapacity.ToString(CultureInfo.InvariantCulture) + JsonFormat,
104106
new Dictionary<string, string>(StringComparer.Ordinal) { [After] = after }, token);
105107

‎benchmarks/KeyLoad.Comparisons/Targets/HttpDocuments/Contracts/HttpDocumentStore.cs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Buffers;
22
using System.Net.Http.Headers;
3+
using System.Runtime.CompilerServices;
34
using System.Text;
45
using System.Text.Json;
56
using Microsoft.Extensions.Options;
@@ -25,7 +26,7 @@ internal abstract class HttpDocumentStore(HttpClient http, string name, IOptions
2526
internal abstract int BatchRecords { get; }
2627
internal abstract Task CreateAsync(IReadOnlyList<BenchmarkDocument> documents, CancellationToken token);
2728
internal abstract Task<JsonDocument> ReadAsync(string id, int? number, CancellationToken token);
28-
internal abstract Task<JsonDocument> PageAsync(string after, CancellationToken token);
29+
internal abstract IAsyncEnumerable<FoundDocument> ReadDocumentsAsync(CancellationToken token);
2930
internal abstract Task IndexAsync(CancellationToken token);
3031

3132
internal static FoundDocument[] Documents(JsonDocument document)
@@ -37,6 +38,26 @@ internal static FoundDocument[] Documents(JsonDocument document)
3738
row.GetProperty(Id).GetString()!, row.GetProperty(Body).GetString()!)).ToArray();
3839
}
3940

41+
protected static async IAsyncEnumerable<FoundDocument> ReadPagesAsync(Func<string, CancellationToken, Task<JsonDocument>> page,
42+
[EnumeratorCancellation] CancellationToken token)
43+
{
44+
var after = string.Empty;
45+
while (true)
46+
{
47+
using var result = await page(after, token).ConfigureAwait(false);
48+
var rows = Documents(result);
49+
if (rows.Length == 0)
50+
{
51+
yield break;
52+
}
53+
foreach (var row in rows)
54+
{
55+
after = row.Id;
56+
yield return row;
57+
}
58+
}
59+
}
60+
4061
// Encodes each newline-terminated row straight to UTF-8, so a batch never holds a UTF-16 copy of its whole body.
4162
protected static ReadOnlyMemoryContent Rows(string head, IReadOnlyList<BenchmarkDocument> documents, Func<BenchmarkDocument, string> row)
4263
{

‎benchmarks/KeyLoad.Comparisons/Targets/HttpDocuments/Execution/HttpDocumentSession.cs‎

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,15 @@ public async IAsyncEnumerable<FoundDocument> ReadDocumentsAsync([EnumeratorCance
5050
{
5151
const string InvalidOrder = "HttpDocumentDatabaseReadbackOrderRejected";
5252
var after = string.Empty;
53-
while (true)
53+
await foreach (var row in store.ReadDocumentsAsync(cancellationToken).ConfigureAwait(false))
5454
{
55-
using var result = await store.PageAsync(after, cancellationToken).ConfigureAwait(false);
56-
var rows = HttpDocumentStore.Documents(result);
57-
if (rows.Length == EmptyCount)
55+
cancellationToken.ThrowIfCancellationRequested();
56+
if (string.CompareOrdinal(row.Id, after) <= EmptyCount)
5857
{
59-
yield break;
60-
}
61-
foreach (var row in rows)
62-
{
63-
if (string.CompareOrdinal(row.Id, after) <= EmptyCount)
64-
{
65-
throw new ComparisonFailureException(InvalidOrder);
66-
}
67-
after = row.Id;
68-
yield return row;
58+
throw new ComparisonFailureException(InvalidOrder);
6959
}
60+
after = row.Id;
61+
yield return row;
7062
}
7163
}
7264

‎benchmarks/KeyLoad.Comparisons/Targets/InfluxDB/Execution/InfluxDocumentStore.cs‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Globalization;
22
using System.Net.Http.Json;
3+
using System.Runtime.CompilerServices;
34
using System.Text;
45
using System.Text.Json;
56
using Microsoft.Extensions.Options;
@@ -25,8 +26,8 @@ internal sealed class InfluxDocumentStore(HttpClient http, string name, IOptions
2526
private const string Select = "SELECT id,body FROM documents WHERE id=$id";
2627
private const string NumericPredicate = " AND number>=$number AND number<$number+1";
2728
private const string Limit = " ORDER BY id LIMIT 2";
28-
private const string Page = "SELECT id,body FROM documents WHERE id>$after ORDER BY id LIMIT ";
29-
private const string Id = "id", Number = "number", After = "after", Json = "json";
29+
private const string Readback = "SELECT id,body FROM documents ORDER BY id";
30+
private const string Id = "id", Number = "number", Json = "json", JsonLines = "jsonl";
3031
private const string LinePrefix = "documents,id=";
3132
private const string BodyField = " body=\"", NumberField = "\",number=", IntegerSuffix = "i ";
3233
private const string Backslash = "\\", EscapedBackslash = "\\\\", Quote = "\"", EscapedQuote = "\\\"";
@@ -131,9 +132,15 @@ internal override Task<JsonDocument> ReadAsync(string id, int? number, Cancellat
131132
return QueryAsync(Select + (number.HasValue ? NumericPredicate : string.Empty) + Limit, parameters, token);
132133
}
133134

134-
internal override Task<JsonDocument> PageAsync(string after, CancellationToken token)
135-
=> QueryAsync(Page + Policy.ReadbackBatchCapacity.ToString(CultureInfo.InvariantCulture),
136-
new Dictionary<string, object>(StringComparer.Ordinal) { [After] = after }, token);
135+
internal override async IAsyncEnumerable<FoundDocument> ReadDocumentsAsync([EnumeratorCancellation] CancellationToken token)
136+
{
137+
using var request = new HttpRequestMessage(HttpMethod.Post, Query)
138+
{ Content = JsonContent.Create(new { db = Name, q = Readback, format = JsonLines }) };
139+
await foreach (var row in InfluxDocumentStream.ReadAsync(Http, request, Policy, token).ConfigureAwait(false))
140+
{
141+
yield return row;
142+
}
143+
}
137144

138145
internal override Task IndexAsync(CancellationToken token)
139146
{
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Runtime.CompilerServices;
2+
3+
namespace KeyLoad.Comparisons.Targets;
4+
5+
internal static class InfluxDocumentStream
6+
{
7+
private const string Rejected = "InfluxReadbackRequestRejected";
8+
private const string TooManyRows = "InfluxReadbackRecordLimitExceeded";
9+
private const string StatusSeparator = ":";
10+
private const int ErrorCharacters = 512;
11+
12+
internal static async IAsyncEnumerable<FoundDocument> ReadAsync(HttpClient http, HttpRequestMessage request,
13+
ComparisonExecutionOptions policy, [EnumeratorCancellation] CancellationToken token)
14+
{
15+
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token);
16+
deadline.CancelAfter(policy.OperationTimeout);
17+
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
18+
if (!response.IsSuccessStatusCode)
19+
{
20+
var body = await ComparisonResponse.ReadTextAsync(response.Content, policy, deadline.Token).ConfigureAwait(false);
21+
var detail = new string(body.Take(ErrorCharacters).Where(character => char.IsAscii(character) && !char.IsControl(character)).ToArray());
22+
throw new ComparisonFailureException(Rejected + StatusSeparator + ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)
23+
+ StatusSeparator + detail);
24+
}
25+
await using var input = await response.Content.ReadAsStreamAsync(deadline.Token).ConfigureAwait(false);
26+
var count = 0;
27+
var maximum = checked(DocumentComparisonContract.Current.Ingestion.Records + DocumentComparisonContract.Current.Operations);
28+
await foreach (var row in InfluxJsonLines.ReadAsync(input, policy, deadline).ConfigureAwait(false))
29+
{
30+
if (++count > maximum)
31+
{
32+
throw new ComparisonFailureException(TooManyRows);
33+
}
34+
yield return row;
35+
}
36+
}
37+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System.Buffers;
2+
using System.IO.Pipelines;
3+
using System.Text.Json;
4+
5+
namespace KeyLoad.Comparisons.Targets;
6+
7+
internal static class InfluxJsonLines
8+
{
9+
private const byte Newline = (byte)'\n';
10+
private const string Id = "id", Body = "body";
11+
private const string TooLarge = "InfluxReadbackRowLimitExceeded", Truncated = "InfluxReadbackRowTruncated";
12+
13+
internal static async IAsyncEnumerable<FoundDocument> ReadAsync(Stream input, ComparisonExecutionOptions policy,
14+
CancellationTokenSource deadline)
15+
{
16+
var reader = PipeReader.Create(input, new StreamPipeReaderOptions(bufferSize: policy.ReadBufferBytes,
17+
minimumReadSize: policy.ReadBufferBytes, leaveOpen: true));
18+
try
19+
{
20+
while (true)
21+
{
22+
deadline.CancelAfter(policy.OperationTimeout);
23+
var result = await reader.ReadAsync(deadline.Token).ConfigureAwait(false);
24+
var pending = result.Buffer;
25+
while (pending.PositionOf(Newline) is { } end)
26+
{
27+
deadline.Token.ThrowIfCancellationRequested();
28+
var line = pending.Slice(pending.Start, end);
29+
RequireLength(line.Length, policy.MaxResponseBytes);
30+
pending = pending.Slice(pending.GetPosition(1, end));
31+
yield return Parse(line);
32+
}
33+
RequireLength(pending.Length, policy.MaxResponseBytes);
34+
if (result.IsCompleted)
35+
{
36+
RequireComplete(pending);
37+
}
38+
reader.AdvanceTo(pending.Start, pending.End);
39+
if (result.IsCompleted)
40+
{
41+
yield break;
42+
}
43+
}
44+
}
45+
finally
46+
{
47+
await reader.CompleteAsync().ConfigureAwait(false);
48+
}
49+
}
50+
51+
private static FoundDocument Parse(ReadOnlySequence<byte> line)
52+
{
53+
using var json = JsonDocument.Parse(line);
54+
return new(json.RootElement.GetProperty(Id).GetString()!, json.RootElement.GetProperty(Body).GetString()!);
55+
}
56+
57+
private static void RequireLength(long length, int maximum)
58+
{
59+
if (length > maximum)
60+
{
61+
throw new ComparisonFailureException(TooLarge);
62+
}
63+
}
64+
65+
private static void RequireComplete(ReadOnlySequence<byte> remaining)
66+
{
67+
if (!remaining.IsEmpty)
68+
{
69+
throw new ComparisonFailureException(Truncated);
70+
}
71+
}
72+
}

‎build/KeyLoad.LoadTests.AppHost/Configuration/BenchmarkDeploymentOptions.cs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ internal sealed record BenchmarkDeploymentOptions
99
private const int DefaultNeo4jMaximumHeap = 512;
1010
private const int DefaultNeo4jPageCache = 256;
1111
private const int DefaultOpenSearchHeap = 1024;
12+
private const int DefaultInfluxQueryMemoryPercent = 70;
13+
private const int MaximumQueryMemoryPercent = 90;
1214
private const int DefaultPostgresSlotRetention = 512;
1315
private const int DefaultDocumentPostgresConnections = 512;
1416
private const int MinimumDocumentPostgresConnections = 512;
@@ -21,11 +23,13 @@ internal sealed record BenchmarkDeploymentOptions
2123
public int Neo4jMaximumHeapMegabytes { get; init; } = DefaultNeo4jMaximumHeap;
2224
public int Neo4jPageCacheMegabytes { get; init; } = DefaultNeo4jPageCache;
2325
public int OpenSearchHeapMegabytes { get; init; } = DefaultOpenSearchHeap;
26+
public int InfluxQueryMemoryPercent { get; init; } = DefaultInfluxQueryMemoryPercent;
2427
public int PostgresSlotWalRetentionMegabytes { get; init; } = DefaultPostgresSlotRetention;
2528
public int DocumentPostgresMaximumConnections { get; init; } = DefaultDocumentPostgresConnections;
2629
internal bool IsValid() => DocumentPostgresMaximumConnections is >= MinimumDocumentPostgresConnections and <= MaximumDocumentPostgresConnections
2730
&& ValidMemory(Neo4jInitialHeapMegabytes) && ValidMemory(Neo4jMaximumHeapMegabytes)
2831
&& Neo4jInitialHeapMegabytes <= Neo4jMaximumHeapMegabytes && ValidMemory(Neo4jPageCacheMegabytes)
29-
&& ValidMemory(OpenSearchHeapMegabytes) && PostgresSlotWalRetentionMegabytes is >= 1 and <= MaximumWalMegabytes;
32+
&& ValidMemory(OpenSearchHeapMegabytes) && InfluxQueryMemoryPercent is >= 1 and <= MaximumQueryMemoryPercent
33+
&& PostgresSlotWalRetentionMegabytes is >= 1 and <= MaximumWalMegabytes;
3034
private static bool ValidMemory(int value) => value is >= 1 and <= MaximumMemoryMegabytes;
3135
}

0 commit comments

Comments
 (0)