Skip to content

Commit 6ec9233

Browse files
committed
Fix native benchmark registry probes and Kurrent startup sequencing
1 parent 35ea896 commit 6ec9233

13 files changed

Lines changed: 468 additions & 28 deletions

File tree

‎.github/workflows/benchmarks.yml‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ jobs:
7676
global-json-file: global.json
7777
- name: Check Docker
7878
run: docker version
79+
- name: Restore .NET packages
80+
run: dotnet restore KeyLoad.slnx
81+
- name: Build benchmark tests
82+
id: benchmark-build
83+
run: dotnet build tests/KeyLoad.ComparisonTests --no-restore --configuration Release
84+
- name: Build benchmark preparation unit tests
85+
run: dotnet build tests/KeyLoad.UnitTests --no-restore --configuration Release
86+
- name: Check bounded Docker registry readiness
87+
run: dotnet run --project src/KeyLoad.AppHost --no-build --no-restore --configuration Release -- --KeyLoadTests:Suite=unit '--KeyLoadTests:Filter=/*/*/ImageRegistryReadinessTests/*' --KeyLoadTests:ResultsDirectory=TestResults/comparison-images/registry-readiness
7988
- name: Build KeyLoad server and benchmark Docker images
8089
id: images
8190
run: node scripts/Features/BenchmarkComparisons/prepare-images.mjs
@@ -86,11 +95,8 @@ jobs:
8695
run: |
8796
test -n "$KEYLOAD_SERVER_IMAGE" && test -n "$KEYLOAD_RUNNER_IMAGE"
8897
printf 'KeyLoad__ContainerImages__Server=%s\nBenchmarks__ContainerImages__LoadGenerator=%s\nKEYLOAD_IMAGE_RECEIPT=%s\n' "$KEYLOAD_SERVER_IMAGE" "$KEYLOAD_RUNNER_IMAGE" "$RUNNER_TEMP/keyload-images/image-receipt.json" >> "$GITHUB_ENV"
89-
- name: Restore .NET packages
90-
run: dotnet restore KeyLoad.slnx
91-
- name: Build benchmark tests
92-
id: benchmark-build
93-
run: dotnet build tests/KeyLoad.ComparisonTests --no-restore --configuration Release
98+
- name: Check native KurrentDB discovery settings
99+
run: dotnet run --project src/KeyLoad.AppHost --no-build --no-restore --configuration Release -- --KeyLoadTests:Suite=comparison '--KeyLoadTests:Filter=/*/*/IsolatedKurrentDiscoverySettingsTests/*' --KeyLoadTests:ResultsDirectory=TestResults/comparison-images/kurrent-discovery
94100
- name: Check TimescaleDB Docker image and startup tools
95101
env:
96102
KEYLOAD_TIMESERIES_IMAGE_FACTS_DIRECTORY: ${{ runner.temp }}/keyload-timeseries-image-facts

