realtime-subs.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. /**
  2. * Realtime subscriptions load test — opens N concurrent WebSocket subs
  3. * against the realtime service, exercises NOTIFY fan-out, reports
  4. * latency + connection-failure stats.
  5. *
  6. * Phase 2 target: 1,000 concurrent subs on KVM4 with p99 fan-out latency
  7. * under 500ms and zero connection failures.
  8. *
  9. * Usage:
  10. * bun run infra/load-tests/realtime-subs.ts \
  11. * --url ws://localhost:3004/v1/subscribe \
  12. * --secret "$BRIVEN_RUNTIME_SHARED_SECRET" \
  13. * --project p_01HZ... \
  14. * --function poolStats \
  15. * --subs 1000 \
  16. * --duration 60
  17. *
  18. * `--ws-impl` defaults to bun's built-in WebSocket. The harness expects
  19. * to be run with bun, not node.
  20. *
  21. * Stops after `--duration` seconds; emits a summary on stdout.
  22. */
  23. interface Args {
  24. url: string;
  25. secret: string;
  26. projectId: string;
  27. functionName: string;
  28. subs: number;
  29. durationSec: number;
  30. rampMs: number;
  31. }
  32. interface Stats {
  33. opened: number;
  34. failed: number;
  35. closed: number;
  36. framesReceived: number;
  37. firstFrameLatencyMs: number[];
  38. errors: Map<string, number>;
  39. }
  40. function parseArgs(argv: readonly string[]): Args {
  41. const out: Partial<Args> = {};
  42. for (let i = 0; i < argv.length; i++) {
  43. const a = argv[i];
  44. const next = argv[i + 1];
  45. if (a === '--url' && next) out.url = next;
  46. else if (a === '--secret' && next) out.secret = next;
  47. else if (a === '--project' && next) out.projectId = next;
  48. else if (a === '--function' && next) out.functionName = next;
  49. else if (a === '--subs' && next) out.subs = Number(next);
  50. else if (a === '--duration' && next) out.durationSec = Number(next);
  51. else if (a === '--ramp' && next) out.rampMs = Number(next);
  52. }
  53. return {
  54. url: out.url ?? 'ws://localhost:3004/v1/subscribe',
  55. secret: out.secret ?? process.env.BRIVEN_RUNTIME_SHARED_SECRET ?? '',
  56. projectId: out.projectId ?? '',
  57. functionName: out.functionName ?? 'poolStats',
  58. subs: out.subs ?? 100,
  59. durationSec: out.durationSec ?? 30,
  60. rampMs: out.rampMs ?? 10,
  61. };
  62. }
  63. function help(): void {
  64. process.stdout.write(`realtime-subs — load test the briven realtime service
  65. flags:
  66. --url URL ws[s]:// endpoint (default: ws://localhost:3004/v1/subscribe)
  67. --secret HEX runtime shared secret (default: \$BRIVEN_RUNTIME_SHARED_SECRET)
  68. --project ID target project id, p_…
  69. --function NAME function to subscribe to (default: poolStats)
  70. --subs N concurrent subs to open (default: 100)
  71. --duration SEC how long to hold open after ramp completes (default: 30)
  72. --ramp MS delay between successive subscribe frames (default: 10)
  73. `);
  74. }
  75. async function openOne(
  76. args: Args,
  77. stats: Stats,
  78. index: number,
  79. signal: AbortSignal,
  80. ): Promise<void> {
  81. // Bun's WebSocket supports the `headers` option via a second-arg trick.
  82. // The realtime service expects Authorization on the upgrade request.
  83. // We use a custom protocol prefix to convey the bearer because the
  84. // browser-style WebSocket constructor doesn't accept headers; the
  85. // realtime side currently reads from the upgrade handler. Workaround:
  86. // include the token in the URL as a query string, OR run this script
  87. // with an env var the harness understands. We use a `?bearer=` param
  88. // that the test harness can recognise — for production the dashboard
  89. // SDK sends a real Authorization header during the upgrade.
  90. const wsUrl = `${args.url}?bearer=${encodeURIComponent(args.secret)}`;
  91. const ws = new WebSocket(wsUrl);
  92. const t0 = performance.now();
  93. let firstFrameSeen = false;
  94. return new Promise((resolve) => {
  95. const cleanup = (): void => {
  96. try {
  97. ws.close();
  98. } catch {
  99. /* ignore */
  100. }
  101. resolve();
  102. };
  103. signal.addEventListener('abort', cleanup, { once: true });
  104. ws.addEventListener('open', () => {
  105. stats.opened++;
  106. ws.send(
  107. JSON.stringify({
  108. type: 'subscribe',
  109. subscriptionId: `s_${index}_${Date.now()}`,
  110. projectId: args.projectId,
  111. functionName: args.functionName,
  112. args: {},
  113. }),
  114. );
  115. });
  116. ws.addEventListener('message', (ev: MessageEvent) => {
  117. stats.framesReceived++;
  118. if (!firstFrameSeen) {
  119. firstFrameSeen = true;
  120. stats.firstFrameLatencyMs.push(performance.now() - t0);
  121. }
  122. const data = typeof ev.data === 'string' ? ev.data : '';
  123. try {
  124. const frame = JSON.parse(data) as { type?: string; code?: string };
  125. if (frame.type === 'error' && frame.code) {
  126. stats.errors.set(frame.code, (stats.errors.get(frame.code) ?? 0) + 1);
  127. }
  128. } catch {
  129. /* ignore */
  130. }
  131. });
  132. ws.addEventListener('error', () => {
  133. stats.failed++;
  134. cleanup();
  135. });
  136. ws.addEventListener('close', () => {
  137. stats.closed++;
  138. resolve();
  139. });
  140. });
  141. }
  142. function percentile(values: number[], p: number): number {
  143. if (values.length === 0) return 0;
  144. const sorted = [...values].sort((a, b) => a - b);
  145. const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
  146. return sorted[idx] ?? 0;
  147. }
  148. async function main(): Promise<number> {
  149. const args = parseArgs(process.argv.slice(2));
  150. if (process.argv.includes('--help') || process.argv.includes('-h')) {
  151. help();
  152. return 0;
  153. }
  154. if (!args.projectId) {
  155. process.stderr.write('error: --project is required\n');
  156. help();
  157. return 1;
  158. }
  159. if (!args.secret) {
  160. process.stderr.write('error: --secret or BRIVEN_RUNTIME_SHARED_SECRET is required\n');
  161. return 1;
  162. }
  163. const stats: Stats = {
  164. opened: 0,
  165. failed: 0,
  166. closed: 0,
  167. framesReceived: 0,
  168. firstFrameLatencyMs: [],
  169. errors: new Map(),
  170. };
  171. process.stdout.write(
  172. `opening ${args.subs} subscriptions against ${args.url} (ramp ${args.rampMs}ms)\n`,
  173. );
  174. const controller = new AbortController();
  175. const inflight: Promise<void>[] = [];
  176. for (let i = 0; i < args.subs; i++) {
  177. inflight.push(openOne(args, stats, i, controller.signal));
  178. if (args.rampMs > 0) await new Promise((r) => setTimeout(r, args.rampMs));
  179. }
  180. process.stdout.write(`ramp complete. holding for ${args.durationSec}s…\n`);
  181. await new Promise((r) => setTimeout(r, args.durationSec * 1000));
  182. controller.abort();
  183. await Promise.all(inflight);
  184. const summary = {
  185. opened: stats.opened,
  186. failed: stats.failed,
  187. closed: stats.closed,
  188. framesReceived: stats.framesReceived,
  189. firstFrameLatency: {
  190. n: stats.firstFrameLatencyMs.length,
  191. p50: Math.round(percentile(stats.firstFrameLatencyMs, 50)),
  192. p99: Math.round(percentile(stats.firstFrameLatencyMs, 99)),
  193. max: Math.round(Math.max(0, ...stats.firstFrameLatencyMs)),
  194. },
  195. errorsByCode: Object.fromEntries(stats.errors.entries()),
  196. };
  197. process.stdout.write(`\n${JSON.stringify(summary, null, 2)}\n`);
  198. // Exit non-zero if anything failed — useful in CI.
  199. return stats.failed > 0 ? 1 : 0;
  200. }
  201. const code = await main();
  202. process.exit(code);