UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

316 lines (315 loc) • 13.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.applyStepEvent = applyStepEvent; exports.markInstallFailed = markInstallFailed; exports.uncoveredFailedStepIds = uncoveredFailedStepIds; exports.hasPendingCommitDebt = hasPendingCommitDebt; exports.coveringLandedEntries = coveringLandedEntries; exports.latestRound = latestRound; exports.splitMigrationId = splitMigrationId; exports.stepsToPendingMigrations = stepsToPendingMigrations; exports.commitResultToLedgerEntry = commitResultToLedgerEntry; const PROMPT_OUTCOME_TO_STEP_STATUS = { completed: 'succeeded', skipped: 'skipped', failed: 'failed', }; function applyStepEvent(state, event) { const index = state.steps.findIndex((s) => s.id === event.stepId); if (index === -1) { return { kind: 'error', reason: `No step with id '${event.stepId}' in run state.`, }; } const step = state.steps[index]; switch (event.type) { case 'dispense': // 'failed' and 'died' steps are re-armed to 'pending' by a stepAction // first; this event never dispenses them directly. if (step.status !== 'pending') return illegal(step, event.type); return commit(state, index, { ...step, status: 'dispensed', dispenseCount: step.dispenseCount + 1, }); case 'start': if (step.status !== 'dispensed') return illegal(step, event.type); return commit(state, index, { ...step, status: 'running', pid: event.pid, startedAt: event.startedAt, }); case 'succeed': if (step.status !== 'running') return illegal(step, event.type); return commit(state, index, { ...step, status: 'succeeded', finishedAt: event.finishedAt, ...(event.outcome ? { outcome: event.outcome } : {}), }); case 'fail': if (step.status !== 'running') return illegal(step, event.type); return commit(state, index, { ...step, status: 'failed', finishedAt: event.finishedAt, ...(event.outcome ? { outcome: event.outcome } : {}), }); case 'awaitPromptOutcome': if (step.status !== 'running') return illegal(step, event.type); return commit(state, index, { ...step, status: 'awaiting-prompt-outcome', finishedAt: event.finishedAt, }); case 'markGeneratorCompleted': if (step.status !== 'running') return illegal(step, event.type); return commit(state, index, { ...step, generatorCompleted: true }); case 'foldPromptOutcome': if (step.status !== 'awaiting-prompt-outcome') return illegal(step, event.type); if (step.attempt !== event.attempt) return staleAttempt(step, event.type, event.attempt); return commit(state, index, { ...step, status: PROMPT_OUTCOME_TO_STEP_STATUS[event.promptOutcome.status], promptOutcome: event.promptOutcome, }); case 'markDied': // A step awaiting a prompt outcome has no live process left to die; // only a running step can. if (step.status !== 'running') return illegal(step, event.type); if (step.attempt !== event.attempt) return staleAttempt(step, event.type, event.attempt); return commit(state, index, { ...step, status: 'died' }); case 'stepAction': if (step.attempt !== event.attempt) return staleAttempt(step, event.type, event.attempt); return applyStepAction(state, index, step, event.action); default: { const exhaustive = event; return exhaustive; } } } function applyStepAction(state, index, step, action) { if (step.status === 'failed') { switch (action) { case 'retry': // Nothing resets the tree, so the generator's changes are still in it. // A pre-marker retry is additionally gated at the reconcile acceptance // site, which can read the live tree; this pure layer cannot. return commit(state, index, rearm(step, true)); case 'retry-clean': // A failed generator can have written before throwing (a direct fs or // exec side effect, or a crash mid-flush), so a reset-backed retry is // offered under the same guard as for a death. return commit(state, index, cleanRearm(state, step)); case 'skip': return commit(state, index, { ...step, status: 'skipped' }); } } if (step.status === 'died') { switch (action) { case 'retry': // Same no-reset rearm as from 'failed', and legal only once the // generator half is recorded (or the step never had one): that is // what leaves the redispensed worker something to do (a prompt, or // the install and commit) other than reapplying the generator over // its own changes. if (step.generatorCompleted === true || step.hasGenerator === false) { return commit(state, index, rearm(step, true)); } return { kind: 'error', reason: `Cannot apply action 'retry' to step '${step.id}': the worker died before recording that its generator ran, so keeping the current tree could apply the migration twice. Use 'retry-clean', 'adopt' or 'skip' instead.`, }; case 'retry-clean': return commit(state, index, cleanRearm(state, step)); case 'adopt': return commit(state, index, { ...step, status: 'succeeded', outcome: { ...step.outcome, summary: adoptedSummary(step) }, }); case 'skip': // Same as skipping a failure: the tree stays as the worker left it. return commit(state, index, { ...step, status: 'skipped' }); } } return { kind: 'error', reason: `Cannot apply action '${action}' to step '${step.id}' in status '${step.status}'.`, }; } // Re-arms for a retry that resets the tree first. The reset target predates // the generator unless a commit of this step's own already carries it, so the // marker only survives when such a commit exists; otherwise the reset discards // the generator's changes and the retry has to run it again. function cleanRearm(state, step) { return rearm(step, coveringLandedEntries(state, step.id).length > 0); } // An adopted death records how far the worker got, since 'succeeded' alone // says the migration was applied and cannot say by what. function adoptedSummary(step) { return step.generatorCompleted === true ? "Adopted after the worker died: its generator had run, and the working tree it left was taken as this migration's result." : "Adopted after the worker died before recording that its generator had run; the working tree it left was taken as this migration's result."; } // Re-arms a step for a fresh attempt. Drops every field the previous attempt // wrote (pid, timestamps, git ref, tree state, outcomes) so a later success // can't carry a stale failure outcome; dispenseCount stays cumulative across // attempts. // `keepGeneratorCompleted` says whether the generator's changes reach the new // attempt. They do when nothing resets the tree, and when the reset target // already contains the commit that landed them; re-running the generator there // would apply them twice. They do not when the reset discards them, and // keeping the marker then would skip the generator and record a success for a // migration that never ran. The step kind is a plan fact, not an attempt's, // and always survives. So does the dependency baseline: it tracks // the last dependencies that were installed, so dropping it here would leave // the retry with nothing to detect the previous attempt's package.json edits. function rearm(step, keepGeneratorCompleted) { return { id: step.id, roundIndex: step.roundIndex, migrationId: step.migrationId, status: 'pending', attempt: step.attempt + 1, dispenseCount: step.dispenseCount, ...(step.hasGenerator !== undefined ? { hasGenerator: step.hasGenerator } : {}), ...(step.depsHashAtDispense !== undefined ? { depsHashAtDispense: step.depsHashAtDispense } : {}), ...(keepGeneratorCompleted && step.generatorCompleted ? { generatorCompleted: true } : {}), }; } // A guarded transition whose observation was made against an earlier attempt // of the same step: the status recurred, so the observation says nothing about // the attempt on disk now. function staleAttempt(step, eventType, observedAttempt) { return { kind: 'error', reason: `Cannot apply '${eventType}' to step '${step.id}': it was observed on attempt ${observedAttempt} and the step is now on attempt ${step.attempt}.`, }; } function illegal(step, eventType) { return { kind: 'error', reason: `Cannot apply '${eventType}' to step '${step.id}' in status '${step.status}'.`, }; } function commit(state, index, updatedStep) { const nextSteps = state.steps.slice(); nextSteps[index] = updatedStep; return { kind: 'ok', state: { ...state, steps: nextSteps } }; } /** * Records that the run could not install the dependency changes a step left * behind. Not a {@link StepEvent}: it annotates a step instead of moving it, * and every status can carry it, since the orchestrator marks a step it has * just settled while the worker marks one it is about to fail. */ function markInstallFailed(state, stepId) { return { ...state, steps: state.steps.map((s) => s.id === stepId ? { ...s, installFailed: true } : s), }; } // Step ids named by a 'failed' ledger entry with no later 'landed' entry // covering them. function uncoveredFailedStepIds(state) { const uncovered = new Set(); const { commits } = state; for (let i = 0; i < commits.length; i++) { if (commits[i].kind !== 'failed') continue; for (const stepId of commits[i].stepIds) { const covered = commits .slice(i + 1) .some((c) => c.kind === 'landed' && c.stepIds.includes(stepId)); if (!covered) uncovered.add(stepId); } } return [...uncovered]; } /** * A step has commit debt when a 'failed' ledger entry names it and no later * 'landed' entry also names it (checkpoint entries neither create nor cover * debt). There is no per-step commit object; debt is always derived from the * ledger. */ function hasPendingCommitDebt(state) { return uncoveredFailedStepIds(state).length > 0; } // The 'landed' ledger entries naming the given step, in ledger order. function coveringLandedEntries(state, stepId) { return state.commits.filter((commit) => commit.kind === 'landed' && commit.stepIds.includes(stepId)); } // The round with the highest index. function latestRound(state) { return state.rounds.reduce((newest, round) => (!newest || round.index > newest.index ? round : newest), undefined); } // '<package>:<name>' splits on the first ':', leaving names that contain a ':' // intact; a bare id has no package. function splitMigrationId(id) { const colon = id.indexOf(':'); return colon === -1 ? { package: '', name: id } : { package: id.slice(0, colon), name: id.slice(colon + 1) }; } // Maps absorbed step ids to `{package, name}` for the commit body; an id with // no matching step, or one whose migration id carries no package, can't be // attributed there. function stepsToPendingMigrations(state, stepIds) { const pending = []; for (const id of stepIds) { const migrationId = state.steps.find((s) => s.id === id)?.migrationId; if (!migrationId) continue; const { package: pkg, name } = splitMigrationId(migrationId); if (!pkg) continue; pending.push({ package: pkg, name }); } return pending; } /** * Classifies a commit attempt into the ledger entry to record, or null when * there is nothing to record ('no-changes' / 'disabled'). A landed entry * covers the absorbed steps too: the commit's `git add -A` captured their * diffs. A failed entry records only this step's debt. */ function commitResultToLedgerEntry(result, stepId, absorbedStepIds) { switch (result.status) { case 'committed': return { kind: 'landed', ...(result.sha ? { sha: result.sha } : {}), stepIds: [stepId, ...absorbedStepIds], }; case 'failed': return { kind: 'failed', stepIds: [stepId] }; case 'no-changes': case 'disabled': return null; default: { const exhaustive = result; return exhaustive; } } }