‎benchmarks/KeyLoad.Comparisons/Features/BenchmarkComparisons/KurrentTarget.cs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,16 @@ public async Task InitializeAsync(BenchmarkDataset dataset, CancellationToken ca
5757
try
5858
{
5959
ownership = new KurrentStreamOwnership(dataset.Options);
60-
setupStage = KurrentSetupStage.WriterConstruction;
61-
writer = new KurrentDBClient(KurrentNativeSettings.CreateWriter(connectionString));
62-
ownedClients.Add(writer);
6360
setupStage = KurrentSetupStage.MemberVerification;
6461
var timeout = TimeSpan.FromSeconds(dataset.Options.TimeoutSeconds);
6562
var proof = await KurrentClusterVerifier.VerifyAsync(connectionString, nodeHttpClients, topology, timeout, cancellationToken);
6663
nodeClients = proof.NodeClients;
6764
ownedClients.AddRange(nodeClients);
65+
// Native SDK construction eagerly discovers and caches a preferred live member.
66+
// Construct the writer after all native views agree on their actual leader.
67+
setupStage = KurrentSetupStage.WriterConstruction;
68+
writer = new KurrentDBClient(KurrentNativeSettings.CreateWriter(connectionString));
69+
ownedClients.Add(writer);
6870
setupStage = KurrentSetupStage.NoStreamSemantics;
6971
await VerifyNoStreamConflictAsync(cancellationToken);
7072

‎docs/ADR/ADR-080-benchmark-failure-isolation.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# ADR-080: Isolate benchmark failures during publication
22

33
Status: Accepted; source implemented, delivered-source verification pending.
4-
Date: 2026-10-04. Related: REQ/AC-BC-FAIL-001..005, ADR-056/074/076.
4+
Date: 2026-10-04. Related: REQ/AC-BC-FAIL-001..007, ADR-056/074/076.
55

66
## Decision
77

@@ -33,14 +33,27 @@ upload; cancellation/timeouts that prevent artifacts remain explicit blockers.
3333
4. Root owns workflow always-finalization/aggregate condition, shared receipt
3434
validation, dependency/coverage inventory, source name tests and documentation.
3535
Diagnostic worker inspects native startup errors without changing KeyLoad engine.
36+
Under FAIL-PREP-REGISTRY, the existing image-contracts/manifests/evidence modules
37+
own2s real HTTP probes inside the unchanged30s readiness bound and private
38+
no-follow121-record/64KiB probe facts. Evidence I/O errors escape transport
39+
retry handling. Ten actual HTTP fixture cases belong to UnitTests; the image
40+
preparation job must build that runner and invoke its Aspire-owned filter.
41+
Run these actual loopback fixture tests before `prepare-images.mjs` owns the
42+
same registry port; restore/build the native test runners first. Keep the
43+
actual pinned image export/import checks after image construction.
44+
Under FAIL-PREP-KURRENT, `KurrentTarget.InitializeAsync` constructs the actual
45+
SDK writer only after `KurrentClusterVerifier.VerifyAsync` proves membership.
46+
Three SDK/resource-model tests belong to ComparisonTests and run in common
47+
preparation; existing native StreamAppend preflights qualify1/2/3-node semantics
48+
and copy/cleanup behavior. No provider replacement, URI or ACK/retry change.
3649
5. Root reviews all diffs, builds solution, runs formatter/governance and focused
3750
Aspire-owned suites, then checkpoints scoped changes on current main and pushes.
3851
6. Genuine Linux Benchmarks run qualifies all cells, aggregate, site coverage/browser
3952
and Pages publication. Local tests are development proof only.
4053

4154
Migration is additive to version4 dispositions; deploy producer/validators/site
4255
atomically. Rollback reverts this coherent change and restores the conservative
43-
publication gate, retaining immutable original artifacts. AC-BC-FAIL-001..005 map
56+
publication gate, retaining immutable original artifacts. AC-BC-FAIL-001..007 map
4457
to automated and actual-provider evidence in the feature specification. Root alone
4558
owns integration and shared contract updates; workers never commit or push.
4659

‎docs/Features/BenchmarkComparisons.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,8 @@ KeyLoad engine repair and concurrent series-codec work are outside this task.
688688
| REQ-BC-FAIL-003 authenticated partial results | AC-BC-FAIL-003 failed job accepted only with failed workload, successful result upload and matching failed envelope; missing/malformed/expired/mixed evidence rejected | producer/aggregate/site negative and positive TUnit regressions |
689689
| REQ-BC-FAIL-004 honest site | AC-BC-FAIL-004 successful competitors retain values; failed cells have no numeric values and expose actual job link | independent numeric oracle, projection validation and real Chrome |
690690
| REQ-BC-FAIL-005 repair shared preparation | AC-BC-FAIL-005 diagnose exact failed logs, repair benchmark setup/build invocation, retain native isolated topology and Aspire ownership | exact failed-source log, focused regression, delivered-source GitHub rerun |
691+
| REQ-BC-FAIL-006 bounded registry readiness | AC-BC-FAIL-006 each native HTTP probe has at most2s within the unchanged30s total; only settled non-aborted HTTP200 succeeds; private no-follow diagnostics remain at most121 records/64KiB and evidence-write failures propagate | `ImageRegistryReadinessTests`:10 actual loopback HTTP/error/bounds cases, plus genuine pinned Docker image export/import in GitHub |
692+
| REQ-BC-FAIL-007 Kurrent writer starts after membership | AC-BC-FAIL-007 verify all native1/2/3-member views before constructing the SDK writer; retain native DNS seeds, TLS verification, leader preference, NoStream semantics, acknowledgements, replica-copy oracle and cleanup | `IsolatedKurrentDiscoverySettingsTests`:3 actual SDK/resource-model cases; genuine Aspire-owned StreamAppend preflights for1/2/3 nodes |
691693

692694
[ADR-080](../ADR/ADR-080-benchmark-failure-isolation.md) owns the boundary change.
693695
Ordered task graph: FAIL-CONTRACT (root, complete) -> FAIL-SITE (site worker),
@@ -712,6 +714,35 @@ as compact immutable test-only fixtures with original hashes, source and provena
712714
substitute for current publication input. Final formatting is recorded separately; unrelated concurrent SampleChunk
713715
formatting is outside this repair. Delivered-source workload/site publication proof remains pending.
714716

717+
The first repair run37158699545/source65e8bf59 passed the clean Linux solution
718+
build, formatter and pinned Docker image preparation/roundtrip. Actual website
719+
cell `keyload-n1-document-update` job111309324270/artifact11286559225 failed its
720+
workload, uploaded successfully and passed unchanged production job/artifact/ZIP/
721+
envelope/agreement/site validators with its original null report. This is genuine
722+
failed-cell runtime proof; complete270 aggregation and Pages remain pending.
723+
724+
That run exposed two native preparation defects. Kurrent n3 job111309323415
725+
started a listening registry but its first HTTP request consumed the entire30s
726+
probe budget; the underlying transport cause was not logged. FAIL-PREP-REGISTRY
727+
uses2s attempts inside the same30s total and records bounded safe probe facts.
728+
Kurrent n2 job111309323419/artifact11286971852 began writer construction while
729+
native election was still `PreLeader`. Actual pinned SDK1.4.0 constructor/selector
730+
inspection proves eager discovery can choose a live follower at that stage;
731+
the exact cached endpoint was not logged. FAIL-PREP-KURRENT moves construction
732+
after awaited complete membership verification. These are benchmark fixture
733+
repairs; neither changes a database engine, URI, retry/ACK contract or topology.
734+
Root records this contract before integration, then runs the focused Aspire
735+
unit/comparison cases, full build/formatter/governance and an exact-source Linux
736+
rerun before claiming the two repairs or publication qualified.
737+
738+
Native-stage local development checks: initial full Release build and formatter
739+
passed; Aspire registry10/10 and actual SDK/resource settings3/3 passed. The new
740+
mandatory preparation workflow regression passed. The full24-case workflow filter
741+
had23 passes and exposed its old22-comparison-filter inventory expectation; source
742+
now enumerates the added23rd filter explicitly. Its clean-source rerun is pending.
743+
Concurrent uncommitted NativeTextAsync CA1849/IDE0005 prevent repeat shared-checkout
744+
full checks; those product changes remain outside this repair and are not staged.
745+
715746
```mermaid
716747
flowchart LR
717748
Plan[Complete native cell plan] --> Jobs[Independent Aspire workloads]

‎docs/implementation/status.json‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3124,12 +3124,30 @@
31243124
"status": "in_progress",
31253125
"feature": "docs/Features/BenchmarkComparisons.md",
31263126
"decision": "docs/ADR/ADR-080-benchmark-failure-isolation.md",
3127-
"sourceStage": "bounded_transient_github_get_retries_and_authenticated_failed_cell_publication",
3127+
"sourceStage": "authenticated_failed_cell_publication_with_bounded_registry_probes_and_kurrent_writer_after_membership",
31283128
"baselineRun": 37154664616,
31293129
"originalFailedJob": 111299652762,
31303130
"originalDiagnosticArtifact": 11286445994,
31313131
"provenFailure": "GitHub HTTP503 rejected by rate-only retry policy before database startup",
3132-
"localDevelopmentVerification": "Local macOS development: full Release solution build passed with zero warnings/errors; Aspire comparison producer/finalizer 2/2 and GitHub evidence 84/84 passed; original PostgreSQL/RabbitMQ report probes 48/48 passed. Aspire workflow regressions 23/23 passed; full formatting remains blocked by unrelated concurrent SampleChunk files. Linux runtime/site/Pages qualification pending.",
3132+
"failedEnvelopeRuntimeEvidence": {
3133+
"sourceRevision": "65e8bf59fbac150d7d1d13211abc73f6ad7ea66b",
3134+
"runId": 37158699545,
3135+
"jobId": 111309324270,
3136+
"artifactId": 11286559225,
3137+
"cellId": "keyload-n1-document-update",
3138+
"originalArchiveDigest": "sha256:09a4d8e58791a59a230fd3c2137f9108a60571ce892fa073b729a58c2d022439",
3139+
"verified": "actual failed job/workload, successful upload and original null-report envelope accepted by production archive, agreement and site validators",
3140+
"completeCohortQualified": false
3141+
},
3142+
"nativePreparationRepairEvidence": {
3143+
"registryFailureJobId": 111309323415,
3144+
"registryDefect": "One readiness request could consume the entire30s total; native registry was listening but the transport cause was not logged",
3145+
"kurrentFailureJobId": 111309323419,
3146+
"kurrentDiagnosticArtifactId": 11286971852,
3147+
"kurrentDefect": "Writer SDK eagerly discovered before verified membership during PreLeader; pinned selector can cache follower, exact selected endpoint was not logged",
3148+
"qualification": "Aspire local registry10/10 and native SDK settings3/3 passed; exact-source native Linux rerun pending"
3149+
},
3150+
"localDevelopmentVerification": "Local macOS development: initial integrated full Release build and formatter passed with zero warnings/errors. Aspire registry10/10 and SDK settings3/3 passed. Workflow24 cases:23 passed including the new mandatory-preparation regression; the old22-filter inventory assertion was updated for the added23rd native comparison filter and awaits clean-source CI. Repeat full checks are blocked by concurrent uncommitted NativeTextAsync CA1849/IDE0005 outside benchmark scope. Earlier producer/finalizer2/2, GitHub evidence84/84, original report probes48/48 and workflow23/23 passed. Exact-source Linux native/site/Pages qualification pending.",
31333151
"runtimeQualified": false,
31343152
"publicationQualified": false,
31353153
"keyLoadEngineChanges": false

‎scripts/Features/BenchmarkComparisons/image-contracts.mjs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const fileName = Object.freeze({
1919
registryState: 'registry-state.json',
2020
nativeCommands: 'native-commands.jsonl',
2121
registryHeaders: 'registry-headers.jsonl',
22+
registryReadiness: 'registry-readiness.jsonl',
2223
serverDockerfile: 'Dockerfile',
2324
runnerDockerfile: 'benchmarks/KeyLoad.ComparisonHost/Features/BenchmarkComparisons/Dockerfile',
2425
});
@@ -198,7 +199,10 @@ export const processLimit = Object.freeze({
198199
inspectTimeoutMs: 30000,
199200
cleanupTimeoutMs: 30000,
200201
readinessTimeoutMs: 30000,
202+
readinessProbeTimeoutMs: 2000,
201203
readinessIntervalMs: 250,
204+
maxRegistryReadinessRecords: 121,
205+
maxRegistryReadinessBytes: 64 * 1024,
202206
maxOutputBytes: 256 * 1024,
203207
maxLogBytes: 128 * 1024,
204208
maxInspectBytes: 64 * 1024,
@@ -210,6 +214,17 @@ export const processLimit = Object.freeze({
210214
killGraceMs: 1000,
211215
});
212216

217+
export const registryProbeErrorCodes = Object.freeze([
218+
'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH',
219+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
220+
]);
221+
222+
export const registryReadinessTokens = Object.freeze({
223+
phase: Object.freeze({ request: 'request', bodyCancel: 'body-cancel' }),
224+
outcome: Object.freeze({ ready: 'ready', httpStatus: 'http-status', timeout: 'timeout',
225+
requestFailed: 'request-failed', bodyCancelFailed: 'body-cancel-failed' }),
226+
});
227+
213228
export const outputFormat = Object.freeze({
214229
jsonIndent: 2,
215230
newline: '\n',

‎scripts/Features/BenchmarkComparisons/image-evidence.mjs‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
22
import { constants as fileFlags } from 'node:fs';
33
import { lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises';
44
import path from 'node:path';
5-
import { directoryName, fileName, imageReference, message, outputFormat, processLimit, validation } from './image-contracts.mjs';
5+
import { directoryName, fileName, imageReference, message, outputFormat, processLimit, registryProbeErrorCodes, registryReadinessTokens, validation } from './image-contracts.mjs';
66

77
const privateDirectoryMode = 0o700;
88
const privateFileMode = 0o600;
@@ -19,6 +19,9 @@ const diagnosticRedactions = Object.freeze([
1919
}),
2020
Object.freeze({ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16})\b/g, replacement: '[REDACTED]' }),
2121
]);
22+
const readinessOutcomes = Object.freeze(Object.values(registryReadinessTokens.outcome));
23+
const readinessPhases = Object.freeze(Object.values(registryReadinessTokens.phase));
24+
const utcTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
2225

2326
export async function ensureEvidenceDirectory(context) {
2427
const expectedPath = path.resolve(context.runnerTemp, directoryName.evidence);
@@ -142,6 +145,23 @@ export async function recordRegistryHeaders(context, imageName, status, headers)
142145
await appendOwnedLine(context, fileName.registryHeaders, record, processLimit.maxNativeCommandRecords);
143146
}
144147

148+
export async function recordRegistryReadiness(context, probe) {
149+
if (!probe || !Number.isInteger(probe.sequence) || probe.sequence < 1 || probe.sequence > processLimit.maxRegistryReadinessRecords
150+
|| typeof probe.startedAt !== 'string' || !utcTimestampPattern.test(probe.startedAt) || !Number.isFinite(Date.parse(probe.startedAt))
151+
|| !Number.isSafeInteger(probe.durationMs) || probe.durationMs < 0
152+
|| !Number.isInteger(probe.timeoutMs) || probe.timeoutMs <= 0 || probe.timeoutMs > processLimit.readinessProbeTimeoutMs
153+
|| (probe.status !== null && (!Number.isInteger(probe.status) || probe.status < 100 || probe.status > 599))
154+
|| typeof probe.aborted !== 'boolean' || !readinessPhases.includes(probe.phase) || !readinessOutcomes.includes(probe.outcome)
155+
|| (probe.errorCode !== null && !registryProbeErrorCodes.includes(probe.errorCode))) {
156+
throw new Error(message.commandOutputLimit);
157+
}
158+
const record = Object.freeze({ sequence: probe.sequence, startedAt: probe.startedAt, durationMs: probe.durationMs,
159+
timeoutMs: probe.timeoutMs, status: probe.status, aborted: probe.aborted, phase: probe.phase,
160+
outcome: probe.outcome, errorCode: probe.errorCode });
161+
await appendOwnedLine(context, fileName.registryReadiness, record, processLimit.maxRegistryReadinessRecords,
162+
processLimit.maxRegistryReadinessBytes);
163+
}
164+
145165
export async function appendImageOutputs(context, serverReference, loadGeneratorReference) {
146166
const outputFilePath = context.githubOutput;
147167
const fileInfo = await lstat(outputFilePath).catch(() => null);
@@ -162,7 +182,7 @@ async function writeOwnedBytes(target, bytes) {
162182
await writeFile(target, bytes, { flag: existing ? replaceWriteFlag : exclusiveWriteFlag, mode: privateFileMode });
163183
}
164184

165-
async function appendOwnedLine(context, name, value, maximumRecords) {
185+
async function appendOwnedLine(context, name, value, maximumRecords, maximumBytes = processLimit.maxCommandEvidenceBytes) {
166186
const target = await ownedPath(context, name);
167187
const line = Buffer.from(`${JSON.stringify(value)}${outputFormat.newline}`, outputFormat.utf8);
168188
if (line.length > processLimit.maxCommandEvidenceRecordBytes) throw new Error(message.commandOutputLimit);
@@ -173,7 +193,7 @@ async function appendOwnedLine(context, name, value, maximumRecords) {
173193
const info = await handle.stat();
174194
if (!info.isFile() || info.uid !== process.getuid() || (info.mode & 0o077) !== 0) throw new Error(message.unsafeEvidencePath);
175195
const recordCount = (await readFile(target, outputFormat.utf8)).split(outputFormat.newline).filter(Boolean).length;
176-
if (info.size + line.length > processLimit.maxCommandEvidenceBytes || recordCount >= maximumRecords) {
196+
if (info.size + line.length > maximumBytes || recordCount >= maximumRecords) {
177197
throw new Error(message.commandOutputLimit);
178198
}
179199
await handle.writeFile(line);

0 commit comments

Comments
 (0)