@steve31415/baselib 3.2.0 → 3.3.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/dist/deploy/deploy.js +1 -0
- package/dist/deploy/gcs.d.ts +5 -0
- package/dist/deploy/gcs.js +55 -7
- package/package.json +1 -1
package/dist/deploy/deploy.js
CHANGED
|
@@ -228,6 +228,7 @@ export async function runDeploy(options) {
|
|
|
228
228
|
throw new Error('deploy exceeded its 25-minute deadline; lock may be breakable — aborting before traffic');
|
|
229
229
|
}
|
|
230
230
|
if (!(await verifyLock(runner, lock))) {
|
|
231
|
+
lock.stopHeartbeat();
|
|
231
232
|
lock = null; // someone broke and superseded us; never touch their lock
|
|
232
233
|
throw new Error('deploy lock lost (superseded by another deploy or rollback); aborting before traffic');
|
|
233
234
|
}
|
package/dist/deploy/gcs.d.ts
CHANGED
|
@@ -2,11 +2,16 @@ import type { HoldRecord, ReleaseRecord, Runner } from './types.js';
|
|
|
2
2
|
export interface LockHandle {
|
|
3
3
|
uri: string;
|
|
4
4
|
generation: string;
|
|
5
|
+
/** Ends the heartbeat; releaseLock calls it, and so must anyone who gives
|
|
6
|
+
* the lock up without releasing it. */
|
|
7
|
+
stopHeartbeat(): void;
|
|
5
8
|
}
|
|
6
9
|
export interface AcquireOptions {
|
|
7
10
|
waitMs?: number;
|
|
8
11
|
staleMs?: number;
|
|
9
12
|
pollMs?: number;
|
|
13
|
+
/** Refresh interval; 0 disables the heartbeat (tests). */
|
|
14
|
+
heartbeatMs?: number;
|
|
10
15
|
now?: () => number;
|
|
11
16
|
sleep?: (ms: number) => Promise<void>;
|
|
12
17
|
log?: (message: string) => void;
|
package/dist/deploy/gcs.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// GCS-backed coordination for pw-deploy/pw-rollback: the per-app deploy lock
|
|
2
|
-
// (create-only, generation-fenced, server-time
|
|
3
|
-
// release records, and the default
|
|
2
|
+
// (create-only, generation-fenced, heartbeat-refreshed, server-time
|
|
3
|
+
// staleness), the rollback hold, release records, and the default
|
|
4
|
+
// retained-asset publisher.
|
|
4
5
|
import { readdir, stat, writeFile } from 'node:fs/promises';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { tmpdir } from 'node:os';
|
|
7
8
|
import { CommandFailure, must } from './exec.js';
|
|
8
9
|
import { contentTypeFor, lockIsStale, SHA40 } from './plan.js';
|
|
9
|
-
|
|
10
|
+
/** A holder refreshes its lock every HEARTBEAT_MS; a lock not refreshed for
|
|
11
|
+
* STALE_MS is a dead holder's and may be broken — well inside the WAIT_MS a
|
|
12
|
+
* waiter is prepared to spend, so a killed deploy no longer needs a human
|
|
13
|
+
* to delete its lock. (Until 2026-09-04 staleness was judged from creation
|
|
14
|
+
* time with a 30-minute threshold, read from a key gcloud does not emit:
|
|
15
|
+
* the automatic break never fired.) */
|
|
16
|
+
const HEARTBEAT_MS = 30_000;
|
|
17
|
+
const STALE_MS = 3 * 60_000;
|
|
10
18
|
const WAIT_MS = 5 * 60_000;
|
|
11
19
|
const POLL_MS = 10_000;
|
|
12
20
|
async function describeObject(runner, uri) {
|
|
@@ -45,17 +53,23 @@ export async function acquireLock(runner, uri, body, opts = {}) {
|
|
|
45
53
|
const meta = await describeObject(runner, uri);
|
|
46
54
|
if (!meta?.generation)
|
|
47
55
|
throw new Error(`lock created but not describable: ${uri}`);
|
|
48
|
-
|
|
56
|
+
const generation = String(meta.generation);
|
|
57
|
+
const stopHeartbeat = startHeartbeat(runner, uri, generation, {
|
|
58
|
+
everyMs: opts.heartbeatMs ?? HEARTBEAT_MS,
|
|
59
|
+
now,
|
|
60
|
+
log,
|
|
61
|
+
});
|
|
62
|
+
return { uri, generation, stopHeartbeat };
|
|
49
63
|
}
|
|
50
64
|
const meta = await describeObject(runner, uri);
|
|
51
65
|
if (meta?.generation) {
|
|
52
66
|
const stale = opts.breakExisting ||
|
|
53
|
-
(meta.
|
|
54
|
-
lockIsStale(meta.
|
|
67
|
+
(meta.update_time !== undefined &&
|
|
68
|
+
lockIsStale(meta.update_time, now(), opts.staleMs ?? STALE_MS));
|
|
55
69
|
if (stale) {
|
|
56
70
|
log(opts.breakExisting
|
|
57
71
|
? `breaking existing lock (rollback takes priority): ${uri}`
|
|
58
|
-
: `breaking stale lock (
|
|
72
|
+
: `breaking stale lock (last refreshed ${meta.update_time}): ${uri}`);
|
|
59
73
|
// Conditional on the observed generation: two breakers cannot both
|
|
60
74
|
// win, and a just-released-and-reacquired lock is not clobbered.
|
|
61
75
|
await conditionalDelete(runner, uri, String(meta.generation));
|
|
@@ -77,12 +91,46 @@ export async function acquireLock(runner, uri, body, opts = {}) {
|
|
|
77
91
|
await sleep(opts.pollMs ?? POLL_MS);
|
|
78
92
|
}
|
|
79
93
|
}
|
|
94
|
+
/** Refresh the lock's custom metadata, fenced on our generation: a metadata
|
|
95
|
+
* update advances the object's server-side update_time (what staleness is
|
|
96
|
+
* judged from) without changing its generation (what the fence is). A
|
|
97
|
+
* refresh that fails the fence means a successor broke us — stop, and let
|
|
98
|
+
* verifyLock report it before traffic. Refreshes run one at a time. */
|
|
99
|
+
function startHeartbeat(runner, uri, generation, opts) {
|
|
100
|
+
if (opts.everyMs <= 0)
|
|
101
|
+
return () => { };
|
|
102
|
+
let stopped = false;
|
|
103
|
+
let chain = Promise.resolve();
|
|
104
|
+
const stop = () => {
|
|
105
|
+
stopped = true;
|
|
106
|
+
clearInterval(timer);
|
|
107
|
+
};
|
|
108
|
+
const refresh = async () => {
|
|
109
|
+
if (stopped)
|
|
110
|
+
return;
|
|
111
|
+
const result = await runner('gcloud', [
|
|
112
|
+
'storage', 'objects', 'update', uri,
|
|
113
|
+
`--custom-metadata=heartbeat=${new Date(opts.now()).toISOString()}`,
|
|
114
|
+
`--if-generation-match=${generation}`, '--quiet',
|
|
115
|
+
]);
|
|
116
|
+
if (result.code !== 0) {
|
|
117
|
+
opts.log(`lock heartbeat failed (generation ${generation} gone?): ${result.stderr.trim()}`);
|
|
118
|
+
stop();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const timer = setInterval(() => {
|
|
122
|
+
chain = chain.then(refresh);
|
|
123
|
+
}, opts.everyMs);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
return stop;
|
|
126
|
+
}
|
|
80
127
|
/** The fence: true only while our exact generation still exists. */
|
|
81
128
|
export async function verifyLock(runner, handle) {
|
|
82
129
|
const meta = await describeObject(runner, handle.uri);
|
|
83
130
|
return meta !== null && String(meta.generation) === handle.generation;
|
|
84
131
|
}
|
|
85
132
|
export async function releaseLock(runner, handle) {
|
|
133
|
+
handle.stopHeartbeat();
|
|
86
134
|
const ok = await conditionalDelete(runner, handle.uri, handle.generation);
|
|
87
135
|
if (!ok) {
|
|
88
136
|
// A successor broke us; their lock must survive. Nothing to clean up.
|
package/package.json
CHANGED