Skip to content

Commit fd36a49

Browse files
committed
SDK: pipelineStore stress test, plot script, and Versi sweep
pipeline-stress.ts is a standalone benchmark harness. It authorises a fresh derived submitter, generates the requested payloads, runs pipelineStore, prints the latency summary (p50 / p90 / p99 plus min / max / mean), and writes the full PipelineResult plus raw latency arrays to a JSON file via --output-json. plot-pipeline-results.py reads one or more of those JSON files and produces a 2-panel chart: finalised throughput on the left (tx/s and KB/s) and stacked finalisation latency on the right (each colour band is the additional latency from p50 -> p90 -> p99). --latency inclusion switches the right panel to broadcast -> best-block. The committed pipeline-results.png is a payload sweep against Versi (4 RPCs, single account): | items | size | tx/s | KB/s | final p50 | final p99 | |------:|-------|------:|-----:|----------:|----------:| | 2000 | 1 KB | 30.44 | 30 | 31.0s | 44.7s | | 1000 | 10 KB | 25.69 | 257 | 31.5s | 33.8s | | 500 | 100 KB| 6.79 | 679 | 39.7s | 48.2s | | 500 | 128 KB| 4.92 | 630 | 40.9s | 70.9s | | 250 | 256 KB| 2.60 | 665 | 33.6s | 70.3s | | 150 | 512 KB| 1.48 | 756 | 43.9s | 74.1s | | 100 | 1 MB | 0.67 | 683 | 69.2s | 89.7s | Bandwidth peaks around 512 KB (~756 KB/s) which is where length and count budgets balance. Below 100 KB the count cap (512 tx/block) idles the length budget; above 256 KB length pressure starts trimming the batch. Result JSON files live in sdk/typescript/test/stress/results/ and are gitignored.
1 parent 2fa3160 commit fd36a49

4 files changed

Lines changed: 464 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
results/
82.8 KB
Loading
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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

Comments
 (0)