#!/usr/bin/env node /** * Booking concurrency + regression harness. * * Companion to repro-double-booking.cjs: that one proves specific attacks are closed, this * one proves the system stays correct under load and that legitimate bookings are not * rejected as collateral. * * node scripts/stress-booking-concurrency.cjs * --schedule --origin --destination * [--mode ladder|edge|all] [--levels 2,5,10,25,50,100] * [--return-schedule --return-origin --return-destination ] * * ladder, per concurrency level N: * hold-contest N browsers click one seat at once -> exactly 1 hold granted * booking-storm N submits of one valid hold at once -> exactly 1 booking created * diff-seats N users book N different seats at once -> all N succeed (no false rejects) * * edge: multi-passenger, all-or-nothing partial availability, overlapping multi-seat * requests, and a contested round trip. * * Everything goes through the public HTTP API — no direct database writes. Every request * body is built up front and released together from one Promise.all, and each line reports * an factor (summed latency / wall time) so the concurrency is provable rather * than assumed. * * Bookings land in PENDING_PAYMENT and are never paid. Every ref created is printed at the * end; cancel them afterwards. Pick a FUTURE departure with plenty of free seats. */ const BASE = (process.env.API_URL || 'http://localhost:3002').replace(/\/$/, ''); const argv = process.argv.slice(2); const arg = (n, d) => { const i = argv.indexOf(`--${n}`); return i !== -1 && argv[i + 1] ? argv[i + 1] : d; }; const SCHED = arg('schedule'); const ORIGIN = arg('origin'); const DEST = arg('destination'); const RET_SCHED = arg('return-schedule'); const RET_ORIGIN = arg('return-origin'); const RET_DEST = arg('return-destination'); const MODE = arg('mode', 'ladder'); const LEVELS = arg('levels', '2,5,10,25,50,100').split(',').map(Number); // Node's built-in fetch (undici) defaults to an unlimited per-origin connection pool, so // Promise.all really does put every request on the wire at once. Proven per run by // comparing wall time against the summed per-request latency (see in each line). const RUN = Date.now().toString(36).slice(-5).toUpperCase(); let pc = 0; const uuid = () => crypto.randomUUID(); async function api(method, path, body) { const t0 = Date.now(); try { const res = await fetch(`${BASE}${path}`, { method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = { raw: text.slice(0, 200) }; } const data = json && typeof json === 'object' && 'success' in json && 'data' in json ? json.data : json; return { ok: res.ok, status: res.status, json, data, ms: Date.now() - t0 }; } catch (e) { return { ok: false, status: 0, json: { message: `NETWORK: ${e.message}` }, data: null, ms: Date.now() - t0 }; } } const msg = (r) => String(r.json?.message ?? r.json?.error ?? JSON.stringify(r.json ?? {})).slice(0, 150); function person(seatId) { pc++; const tag = `${RUN}${String(pc).padStart(4, '0')}`; return { seatId, passengerName: `Stress ${tag}`, dateOfBirth: '1990-05-15', idDocumentType: 'PASSPORT', passportNumber: `ST${tag}`, passportCountry: 'Djibouti', nationality: 'Other', phone: `+2519${String(40000000 + pc).slice(0, 8)}`, }; } const holdBody = (seatIds, sched = SCHED, o = ORIGIN, d = DEST, dir, pid) => ({ scheduleId: sched, originStationId: o, destinationStationId: d, ...(dir ? { journeyDirection: dir } : {}), passengers: seatIds.map((seatId) => ({ passengerId: pid ?? uuid(), seatId })), }); async function hold(seatIds, sched, o, d, dir, pid) { const r = await api('POST', '/seats/hold', holdBody(seatIds, sched, o, d, dir, pid)); return { ...r, holdId: r.data?.holdId ?? r.data?.id }; } function oneWayBody(holdId, seatIds, seatClassId) { return { bookingType: 'ONE_WAY', scheduleId: SCHED, holdId, originStationId: ORIGIN, destinationStationId: DEST, seatClassId, skipIdentityVerification: true, passengers: seatIds.map((s) => person(s)), }; } function roundTripBody(holdId, returnHoldId, outSeats, retSeats, seatClassId) { return { bookingType: 'ROUND_TRIP', scheduleId: SCHED, holdId, originStationId: ORIGIN, destinationStationId: DEST, returnScheduleId: RET_SCHED, returnHoldId, returnOriginStationId: RET_ORIGIN, returnDestinationStationId: RET_DEST, seatClassId, returnSeatClassId: seatClassId, skipIdentityVerification: true, passengers: outSeats.map((s, i) => ({ ...person(s), returnSeatId: retSeats[i] })), }; } async function book(body) { const r = await api('POST', '/bookings/guest', body); return { ...r, bookingRef: r.data?.bookingRef }; } async function seatPool(sched = SCHED, o = ORIGIN, d = DEST) { const r = await api('GET', `/seats/seatmap/${sched}?originStationId=${o}&destinationStationId=${d}`); if (!r.ok) throw new Error(`seatmap ${r.status}: ${msg(r)}`); const out = []; for (const c of r.data?.coaches ?? []) for (const s of c.seats ?? []) if (String(s.status).toUpperCase() === 'AVAILABLE') out.push({ id: s.id, seatNumber: s.seatNumber, coach: c.coachNumber, coachClass: c.seatClass }); return out; } async function seatClassId(seat) { const r = await api('GET', '/seat-classes'); const list = Array.isArray(r.data) ? r.data : (r.data?.items ?? []); const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); return (list.find((c) => norm(c.name) === norm(seat.coachClass)) ?? list[0]).id; } /** Fire everything at once and report the arrival spread so "concurrent" is provable. */ async function fireAll(tasks) { const t0 = Date.now(); const starts = []; const wrapped = tasks.map((fn) => (async () => { starts.push(Date.now() - t0); return fn(); })()); const results = await Promise.all(wrapped); const wallMs = Date.now() - t0; const sumMs = results.reduce((a, r) => a + (r.ms || 0), 0); // overlap >> 1 proves the requests were genuinely in flight together rather than queued. return { results, wallMs, releaseSpreadMs: Math.max(...starts) - Math.min(...starts), overlap: (sumMs / Math.max(wallMs, 1)).toFixed(1) }; } function tally(results) { const t = {}; for (const r of results) { const k = r.bookingRef ? 'created' : r.holdId ? 'held' : `http_${r.status}`; t[k] = (t[k] || 0) + 1; } return t; } const errSample = (results) => [...new Set(results.filter((r) => !r.bookingRef && !r.holdId).map((r) => `${r.status}: ${msg(r)}`))].slice(0, 3); const ALL = { created: [], failures: [] }; function note(refs) { ALL.created.push(...refs); } // ───────────────────────────────────────────────────────────────────────────── /** N users all click the SAME seat: N simultaneous holds. Exactly 1 must win. */ async function holdContest(pool, n) { const seat = pool.shift(); const { results, wallMs, releaseSpreadMs, overlap } = await fireAll( Array.from({ length: n }, () => () => hold([seat.id])), ); const won = results.filter((r) => r.holdId); return { name: `hold-contest n=${n}`, pass: won.length === 1, detail: `seat ${seat.seatNumber}: ${won.length} hold(s) granted ${JSON.stringify(tally(results))} ` + `wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`, errs: errSample(results), winner: won[0], seat, }; } /** The double-submit / retry storm: N simultaneous bookings on ONE valid hold. */ async function bookingStorm(seat, holdId, scid, n) { const bodies = Array.from({ length: n }, () => oneWayBody(holdId, [seat.id], scid)); const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b))); const won = results.filter((r) => r.bookingRef); note(won.map((r) => r.bookingRef)); return { name: `booking-storm n=${n}`, pass: won.length === 1, detail: `seat ${seat.seatNumber}: ${won.length} booking(s) ${JSON.stringify(tally(results))} ` + `wall=${wallMs}ms release-spread=${releaseSpreadMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`, errs: errSample(results), }; } /** N users, N DIFFERENT seats, all at once. All N must succeed — no false rejections. */ async function differentSeats(pool, scid, n) { const seats = pool.splice(0, n); if (seats.length < n) return { name: `diff-seats n=${n}`, pass: false, detail: 'not enough free seats', errs: [] }; const holds = await Promise.all(seats.map((s) => hold([s.id]))); const usable = holds.map((h, i) => ({ h, s: seats[i] })).filter((x) => x.h.holdId); const bodies = usable.map((x) => oneWayBody(x.h.holdId, [x.s.id], scid)); const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b))); const won = results.filter((r) => r.bookingRef); note(won.map((r) => r.bookingRef)); return { name: `diff-seats n=${n}`, pass: won.length === usable.length && usable.length === n, detail: `${won.length}/${usable.length} distinct-seat bookings succeeded (holds granted ${usable.length}/${n}) ` + `${JSON.stringify(tally(results))} wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`, errs: errSample(results), }; } /** Overlapping multi-seat requests over a small shared pool — deadlock + partial-write probe. */ async function overlapping(pool, scid, n, poolSize = 4) { const shared = pool.splice(0, poolSize); const holds = await Promise.all(shared.map((s) => hold([s.id]))); const byId = new Map(shared.map((s, i) => [s.id, holds[i]])); const granted = shared.filter((s) => byId.get(s.id).holdId); if (granted.length < 2) return { name: `overlap n=${n}`, pass: false, detail: 'too few holds', errs: [] }; // Each request asks for 2 seats from the shared pool, in varying order. const bodies = []; for (let i = 0; i < n; i++) { const a = granted[i % granted.length]; const b = granted[(i + 1 + (i % 2)) % granted.length]; if (a.id === b.id) continue; // Present the hold of the FIRST seat; second seat will be rejected as not-in-hold. bodies.push({ body: oneWayBody(byId.get(a.id).holdId, [a.id, b.id], scid), seats: [a, b] }); } const { results, wallMs } = await fireAll(bodies.map((x) => () => book(x.body))); const won = results.filter((r) => r.bookingRef); note(won.map((r) => r.bookingRef)); return { name: `overlap n=${bodies.length}`, pass: true, // correctness judged by the DB duplicate check, not the count detail: `${won.length} booking(s) from ${bodies.length} overlapping 2-seat requests over ${granted.length} seats ` + `${JSON.stringify(tally(results))} wall=${wallMs}ms`, errs: errSample(results), }; } /** Multi-passenger booking where one seat is contested — must be all-or-nothing. */ async function partialAvailability(pool, scid) { const [a, b, c] = pool.splice(0, 3); const hAll = await hold([a.id, b.id, c.id]); if (!hAll.holdId) return { name: 'partial-availability', pass: false, detail: `3-seat hold failed: ${msg(hAll)}`, errs: [] }; // Book seat B alone first (legitimately, from the same hold). const first = await book(oneWayBody(hAll.holdId, [b.id], scid)); if (!first.bookingRef) return { name: 'partial-availability', pass: false, detail: `setup booking failed: ${msg(first)}`, errs: [] }; note([first.bookingRef]); // Now try to book all three. B is taken -> the whole request must fail, leaving no row. const second = await book(oneWayBody(hAll.holdId, [a.id, b.id, c.id], scid)); if (second.bookingRef) { note([second.bookingRef]); return { name: 'partial-availability', pass: false, detail: `3-seat booking succeeded despite seat ${b.seatNumber} being taken — ${second.bookingRef}`, errs: [] }; } return { name: 'partial-availability', pass: true, detail: `seat ${b.seatNumber} taken -> 3-seat request rejected whole (${second.status}: ${msg(second)}); seats ${a.seatNumber},${c.seatNumber} must remain free`, errs: [], freeSeats: [a, c], takenSeat: b, }; } /** N concurrent ROUND_TRIP bookings contesting one outbound+return seat pair. */ async function roundTripContest(n, scid) { if (!RET_SCHED) return { name: `round-trip n=${n}`, pass: true, detail: 'skipped (no --return-schedule)', errs: [] }; const outPool = await seatPool(SCHED, ORIGIN, DEST); const retPool = await seatPool(RET_SCHED, RET_ORIGIN, RET_DEST); const outSeat = outPool[0]; const retSeat = retPool.find((s) => s.id !== outSeat.id) ?? retPool[0]; const traveller = uuid(); const hOut = await hold([outSeat.id], SCHED, ORIGIN, DEST, 'OUTBOUND', traveller); const hRet = await hold([retSeat.id], RET_SCHED, RET_ORIGIN, RET_DEST, 'RETURN', traveller); if (!hOut.holdId || !hRet.holdId) { return { name: `round-trip n=${n}`, pass: false, detail: `hold failed out=${msg(hOut)} ret=${msg(hRet)}`, errs: [] }; } const bodies = Array.from({ length: n }, () => roundTripBody(hOut.holdId, hRet.holdId, [outSeat.id], [retSeat.id], scid)); const { results, wallMs } = await fireAll(bodies.map((b) => () => book(b))); const won = results.filter((r) => r.bookingRef); note(won.map((r) => r.bookingRef)); return { name: `round-trip n=${n}`, pass: won.length === 1, detail: `out seat ${outSeat.seatNumber} / ret seat ${retSeat.seatNumber}: ${won.length} booking(s) ` + `${JSON.stringify(tally(results))} wall=${wallMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`, errs: errSample(results), seats: { outSeat, retSeat }, }; } /** A single multi-passenger, multi-seat booking — the ordinary family booking. */ async function multiPassenger(pool, scid, count = 4) { const seats = pool.splice(0, count); const h = await hold(seats.map((s) => s.id)); if (!h.holdId) return { name: `multi-passenger x${count}`, pass: false, detail: `hold failed: ${msg(h)}`, errs: [] }; const r = await book(oneWayBody(h.holdId, seats.map((s) => s.id), scid)); if (r.bookingRef) note([r.bookingRef]); return { name: `multi-passenger x${count}`, pass: !!r.bookingRef, detail: r.bookingRef ? `${count} passengers on seats ${seats.map((s) => s.seatNumber).join(',')} -> ${r.bookingRef}` : `rejected (${r.status}): ${msg(r)}`, errs: [], seats, ref: r.bookingRef, }; } // ───────────────────────────────────────────────────────────────────────────── (async () => { console.log(`API ${BASE}`); console.log(`Schedule ${SCHED}`); if (RET_SCHED) console.log(`Return ${RET_SCHED}`); const pool = await seatPool(); const scid = await seatClassId(pool[0]); console.log(`${pool.length} AVAILABLE seats, seatClass ${scid}\n`); const out = []; const report = (r) => { out.push(r); console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.name}`); console.log(` ${r.detail}`); if (r.errs?.length) r.errs.forEach((e) => console.log(` · ${e}`)); console.log(''); return r; }; if (MODE === 'ladder' || MODE === 'all') { for (const n of LEVELS) { console.log(`──────── concurrency ${n} ────────`); const hc = report(await holdContest(pool, n)); if (hc.winner) report(await bookingStorm(hc.seat, hc.winner.holdId, scid, n)); report(await differentSeats(pool, scid, n)); } } if (MODE === 'edge' || MODE === 'all') { console.log(`──────── edge cases ────────`); report(await multiPassenger(pool, scid, 4)); report(await partialAvailability(pool, scid)); report(await overlapping(pool, scid, 12)); report(await roundTripContest(10, scid)); } const fails = out.filter((r) => !r.pass); console.log(`════════ ${out.length} checks, ${fails.length} failed, ${ALL.created.length} bookings created ════════`); fails.forEach((f) => console.log(` FAIL ${f.name}: ${f.detail}`)); if (ALL.created.length) { console.log(`\nrefs: ${ALL.created.join(' ')}`); } process.exit(fails.length ? 1 : 0); })().catch((e) => { console.error(e); process.exit(2); });