feat(migration): add lock heartbeats, predictive dry-run planning, and stricter ledger option validation
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user