mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
test(integration): boot freight API in-process across parallel shards
The suite drove a containerized freight API, so every code change needed an image rebuild before a test could see it, and there was no way to attach a debugger. Files also ran strictly in sequence against one shared database, which is the root of the warm-stack gotchas the README documents: stowaway paid bookings climbing back aboard, a short consist on the fifth file. The freight app now boots inside each vitest worker from dist/, and each worker owns a whole shard of the topology - its own database, payment API, gateway mock and broker vhost - so nothing mutable is shared and files run in parallel. Full suite drops from roughly 20 minutes to 196s at 4 shards. - main.ts exports createFreightApp() so the harness applies the same prefix, pipes, filters and interceptors as production instead of replaying them by hand; self-start is guarded by require.main so the Dockerfile CMD still boots - booking-window tick cadence is env-driven (BOOKING_WINDOW_TICK_CRON), */1 in the suite, */10 unchanged in production - prepare-shards.mjs seeds a template database (boot seeders, then the SQL fixtures that depend on them) and clones it per shard; it.mjs re-clones on every run, so each run is hermetic - gateway mock and payment API are generated per shard: the mock keeps modes and orders process-global and 20 of 25 specs reset it in beforeAll, and the inbound CBE bill query has to reach one specific shard's app - poll() samples every 250ms instead of 2000ms, keeping the caller's deadline - authz.it.ts seeds its own invoice; it previously read another spec's leftover and returned early, which silently passed on a pristine database Known: an unlocked MAX(sequence_no)+1 in train-scheduling.service.ts races under concurrent allocation and leaves a short consist, so 1-3 specs fail intermittently. Pre-existing and reproduces at the production tick cadence.
This commit is contained in:
134
integration/scripts/prepare-shards.mjs
Normal file
134
integration/scripts/prepare-shards.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the seeded template database, then clone it once per shard.
|
||||
*
|
||||
* node integration/scripts/prepare-shards.mjs # seed template + clone
|
||||
* node integration/scripts/prepare-shards.mjs --clone # clone only (per-run reset)
|
||||
*
|
||||
* Seeding has to happen here, not in vitest's globalSetup, because the order is
|
||||
* load-bearing and half of it is the app itself:
|
||||
*
|
||||
* 1. freight migrations (freight-migration-e2e container, before us)
|
||||
* 2. the app's always-on boot seeders — org, units, positions, permissions
|
||||
* (app.module.ts onApplicationBootstrap)
|
||||
* 3. the SQL fixtures, which declare those as prerequisites
|
||||
* (seed-users.sql: "created by the API's always-on boot seeders")
|
||||
*
|
||||
* Step 2 needs a real Nest boot, and the app now lives inside the vitest workers
|
||||
* — which globalSetup runs in a different process from. So we boot it once here
|
||||
* against the template and every shard is a `CREATE DATABASE … TEMPLATE` copy of
|
||||
* the result: a file copy on the tmpfs postgres, versus re-running migrations
|
||||
* and five seeders per shard.
|
||||
*
|
||||
* Run by it.mjs, which owns the env. No dependencies beyond `pg`.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "pg";
|
||||
|
||||
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = resolve(itDir, "..");
|
||||
const freightDir = join(repoRoot, "apps", "edr-freight-api");
|
||||
|
||||
const TEMPLATE = process.env.IT_TEMPLATE_DB ?? "edr_freight_e2e";
|
||||
const PREFIX = process.env.IT_DB_PREFIX ?? "edr_it_s";
|
||||
const SHARDS = Number(process.env.IT_SHARDS ?? 1);
|
||||
|
||||
const admin = () =>
|
||||
new Client({
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: Number(process.env.DB_PORT ?? 5543),
|
||||
user: process.env.DB_USER ?? "edr_e2e",
|
||||
password: process.env.DB_PASSWORD ?? "edr_e2e",
|
||||
// Never the template itself: CREATE DATABASE … TEMPLATE refuses while any
|
||||
// session is connected to the source.
|
||||
database: "postgres",
|
||||
});
|
||||
|
||||
const CYPRESS_FIXTURES = join(repoRoot, "e2e", "freight", "cypress", "fixtures");
|
||||
const OWN_FIXTURES = join(itDir, "sql");
|
||||
|
||||
/** Order matters — company needs the users, everything needs the corridor. */
|
||||
const SEEDS = [
|
||||
[CYPRESS_FIXTURES, "seed-users.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-company.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g2-weight.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-government.sql"],
|
||||
[OWN_FIXTURES, "seed-company-b.sql"],
|
||||
[OWN_FIXTURES, "seed-customs-service-type.sql"],
|
||||
];
|
||||
|
||||
async function seedTemplate() {
|
||||
console.log(`it: seeding template ${TEMPLATE}`);
|
||||
|
||||
process.env.DB_NAME = TEMPLATE;
|
||||
// No broker for the template boot: payment.module.ts skips RabbitMQModule
|
||||
// entirely when this is unset, and nothing here publishes.
|
||||
delete process.env.PAYMENT_RABBITMQ_URL;
|
||||
|
||||
const { createFreightApp } = createRequire(join(freightDir, "package.json"))("./dist/main.js");
|
||||
|
||||
// createFreightApp() only builds the graph. `init()` is what fires
|
||||
// onApplicationBootstrap — the seeders we are here for. We never listen.
|
||||
const app = await createFreightApp();
|
||||
try {
|
||||
await app.init();
|
||||
console.log("it: boot seeders done (org, units, positions, permissions)");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: Number(process.env.DB_PORT ?? 5543),
|
||||
user: process.env.DB_USER ?? "edr_e2e",
|
||||
password: process.env.DB_PASSWORD ?? "edr_e2e",
|
||||
database: TEMPLATE,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
for (const [dir, file] of SEEDS) {
|
||||
await client.query(readFileSync(join(dir, file), "utf8"));
|
||||
console.log(`it: seeded ${file}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneShards() {
|
||||
const pg = admin();
|
||||
await pg.connect();
|
||||
try {
|
||||
// The app's pool and any stray psql hold the template open; without this the
|
||||
// CREATE below fails with "source database is being accessed by other users".
|
||||
await pg.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
||||
[TEMPLATE],
|
||||
);
|
||||
|
||||
for (let i = 0; i < SHARDS; i++) {
|
||||
const db = `${PREFIX}${i}`;
|
||||
await pg.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
||||
[db],
|
||||
);
|
||||
await pg.query(`DROP DATABASE IF EXISTS ${db}`);
|
||||
await pg.query(`CREATE DATABASE ${db} TEMPLATE ${TEMPLATE}`);
|
||||
console.log(`it: shard ${i} → ${db}`);
|
||||
}
|
||||
} finally {
|
||||
await pg.end();
|
||||
}
|
||||
}
|
||||
|
||||
const cloneOnly = process.argv.includes("--clone");
|
||||
if (!cloneOnly) await seedTemplate();
|
||||
await cloneShards();
|
||||
Reference in New Issue
Block a user