@ftschopp/dynatable-migrations 1.2.3 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/core/lock-heartbeat.d.ts +15 -0
- package/dist/core/lock-heartbeat.d.ts.map +1 -0
- package/dist/core/lock-heartbeat.js +44 -0
- package/dist/core/lock-heartbeat.js.map +1 -0
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +11 -1
- package/dist/core/runner.js.map +1 -1
- package/dist/core/tracker.d.ts +16 -0
- package/dist/core/tracker.d.ts.map +1 -1
- package/dist/core/tracker.js +132 -59
- package/dist/core/tracker.js.map +1 -1
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/core/lock-heartbeat.test.ts +78 -0
- package/src/core/lock-heartbeat.ts +48 -0
- package/src/core/runner.ts +9 -1
- package/src/core/tracker.test.ts +150 -0
- package/src/core/tracker.ts +144 -61
- package/src/types/index.ts +12 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { startLockHeartbeat } from './lock-heartbeat';
|
|
2
|
+
import type { MigrationTracker } from '../types';
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.useFakeTimers();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
jest.useRealTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function makeTracker(refresh: jest.Mock): MigrationTracker {
|
|
13
|
+
// Only the methods the heartbeat actually touches need to be present.
|
|
14
|
+
return {
|
|
15
|
+
refreshLock: refresh,
|
|
16
|
+
} as unknown as MigrationTracker;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('startLockHeartbeat', () => {
|
|
20
|
+
test('calls refreshLock every ~ttl/3 seconds', () => {
|
|
21
|
+
const refresh = jest.fn().mockResolvedValue(undefined);
|
|
22
|
+
const tracker = makeTracker(refresh);
|
|
23
|
+
const stop = startLockHeartbeat(tracker, 30); // every 10s
|
|
24
|
+
|
|
25
|
+
jest.advanceTimersByTime(10_000);
|
|
26
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
27
|
+
|
|
28
|
+
jest.advanceTimersByTime(10_000);
|
|
29
|
+
expect(refresh).toHaveBeenCalledTimes(2);
|
|
30
|
+
|
|
31
|
+
stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('stop() clears the interval and prevents further refreshes', () => {
|
|
35
|
+
const refresh = jest.fn().mockResolvedValue(undefined);
|
|
36
|
+
const tracker = makeTracker(refresh);
|
|
37
|
+
const stop = startLockHeartbeat(tracker, 30);
|
|
38
|
+
|
|
39
|
+
jest.advanceTimersByTime(10_000);
|
|
40
|
+
stop();
|
|
41
|
+
jest.advanceTimersByTime(60_000);
|
|
42
|
+
|
|
43
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('clamps the interval to a 1s minimum even with absurdly small TTLs', () => {
|
|
47
|
+
const refresh = jest.fn().mockResolvedValue(undefined);
|
|
48
|
+
const tracker = makeTracker(refresh);
|
|
49
|
+
const stop = startLockHeartbeat(tracker, 0.5); // would compute < 1s
|
|
50
|
+
|
|
51
|
+
jest.advanceTimersByTime(999);
|
|
52
|
+
expect(refresh).toHaveBeenCalledTimes(0);
|
|
53
|
+
jest.advanceTimersByTime(1);
|
|
54
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
55
|
+
|
|
56
|
+
stop();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('swallows refresh errors so transient failures do not crash the runner', async () => {
|
|
60
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
61
|
+
const refresh = jest
|
|
62
|
+
.fn()
|
|
63
|
+
.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'NetworkError' }));
|
|
64
|
+
const tracker = makeTracker(refresh);
|
|
65
|
+
const stop = startLockHeartbeat(tracker, 30);
|
|
66
|
+
|
|
67
|
+
jest.advanceTimersByTime(10_000);
|
|
68
|
+
// Let the rejected promise settle
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
await Promise.resolve();
|
|
71
|
+
|
|
72
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(warn).toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
stop();
|
|
76
|
+
warn.mockRestore();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MigrationTracker } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Periodically refreshes the migration lock so that migrations longer than
|
|
5
|
+
* the lock TTL don't have their lock taken by another worker.
|
|
6
|
+
*
|
|
7
|
+
* Returns a stop function. Call it from the same `finally` that releases
|
|
8
|
+
* the lock to make sure the interval doesn't leak.
|
|
9
|
+
*
|
|
10
|
+
* The heartbeat fires every `ttlSeconds / 3` seconds. Errors from the
|
|
11
|
+
* underlying refresh are logged and swallowed: if the lock is genuinely
|
|
12
|
+
* lost, the next tracker mutation will fail its ConditionCheck and the
|
|
13
|
+
* runner will surface a clear `TransactionCanceledException` to the user.
|
|
14
|
+
*/
|
|
15
|
+
export function startLockHeartbeat(
|
|
16
|
+
tracker: MigrationTracker,
|
|
17
|
+
ttlSeconds: number
|
|
18
|
+
): () => void {
|
|
19
|
+
// Fire well before expiry so a single missed beat doesn't drop the lock.
|
|
20
|
+
const intervalMs = Math.max(1_000, Math.floor((ttlSeconds * 1000) / 3));
|
|
21
|
+
let active = true;
|
|
22
|
+
|
|
23
|
+
const handle = setInterval(() => {
|
|
24
|
+
if (!active) return;
|
|
25
|
+
void tracker.refreshLock().catch((err: unknown) => {
|
|
26
|
+
const name = (err as { name?: string } | null)?.name;
|
|
27
|
+
const message = (err as { message?: string } | null)?.message ?? String(err);
|
|
28
|
+
if (name === 'ConditionalCheckFailedException') {
|
|
29
|
+
console.warn(
|
|
30
|
+
'⚠️ Migration lock was taken by another process. ' +
|
|
31
|
+
'Subsequent tracker writes will fail.'
|
|
32
|
+
);
|
|
33
|
+
} else {
|
|
34
|
+
console.warn(`⚠️ Failed to refresh migration lock: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}, intervalMs);
|
|
38
|
+
|
|
39
|
+
// Don't keep the Node event loop alive just for the heartbeat.
|
|
40
|
+
if (typeof handle.unref === 'function') {
|
|
41
|
+
handle.unref();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
active = false;
|
|
46
|
+
clearInterval(handle);
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/core/runner.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { MigrationConfig, MigrationContext, MigrationFile, MigrationStatus } fro
|
|
|
15
15
|
import { DynamoDBMigrationTracker } from './tracker';
|
|
16
16
|
import { MigrationLoader } from './loader';
|
|
17
17
|
import { compareSemver } from './semver';
|
|
18
|
+
import { startLockHeartbeat } from './lock-heartbeat';
|
|
18
19
|
|
|
19
20
|
export interface RunOptions {
|
|
20
21
|
limit?: number;
|
|
@@ -50,6 +51,7 @@ export class MigrationRunner {
|
|
|
50
51
|
await this.initialize();
|
|
51
52
|
|
|
52
53
|
// Acquire lock unless dry run
|
|
54
|
+
let stopHeartbeat: (() => void) | undefined;
|
|
53
55
|
if (!dryRun) {
|
|
54
56
|
const lockAcquired = await this.tracker.acquireLock();
|
|
55
57
|
if (!lockAcquired) {
|
|
@@ -58,6 +60,7 @@ export class MigrationRunner {
|
|
|
58
60
|
'If you believe this is an error, wait a few minutes and try again.'
|
|
59
61
|
);
|
|
60
62
|
}
|
|
63
|
+
stopHeartbeat = startLockHeartbeat(this.tracker, this.tracker.lockTtlSeconds);
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
try {
|
|
@@ -133,7 +136,9 @@ export class MigrationRunner {
|
|
|
133
136
|
|
|
134
137
|
return executed;
|
|
135
138
|
} finally {
|
|
136
|
-
// Always release lock
|
|
139
|
+
// Always stop the heartbeat and release the lock — in that order, so
|
|
140
|
+
// we don't refresh a lock we're about to delete.
|
|
141
|
+
if (stopHeartbeat) stopHeartbeat();
|
|
137
142
|
if (!dryRun) {
|
|
138
143
|
await this.tracker.releaseLock();
|
|
139
144
|
}
|
|
@@ -147,11 +152,13 @@ export class MigrationRunner {
|
|
|
147
152
|
await this.initialize();
|
|
148
153
|
|
|
149
154
|
// Acquire lock unless dry run
|
|
155
|
+
let stopHeartbeat: (() => void) | undefined;
|
|
150
156
|
if (!dryRun) {
|
|
151
157
|
const lockAcquired = await this.tracker.acquireLock();
|
|
152
158
|
if (!lockAcquired) {
|
|
153
159
|
throw new Error('Could not acquire migration lock. Another migration may be in progress.');
|
|
154
160
|
}
|
|
161
|
+
stopHeartbeat = startLockHeartbeat(this.tracker, this.tracker.lockTtlSeconds);
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
try {
|
|
@@ -207,6 +214,7 @@ export class MigrationRunner {
|
|
|
207
214
|
|
|
208
215
|
return rolledBack;
|
|
209
216
|
} finally {
|
|
217
|
+
if (stopHeartbeat) stopHeartbeat();
|
|
210
218
|
if (!dryRun) {
|
|
211
219
|
await this.tracker.releaseLock();
|
|
212
220
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
+
import {
|
|
4
|
+
DynamoDBDocumentClient,
|
|
5
|
+
GetCommand,
|
|
6
|
+
PutCommand,
|
|
7
|
+
UpdateCommand,
|
|
8
|
+
TransactWriteCommand,
|
|
9
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
10
|
+
import { mockClient } from 'aws-sdk-client-mock';
|
|
11
|
+
import { DynamoDBMigrationTracker, DEFAULT_LOCK_TTL_SECONDS } from './tracker';
|
|
12
|
+
import type { MigrationConfig } from '../types';
|
|
13
|
+
|
|
14
|
+
const ddbMock = mockClient(DynamoDBDocumentClient);
|
|
15
|
+
|
|
16
|
+
const baseConfig: MigrationConfig = {
|
|
17
|
+
tableName: 'TestTable',
|
|
18
|
+
client: { region: 'us-east-1' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeTracker(config: Partial<MigrationConfig> = {}) {
|
|
22
|
+
const client = DynamoDBDocumentClient.from(new DynamoDBClient({ region: 'us-east-1' }));
|
|
23
|
+
return new DynamoDBMigrationTracker(client, { ...baseConfig, ...config });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
ddbMock.reset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('DynamoDBMigrationTracker - lock TTL', () => {
|
|
31
|
+
test('uses the default 5-minute TTL when none is configured', () => {
|
|
32
|
+
const tracker = makeTracker();
|
|
33
|
+
expect(tracker.lockTtlSeconds).toBe(DEFAULT_LOCK_TTL_SECONDS);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('honors a custom lockTtlSeconds from config', () => {
|
|
37
|
+
const tracker = makeTracker({ lockTtlSeconds: 30 });
|
|
38
|
+
expect(tracker.lockTtlSeconds).toBe(30);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('DynamoDBMigrationTracker - acquireLock', () => {
|
|
43
|
+
test('uses <= for the expiry boundary so simultaneous-ms expiries are takeable', async () => {
|
|
44
|
+
ddbMock.on(PutCommand).resolves({});
|
|
45
|
+
const tracker = makeTracker();
|
|
46
|
+
|
|
47
|
+
await tracker.acquireLock();
|
|
48
|
+
|
|
49
|
+
const calls = ddbMock.commandCalls(PutCommand);
|
|
50
|
+
expect(calls).toHaveLength(1);
|
|
51
|
+
const expr = (calls[0]!.args[0].input as any).ConditionExpression as string;
|
|
52
|
+
expect(expr).toContain('expiresAt <= :now');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns false when the lock row exists and has not expired', async () => {
|
|
56
|
+
const err = Object.assign(new Error('cond failed'), {
|
|
57
|
+
name: 'ConditionalCheckFailedException',
|
|
58
|
+
});
|
|
59
|
+
ddbMock.on(PutCommand).rejects(err);
|
|
60
|
+
const tracker = makeTracker();
|
|
61
|
+
|
|
62
|
+
await expect(tracker.acquireLock()).resolves.toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('DynamoDBMigrationTracker - refreshLock', () => {
|
|
67
|
+
test('extends the lock with a ConditionExpression on the current lockId', async () => {
|
|
68
|
+
ddbMock.on(PutCommand).resolves({});
|
|
69
|
+
ddbMock.on(UpdateCommand).resolves({});
|
|
70
|
+
|
|
71
|
+
const tracker = makeTracker({ lockTtlSeconds: 60 });
|
|
72
|
+
await tracker.acquireLock();
|
|
73
|
+
await tracker.refreshLock();
|
|
74
|
+
|
|
75
|
+
const updates = ddbMock.commandCalls(UpdateCommand);
|
|
76
|
+
expect(updates).toHaveLength(1);
|
|
77
|
+
const input = updates[0]!.args[0].input as any;
|
|
78
|
+
expect(input.ConditionExpression).toBe('lockId = :lockId');
|
|
79
|
+
expect(typeof input.ExpressionAttributeValues[':lockId']).toBe('string');
|
|
80
|
+
expect(typeof input.ExpressionAttributeValues[':exp']).toBe('number');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('throws when the lockId no longer matches (lost race)', async () => {
|
|
84
|
+
ddbMock.on(PutCommand).resolves({});
|
|
85
|
+
const err = Object.assign(new Error('cond failed'), {
|
|
86
|
+
name: 'ConditionalCheckFailedException',
|
|
87
|
+
});
|
|
88
|
+
ddbMock.on(UpdateCommand).rejects(err);
|
|
89
|
+
|
|
90
|
+
const tracker = makeTracker();
|
|
91
|
+
await tracker.acquireLock();
|
|
92
|
+
|
|
93
|
+
await expect(tracker.refreshLock()).rejects.toMatchObject({
|
|
94
|
+
name: 'ConditionalCheckFailedException',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('is a silent no-op when no lock has been acquired yet', async () => {
|
|
99
|
+
const tracker = makeTracker();
|
|
100
|
+
await expect(tracker.refreshLock()).resolves.toBeUndefined();
|
|
101
|
+
expect(ddbMock.commandCalls(UpdateCommand)).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('DynamoDBMigrationTracker - tracker writes are gated by lock ownership', () => {
|
|
106
|
+
test('markAsApplied (new record path) emits a ConditionCheck on the lock row', async () => {
|
|
107
|
+
ddbMock.on(PutCommand).resolves({}); // acquireLock
|
|
108
|
+
ddbMock.on(TransactWriteCommand).resolves({});
|
|
109
|
+
// getMigration returns no existing record → take the Put branch
|
|
110
|
+
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
111
|
+
|
|
112
|
+
const tracker = makeTracker();
|
|
113
|
+
await tracker.acquireLock();
|
|
114
|
+
await tracker.markAsApplied('0.1.0', 'init');
|
|
115
|
+
|
|
116
|
+
const tx = ddbMock.commandCalls(TransactWriteCommand);
|
|
117
|
+
expect(tx).toHaveLength(1);
|
|
118
|
+
const items = (tx[0]!.args[0].input as any).TransactItems as any[];
|
|
119
|
+
// First item should be the lock-row ConditionCheck
|
|
120
|
+
expect(items[0].ConditionCheck).toBeDefined();
|
|
121
|
+
expect(items[0].ConditionCheck.ConditionExpression).toBe('lockId = :lockId');
|
|
122
|
+
expect(items[0].ConditionCheck.Key).toEqual({
|
|
123
|
+
PK: '_SCHEMA#VERSION#LOCK',
|
|
124
|
+
SK: '_SCHEMA#VERSION#LOCK',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('markAsApplied throws when no lock has been acquired', async () => {
|
|
129
|
+
const tracker = makeTracker();
|
|
130
|
+
await expect(tracker.markAsApplied('0.1.0', 'init')).rejects.toThrow(
|
|
131
|
+
/Tracker has no active lock/i
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('markAsApplied (new record path) uses attribute_not_exists(SK), not (PK)', async () => {
|
|
136
|
+
ddbMock.on(PutCommand).resolves({});
|
|
137
|
+
ddbMock.on(TransactWriteCommand).resolves({});
|
|
138
|
+
ddbMock.on(GetCommand).resolves({ Item: undefined });
|
|
139
|
+
|
|
140
|
+
const tracker = makeTracker();
|
|
141
|
+
await tracker.acquireLock();
|
|
142
|
+
await tracker.markAsApplied('0.1.0', 'init');
|
|
143
|
+
|
|
144
|
+
const tx = ddbMock.commandCalls(TransactWriteCommand);
|
|
145
|
+
const items = (tx[0]!.args[0].input as any).TransactItems as any[];
|
|
146
|
+
// The Put is the second item (index 1) after the ConditionCheck
|
|
147
|
+
const put = items.find((i) => i.Put);
|
|
148
|
+
expect(put.Put.ConditionExpression).toBe('attribute_not_exists(SK)');
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/core/tracker.ts
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
import { MigrationTracker, MigrationRecord, SchemaChange, MigrationConfig } from '../types';
|
|
11
11
|
import { compareSemver } from './semver';
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** Default lock TTL in seconds. Configurable via MigrationConfig.lockTtlSeconds. */
|
|
14
|
+
export const DEFAULT_LOCK_TTL_SECONDS = 300; // 5 minutes
|
|
14
15
|
|
|
15
16
|
export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
16
17
|
private client: DynamoDBDocumentClient;
|
|
@@ -18,12 +19,47 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
18
19
|
private trackingPrefix: string;
|
|
19
20
|
private gsi1Name: string;
|
|
20
21
|
private lockId: string | null = null;
|
|
22
|
+
/** TTL for newly-acquired or refreshed locks. */
|
|
23
|
+
public readonly lockTtlSeconds: number;
|
|
21
24
|
|
|
22
25
|
constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
|
|
23
26
|
this.client = client;
|
|
24
27
|
this.tableName = config.tableName;
|
|
25
28
|
this.trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
|
|
26
29
|
this.gsi1Name = config.gsi1Name || 'GSI1';
|
|
30
|
+
this.lockTtlSeconds = config.lockTtlSeconds ?? DEFAULT_LOCK_TTL_SECONDS;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private get lockKey() {
|
|
34
|
+
return {
|
|
35
|
+
PK: `${this.trackingPrefix}#LOCK`,
|
|
36
|
+
SK: `${this.trackingPrefix}#LOCK`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Throws if there is no active lock. Call before any tracker write. */
|
|
41
|
+
private requireLock(): void {
|
|
42
|
+
if (!this.lockId) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'Tracker has no active lock; refusing to issue tracker mutations. ' +
|
|
45
|
+
'Call acquireLock() first.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Builds a TransactWrite ConditionCheck that asserts we still own the lock. */
|
|
51
|
+
private lockOwnershipCheck() {
|
|
52
|
+
this.requireLock();
|
|
53
|
+
return {
|
|
54
|
+
ConditionCheck: {
|
|
55
|
+
TableName: this.tableName,
|
|
56
|
+
Key: this.lockKey,
|
|
57
|
+
ConditionExpression: 'lockId = :lockId',
|
|
58
|
+
ExpressionAttributeValues: {
|
|
59
|
+
':lockId': this.lockId,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
27
63
|
}
|
|
28
64
|
|
|
29
65
|
async initialize(): Promise<void> {
|
|
@@ -51,13 +87,8 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
51
87
|
* Acquire a distributed lock to prevent concurrent migrations
|
|
52
88
|
*/
|
|
53
89
|
async acquireLock(): Promise<boolean> {
|
|
54
|
-
const lockKey = {
|
|
55
|
-
PK: `${this.trackingPrefix}#LOCK`,
|
|
56
|
-
SK: `${this.trackingPrefix}#LOCK`,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
90
|
const now = Date.now();
|
|
60
|
-
const expiresAt = now +
|
|
91
|
+
const expiresAt = now + this.lockTtlSeconds * 1000;
|
|
61
92
|
this.lockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
|
|
62
93
|
|
|
63
94
|
try {
|
|
@@ -65,13 +96,14 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
65
96
|
new PutCommand({
|
|
66
97
|
TableName: this.tableName,
|
|
67
98
|
Item: {
|
|
68
|
-
...lockKey,
|
|
99
|
+
...this.lockKey,
|
|
69
100
|
lockId: this.lockId,
|
|
70
101
|
acquiredAt: new Date().toISOString(),
|
|
71
102
|
expiresAt,
|
|
72
103
|
},
|
|
73
|
-
// Only succeed if lock doesn't exist or has expired
|
|
74
|
-
|
|
104
|
+
// Only succeed if lock doesn't exist or has expired. The `<=`
|
|
105
|
+
// covers the boundary where two clocks read the exact ms.
|
|
106
|
+
ConditionExpression: 'attribute_not_exists(PK) OR expiresAt <= :now',
|
|
75
107
|
ExpressionAttributeValues: {
|
|
76
108
|
':now': now,
|
|
77
109
|
},
|
|
@@ -87,22 +119,42 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
87
119
|
}
|
|
88
120
|
}
|
|
89
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Extend the lock's expiration by another `lockTtlSeconds`. Throws
|
|
124
|
+
* `ConditionalCheckFailedException` if another worker has already taken
|
|
125
|
+
* the lock — callers should treat that as "we lost the race; stop
|
|
126
|
+
* making writes". Silent no-op if no lock is currently held.
|
|
127
|
+
*/
|
|
128
|
+
async refreshLock(): Promise<void> {
|
|
129
|
+
if (!this.lockId) return;
|
|
130
|
+
|
|
131
|
+
const newExpiresAt = Date.now() + this.lockTtlSeconds * 1000;
|
|
132
|
+
|
|
133
|
+
await this.client.send(
|
|
134
|
+
new UpdateCommand({
|
|
135
|
+
TableName: this.tableName,
|
|
136
|
+
Key: this.lockKey,
|
|
137
|
+
UpdateExpression: 'SET expiresAt = :exp',
|
|
138
|
+
ConditionExpression: 'lockId = :lockId',
|
|
139
|
+
ExpressionAttributeValues: {
|
|
140
|
+
':exp': newExpiresAt,
|
|
141
|
+
':lockId': this.lockId,
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
90
147
|
/**
|
|
91
148
|
* Release the distributed lock
|
|
92
149
|
*/
|
|
93
150
|
async releaseLock(): Promise<void> {
|
|
94
151
|
if (!this.lockId) return;
|
|
95
152
|
|
|
96
|
-
const lockKey = {
|
|
97
|
-
PK: `${this.trackingPrefix}#LOCK`,
|
|
98
|
-
SK: `${this.trackingPrefix}#LOCK`,
|
|
99
|
-
};
|
|
100
|
-
|
|
101
153
|
try {
|
|
102
154
|
await this.client.send(
|
|
103
155
|
new DeleteCommand({
|
|
104
156
|
TableName: this.tableName,
|
|
105
|
-
Key: lockKey,
|
|
157
|
+
Key: this.lockKey,
|
|
106
158
|
// Only delete if we own the lock
|
|
107
159
|
ConditionExpression: 'lockId = :lockId',
|
|
108
160
|
ExpressionAttributeValues: {
|
|
@@ -171,6 +223,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
171
223
|
schemaChanges?: SchemaChange[],
|
|
172
224
|
checksum?: string
|
|
173
225
|
): Promise<void> {
|
|
226
|
+
this.requireLock();
|
|
174
227
|
const now = new Date().toISOString();
|
|
175
228
|
|
|
176
229
|
// Check if migration record already exists (e.g., rolled back or failed)
|
|
@@ -202,6 +255,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
202
255
|
await this.client.send(
|
|
203
256
|
new TransactWriteCommand({
|
|
204
257
|
TransactItems: [
|
|
258
|
+
this.lockOwnershipCheck(),
|
|
205
259
|
{
|
|
206
260
|
Update: {
|
|
207
261
|
TableName: this.tableName,
|
|
@@ -244,6 +298,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
244
298
|
await this.client.send(
|
|
245
299
|
new TransactWriteCommand({
|
|
246
300
|
TransactItems: [
|
|
301
|
+
this.lockOwnershipCheck(),
|
|
247
302
|
{
|
|
248
303
|
Put: {
|
|
249
304
|
TableName: this.tableName,
|
|
@@ -259,8 +314,9 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
259
314
|
schemaChanges,
|
|
260
315
|
checksum,
|
|
261
316
|
} as MigrationRecord,
|
|
262
|
-
// Ensure migration wasn't already applied
|
|
263
|
-
|
|
317
|
+
// Ensure migration wasn't already applied (uniqueness on
|
|
318
|
+
// SK; PK is shared across all migration rows).
|
|
319
|
+
ConditionExpression: 'attribute_not_exists(SK)',
|
|
264
320
|
},
|
|
265
321
|
},
|
|
266
322
|
{
|
|
@@ -289,6 +345,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
289
345
|
* Mark migration as rolled back using TransactWrite for atomicity
|
|
290
346
|
*/
|
|
291
347
|
async markAsRolledBack(version: string): Promise<void> {
|
|
348
|
+
this.requireLock();
|
|
292
349
|
const now = new Date().toISOString();
|
|
293
350
|
|
|
294
351
|
// Find previous version
|
|
@@ -303,6 +360,7 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
303
360
|
await this.client.send(
|
|
304
361
|
new TransactWriteCommand({
|
|
305
362
|
TransactItems: [
|
|
363
|
+
this.lockOwnershipCheck(),
|
|
306
364
|
{
|
|
307
365
|
Update: {
|
|
308
366
|
TableName: this.tableName,
|
|
@@ -342,70 +400,95 @@ export class DynamoDBMigrationTracker implements MigrationTracker {
|
|
|
342
400
|
}
|
|
343
401
|
|
|
344
402
|
async markAsFailed(version: string, error: string): Promise<void> {
|
|
403
|
+
this.requireLock();
|
|
345
404
|
// Check if the migration record exists first
|
|
346
405
|
const existing = await this.getMigration(version);
|
|
406
|
+
const now = new Date().toISOString();
|
|
347
407
|
|
|
348
408
|
if (existing) {
|
|
349
|
-
// Update existing record
|
|
409
|
+
// Update existing record, gated by lock ownership
|
|
350
410
|
await this.client.send(
|
|
351
|
-
new
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
411
|
+
new TransactWriteCommand({
|
|
412
|
+
TransactItems: [
|
|
413
|
+
this.lockOwnershipCheck(),
|
|
414
|
+
{
|
|
415
|
+
Update: {
|
|
416
|
+
TableName: this.tableName,
|
|
417
|
+
Key: {
|
|
418
|
+
PK: this.trackingPrefix,
|
|
419
|
+
SK: version,
|
|
420
|
+
},
|
|
421
|
+
UpdateExpression:
|
|
422
|
+
'SET #status = :status, #error = :error, failedAt = :timestamp',
|
|
423
|
+
ExpressionAttributeNames: {
|
|
424
|
+
'#status': 'status',
|
|
425
|
+
'#error': 'error',
|
|
426
|
+
},
|
|
427
|
+
ExpressionAttributeValues: {
|
|
428
|
+
':status': 'failed',
|
|
429
|
+
':error': error,
|
|
430
|
+
':timestamp': now,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
],
|
|
367
435
|
})
|
|
368
436
|
);
|
|
369
437
|
} else {
|
|
370
|
-
// Create new record for failed migration
|
|
438
|
+
// Create new record for failed migration, gated by lock ownership
|
|
371
439
|
await this.client.send(
|
|
372
|
-
new
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
440
|
+
new TransactWriteCommand({
|
|
441
|
+
TransactItems: [
|
|
442
|
+
this.lockOwnershipCheck(),
|
|
443
|
+
{
|
|
444
|
+
Put: {
|
|
445
|
+
TableName: this.tableName,
|
|
446
|
+
Item: {
|
|
447
|
+
PK: this.trackingPrefix,
|
|
448
|
+
SK: version,
|
|
449
|
+
version,
|
|
450
|
+
name: 'unknown',
|
|
451
|
+
status: 'failed',
|
|
452
|
+
error,
|
|
453
|
+
failedAt: now,
|
|
454
|
+
timestamp: now,
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
],
|
|
384
459
|
})
|
|
385
460
|
);
|
|
386
461
|
}
|
|
387
462
|
}
|
|
388
463
|
|
|
389
464
|
async recordSchemaChange(change: SchemaChange): Promise<void> {
|
|
465
|
+
this.requireLock();
|
|
390
466
|
const currentVersion = await this.getCurrentVersion();
|
|
391
467
|
if (!currentVersion || currentVersion === 'v0000') {
|
|
392
468
|
throw new Error('Cannot record schema change: No migration has been applied yet');
|
|
393
469
|
}
|
|
394
470
|
|
|
395
|
-
// Append to schemaChanges array
|
|
471
|
+
// Append to schemaChanges array, gated by lock ownership
|
|
396
472
|
await this.client.send(
|
|
397
|
-
new
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
473
|
+
new TransactWriteCommand({
|
|
474
|
+
TransactItems: [
|
|
475
|
+
this.lockOwnershipCheck(),
|
|
476
|
+
{
|
|
477
|
+
Update: {
|
|
478
|
+
TableName: this.tableName,
|
|
479
|
+
Key: {
|
|
480
|
+
PK: this.trackingPrefix,
|
|
481
|
+
SK: currentVersion,
|
|
482
|
+
},
|
|
483
|
+
UpdateExpression:
|
|
484
|
+
'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
|
|
485
|
+
ExpressionAttributeValues: {
|
|
486
|
+
':empty_list': [],
|
|
487
|
+
':change': [change],
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
],
|
|
409
492
|
})
|
|
410
493
|
);
|
|
411
494
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -117,6 +117,12 @@ export interface MigrationConfig {
|
|
|
117
117
|
migrationsDir?: string;
|
|
118
118
|
trackingPrefix?: string; // Default: "_SCHEMA#VERSION"
|
|
119
119
|
gsi1Name?: string; // Default: "GSI1"
|
|
120
|
+
/**
|
|
121
|
+
* How long an acquired migration lock stays valid before another worker
|
|
122
|
+
* can take it over, in seconds. The runner sends a heartbeat every
|
|
123
|
+
* `lockTtlSeconds / 3` to extend it. Default: 300 (5 minutes).
|
|
124
|
+
*/
|
|
125
|
+
lockTtlSeconds?: number;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/**
|
|
@@ -148,6 +154,12 @@ export interface MigrationTracker {
|
|
|
148
154
|
*/
|
|
149
155
|
releaseLock(): Promise<void>;
|
|
150
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Extend the lock's expiration. Throws `ConditionalCheckFailedException`
|
|
159
|
+
* if the lock has already been taken by someone else.
|
|
160
|
+
*/
|
|
161
|
+
refreshLock(): Promise<void>;
|
|
162
|
+
|
|
151
163
|
/**
|
|
152
164
|
* Mark migration as applied
|
|
153
165
|
*/
|