Skip to content

test(load): complete WP-X.2 W5 binary load gate - #186

Closed
joseederangojr wants to merge 2 commits into
mainfrom
wave-5/WP-X.2-built-binary-load-test
Closed

joseederangojr wants to merge 2 commits into
mainfrom
wave-5/WP-X.2-built-binary-load-test

Conversation

@joseederangojr

@joseederangojr joseederangojr commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

WP-X.2
Refs OZE-3217

W5 built-binary load gate passed: 1,000 requests, concurrency 16, cache-hit redirect p95 21.46 ms and p99 23.69 ms. Artifact records environment, signed profile, measurements, and exact built-binary source commit and SHA-256.

W8 measurement remains outstanding; keep OZE-3217 In Progress until the W8 gate also passes.

@joseederangojr
joseederangojr force-pushed the wave-5/WP-X.2-built-binary-load-test branch from 66735ea to 8ad4cd5 Compare November 2, 2026 12:19
@joseederangojr

Copy link
Copy Markdown
Collaborator Author

🟡 Changes requested

Adds apps/server/run-w5-load.ts, a local harness that builds the binary, starts Docker Compose, seeds one active link, primes the route cache, runs 1000 redirects at concurrency 16, and writes ops/load/results/wp-x2-w5.json (TP-W5-030). The harness's mechanics are sound, but the artifact can record a failed run and some of its fields are asserted rather than measured. No runtime code is changed.

7 open issues
  • [Medium] Harness writes the results artifact before the latency gate runs
  • [Medium] cacheHit is hard-coded to true, and the 3 s route-view freshness window can expire mid-run
  • [Low] Per-request 4 s timers are never cleared
  • [Low] Whole-run deadline does not cover setup or in-flight requests
  • [Low] Ports are released before Compose starts, so they can be taken first
  • [Low] Seeded cache entry is hand-built, not written by the production cache path
  • [Low] finally cleanup can throw and mask the original error
What changed in this PR

Changes:

  • New load harness apps/server/run-w5-load.ts (152 lines)
  • New result artifact ops/load/results/wp-x2-w5.json recording p95 21.46 ms and p99 23.69 ms against 50 ms and 150 ms targets, plus the built binary's SHA-256
File Description
apps/server/run-w5-load.ts Compose-backed load harness for the built binary, with cleanup in finally.
ops/load/results/wp-x2-w5.json Recorded TP-W5-030 measurements for commit ebd4e35.

Review effort: medium

@joseederangojr joseederangojr left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Inline findings for the W5 load harness. Overview comment posted separately.

