mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
CompletionReceiptOrphanFileDirectory,
|
||||
} = require('../back/runtime/adapters/fs/completionReceiptOrphanDirectory');
|
||||
const {
|
||||
CompletionReceiptOrphanAuditor,
|
||||
} = require('../back/runtime/application/completionReceiptOrphanAuditor');
|
||||
|
||||
const ATTEMPT_STATUSES = new Set([
|
||||
'claimed',
|
||||
'starting',
|
||||
'running',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
'lost',
|
||||
]);
|
||||
const JOURNAL_STATES = new Set(['pending', 'quarantined']);
|
||||
|
||||
function numericArgument(name, value, minimum, maximum) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new Error(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {
|
||||
database: path.resolve(process.cwd(), 'data/db/database.sqlite'),
|
||||
root: path.resolve(process.cwd(), 'data/runtime/completion-receipts'),
|
||||
mode: 'audit',
|
||||
startShard: 0,
|
||||
shardCount: 8,
|
||||
maxEntriesPerShard: 32,
|
||||
minimumAgeMs: 5 * 60_000,
|
||||
json: false,
|
||||
failOnFindings: false,
|
||||
};
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--json') {
|
||||
options.json = true;
|
||||
} else if (argument === '--quarantine') {
|
||||
options.mode = 'quarantine';
|
||||
} else if (argument === '--fail-on-findings') {
|
||||
options.failOnFindings = true;
|
||||
} else if (argument.startsWith('--database=')) {
|
||||
const value = argument.slice('--database='.length);
|
||||
if (!value) throw new Error('--database must not be empty');
|
||||
options.database = path.resolve(value);
|
||||
} else if (argument.startsWith('--root=')) {
|
||||
const value = argument.slice('--root='.length);
|
||||
if (!value) throw new Error('--root must not be empty');
|
||||
options.root = path.resolve(value);
|
||||
} else if (argument.startsWith('--start-shard=')) {
|
||||
const value = argument.slice('--start-shard='.length);
|
||||
if (!/^[0-9a-f]{2}$/.test(value)) {
|
||||
throw new Error('--start-shard must be two lowercase hex digits');
|
||||
}
|
||||
options.startShard = Number.parseInt(value, 16);
|
||||
} else if (argument.startsWith('--shards=')) {
|
||||
options.shardCount = numericArgument(
|
||||
'--shards',
|
||||
argument.slice('--shards='.length),
|
||||
1,
|
||||
32,
|
||||
);
|
||||
} else if (argument.startsWith('--entries-per-shard=')) {
|
||||
options.maxEntriesPerShard = numericArgument(
|
||||
'--entries-per-shard',
|
||||
argument.slice('--entries-per-shard='.length),
|
||||
1,
|
||||
64,
|
||||
);
|
||||
} else if (argument.startsWith('--minimum-age-ms=')) {
|
||||
options.minimumAgeMs = numericArgument(
|
||||
'--minimum-age-ms',
|
||||
argument.slice('--minimum-age-ms='.length),
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
class SqliteCompletionReceiptOwnershipSource {
|
||||
constructor(database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
async lookup(attemptIds) {
|
||||
const ownership = new Map();
|
||||
for (let offset = 0; offset < attemptIds.length; offset += 32) {
|
||||
const chunk = attemptIds.slice(offset, offset + 32);
|
||||
if (chunk.length === 0) continue;
|
||||
const placeholders = chunk.map(() => '?').join(', ');
|
||||
const journalRows = this.database
|
||||
.prepare(
|
||||
`SELECT attempt_id, state AS journal_state
|
||||
FROM CompletionReceiptJournals
|
||||
WHERE attempt_id IN (${placeholders})`,
|
||||
)
|
||||
.all(...chunk);
|
||||
for (const row of journalRows) {
|
||||
if (!JOURNAL_STATES.has(row.journal_state)) {
|
||||
throw new Error(
|
||||
`Receipt journal ${row.attempt_id} has an invalid state`,
|
||||
);
|
||||
}
|
||||
ownership.set(row.attempt_id, {
|
||||
attemptId: row.attempt_id,
|
||||
journalState: row.journal_state,
|
||||
});
|
||||
}
|
||||
const attemptRows = this.database
|
||||
.prepare(
|
||||
`SELECT id AS attempt_id, status AS attempt_status
|
||||
FROM RunAttempts
|
||||
WHERE id IN (${placeholders})`,
|
||||
)
|
||||
.all(...chunk);
|
||||
for (const row of attemptRows) {
|
||||
if (!ATTEMPT_STATUSES.has(row.attempt_status)) {
|
||||
throw new Error(
|
||||
`Run Attempt ${row.attempt_id} has an invalid status`,
|
||||
);
|
||||
}
|
||||
ownership.set(row.attempt_id, {
|
||||
...ownership.get(row.attempt_id),
|
||||
attemptId: row.attempt_id,
|
||||
attemptStatus: row.attempt_status,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ownership;
|
||||
}
|
||||
}
|
||||
|
||||
function assertRequiredSchema(database) {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name IN ('RunAttempts', 'CompletionReceiptJournals')`,
|
||||
)
|
||||
.all();
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
for (const required of ['RunAttempts', 'CompletionReceiptJournals']) {
|
||||
if (!names.has(required)) {
|
||||
throw new Error(
|
||||
`Database is not ready for receipt audit: missing ${required}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findingCount(report) {
|
||||
const findingCategories = new Set([
|
||||
'terminal_orphan',
|
||||
'unknown_receipt',
|
||||
'stale_temporary',
|
||||
'unknown_entry',
|
||||
'unsafe_entry',
|
||||
]);
|
||||
return (
|
||||
report.entries.filter((entry) => findingCategories.has(entry.category))
|
||||
.length + report.overflowShards.length
|
||||
);
|
||||
}
|
||||
|
||||
function renderText(report, options) {
|
||||
const lines = [
|
||||
`QingLong 3.0 receipt audit: ${report.mode}`,
|
||||
`root: ${options.root}`,
|
||||
`shards: ${report.startShard} -> ${report.nextShard}`,
|
||||
`scanned entries: ${report.scannedEntries}`,
|
||||
`overflow shards: ${report.overflowShards.join(', ') || 'none'}`,
|
||||
];
|
||||
for (const [category, count] of Object.entries(report.counts)) {
|
||||
if (count > 0) lines.push(`${category}: ${count}`);
|
||||
}
|
||||
for (const entry of report.entries) {
|
||||
if (entry.action !== 'retained') {
|
||||
lines.push(
|
||||
`${JSON.stringify(`${entry.shard}/${entry.name}`)}: ${
|
||||
entry.category
|
||||
} (${entry.action})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
||||
if (nodeMajor < 24) {
|
||||
throw new Error('ql3-receipt-audit requires Node.js 24 or newer');
|
||||
}
|
||||
if (!fs.existsSync(options.database)) {
|
||||
throw new Error(`Database does not exist: ${options.database}`);
|
||||
}
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const database = new DatabaseSync(options.database, {
|
||||
allowExtension: false,
|
||||
allowUnknownNamedParameters: false,
|
||||
defensive: true,
|
||||
enableDoubleQuotedStringLiterals: false,
|
||||
enableForeignKeyConstraints: true,
|
||||
readOnly: true,
|
||||
timeout: 1_000,
|
||||
});
|
||||
try {
|
||||
assertRequiredSchema(database);
|
||||
const auditor = new CompletionReceiptOrphanAuditor(
|
||||
new CompletionReceiptOrphanFileDirectory(options.root),
|
||||
new SqliteCompletionReceiptOwnershipSource(database),
|
||||
);
|
||||
const report = await auditor.run(options);
|
||||
process.stdout.write(
|
||||
options.json
|
||||
? `${JSON.stringify({
|
||||
...report,
|
||||
database: path.basename(options.database),
|
||||
root: options.root,
|
||||
})}\n`
|
||||
: renderText(report, options),
|
||||
);
|
||||
if (options.failOnFindings && findingCount(report) > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SqliteCompletionReceiptOwnershipSource,
|
||||
assertRequiredSchema,
|
||||
findingCount,
|
||||
parseArguments,
|
||||
renderText,
|
||||
};
|
||||
Reference in New Issue
Block a user