feat(migration): add lock heartbeats, predictive dry-run planning, and stricter ledger option validation
This commit is contained in:
@@ -4,6 +4,24 @@ import * as smartbucket from '@push.rocks/smartbucket';
|
||||
|
||||
const qenv = new Qenv(process.cwd(), process.cwd() + '/.nogit/');
|
||||
|
||||
async function createSmartBucket(): Promise<smartbucket.SmartBucket> {
|
||||
return new smartbucket.SmartBucket({
|
||||
accessKey: await qenv.getEnvVarOnDemandStrict('S3_ACCESSKEY'),
|
||||
accessSecret: await qenv.getEnvVarOnDemandStrict('S3_SECRETKEY'),
|
||||
endpoint: await qenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
|
||||
port: parseInt(await qenv.getEnvVarOnDemandStrict('S3_PORT'), 10),
|
||||
useSsl: false,
|
||||
});
|
||||
}
|
||||
|
||||
function buildUniqueBucketName(baseName: string, suffix: string): string {
|
||||
const safeBase = baseName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const safeSuffix = suffix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const uniquePart = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const combined = `${safeBase}-${safeSuffix}-${uniquePart}`.replace(/-+/g, '-');
|
||||
return combined.slice(0, 63).replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Spin up a fresh `SmartdataDb` connected to the local mongo from .nogit/env.json,
|
||||
* scoped to a unique database name so tests cannot collide. Returns the db plus
|
||||
@@ -45,13 +63,7 @@ export async function makeTestDb(suffix: string) {
|
||||
* unique prefix and clean them up afterwards.
|
||||
*/
|
||||
export async function makeTestBucket() {
|
||||
const sb = new smartbucket.SmartBucket({
|
||||
accessKey: await qenv.getEnvVarOnDemandStrict('S3_ACCESSKEY'),
|
||||
accessSecret: await qenv.getEnvVarOnDemandStrict('S3_SECRETKEY'),
|
||||
endpoint: await qenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
|
||||
port: parseInt(await qenv.getEnvVarOnDemandStrict('S3_PORT'), 10),
|
||||
useSsl: false,
|
||||
});
|
||||
const sb = await createSmartBucket();
|
||||
const bucketName = await qenv.getEnvVarOnDemandStrict('S3_BUCKET');
|
||||
|
||||
// The bucket may not exist yet — try to fetch it; if missing, create it.
|
||||
@@ -64,3 +76,29 @@ export async function makeTestBucket() {
|
||||
}
|
||||
return { sb, bucket, bucketName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique bucket for tests that need a truly empty object store.
|
||||
*/
|
||||
export async function makeIsolatedTestBucket(suffix: string) {
|
||||
const sb = await createSmartBucket();
|
||||
const baseBucketName = await qenv.getEnvVarOnDemandStrict('S3_BUCKET');
|
||||
const bucketName = buildUniqueBucketName(baseBucketName, suffix);
|
||||
|
||||
const bucket = await sb.createBucket(bucketName);
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await bucket.cleanAllContents();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await sb.removeBucket(bucketName);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return { sb, bucket, bucketName, cleanup };
|
||||
}
|
||||
|
||||
@@ -24,6 +24,21 @@ tap.test('constructor: rejects invalid targetVersion', async () => {
|
||||
expect(caught!.code).toEqual('INVALID_VERSION');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects invalid freshInstallVersion', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
freshInstallVersion: 'nope',
|
||||
db: {} as any,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('INVALID_VERSION');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects when neither db nor bucket given', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
@@ -70,6 +85,51 @@ tap.test('constructor: rejects mongo backend without db', async () => {
|
||||
expect(caught!.code).toEqual('LEDGER_BACKEND_MISMATCH');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects blank ledgerName', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
db: {} as any,
|
||||
ledgerName: ' ',
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('INVALID_LEDGER_NAME');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects negative lockWaitMs', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
db: {} as any,
|
||||
lockWaitMs: -1,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('INVALID_LOCK_WAIT_MS');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects non-positive lockTtlMs', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
new SmartMigration({
|
||||
targetVersion: '1.0.0',
|
||||
db: {} as any,
|
||||
lockTtlMs: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err as SmartMigrationError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SmartMigrationError);
|
||||
expect(caught!.code).toEqual('INVALID_LOCK_TTL_MS');
|
||||
});
|
||||
|
||||
tap.test('constructor: rejects s3 backend without bucket', async () => {
|
||||
let caught: SmartMigrationError | undefined;
|
||||
try {
|
||||
|
||||
@@ -29,6 +29,7 @@ tap.test('dryRun: returns plan without invoking handlers', async () => {
|
||||
expect(r.stepsApplied).toHaveLength(0);
|
||||
expect(r.stepsSkipped).toHaveLength(2);
|
||||
expect(r.stepsSkipped.map((s) => s.id)).toEqual(['a', 'b']);
|
||||
expect(r.currentVersionAfter).toEqual('2.0.0');
|
||||
|
||||
// The ledger should still be in its initial state (currentVersion = null).
|
||||
const current = await m.getCurrentVersion();
|
||||
@@ -45,12 +46,54 @@ tap.test('plan(): returns plan without writing or running', async () => {
|
||||
expect(stepCalled).toBeFalse();
|
||||
expect(planResult.stepsSkipped).toHaveLength(1);
|
||||
expect(planResult.stepsApplied).toHaveLength(0);
|
||||
expect(planResult.currentVersionAfter).toEqual('2.0.0');
|
||||
|
||||
// Plan does not modify the ledger.
|
||||
const current = await m.getCurrentVersion();
|
||||
expect(current).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('dryRun: models freshInstallVersion on an empty database', async () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '2.0.0',
|
||||
db,
|
||||
ledgerName: 'dryrun_fresh',
|
||||
freshInstallVersion: '2.0.0',
|
||||
dryRun: true,
|
||||
});
|
||||
let stepCalled = false;
|
||||
m.step('a').from('1.0.0').to('2.0.0').up(async () => { stepCalled = true; });
|
||||
|
||||
const r = await m.run();
|
||||
expect(stepCalled).toBeFalse();
|
||||
expect(r.wasFreshInstall).toBeTrue();
|
||||
expect(r.stepsSkipped).toHaveLength(0);
|
||||
expect(r.currentVersionBefore).toBeNull();
|
||||
expect(r.currentVersionAfter).toEqual('2.0.0');
|
||||
expect(await m.getCurrentVersion()).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('plan(): does not use freshInstallVersion when user data already exists', async () => {
|
||||
await db.mongoDb.collection('dryrun_preexisting_users').insertOne({ id: 'user-1' });
|
||||
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '2.0.0',
|
||||
db,
|
||||
ledgerName: 'plan_preexisting',
|
||||
freshInstallVersion: '2.0.0',
|
||||
});
|
||||
let stepCalled = false;
|
||||
m.step('a').from('1.0.0').to('2.0.0').up(async () => { stepCalled = true; });
|
||||
|
||||
const r = await m.plan();
|
||||
expect(stepCalled).toBeFalse();
|
||||
expect(r.wasFreshInstall).toBeFalse();
|
||||
expect(r.stepsSkipped).toHaveLength(1);
|
||||
expect(r.stepsSkipped[0].id).toEqual('a');
|
||||
expect(r.currentVersionAfter).toEqual('2.0.0');
|
||||
expect(await m.getCurrentVersion()).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
@@ -74,6 +74,26 @@ tap.test('MongoLedger: expired lock can be stolen', async () => {
|
||||
expect(got2).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('MongoLedger: holder can renew lock before expiry', async () => {
|
||||
const ledger = new MongoLedger(db, 'renew-test');
|
||||
await ledger.init();
|
||||
|
||||
const got = await ledger.acquireLock('renew-holder', 20);
|
||||
expect(got).toBeTrue();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const renewed = await ledger.renewLock('renew-holder', 40);
|
||||
expect(renewed).toBeTrue();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
const stolenEarly = await ledger.acquireLock('other-holder', 40);
|
||||
expect(stolenEarly).toBeFalse();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
const stolenLate = await ledger.acquireLock('other-holder', 40);
|
||||
expect(stolenLate).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
@@ -65,6 +65,24 @@ tap.test('checkpoint: ctx.checkpoint is undefined for non-resumable steps', asyn
|
||||
expect(observed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('checkpoint: successful resumable step clears stored progress', async () => {
|
||||
const m1 = new SmartMigration({ targetVersion: '1.1.0', db, ledgerName: 'checkpoint_cleanup' });
|
||||
m1.step('big-job').from('1.0.0').to('1.1.0').resumable().up(async (ctx) => {
|
||||
await ctx.checkpoint!.write('processed', 5);
|
||||
});
|
||||
await m1.run();
|
||||
|
||||
const m2 = new SmartMigration({ targetVersion: '1.2.0', db, ledgerName: 'checkpoint_cleanup' });
|
||||
let resumedFrom: number | undefined;
|
||||
m2
|
||||
.step('big-job').from('1.1.0').to('1.2.0').resumable().up(async (ctx) => {
|
||||
resumedFrom = await ctx.checkpoint!.read<number>('processed');
|
||||
});
|
||||
|
||||
await m2.run();
|
||||
expect(resumedFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
@@ -117,6 +117,42 @@ tap.test('run: ctx.startSession works inside a step', async () => {
|
||||
expect(sessionWasUsed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('run: lock heartbeat prevents concurrent execution of a slow step', async () => {
|
||||
let activeSteps = 0;
|
||||
let maxActiveSteps = 0;
|
||||
let totalCalls = 0;
|
||||
|
||||
const createRunner = () => {
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '1.1.0',
|
||||
db,
|
||||
ledgerName: 'lock_heartbeat',
|
||||
lockTtlMs: 100,
|
||||
lockWaitMs: 1_500,
|
||||
});
|
||||
|
||||
m.step('slow-step').from('1.0.0').to('1.1.0').up(async () => {
|
||||
totalCalls++;
|
||||
activeSteps++;
|
||||
maxActiveSteps = Math.max(maxActiveSteps, activeSteps);
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
activeSteps--;
|
||||
});
|
||||
|
||||
return m;
|
||||
};
|
||||
|
||||
const firstRun = createRunner().run();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const secondRun = createRunner().run();
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([firstRun, secondRun]);
|
||||
expect(firstResult.stepsApplied).toHaveLength(1);
|
||||
expect(secondResult.wasUpToDate).toBeTrue();
|
||||
expect(totalCalls).toEqual(1);
|
||||
expect(maxActiveSteps).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('cleanup: close shared db', async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
+27
-1
@@ -1,5 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { makeTestBucket } from './helpers/services.js';
|
||||
import { makeIsolatedTestBucket, makeTestBucket } from './helpers/services.js';
|
||||
import type * as smartbucket from '@push.rocks/smartbucket';
|
||||
import { SmartMigration } from '../ts/index.js';
|
||||
|
||||
@@ -94,6 +94,32 @@ tap.test('s3 ctx exposes bucket and s3 client', async () => {
|
||||
expect(observed!.hasS3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('s3 dryRun: freshInstallVersion skips steps without creating a sidecar', async () => {
|
||||
const isolated = await makeIsolatedTestBucket('fresh-plan');
|
||||
|
||||
try {
|
||||
const ledgerName = 'fresh-plan';
|
||||
const m = new SmartMigration({
|
||||
targetVersion: '2.0.0',
|
||||
bucket: isolated.bucket,
|
||||
ledgerName,
|
||||
freshInstallVersion: '2.0.0',
|
||||
dryRun: true,
|
||||
});
|
||||
let stepCalled = false;
|
||||
m.step('move-uploads').from('1.0.0').to('2.0.0').up(async () => { stepCalled = true; });
|
||||
|
||||
const r = await m.run();
|
||||
expect(stepCalled).toBeFalse();
|
||||
expect(r.wasFreshInstall).toBeTrue();
|
||||
expect(r.stepsSkipped).toHaveLength(0);
|
||||
expect(r.currentVersionAfter).toEqual('2.0.0');
|
||||
expect(await isolated.bucket.fastExists({ path: `.smartmigration/${ledgerName}.json` })).toBeFalse();
|
||||
} finally {
|
||||
await isolated.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup: wipe smartmigration sidecars', async () => {
|
||||
for await (const key of bucket.listAllObjects('.smartmigration/')) {
|
||||
await bucket.fastRemove({ path: key });
|
||||
|
||||
Reference in New Issue
Block a user