@alexify/migronaut 2.0.0 → 2.1.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/CHANGELOG.md +320 -0
- package/README.md +208 -6
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +634 -18
- package/migronaut.schema.json +182 -1
- package/package.json +21 -5
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +608 -0
- package/src/bullmq/producer.js +424 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/converge.js +160 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +9 -2
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +14 -4
- package/src/cli/table.js +105 -0
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +372 -0
- package/src/core/config.js +100 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +45 -16
- package/src/core/migrator.js +563 -283
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +56 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +21 -1
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +36 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
const { ConfigInvalidError, MigrationInvalidNameError } = require('../errors/index.js');
|
|
2
|
+
const { actorIssue } = require('../utils/actor.js');
|
|
3
|
+
const { isCollectionName } = require('../utils/collection-name.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validation of the options the kit's run methods take. Pure — no config, no
|
|
7
|
+
* database, no file system — so every one runs before a run method resolves
|
|
8
|
+
* its config or connects: a caller mistake costs neither a round trip nor the
|
|
9
|
+
* lock, and is reported as itself rather than as whatever it would have
|
|
10
|
+
* broken later.
|
|
11
|
+
*
|
|
12
|
+
* Each `assert*Options` is the whole preamble of one method, its checks in
|
|
13
|
+
* the order that decides which error a caller sees when several apply.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reject non-string filenames before they reach a changelog query or a path
|
|
18
|
+
* join. A programmatic caller passing e.g. `{ $ne: null }` would otherwise
|
|
19
|
+
* become a query-operator injection in `findOne({ name })`.
|
|
20
|
+
*/
|
|
21
|
+
function assertFilename(filename) {
|
|
22
|
+
if (filename !== undefined && typeof filename !== 'string') {
|
|
23
|
+
throw new MigrationInvalidNameError('Migration name must be a string', {
|
|
24
|
+
name: filename,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate the `--steps` option for `down`/`dry-run down`: a positive integer,
|
|
31
|
+
* mutually exclusive with a filename and `--batch`. No-op when steps is unset.
|
|
32
|
+
*/
|
|
33
|
+
function assertStepsValid(steps, filename, batch) {
|
|
34
|
+
if (steps === undefined) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (filename) {
|
|
38
|
+
throw new ConfigInvalidError('Cannot combine a filename with --steps', { filename });
|
|
39
|
+
}
|
|
40
|
+
if (batch !== undefined) {
|
|
41
|
+
throw new ConfigInvalidError('Cannot combine --batch with --steps', { batch, steps });
|
|
42
|
+
}
|
|
43
|
+
if (!Number.isInteger(steps) || steps < 1) {
|
|
44
|
+
throw new ConfigInvalidError('--steps must be a positive integer', { steps });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `--to` names a point in the sequence, so it cannot be combined with the
|
|
50
|
+
* other ways of choosing targets.
|
|
51
|
+
*/
|
|
52
|
+
function assertToValid(to, filename, options = {}) {
|
|
53
|
+
if (to === undefined) return;
|
|
54
|
+
assertFilename(to);
|
|
55
|
+
if (filename) {
|
|
56
|
+
throw new ConfigInvalidError('Cannot combine a filename with --to', { filename, to });
|
|
57
|
+
}
|
|
58
|
+
if (options.steps !== undefined) {
|
|
59
|
+
throw new ConfigInvalidError('Cannot combine --steps with --to', {
|
|
60
|
+
steps: options.steps,
|
|
61
|
+
to,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (options.batch !== undefined) {
|
|
65
|
+
throw new ConfigInvalidError('Cannot combine --batch with --to', {
|
|
66
|
+
batch: options.batch,
|
|
67
|
+
to,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate `--batch`. Without this a typo (`--batch abc` → NaN) matches no
|
|
74
|
+
* records, so the run prints "Nothing to rollback" and exits 0 — the worst
|
|
75
|
+
* possible answer to a mistyped rollback.
|
|
76
|
+
*/
|
|
77
|
+
function assertBatchValid(batch) {
|
|
78
|
+
if (batch === undefined) return;
|
|
79
|
+
if (!Number.isInteger(batch) || batch < 1) {
|
|
80
|
+
throw new ConfigInvalidError('--batch must be a positive integer', { batch });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate `requestedBy` / `reason`: who asked for a run, and why — stamped
|
|
86
|
+
* on what it writes to the changelog (and on a converge's history entry).
|
|
87
|
+
* The OS user that ran it is `executedBy` already; on a queue worker that is
|
|
88
|
+
* the container's, which is why the requester has a field of its own.
|
|
89
|
+
*/
|
|
90
|
+
function assertActorValid(options) {
|
|
91
|
+
for (const key of ['requestedBy', 'reason']) {
|
|
92
|
+
const issue = actorIssue(key, options?.[key]);
|
|
93
|
+
if (issue) {
|
|
94
|
+
throw new ConfigInvalidError(issue, {
|
|
95
|
+
[key]:
|
|
96
|
+
typeof options[key] === 'string'
|
|
97
|
+
? `${options[key].length} characters`
|
|
98
|
+
: typeof options[key],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate `checksum`: the SHA-256 the caller expects the named file to have
|
|
106
|
+
* — how a queue job says which version of the file it was planned with.
|
|
107
|
+
*/
|
|
108
|
+
function assertChecksumValid(checksum, filename) {
|
|
109
|
+
if (checksum === undefined) return;
|
|
110
|
+
if (typeof checksum !== 'string' || !/^[0-9a-f]{64}$/.test(checksum)) {
|
|
111
|
+
throw new ConfigInvalidError('checksum must be a SHA-256 hex digest', {
|
|
112
|
+
checksum: typeof checksum,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (!filename) {
|
|
116
|
+
throw new ConfigInvalidError('checksum requires a filename', {});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate `ordered`: a boolean, and only meaningful for a named file — a
|
|
122
|
+
* bulk run is in order by construction, so asking for it there is a caller
|
|
123
|
+
* mistake worth naming rather than silently ignoring.
|
|
124
|
+
*/
|
|
125
|
+
function assertOrderedValid(ordered, filename) {
|
|
126
|
+
if (ordered === undefined) return;
|
|
127
|
+
if (typeof ordered !== 'boolean') {
|
|
128
|
+
throw new ConfigInvalidError('ordered must be a boolean', { ordered });
|
|
129
|
+
}
|
|
130
|
+
if (ordered && !filename) {
|
|
131
|
+
throw new ConfigInvalidError('ordered requires a filename', { ordered });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `up`'s `converge` option: a boolean, and only for a bulk run that reaches the head */
|
|
136
|
+
function assertConvergeAfterUpValid(converge, filename, to) {
|
|
137
|
+
if (converge === undefined) return;
|
|
138
|
+
if (typeof converge !== 'boolean') {
|
|
139
|
+
throw new ConfigInvalidError('converge must be a boolean', { converge });
|
|
140
|
+
}
|
|
141
|
+
if (converge && filename !== undefined) {
|
|
142
|
+
throw new ConfigInvalidError('converge cannot follow a single-file up', {
|
|
143
|
+
converge,
|
|
144
|
+
filename,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (converge && to !== undefined) {
|
|
148
|
+
throw new ConfigInvalidError(
|
|
149
|
+
'converge cannot follow up --to: the declared state describes the newest migration',
|
|
150
|
+
{ converge, to },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `up(filename, options)` */
|
|
156
|
+
function assertUpOptions(filename, options) {
|
|
157
|
+
assertFilename(filename);
|
|
158
|
+
// `to` is checked against a filename only: unlike `down`, an explicit
|
|
159
|
+
// `batch` here is a label for whatever gets applied, not a selector.
|
|
160
|
+
assertToValid(options.to, filename);
|
|
161
|
+
assertBatchValid(options.batch);
|
|
162
|
+
if (options.batch !== undefined && options.step) {
|
|
163
|
+
throw new ConfigInvalidError('Cannot combine --batch with --step', {
|
|
164
|
+
batch: options.batch,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
assertOrderedValid(options.ordered, filename);
|
|
168
|
+
assertConvergeAfterUpValid(options.converge, filename, options.to);
|
|
169
|
+
assertChecksumValid(options.checksum, filename);
|
|
170
|
+
assertActorValid(options);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `down(filename, options)` */
|
|
174
|
+
function assertDownOptions(filename, options) {
|
|
175
|
+
assertFilename(filename);
|
|
176
|
+
assertStepsValid(options.steps, filename, options.batch);
|
|
177
|
+
assertBatchValid(options.batch);
|
|
178
|
+
assertToValid(options.to, filename, options);
|
|
179
|
+
assertOrderedValid(options.ordered, filename);
|
|
180
|
+
assertActorValid(options);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `redo(filename, options)` */
|
|
184
|
+
function assertRedoOptions(filename, options) {
|
|
185
|
+
assertFilename(filename);
|
|
186
|
+
assertActorValid(options);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `dryRun(direction, filename, options)` */
|
|
190
|
+
function assertDryRunOptions(filename, options) {
|
|
191
|
+
assertFilename(filename);
|
|
192
|
+
// `batch`/`to` must be passed too, or a conflict that `down` rejects would
|
|
193
|
+
// be silently allowed in its own preview.
|
|
194
|
+
assertStepsValid(options.steps, filename, options.batch);
|
|
195
|
+
assertBatchValid(options.batch);
|
|
196
|
+
assertToValid(options.to, filename, options);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `converge(options)` */
|
|
200
|
+
function assertConvergeOptions(options) {
|
|
201
|
+
for (const key of ['dryRun', 'prune', 'noLock', 'ordered', 'rebuildUnique']) {
|
|
202
|
+
if (options[key] !== undefined && typeof options[key] !== 'boolean') {
|
|
203
|
+
throw new ConfigInvalidError(`${key} must be a boolean`, { [key]: options[key] });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
assertActorValid(options);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `convergeHistory({ limit })` */
|
|
210
|
+
function assertHistoryLimit(limit) {
|
|
211
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
212
|
+
throw new ConfigInvalidError('limit must be an integer from 1 to 1000', { limit });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** `list(filter, options)` */
|
|
217
|
+
function assertListOptions(filter, options) {
|
|
218
|
+
if (options.checksums !== undefined && typeof options.checksums !== 'boolean') {
|
|
219
|
+
throw new ConfigInvalidError('checksums must be a boolean', { checksums: options.checksums });
|
|
220
|
+
}
|
|
221
|
+
// An unknown filter silently returning [] reads as "nothing to report" —
|
|
222
|
+
// the worst possible answer to a typo.
|
|
223
|
+
if (filter !== 'all' && filter !== 'pending' && filter !== 'applied') {
|
|
224
|
+
throw new ConfigInvalidError("list filter must be 'all', 'pending' or 'applied'", { filter });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `import(options)`: a bad --from/--to must not cost a round trip or take the
|
|
230
|
+
* lock. Defaults come from the already-validated config.
|
|
231
|
+
*/
|
|
232
|
+
function assertImportOptions(options) {
|
|
233
|
+
if (options.from !== undefined && !isCollectionName(options.from)) {
|
|
234
|
+
throw new ConfigInvalidError('Invalid source collection name', { from: options.from });
|
|
235
|
+
}
|
|
236
|
+
if (options.to !== undefined && !isCollectionName(options.to)) {
|
|
237
|
+
throw new ConfigInvalidError('Invalid target collection name', { to: options.to });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
assertConvergeOptions,
|
|
243
|
+
assertDownOptions,
|
|
244
|
+
assertDryRunOptions,
|
|
245
|
+
assertFilename,
|
|
246
|
+
assertHistoryLimit,
|
|
247
|
+
assertImportOptions,
|
|
248
|
+
assertListOptions,
|
|
249
|
+
assertRedoOptions,
|
|
250
|
+
assertUpOptions,
|
|
251
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const { LockAlreadyHeldError, MigronautError } = require('../errors/index.js');
|
|
2
|
+
const { errorText } = require('../utils/error.js');
|
|
3
|
+
const { ATTRIBUTES } = require('../utils/telemetry.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The record one run under the lock leaves behind: its `run:start` /
|
|
7
|
+
* `run:end` and lock events, its run metrics, the end of its span, and the
|
|
8
|
+
* closing "Done" line. Kept apart from MigratorKit#withLock, which is left
|
|
9
|
+
* with what is its own — reentrancy, the abort wiring, and the lock.
|
|
10
|
+
*
|
|
11
|
+
* Built from the kit's own capabilities: `emit` (runId stamped, listeners
|
|
12
|
+
* contained), `telemetry` (a no-op without one), the resolved `logger` and
|
|
13
|
+
* `fields`. All of them are guarded, so nothing recorded here can fail a run.
|
|
14
|
+
* The span itself is opened by the kit — the run span is one of its two wrap
|
|
15
|
+
* sites — and handed over with {@link RunRecorder#spanOpened}.
|
|
16
|
+
*/
|
|
17
|
+
class RunRecorder {
|
|
18
|
+
#info;
|
|
19
|
+
#runId;
|
|
20
|
+
#telemetry;
|
|
21
|
+
#emit;
|
|
22
|
+
#logger;
|
|
23
|
+
#fields;
|
|
24
|
+
#startedAt;
|
|
25
|
+
/** What the lock reported when it was acquired — the run span's lock attributes */
|
|
26
|
+
#acquired;
|
|
27
|
+
/** The first reason the lock was lost */
|
|
28
|
+
#lostReason;
|
|
29
|
+
/** Set once the lock is held and the run span is open */
|
|
30
|
+
#span;
|
|
31
|
+
|
|
32
|
+
constructor({ info, runId, telemetry, emit, logger, fields }) {
|
|
33
|
+
this.#info = info;
|
|
34
|
+
this.#runId = runId;
|
|
35
|
+
this.#telemetry = telemetry;
|
|
36
|
+
this.#emit = emit;
|
|
37
|
+
this.#logger = logger;
|
|
38
|
+
this.#fields = fields;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
start() {
|
|
42
|
+
this.#startedAt = Date.now();
|
|
43
|
+
this.#emit('run:start', { ...this.#info });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
lockAcquired(extra) {
|
|
47
|
+
this.#acquired = extra;
|
|
48
|
+
if (typeof extra?.acquireMs === 'number') this.#telemetry.lockAcquired(extra.acquireMs);
|
|
49
|
+
this.#emit('lock:acquired', { owner: this.#runId, ...extra });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
lockReleased(extra) {
|
|
53
|
+
this.#emit('lock:released', { owner: this.#runId, ...extra });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lockLost(reason) {
|
|
57
|
+
// The heartbeat and the TTL deadline can each report the same loss: the
|
|
58
|
+
// first reason is the cause, and it is one lost lock.
|
|
59
|
+
if (this.#lostReason === undefined) {
|
|
60
|
+
this.#lostReason = reason;
|
|
61
|
+
this.#telemetry.lockLost();
|
|
62
|
+
}
|
|
63
|
+
this.#emit('lock:lost', { owner: this.#runId, reason });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The run span's attributes — complete once the lock is held */
|
|
67
|
+
spanAttributes() {
|
|
68
|
+
return {
|
|
69
|
+
[ATTRIBUTES.RUN_ID]: this.#runId,
|
|
70
|
+
[ATTRIBUTES.RUN_COMMAND]: this.#info.command,
|
|
71
|
+
[ATTRIBUTES.RUN_DIRECTION]: this.#info.direction,
|
|
72
|
+
[ATTRIBUTES.LOCK_ACQUIRE_MS]: this.#acquired?.acquireMs,
|
|
73
|
+
[ATTRIBUTES.LOCK_SKIPPED]: this.#acquired?.skipped,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
spanOpened(span) {
|
|
78
|
+
this.#span = span;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Close the record: `result` on success, `failure` (what the run threw) otherwise */
|
|
82
|
+
finish(result, failure) {
|
|
83
|
+
// Result counts, so a metrics subscriber gets "3 applied in 812ms"
|
|
84
|
+
// without reconstructing it from per-migration events. On the failure
|
|
85
|
+
// path the partial rows live on the error's context — exactly the case
|
|
86
|
+
// where "how far did it get?" is the question, so they count too.
|
|
87
|
+
const rows = Array.isArray(result)
|
|
88
|
+
? result
|
|
89
|
+
: failure instanceof MigronautError && Array.isArray(failure.context?.results)
|
|
90
|
+
? failure.context.results
|
|
91
|
+
: null;
|
|
92
|
+
const { summary, skipped } = countRows(rows);
|
|
93
|
+
const durationMs = Date.now() - this.#startedAt;
|
|
94
|
+
const telemetry = this.#telemetry;
|
|
95
|
+
if (this.#span) {
|
|
96
|
+
this.#span.finish(
|
|
97
|
+
{
|
|
98
|
+
[ATTRIBUTES.RUN_APPLIED]: summary.applied,
|
|
99
|
+
[ATTRIBUTES.RUN_REVERTED]: summary.reverted,
|
|
100
|
+
[ATTRIBUTES.RUN_SKIPPED]: skipped,
|
|
101
|
+
[ATTRIBUTES.RUN_TOTAL]: summary.total,
|
|
102
|
+
[ATTRIBUTES.LOCK_LOST_REASON]: this.#lostReason,
|
|
103
|
+
},
|
|
104
|
+
failure,
|
|
105
|
+
);
|
|
106
|
+
telemetry.runEnded({ ...this.#info, durationMs, error: failure });
|
|
107
|
+
} else if (failure instanceof LockAlreadyHeldError) {
|
|
108
|
+
// Never held the lock, so there is no run to time — only a refusal to
|
|
109
|
+
// count. Any other failure this early (an unreachable database) is the
|
|
110
|
+
// caller's to report; it is not contention.
|
|
111
|
+
telemetry.lockRefused();
|
|
112
|
+
}
|
|
113
|
+
this.#emit('run:end', {
|
|
114
|
+
...this.#info,
|
|
115
|
+
success: failure === undefined,
|
|
116
|
+
durationMs,
|
|
117
|
+
...summary,
|
|
118
|
+
// A raw Error here would hand subscribers an unredacted driver message
|
|
119
|
+
// (which can echo the credentialed URI) — errorText is the same
|
|
120
|
+
// chokepoint every log line and result row already goes through.
|
|
121
|
+
...(failure ? { error: errorText(failure) } : {}),
|
|
122
|
+
});
|
|
123
|
+
// One human rollup after the per-migration lines: total wall-clock time
|
|
124
|
+
// (lock wait and hooks included) is otherwise unobtainable from the
|
|
125
|
+
// output — per-file durations exclude all overhead. Success path only;
|
|
126
|
+
// a failure already ends with its own error line.
|
|
127
|
+
if (failure === undefined && (summary.applied || summary.reverted)) {
|
|
128
|
+
const parts = [];
|
|
129
|
+
if (summary.applied) parts.push(`${summary.applied} applied`);
|
|
130
|
+
if (summary.reverted) parts.push(`${summary.reverted} reverted`);
|
|
131
|
+
this.#logger.info(
|
|
132
|
+
`✔ Done ${parts.join(', ')} in ${durationMs}ms`,
|
|
133
|
+
this.#fields({ ...this.#info, ...summary, durationMs }),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `{ summary: { applied, reverted, total }, skipped }` of a run's rows, in one
|
|
141
|
+
* pass — `summary` is empty when the run produced no rows (a converge, a
|
|
142
|
+
* failure before the first migration).
|
|
143
|
+
*/
|
|
144
|
+
function countRows(rows) {
|
|
145
|
+
if (!rows) return { summary: {}, skipped: undefined };
|
|
146
|
+
let applied = 0;
|
|
147
|
+
let reverted = 0;
|
|
148
|
+
let skipped = 0;
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
if (row.status === 'applied') applied += 1;
|
|
151
|
+
else if (row.status === 'reverted') reverted += 1;
|
|
152
|
+
else if (row.status === 'skipped') skipped += 1;
|
|
153
|
+
}
|
|
154
|
+
return { summary: { applied, reverted, total: rows.length }, skipped };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { RunRecorder };
|
package/src/core/run.js
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
const {
|
|
2
|
-
const {
|
|
3
|
-
const {
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Longer than the default 60s lock TTL on purpose: with a shorter budget, a peer
|
|
7
|
-
* migration that outlives it makes every waiting instance fail to boot, even
|
|
8
|
-
* though the peer is healthy and still holding a valid lock.
|
|
9
|
-
*/
|
|
10
|
-
const DEFAULT_LOCK_WAIT_TIMEOUT_MS = 90_000;
|
|
11
|
-
const DEFAULT_LOCK_POLL_INTERVAL_MS = 500;
|
|
12
|
-
/** ±25% jitter so N instances booting together stop polling in lockstep */
|
|
13
|
-
const POLL_JITTER_RATIO = 0.25;
|
|
14
|
-
|
|
15
|
-
function jitteredDelay(baseMs) {
|
|
16
|
-
const spread = baseMs * POLL_JITTER_RATIO;
|
|
17
|
-
return Math.max(1, Math.round(baseMs - spread + Math.random() * spread * 2));
|
|
18
|
-
}
|
|
1
|
+
const { ConfigInvalidError, RunAbortedError } = require('../errors/index.js');
|
|
2
|
+
const { errorText } = require('../utils/error.js');
|
|
3
|
+
const { assertLockWaitOptions, withLockWait } = require('./lock-wait.js');
|
|
4
|
+
const { MigratorKit, RECORD_LOCK_WAIT } = require('./migrator.js');
|
|
19
5
|
|
|
20
6
|
/**
|
|
21
7
|
* Run all pending migrations and return a summary — the blessed one-call entry
|
|
@@ -31,7 +17,9 @@ function jitteredDelay(baseMs) {
|
|
|
31
17
|
*
|
|
32
18
|
* For multi-instance deploys, set `onLockHeld: 'wait'` so instances that lose
|
|
33
19
|
* the race to acquire the lock block until the migrating peer finishes, then
|
|
34
|
-
* confirm there is nothing left to apply.
|
|
20
|
+
* confirm there is nothing left to apply. Pass a `signal` (wired to SIGTERM)
|
|
21
|
+
* so a pod being shut down stops waiting — and stops between migrations if it
|
|
22
|
+
* already holds the lock — instead of taking the lock just before SIGKILL.
|
|
35
23
|
*
|
|
36
24
|
* Running several kits against several databases in ONE process: pass
|
|
37
25
|
* `envFile: false` and supply `uri`/`dbName` directly. `.env` loading mutates
|
|
@@ -54,95 +42,75 @@ async function runMigrations(config = {}, options = {}) {
|
|
|
54
42
|
const {
|
|
55
43
|
noLock,
|
|
56
44
|
onLockHeld = 'throw',
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
// Left undefined unless given: the default then follows the holder's TTL.
|
|
46
|
+
lockWaitTimeoutMs,
|
|
47
|
+
lockPollIntervalMs,
|
|
59
48
|
onKit,
|
|
49
|
+
signal,
|
|
60
50
|
...kitOptions
|
|
61
51
|
} = options;
|
|
62
52
|
|
|
63
53
|
if (onKit !== undefined && typeof onKit !== 'function') {
|
|
64
54
|
throw new ConfigInvalidError('onKit must be a function', { onKit: typeof onKit });
|
|
65
55
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// disables every deadline comparison below — `NaN > deadline` is always
|
|
69
|
-
// false — turning the wait loop into an unbounded retry storm against the
|
|
70
|
-
// lock collection that never returns and never surfaces the real error.
|
|
71
|
-
if (!Number.isFinite(lockWaitTimeoutMs) || lockWaitTimeoutMs <= 0) {
|
|
72
|
-
throw new ConfigInvalidError('lockWaitTimeoutMs must be a positive finite number', {
|
|
73
|
-
lockWaitTimeoutMs,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
if (!Number.isFinite(lockPollIntervalMs) || lockPollIntervalMs <= 0) {
|
|
77
|
-
throw new ConfigInvalidError('lockPollIntervalMs must be a positive finite number', {
|
|
78
|
-
lockPollIntervalMs,
|
|
79
|
-
});
|
|
56
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) {
|
|
57
|
+
throw new ConfigInvalidError('signal must be an AbortSignal', { signal: typeof signal });
|
|
80
58
|
}
|
|
81
59
|
|
|
60
|
+
// Validated before anything connects — see assertLockWaitOptions.
|
|
61
|
+
assertLockWaitOptions({ onLockHeld, lockWaitTimeoutMs, lockPollIntervalMs });
|
|
62
|
+
|
|
82
63
|
const kit = new MigratorKit(config, kitOptions);
|
|
83
64
|
// Handed out before connect so listeners catch every lifecycle event —
|
|
84
65
|
// this is the metrics/alerting injection point for apps that embed
|
|
85
66
|
// runMigrations and cannot reach the internally-constructed kit otherwise.
|
|
86
67
|
onKit?.(kit);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let
|
|
68
|
+
// `up` keeps returning the migration rows; with `convergeAfterUp` the
|
|
69
|
+
// converge outcome rides along in the summary, from the kit's own event.
|
|
70
|
+
let converge;
|
|
71
|
+
kit.on('converge:end', (event) => {
|
|
72
|
+
if (event.trigger === 'up' && event.success) converge = event.result;
|
|
73
|
+
});
|
|
90
74
|
|
|
75
|
+
// An abort reaches the run wherever it is: the wait loop sees the signal
|
|
76
|
+
// between polls, and kit.stop() stops a run that is setting up or between
|
|
77
|
+
// migrations (one already executing finishes — as stop() always has).
|
|
78
|
+
const onAbort = () => kit.stop(errorText(signal.reason ?? 'Aborted'));
|
|
79
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
91
80
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// The clock starts at the first contention, not before the first attempt —
|
|
98
|
-
// otherwise a slow initial attempt eats the whole waiting budget.
|
|
99
|
-
let deadline;
|
|
100
|
-
// The holder's lockedAt from the last refusal: its heartbeat advances it
|
|
101
|
-
// every TTL/2, so a change between polls is proof of a live, progressing
|
|
102
|
-
// peer.
|
|
103
|
-
let lastHolderLockedAt;
|
|
104
|
-
|
|
105
|
-
for (;;) {
|
|
106
|
-
try {
|
|
107
|
-
attempts += 1;
|
|
108
|
-
const applied = await kit.up(undefined, noLock ? { noLock: true } : {});
|
|
109
|
-
return { applied, upToDate: applied.length === 0, waited, waitedMs, attempts };
|
|
110
|
-
} catch (error) {
|
|
111
|
-
if (onLockHeld !== 'wait' || !(error instanceof LockAlreadyHeldError)) {
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
// The timeout bounds *stall* time, not total wait: while the holder's
|
|
115
|
-
// heartbeat visibly advances, it is healthy and working through its
|
|
116
|
-
// backlog — timing out then would crash-loop every waiting instance
|
|
117
|
-
// on exactly the deploys (a large first backlog) that take longest.
|
|
118
|
-
// Only a holder that stops renewing runs the deadline down.
|
|
119
|
-
const holderLockedAt = error.context?.holder?.lockedAt?.getTime?.();
|
|
120
|
-
const holderAdvanced =
|
|
121
|
-
holderLockedAt !== undefined &&
|
|
122
|
-
lastHolderLockedAt !== undefined &&
|
|
123
|
-
holderLockedAt > lastHolderLockedAt;
|
|
124
|
-
if (deadline === undefined || holderAdvanced) {
|
|
125
|
-
deadline = Date.now() + lockWaitTimeoutMs;
|
|
126
|
-
}
|
|
127
|
-
if (holderLockedAt !== undefined) lastHolderLockedAt = holderLockedAt;
|
|
128
|
-
const nextDelay = jitteredDelay(lockPollIntervalMs);
|
|
129
|
-
if (Date.now() + nextDelay > deadline) {
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
132
|
-
if (!waited) {
|
|
133
|
-
logger.info('Migration lock held by another process — waiting for it to release…');
|
|
134
|
-
}
|
|
135
|
-
waited = true;
|
|
136
|
-
logger.debug('Migration lock still held — retrying', {
|
|
137
|
-
attempts,
|
|
138
|
-
waitedMs,
|
|
139
|
-
nextDelayMs: nextDelay,
|
|
140
|
-
});
|
|
141
|
-
waitedMs += nextDelay;
|
|
142
|
-
await delay(nextDelay);
|
|
143
|
-
}
|
|
81
|
+
if (signal?.aborted) {
|
|
82
|
+
throw new RunAbortedError('Aborted before the run started', {
|
|
83
|
+
reason: errorText(signal.reason ?? 'aborted'),
|
|
84
|
+
results: [],
|
|
85
|
+
});
|
|
144
86
|
}
|
|
87
|
+
await kit.connect();
|
|
88
|
+
const {
|
|
89
|
+
result: applied,
|
|
90
|
+
waited,
|
|
91
|
+
waitedMs,
|
|
92
|
+
attempts,
|
|
93
|
+
} = await withLockWait(() => kit.up(undefined, noLock ? { noLock: true } : {}), {
|
|
94
|
+
onLockHeld,
|
|
95
|
+
...(lockWaitTimeoutMs !== undefined ? { lockWaitTimeoutMs } : {}),
|
|
96
|
+
...(lockPollIntervalMs !== undefined ? { lockPollIntervalMs } : {}),
|
|
97
|
+
// Resolved AFTER connect, from the kit's own merged config: a `logger:
|
|
98
|
+
// null` in the config file must silence the wait lines too, not only
|
|
99
|
+
// the kit's own.
|
|
100
|
+
logger: kit.logger,
|
|
101
|
+
...(signal ? { signal } : {}),
|
|
102
|
+
onSettle: (wait) => kit[RECORD_LOCK_WAIT](wait),
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
applied,
|
|
106
|
+
upToDate: applied.length === 0,
|
|
107
|
+
waited,
|
|
108
|
+
waitedMs,
|
|
109
|
+
attempts,
|
|
110
|
+
...(converge ? { converge } : {}),
|
|
111
|
+
};
|
|
145
112
|
} finally {
|
|
113
|
+
signal?.removeEventListener('abort', onAbort);
|
|
146
114
|
await kit.disconnect().catch(() => undefined);
|
|
147
115
|
}
|
|
148
116
|
}
|