feat(versionresolver): support skip-forward resume for orphan ledger versions

This commit is contained in:
2026-04-08 13:36:38 +00:00
parent 5ffeeefc7a
commit 4e3fd845d4
6 changed files with 104 additions and 15 deletions

View File

@@ -1,5 +1,12 @@
# Changelog
## 2026-04-08 - 1.2.0 - feat(versionresolver)
support skip-forward resume for orphan ledger versions
- Update migration planning to resume from the first step whose toVersion is greater than the current ledger version instead of requiring an exact fromVersion match.
- Log when a migration step runs in skip-forward mode so operators can identify idempotent replays against partially migrated data.
- Add tests and documentation for skip-forward resume behavior and TARGET_NOT_REACHABLE error handling when the chain cannot advance.
## 2026-04-07 - 1.1.1 - fix(repo)
no changes to commit

View File

@@ -118,6 +118,18 @@ Each migration is a `step` with:
Steps execute in **registration order**. The runner validates that the chain is contiguous: `step[N].to === step[N+1].from`. This catches gaps and overlaps at definition time, before any handler runs.
#### Resume modes
When `run()` reads the ledger and finds a current version, it computes a plan: the subset of steps needed to advance from `currentVersion` to `targetVersion`. Two resume modes are supported:
1. **Exact resume**`currentVersion === step.fromVersion` for some step. The normal case, where the ledger sits exactly at a step's starting point (because the previous step's `to` was written to the ledger when it completed).
2. **Skip-forward resume**`currentVersion > step.fromVersion` but `currentVersion < step.toVersion`. The **orphan case**: the ledger was stamped to an intermediate version that no registered step starts at. This typically happens when an app configures `freshInstallVersion: targetVersion` across several releases that didn't add any migrations — fresh installs get stamped to whatever `commitinfo.version` was at install time, not to the last step's `to`. When a migration is finally added, those installs have a ledger value that doesn't match any step's `from`.
In skip-forward mode, the planner picks the first step whose `toVersion > currentVersion` and runs it (and all subsequent steps) normally. The step's handler is being invoked against data that may already be partially in the target shape, so **step handlers must be idempotent** (use `$set` over `$inc`, check existence before insert, filter-based `updateMany` over cursor iteration where possible). A log line at INFO level announces when a step runs in skip-forward mode.
If no step's `toVersion` is greater than `currentVersion` (the ledger is past the end of the chain), the runner throws `TARGET_NOT_REACHABLE`.
### The ledger
The ledger is the source of truth for "what data version are we at, what steps have been applied, who holds the lock right now." It is persisted in one of two backends:
@@ -339,9 +351,13 @@ Another instance crashed while holding the lock. Wait for `lockTtlMs` (default 1
Two adjacent steps have mismatched versions: `step[N].to !== step[N+1].from`. Steps must form a contiguous chain in registration order. Fix the version on the offending step.
### `CHAIN_NOT_AT_CURRENT`
### `CHAIN_NOT_AT_CURRENT` (legacy)
The ledger says the data is at version X, but no registered step starts at X. This usually happens when you delete a step that has already been applied to production data. Either keep the step or manually update the ledger's `currentVersion`.
Retained in the error vocabulary for backward compatibility with downstream consumers that previously branched on it, but **no longer thrown by `computePlan` in normal operation**. Prior versions of smartmigration required an exact `fromVersion === currentVersion` match when resolving the plan; the current planner supports [skip-forward resume](#resume-modes) and handles intermediate-version ledger stamps transparently.
### `TARGET_NOT_REACHABLE`
Either (a) a step in the plan upgrades to a version past `targetVersion` without any step ending exactly at `targetVersion`, or (b) the ledger's `currentVersion` is past the end of the registered chain but has not reached `targetVersion`. Case (a) means the chain has a mid-step that overshoots — add the missing final step or adjust `targetVersion`. Case (b) means the chain needs a new step extending it toward `targetVersion`.
### S3-only deployments and concurrent instances

View File

@@ -109,18 +109,48 @@ tap.test('computePlan: returns sub-slice when target is mid-chain', async () =>
expect(plan.map((s) => s.id)).toEqual(['a', 'b']);
});
tap.test('computePlan: throws when current does not match any step from', async () => {
tap.test('computePlan: skip-forward resume when current is inside a step range', async () => {
// Chain: 1.0.0 → 1.1.0 → 2.0.0 → 3.0.0
// Orphan install is stamped at 1.2.5 (between steps b and c). The planner
// should resume at the first step whose toVersion > 1.2.5 (step b: 1.1.0 → 2.0.0),
// then run step c to reach the target.
const steps = [
makeStep('a', '1.0.0', '1.1.0'),
makeStep('b', '1.1.0', '2.0.0'),
makeStep('c', '2.0.0', '3.0.0'),
];
const plan = VersionResolver.computePlan(steps, '1.2.5', '3.0.0');
expect(plan.map((s) => s.id)).toEqual(['b', 'c']);
});
tap.test('computePlan: skip-forward when current is below the first step fromVersion', async () => {
// Chain: 1.0.0 → 1.1.0 → 2.0.0
// Current 0.9.0 (below the chain's starting point). Should resume at step a
// (its toVersion 1.1.0 > 0.9.0).
const steps = [
makeStep('a', '1.0.0', '1.1.0'),
makeStep('b', '1.1.0', '2.0.0'),
];
const plan = VersionResolver.computePlan(steps, '0.9.0', '2.0.0');
expect(plan.map((s) => s.id)).toEqual(['a', 'b']);
});
tap.test('computePlan: throws TARGET_NOT_REACHABLE when current is past all step toVersions', async () => {
// Chain: 1.0.0 → 1.1.0 → 2.0.0
// Current 2.5.0 (past the chain's ending point), target 3.0.0.
// No step can advance us (all toVersions ≤ 2.5.0 < target). Throw.
const steps = [
makeStep('a', '1.0.0', '1.1.0'),
makeStep('b', '1.1.0', '2.0.0'),
];
let caught: SmartMigrationError | undefined;
try {
VersionResolver.computePlan(steps, '1.0.5', '2.0.0');
VersionResolver.computePlan(steps, '2.5.0', '3.0.0');
} catch (err) {
caught = err as SmartMigrationError;
}
expect(caught!.code).toEqual('CHAIN_NOT_AT_CURRENT');
expect(caught).toBeDefined();
expect(caught!.code).toEqual('TARGET_NOT_REACHABLE');
});
tap.test('computePlan: throws when target overshoots', async () => {

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartmigration',
version: '1.1.1',
version: '1.2.0',
description: 'Unified migration runner for MongoDB (smartdata) and S3 (smartbucket) — designed to be invoked at SaaS app startup, with semver-based version tracking, sequential step execution, idempotent re-runs, and per-step resumable checkpoints.'
}

View File

@@ -244,6 +244,22 @@ export class SmartMigration {
const stepStart = Date.now();
let entry: IMigrationLedgerEntry;
try {
// Detect skip-forward resume: the running ledger cursor is past
// this step's fromVersion but hasn't yet reached its toVersion.
// The step handler is being run against data that may already be
// partially in the target shape — handlers must be idempotent.
const isSkipForward = VersionResolver.greaterThan(
currentVersion,
step.fromVersion,
);
if (isSkipForward) {
this.log.log(
'info',
`smartmigration: step "${step.id}" running in skip-forward mode ` +
`(ledger at "${currentVersion}", step starts at "${step.fromVersion}"). ` +
`Step handler must be idempotent.`,
);
}
this.log.log(
'info',
`smartmigration: running step "${step.id}" (${step.fromVersion}${step.toVersion})`,
@@ -273,6 +289,8 @@ export class SmartMigration {
data.steps[step.id] = entry;
data.currentVersion = step.toVersion;
await ledger.write(data);
// Advance the running cursor used by skip-forward detection.
currentVersion = step.toVersion;
applied.push({ ...entry });
this.log.log(
'info',

View File

@@ -110,11 +110,27 @@ export class VersionResolver {
*
* Behavior:
* - If currentVersion === targetVersion → returns []
* - Otherwise, finds the step whose `fromVersion === currentVersion`
* and returns it plus all subsequent steps up to (and including)
* the one whose `toVersion === targetVersion`.
* - If no step starts at currentVersion → throws CHAIN_NOT_AT_CURRENT.
* - If walking past targetVersion never matches → throws TARGET_NOT_REACHABLE.
* - Otherwise, finds the first step whose `toVersion > currentVersion`
* (i.e. the first step that hasn't been fully applied yet) and returns
* it plus all subsequent steps up to (and including) the one whose
* `toVersion === targetVersion`.
* - Supports two resume modes:
* 1. **Exact resume**: currentVersion === step.fromVersion — the normal
* case, where the ledger sits exactly at a step's starting point.
* 2. **Skip-forward resume**: currentVersion > step.fromVersion but
* currentVersion < step.toVersion — the orphan case, where the
* ledger was stamped to an intermediate version that no registered
* step starts at (e.g. fresh installs using
* `freshInstallVersion: targetVersion` across releases that didn't
* add migrations). Skip-forward assumes step handlers are
* idempotent (safe to re-run against data already partially in the
* target shape). This is the documented contract for all step
* handlers.
* - If a step's `toVersion` overshoots `targetVersion` → throws
* TARGET_NOT_REACHABLE.
* - If no step can advance `currentVersion` toward `targetVersion`
* (currentVersion is past the end of the chain) → throws
* TARGET_NOT_REACHABLE.
* - If currentVersion > targetVersion → throws DOWNGRADE_NOT_SUPPORTED.
*/
public static computePlan(
@@ -137,12 +153,14 @@ export class VersionResolver {
);
}
const startIndex = steps.findIndex((s) => this.equals(s.fromVersion, currentVersion));
// Find the first step that hasn't been fully applied yet.
// A step is "not yet applied" iff its toVersion > currentVersion.
const startIndex = steps.findIndex((s) => this.greaterThan(s.toVersion, currentVersion));
if (startIndex === -1) {
throw new SmartMigrationError(
'CHAIN_NOT_AT_CURRENT',
`No registered migration step starts at the current data version "${currentVersion}". Registered chain: ${this.describeChain(steps)}`,
{ currentVersion, registeredChain: steps.map((s) => `${s.fromVersion}${s.toVersion}`) },
'TARGET_NOT_REACHABLE',
`Current data version "${currentVersion}" is past the end of the registered chain, but target "${targetVersion}" has not been reached. No step can advance. Registered chain: ${this.describeChain(steps)}`,
{ currentVersion, targetVersion, registeredChain: steps.map((s) => `${s.fromVersion}${s.toVersion}`) },
);
}