Files
wikideck/scripts/benchmark-market-reconciliation.ts
2026-10-21 12:40:00 -04:00

172 lines
11 KiB
TypeScript
Executable File

import { DatabaseSync } from 'node:sqlite';
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import { randomUUID } from 'node:crypto';
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
import { createServer } from 'node:http';
import { MARKET_RECONCILIATION_BATCH_SIZE, Market } from '../server/market/store.ts';
import { CHAIN_ID, GENESIS_HASH, transactionId } from '../server/testnet/consensus.ts';
import { NETWORK } from '../shared/testnet.ts';
// Synthetic market journal only. It is isolated in a temporary SQLite database and never
// connects to a Ledger, peer, payment adapter, wallet, or production data directory.
const text = process.argv[2] ?? '10000';
if (!/^[1-9]\d{0,5}$/.test(text) || Number(text) > 100000) throw new Error('Fixture size must be 1..100000 orders');
const count = Number(text), scenario = process.argv[3] ?? 'terminal';
if (!['terminal', 'confirmed-delivery'].includes(scenario)) throw new Error('Scenario must be terminal or confirmed-delivery');
const executionMode = process.argv[4] ?? 'sync';
if (!['sync', 'worker'].includes(executionMode)) throw new Error('Execution mode must be sync or worker');
const workerEnabled = executionMode === 'worker';
const httpLoadEnabled = process.argv[5] === 'http';
const directory = await mkdtemp(join(tmpdir(), 'wikideck-market-reconciliation-'));
const streamId = randomUUID(), tip = 'a'.repeat(64);
const ledgerFile = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'ledger.sqlite');
const seller = '1'.repeat(64), buyer = '2'.repeat(64);
let market: Market | undefined;
let ledgerDb: DatabaseSync | undefined;
let loadServer: ReturnType<typeof createServer> | undefined;
try {
// Indexed-only adapter: the benchmark must not fall back to materializing chain snapshots.
await mkdir(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet'), { recursive: true });
ledgerDb = new DatabaseSync(ledgerFile);
ledgerDb.exec(`PRAGMA journal_mode=WAL; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL);
CREATE TABLE canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL);
CREATE TABLE canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL,minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_events(sequence INTEGER PRIMARY KEY,type TEXT NOT NULL);
CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`);
const insertBlock = ledgerDb.prepare('INSERT INTO blocks VALUES(?,?)');
const insertTip = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)');
const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
const priorHash = 'b'.repeat(64);
for (const [height, hash] of [[1, priorHash], [2, tip]] as const) {
insertBlock.run(hash, JSON.stringify({ height, hash, tokenId: '', owner: seller, transactions: [] }));
insertTip.run(height, hash);
}
const chain = {
snapshot: () => { throw new Error('Benchmark must not request a full chain snapshot'); },
summary: () => ({ tip, revision: 0, height: 2 }),
nft: (token: string) => {
const row = ledgerDb!.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n
JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`).get(token);
if (!row) return undefined;
return { block: JSON.parse(row.block_json), owner: row.owner, sequence: row.sequence,
confirmations: 2 - row.minted_height + 1 };
},
canonicalTransaction: (id: string) => ledgerDb!.prepare('SELECT height,block_hash AS blockHash FROM canonical_transactions WHERE id=?').get(id),
pendingTransactions: () => ledgerDb!.prepare('SELECT transaction_json FROM pending_transfers ORDER BY position').all()
.map(row => JSON.parse(row.transaction_json)),
canonicalHead: () => 0,
canonicalEvents: (after: number) => ({ streamId, previousAnchor: GENESIS_HASH, anchor: GENESIS_HASH,
events: [], cursor: after, head: 0, caughtUp: true }),
...(workerEnabled ? { reconciliationDatabasePath: () => ledgerFile } : {}),
submitTransfer: async () => { throw new Error('Benchmark never submits transfers'); },
};
market = await Market.open(directory, chain, { feeBps: 250, confirmations: 2 }, Date.now, { worker: workerEnabled });
const db = new DatabaseSync(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'market.sqlite'));
try {
const insertListing = db.prepare('INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?)');
const insertOrder = db.prepare('INSERT INTO orders(id,data) VALUES(?,?)');
const insertNft = ledgerDb?.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
const insertTransaction = ledgerDb?.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)');
db.exec('BEGIN IMMEDIATE');
try {
for (let i = 0; i < count; i++) {
const id = i.toString().padStart(12, '0');
const listingId = `listing-${id}`, orderId = `order-${id}`, cardHash = i.toString(16).padStart(64, '0');
const terminal = scenario === 'terminal';
const listingStatus = terminal ? 'cancelled' : 'reserved';
const listing = { id: listingId, cardHash, seller, sequence: 0, kind: 'fixed', status: listingStatus };
const order = { id: orderId, listingId, seller, buyer, cardHash, sequence: 0,
status: terminal ? 'refunded' : 'paid', payout: null, deliveryConfirmations: 0,
requiredConfirmations: 2, createdAt: 1, payBy: 2, deliverBy: 3 };
if (!terminal) {
const delivery = { network: NETWORK, chainId: CHAIN_ID, cardHash, from: seller, to: buyer,
sequence: 0, publicKey: '', signature: '' };
const id = transactionId(delivery);
insertTransaction?.run(id, 1, 'b'.repeat(64));
}
insertNft?.run(cardHash, terminal ? seller : buyer, terminal ? 0 : 1, 1, 'b'.repeat(64));
insertListing.run(listingId, cardHash, listingStatus, JSON.stringify(listing));
insertOrder.run(orderId, JSON.stringify(order));
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
} finally { db.close(); }
let httpLatencies: number[] = [], httpRequests = 0;
let httpLoadPromise: Promise<void> | undefined;
if (httpLoadEnabled) {
loadServer = createServer((request, response) => {
try {
if (request.url === '/health') {
response.writeHead(200, { 'content-type': 'application/json' }); response.end('{"ok":true}'); return;
}
if (request.url === '/market') {
const result = market!.browse();
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ listingCount: result.listings.length, backlog: market!.hasReconciliationBacklog() }));
return;
}
response.writeHead(404); response.end();
} catch {
response.writeHead(503, { 'retry-after': '1' }); response.end('{"error":"reconciling"}');
}
});
await new Promise<void>(resolve => loadServer!.listen(0, '127.0.0.1', resolve));
const address = loadServer.address();
if (!address || typeof address === 'string') throw new Error('Benchmark HTTP listener did not bind TCP');
const baseUrl = `http://127.0.0.1:${address.port}`;
httpLoadPromise = Promise.all(Array.from({ length: 160 }, (_, i) => (async () => {
const before = performance.now();
const response = await fetch(`${baseUrl}${i % 4 === 0 ? '/market' : '/health'}`);
if (response.status !== 200) throw new Error(`Loopback benchmark endpoint returned ${response.status}`);
await response.arrayBuffer();
httpLatencies.push(performance.now() - before);
})())).then(() => { httpLatencies.sort((a, b) => a - b); httpRequests = httpLatencies.length; });
}
const latencies: number[] = [];
const deadline = Date.now() + Math.max(30_000, count * 20);
const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
eventLoopDelay.enable();
const start = performance.now();
let started = false;
try {
while (Date.now() < deadline && (!started || market.hasReconciliationBacklog() || market.isReconciliationWorkerBusy() ||
(!!httpLoadPromise && httpRequests === 0))) {
if (!workerEnabled || !market.isReconciliationWorkerBusy()) {
const before = performance.now();
market.browse();
latencies.push(performance.now() - before);
started = true;
}
await yieldToEventLoop(); // Permit the delay monitor to observe synchronous batch stalls.
if (workerEnabled && market.isReconciliationWorkerBusy()) await new Promise(resolve => setTimeout(resolve, 1));
}
} finally { eventLoopDelay.disable(); }
const elapsedMs = performance.now() - start;
if (!started || market.hasReconciliationBacklog() || market.isReconciliationWorkerBusy()) throw new Error('Reconciliation did not finish before benchmark deadline');
await httpLoadPromise;
await market.close(); market = undefined;
ledgerDb.close(); ledgerDb = undefined;
const bytes = (await stat(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'market.sqlite'))).size;
latencies.sort((a, b) => a - b);
const percentile = (p: number) => latencies[Math.min(latencies.length - 1, Math.ceil(latencies.length * p) - 1)];
const httpPercentile = (p: number) => httpLatencies.length ? httpLatencies[Math.min(httpLatencies.length - 1, Math.ceil(httpLatencies.length * p) - 1)] : null;
console.log(JSON.stringify({ fixture: `synthetic-${scenario}-orders-and-listings`, workerEnabled, httpLoadEnabled, orders: count, listings: count,
batchSizePerTable: MARKET_RECONCILIATION_BATCH_SIZE, reconciliationPasses: latencies.length, elapsedMs,
rowsPerSecond: Math.round((count * 2 * 1000) / elapsedMs), passLatencyP50Ms: percentile(0.50),
passLatencyP95Ms: percentile(0.95), passLatencyMaxMs: latencies.at(-1), sqliteBytes: bytes,
eventLoopDelayResolutionMs: 10, eventLoopDelayP95Ms: eventLoopDelay.percentile(95) / 1e6,
eventLoopDelayMaxMs: eventLoopDelay.max / 1e6,
httpRequests, httpLatencyP50Ms: httpPercentile(0.5), httpLatencyP95Ms: httpPercentile(0.95),
httpLatencyMaxMs: httpLatencies.at(-1) ?? null,
warning: 'Synthetic fixture on this development host only; SQLite Ledger-shaped facts are not mined or validated chain evidence. Excludes concurrent HTTP load, PostgreSQL, and production SLO certification.' }, null, 2));
} finally {
if (loadServer) await new Promise<void>(resolve => loadServer!.close(() => resolve()));
if (market) await market.close();
ledgerDb?.close();
await rm(directory, { recursive: true, force: true });
}