mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
test(ql3): qualify failed upgrades on edge devices
This commit is contained in:
@@ -0,0 +1,863 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const DEFAULT_MAX_DURATION_MS = 10_000;
|
||||
const DEFAULT_MAX_RSS_DELTA_MB = 96;
|
||||
const DEFAULT_MAX_DATABASE_GROWTH_BYTES = 4 * MIB;
|
||||
|
||||
const REPORT_KEYS = Object.freeze([
|
||||
'calls',
|
||||
'database',
|
||||
'durable',
|
||||
'evidenceClass',
|
||||
'gates',
|
||||
'identity',
|
||||
'limitations',
|
||||
'measurement',
|
||||
'recovery',
|
||||
'schemaVersion',
|
||||
'supported',
|
||||
'thresholds',
|
||||
]);
|
||||
const CALL_KEYS = Object.freeze([
|
||||
'byteSourceClose',
|
||||
'byteSourceOpen',
|
||||
'byteSourceRead',
|
||||
'publisherInspect',
|
||||
'publisherPublish',
|
||||
'stage',
|
||||
]);
|
||||
const DATABASE_KEYS = Object.freeze([
|
||||
'allocatedBytes',
|
||||
'allocatedGrowthBytes',
|
||||
'files',
|
||||
'integrityCheck',
|
||||
'journalMode',
|
||||
'logicalBytes',
|
||||
'logicalGrowthBytes',
|
||||
'materializedCandidateRevisions',
|
||||
'synchronous',
|
||||
]);
|
||||
const DATABASE_FILE_KEYS = Object.freeze([
|
||||
'allocatedBytes',
|
||||
'logicalBytes',
|
||||
'suffix',
|
||||
]);
|
||||
const DURABLE_KEYS = Object.freeze([
|
||||
'activeLockDigestPreserved',
|
||||
'failedFrom',
|
||||
'failureReason',
|
||||
'headInstallationIsCandidate',
|
||||
'previousActiveLockDigestPreserved',
|
||||
'state',
|
||||
]);
|
||||
const GATE_KEYS = Object.freeze(['passed', 'violations']);
|
||||
const IDENTITY_KEYS = Object.freeze(['architecture', 'node', 'platform']);
|
||||
const MEASUREMENT_KEYS = Object.freeze([
|
||||
'durationMs',
|
||||
'rssAfterBytes',
|
||||
'rssBeforeBytes',
|
||||
'rssDeltaBytes',
|
||||
]);
|
||||
const RECOVERY_KEYS = Object.freeze([
|
||||
'deferred',
|
||||
'manualRequired',
|
||||
'pages',
|
||||
'remaining',
|
||||
'retry',
|
||||
'safeToAdmit',
|
||||
'scanned',
|
||||
'settled',
|
||||
'superseded',
|
||||
]);
|
||||
const THRESHOLD_KEYS = Object.freeze([
|
||||
'maxDatabaseGrowthBytes',
|
||||
'maxDurationMs',
|
||||
'maxRssDeltaBytes',
|
||||
]);
|
||||
const EXPECTED_LIMITATIONS = Object.freeze([
|
||||
'container_or_vm_resource_limits_are_not_physical_support_evidence',
|
||||
'physical_power_loss_not_proven',
|
||||
]);
|
||||
|
||||
class QingLong3PluginPackageRecoveryEdgeBenchmarkError extends Error {
|
||||
constructor(message) {
|
||||
super(
|
||||
`QingLong 3.0 Plugin Package recovery Edge benchmark failed: ${message}`,
|
||||
);
|
||||
this.name = 'QingLong3PluginPackageRecoveryEdgeBenchmarkError';
|
||||
}
|
||||
}
|
||||
|
||||
function hasExactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value).sort()) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function positiveNumber(value, label) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`${label} must be a positive number`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value, label) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`${label} must be a positive safe integer`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {
|
||||
json: false,
|
||||
maxDatabaseGrowthBytes: DEFAULT_MAX_DATABASE_GROWTH_BYTES,
|
||||
maxDurationMs: DEFAULT_MAX_DURATION_MS,
|
||||
maxRssDeltaBytes: DEFAULT_MAX_RSS_DELTA_MB * MIB,
|
||||
};
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--json') {
|
||||
options.json = true;
|
||||
continue;
|
||||
}
|
||||
const [name, raw] = argument.split('=', 2);
|
||||
if (raw === undefined) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`unsupported argument ${argument}`,
|
||||
);
|
||||
}
|
||||
if (name === '--max-duration-ms') {
|
||||
options.maxDurationMs = positiveNumber(raw, name);
|
||||
} else if (name === '--max-rss-delta-mb') {
|
||||
options.maxRssDeltaBytes = positiveNumber(raw, name) * MIB;
|
||||
} else if (name === '--max-database-growth-bytes') {
|
||||
options.maxDatabaseGrowthBytes = positiveSafeInteger(raw, name);
|
||||
} else {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`unsupported argument ${name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze(options);
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function storageSnapshot(databasePath) {
|
||||
const files = [
|
||||
['database', databasePath],
|
||||
['-journal', `${databasePath}-journal`],
|
||||
['-wal', `${databasePath}-wal`],
|
||||
['-shm', `${databasePath}-shm`],
|
||||
]
|
||||
.filter(([, filePath]) => fs.existsSync(filePath))
|
||||
.map(([suffix, filePath]) => {
|
||||
const stat = fs.statSync(filePath);
|
||||
return Object.freeze({
|
||||
suffix,
|
||||
logicalBytes: stat.size,
|
||||
allocatedBytes: stat.blocks * 512,
|
||||
});
|
||||
});
|
||||
return Object.freeze({
|
||||
files: Object.freeze(files),
|
||||
logicalBytes: files.reduce((sum, file) => sum + file.logicalBytes, 0),
|
||||
allocatedBytes: files.reduce((sum, file) => sum + file.allocatedBytes, 0),
|
||||
});
|
||||
}
|
||||
|
||||
function packageFixture(runtime, generation, previous, invalidWorkflow) {
|
||||
const packageArchitecture =
|
||||
process.arch === 'x64'
|
||||
? 'amd64'
|
||||
: process.arch === 'arm'
|
||||
? 'arm/v7'
|
||||
: process.arch;
|
||||
const task = (id) => ({
|
||||
schema: 'qinglong/plugin-package-task-resource@v1',
|
||||
id,
|
||||
name: `Recovery ${id}`,
|
||||
labels: { 'plugin.qinglong.io/source': 'edge-recovery-benchmark' },
|
||||
enabled: true,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/usr/bin/printf',
|
||||
args: [id],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const workflow = {
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'recovery',
|
||||
name: 'Recovery qualification',
|
||||
enabled: true,
|
||||
steps: invalidWorkflow
|
||||
? [
|
||||
{ id: 'collect', task: 'collect', needs: ['report'] },
|
||||
{ id: 'report', task: 'report', needs: ['collect'] },
|
||||
]
|
||||
: [
|
||||
{ id: 'collect', task: 'collect', needs: [] },
|
||||
{ id: 'report', task: 'report', needs: ['collect'] },
|
||||
],
|
||||
};
|
||||
const values = {
|
||||
'tasks/collect.json': task('collect'),
|
||||
'tasks/report.json': task('report'),
|
||||
'workflows/recovery.json': workflow,
|
||||
};
|
||||
const bytes = Object.fromEntries(
|
||||
Object.entries(values).map(([resourcePath, value]) => [
|
||||
resourcePath,
|
||||
Buffer.from(JSON.stringify(value)),
|
||||
]),
|
||||
);
|
||||
const descriptors = Object.entries(bytes)
|
||||
.map(([resourcePath, material]) => ({
|
||||
path: resourcePath,
|
||||
bytes: material.byteLength,
|
||||
digest: sha256(material),
|
||||
}))
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const manifest = {
|
||||
apiVersion: runtime.PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: runtime.PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'edge-recovery-benchmark',
|
||||
displayName: 'Edge recovery benchmark',
|
||||
version: `${generation}.0.0`,
|
||||
description: 'Bounded failed-upgrade qualification workload',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: [packageArchitecture],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: ['system.command'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.json', 'tasks/report.json'],
|
||||
workflows: ['workflows/recovery.json'],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: packageArchitecture,
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * MIB,
|
||||
availableDiskBytes: 256 * MIB,
|
||||
};
|
||||
const plan = runtime.planPluginPackageInstall(
|
||||
manifest,
|
||||
environment,
|
||||
previous?.manifest,
|
||||
);
|
||||
const artifactDigest = sha256(`edge-recovery-artifact-${generation}`);
|
||||
const action = {
|
||||
lockId: `edge-recovery-lock-${generation}`,
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
...(previous ? { previousManifest: previous.manifest } : {}),
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${artifactDigest}`,
|
||||
artifactDigest,
|
||||
artifactBytes: Object.values(bytes).reduce(
|
||||
(sum, material) => sum + material.byteLength,
|
||||
0,
|
||||
),
|
||||
contentDigest: runtime.pluginPackageContentTreeDigest(descriptors),
|
||||
},
|
||||
architecture: packageArchitecture,
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: generation,
|
||||
...(previous ? { previousLockDigest: previous.lock.lockDigest } : {}),
|
||||
};
|
||||
const lock = runtime.createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: `edge-recovery-approval-${generation}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `edge-recovery-dispatch-${generation}`,
|
||||
actionDigest: runtime.pluginPackageInstallActionDigest(action),
|
||||
previewDigest: runtime.pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'edge-owner' },
|
||||
approvedAtMs: 100 + generation,
|
||||
expiresAtMs: 100_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200 + generation,
|
||||
});
|
||||
return Object.freeze({
|
||||
bytes: Object.freeze(bytes),
|
||||
lock,
|
||||
manifest,
|
||||
manifestBytes: Buffer.from(
|
||||
runtime.serializePluginPackageManifest(manifest),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function stage(runtime, value, record, occurredAtMs) {
|
||||
return runtime.transitionPluginPackageInstall(value.lock, record, {
|
||||
type: 'stage_completed',
|
||||
mutationId: `edge-recovery-stage-${value.lock.targetGeneration}`,
|
||||
occurredAtMs,
|
||||
stageRef: `edge-recovery:${value.lock.lockDigest}`,
|
||||
artifactDigest: value.lock.source.artifactDigest,
|
||||
manifestDigest: value.lock.manifestDigest,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
evidenceDigest: sha256(
|
||||
`edge-recovery-stage-${value.lock.targetGeneration}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function createActiveGeneration(runtime, repository, value) {
|
||||
const queued = runtime.createPluginPackageInstall(value.lock, {
|
||||
installationId: 'edge-recovery-install-1',
|
||||
mutationId: 'edge-recovery-create-1',
|
||||
occurredAtMs: 301,
|
||||
});
|
||||
await repository.create(
|
||||
runtime.pluginPackageInstallCreate(value.lock, queued, null),
|
||||
);
|
||||
const staged = stage(runtime, value, queued, 302);
|
||||
await repository.commit(runtime.pluginPackageInstallCommit(queued, staged));
|
||||
const activating = runtime.transitionPluginPackageInstall(
|
||||
value.lock,
|
||||
staged,
|
||||
{
|
||||
type: 'activation_started',
|
||||
mutationId: 'edge-recovery-activate-1',
|
||||
occurredAtMs: 303,
|
||||
},
|
||||
);
|
||||
await repository.commit(
|
||||
runtime.pluginPackageInstallCommit(staged, activating),
|
||||
);
|
||||
const active = runtime.transitionPluginPackageInstall(
|
||||
value.lock,
|
||||
activating,
|
||||
{
|
||||
type: 'activation_committed',
|
||||
mutationId: 'edge-recovery-commit-1',
|
||||
occurredAtMs: 304,
|
||||
activationRef: `edge-active:${value.lock.lockDigest}`,
|
||||
intentDigest: runtime.pluginPackageActivationIntentDigest(
|
||||
value.lock,
|
||||
activating,
|
||||
),
|
||||
generation: 1,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
},
|
||||
);
|
||||
await repository.commit(
|
||||
runtime.pluginPackageInstallCommit(activating, active),
|
||||
);
|
||||
return active;
|
||||
}
|
||||
|
||||
async function createStagedUpgrade(runtime, repository, value, active) {
|
||||
const queued = runtime.createPluginPackageInstall(value.lock, {
|
||||
installationId: 'edge-recovery-install-2',
|
||||
mutationId: 'edge-recovery-create-2',
|
||||
occurredAtMs: 401,
|
||||
});
|
||||
await repository.create(
|
||||
runtime.pluginPackageInstallCreate(value.lock, queued, active),
|
||||
);
|
||||
const staged = stage(runtime, value, queued, 402);
|
||||
await repository.commit(runtime.pluginPackageInstallCommit(queued, staged));
|
||||
return staged;
|
||||
}
|
||||
|
||||
function runtimeDependencies() {
|
||||
const pluginPackage = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackage');
|
||||
const install = require('../packages/ql3-runtime-core/dist/plugin-package/installation/pluginPackageInstall');
|
||||
const bundle = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackageBundle');
|
||||
const recovery = require('../packages/ql3-runtime-core/dist/plugin-package/installation/pluginPackageRecovery');
|
||||
const materialization = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
const generation = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const semantics = require('../packages/ql3-runtime-core/dist/task-definition/taskSpecSemantic');
|
||||
return {
|
||||
...pluginPackage,
|
||||
...install,
|
||||
...bundle,
|
||||
...recovery,
|
||||
...materialization,
|
||||
...generation,
|
||||
...semantics,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBenchmark(options) {
|
||||
if (Number(process.versions.node.split('.')[0]) < 24) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
'Node.js 24 or newer is required',
|
||||
);
|
||||
}
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const runtime = runtimeDependencies();
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../packages/ql3-local-sqlite/dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../packages/ql3-local-sqlite/dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('../packages/ql3-local-sqlite/dist/migration/migration');
|
||||
const temporaryRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-package-recovery-edge-'),
|
||||
);
|
||||
fs.chmodSync(temporaryRoot, 0o700);
|
||||
const databasePath = path.join(temporaryRoot, 'recovery.sqlite');
|
||||
let database;
|
||||
try {
|
||||
database = new DatabaseSync(databasePath, {
|
||||
allowExtension: false,
|
||||
allowUnknownNamedParameters: false,
|
||||
defensive: true,
|
||||
enableDoubleQuotedStringLiterals: false,
|
||||
enableForeignKeyConstraints: true,
|
||||
timeout: 1_000,
|
||||
});
|
||||
await migrateLocalSqliteDatabase(database);
|
||||
database.exec('PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL;');
|
||||
const baseline = storageSnapshot(databasePath);
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(database);
|
||||
const registry = runtime.createBuiltInTaskSpecSemanticRegistry();
|
||||
const materializedRepository =
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
database,
|
||||
registry,
|
||||
);
|
||||
const first = packageFixture(runtime, 1, null, false);
|
||||
const second = packageFixture(runtime, 2, first, true);
|
||||
const calls = {
|
||||
byteSourceClose: 0,
|
||||
byteSourceOpen: 0,
|
||||
byteSourceRead: 0,
|
||||
publisherInspect: 0,
|
||||
publisherPublish: 0,
|
||||
stage: 0,
|
||||
};
|
||||
const rssBeforeBytes = process.memoryUsage().rss;
|
||||
const startedAt = performance.now();
|
||||
const active = await createActiveGeneration(runtime, repository, first);
|
||||
const staged = await createStagedUpgrade(
|
||||
runtime,
|
||||
repository,
|
||||
second,
|
||||
active,
|
||||
);
|
||||
const prerequisite =
|
||||
new runtime.PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open(generation) {
|
||||
if (
|
||||
generation.lockDigest !== second.lock.lockDigest ||
|
||||
generation.installationId !== staged.installationId
|
||||
) {
|
||||
throw new Error('byte source received another generation');
|
||||
}
|
||||
calls.byteSourceOpen += 1;
|
||||
return {
|
||||
async read(resourcePath, maximumBytes) {
|
||||
calls.byteSourceRead += 1;
|
||||
const material =
|
||||
resourcePath === 'package.json'
|
||||
? second.manifestBytes
|
||||
: second.bytes[resourcePath];
|
||||
if (!material || material.byteLength > maximumBytes) {
|
||||
throw new Error('byte source path or bound is invalid');
|
||||
}
|
||||
return new Uint8Array(material);
|
||||
},
|
||||
async close() {
|
||||
calls.byteSourceClose += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository,
|
||||
taskSpecSemanticRegistry: registry,
|
||||
});
|
||||
const coordinator = new runtime.PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
calls.stage += 1;
|
||||
throw new Error('staged upgrade must not be staged again');
|
||||
},
|
||||
},
|
||||
publisher: {
|
||||
async publish() {
|
||||
calls.publisherPublish += 1;
|
||||
throw new Error('invalid candidate must not be published');
|
||||
},
|
||||
async inspect() {
|
||||
calls.publisherInspect += 1;
|
||||
throw new Error('invalid candidate must not be inspected');
|
||||
},
|
||||
},
|
||||
activationPrerequisite: prerequisite,
|
||||
now: () => 500,
|
||||
});
|
||||
const recovery = await coordinator.recover({ pageSize: 1, maxPages: 2 });
|
||||
const durationMs = performance.now() - startedAt;
|
||||
const rssAfterBytes = process.memoryUsage().rss;
|
||||
const durable = await repository.find(staged.projectId, staged.packageName);
|
||||
const candidateGeneration =
|
||||
runtime.createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: staged.installationId,
|
||||
projectId: staged.projectId,
|
||||
packageName: staged.packageName,
|
||||
lockDigest: second.lock.lockDigest,
|
||||
generation: second.lock.targetGeneration,
|
||||
previousActiveLockDigest: first.lock.lockDigest,
|
||||
contentDigest: second.lock.source.contentDigest,
|
||||
resources: second.lock.resources,
|
||||
});
|
||||
const materializedCandidateRevisions = database
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3PluginPackageMaterializedRevisions"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(candidateGeneration.generationDigest).count;
|
||||
const integrityCheck = database
|
||||
.prepare('PRAGMA integrity_check')
|
||||
.get().integrity_check;
|
||||
const finalStorage = storageSnapshot(databasePath);
|
||||
const logicalGrowthBytes = Math.max(
|
||||
0,
|
||||
finalStorage.logicalBytes - baseline.logicalBytes,
|
||||
);
|
||||
const allocatedGrowthBytes = Math.max(
|
||||
0,
|
||||
finalStorage.allocatedBytes - baseline.allocatedBytes,
|
||||
);
|
||||
const rssDeltaBytes = Math.max(0, rssAfterBytes - rssBeforeBytes);
|
||||
const violations = [];
|
||||
if (
|
||||
!durable ||
|
||||
durable.installationId !== staged.installationId ||
|
||||
durable.state !== 'failed' ||
|
||||
durable.failure?.reason !== 'activation_fact_conflict' ||
|
||||
durable.failure?.failedFrom !== 'staged'
|
||||
) {
|
||||
violations.push('candidate did not fail closed from staged');
|
||||
}
|
||||
if (
|
||||
durable?.previousActiveLockDigest !== first.lock.lockDigest ||
|
||||
durable?.activeLockDigest !== first.lock.lockDigest
|
||||
) {
|
||||
violations.push('previous active lock was not preserved');
|
||||
}
|
||||
if (
|
||||
calls.stage !== 0 ||
|
||||
calls.publisherPublish !== 0 ||
|
||||
calls.publisherInspect !== 0
|
||||
) {
|
||||
violations.push(
|
||||
'invalid candidate reached stage or activation publisher',
|
||||
);
|
||||
}
|
||||
if (
|
||||
calls.byteSourceOpen !== 1 ||
|
||||
calls.byteSourceRead !== 4 ||
|
||||
calls.byteSourceClose !== 1
|
||||
) {
|
||||
violations.push('candidate bytes were not read exactly once');
|
||||
}
|
||||
if (materializedCandidateRevisions !== 0) {
|
||||
violations.push('invalid candidate materialized revision was published');
|
||||
}
|
||||
if (integrityCheck !== 'ok') {
|
||||
violations.push(`SQLite integrity_check returned ${integrityCheck}`);
|
||||
}
|
||||
if (durationMs > options.maxDurationMs) {
|
||||
violations.push('duration exceeded the configured bound');
|
||||
}
|
||||
if (rssDeltaBytes > options.maxRssDeltaBytes) {
|
||||
violations.push('RSS delta exceeded the configured bound');
|
||||
}
|
||||
if (
|
||||
Math.max(logicalGrowthBytes, allocatedGrowthBytes) >
|
||||
options.maxDatabaseGrowthBytes
|
||||
) {
|
||||
violations.push('database growth exceeded the configured bound');
|
||||
}
|
||||
const report = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
evidenceClass: 'plugin_package_failed_upgrade_edge_candidate',
|
||||
supported: false,
|
||||
identity: Object.freeze({
|
||||
node: process.version,
|
||||
architecture: process.arch,
|
||||
platform: process.platform,
|
||||
}),
|
||||
measurement: Object.freeze({
|
||||
durationMs: round(durationMs),
|
||||
rssBeforeBytes,
|
||||
rssAfterBytes,
|
||||
rssDeltaBytes,
|
||||
}),
|
||||
thresholds: Object.freeze({
|
||||
maxDurationMs: options.maxDurationMs,
|
||||
maxRssDeltaBytes: options.maxRssDeltaBytes,
|
||||
maxDatabaseGrowthBytes: options.maxDatabaseGrowthBytes,
|
||||
}),
|
||||
database: Object.freeze({
|
||||
journalMode: 'delete',
|
||||
synchronous: 'full',
|
||||
integrityCheck,
|
||||
logicalBytes: finalStorage.logicalBytes,
|
||||
allocatedBytes: finalStorage.allocatedBytes,
|
||||
logicalGrowthBytes,
|
||||
allocatedGrowthBytes,
|
||||
files: finalStorage.files,
|
||||
materializedCandidateRevisions,
|
||||
}),
|
||||
durable: Object.freeze({
|
||||
state: durable?.state ?? null,
|
||||
failureReason: durable?.failure?.reason ?? null,
|
||||
failedFrom: durable?.failure?.failedFrom ?? null,
|
||||
headInstallationIsCandidate:
|
||||
durable?.installationId === staged.installationId,
|
||||
previousActiveLockDigestPreserved:
|
||||
durable?.previousActiveLockDigest === first.lock.lockDigest,
|
||||
activeLockDigestPreserved:
|
||||
durable?.activeLockDigest === first.lock.lockDigest,
|
||||
}),
|
||||
recovery: Object.freeze(recovery),
|
||||
calls: Object.freeze(calls),
|
||||
limitations: EXPECTED_LIMITATIONS,
|
||||
gates: Object.freeze({
|
||||
passed: violations.length === 0,
|
||||
violations: Object.freeze(violations),
|
||||
}),
|
||||
});
|
||||
const reportViolations = validateReport(report);
|
||||
if (reportViolations.length > 0) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`report contract rejected: ${reportViolations.join('; ')}`,
|
||||
);
|
||||
}
|
||||
if (!report.gates.passed) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
report.gates.violations.join('; '),
|
||||
);
|
||||
}
|
||||
return report;
|
||||
} finally {
|
||||
database?.close();
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateReport(report) {
|
||||
const violations = [];
|
||||
if (!hasExactKeys(report, REPORT_KEYS)) {
|
||||
return Object.freeze(['report shape is invalid']);
|
||||
}
|
||||
if (
|
||||
report.schemaVersion !== 1 ||
|
||||
report.evidenceClass !== 'plugin_package_failed_upgrade_edge_candidate' ||
|
||||
report.supported !== false
|
||||
) {
|
||||
violations.push('report identity is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.identity, IDENTITY_KEYS) ||
|
||||
typeof report.identity.node !== 'string' ||
|
||||
!['arm64', 'x64', 'arm'].includes(report.identity.architecture) ||
|
||||
typeof report.identity.platform !== 'string'
|
||||
) {
|
||||
violations.push('runtime identity is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.measurement, MEASUREMENT_KEYS) ||
|
||||
!Number.isFinite(report.measurement.durationMs) ||
|
||||
report.measurement.durationMs < 0 ||
|
||||
!Number.isSafeInteger(report.measurement.rssBeforeBytes) ||
|
||||
report.measurement.rssBeforeBytes < 1 ||
|
||||
!Number.isSafeInteger(report.measurement.rssAfterBytes) ||
|
||||
report.measurement.rssAfterBytes < 1 ||
|
||||
!Number.isSafeInteger(report.measurement.rssDeltaBytes) ||
|
||||
report.measurement.rssDeltaBytes < 0
|
||||
) {
|
||||
violations.push('measurement is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.thresholds, THRESHOLD_KEYS) ||
|
||||
!Number.isFinite(report.thresholds.maxDurationMs) ||
|
||||
report.thresholds.maxDurationMs <= 0 ||
|
||||
!Number.isSafeInteger(report.thresholds.maxRssDeltaBytes) ||
|
||||
report.thresholds.maxRssDeltaBytes < 1 ||
|
||||
!Number.isSafeInteger(report.thresholds.maxDatabaseGrowthBytes) ||
|
||||
report.thresholds.maxDatabaseGrowthBytes < 1
|
||||
) {
|
||||
violations.push('thresholds are invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.database, DATABASE_KEYS) ||
|
||||
report.database.journalMode !== 'delete' ||
|
||||
report.database.synchronous !== 'full' ||
|
||||
report.database.integrityCheck !== 'ok' ||
|
||||
report.database.materializedCandidateRevisions !== 0 ||
|
||||
![
|
||||
'logicalBytes',
|
||||
'allocatedBytes',
|
||||
'logicalGrowthBytes',
|
||||
'allocatedGrowthBytes',
|
||||
].every(
|
||||
(key) =>
|
||||
Number.isSafeInteger(report.database[key]) && report.database[key] >= 0,
|
||||
) ||
|
||||
!Array.isArray(report.database.files) ||
|
||||
report.database.files.length < 1 ||
|
||||
report.database.files.length > 4 ||
|
||||
report.database.files.some(
|
||||
(file) =>
|
||||
!hasExactKeys(file, DATABASE_FILE_KEYS) ||
|
||||
!['database', '-journal', '-wal', '-shm'].includes(file.suffix) ||
|
||||
!Number.isSafeInteger(file.logicalBytes) ||
|
||||
file.logicalBytes < 0 ||
|
||||
!Number.isSafeInteger(file.allocatedBytes) ||
|
||||
file.allocatedBytes < 0,
|
||||
)
|
||||
) {
|
||||
violations.push('database evidence is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.durable, DURABLE_KEYS) ||
|
||||
report.durable.state !== 'failed' ||
|
||||
report.durable.failureReason !== 'activation_fact_conflict' ||
|
||||
report.durable.failedFrom !== 'staged' ||
|
||||
report.durable.headInstallationIsCandidate !== true ||
|
||||
report.durable.previousActiveLockDigestPreserved !== true ||
|
||||
report.durable.activeLockDigestPreserved !== true
|
||||
) {
|
||||
violations.push('durable recovery facts are invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.recovery, RECOVERY_KEYS) ||
|
||||
report.recovery.pages !== 1 ||
|
||||
report.recovery.scanned !== 1 ||
|
||||
report.recovery.settled !== 1 ||
|
||||
report.recovery.retry !== 0 ||
|
||||
report.recovery.manualRequired !== 0 ||
|
||||
report.recovery.superseded !== 0 ||
|
||||
report.recovery.deferred !== 0 ||
|
||||
report.recovery.remaining !== false ||
|
||||
report.recovery.safeToAdmit !== true
|
||||
) {
|
||||
violations.push('recovery outcome is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.calls, CALL_KEYS) ||
|
||||
report.calls.stage !== 0 ||
|
||||
report.calls.publisherPublish !== 0 ||
|
||||
report.calls.publisherInspect !== 0 ||
|
||||
report.calls.byteSourceOpen !== 1 ||
|
||||
report.calls.byteSourceRead !== 4 ||
|
||||
report.calls.byteSourceClose !== 1
|
||||
) {
|
||||
violations.push('authority call evidence is invalid');
|
||||
}
|
||||
if (
|
||||
JSON.stringify(report.limitations) !== JSON.stringify(EXPECTED_LIMITATIONS)
|
||||
) {
|
||||
violations.push('limitations are invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.gates, GATE_KEYS) ||
|
||||
report.gates.passed !== true ||
|
||||
!Array.isArray(report.gates.violations) ||
|
||||
report.gates.violations.length !== 0
|
||||
) {
|
||||
violations.push('gates did not pass exactly');
|
||||
}
|
||||
if (
|
||||
report.measurement?.durationMs > report.thresholds?.maxDurationMs ||
|
||||
report.measurement?.rssDeltaBytes > report.thresholds?.maxRssDeltaBytes ||
|
||||
Math.max(
|
||||
report.database?.logicalGrowthBytes ?? Number.POSITIVE_INFINITY,
|
||||
report.database?.allocatedGrowthBytes ?? Number.POSITIVE_INFINITY,
|
||||
) > report.thresholds?.maxDatabaseGrowthBytes
|
||||
) {
|
||||
violations.push('measurement exceeded its declared threshold');
|
||||
}
|
||||
return Object.freeze(violations);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const report = await runBenchmark(options);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(report, null, options.json ? 0 : 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QingLong3PluginPackageRecoveryEdgeBenchmarkError,
|
||||
parseArguments,
|
||||
runBenchmark,
|
||||
validateReport,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user