|
| 1 | +#!/usr/bin/env npx tsx |
| 2 | +// Pipeline stress test — uses the SDK's pipelineStore against Versi/dev chain. |
| 3 | +// |
| 4 | +// Usage: |
| 5 | +// npx tsx test/stress/pipeline-stress.ts \ |
| 6 | +// --ws-url wss://bc-3000-rpc-node-0.parity-versi.parity.io,wss://bc-3000-rpc-node-1.parity-versi.parity.io,wss://bc-3000-rpc-node-2.parity-versi.parity.io,wss://bc-3000-rpc-node-3.parity-versi.parity.io \ |
| 7 | +// --items 100 --payload-size 1024 --authorizer-seed "//Alice" |
| 8 | + |
| 9 | +import { mkdirSync, writeFileSync } from "node:fs" |
| 10 | +import { dirname, resolve as resolvePath } from "node:path" |
| 11 | +import { parseArgs } from "node:util" |
| 12 | +import { createClient as createSubstrateClient } from "@polkadot-api/substrate-client" |
| 13 | +import { sr25519CreateDerive } from "@polkadot-labs/hdkd" |
| 14 | +import { DEV_MINI_SECRET, ss58Address } from "@polkadot-labs/hdkd-helpers" |
| 15 | +import { createClient as createPolkadotClient } from "polkadot-api" |
| 16 | +import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat" |
| 17 | +import { getPolkadotSigner } from "polkadot-api/signer" |
| 18 | +import { getWsProvider } from "polkadot-api/ws-provider/node" |
| 19 | +import type { BulletinTypedApi } from "../../src/async-client.js" |
| 20 | +import { |
| 21 | + type BlockLimits, |
| 22 | + type LatencyStats, |
| 23 | + type PipelineStats, |
| 24 | + pipelineStore, |
| 25 | +} from "../../src/pipeline.js" |
| 26 | + |
| 27 | +// --------------------------------------------------------------------------- |
| 28 | +// CLI args |
| 29 | +// --------------------------------------------------------------------------- |
| 30 | + |
| 31 | +const { values } = parseArgs({ |
| 32 | + options: { |
| 33 | + "ws-url": { type: "string", default: "ws://127.0.0.1:9944" }, |
| 34 | + items: { type: "string", default: "20" }, |
| 35 | + "payload-size": { type: "string", default: "1024" }, |
| 36 | + "authorizer-seed": { type: "string", default: "//Alice" }, |
| 37 | + "submitter-seed": { type: "string" }, |
| 38 | + "authorize-budget-mb": { type: "string", default: "50" }, |
| 39 | + "skip-authorize": { type: "boolean", default: false }, |
| 40 | + "output-json": { type: "string" }, |
| 41 | + help: { type: "boolean", default: false }, |
| 42 | + }, |
| 43 | + strict: true, |
| 44 | +}) |
| 45 | + |
| 46 | +if (values.help) { |
| 47 | + console.log(` |
| 48 | +Pipeline stress test for Bulletin Chain SDK |
| 49 | +
|
| 50 | +Options: |
| 51 | + --ws-url <urls> Comma-separated RPC WebSocket URLs |
| 52 | + --items <n> Number of store transactions (default: 20) |
| 53 | + --payload-size <bytes> Payload size per item in bytes (default: 1024) |
| 54 | + --authorizer-seed <seed> Authorizer key URI (default: //Alice) |
| 55 | + --submitter-seed <seed> Submitter key URI (default: same as authorizer) |
| 56 | + --authorize-budget-mb <n> Authorization budget in MB (default: 50) |
| 57 | + --output-json <path> Write full result JSON to this path |
| 58 | +`) |
| 59 | + process.exit(0) |
| 60 | +} |
| 61 | + |
| 62 | +const wsUrls = (values["ws-url"] ?? "ws://127.0.0.1:9944") |
| 63 | + .split(",") |
| 64 | + .map((s) => s.trim()) |
| 65 | + .filter(Boolean) |
| 66 | +const numItems = parseInt(values.items ?? "20", 10) |
| 67 | +const payloadSize = parseInt(values["payload-size"] ?? "1024", 10) |
| 68 | +const authorizerSeed = values["authorizer-seed"] ?? "//Alice" |
| 69 | +const submitterSeed = values["submitter-seed"] ?? authorizerSeed |
| 70 | +const authBudgetMb = parseInt(values["authorize-budget-mb"] ?? "50", 10) |
| 71 | + |
| 72 | +// --------------------------------------------------------------------------- |
| 73 | +// Helpers |
| 74 | +// --------------------------------------------------------------------------- |
| 75 | + |
| 76 | +function createSigner(seed: string) { |
| 77 | + const derive = sr25519CreateDerive(DEV_MINI_SECRET) |
| 78 | + const keyPair = derive(seed) |
| 79 | + return { |
| 80 | + signer: getPolkadotSigner(keyPair.publicKey, "Sr25519", keyPair.sign), |
| 81 | + rawSign: keyPair.sign as (message: Uint8Array) => Promise<Uint8Array>, |
| 82 | + address: ss58Address(keyPair.publicKey, 42), |
| 83 | + publicKey: keyPair.publicKey, |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +function generatePayloads(count: number, size: number): Uint8Array[] { |
| 88 | + const items: Uint8Array[] = [] |
| 89 | + for (let i = 0; i < count; i++) { |
| 90 | + const buf = new Uint8Array(size) |
| 91 | + // Fill with deterministic but unique data |
| 92 | + const header = new TextEncoder().encode(`stress-item-${i}-`) |
| 93 | + buf.set(header) |
| 94 | + // Fill rest with pseudo-random bytes (seeded by index) |
| 95 | + for (let j = header.length; j < size; j++) { |
| 96 | + buf[j] = ((i * 31 + j * 7) ^ 0xa5) & 0xff |
| 97 | + } |
| 98 | + items.push(buf) |
| 99 | + } |
| 100 | + return items |
| 101 | +} |
| 102 | + |
| 103 | +function formatBytes(bytes: number): string { |
| 104 | + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB` |
| 105 | + if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KB` |
| 106 | + return `${bytes} B` |
| 107 | +} |
| 108 | + |
| 109 | +function formatDuration(ms: number): string { |
| 110 | + const sec = ms / 1000 |
| 111 | + if (sec >= 60) return `${Math.floor(sec / 60)}m${(sec % 60).toFixed(1)}s` |
| 112 | + return `${sec.toFixed(1)}s` |
| 113 | +} |
| 114 | + |
| 115 | +// --------------------------------------------------------------------------- |
| 116 | +// Block limits for Bulletin Chain |
| 117 | +// --------------------------------------------------------------------------- |
| 118 | + |
| 119 | +// Values from runtimes/bulletin-westend/src/lib.rs and pallet benchmarks. |
| 120 | +// The Rust stress-test queries them from the runtime (ChainLimits::query); |
| 121 | +// a future version should read them from storage dynamically. |
| 122 | +const BLOCK_LIMITS: BlockLimits = { |
| 123 | + maxNormalWeight: 1_500_000_000_000n, // 75% of 2s weight budget |
| 124 | + normalBlockLength: 9_437_184, // 90% of 10 MiB MAX_BLOCK_LENGTH |
| 125 | + maxBlockTransactions: 512, // TransactionStorage::MaxBlockTransactions |
| 126 | + storeWeightBase: 35_489_000n, // from pallet benchmark weights.rs |
| 127 | + storeWeightPerByte: 6_912n, // from pallet benchmark weights.rs |
| 128 | + extrinsicOverhead: 110, // signature + address + extensions |
| 129 | +} |
| 130 | + |
| 131 | +// --------------------------------------------------------------------------- |
| 132 | +// Main |
| 133 | +// --------------------------------------------------------------------------- |
| 134 | + |
| 135 | +async function main() { |
| 136 | + console.log("=== Pipeline Stress Test ===") |
| 137 | + console.log(` RPC endpoints: ${wsUrls.length}`) |
| 138 | + for (const url of wsUrls) console.log(` - ${url}`) |
| 139 | + console.log(` Items: ${numItems}`) |
| 140 | + console.log(` Payload size: ${formatBytes(payloadSize)}`) |
| 141 | + console.log(` Total data: ${formatBytes(numItems * payloadSize)}`) |
| 142 | + console.log() |
| 143 | + |
| 144 | + // Create accounts |
| 145 | + const authorizer = createSigner(authorizerSeed) |
| 146 | + const submitter = |
| 147 | + submitterSeed === authorizerSeed ? authorizer : createSigner(submitterSeed) |
| 148 | + |
| 149 | + console.log(` Authorizer: ${authorizer.address} (${authorizerSeed})`) |
| 150 | + console.log(` Submitter: ${submitter.address} (${submitterSeed})`) |
| 151 | + console.log() |
| 152 | + |
| 153 | + // Connect PAPI client for authorization |
| 154 | + console.log("Connecting to chain...") |
| 155 | + const papiClient = createPolkadotClient( |
| 156 | + withPolkadotSdkCompat(getWsProvider(wsUrls[0]!)), |
| 157 | + ) |
| 158 | + const api = papiClient.getUnsafeApi() as unknown as BulletinTypedApi |
| 159 | + |
| 160 | + // Authorize submitter account (use fire-and-forget to avoid signAndSubmit hang) |
| 161 | + if (values["skip-authorize"]) { |
| 162 | + console.log("Skipping authorization (--skip-authorize)") |
| 163 | + } else { |
| 164 | + // Budget must cover total payload; use max of user-specified and actual data |
| 165 | + const dataSizeMb = Math.ceil((numItems * payloadSize) / (1024 * 1024)) + 10 // +10MB headroom |
| 166 | + const effectiveMb = Math.max(authBudgetMb, dataSizeMb) |
| 167 | + const budgetBytes = BigInt(effectiveMb) * 1024n * 1024n |
| 168 | + const budgetTxs = numItems + 100 // some headroom |
| 169 | + console.log( |
| 170 | + `Authorizing ${submitter.address} for ${budgetTxs} txs / ${formatBytes(Number(budgetBytes))}...`, |
| 171 | + ) |
| 172 | + try { |
| 173 | + const authTx = api.tx.TransactionStorage.authorize_account({ |
| 174 | + who: submitter.address, |
| 175 | + transactions: budgetTxs, |
| 176 | + bytes: budgetBytes, |
| 177 | + }) |
| 178 | + const hex = await (authTx as any).sign(authorizer.signer) |
| 179 | + const rawClient = createSubstrateClient( |
| 180 | + withPolkadotSdkCompat(getWsProvider(wsUrls[0]!)), |
| 181 | + ) |
| 182 | + await rawClient.request("author_submitExtrinsic", [hex]) |
| 183 | + rawClient.destroy() |
| 184 | + // Wait a block for inclusion |
| 185 | + await new Promise((r) => setTimeout(r, 4000)) |
| 186 | + console.log("Authorization submitted") |
| 187 | + } catch (e: any) { |
| 188 | + // May already be authorized — continue |
| 189 | + console.log(`Authorization: ${e.message?.slice(0, 80) ?? e}`) |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + // Generate payloads |
| 194 | + console.log( |
| 195 | + `Generating ${numItems} payloads of ${formatBytes(payloadSize)}...`, |
| 196 | + ) |
| 197 | + const items = generatePayloads(numItems, payloadSize) |
| 198 | + console.log("Payloads ready") |
| 199 | + console.log() |
| 200 | + |
| 201 | + // Run pipeline |
| 202 | + console.log("Starting pipeline...") |
| 203 | + const _startTime = Date.now() |
| 204 | + |
| 205 | + const result = await pipelineStore(api, submitter.signer, items, { |
| 206 | + wsUrls, |
| 207 | + createProvider: (url: string) => withPolkadotSdkCompat(getWsProvider(url)), |
| 208 | + blockLimits: BLOCK_LIMITS, |
| 209 | + rawSign: submitter.rawSign, |
| 210 | + signingType: "Sr25519", |
| 211 | + onProgress: (stats: PipelineStats) => { |
| 212 | + const pct = |
| 213 | + stats.totalItems > 0 |
| 214 | + ? ((stats.finalized / stats.totalItems) * 100).toFixed(1) |
| 215 | + : "0" |
| 216 | + const elapsed = formatDuration(stats.elapsedMs) |
| 217 | + console.log( |
| 218 | + ` [${elapsed}] wave ${stats.waves}: ` + |
| 219 | + `${stats.confirmed} best, ${stats.finalized}/${stats.totalItems} fin (${pct}%), ` + |
| 220 | + `${stats.txsBroadcast} broadcast, ${stats.broadcastErrors} errs, ` + |
| 221 | + `${stats.txPerSec.toFixed(2)} tx/s, ${formatBytes(stats.throughputBytesPerSec)}/s`, |
| 222 | + ) |
| 223 | + }, |
| 224 | + }) |
| 225 | + |
| 226 | + // Print results |
| 227 | + console.log() |
| 228 | + console.log("=== Results ===") |
| 229 | + console.log(` Duration: ${formatDuration(result.durationMs)}`) |
| 230 | + console.log(` Waves: ${result.waves}`) |
| 231 | + console.log( |
| 232 | + ` Broadcast: ${result.txsBroadcast} (${result.broadcastErrors} errors)`, |
| 233 | + ) |
| 234 | + console.log(` Confirmed: ${result.confirmed} (best)`) |
| 235 | + console.log(` Finalized: ${result.finalized} / ${result.totalItems}`) |
| 236 | + console.log(` Throughput: ${result.txPerSec.toFixed(4)} tx/s`) |
| 237 | + console.log(` Data rate: ${formatBytes(result.throughputBytesPerSec)}/s`) |
| 238 | + console.log(` Total data: ${formatBytes(result.totalBytes)}`) |
| 239 | + console.log( |
| 240 | + ` Nonce range: ${result.startNonce} -> ${result.expectedFinalNonce}`, |
| 241 | + ) |
| 242 | + console.log() |
| 243 | + console.log("=== Latency (per-item, broadcast → block) ===") |
| 244 | + printLatency("Inclusion (best) ", result.inclusionLatency) |
| 245 | + printLatency("Finalization ", result.finalizationLatency) |
| 246 | + console.log() |
| 247 | + |
| 248 | + // Write JSON result file if requested |
| 249 | + const outputPath = values["output-json"] |
| 250 | + if (outputPath) { |
| 251 | + const absPath = resolvePath(outputPath) |
| 252 | + mkdirSync(dirname(absPath), { recursive: true }) |
| 253 | + const payload = { |
| 254 | + config: { |
| 255 | + wsUrls, |
| 256 | + items: numItems, |
| 257 | + payloadSize, |
| 258 | + authorizerSeed, |
| 259 | + submitterSeed, |
| 260 | + authBudgetMb, |
| 261 | + }, |
| 262 | + result, |
| 263 | + generatedAt: new Date().toISOString(), |
| 264 | + } |
| 265 | + writeFileSync( |
| 266 | + absPath, |
| 267 | + JSON.stringify( |
| 268 | + payload, |
| 269 | + (_k, v) => (typeof v === "bigint" ? v.toString() : v), |
| 270 | + 2, |
| 271 | + ), |
| 272 | + ) |
| 273 | + console.log(`Wrote results JSON to ${absPath}`) |
| 274 | + } |
| 275 | + |
| 276 | + papiClient.destroy() |
| 277 | + process.exit(result.finalized === result.totalItems ? 0 : 1) |
| 278 | +} |
| 279 | + |
| 280 | +function printLatency(label: string, lat: LatencyStats | null): void { |
| 281 | + if (!lat) { |
| 282 | + console.log(` ${label}: n/a (no samples)`) |
| 283 | + return |
| 284 | + } |
| 285 | + console.log( |
| 286 | + ` ${label}: n=${lat.count} ` + |
| 287 | + `min=${lat.min.toFixed(0)}ms ` + |
| 288 | + `p50=${lat.p50.toFixed(0)}ms ` + |
| 289 | + `p90=${lat.p90.toFixed(0)}ms ` + |
| 290 | + `p99=${lat.p99.toFixed(0)}ms ` + |
| 291 | + `max=${lat.max.toFixed(0)}ms ` + |
| 292 | + `mean=${lat.mean.toFixed(0)}ms`, |
| 293 | + ) |
| 294 | +} |
| 295 | + |
| 296 | +main().catch((e) => { |
| 297 | + console.error("Fatal:", e) |
| 298 | + process.exit(1) |
| 299 | +}) |
0 commit comments