conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit a7d3cdd18d7d49596731c3d1f4e886ad4e6bc2d2
parent 6b4544cc2b3b44c600abc16f870c728cff3e5786
Author: finwo <finwo@pm.me>
Date:   Tue, 29 Sep 2026 22:02:47 +0200

Settle a job after the task outcome commits rather than inside it

Diffstat:
Mdocs/pipeline.md | 6+++---
Msrc/conductor/app.js | 3+++
Msrc/conductor/scheduler.js | 55+++++++++++++++++++++++++++++++++++++++++++------------
Mtest/conductor.test.js | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/dialects.test.js | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/only.test.js | 4++--
6 files changed, 167 insertions(+), 17 deletions(-)

diff --git a/docs/pipeline.md b/docs/pipeline.md @@ -116,9 +116,9 @@ A `*` stands for any run of characters; everything else is literal, so a dot is a dot rather than any character. A task that does not match is left out of the job entirely, not recorded -as skipped. A skipped task means a dependency collapsed and fails the job, -which is the wrong reading for a publish step that was never meant to run -on this branch. +as skipped. A skipped task still satisfies `needs`, so everything below it +runs anyway, which is the wrong reading for a publish step that was never +meant to run on this branch. Anything depending on an excluded task is excluded with it. Dropping the dependency instead would let a task run without something it declared it diff --git a/src/conductor/app.js b/src/conductor/app.js @@ -113,6 +113,9 @@ export function startReaper(services) { running = true; try { await scheduler.reap(); + for (const job of await scheduler.settleStalled()) { + logger.warn?.(`settled stalled job ${job.id} as ${job.state}`); + } } catch (e) { logger.error?.(`reaper failed: ${e.message}`); } finally { diff --git a/src/conductor/scheduler.js b/src/conductor/scheduler.js @@ -119,7 +119,8 @@ export function createScheduler({ return found[0] ?? null; } - // Marks the job finished once nothing is left to do. + // Needs a transaction opened after the task outcome committed: a REPEATABLE + // READ snapshot older than a sibling's commit still sees it running. async function settleJob(tx, jobId) { const outstanding = await tx.get( "SELECT COUNT(*) AS c FROM tasks WHERE job_id = {job} AND state IN ('queued', 'running')", @@ -135,13 +136,16 @@ export function createScheduler({ { job: jobId } ); const state = bad.c > 0 ? 'failed' : 'success'; - await tx.run( + const moved = await tx.run( "UPDATE jobs SET state = {state}, finished_at = {now} WHERE id = {job} AND state = 'running'", { state, job: jobId, now: Date.now() } ); - return state; + // Only the completion that moved the row reports it: settle fires once. + return moved.changes === 1 ? state : null; } + const settle = (jobId) => db.transaction((tx) => settleJob(tx, jobId)); + // Background compilation: git sync, pipeline read/compile, task creation. // Updates the job from 'pending' to either 'running' (with tasks) or // 'failed' (with an error). This is what lets a push hook return immediately. @@ -436,7 +440,7 @@ export function createScheduler({ outputs = [], tokenId = null, }) { const outcome = reported ?? (success ? 'success' : 'failed'); - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const task = await tx.get( `SELECT id, job_id, name, state, attempt, max_attempts, allow_failure, worker_token_id FROM tasks WHERE id = {id}`, @@ -460,8 +464,7 @@ export function createScheduler({ { id: taskId, state: outcome, code: exitCode, now } ); await writeOutputs(tx, taskId, outputs); - const jobState = await settleJob(tx, task.job_id); - return { ok: true, state: outcome, jobState }; + return { ok: true, state: outcome, settleJobId: task.job_id }; } // Transient failure with attempts left: back to the queue rather @@ -492,15 +495,22 @@ export function createScheduler({ skipped = await skipDependents(tx, task.job_id, [taskId], now); } - const jobState = await settleJob(tx, task.job_id); - return { ok: true, state: 'failed', skipped, jobState }; + return { ok: true, state: 'failed', skipped, settleJobId: task.job_id }; }); + + if (result.settleJobId === undefined) return result; + const { settleJobId, ...rest } = result; + return { ...rest, jobState: await settle(settleJobId) }; }, // Every task reachable from the failed ones, not just their immediate // dependents. async skipDependents(jobId, fromTaskIds) { - return db.transaction((tx) => skipDependents(tx, jobId, fromTaskIds, Date.now())); + const skipped = await db.transaction( + (tx) => skipDependents(tx, jobId, fromTaskIds, Date.now()) + ); + await settle(jobId); + return skipped; }, // Requeues or fails tasks whose worker stopped reporting. @@ -528,6 +538,26 @@ export function createScheduler({ return results; }, + // Recovers jobs whose settle was lost to a crash between the outcome and + // the settle transaction. + async settleStalled() { + const stalled = await db.all( + `SELECT id FROM jobs j + WHERE j.state = 'running' + AND NOT EXISTS ( + SELECT 1 FROM tasks t + WHERE t.job_id = j.id AND t.state IN ('queued', 'running') + )` + ); + + const settled = []; + for (const job of stalled) { + const state = await settle(job.id); + if (state !== null) settled.push({ id: job.id, state }); + } + return settled; + }, + async cancelJob(jobId, reason = 'cancelled by request') { return db.transaction(async (tx) => { const job = await tx.get('SELECT id, state FROM jobs WHERE id = {id}', { id: jobId }); @@ -624,17 +654,18 @@ export function createScheduler({ // Never claimed, so it never ran: failed straight from queued. async function failUnclaimable(candidate, message) { - await db.transaction(async (tx) => { + const claimed = await db.transaction(async (tx) => { const now = Date.now(); const marked = await tx.run( `UPDATE tasks SET state = 'failed', error = {error}, finished_at = {now} WHERE id = {id} AND state = 'queued'`, { id: candidate.id, error: message, now } ); - if (marked.changes !== 1) return; + if (marked.changes !== 1) return false; await skipDependents(tx, candidate.job_id, [candidate.id], now); - await settleJob(tx, candidate.job_id); + return true; }); + if (claimed) await settle(candidate.job_id); logger.warn?.(`task ${candidate.name} cannot run: ${message}`); } diff --git a/test/conductor.test.js b/test/conductor.test.js @@ -1631,6 +1631,63 @@ tasks: }); }); +// Regression: settling inside the completing transaction stranded jobs. +test('a job whose settle was lost is recovered by the sweep', async () => { + const pipeline = ` +version: 1 +defaults: + image: alpine +tasks: + build: + script: ['true'] +`; + await withHarness({ pipeline }, async (h) => { + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; + await h.waitForJob(job); + + const build = await claimNamed(h, 'build', {}); + assert.equal((await finish(h, build.id, { success: true })).job_state, 'success'); + + await h.services.db.run( + "UPDATE jobs SET state = 'running', finished_at = NULL WHERE id = {id}", + { id: job } + ); + + const settled = await h.services.scheduler.settleStalled(); + assert.deepEqual(settled, [{ id: job, state: 'success' }]); + + const row = await h.services.db.get('SELECT state, finished_at FROM jobs WHERE id = {id}', + { id: job }); + assert.equal(row.state, 'success'); + assert.ok(row.finished_at > 0, 'the recovered job gets a finish time'); + }); +}); + +test('the sweep leaves a job that still has work to do alone', async () => { + const pipeline = ` +version: 1 +defaults: + image: alpine +tasks: + build: + script: ['true'] + publish: + script: ['true'] + needs: [build] +`; + await withHarness({ pipeline }, async (h) => { + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; + await h.waitForJob(job); + + const build = await claimNamed(h, 'build', {}); + await finish(h, build.id, { success: true }); + + assert.deepEqual(await h.services.scheduler.settleStalled(), []); + const row = await h.services.db.get('SELECT state FROM jobs WHERE id = {id}', { id: job }); + assert.equal(row.state, 'running'); + }); +}); + test('a skipped task that owed a value fails the one that needed it', async () => { const pipeline = ` version: 1 diff --git a/test/dialects.test.js b/test/dialects.test.js @@ -24,6 +24,7 @@ import { createLogStore } from '../src/lib/log.js'; import { createRetention } from '../src/conductor/retention.js'; import { createWorkerTokens } from '../src/lib/workers.js'; import { createRefreshTokens } from '../src/lib/auth/refresh.js'; +import { createScheduler } from '../src/conductor/scheduler.js'; const DAY = 24 * 60 * 60 * 1000; const NOW = 1_800_000_000_000; @@ -194,4 +195,62 @@ for (const dialect of ['postgres', 'mysql']) { await server.stop(); } }); + + // Snapshot-at-first-read dialects only; sqlite serialises and cannot reach this. + test(`simultaneous completions settle the job on ${dialect}`, { skip: !dialectAvailable(dialect) }, async () => { + const server = await startDatabase(dialect); + const cfg = { database: { url: server.url, dialect: dialectFromUrl(server.url) } }; + const width = 8; + + let db; + try { + db = await openDatabase(cfg); + await runMigrations(db, { logger: () => {} }); + + await db.run( + `INSERT INTO projects (id, name, repo_url, enabled, job_counter, created_at, updated_at) + VALUES ('demo', 'Demo', '/tmp/repo', 1, 0, {now}, {now})`, + { now: NOW } + ); + await db.run( + `INSERT INTO jobs (id, project_id, number, head_sha, trigger_type, state, + visibility, created_at, started_at) + VALUES ('j1', 'demo', 1, {sha}, 'push', 'running', 'public', {now}, {now})`, + { sha: 'a'.repeat(40), now: NOW } + ); + for (let i = 0; i < width; i += 1) { + await db.run( + `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state, + allow_failure, attempt, max_attempts, timeout, log_size, created_at) + VALUES ({id}, 'j1', {name}, {name}, 'alpine:3', '[]', '{}', 'running', + 0, 1, 1, 3600, 0, {now})`, + { id: `t${i}`, name: `build-${i}`, now: NOW } + ); + } + + const scheduler = createScheduler({ + cfg: { ...cfg, scheduler: {} }, + db, + git: {}, logs: {}, storage: {}, projects: {}, secrets: {}, + logger: { warn() {}, error() {}, info() {}, debug() {} }, + }); + + // Started together so every snapshot predates every commit; sequential + // completion hides the bug. + const outcomes = await Promise.all( + Array.from({ length: width }, (_, i) => scheduler.complete(`t${i}`, { success: true })) + ); + assert.ok(outcomes.every((o) => o.ok), 'every completion should be accepted'); + + const job = await db.get("SELECT state FROM jobs WHERE id = 'j1'", {}); + assert.equal(job.state, 'success', 'the last completion has to settle the job'); + assert.equal( + outcomes.filter((o) => o.jobState === 'success').length, 1, + 'exactly one completion should report the settle' + ); + } finally { + await db?.close().catch(() => {}); + await server.stop(); + } + }); } diff --git a/test/only.test.js b/test/only.test.js @@ -59,8 +59,8 @@ test('a restricted task is left out of jobs for other refs', () => { }); test('it is left out rather than recorded as skipped', () => { - // The scheduler reads a skipped task as a reason to fail the job, so a - // publish step that was never meant to run here must not appear at all. + // A skipped task satisfies needs, so recording it that way would let the + // steps below a never-intended publish run anyway. const { tasks } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); assert.ok(!tasks.some((j) => j.name === 'publish')); });