@molecule/api-deploy-rollout-default 0.0.0-stage → 1.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/LICENSE +202 -0
- package/README.md +384 -2
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +18 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/provider.d.ts +151 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +559 -0
- package/package.json +54 -4
package/dist/provider.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default deploy-rollout provider: the state machine itself.
|
|
3
|
+
*
|
|
4
|
+
* Provider-agnostic on purpose. The deploy path wires it to Fly Machines or to a
|
|
5
|
+
* sandbox provider; the tests wire it to a fake that records every call. What it
|
|
6
|
+
* guarantees is the part that is hard to get right:
|
|
7
|
+
*
|
|
8
|
+
* 1. **A new instance joins only after it is READY.** `waitReady` is the provider's
|
|
9
|
+
* readiness gate (`/health/ready` when the app has the lifecycle bond,
|
|
10
|
+
* `/health` otherwise — the caller decides).
|
|
11
|
+
* 2. **Healthy capacity never drops below the policy floor.** Before any instance
|
|
12
|
+
* leaves the routable set, the run counts what is still serving and refuses a
|
|
13
|
+
* step that would go below `minHealthy`.
|
|
14
|
+
* 3. **One at a time, with a soak.** The next step starts only after the previous
|
|
15
|
+
* instance passed its gate and kept answering for the soak window.
|
|
16
|
+
* 4. **A failure aborts and rolls back in reverse.** Grow-then-shrink destroys every
|
|
17
|
+
* instance it created and leaves the old set untouched (nothing was cut over);
|
|
18
|
+
* in-place restores every instance it changed, newest first.
|
|
19
|
+
* 5. **A retirement failure never strands the set.** The step is reported and the
|
|
20
|
+
* rollout continues; an unreachable instance is the orphan sweep's to reclaim.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
/** The default bounded drain wait — matches the lifecycle bond's own budget. */
|
|
25
|
+
export const DEFAULT_ROLLOUT_DRAIN_MS = 25_000;
|
|
26
|
+
/** The default readiness budget handed to a provider. */
|
|
27
|
+
export const DEFAULT_ROLLOUT_READINESS_TIMEOUT_MS = 120_000;
|
|
28
|
+
/** The default cadence of the soak probe. */
|
|
29
|
+
export const DEFAULT_SOAK_PROBE_MS = 2_000;
|
|
30
|
+
/** The default floor of healthy instances: never blank the deployment. */
|
|
31
|
+
export const DEFAULT_MIN_HEALTHY = 1;
|
|
32
|
+
/**
|
|
33
|
+
* A rollout that could not finish, carrying what it did so the caller can report
|
|
34
|
+
* it (and, where the topology allows, undo the rest itself).
|
|
35
|
+
*
|
|
36
|
+
* The same class serves both topologies: grow-then-shrink fills `role`, `created`
|
|
37
|
+
* and `outcome`; in-place fills `failed`, `rolledBack` and `rollbackFailures`.
|
|
38
|
+
*/
|
|
39
|
+
export class RolloutAbortedError extends Error {
|
|
40
|
+
/** The API layer's key for this abort. */
|
|
41
|
+
errorKey = 'deploy-rollout.error.aborted';
|
|
42
|
+
/** The step that failed. */
|
|
43
|
+
role;
|
|
44
|
+
/** The instance whose step failed, when one did. */
|
|
45
|
+
failed;
|
|
46
|
+
/** Instances restored to the old build, newest first. */
|
|
47
|
+
rolledBack;
|
|
48
|
+
/** Restores that themselves failed. */
|
|
49
|
+
rollbackFailures;
|
|
50
|
+
/** The grow-then-shrink outcome, when the abort happened there. */
|
|
51
|
+
outcome;
|
|
52
|
+
/**
|
|
53
|
+
* Build the abort with what the caller must report.
|
|
54
|
+
*
|
|
55
|
+
* @param message - What happened, in plain words.
|
|
56
|
+
* @param details - The role, the failed instance, what was undone and the outcome.
|
|
57
|
+
* @param cause - The underlying error, when there was one.
|
|
58
|
+
*/
|
|
59
|
+
constructor(message, details = {}, cause) {
|
|
60
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
61
|
+
this.name = 'RolloutAbortedError';
|
|
62
|
+
this.role = details.role ?? null;
|
|
63
|
+
this.failed = details.failed ?? null;
|
|
64
|
+
this.rolledBack = details.rolledBack ?? [];
|
|
65
|
+
this.rollbackFailures = details.rollbackFailures ?? [];
|
|
66
|
+
this.outcome = details.outcome ?? null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Fill the policy defaults.
|
|
71
|
+
*
|
|
72
|
+
* `maxUnavailable` derives `minHealthy` only when it is set; without it the floor
|
|
73
|
+
* is one healthy instance, which is exactly the rule every existing rolling path
|
|
74
|
+
* has today.
|
|
75
|
+
*
|
|
76
|
+
* @param policy - The caller's policy, if any.
|
|
77
|
+
* @param instanceCount - How many instances the topology has, for `maxUnavailable`.
|
|
78
|
+
* @returns The resolved policy.
|
|
79
|
+
*/
|
|
80
|
+
export const resolveRolloutPolicy = (policy, instanceCount) => {
|
|
81
|
+
const soakMs = Math.max(0, policy?.soakMs ?? 0);
|
|
82
|
+
const derivedFloor = policy?.maxUnavailable === undefined
|
|
83
|
+
? DEFAULT_MIN_HEALTHY
|
|
84
|
+
: Math.max(1, instanceCount - Math.max(0, Math.floor(policy.maxUnavailable)));
|
|
85
|
+
return {
|
|
86
|
+
replicas: Math.max(1, Math.floor(policy?.replicas ?? instanceCount)),
|
|
87
|
+
minHealthy: Math.max(1, Math.floor(policy?.minHealthy ?? derivedFloor)),
|
|
88
|
+
soakMs,
|
|
89
|
+
soakProbeMs: Math.max(1, Math.floor(policy?.soakProbeMs ?? DEFAULT_SOAK_PROBE_MS)),
|
|
90
|
+
soakGraceMs: Math.max(1, Math.floor(policy?.soakGraceMs ?? soakMs + 60_000)),
|
|
91
|
+
drainMs: Math.max(0, Math.floor(policy?.drainMs ?? DEFAULT_ROLLOUT_DRAIN_MS)),
|
|
92
|
+
readinessTimeoutMs: Math.max(1, Math.floor(policy?.readinessTimeoutMs ?? DEFAULT_ROLLOUT_READINESS_TIMEOUT_MS)),
|
|
93
|
+
rollback: policy?.rollback ?? 'reverse',
|
|
94
|
+
resume: policy?.resume ?? true,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/** A cancellation probe that treats its own failure as "do not stop". */
|
|
98
|
+
const cancelled = async (isCancelled) => {
|
|
99
|
+
if (!isCancelled)
|
|
100
|
+
return false;
|
|
101
|
+
try {
|
|
102
|
+
return Boolean(await isCancelled());
|
|
103
|
+
}
|
|
104
|
+
catch (_error) {
|
|
105
|
+
// A transient error asking "is this deploy cancelled?" must not abort a roll
|
|
106
|
+
// that is otherwise fine — the same direction the deploy's own probes take.
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
/** The message of an unknown thrown value. */
|
|
111
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
112
|
+
/** A progress sink that never lets a broken logger break the roll. */
|
|
113
|
+
const makeLog = (sink) => {
|
|
114
|
+
return async (line) => {
|
|
115
|
+
try {
|
|
116
|
+
await sink?.(line);
|
|
117
|
+
}
|
|
118
|
+
catch (_error) {
|
|
119
|
+
// A log sink is reporting, never work the rollout depends on.
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Wait until an instance has answered `isServing` continuously for `soakMs`.
|
|
125
|
+
*
|
|
126
|
+
* A gap — a failed probe, or a probe that throws — restarts the window: the
|
|
127
|
+
* question is "has it carried traffic without a gap", not "did it answer once".
|
|
128
|
+
* Never probes faster than `soakProbeMs`, and gives up after `soakGraceMs` of
|
|
129
|
+
* wall-clock so a wedged instance aborts the roll instead of hanging it.
|
|
130
|
+
*
|
|
131
|
+
* @param instance - The instance to watch.
|
|
132
|
+
* @param options - The probe, the window and the injected clock.
|
|
133
|
+
* @returns Whether the soak completed.
|
|
134
|
+
*/
|
|
135
|
+
const soakServing = async (instance, options) => {
|
|
136
|
+
const { isServing, policy, now, sleep, log } = options;
|
|
137
|
+
const started = now();
|
|
138
|
+
const deadline = started + policy.soakGraceMs;
|
|
139
|
+
let healthySince = null;
|
|
140
|
+
let lastHealthy = false;
|
|
141
|
+
while (true) {
|
|
142
|
+
let healthy;
|
|
143
|
+
try {
|
|
144
|
+
healthy = (await isServing(instance)) === true;
|
|
145
|
+
}
|
|
146
|
+
catch (_error) {
|
|
147
|
+
// A probe that throws counts as NOT serving: the safe direction is to keep
|
|
148
|
+
// the roll here, never to assume an instance we cannot reach is fine.
|
|
149
|
+
healthy = false;
|
|
150
|
+
}
|
|
151
|
+
const at = now();
|
|
152
|
+
if (healthy) {
|
|
153
|
+
if (healthySince === null)
|
|
154
|
+
healthySince = at;
|
|
155
|
+
if (at - healthySince >= policy.soakMs) {
|
|
156
|
+
await log(`Instance ${instance.id}: healthy for ${Math.round(policy.soakMs / 1000)} s.`);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
if (lastHealthy) {
|
|
162
|
+
await log(`Instance ${instance.id}: stopped answering — restarting its soak window.`);
|
|
163
|
+
}
|
|
164
|
+
healthySince = null;
|
|
165
|
+
}
|
|
166
|
+
lastHealthy = healthy;
|
|
167
|
+
if (at >= deadline) {
|
|
168
|
+
await log(`Instance ${instance.id}: never stayed healthy for the soak window — giving up.`);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
await sleep(Math.max(1, Math.min(policy.soakProbeMs, deadline - at)));
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Run a grow-then-shrink replacement.
|
|
176
|
+
*
|
|
177
|
+
* The caller has already created, gated and joined the PRIMARY instance of the new
|
|
178
|
+
* version when `primaryServing` is set; `replicas` is the size of the new set
|
|
179
|
+
* including that one, and this function creates the remaining `replicas - 1`.
|
|
180
|
+
*
|
|
181
|
+
* @param options - The set to build, the old set, and the provider.
|
|
182
|
+
* @returns The new set, the retired instances and non-fatal failures.
|
|
183
|
+
* @throws {RolloutAbortedError} When an instance cannot be created, become ready or
|
|
184
|
+
* join — after undoing everything this run created and leaving the old set serving.
|
|
185
|
+
*/
|
|
186
|
+
export const runGrowThenShrink = async (options) => {
|
|
187
|
+
const { provider } = options;
|
|
188
|
+
const current = [...(options.current ?? [])];
|
|
189
|
+
const policy = resolveRolloutPolicy(options.policy, current.length || 1);
|
|
190
|
+
const log = makeLog(options.log);
|
|
191
|
+
const sleep = options.sleep ??
|
|
192
|
+
((ms) => new Promise((resolve) => {
|
|
193
|
+
setTimeout(resolve, ms);
|
|
194
|
+
}));
|
|
195
|
+
const now = options.now ?? (() => Date.now());
|
|
196
|
+
const created = [];
|
|
197
|
+
const joined = [];
|
|
198
|
+
const retired = [];
|
|
199
|
+
const failures = [];
|
|
200
|
+
const outcome = (status) => ({
|
|
201
|
+
status,
|
|
202
|
+
created: [...created],
|
|
203
|
+
upstreams: [...joined],
|
|
204
|
+
retired: [...retired],
|
|
205
|
+
failures: [...failures],
|
|
206
|
+
rolledBack: [],
|
|
207
|
+
rollbackFailures: [],
|
|
208
|
+
});
|
|
209
|
+
const abort = async (role, message, cause) => {
|
|
210
|
+
const rolledBack = [];
|
|
211
|
+
// Leave the set FIRST, so the edge stops balancing to an instance that is about
|
|
212
|
+
// to disappear; then destroy. A leave that fails does not stop the destroy —
|
|
213
|
+
// the set entry is the routing, the instance is the bill.
|
|
214
|
+
for (const instance of [...joined].reverse()) {
|
|
215
|
+
try {
|
|
216
|
+
await provider.leave(instance);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
await log(`Could not remove a new copy from the edge: ${messageOf(error)}.`);
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await provider.destroy(instance);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
await log(`Could not destroy a new copy: ${messageOf(error)}.`);
|
|
226
|
+
}
|
|
227
|
+
rolledBack.push(instance.id);
|
|
228
|
+
}
|
|
229
|
+
// A created-but-never-joined instance was never routable; destroy it.
|
|
230
|
+
for (const instance of created.filter((i) => !joined.includes(i)).reverse()) {
|
|
231
|
+
try {
|
|
232
|
+
await provider.destroy(instance);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
await log(`Could not destroy a new copy: ${messageOf(error)}.`);
|
|
236
|
+
}
|
|
237
|
+
rolledBack.push(instance.id);
|
|
238
|
+
}
|
|
239
|
+
throw new RolloutAbortedError(message, { role, rolledBack, rollbackFailures: [], outcome: outcome('aborted') }, cause);
|
|
240
|
+
};
|
|
241
|
+
// --- Idempotent resume ----------------------------------------------------
|
|
242
|
+
// A rollout re-run after it already finished is a no-op: when the provider can
|
|
243
|
+
// enumerate its instances and every one of them is serving at the target size,
|
|
244
|
+
// there is nothing to create or retire.
|
|
245
|
+
if (policy.resume && provider.list) {
|
|
246
|
+
try {
|
|
247
|
+
const listed = await provider.list();
|
|
248
|
+
if (listed.length === policy.replicas && listed.every((instance) => instance.id.length > 0)) {
|
|
249
|
+
let allServing = true;
|
|
250
|
+
if (provider.isServing) {
|
|
251
|
+
for (const instance of listed) {
|
|
252
|
+
if (!(await provider.isServing(instance))) {
|
|
253
|
+
allServing = false;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (allServing) {
|
|
259
|
+
await log(`Rollout is already complete: ${listed.length} instance(s) serving.`);
|
|
260
|
+
return {
|
|
261
|
+
status: 'completed',
|
|
262
|
+
created: [],
|
|
263
|
+
upstreams: [...listed],
|
|
264
|
+
retired: [],
|
|
265
|
+
failures: [],
|
|
266
|
+
rolledBack: [],
|
|
267
|
+
rollbackFailures: [],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// Enumeration is an optimisation, never a precondition: a provider whose
|
|
274
|
+
// listing failed still rolls from the caller's `current` set.
|
|
275
|
+
await log(`Could not list the current instances (${messageOf(error)}); rolling anyway.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// --- Phase 1: build the new set beside the old one ------------------------
|
|
279
|
+
for (let index = 0; index < policy.replicas - 1; index++) {
|
|
280
|
+
if (await cancelled(options.isCancelled)) {
|
|
281
|
+
return abort('cancelled', 'The deploy was cancelled before all copies were started.');
|
|
282
|
+
}
|
|
283
|
+
let instance;
|
|
284
|
+
try {
|
|
285
|
+
instance = await provider.create(index);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
return abort('create', `Could not start copy ${index + 2}: ${messageOf(error)}`, error);
|
|
289
|
+
}
|
|
290
|
+
created.push(instance);
|
|
291
|
+
let ready;
|
|
292
|
+
try {
|
|
293
|
+
ready = await provider.waitReady(instance, policy.readinessTimeoutMs);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
return abort('ready', `Copy ${index + 2} did not become ready: ${messageOf(error)}`, error);
|
|
297
|
+
}
|
|
298
|
+
if (!ready) {
|
|
299
|
+
return abort('ready', `Copy ${index + 2} did not become ready in time.`);
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
await provider.join(instance);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
return abort('join', `Copy ${index + 2} could not join the edge: ${messageOf(error)}`, error);
|
|
306
|
+
}
|
|
307
|
+
joined.push(instance);
|
|
308
|
+
if (policy.soakMs > 0) {
|
|
309
|
+
const soaked = await soakServing(instance, {
|
|
310
|
+
isServing: (candidate) => {
|
|
311
|
+
if (!provider.isServing) {
|
|
312
|
+
throw new Error('soakMs requires provider.isServing — a blocking readiness wait cannot tell a ' +
|
|
313
|
+
'warm instance from a recovering one');
|
|
314
|
+
}
|
|
315
|
+
return provider.isServing(candidate);
|
|
316
|
+
},
|
|
317
|
+
policy,
|
|
318
|
+
now,
|
|
319
|
+
sleep,
|
|
320
|
+
log,
|
|
321
|
+
});
|
|
322
|
+
if (!soaked) {
|
|
323
|
+
return abort('ready', `Copy ${index + 2} did not stay healthy for ${Math.round(policy.soakMs / 1000)} s.`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
await log(`Copy ${index + 2} is ready and serving.`);
|
|
327
|
+
}
|
|
328
|
+
// --- Phase 2: retire the old set, one at a time --------------------------
|
|
329
|
+
// Not one old instance is touched until every new one is created, gated and
|
|
330
|
+
// joined, so the edge has the whole new set before the first one leaves.
|
|
331
|
+
for (const old of current) {
|
|
332
|
+
// Never blank the deployment: with nothing new serving (a first deploy with
|
|
333
|
+
// replicas = 1 has no phase 1 at all) retiring the only serving instance is
|
|
334
|
+
// the caller's decision, not a roll's.
|
|
335
|
+
if (joined.length === 0)
|
|
336
|
+
break;
|
|
337
|
+
const healthyBefore = joined.length + (options.primaryServing ? 1 : 0) + (current.length - retired.length);
|
|
338
|
+
if (healthyBefore - 1 < policy.minHealthy) {
|
|
339
|
+
return abort('capacity', `Retiring an old copy would leave ${healthyBefore - 1} healthy ` +
|
|
340
|
+
`${healthyBefore - 1 === 1 ? 'instance' : 'instances'}, below the floor of ` +
|
|
341
|
+
`${policy.minHealthy}.`);
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
await provider.leave(old);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
failures.push({ instance: old, step: 'leave', error });
|
|
348
|
+
await log(`Could not remove an old copy from the edge: ${messageOf(error)}.`);
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
await provider.drain(old, policy.drainMs);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
failures.push({ instance: old, step: 'drain', error });
|
|
355
|
+
await log(`An old copy did not finish draining cleanly: ${messageOf(error)}.`);
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
await provider.destroy(old);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
failures.push({ instance: old, step: 'destroy', error });
|
|
362
|
+
await log(`Could not destroy an old copy: ${messageOf(error)}.`);
|
|
363
|
+
}
|
|
364
|
+
retired.push(old);
|
|
365
|
+
}
|
|
366
|
+
return outcome('completed');
|
|
367
|
+
};
|
|
368
|
+
/**
|
|
369
|
+
* Run an in-place replacement: the same instances changed one at a time.
|
|
370
|
+
*
|
|
371
|
+
* The instance list comes from `machines`, or from the provider's own `list()`.
|
|
372
|
+
* The operations come from `apply`/`rollback`/`probeServing`, or from the provider's
|
|
373
|
+
* `replace`/`restore`/`isServing`. Before an instance is taken out of rotation,
|
|
374
|
+
* every OTHER instance must answer its probe, and the resulting healthy count must
|
|
375
|
+
* stay at or above the policy floor.
|
|
376
|
+
*
|
|
377
|
+
* @param options - The instances to roll and the provider or callbacks.
|
|
378
|
+
* @returns The instances changed, in roll order.
|
|
379
|
+
* @throws {RolloutAbortedError} On the first failed step, after the reverse rollback.
|
|
380
|
+
*/
|
|
381
|
+
export const runInPlace = async (options) => {
|
|
382
|
+
const provider = options.provider;
|
|
383
|
+
const apply = options.apply ?? provider?.replace?.bind(provider);
|
|
384
|
+
const restore = options.rollback ?? provider?.restore?.bind(provider);
|
|
385
|
+
const probe = options.probeServing;
|
|
386
|
+
const sleep = options.sleep ??
|
|
387
|
+
((ms) => new Promise((resolve) => {
|
|
388
|
+
setTimeout(resolve, ms);
|
|
389
|
+
}));
|
|
390
|
+
const now = options.now ?? (() => Date.now());
|
|
391
|
+
let machines = options.machines ? [...options.machines] : [];
|
|
392
|
+
if (machines.length === 0 && provider)
|
|
393
|
+
machines = await provider.list();
|
|
394
|
+
if (machines.length === 0)
|
|
395
|
+
throw new Error('runInPlace: no instances to roll');
|
|
396
|
+
const ids = new Set(machines.map((machine) => machine.id));
|
|
397
|
+
if (ids.size !== machines.length) {
|
|
398
|
+
throw new Error('runInPlace: duplicate instance id in the target list');
|
|
399
|
+
}
|
|
400
|
+
const probeServing = async (id) => {
|
|
401
|
+
if (probe)
|
|
402
|
+
return probe(id);
|
|
403
|
+
if (provider) {
|
|
404
|
+
const machine = machines.find((candidate) => candidate.id === id);
|
|
405
|
+
if (machine)
|
|
406
|
+
return provider.isServing(machine);
|
|
407
|
+
}
|
|
408
|
+
throw new Error('runInPlace: no probeServing and no provider.isServing');
|
|
409
|
+
};
|
|
410
|
+
const policy = resolveRolloutPolicy({ ...options.policy, replicas: machines.length }, machines.length);
|
|
411
|
+
if (policy.soakMs > 0 && !probe && !provider) {
|
|
412
|
+
throw new Error('runInPlace: soakMs requires probeServing — a soak needs something to ask');
|
|
413
|
+
}
|
|
414
|
+
if (machines.length > 1 && !probe && !provider) {
|
|
415
|
+
throw new Error('runInPlace: probeServing is required with more than one instance — without it the roll ' +
|
|
416
|
+
'cannot prove another instance is serving before a restart');
|
|
417
|
+
}
|
|
418
|
+
if (!apply)
|
|
419
|
+
throw new Error('runInPlace: nothing to apply — pass apply or a provider');
|
|
420
|
+
if (!restore && policy.rollback === 'reverse') {
|
|
421
|
+
throw new Error('runInPlace: nothing to roll back with — pass rollback or a provider');
|
|
422
|
+
}
|
|
423
|
+
const log = makeLog(options.log);
|
|
424
|
+
const touched = [];
|
|
425
|
+
const applied = [];
|
|
426
|
+
const betweenMs = Math.max(0, Math.floor(options.betweenMs ?? 0));
|
|
427
|
+
for (const [index, machine] of machines.entries()) {
|
|
428
|
+
if (await cancelled(options.isCancelled)) {
|
|
429
|
+
throw new RolloutAbortedError('The rollout was cancelled before the next instance.', {
|
|
430
|
+
role: 'cancelled',
|
|
431
|
+
rolledBack: [],
|
|
432
|
+
rollbackFailures: [],
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (machines.length > 1) {
|
|
436
|
+
// The invariant: enough OTHER instances must be answering before this one is
|
|
437
|
+
// taken out of rotation. A probe that throws counts as NOT serving — the safe
|
|
438
|
+
// direction (refuse the roll, leave production untouched).
|
|
439
|
+
const others = machines.filter((candidate) => candidate.id !== machine.id);
|
|
440
|
+
let serving = 0;
|
|
441
|
+
for (const other of others) {
|
|
442
|
+
try {
|
|
443
|
+
if (await probeServing(other.id))
|
|
444
|
+
serving += 1;
|
|
445
|
+
}
|
|
446
|
+
catch (_error) {
|
|
447
|
+
// Counted as not serving; reported through the refusal below.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const floor = Math.min(policy.minHealthy, machines.length - 1);
|
|
451
|
+
if (serving < floor) {
|
|
452
|
+
const error = new RolloutAbortedError(`refusing to change instance ${machine.id}: ${serving} other ` +
|
|
453
|
+
`${serving === 1 ? 'instance is' : 'instances are'} serving but ${floor} ` +
|
|
454
|
+
`${floor === 1 ? 'is' : 'are'} required, so this step would take capacity below ` +
|
|
455
|
+
'the floor', { role: 'capacity', failed: machine.id });
|
|
456
|
+
if (touched.length > 0)
|
|
457
|
+
return abortInPlace(error, touched, restore, log);
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
await log(`Instance ${machine.id}: ${serving} other ` +
|
|
461
|
+
`${serving === 1 ? 'instance is' : 'instances are'} serving.`);
|
|
462
|
+
}
|
|
463
|
+
touched.push(machine);
|
|
464
|
+
try {
|
|
465
|
+
await apply(machine);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
return abortInPlace(new RolloutAbortedError(`instance ${machine.id} failed: ${messageOf(error)}`, { role: 'replace', failed: machine.id }, error), touched, restore, log);
|
|
469
|
+
}
|
|
470
|
+
if (policy.soakMs > 0) {
|
|
471
|
+
const soaked = await soakServing(machine, {
|
|
472
|
+
isServing: async (candidate) => probeServing(candidate.id),
|
|
473
|
+
policy,
|
|
474
|
+
now,
|
|
475
|
+
sleep,
|
|
476
|
+
log,
|
|
477
|
+
});
|
|
478
|
+
if (!soaked) {
|
|
479
|
+
return abortInPlace(new RolloutAbortedError(`instance ${machine.id} did not stay healthy for ${Math.round(policy.soakMs / 1000)} s ` +
|
|
480
|
+
'after the change — refusing to take the next instance out of rotation', { role: 'ready', failed: machine.id }), touched, restore, log);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
applied.push(machine.id);
|
|
484
|
+
if (betweenMs > 0 && index < machines.length - 1)
|
|
485
|
+
await sleep(betweenMs);
|
|
486
|
+
}
|
|
487
|
+
return { applied, instances: [...machines] };
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* Roll every touched instance back, newest first, then throw the abort.
|
|
491
|
+
*
|
|
492
|
+
* A rollback failure does not stop the later rollbacks: the goal is to get as many
|
|
493
|
+
* instances back on the old build as possible while the untouched ones keep
|
|
494
|
+
* serving.
|
|
495
|
+
*
|
|
496
|
+
* @param abort - The failure to report.
|
|
497
|
+
* @param touched - Instances this run changed, in order.
|
|
498
|
+
* @param restore - The restore operation, when there is one.
|
|
499
|
+
* @param log - Progress sink.
|
|
500
|
+
* @returns Never — always throws.
|
|
501
|
+
*/
|
|
502
|
+
const abortInPlace = async (abort, touched, restore, log) => {
|
|
503
|
+
const rolledBack = [];
|
|
504
|
+
const rollbackFailures = [];
|
|
505
|
+
if (restore) {
|
|
506
|
+
for (const machine of [...touched].reverse()) {
|
|
507
|
+
try {
|
|
508
|
+
await log(`Rolling instance ${machine.id} back to the old build.`);
|
|
509
|
+
await restore(machine);
|
|
510
|
+
rolledBack.push(machine.id);
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
rollbackFailures.push({ instance: machine, step: 'restore', error });
|
|
514
|
+
await log(`Could not roll instance ${machine.id} back: ${messageOf(error)}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
throw new RolloutAbortedError(abort.message, {
|
|
519
|
+
role: abort.role,
|
|
520
|
+
failed: abort.failed,
|
|
521
|
+
rolledBack,
|
|
522
|
+
rollbackFailures,
|
|
523
|
+
outcome: abort.outcome,
|
|
524
|
+
}, abort.cause);
|
|
525
|
+
};
|
|
526
|
+
/**
|
|
527
|
+
* The provider implementation registered under `deploy-rollout`.
|
|
528
|
+
*/
|
|
529
|
+
export const provider = {
|
|
530
|
+
name: 'default',
|
|
531
|
+
run: runGrowThenShrink,
|
|
532
|
+
runInPlace,
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* Create a fresh provider object (the same algorithm; a distinct identity for
|
|
536
|
+
* tests and for apps that register more than one).
|
|
537
|
+
*
|
|
538
|
+
* @returns A deploy-rollout provider.
|
|
539
|
+
*/
|
|
540
|
+
export const createProvider = () => ({
|
|
541
|
+
name: 'default',
|
|
542
|
+
run: runGrowThenShrink,
|
|
543
|
+
runInPlace,
|
|
544
|
+
});
|
|
545
|
+
/**
|
|
546
|
+
* One plain sentence about what a finished rollout did.
|
|
547
|
+
*
|
|
548
|
+
* @param outcome - What the rollout returned.
|
|
549
|
+
* @param replicas - The set size, for the sentence.
|
|
550
|
+
* @returns The line, or `null` when there was nothing to retire or the set is one.
|
|
551
|
+
*/
|
|
552
|
+
export const describeRolloutResult = (outcome, replicas) => {
|
|
553
|
+
if (replicas <= 1)
|
|
554
|
+
return null;
|
|
555
|
+
const copies = replicas === 1 ? 'copy' : 'copies';
|
|
556
|
+
return (`Running ${replicas} ${copies} of this app. ` +
|
|
557
|
+
`Replaced ${outcome.retired.length} old ${outcome.retired.length === 1 ? 'copy' : 'copies'} ` +
|
|
558
|
+
'one at a time; each was told to finish its requests first.');
|
|
559
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@molecule/api-deploy-rollout-default",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Default deploy-rollout provider for molecule.dev: grow-then-shrink and in-place instance replacement with a capacity floor, continuous soak, reverse rollback and idempotent resume.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "tsc --watch",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"test:coverage": "vitest run --coverage"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"molecule",
|
|
27
|
+
"deploy",
|
|
28
|
+
"rollout",
|
|
29
|
+
"rolling deploy",
|
|
30
|
+
"zero downtime"
|
|
31
|
+
],
|
|
32
|
+
"author": "Molecule Dev, Inc. (https://molecule.dev)",
|
|
33
|
+
"license": "Apache-2.0",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@molecule/api-deploy-rollout": "^1.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@molecule/api-deploy-rollout": "1.1.0",
|
|
42
|
+
"@types/node": "26.1.2",
|
|
43
|
+
"typescript": "6.0.3",
|
|
44
|
+
"vitest": "4.1.11"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/molecule-dev/molecule.git",
|
|
49
|
+
"directory": "packages/api/bonds/deploy-rollout/default"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://www.molecule.dev/packages/api-deploy-rollout-default",
|
|
52
|
+
"bugs": "https://github.com/molecule-dev/molecule/issues",
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|