From 26dc926dd07267fa886f80a704d5d0dc14ee4140 Mon Sep 17 00:00:00 2001 From: Nguyen Mau Minh Duc Date: Wed, 13 May 2026 13:40:06 +0700 Subject: [PATCH] Add k6 relayer stress tests --- .github/workflows/benchmark-smoke.yml | 6 + docs/docs.json | 3 +- docs/relayer/api-reference.md | 10 +- docs/relayer/benchmark-ci-setup.md | 17 + docs/relayer/k6-stress-tests.md | 159 +++++++ services/server/scripts/k6/k6.d.ts | 62 +++ services/server/scripts/k6/relayer.ts | 479 ++++++++++++++++++++++ services/server/scripts/package-lock.json | 10 + services/server/scripts/package.json | 11 +- services/server/scripts/sidecar-server.ts | 3 +- 10 files changed, 753 insertions(+), 7 deletions(-) create mode 100644 docs/relayer/k6-stress-tests.md create mode 100644 services/server/scripts/k6/k6.d.ts create mode 100644 services/server/scripts/k6/relayer.ts diff --git a/.github/workflows/benchmark-smoke.yml b/.github/workflows/benchmark-smoke.yml index f157a44f..42119457 100644 --- a/.github/workflows/benchmark-smoke.yml +++ b/.github/workflows/benchmark-smoke.yml @@ -7,6 +7,7 @@ on: - '.github/workflows/benchmark-live.yml' - '.github/workflows/benchmark-smoke.yml' - 'docs/relayer/benchmark-ci-setup.md' + - 'docs/relayer/k6-stress-tests.md' push: branches: - main @@ -17,6 +18,7 @@ on: - '.github/workflows/benchmark-live.yml' - '.github/workflows/benchmark-smoke.yml' - 'docs/relayer/benchmark-ci-setup.md' + - 'docs/relayer/k6-stress-tests.md' workflow_dispatch: concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -66,3 +68,7 @@ jobs: working-directory: services/server/scripts run: | ./node_modules/.bin/tsx bench-recall-latency.ts --help + + - name: Build k6 relayer bundle + working-directory: services/server/scripts + run: npm run k6:build diff --git a/docs/docs.json b/docs/docs.json index f8aaecdb..c2d077f5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -99,7 +99,8 @@ "relayer/overview", "relayer/public-relayer", "relayer/self-hosting", - "relayer/api-reference" + "relayer/api-reference", + "relayer/k6-stress-tests" ] } ] diff --git a/docs/relayer/api-reference.md b/docs/relayer/api-reference.md index 5149310e..210bd7f7 100644 --- a/docs/relayer/api-reference.md +++ b/docs/relayer/api-reference.md @@ -20,19 +20,21 @@ All `/api/*` routes require signed headers. The SDK handles this automatically. | `x-public-key` | Hex-encoded Ed25519 public key (32 bytes) | | `x-signature` | Hex-encoded Ed25519 signature (64 bytes) | | `x-timestamp` | Unix timestamp in seconds (5-minute validity window) | +| `x-nonce` | UUID nonce, unique per request, used for replay protection | +| `x-account-id` | MemWalAccount object ID; included in the signed message | ### Optional Headers | Header | Description | |--------|-------------| -| `x-account-id` | MemWalAccount object ID hint — speeds up account resolution when not cached | -| `x-delegate-key` | Delegate private key (hex) — used by the default SDK for SEAL decrypt flows | +| `x-seal-session` | Client-built SEAL SessionKey for server-side decrypt flows | +| `x-delegate-key` | Legacy delegate private key (hex or `suiprivkey`) fallback for SEAL decrypt flows | ### Signature Format -The signed message is: `{timestamp}.{method}.{path}.{body_sha256}` +The signed message is: `{timestamp}.{method}.{path_and_query}.{body_sha256}.{nonce}.{account_id}` -The relayer verifies the Ed25519 signature, then resolves the owner by looking up the public key in onchain `MemWalAccount.delegate_keys`. +The relayer verifies the Ed25519 signature, checks the nonce, then resolves the owner by looking up the public key in onchain `MemWalAccount.delegate_keys`. ## Public Routes diff --git a/docs/relayer/benchmark-ci-setup.md b/docs/relayer/benchmark-ci-setup.md index 89f09fc2..0dc10215 100644 --- a/docs/relayer/benchmark-ci-setup.md +++ b/docs/relayer/benchmark-ci-setup.md @@ -14,6 +14,7 @@ deployment. - Runs `cargo check`. - Typechecks `bench-recall-latency.ts`. - Runs a `--help` smoke check for the recall benchmark CLI. + - Builds the k6 relayer bundle from `services/server/scripts/k6/relayer.ts`. - Does not need secrets and does not call Sui, Walrus, SEAL, or OpenAI. - `.github/workflows/benchmark-live.yml` @@ -84,3 +85,19 @@ cd services/server/scripts --remember-text "benchmark memory" \ --query "benchmark memory" ``` + +k6 smoke against staging: + +```bash +cd services/server/scripts +npm run k6:build + +BENCH_SERVER_URL=https://relayer.staging.memwal.ai \ +BENCH_ACCOUNT_ID="$BENCH_ACCOUNT_ID" \ +BENCH_DELEGATE_KEY="$BENCH_DELEGATE_KEY" \ +MEMWAL_NAMESPACE=benchmark \ +K6_PROFILE=smoke \ +k6 run dist/k6/relayer.js +``` + +For load, stress, and spike profiles, see [k6 Stress Tests](/relayer/k6-stress-tests). diff --git a/docs/relayer/k6-stress-tests.md b/docs/relayer/k6-stress-tests.md new file mode 100644 index 00000000..f31e938d --- /dev/null +++ b/docs/relayer/k6-stress-tests.md @@ -0,0 +1,159 @@ +--- +title: "k6 Stress Tests" +--- + +The relayer k6 harness lives in `services/server/scripts/k6/relayer.ts`. +It signs protected requests with the same canonical message as the Rust auth +middleware: + +```text +{timestamp}.{method}.{path_and_query}.{body_sha256}.{nonce}.{account_id} +``` + +The bundle sends `x-nonce`, `x-account-id`, and the legacy `x-delegate-key` +header so relayer-mode `recall`, `ask`, and `restore` can decrypt through SEAL +without needing SDK SessionKey construction inside k6. + +## Build + +```bash +cd services/server/scripts +npm ci +npm run k6:build +``` + +This writes `dist/k6/relayer.js`, which is the file k6 runs. + +## Required Environment + +| Variable | Notes | +| --- | --- | +| `MEMWAL_SERVER_URL` | Relayer base URL, for example `http://localhost:8000` | +| `MEMWAL_ACCOUNT_ID` | MemWal account object ID used by `x-account-id` | +| `MEMWAL_DELEGATE_KEY` | Ed25519 delegate key, as `suiprivkey`, 64-char hex, or `0x` hex | +| `MEMWAL_NAMESPACE` | Namespace to read/write. Defaults to `k6` | + +The harness also accepts the existing benchmark variable names: +`BENCH_SERVER_URL`, `BENCH_ACCOUNT_ID`, and `BENCH_DELEGATE_KEY`. + +## Profiles + +Run from `services/server/scripts` after building: + +```bash +# Health only, no auth required +K6_PROFILE=health k6 run dist/k6/relayer.js + +# One end-to-end remember -> poll -> recall pass +K6_PROFILE=smoke \ +MEMWAL_SERVER_URL=http://localhost:8000 \ +MEMWAL_ACCOUNT_ID=0x... \ +MEMWAL_DELEGATE_KEY=suiprivkey1... \ +k6 run dist/k6/relayer.js + +# Steady load. Defaults to 1 iteration/sec for 5 minutes. +K6_PROFILE=load K6_RATE=2 K6_DURATION=10m k6 run dist/k6/relayer.js + +# Gradual ramp. Override stage rates/durations as needed. +K6_PROFILE=stress \ +K6_STRESS_STAGE_1_RATE=2 \ +K6_STRESS_STAGE_2_RATE=5 \ +K6_STRESS_STAGE_3_RATE=10 \ +k6 run dist/k6/relayer.js + +# Sudden traffic burst. +K6_PROFILE=spike K6_SPIKE_RATE=30 k6 run dist/k6/relayer.js +``` + +The default `mixedFlow` is conservative: + +| Flow | Default weight | Main bottlenecks exercised | +| --- | ---: | --- | +| `POST /api/recall` | `0.75` | query embedding, pgvector search, Walrus download/cache, SEAL decrypt | +| `POST /api/remember` + status polling | `0.25` | enqueue latency, background embedding, SEAL encrypt, Walrus upload, DB insert | +| `POST /api/ask` | `0` | recall path plus LLM completion | +| `POST /api/restore` | `0` | onchain blob query, Walrus download, SEAL decrypt, re-embedding, DB insert | + +Enable expensive flows explicitly: + +```bash +MEMWAL_ASK_WEIGHT=0.05 MEMWAL_RESTORE_WEIGHT=0.01 K6_PROFILE=load k6 run dist/k6/relayer.js +``` + +To isolate one endpoint family, set `K6_EXEC`: + +```bash +K6_PROFILE=load K6_EXEC=recallOnly k6 run dist/k6/relayer.js +K6_PROFILE=load K6_EXEC=rememberOnly k6 run dist/k6/relayer.js +K6_PROFILE=load K6_EXEC=askOnly k6 run dist/k6/relayer.js +K6_PROFILE=load K6_EXEC=restoreOnly k6 run dist/k6/relayer.js +``` + +## Metrics + +k6 already reports `http_req_duration`, `http_reqs`, `http_req_failed`, and +request throughput. The harness adds: + +| Metric | Meaning | +| --- | --- | +| `memwal_remember_enqueue_duration` | Time for `POST /api/remember` to return `202` | +| `memwal_remember_worker_duration` | Time from enqueue to terminal remember job status | +| `memwal_recall_duration` | End-to-end `POST /api/recall` latency | +| `memwal_ask_duration` | End-to-end `POST /api/ask` latency | +| `memwal_restore_duration` | End-to-end `POST /api/restore` latency | +| `memwal_timeout_rate` | Requests or worker polls that timed out | +| `memwal_remember_job_failed_rate` | Remember jobs that ended failed or timed out | +| `memwal_http_errors` | HTTP status `0` or `>=400`, tagged by endpoint | + +Thresholds include p95/p99 latency checks for health, remember enqueue, recall, +timeout rate, and remember job failure rate. + +## Local Runs + +For local capacity testing, start the relayer with the benchmark rate-limit +escape hatch so polling does not dominate the results: + +```bash +RATE_LIMIT_DISABLED=1 cargo run --release +``` + +Use this only on local benchmark instances. Keep normal rate limits enabled for +shared staging unless the environment is isolated for a run. + +## Staging Runs + +Use the existing benchmark secrets: + +```bash +cd services/server/scripts +npm run k6:build + +BENCH_SERVER_URL=https://relayer.staging.memwal.ai \ +BENCH_ACCOUNT_ID="$BENCH_ACCOUNT_ID" \ +BENCH_DELEGATE_KEY="$BENCH_DELEGATE_KEY" \ +MEMWAL_NAMESPACE=benchmark \ +K6_PROFILE=smoke \ +k6 run dist/k6/relayer.js +``` + +Before running load, stress, or spike against staging, seed the namespace with +several memories and agree on rate limits for OpenAI, SEAL, Walrus, and Sui RPC. +For recall-only tests, a namespace with no memories still exercises query +embedding and pgvector search, but it does not exercise Walrus download or SEAL +decrypt. + +## RPC Self-Hosting + +The Rust relayer honors `SUI_RPC_URL`; the sidecar now uses the same env var +before falling back to `getJsonRpcFullnodeUrl(SUI_NETWORK)`. If public Sui RPC is +blocked or rate-limited, self-hosting is viable as long as the relayer and +sidecar are both configured with the same private or paid RPC endpoint: + +```bash +SUI_NETWORK=testnet +SUI_RPC_URL=https://your-sui-fullnode.example.com +``` + +Walrus publisher, aggregator, and upload relay are separate from Sui RPC. If +those endpoints are blocked too, override `WALRUS_PUBLISHER_URL`, +`WALRUS_AGGREGATOR_URL`, and `WALRUS_UPLOAD_RELAY_URL` separately. diff --git a/services/server/scripts/k6/k6.d.ts b/services/server/scripts/k6/k6.d.ts new file mode 100644 index 00000000..1aa65290 --- /dev/null +++ b/services/server/scripts/k6/k6.d.ts @@ -0,0 +1,62 @@ +declare module "k6/http" { + export type Response = { + status: number; + body: unknown; + timings: { duration: number }; + json: () => unknown; + }; + + type Params = { + headers?: Record; + tags?: Record; + timeout?: string; + }; + + const http: { + get: (url: string, params?: Params) => Response; + request: ( + method: string, + url: string, + body?: string | null, + params?: Params, + ) => Response; + }; + + export default http; +} + +declare module "k6/execution" { + const exec: { + vu: { idInTest: number }; + scenario: { iterationInTest: number }; + }; + export default exec; +} + +declare module "k6" { + import type { Response } from "k6/http"; + + export function check( + value: T, + sets: Record boolean>, + ): boolean; + export function fail(message: string): never; + export function sleep(seconds: number): void; +} + +declare module "k6/metrics" { + export class Counter { + constructor(name: string); + add(value: number, tags?: Record): void; + } + + export class Rate { + constructor(name: string); + add(value: boolean, tags?: Record): void; + } + + export class Trend { + constructor(name: string, isTime?: boolean); + add(value: number, tags?: Record): void; + } +} diff --git a/services/server/scripts/k6/relayer.ts b/services/server/scripts/k6/relayer.ts new file mode 100644 index 00000000..aaa5c094 --- /dev/null +++ b/services/server/scripts/k6/relayer.ts @@ -0,0 +1,479 @@ +/// + +import http from "k6/http"; +import exec from "k6/execution"; +import { check, fail, sleep } from "k6"; +import { Counter, Rate, Trend } from "k6/metrics"; +import * as ed from "@noble/ed25519"; +import { sha256, sha512 } from "@noble/hashes/sha2.js"; +import { bech32 } from "bech32"; + +declare const __ENV: Record; + +ed.etc.sha512Sync = (...messages: Uint8Array[]) => sha512(ed.etc.concatBytes(...messages)); + +type Json = Record; +type Profile = "smoke" | "load" | "stress" | "spike" | "health"; + +const serverUrl = trimTrailingSlash( + env("MEMWAL_SERVER_URL", "SERVER_URL", "BENCH_SERVER_URL") ?? "http://localhost:8000", +); +const accountId = env("MEMWAL_ACCOUNT_ID", "BENCH_ACCOUNT_ID") ?? ""; +const delegateKeyRaw = env("MEMWAL_DELEGATE_KEY", "BENCH_DELEGATE_KEY") ?? ""; +const namespace = __ENV.MEMWAL_NAMESPACE ?? __ENV.NAMESPACE ?? "k6"; +const query = __ENV.MEMWAL_QUERY ?? __ENV.QUERY ?? "benchmark memory"; +const rememberText = __ENV.MEMWAL_REMEMBER_TEXT ?? __ENV.REMEMBER_TEXT ?? "benchmark memory"; +const limit = intEnv("MEMWAL_LIMIT", 5); +const requestTimeout = __ENV.MEMWAL_REQUEST_TIMEOUT ?? "60s"; +const rememberPollTimeoutMs = intEnv("MEMWAL_REMEMBER_POLL_TIMEOUT_MS", 180_000); +const rememberPollIntervalMs = intEnv("MEMWAL_REMEMBER_POLL_INTERVAL_MS", 1_000); +const healthSampleRate = numberEnv("MEMWAL_HEALTH_SAMPLE_RATE", 0.05); +const recallWeight = numberEnv("MEMWAL_RECALL_WEIGHT", 0.75); +const rememberWeight = numberEnv("MEMWAL_REMEMBER_WEIGHT", 0.25); +const askWeight = numberEnv("MEMWAL_ASK_WEIGHT", 0); +const restoreWeight = numberEnv("MEMWAL_RESTORE_WEIGHT", 0); +const profile = (env("K6_PROFILE", "MEMWAL_K6_PROFILE") ?? "smoke") as Profile; + +let secretKey: Uint8Array | null = null; +let publicKeyHex = ""; +if (profile !== "health") { + if (!accountId) fail("MEMWAL_ACCOUNT_ID or BENCH_ACCOUNT_ID is required"); + if (!delegateKeyRaw) fail("MEMWAL_DELEGATE_KEY or BENCH_DELEGATE_KEY is required"); + secretKey = decodeDelegateKey(delegateKeyRaw); + publicKeyHex = bytesToHex(ed.getPublicKey(secretKey)); +} + +export const options = buildOptions(profile); + +const httpErrors = new Counter("memwal_http_errors"); +const requestTimeouts = new Rate("memwal_timeout_rate"); +const rememberEnqueueDuration = new Trend("memwal_remember_enqueue_duration", true); +const rememberWorkerDuration = new Trend("memwal_remember_worker_duration", true); +const recallDuration = new Trend("memwal_recall_duration", true); +const askDuration = new Trend("memwal_ask_duration", true); +const restoreDuration = new Trend("memwal_restore_duration", true); +const jobFailures = new Rate("memwal_remember_job_failed_rate"); + +export function setup() { + const health = http.get(`${serverUrl}/health`, { + tags: { endpoint: "health", flow: "setup" }, + timeout: "10s", + }); + check(health, { "setup health is 200": (r) => r.status === 200 }); + if (health.status !== 200) { + fail(`GET /health returned ${health.status}`); + } + return { baseUrl: serverUrl, namespace }; +} + +export function healthOnly() { + const res = http.get(`${serverUrl}/health`, { + tags: { endpoint: "health", flow: "health" }, + timeout: "10s", + }); + recordHttpResult(res.status, "health"); + check(res, { "health is 200": (r) => r.status === 200 }); +} + +export function smoke() { + healthOnly(); + const job = remember(); + if (job) { + pollRememberJob(job.jobId, job.startedAt); + } + recall(); +} + +export function mixedFlow() { + sampleHealth("mixed"); + const total = recallWeight + rememberWeight + askWeight + restoreWeight; + if (total <= 0) { + recall(); + return; + } + + const r = Math.random() * total; + if (r < recallWeight) { + recall(); + } else if (r < recallWeight + rememberWeight) { + const job = remember(); + if (job && shouldPollRemember()) { + pollRememberJob(job.jobId, job.startedAt); + } + } else if (r < recallWeight + rememberWeight + askWeight) { + ask(); + } else { + restore(); + } +} + +export function recallOnly() { + sampleHealth("recall"); + recall(); +} + +export function rememberOnly() { + sampleHealth("remember"); + const job = remember(); + if (job && shouldPollRemember()) { + pollRememberJob(job.jobId, job.startedAt); + } +} + +export function askOnly() { + sampleHealth("ask"); + ask(); +} + +export function restoreOnly() { + sampleHealth("restore"); + restore(); +} + +function remember(): { jobId: string; startedAt: number } | null { + const startedAt = Date.now(); + const body = { + text: `${rememberText} vu=${exec.vu.idInTest} iter=${exec.scenario.iterationInTest} ts=${startedAt}`, + namespace, + }; + const res = signedRequest("POST", "/api/remember", body, "remember_enqueue"); + rememberEnqueueDuration.add(res.durationMs); + + const ok = check(res, { + "remember accepted": (r) => r.status === 202, + "remember returned job_id": (r) => typeof r.json?.job_id === "string", + }); + if (!ok) return null; + + return { jobId: res.json!.job_id as string, startedAt }; +} + +function pollRememberJob(jobId: string, startedAt: number): boolean { + const path = `/api/remember/${jobId}`; + while (Date.now() - startedAt < rememberPollTimeoutMs) { + const res = signedRequest("GET", path, undefined, "remember_status"); + if (res.status !== 200) { + sleep(rememberPollIntervalMs / 1000); + continue; + } + + const status = res.json?.status; + if (status === "done") { + rememberWorkerDuration.add(Date.now() - startedAt); + jobFailures.add(false); + return true; + } + if (status === "failed") { + rememberWorkerDuration.add(Date.now() - startedAt); + jobFailures.add(true); + return false; + } + sleep(rememberPollIntervalMs / 1000); + } + + requestTimeouts.add(true, { endpoint: "remember_worker" }); + jobFailures.add(true); + return false; +} + +function recall() { + const res = signedRequest("POST", "/api/recall", { query, namespace, limit }, "recall"); + recallDuration.add(res.durationMs); + check(res, { + "recall is 200": (r) => r.status === 200, + "recall has results array": (r) => Array.isArray(r.json?.results), + }); +} + +function ask() { + const res = signedRequest( + "POST", + "/api/ask", + { question: query, namespace, limit }, + "ask", + ); + askDuration.add(res.durationMs); + check(res, { + "ask is 200": (r) => r.status === 200, + "ask has answer": (r) => typeof r.json?.answer === "string", + }); +} + +function restore() { + const res = signedRequest("POST", "/api/restore", { namespace, limit }, "restore"); + restoreDuration.add(res.durationMs); + check(res, { + "restore is 200": (r) => r.status === 200, + "restore reports totals": (r) => typeof r.json?.total === "number", + }); +} + +function signedRequest( + method: "GET" | "POST", + path: string, + body: Json | undefined, + endpoint: string, +): { status: number; durationMs: number; json?: Json } { + if (!secretKey) fail("signedRequest called without a delegate key"); + + const bodyStr = method === "GET" ? "" : JSON.stringify(body ?? {}); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = randomUuid(); + const bodyHash = bytesToHex(sha256(utf8(bodyStr))); + const message = `${timestamp}.${method}.${path}.${bodyHash}.${nonce}.${accountId}`; + const signature = ed.sign(utf8(message), secretKey); + + try { + const res = http.request(method, `${serverUrl}${path}`, method === "GET" ? null : bodyStr, { + headers: { + "Content-Type": "application/json", + "x-public-key": publicKeyHex, + "x-signature": bytesToHex(signature), + "x-timestamp": timestamp, + "x-nonce": nonce, + "x-account-id": accountId, + "x-delegate-key": normalizeDelegateKeyForHeader(delegateKeyRaw), + }, + tags: { endpoint }, + timeout: requestTimeout, + }); + + recordHttpResult(res.status, endpoint); + return { + status: res.status, + durationMs: res.timings.duration, + json: parseJson(res.body), + }; + } catch (err) { + httpErrors.add(1, { endpoint }); + requestTimeouts.add(String(err).toLowerCase().includes("timeout"), { endpoint }); + return { status: 0, durationMs: 0 }; + } +} + +function sampleHealth(flow: string) { + if (healthSampleRate <= 0 || Math.random() > healthSampleRate) return; + const res = http.get(`${serverUrl}/health`, { + tags: { endpoint: "health", flow }, + timeout: "10s", + }); + recordHttpResult(res.status, "health"); +} + +function shouldPollRemember(): boolean { + const raw = __ENV.MEMWAL_POLL_REMEMBER ?? "true"; + return raw !== "0" && raw.toLowerCase() !== "false"; +} + +function recordHttpResult(status: number, endpoint: string) { + if (status === 0 || status >= 400) { + httpErrors.add(1, { endpoint }); + } + requestTimeouts.add(status === 0, { endpoint }); +} + +function parseJson(body: unknown): Json | undefined { + if (typeof body !== "string" || body.length === 0) return undefined; + try { + return JSON.parse(body) as Json; + } catch { + return undefined; + } +} + +function buildOptions(selectedProfile: Profile) { + const thresholds = { + http_req_failed: ["rate<0.05"], + "http_req_duration{endpoint:health}": ["p(95)<500", "p(99)<1000"], + memwal_timeout_rate: ["rate<0.02"], + memwal_remember_job_failed_rate: ["rate<0.05"], + memwal_recall_duration: ["p(95)<5000", "p(99)<10000"], + memwal_remember_enqueue_duration: ["p(95)<2000", "p(99)<5000"], + }; + + if (selectedProfile === "health") { + return { + scenarios: { + health: { + executor: "constant-arrival-rate", + rate: intEnv("K6_HEALTH_RATE", 10), + timeUnit: "1s", + duration: __ENV.K6_DURATION ?? "2m", + preAllocatedVUs: intEnv("K6_PRE_ALLOCATED_VUS", 20), + exec: "healthOnly", + }, + }, + thresholds, + }; + } + + if (selectedProfile === "load") { + return { + scenarios: { + load: { + executor: "constant-arrival-rate", + rate: intEnv("K6_RATE", 1), + timeUnit: "1s", + duration: __ENV.K6_DURATION ?? "5m", + preAllocatedVUs: intEnv("K6_PRE_ALLOCATED_VUS", 20), + maxVUs: intEnv("K6_MAX_VUS", 100), + exec: __ENV.K6_EXEC ?? "mixedFlow", + }, + }, + thresholds, + }; + } + + if (selectedProfile === "stress") { + return { + scenarios: { + stress: { + executor: "ramping-arrival-rate", + startRate: 1, + timeUnit: "1s", + preAllocatedVUs: intEnv("K6_PRE_ALLOCATED_VUS", 30), + maxVUs: intEnv("K6_MAX_VUS", 200), + exec: __ENV.K6_EXEC ?? "mixedFlow", + stages: [ + { target: intEnv("K6_STRESS_STAGE_1_RATE", 2), duration: __ENV.K6_STRESS_STAGE_1_DURATION ?? "2m" }, + { target: intEnv("K6_STRESS_STAGE_2_RATE", 5), duration: __ENV.K6_STRESS_STAGE_2_DURATION ?? "5m" }, + { target: intEnv("K6_STRESS_STAGE_3_RATE", 10), duration: __ENV.K6_STRESS_STAGE_3_DURATION ?? "5m" }, + { target: 0, duration: __ENV.K6_STRESS_COOLDOWN ?? "1m" }, + ], + }, + }, + thresholds, + }; + } + + if (selectedProfile === "spike") { + return { + scenarios: { + spike: { + executor: "ramping-arrival-rate", + startRate: intEnv("K6_SPIKE_BASE_RATE", 1), + timeUnit: "1s", + preAllocatedVUs: intEnv("K6_PRE_ALLOCATED_VUS", 50), + maxVUs: intEnv("K6_MAX_VUS", 300), + exec: __ENV.K6_EXEC ?? "mixedFlow", + stages: [ + { target: intEnv("K6_SPIKE_BASE_RATE", 1), duration: "30s" }, + { target: intEnv("K6_SPIKE_RATE", 30), duration: "15s" }, + { target: intEnv("K6_SPIKE_RATE", 30), duration: "45s" }, + { target: intEnv("K6_SPIKE_BASE_RATE", 1), duration: "1m" }, + ], + }, + }, + thresholds, + }; + } + + return { + scenarios: { + smoke: { + executor: "shared-iterations", + vus: 1, + iterations: 1, + maxDuration: "5m", + exec: "smoke", + }, + }, + thresholds, + }; +} + +function decodeDelegateKey(key: string): Uint8Array { + if (key.startsWith("suiprivkey")) { + const decoded = bech32.decode(key, 1_000); + if (decoded.prefix !== "suiprivkey") { + throw new Error(`unexpected Sui private key prefix: ${decoded.prefix}`); + } + const bytes = Uint8Array.from(bech32.fromWords(decoded.words)); + if (bytes[0] !== 0) { + throw new Error("k6 relayer tests require an Ed25519 Sui delegate key"); + } + if (bytes.length !== 33) { + throw new Error(`expected 33 Sui private key bytes, got ${bytes.length}`); + } + return bytes.slice(1); + } + + const hex = stripHexPrefix(key); + if (!/^[0-9a-fA-F]{64}$/.test(hex)) { + throw new Error("delegate key must be 64-char hex, 0x-prefixed hex, or suiprivkey"); + } + return hexToBytes(hex); +} + +function normalizeDelegateKeyForHeader(key: string): string { + return key.startsWith("0x") && key.length === 66 ? key.slice(2) : key; +} + +function randomUuid(): string { + const hex = `${randomHex(8)}${randomHex(8)}${randomHex(8)}${randomHex(8)}`.split(""); + hex[12] = "4"; + hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); + return `${hex.slice(0, 8).join("")}-${hex.slice(8, 12).join("")}-${hex + .slice(12, 16) + .join("")}-${hex.slice(16, 20).join("")}-${hex.slice(20, 32).join("")}`; +} + +function randomHex(chars: number): string { + let out = ""; + while (out.length < chars) { + out += Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, "0"); + } + return out.slice(0, chars); +} + +function utf8(input: string): Uint8Array { + return new TextEncoder().encode(input); +} + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) { + out += b.toString(16).padStart(2, "0"); + } + return out; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function stripHexPrefix(value: string): string { + return value.startsWith("0x") ? value.slice(2) : value; +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +function env(...names: string[]): string | undefined { + for (const name of names) { + const value = __ENV[name]; + if (value !== undefined && value !== "") return value; + } + return undefined; +} + +function intEnv(name: string, fallback: number): number { + const value = __ENV[name]; + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function numberEnv(name: string, fallback: number): number { + const value = __ENV[name]; + if (!value) return fallback; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} diff --git a/services/server/scripts/package-lock.json b/services/server/scripts/package-lock.json index a5b7e85b..0929e200 100644 --- a/services/server/scripts/package-lock.json +++ b/services/server/scripts/package-lock.json @@ -19,7 +19,10 @@ "zod": "^3.25.0" }, "devDependencies": { + "@noble/hashes": "^2.0.1", "@types/express": "5.0.0", + "bech32": "^2.0.0", + "esbuild": "^0.27.7", "typescript": "5.6.3" } }, @@ -968,6 +971,13 @@ } } }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", diff --git a/services/server/scripts/package.json b/services/server/scripts/package.json index c92650f1..d7e683e2 100644 --- a/services/server/scripts/package.json +++ b/services/server/scripts/package.json @@ -5,7 +5,13 @@ "type": "module", "scripts": { "sidecar": "tsx sidecar-server.ts", - "test": "node --test --import tsx './mcp/__tests__/*.test.ts'" + "test": "node --test --import tsx './mcp/__tests__/*.test.ts'", + "k6:build": "esbuild k6/relayer.ts --bundle --platform=browser --format=esm --target=es2020 --external:k6 --external:k6/* --outfile=dist/k6/relayer.js", + "k6:health": "npm run k6:build && K6_PROFILE=health k6 run dist/k6/relayer.js", + "k6:smoke": "npm run k6:build && K6_PROFILE=smoke k6 run dist/k6/relayer.js", + "k6:load": "npm run k6:build && K6_PROFILE=load k6 run dist/k6/relayer.js", + "k6:stress": "npm run k6:build && K6_PROFILE=stress k6 run dist/k6/relayer.js", + "k6:spike": "npm run k6:build && K6_PROFILE=spike k6 run dist/k6/relayer.js" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", @@ -19,7 +25,10 @@ "zod": "^3.25.0" }, "devDependencies": { + "@noble/hashes": "^2.0.1", "@types/express": "5.0.0", + "bech32": "^2.0.0", + "esbuild": "^0.27.7", "typescript": "5.6.3" } } diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index dc7c7f1f..20f09934 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -30,6 +30,7 @@ import { getSealServerConfigsFromEnv, getSealThresholdFromEnv } from "./seal-con // ============================================================ const SUI_NETWORK = (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet"; +const SUI_RPC_URL = process.env.SUI_RPC_URL || getJsonRpcFullnodeUrl(SUI_NETWORK); const SEAL_SERVER_CONFIGS = getSealServerConfigsFromEnv(); const SEAL_THRESHOLD = getSealThresholdFromEnv(SEAL_SERVER_CONFIGS); @@ -70,7 +71,7 @@ const WALRUS_UPLOAD_RELAY_URL = process.env.WALRUS_UPLOAD_RELAY_URL || ( const DEFAULT_WALRUS_EPOCHS = SUI_NETWORK === "testnet" ? 50 : 3; const suiClient = new SuiJsonRpcClient({ - url: getJsonRpcFullnodeUrl(SUI_NETWORK), + url: SUI_RPC_URL, network: SUI_NETWORK, });