feat(migration): add lock heartbeats, predictive dry-run planning, and stricter ledger option validation
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmigration',
|
||||
version: '1.2.0',
|
||||
version: '1.3.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.'
|
||||
}
|
||||
|
||||
@@ -102,8 +102,5 @@ function extractS3Client(
|
||||
bucket: plugins.smartbucket.Bucket | undefined,
|
||||
): plugins.TRawS3Client | undefined {
|
||||
if (!bucket) return undefined;
|
||||
// `getStorageClient` is typed as the actual S3Client, but it lives behind
|
||||
// an optional peer dep type so we cast through unknown to keep this file
|
||||
// compiling when smartbucket isn't installed by the consumer.
|
||||
return (bucket as any).getStorageClient() as plugins.TRawS3Client | undefined;
|
||||
return bucket.getStorageClient();
|
||||
}
|
||||
|
||||
+246
-82
@@ -22,6 +22,22 @@ const DEFAULT_LEDGER_NAME = 'smartmigration';
|
||||
const DEFAULT_LOCK_WAIT_MS = 60_000;
|
||||
const DEFAULT_LOCK_TTL_MS = 600_000;
|
||||
const LOCK_POLL_INTERVAL_MS = 500;
|
||||
const MIN_LOCK_HEARTBEAT_MS = 1;
|
||||
|
||||
interface IResolvedLedgerState {
|
||||
currentVersionBefore: string | null;
|
||||
effectiveCurrentVersion: string;
|
||||
bootstrapVersionToPersist: string | null;
|
||||
bootstrapMode: 'none' | 'fresh-install-version' | 'chain-start' | 'target-without-steps';
|
||||
wasFreshInstall: boolean;
|
||||
plannedSteps: IMigrationStepDefinition[];
|
||||
predictedCurrentVersionAfter: string;
|
||||
}
|
||||
|
||||
interface ILockHeartbeat {
|
||||
getError(): SmartMigrationError | null;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SmartMigration — the runner. See readme.md for the full API.
|
||||
@@ -55,6 +71,10 @@ export class SmartMigration {
|
||||
}
|
||||
VersionResolver.assertValid(options.targetVersion, 'options.targetVersion');
|
||||
|
||||
if (options.freshInstallVersion !== undefined) {
|
||||
VersionResolver.assertValid(options.freshInstallVersion, 'options.freshInstallVersion');
|
||||
}
|
||||
|
||||
if (!options.db && !options.bucket) {
|
||||
throw new SmartMigrationError(
|
||||
'NO_RESOURCES',
|
||||
@@ -78,6 +98,28 @@ export class SmartMigration {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
options.ledgerName !== undefined &&
|
||||
(typeof options.ledgerName !== 'string' || options.ledgerName.trim() === '')
|
||||
) {
|
||||
throw new SmartMigrationError(
|
||||
'INVALID_LEDGER_NAME',
|
||||
'ledgerName must be a non-empty string when provided.',
|
||||
);
|
||||
}
|
||||
|
||||
if (options.lockWaitMs !== undefined) {
|
||||
this.assertIntegerOption('INVALID_LOCK_WAIT_MS', 'lockWaitMs', options.lockWaitMs, {
|
||||
min: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.lockTtlMs !== undefined) {
|
||||
this.assertIntegerOption('INVALID_LOCK_TTL_MS', 'lockTtlMs', options.lockTtlMs, {
|
||||
min: 1,
|
||||
});
|
||||
}
|
||||
|
||||
this.settings = {
|
||||
targetVersion: options.targetVersion,
|
||||
db: options.db,
|
||||
@@ -176,70 +218,44 @@ export class SmartMigration {
|
||||
|
||||
const runStart = Date.now();
|
||||
const applied: IMigrationStepResult[] = [];
|
||||
let wasFreshInstall = false;
|
||||
let currentVersionBefore: string | null = null;
|
||||
let lockHeartbeat: ILockHeartbeat | null = null;
|
||||
|
||||
try {
|
||||
lockHeartbeat = this.startLockHeartbeat(ledger);
|
||||
|
||||
// Re-read after acquiring lock (state may have changed while we waited).
|
||||
let data = await ledger.read();
|
||||
currentVersionBefore = data.currentVersion;
|
||||
const resolvedState = await this.resolveLedgerState(data);
|
||||
|
||||
// Resolve initial version.
|
||||
let currentVersion: string;
|
||||
if (data.currentVersion === null) {
|
||||
const fresh = await this.detectFreshInstall();
|
||||
if (fresh && this.settings.freshInstallVersion) {
|
||||
wasFreshInstall = true;
|
||||
currentVersion = this.settings.freshInstallVersion;
|
||||
VersionResolver.assertValid(currentVersion, 'freshInstallVersion');
|
||||
data.currentVersion = currentVersion;
|
||||
await ledger.write(data);
|
||||
this.log.log('info', `smartmigration: fresh install detected, jumping to ${currentVersion}`);
|
||||
} else {
|
||||
if (this.steps.length === 0) {
|
||||
// No steps and no current version — nothing to do.
|
||||
data.currentVersion = this.settings.targetVersion;
|
||||
await ledger.write(data);
|
||||
return {
|
||||
currentVersionBefore: null,
|
||||
currentVersionAfter: this.settings.targetVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: false,
|
||||
wasFreshInstall: true,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: Date.now() - runStart,
|
||||
};
|
||||
}
|
||||
currentVersion = this.steps[0].fromVersion;
|
||||
data.currentVersion = currentVersion;
|
||||
await ledger.write(data);
|
||||
if (resolvedState.bootstrapVersionToPersist !== null) {
|
||||
data.currentVersion = resolvedState.bootstrapVersionToPersist;
|
||||
await ledger.write(data);
|
||||
if (resolvedState.bootstrapMode === 'fresh-install-version') {
|
||||
this.log.log(
|
||||
'info',
|
||||
`smartmigration: fresh install detected, jumping to ${resolvedState.bootstrapVersionToPersist}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
currentVersion = data.currentVersion;
|
||||
}
|
||||
|
||||
// Already at target after fresh-install resolution?
|
||||
if (VersionResolver.equals(currentVersion, this.settings.targetVersion)) {
|
||||
this.assertLockHealthy(lockHeartbeat);
|
||||
|
||||
if (resolvedState.plannedSteps.length === 0) {
|
||||
return {
|
||||
currentVersionBefore,
|
||||
currentVersionAfter: currentVersion,
|
||||
currentVersionBefore: resolvedState.currentVersionBefore,
|
||||
currentVersionAfter: resolvedState.predictedCurrentVersionAfter,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: true,
|
||||
wasFreshInstall,
|
||||
wasUpToDate: resolvedState.bootstrapMode !== 'target-without-steps',
|
||||
wasFreshInstall: resolvedState.wasFreshInstall,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: Date.now() - runStart,
|
||||
};
|
||||
}
|
||||
|
||||
const plan = VersionResolver.computePlan(
|
||||
this.steps,
|
||||
currentVersion,
|
||||
this.settings.targetVersion,
|
||||
);
|
||||
let currentVersion = resolvedState.effectiveCurrentVersion;
|
||||
|
||||
for (const step of plan) {
|
||||
for (const step of resolvedState.plannedSteps) {
|
||||
const startedAt = new Date();
|
||||
const stepStart = Date.now();
|
||||
let entry: IMigrationLedgerEntry;
|
||||
@@ -272,6 +288,7 @@ export class SmartMigration {
|
||||
log: this.log,
|
||||
});
|
||||
await step.handler(ctx);
|
||||
this.assertLockHealthy(lockHeartbeat, { stepId: step.id });
|
||||
|
||||
const finishedAt = new Date();
|
||||
const durationMs = Date.now() - stepStart;
|
||||
@@ -288,6 +305,7 @@ export class SmartMigration {
|
||||
data = await ledger.read();
|
||||
data.steps[step.id] = entry;
|
||||
data.currentVersion = step.toVersion;
|
||||
delete data.checkpoints[step.id];
|
||||
await ledger.write(data);
|
||||
// Advance the running cursor used by skip-forward detection.
|
||||
currentVersion = step.toVersion;
|
||||
@@ -300,6 +318,15 @@ export class SmartMigration {
|
||||
const finishedAt = new Date();
|
||||
const durationMs = Date.now() - stepStart;
|
||||
const error = err as Error;
|
||||
const lockError = this.getLockHealthError(lockHeartbeat, {
|
||||
stepId: step.id,
|
||||
originalError: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
if (lockError) {
|
||||
throw lockError;
|
||||
}
|
||||
|
||||
entry = {
|
||||
id: step.id,
|
||||
fromVersion: step.fromVersion,
|
||||
@@ -330,18 +357,23 @@ export class SmartMigration {
|
||||
}
|
||||
}
|
||||
|
||||
this.assertLockHealthy(lockHeartbeat);
|
||||
|
||||
const finalData = await ledger.read();
|
||||
return {
|
||||
currentVersionBefore,
|
||||
currentVersionBefore: resolvedState.currentVersionBefore,
|
||||
currentVersionAfter: finalData.currentVersion ?? this.settings.targetVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: false,
|
||||
wasFreshInstall,
|
||||
wasFreshInstall: resolvedState.wasFreshInstall,
|
||||
stepsApplied: applied,
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: Date.now() - runStart,
|
||||
};
|
||||
} finally {
|
||||
if (lockHeartbeat) {
|
||||
await lockHeartbeat.stop();
|
||||
}
|
||||
await ledger.releaseLock(this.instanceId).catch((err) => {
|
||||
this.log.log(
|
||||
'warn',
|
||||
@@ -355,31 +387,10 @@ export class SmartMigration {
|
||||
* Resolve the plan against the current ledger state without acquiring a
|
||||
* lock or executing anything. Used by `plan()` and `dryRun: true`.
|
||||
*/
|
||||
private computeResultWithoutRun(data: ISmartMigrationLedgerData): IMigrationRunResult {
|
||||
const currentVersion =
|
||||
data.currentVersion ??
|
||||
(this.steps.length > 0 ? this.steps[0].fromVersion : this.settings.targetVersion);
|
||||
private async computeResultWithoutRun(data: ISmartMigrationLedgerData): Promise<IMigrationRunResult> {
|
||||
const resolvedState = await this.resolveLedgerState(data);
|
||||
|
||||
if (VersionResolver.equals(currentVersion, this.settings.targetVersion)) {
|
||||
return {
|
||||
currentVersionBefore: data.currentVersion,
|
||||
currentVersionAfter: currentVersion,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: true,
|
||||
wasFreshInstall: false,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: [],
|
||||
totalDurationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const plan = VersionResolver.computePlan(
|
||||
this.steps,
|
||||
currentVersion,
|
||||
this.settings.targetVersion,
|
||||
);
|
||||
|
||||
const skipped: IMigrationStepResult[] = plan.map((step) => ({
|
||||
const skipped: IMigrationStepResult[] = resolvedState.plannedSteps.map((step) => ({
|
||||
id: step.id,
|
||||
fromVersion: step.fromVersion,
|
||||
toVersion: step.toVersion,
|
||||
@@ -390,17 +401,73 @@ export class SmartMigration {
|
||||
}));
|
||||
|
||||
return {
|
||||
currentVersionBefore: data.currentVersion,
|
||||
currentVersionAfter: currentVersion,
|
||||
currentVersionBefore: resolvedState.currentVersionBefore,
|
||||
currentVersionAfter: resolvedState.predictedCurrentVersionAfter,
|
||||
targetVersion: this.settings.targetVersion,
|
||||
wasUpToDate: false,
|
||||
wasFreshInstall: false,
|
||||
wasUpToDate:
|
||||
resolvedState.plannedSteps.length === 0 &&
|
||||
resolvedState.bootstrapMode !== 'target-without-steps',
|
||||
wasFreshInstall: resolvedState.wasFreshInstall,
|
||||
stepsApplied: [],
|
||||
stepsSkipped: skipped,
|
||||
totalDurationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveLedgerState(
|
||||
data: ISmartMigrationLedgerData,
|
||||
): Promise<IResolvedLedgerState> {
|
||||
let effectiveCurrentVersion: string;
|
||||
let bootstrapVersionToPersist: string | null = null;
|
||||
let bootstrapMode: IResolvedLedgerState['bootstrapMode'] = 'none';
|
||||
let wasFreshInstall = false;
|
||||
|
||||
if (data.currentVersion === null) {
|
||||
const isFreshInstall = await this.detectFreshInstall();
|
||||
if (isFreshInstall && this.settings.freshInstallVersion) {
|
||||
effectiveCurrentVersion = this.settings.freshInstallVersion;
|
||||
bootstrapVersionToPersist = effectiveCurrentVersion;
|
||||
bootstrapMode = 'fresh-install-version';
|
||||
wasFreshInstall = true;
|
||||
} else if (this.steps.length === 0) {
|
||||
effectiveCurrentVersion = this.settings.targetVersion;
|
||||
bootstrapVersionToPersist = effectiveCurrentVersion;
|
||||
bootstrapMode = 'target-without-steps';
|
||||
wasFreshInstall = isFreshInstall;
|
||||
} else {
|
||||
effectiveCurrentVersion = this.steps[0].fromVersion;
|
||||
bootstrapVersionToPersist = effectiveCurrentVersion;
|
||||
bootstrapMode = 'chain-start';
|
||||
}
|
||||
} else {
|
||||
effectiveCurrentVersion = data.currentVersion;
|
||||
}
|
||||
|
||||
const plannedSteps = VersionResolver.equals(
|
||||
effectiveCurrentVersion,
|
||||
this.settings.targetVersion,
|
||||
)
|
||||
? []
|
||||
: VersionResolver.computePlan(
|
||||
this.steps,
|
||||
effectiveCurrentVersion,
|
||||
this.settings.targetVersion,
|
||||
);
|
||||
|
||||
return {
|
||||
currentVersionBefore: data.currentVersion,
|
||||
effectiveCurrentVersion,
|
||||
bootstrapVersionToPersist,
|
||||
bootstrapMode,
|
||||
wasFreshInstall,
|
||||
plannedSteps,
|
||||
predictedCurrentVersionAfter:
|
||||
plannedSteps.length > 0
|
||||
? plannedSteps[plannedSteps.length - 1].toVersion
|
||||
: effectiveCurrentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureLedger(): Promise<Ledger> {
|
||||
if (this.ledger) return this.ledger;
|
||||
const ledgerName = this.settings.ledgerName;
|
||||
@@ -424,6 +491,60 @@ export class SmartMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
private startLockHeartbeat(ledger: Ledger): ILockHeartbeat {
|
||||
const intervalMs = this.getLockHeartbeatMs();
|
||||
let stopped = false;
|
||||
let resolveStop!: () => void;
|
||||
let lockError: SmartMigrationError | null = null;
|
||||
|
||||
const stopSignal = new Promise<void>((resolve) => {
|
||||
resolveStop = resolve;
|
||||
});
|
||||
|
||||
const loopPromise = (async () => {
|
||||
while (!stopped) {
|
||||
await Promise.race([this.sleep(intervalMs), stopSignal]);
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const renewed = await ledger.renewLock(this.instanceId, this.settings.lockTtlMs);
|
||||
if (!renewed) {
|
||||
lockError = new SmartMigrationError(
|
||||
'LOCK_LOST',
|
||||
'Lost the migration lock while running steps. Another instance may have taken over.',
|
||||
{ holderId: this.instanceId },
|
||||
);
|
||||
stopped = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
lockError = new SmartMigrationError(
|
||||
'LOCK_LOST',
|
||||
`Failed to renew the migration lock: ${error.message}`,
|
||||
{ holderId: this.instanceId, originalError: error.message },
|
||||
);
|
||||
stopped = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
getError: () => lockError,
|
||||
stop: async () => {
|
||||
if (stopped) {
|
||||
await loopPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
stopped = true;
|
||||
resolveStop();
|
||||
await loopPromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic fresh-install detector. Returns true when neither mongo nor S3
|
||||
* contain anything besides smartmigration's own ledger artifacts.
|
||||
@@ -439,11 +560,10 @@ export class SmartMigration {
|
||||
if (userCollections.length > 0) return false;
|
||||
}
|
||||
if (this.settings.bucket) {
|
||||
const cursor = (this.settings.bucket as any).createCursor('', { pageSize: 5 });
|
||||
const batch = (await cursor.next()) as string[] | undefined;
|
||||
if (batch && batch.length > 0) {
|
||||
const userKeys = batch.filter((k) => !k.startsWith('.smartmigration/'));
|
||||
if (userKeys.length > 0) return false;
|
||||
for await (const key of this.settings.bucket.listAllObjects('')) {
|
||||
if (!key.startsWith('.smartmigration/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -454,6 +574,50 @@ export class SmartMigration {
|
||||
return name === 'SmartdataEasyStore' || name.startsWith('system.');
|
||||
}
|
||||
|
||||
private getLockHeartbeatMs(): number {
|
||||
return Math.max(MIN_LOCK_HEARTBEAT_MS, Math.floor(this.settings.lockTtlMs / 3));
|
||||
}
|
||||
|
||||
private getLockHealthError(
|
||||
lockHeartbeat: ILockHeartbeat | null,
|
||||
details?: Record<string, unknown>,
|
||||
): SmartMigrationError | null {
|
||||
const lockError = lockHeartbeat?.getError();
|
||||
if (!lockError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SmartMigrationError(lockError.code, lockError.message, {
|
||||
...lockError.details,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
private assertLockHealthy(
|
||||
lockHeartbeat: ILockHeartbeat | null,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
const lockError = this.getLockHealthError(lockHeartbeat, details);
|
||||
if (lockError) {
|
||||
throw lockError;
|
||||
}
|
||||
}
|
||||
|
||||
private assertIntegerOption(
|
||||
code: string,
|
||||
optionName: string,
|
||||
value: number,
|
||||
constraints: { min: number },
|
||||
): void {
|
||||
if (!Number.isInteger(value) || value < constraints.min) {
|
||||
throw new SmartMigrationError(
|
||||
code,
|
||||
`${optionName} must be an integer >= ${constraints.min}.`,
|
||||
{ [optionName]: value },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export abstract class Ledger {
|
||||
public abstract read(): Promise<ISmartMigrationLedgerData>;
|
||||
public abstract write(data: ISmartMigrationLedgerData): Promise<void>;
|
||||
public abstract acquireLock(holderId: string, ttlMs: number): Promise<boolean>;
|
||||
public abstract renewLock(holderId: string, ttlMs: number): Promise<boolean>;
|
||||
public abstract releaseLock(holderId: string): Promise<void>;
|
||||
public abstract close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@ import type * as plugins from '../plugins.js';
|
||||
import type { ISmartMigrationLedgerData } from '../interfaces.js';
|
||||
import { Ledger, emptyLedgerData } from './classes.ledger.js';
|
||||
|
||||
interface IMongoLedgerDocument {
|
||||
nameId: string;
|
||||
data: ISmartMigrationLedgerData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mongo-backed ledger that persists `ISmartMigrationLedgerData` as a single
|
||||
* document via smartdata's `EasyStore`. The EasyStore's nameId is
|
||||
* `smartmigration:<ledgerName>`, scoping multiple migration ledgers in the
|
||||
* same database.
|
||||
* document in smartdata's `SmartdataEasyStore` collection, keyed by
|
||||
* `smartmigration:<ledgerName>`.
|
||||
*/
|
||||
export class MongoLedger extends Ledger {
|
||||
private db: plugins.smartdata.SmartdataDb;
|
||||
private ledgerName: string;
|
||||
private easyStore: any | null = null; // EasyStore<ISmartMigrationLedgerData> — typed loosely because the peer type may not be present at compile time
|
||||
private indexReadyPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(db: plugins.smartdata.SmartdataDb, ledgerName: string) {
|
||||
super();
|
||||
@@ -20,87 +24,146 @@ export class MongoLedger extends Ledger {
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
this.easyStore = await this.db.createEasyStore(`smartmigration:${this.ledgerName}`);
|
||||
// EasyStore creates an empty `data: {}` on first read. Hydrate it to the
|
||||
// canonical empty shape so subsequent reads always return all fields.
|
||||
const existing = (await this.easyStore.readAll()) as Partial<ISmartMigrationLedgerData>;
|
||||
if (
|
||||
existing.currentVersion === undefined ||
|
||||
existing.steps === undefined ||
|
||||
existing.lock === undefined ||
|
||||
existing.checkpoints === undefined
|
||||
) {
|
||||
await this.easyStore.writeAll(emptyLedgerData());
|
||||
}
|
||||
// Intentionally empty. The backing collection and unique index are created
|
||||
// lazily on first write so `plan()` / `getCurrentVersion()` stay read-only.
|
||||
}
|
||||
|
||||
public async read(): Promise<ISmartMigrationLedgerData> {
|
||||
if (!this.easyStore) {
|
||||
throw new Error('MongoLedger.read() called before init()');
|
||||
}
|
||||
const data = (await this.easyStore.readAll()) as ISmartMigrationLedgerData;
|
||||
return this.normalize(data);
|
||||
const document = await this.getCollection().findOne({ nameId: this.getNameId() });
|
||||
return this.normalize(document?.data);
|
||||
}
|
||||
|
||||
public async write(data: ISmartMigrationLedgerData): Promise<void> {
|
||||
if (!this.easyStore) {
|
||||
throw new Error('MongoLedger.write() called before init()');
|
||||
}
|
||||
// Use EasyStore.replace (added in @push.rocks/smartdata 7.1.7) for true
|
||||
// overwrite semantics. This lets us actually delete keys from
|
||||
// checkpoints / steps when the in-memory ledger drops them — writeAll
|
||||
// would merge and silently retain them.
|
||||
await this.easyStore.replace(data);
|
||||
await this.ensureCollectionReady();
|
||||
await this.getCollection().updateOne(
|
||||
{ nameId: this.getNameId() },
|
||||
{
|
||||
$set: {
|
||||
nameId: this.getNameId(),
|
||||
data,
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
public async acquireLock(holderId: string, ttlMs: number): Promise<boolean> {
|
||||
const data = await this.read();
|
||||
await this.ensureDocumentExists();
|
||||
|
||||
const now = new Date();
|
||||
const lockHeld = data.lock.holder !== null;
|
||||
const lockExpired =
|
||||
data.lock.expiresAt !== null && new Date(data.lock.expiresAt).getTime() < now.getTime();
|
||||
const nowIso = now.toISOString();
|
||||
const result = await this.getCollection().findOneAndUpdate(
|
||||
{
|
||||
nameId: this.getNameId(),
|
||||
$or: [
|
||||
{ 'data.lock.holder': null },
|
||||
{ 'data.lock.holder': { $exists: false } },
|
||||
{ 'data.lock.expiresAt': null },
|
||||
{ 'data.lock.expiresAt': { $exists: false } },
|
||||
{ 'data.lock.expiresAt': { $lt: nowIso } },
|
||||
],
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'data.lock': {
|
||||
holder: holderId,
|
||||
acquiredAt: nowIso,
|
||||
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ returnDocument: 'after' },
|
||||
);
|
||||
|
||||
if (lockHeld && !lockExpired) {
|
||||
return false;
|
||||
}
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(now.getTime() + ttlMs);
|
||||
data.lock = {
|
||||
holder: holderId,
|
||||
acquiredAt: now.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
await this.write(data);
|
||||
public async renewLock(holderId: string, ttlMs: number): Promise<boolean> {
|
||||
await this.ensureCollectionReady();
|
||||
|
||||
// Re-read to confirm we won the race. EasyStore is last-writer-wins so
|
||||
// this is a probabilistic CAS, not a true atomic CAS — adequate for v1.
|
||||
const verify = await this.read();
|
||||
return verify.lock.holder === holderId;
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const result = await this.getCollection().updateOne(
|
||||
{
|
||||
nameId: this.getNameId(),
|
||||
'data.lock.holder': holderId,
|
||||
'data.lock.expiresAt': { $gte: nowIso },
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'data.lock.expiresAt': new Date(now.getTime() + ttlMs).toISOString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return result.modifiedCount === 1;
|
||||
}
|
||||
|
||||
public async releaseLock(holderId: string): Promise<void> {
|
||||
const data = await this.read();
|
||||
if (data.lock.holder !== holderId) {
|
||||
// Lock was stolen or never held — nothing to release.
|
||||
return;
|
||||
}
|
||||
data.lock = { holder: null, acquiredAt: null, expiresAt: null };
|
||||
await this.write(data);
|
||||
await this.ensureCollectionReady();
|
||||
|
||||
await this.getCollection().updateOne(
|
||||
{
|
||||
nameId: this.getNameId(),
|
||||
'data.lock.holder': holderId,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'data.lock': {
|
||||
holder: null,
|
||||
acquiredAt: null,
|
||||
expiresAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
// EasyStore has no explicit close — it just dereferences when the parent
|
||||
// SmartdataDb closes.
|
||||
this.easyStore = null;
|
||||
// No per-ledger resources to release. The parent SmartdataDb owns the
|
||||
// Mongo connection lifecycle.
|
||||
}
|
||||
|
||||
/** Fill in any missing top-level fields with their defaults. */
|
||||
private normalize(data: Partial<ISmartMigrationLedgerData>): ISmartMigrationLedgerData {
|
||||
private normalize(data: Partial<ISmartMigrationLedgerData> | undefined): ISmartMigrationLedgerData {
|
||||
return {
|
||||
currentVersion: data.currentVersion ?? null,
|
||||
steps: data.steps ?? {},
|
||||
lock: data.lock ?? { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: data.checkpoints ?? {},
|
||||
currentVersion: data?.currentVersion ?? null,
|
||||
steps: data?.steps ?? {},
|
||||
lock: data?.lock ?? { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: data?.checkpoints ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private getCollection() {
|
||||
return this.db.mongoDb.collection<IMongoLedgerDocument>('SmartdataEasyStore');
|
||||
}
|
||||
|
||||
private getNameId(): string {
|
||||
return `smartmigration:${this.ledgerName}`;
|
||||
}
|
||||
|
||||
private async ensureDocumentExists(): Promise<void> {
|
||||
await this.ensureCollectionReady();
|
||||
|
||||
await this.getCollection().updateOne(
|
||||
{ nameId: this.getNameId() },
|
||||
{
|
||||
$setOnInsert: {
|
||||
nameId: this.getNameId(),
|
||||
data: emptyLedgerData(),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureCollectionReady(): Promise<void> {
|
||||
if (!this.indexReadyPromise) {
|
||||
this.indexReadyPromise = this.getCollection()
|
||||
.createIndex({ nameId: 1 }, { unique: true })
|
||||
.then(() => undefined);
|
||||
}
|
||||
|
||||
await this.indexReadyPromise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,21 +22,24 @@ export class S3Ledger extends Ledger {
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const exists = await (this.bucket as any).fastExists({ path: this.path });
|
||||
if (!exists) {
|
||||
await this.write(emptyLedgerData());
|
||||
}
|
||||
// Intentionally empty. The sidecar object is created lazily on first write
|
||||
// so `plan()` / `dryRun` stay read-only for S3-backed ledgers.
|
||||
}
|
||||
|
||||
public async read(): Promise<ISmartMigrationLedgerData> {
|
||||
const buffer = await (this.bucket as any).fastGet({ path: this.path });
|
||||
const exists = await this.bucket.fastExists({ path: this.path });
|
||||
if (!exists) {
|
||||
return emptyLedgerData();
|
||||
}
|
||||
|
||||
const buffer = await this.bucket.fastGet({ path: this.path });
|
||||
const data = JSON.parse(buffer.toString('utf8')) as Partial<ISmartMigrationLedgerData>;
|
||||
return this.normalize(data);
|
||||
}
|
||||
|
||||
public async write(data: ISmartMigrationLedgerData): Promise<void> {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
await (this.bucket as any).fastPut({
|
||||
await this.bucket.fastPut({
|
||||
path: this.path,
|
||||
contents: json,
|
||||
overwrite: true,
|
||||
@@ -67,6 +70,24 @@ export class S3Ledger extends Ledger {
|
||||
return verify.lock.holder === holderId;
|
||||
}
|
||||
|
||||
public async renewLock(holderId: string, ttlMs: number): Promise<boolean> {
|
||||
const data = await this.read();
|
||||
if (data.lock.holder !== holderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
data.lock = {
|
||||
holder: holderId,
|
||||
acquiredAt: data.lock.acquiredAt ?? now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
||||
};
|
||||
await this.write(data);
|
||||
|
||||
const verify = await this.read();
|
||||
return verify.lock.holder === holderId;
|
||||
}
|
||||
|
||||
public async releaseLock(holderId: string): Promise<void> {
|
||||
const data = await this.read();
|
||||
if (data.lock.holder !== holderId) {
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ export type { smartdata, smartbucket };
|
||||
// Driver types are derived from the smartdata / smartbucket public surface
|
||||
// to avoid having to declare `mongodb` or `@aws-sdk/client-s3` as direct
|
||||
// dependencies. These aliases are exported for use in the public interfaces.
|
||||
export type TEasyStore<T> = smartdata.EasyStore<T>;
|
||||
export type TRawMongoDb = smartdata.SmartdataDb['mongoDb'];
|
||||
export type TRawMongoClient = smartdata.SmartdataDb['mongoDbClient'];
|
||||
export type TMongoClientSession = ReturnType<smartdata.SmartdataDb['startSession']>;
|
||||
export type TRawS3Client = smartbucket.SmartBucket['storageClient'];
|
||||
export type TRawS3Client = ReturnType<smartbucket.Bucket['getStorageClient']>;
|
||||
|
||||
Reference in New Issue
Block a user