blob: 43a67e4e0d080e9dfe843671952f8f5e428db941 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Startup profile for the execution Runtime Host composition.
//
// Seeds a usage-shaped fixture (Sessions × Turns × artifacts), then reopens
// the composition several times, timing each restart's anchors separately:
//
// open construct the execution composition (storage roots, stores)
// recover the five-phase domain-module recovery
// firstQuery the first `session.catalog.query` after recovery
// close orderly composition shutdown
//
// Usage (from packages/runtime-host, after a workspace build):
// node scripts/startup-profile.mjs
// SESSIONS=300 TURNS=3 ARTIFACTS=4 ROUNDS=5 node scripts/startup-profile.mjs
import { performance } from 'node:perf_hooks';
import { createHash } from 'node:crypto';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { FakeBackend } from '@maka/runtime/test-only/fake-backend';
import { SessionManager } from '@maka/runtime/session-manager';
import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
import { createExecutionRuntimeHostComposition } from '../dist/server/execution-composition.js';
const SESSIONS = Number(process.env.SESSIONS ?? 30);
const TURNS = Number(process.env.TURNS ?? 3);
const ARTIFACTS = Number(process.env.ARTIFACTS ?? 2);
const ARTIFACT_BYTES = Number(process.env.ARTIFACT_BYTES ?? 2048);
const ROUNDS = Number(process.env.ROUNDS ?? 3);
const CONNECTION_ID = randomUUID();
const operationContext = {
hostEpoch: 'startup-profile',
connectionId: 'startup-profile-client',
principal: 'local_os_user',
acquireResidency: () => ({ release() {} }),
};
const root = await mkdtemp(join(tmpdir(), 'maka-startup-profile-'));
const capability = await resolveStorageRoot({ path: root, kind: 'interactive' });
const owner = await tryAcquireInteractiveRootOwner(capability);
if (!owner) throw new Error(`Could not acquire profile storage root: ${root}`);
console.log(
`fixture target: ${SESSIONS} sessions × ${TURNS} turns × ` +
`${ARTIFACTS} artifacts(${ARTIFACT_BYTES}B), ${ROUNDS} restart rounds`,
);
// ── Seed pass ────────────────────────────────────────────────────────────────
const seedOpenStart = performance.now();
let seedComposition;
try {
const seedOriginalRecover = SessionManager.prototype.recoverInterruptedSessionsStrict;
let seedManager;
SessionManager.prototype.recoverInterruptedSessionsStrict = async function (stores) {
seedManager = this;
return seedOriginalRecover.call(this, stores);
};
seedComposition = await createExecutionRuntimeHostComposition(
{
owner,
hostEpoch: operationContext.hostEpoch,
acquireResidency: () => ({ release() {} }),
retainUntilProcessExit: () => undefined,
requestDrain: () => undefined,
},
{},
{ primaryBackendFactory: (context) => new FakeBackend(context) },
);
await seedComposition.recover();
SessionManager.prototype.recoverInterruptedSessionsStrict = seedOriginalRecover;
if (!seedManager) throw new Error('Seed composition did not construct a SessionManager');
await configureDefaultModel(seedComposition.handlers);
const artifactBytes = Buffer.from(
randomUUID()
.repeat(Math.ceil(ARTIFACT_BYTES / 36))
.slice(0, ARTIFACT_BYTES),
'utf8',
);
const artifactSha = `sha256:${createHash('sha256').update(artifactBytes).digest('hex')}`;
for (let sessionIndex = 0; sessionIndex < SESSIONS; sessionIndex += 1) {
const sessionStart = performance.now();
let turnsMs = 0;
let artifactsMs = 0;
const created = await seedComposition.handlers['session.create'](
{
sessionId: randomUUID(),
workspace: { kind: 'host_path', path: root },
name: `startup-profile-${sessionIndex}`,
modelTarget: { kind: 'default' },
},
operationContext,
);
if (!created.ok) throw new Error(`session.create failed: ${JSON.stringify(created.error)}`);
const sessionId = created.result.id;
for (let turnIndex = 0; turnIndex < TURNS; turnIndex += 1) {
const turnStart = performance.now();
const turnId = `turn-${sessionIndex}-${turnIndex}`;
const started = await seedComposition.handlers['turn.start'](
{
sessionId,
turnId,
// FakeBackend paces non-steering responses as 9-char chunks with a
// 45ms typing sleep per chunk, so a long seed turn spends seconds in
// that simulation instead of the anchors this profile measures. A
// ~25-char turn still streams a few deltas on the same durable path.
content: { text: `startup profile turn ${sessionIndex}/${turnIndex}` },
},
operationContext,
);
if (!started.ok) throw new Error(`turn.start failed: ${JSON.stringify(started.error)}`);
await waitForCompletedTurn(seedManager, sessionId, turnId);
turnsMs += performance.now() - turnStart;
}
for (let artifactIndex = 0; artifactIndex < ARTIFACTS; artifactIndex += 1) {
const artifactStart = performance.now();
const uploadId = randomUUID();
const ingest = async (input) => {
const outcome = await seedComposition.handlers['artifact.ingest'](input, operationContext);
if (!outcome.ok) {
throw new Error(`artifact.ingest ${input.kind} failed: ${JSON.stringify(outcome.error)}`);
}
if (process.env.PROFILE_DEBUG) console.log(input.kind, JSON.stringify(outcome.result));
return outcome.result;
};
await ingest({
kind: 'begin',
sessionId,
uploadId,
name: `profile-${sessionIndex}-${artifactIndex}.bin`,
mimeType: 'application/octet-stream',
totalBytes: ARTIFACT_BYTES,
contentSha256: artifactSha,
});
// The wire limits each chunk's base64 payload; walk offsets from the
// accepted nextOffset instead of assuming one chunk fits.
let offset = 0;
const chunkBytes = 1536;
while (offset < ARTIFACT_BYTES) {
const slice = artifactBytes.subarray(offset, offset + chunkBytes);
const accepted = await ingest({
kind: 'chunk',
sessionId,
uploadId,
offset,
chunkBase64: slice.toString('base64'),
});
offset = accepted.nextOffset;
}
await ingest({ kind: 'commit', sessionId, uploadId });
artifactsMs += performance.now() - artifactStart;
}
if (process.env.PROFILE_DEBUG) {
console.log(
`session ${sessionIndex}: total=${((performance.now() - sessionStart) / 1000).toFixed(2)}s ` +
`turns=${(turnsMs / 1000).toFixed(2)}s artifacts=${(artifactsMs / 1000).toFixed(2)}s ` +
`other=${((performance.now() - sessionStart - turnsMs - artifactsMs) / 1000).toFixed(2)}s`,
);
}
}
const seeded = await seedManager.listSessions();
console.log(
`seed done in ${((performance.now() - seedOpenStart) / 1000).toFixed(1)}s: ` +
`${seeded.length} sessions on disk`,
);
} finally {
await seedComposition?.close?.();
}
async function configureDefaultModel(handlers) {
const call = async (operation, input) => {
const outcome = await handlers[operation](input, operationContext);
if (!outcome.ok) throw new Error(`${operation} failed: ${JSON.stringify(outcome.error)}`);
return outcome.result;
};
const catalog = await call('connection.catalog.query', { kind: 'start' });
const created = await call('connection.catalog.create', {
expectedCatalogRevision: catalog.revision,
connection: {
slug: 'startup-profile',
name: 'Startup profile',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
baseUrl: 'https://startup-profile.invalid/v1',
enabled: true,
enabledModelIds: ['profile-test-model'],
},
});
if (created.kind !== 'committed') throw new Error('Profile connection must commit');
await call('credential.vault.set', {
locator: {
scope: 'connection',
connectionId: created.connection.connectionId,
kind: 'api_key',
},
expected: null,
secret: 'startup-profile-key',
});
await call('connection.catalog.set-default-target', {
expectedCatalogRevision: created.catalogRevision,
target: { connectionId: created.connection.connectionId, modelId: 'profile-test-model' },
});
}
// ── Restart rounds ───────────────────────────────────────────────────────────
const rounds = [];
for (let round = 0; round < ROUNDS; round += 1) {
rounds.push(await restartRound(round));
}
console.table(
rounds.map((round) => ({
round: round.round,
openMs: round.openMs.toFixed(0),
recoverMs: round.recoverMs.toFixed(0),
firstQueryMs: round.firstQueryMs.toFixed(1),
closeMs: round.closeMs.toFixed(0),
totalMs: round.totalMs.toFixed(0),
})),
);
const med = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
console.log(
`median: open=${med(rounds.map((r) => r.openMs)).toFixed(0)}ms ` +
`recover=${med(rounds.map((r) => r.recoverMs)).toFixed(0)}ms ` +
`firstQuery=${med(rounds.map((r) => r.firstQueryMs)).toFixed(1)}ms ` +
`close=${med(rounds.map((r) => r.closeMs)).toFixed(0)}ms ` +
`total=${med(rounds.map((r) => r.totalMs)).toFixed(0)}ms`,
);
const rootInfo = await stat(root);
console.log(`storage root: ${root} (${(rootInfo.size / 1024).toFixed(1)} KiB top-level)`);
await rm(root, { recursive: true, force: true });
async function restartRound(round) {
const openStart = performance.now();
const composition = await createExecutionRuntimeHostComposition(
{
owner,
hostEpoch: `${operationContext.hostEpoch}-${round}`,
acquireResidency: () => ({ release() {} }),
retainUntilProcessExit: () => undefined,
requestDrain: () => undefined,
},
{},
{ primaryBackendFactory: (context) => new FakeBackend(context) },
);
const openMs = performance.now() - openStart;
const recoverStart = performance.now();
await composition.recover();
const recoverMs = performance.now() - recoverStart;
const queryStart = performance.now();
const listed = await composition.handlers['session.catalog.query'](
{ kind: 'list_start' },
operationContext,
);
const firstQueryMs = performance.now() - queryStart;
if (!listed.ok) throw new Error(`catalog query failed: ${JSON.stringify(listed.error)}`);
const closeStart = performance.now();
await composition.close?.();
const closeMs = performance.now() - closeStart;
return {
round,
openMs,
recoverMs,
firstQueryMs,
closeMs,
totalMs: performance.now() - openStart,
};
}
async function waitForCompletedTurn(manager, sessionId, turnId) {
const deadline = performance.now() + 15_000;
while (performance.now() < deadline) {
const messages = await manager.getMessages(sessionId);
const state = messages.findLast(
(message) => message.type === 'turn_state' && message.turnId === turnId,
);
if (state?.status === 'completed') return;
if (state?.status === 'failed' || state?.status === 'aborted') {
throw new Error(`Profile turn ${turnId} ended as ${state.status}`);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`Timed out waiting for profile turn ${turnId}`);
}