Comment thread apps/server/run-w5-load.ts Outdated
const stderrText = new Response(curl.stderr).text();
const [exitCode, headers] = await Promise.race([
Promise.all([curl.exited, stdoutText, stderrText]).then(([code, out]) => [code, out] as const),
new Promise<never>((_, reject) => setTimeout(() => { curl.kill(); reject(new Error("curl request exceeded independent 4s deadline")); }, 4000)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Per-request 4 s timers are never cleared

Location: apps/server/run-w5-load.ts:43

The setTimeout inside the Promise.race is never cleared when curl finishes first. Each of the 1000 requests leaves a live 4 s timer, so the process cannot exit until they all fire. Each one then calls curl.kill() on an exited process and rejects a promise nobody awaits. This adds up to about 4 s of shutdown delay and muddies the event loop during the run.

Suggested fix:

let deadline: ReturnType<typeof setTimeout> | undefined;
const [exitCode, headers] = await Promise.race([
  Promise.all([curl.exited, stdoutText, stderrText]).then(([code, out]) => [code, out] as const),
  new Promise<never>((_, reject) => {
    deadline = setTimeout(() => { curl.kill(); reject(new Error("curl request exceeded independent 4s deadline")); }, 4000);
  }),
]).finally(() => clearTimeout(deadline));

Comment thread apps/server/run-w5-load.ts Outdated
const ownedServers: ReturnType<typeof Bun.spawn>[] = [];
let serverExitCode: number | undefined;
const abortRun = new AbortController();
const wholeRunTimeout = setTimeout(() => { console.error("whole-run deadline reached"); abortRun.abort(new Error("whole-run deadline exceeded")); }, 180_000);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Whole-run deadline does not cover setup or in-flight requests

Location: apps/server/run-w5-load.ts:55

abortRun is wired only to the Bun.sleep inside the request loop (line 121). The 180 s deadline therefore does not stop docker compose up --wait, migrate, or the warm-up loop, and it does not cancel a requestRedirect already in flight. A hung Compose start or migration can run past the deadline, and the message "whole-run deadline reached" is misleading because nothing is actually aborted. Either pass abortRun.signal into requestRedirect and the setup steps, or drop the timer and rely on the per-step timeouts.

Comment thread apps/server/run-w5-load.ts Outdated
const wholeRunTimeout = setTimeout(() => { console.error("whole-run deadline reached"); abortRun.abort(new Error("whole-run deadline exceeded")); }, 180_000);
try {
run(["bun", "apps/server/build.ts"]);
for (const reservation of reservations) reservation.release();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Ports are released before Compose starts, so they can be taken first

Location: apps/server/run-w5-load.ts:58

freePort() reserves each port by listening on port 0 and then releases it. Line 58 releases all five reservations before docker compose up, leaving a window where another process can bind the same port. Compose then fails with a bind error, or a second run's freePort races this one. The chance is low on a dev machine, but the failure is hard to diagnose. Retrying the whole Compose step on a bind error, or letting Compose pick the ports and reading them back, would close the gap.

Comment thread apps/server/run-w5-load.ts Outdated
if (!view) throw new Error("seeded route missing from production route source");
const cache = createRouteCache(redis);
const redisNow = await cache.readServerTime();
const routeView = { linkId: view.link.id, destination: view.link.destinationUrl, state: view.link.status, eligible: view.eligible, allowedHosts: view.allowedHosts, decision: "allow", version: view.link.version };

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Seeded cache entry is hand-built, not written by the production cache path

Location: apps/server/run-w5-load.ts:85-87

The warm entry is built by hand (routeView, lines 85-86) with a fixed decision: "allow" and written with cache.put. The log line at line 87 calls this the "production route cache", but the production writer is the redirect handler, and it never runs during seeding. Any drift between this hand-built shape and what the handler stores would go unnoticed, and the "cache hit" would then describe the harness's own data. Priming the cache with a real request (cold miss, then the measured hit) would make the artifact reflect the production path.

Comment thread apps/server/run-w5-load.ts Outdated
}
}));
const percentile = (p: number) => Number([...times].sort((a,b) => a-b)[Math.ceil(times.length*p)-1].toFixed(2));
const measurements = { requests: times.length, concurrency: 16, status: 302, cacheHit: true, p95Ms: percentile(.95), p99Ms: percentile(.99), maxMs: Number(Math.max(...times).toFixed(2)) };

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] cacheHit is hard-coded to true, and the 3 s route-view freshness window can expire mid-run

Location: apps/server/run-w5-load.ts:129

cacheHit: true and status: 302 are constants in the measurements object, not observations. The request loop checks status and Location (lines 122-123), but nothing checks whether a request was served from cache. ROUTE_VIEW_MAX_AGE_MS is 3000 (packages/adapter-redis/src/route-cache.ts:6), and the GET_SCRIPT age check returns null past that. The seed is written once before the loop, so any part of the 1000-request run that runs past about 3 s is served from the primary. The artifact would still report cacheHit: true. The label is therefore not backed by the run.

Failure scenario: The machine is slow, or the run is delayed. Requests after 3 s miss Redis, hit Postgres, and still pass the status check. The JSON records "cacheHit": true and p95/p99 that mix hit and miss latencies.

Suggested fix: Derive cacheHit from the run, for example by counting cache.get results inside the loop, or by re-checking cache.get(ids.link) after the run and recording the result. If a hit can't be proven for every request, record the observed hit count instead of a boolean.

Comment thread apps/server/run-w5-load.ts Outdated
profile: { token: "single hot token", requests: 1000, concurrency: 16, stack: "ops/docker-compose.yml", binary: "dist/moneytrap", timing: "HTTP round-trip including client overhead; manual redirect, response body consumed", targets: { p95Ms: 50, p99Ms: 150 } },
measurements, builtBinary: { commit: sha, path: "dist/moneytrap", sha256: Bun.spawnSync(["sh", "-c", `shasum -a 256 '${target}'`], { stdout: "pipe" }).stdout.toString().split(/\s+/)[0] },
};
await Bun.write(resolve(root, "ops/load/results/wp-x2-w5.json"), `${JSON.stringify(result, null, 2)}\n`);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] Harness writes the results artifact before the latency gate runs

Location: apps/server/run-w5-load.ts:136-138

Line 136 overwrites ops/load/results/wp-x2-w5.json before line 138 checks the gate. When the gate fails, the script exits non-zero but the file already holds the failing numbers, with no pass/fail field and nothing that marks the run as failed. Anyone who commits the file without checking the exit code records a failing run as if it were the result. It also replaces a previous good artifact with no trace.

