Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .github/workflows/soak.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ on:
required: false
type: string
default: ''
runtime:
description: 'Runtime for a locally started node, node or bun, ignored by the cloud profile (blank = node)'
required: false
type: string
default: ''
web_server_type:
description: 'Web server for a locally started node, fastify, hono or native, ignored by the cloud profile (blank = node default)'
required: false
type: string
default: ''
profile:
description: 'Soak profile (cloud, iota-testnet, ...)'
required: false
Expand Down Expand Up @@ -124,6 +134,11 @@ jobs:
}
done

# Only needed when the harness spawns the node under Bun.
- name: Setup Bun
if: ${{ inputs.runtime == 'bun' }}
uses: oven-sh/setup-bun@v2

- name: Install k6
uses: grafana/setup-k6-action@v1

Expand Down Expand Up @@ -157,6 +172,8 @@ jobs:
INPUT_P99_MS: ${{ inputs.p99_ms }}
INPUT_MEM_GROWTH_MB_PER_HR: ${{ inputs.mem_growth_mb_per_hr }}
INPUT_DISABLED_GROUPS: ${{ inputs.disabled_groups }}
INPUT_RUNTIME: ${{ inputs.runtime }}
INPUT_WEB_SERVER_TYPE: ${{ inputs.web_server_type }}
# SOAK_BASE_URL, SOAK_TENANT_API_KEY, SOAK_ADMIN_EMAIL, SOAK_ADMIN_PASSWORD,
# and SOAK_SLACK_WEBHOOK_URL come from the ORG_SOAK_ENV_VARS blob loaded above.
# Credentials stay in env (not CLI args) so they never appear in the process list.
Expand All @@ -175,6 +192,8 @@ jobs:
[ -n "$INPUT_P99_MS" ] && export SOAK_P99_MS="$INPUT_P99_MS"
[ -n "$INPUT_MEM_GROWTH_MB_PER_HR" ] && export SOAK_MEM_GROWTH_MB_PER_HR="$INPUT_MEM_GROWTH_MB_PER_HR"
[ -n "$INPUT_DISABLED_GROUPS" ] && export SOAK_DISABLED_GROUPS="$INPUT_DISABLED_GROUPS"
[ -n "$INPUT_RUNTIME" ] && export SOAK_RUNTIME="$INPUT_RUNTIME"
[ -n "$INPUT_WEB_SERVER_TYPE" ] && export SOAK_WEB_SERVER_TYPE="$INPUT_WEB_SERVER_TYPE"
pnpm --dir apps/node run test:soak

