Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ packages/wallet-service/.warmup
.yarn/
.env.*
*.tsbuildinfo
packages/daemon/bench-results-*.json
1 change: 1 addition & 0 deletions packages/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dev:migrate": "cd ../.. && DB_ENDPOINT=localhost DB_NAME=wallet_service DB_USER=hathor DB_PASS=hathor DB_PORT=3306 npx sequelize-cli db:migrate && DB_ENDPOINT=localhost DB_NAME=wallet_service DB_USER=hathor DB_PASS=hathor DB_PORT=3306 npx sequelize-cli db:seed:all",
"dev:fetch-ids": "FULLNODE_WEBSOCKET_BASEURL=${FULLNODE_HOST} node ../../scripts/fetch-fullnode-ids.js",
"replay-balance": "yarn dlx ts-node src/scripts/replay-balance.ts",
"bench:sync": "yarn dlx ts-node src/scripts/bench-sync.ts",
"watch": "tsc -w",
"test_images_up": "docker compose -f ./__tests__/integration/scripts/docker-compose.yml up -d",
"test_images_down": "docker compose -f ./__tests__/integration/scripts/docker-compose.yml down",
Expand Down
314 changes: 314 additions & 0 deletions packages/daemon/src/scripts/bench-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
/**
* Copyright (c) Hathor Labs and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Sync benchmark harness.
*
* Runs the daemon's SyncMachine against an integration-test event simulator,
* captures per-span timings via an in-memory OTel exporter, and writes
* aggregated stats to JSON. Produces numbers that can be compared across
* branches to reason about per-event sync performance.
*
* Prerequisites (run from packages/daemon):
* yarn test_images_up # starts MySQL + all simulator containers
* yarn test_images_wait_for_db
* yarn test_images_migrate
* yarn test_images_wait_for_ws
*
* Usage:
* yarn bench:sync --scenario UNVOIDED --runs 5 --warmup 1 --label master
* yarn bench:sync --scenario VOIDED_TOKEN_AUTHORITY --runs 10 --out bench.json
*
* Scenarios mirror __tests__/integration/config.ts. Current scenarios all top
* out at <70 events, which is too few for stable per-event timing — fullnode
* connect / MySQL pool warmup / JIT noise dominate. Add a larger scenario
* before drawing conclusions from absolute numbers; the harness is
* otherwise correct.
*/

// Disable the daemon's built-in OTLP exporter — we install our own
// in-memory exporter below. Must be set before any daemon import.
process.env.OTEL_SDK_DISABLED = 'true';

import { writeFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';

// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------

interface Opts {
scenario: string;
runs: number;
warmup: number;
label: string;
out: string;
}

function parseArgs(): Opts {
const args = process.argv.slice(2);
const opts: Opts = {
scenario: 'UNVOIDED',
runs: 5,
warmup: 1,
label: 'local',
out: '',
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
const v = args[i + 1];
switch (a) {
case '--scenario': opts.scenario = v; i++; break;
case '--runs': opts.runs = parseInt(v, 10); i++; break;
case '--warmup': opts.warmup = parseInt(v, 10); i++; break;
case '--label': opts.label = v; i++; break;
case '--out': opts.out = v; i++; break;
case '--help':
case '-h':
printHelp();
process.exit(0);
break;
default:
console.error(`Unknown arg: ${a}`);
printHelp();
process.exit(1);
}
}
if (!opts.out) opts.out = `bench-results-${opts.label}.json`;
return opts;
}
Comment on lines +61 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate CLI argument values (NaN / missing).

Two small gaps in the arg parser:

  1. parseInt(v, 10) on lines 66-67 silently returns NaN for non-numeric input. --runs abc results in for (let i = 0; i < NaN; i++) executing zero iterations, yet the script still writes an "empty" results file and exits 0, which is misleading for a benchmarking tool.
  2. If a flag is the last argument (e.g. yarn bench:sync --scenario), v is undefined and gets assigned to opts.scenario/opts.label/opts.out without detection. --scenario undefined then falls through to the SCENARIOS[opts.scenario] check, but flags like --label would silently produce bench-results-undefined.json.
🛠️ Suggested fix
   for (let i = 0; i < args.length; i++) {
     const a = args[i];
     const v = args[i + 1];
+    const requireValue = () => {
+      if (v === undefined) {
+        console.error(`Missing value for ${a}`);
+        process.exit(1);
+      }
+      return v;
+    };
+    const requireInt = () => {
+      const n = parseInt(requireValue(), 10);
+      if (!Number.isFinite(n) || n < 0) {
+        console.error(`Invalid integer for ${a}: ${v}`);
+        process.exit(1);
+      }
+      return n;
+    };
     switch (a) {
-      case '--scenario': opts.scenario = v; i++; break;
-      case '--runs': opts.runs = parseInt(v, 10); i++; break;
-      case '--warmup': opts.warmup = parseInt(v, 10); i++; break;
-      case '--label': opts.label = v; i++; break;
-      case '--out': opts.out = v; i++; break;
+      case '--scenario': opts.scenario = requireValue(); i++; break;
+      case '--runs':     opts.runs     = requireInt();   i++; break;
+      case '--warmup':   opts.warmup   = requireInt();   i++; break;
+      case '--label':    opts.label    = requireValue(); i++; break;
+      case '--out':      opts.out      = requireValue(); i++; break;

Also consider rejecting opts.runs === 0 (no measured runs) explicitly so the results file isn't produced with n: 0 / null summaries.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/daemon/src/scripts/bench-sync.ts` around lines 61 - 83, The arg
parser currently assigns raw v to opts and uses parseInt(v,10) without
validation; update the loop that handles args to (1) check that v is not
undefined before assigning to opts.scenario / opts.label / opts.out and if
missing call printHelp() and exit(1) with an error message, (2) parse numeric
flags via const n = Number(v) or parseInt(v,10) and validate Number.isInteger(n)
&& n > 0 for opts.runs and opts.warmup (reject NaN, non-integer or <=0, log an
error, call printHelp(), exit(1)), and (3) explicitly reject opts.runs === 0
(treat as invalid) so the script does not produce empty results; adjust
references to opts, parseInt/Number, printHelp() and the switch handling in the
same function accordingly.


function printHelp() {
console.log(`Usage: bench-sync [options]

Options:
--scenario <name> Simulator scenario (default: UNVOIDED)
--runs <n> Measured runs (default: 5)
--warmup <n> Discarded warmup runs (default: 1)
--label <str> Label for the output file and opts block (default: local)
--out <path> Output JSON path (default: bench-results-<label>.json)

Available scenarios: ${Object.keys(SCENARIOS).join(', ')}
`);
}

// Keep in sync with __tests__/integration/config.ts
const SCENARIOS: Record<string, { port: number; lastEvent: number }> = {
UNVOIDED: { port: 8081, lastEvent: 39 },
REORG: { port: 8082, lastEvent: 18 },
SINGLE_CHAIN_BLOCKS_AND_TRANSACTIONS: { port: 8083, lastEvent: 37 },
INVALID_MEMPOOL_TRANSACTION: { port: 8085, lastEvent: 40 },
CUSTOM_SCRIPT: { port: 8086, lastEvent: 37 },
EMPTY_SCRIPT: { port: 8087, lastEvent: 37 },
NC_EVENTS: { port: 8088, lastEvent: 36 },
TRANSACTION_VOIDING_CHAIN: { port: 8089, lastEvent: 52 },
VOIDED_TOKEN_AUTHORITY: { port: 8090, lastEvent: 66 },
SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION: { port: 8091, lastEvent: 50 },
SINGLE_VOIDED_REGULAR_TRANSACTION: { port: 8092, lastEvent: 60 },
TOKEN_CREATION: { port: 8093, lastEvent: 45 },
};

const opts = parseArgs();
const scenario = SCENARIOS[opts.scenario];
if (!scenario) {
console.error(`Unknown scenario: ${opts.scenario}`);
console.error(`Available: ${Object.keys(SCENARIOS).join(', ')}`);
process.exit(1);
}

// ---------------------------------------------------------------------------
// Environment setup — must happen before any daemon import (config is
// env-driven at module load time).
// ---------------------------------------------------------------------------

Object.assign(process.env, {
NETWORK: 'testnet',
SERVICE_NAME: 'daemon-bench',
CONSOLE_LEVEL: 'error',
TX_CACHE_SIZE: '100',
BLOCK_REWARD_LOCK: '300',
FULLNODE_PEER_ID: 'simulator_peer_id',
STREAM_ID: 'simulator_stream_id',
FULLNODE_NETWORK: 'unittests',
FULLNODE_HOST: `127.0.0.1:${scenario.port}`,
USE_SSL: 'false',
DB_ENDPOINT: '127.0.0.1',
DB_NAME: 'hathor',
DB_USER: 'root',
DB_PASS: 'hathor',
DB_PORT: '3380',
ACK_TIMEOUT_MS: '300000',
IDLE_EVENT_TIMEOUT_MS: String(5 * 60 * 1000),
STUCK_PROCESSING_TIMEOUT_MS: String(5 * 60 * 1000),
RECONNECTION_STORM_THRESHOLD: '10',
RECONNECTION_STORM_WINDOW_MS: String(5 * 60 * 1000),
// checkEnvVariables() requires these but they are never actually called
// because the AWS/lambda/SQS paths are stubbed below.
NEW_TX_SQS: 'bench-stub',
PUSH_NOTIFICATION_ENABLED: 'false',
WALLET_SERVICE_LAMBDA_ENDPOINT: 'bench-stub',
STAGE: 'local',
ACCOUNT_ID: '000000000000',
ALERT_MANAGER_TOPIC: 'bench-stub',
ALERT_MANAGER_REGION: 'us-east-1',
APPLICATION_NAME: 'bench',
});

// ---------------------------------------------------------------------------
// In-memory OTel capture. Using BasicTracerProvider directly (instead of
// NodeSDK) because .register() is synchronous — the global tracer provider
// is guaranteed to be in place before any `trace.getTracer()` call from the
// daemon modules imported below resolves its first span.
// ---------------------------------------------------------------------------

import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';

const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
provider.register();

// ---------------------------------------------------------------------------
// Stub external services that the daemon would otherwise try to reach
// (SQS, push-notification lambda, fullnode HTTP API). Mirrors the jest.mock
// calls in __tests__/integration/balances.test.ts.
// ---------------------------------------------------------------------------

/* eslint-disable @typescript-eslint/no-explicit-any */
import * as awsUtils from '../utils/aws';
import * as services from '../services';

(awsUtils as any).sendRealtimeTx = async () => undefined;
(awsUtils as any).invokeOnTxPushNotificationRequestedLambda = async () => undefined;
(services as any).checkForMissedEvents = async () => ({ hasNewEvents: false, events: [] });
(services as any).fetchMinRewardBlocks = async () => 300;
/* eslint-enable @typescript-eslint/no-explicit-any */

import { interpret } from 'xstate';
import { SyncMachine } from '../machines';
import { getDbConnection } from '../db';
import { cleanDatabase, transitionUntilEvent } from '../../__tests__/integration/utils';

// ---------------------------------------------------------------------------
// Run loop
// ---------------------------------------------------------------------------

interface RunResult {
totalMs: number;
/** span name → list of individual span durations observed during the run */
spans: Map<string, number[]>;
}

async function runOnce(mysql: Awaited<ReturnType<typeof getDbConnection>>): Promise<RunResult> {
await cleanDatabase(mysql);
exporter.reset();

const machine = interpret(SyncMachine);
const start = performance.now();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await transitionUntilEvent(mysql as any, machine as any, scenario.lastEvent);
const totalMs = performance.now() - start;

const spans = new Map<string, number[]>();
for (const span of exporter.getFinishedSpans()) {
const [s, ns] = span.duration;
const ms = s * 1000 + ns / 1e6;
const list = spans.get(span.name) ?? [];
list.push(ms);
spans.set(span.name, list);
}
return { totalMs, spans };
}

interface Summary {
n: number;
min: number;
p50: number;
p95: number;
max: number;
mean: number;
}

function summarize(values: number[]): Summary | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
return {
n: values.length,
min: sorted[0],
p50: pick(0.5),
p95: pick(0.95),
max: sorted[sorted.length - 1],
mean: values.reduce((a, b) => a + b, 0) / values.length,
};
}
Comment on lines +241 to +253
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Percentile formula uses Math.floor(q * n) — off-by-one at the top end.

For n = 20 and q = 0.95, Math.floor(0.95 * 20) = 19, which picks sorted[19] (the max). That's not a p95, that's the max. More standard is the nearest-rank method using Math.ceil(q * n) - 1 clamped to [0, n-1], i.e. sorted[Math.min(n - 1, Math.ceil(q * n) - 1)].

For the current typical opts.runs = 5 this produces pick(0.95) = sorted[4] = max, which is still the max — so p95 is effectively an alias of max for small samples either way. Not a correctness bug for today's usage, but worth fixing now so the output is meaningful once larger scenarios land (which the file header explicitly anticipates).

🛠️ Suggested fix
-  const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
+  const pick = (q: number) =>
+    sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function summarize(values: number[]): Summary | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
return {
n: values.length,
min: sorted[0],
p50: pick(0.5),
p95: pick(0.95),
max: sorted[sorted.length - 1],
mean: values.reduce((a, b) => a + b, 0) / values.length,
};
}
function summarize(values: number[]): Summary | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const pick = (q: number) =>
sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
return {
n: values.length,
min: sorted[0],
p50: pick(0.5),
p95: pick(0.95),
max: sorted[sorted.length - 1],
mean: values.reduce((a, b) => a + b, 0) / values.length,
};
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/daemon/src/scripts/bench-sync.ts` around lines 241 - 253, The
percentile picker in summarize currently uses Math.floor(q * sorted.length)
which makes high quantiles map to the max (off-by-one); change the pick
implementation inside summarize to use the nearest-rank formula: index =
Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
return sorted[index] for p50/p95 so percentiles (pick) are computed correctly
(update references to pick used for p50 and p95).


async function main() {
const mysql = await getDbConnection();

const totalMsRuns: number[] = [];
// Per span name: one entry per measured run, each entry is the sum of all
// durations of that span within the run. Summing makes runs comparable even
// when the number of span occurrences differs (e.g., voided vs un-voided
// paths fire different spans).
const perSpanRuns = new Map<string, number[]>();

const total = opts.warmup + opts.runs;
for (let i = 0; i < total; i++) {
const phase = i < opts.warmup ? `warmup ${i + 1}/${opts.warmup}` : `run ${i - opts.warmup + 1}/${opts.runs}`;
process.stderr.write(`[${phase}] `);
const result = await runOnce(mysql);
process.stderr.write(`totalMs=${result.totalMs.toFixed(2)}\n`);

if (i < opts.warmup) continue;

totalMsRuns.push(result.totalMs);
for (const [name, durations] of result.spans) {
const sum = durations.reduce((a, b) => a + b, 0);
const arr = perSpanRuns.get(name) ?? [];
arr.push(sum);
perSpanRuns.set(name, arr);
}
}

const output = {
opts: {
scenario: opts.scenario,
port: scenario.port,
lastEvent: scenario.lastEvent,
runs: opts.runs,
warmup: opts.warmup,
label: opts.label,
},
totalMs: summarize(totalMsRuns),
perSpan: Object.fromEntries(
[...perSpanRuns.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, runs]) => [name, summarize(runs)])
),
};

writeFileSync(opts.out, JSON.stringify(output, null, 2));
console.log(`Wrote ${opts.out}`);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ('release' in (mysql as any)) (mysql as any).release();
await provider.shutdown();
}

main().then(
() => process.exit(0),
(err) => {
console.error(err);
process.exit(1);
},
Comment on lines +303 to +313
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep --pattern 'export $_ getDbConnection = $$$'
rg -nP --type=ts 'getDbConnection' -C3 packages/daemon/src/db

Repository: HathorNetwork/hathor-wallet-service

Length of output: 483


🏁 Script executed:

#!/bin/bash
# Check the type definition of MysqlConnection
rg -nP --type=ts 'type MysqlConnection|interface MysqlConnection' packages/daemon/src/db

# Look at the full getDbConnection implementation
cat -n packages/daemon/src/db/index.ts | head -80 | tail -40

# Check the actual bench-sync.ts code around lines 303-313
cat -n packages/daemon/src/scripts/bench-sync.ts | sed -n '290,320p'

Repository: HathorNetwork/hathor-wallet-service

Length of output: 1889


🏁 Script executed:

#!/bin/bash
# Find MysqlConnection type definition
rg -nP --type=ts 'MysqlConnection' packages/daemon/src/types

# Check the main() function in full
cat -n packages/daemon/src/scripts/bench-sync.ts | sed -n '250,315p'

# Check imports to understand which mysql library is being used
head -30 packages/daemon/src/scripts/bench-sync.ts

Repository: HathorNetwork/hathor-wallet-service

Length of output: 3705


🏁 Script executed:

#!/bin/bash
# Find the imports at the top of bench-sync.ts to see what mysql library is used
head -50 packages/daemon/src/scripts/bench-sync.ts

# Check the type imports/definitions
rg -nP --type=ts 'import.*MysqlConnection|type MysqlConnection|interface MysqlConnection' packages/daemon/src

Repository: HathorNetwork/hathor-wallet-service

Length of output: 2194


🏁 Script executed:

#!/bin/bash
# Check runOnce function to see if it can throw
rg -nP --type=ts 'async function runOnce|runOnce\(' packages/daemon/src/scripts/bench-sync.ts -A 2

# Look for error handling patterns in the codebase for similar scripts
rg -nP --type=ts 'try.*finally|\.then\(' packages/daemon/src/scripts/ -A 3

# Check if there are other places where provider.shutdown() is called
rg -nP --type=ts 'provider\.shutdown' packages/daemon/src

Repository: HathorNetwork/hathor-wallet-service

Length of output: 696


🏁 Script executed:

#!/bin/bash
# Verify what methods the mysql2 connection object actually has
# Let's check the package.json to see mysql2 version
rg -nP '"mysql2"' packages/daemon/package.json

# Check if there are other examples of release() or end() being called
rg -nP '\.release\(\)|\.end\(\)' packages/daemon/src --type=ts -B 2

Repository: HathorNetwork/hathor-wallet-service

Length of output: 2225


🏁 Script executed:

#!/bin/bash
# Check how mysql connection is used in runOnce and cleanDatabase
rg -nP --type=ts 'async function cleanDatabase|cleanDatabase\(' packages/daemon/src/scripts/bench-sync.ts -A 5

# Check what methods Connection from mysql2 actually exposes by looking at how it's used
rg -nP --type=ts 'mysql\.(destroy|release|end)\(' packages/daemon/src --type=ts -B 2

Repository: HathorNetwork/hathor-wallet-service

Length of output: 2417


🏁 Script executed:

#!/bin/bash
# Find where provider is instantiated/initialized
rg -nP --type=ts 'const provider|provider =' packages/daemon/src/scripts/bench-sync.ts -B 2 -A 2

# Check what provider.shutdown() does and whether it needs error handling
rg -nP --type=ts 'provider\.shutdown|\.shutdown\(\)' packages/daemon/src/scripts/bench-sync.ts -B 5

Repository: HathorNetwork/hathor-wallet-service

Length of output: 522


🏁 Script executed:

#!/bin/bash
# Double-check mysql2/promise Connection API to be thorough
# Look at how release() is actually used in tests or elsewhere
rg -nP --type=ts 'release\(\)' packages/daemon --type=ts -B 3 -A 1

# Check if there are any tests that verify connection cleanup
fd -t f '.*test.*' packages/daemon/src/scripts/ --exec basename {}

Repository: HathorNetwork/hathor-wallet-service

Length of output: 1994


Wrap main() body in try/finally to ensure cleanup on errors.

If runOnce throws mid-benchmark, the rejection handler (line 310) exits without calling cleanup: mysql.release() and provider.shutdown() are skipped, potentially leaving the DB connection unreleased. The try/finally pattern ensures cleanup runs regardless of whether the function succeeds or errors.

🛠️ Suggested fix
 async function main() {
   const mysql = await getDbConnection();
-
-  const totalMsRuns: number[] = [];
+  try {
+    const totalMsRuns: number[] = [];
     // ... existing body ...
-  writeFileSync(opts.out, JSON.stringify(output, null, 2));
-  console.log(`Wrote ${opts.out}`);
-
-  // eslint-disable-next-line `@typescript-eslint/no-explicit-any`
-  if ('release' in (mysql as any)) (mysql as any).release();
-  await provider.shutdown();
+    writeFileSync(opts.out, JSON.stringify(output, null, 2));
+    console.log(`Wrote ${opts.out}`);
+  } finally {
+    // eslint-disable-next-line `@typescript-eslint/no-explicit-any`
+    if ('release' in (mysql as any)) (mysql as any).release();
+    await provider.shutdown();
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/daemon/src/scripts/bench-sync.ts` around lines 303 - 313, Wrap the
body of main() in a try/finally so cleanup always runs: ensure runOnce (and any
other awaited work) is executed inside the try block and move the existing
cleanup calls — the mysql release call ((mysql as any).release()) and await
provider.shutdown() — into the finally block so they execute whether runOnce
succeeds or throws; update main() to rethrow or allow errors to propagate so the
existing .then rejection handler still receives the error.

);
Loading