@loadstrike/loadstrike-sdk 1.0.30201 → 1.0.30401
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/cjs/cluster.js +2417 -7
- package/dist/cjs/index.js +13 -2
- package/dist/cjs/iteration-observations.js +711 -0
- package/dist/cjs/load-engine-v2.js +966 -0
- package/dist/cjs/local.js +73 -20
- package/dist/cjs/reporting.js +122 -12
- package/dist/cjs/runtime.js +2368 -129
- package/dist/cjs/sinks.js +471 -17
- package/dist/cjs/transports.js +84 -146
- package/dist/esm/cluster.js +2386 -7
- package/dist/esm/index.js +1 -0
- package/dist/esm/iteration-observations.js +698 -0
- package/dist/esm/load-engine-v2.js +942 -0
- package/dist/esm/local.js +73 -20
- package/dist/esm/reporting.js +122 -12
- package/dist/esm/runtime.js +2369 -130
- package/dist/esm/sinks.js +471 -17
- package/dist/esm/transports.js +84 -146
- package/dist/types/cluster.d.ts +379 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/iteration-observations.d.ts +225 -0
- package/dist/types/load-engine-v2.d.ts +147 -0
- package/dist/types/runtime.d.ts +214 -8
- package/dist/types/sinks.d.ts +67 -0
- package/dist/types/transports.d.ts +2 -8
- package/package.json +3 -4
package/dist/esm/cluster.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { cpus } from "node:os";
|
|
2
3
|
import { EndpointAdapterFactory } from "./transports.js";
|
|
4
|
+
import { parseLoadEngineV2HistogramArtifact, serializeLoadEngineV2HistogramArtifact } from "./load-engine-v2.js";
|
|
5
|
+
const EMPTY_LOAD_ENGINE_V2_HISTOGRAM_ARTIFACT_BASE64 = serializeLoadEngineV2HistogramArtifact({
|
|
6
|
+
distributions: [],
|
|
7
|
+
measurementSummaries: []
|
|
8
|
+
}).toString("base64");
|
|
3
9
|
export class LocalClusterCoordinator {
|
|
4
10
|
/**
|
|
5
11
|
* Exposes the public constructor operation.
|
|
@@ -120,6 +126,1444 @@ export function planAgentScenarioAssignments(scenarioNames, agentCount, options
|
|
|
120
126
|
}
|
|
121
127
|
return assignments;
|
|
122
128
|
}
|
|
129
|
+
export const LOAD_ENGINE_V2_CLUSTER_CAPABILITIES = Object.freeze({
|
|
130
|
+
schedulerVersions: ["2"],
|
|
131
|
+
histogramVersions: ["1"],
|
|
132
|
+
shardingVersions: ["1"],
|
|
133
|
+
resultChunkVersions: ["1"],
|
|
134
|
+
iterationObservationSchemas: ["loadstrike.iteration-observation/1"],
|
|
135
|
+
// Distributed correlation is negotiated only when the runtime has a shared
|
|
136
|
+
// correlation implementation. An empty list is deliberately fail-closed.
|
|
137
|
+
correlationSetVersions: [],
|
|
138
|
+
planHashVersions: ["LS-SP1"],
|
|
139
|
+
callbackExecutionModes: ["async"],
|
|
140
|
+
maximumResultChunkBytes: 256 * 1024
|
|
141
|
+
});
|
|
142
|
+
const loadEngineV2ProcessInstanceId = randomBytes(32).toString("hex");
|
|
143
|
+
const LOAD_ENGINE_V2_MAX_RESULT_BYTES = 64 * 1024 * 1024;
|
|
144
|
+
const LOAD_ENGINE_V2_DEFAULT_CHUNK_BYTES = 128 * 1024;
|
|
145
|
+
export function createLoadEngineV2Registration(input) {
|
|
146
|
+
validateBoundedV2Identifier(input.runId, "run ID", 128);
|
|
147
|
+
validateBoundedV2Identifier(input.sessionId, "session ID", 128);
|
|
148
|
+
validateBoundedV2Identifier(input.coordinatorNonce, "coordinator nonce", 128);
|
|
149
|
+
validateLoadEngineV2AgentId(input.agentId);
|
|
150
|
+
if (input.targetSubject !== buildLoadEngineV2ScopedTargetSubject(input.runId, input.sessionId, input.coordinatorNonce, input.agentId)) {
|
|
151
|
+
throw new Error("Load Engine V2 registration targeted subject is outside the run/session/nonce scope.");
|
|
152
|
+
}
|
|
153
|
+
const processInstanceId = input.processInstanceId ?? loadEngineV2ProcessInstanceId;
|
|
154
|
+
validateLowercaseHash(processInstanceId, "process instance id");
|
|
155
|
+
const registrationSequence = parseCanonicalNonNegativeInt64(input.registrationSequence64, "registration sequence");
|
|
156
|
+
const leaseIssued = parseCanonicalNonNegativeInt64(input.leaseIssuedUtcNs64, "registration lease issue");
|
|
157
|
+
const leaseExpires = parseCanonicalNonNegativeInt64(input.leaseExpiresUtcNs64, "registration lease expiry");
|
|
158
|
+
if (leaseExpires <= leaseIssued || leaseExpires - leaseIssued > 30000000000n) {
|
|
159
|
+
throw new Error("Load Engine V2 registration lease must be positive and no longer than 30 seconds.");
|
|
160
|
+
}
|
|
161
|
+
void registrationSequence;
|
|
162
|
+
const schedulerVisibleProcessorCount = input.schedulerVisibleProcessorCount ?? Math.max(cpus().length, 1);
|
|
163
|
+
if (!Number.isSafeInteger(schedulerVisibleProcessorCount) || schedulerVisibleProcessorCount <= 0) {
|
|
164
|
+
throw new Error("Load Engine V2 registration scheduler-visible processor count is invalid.");
|
|
165
|
+
}
|
|
166
|
+
const sdkLanguage = input.sdkLanguage ?? "typescript";
|
|
167
|
+
const sdkPackageVersion = input.sdkPackageVersion ?? process.env.npm_package_version ?? "0.0.0";
|
|
168
|
+
const runtimeVersion = input.runtimeVersion ?? process.version;
|
|
169
|
+
validateBoundedV2Identifier(sdkLanguage, "SDK language", 128);
|
|
170
|
+
validateBoundedV2Identifier(sdkPackageVersion, "SDK package version", 128);
|
|
171
|
+
validateBoundedV2Identifier(runtimeVersion, "runtime version", 128);
|
|
172
|
+
const selected = { ...LOAD_ENGINE_V2_CLUSTER_CAPABILITIES, ...(input.capabilities ?? {}) };
|
|
173
|
+
const registration = {
|
|
174
|
+
runId: input.runId,
|
|
175
|
+
sessionId: input.sessionId,
|
|
176
|
+
coordinatorNonce: input.coordinatorNonce,
|
|
177
|
+
agentId: input.agentId,
|
|
178
|
+
targetSubject: input.targetSubject,
|
|
179
|
+
processInstanceId,
|
|
180
|
+
schedulerVisibleProcessorCount,
|
|
181
|
+
sdkLanguage,
|
|
182
|
+
sdkPackageVersion,
|
|
183
|
+
runtimeVersion,
|
|
184
|
+
registrationSequence64: input.registrationSequence64,
|
|
185
|
+
leaseIssuedUtcNs64: input.leaseIssuedUtcNs64,
|
|
186
|
+
leaseExpiresUtcNs64: input.leaseExpiresUtcNs64,
|
|
187
|
+
schedulerVersions: canonicalV2CapabilityList(selected.schedulerVersions, "scheduler versions"),
|
|
188
|
+
histogramVersions: canonicalV2CapabilityList(selected.histogramVersions, "histogram versions"),
|
|
189
|
+
shardingVersions: canonicalV2CapabilityList(selected.shardingVersions, "sharding versions"),
|
|
190
|
+
resultChunkVersions: canonicalV2CapabilityList(selected.resultChunkVersions, "result chunk versions"),
|
|
191
|
+
iterationObservationSchemas: canonicalV2CapabilityList(selected.iterationObservationSchemas, "iteration observation schemas"),
|
|
192
|
+
correlationSetVersions: canonicalV2CapabilityList(selected.correlationSetVersions, "correlation versions"),
|
|
193
|
+
planHashVersions: canonicalV2CapabilityList(selected.planHashVersions, "plan hash versions"),
|
|
194
|
+
callbackExecutionModes: canonicalV2CallbackModes(selected.callbackExecutionModes),
|
|
195
|
+
maximumResultChunkBytes: selected.maximumResultChunkBytes,
|
|
196
|
+
registrationBindingHash: ""
|
|
197
|
+
};
|
|
198
|
+
if (!Number.isSafeInteger(registration.maximumResultChunkBytes)
|
|
199
|
+
|| registration.maximumResultChunkBytes < 16 * 1024
|
|
200
|
+
|| registration.maximumResultChunkBytes > 256 * 1024) {
|
|
201
|
+
throw new Error("Load Engine V2 registration result chunk bound must be 16384..262144 bytes.");
|
|
202
|
+
}
|
|
203
|
+
registration.registrationBindingHash = buildLoadEngineV2RegistrationBindingHash(registration);
|
|
204
|
+
return registration;
|
|
205
|
+
}
|
|
206
|
+
export function negotiateLoadEngineV2Registrations(runId, sessionId, coordinatorNonce, expectedAgentIds, registrations, nowUtcNs64) {
|
|
207
|
+
const expected = normalizeExpectedAgentIds(expectedAgentIds);
|
|
208
|
+
const now = parseCanonicalInt64(nowUtcNs64, "coordinator UTC nanoseconds");
|
|
209
|
+
if (registrations.length !== expected.length) {
|
|
210
|
+
throw new Error("Load Engine V2 capability negotiation requires the exact expected participant set.");
|
|
211
|
+
}
|
|
212
|
+
const actual = [...registrations].sort((left, right) => compareAgentIds(left.agentId, right.agentId));
|
|
213
|
+
if (new Set(actual.map((value) => value.agentId)).size !== actual.length
|
|
214
|
+
|| actual.some((value, index) => value.agentId !== expected[index])) {
|
|
215
|
+
throw new Error("Load Engine V2 capability negotiation did not receive the exact expected participant set.");
|
|
216
|
+
}
|
|
217
|
+
for (const registration of actual) {
|
|
218
|
+
validateLoadEngineV2AgentId(registration.agentId);
|
|
219
|
+
if (registration.runId !== runId || registration.sessionId !== sessionId
|
|
220
|
+
|| registration.coordinatorNonce !== coordinatorNonce
|
|
221
|
+
|| registration.targetSubject !== buildLoadEngineV2ScopedTargetSubject(runId, sessionId, coordinatorNonce, registration.agentId)) {
|
|
222
|
+
throw new Error("Load Engine V2 registration does not match the coordinator run/session/nonce scope.");
|
|
223
|
+
}
|
|
224
|
+
validateLowercaseHash(registration.processInstanceId, "process instance id");
|
|
225
|
+
const issued = parseCanonicalNonNegativeInt64(registration.leaseIssuedUtcNs64, "registration lease issue");
|
|
226
|
+
const expires = parseCanonicalNonNegativeInt64(registration.leaseExpiresUtcNs64, "registration lease expiry");
|
|
227
|
+
parseCanonicalNonNegativeInt64(registration.registrationSequence64, "registration sequence");
|
|
228
|
+
if (issued > now || expires <= now || expires <= issued || expires - issued > 30000000000n) {
|
|
229
|
+
throw new Error("Load Engine V2 registration lease expired before commit.");
|
|
230
|
+
}
|
|
231
|
+
if (!hashEquals(buildLoadEngineV2RegistrationBindingHash({ ...registration, registrationBindingHash: "" }), registration.registrationBindingHash))
|
|
232
|
+
throw new Error("Load Engine V2 registration binding hash is invalid.");
|
|
233
|
+
requireCapability(registration.schedulerVersions, "2", "scheduler-v2");
|
|
234
|
+
requireCapability(registration.histogramVersions, "1", "histogram-v1");
|
|
235
|
+
requireCapability(registration.shardingVersions, "1", "global-sharding-v1");
|
|
236
|
+
requireCapability(registration.resultChunkVersions, "1", "result-chunks-v1");
|
|
237
|
+
requireCapability(registration.iterationObservationSchemas, "loadstrike.iteration-observation/1", "loadstrike.iteration-observation/1");
|
|
238
|
+
requireCapability(registration.planHashVersions, "LS-SP1", "canonical LS-SP1 plan hash");
|
|
239
|
+
}
|
|
240
|
+
return actual;
|
|
241
|
+
}
|
|
242
|
+
export function buildLoadEngineV2ScopedRegistrationSubject(runId, sessionId, coordinatorNonce) {
|
|
243
|
+
[runId, sessionId, coordinatorNonce].forEach((value, index) => validateBoundedV2Identifier(value, ["run ID", "session ID", "coordinator nonce"][index], 128));
|
|
244
|
+
const bytes = Buffer.concat([
|
|
245
|
+
Buffer.from("LS-RS1\n", "ascii"), frameUtf8("1"), frameUtf8(runId), frameUtf8(sessionId),
|
|
246
|
+
frameUtf8(coordinatorNonce)
|
|
247
|
+
]);
|
|
248
|
+
return `loadstrike.v2.${sha256Hex(bytes)}.register`;
|
|
249
|
+
}
|
|
250
|
+
export function buildLoadEngineV2ScopedTargetSubject(runId, sessionId, coordinatorNonce, agentId) {
|
|
251
|
+
validateBoundedV2Identifier(runId, "run ID", 128);
|
|
252
|
+
validateBoundedV2Identifier(sessionId, "session ID", 128);
|
|
253
|
+
validateBoundedV2Identifier(coordinatorNonce, "coordinator nonce", 128);
|
|
254
|
+
validateLoadEngineV2AgentId(agentId);
|
|
255
|
+
const bytes = Buffer.concat([
|
|
256
|
+
Buffer.from("LS-TS1\n", "ascii"), frameUtf8("1"), frameUtf8(runId), frameUtf8(sessionId),
|
|
257
|
+
frameUtf8(coordinatorNonce), frameUtf8(agentId)
|
|
258
|
+
]);
|
|
259
|
+
return `loadstrike.v2.target.${sha256Hex(bytes)}`;
|
|
260
|
+
}
|
|
261
|
+
export function buildLoadEngineV2GlobalInvocationId(runId, scenarioIndex, simulationIndex, identityKind, primaryOrdinal, secondaryOrdinal) {
|
|
262
|
+
validateBoundedV2Identifier(runId, "run ID", 128);
|
|
263
|
+
const scenario = parseCanonicalNonNegativeInt64(String(scenarioIndex), "scenario index");
|
|
264
|
+
const simulation = parseCanonicalNonNegativeInt64(String(simulationIndex), "simulation index");
|
|
265
|
+
const primary = parseCanonicalNonNegativeInt64(String(primaryOrdinal), "primary ordinal");
|
|
266
|
+
const secondary = parseCanonicalNonNegativeInt64(String(secondaryOrdinal), "secondary ordinal");
|
|
267
|
+
if (!["arrival", "worker-iteration", "constant-iteration"].includes(identityKind)) {
|
|
268
|
+
throw new Error("Load Engine V2 invocation identity kind is invalid.");
|
|
269
|
+
}
|
|
270
|
+
return sha256Hex(Buffer.concat([
|
|
271
|
+
Buffer.from("LS-I1\n", "ascii"),
|
|
272
|
+
frameUtf8(runId),
|
|
273
|
+
frameUtf8(scenario.toString()),
|
|
274
|
+
frameUtf8(simulation.toString()),
|
|
275
|
+
frameUtf8(identityKind),
|
|
276
|
+
frameUtf8(primary.toString()),
|
|
277
|
+
frameUtf8(secondary.toString())
|
|
278
|
+
]));
|
|
279
|
+
}
|
|
280
|
+
export function createLoadEngineV2CoordinatorHello(input) {
|
|
281
|
+
const expectedAgentIds = normalizeExpectedAgentIds(input.expectedAgentIds);
|
|
282
|
+
if (input.registrationSubject !== buildLoadEngineV2ScopedRegistrationSubject(input.runId, input.sessionId, input.coordinatorNonce))
|
|
283
|
+
throw new Error("Load Engine V2 coordinator hello registration subject is invalid.");
|
|
284
|
+
parseCanonicalNonNegativeInt64(input.expiresUtcNs64, "coordinator hello expiry");
|
|
285
|
+
const hello = { ...input, expectedAgentIds, bindingHash: "" };
|
|
286
|
+
hello.bindingHash = buildLoadEngineV2CoordinatorHelloBindingHash(hello);
|
|
287
|
+
return hello;
|
|
288
|
+
}
|
|
289
|
+
export function validateLoadEngineV2CoordinatorHello(hello, expectedSessionId, expectedAgentId, nowUtcNs64) {
|
|
290
|
+
validateLoadEngineV2AgentId(expectedAgentId);
|
|
291
|
+
if (hello.sessionId !== expectedSessionId || !hello.expectedAgentIds.includes(expectedAgentId)) {
|
|
292
|
+
throw new Error("Load Engine V2 coordinator hello does not authorize this session and agent.");
|
|
293
|
+
}
|
|
294
|
+
if (hello.registrationSubject !== buildLoadEngineV2ScopedRegistrationSubject(hello.runId, hello.sessionId, hello.coordinatorNonce) || parseCanonicalNonNegativeInt64(hello.expiresUtcNs64, "coordinator hello expiry")
|
|
295
|
+
<= parseCanonicalNonNegativeInt64(nowUtcNs64, "coordinator hello validation time")) {
|
|
296
|
+
throw new Error("Load Engine V2 coordinator hello subject or expiry is invalid.");
|
|
297
|
+
}
|
|
298
|
+
const expected = buildLoadEngineV2CoordinatorHelloBindingHash({ ...hello, bindingHash: "" });
|
|
299
|
+
if (!hashEquals(expected, hello.bindingHash)) {
|
|
300
|
+
throw new Error("Load Engine V2 coordinator hello binding hash is invalid.");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function buildLoadEngineV2CoordinatorHelloBindingHash(hello) {
|
|
304
|
+
const expected = normalizeExpectedAgentIds(hello.expectedAgentIds);
|
|
305
|
+
const fields = [
|
|
306
|
+
Buffer.from("LS-CH1\n", "ascii"), frameUtf8("1"), frameUtf8(hello.runId),
|
|
307
|
+
frameUtf8(hello.sessionId), frameUtf8(hello.coordinatorNonce), frameUtf8(hello.registrationSubject),
|
|
308
|
+
frameUtf8(hello.expiresUtcNs64), frameUtf8(expected.length.toString()),
|
|
309
|
+
...expected.map(frameUtf8)
|
|
310
|
+
];
|
|
311
|
+
return sha256Hex(Buffer.concat(fields));
|
|
312
|
+
}
|
|
313
|
+
function buildLoadEngineV2RegistrationBindingHash(registration) {
|
|
314
|
+
const fields = [
|
|
315
|
+
Buffer.from("LS-RG1\n", "ascii"), frameUtf8("1"), frameUtf8(registration.runId),
|
|
316
|
+
frameUtf8(registration.sessionId), frameUtf8(registration.coordinatorNonce),
|
|
317
|
+
frameUtf8(registration.agentId), frameUtf8(registration.processInstanceId),
|
|
318
|
+
frameUtf8(registration.targetSubject), frameUtf8(registration.schedulerVisibleProcessorCount.toString()),
|
|
319
|
+
frameUtf8(registration.sdkLanguage), frameUtf8(registration.sdkPackageVersion),
|
|
320
|
+
frameUtf8(registration.runtimeVersion)
|
|
321
|
+
];
|
|
322
|
+
for (const values of [
|
|
323
|
+
registration.schedulerVersions, registration.histogramVersions, registration.shardingVersions,
|
|
324
|
+
registration.resultChunkVersions, registration.iterationObservationSchemas,
|
|
325
|
+
registration.correlationSetVersions, registration.planHashVersions, registration.callbackExecutionModes
|
|
326
|
+
]) {
|
|
327
|
+
const ordered = canonicalV2CapabilityList(values, "registration capability");
|
|
328
|
+
fields.push(frameUtf8(ordered.length.toString()), ...ordered.map(frameUtf8));
|
|
329
|
+
}
|
|
330
|
+
fields.push(frameUtf8(registration.maximumResultChunkBytes.toString()), frameUtf8(registration.registrationSequence64), frameUtf8(registration.leaseIssuedUtcNs64), frameUtf8(registration.leaseExpiresUtcNs64));
|
|
331
|
+
return sha256Hex(Buffer.concat(fields));
|
|
332
|
+
}
|
|
333
|
+
function canonicalV2CapabilityList(values, field) {
|
|
334
|
+
if (!Array.isArray(values) || values.length > 32 || new Set(values).size !== values.length) {
|
|
335
|
+
throw new Error(`Load Engine V2 registration ${field} are invalid.`);
|
|
336
|
+
}
|
|
337
|
+
values.forEach((value) => validateBoundedV2Identifier(value, field, 128));
|
|
338
|
+
return [...values].sort(compareUtf8);
|
|
339
|
+
}
|
|
340
|
+
function canonicalV2CallbackModes(values) {
|
|
341
|
+
const result = canonicalV2CapabilityList(values, "callback execution modes");
|
|
342
|
+
if (!result.length || result.some((value) => value !== "async" && value !== "blocking")) {
|
|
343
|
+
throw new Error("Load Engine V2 registration callback execution modes are invalid.");
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
function acceptLoadEngineV2RegistrationRenewal(registrations, next, runId, sessionId, coordinatorNonce, nowUtcNs64) {
|
|
348
|
+
negotiateLoadEngineV2Registrations(runId, sessionId, coordinatorNonce, [next.agentId], [next], nowUtcNs64);
|
|
349
|
+
const current = registrations.get(next.agentId);
|
|
350
|
+
if (!current) {
|
|
351
|
+
registrations.set(next.agentId, next);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
const currentSequence = parseCanonicalNonNegativeInt64(current.registrationSequence64, "registration sequence");
|
|
355
|
+
const nextSequence = parseCanonicalNonNegativeInt64(next.registrationSequence64, "registration sequence");
|
|
356
|
+
if (nextSequence === currentSequence) {
|
|
357
|
+
if (!hashEquals(current.registrationBindingHash, next.registrationBindingHash)) {
|
|
358
|
+
throw new Error("Load Engine V2 registration has a conflicting retry for the same sequence.");
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
const immutableKeys = [
|
|
363
|
+
"runId", "sessionId", "coordinatorNonce", "agentId", "processInstanceId", "targetSubject",
|
|
364
|
+
"schedulerVisibleProcessorCount", "sdkLanguage", "sdkPackageVersion", "runtimeVersion",
|
|
365
|
+
"maximumResultChunkBytes"
|
|
366
|
+
];
|
|
367
|
+
const immutableLists = [
|
|
368
|
+
"schedulerVersions", "histogramVersions", "shardingVersions", "resultChunkVersions",
|
|
369
|
+
"iterationObservationSchemas", "correlationSetVersions", "planHashVersions", "callbackExecutionModes"
|
|
370
|
+
];
|
|
371
|
+
if (nextSequence !== currentSequence + 1n
|
|
372
|
+
|| immutableKeys.some((key) => current[key] !== next[key])
|
|
373
|
+
|| immutableLists.some((key) => JSON.stringify(current[key]) !== JSON.stringify(next[key]))
|
|
374
|
+
|| parseCanonicalNonNegativeInt64(next.leaseIssuedUtcNs64, "registration lease issue")
|
|
375
|
+
< parseCanonicalNonNegativeInt64(current.leaseIssuedUtcNs64, "registration lease issue")
|
|
376
|
+
|| parseCanonicalNonNegativeInt64(next.leaseExpiresUtcNs64, "registration lease expiry")
|
|
377
|
+
<= parseCanonicalNonNegativeInt64(current.leaseExpiresUtcNs64, "registration lease expiry")) {
|
|
378
|
+
throw new Error("Load Engine V2 registration lease renewal is stale or changed immutable metadata.");
|
|
379
|
+
}
|
|
380
|
+
registrations.set(next.agentId, next);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
async function refreshLoadEngineV2Registrations(consumer, registrations, runId, sessionId, coordinatorNonce, expectedAgentIds, timeoutMs, requiredFutureNs) {
|
|
384
|
+
const deadline = Date.now() + timeoutMs;
|
|
385
|
+
for (;;) {
|
|
386
|
+
const now = utcNowNs();
|
|
387
|
+
const live = expectedAgentIds.every((agentId) => {
|
|
388
|
+
const registration = registrations.get(agentId);
|
|
389
|
+
return registration && parseCanonicalNonNegativeInt64(registration.leaseExpiresUtcNs64, "registration lease expiry") > now + requiredFutureNs;
|
|
390
|
+
});
|
|
391
|
+
if (live) {
|
|
392
|
+
return negotiateLoadEngineV2Registrations(runId, sessionId, coordinatorNonce, expectedAgentIds, [...registrations.values()], now.toString());
|
|
393
|
+
}
|
|
394
|
+
if (Date.now() >= deadline) {
|
|
395
|
+
throw new Error("Load Engine V2 registration lease renewal timed out before the next commit.");
|
|
396
|
+
}
|
|
397
|
+
const payload = await consumer.consume();
|
|
398
|
+
const body = recordOrUndefined(payload?.body);
|
|
399
|
+
if (!body || !expectedAgentIds.includes(body.agentId)) {
|
|
400
|
+
await sleep(5);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
acceptLoadEngineV2RegistrationRenewal(registrations, body, runId, sessionId, coordinatorNonce, utcNowNs64());
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
export function buildLoadEngineV2Plan(input) {
|
|
407
|
+
const expected = normalizeExpectedAgentIds(input.expectedAgentIds);
|
|
408
|
+
validateBoundedV2Identifier(input.runId, "run ID", 128);
|
|
409
|
+
validateBoundedV2Identifier(input.sessionId, "session ID", 128);
|
|
410
|
+
validateBoundedV2Identifier(input.registrationNonce, "registration nonce", 128);
|
|
411
|
+
const maxInFlight = parseCanonicalPositiveInt64(input.maxInFlight64, "MaxInFlight");
|
|
412
|
+
const reportingInterval = parseCanonicalPositiveInt64(input.reportingIntervalNs64, "reporting interval");
|
|
413
|
+
const schedulerSeed = parseCanonicalNonNegativeInt64(input.schedulerSeed64 ?? "0", "scheduler seed");
|
|
414
|
+
const processorCount = parseCanonicalPositiveInt64(input.schedulerVisibleProcessorCount64 ?? "1", "scheduler-visible processor count");
|
|
415
|
+
const maxStatisticsStateBytes = parseV2Limit(input.maxStatisticsStateBytes64 ?? "536870912", "MaxStatisticsStateBytes", 67108864n, 4294967296n);
|
|
416
|
+
const maxDistributionSeries = parseV2Limit(input.maxDistributionSeries64 ?? "256", "MaxDistributionSeries", 16n, 100000n);
|
|
417
|
+
const maxCorrelationGroups = parseV2Limit(input.maxCorrelationGroups64 ?? "512", "MaxCorrelationGroups", 16n, 100000n);
|
|
418
|
+
const maxStatusGroups = parseV2Limit(input.maxStatusGroupsPerSeries64 ?? "64", "MaxStatusGroupsPerSeries", 8n, 10000n);
|
|
419
|
+
const maxPortalBytes = parseV2Limit(input.maxPortalRunDistributionBytes64 ?? "1073741824", "MaxPortalRunDistributionBytes", 134217728n, 8589934592n);
|
|
420
|
+
if (maxInFlight > 1000000n || reportingInterval > 9223372036854775807n
|
|
421
|
+
|| schedulerSeed > 4294967295n || processorCount > 2147483647n) {
|
|
422
|
+
throw new Error("Load Engine V2 plan settings exceed the supported non-correlation profile.");
|
|
423
|
+
}
|
|
424
|
+
const scenarios = [...input.scenarios].sort((left, right) => compareCanonicalIntegers(left.scenarioIndex64, right.scenarioIndex64));
|
|
425
|
+
if (!scenarios.length || new Set(scenarios.map((value) => value.scenarioIndex64)).size !== scenarios.length) {
|
|
426
|
+
throw new Error("Load Engine V2 requires unique selected scenario declaration indexes.");
|
|
427
|
+
}
|
|
428
|
+
const declaredSteps = new Map();
|
|
429
|
+
const portalSeries = [];
|
|
430
|
+
let simulationCount = 0n;
|
|
431
|
+
for (const scenario of scenarios) {
|
|
432
|
+
parseCanonicalNonNegativeInt64(scenario.scenarioIndex64, "scenario index");
|
|
433
|
+
validateBoundedV2Identifier(scenario.scenarioName, "scenario name", 1024);
|
|
434
|
+
if (scenario.target !== "agent") {
|
|
435
|
+
throw new Error("The supported non-correlation LS-SP1 profile requires agent-target scenarios.");
|
|
436
|
+
}
|
|
437
|
+
const mode = scenario.callbackExecutionMode ?? "async";
|
|
438
|
+
if (mode !== "async" && mode !== "blocking")
|
|
439
|
+
throw new Error("Load Engine V2 callback mode is invalid.");
|
|
440
|
+
const byIdentity = new Map();
|
|
441
|
+
for (const stepName of scenario.declaredStepNames ?? []) {
|
|
442
|
+
const row = buildV2DeclaredStep(scenario.scenarioIndex64, stepName);
|
|
443
|
+
const key = row.identity.toString("hex");
|
|
444
|
+
const prior = byIdentity.get(key);
|
|
445
|
+
if (!prior || Buffer.compare(row.display, prior.display) < 0)
|
|
446
|
+
byIdentity.set(key, row);
|
|
447
|
+
}
|
|
448
|
+
const steps = [...byIdentity.values()].sort((left, right) => Buffer.compare(left.identity, right.identity));
|
|
449
|
+
declaredSteps.set(scenario.scenarioIndex64, steps);
|
|
450
|
+
addV2MeasurementPortalSeries(portalSeries, buildV2Identity(0x01, scenario.scenarioIndex64));
|
|
451
|
+
for (const step of steps)
|
|
452
|
+
addV2MeasurementPortalSeries(portalSeries, step.identity);
|
|
453
|
+
addV2MeasurementPortalSeries(portalSeries, buildV2Identity(0x04, scenario.scenarioIndex64));
|
|
454
|
+
const simulations = [...scenario.simulations].sort((left, right) => compareCanonicalIntegers(left.simulationIndex64, right.simulationIndex64));
|
|
455
|
+
if (!simulations.length
|
|
456
|
+
|| new Set(simulations.map((value) => value.simulationIndex64)).size !== simulations.length) {
|
|
457
|
+
throw new Error("Load Engine V2 requires unique simulation indexes in every selected scenario.");
|
|
458
|
+
}
|
|
459
|
+
for (const simulation of simulations) {
|
|
460
|
+
validateV2Simulation(simulation);
|
|
461
|
+
addV2SchedulerPortalSeries(portalSeries, buildV2Identity(0x05, scenario.scenarioIndex64, simulation.simulationIndex64));
|
|
462
|
+
addV2SchedulerPortalSeries(portalSeries, buildV2Identity(0x06, scenario.scenarioIndex64, simulation.simulationIndex64));
|
|
463
|
+
simulationCount += 1n;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const staticMeasurementCount = 2n * BigInt(scenarios.length)
|
|
467
|
+
+ [...declaredSteps.values()].reduce((sum, rows) => sum + BigInt(rows.length), 0n);
|
|
468
|
+
if (staticMeasurementCount > maxDistributionSeries) {
|
|
469
|
+
throw new Error("Load Engine V2 static scenario/step identities exceed MaxDistributionSeries.");
|
|
470
|
+
}
|
|
471
|
+
portalSeries.sort((left, right) => Buffer.compare(Buffer.from(left.seriesKeyHex, "hex"), Buffer.from(right.seriesKeyHex, "hex")) || compareUtf8(left.unit, right.unit) || compareUtf8(left.outcome, right.outcome));
|
|
472
|
+
if (BigInt(portalSeries.length) !== 6n * staticMeasurementCount + 2n * simulationCount) {
|
|
473
|
+
throw new Error("Load Engine V2 frozen portal series enumeration is incomplete.");
|
|
474
|
+
}
|
|
475
|
+
const exactPlanStringBytes = calculateV2ExactPlanStringBytes(input, expected, portalSeries, declaredSteps);
|
|
476
|
+
const globalAggregateScratch = calculateV2StatisticsReservation(maxDistributionSeries, simulationCount, maxStatusGroups, exactPlanStringBytes);
|
|
477
|
+
if (globalAggregateScratch > maxStatisticsStateBytes) {
|
|
478
|
+
throw new Error(`Load Engine V2 statistics reservation ${globalAggregateScratch} exceeds configured ${maxStatisticsStateBytes}.`);
|
|
479
|
+
}
|
|
480
|
+
const resultOwnerCount = BigInt(expected.length);
|
|
481
|
+
const receiverScratch = checkedV2Sum(globalAggregateScratch, 262144n, 131072n);
|
|
482
|
+
let effectivePortalPointLimit = 0n;
|
|
483
|
+
let portalLiveStateBytes = 0n;
|
|
484
|
+
for (let points = 1000n; points >= 2n; points -= 1n) {
|
|
485
|
+
const candidate = calculateV2PortalLiveState(BigInt(portalSeries.length), resultOwnerCount, points);
|
|
486
|
+
if (candidate + receiverScratch <= maxPortalBytes) {
|
|
487
|
+
effectivePortalPointLimit = points;
|
|
488
|
+
portalLiveStateBytes = candidate;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (effectivePortalPointLimit === 0n) {
|
|
493
|
+
throw new Error("Load Engine V2 portal reservation cannot retain the two protected endpoints.");
|
|
494
|
+
}
|
|
495
|
+
const maxEncodedNonDetailArtifactBytes = 67108864n;
|
|
496
|
+
const maxEncodedRecoveryOverlayBytes = 4096n;
|
|
497
|
+
const latestPerOwner = 262144n + maxEncodedNonDetailArtifactBytes;
|
|
498
|
+
const latestSnapshotSpoolBytes = BigInt(expected.length)
|
|
499
|
+
* (latestPerOwner + maxEncodedRecoveryOverlayBytes)
|
|
500
|
+
+ (latestPerOwner > maxEncodedRecoveryOverlayBytes ? latestPerOwner : maxEncodedRecoveryOverlayBytes);
|
|
501
|
+
const blocking = scenarios.some((scenario) => (scenario.callbackExecutionMode ?? "async") === "blocking");
|
|
502
|
+
const configuredBlockingWorkers = blocking
|
|
503
|
+
? minimumBigIntV2(maxInFlight, 1024n, maximumBigIntV2(32n, processorCount * 32n))
|
|
504
|
+
: 0n;
|
|
505
|
+
const fields = [Buffer.from("LS-SP1\n", "ascii")];
|
|
506
|
+
const write = (value) => { fields.push(frameUtf8(value)); };
|
|
507
|
+
[
|
|
508
|
+
"1", input.runId, input.sessionId, input.registrationNonce, "2", "2", "1", "1", "", "1",
|
|
509
|
+
"131072", "0", "", "", "", "0", schedulerSeed.toString(), "global",
|
|
510
|
+
maxInFlight.toString(), reportingInterval.toString(), "0", maxStatisticsStateBytes.toString(),
|
|
511
|
+
maxDistributionSeries.toString(), maxCorrelationGroups.toString(), maxStatusGroups.toString(),
|
|
512
|
+
maxPortalBytes.toString(), "0", "0", "0", "0", "0", "0",
|
|
513
|
+
"0", "-1", "0", "0", "0", "0", "0", resultOwnerCount.toString(),
|
|
514
|
+
expected.length.toString(), portalSeries.length.toString(), effectivePortalPointLimit.toString(),
|
|
515
|
+
globalAggregateScratch.toString(), portalLiveStateBytes.toString(), receiverScratch.toString(),
|
|
516
|
+
latestSnapshotSpoolBytes.toString(), "0", expected.length.toString()
|
|
517
|
+
].forEach(write);
|
|
518
|
+
expected.forEach((agentId, processGroupIndex) => {
|
|
519
|
+
[
|
|
520
|
+
agentId, processGroupIndex.toString(), "1", processorCount.toString(),
|
|
521
|
+
configuredBlockingWorkers.toString(), maxEncodedNonDetailArtifactBytes.toString(),
|
|
522
|
+
maxEncodedRecoveryOverlayBytes.toString(), "0"
|
|
523
|
+
].forEach(write);
|
|
524
|
+
});
|
|
525
|
+
portalSeries.forEach((series) => {
|
|
526
|
+
write(series.seriesKeyHex);
|
|
527
|
+
write(series.unit);
|
|
528
|
+
write(series.outcome);
|
|
529
|
+
});
|
|
530
|
+
write(scenarios.length.toString());
|
|
531
|
+
for (const scenario of scenarios) {
|
|
532
|
+
const mode = scenario.callbackExecutionMode ?? "async";
|
|
533
|
+
write(scenario.scenarioIndex64);
|
|
534
|
+
write(scenario.scenarioName);
|
|
535
|
+
write(scenario.target);
|
|
536
|
+
write("");
|
|
537
|
+
["", "", "-1", "0", ""].forEach(write);
|
|
538
|
+
const steps = declaredSteps.get(scenario.scenarioIndex64) ?? [];
|
|
539
|
+
write(steps.length.toString());
|
|
540
|
+
steps.forEach((step) => {
|
|
541
|
+
write(step.identity.toString("hex"));
|
|
542
|
+
write(step.display.toString("hex"));
|
|
543
|
+
});
|
|
544
|
+
write("0");
|
|
545
|
+
write(expected.length.toString());
|
|
546
|
+
expected.forEach((agentId, shardIndex) => {
|
|
547
|
+
write(agentId);
|
|
548
|
+
write(shardIndex.toString());
|
|
549
|
+
write(expected.length.toString());
|
|
550
|
+
write(mode);
|
|
551
|
+
});
|
|
552
|
+
const simulations = [...scenario.simulations].sort((left, right) => compareCanonicalIntegers(left.simulationIndex64, right.simulationIndex64));
|
|
553
|
+
write(simulations.length.toString());
|
|
554
|
+
for (const simulation of simulations) {
|
|
555
|
+
write(simulation.simulationIndex64);
|
|
556
|
+
write("-1");
|
|
557
|
+
write("");
|
|
558
|
+
write(simulation.kind);
|
|
559
|
+
[simulation.rate64, simulation.minRate64, simulation.maxRate64, simulation.copies64,
|
|
560
|
+
simulation.iterations64, simulation.intervalNs64, simulation.durationNs64, "0"].forEach(write);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const bytes = Buffer.concat(fields);
|
|
564
|
+
return { bytes, hash: sha256Hex(bytes), expectedAgentIds: expected, portalSeries };
|
|
565
|
+
}
|
|
566
|
+
/** Canonical LoadStrikeIdentityKeyV1 scenario bytes. */
|
|
567
|
+
export function buildLoadEngineV2ScenarioIdentityKey(scenarioIndex64) {
|
|
568
|
+
parseCanonicalNonNegativeInt64(scenarioIndex64, "scenario identity index");
|
|
569
|
+
return buildV2Identity(0x01, scenarioIndex64);
|
|
570
|
+
}
|
|
571
|
+
/** Canonical LoadStrikeIdentityKeyV1 reserved step-other bytes. */
|
|
572
|
+
export function buildLoadEngineV2ReservedStepOtherIdentityKey(scenarioIndex64) {
|
|
573
|
+
parseCanonicalNonNegativeInt64(scenarioIndex64, "reserved step identity index");
|
|
574
|
+
return buildV2Identity(0x04, scenarioIndex64);
|
|
575
|
+
}
|
|
576
|
+
export function buildLoadEngineV2StepIdentityKey(scenarioIndex64, stepName) {
|
|
577
|
+
parseCanonicalNonNegativeInt64(scenarioIndex64, "step identity index");
|
|
578
|
+
const row = buildV2DeclaredStep(scenarioIndex64, stepName);
|
|
579
|
+
return { identity: row.identity, display: new TextDecoder("utf-8", { fatal: true }).decode(row.display) };
|
|
580
|
+
}
|
|
581
|
+
/** Resolves a runtime step to its frozen signed identity or the reserved `<other>` bucket. */
|
|
582
|
+
export function resolveLoadEngineV2StepIdentityKey(scenarioIndex64, stepName, declaredStepNames) {
|
|
583
|
+
const observed = buildV2DeclaredStep(scenarioIndex64, stepName);
|
|
584
|
+
let signedDisplay;
|
|
585
|
+
for (const declaredName of declaredStepNames) {
|
|
586
|
+
const declared = buildV2DeclaredStep(scenarioIndex64, declaredName);
|
|
587
|
+
if (!declared.identity.equals(observed.identity))
|
|
588
|
+
continue;
|
|
589
|
+
if (!signedDisplay || Buffer.compare(declared.display, signedDisplay) < 0) {
|
|
590
|
+
signedDisplay = declared.display;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!signedDisplay) {
|
|
594
|
+
return {
|
|
595
|
+
identity: buildLoadEngineV2ReservedStepOtherIdentityKey(scenarioIndex64),
|
|
596
|
+
display: "<other>",
|
|
597
|
+
routedToOther: true
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
identity: observed.identity,
|
|
602
|
+
display: new TextDecoder("utf-8", { fatal: true }).decode(signedDisplay),
|
|
603
|
+
routedToOther: false
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
export function buildLoadEngineV2SchedulerIdentityKey(kind, scenarioIndex64, simulationIndex64) {
|
|
607
|
+
parseCanonicalNonNegativeInt64(scenarioIndex64, "scheduler identity scenario index");
|
|
608
|
+
parseCanonicalNonNegativeInt64(simulationIndex64, "scheduler identity simulation index");
|
|
609
|
+
return buildV2Identity(kind === "decision" ? 0x05 : 0x06, scenarioIndex64, simulationIndex64);
|
|
610
|
+
}
|
|
611
|
+
export function buildLoadEngineV2StatusIdentityKey(status, message) {
|
|
612
|
+
validateUnicodeScalars(status);
|
|
613
|
+
validateUnicodeScalars(message);
|
|
614
|
+
if (!status && !message)
|
|
615
|
+
return undefined;
|
|
616
|
+
const payload = Buffer.concat([Buffer.from("LS-ST1\n", "ascii"), frameUtf8(status), frameUtf8(message)]);
|
|
617
|
+
const digest = sha256Hex(payload);
|
|
618
|
+
const identity = payload.length <= 1024
|
|
619
|
+
? buildV2Identity(0x0b, status, message)
|
|
620
|
+
: buildV2Identity(0x0c, payload.length.toString(), digest);
|
|
621
|
+
const source = !message ? status : !status ? message : `${status}: ${message}`;
|
|
622
|
+
let prefix = "";
|
|
623
|
+
for (const scalar of source) {
|
|
624
|
+
if (Buffer.byteLength(prefix + scalar, "utf8") > 512)
|
|
625
|
+
break;
|
|
626
|
+
prefix += scalar;
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
identity,
|
|
630
|
+
display: Buffer.byteLength(source, "utf8") <= 512 ? source : `${prefix}…#${digest.slice(0, 32)}`
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/** Canonical LoadStrikePortalSeriesKeyV1 bytes. */
|
|
634
|
+
export function buildLoadEngineV2PortalSeriesKey(identity, unit, outcome) {
|
|
635
|
+
if (!identity.length)
|
|
636
|
+
throw new Error("Load Engine V2 portal identity cannot be empty.");
|
|
637
|
+
return Buffer.concat([
|
|
638
|
+
Buffer.from("LS-PS1\n", "ascii"),
|
|
639
|
+
frameUtf8("1"),
|
|
640
|
+
frameBytes(Buffer.from(identity)),
|
|
641
|
+
frameUtf8(unit),
|
|
642
|
+
frameUtf8(outcome)
|
|
643
|
+
]);
|
|
644
|
+
}
|
|
645
|
+
function buildV2Identity(tag, ...values) {
|
|
646
|
+
if (!Number.isInteger(tag) || tag < 1 || tag > 0x0e) {
|
|
647
|
+
throw new Error("Load Engine V2 identity tag is invalid.");
|
|
648
|
+
}
|
|
649
|
+
return Buffer.concat([
|
|
650
|
+
Buffer.from("LS-ID1\n", "ascii"),
|
|
651
|
+
Buffer.from([tag]),
|
|
652
|
+
...values.map(frameUtf8)
|
|
653
|
+
]);
|
|
654
|
+
}
|
|
655
|
+
function buildV2DeclaredStep(scenarioIndex64, stepName) {
|
|
656
|
+
validateUnicodeScalars(stepName);
|
|
657
|
+
const bytes = Buffer.from(stepName, "utf8");
|
|
658
|
+
if (bytes.length <= 1024) {
|
|
659
|
+
return { identity: buildV2Identity(0x02, scenarioIndex64, stepName), display: bytes };
|
|
660
|
+
}
|
|
661
|
+
const digest = sha256Hex(bytes);
|
|
662
|
+
let prefix = "";
|
|
663
|
+
for (const scalar of stepName) {
|
|
664
|
+
if (Buffer.byteLength(prefix + scalar, "utf8") > 512)
|
|
665
|
+
break;
|
|
666
|
+
prefix += scalar;
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
identity: buildV2Identity(0x03, scenarioIndex64, bytes.length.toString(), digest),
|
|
670
|
+
display: Buffer.from(`${prefix}…#${digest.slice(0, 32)}`, "utf8")
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function addV2MeasurementPortalSeries(target, identity) {
|
|
674
|
+
for (const unit of ["microseconds", "bytes"]) {
|
|
675
|
+
for (const outcome of ["ok", "fail", "all"]) {
|
|
676
|
+
target.push({
|
|
677
|
+
seriesKeyHex: buildLoadEngineV2PortalSeriesKey(identity, unit, outcome).toString("hex"),
|
|
678
|
+
unit,
|
|
679
|
+
outcome
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function addV2SchedulerPortalSeries(target, identity) {
|
|
685
|
+
target.push({
|
|
686
|
+
seriesKeyHex: buildLoadEngineV2PortalSeriesKey(identity, "microseconds", "none").toString("hex"),
|
|
687
|
+
unit: "microseconds",
|
|
688
|
+
outcome: "none"
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
function validateV2Simulation(simulation) {
|
|
692
|
+
const allowed = new Set([
|
|
693
|
+
"inject", "inject-random", "ramping-inject", "keep-constant", "ramping-constant",
|
|
694
|
+
"iterations-for-inject", "iterations-for-constant", "pause"
|
|
695
|
+
]);
|
|
696
|
+
if (!allowed.has(simulation.kind))
|
|
697
|
+
throw new Error("Load Engine V2 simulation kind is invalid.");
|
|
698
|
+
[simulation.simulationIndex64, simulation.rate64, simulation.minRate64,
|
|
699
|
+
simulation.maxRate64, simulation.copies64, simulation.iterations64,
|
|
700
|
+
simulation.intervalNs64, simulation.durationNs64]
|
|
701
|
+
.forEach((value) => parseCanonicalNonNegativeInt64(value, "simulation plan integer"));
|
|
702
|
+
}
|
|
703
|
+
function calculateV2ExactPlanStringBytes(input, expectedAgentIds, portalSeries, declaredSteps) {
|
|
704
|
+
let total = 0n;
|
|
705
|
+
const addText = (value) => { total += BigInt(Buffer.byteLength(value, "utf8")); };
|
|
706
|
+
[input.runId, input.sessionId, input.registrationNonce, ...expectedAgentIds].forEach(addText);
|
|
707
|
+
for (const scenario of input.scenarios) {
|
|
708
|
+
addText(scenario.scenarioName);
|
|
709
|
+
// Agent-target scenarios have no coordinator callback mode. Each signed
|
|
710
|
+
// participant owns both its agent ID and callback execution mode bytes.
|
|
711
|
+
for (const agentId of expectedAgentIds) {
|
|
712
|
+
addText(agentId);
|
|
713
|
+
addText(scenario.callbackExecutionMode ?? "async");
|
|
714
|
+
}
|
|
715
|
+
for (const simulation of scenario.simulations)
|
|
716
|
+
addText(simulation.kind);
|
|
717
|
+
for (const step of declaredSteps.get(scenario.scenarioIndex64) ?? []) {
|
|
718
|
+
total += BigInt(step.identity.length + step.display.length);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const series of portalSeries) {
|
|
722
|
+
total += BigInt(series.seriesKeyHex.length / 2);
|
|
723
|
+
addText(series.unit);
|
|
724
|
+
addText(series.outcome);
|
|
725
|
+
}
|
|
726
|
+
if (total > 9223372036854775807n)
|
|
727
|
+
throw new Error("Load Engine V2 plan strings exceed signed storage.");
|
|
728
|
+
return total;
|
|
729
|
+
}
|
|
730
|
+
function calculateV2StatisticsReservation(maxDistributionSeries, simulationCount, maxStatusGroups, exactPlanStringBytes) {
|
|
731
|
+
return checkedV2Sum(maxDistributionSeries * 786432n, maxDistributionSeries * (256n + 1088n), 2n * maxDistributionSeries * maxStatusGroups * (128n + 1664n), simulationCount * 212992n, 16n * 1024n * 1024n, 1024n * 1024n, exactPlanStringBytes);
|
|
732
|
+
}
|
|
733
|
+
function calculateV2PortalLiveState(frozenSeriesCount, resultOwnerCount, pointLimit) {
|
|
734
|
+
return checkedV2Sum(frozenSeriesCount * ((resultOwnerCount + 1n) * 98304n + pointLimit * 256n + 4096n), resultOwnerCount * 4096n, 1048576n);
|
|
735
|
+
}
|
|
736
|
+
function checkedV2Sum(...values) {
|
|
737
|
+
let result = 0n;
|
|
738
|
+
for (const value of values) {
|
|
739
|
+
if (value < 0n)
|
|
740
|
+
throw new Error("Load Engine V2 resource calculation became negative.");
|
|
741
|
+
result += value;
|
|
742
|
+
if (result > 9223372036854775807n) {
|
|
743
|
+
throw new Error("Load Engine V2 resource calculation exceeds signed 64-bit storage.");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return result;
|
|
747
|
+
}
|
|
748
|
+
function parseV2Limit(value, field, minimum, maximum) {
|
|
749
|
+
const parsed = parseCanonicalNonNegativeInt64(value, field);
|
|
750
|
+
if (parsed < minimum || parsed > maximum) {
|
|
751
|
+
throw new Error(`${field} must be from ${minimum} through ${maximum}.`);
|
|
752
|
+
}
|
|
753
|
+
return parsed;
|
|
754
|
+
}
|
|
755
|
+
function parseCanonicalPositiveInt64(value, field) {
|
|
756
|
+
const parsed = parseCanonicalNonNegativeInt64(value, field);
|
|
757
|
+
if (parsed === 0n)
|
|
758
|
+
throw new Error(`${field} must be positive.`);
|
|
759
|
+
return parsed;
|
|
760
|
+
}
|
|
761
|
+
function validateBoundedV2Identifier(value, field, maximumBytes) {
|
|
762
|
+
validateUnicodeScalars(value);
|
|
763
|
+
const length = Buffer.byteLength(value, "utf8");
|
|
764
|
+
if (length === 0 || length > maximumBytes) {
|
|
765
|
+
throw new Error(`Load Engine V2 ${field} must be from 1 through ${maximumBytes} UTF-8 bytes.`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function frameBytes(value) {
|
|
769
|
+
if (value.length > 4294967295)
|
|
770
|
+
throw new Error("Load Engine V2 canonical bytes exceed the framing limit.");
|
|
771
|
+
const frame = Buffer.allocUnsafe(4 + value.length);
|
|
772
|
+
frame.writeUInt32BE(value.length, 0);
|
|
773
|
+
value.copy(frame, 4);
|
|
774
|
+
return frame;
|
|
775
|
+
}
|
|
776
|
+
function minimumBigIntV2(...values) {
|
|
777
|
+
return values.reduce((minimum, value) => value < minimum ? value : minimum);
|
|
778
|
+
}
|
|
779
|
+
function maximumBigIntV2(...values) {
|
|
780
|
+
return values.reduce((maximum, value) => value > maximum ? value : maximum);
|
|
781
|
+
}
|
|
782
|
+
function orderedLoadEngineV2Segments(input) {
|
|
783
|
+
return [...input.scenarios]
|
|
784
|
+
.sort((left, right) => compareCanonicalIntegers(left.scenarioIndex64, right.scenarioIndex64))
|
|
785
|
+
.flatMap((scenario) => [...scenario.simulations]
|
|
786
|
+
.sort((left, right) => compareCanonicalIntegers(left.simulationIndex64, right.simulationIndex64))
|
|
787
|
+
.map((simulation) => ({
|
|
788
|
+
scenarioIndex64: scenario.scenarioIndex64,
|
|
789
|
+
simulationIndex64: simulation.simulationIndex64
|
|
790
|
+
})));
|
|
791
|
+
}
|
|
792
|
+
export function buildLoadEngineV2BarrierScopeId(planHash, scope) {
|
|
793
|
+
validateLowercaseHash(planHash, "plan hash");
|
|
794
|
+
const fields = [Buffer.from("LS-BS1\n", "ascii"), frameUtf8(planHash), frameUtf8(scope.kind)];
|
|
795
|
+
if (scope.kind === "scenario") {
|
|
796
|
+
parseCanonicalNonNegativeInt64(scope.scenarioIndex64, "barrier scenario index");
|
|
797
|
+
parseCanonicalNonNegativeInt64(scope.simulationIndex64, "barrier simulation index");
|
|
798
|
+
fields.push(frameUtf8(scope.scenarioIndex64), frameUtf8(scope.simulationIndex64));
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
validateLowercaseHash(scope.trafficMixId, "traffic mix id");
|
|
802
|
+
parseCanonicalNonNegativeInt64(scope.trafficMixPhaseIndex64, "traffic mix phase index");
|
|
803
|
+
fields.push(frameUtf8(scope.trafficMixId), frameUtf8(scope.trafficMixPhaseIndex64));
|
|
804
|
+
}
|
|
805
|
+
return sha256Hex(Buffer.concat(fields));
|
|
806
|
+
}
|
|
807
|
+
export function buildLoadEngineV2AssignmentHash(barrierScopeId, assignments) {
|
|
808
|
+
validateLowercaseHash(barrierScopeId, "barrier scope id");
|
|
809
|
+
if (!assignments.length)
|
|
810
|
+
throw new Error("Load Engine V2 barrier assignment requires participants.");
|
|
811
|
+
const ordered = [...assignments].sort(compareBarrierAssignments);
|
|
812
|
+
const fields = [Buffer.from("LS-BA1\n", "ascii")];
|
|
813
|
+
const write = (value) => { fields.push(frameUtf8(value)); };
|
|
814
|
+
write("1");
|
|
815
|
+
write(barrierScopeId);
|
|
816
|
+
write(ordered.length.toString());
|
|
817
|
+
const identities = new Set();
|
|
818
|
+
for (const assignment of ordered) {
|
|
819
|
+
validateLoadEngineV2AgentId(assignment.agentId);
|
|
820
|
+
parseCanonicalNonNegativeInt64(assignment.scenarioIndex64, "scenario index");
|
|
821
|
+
parseCanonicalNonNegativeInt64(assignment.simulationIndex64, "simulation index");
|
|
822
|
+
const lane = assignment.trafficMixLaneIndex64 ?? "-1";
|
|
823
|
+
const phase = assignment.trafficMixPhaseIndex64 ?? "-1";
|
|
824
|
+
parseCanonicalInt64(lane, "traffic mix lane index");
|
|
825
|
+
parseCanonicalInt64(phase, "traffic mix phase index");
|
|
826
|
+
parseCanonicalNonNegativeInt64(assignment.shardIndex64, "shard index");
|
|
827
|
+
const shardCount = parseCanonicalNonNegativeInt64(assignment.shardCount64, "shard count");
|
|
828
|
+
if (shardCount <= 0n || BigInt(assignment.shardIndex64) >= shardCount) {
|
|
829
|
+
throw new Error("Load Engine V2 shard assignment is invalid.");
|
|
830
|
+
}
|
|
831
|
+
const identity = [assignment.scenarioIndex64, assignment.simulationIndex64,
|
|
832
|
+
assignment.trafficMixId ?? "", lane, phase, assignment.agentId, assignment.shardIndex64].join("\u0000");
|
|
833
|
+
if (identities.has(identity))
|
|
834
|
+
throw new Error("Load Engine V2 barrier assignment contains a duplicate record.");
|
|
835
|
+
identities.add(identity);
|
|
836
|
+
write(assignment.scenarioIndex64);
|
|
837
|
+
write(assignment.simulationIndex64);
|
|
838
|
+
write(assignment.trafficMixId ?? "");
|
|
839
|
+
write(lane);
|
|
840
|
+
write(phase);
|
|
841
|
+
write(assignment.agentId);
|
|
842
|
+
write(assignment.shardIndex64);
|
|
843
|
+
write(assignment.shardCount64);
|
|
844
|
+
}
|
|
845
|
+
return sha256Hex(Buffer.concat(fields));
|
|
846
|
+
}
|
|
847
|
+
function compareBarrierAssignments(left, right) {
|
|
848
|
+
const numeric = (a, b) => {
|
|
849
|
+
const av = parseCanonicalInt64(a, "barrier assignment index");
|
|
850
|
+
const bv = parseCanonicalInt64(b, "barrier assignment index");
|
|
851
|
+
return av < bv ? -1 : av > bv ? 1 : 0;
|
|
852
|
+
};
|
|
853
|
+
return numeric(left.scenarioIndex64, right.scenarioIndex64)
|
|
854
|
+
|| numeric(left.simulationIndex64, right.simulationIndex64)
|
|
855
|
+
|| numeric(left.trafficMixLaneIndex64 ?? "-1", right.trafficMixLaneIndex64 ?? "-1")
|
|
856
|
+
|| compareAgentIds(left.agentId, right.agentId)
|
|
857
|
+
|| numeric(left.shardIndex64, right.shardIndex64);
|
|
858
|
+
}
|
|
859
|
+
export function evaluateLoadEngineV2ClockProbe(sample) {
|
|
860
|
+
if (!Number.isInteger(sample.probeIndex) || sample.probeIndex < 0 || sample.probeIndex > 4) {
|
|
861
|
+
throw new Error("Load Engine V2 clock probe index must be from zero through four.");
|
|
862
|
+
}
|
|
863
|
+
const c0 = parseCanonicalInt64(sample.coordinatorSendUtcNs64, "clock c0");
|
|
864
|
+
const a1 = parseCanonicalInt64(sample.agentReceiveUtcNs64, "clock a1");
|
|
865
|
+
const a2 = parseCanonicalInt64(sample.agentSendUtcNs64, "clock a2");
|
|
866
|
+
const c3 = parseCanonicalInt64(sample.coordinatorReceiveUtcNs64, "clock c3");
|
|
867
|
+
const coordinatorResolution = parseCanonicalNonNegativeInt64(sample.coordinatorClockResolutionNs64, "coordinator clock resolution");
|
|
868
|
+
const agentResolution = parseCanonicalNonNegativeInt64(sample.agentClockResolutionNs64, "agent clock resolution");
|
|
869
|
+
if (c3 < c0 || a2 < a1)
|
|
870
|
+
throw new Error("Load Engine V2 clock probe timestamps moved backwards.");
|
|
871
|
+
const roundTrip = (c3 - c0) - (a2 - a1);
|
|
872
|
+
if (roundTrip < 0n)
|
|
873
|
+
throw new Error("Load Engine V2 clock probe round trip is negative.");
|
|
874
|
+
const offset = ((a1 - c0) + (a2 - c3)) / 2n;
|
|
875
|
+
const uncertainty = ((roundTrip + 1n) / 2n) + coordinatorResolution + agentResolution;
|
|
876
|
+
return {
|
|
877
|
+
...sample,
|
|
878
|
+
roundTripNs64: roundTrip.toString(),
|
|
879
|
+
offsetAgentMinusCoordinatorNs64: offset.toString(),
|
|
880
|
+
uncertaintyNs64: uncertainty.toString()
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
export function selectLoadEngineV2ClockProbe(samples, latenessToleranceNs64) {
|
|
884
|
+
if (samples.length !== 5 || new Set(samples.map((value) => value.probeIndex)).size !== 5) {
|
|
885
|
+
throw new Error("Load Engine V2 clock negotiation requires exactly five distinct probes.");
|
|
886
|
+
}
|
|
887
|
+
const selected = samples.map(evaluateLoadEngineV2ClockProbe).sort((left, right) => {
|
|
888
|
+
const uncertainty = parseCanonicalInt64(left.uncertaintyNs64, "uncertainty")
|
|
889
|
+
- parseCanonicalInt64(right.uncertaintyNs64, "uncertainty");
|
|
890
|
+
if (uncertainty !== 0n)
|
|
891
|
+
return uncertainty < 0n ? -1 : 1;
|
|
892
|
+
const roundTrip = parseCanonicalInt64(left.roundTripNs64, "round trip")
|
|
893
|
+
- parseCanonicalInt64(right.roundTripNs64, "round trip");
|
|
894
|
+
return roundTrip === 0n ? left.probeIndex - right.probeIndex : roundTrip < 0n ? -1 : 1;
|
|
895
|
+
})[0];
|
|
896
|
+
let bound = 25000000n;
|
|
897
|
+
if (latenessToleranceNs64 !== undefined) {
|
|
898
|
+
const tolerance = parseCanonicalNonNegativeInt64(latenessToleranceNs64, "lateness tolerance");
|
|
899
|
+
if (tolerance < bound)
|
|
900
|
+
bound = tolerance;
|
|
901
|
+
}
|
|
902
|
+
if (parseCanonicalInt64(selected.uncertaintyNs64, "clock uncertainty") > bound) {
|
|
903
|
+
throw new Error("Load Engine V2 clock uncertainty exceeds the permitted start-barrier bound.");
|
|
904
|
+
}
|
|
905
|
+
return selected;
|
|
906
|
+
}
|
|
907
|
+
export function validateLoadEngineV2BarrierAcks(phase, expectedAgentIds, acknowledgements, planHash, assignmentHash, barrierEpoch64, nowUtcNs64, barrierScopeId) {
|
|
908
|
+
const expected = normalizeExpectedAgentIds(expectedAgentIds);
|
|
909
|
+
const ordered = [...acknowledgements].sort((left, right) => compareAgentIds(left.agentId, right.agentId));
|
|
910
|
+
if (ordered.length !== expected.length || ordered.some((value, index) => value.agentId !== expected[index])) {
|
|
911
|
+
throw new Error(`Load Engine V2 ${phase} barrier is missing an exact participant acknowledgement.`);
|
|
912
|
+
}
|
|
913
|
+
const now = parseCanonicalInt64(nowUtcNs64, "barrier validation time");
|
|
914
|
+
for (const acknowledgement of ordered) {
|
|
915
|
+
const deadline = parseCanonicalInt64(acknowledgement.deadlineUtcNs64, "barrier deadline");
|
|
916
|
+
const acknowledged = parseCanonicalInt64(acknowledgement.acknowledgedUtcNs64, "barrier acknowledgement time");
|
|
917
|
+
if (!acknowledgement.accepted || acknowledgement.phase !== phase
|
|
918
|
+
|| acknowledgement.planHash !== planHash || acknowledgement.assignmentHash !== assignmentHash
|
|
919
|
+
|| (barrierScopeId !== undefined && acknowledgement.barrierScopeId !== barrierScopeId)
|
|
920
|
+
|| acknowledgement.barrierEpoch64 !== barrierEpoch64 || acknowledged >= deadline) {
|
|
921
|
+
throw new Error(`Load Engine V2 ${phase} barrier acknowledgement is invalid.`);
|
|
922
|
+
}
|
|
923
|
+
if (now >= deadline)
|
|
924
|
+
throw new Error(`Load Engine V2 ${phase} barrier deadline has elapsed.`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const LOAD_ENGINE_V2_RESULT_EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
928
|
+
const LOAD_ENGINE_V2_MAX_MANIFEST_BYTES = 262144;
|
|
929
|
+
const LOAD_ENGINE_V2_MAX_MANIFEST_ENTRIES = 1029;
|
|
930
|
+
const LOAD_ENGINE_V2_MAX_DETAIL_PARTS = 1024;
|
|
931
|
+
const LOAD_ENGINE_V2_MAIN_ARTIFACTS = [
|
|
932
|
+
["scheduler", "scheduler-snapshot-v2"],
|
|
933
|
+
["histograms", "histogram-set-v1"],
|
|
934
|
+
["correlations", "correlation-set-v1"],
|
|
935
|
+
["portal-deltas", "portal-delta-set-v1"],
|
|
936
|
+
["correlation-detail-index", "correlation-detail-index-v1"]
|
|
937
|
+
];
|
|
938
|
+
export function loadEngineV2PresentArtifact(kind, artifactId, schema, payload) {
|
|
939
|
+
return { kind, artifactId, schema, applicability: "present", payload: Buffer.from(payload) };
|
|
940
|
+
}
|
|
941
|
+
export function loadEngineV2EmptyArtifact(kind, artifactId, schema) {
|
|
942
|
+
return { kind, artifactId, schema, applicability: "empty", payload: Buffer.alloc(0) };
|
|
943
|
+
}
|
|
944
|
+
export function loadEngineV2NotApplicableArtifact(kind, artifactId) {
|
|
945
|
+
return { kind, artifactId, schema: "0", applicability: "not-applicable", payload: Buffer.alloc(0) };
|
|
946
|
+
}
|
|
947
|
+
export function createLoadEngineV2ResultSnapshot(runId, sessionId, resultOwnerId, snapshotSequence64, planHash, isFinal, chunkBytes, artifacts) {
|
|
948
|
+
validateV2ResultScope(runId, "run ID");
|
|
949
|
+
validateV2ResultScope(sessionId, "session ID");
|
|
950
|
+
validateV2ResultOwner(resultOwnerId);
|
|
951
|
+
validateLowercaseHash(planHash, "plan hash");
|
|
952
|
+
parseCanonicalNonNegativeInt64(snapshotSequence64, "snapshot sequence");
|
|
953
|
+
validateV2ResultChunkBound(chunkBytes);
|
|
954
|
+
const source = artifacts.map((artifact) => ({ ...artifact, payload: Buffer.from(artifact.payload) }));
|
|
955
|
+
validateV2ResultArtifactOrder(source);
|
|
956
|
+
const entries = source.map((artifact) => {
|
|
957
|
+
if (artifact.applicability === "present" && artifact.payload.length > 0) {
|
|
958
|
+
return {
|
|
959
|
+
kind: artifact.kind, artifactId: artifact.artifactId, schema: artifact.schema,
|
|
960
|
+
applicability: "present", uncompressedLength64: artifact.payload.length.toString(),
|
|
961
|
+
chunkCount: Math.ceil(artifact.payload.length / chunkBytes),
|
|
962
|
+
artifactSha256: sha256Hex(artifact.payload)
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
if (artifact.applicability === "empty" && artifact.payload.length === 0) {
|
|
966
|
+
return {
|
|
967
|
+
kind: artifact.kind, artifactId: artifact.artifactId, schema: artifact.schema,
|
|
968
|
+
applicability: "empty", uncompressedLength64: "0", chunkCount: 0,
|
|
969
|
+
artifactSha256: LOAD_ENGINE_V2_RESULT_EMPTY_SHA256
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
if (artifact.applicability === "not-applicable" && artifact.schema === "0"
|
|
973
|
+
&& artifact.payload.length === 0) {
|
|
974
|
+
return {
|
|
975
|
+
kind: artifact.kind, artifactId: artifact.artifactId, schema: "0",
|
|
976
|
+
applicability: "not-applicable", uncompressedLength64: "0", chunkCount: 0,
|
|
977
|
+
artifactSha256: ""
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
throw new Error("Load Engine V2 artifact applicability or payload is invalid.");
|
|
981
|
+
});
|
|
982
|
+
const partialManifest = {
|
|
983
|
+
runId, sessionId, resultOwnerId, snapshotSequence64, planHash, isFinal,
|
|
984
|
+
correlationDetailPartCount: source.length - 5, entries,
|
|
985
|
+
bytes: Buffer.alloc(0), hash: ""
|
|
986
|
+
};
|
|
987
|
+
const manifestBytes = encodeLoadEngineV2ResultManifest(partialManifest);
|
|
988
|
+
if (manifestBytes.length > LOAD_ENGINE_V2_MAX_MANIFEST_BYTES) {
|
|
989
|
+
throw new Error("Load Engine V2 manifest exceeds its encoded bound.");
|
|
990
|
+
}
|
|
991
|
+
const manifest = {
|
|
992
|
+
...partialManifest, bytes: manifestBytes, hash: sha256Hex(manifestBytes)
|
|
993
|
+
};
|
|
994
|
+
const chunks = [];
|
|
995
|
+
let nonDetailBytes = 0n;
|
|
996
|
+
let detailBytes = 0n;
|
|
997
|
+
entries.forEach((entry, entryIndex) => {
|
|
998
|
+
const payload = source[entryIndex].payload;
|
|
999
|
+
if (entry.applicability === "present") {
|
|
1000
|
+
for (let chunkIndex = 0; chunkIndex < entry.chunkCount; chunkIndex += 1) {
|
|
1001
|
+
const chunkPayload = Buffer.from(payload.subarray(chunkIndex * chunkBytes, Math.min((chunkIndex + 1) * chunkBytes, payload.length)));
|
|
1002
|
+
const partial = {
|
|
1003
|
+
runId, sessionId, resultOwnerId, snapshotSequence64,
|
|
1004
|
+
manifestSha256: manifest.hash, kind: entry.kind, artifactId: entry.artifactId,
|
|
1005
|
+
schema: entry.schema, artifactSha256: entry.artifactSha256,
|
|
1006
|
+
chunkIndex, chunkCount: entry.chunkCount,
|
|
1007
|
+
uncompressedTotalLength64: entry.uncompressedLength64,
|
|
1008
|
+
payload: chunkPayload, chunkSha256: sha256Hex(chunkPayload), bytes: Buffer.alloc(0)
|
|
1009
|
+
};
|
|
1010
|
+
chunks.push({ ...partial, bytes: encodeLoadEngineV2ResultChunk(partial) });
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const length = BigInt(entry.uncompressedLength64);
|
|
1014
|
+
if (entry.kind === "correlation-detail")
|
|
1015
|
+
detailBytes += length;
|
|
1016
|
+
else
|
|
1017
|
+
nonDetailBytes += length;
|
|
1018
|
+
});
|
|
1019
|
+
const partialCommit = {
|
|
1020
|
+
runId, sessionId, resultOwnerId, snapshotSequence64, planHash,
|
|
1021
|
+
manifestLength: manifestBytes.length, manifestSha256: manifest.hash,
|
|
1022
|
+
entryCount: entries.length, totalNonDetailBytes64: nonDetailBytes.toString(),
|
|
1023
|
+
totalDetailBytes64: detailBytes.toString(), bytes: Buffer.alloc(0)
|
|
1024
|
+
};
|
|
1025
|
+
return {
|
|
1026
|
+
manifest, chunks,
|
|
1027
|
+
commit: { ...partialCommit, bytes: encodeLoadEngineV2ResultCommit(partialCommit) }
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
export function decodeLoadEngineV2ResultManifest(bytesValue) {
|
|
1031
|
+
const bytes = Buffer.from(bytesValue);
|
|
1032
|
+
if (bytes.length > LOAD_ENGINE_V2_MAX_MANIFEST_BYTES) {
|
|
1033
|
+
throw new Error("Load Engine V2 manifest exceeds its encoded bound.");
|
|
1034
|
+
}
|
|
1035
|
+
const cursor = new LoadEngineV2ResultCursor(bytes);
|
|
1036
|
+
cursor.requireMagic("LS-M1\n");
|
|
1037
|
+
cursor.require("1", "manifest version");
|
|
1038
|
+
const manifest = {
|
|
1039
|
+
runId: cursor.readText(), sessionId: cursor.readText(), resultOwnerId: cursor.readText(),
|
|
1040
|
+
snapshotSequence64: cursor.readNonNegative("snapshot sequence"), planHash: cursor.readText(),
|
|
1041
|
+
isFinal: cursor.readBit("final bit"), correlationDetailPartCount: cursor.readCount("detail part count", LOAD_ENGINE_V2_MAX_DETAIL_PARTS), entries: [], bytes, hash: sha256Hex(bytes)
|
|
1042
|
+
};
|
|
1043
|
+
const entryCount = cursor.readCount("manifest entry count", LOAD_ENGINE_V2_MAX_MANIFEST_ENTRIES);
|
|
1044
|
+
if (entryCount !== 5 + manifest.correlationDetailPartCount) {
|
|
1045
|
+
throw new Error("Load Engine V2 manifest entry count does not reconcile.");
|
|
1046
|
+
}
|
|
1047
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
1048
|
+
manifest.entries.push({
|
|
1049
|
+
kind: cursor.readText(), artifactId: cursor.readText(), schema: cursor.readText(),
|
|
1050
|
+
applicability: cursor.readText(),
|
|
1051
|
+
uncompressedLength64: cursor.readNonNegative("artifact length"),
|
|
1052
|
+
chunkCount: cursor.readCount("chunk count", 2147483647), artifactSha256: cursor.readText()
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
cursor.requireEnd();
|
|
1056
|
+
validateDecodedV2ResultManifest(manifest);
|
|
1057
|
+
if (!manifest.bytes.equals(encodeLoadEngineV2ResultManifest(manifest))) {
|
|
1058
|
+
throw new Error("Load Engine V2 manifest encoding is invalid.");
|
|
1059
|
+
}
|
|
1060
|
+
return manifest;
|
|
1061
|
+
}
|
|
1062
|
+
export function decodeLoadEngineV2ResultChunk(bytesValue, maximumChunkBytes) {
|
|
1063
|
+
validateV2ResultChunkBound(maximumChunkBytes);
|
|
1064
|
+
const bytes = Buffer.from(bytesValue);
|
|
1065
|
+
if (bytes.length > maximumChunkBytes + 4096) {
|
|
1066
|
+
throw new Error("Load Engine V2 result chunk exceeds its encoded bound.");
|
|
1067
|
+
}
|
|
1068
|
+
const cursor = new LoadEngineV2ResultCursor(bytes);
|
|
1069
|
+
cursor.requireMagic("LS-RC1\n");
|
|
1070
|
+
cursor.require("1", "result chunk version");
|
|
1071
|
+
const runId = cursor.readText();
|
|
1072
|
+
const sessionId = cursor.readText();
|
|
1073
|
+
const resultOwnerId = cursor.readText();
|
|
1074
|
+
const snapshotSequence64 = cursor.readNonNegative("snapshot sequence");
|
|
1075
|
+
const manifestSha256 = cursor.readText();
|
|
1076
|
+
const kind = cursor.readText();
|
|
1077
|
+
const artifactId = cursor.readText();
|
|
1078
|
+
const schema = cursor.readText();
|
|
1079
|
+
const artifactSha256 = cursor.readText();
|
|
1080
|
+
const chunkIndex = cursor.readCount("chunk index", 2147483647);
|
|
1081
|
+
const chunkCount = cursor.readCount("chunk count", 2147483647);
|
|
1082
|
+
const uncompressedTotalLength64 = cursor.readNonNegative("uncompressed total length");
|
|
1083
|
+
const chunkLength = cursor.readCount("chunk length", maximumChunkBytes);
|
|
1084
|
+
const chunkSha256 = cursor.readText();
|
|
1085
|
+
const payload = cursor.readRaw(chunkLength);
|
|
1086
|
+
cursor.requireEnd();
|
|
1087
|
+
const result = {
|
|
1088
|
+
runId, sessionId, resultOwnerId, snapshotSequence64, manifestSha256, kind,
|
|
1089
|
+
artifactId, schema, artifactSha256, chunkIndex, chunkCount,
|
|
1090
|
+
uncompressedTotalLength64, payload, chunkSha256, bytes
|
|
1091
|
+
};
|
|
1092
|
+
if (!hashEquals(sha256Hex(payload), chunkSha256)
|
|
1093
|
+
|| !bytes.equals(encodeLoadEngineV2ResultChunk(result))) {
|
|
1094
|
+
throw new Error("Load Engine V2 result chunk hash or encoding is invalid.");
|
|
1095
|
+
}
|
|
1096
|
+
return result;
|
|
1097
|
+
}
|
|
1098
|
+
export function decodeLoadEngineV2ResultCommit(bytesValue) {
|
|
1099
|
+
const bytes = Buffer.from(bytesValue);
|
|
1100
|
+
if (bytes.length > 4096)
|
|
1101
|
+
throw new Error("Load Engine V2 snapshot commit exceeds its encoded bound.");
|
|
1102
|
+
const cursor = new LoadEngineV2ResultCursor(bytes);
|
|
1103
|
+
cursor.requireMagic("LS-MC1\n");
|
|
1104
|
+
cursor.require("1", "snapshot commit version");
|
|
1105
|
+
const result = {
|
|
1106
|
+
runId: cursor.readText(), sessionId: cursor.readText(), resultOwnerId: cursor.readText(),
|
|
1107
|
+
snapshotSequence64: cursor.readNonNegative("snapshot sequence"), planHash: cursor.readText(),
|
|
1108
|
+
manifestLength: cursor.readCount("manifest length", LOAD_ENGINE_V2_MAX_MANIFEST_BYTES),
|
|
1109
|
+
manifestSha256: cursor.readText(),
|
|
1110
|
+
entryCount: cursor.readCount("entry count", LOAD_ENGINE_V2_MAX_MANIFEST_ENTRIES),
|
|
1111
|
+
totalNonDetailBytes64: cursor.readNonNegative("non-detail byte total"),
|
|
1112
|
+
totalDetailBytes64: cursor.readNonNegative("detail byte total"), bytes
|
|
1113
|
+
};
|
|
1114
|
+
cursor.requireEnd();
|
|
1115
|
+
if (!bytes.equals(encodeLoadEngineV2ResultCommit(result))) {
|
|
1116
|
+
throw new Error("Load Engine V2 snapshot commit encoding is invalid.");
|
|
1117
|
+
}
|
|
1118
|
+
return result;
|
|
1119
|
+
}
|
|
1120
|
+
export class LoadEngineV2ResultSnapshotReceiver {
|
|
1121
|
+
constructor(runId, sessionId, resultOwnerId, planHash, chunkBytes, maximumResultBytes) {
|
|
1122
|
+
this.runId = runId;
|
|
1123
|
+
this.sessionId = sessionId;
|
|
1124
|
+
this.resultOwnerId = resultOwnerId;
|
|
1125
|
+
this.planHash = planHash;
|
|
1126
|
+
this.chunkBytes = chunkBytes;
|
|
1127
|
+
this.maximumResultBytes = maximumResultBytes;
|
|
1128
|
+
this.lastSequence = -1n;
|
|
1129
|
+
this.lastManifestHash = "";
|
|
1130
|
+
this.lastCommitBytes = Buffer.alloc(0);
|
|
1131
|
+
this.lastArtifacts = new Map();
|
|
1132
|
+
this.ownerLost = false;
|
|
1133
|
+
validateV2ResultScope(runId, "run ID");
|
|
1134
|
+
validateV2ResultScope(sessionId, "session ID");
|
|
1135
|
+
validateV2ResultOwner(resultOwnerId);
|
|
1136
|
+
validateLowercaseHash(planHash, "plan hash");
|
|
1137
|
+
validateV2ResultChunkBound(chunkBytes);
|
|
1138
|
+
if (!Number.isSafeInteger(maximumResultBytes) || maximumResultBytes <= 0) {
|
|
1139
|
+
throw new RangeError("Load Engine V2 receiver bounds are invalid.");
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
begin(manifest) {
|
|
1143
|
+
if (this.ownerLost)
|
|
1144
|
+
throw new Error("Load Engine V2 result owner is sealed as lost.");
|
|
1145
|
+
if (manifest.runId !== this.runId || manifest.sessionId !== this.sessionId
|
|
1146
|
+
|| manifest.resultOwnerId !== this.resultOwnerId || manifest.planHash !== this.planHash
|
|
1147
|
+
|| !manifest.bytes.equals(encodeLoadEngineV2ResultManifest(manifest))
|
|
1148
|
+
|| !hashEquals(manifest.hash, sha256Hex(manifest.bytes))) {
|
|
1149
|
+
throw new Error("Load Engine V2 manifest has an unsigned scope or invalid hash.");
|
|
1150
|
+
}
|
|
1151
|
+
const sequence = BigInt(manifest.snapshotSequence64);
|
|
1152
|
+
if (sequence === this.lastSequence && manifest.hash === this.lastManifestHash)
|
|
1153
|
+
return;
|
|
1154
|
+
if (sequence !== this.lastSequence + 1n) {
|
|
1155
|
+
throw new Error("Load Engine V2 snapshot sequence is stale or skipped.");
|
|
1156
|
+
}
|
|
1157
|
+
if (this.pending) {
|
|
1158
|
+
if (this.pending.manifest.hash === manifest.hash)
|
|
1159
|
+
return;
|
|
1160
|
+
throw new Error("Load Engine V2 result owner already has an uncommitted snapshot.");
|
|
1161
|
+
}
|
|
1162
|
+
let total = 0n;
|
|
1163
|
+
for (const entry of manifest.entries) {
|
|
1164
|
+
const length = BigInt(entry.uncompressedLength64);
|
|
1165
|
+
if (entry.applicability === "present"
|
|
1166
|
+
&& entry.chunkCount !== Number((length + BigInt(this.chunkBytes) - 1n) / BigInt(this.chunkBytes))) {
|
|
1167
|
+
throw new Error("Load Engine V2 manifest chunk count is not canonical for the signed chunk size.");
|
|
1168
|
+
}
|
|
1169
|
+
total += length;
|
|
1170
|
+
if (total > BigInt(this.maximumResultBytes)) {
|
|
1171
|
+
throw new Error("Load Engine V2 manifest exceeds receiver quota.");
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
this.pending = { manifest, chunks: new Map() };
|
|
1175
|
+
}
|
|
1176
|
+
add(chunk) {
|
|
1177
|
+
const pending = this.pending;
|
|
1178
|
+
if (!pending)
|
|
1179
|
+
throw new Error("Load Engine V2 chunk has no active manifest.");
|
|
1180
|
+
const manifest = pending.manifest;
|
|
1181
|
+
if (chunk.runId !== this.runId || chunk.sessionId !== this.sessionId
|
|
1182
|
+
|| chunk.resultOwnerId !== this.resultOwnerId
|
|
1183
|
+
|| chunk.snapshotSequence64 !== manifest.snapshotSequence64
|
|
1184
|
+
|| chunk.manifestSha256 !== manifest.hash) {
|
|
1185
|
+
throw new Error("Load Engine V2 result chunk identity is invalid.");
|
|
1186
|
+
}
|
|
1187
|
+
const entry = manifest.entries.find((value) => value.kind === chunk.kind && value.artifactId === chunk.artifactId);
|
|
1188
|
+
if (!entry)
|
|
1189
|
+
throw new Error("Load Engine V2 result chunk is undeclared.");
|
|
1190
|
+
const offset = BigInt(chunk.chunkIndex) * BigInt(this.chunkBytes);
|
|
1191
|
+
const length = BigInt(entry.uncompressedLength64);
|
|
1192
|
+
const expectedLength = Number(length - offset < BigInt(this.chunkBytes)
|
|
1193
|
+
? length - offset : BigInt(this.chunkBytes));
|
|
1194
|
+
if (entry.applicability !== "present" || entry.schema !== chunk.schema
|
|
1195
|
+
|| entry.artifactSha256 !== chunk.artifactSha256 || entry.chunkCount !== chunk.chunkCount
|
|
1196
|
+
|| entry.uncompressedLength64 !== chunk.uncompressedTotalLength64
|
|
1197
|
+
|| chunk.chunkIndex < 0 || chunk.chunkIndex >= chunk.chunkCount
|
|
1198
|
+
|| offset < 0n || offset >= length || chunk.payload.length !== expectedLength
|
|
1199
|
+
|| !hashEquals(chunk.chunkSha256, sha256Hex(chunk.payload))
|
|
1200
|
+
|| !chunk.bytes.equals(encodeLoadEngineV2ResultChunk(chunk))) {
|
|
1201
|
+
throw new Error("Load Engine V2 result chunk hash, bounds, or declaration is invalid.");
|
|
1202
|
+
}
|
|
1203
|
+
const key = `${chunk.kind}/${chunk.artifactId}/${chunk.chunkIndex}`;
|
|
1204
|
+
const existing = pending.chunks.get(key);
|
|
1205
|
+
if (existing) {
|
|
1206
|
+
if (!existing.bytes.equals(chunk.bytes)) {
|
|
1207
|
+
throw new Error("Load Engine V2 result chunk retry changed bytes.");
|
|
1208
|
+
}
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
pending.chunks.set(key, chunk);
|
|
1212
|
+
}
|
|
1213
|
+
get hasCompleteSnapshot() {
|
|
1214
|
+
if (!this.pending)
|
|
1215
|
+
return false;
|
|
1216
|
+
return this.pending.manifest.entries
|
|
1217
|
+
.filter((entry) => entry.applicability === "present")
|
|
1218
|
+
.every((entry) => Array.from({ length: entry.chunkCount }, (_, index) => this.pending.chunks.has(`${entry.kind}/${entry.artifactId}/${index}`)).every(Boolean));
|
|
1219
|
+
}
|
|
1220
|
+
commitSnapshot(commit) {
|
|
1221
|
+
if (BigInt(commit.snapshotSequence64) === this.lastSequence
|
|
1222
|
+
&& commit.bytes.equals(this.lastCommitBytes))
|
|
1223
|
+
return this.lastArtifacts;
|
|
1224
|
+
const pending = this.pending;
|
|
1225
|
+
if (!pending)
|
|
1226
|
+
throw new Error("Load Engine V2 commit has no active manifest.");
|
|
1227
|
+
const manifest = pending.manifest;
|
|
1228
|
+
if (commit.runId !== this.runId || commit.sessionId !== this.sessionId
|
|
1229
|
+
|| commit.resultOwnerId !== this.resultOwnerId
|
|
1230
|
+
|| commit.snapshotSequence64 !== manifest.snapshotSequence64
|
|
1231
|
+
|| commit.planHash !== this.planHash || commit.manifestLength !== manifest.bytes.length
|
|
1232
|
+
|| commit.manifestSha256 !== manifest.hash || commit.entryCount !== manifest.entries.length
|
|
1233
|
+
|| !commit.bytes.equals(encodeLoadEngineV2ResultCommit(commit))) {
|
|
1234
|
+
throw new Error("Load Engine V2 commit does not match its manifest.");
|
|
1235
|
+
}
|
|
1236
|
+
const artifacts = new Map();
|
|
1237
|
+
let nonDetail = 0n;
|
|
1238
|
+
let detail = 0n;
|
|
1239
|
+
for (const entry of manifest.entries) {
|
|
1240
|
+
const artifactKey = `${entry.kind}/${entry.artifactId}`;
|
|
1241
|
+
if (entry.applicability === "present") {
|
|
1242
|
+
const pieces = [];
|
|
1243
|
+
for (let index = 0; index < entry.chunkCount; index += 1) {
|
|
1244
|
+
const chunk = pending.chunks.get(`${artifactKey}/${index}`);
|
|
1245
|
+
if (!chunk)
|
|
1246
|
+
throw new Error("Load Engine V2 commit is missing chunks.");
|
|
1247
|
+
pieces.push(chunk.payload);
|
|
1248
|
+
}
|
|
1249
|
+
const payload = Buffer.concat(pieces);
|
|
1250
|
+
if (payload.length.toString() !== entry.uncompressedLength64
|
|
1251
|
+
|| !hashEquals(sha256Hex(payload), entry.artifactSha256)) {
|
|
1252
|
+
throw new Error("Load Engine V2 reassembled artifact is invalid.");
|
|
1253
|
+
}
|
|
1254
|
+
artifacts.set(artifactKey, payload);
|
|
1255
|
+
}
|
|
1256
|
+
else if (entry.applicability === "empty") {
|
|
1257
|
+
artifacts.set(artifactKey, Buffer.alloc(0));
|
|
1258
|
+
}
|
|
1259
|
+
const length = BigInt(entry.uncompressedLength64);
|
|
1260
|
+
if (entry.kind === "correlation-detail")
|
|
1261
|
+
detail += length;
|
|
1262
|
+
else
|
|
1263
|
+
nonDetail += length;
|
|
1264
|
+
}
|
|
1265
|
+
if (nonDetail.toString() !== commit.totalNonDetailBytes64
|
|
1266
|
+
|| detail.toString() !== commit.totalDetailBytes64) {
|
|
1267
|
+
throw new Error("Load Engine V2 commit byte totals are invalid.");
|
|
1268
|
+
}
|
|
1269
|
+
this.lastSequence = BigInt(manifest.snapshotSequence64);
|
|
1270
|
+
this.lastManifestHash = manifest.hash;
|
|
1271
|
+
this.lastCommitBytes = Buffer.from(commit.bytes);
|
|
1272
|
+
this.lastArtifacts = artifacts;
|
|
1273
|
+
this.pending = undefined;
|
|
1274
|
+
return artifacts;
|
|
1275
|
+
}
|
|
1276
|
+
sealOwnerLost() {
|
|
1277
|
+
this.ownerLost = true;
|
|
1278
|
+
this.pending = undefined;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
function encodeLoadEngineV2ResultManifest(value) {
|
|
1282
|
+
const fields = [Buffer.from("LS-M1\n", "ascii")];
|
|
1283
|
+
const write = (textValue) => { fields.push(frameUtf8(textValue)); };
|
|
1284
|
+
["1", value.runId, value.sessionId, value.resultOwnerId, value.snapshotSequence64,
|
|
1285
|
+
value.planHash, value.isFinal ? "1" : "0", value.correlationDetailPartCount.toString(),
|
|
1286
|
+
value.entries.length.toString()].forEach(write);
|
|
1287
|
+
value.entries.forEach((entry) => {
|
|
1288
|
+
[entry.kind, entry.artifactId, entry.schema, entry.applicability,
|
|
1289
|
+
entry.uncompressedLength64, entry.chunkCount.toString(), entry.artifactSha256].forEach(write);
|
|
1290
|
+
});
|
|
1291
|
+
return Buffer.concat(fields);
|
|
1292
|
+
}
|
|
1293
|
+
function encodeLoadEngineV2ResultChunk(value) {
|
|
1294
|
+
const fields = [Buffer.from("LS-RC1\n", "ascii")];
|
|
1295
|
+
const write = (textValue) => { fields.push(frameUtf8(textValue)); };
|
|
1296
|
+
["1", value.runId, value.sessionId, value.resultOwnerId, value.snapshotSequence64,
|
|
1297
|
+
value.manifestSha256, value.kind, value.artifactId, value.schema, value.artifactSha256,
|
|
1298
|
+
value.chunkIndex.toString(), value.chunkCount.toString(), value.uncompressedTotalLength64,
|
|
1299
|
+
value.payload.length.toString(), value.chunkSha256].forEach(write);
|
|
1300
|
+
fields.push(value.payload);
|
|
1301
|
+
return Buffer.concat(fields);
|
|
1302
|
+
}
|
|
1303
|
+
function encodeLoadEngineV2ResultCommit(value) {
|
|
1304
|
+
const fields = [Buffer.from("LS-MC1\n", "ascii")];
|
|
1305
|
+
const write = (textValue) => { fields.push(frameUtf8(textValue)); };
|
|
1306
|
+
["1", value.runId, value.sessionId, value.resultOwnerId, value.snapshotSequence64,
|
|
1307
|
+
value.planHash, value.manifestLength.toString(), value.manifestSha256,
|
|
1308
|
+
value.entryCount.toString(), value.totalNonDetailBytes64, value.totalDetailBytes64].forEach(write);
|
|
1309
|
+
return Buffer.concat(fields);
|
|
1310
|
+
}
|
|
1311
|
+
function validateV2ResultArtifactOrder(artifacts) {
|
|
1312
|
+
if (artifacts.length < 5 || artifacts.length > LOAD_ENGINE_V2_MAX_MANIFEST_ENTRIES) {
|
|
1313
|
+
throw new Error("Load Engine V2 manifest entry count is invalid.");
|
|
1314
|
+
}
|
|
1315
|
+
for (let index = 0; index < 5; index += 1) {
|
|
1316
|
+
const artifact = artifacts[index];
|
|
1317
|
+
const [kind, schema] = LOAD_ENGINE_V2_MAIN_ARTIFACTS[index];
|
|
1318
|
+
if (artifact.kind !== kind || artifact.artifactId !== "main"
|
|
1319
|
+
|| (artifact.applicability !== "not-applicable" && artifact.schema !== schema)
|
|
1320
|
+
|| (index < 2 && artifact.applicability === "not-applicable")) {
|
|
1321
|
+
throw new Error("Load Engine V2 manifest main entry order or schema is invalid.");
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
const detailCount = artifacts.length - 5;
|
|
1325
|
+
if (detailCount > LOAD_ENGINE_V2_MAX_DETAIL_PARTS) {
|
|
1326
|
+
throw new Error("Load Engine V2 detail part count exceeds its bound.");
|
|
1327
|
+
}
|
|
1328
|
+
for (let index = 0; index < detailCount; index += 1) {
|
|
1329
|
+
const artifact = artifacts[index + 5];
|
|
1330
|
+
if (artifact.kind !== "correlation-detail"
|
|
1331
|
+
|| artifact.artifactId !== `part-${index.toString().padStart(20, "0")}`
|
|
1332
|
+
|| artifact.schema !== "correlation-detail-v1" || artifact.applicability !== "present") {
|
|
1333
|
+
throw new Error("Load Engine V2 correlation detail sequence is invalid.");
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
function validateDecodedV2ResultManifest(manifest) {
|
|
1338
|
+
validateV2ResultScope(manifest.runId, "run ID");
|
|
1339
|
+
validateV2ResultScope(manifest.sessionId, "session ID");
|
|
1340
|
+
validateV2ResultOwner(manifest.resultOwnerId);
|
|
1341
|
+
validateLowercaseHash(manifest.planHash, "plan hash");
|
|
1342
|
+
validateV2ResultArtifactOrder(manifest.entries.map((entry) => ({
|
|
1343
|
+
kind: entry.kind, artifactId: entry.artifactId, schema: entry.schema,
|
|
1344
|
+
applicability: entry.applicability,
|
|
1345
|
+
payload: entry.applicability === "present" ? Buffer.from([1]) : Buffer.alloc(0)
|
|
1346
|
+
})));
|
|
1347
|
+
for (const entry of manifest.entries) {
|
|
1348
|
+
if (entry.applicability === "present") {
|
|
1349
|
+
validateLowercaseHash(entry.artifactSha256, "artifact hash");
|
|
1350
|
+
if (BigInt(entry.uncompressedLength64) <= 0n || entry.chunkCount <= 0) {
|
|
1351
|
+
throw new Error("Load Engine V2 present manifest entry is invalid.");
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
else if (entry.applicability === "empty") {
|
|
1355
|
+
if (entry.uncompressedLength64 !== "0" || entry.chunkCount !== 0
|
|
1356
|
+
|| entry.artifactSha256 !== LOAD_ENGINE_V2_RESULT_EMPTY_SHA256) {
|
|
1357
|
+
throw new Error("Load Engine V2 empty manifest entry is invalid.");
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
else if (entry.applicability === "not-applicable") {
|
|
1361
|
+
if (entry.schema !== "0" || entry.uncompressedLength64 !== "0"
|
|
1362
|
+
|| entry.chunkCount !== 0 || entry.artifactSha256 !== "") {
|
|
1363
|
+
throw new Error("Load Engine V2 not-applicable manifest entry is invalid.");
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
else {
|
|
1367
|
+
throw new Error("Load Engine V2 manifest applicability is invalid.");
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
function validateV2ResultScope(value, label) {
|
|
1372
|
+
validateUnicodeScalars(value);
|
|
1373
|
+
if (!value.trim() || Buffer.byteLength(value, "utf8") > 128) {
|
|
1374
|
+
throw new Error(`Load Engine V2 ${label} is invalid.`);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function validateV2ResultOwner(value) {
|
|
1378
|
+
validateUnicodeScalars(value);
|
|
1379
|
+
if (Buffer.byteLength(value, "utf8") > 256) {
|
|
1380
|
+
throw new Error("Load Engine V2 result owner ID is invalid.");
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function validateV2ResultChunkBound(value) {
|
|
1384
|
+
if (!Number.isSafeInteger(value) || value < 16384 || value > 262144) {
|
|
1385
|
+
throw new RangeError("Load Engine V2 result chunk size must be from 16384 through 262144 bytes.");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
class LoadEngineV2ResultCursor {
|
|
1389
|
+
constructor(bytes) {
|
|
1390
|
+
this.bytes = bytes;
|
|
1391
|
+
this.position = 0;
|
|
1392
|
+
}
|
|
1393
|
+
requireMagic(value) {
|
|
1394
|
+
const expected = Buffer.from(value, "ascii");
|
|
1395
|
+
if (!this.bytes.subarray(this.position, this.position + expected.length).equals(expected)) {
|
|
1396
|
+
throw new Error("Load Engine V2 artifact magic is invalid.");
|
|
1397
|
+
}
|
|
1398
|
+
this.position += expected.length;
|
|
1399
|
+
}
|
|
1400
|
+
readText() {
|
|
1401
|
+
if (this.bytes.length - this.position < 4)
|
|
1402
|
+
throw new Error("Load Engine V2 artifact is truncated.");
|
|
1403
|
+
const length = this.bytes.readUInt32BE(this.position);
|
|
1404
|
+
this.position += 4;
|
|
1405
|
+
if (this.bytes.length - this.position < length)
|
|
1406
|
+
throw new Error("Load Engine V2 artifact field is truncated.");
|
|
1407
|
+
const raw = this.bytes.subarray(this.position, this.position + length);
|
|
1408
|
+
this.position += length;
|
|
1409
|
+
try {
|
|
1410
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
throw new Error("Load Engine V2 artifact field is not strict UTF-8.");
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
readRaw(length) {
|
|
1417
|
+
if (length < 0 || this.bytes.length - this.position < length) {
|
|
1418
|
+
throw new Error("Load Engine V2 artifact body is truncated.");
|
|
1419
|
+
}
|
|
1420
|
+
const value = Buffer.from(this.bytes.subarray(this.position, this.position + length));
|
|
1421
|
+
this.position += length;
|
|
1422
|
+
return value;
|
|
1423
|
+
}
|
|
1424
|
+
require(expected, field) {
|
|
1425
|
+
if (this.readText() !== expected)
|
|
1426
|
+
throw new Error(`Load Engine V2 ${field} is invalid.`);
|
|
1427
|
+
}
|
|
1428
|
+
readBit(field) {
|
|
1429
|
+
const value = this.readText();
|
|
1430
|
+
if (value === "0")
|
|
1431
|
+
return false;
|
|
1432
|
+
if (value === "1")
|
|
1433
|
+
return true;
|
|
1434
|
+
throw new Error(`Load Engine V2 ${field} is invalid.`);
|
|
1435
|
+
}
|
|
1436
|
+
readNonNegative(field) {
|
|
1437
|
+
const value = this.readText();
|
|
1438
|
+
parseCanonicalNonNegativeInt64(value, field);
|
|
1439
|
+
return value;
|
|
1440
|
+
}
|
|
1441
|
+
readCount(field, maximum) {
|
|
1442
|
+
const value = this.readNonNegative(field);
|
|
1443
|
+
const parsed = BigInt(value);
|
|
1444
|
+
if (parsed > BigInt(maximum))
|
|
1445
|
+
throw new Error(`Load Engine V2 ${field} exceeds its bound.`);
|
|
1446
|
+
return Number(parsed);
|
|
1447
|
+
}
|
|
1448
|
+
requireEnd() {
|
|
1449
|
+
if (this.position !== this.bytes.length)
|
|
1450
|
+
throw new Error("Load Engine V2 artifact has trailing bytes.");
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
export function createLoadEngineV2ResultChunks(result, chunkBytes = LOAD_ENGINE_V2_DEFAULT_CHUNK_BYTES) {
|
|
1454
|
+
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 16384 || chunkBytes > 262144) {
|
|
1455
|
+
throw new RangeError("Load Engine V2 result chunk size must be from 16384 through 262144 bytes.");
|
|
1456
|
+
}
|
|
1457
|
+
const payload = Buffer.from(JSON.stringify(result), "utf8");
|
|
1458
|
+
if (payload.length > LOAD_ENGINE_V2_MAX_RESULT_BYTES) {
|
|
1459
|
+
throw new Error("Load Engine V2 mergeable result exceeds the bounded result spool.");
|
|
1460
|
+
}
|
|
1461
|
+
const chunkCount = Math.max(1, Math.ceil(payload.length / chunkBytes));
|
|
1462
|
+
const record = result;
|
|
1463
|
+
const chunks = [];
|
|
1464
|
+
for (let index = 0; index < chunkCount; index += 1) {
|
|
1465
|
+
const data = payload.subarray(index * chunkBytes, Math.min((index + 1) * chunkBytes, payload.length));
|
|
1466
|
+
chunks.push({
|
|
1467
|
+
commandId: String(record.commandId ?? record.CommandId ?? ""),
|
|
1468
|
+
agentId: String(record.agentId ?? record.AgentId ?? ""),
|
|
1469
|
+
chunkIndex: index,
|
|
1470
|
+
chunkCount,
|
|
1471
|
+
totalLength64: payload.length.toString(),
|
|
1472
|
+
payloadSha256: sha256Hex(payload),
|
|
1473
|
+
chunkSha256: sha256Hex(data),
|
|
1474
|
+
dataBase64: data.toString("base64")
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
return chunks;
|
|
1478
|
+
}
|
|
1479
|
+
export class LoadEngineV2ResultChunkAccumulator {
|
|
1480
|
+
constructor(maximumResultBytes = LOAD_ENGINE_V2_MAX_RESULT_BYTES) {
|
|
1481
|
+
this.maximumResultBytes = maximumResultBytes;
|
|
1482
|
+
this.chunks = new Map();
|
|
1483
|
+
this.complete = false;
|
|
1484
|
+
}
|
|
1485
|
+
add(message) {
|
|
1486
|
+
const totalLength = Number(parseCanonicalNonNegativeInt64(message.totalLength64, "result total length"));
|
|
1487
|
+
if (!Number.isSafeInteger(totalLength) || totalLength > this.maximumResultBytes
|
|
1488
|
+
|| message.chunkCount <= 0 || message.chunkIndex < 0 || message.chunkIndex >= message.chunkCount) {
|
|
1489
|
+
throw new Error("Load Engine V2 result chunk bounds are invalid.");
|
|
1490
|
+
}
|
|
1491
|
+
validateLowercaseHash(message.payloadSha256, "result payload hash");
|
|
1492
|
+
validateLowercaseHash(message.chunkSha256, "result chunk hash");
|
|
1493
|
+
const data = Buffer.from(message.dataBase64, "base64");
|
|
1494
|
+
if (data.length > 262144 || !hashEquals(sha256Hex(data), message.chunkSha256)) {
|
|
1495
|
+
throw new Error("Load Engine V2 result chunk hash does not match its payload.");
|
|
1496
|
+
}
|
|
1497
|
+
const nextManifest = {
|
|
1498
|
+
commandId: message.commandId,
|
|
1499
|
+
agentId: message.agentId,
|
|
1500
|
+
chunkCount: message.chunkCount,
|
|
1501
|
+
totalLength64: message.totalLength64,
|
|
1502
|
+
payloadSha256: message.payloadSha256
|
|
1503
|
+
};
|
|
1504
|
+
if (!this.manifest)
|
|
1505
|
+
this.manifest = nextManifest;
|
|
1506
|
+
else if (JSON.stringify(this.manifest) !== JSON.stringify(nextManifest)) {
|
|
1507
|
+
throw new Error("Load Engine V2 result chunk manifest changed within one snapshot.");
|
|
1508
|
+
}
|
|
1509
|
+
const existing = this.chunks.get(message.chunkIndex);
|
|
1510
|
+
if (existing) {
|
|
1511
|
+
if (!existing.equals(data))
|
|
1512
|
+
throw new Error("Load Engine V2 result chunk retry changed bytes.");
|
|
1513
|
+
return undefined;
|
|
1514
|
+
}
|
|
1515
|
+
if (this.complete)
|
|
1516
|
+
throw new Error("Load Engine V2 result stream is already complete.");
|
|
1517
|
+
this.chunks.set(message.chunkIndex, data);
|
|
1518
|
+
if (this.chunks.size !== message.chunkCount)
|
|
1519
|
+
return undefined;
|
|
1520
|
+
const payload = Buffer.concat(Array.from({ length: message.chunkCount }, (_, index) => {
|
|
1521
|
+
const chunk = this.chunks.get(index);
|
|
1522
|
+
if (!chunk)
|
|
1523
|
+
throw new Error("Load Engine V2 result chunks are incomplete.");
|
|
1524
|
+
return chunk;
|
|
1525
|
+
}));
|
|
1526
|
+
if (payload.length !== totalLength || !hashEquals(sha256Hex(payload), message.payloadSha256)) {
|
|
1527
|
+
throw new Error("Load Engine V2 result snapshot hash or length is invalid.");
|
|
1528
|
+
}
|
|
1529
|
+
this.complete = true;
|
|
1530
|
+
return JSON.parse(payload.toString("utf8"));
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
export function buildLoadEngineV2OwnerLoss(input) {
|
|
1534
|
+
const missing = normalizeExpectedAgentIds(input.missingAgentIds);
|
|
1535
|
+
const assignments = input.assignments.filter((value) => missing.includes(value.agentId));
|
|
1536
|
+
const schedulerSegments = assignments.flatMap((assignment) => assignment.scenarios.flatMap((scenario) => scenario.simulations.map((simulation) => {
|
|
1537
|
+
parseCanonicalNonNegativeInt64(simulation.plannedIterations64, "owned planned iterations");
|
|
1538
|
+
return {
|
|
1539
|
+
scenarioName: scenario.scenarioName,
|
|
1540
|
+
scenarioIndex: Number(parseCanonicalNonNegativeInt64(scenario.scenarioIndex64, "scenario index")),
|
|
1541
|
+
simulationIndex: Number(parseCanonicalNonNegativeInt64(simulation.simulationIndex64, "simulation index")),
|
|
1542
|
+
kind: simulation.kind,
|
|
1543
|
+
shardIndex: Number(parseCanonicalNonNegativeInt64(assignment.shardIndex64, "shard index")),
|
|
1544
|
+
shardCount: Number(parseCanonicalNonNegativeInt64(assignment.shardCount64, "shard count")),
|
|
1545
|
+
plannedIterations64: simulation.plannedIterations64,
|
|
1546
|
+
dueIterations64: "0",
|
|
1547
|
+
startedIterations64: "0",
|
|
1548
|
+
completedIterations64: "0",
|
|
1549
|
+
droppedIterations64: "0",
|
|
1550
|
+
unreachedIterations64: simulation.plannedIterations64,
|
|
1551
|
+
requestedWorkerSlots64: "0",
|
|
1552
|
+
startedWorkerSlots64: "0",
|
|
1553
|
+
unavailableWorkerSlots64: "0",
|
|
1554
|
+
dropReasons: {},
|
|
1555
|
+
unavailableWorkerReasons: {},
|
|
1556
|
+
deliveryPercent: simulation.plannedIterations64 === "0" ? 100 : 0,
|
|
1557
|
+
accountingComplete: false
|
|
1558
|
+
};
|
|
1559
|
+
})));
|
|
1560
|
+
return {
|
|
1561
|
+
reportingComplete: false,
|
|
1562
|
+
applicationFailureCount64: "0",
|
|
1563
|
+
generatorWarnings: missing.map((agentId) => ({ code: "cluster_result_owner_lost", agentId, count64: "1" })),
|
|
1564
|
+
schedulerSegments
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
123
1567
|
export class DistributedClusterCoordinator {
|
|
124
1568
|
/**
|
|
125
1569
|
* Exposes the public constructor operation.
|
|
@@ -133,8 +1577,8 @@ export class DistributedClusterCoordinator {
|
|
|
133
1577
|
const timeoutMs = Math.max(this.options.commandTimeoutMs ?? 120000, 1);
|
|
134
1578
|
const runSubject = this.buildRunSubject();
|
|
135
1579
|
const replySubject = this.buildReplySubject();
|
|
136
|
-
const commandProducer =
|
|
137
|
-
const resultConsumer =
|
|
1580
|
+
const commandProducer = this.createAdapter(this.natsEndpoint("Produce", "coordinator-command", runSubject));
|
|
1581
|
+
const resultConsumer = this.createAdapter(this.natsEndpoint("Consume", "coordinator-result", replySubject));
|
|
138
1582
|
try {
|
|
139
1583
|
// Prime the reply subscription before commands are published because core NATS subjects do not retain messages.
|
|
140
1584
|
await resultConsumer.consume();
|
|
@@ -192,12 +1636,19 @@ export class DistributedClusterCoordinator {
|
|
|
192
1636
|
await commandProducer.dispose?.().catch(() => { });
|
|
193
1637
|
}
|
|
194
1638
|
}
|
|
1639
|
+
/** Runs the native V2 capability, targeted-command, barrier, and chunk-result protocol. */
|
|
1640
|
+
async dispatchV2(assignments, planInput, buildAgentRunToken) {
|
|
1641
|
+
return dispatchDistributedV2(this.options, assignments, planInput, buildAgentRunToken, (definition) => this.createAdapter(definition));
|
|
1642
|
+
}
|
|
195
1643
|
buildRunSubject() {
|
|
196
1644
|
return `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.run`;
|
|
197
1645
|
}
|
|
198
1646
|
buildReplySubject() {
|
|
199
1647
|
return `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.sessionId)}.reply.${randomUUID().replace(/-/g, "")}`;
|
|
200
1648
|
}
|
|
1649
|
+
createAdapter(definition) {
|
|
1650
|
+
return (this.options.adapterFactory ?? EndpointAdapterFactory).create(definition);
|
|
1651
|
+
}
|
|
201
1652
|
natsEndpoint(mode, name, subject) {
|
|
202
1653
|
return {
|
|
203
1654
|
kind: "Nats",
|
|
@@ -212,6 +1663,303 @@ export class DistributedClusterCoordinator {
|
|
|
212
1663
|
};
|
|
213
1664
|
}
|
|
214
1665
|
}
|
|
1666
|
+
async function dispatchDistributedV2(options, assignments, planInput, buildAgentRunToken, createAdapter) {
|
|
1667
|
+
if (options.loadEngineContractVersion !== 2) {
|
|
1668
|
+
throw new Error("dispatchV2 requires LoadEngineContractVersion 2.");
|
|
1669
|
+
}
|
|
1670
|
+
const expected = normalizeExpectedAgentIds(options.expectedAgentIds ?? []);
|
|
1671
|
+
if (options.expectedAgentResults !== expected.length || assignments.length !== expected.length) {
|
|
1672
|
+
throw new Error("Load Engine V2 requires assignments for the exact ExpectedAgentIds set.");
|
|
1673
|
+
}
|
|
1674
|
+
if (planInput.sessionId !== options.sessionId) {
|
|
1675
|
+
throw new Error("Load Engine V2 plan session does not match the coordinator session.");
|
|
1676
|
+
}
|
|
1677
|
+
const canonicalInput = { ...planInput, expectedAgentIds: expected };
|
|
1678
|
+
const plan = buildLoadEngineV2Plan(canonicalInput);
|
|
1679
|
+
const segments = orderedLoadEngineV2Segments(canonicalInput);
|
|
1680
|
+
if (!segments.length) {
|
|
1681
|
+
throw new Error("Load Engine V2 distributed execution requires at least one signed simulation segment.");
|
|
1682
|
+
}
|
|
1683
|
+
const timeoutMs = Math.max(options.commandTimeoutMs ?? 120000, 1);
|
|
1684
|
+
const prefix = `loadstrike.${sanitizeToken(options.clusterId)}.${sanitizeToken(options.sessionId)}.v2`;
|
|
1685
|
+
const helloSubject = `${prefix}.hello`;
|
|
1686
|
+
const registerSubject = buildLoadEngineV2ScopedRegistrationSubject(canonicalInput.runId, canonicalInput.sessionId, canonicalInput.registrationNonce);
|
|
1687
|
+
const controlSubject = `${prefix}.control.${sanitizeToken(canonicalInput.registrationNonce)}`;
|
|
1688
|
+
const resultSubject = `${prefix}.result.${sanitizeToken(canonicalInput.registrationNonce)}`;
|
|
1689
|
+
const endpoint = (mode, name, subject) => ({
|
|
1690
|
+
kind: "Nats", mode, name, trackingField: "header:x-cluster-command-id",
|
|
1691
|
+
nats: { ...(options.nats ?? {}), Subject: subject, StartFromEarliest: true }
|
|
1692
|
+
});
|
|
1693
|
+
const registrationConsumer = createAdapter(endpoint("Consume", "coordinator-v2-registration", registerSubject));
|
|
1694
|
+
const controlConsumer = createAdapter(endpoint("Consume", "coordinator-v2-control", controlSubject));
|
|
1695
|
+
const resultConsumer = createAdapter({
|
|
1696
|
+
...endpoint("Consume", "coordinator-v2-result", resultSubject),
|
|
1697
|
+
contentType: "application/octet-stream", messagePayloadType: "binary"
|
|
1698
|
+
});
|
|
1699
|
+
const producers = [];
|
|
1700
|
+
try {
|
|
1701
|
+
await Promise.all([registrationConsumer.consume(), controlConsumer.consume(), resultConsumer.consume()]);
|
|
1702
|
+
const helloProducer = createAdapter(endpoint("Produce", "coordinator-v2-hello", helloSubject));
|
|
1703
|
+
producers.push(helloProducer);
|
|
1704
|
+
const hello = createLoadEngineV2CoordinatorHello({
|
|
1705
|
+
runId: canonicalInput.runId,
|
|
1706
|
+
sessionId: canonicalInput.sessionId,
|
|
1707
|
+
coordinatorNonce: canonicalInput.registrationNonce,
|
|
1708
|
+
registrationSubject: registerSubject,
|
|
1709
|
+
expiresUtcNs64: (utcNowNs() + BigInt(timeoutMs) * 1000000n).toString(),
|
|
1710
|
+
expectedAgentIds: expected
|
|
1711
|
+
});
|
|
1712
|
+
const registrations = new Map();
|
|
1713
|
+
const registrationDeadline = Date.now() + timeoutMs;
|
|
1714
|
+
let lastHelloMs = 0;
|
|
1715
|
+
while (registrations.size < expected.length && Date.now() < registrationDeadline) {
|
|
1716
|
+
if (Date.now() - lastHelloMs >= 100) {
|
|
1717
|
+
await helloProducer.produce({ headers: {}, body: hello });
|
|
1718
|
+
lastHelloMs = Date.now();
|
|
1719
|
+
}
|
|
1720
|
+
const payload = await registrationConsumer.consume();
|
|
1721
|
+
const body = recordOrUndefined(payload?.body);
|
|
1722
|
+
if (!body) {
|
|
1723
|
+
await sleep(5);
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
const registration = body;
|
|
1727
|
+
if (!expected.includes(registration.agentId))
|
|
1728
|
+
continue;
|
|
1729
|
+
acceptLoadEngineV2RegistrationRenewal(registrations, registration, canonicalInput.runId, options.sessionId, canonicalInput.registrationNonce, utcNowNs64());
|
|
1730
|
+
}
|
|
1731
|
+
let negotiated = negotiateLoadEngineV2Registrations(canonicalInput.runId, options.sessionId, canonicalInput.registrationNonce, expected, [...registrations.values()], utcNowNs64());
|
|
1732
|
+
const commandId = randomUUID().replace(/-/g, "");
|
|
1733
|
+
const leadTimeMs = Math.max(options.barrierLeadTimeMs ?? 3000, 3000);
|
|
1734
|
+
const assignmentRows = expected.map((agentId, index) => ({
|
|
1735
|
+
agentId,
|
|
1736
|
+
shardIndex64: index.toString(),
|
|
1737
|
+
shardCount64: expected.length.toString(),
|
|
1738
|
+
scenarioNames: [...(assignments[index] ?? [])]
|
|
1739
|
+
}));
|
|
1740
|
+
const commands = new Map();
|
|
1741
|
+
for (const registration of negotiated) {
|
|
1742
|
+
const index = expected.indexOf(registration.agentId);
|
|
1743
|
+
const command = {
|
|
1744
|
+
commandId,
|
|
1745
|
+
sessionId: options.sessionId,
|
|
1746
|
+
testSuite: options.testSuite,
|
|
1747
|
+
testName: options.testName,
|
|
1748
|
+
agentIndex: index,
|
|
1749
|
+
agentCount: expected.length,
|
|
1750
|
+
targetScenarios: [...(assignments[index] ?? [])]
|
|
1751
|
+
};
|
|
1752
|
+
if (buildAgentRunToken)
|
|
1753
|
+
command.agentRunToken = await buildAgentRunToken(command);
|
|
1754
|
+
commands.set(registration.agentId, command);
|
|
1755
|
+
}
|
|
1756
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
|
|
1757
|
+
const segment = segments[segmentIndex];
|
|
1758
|
+
const barrierScopeId = buildLoadEngineV2BarrierScopeId(plan.hash, {
|
|
1759
|
+
kind: "scenario",
|
|
1760
|
+
scenarioIndex64: segment.scenarioIndex64,
|
|
1761
|
+
simulationIndex64: segment.simulationIndex64
|
|
1762
|
+
});
|
|
1763
|
+
const barrierAssignments = expected.map((agentId, shardIndex) => ({
|
|
1764
|
+
scenarioIndex64: segment.scenarioIndex64,
|
|
1765
|
+
simulationIndex64: segment.simulationIndex64,
|
|
1766
|
+
agentId,
|
|
1767
|
+
shardIndex64: shardIndex.toString(),
|
|
1768
|
+
shardCount64: expected.length.toString()
|
|
1769
|
+
}));
|
|
1770
|
+
const assignmentHash = buildLoadEngineV2AssignmentHash(barrierScopeId, barrierAssignments);
|
|
1771
|
+
const barrierEpoch64 = segmentIndex.toString();
|
|
1772
|
+
if (segmentIndex > 0) {
|
|
1773
|
+
const previous = segments[segmentIndex - 1];
|
|
1774
|
+
const previousScope = buildLoadEngineV2BarrierScopeId(plan.hash, {
|
|
1775
|
+
kind: "scenario", scenarioIndex64: previous.scenarioIndex64,
|
|
1776
|
+
simulationIndex64: previous.simulationIndex64
|
|
1777
|
+
});
|
|
1778
|
+
const previousAssignments = expected.map((agentId, shardIndex) => ({
|
|
1779
|
+
scenarioIndex64: previous.scenarioIndex64, simulationIndex64: previous.simulationIndex64,
|
|
1780
|
+
agentId, shardIndex64: shardIndex.toString(), shardCount64: expected.length.toString()
|
|
1781
|
+
}));
|
|
1782
|
+
const previousAssignmentHash = buildLoadEngineV2AssignmentHash(previousScope, previousAssignments);
|
|
1783
|
+
const drained = await collectV2Acks(controlConsumer, "drained", expected, commandId, timeoutMs);
|
|
1784
|
+
validateV2DrainAcks(expected, drained, plan.hash, previousAssignmentHash, (segmentIndex - 1).toString(), previousScope);
|
|
1785
|
+
}
|
|
1786
|
+
negotiated = await refreshLoadEngineV2Registrations(registrationConsumer, registrations, canonicalInput.runId, options.sessionId, canonicalInput.registrationNonce, expected, timeoutMs, BigInt(Math.max(options.barrierLeadTimeMs ?? 3000, 3000) + 1000) * 1000000n);
|
|
1787
|
+
const coordinatorResolution = options.v2ClockResolutionNs64 ?? "1000000";
|
|
1788
|
+
parseCanonicalNonNegativeInt64(coordinatorResolution, "coordinator clock resolution");
|
|
1789
|
+
const clockSamples = new Map();
|
|
1790
|
+
for (let probeIndex = 0; probeIndex < 5; probeIndex += 1) {
|
|
1791
|
+
for (const registration of negotiated) {
|
|
1792
|
+
const producer = createAdapter(endpoint("Produce", `coordinator-v2-clock-${segmentIndex}-${registration.agentId}-${probeIndex}`, registration.targetSubject));
|
|
1793
|
+
producers.push(producer);
|
|
1794
|
+
await producer.produce({ headers: { "x-cluster-command-id": commandId }, body: {
|
|
1795
|
+
phase: "clock-probe", commandId, runId: canonicalInput.runId,
|
|
1796
|
+
sessionId: options.sessionId, coordinatorNonce: canonicalInput.registrationNonce,
|
|
1797
|
+
agentId: registration.agentId, probeIndex,
|
|
1798
|
+
coordinatorSendUtcNs64: utcNowNs64(),
|
|
1799
|
+
coordinatorClockResolutionNs64: coordinatorResolution,
|
|
1800
|
+
barrierEpoch64, barrierScopeId, replySubject: controlSubject
|
|
1801
|
+
} });
|
|
1802
|
+
}
|
|
1803
|
+
const round = await collectV2ClockProbeResults(controlConsumer, expected, commandId, probeIndex, timeoutMs, barrierEpoch64);
|
|
1804
|
+
for (const [agentId, sample] of round) {
|
|
1805
|
+
const rows = clockSamples.get(agentId) ?? [];
|
|
1806
|
+
rows.push(sample);
|
|
1807
|
+
clockSamples.set(agentId, rows);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
const selectedClocks = new Map(expected.map((agentId) => [
|
|
1811
|
+
agentId, selectLoadEngineV2ClockProbe(clockSamples.get(agentId) ?? [])
|
|
1812
|
+
]));
|
|
1813
|
+
const deadlineUtcNs64 = (utcNowNs() + BigInt(leadTimeMs) * 1000000n).toString();
|
|
1814
|
+
for (const registration of negotiated) {
|
|
1815
|
+
const index = expected.indexOf(registration.agentId);
|
|
1816
|
+
const producer = createAdapter(endpoint("Produce", `coordinator-v2-prepare-${segmentIndex}-${index}`, registration.targetSubject));
|
|
1817
|
+
producers.push(producer);
|
|
1818
|
+
await producer.produce({ headers: { "x-cluster-command-id": commandId }, body: {
|
|
1819
|
+
...commands.get(registration.agentId), phase: "prepare", agentId: registration.agentId,
|
|
1820
|
+
runId: canonicalInput.runId, coordinatorNonce: canonicalInput.registrationNonce,
|
|
1821
|
+
planInput: canonicalInput, planHash: plan.hash, barrierScopeId, barrierAssignments,
|
|
1822
|
+
assignmentHash, assignments: assignmentRows, barrierEpoch64, deadlineUtcNs64,
|
|
1823
|
+
replySubject: controlSubject, resultSubject,
|
|
1824
|
+
resultAckSubject: `${resultSubject}.ack.${sanitizeToken(registration.agentId)}`,
|
|
1825
|
+
negotiatedResultChunkBytes: LOAD_ENGINE_V2_DEFAULT_CHUNK_BYTES,
|
|
1826
|
+
leaseExpiresUtcNs64: registration.leaseExpiresUtcNs64,
|
|
1827
|
+
clockOffsetNs64: selectedClocks.get(registration.agentId).offsetAgentMinusCoordinatorNs64,
|
|
1828
|
+
clockUncertaintyNs64: selectedClocks.get(registration.agentId).uncertaintyNs64
|
|
1829
|
+
} });
|
|
1830
|
+
}
|
|
1831
|
+
const armed = await collectV2Acks(controlConsumer, "armed", expected, commandId, timeoutMs);
|
|
1832
|
+
if (utcNowNs() >= parseCanonicalInt64(deadlineUtcNs64, "barrier deadline") - 1000000000n) {
|
|
1833
|
+
throw new Error("Load Engine V2 armed barrier did not retain the required one-second commit window.");
|
|
1834
|
+
}
|
|
1835
|
+
validateLoadEngineV2BarrierAcks("armed", expected, armed, plan.hash, assignmentHash, barrierEpoch64, utcNowNs64(), barrierScopeId);
|
|
1836
|
+
const armedHash = buildV2ArmedHash(armed);
|
|
1837
|
+
for (const registration of negotiated) {
|
|
1838
|
+
const producer = createAdapter(endpoint("Produce", `coordinator-v2-commit-${segmentIndex}-${registration.agentId}`, registration.targetSubject));
|
|
1839
|
+
producers.push(producer);
|
|
1840
|
+
await producer.produce({ headers: { "x-cluster-command-id": commandId }, body: {
|
|
1841
|
+
phase: "commit", commandId, runId: canonicalInput.runId, sessionId: options.sessionId,
|
|
1842
|
+
coordinatorNonce: canonicalInput.registrationNonce, agentId: registration.agentId,
|
|
1843
|
+
planHash: plan.hash, barrierScopeId, assignmentHash, barrierEpoch64,
|
|
1844
|
+
deadlineUtcNs64, armedHash, armedAcks: armed, replySubject: controlSubject, resultSubject
|
|
1845
|
+
} });
|
|
1846
|
+
}
|
|
1847
|
+
const committed = await collectV2Acks(controlConsumer, "committed", expected, commandId, timeoutMs);
|
|
1848
|
+
validateLoadEngineV2BarrierAcks("committed", expected, committed, plan.hash, assignmentHash, barrierEpoch64, utcNowNs64(), barrierScopeId);
|
|
1849
|
+
}
|
|
1850
|
+
const finalSegment = segments[segments.length - 1];
|
|
1851
|
+
const finalScope = buildLoadEngineV2BarrierScopeId(plan.hash, {
|
|
1852
|
+
kind: "scenario", scenarioIndex64: finalSegment.scenarioIndex64,
|
|
1853
|
+
simulationIndex64: finalSegment.simulationIndex64
|
|
1854
|
+
});
|
|
1855
|
+
const finalAssignments = expected.map((agentId, shardIndex) => ({
|
|
1856
|
+
scenarioIndex64: finalSegment.scenarioIndex64, simulationIndex64: finalSegment.simulationIndex64,
|
|
1857
|
+
agentId, shardIndex64: shardIndex.toString(), shardCount64: expected.length.toString()
|
|
1858
|
+
}));
|
|
1859
|
+
const finalDrained = await collectV2Acks(controlConsumer, "drained", expected, commandId, timeoutMs);
|
|
1860
|
+
validateV2DrainAcks(expected, finalDrained, plan.hash, buildLoadEngineV2AssignmentHash(finalScope, finalAssignments), (segments.length - 1).toString(), finalScope);
|
|
1861
|
+
const receivers = new Map(expected.map((agentId) => [
|
|
1862
|
+
agentId,
|
|
1863
|
+
new LoadEngineV2ResultSnapshotReceiver(canonicalInput.runId, canonicalInput.sessionId, agentId, plan.hash, LOAD_ENGINE_V2_DEFAULT_CHUNK_BYTES, LOAD_ENGINE_V2_MAX_RESULT_BYTES)
|
|
1864
|
+
]));
|
|
1865
|
+
const results = new Map();
|
|
1866
|
+
const resultDeadline = Date.now() + timeoutMs;
|
|
1867
|
+
while (results.size < expected.length && Date.now() < resultDeadline) {
|
|
1868
|
+
const payload = await resultConsumer.consume();
|
|
1869
|
+
const bytes = loadEngineV2BinaryBody(payload?.body);
|
|
1870
|
+
if (!bytes) {
|
|
1871
|
+
await sleep(5);
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
if (bytes.subarray(0, 6).toString("ascii") === "LS-M1\n") {
|
|
1875
|
+
const manifest = decodeLoadEngineV2ResultManifest(bytes);
|
|
1876
|
+
const receiver = receivers.get(manifest.resultOwnerId);
|
|
1877
|
+
if (!receiver || results.has(manifest.resultOwnerId)) {
|
|
1878
|
+
throw new Error("Load Engine V2 received a manifest for an unallocated result owner.");
|
|
1879
|
+
}
|
|
1880
|
+
receiver.begin(manifest);
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
if (bytes.subarray(0, 7).toString("ascii") === "LS-RC1\n") {
|
|
1884
|
+
const chunk = decodeLoadEngineV2ResultChunk(bytes, LOAD_ENGINE_V2_DEFAULT_CHUNK_BYTES);
|
|
1885
|
+
const receiver = receivers.get(chunk.resultOwnerId);
|
|
1886
|
+
if (!receiver || results.has(chunk.resultOwnerId)) {
|
|
1887
|
+
throw new Error("Load Engine V2 received a chunk for an unallocated result owner.");
|
|
1888
|
+
}
|
|
1889
|
+
receiver.add(chunk);
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
if (bytes.subarray(0, 7).toString("ascii") !== "LS-MC1\n") {
|
|
1893
|
+
throw new Error("Load Engine V2 result subject received an unknown canonical artifact.");
|
|
1894
|
+
}
|
|
1895
|
+
const commit = decodeLoadEngineV2ResultCommit(bytes);
|
|
1896
|
+
const receiver = receivers.get(commit.resultOwnerId);
|
|
1897
|
+
if (!receiver || results.has(commit.resultOwnerId)) {
|
|
1898
|
+
throw new Error("Load Engine V2 received a commit for an unallocated result owner.");
|
|
1899
|
+
}
|
|
1900
|
+
if (!receiver.hasCompleteSnapshot)
|
|
1901
|
+
continue;
|
|
1902
|
+
const artifacts = receiver.commitSnapshot(commit);
|
|
1903
|
+
const scheduler = artifacts.get("scheduler/main");
|
|
1904
|
+
const histograms = artifacts.get("histograms/main");
|
|
1905
|
+
if (!scheduler || !histograms) {
|
|
1906
|
+
throw new Error("Load Engine V2 canonical result omits scheduler or histogram artifacts.");
|
|
1907
|
+
}
|
|
1908
|
+
let decoded;
|
|
1909
|
+
try {
|
|
1910
|
+
decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(scheduler));
|
|
1911
|
+
}
|
|
1912
|
+
catch {
|
|
1913
|
+
throw new Error("Load Engine V2 scheduler result artifact is invalid.");
|
|
1914
|
+
}
|
|
1915
|
+
const decodedRecord = recordOrUndefined(decoded);
|
|
1916
|
+
if (!decodedRecord)
|
|
1917
|
+
throw new Error("Load Engine V2 scheduler result artifact must be an object.");
|
|
1918
|
+
const stats = recordOrUndefined(decodedRecord.stats ?? decodedRecord.Stats) ?? {};
|
|
1919
|
+
decodedRecord.stats = { ...stats, histogramArtifactBase64: histograms.toString("base64") };
|
|
1920
|
+
const parsed = parseRunResult(decodedRecord, true);
|
|
1921
|
+
if (parsed.commandId !== commandId || parsed.agentId !== commit.resultOwnerId) {
|
|
1922
|
+
throw new Error("Load Engine V2 canonical result identity does not match its command owner.");
|
|
1923
|
+
}
|
|
1924
|
+
results.set(commit.resultOwnerId, parsed);
|
|
1925
|
+
const ackProducer = createAdapter(endpoint("Produce", `coordinator-v2-result-ack-${commit.resultOwnerId}`, `${resultSubject}.ack.${sanitizeToken(commit.resultOwnerId)}`));
|
|
1926
|
+
producers.push(ackProducer);
|
|
1927
|
+
await ackProducer.produce({ headers: { "x-cluster-command-id": commandId }, body: {
|
|
1928
|
+
phase: "result-ack", commandId, resultOwnerId: commit.resultOwnerId,
|
|
1929
|
+
snapshotSequence64: commit.snapshotSequence64,
|
|
1930
|
+
manifestSha256: commit.manifestSha256, accepted: true
|
|
1931
|
+
} });
|
|
1932
|
+
}
|
|
1933
|
+
const nodeResults = expected.flatMap((agentId) => {
|
|
1934
|
+
const result = results.get(agentId);
|
|
1935
|
+
return result ? [convertRunResult(result)] : [];
|
|
1936
|
+
});
|
|
1937
|
+
const missingAgentIds = expected.filter((agentId) => !results.has(agentId));
|
|
1938
|
+
const ownerLoss = missingAgentIds.length ? buildLoadEngineV2OwnerLoss({
|
|
1939
|
+
missingAgentIds,
|
|
1940
|
+
assignments: buildOwnerLossAssignments(canonicalInput, missingAgentIds, expected)
|
|
1941
|
+
}) : undefined;
|
|
1942
|
+
return {
|
|
1943
|
+
nodeResults,
|
|
1944
|
+
allRequestCount: nodeResults.reduce((sum, row) => sum + row.allRequestCount, 0),
|
|
1945
|
+
allOkCount: nodeResults.reduce((sum, row) => sum + row.allOkCount, 0),
|
|
1946
|
+
allFailCount: nodeResults.reduce((sum, row) => sum + row.allFailCount, 0),
|
|
1947
|
+
failedNodes: nodeResults.filter((row) => !row.success).length,
|
|
1948
|
+
missingNodes: missingAgentIds.length,
|
|
1949
|
+
runSubject: registerSubject,
|
|
1950
|
+
replySubject: resultSubject,
|
|
1951
|
+
ownerLoss
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
finally {
|
|
1955
|
+
await Promise.all([
|
|
1956
|
+
registrationConsumer.dispose?.().catch(() => { }),
|
|
1957
|
+
controlConsumer.dispose?.().catch(() => { }),
|
|
1958
|
+
resultConsumer.dispose?.().catch(() => { }),
|
|
1959
|
+
...producers.map((producer) => producer.dispose?.().catch(() => { }))
|
|
1960
|
+
]);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
215
1963
|
export class DistributedClusterAgent {
|
|
216
1964
|
/**
|
|
217
1965
|
* Exposes the public constructor operation.
|
|
@@ -219,17 +1967,36 @@ export class DistributedClusterAgent {
|
|
|
219
1967
|
*/
|
|
220
1968
|
constructor(options) {
|
|
221
1969
|
this.commandConsumer = null;
|
|
1970
|
+
this.v2HelloConsumer = null;
|
|
1971
|
+
this.v2CommandConsumer = null;
|
|
1972
|
+
this.v2RegistrationProducer = null;
|
|
1973
|
+
this.v2LastRegistrationMs = 0;
|
|
1974
|
+
this.v2Hello = null;
|
|
1975
|
+
this.v2CurrentRegistration = null;
|
|
1976
|
+
this.v2RegistrationSequence = -1n;
|
|
1977
|
+
this.v2RenewalTimer = null;
|
|
1978
|
+
this.v2RenewalError = null;
|
|
1979
|
+
this.v2Prepared = null;
|
|
222
1980
|
this.options = options;
|
|
223
1981
|
}
|
|
224
1982
|
async dispose() {
|
|
225
1983
|
await this.commandConsumer?.dispose?.().catch(() => { });
|
|
1984
|
+
await this.v2HelloConsumer?.dispose?.().catch(() => { });
|
|
1985
|
+
await this.v2CommandConsumer?.dispose?.().catch(() => { });
|
|
1986
|
+
await this.v2RegistrationProducer?.dispose?.().catch(() => { });
|
|
1987
|
+
if (this.v2RenewalTimer)
|
|
1988
|
+
clearInterval(this.v2RenewalTimer);
|
|
226
1989
|
this.commandConsumer = null;
|
|
1990
|
+
this.v2HelloConsumer = null;
|
|
1991
|
+
this.v2CommandConsumer = null;
|
|
1992
|
+
this.v2RegistrationProducer = null;
|
|
1993
|
+
this.v2RenewalTimer = null;
|
|
227
1994
|
}
|
|
228
1995
|
async pollAndExecuteOnce(execute) {
|
|
229
1996
|
const runSubject = `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.run`;
|
|
230
1997
|
const queueGroup = `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.agents`;
|
|
231
1998
|
if (!this.commandConsumer) {
|
|
232
|
-
this.commandConsumer =
|
|
1999
|
+
this.commandConsumer = this.createAdapter({
|
|
233
2000
|
kind: "Nats",
|
|
234
2001
|
mode: "Consume",
|
|
235
2002
|
name: `agent-${this.options.agentId}`,
|
|
@@ -252,7 +2019,7 @@ export class DistributedClusterAgent {
|
|
|
252
2019
|
}
|
|
253
2020
|
const replySubject = stringOrDefault(payload.headers?.["x-cluster-reply-subject"], command.replySubject ?? "");
|
|
254
2021
|
const resultProducer = replySubject
|
|
255
|
-
?
|
|
2022
|
+
? this.createAdapter({
|
|
256
2023
|
kind: "Nats",
|
|
257
2024
|
mode: "Produce",
|
|
258
2025
|
name: `agent-${this.options.agentId}-result`,
|
|
@@ -307,6 +2074,373 @@ export class DistributedClusterAgent {
|
|
|
307
2074
|
await resultProducer?.dispose?.().catch(() => { });
|
|
308
2075
|
}
|
|
309
2076
|
}
|
|
2077
|
+
/** Polls one targeted V2 message. A callback can run only after a matching commit. */
|
|
2078
|
+
async pollAndExecuteV2Once(execute) {
|
|
2079
|
+
if (this.options.loadEngineContractVersion !== 2) {
|
|
2080
|
+
throw new Error("pollAndExecuteV2Once requires LoadEngineContractVersion 2.");
|
|
2081
|
+
}
|
|
2082
|
+
validateLoadEngineV2AgentId(this.options.agentId);
|
|
2083
|
+
if (!(await this.ensureV2Registration()))
|
|
2084
|
+
return false;
|
|
2085
|
+
if (this.v2RenewalError)
|
|
2086
|
+
throw this.v2RenewalError;
|
|
2087
|
+
const payload = await this.ensureV2CommandConsumer().consume();
|
|
2088
|
+
const body = recordOrUndefined(payload?.body);
|
|
2089
|
+
if (!body)
|
|
2090
|
+
return false;
|
|
2091
|
+
const phase = String(body.phase ?? "");
|
|
2092
|
+
if (String(body.agentId ?? "") !== this.options.agentId || !this.matchesV2Scope(body))
|
|
2093
|
+
return false;
|
|
2094
|
+
if (phase === "clock-probe") {
|
|
2095
|
+
await this.publishV2ClockProbe(body);
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
2098
|
+
if (phase === "abort") {
|
|
2099
|
+
if (this.v2Prepared && body.assignmentHash === this.v2Prepared.assignmentHash
|
|
2100
|
+
&& body.barrierEpoch64 === this.v2Prepared.barrierEpoch64)
|
|
2101
|
+
this.v2Prepared = null;
|
|
2102
|
+
return false;
|
|
2103
|
+
}
|
|
2104
|
+
if (phase === "prepare") {
|
|
2105
|
+
await this.acceptV2Prepare(body);
|
|
2106
|
+
return false;
|
|
2107
|
+
}
|
|
2108
|
+
if (phase !== "commit")
|
|
2109
|
+
return false;
|
|
2110
|
+
let activeCommand = await this.acceptV2Commit(body);
|
|
2111
|
+
const command = parseRunCommand(activeCommand);
|
|
2112
|
+
const signedSegments = orderedLoadEngineV2Segments(activeCommand.planInput);
|
|
2113
|
+
let activeSegmentIndex = -1;
|
|
2114
|
+
let activeSegmentDrained = true;
|
|
2115
|
+
const segmentLifecycle = {
|
|
2116
|
+
beforeSegment: async (scenarioIndex, simulationIndex) => {
|
|
2117
|
+
const nextIndex = activeSegmentIndex + 1;
|
|
2118
|
+
const expected = signedSegments[nextIndex];
|
|
2119
|
+
if (!expected || expected.scenarioIndex64 !== scenarioIndex.toString()
|
|
2120
|
+
|| expected.simulationIndex64 !== simulationIndex.toString() || !activeSegmentDrained) {
|
|
2121
|
+
throw new Error("Load Engine V2 scheduler attempted a segment outside the signed serial order.");
|
|
2122
|
+
}
|
|
2123
|
+
if (nextIndex > 0) {
|
|
2124
|
+
for (;;) {
|
|
2125
|
+
const nextPayload = await this.ensureV2CommandConsumer().consume();
|
|
2126
|
+
const nextBody = recordOrUndefined(nextPayload?.body);
|
|
2127
|
+
if (!nextBody || String(nextBody.agentId ?? "") !== this.options.agentId
|
|
2128
|
+
|| !this.matchesV2Scope(nextBody)) {
|
|
2129
|
+
await sleep(2);
|
|
2130
|
+
continue;
|
|
2131
|
+
}
|
|
2132
|
+
const nextPhase = String(nextBody.phase ?? "");
|
|
2133
|
+
if (nextPhase === "clock-probe") {
|
|
2134
|
+
if (String(nextBody.barrierEpoch64 ?? "") !== nextIndex.toString()) {
|
|
2135
|
+
throw new Error("Load Engine V2 clock probe does not match the next scoped epoch.");
|
|
2136
|
+
}
|
|
2137
|
+
await this.publishV2ClockProbe(nextBody);
|
|
2138
|
+
continue;
|
|
2139
|
+
}
|
|
2140
|
+
if (nextPhase === "prepare") {
|
|
2141
|
+
if (String(nextBody.barrierEpoch64 ?? "") !== nextIndex.toString()) {
|
|
2142
|
+
throw new Error("Load Engine V2 prepare does not match the next scoped epoch.");
|
|
2143
|
+
}
|
|
2144
|
+
await this.acceptV2Prepare(nextBody);
|
|
2145
|
+
continue;
|
|
2146
|
+
}
|
|
2147
|
+
if (nextPhase === "abort") {
|
|
2148
|
+
throw new Error("Load Engine V2 coordinator aborted the next scoped epoch.");
|
|
2149
|
+
}
|
|
2150
|
+
if (nextPhase !== "commit") {
|
|
2151
|
+
await sleep(2);
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
activeCommand = await this.acceptV2Commit(nextBody);
|
|
2155
|
+
break;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
const expectedScope = buildLoadEngineV2BarrierScopeId(String(activeCommand.planHash), {
|
|
2159
|
+
kind: "scenario", scenarioIndex64: expected.scenarioIndex64,
|
|
2160
|
+
simulationIndex64: expected.simulationIndex64
|
|
2161
|
+
});
|
|
2162
|
+
if (String(activeCommand.barrierEpoch64) !== nextIndex.toString()
|
|
2163
|
+
|| String(activeCommand.barrierScopeId) !== expectedScope) {
|
|
2164
|
+
throw new Error("Load Engine V2 committed scope does not match the scheduler segment.");
|
|
2165
|
+
}
|
|
2166
|
+
activeSegmentIndex = nextIndex;
|
|
2167
|
+
activeSegmentDrained = false;
|
|
2168
|
+
},
|
|
2169
|
+
afterSegment: async (scenarioIndex, simulationIndex) => {
|
|
2170
|
+
const expected = signedSegments[activeSegmentIndex];
|
|
2171
|
+
if (!expected || activeSegmentDrained || expected.scenarioIndex64 !== scenarioIndex.toString()
|
|
2172
|
+
|| expected.simulationIndex64 !== simulationIndex.toString()) {
|
|
2173
|
+
throw new Error("Load Engine V2 scheduler drain does not match the active scoped epoch.");
|
|
2174
|
+
}
|
|
2175
|
+
await this.publishV2Registration(true);
|
|
2176
|
+
await this.publishV2Ack(activeCommand, "drained", true, {
|
|
2177
|
+
finalDeltaAcknowledged: true,
|
|
2178
|
+
accountingComplete: true
|
|
2179
|
+
});
|
|
2180
|
+
activeSegmentDrained = true;
|
|
2181
|
+
}
|
|
2182
|
+
};
|
|
2183
|
+
let result;
|
|
2184
|
+
try {
|
|
2185
|
+
const node = await execute({
|
|
2186
|
+
scenarioNames: command.targetScenarios,
|
|
2187
|
+
commandId: command.commandId,
|
|
2188
|
+
agentRunToken: command.agentRunToken,
|
|
2189
|
+
agentIndex: command.agentIndex,
|
|
2190
|
+
agentCount: command.agentCount,
|
|
2191
|
+
segmentLifecycle
|
|
2192
|
+
});
|
|
2193
|
+
if (activeSegmentIndex !== signedSegments.length - 1 || !activeSegmentDrained) {
|
|
2194
|
+
throw new Error("Load Engine V2 execution returned before every signed segment drained.");
|
|
2195
|
+
}
|
|
2196
|
+
result = {
|
|
2197
|
+
commandId: command.commandId,
|
|
2198
|
+
agentId: this.options.agentId,
|
|
2199
|
+
isSuccess: node.success,
|
|
2200
|
+
errorMessage: node.error,
|
|
2201
|
+
stats: node.stats ?? {
|
|
2202
|
+
allRequestCount: node.allRequestCount,
|
|
2203
|
+
allOkCount: node.allOkCount,
|
|
2204
|
+
allFailCount: node.allFailCount
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
catch (error) {
|
|
2209
|
+
result = {
|
|
2210
|
+
commandId: command.commandId,
|
|
2211
|
+
agentId: this.options.agentId,
|
|
2212
|
+
isSuccess: false,
|
|
2213
|
+
errorMessage: String(error ?? "agent execution failed"),
|
|
2214
|
+
stats: {
|
|
2215
|
+
allRequestCount: 0,
|
|
2216
|
+
allOkCount: 0,
|
|
2217
|
+
allFailCount: 0,
|
|
2218
|
+
histogramArtifactBase64: EMPTY_LOAD_ENGINE_V2_HISTOGRAM_ARTIFACT_BASE64
|
|
2219
|
+
}
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
try {
|
|
2223
|
+
await this.publishV2CanonicalResult(activeCommand, result);
|
|
2224
|
+
}
|
|
2225
|
+
finally {
|
|
2226
|
+
this.v2Prepared = null;
|
|
2227
|
+
}
|
|
2228
|
+
return true;
|
|
2229
|
+
}
|
|
2230
|
+
async publishV2CanonicalResult(command, result) {
|
|
2231
|
+
const resultSubject = String(command.resultSubject ?? "");
|
|
2232
|
+
const ackSubject = String(command.resultAckSubject ?? "");
|
|
2233
|
+
const chunkBytes = Number(command.negotiatedResultChunkBytes ?? LOAD_ENGINE_V2_DEFAULT_CHUNK_BYTES);
|
|
2234
|
+
if (!resultSubject || !ackSubject) {
|
|
2235
|
+
throw new Error("Load Engine V2 canonical result subjects are incomplete.");
|
|
2236
|
+
}
|
|
2237
|
+
validateV2ResultChunkBound(chunkBytes);
|
|
2238
|
+
const histogramBase64 = String(result.stats?.histogramArtifactBase64 ?? "");
|
|
2239
|
+
const histogramBytes = Buffer.from(histogramBase64, "base64");
|
|
2240
|
+
if (!histogramBase64 || histogramBytes.toString("base64") !== histogramBase64) {
|
|
2241
|
+
throw new Error("Load Engine V2 canonical result requires a canonical histogram artifact.");
|
|
2242
|
+
}
|
|
2243
|
+
parseLoadEngineV2HistogramArtifact(histogramBytes);
|
|
2244
|
+
const schedulerProjection = JSON.parse(JSON.stringify(result));
|
|
2245
|
+
for (const key of ["stats", "details"]) {
|
|
2246
|
+
const value = recordOrUndefined(schedulerProjection[key]);
|
|
2247
|
+
if (value) {
|
|
2248
|
+
delete value.histogramArtifactBase64;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
const schedulerPayload = Buffer.from(JSON.stringify(schedulerProjection), "utf8");
|
|
2252
|
+
const snapshot = createLoadEngineV2ResultSnapshot(String(command.runId), String(command.sessionId), this.options.agentId, "0", String(command.planHash), true, chunkBytes, [
|
|
2253
|
+
loadEngineV2PresentArtifact("scheduler", "main", "scheduler-snapshot-v2", schedulerPayload),
|
|
2254
|
+
loadEngineV2PresentArtifact("histograms", "main", "histogram-set-v1", histogramBytes),
|
|
2255
|
+
loadEngineV2NotApplicableArtifact("correlations", "main"),
|
|
2256
|
+
loadEngineV2NotApplicableArtifact("portal-deltas", "main"),
|
|
2257
|
+
loadEngineV2NotApplicableArtifact("correlation-detail-index", "main")
|
|
2258
|
+
]);
|
|
2259
|
+
const producer = this.createAdapter(this.endpoint("Produce", `agent-${this.options.agentId}-v2-result`, resultSubject, true));
|
|
2260
|
+
const acknowledgement = this.createAdapter(this.endpoint("Consume", `agent-${this.options.agentId}-v2-result-ack`, ackSubject));
|
|
2261
|
+
try {
|
|
2262
|
+
await acknowledgement.consume();
|
|
2263
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
2264
|
+
for (const bytes of [
|
|
2265
|
+
snapshot.manifest.bytes,
|
|
2266
|
+
...snapshot.chunks.map((chunk) => chunk.bytes),
|
|
2267
|
+
snapshot.commit.bytes
|
|
2268
|
+
]) {
|
|
2269
|
+
await producer.produce({
|
|
2270
|
+
headers: { "x-cluster-command-id": result.commandId },
|
|
2271
|
+
body: bytes,
|
|
2272
|
+
contentType: "application/octet-stream",
|
|
2273
|
+
messagePayloadType: "binary"
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
const deadline = Date.now() + 100 * (2 ** attempt);
|
|
2277
|
+
while (Date.now() < deadline) {
|
|
2278
|
+
const payload = await acknowledgement.consume();
|
|
2279
|
+
const body = recordOrUndefined(payload?.body);
|
|
2280
|
+
if (body?.phase === "result-ack" && body.accepted === true
|
|
2281
|
+
&& body.commandId === result.commandId && body.resultOwnerId === this.options.agentId
|
|
2282
|
+
&& body.snapshotSequence64 === snapshot.commit.snapshotSequence64
|
|
2283
|
+
&& body.manifestSha256 === snapshot.commit.manifestSha256)
|
|
2284
|
+
return;
|
|
2285
|
+
await sleep(5);
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
throw new Error("Load Engine V2 canonical result acknowledgement timed out.");
|
|
2289
|
+
}
|
|
2290
|
+
finally {
|
|
2291
|
+
await Promise.all([
|
|
2292
|
+
producer.dispose?.().catch(() => { }),
|
|
2293
|
+
acknowledgement.dispose?.().catch(() => { })
|
|
2294
|
+
]);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
async publishV2ClockProbe(body) {
|
|
2298
|
+
const a1 = utcNowNs64();
|
|
2299
|
+
const agentResolution = this.options.v2ClockResolutionNs64 ?? "1000000";
|
|
2300
|
+
parseCanonicalNonNegativeInt64(agentResolution, "agent clock resolution");
|
|
2301
|
+
const producer = this.createAdapter(this.endpoint("Produce", `agent-${this.options.agentId}-v2-clock`, String(body.replySubject)));
|
|
2302
|
+
try {
|
|
2303
|
+
await producer.produce({ headers: { "x-cluster-command-id": String(body.commandId) }, body: {
|
|
2304
|
+
phase: "clock-probe-result", commandId: String(body.commandId), agentId: this.options.agentId,
|
|
2305
|
+
probeIndex: Number(body.probeIndex), barrierEpoch64: String(body.barrierEpoch64 ?? "0"),
|
|
2306
|
+
coordinatorSendUtcNs64: String(body.coordinatorSendUtcNs64),
|
|
2307
|
+
agentReceiveUtcNs64: a1, agentSendUtcNs64: utcNowNs64(),
|
|
2308
|
+
coordinatorClockResolutionNs64: String(body.coordinatorClockResolutionNs64),
|
|
2309
|
+
agentClockResolutionNs64: agentResolution
|
|
2310
|
+
} });
|
|
2311
|
+
}
|
|
2312
|
+
finally {
|
|
2313
|
+
await producer.dispose?.().catch(() => { });
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
async acceptV2Prepare(body) {
|
|
2317
|
+
const planInput = body.planInput;
|
|
2318
|
+
this.options.validateV2Plan?.(planInput);
|
|
2319
|
+
const recomputed = buildLoadEngineV2Plan(planInput);
|
|
2320
|
+
const barrierAssignments = body.barrierAssignments;
|
|
2321
|
+
const assignmentHash = buildLoadEngineV2AssignmentHash(String(body.barrierScopeId), barrierAssignments);
|
|
2322
|
+
const deadline = parseCanonicalInt64(String(body.deadlineUtcNs64), "barrier deadline");
|
|
2323
|
+
const leaseExpiry = parseCanonicalInt64(String(body.leaseExpiresUtcNs64), "registration lease expiry");
|
|
2324
|
+
const now = utcNowNs();
|
|
2325
|
+
if (!hashEquals(recomputed.hash, String(body.planHash))
|
|
2326
|
+
|| !hashEquals(assignmentHash, String(body.assignmentHash))
|
|
2327
|
+
|| !barrierAssignments.some((row) => row.agentId === this.options.agentId)
|
|
2328
|
+
|| deadline - now < 1000000000n || leaseExpiry <= now) {
|
|
2329
|
+
throw new Error("Load Engine V2 targeted prepare failed signed plan, assignment, lead-time, or lease validation.");
|
|
2330
|
+
}
|
|
2331
|
+
this.v2Prepared = { ...body, receivedMonotonicNs64: process.hrtime.bigint().toString() };
|
|
2332
|
+
await this.publishV2Ack(body, "armed", true);
|
|
2333
|
+
}
|
|
2334
|
+
async acceptV2Commit(body) {
|
|
2335
|
+
const prepared = this.v2Prepared;
|
|
2336
|
+
if (!prepared || prepared.commandId !== body.commandId || prepared.planHash !== body.planHash
|
|
2337
|
+
|| prepared.barrierScopeId !== body.barrierScopeId || prepared.assignmentHash !== body.assignmentHash
|
|
2338
|
+
|| prepared.barrierEpoch64 !== body.barrierEpoch64 || prepared.deadlineUtcNs64 !== body.deadlineUtcNs64) {
|
|
2339
|
+
throw new Error("Load Engine V2 commit does not match a prepared scoped epoch.");
|
|
2340
|
+
}
|
|
2341
|
+
const armedAcks = Array.isArray(body.armedAcks) ? body.armedAcks : [];
|
|
2342
|
+
if (!hashEquals(buildV2ArmedHash(armedAcks), String(body.armedHash))) {
|
|
2343
|
+
throw new Error("Load Engine V2 commit armed-acknowledgement hash is invalid.");
|
|
2344
|
+
}
|
|
2345
|
+
const deadline = parseCanonicalInt64(String(body.deadlineUtcNs64), "barrier deadline");
|
|
2346
|
+
const leaseExpiry = parseCanonicalNonNegativeInt64(String(prepared.leaseExpiresUtcNs64), "registration lease expiry");
|
|
2347
|
+
if (deadline - utcNowNs() < 100000000n || leaseExpiry <= utcNowNs()) {
|
|
2348
|
+
throw new Error("Load Engine V2 commit arrived too late to start traffic.");
|
|
2349
|
+
}
|
|
2350
|
+
const committed = { ...prepared, ...body };
|
|
2351
|
+
await this.publishV2Ack(committed, "committed", true);
|
|
2352
|
+
const delayMs = Number((deadline - utcNowNs()) / 1000000n);
|
|
2353
|
+
if (delayMs > 0)
|
|
2354
|
+
await sleep(delayMs);
|
|
2355
|
+
return committed;
|
|
2356
|
+
}
|
|
2357
|
+
async ensureV2Registration() {
|
|
2358
|
+
const prefix = `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.sessionId)}.v2`;
|
|
2359
|
+
if (!this.v2HelloConsumer) {
|
|
2360
|
+
this.v2HelloConsumer = this.createAdapter(this.endpoint("Consume", `agent-${this.options.agentId}-v2-hello`, `${prefix}.hello`));
|
|
2361
|
+
}
|
|
2362
|
+
if (!this.v2Hello) {
|
|
2363
|
+
const payload = await this.v2HelloConsumer.consume();
|
|
2364
|
+
const body = recordOrUndefined(payload?.body);
|
|
2365
|
+
if (!body)
|
|
2366
|
+
return false;
|
|
2367
|
+
const hello = body;
|
|
2368
|
+
validateLoadEngineV2CoordinatorHello(hello, this.options.sessionId, this.options.agentId, utcNowNs64());
|
|
2369
|
+
this.v2Hello = hello;
|
|
2370
|
+
this.v2RegistrationProducer = this.createAdapter(this.endpoint("Produce", `agent-${this.options.agentId}-v2-registration`, hello.registrationSubject));
|
|
2371
|
+
this.v2CommandConsumer = this.createAdapter(this.endpoint("Consume", `agent-${this.options.agentId}-v2-command`, buildLoadEngineV2ScopedTargetSubject(hello.runId, hello.sessionId, hello.coordinatorNonce, this.options.agentId)));
|
|
2372
|
+
await this.v2CommandConsumer.initialize?.();
|
|
2373
|
+
await this.publishV2Registration(true);
|
|
2374
|
+
this.v2RenewalTimer = setInterval(() => {
|
|
2375
|
+
void this.publishV2Registration(true).catch((error) => { this.v2RenewalError = error; });
|
|
2376
|
+
}, 10000);
|
|
2377
|
+
this.v2RenewalTimer.unref?.();
|
|
2378
|
+
return true;
|
|
2379
|
+
}
|
|
2380
|
+
if (Date.now() - this.v2LastRegistrationMs >= 100)
|
|
2381
|
+
await this.publishV2Registration(false);
|
|
2382
|
+
return true;
|
|
2383
|
+
}
|
|
2384
|
+
ensureV2CommandConsumer() {
|
|
2385
|
+
if (!this.v2CommandConsumer) {
|
|
2386
|
+
throw new Error("Load Engine V2 command consumer requires an authenticated coordinator hello.");
|
|
2387
|
+
}
|
|
2388
|
+
return this.v2CommandConsumer;
|
|
2389
|
+
}
|
|
2390
|
+
async publishV2Registration(renewLease) {
|
|
2391
|
+
const hello = this.v2Hello;
|
|
2392
|
+
const producer = this.v2RegistrationProducer;
|
|
2393
|
+
if (!hello || !producer)
|
|
2394
|
+
throw new Error("Load Engine V2 registration requires an authenticated coordinator hello.");
|
|
2395
|
+
if (!this.v2CurrentRegistration || renewLease) {
|
|
2396
|
+
this.v2RegistrationSequence += 1n;
|
|
2397
|
+
const issued = utcNowNs();
|
|
2398
|
+
this.v2CurrentRegistration = createLoadEngineV2Registration({
|
|
2399
|
+
runId: hello.runId,
|
|
2400
|
+
sessionId: hello.sessionId,
|
|
2401
|
+
coordinatorNonce: hello.coordinatorNonce,
|
|
2402
|
+
agentId: this.options.agentId,
|
|
2403
|
+
targetSubject: buildLoadEngineV2ScopedTargetSubject(hello.runId, hello.sessionId, hello.coordinatorNonce, this.options.agentId),
|
|
2404
|
+
registrationSequence64: this.v2RegistrationSequence.toString(),
|
|
2405
|
+
leaseIssuedUtcNs64: issued.toString(),
|
|
2406
|
+
leaseExpiresUtcNs64: (issued + 30000000000n).toString(),
|
|
2407
|
+
capabilities: this.options.v2Capabilities
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
await producer.produce({ headers: {}, body: this.v2CurrentRegistration });
|
|
2411
|
+
this.v2LastRegistrationMs = Date.now();
|
|
2412
|
+
}
|
|
2413
|
+
matchesV2Scope(body) {
|
|
2414
|
+
const hello = this.v2Hello;
|
|
2415
|
+
return !!hello && String(body.runId ?? "") === hello.runId
|
|
2416
|
+
&& String(body.sessionId ?? "") === hello.sessionId
|
|
2417
|
+
&& String(body.coordinatorNonce ?? "") === hello.coordinatorNonce;
|
|
2418
|
+
}
|
|
2419
|
+
async publishV2Ack(command, phase, accepted, extra = {}) {
|
|
2420
|
+
const producer = this.createAdapter(this.endpoint("Produce", `agent-${this.options.agentId}-v2-${phase}`, String(command.replySubject)));
|
|
2421
|
+
try {
|
|
2422
|
+
await producer.produce({ headers: { "x-cluster-command-id": String(command.commandId) }, body: {
|
|
2423
|
+
phase, commandId: String(command.commandId), agentId: this.options.agentId, accepted,
|
|
2424
|
+
planHash: String(command.planHash), barrierScopeId: String(command.barrierScopeId),
|
|
2425
|
+
assignmentHash: String(command.assignmentHash), barrierEpoch64: String(command.barrierEpoch64),
|
|
2426
|
+
deadlineUtcNs64: String(command.deadlineUtcNs64), acknowledgedUtcNs64: utcNowNs64(),
|
|
2427
|
+
...extra
|
|
2428
|
+
} });
|
|
2429
|
+
}
|
|
2430
|
+
finally {
|
|
2431
|
+
await producer.dispose?.().catch(() => { });
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
endpoint(mode, name, subject, binary = false) {
|
|
2435
|
+
return {
|
|
2436
|
+
kind: "Nats", mode, name, trackingField: "header:x-cluster-command-id",
|
|
2437
|
+
nats: { ...(this.options.nats ?? {}), Subject: subject, StartFromEarliest: true },
|
|
2438
|
+
...(binary ? { contentType: "application/octet-stream", messagePayloadType: "binary" } : {})
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
createAdapter(definition) {
|
|
2442
|
+
return (this.options.adapterFactory ?? EndpointAdapterFactory).create(definition);
|
|
2443
|
+
}
|
|
310
2444
|
}
|
|
311
2445
|
function parseRunCommand(value) {
|
|
312
2446
|
const targetScenarios = Array.isArray(value.targetScenarios)
|
|
@@ -326,11 +2460,24 @@ function parseRunCommand(value) {
|
|
|
326
2460
|
replySubject: stringOrDefault(value.replySubject, stringOrDefault(value.ReplySubject, ""))
|
|
327
2461
|
};
|
|
328
2462
|
}
|
|
329
|
-
function parseRunResult(value) {
|
|
2463
|
+
function parseRunResult(value, requireHistogramArtifact = false) {
|
|
330
2464
|
const statsValue = value.stats ?? value.Stats;
|
|
331
2465
|
const statsRecord = statsValue && typeof statsValue === "object" && !Array.isArray(statsValue)
|
|
332
2466
|
? statsValue
|
|
333
2467
|
: null;
|
|
2468
|
+
const histogramArtifactBase64 = statsRecord
|
|
2469
|
+
? stringOrDefault(statsRecord.histogramArtifactBase64, stringOrDefault(statsRecord.HistogramArtifactBase64, ""))
|
|
2470
|
+
: "";
|
|
2471
|
+
if (requireHistogramArtifact && !histogramArtifactBase64) {
|
|
2472
|
+
throw new Error("Load Engine V2 completed result requires a canonical histogram artifact.");
|
|
2473
|
+
}
|
|
2474
|
+
if (histogramArtifactBase64) {
|
|
2475
|
+
const decoded = Buffer.from(histogramArtifactBase64, "base64");
|
|
2476
|
+
if (decoded.toString("base64") !== histogramArtifactBase64) {
|
|
2477
|
+
throw new Error("Load Engine V2 histogram artifact base64 is not canonical.");
|
|
2478
|
+
}
|
|
2479
|
+
parseLoadEngineV2HistogramArtifact(decoded);
|
|
2480
|
+
}
|
|
334
2481
|
return {
|
|
335
2482
|
commandId: stringOrDefault(value.commandId, stringOrDefault(value.CommandId, "")),
|
|
336
2483
|
agentId: stringOrDefault(value.agentId, stringOrDefault(value.AgentId, "")),
|
|
@@ -348,7 +2495,20 @@ function parseRunResult(value) {
|
|
|
348
2495
|
thresholds: arrayOrUndefined(statsRecord.thresholds ?? statsRecord.Thresholds),
|
|
349
2496
|
pluginsData: arrayOrUndefined(statsRecord.pluginsData ?? statsRecord.PluginsData),
|
|
350
2497
|
nodeInfo: recordOrUndefined(statsRecord.nodeInfo ?? statsRecord.NodeInfo),
|
|
351
|
-
testInfo: recordOrUndefined(statsRecord.testInfo ?? statsRecord.TestInfo)
|
|
2498
|
+
testInfo: recordOrUndefined(statsRecord.testInfo ?? statsRecord.TestInfo),
|
|
2499
|
+
logFiles: arrayOrUndefined(statsRecord.logFiles ?? statsRecord.LogFiles)?.map((value) => String(value)),
|
|
2500
|
+
generatorWarnings: arrayOrUndefined(statsRecord.generatorWarnings ?? statsRecord.GeneratorWarnings),
|
|
2501
|
+
observationDeliveryStats: recordOrUndefined(statsRecord.observationDeliveryStats ?? statsRecord.ObservationDeliveryStats),
|
|
2502
|
+
schedulerSegments: arrayOrUndefined(statsRecord.schedulerSegments ?? statsRecord.SchedulerSegments),
|
|
2503
|
+
schedulerStats: recordOrUndefined(statsRecord.schedulerStats ?? statsRecord.SchedulerStats),
|
|
2504
|
+
histogramSidecars: arrayOrUndefined(statsRecord.histogramSidecars ?? statsRecord.HistogramSidecars),
|
|
2505
|
+
...(histogramArtifactBase64 ? { histogramArtifactBase64 } : {}),
|
|
2506
|
+
...(Object.prototype.hasOwnProperty.call(statsRecord, "reportingComplete")
|
|
2507
|
+
|| Object.prototype.hasOwnProperty.call(statsRecord, "ReportingComplete")
|
|
2508
|
+
? {
|
|
2509
|
+
reportingComplete: booleanOrDefault(statsRecord.reportingComplete, booleanOrDefault(statsRecord.ReportingComplete, false))
|
|
2510
|
+
}
|
|
2511
|
+
: {})
|
|
352
2512
|
}
|
|
353
2513
|
: undefined
|
|
354
2514
|
};
|
|
@@ -364,12 +2524,224 @@ function convertRunResult(result) {
|
|
|
364
2524
|
error: result.errorMessage ?? ""
|
|
365
2525
|
};
|
|
366
2526
|
}
|
|
2527
|
+
function utcNowNs() {
|
|
2528
|
+
return BigInt(Date.now()) * 1000000n;
|
|
2529
|
+
}
|
|
2530
|
+
function utcNowNs64() {
|
|
2531
|
+
return utcNowNs().toString();
|
|
2532
|
+
}
|
|
2533
|
+
async function collectV2Acks(consumer, phase, expectedAgentIds, commandId, timeoutMs) {
|
|
2534
|
+
const byAgent = new Map();
|
|
2535
|
+
const deadline = Date.now() + timeoutMs;
|
|
2536
|
+
while (byAgent.size < expectedAgentIds.length && Date.now() < deadline) {
|
|
2537
|
+
const payload = await consumer.consume();
|
|
2538
|
+
const body = recordOrUndefined(payload?.body);
|
|
2539
|
+
if (!body || body.phase !== phase || body.commandId !== commandId) {
|
|
2540
|
+
await sleep(5);
|
|
2541
|
+
continue;
|
|
2542
|
+
}
|
|
2543
|
+
const acknowledgement = body;
|
|
2544
|
+
if (!expectedAgentIds.includes(acknowledgement.agentId))
|
|
2545
|
+
continue;
|
|
2546
|
+
const previous = byAgent.get(acknowledgement.agentId);
|
|
2547
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(acknowledgement)) {
|
|
2548
|
+
throw new Error(`Load Engine V2 ${phase} acknowledgement changed during retry.`);
|
|
2549
|
+
}
|
|
2550
|
+
byAgent.set(acknowledgement.agentId, acknowledgement);
|
|
2551
|
+
}
|
|
2552
|
+
return [...byAgent.values()];
|
|
2553
|
+
}
|
|
2554
|
+
async function collectV2ClockProbeResults(consumer, expectedAgentIds, commandId, probeIndex, timeoutMs, barrierEpoch64) {
|
|
2555
|
+
const results = new Map();
|
|
2556
|
+
const deadline = Date.now() + timeoutMs;
|
|
2557
|
+
while (results.size < expectedAgentIds.length && Date.now() < deadline) {
|
|
2558
|
+
const payload = await consumer.consume();
|
|
2559
|
+
const body = recordOrUndefined(payload?.body);
|
|
2560
|
+
if (!body || body.phase !== "clock-probe-result" || body.commandId !== commandId
|
|
2561
|
+
|| Number(body.probeIndex) !== probeIndex
|
|
2562
|
+
|| (barrierEpoch64 !== undefined && String(body.barrierEpoch64 ?? "") !== barrierEpoch64)) {
|
|
2563
|
+
await sleep(2);
|
|
2564
|
+
continue;
|
|
2565
|
+
}
|
|
2566
|
+
const agentId = String(body.agentId ?? "");
|
|
2567
|
+
if (!expectedAgentIds.includes(agentId))
|
|
2568
|
+
continue;
|
|
2569
|
+
results.set(agentId, {
|
|
2570
|
+
probeIndex,
|
|
2571
|
+
coordinatorSendUtcNs64: String(body.coordinatorSendUtcNs64),
|
|
2572
|
+
agentReceiveUtcNs64: String(body.agentReceiveUtcNs64),
|
|
2573
|
+
agentSendUtcNs64: String(body.agentSendUtcNs64),
|
|
2574
|
+
coordinatorReceiveUtcNs64: utcNowNs64(),
|
|
2575
|
+
coordinatorClockResolutionNs64: String(body.coordinatorClockResolutionNs64),
|
|
2576
|
+
agentClockResolutionNs64: String(body.agentClockResolutionNs64)
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
if (results.size !== expectedAgentIds.length) {
|
|
2580
|
+
throw new Error("Load Engine V2 clock negotiation is missing an exact participant probe response.");
|
|
2581
|
+
}
|
|
2582
|
+
return results;
|
|
2583
|
+
}
|
|
2584
|
+
function validateV2DrainAcks(expectedAgentIds, acknowledgements, planHash, assignmentHash, barrierEpoch64, barrierScopeId) {
|
|
2585
|
+
const expected = normalizeExpectedAgentIds(expectedAgentIds);
|
|
2586
|
+
const ordered = [...acknowledgements].sort((left, right) => compareAgentIds(left.agentId, right.agentId));
|
|
2587
|
+
if (ordered.length !== expected.length || ordered.some((value, index) => value.agentId !== expected[index])) {
|
|
2588
|
+
throw new Error("Load Engine V2 drained barrier is missing an exact participant acknowledgement.");
|
|
2589
|
+
}
|
|
2590
|
+
for (const acknowledgement of ordered) {
|
|
2591
|
+
const row = acknowledgement;
|
|
2592
|
+
parseCanonicalInt64(row.acknowledgedUtcNs64, "drain acknowledgement time");
|
|
2593
|
+
if (!row.accepted || row.phase !== "drained" || row.planHash !== planHash
|
|
2594
|
+
|| row.assignmentHash !== assignmentHash || row.barrierScopeId !== barrierScopeId
|
|
2595
|
+
|| row.barrierEpoch64 !== barrierEpoch64 || row.finalDeltaAcknowledged !== true
|
|
2596
|
+
|| row.accountingComplete !== true) {
|
|
2597
|
+
throw new Error("Load Engine V2 drained acknowledgement is invalid.");
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
function buildV2ArmedHash(acknowledgements) {
|
|
2602
|
+
const fields = [Buffer.from("LS-AR1\n", "ascii"), frameUtf8("1")];
|
|
2603
|
+
const ordered = [...acknowledgements].sort((left, right) => compareAgentIds(left.agentId, right.agentId));
|
|
2604
|
+
fields.push(frameUtf8(ordered.length.toString()));
|
|
2605
|
+
for (const acknowledgement of ordered) {
|
|
2606
|
+
for (const value of [
|
|
2607
|
+
acknowledgement.agentId,
|
|
2608
|
+
acknowledgement.planHash,
|
|
2609
|
+
acknowledgement.barrierScopeId ?? "",
|
|
2610
|
+
acknowledgement.assignmentHash,
|
|
2611
|
+
acknowledgement.barrierEpoch64,
|
|
2612
|
+
acknowledgement.deadlineUtcNs64,
|
|
2613
|
+
acknowledgement.acknowledgedUtcNs64
|
|
2614
|
+
])
|
|
2615
|
+
fields.push(frameUtf8(value));
|
|
2616
|
+
}
|
|
2617
|
+
return sha256Hex(Buffer.concat(fields));
|
|
2618
|
+
}
|
|
2619
|
+
function buildOwnerLossAssignments(plan, missingAgentIds, expectedAgentIds) {
|
|
2620
|
+
return missingAgentIds.map((agentId) => {
|
|
2621
|
+
const shardIndex = expectedAgentIds.indexOf(agentId);
|
|
2622
|
+
return {
|
|
2623
|
+
agentId,
|
|
2624
|
+
shardIndex64: shardIndex.toString(),
|
|
2625
|
+
shardCount64: expectedAgentIds.length.toString(),
|
|
2626
|
+
scenarios: plan.scenarios.filter((scenario) => scenario.target === "agent").map((scenario) => ({
|
|
2627
|
+
scenarioName: scenario.scenarioName,
|
|
2628
|
+
scenarioIndex64: scenario.scenarioIndex64,
|
|
2629
|
+
simulations: scenario.simulations.map((simulation) => ({
|
|
2630
|
+
simulationIndex64: simulation.simulationIndex64,
|
|
2631
|
+
kind: simulation.kind,
|
|
2632
|
+
plannedIterations64: ownedPlannedIterations(simulation, shardIndex, expectedAgentIds.length).toString()
|
|
2633
|
+
}))
|
|
2634
|
+
}))
|
|
2635
|
+
};
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
function ownedPlannedIterations(simulation, shardIndex, shardCount) {
|
|
2639
|
+
let global = 0n;
|
|
2640
|
+
if (simulation.kind === "iterations-for-inject" || simulation.kind === "iterations-for-constant") {
|
|
2641
|
+
global = parseCanonicalNonNegativeInt64(simulation.iterations64, "planned iterations");
|
|
2642
|
+
}
|
|
2643
|
+
else if (simulation.kind === "inject") {
|
|
2644
|
+
const duration = parseCanonicalNonNegativeInt64(simulation.durationNs64, "duration");
|
|
2645
|
+
const interval = parseCanonicalNonNegativeInt64(simulation.intervalNs64, "interval");
|
|
2646
|
+
const rate = parseCanonicalNonNegativeInt64(simulation.rate64, "rate");
|
|
2647
|
+
if (duration > 0n && interval > 0n)
|
|
2648
|
+
global = ((duration + interval - 1n) / interval) * rate;
|
|
2649
|
+
}
|
|
2650
|
+
const index = BigInt(shardIndex);
|
|
2651
|
+
const count = BigInt(shardCount);
|
|
2652
|
+
return global <= index ? 0n : ((global - 1n - index) / count) + 1n;
|
|
2653
|
+
}
|
|
367
2654
|
function sanitizeToken(value) {
|
|
368
2655
|
if (!value.trim()) {
|
|
369
2656
|
return "default";
|
|
370
2657
|
}
|
|
371
2658
|
return value.replace(/[^a-zA-Z0-9\-_]/g, "_");
|
|
372
2659
|
}
|
|
2660
|
+
function validateLoadEngineV2AgentId(agentId) {
|
|
2661
|
+
if (!agentId)
|
|
2662
|
+
throw new Error("Load Engine V2 remote agents require an explicit stable AgentId.");
|
|
2663
|
+
validateUnicodeScalars(agentId);
|
|
2664
|
+
if (Buffer.byteLength(agentId, "utf8") > 256) {
|
|
2665
|
+
throw new Error("Load Engine V2 AgentId must be at most 256 UTF-8 bytes.");
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
function normalizeExpectedAgentIds(values) {
|
|
2669
|
+
if (!values.length)
|
|
2670
|
+
throw new Error("Load Engine V2 requires an exact ExpectedAgentIds participant set.");
|
|
2671
|
+
values.forEach(validateLoadEngineV2AgentId);
|
|
2672
|
+
if (new Set(values).size !== values.length) {
|
|
2673
|
+
throw new Error("Load Engine V2 expected agent identities must be unique.");
|
|
2674
|
+
}
|
|
2675
|
+
return [...values].sort(compareUtf8);
|
|
2676
|
+
}
|
|
2677
|
+
function compareUtf8(left, right) {
|
|
2678
|
+
validateUnicodeScalars(left);
|
|
2679
|
+
validateUnicodeScalars(right);
|
|
2680
|
+
return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
|
|
2681
|
+
}
|
|
2682
|
+
function compareAgentIds(left, right) {
|
|
2683
|
+
return compareUtf8(left, right);
|
|
2684
|
+
}
|
|
2685
|
+
function validateUnicodeScalars(value) {
|
|
2686
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2687
|
+
const code = value.charCodeAt(index);
|
|
2688
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
2689
|
+
const next = value.charCodeAt(index + 1);
|
|
2690
|
+
if (next < 0xdc00 || next > 0xdfff)
|
|
2691
|
+
throw new Error("Load Engine V2 text must contain valid Unicode scalars.");
|
|
2692
|
+
index += 1;
|
|
2693
|
+
}
|
|
2694
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
2695
|
+
throw new Error("Load Engine V2 text must contain valid Unicode scalars.");
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
function frameUtf8(value) {
|
|
2700
|
+
validateUnicodeScalars(value);
|
|
2701
|
+
const bytes = Buffer.from(value, "utf8");
|
|
2702
|
+
const frame = Buffer.allocUnsafe(4 + bytes.length);
|
|
2703
|
+
frame.writeUInt32BE(bytes.length, 0);
|
|
2704
|
+
bytes.copy(frame, 4);
|
|
2705
|
+
return frame;
|
|
2706
|
+
}
|
|
2707
|
+
function parseCanonicalInt64(value, field) {
|
|
2708
|
+
if (!/^-?(0|[1-9][0-9]*)$/.test(value) || value === "-0") {
|
|
2709
|
+
throw new Error(`${field} must be a canonical decimal string.`);
|
|
2710
|
+
}
|
|
2711
|
+
const parsed = BigInt(value);
|
|
2712
|
+
if (parsed < -9223372036854775808n || parsed > 9223372036854775807n) {
|
|
2713
|
+
throw new Error(`${field} exceeds signed 64-bit storage.`);
|
|
2714
|
+
}
|
|
2715
|
+
return parsed;
|
|
2716
|
+
}
|
|
2717
|
+
function parseCanonicalNonNegativeInt64(value, field) {
|
|
2718
|
+
const parsed = parseCanonicalInt64(value, field);
|
|
2719
|
+
if (parsed < 0n)
|
|
2720
|
+
throw new Error(`${field} must be non-negative.`);
|
|
2721
|
+
return parsed;
|
|
2722
|
+
}
|
|
2723
|
+
function compareCanonicalIntegers(left, right) {
|
|
2724
|
+
const leftValue = parseCanonicalNonNegativeInt64(left, "canonical index");
|
|
2725
|
+
const rightValue = parseCanonicalNonNegativeInt64(right, "canonical index");
|
|
2726
|
+
return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0;
|
|
2727
|
+
}
|
|
2728
|
+
function validateLowercaseHash(value, field) {
|
|
2729
|
+
if (!/^[0-9a-f]{64}$/.test(value)) {
|
|
2730
|
+
throw new Error(`Load Engine V2 ${field} must be 64 lowercase hexadecimal characters.`);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
function sha256Hex(value) {
|
|
2734
|
+
return createHash("sha256").update(value).digest("hex");
|
|
2735
|
+
}
|
|
2736
|
+
function hashEquals(left, right) {
|
|
2737
|
+
validateLowercaseHash(left, "hash");
|
|
2738
|
+
validateLowercaseHash(right, "hash");
|
|
2739
|
+
return timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex"));
|
|
2740
|
+
}
|
|
2741
|
+
function requireCapability(offered, required, display) {
|
|
2742
|
+
if (!offered.includes(required))
|
|
2743
|
+
throw new Error(`Load Engine V2 capability negotiation requires ${display}.`);
|
|
2744
|
+
}
|
|
373
2745
|
function buildWeightedCycle(weights) {
|
|
374
2746
|
const cycle = [];
|
|
375
2747
|
for (let i = 0; i < weights.length; i += 1) {
|
|
@@ -402,6 +2774,13 @@ function recordOrUndefined(value) {
|
|
|
402
2774
|
? value
|
|
403
2775
|
: undefined;
|
|
404
2776
|
}
|
|
2777
|
+
function loadEngineV2BinaryBody(value) {
|
|
2778
|
+
if (value instanceof Uint8Array)
|
|
2779
|
+
return Buffer.from(value);
|
|
2780
|
+
if (value instanceof ArrayBuffer)
|
|
2781
|
+
return Buffer.from(value);
|
|
2782
|
+
return undefined;
|
|
2783
|
+
}
|
|
405
2784
|
function arrayOrUndefined(value) {
|
|
406
2785
|
return Array.isArray(value) ? value : undefined;
|
|
407
2786
|
}
|