Skip to content

Commit ca554fb

Browse files
authored
perf(agent): accelerate local verification scheduling and reporting (#460)
* perf(agent): accelerate local verification scheduling and reporting * perf(agent): shard the security spec, widen the UI test pool, open SSE streams at once Per-task timings showed the security spec as the critical path: one bun process for 22 files, 100 s, while everything else finished by 56 s. - Security shards: locally the spec files are packed into as many shards as browser workers (four on a large host), each on its own lane database and Valkey index (app_security_1..4); an aggregate task merges the JUnit reports so evidence and the findings manifest read one run. Packing uses per-file durations recorded from the previous run's report (size only breaks ties), so a cold checkout balances on its second run. With one worker the spec runs whole on app_security exactly as before. - UI pool: vitest runs on half the slot budget, capped at eight, once the spec is no longer the long pole (56 s -> 38 s). - The SSE stream now yields a ping on open. Elysia turns a generator into a response only after its first yield, so a silent stream kept the response headers, and a browser's EventSource open event, waiting for a notification or the 25 s keepalive; the spec's HTTP tests spent 25 s each on exactly that. The generator fixture consumes the handshake. Release-local on a 20-core host: 104 s -> 49 s (three passing runs: 50, 50, 49). The SSE spec: 79 s -> 6 s. * perf(agent): shard the API test suite too, with coverage merged from the shards api.tests was the next critical path at ~40 s in one process. It now shards like the security spec (app_tests_1..4, Valkey 9..12), packed by recorded per-file durations, with an aggregate task that merges the JUnit reports for inventory evidence. Coverage in release-local comes from the shards' LCOV reports. Bun's "All files" row is the unweighted mean of per-file percentages (verified against bun test --coverage on the same run), and that is what the merge reproduces: lines union exactly per file; functions can only be combined as the best shard per file because Bun's LCOV has no per-function records, so when that lower bound alone misses the floor the whole suite runs once through check-coverage.ts and its verdict decides. The floor constants move to coverage-thresholds.ts, shared by both paths. Release-local on a 20-core host: 44 s, twice in a row (from 49 s; from 104 s before sharding; from ~10 min before parallel lanes). The remaining floor is ui.tests at ~40 s.
1 parent ecb377e commit ca554fb

35 files changed

Lines changed: 2224 additions & 252 deletions

‎apps/api/scripts/quality/check-coverage.ts‎

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,12 @@
55
* `coverageThreshold` is documented but not enforced, so we parse the
66
* text report ourselves and exit non-zero on regression.
77
*
8-
* The threshold is a ratchet, not a wishlist: it sits a few points
9-
* below the current measured rate so a small slip triggers the alarm.
10-
* Raise it as coverage climbs; never lower it to silence a regression.
8+
* The floor lives in coverage-thresholds.ts, shared with the sharded
9+
* verification runner.
1110
*/
1211
import { spawnSync } from "node:child_process";
12+
import { MIN_FUNCTION, MIN_LINE } from "./coverage-thresholds";
1313

14-
const MIN_LINE = 0.65;
15-
const MIN_FUNCTION = 0.7;
1614
const MAX_TEST_OUTPUT_BUFFER_BYTES = 64 * 1024 * 1024;
1715

1816
const FORBIDDEN_OUTPUT = [
@@ -49,16 +47,26 @@ const runCoverage = (): {
4947
* letting them into the coverage gate turns the ordinary merge gate red
5048
* for reasons unrelated to the change under review.
5149
*/
52-
const result = spawnSync("bun", ["test", "tests", "--coverage"], {
53-
encoding: "utf8",
54-
maxBuffer: MAX_TEST_OUTPUT_BUFFER_BYTES,
55-
env: {
56-
...process.env,
57-
NODE_ENV: "test",
58-
LOG_LEVEL: "error",
59-
NODE_NO_WARNINGS: "1",
60-
},
61-
});
50+
const result = spawnSync(
51+
"bun",
52+
[
53+
...(process.env.AGENT_SANDBOX === "1" ? ["--no-env-file"] : []),
54+
"test",
55+
"tests",
56+
"--coverage",
57+
...process.argv.slice(2),
58+
],
59+
{
60+
encoding: "utf8",
61+
maxBuffer: MAX_TEST_OUTPUT_BUFFER_BYTES,
62+
env: {
63+
...process.env,
64+
NODE_ENV: "test",
65+
LOG_LEVEL: "error",
66+
NODE_NO_WARNINGS: "1",
67+
},
68+
}
69+
);
6270

6371
return {
6472
combined: result.stdout + result.stderr,
@@ -80,7 +88,14 @@ const parseAllFilesRow = (output: string): ICoverageResult | null => {
8088
const functionPct = parseFloat(parts[1] ?? "");
8189
const linePct = parseFloat(parts[2] ?? "");
8290

83-
if (Number.isNaN(linePct) || Number.isNaN(functionPct)) {
91+
if (
92+
!Number.isFinite(linePct) ||
93+
!Number.isFinite(functionPct) ||
94+
linePct < 0 ||
95+
linePct > 100 ||
96+
functionPct < 0 ||
97+
functionPct > 100
98+
) {
8499
return null;
85100
}
86101

@@ -118,7 +133,7 @@ if (warningLines.length > 0) {
118133
console.error(` ${line}`);
119134
}
120135

121-
process.exit(1);
136+
process.exit(86);
122137
}
123138

124139
if (exitCode !== 0) {
@@ -145,7 +160,7 @@ if (!lineOk || !functionOk) {
145160
`\n\nTo raise: add tests for under-covered surfaces (queues / SSE / web push / setup).` +
146161
`\nTo lower the threshold: do not. Treat the gate as a ratchet.`
147162
);
148-
process.exit(1);
163+
process.exit(86);
149164
}
150165

151166
console.log(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*
2+
* The coverage floor is a ratchet, not a wishlist: it sits a few points
3+
* below the current measured rate so a small slip triggers the alarm.
4+
* Raise it as coverage climbs; never lower it to silence a regression.
5+
* Shared by the single-process gate (check-coverage.ts) and the sharded
6+
* verification runner, so both enforce the same numbers.
7+
*/
8+
export const MIN_LINE = 0.65;
9+
export const MIN_FUNCTION = 0.7;

‎apps/api/security-spec/f14-sse-stream-lifetime.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,32 @@ const openStream = (userId: string, jti: string): IStreamFixture => {
129129
}),
130130
});
131131

132+
/*
133+
* The stream opens with a ping so Elysia can flush the response headers
134+
* before the first notification. The fixture consumes it, so every test
135+
* reads the real payloads exactly as it would have without the handshake.
136+
*/
137+
let opened = false;
138+
139+
const next = async (): Promise<IteratorResult<string, void>> => {
140+
if (!opened) {
141+
opened = true;
142+
143+
const handshake = await generator.next();
144+
145+
if (
146+
handshake.done === true ||
147+
handshake.value !== JSON.stringify({ type: "ping" })
148+
) {
149+
throw new Error("f14: the stream did not open with a ping");
150+
}
151+
}
152+
153+
return generator.next();
154+
};
155+
132156
return {
133-
next: () => generator.next(),
157+
next,
134158
publish: (message) =>
135159
valkeyPubSub.publish(userNotificationChannel(userId), message),
136160
credential,

‎apps/api/src/api/notifications/notifications.sse.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ export const notificationsStreamHandler = async function* (
157157
let lastPingAtMs = nowMs();
158158

159159
try {
160+
/*
161+
* Elysia turns a generator into a response only after its first
162+
* `yield`. A stream that stays silent until a notification arrives, or
163+
* until the keepalive below, would keep the browser's `EventSource`
164+
* from opening and any proxy from seeing bytes for up to 25 seconds.
165+
* A ping on open flushes the headers at once; the client ignores it.
166+
*/
167+
yield JSON.stringify({ type: "ping" });
168+
160169
while (!isAborted()) {
161170
/*
162171
* The credential is re-checked before every payload, not once per

‎apps/api/src/templates/email/build.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ const precompilePartials = (): Record<string, string> => {
103103
return partials;
104104
};
105105

106+
/** Avoid replacing artifacts while parallel test and build consumers read them. */
107+
const writeArtifact = (outputPath: string, content: string): void => {
108+
if (
109+
fs.existsSync(outputPath) &&
110+
fs.readFileSync(outputPath, "utf8") === content
111+
) {
112+
return;
113+
}
114+
115+
fs.writeFileSync(outputPath, content, "utf8");
116+
};
117+
106118
const buildTemplate = (templatePath: string): void => {
107119
const source = fs.readFileSync(templatePath, "utf8");
108120
const baseTemplate = precompileToString(source);
@@ -123,10 +135,9 @@ const buildTemplate = (templatePath: string): void => {
123135
contentTemplate = precompileToString(contentSource);
124136
}
125137

126-
fs.writeFileSync(
138+
writeArtifact(
127139
outputPath,
128-
JSON.stringify({ baseTemplate, contentTemplate }, null, 2),
129-
"utf8"
140+
JSON.stringify({ baseTemplate, contentTemplate }, null, 2)
130141
);
131142
console.log(`✓ Built: ${path.relative(__dirname, outputPath)}`);
132143
};
@@ -137,7 +148,7 @@ const buildPartialsManifest = (): void => {
137148
fs.ensureDirSync(DIST_DIR);
138149
const manifestPath = path.join(DIST_DIR, "partials.json");
139150

140-
fs.writeFileSync(manifestPath, JSON.stringify(partials, null, 2), "utf8");
151+
writeArtifact(manifestPath, JSON.stringify(partials, null, 2));
141152
console.log(
142153
`✓ Built partials manifest: ${path.relative(__dirname, manifestPath)}`
143154
);

‎apps/api/tsconfig.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{
22
"compilerOptions": {
3+
"incremental": true,
4+
"tsBuildInfoFile": "./node_modules/.cache/tsc/api.tsbuildinfo",
35
"target": "ES2022",
46
"module": "ES2022",
57
"moduleResolution": "bundler",
@@ -29,6 +31,11 @@
2931
"@/*": ["./src/*"]
3032
}
3133
},
32-
"include": ["src/**/*.ts", "tests/**/*.ts", "security-spec/**/*.ts", "scripts/**/*.ts"],
34+
"include": [
35+
"src/**/*.ts",
36+
"tests/**/*.ts",
37+
"security-spec/**/*.ts",
38+
"scripts/**/*.ts"
39+
],
3340
"exclude": ["node_modules", "dist", "drizzle", "src/templates/email/dist"]
3441
}

‎apps/ui/.size-limit.json‎

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"dist/assets/query-*.js"
1010
],
1111
"limit": "255 KB",
12-
"gzip": true
12+
"gzip": true,
13+
"running": false
1314
},
1415
{
1516
"name": "Modulepreload runtime + shared shell",
@@ -24,120 +25,140 @@
2425
"dist/assets/dist-*.js"
2526
],
2627
"limit": "165 KB",
27-
"gzip": true
28+
"gzip": true,
29+
"running": false
2830
},
2931
{
3032
"name": "CSS (Tailwind compiled)",
3133
"path": "dist/assets/*.css",
3234
"limit": "12 KB",
33-
"gzip": true
35+
"gzip": true,
36+
"running": false
3437
},
3538
{
3639
"name": "LoginPage chunk",
3740
"path": "dist/assets/LoginPage-*.js",
3841
"limit": "20 KB",
39-
"gzip": true
42+
"gzip": true,
43+
"running": false
4044
},
4145
{
4246
"name": "DashboardPage chunk",
4347
"path": "dist/assets/DashboardPage-*.js",
4448
"limit": "5 KB",
45-
"gzip": true
49+
"gzip": true,
50+
"running": false
4651
},
4752
{
4853
"name": "SettingsPage chunk",
4954
"path": "dist/assets/SettingsPage-*.js",
5055
"limit": "20 KB",
51-
"gzip": true
56+
"gzip": true,
57+
"running": false
5258
},
5359
{
5460
"name": "BillingPage chunk",
5561
"path": "dist/assets/BillingPage-*.js",
5662
"limit": "4 KB",
57-
"gzip": true
63+
"gzip": true,
64+
"running": false
5865
},
5966
{
6067
"name": "NotificationsPage chunk",
6168
"path": "dist/assets/NotificationsPage-*.js",
6269
"limit": "4 KB",
63-
"gzip": true
70+
"gzip": true,
71+
"running": false
6472
},
6573
{
6674
"name": "NotificationsPreferencesPage chunk",
6775
"path": "dist/assets/NotificationsPreferencesPage-*.js",
6876
"limit": "3 KB",
69-
"gzip": true
77+
"gzip": true,
78+
"running": false
7079
},
7180
{
7281
"name": "InvitationsPage chunk",
7382
"path": "dist/assets/InvitationsPage-*.js",
7483
"limit": "4 KB",
75-
"gzip": true
84+
"gzip": true,
85+
"running": false
7686
},
7787
{
7888
"name": "InvitationAcceptPage chunk",
7989
"path": "dist/assets/InvitationAcceptPage-*.js",
8090
"limit": "3 KB",
81-
"gzip": true
91+
"gzip": true,
92+
"running": false
8293
},
8394
{
8495
"name": "OwnershipTransferAcceptPage chunk",
8596
"path": "dist/assets/OwnershipTransferAcceptPage-*.js",
8697
"limit": "3 KB",
87-
"gzip": true
98+
"gzip": true,
99+
"running": false
88100
},
89101
{
90102
"name": "JoinRequestsPage chunk",
91103
"path": "dist/assets/JoinRequestsPage-*.js",
92104
"limit": "3 KB",
93-
"gzip": true
105+
"gzip": true,
106+
"running": false
94107
},
95108
{
96109
"name": "AuditLogPage chunk",
97110
"path": "dist/assets/AuditLogPage-*.js",
98111
"limit": "3 KB",
99-
"gzip": true
112+
"gzip": true,
113+
"running": false
100114
},
101115
{
102116
"name": "ProfilePage chunk",
103117
"path": "dist/assets/ProfilePage-*.js",
104118
"limit": "3 KB",
105-
"gzip": true
119+
"gzip": true,
120+
"running": false
106121
},
107122
{
108123
"name": "SignUpPage chunk",
109124
"path": "dist/assets/SignUpPage-*.js",
110125
"limit": "3 KB",
111-
"gzip": true
126+
"gzip": true,
127+
"running": false
112128
},
113129
{
114130
"name": "ForgotPasswordPage chunk",
115131
"path": "dist/assets/ForgotPasswordPage-*.js",
116132
"limit": "3 KB",
117-
"gzip": true
133+
"gzip": true,
134+
"running": false
118135
},
119136
{
120137
"name": "ResetPasswordPage chunk",
121138
"path": "dist/assets/ResetPasswordPage-*.js",
122139
"limit": "3 KB",
123-
"gzip": true
140+
"gzip": true,
141+
"running": false
124142
},
125143
{
126144
"name": "VerifyEmailPage chunk",
127145
"path": "dist/assets/VerifyEmailPage-*.js",
128146
"limit": "3 KB",
129-
"gzip": true
147+
"gzip": true,
148+
"running": false
130149
},
131150
{
132151
"name": "OAuthCallbackPage chunk",
133152
"path": "dist/assets/OAuthCallbackPage-*.js",
134153
"limit": "3 KB",
135-
"gzip": true
154+
"gzip": true,
155+
"running": false
136156
},
137157
{
138158
"name": "NotFoundPage chunk",
139159
"path": "dist/assets/NotFoundPage-*.js",
140160
"limit": "2 KB",
141-
"gzip": true
161+
"gzip": true,
162+
"running": false
142163
}
143164
]

‎apps/ui/scripts/codegen/new-feature.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ if (namespaceEnabled) {
322322
name: `${Name} translations (all locales)`,
323323
path: `dist/assets/${lower}-*.js`,
324324
limit: "10 KB",
325-
gzip: true
325+
gzip: true,
326+
running: false
326327
});
327328
writeFileSync(
328329
budgetPath,

0 commit comments

Comments
 (0)