Suggested fix: Move the gate above the write, or add a gate: { passed, p95Ms, p99Ms } field and write only on success. Also add the omitted smtp and mailpit ports to environment.ports so the artifact records every port in use.

Comment thread apps/server/run-w5-load.ts Outdated
child.kill();
await child.exited;
}
run([...compose, "down", "-v", "--remove-orphans"]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] finally cleanup can throw and mask the original error

Location: apps/server/run-w5-load.ts:146-147

run() throws on any non-zero exit (line 30). If docker compose down -v fails, that error replaces the original failure, such as a failed latency gate, and the remaining container check on line 147 never runs. Catch the teardown error, record it, and always run the container check. Set process.exitCode and rethrow the original error afterward.

@joseederangojr joseederangojr left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Inline findings for the W5 load harness, rereview at 4134628.

const ownedServers: ReturnType<typeof Bun.spawn>[] = [];
let serverExitCode: number | undefined;
const abortRun = new AbortController();
const wholeRunTimeout = setTimeout(() => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Whole-run deadline does not cover setup or in-flight requests

Location: apps/server/run-w5-load.ts:65

Prior finding 4 is still open. abortRun is only wired to the Bun.sleep inside the request loop (line 207). docker compose up --wait, migrate, the seed, and the warm-up loop are not aborted, and an in-flight requestRedirect is not cancelled. The message "whole-run deadline reached" therefore implies more than happens.

Suggested fix: pass abortRun.signal into the setup steps and requestRedirect, or drop the timer and rely on per-step timeouts.

try {
run(["bun", "apps/server/build.ts"]);
run([...compose, "up", "-d", "--wait"]);
for (const reservation of reservations) reservation.release();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[High] Port reservations are held through compose up, so Compose cannot bind them

Location: apps/server/run-w5-load.ts:74

The reservations are moved to after docker compose up -d --wait (line 73). Each reservation is a live Bun.listen on 127.0.0.1:<port>, and ops/docker-compose.yml publishes 127.0.0.1:${MONEYTRAP_PG_PORT}, …REDIS_PORT, …SMTP_PORT, and …MAILPIT_UI_PORT. Compose therefore tries to bind a port that this process is still holding. I checked the bind conflict locally: a port held by a Bun listener returns Address already in use to a second bind on the same address. Compose will fail at up --wait on every run.

The prior finding asked for the release to be closed off from other processes, not held longer. The fix needs the release before Compose, plus a retry on a bind failure, or Compose choosing the ports and the harness reading them back.

Suggested fix:

for (const reservation of reservations) reservation.release();
run([...compose, "up", "-d", "--wait"]);

and retry the compose up step on a bind error.

decision: "allow",
version: view.link.version,
};
await cache.put(ids.link, routeView, redisNow);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Seeded cache entry is still hand-built, not written by the production cache path

Location: apps/server/run-w5-load.ts:146

Prior finding 6 is still open. The seed is built by hand (routeView, lines 137-145) with a fixed decision: "allow" and written with cache.put. The redirect handler is the production writer, and it never runs during seeding, so any drift between this shape and what the handler stores would go unnoticed.

Suggested fix: prime the cache with a real request to the server (cold miss, then the measured hit), so the artifact reflects the production path.

const start = performance.now();
const response = await Promise.race([
requestRedirect(url),
Bun.sleep(5000, { signal: abortRun.signal }).then(() => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Per-request Bun.sleep(5000) timers are never cleared

Location: apps/server/run-w5-load.ts:207

Prior finding 3 is fixed for the curl timer, but the same pattern remains here. Each of the 1000 requests creates a Bun.sleep(5000, { signal }) inside Promise.race that is never cancelled when the request finishes. The warm-up loop (line 180) has the same issue. The process cannot exit until these fire, and each rejects an unawaited promise.

Suggested fix: clear the sleep on completion, for example with an AbortController per request that is aborted in finally, or with a shared setTimeout that is cleared in finally.

if (response.status !== 302 || response.location !== "https://shop.example.com/spring")
throw new Error(`unexpected redirect ${response.status} ${response.location}`);
times.push(performance.now() - start);
if (performance.now() - measuredStart >= 2500)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] Freshness guard is measured from measuredStart, not from the seed write

Location: apps/server/run-w5-load.ts:214

The cache entry is written at cache.put (line 146), and ROUTE_VIEW_MAX_AGE_MS is 3000 ms from that write (packages/adapter-redis/src/route-cache.ts:6). The guard counts from measuredStart (line 197), which is taken after the warm-up. The warm-up can take a meaningful part of the 3 s window, so the run can extend past put + 3000 ms while the guard still allows it: a run that starts at put + 1 s can finish a last request at about put + 3.5 s. Those requests can be served from Postgres and still pass the 302 and Location check.

Suggested fix: record the seed time (redisNow or Date.now() at put), and fail when Date.now() - seededAt reaches the window at any request, not just performance.now() - measuredStart.

requests: times.length,
concurrency: 16,
status: 302,
cacheHit: "primed production route cache; 1000 requests completed within freshness window",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] cacheHit is still asserted, not observed

Location: apps/server/run-w5-load.ts:226

Prior finding 2 is only partly addressed. cacheHit changed from true to a string, but the value is still a constant claim ("primed production route cache; 1000 requests completed within freshness window"). Nothing in the loop checks whether a request was a cache hit, and the freshness guard above is anchored to the wrong origin. The artifact still asserts a cache hit the run did not record.

Suggested fix: count cache.get results inside the loop or re-check the cache after the run, and record the observed hit count (for example cacheHits: n), not a constant string.

bun: Bun.version,
dockerCompose: Bun.spawnSync(["docker", "compose", "version"], { stdout: "pipe" }).stdout.toString().trim(),
host: "local Docker Compose",
ports: { postgres: pgPort, redis: redisPort, http: httpPort },

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] Committed artifact is stale and omits smtp/mailpit ports

Location: apps/server/run-w5-load.ts:242

ops/load/results/wp-x2-w5.json is unchanged in this PR. It was produced by the earlier harness at ebd4e35, with a boolean cacheHit, and it has no gate fields. The current harness produces a different measurements.cacheHit shape, so the committed file no longer matches what the harness writes, and the W5 gate evidence comes from code that is no longer here. The prior finding also asked for smtp and mailpit to be added to environment.ports; ports still lists only postgres, redis, and http.

Suggested fix: add the smtp and mailpit ports to environment.ports, re-run the harness on the final code, and commit the regenerated artifact with the commit that produced it.

cleanupError = `Compose cleanup failed: ${String(error)}`;
}
}
if (cleanupError) throw new Error(cleanupError);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Teardown error is dropped when the body also throws

