db-migrate 1.0.0-beta.9 → 1.0.0

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/lib/state.js CHANGED
@@ -1,74 +1,462 @@
1
1
  const MSTATE = '__dbmigrate_state__';
2
2
  const SSTATE = '__dbmigrate_schema__';
3
+
3
4
  const log = require('db-migrate-shared').log;
5
+ const fs = require('fs').promises;
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const { performance } = require('perf_hooks');
9
+
10
+ const ID = crypto.randomBytes(32).toString('base64');
11
+
12
+ const LOCK_TIMEOUT = 60000;
13
+ const LOCK_INTERVAL = 1000;
14
+
15
+ /**
16
+ * active: a lock session is running, from acquire until release, even if
17
+ * the lock got lost in between
18
+ * owner: we currently hold the lock
19
+ * current: the last MSTATE row we wrote and verified, the expected
20
+ * value for our next compare and swap
21
+ * queue: serializes our own writes, so heartbeat and state updates
22
+ * never race each other on the same expected row
23
+ */
24
+ let active = false;
25
+ let owner = false;
26
+ let current = null;
27
+ let heartbeat = null;
28
+ let queue = Promise.resolve();
29
+
30
+ const nonce = () => crypto.randomBytes(8).toString('hex');
31
+
32
+ const hashFile = async file => {
33
+ if (!file.path) {
34
+ return null;
35
+ }
36
+
37
+ try {
38
+ return crypto
39
+ .createHash('sha256')
40
+ .update(await fs.readFile(file.path))
41
+ .digest('hex');
42
+ } catch (err) {
43
+ return null;
44
+ }
45
+ };
46
+
47
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
48
+
49
+ const lockOptions = internals => {
50
+ const argv = internals.argv || {};
51
+ return {
52
+ timeout: Number(argv['lock-timeout']) || LOCK_TIMEOUT,
53
+ interval: Number(argv['lock-interval']) || LOCK_INTERVAL
54
+ };
55
+ };
56
+
57
+ const read = (driver, internals) =>
58
+ driver._getKV(internals.migrationState, MSTATE);
59
+
60
+ const holder = row => JSON.parse(row.value).s.ID;
61
+
62
+ const heldByOther = row => {
63
+ const id = holder(row);
64
+ return !!id && id !== ID;
65
+ };
66
+
67
+ /**
68
+ * Atomic update of the lock row. The where clause contains the value we
69
+ * have read before, which carries the date and a nonce renewed on every
70
+ * write. If anybody else wrote in between, the update matches no row.
71
+ * Whoever wins, sees his own value on re-read.
72
+ */
73
+ const cas = (driver, internals, expected, value) =>
74
+ driver._updateKVC(
75
+ internals.migrationState,
76
+ MSTATE,
77
+ value,
78
+ 'value',
79
+ expected.value
80
+ );
81
+
82
+ /**
83
+ * Concurrent CREATE TABLE IF NOT EXISTS can still collide (e.g. on pg_type
84
+ * in postgres) when several processes start on an empty database. The
85
+ * second attempt finds the table and is a noop.
86
+ */
87
+ const createConcurrently = async create => {
88
+ try {
89
+ await create();
90
+ } catch (err) {
91
+ log.verbose('[state] create failed, retrying once', err.message);
92
+ await create();
93
+ }
94
+ };
95
+
96
+ /**
97
+ * The stored schema starts as '{}', keep the structure the walker expects.
98
+ */
99
+ const parseSchema = value =>
100
+ Object.assign({ i: {}, c: {}, f: {}, e: {} }, JSON.parse(value));
101
+
102
+ const stopHeartbeat = () => {
103
+ if (heartbeat) {
104
+ clearInterval(heartbeat);
105
+ heartbeat = null;
106
+ }
107
+ };
108
+
109
+ const write = (driver, internals, mutate) => {
110
+ const run = queue.then(async () => {
111
+ if (!owner) {
112
+ throw new Error('[state] the migration lock is not held by this process');
113
+ }
114
+
115
+ const state = JSON.parse(current.value);
116
+ Object.assign(state.s, { ID, date: new Date(), n: nonce() });
117
+ mutate(state.s);
118
+
119
+ const value = JSON.stringify(state);
120
+ await cas(driver, internals, current, value);
121
+ const row = await read(driver, internals);
122
+
123
+ if (!row || row.value !== value) {
124
+ owner = false;
125
+ current = null;
126
+ stopHeartbeat();
127
+ throw new Error('[state] lost the migration lock to another process');
128
+ }
129
+
130
+ current = row;
131
+ });
132
+
133
+ queue = run.catch(() => {});
134
+ return run;
135
+ };
136
+
137
+ const claim = (row, driver, internals) => {
138
+ if (!row || holder(row) !== ID) {
139
+ return false;
140
+ }
141
+
142
+ active = true;
143
+ owner = true;
144
+ current = row;
145
+
146
+ const { timeout } = lockOptions(internals);
147
+ heartbeat = setInterval(() => {
148
+ write(driver, internals, () => {}).catch(err => log.error(err.message));
149
+ }, Math.max(Math.floor(timeout / 3), 1));
150
+ heartbeat.unref();
151
+
152
+ return true;
153
+ };
4
154
 
5
155
  module.exports = {
6
- lockState: async function (driver, state, internals) {
7
- if (!state) {
8
- return driver._insertKV(
9
- internals.migrationState,
10
- MSTATE,
11
- JSON.stringify({
12
- s: {
13
- step: 0,
14
- fin: 0,
15
- date: new Date()
16
- }
17
- })
156
+ createConcurrently,
157
+
158
+ // for tests, simulates a process which died while holding the lock
159
+ __stopHeartbeat: stopHeartbeat,
160
+
161
+ /**
162
+ * Drivers declare that their state methods were verified for locking,
163
+ * with an atomic _updateKVC and run_on set by the database clock.
164
+ */
165
+ supportsLock: function (driver) {
166
+ return !!(
167
+ driver &&
168
+ driver._meta &&
169
+ driver._meta.supports &&
170
+ driver._meta.supports.locking === true
171
+ );
172
+ },
173
+
174
+ /**
175
+ * Try to acquire the migration lock once. Resolves to true if this process
176
+ * is the owner afterwards. A lock held by another process is only taken
177
+ * over, if it is the exact row waitForRelease reported as stale.
178
+ */
179
+ acquire: async function (driver, internals, stale = null, retries = 3) {
180
+ if (owner) {
181
+ return true;
182
+ }
183
+
184
+ const row = await read(driver, internals);
185
+
186
+ if (!row) {
187
+ try {
188
+ await driver._insertKV(
189
+ internals.migrationState,
190
+ MSTATE,
191
+ JSON.stringify({
192
+ s: { step: 0, fin: 1, ID, date: new Date(), n: nonce() }
193
+ })
194
+ );
195
+ } catch (err) {
196
+ // somebody else inserted the row first, the re-read decides
197
+ log.verbose('[state] lock insert failed', err.message);
198
+ }
199
+
200
+ return claim(await read(driver, internals), driver, internals);
201
+ }
202
+
203
+ if (heldByOther(row)) {
204
+ if (!stale || stale.value !== row.value) {
205
+ return false;
206
+ }
207
+
208
+ const { s } = JSON.parse(row.value);
209
+ log.warn(
210
+ `[state] taking over a stale migration lock from ${s.date}` +
211
+ (s.fin === 0
212
+ ? `, the previous run was interrupted during ${
213
+ s.f ? `migration "${s.f}"` : 'a migration'
214
+ } at step ${s.step}`
215
+ : '')
18
216
  );
19
- } else {
20
- state = state.value;
21
- state = JSON.parse(state);
22
- state.s.date = new Date();
23
- return driver._updateKV(
24
- internals.migrationState,
25
- MSTATE,
217
+ }
26
218
 
27
- JSON.stringify(state)
219
+ const state = JSON.parse(row.value);
220
+ Object.assign(state.s, { ID, date: new Date(), n: nonce() });
221
+ await cas(driver, internals, row, JSON.stringify(state));
222
+
223
+ const after = await read(driver, internals);
224
+ if (claim(after, driver, internals)) {
225
+ return true;
226
+ }
227
+
228
+ // Nobody holds the lock, still our update did not apply. Either another
229
+ // process acquired and released in between, or the compare and swap does
230
+ // not work with this driver at all, which must not end in a busy loop.
231
+ if (after && !heldByOther(after)) {
232
+ if (retries > 0) {
233
+ return module.exports.acquire(driver, internals, null, retries - 1);
234
+ }
235
+
236
+ throw new Error(
237
+ '[state] could not acquire the migration lock although it is free, ' +
238
+ 'the driver does not seem to support compare and swap on the state'
28
239
  );
29
240
  }
241
+
242
+ return false;
30
243
  },
31
244
 
32
- init: async function (driver, internals) {
33
- await driver._createKV(internals.migrationState);
34
- const schema = await driver._getKV(internals.migrationState, SSTATE);
35
- if (schema) {
36
- internals.schema = JSON.parse(schema.value);
37
- } else {
38
- await driver._insertKV(internals.migrationState, SSTATE, '{}');
245
+ /**
246
+ * Wait until the lock is released. A lock whose row did not change for
247
+ * lock-timeout ms is considered stale, its holder is gone. This is
248
+ * measured on our own monotonic clock, so no clocks have to be in sync.
249
+ *
250
+ * Resolves to the stale row, or null if the lock was released properly.
251
+ */
252
+ waitForRelease: async function (driver, internals) {
253
+ const { timeout, interval } = lockOptions(internals);
254
+ let seen = null;
255
+ let since = 0;
256
+
257
+ for (;;) {
258
+ const row = await read(driver, internals);
259
+
260
+ if (!row || !heldByOther(row)) {
261
+ return null;
262
+ }
263
+
264
+ const now = performance.now();
265
+ if (
266
+ !seen ||
267
+ seen.value !== row.value ||
268
+ String(seen.run_on) !== String(row.run_on)
269
+ ) {
270
+ seen = row;
271
+ since = now;
272
+ } else if (now - since >= timeout) {
273
+ return row;
274
+ }
275
+
276
+ await delay(interval);
277
+ }
278
+ },
279
+
280
+ release: async function (driver, internals) {
281
+ stopHeartbeat();
282
+
283
+ if (!owner) {
284
+ active = false;
285
+ return;
286
+ }
287
+
288
+ try {
289
+ await write(driver, internals, s => {
290
+ s.ID = 0;
291
+ });
292
+ } catch (err) {
293
+ log.warn(err.message);
294
+ }
295
+
296
+ active = false;
297
+ owner = false;
298
+ current = null;
299
+ },
300
+
301
+ isOwner: function () {
302
+ return owner;
303
+ },
304
+
305
+ init: async function (driver, internals, { emptyState, backupState }) {
306
+ await createConcurrently(() => driver._createKV(internals.migrationState));
307
+ const _schema = await driver._getKV(internals.migrationState, SSTATE);
308
+
309
+ if (_schema && backupState) {
310
+ const newName = `${internals.migrationState}_b_${Math.floor((new Date() - 0) / 1000)}`;
311
+ log.info(`[state] Created a backup of ${internals.migrationState} by writing to file ${newName}.dbmigrate`);
312
+
313
+ await fs.writeFile(path.resolve(`${newName}.dbmigrate`), JSON.stringify(_schema), 'utf8');
314
+
315
+ if (emptyState) {
316
+ await driver.renameTable(internals.migrationState,
317
+ newName);
318
+
319
+ log.info(`[state] Created a backup of ${internals.migrationState} by renaming table to ${newName}`);
320
+ await driver._createKV(internals.migrationState);
321
+ await driver._insertKV(internals.migrationState, SSTATE, '{}');
322
+ }
39
323
  }
40
324
 
41
- if (!internals.dryRun) {
42
- const state = await driver._getKV(internals.migrationState, MSTATE);
43
- return module.exports.lockState(driver, state, internals);
325
+ const schema = emptyState !== true ? _schema : null;
326
+ if (schema) {
327
+ internals.schema = parseSchema(schema.value);
328
+ } else if (!emptyState) {
329
+ try {
330
+ await driver._insertKV(internals.migrationState, SSTATE, '{}');
331
+ } catch (err) {
332
+ // another process initialized the state at the same time
333
+ const row = await driver._getKV(internals.migrationState, SSTATE);
334
+ if (!row) {
335
+ throw err;
336
+ }
337
+
338
+ internals.schema = parseSchema(row.value);
339
+ }
44
340
  }
341
+ },
45
342
 
46
- return Promise.resolve();
343
+ /**
344
+ * Another process may have migrated while we were waiting for the lock,
345
+ * so the schema loaded during init is outdated.
346
+ */
347
+ reloadSchema: async function (driver, internals) {
348
+ const schema = await driver._getKV(internals.migrationState, SSTATE);
349
+ if (schema) {
350
+ internals.schema = parseSchema(schema.value);
351
+ }
47
352
  },
48
353
 
49
- startMigration: async function (driver, file, internals) {
50
- const state = await driver._getKV(internals.migrationState, MSTATE);
354
+ /**
355
+ * Start a migration in the state. With recover, an interrupted previous
356
+ * run of the same migration is not reset but returned, so the caller can
357
+ * decide how to recover:
358
+ *
359
+ * step: the last step started
360
+ * learned: the last step whose reverse operation was recorded
361
+ * done: the last step executed on the database
362
+ * rollback: the run was interrupted while rolling back
363
+ * changed: the migration file changed since
364
+ */
365
+ startMigration: async function (
366
+ driver,
367
+ file,
368
+ internals,
369
+ { op = 'up', recover = false } = {}
370
+ ) {
51
371
  const mig = await driver._getKV(internals.migrationState, file.name);
52
372
 
53
373
  if (mig && mig.value !== '{}') {
54
374
  internals.modSchema = JSON.parse(mig.value);
375
+ // records of older runs, they belong to no step of this run
376
+ (internals.modSchema.s || []).forEach(entry => {
377
+ if (entry.n === undefined) entry.n = 0;
378
+ });
55
379
  }
56
380
 
57
381
  if (internals.dryRun) {
58
- return Promise.resolve();
382
+ return null;
383
+ }
384
+
385
+ const h = await hashFile(file);
386
+ const row = await read(driver, internals);
387
+ const prev = row ? JSON.parse(row.value).s : null;
388
+
389
+ if (prev && prev.fin === 0 && prev.f) {
390
+ if (recover && prev.f === file.name && prev.o === 'up' && op === 'up') {
391
+ return {
392
+ step: prev.step || 0,
393
+ learned: prev.learned || 0,
394
+ done: prev.done || 0,
395
+ rollback: prev.rb === 1,
396
+ changed: !!prev.h && !!h && prev.h !== h
397
+ };
398
+ }
399
+
400
+ log.warn(
401
+ `[state] ignoring the interrupted ${prev.o} of migration ` +
402
+ `"${prev.f}" at step ${prev.step}`
403
+ );
59
404
  }
60
405
 
61
- await module.exports.lockState(driver, state, internals);
406
+ await module.exports.progress(driver, internals, s => {
407
+ Object.assign(s, {
408
+ step: 0,
409
+ fin: 0,
410
+ f: file.name,
411
+ o: op,
412
+ learned: 0,
413
+ done: 0,
414
+ rb: 0,
415
+ h
416
+ });
417
+ });
62
418
 
63
419
  if (!mig) {
64
- return driver._insertKV(
420
+ await driver._insertKV(
65
421
  internals.migrationState,
66
422
  file.name,
67
423
  JSON.stringify({})
68
424
  );
69
- } else {
70
- return Promise.resolve();
71
425
  }
426
+
427
+ return null;
428
+ },
429
+
430
+ /**
431
+ * Write migration progress into the lock row. While holding the lock this
432
+ * is a compare and swap, which fails if we lost the lock. Without a lock
433
+ * (driver does not support it) the row is written unconditionally.
434
+ */
435
+ progress: async function (driver, internals, mutate) {
436
+ if (active) {
437
+ return write(driver, internals, mutate);
438
+ }
439
+
440
+ const row = await read(driver, internals);
441
+ const state = row
442
+ ? JSON.parse(row.value)
443
+ : { s: { step: 0, fin: 0, ID: 0 } };
444
+ state.s.date = new Date();
445
+ mutate(state.s);
446
+
447
+ if (!row) {
448
+ return driver._insertKV(
449
+ internals.migrationState,
450
+ MSTATE,
451
+ JSON.stringify(state)
452
+ );
453
+ }
454
+
455
+ return driver._updateKV(
456
+ internals.migrationState,
457
+ MSTATE,
458
+ JSON.stringify(state)
459
+ );
72
460
  },
73
461
 
74
462
  update: async function (driver, file, state, internals) {
@@ -77,7 +465,7 @@ module.exports = {
77
465
  return Promise.resolve();
78
466
  }
79
467
 
80
- await driver._updateKV(internals.migrationState, SSTATE, internals.schema);
468
+ await driver._updateKV(internals.migrationState, SSTATE, JSON.stringify(internals.schema));
81
469
 
82
470
  return driver._updateKV(
83
471
  internals.migrationState,
@@ -90,22 +478,34 @@ module.exports = {
90
478
  return driver._getKV(internals.migrationState, file.name);
91
479
  },
92
480
 
93
- tick: async function (driver, internals) {
481
+ learned: async function (driver, step, internals) {
94
482
  if (internals.dryRun) {
95
483
  return Promise.resolve();
96
484
  }
97
485
 
98
- let { value: state } = await driver._getKV(
99
- internals.migrationState,
100
- MSTATE
101
- );
102
- state = JSON.parse(state);
103
- state.s.date = new Date();
104
- return driver._updateKV(
105
- internals.migrationState,
106
- MSTATE,
107
- JSON.stringify(state)
108
- );
486
+ return module.exports.progress(driver, internals, s => {
487
+ s.learned = step;
488
+ });
489
+ },
490
+
491
+ done: async function (driver, step, internals) {
492
+ if (internals.dryRun) {
493
+ return Promise.resolve();
494
+ }
495
+
496
+ return module.exports.progress(driver, internals, s => {
497
+ s.done = step;
498
+ });
499
+ },
500
+
501
+ rollingBack: async function (driver, internals) {
502
+ if (internals.dryRun) {
503
+ return Promise.resolve();
504
+ }
505
+
506
+ return module.exports.progress(driver, internals, s => {
507
+ s.rb = 1;
508
+ });
109
509
  },
110
510
 
111
511
  step: async function (driver, step, internals) {
@@ -114,18 +514,9 @@ module.exports = {
114
514
  return Promise.resolve();
115
515
  }
116
516
 
117
- let { value: state } = await driver._getKV(
118
- internals.migrationState,
119
- MSTATE
120
- );
121
- state = JSON.parse(state);
122
- state.s.date = new Date();
123
- state.s.step = step;
124
- return driver._updateKV(
125
- internals.migrationState,
126
- MSTATE,
127
- JSON.stringify(state)
128
- );
517
+ return module.exports.progress(driver, internals, s => {
518
+ s.step = step;
519
+ });
129
520
  },
130
521
 
131
522
  deleteState: function (driver, file, internals) {
@@ -137,18 +528,10 @@ module.exports = {
137
528
  return Promise.resolve();
138
529
  }
139
530
 
140
- let { value: state } = await driver._getKV(
141
- internals.migrationState,
142
- MSTATE
143
- );
144
- state = JSON.parse(state);
145
- state.s.date = new Date();
146
- state.s.fin = 1;
147
-
148
- return driver._updateKV(
149
- internals.migrationState,
150
- MSTATE,
151
- JSON.stringify(state)
152
- );
531
+ // the lock itself stays until the walker releases it
532
+ return module.exports.progress(driver, internals, s => {
533
+ s.fin = 1;
534
+ s.rb = 0;
535
+ });
153
536
  }
154
537
  };