@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.
@@ -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
  }
@@ -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;
@@ -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 staleness), the rollback hold,
3
- // release records, and the default retained-asset publisher.
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
- const STALE_MS = 30 * 60_000;
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
- return { uri, generation: String(meta.generation) };
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.timeCreated !== undefined &&
54
- lockIsStale(meta.timeCreated, now(), opts.staleMs ?? STALE_MS));
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 (created ${meta.timeCreated}): ${uri}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",