@steve31415/baselib 3.2.0 → 3.3.1

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.
@@ -12,7 +12,7 @@ import { DEFAULT_ACCOUNT, loadConfig, loadHooks } from './config.js';
12
12
  import { must, mustJson } from './exec.js';
13
13
  import { openEvidence, PhaseTimer } from './evidence.js';
14
14
  import { acquireLock, clearHold, publishRetainedAssets, readHold, readReleaseRecord, releaseLock, verifyLock, writeReleaseRecord, } from './gcs.js';
15
- import { consistentBaseSha, servingStateOf, SHA40 } from './plan.js';
15
+ import { consistentBaseSha, readServingState, SHA40 } from './plan.js';
16
16
  const RELEASE_DEADLINE_MS = 25 * 60_000;
17
17
  const IMAGE_DIGEST = /@sha256:[0-9a-f]{64}$/;
18
18
  export async function runDeploy(options) {
@@ -87,12 +87,9 @@ export async function runDeploy(options) {
87
87
  }
88
88
  const serving = [];
89
89
  for (const service of config.services) {
90
- const described = await mustJson(runner, 'gcloud', [
91
- 'run', 'services', 'describe', service.name,
92
- ...gcloudBase, '--region', config.region, '--format=json',
93
- ]);
90
+ const { described, state } = await readServingState(runner, gcloudBase, config.region, service.name);
94
91
  await evidence.save(`${service.name}-before.json`, JSON.stringify(described, null, 2));
95
- serving.push(servingStateOf(service.name, described));
92
+ serving.push(state);
96
93
  }
97
94
  const base = consistentBaseSha(serving, sha);
98
95
  if (!base.ok)
@@ -228,6 +225,7 @@ export async function runDeploy(options) {
228
225
  throw new Error('deploy exceeded its 25-minute deadline; lock may be breakable — aborting before traffic');
229
226
  }
230
227
  if (!(await verifyLock(runner, lock))) {
228
+ lock.stopHeartbeat();
231
229
  lock = null; // someone broke and superseded us; never touch their lock
232
230
  throw new Error('deploy lock lost (superseded by another deploy or rollback); aborting before traffic');
233
231
  }
@@ -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.
@@ -1,4 +1,4 @@
1
- import type { ServingState } from './types.js';
1
+ import type { Runner, ServingState } from './types.js';
2
2
  export declare const SHA40: RegExp;
3
3
  /** Lock staleness is judged from the GCS object's server-side timeCreated,
4
4
  * never from anything the lock writer wrote (its clock may be wrong). */
@@ -41,8 +41,32 @@ export declare function pickRevisionForSha(revisions: unknown[], targetSha: stri
41
41
  revision: string;
42
42
  imageDigest: string;
43
43
  } | null;
44
- /** Extract serving state from a `gcloud run services describe --format=json`. */
45
- export declare function servingStateOf(service: string, described: unknown): ServingState;
44
+ /** The one revision carrying 100% of traffic in a `gcloud run services
45
+ * describe --format=json`, from *observed* traffic (`status.traffic`, never
46
+ * `spec.traffic`, which is only the desired state). */
47
+ export declare function servingRevisionName(service: string, described: unknown): string;
48
+ /**
49
+ * Extract serving state from a `gcloud run services describe --format=json`.
50
+ *
51
+ * The serving revision's BUILD_ID and image are read from the service
52
+ * template only when that revision IS the latest created one. When it is
53
+ * not — a previous release created a revision that never took traffic (a
54
+ * failed secret-access check, a crashed startup, an aborted deploy) — the
55
+ * template describes the *wrong* revision, so the caller must pass that
56
+ * revision's own `gcloud run revisions describe --format=json` as
57
+ * `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
58
+ * a re-run after a failed traffic switch read the new SHA out of the
59
+ * template, logged "already serving", wrote the release record, and left
60
+ * the old revision serving.
61
+ */
62
+ export declare function servingStateOf(service: string, described: unknown, servingRevision?: unknown): ServingState;
63
+ /** Describe a service and resolve its serving state, describing the serving
64
+ * revision too whenever the template no longer belongs to it. Returns the
65
+ * raw service description as well, for evidence. */
66
+ export declare function readServingState(runner: Runner, gcloudBase: string[], region: string, service: string): Promise<{
67
+ described: unknown;
68
+ state: ServingState;
69
+ }>;
46
70
  export declare function contentTypeFor(path: string): string;
47
71
  /** Parse release-record listing rows into {sha, at} entries. */
48
72
  export declare function releaseEntriesFrom(rows: {
@@ -1,6 +1,7 @@
1
1
  // Pure decision logic for pw-deploy/pw-rollback: everything here is
2
2
  // side-effect-free so the concurrency-sensitive choices (lock staleness,
3
3
  // rollback targeting, serving-state consistency) are unit-tested directly.
4
+ import { mustJson } from './exec.js';
4
5
  export const SHA40 = /^[0-9a-f]{40}$/;
5
6
  /** Lock staleness is judged from the GCS object's server-side timeCreated,
6
7
  * never from anything the lock writer wrote (its clock may be wrong). */
@@ -101,22 +102,71 @@ export function pickRevisionForSha(revisions, targetSha) {
101
102
  matches.sort((a, b) => b.created - a.created);
102
103
  return matches[0] ? { revision: matches[0].revision, imageDigest: matches[0].imageDigest } : null;
103
104
  }
104
- /** Extract serving state from a `gcloud run services describe --format=json`. */
105
- export function servingStateOf(service, described) {
105
+ /** The one revision carrying 100% of traffic in a `gcloud run services
106
+ * describe --format=json`, from *observed* traffic (`status.traffic`, never
107
+ * `spec.traffic`, which is only the desired state). */
108
+ export function servingRevisionName(service, described) {
106
109
  const d = described;
107
110
  const active = (d.status?.traffic ?? []).filter((t) => (t.percent ?? 0) === 100);
108
111
  if (active.length !== 1 || !active[0].revisionName) {
109
112
  throw new Error(`${service}: expected exactly one 100% traffic target`);
110
113
  }
111
- const env = d.spec?.template?.spec?.containers?.[0]?.env ?? [];
114
+ return active[0].revisionName;
115
+ }
116
+ /**
117
+ * Extract serving state from a `gcloud run services describe --format=json`.
118
+ *
119
+ * The serving revision's BUILD_ID and image are read from the service
120
+ * template only when that revision IS the latest created one. When it is
121
+ * not — a previous release created a revision that never took traffic (a
122
+ * failed secret-access check, a crashed startup, an aborted deploy) — the
123
+ * template describes the *wrong* revision, so the caller must pass that
124
+ * revision's own `gcloud run revisions describe --format=json` as
125
+ * `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
126
+ * a re-run after a failed traffic switch read the new SHA out of the
127
+ * template, logged "already serving", wrote the release record, and left
128
+ * the old revision serving.
129
+ */
130
+ export function servingStateOf(service, described, servingRevision) {
131
+ const d = described;
132
+ const revision = servingRevisionName(service, described);
133
+ const latest = d.status?.latestCreatedRevisionName;
134
+ let container;
135
+ if (latest === undefined || latest === revision) {
136
+ container = d.spec?.template?.spec?.containers?.[0];
137
+ }
138
+ else if (servingRevision !== undefined) {
139
+ container = servingRevision.spec?.containers?.[0];
140
+ }
141
+ else {
142
+ throw new Error(`${service}: traffic serves ${revision} but the latest revision is ${latest}; ` +
143
+ 'the serving revision must be described separately');
144
+ }
145
+ const env = container?.env ?? [];
112
146
  const buildSha = env.find((e) => e.name === 'BUILD_ID')?.value ?? null;
113
147
  return {
114
148
  service,
115
- revision: active[0].revisionName,
149
+ revision,
116
150
  buildSha: buildSha !== null && SHA40.test(buildSha) ? buildSha : null,
117
- imageDigest: d.spec?.template?.spec?.containers?.[0]?.image ?? null,
151
+ imageDigest: container?.image ?? null,
118
152
  };
119
153
  }
154
+ /** Describe a service and resolve its serving state, describing the serving
155
+ * revision too whenever the template no longer belongs to it. Returns the
156
+ * raw service description as well, for evidence. */
157
+ export async function readServingState(runner, gcloudBase, region, service) {
158
+ const described = await mustJson(runner, 'gcloud', [
159
+ 'run', 'services', 'describe', service, ...gcloudBase, '--region', region, '--format=json',
160
+ ]);
161
+ const revision = servingRevisionName(service, described);
162
+ const latest = described.status?.latestCreatedRevisionName;
163
+ const servingRevision = latest !== undefined && latest !== revision
164
+ ? await mustJson(runner, 'gcloud', [
165
+ 'run', 'revisions', 'describe', revision, ...gcloudBase, '--region', region, '--format=json',
166
+ ])
167
+ : undefined;
168
+ return { described, state: servingStateOf(service, described, servingRevision) };
169
+ }
120
170
  const CONTENT_TYPES = {
121
171
  '.js': 'text/javascript',
122
172
  '.mjs': 'text/javascript',
@@ -8,7 +8,7 @@ import { must, mustJson } from './exec.js';
8
8
  import { openEvidence, PhaseTimer } from './evidence.js';
9
9
  import { acquireLock, listReleaseRecords, placeHold, releaseLock } from './gcs.js';
10
10
  import { DEFAULT_ACCOUNT, loadConfig } from './config.js';
11
- import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, servingStateOf, SHA40, } from './plan.js';
11
+ import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, readServingState, SHA40, } from './plan.js';
12
12
  export async function runRollback(options) {
13
13
  const log = options.log ?? ((m) => console.error(`[pw-rollback] ${m}`));
14
14
  const runner = options.runner;
@@ -39,11 +39,8 @@ export async function runRollback(options) {
39
39
  timer.enter('target');
40
40
  const serving = [];
41
41
  for (const service of config.services) {
42
- const described = await mustJson(runner, 'gcloud', [
43
- 'run', 'services', 'describe', service.name,
44
- ...gcloudBase, '--region', config.region, '--format=json',
45
- ]);
46
- serving.push(servingStateOf(service.name, described));
42
+ const { state } = await readServingState(runner, gcloudBase, config.region, service.name);
43
+ serving.push(state);
47
44
  }
48
45
  const evidence = await openEvidence(config.app, requested ?? serving[0].buildSha ?? 'rollback');
49
46
  evidenceDir = evidence.dir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
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",