## Summary Listing schedules could block the event loop for seconds. A page of 100 timezone-aware schedules spent over two seconds on cron arithmetic alone, after the database work was already done, which stalls every other request on that process. The same page now resolves in tens of milliseconds. ## Root cause and fix `cron-parser` walks the calendar unit by unit, and under a named timezone every step goes through luxon. Parsing an expression is cheap (single-digit microseconds); *stepping* it is not, ranging from a couple of hundred microseconds for a common expression to several milliseconds for a sparse one like `0 0 29 2 *`. The presenter did three independent walks per row, one backwards for "last run" and two forwards (re-parsing each time) for the next run and the occurrence after it. At 100 rows that is 300 calendar walks in one uninterrupted tick. Run times now resolve for the whole page in one pass, in a new `resolveScheduleTimings` that takes plain values rather than Prisma rows so it can be tested and benchmarked on its own. - **Nominal times are cached per `(cron, timezone)`** against a single `now` pinned for the batch, so cost scales with the number of distinct expressions instead of the number of rows. Rows in one response also stop disagreeing about the current time. - **The backwards walk is opt-in.** It is the most expensive of the three and only the dashboard renders the column; the public API never returned it at all. - **Windowless schedules take one step instead of two.** The second step only measures the interval to the following occurrence, and that interval reaches the result solely through `min(intervalMs, max(MINIMUM_SCHEDULE_RANGE_MS, windowMs))`. With no window `windowMs` is 0, and `CronPattern` rejects expressions with a seconds field, so occurrences are always at least `MINIMUM_SCHEDULE_RANGE_MS` apart and that `min` can never bind. It is also the costlier step, since it walks a whole period rather than the remainder of the current one. - **`nextScheduledTimestamps` steps one parsed expression** instead of re-parsing per step, which also helps the single-schedule callers. Behaviour is unchanged, error semantics included: a malformed expression still throws for the next run and still degrades to an undefined last run. ## Verification Measured inside a real request against a live environment, 100 schedules: sparse expressions went from 2250-2652 ms to 23-30 ms, and five distinct timezone expressions from 463-500 ms to 9.7-10.6 ms. The new suite checks the optimized code against an inline copy of the previous implementation across eleven cron and timezone combinations plus five DST transitions, so the rewrite is verified as behaviour-preserving rather than just faster. Separate tests pin the invariant the single-step path depends on, so if sub-minute crons are ever allowed they fail loudly instead of the timings quietly going wrong. Worth knowing for later: `cron-parser` v5 is a much faster rewrite on exactly this workload (`prev()` under a timezone drops from roughly 2700 to 60 microseconds), but it is a breaking API change across several call sites including the schedule engine, so it belongs on its own. The differential test added here is the tool to de-risk it.
Webapp tests
Three suites live in this directory.
Unit tests — *.test.ts
Run with pnpm test from apps/webapp. Default vitest pickup. No
container setup. Run on every PR via unit-tests-webapp.yml.
Smoke e2e — *.e2e.test.ts
End-to-end auth baseline that proves the route auth plumbing is wired up.
Each file spins up its own webapp + Postgres + Redis container in
beforeAll (~30s startup). Vitest config: vitest.e2e.config.ts. Run on
every PR via e2e-webapp.yml.
cd apps/webapp
pnpm exec vitest --config vitest.e2e.config.ts
Comprehensive auth e2e — *.e2e.full.test.ts
The full RBAC auth matrix — every route family with explicit pass/fail scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for each family's coverage spec.
Architecture: one container reused across the whole suite via
vitest.e2e.full.config.ts's globalSetup. Test files share the server
through getTestServer() from helpers/sharedTestServer.ts. Each test
seeds its own resources so order doesn't matter.
Layout:
| File | Top-level describe | Family subtasks |
|---|---|---|
auth-api.e2e.full.test.ts |
API |
TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT |
auth-dashboard.e2e.full.test.ts |
Dashboard |
TRI-8742 admin pages |
auth-cross-cutting.e2e.full.test.ts |
Cross-cutting |
TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle |
Adding a new family: pick the relevant file, add a nested describe
block. Inside, seed your own fixtures via the helpers and hit the shared
server.
describe("Trigger task", () => {
const server = getTestServer();
it("missing Authorization → 401", async () => {
const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" });
expect(res.status).toBe(401);
});
});
CI: e2e-webapp-auth-full.yml. Triggers on workflow_dispatch,
nightly schedule, and PRs touching auth-relevant paths (route builders,
rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself).
Run locally:
cd apps/webapp
pnpm exec vitest --config vitest.e2e.full.config.ts