Location: apps/server/run-w5-load.ts:285

Prior finding 7 is mostly fixed: a teardown failure no longer replaces the original error. But cleanupError is only thrown after the try/finally (line 285). If the body threw (for example, the latency gate), that error propagates from finally, so line 285 is never reached and a failed compose down -v or a remaining container is silently dropped.

Suggested fix: when a primary error is already in flight, log cleanupError with console.error before rethrowing, or attach it to the primary error.

@joseederangojr

Copy link
Copy Markdown
Collaborator Author

🔴 Changes requested

The harness now writes the artifact only after the latency gate passes, and the curl timers are cleared. A new regression blocks the run: the port reservations are released after docker compose up, so Compose cannot bind them. Several earlier findings remain open, and the committed artifact was produced by the previous harness.

7 open issues
  • [High] Port reservations are held through compose up, so Compose cannot bind them · open
  • [Medium] cacheHit is still asserted as a string, not observed · open
  • [Medium] Freshness guard is measured from measuredStart, not from the seed write · open
  • [Medium] Committed artifact is stale and omits smtp/mailpit ports · open
  • [Low] Per-request Bun.sleep(5000) timers are never cleared · open
  • [Low] Whole-run deadline still does not cover setup or in-flight requests · open
  • [Low] Seeded cache entry is still hand-built · open
  • [Low] Teardown error is dropped when the body also throws · open
Prior findings (7)
# Prior finding Status
1 Artifact written before latency gate Fixed (run-w5-load.ts:262-287)
2 cacheHit hard-coded true; freshness window can expire Not fixed: still asserted (:226); guard anchored wrong (:214)
3 Per-request 4 s curl timers never cleared Fixed for curl (:57-59); Bun.sleep timers remain (:207)
4 Whole-run deadline does not cover setup Not fixed (:65-68, :207)
5 Ports released before Compose starts Regressed: now released after Compose (:74)
6 Seeded cache entry hand-built Not fixed (:146)
7 finally cleanup masks original error Mostly fixed (:271-285); see teardown finding

What changed in this PR

Changes:

  • Artifact write moved after the latency gate; passedResult is written only on success
  • Curl deadline timer cleared in finally; warm-up and measured loops refactored
  • Compose teardown wrapped in try/catch; cleanup error recorded
  • Freshness guard added (>= 2500 ms since measuredStart)

The committed ops/load/results/wp-x2-w5.json is unchanged from 8ad4cd5. It was produced by the earlier harness at ebd4e35, so it does not reflect the current code.

Review effort: medium

@joseederangojr
joseederangojr deleted the wave-5/WP-X.2-built-binary-load-test branch November 3, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant