db-migrate 1.0.0-beta.35 → 1.0.0-beta.37

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/connect.js CHANGED
@@ -85,7 +85,8 @@ exports.connect = async function (config, PassedClass) {
85
85
  dirPath,
86
86
  internals.mode !== 'static',
87
87
  internals,
88
- prefix
88
+ prefix,
89
+ { db2 }
89
90
  )
90
91
  );
91
92
  }
package/lib/chain.js CHANGED
@@ -1,3 +1,5 @@
1
+ const log = require('db-migrate-shared').log;
2
+
1
3
  const DEFAULT = [
2
4
  'renameCollection',
3
5
  'dropCollection',
@@ -15,6 +17,21 @@ const DEFAULT = [
15
17
  'removeForeignKey'
16
18
  ];
17
19
 
20
+ /**
21
+ * Human readable form of an instruction for the log, like
22
+ * addColumn("users", "email")
23
+ */
24
+ const describe = (method, args) =>
25
+ `${method}(${args
26
+ .filter(
27
+ a =>
28
+ typeof a === 'string' ||
29
+ typeof a === 'number' ||
30
+ (Array.isArray(a) && a.every(x => typeof x === 'string'))
31
+ )
32
+ .map(a => JSON.stringify(a))
33
+ .join(', ')})`;
34
+
18
35
  let rollbackCounter = 0;
19
36
  let rollbackSignal = null;
20
37
  let signal = null;
@@ -82,6 +99,14 @@ class Chain {
82
99
  this._step = null;
83
100
  this._interface = null;
84
101
 
102
+ /**
103
+ * op: number of the current step, the instructions of a migration
104
+ * recovery: progress of an interrupted previous run to resume from,
105
+ * see State.startMigration
106
+ */
107
+ this.op = 0;
108
+ this.recovery = null;
109
+
85
110
  DEFAULT.concat(Object.keys(this.context.learnable || [])).forEach(
86
111
  method => {
87
112
  this[method] = function (...args) {
@@ -130,7 +155,33 @@ class Chain {
130
155
  async exec (m, ...args) {
131
156
  let ret;
132
157
  const stat = [];
158
+ const op = ++this.op;
159
+ const recovery = this.recovery;
160
+ this.internals.migrationOp = op;
161
+
162
+ if (recovery && op <= recovery.done) {
163
+ log.info(
164
+ `[recovery] ${this.file.name}: skipping already executed step ` +
165
+ `${op}/${recovery.done} ${describe(m, args)}`
166
+ );
167
+ return ret;
168
+ }
169
+
170
+ // the step was interrupted after its reverse operation was recorded,
171
+ // but before it finished on the database
172
+ const learned = recovery !== null && op <= recovery.learned;
173
+ if (learned) {
174
+ log.info(
175
+ `[recovery] ${this.file.name}: executing interrupted step ${op} ` +
176
+ `${describe(m, args)} without learning it again`
177
+ );
178
+ }
179
+
133
180
  while (this.step()) {
181
+ if (learned && this._step.learns) {
182
+ continue;
183
+ }
184
+
134
185
  ret = await this._interface[m].apply(this._interface, args);
135
186
  stat.push(ret);
136
187
 
@@ -17,7 +17,9 @@ module.exports = function (internals, isModule) {
17
17
  'vcseeder-dir': internals.cwd + '/VCSeeder',
18
18
  'staticseeder-dir': internals.cwd + '/Seeder',
19
19
  'ignore-completed-migrations': false,
20
- 'backup-state': false
20
+ 'backup-state': false,
21
+ 'lock-timeout': 60000,
22
+ 'lock-interval': 1000
21
23
  };
22
24
 
23
25
  if (!isModule) {
@@ -116,6 +118,17 @@ module.exports = function (internals, isModule) {
116
118
  .string('log-level')
117
119
  .describe('backup-state', 'For use with fix, to take a backup of the current state before continuing.')
118
120
  .boolean('backup-state')
121
+ .describe(
122
+ 'lock-timeout',
123
+ 'Milliseconds without any sign of life after which a migration lock' +
124
+ ' held by another process is considered stale and taken over.'
125
+ )
126
+ .number('lock-timeout')
127
+ .describe(
128
+ 'lock-interval',
129
+ 'Milliseconds between checks while waiting for the migration lock.'
130
+ )
131
+ .number('lock-interval')
119
132
  .parse();
120
133
  } else {
121
134
  internals.argv = Object.assign(defaultConfig, internals.cmdOptions);
@@ -8,10 +8,76 @@ const Chain = require('../../chain');
8
8
  const StateTravel = require('../../methods/v2/statetravel')();
9
9
  const StateNoStepTravel = require('../../methods/v2/statetravel')(false);
10
10
  const Migrate = require('../../methods/v2/migrate');
11
+ const StateDone = require('../../methods/v2/statedone');
11
12
  const TranslateState = require('../../methods/v2/translatestate');
12
13
  const AddConventions = require('../../methods/v2/conventions');
13
14
  const util = require('util');
14
15
 
16
+ /**
17
+ * How to recover from an interrupted run of a migration, set per migration
18
+ * by _meta.recovery:
19
+ *
20
+ * skip: skip the steps already executed and continue with the rest
21
+ * rollback: revert the steps already executed and run the migration again
22
+ */
23
+ const RECOVERY_MODES = ['skip', 'rollback'];
24
+ const RECOVERY_DEFAULT = 'skip';
25
+
26
+ const recoveryMode = (file, interrupted) => {
27
+ const name = file.name;
28
+ const mode = file.get()._meta.recovery || RECOVERY_DEFAULT;
29
+
30
+ if (RECOVERY_MODES.indexOf(mode) === -1) {
31
+ throw new Error(
32
+ `Invalid recovery mode "${mode}" in migration "${name}", use one of ` +
33
+ RECOVERY_MODES.join(', ')
34
+ );
35
+ }
36
+
37
+ if (interrupted.rollback) {
38
+ if (mode !== 'rollback') {
39
+ log.warn(
40
+ `[recovery] ${name}: the previous run was interrupted while rolling ` +
41
+ 'back, continuing the rollback'
42
+ );
43
+ }
44
+
45
+ return 'rollback';
46
+ }
47
+
48
+ if (mode === 'skip' && interrupted.changed) {
49
+ throw new Error(
50
+ `Migration "${name}" was interrupted at step ${interrupted.step} and ` +
51
+ 'changed since, so the executed steps can not be skipped safely. ' +
52
+ "Set _meta.recovery to 'rollback' to revert them, or repair the " +
53
+ 'state manually.'
54
+ );
55
+ }
56
+
57
+ return mode;
58
+ };
59
+
60
+ /**
61
+ * Revert the steps executed by an interrupted run. A step started but not
62
+ * done did not change the database, so its reverse operation is dropped.
63
+ */
64
+ const recoverByRollback = async (context, driver, file, interrupted) => {
65
+ const internals = context.internals;
66
+ const pdriver = context._pdriver;
67
+
68
+ await State.rollingBack(pdriver, internals);
69
+ internals.modSchema.s = internals.modSchema.s.filter(
70
+ entry => !(entry.n > interrupted.done)
71
+ );
72
+ await TranslateState(context._driver, file, driver, internals, pdriver);
73
+
74
+ internals.unlearn = false;
75
+ internals.rollback = false;
76
+ internals.rollbackContinue = false;
77
+ internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
78
+ await State.endMigration(pdriver, file, internals);
79
+ };
80
+
15
81
  const execUnit = {
16
82
  _extend: (context, type) => {
17
83
  return {
@@ -38,8 +104,31 @@ const execUnit = {
38
104
  chain.addChain(Learn);
39
105
  chain.addChain(StateNoStepTravel);
40
106
  chain.addChain(Migrate);
107
+ chain.addChain(StateDone);
108
+
109
+ const interrupted = await State.startMigration(
110
+ context._pdriver,
111
+ file,
112
+ context.internals,
113
+ { recover: true }
114
+ );
115
+
116
+ if (interrupted) {
117
+ const mode = recoveryMode(file, interrupted);
118
+ log.warn(
119
+ `[recovery] ${file.name}: the previous run was interrupted at step ` +
120
+ `${interrupted.step}, ${interrupted.done} steps were executed, ` +
121
+ `recovering by ${mode}`
122
+ );
123
+
124
+ if (mode === 'rollback') {
125
+ await recoverByRollback(context, driver, file, interrupted);
126
+ await State.startMigration(context._pdriver, file, context.internals);
127
+ } else {
128
+ chain.recovery = interrupted;
129
+ }
130
+ }
41
131
 
42
- await State.startMigration(context._pdriver, file, context.internals);
43
132
  // startMigration - needs secondary instance since we can not afford to
44
133
  // loose state and the transaction start will include these for roll back
45
134
  // we will disable them probably at all from DDL when the driver does not
@@ -60,6 +149,7 @@ const execUnit = {
60
149
  'An error occured. No alternative failure strategy defined. Rolling back!',
61
150
  err
62
151
  );
152
+ await State.rollingBack(context._pdriver, context.internals);
63
153
  await execUnit.down(context, driver, file, { abort: true });
64
154
  throw err;
65
155
  }
@@ -77,7 +167,9 @@ const execUnit = {
77
167
  chain.addChain(Learn);
78
168
  chain.addChain(StateTravel);
79
169
 
80
- await State.startMigration(context._pdriver, file, context.internals);
170
+ await State.startMigration(context._pdriver, file, context.internals, {
171
+ op: 'fix'
172
+ });
81
173
  // startMigration - needs secondary instance since we can not afford to
82
174
  // loose state and the transaction start will include these for roll back
83
175
  // we will disable them probably at all from DDL when the driver does not
@@ -110,7 +202,9 @@ const execUnit = {
110
202
  // if we get the abort signal this means we are in a rollback routine
111
203
  // which means in turn we want to avoid reloading the state
112
204
  if (abort !== true) {
113
- await State.startMigration(context._pdriver, file, context.internals);
205
+ await State.startMigration(context._pdriver, file, context.internals, {
206
+ op: 'down'
207
+ });
114
208
  }
115
209
  // start migration, see up comments
116
210
  await TranslateState(context._driver, file, driver, context.internals, context._pdriver);
package/lib/learn.js CHANGED
@@ -260,7 +260,7 @@ class STD {
260
260
 
261
261
  addIndex (t, i, c, u) {
262
262
  if (Array.isArray(c)) {
263
- c.forEach(x => this.checkColumn(t, typeof x === 'object' ? x.name : x));
263
+ c.forEach(x => this.checkColumn(t, typeof x === 'object' ? x.name : x));
264
264
  } else {
265
265
  this.checkColumn(t, c);
266
266
  }
@@ -371,6 +371,9 @@ const noLearnError = prop => {
371
371
  };
372
372
 
373
373
  module.exports = {
374
+ // learns the schema and records the reverse operations
375
+ learns: true,
376
+
374
377
  getInterface: (context, file, driver, internals) => {
375
378
  if (context.learnable) {
376
379
  const _std = new STD(internals, context);
@@ -0,0 +1,16 @@
1
+ const Shadow = require('../../driver/shadow');
2
+ const State = require('../../state');
3
+
4
+ /**
5
+ * Records a step as done, after it was executed on the database.
6
+ */
7
+ module.exports = {
8
+ getInterface: (context, file, driver, internals, pdriver) => {
9
+ const done = () =>
10
+ function () {
11
+ return State.done(pdriver, internals.migrationOp, internals);
12
+ };
13
+
14
+ return Shadow.overshadow(driver, {}, done);
15
+ }
16
+ };
@@ -17,24 +17,38 @@ const DEFAULT = {
17
17
  removeForeignKey: 'addForeignKey'
18
18
  };
19
19
 
20
+ /**
21
+ * Persists the state around learning a step. Before learning (step) it
22
+ * records the step as started, afterwards it tags the recorded reverse
23
+ * operations with their step and records the step as learned.
24
+ */
20
25
  class StateTravel {
21
26
  constructor (internals, file, driver, pdriver, step = true) {
22
27
  this.file = file;
23
28
  this.internals = internals;
24
29
  this.driver = driver;
25
30
  this.pdriver = pdriver;
26
- this._counter = 0;
27
31
  this._default = async function _default () {
32
+ const op = this.internals.migrationOp;
33
+
28
34
  if (step) {
29
- await State.step(this.pdriver, ++this._counter, this.internals);
35
+ await State.step(this.pdriver, op, this.internals);
36
+ } else {
37
+ this.internals.modSchema.s.forEach(entry => {
38
+ if (entry.n === undefined) entry.n = op;
39
+ });
30
40
  }
31
41
 
32
- return State.update(
42
+ await State.update(
33
43
  this.pdriver,
34
44
  this.file,
35
45
  this.internals.modSchema,
36
46
  this.internals
37
47
  );
48
+
49
+ if (!step) {
50
+ await State.learned(this.pdriver, op, this.internals);
51
+ }
38
52
  };
39
53
  }
40
54
  }
@@ -101,7 +101,7 @@ async function processEntry (
101
101
  }
102
102
  }
103
103
 
104
- internals.modSchema.s.shift();
104
+ internals.modSchema.s.pop();
105
105
  await State.update(pdriver, file, internals.modSchema, internals);
106
106
  }
107
107
 
@@ -113,7 +113,9 @@ module.exports = async (context, file, driver, internals, pdriver) => {
113
113
 
114
114
  chain.addChain(Learn);
115
115
  chain.addChain(Migrate);
116
- await Promise.resolve(mod.s.reverse()).each(args =>
116
+ // the stored records keep their order, so an interrupted rollback can
117
+ // be continued
118
+ await Promise.resolve(mod.s.slice().reverse()).each(args =>
117
119
  processEntry(context, file, chain, internals, pdriver, args)
118
120
  );
119
121
 
package/lib/state.js CHANGED
@@ -1,68 +1,301 @@
1
1
  const MSTATE = '__dbmigrate_state__';
2
2
  const SSTATE = '__dbmigrate_schema__';
3
- const RUNON = 'run_on';
4
3
 
5
4
  const log = require('db-migrate-shared').log;
6
5
  const fs = require('fs').promises;
7
6
  const path = require('path');
8
7
  const crypto = require('crypto');
8
+ const { performance } = require('perf_hooks');
9
9
 
10
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;
11
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
+ };
12
154
 
13
155
  module.exports = {
156
+ createConcurrently,
157
+
158
+ // for tests, simulates a process which died while holding the lock
159
+ __stopHeartbeat: stopHeartbeat,
160
+
14
161
  /**
15
- * Lock state will add the basic state controller an ID
16
- * and a process controlled date.
17
- * It is important that all systems are in sync. We will use the
18
- * database clock instead if possible.
162
+ * Drivers declare that their state methods were verified for locking,
163
+ * with an atomic _updateKVC and run_on set by the database clock.
19
164
  */
20
- lockState: async function (driver, state, internals) {
21
- if (!state) {
22
- owner = true;
23
- return driver._insertKV(
24
- internals.migrationState,
25
- MSTATE,
26
- JSON.stringify({
27
- s: {
28
- step: 0,
29
- fin: 0,
30
- ID,
31
- date: new Date()
32
- }
33
- })
34
- );
35
- } else {
36
- let runOn = state.run_on;
37
- state = state.value;
38
- state = JSON.parse(state);
39
-
40
- if (!state.s.ID || state.s.ID !== ID) {
41
- owner = true;
42
- state.s.ID = ID;
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);
43
198
  }
44
199
 
45
- const d = new Date();
200
+ return claim(await read(driver, internals), driver, internals);
201
+ }
46
202
 
47
- state.s.date = d;
48
- await driver._updateKVC(
49
- internals.migrationState,
50
- MSTATE,
51
- JSON.stringify(state),
52
- RUNON,
53
- runOn
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
+ : '')
54
216
  );
217
+ }
55
218
 
56
- state = await driver._getKV(internals.migrationState, MSTATE);
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));
57
222
 
58
- runOn = state.run_on;
59
- state = state.value;
60
- state = JSON.parse(state);
223
+ const after = await read(driver, internals);
224
+ if (claim(after, driver, internals)) {
225
+ return true;
226
+ }
61
227
 
62
- if (state.s.ID !== ID) {
63
- owner = false;
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);
64
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'
239
+ );
240
+ }
241
+
242
+ return false;
243
+ },
244
+
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;
65
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;
66
299
  },
67
300
 
68
301
  isOwner: function () {
@@ -70,7 +303,7 @@ module.exports = {
70
303
  },
71
304
 
72
305
  init: async function (driver, internals, { emptyState, backupState }) {
73
- await driver._createKV(internals.migrationState);
306
+ await createConcurrently(() => driver._createKV(internals.migrationState));
74
307
  const _schema = await driver._getKV(internals.migrationState, SSTATE);
75
308
 
76
309
  if (_schema && backupState) {
@@ -91,42 +324,139 @@ module.exports = {
91
324
 
92
325
  const schema = emptyState !== true ? _schema : null;
93
326
  if (schema) {
94
- internals.schema = JSON.parse(schema.value);
327
+ internals.schema = parseSchema(schema.value);
95
328
  } else if (!emptyState) {
96
- await driver._insertKV(internals.migrationState, SSTATE, '{}');
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
+ }
97
340
  }
341
+ },
98
342
 
99
- if (!internals.dryRun) {
100
- const state = await driver._getKV(internals.migrationState, MSTATE);
101
- return module.exports.lockState(driver, state, internals);
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);
102
351
  }
103
-
104
- return Promise.resolve();
105
352
  },
106
353
 
107
- startMigration: async function (driver, file, internals) {
108
- 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
+ ) {
109
371
  const mig = await driver._getKV(internals.migrationState, file.name);
110
372
 
111
373
  if (mig && mig.value !== '{}') {
112
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
+ });
113
379
  }
114
380
 
115
381
  if (internals.dryRun) {
116
- return Promise.resolve();
382
+ return null;
117
383
  }
118
384
 
119
- await module.exports.lockState(driver, state, internals);
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
+ );
404
+ }
405
+
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
+ });
120
418
 
121
419
  if (!mig) {
122
- return driver._insertKV(
420
+ await driver._insertKV(
123
421
  internals.migrationState,
124
422
  file.name,
125
423
  JSON.stringify({})
126
424
  );
127
- } else {
128
- return Promise.resolve();
129
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
+ );
130
460
  },
131
461
 
132
462
  update: async function (driver, file, state, internals) {
@@ -148,45 +478,34 @@ module.exports = {
148
478
  return driver._getKV(internals.migrationState, file.name);
149
479
  },
150
480
 
151
- tick: async function (driver, internals) {
481
+ learned: async function (driver, step, internals) {
152
482
  if (internals.dryRun) {
153
483
  return Promise.resolve();
154
484
  }
155
485
 
156
- let state = await driver._getKV(
157
- internals.migrationState,
158
- MSTATE
159
- );
160
-
161
- let runOn = state.run_on;
162
- state = state.value;
163
- state = JSON.parse(state);
486
+ return module.exports.progress(driver, internals, s => {
487
+ s.learned = step;
488
+ });
489
+ },
164
490
 
165
- if (!state.s.ID || state.s.ID !== ID) {
166
- owner = true;
167
- state.s.ID = ID;
491
+ done: async function (driver, step, internals) {
492
+ if (internals.dryRun) {
493
+ return Promise.resolve();
168
494
  }
169
495
 
170
- const d = new Date();
171
-
172
- state.s.date = d;
173
- await driver._updateKVC(
174
- internals.migrationState,
175
- MSTATE,
176
- JSON.stringify(state),
177
- RUNON,
178
- runOn
179
- );
180
-
181
- state = await driver._getKV(internals.migrationState, MSTATE);
182
-
183
- runOn = state.run_on;
184
- state = state.value;
185
- state = JSON.parse(state);
496
+ return module.exports.progress(driver, internals, s => {
497
+ s.done = step;
498
+ });
499
+ },
186
500
 
187
- if (state.s.ID !== ID) {
188
- owner = false;
501
+ rollingBack: async function (driver, internals) {
502
+ if (internals.dryRun) {
503
+ return Promise.resolve();
189
504
  }
505
+
506
+ return module.exports.progress(driver, internals, s => {
507
+ s.rb = 1;
508
+ });
190
509
  },
191
510
 
192
511
  step: async function (driver, step, internals) {
@@ -195,18 +514,9 @@ module.exports = {
195
514
  return Promise.resolve();
196
515
  }
197
516
 
198
- let { value: state } = await driver._getKV(
199
- internals.migrationState,
200
- MSTATE
201
- );
202
- state = JSON.parse(state);
203
- state.s.date = new Date();
204
- state.s.step = step;
205
- return driver._updateKV(
206
- internals.migrationState,
207
- MSTATE,
208
- JSON.stringify(state)
209
- );
517
+ return module.exports.progress(driver, internals, s => {
518
+ s.step = step;
519
+ });
210
520
  },
211
521
 
212
522
  deleteState: function (driver, file, internals) {
@@ -218,21 +528,10 @@ module.exports = {
218
528
  return Promise.resolve();
219
529
  }
220
530
 
221
- let { value: state } = await driver._getKV(
222
- internals.migrationState,
223
- MSTATE
224
- );
225
- state = JSON.parse(state);
226
- state.s.date = new Date();
227
- state.s.fin = 1;
228
-
229
- // unlock from current process
230
- state.s.ID = 0;
231
-
232
- return driver._updateKV(
233
- internals.migrationState,
234
- MSTATE,
235
- JSON.stringify(state)
236
- );
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
+ });
237
536
  }
238
537
  };
package/lib/walker.js CHANGED
@@ -63,7 +63,9 @@ const Walker = function (driver, directory, mode, intern, prefix, opts = {}) {
63
63
  Walker.prototype = {
64
64
  createTables: async function (options) {
65
65
  await State.init(this._pdriver, this.internals, options);
66
- return this._driver._createList(this.internals.migrationTable);
66
+ return State.createConcurrently(() =>
67
+ this._driver._createList(this.internals.migrationTable)
68
+ );
67
69
  },
68
70
 
69
71
  createMigrationsTable: function (options = {}) {
@@ -90,6 +92,52 @@ Walker.prototype = {
90
92
  return this.createTables(options);
91
93
  },
92
94
 
95
+ /**
96
+ * Determine what to run first, only lock when there is actually something
97
+ * to run. If another process holds the lock, wait for its release and
98
+ * determine again, the other process might have done the work already.
99
+ * Once we hold the lock, state and pending migrations are read again.
100
+ */
101
+ _withLock: async function (determine, run) {
102
+ if (this.prefix !== 'migration' || this.internals.dryRun) {
103
+ return run(await determine());
104
+ }
105
+
106
+ if (!State.supportsLock(this._pdriver)) {
107
+ log.warn(
108
+ this.title +
109
+ 'Your database driver does not support migration locking, ' +
110
+ 'concurrent processes are not prevented from migrating at the ' +
111
+ 'same time. Update your driver to a version supporting it.'
112
+ );
113
+ return run(await determine());
114
+ }
115
+
116
+ let stale = null;
117
+ let toRun = await determine();
118
+
119
+ while (toRun.length > 0) {
120
+ if (await State.acquire(this._pdriver, this.internals, stale)) {
121
+ break;
122
+ }
123
+
124
+ log.info(this.title + 'Waiting for the migration lock of another process');
125
+ stale = await State.waitForRelease(this._pdriver, this.internals);
126
+ toRun = await determine();
127
+ }
128
+
129
+ if (toRun.length === 0) {
130
+ return run(toRun);
131
+ }
132
+
133
+ try {
134
+ await State.reloadSchema(this._pdriver, this.internals);
135
+ return await run(await determine());
136
+ } finally {
137
+ await State.release(this._pdriver, this.internals);
138
+ }
139
+ },
140
+
93
141
  writeMigrationRecord: function (migration, callback) {
94
142
  const onComplete = err => {
95
143
  if (err) {
@@ -149,96 +197,84 @@ Walker.prototype = {
149
197
  up: function (options, callback) {
150
198
  const partialName = options.destination;
151
199
  const count = options.count;
152
- const files = Promise.all([
153
- File.loadFromFileystem(this.directory, this.prefix, this.internals),
154
- File.loadFromDatabase(
155
- this.directory,
156
- this.prefix,
157
- this._driver,
158
- this.internals
159
- )
160
- ]).spread((allFiles, completedFiles) => {
161
- const toRun = dbmUtil.filterUp(
162
- allFiles,
163
- completedFiles,
164
- partialName,
165
- count
200
+ const determine = () =>
201
+ Promise.all([
202
+ File.loadFromFileystem(this.directory, this.prefix, this.internals),
203
+ File.loadFromDatabase(
204
+ this.directory,
205
+ this.prefix,
206
+ this._driver,
207
+ this.internals
208
+ )
209
+ ]).spread((allFiles, completedFiles) =>
210
+ dbmUtil.filterUp(allFiles, completedFiles, partialName, count)
166
211
  );
167
212
 
168
- if (toRun.length === 0) {
169
- log.info(this.title + 'Nothing to run');
170
- }
171
-
172
- if (this.internals.check) {
173
- const toRunNames = toRun.map(migration => migration.name);
174
- log.info(this.title + 'run:', toRunNames);
175
- return toRunNames;
176
- }
177
-
178
- return toRun;
179
- });
180
-
181
213
  if (this.internals.check) {
182
- return files.nodeify(callback);
214
+ return determine()
215
+ .then(toRun => this._checkResult(toRun))
216
+ .nodeify(callback);
183
217
  }
184
218
 
185
- return files
186
- .each(file => {
187
- log.verbose(this.title + 'preparing to run up:', file.name);
188
- const _meta = file.get()._meta || {};
189
- const version = _meta.version || 1;
190
- this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
191
- return require(`./executors/versioned/v${version}`).up(
192
- this,
193
- this.driver,
194
- file
195
- );
219
+ return Promise.resolve(
220
+ this._withLock(determine, toRun => {
221
+ if (toRun.length === 0) {
222
+ log.info(this.title + 'Nothing to run');
223
+ }
224
+
225
+ return Promise.each(toRun, file => {
226
+ log.verbose(this.title + 'preparing to run up:', file.name);
227
+ const _meta = file.get()._meta || {};
228
+ const version = _meta.version || 1;
229
+ this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
230
+ return require(`./executors/versioned/v${version}`).up(
231
+ this,
232
+ this.driver,
233
+ file
234
+ );
235
+ });
196
236
  })
197
- .nodeify(callback);
237
+ ).nodeify(callback);
198
238
  },
199
239
 
200
240
  down: function (options, callback) {
201
241
  const partialName = options.destination;
202
242
  const count = options.count;
203
-
204
- const files = File.loadFromDatabase(
205
- this.directory,
206
- this.prefix,
207
- this._driver,
208
- this.internals
209
- ).then(completedFiles => {
210
- const toRun = dbmUtil.filterDown(completedFiles, partialName, count);
211
-
212
- if (toRun.length === 0) {
213
- log.info(this.title + 'Nothing to run');
214
- }
215
-
216
- if (this.internals.check) {
217
- const toRunNames = toRun.map(migration => migration.name);
218
- log.info(this.title + 'run:', toRunNames);
219
- return toRunNames;
220
- }
221
-
222
- return toRun;
223
- });
243
+ const determine = () =>
244
+ File.loadFromDatabase(
245
+ this.directory,
246
+ this.prefix,
247
+ this._driver,
248
+ this.internals
249
+ ).then(completedFiles =>
250
+ dbmUtil.filterDown(completedFiles, partialName, count)
251
+ );
224
252
 
225
253
  if (this.internals.check) {
226
- return files.nodeify(callback);
254
+ return determine()
255
+ .then(toRun => this._checkResult(toRun))
256
+ .nodeify(callback);
227
257
  }
228
258
 
229
- return files
230
- .each(file => {
231
- log.verbose(this.title + 'preparing to run down:', file.name);
232
- const _meta = file.get()._meta || {};
233
- const version = _meta.version || 1;
234
- this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
235
- return require(`./executors/versioned/v${version}`).down(
236
- this,
237
- this.driver,
238
- file
239
- );
259
+ return Promise.resolve(
260
+ this._withLock(determine, toRun => {
261
+ if (toRun.length === 0) {
262
+ log.info(this.title + 'Nothing to run');
263
+ }
264
+
265
+ return Promise.each(toRun, file => {
266
+ log.verbose(this.title + 'preparing to run down:', file.name);
267
+ const _meta = file.get()._meta || {};
268
+ const version = _meta.version || 1;
269
+ this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
270
+ return require(`./executors/versioned/v${version}`).down(
271
+ this,
272
+ this.driver,
273
+ file
274
+ );
275
+ });
240
276
  })
241
- .nodeify(callback);
277
+ ).nodeify(callback);
242
278
  },
243
279
 
244
280
  fix: function (options, callback) {
@@ -255,49 +291,56 @@ Walker.prototype = {
255
291
  return a.localeCompare(b);
256
292
  };
257
293
 
258
- const files = File.loadFromDatabase(
259
- this.directory,
260
- this.prefix,
261
- this._driver,
262
- this.internals
263
- ).then(completedFiles => {
264
- const toRun = dbmUtil.filterDown(completedFiles, partialName, count).sort(sortFn);
265
-
266
- if (toRun.length === 0) {
267
- log.info(this.title + 'Nothing to run');
268
- }
269
-
270
- if (this.internals.check) {
271
- const toRunNames = toRun.map(migration => migration.name);
272
- log.info(this.title + 'run:', toRunNames);
273
- return toRunNames;
274
- }
275
-
276
- return toRun;
277
- });
294
+ const determine = () =>
295
+ File.loadFromDatabase(
296
+ this.directory,
297
+ this.prefix,
298
+ this._driver,
299
+ this.internals
300
+ ).then(completedFiles =>
301
+ dbmUtil.filterDown(completedFiles, partialName, count).sort(sortFn)
302
+ );
278
303
 
279
304
  if (this.internals.check) {
280
- return files.nodeify(callback);
305
+ return determine()
306
+ .then(toRun => this._checkResult(toRun))
307
+ .nodeify(callback);
281
308
  }
282
309
 
283
- return files
284
- .each(file => {
285
- log.verbose(this.title + 'preparing to run fix:', file.name);
286
- const _meta = file.get()._meta || {};
287
- const version = _meta.version || 1;
288
- if (version < 2) {
289
- log.warn(`${this.title} skipping "${file.name}" as v1 migrations do not maintain a schema`);
290
- return Promise.resolve();
310
+ return Promise.resolve(
311
+ this._withLock(determine, toRun => {
312
+ if (toRun.length === 0) {
313
+ log.info(this.title + 'Nothing to run');
291
314
  }
292
315
 
293
- this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
294
- return require(`./executors/versioned/v${version}`).fix(
295
- this,
296
- this.driver,
297
- file
298
- );
316
+ return Promise.each(toRun, file => {
317
+ log.verbose(this.title + 'preparing to run fix:', file.name);
318
+ const _meta = file.get()._meta || {};
319
+ const version = _meta.version || 1;
320
+ if (version < 2) {
321
+ log.warn(`${this.title} skipping "${file.name}" as v1 migrations do not maintain a schema`);
322
+ return Promise.resolve();
323
+ }
324
+
325
+ this.internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
326
+ return require(`./executors/versioned/v${version}`).fix(
327
+ this,
328
+ this.driver,
329
+ file
330
+ );
331
+ });
299
332
  })
300
- .nodeify(callback);
333
+ ).nodeify(callback);
334
+ },
335
+
336
+ _checkResult: function (toRun) {
337
+ if (toRun.length === 0) {
338
+ log.info(this.title + 'Nothing to run');
339
+ }
340
+
341
+ const toRunNames = toRun.map(migration => migration.name);
342
+ log.info(this.title + 'run:', toRunNames);
343
+ return toRunNames;
301
344
  },
302
345
 
303
346
  check: function (options, callback) {
package/package.json CHANGED
@@ -31,7 +31,7 @@
31
31
  "programable",
32
32
  "api"
33
33
  ],
34
- "version": "1.0.0-beta.35",
34
+ "version": "1.0.0-beta.37",
35
35
  "engines": {
36
36
  "node": ">=8.0.0"
37
37
  },
package/tttt.js DELETED
@@ -1,2 +0,0 @@
1
-
2
-