You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/store-server/load/loadtest.mjs

307 lines
14 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// S7 load verification (docs/17 §S7, R25) — scripted load against ONE store-server.
//
// Fires 2,500 bill commits distributed across 10 counters at a concurrency of 10 (one
// in-flight request per counter, realistic for a single store-server), then a burst
// phase (100 bills at concurrency 50) to find the knee. Measures wall-clock, throughput,
// and p50/p95/p99/max latency, then runs six integrity checks directly against the load
// DB with better-sqlite3 and prints a pass/fail table.
//
// Plain node (node 22, global fetch). The only imports are better-sqlite3 and esbuild's
// absence — no engine bundle is needed because every billable seed item is priced
// tax-inclusive with no discount, so the payable is exactly roundToRupee(Σ qty·price)
// (billing-engine compute.ts: priceIncludesTax ⇒ lineTotal = net = round(price·qty);
// payable = round(Σ lineTotal / 100)·100 — independent of the tax rate). The server
// re-verifies every bill to the paisa with the real shared engine (R5) and rejects any
// mismatch, so a wrong client payable would surface immediately as failed commits.
import Database from 'better-sqlite3'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
const BASE = process.env.BASE ?? 'http://localhost:5281'
const DATA_DIR = process.env.SIMS_DATA_DIR
if (!DATA_DIR) { console.error('Set SIMS_DATA_DIR to the load target data dir'); process.exit(1) }
const DB_PATH = path.join(DATA_DIR, 'sims.db')
const TENANT = 't1'
const STORE = 's1'
const BUSINESS_DATE = '2026-07-10'
const N_COUNTERS = 10
const TOTAL_BILLS = 2500
const BURST_BILLS = 100
const BURST_CONC = 50
// The 4 seeded users (sessions are per-token, so reusing them across counters is fine).
const USERS = [
{ id: 'u1', pin: '4728' }, { id: 'u2', pin: '8265' },
{ id: 'u3', pin: '9174' }, { id: 'u4', pin: '3591' },
]
const COUNTERS = Array.from({ length: N_COUNTERS }, (_, i) => ({
id: `c${i + 1}`, code: `C${i + 1}`,
}))
const rng = (n) => Math.floor(Math.random() * n)
const pct = (arr, p) => {
if (arr.length === 0) return 0
const s = [...arr].sort((a, b) => a - b)
const idx = Math.min(s.length - 1, Math.ceil((p / 100) * s.length) - 1)
return s[Math.max(0, idx)]
}
const round2 = (n) => Math.round(n * 100) / 100
// ---- setup: ensure 10 real counters (seed makes only C1/C2) ----
function ensureCounters() {
const db = new Database(DB_PATH)
db.pragma('busy_timeout = 5000')
const ins = db.prepare(
`INSERT OR IGNORE INTO counter (id, tenant_id, store_id, code, name) VALUES (?, ?, ?, ?, ?)`,
)
const tx = db.transaction(() => {
for (const c of COUNTERS) ins.run(c.id, TENANT, STORE, c.code, `Counter ${c.code.slice(1)}`)
})
tx()
const n = db.prepare(`SELECT COUNT(*) n FROM counter WHERE tenant_id=? AND store_id=?`).get(TENANT, STORE).n
db.close()
return n
}
async function login(user) {
const r = await fetch(`${BASE}/api/auth/pin`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id, pin: user.pin }),
})
if (!r.ok) throw new Error(`login ${user.id} failed: ${r.status} ${await r.text()}`)
return (await r.json()).token
}
async function loadCatalog(token) {
const r = await fetch(`${BASE}/api/items?all=1`, { headers: { Authorization: `Bearer ${token}` } })
if (!r.ok) throw new Error(`items failed: ${r.status}`)
const items = await r.json()
// Skip batch-tracked items (they need a valid batchId); everything else is
// tax-inclusive, so the payable is trivially roundToRupee(Σ qty·price).
return items
.filter((it) => !it.batchTracked && it.priceIncludesTax)
.map((it) => ({
itemId: it.id, name: it.name, hsn: it.hsn, unitCode: it.unit.code,
unitPricePaise: it.salePricePaise, priceIncludesTax: true, taxClassCode: it.taxClassCode,
}))
}
// Build a random 15 line bill from distinct catalog items, integer qty 15.
function buildBill(catalog, counterId) {
const nLines = 1 + rng(5)
const pool = [...catalog]
const lines = []
for (let i = 0; i < nLines && pool.length > 0; i++) {
const idx = rng(pool.length)
const it = pool.splice(idx, 1)[0]
const qty = 1 + rng(5)
lines.push({ ...it, qty })
}
const total = lines.reduce((a, l) => a + l.unitPricePaise * l.qty, 0)
const payable = Math.round(total / 100) * 100 // roundToRupee (ctx.roundToRupee=true)
return {
body: {
storeId: STORE, counterId, shiftId: 'load-sh1', businessDate: BUSINESS_DATE,
lines, payments: [{ mode: 'CASH', amountPaise: payable }], clientPayablePaise: payable,
},
lines,
}
}
async function postBill(token, bill, tally) {
const t0 = performance.now()
let status = 0, errText = ''
try {
const r = await fetch(`${BASE}/api/bills`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(bill.body),
})
status = r.status
if (!r.ok) errText = await r.text()
else {
await r.json()
for (const l of bill.lines) tally.items[l.itemId] = (tally.items[l.itemId] ?? 0) + l.qty
tally.lines += bill.lines.length
}
} catch (e) {
errText = String(e)
}
const dt = performance.now() - t0
return { ok: status >= 200 && status < 300, status, errText, dt }
}
// ---- steady phase: one worker per counter, 250 sequential bills each ----
async function steadyPhase(tokenByCounter, catalog, tally) {
const perCounter = Math.floor(TOTAL_BILLS / N_COUNTERS)
const latencies = []
let success = 0
const errors = {}
const t0 = performance.now()
await Promise.all(COUNTERS.map(async (counter) => {
const token = tokenByCounter[counter.id]
for (let i = 0; i < perCounter; i++) {
const bill = buildBill(catalog, counter.id)
const res = await postBill(token, bill, tally)
latencies.push(res.dt)
if (res.ok) success++
else errors[res.status] = (errors[res.status] ?? 0) + 1, (tally.firstErr ??= res.errText)
}
}))
const wallMs = performance.now() - t0
return { attempted: perCounter * N_COUNTERS, success, errors, latencies, wallMs }
}
// ---- burst phase: 100 bills, concurrency 50 (find the knee) ----
async function burstPhase(tokens, catalog, tally) {
const latencies = []
let success = 0
const errors = {}
let next = 0
const t0 = performance.now()
async function worker() {
while (true) {
const i = next++
if (i >= BURST_BILLS) return
const counter = COUNTERS[i % N_COUNTERS]
const token = tokens[i % tokens.length]
const bill = buildBill(catalog, counter.id)
const res = await postBill(token, bill, tally)
latencies.push(res.dt)
if (res.ok) success++
else errors[res.status] = (errors[res.status] ?? 0) + 1
}
}
await Promise.all(Array.from({ length: BURST_CONC }, worker))
const wallMs = performance.now() - t0
return { attempted: BURST_BILLS, success, errors, latencies, wallMs }
}
// ---- integrity checks against the load DB ----
function integrity(tally, expectedBills, expectedLines) {
const db = new Database(DB_PATH, { readonly: true })
db.pragma('busy_timeout = 5000')
const checks = []
const add = (name, pass, detail) => checks.push({ name, pass, detail })
// (a) bill count == successful commits
const billCount = db.prepare(`SELECT COUNT(*) n FROM bill`).get().n
add('(a) bill rows == successful commits', billCount === expectedBills,
`bills=${billCount}, successes=${expectedBills}`)
// (b) every counter's SALE series is gap-free and duplicate-free (1..n)
const bills = db.prepare(`SELECT counter_id, doc_no FROM bill WHERE doc_type='SALE'`).all()
const byCounter = {}
for (const b of bills) {
const seq = parseInt(b.doc_no.slice(b.doc_no.lastIndexOf('-') + 1), 10)
;(byCounter[b.counter_id] ??= []).push(seq)
}
let seriesOk = true
const seriesDetail = []
for (const [cid, seqs] of Object.entries(byCounter)) {
seqs.sort((a, b) => a - b)
const dup = new Set(seqs).size !== seqs.length
const gap = seqs.some((s, i) => s !== i + 1)
if (dup || gap) seriesOk = false
seriesDetail.push(`${cid}:1..${seqs.length}${dup ? ' DUP' : ''}${gap ? ' GAP' : ''}`)
}
add('(b) per-counter series gap-free & dup-free', seriesOk, seriesDetail.join(' '))
// (c) SUM(movements per item) == opening billed qty; and one SALE movement per line
let stockOk = true
const stockDetail = []
for (const [itemId, billedQty] of Object.entries(tally.items)) {
const opening = db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=? AND reason='OPENING'`).get(itemId).s
const final = db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=?`).get(itemId).s
const saleQty = -db.prepare(`SELECT COALESCE(SUM(qty_delta),0) s FROM stock_movement WHERE item_id=? AND reason='SALE'`).get(itemId).s
const ok = final === opening - billedQty && saleQty === billedQty
if (!ok) { stockOk = false; stockDetail.push(`${itemId} open=${opening} billed=${billedQty} final=${final} saleMv=${saleQty}`) }
}
const saleMoves = db.prepare(`SELECT COUNT(*) n FROM stock_movement WHERE reason='SALE'`).get().n
if (saleMoves !== expectedLines) { stockOk = false; stockDetail.push(`SALE movements ${saleMoves} != lines ${expectedLines}`) }
add('(c) stock derived == opening billed (per item)', stockOk,
stockOk ? `${Object.keys(tally.items).length} items reconciled; ${saleMoves} SALE movements == ${expectedLines} lines` : stockDetail.join(' | '))
// (d) every bill's payments sum == payable
const payRows = db.prepare(`SELECT payable_paise, payload FROM bill`).all()
let payMismatch = 0
for (const row of payRows) {
const payments = JSON.parse(row.payload).payments ?? []
const sum = payments.reduce((a, p) => a + p.amountPaise, 0)
if (sum !== row.payable_paise) payMismatch++
}
add('(d) payments sum == payable (every bill)', payMismatch === 0,
`${payRows.length} bills checked, ${payMismatch} mismatch`)
// (e) audit BILL_COMMIT count == bill count
const auditCommits = db.prepare(`SELECT COUNT(*) n FROM audit_log WHERE action='BILL_COMMIT'`).get().n
add('(e) audit BILL_COMMIT == bill count', auditCommits === billCount,
`BILL_COMMIT=${auditCommits}, bills=${billCount}`)
// (f) outbox rows == bill count
const outbox = db.prepare(`SELECT COUNT(*) n FROM outbox`).get().n
add('(f) outbox rows == bill count', outbox === billCount,
`outbox=${outbox}, bills=${billCount}`)
db.close()
return checks
}
async function main() {
console.log('=== S7 LOAD VERIFICATION ===')
const cpu = os.cpus()
console.log(`env: node ${process.version} | ${cpu.length}× ${cpu[0].model.trim()} | ${round2(os.totalmem() / 1024 ** 3)} GB RAM | ${os.platform()} ${os.release()}`)
console.log(`target: ${BASE} db: ${DB_PATH}`)
const counterCount = ensureCounters()
console.log(`counters ready: ${counterCount}`)
const tokens = []
for (const u of USERS) tokens.push(await login(u))
console.log(`logged in ${tokens.length} users`)
const tokenByCounter = {}
COUNTERS.forEach((c, i) => { tokenByCounter[c.id] = tokens[i % tokens.length] })
const catalog = await loadCatalog(tokens[0])
console.log(`billable catalog: ${catalog.length} items (batch-tracked skipped)`)
const tally = { items: {}, lines: 0, firstErr: undefined }
console.log(`\n--- STEADY: ${TOTAL_BILLS} bills across ${N_COUNTERS} counters, concurrency ${N_COUNTERS} ---`)
const steady = await steadyPhase(tokenByCounter, catalog, tally)
const sThr = steady.success / (steady.wallMs / 1000)
console.log(`attempted=${steady.attempted} success=${steady.success} errors=${JSON.stringify(steady.errors)}`)
console.log(`wall=${round2(steady.wallMs / 1000)}s throughput=${round2(sThr)} bills/s`)
console.log(`latency ms: p50=${round2(pct(steady.latencies, 50))} p95=${round2(pct(steady.latencies, 95))} p99=${round2(pct(steady.latencies, 99))} max=${round2(Math.max(...steady.latencies))}`)
if (steady.firstErr) console.log(`first error: ${steady.firstErr}`)
if (tally.firstErr) console.log(`sample error body: ${tally.firstErr}`)
console.log(`\n--- BURST: ${BURST_BILLS} bills, concurrency ${BURST_CONC} ---`)
const burst = await burstPhase(tokens, catalog, tally)
const bThr = burst.success / (burst.wallMs / 1000)
console.log(`attempted=${burst.attempted} success=${burst.success} errors=${JSON.stringify(burst.errors)}`)
console.log(`wall=${round2(burst.wallMs / 1000)}s throughput=${round2(bThr)} bills/s`)
console.log(`latency ms: p50=${round2(pct(burst.latencies, 50))} p95=${round2(pct(burst.latencies, 95))} p99=${round2(pct(burst.latencies, 99))} max=${round2(Math.max(...burst.latencies))}`)
const totalSuccess = steady.success + burst.success
console.log(`\n--- INTEGRITY (load DB) ---`)
const checks = integrity(tally, totalSuccess, tally.lines)
for (const c of checks) console.log(`${c.pass ? 'PASS' : 'FAIL'} ${c.name} [${c.detail}]`)
const allPass = checks.every((c) => c.pass)
console.log(`\nINTEGRITY: ${allPass ? 'ALL PASS' : 'FAILURES PRESENT'}`)
// Machine-readable summary for transcription into STATUS.md.
const summary = {
env: { node: process.version, cpus: cpu.length, cpuModel: cpu[0].model.trim(), ramGB: round2(os.totalmem() / 1024 ** 3), platform: `${os.platform()} ${os.release()}` },
steady: { attempted: steady.attempted, success: steady.success, errors: steady.errors, wallS: round2(steady.wallMs / 1000), throughput: round2(sThr), p50: round2(pct(steady.latencies, 50)), p95: round2(pct(steady.latencies, 95)), p99: round2(pct(steady.latencies, 99)), max: round2(Math.max(...steady.latencies)) },
burst: { attempted: burst.attempted, success: burst.success, errors: burst.errors, wallS: round2(burst.wallMs / 1000), throughput: round2(bThr), p50: round2(pct(burst.latencies, 50)), p95: round2(pct(burst.latencies, 95)), p99: round2(pct(burst.latencies, 99)), max: round2(Math.max(...burst.latencies)) },
totalSuccess, totalLines: tally.lines,
checks, allPass,
}
console.log(`\n===SUMMARY_JSON===\n${JSON.stringify(summary)}\n===END_SUMMARY_JSON===`)
process.exit(allPass && totalSuccess === TOTAL_BILLS + BURST_BILLS ? 0 : 1)
}
main().catch((e) => { console.error(e); process.exit(2) })