- name: Upload soak artifacts
Expand Down
9 changes: 9 additions & 0 deletions apps/node/soak/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ thresholds are breached.
pnpm --dir apps/node run test:soak
```

To compare runtimes and web servers, set `SOAK_RUNTIME` and `SOAK_WEB_SERVER_TYPE`. Both are
recorded in `report.json` under `config`:

```bash
SOAK_RUNTIME=bun SOAK_WEB_SERVER_TYPE=hono pnpm --dir apps/node run test:soak
```

Phase 1 prints progress and exits 0 once the node is healthy. Server stdout/stderr is
captured to `apps/node/soak/.out/server.log`; file storage lives under
`apps/node/soak/.out/data` (both gitignored, recreated per run).
Expand All @@ -75,6 +82,8 @@ All knobs are `SOAK_*` for the harness; the node still uses `TWIN_*`. Defaults s
| ------------------------------------------ | --------------------------------- | --------------------------------------------------------------------------- |
| `SOAK_PORT` | `3210` | Port the spawned node binds to |
| `SOAK_PROFILE` | `file-local` | Selects `config/soak.<profile>.env` |
| `SOAK_RUNTIME` | _(orchestrator's runtime)_ | Runtime the node is spawned with, `node` or `bun` |
| `SOAK_WEB_SERVER_TYPE` | _(node default, fastify)_ | Sets `TWIN_WEB_SERVER_TYPE`: `fastify`, `hono` or `native` |
| `SOAK_TENANT_MODE` | `multi` | `multi` or `single` |
| `SOAK_BOOT_TIMEOUT` | `120s` | Max wait for `/readyz` 200 |
| `SOAK_SKIP_BOOTSTRAP` | `false` | Reuse existing state instead of bootstrapping |
Expand Down
36 changes: 33 additions & 3 deletions apps/node/soak/run-soak.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { createRemoteMemorySampler } from './remote-memory-sampler.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_DIR = path.resolve(__dirname, '..');
const ENTRY = path.join('src', 'index.js'); // relative to APP_DIR (the node's cwd)
const RUNTIMES = ['node', 'bun'];
const WEB_SERVER_TYPES = ['fastify', 'hono', 'native'];

// ---------------------------------------------------------------------------
// Config (env-driven, with defaults). See plan §9.
Expand All @@ -46,6 +48,10 @@ const cfg = {
// SOAK_BASE_URL points to an externally-deployed node (e.g. cloud CI runs).
skipServer: bool(process.env.SOAK_SKIP_SERVER, false),
skipLoad: bool(process.env.SOAK_SKIP_LOAD, false),
// Runtime the node is spawned with, defaulting to the one running this orchestrator.
runtime: process.env.SOAK_RUNTIME ?? (process.versions.bun ? 'bun' : 'node'),
// Web server implementation, unset leaves the node's own default (fastify) or the profile's.
webServerType: process.env.SOAK_WEB_SERVER_TYPE,
// Load (k6) parameters.
// 2m used to be enough to show a real (if unenforced) memory slope on the bare default
// invocation. Since the warm-up discard rose to 2m (see warmupDiscardMs below), a 2m run
Expand Down Expand Up @@ -127,9 +133,18 @@ async function main() {
// time-range deep-link once the dashboard grows one (see feat-262 implementation plan).
const startedIso = new Date().toISOString();

if (!RUNTIMES.includes(cfg.runtime)) {
throw new Error(`SOAK_RUNTIME must be one of ${RUNTIMES.join(', ')}, got "${cfg.runtime}"`);
}
if (cfg.webServerType !== undefined && !WEB_SERVER_TYPES.includes(cfg.webServerType)) {
throw new Error(
`SOAK_WEB_SERVER_TYPE must be one of ${WEB_SERVER_TYPES.join(', ')}, got "${cfg.webServerType}"`
);
}

log(
'info',
`Soak orchestrator — profile=${cfg.profile} tenantMode=${cfg.tenantMode} port=${cfg.port}`
`Soak orchestrator — profile=${cfg.profile} tenantMode=${cfg.tenantMode} port=${cfg.port} runtime=${cfg.runtime} webServerType=${cfg.webServerType ?? 'default'}`
);
log('info', `Output dir: ${OUT_DIR}`);

Expand Down Expand Up @@ -351,6 +366,10 @@ async function buildNodeEnv() {
TWIN_STRICT_ENV: cfg.strictEnv
};

if (cfg.webServerType !== undefined) {
env.TWIN_WEB_SERVER_TYPE = cfg.webServerType;
}

if (cfg.tenantMode === 'multi') {
env.TWIN_TENANT_ENABLED = 'true';
env.TWIN_TENANT_ID = cfg.tenantId;
Expand Down Expand Up @@ -429,7 +448,7 @@ async function startServer(nodeEnv) {
log('info', 'Starting node server...');
const logStream = createWriteStream(SERVER_LOG, { flags: 'a' });

serverChild = spawn(process.execPath, [ENTRY], {
serverChild = spawn(runtimeBin(), [ENTRY], {
cwd: APP_DIR,
env: { ...process.env, ...nodeEnv },
stdio: ['ignore', 'pipe', 'pipe']
Expand All @@ -445,11 +464,20 @@ async function startServer(nodeEnv) {
});
}

/**
* The executable for SOAK_RUNTIME: this process's own when it matches, so the exact running
* version is reused, otherwise the runtime's command on PATH.
*/
function runtimeBin() {
const current = process.versions.bun ? 'bun' : 'node';
return cfg.runtime === current ? process.execPath : cfg.runtime;
}

/** Run the node entry with extra args to completion, returning the exit code. */
function runToCompletion(args, env) {
return new Promise((resolve, reject) => {
const logStream = createWriteStream(SERVER_LOG, { flags: 'a' });
const child = spawn(process.execPath, [ENTRY, ...args], {
const child = spawn(runtimeBin(), [ENTRY, ...args], {
cwd: APP_DIR,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe']
Expand Down Expand Up @@ -777,6 +805,8 @@ async function writeReport({
config: {
profile: cfg.profile,
tenantMode: cfg.tenantMode,
runtime: cfg.runtime,
webServerType: cfg.webServerType ?? 'default',
duration: cfg.duration,
vus: cfg.vus,
startedIso,
Expand Down
12 changes: 7 additions & 5 deletions packages/node-core/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2024 IOTA Stiftung.
// Copyright 2026 3Sixty Trade Ltd.
// SPDX-License-Identifier: Apache-2.0.
import type { ITenantAdminComponent } from "@3sixty/api-models";
import { ContextIdKeys, ContextIdStore } from "@3sixty/context";
Expand Down Expand Up @@ -130,11 +131,12 @@ export async function start(
try {
initialiseMigrations(engine, envVars);

// Start the server, which also starts the engine.
await server.start();

await seedAuthorizationDefaults(engine, server, envVars);
await finalizeMigrations(engine, envVars);
// Start the server, which also starts the engine. The authorization model is seeded
// before the server listens, so no request is checked against a partial model.
await server.start(async () => {
await seedAuthorizationDefaults(engine, server, envVars);
await finalizeMigrations(engine, envVars);
});

return {
engine,
Expand Down
66 changes: 33 additions & 33 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading