fix(workflows): restore the once-per-process seed guard

`booted` was assigned but never read, so the guard's early return was missing
and the builtin workflow seeder ran on every call.

Impact was wasteful, not harmful: seedOneBuiltin is idempotent -- it keys on
builtin_key and returns early when adminOwned or storedVersion >= def.version,
writing a graph only on a fresh insert or a version bump. So repeat calls cost
a lookup per builtin plus a graph rebuild, with no duplicate rows.

`booted = true` stays inside the try, so a seed that never got off the ground
(workflows table not migrated, DB down) leaves the flag clear and retries. A
per-builtin failure is still swallowed by the inner catch and does not block
the flag, unchanged.

Restoring the guard broke workflowEngine.test.js, which calls the boot seeder
seven times in one worker and needs the second call to run in two of them.
Followed the existing _backupPathsBoot/_restoreSettingsBoot precedent:
exported _resetBootForTests().

Refs testplan REPORT.md B3.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent 355fe4ff43
commit a7d45ddd0d
2 changed files with 53 additions and 17 deletions
@@ -34,6 +34,15 @@ async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = tr
return workflowId; return workflowId;
} }
// seedBuiltinWorkflowsAtBoot carries a once-per-process guard; these tests
// deliberately re-run it (idempotency, version bumps) inside one worker, so
// clear the flag each time.
async function seedBuiltins(logger) {
const mod = require('../../src/services/_workflowSeedBoot');
mod._resetBootForTests();
return mod.seedBuiltinWorkflowsAtBoot(db, logger);
}
beforeAll(async () => { beforeAll(async () => {
({ db, cleanup } = await bootCrmDb()); ({ db, cleanup } = await bootCrmDb());
// Engine requires the singleton db — require AFTER bootCrmDb wired the test path. // Engine requires the singleton db — require AFTER bootCrmDb wired the test path.
@@ -240,9 +249,9 @@ describe('workflow engine', () => {
}); });
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => { test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); const { DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} }; const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger); await seedBuiltins(noopLogger);
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy(); expect(wf).toBeTruthy();
@@ -256,13 +265,13 @@ describe('workflow engine', () => {
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true); expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true); expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version await seedBuiltins(noopLogger); // idempotent at current seed version
const all = await db('workflows').where({ builtin_key: DUNNING_KEY }); const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
expect(all.length).toBe(1); expect(all.length).toBe(1);
}); });
test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => { test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); const { DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} }; const noopLogger = { info() {}, warn() {} };
// Simulate an older, never-touched seed (v1, with a legacy gate node). // Simulate an older, never-touched seed (v1, with a legacy gate node).
@@ -270,7 +279,7 @@ describe('workflow engine', () => {
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) }); await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 }); await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
await seedBuiltinWorkflowsAtBoot(db, noopLogger); await seedBuiltins(noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first(); const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7); expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
@@ -281,15 +290,14 @@ describe('workflow engine', () => {
// Admin-owned (admin_toggled_at set) + stale → must NOT be touched. // Admin-owned (admin_toggled_at set) + stale → must NOT be touched.
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) }); await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) });
const before = await db('workflows').where({ id: wf.id }).first(); const before = await db('workflows').where({ id: wf.id }).first();
await seedBuiltinWorkflowsAtBoot(db, noopLogger); await seedBuiltins(noopLogger);
const after = await db('workflows').where({ id: wf.id }).first(); const after = await db('workflows').where({ id: wf.id }).first();
expect(after.version).toBe(before.version); // unchanged expect(after.version).toBe(before.version); // unchanged
expect(!!after.enabled).toBe(true); // admin's choice preserved expect(!!after.enabled).toBe(true); // admin's choice preserved
}); });
test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => { test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); await seedBuiltins({ info() {}, warn() {} });
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// First beta: cutover flows ship DISABLED (legacy paths run until enabled); // First beta: cutover flows ship DISABLED (legacy paths run until enabled);
// they delegate to the proven send functions once turned on. // they delegate to the proven send functions once turned on.
@@ -484,8 +492,7 @@ describe('workflow engine', () => {
}); });
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => { test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); await seedBuiltins({ info() {}, warn() {} });
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// All built-ins ship disabled → inactive until the admin enables one. // All built-ins ship disabled → inactive until the admin enables one.
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false); expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false);
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false); expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
@@ -496,8 +503,7 @@ describe('workflow engine', () => {
}); });
test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => { test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); await seedBuiltins({ info() {}, warn() {} }); // pre_event_email seeded DISABLED
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED
// crm_event_reminders_enabled must be on to reach the mutex guard. // crm_event_reminders_enabled must be on to reach the mutex guard.
await db('app_settings') await db('app_settings')
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' }) .insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
@@ -658,4 +664,22 @@ describe('workflow engine', () => {
const emailStep = steps.find((s) => s.node_key === 'a'); const emailStep = steps.find((s) => s.node_key === 'a');
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
}); });
test('the once-per-process guard short-circuits a second boot seed', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
await seedBuiltins({ info() {}, warn() {} });
// Make the row look stale + never-touched, so an UNGUARDED call would
// re-seed it (that's exactly what the version-bump test above asserts).
const before = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
await db('workflows').where({ id: before.id })
.update({ admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
// No _resetBootForTests() — `booted` is still set from seedBuiltins above.
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
const after = await db('workflows').where({ id: before.id }).first();
expect(after.version).toBe(before.version); // no re-seed happened
expect(JSON.parse(after.trigger_config).seedVersion).toBe(1);
});
}); });
+17 -5
View File
@@ -335,10 +335,12 @@ const BUILTINS = [
}, },
]; ];
// NOTE: written at the end of seedBuiltinWorkflowsAtBoot but never read — the // Seed-once-per-process guard. Re-seeding is idempotent (seedOneBuiltin
// intended "seed only once per process" guard is missing its `if (booted) return;` // updates in place, keyed on builtin_key, and skips admin-owned or
// check. Left in place so the gap stays visible rather than being silently dropped. // already-current rows), so the cost of a repeat call is a table scan per
// eslint-disable-next-line no-unused-vars -- write-only boot guard, see note above // builtin plus a graph rebuild — wasted work, not duplicate rows. Set inside
// the try, so a seed that never got off the ground (workflows table not
// migrated yet, DB down) leaves the flag clear and a later call can retry.
let booted = false; let booted = false;
function parseSeedConfig(raw) { function parseSeedConfig(raw) {
@@ -420,6 +422,7 @@ async function seedOneBuiltin(db, logger, def) {
} }
async function seedBuiltinWorkflowsAtBoot(db, logger) { async function seedBuiltinWorkflowsAtBoot(db, logger) {
if (booted) return;
try { try {
if (!(await db.schema.hasTable('workflows'))) return; if (!(await db.schema.hasTable('workflows'))) return;
for (const def of BUILTINS) { for (const def of BUILTINS) {
@@ -435,4 +438,13 @@ async function seedBuiltinWorkflowsAtBoot(db, logger) {
} }
} }
module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY, BUILTINS }; // Test-only: reset the module-level boot flag so jest can re-exercise the
// seeder against a fresh test DB inside a single worker. Matches
// _backupPathsBoot / _restoreSettingsBoot.
function _resetBootForTests() {
booted = false;
}
module.exports = {
seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY, BUILTINS, _resetBootForTests,
};