|
|
// 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 1–5 line bill from distinct catalog items, integer qty 1–5.
|
|
|
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) })
|