@proteinjs/db-driver-spanner 1.12.3 → 1.14.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/dist/generated/index.js +1 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/generated/test/index.d.ts.map +1 -1
  6. package/dist/generated/test/index.js +3 -1
  7. package/dist/generated/test/index.js.map +1 -1
  8. package/dist/src/SpannerConfig.d.ts +22 -0
  9. package/dist/src/SpannerConfig.d.ts.map +1 -1
  10. package/dist/src/SpannerDriver.d.ts +56 -9
  11. package/dist/src/SpannerDriver.d.ts.map +1 -1
  12. package/dist/src/SpannerDriver.js +229 -35
  13. package/dist/src/SpannerDriver.js.map +1 -1
  14. package/dist/src/SpannerLivenessMonitor.d.ts +25 -0
  15. package/dist/src/SpannerLivenessMonitor.d.ts.map +1 -1
  16. package/dist/src/SpannerLivenessMonitor.js +59 -4
  17. package/dist/src/SpannerLivenessMonitor.js.map +1 -1
  18. package/dist/test/ServiceUpdateVerbs.test.d.ts +2 -0
  19. package/dist/test/ServiceUpdateVerbs.test.d.ts.map +1 -0
  20. package/dist/test/ServiceUpdateVerbs.test.js +325 -0
  21. package/dist/test/ServiceUpdateVerbs.test.js.map +1 -0
  22. package/dist/test/SessionPoolExhaustion.test.d.ts +2 -0
  23. package/dist/test/SessionPoolExhaustion.test.d.ts.map +1 -0
  24. package/dist/test/SessionPoolExhaustion.test.js +230 -0
  25. package/dist/test/SessionPoolExhaustion.test.js.map +1 -0
  26. package/dist/test/SessionRecordTableQuery.test.d.ts +2 -0
  27. package/dist/test/SessionRecordTableQuery.test.d.ts.map +1 -0
  28. package/dist/test/SessionRecordTableQuery.test.js +185 -0
  29. package/dist/test/SessionRecordTableQuery.test.js.map +1 -0
  30. package/dist/test/SpannerLivenessMonitor.test.js +65 -0
  31. package/dist/test/SpannerLivenessMonitor.test.js.map +1 -1
  32. package/dist/test/SpannerOperationDeadline.test.d.ts +2 -0
  33. package/dist/test/SpannerOperationDeadline.test.d.ts.map +1 -0
  34. package/dist/test/SpannerOperationDeadline.test.js +227 -0
  35. package/dist/test/SpannerOperationDeadline.test.js.map +1 -0
  36. package/dist/test/TransactionSafety.test.d.ts +2 -0
  37. package/dist/test/TransactionSafety.test.d.ts.map +1 -0
  38. package/dist/test/TransactionSafety.test.js +325 -0
  39. package/dist/test/TransactionSafety.test.js.map +1 -0
  40. package/dist/test/index.d.ts +1 -0
  41. package/dist/test/index.d.ts.map +1 -1
  42. package/dist/test/index.js +1 -0
  43. package/dist/test/index.js.map +1 -1
  44. package/dist/test/util/serviceUpdateVerbsTestTables.d.ts +45 -0
  45. package/dist/test/util/serviceUpdateVerbsTestTables.d.ts.map +1 -0
  46. package/dist/test/util/serviceUpdateVerbsTestTables.js +100 -0
  47. package/dist/test/util/serviceUpdateVerbsTestTables.js.map +1 -0
  48. package/generated/index.ts +6 -9
  49. package/generated/test/index.ts +9 -10
  50. package/package.json +7 -6
  51. package/src/SpannerConfig.ts +22 -0
  52. package/src/SpannerDriver.ts +215 -28
  53. package/src/SpannerLivenessMonitor.ts +73 -5
  54. package/test/ServiceUpdateVerbs.test.ts +188 -0
  55. package/test/SessionPoolExhaustion.test.ts +127 -0
  56. package/test/SessionRecordTableQuery.test.ts +118 -0
  57. package/test/SpannerLivenessMonitor.test.ts +70 -0
  58. package/test/SpannerOperationDeadline.test.ts +156 -0
  59. package/test/TransactionSafety.test.ts +157 -0
  60. package/test/index.ts +1 -0
  61. package/test/util/serviceUpdateVerbsTestTables.ts +55 -0
@@ -19,10 +19,18 @@ import { SpannerSchemaMetadata } from './SpannerSchemaMetadata';
19
19
  * Google Spanner driver for ProteinJs Db
20
20
  */
21
21
  export class SpannerDriver implements DbDriver {
22
- private static SPANNER: Spanner;
23
- private static SPANNER_INSTANCE: Instance;
24
- private static SPANNER_DB: Database;
22
+ private static SPANNER?: Spanner;
23
+ private static SPANNER_INSTANCE?: Instance;
24
+ private static SPANNER_DB?: Database;
25
25
  private static LIVENESS_MONITOR: SpannerLivenessMonitor;
26
+ /**
27
+ * Channel-death recycle accounting (2026-08-06 overnight wedge). Ops are tagged with the
28
+ * generation of the client they started on; only same-generation outcomes touch the counter,
29
+ * so a burst of deadline failures from the OLD (recycled) channel can never spuriously
30
+ * recycle the fresh one.
31
+ */
32
+ private static CLIENT_GENERATION = 0;
33
+ private static CONSECUTIVE_DEADLINE_FAILURES = 0;
26
34
  private logger = new Logger({ name: this.constructor.name });
27
35
  private config: SpannerConfig;
28
36
  public getTable: ((name: string) => Table<any>) | undefined;
@@ -75,7 +83,10 @@ export class SpannerDriver implements DbDriver {
75
83
 
76
84
  private getSpannerDb(): Database {
77
85
  if (!SpannerDriver.SPANNER_DB) {
78
- SpannerDriver.SPANNER_DB = this.getSpannerInstance().database(this.config.databaseName);
86
+ SpannerDriver.SPANNER_DB = this.getSpannerInstance().database(
87
+ this.config.databaseName,
88
+ this.config.sessionPoolOptions
89
+ );
79
90
  SpannerDriver.LIVENESS_MONITOR = new SpannerLivenessMonitor(SpannerDriver.SPANNER_DB).start();
80
91
  }
81
92
 
@@ -174,13 +185,17 @@ export class SpannerDriver implements DbDriver {
174
185
 
175
186
  try {
176
187
  this.logger.debug({ message: `Executing query`, obj: { sql, params: namedParams } });
177
- const [rows] = await this.logIfStalled(
188
+ const [rows] = await this.withDeadline(
178
189
  'spanner query',
179
190
  sql,
180
191
  runner.run({
181
192
  sql,
182
193
  params: namedParams?.params,
183
194
  types: namedParams?.types,
195
+ // The gRPC deadline is what actually cancels the RPC on a dead channel: the stream
196
+ // errors, the library ends the snapshot, and the borrowed session RETURNS to the
197
+ // pool. The withDeadline race alone would fail the caller but leak the session.
198
+ gaxOptions: { timeout: this.operationDeadlineMs() },
184
199
  })
185
200
  );
186
201
  const durationMs = Number(process.hrtime.bigint() - startTime) / 1_000_000;
@@ -214,14 +229,22 @@ export class SpannerDriver implements DbDriver {
214
229
  }
215
230
 
216
231
  // Stalls in the transaction wrapper itself (session acquisition / begin / commit) happen
217
- // OUTSIDE executeDml's instrumentation — wrap the whole round trip too.
218
- return await this.logIfStalled(
232
+ // OUTSIDE executeDml's instrumentation — wrap the whole round trip too. Every await inside
233
+ // the run function is deadline-bounded (dml, commit, rollback): the run function therefore
234
+ // ALWAYS settles, which is what makes runTransactionAsync's own `finally` release the
235
+ // transaction's session back to the pool on a dead channel.
236
+ return await this.withDeadline(
219
237
  'spanner dml transaction',
220
238
  '(runTransactionAsync)',
221
239
  this.getSpannerDb().runTransactionAsync(async (transaction) => {
222
- const rowCount = await this.executeDml(generateStatement, transaction);
223
- await transaction.commit();
224
- return rowCount;
240
+ try {
241
+ const rowCount = await this.executeDml(generateStatement, transaction);
242
+ await this.commit(transaction);
243
+ return rowCount;
244
+ } catch (error) {
245
+ await this.rollbackQuietly(transaction);
246
+ throw error;
247
+ }
225
248
  })
226
249
  );
227
250
  }
@@ -241,13 +264,16 @@ export class SpannerDriver implements DbDriver {
241
264
 
242
265
  try {
243
266
  this.logger.debug({ message: `Executing dml`, obj: { sql, params: namedParams } });
244
- const [rowCount] = await this.logIfStalled(
267
+ const [rowCount] = await this.withDeadline(
245
268
  'spanner dml',
246
269
  sql,
247
270
  runner.runUpdate({
248
271
  sql,
249
272
  params: namedParams?.params,
250
273
  types: namedParams?.types,
274
+ // gRPC deadline: cancels the RPC on a dead channel so the transaction's run function
275
+ // settles and the library releases the session (see runDml).
276
+ gaxOptions: { timeout: this.operationDeadlineMs() },
251
277
  })
252
278
  );
253
279
  const durationMs = Number(process.hrtime.bigint() - startTime) / 1_000_000;
@@ -273,36 +299,91 @@ export class SpannerDriver implements DbDriver {
273
299
  * @returns the return of the `fn`
274
300
  */
275
301
  async runTransaction<T>(fn: (transaction: Transaction) => Promise<T>): Promise<T> {
276
- return await this.logIfStalled(
302
+ return await this.withDeadline(
277
303
  'spanner transaction',
278
304
  '(runTransactionAsync)',
279
305
  this.getSpannerDb().runTransactionAsync(async (transaction) => {
280
- const result = await fn(transaction);
281
- await transaction.commit();
282
- return result;
306
+ try {
307
+ const result = await fn(transaction);
308
+ await this.commit(transaction);
309
+ return result;
310
+ } catch (error) {
311
+ await this.rollbackQuietly(transaction);
312
+ throw error;
313
+ }
283
314
  })
284
315
  );
285
316
  }
286
317
 
287
318
  /**
288
- * Stall diagnostics (2026-07-10 flow-hang investigation): background flow tasks intermittently
289
- * wedge between model calls with every model-layer guard silent — the remaining awaits on that
290
- * path are Spanner ops, and this client has NO acquire/read timeouts (a wedged session/gRPC
291
- * stream waits forever). Logs ops still pending at 30s and 120s — with the op and statement —
292
- * then keeps waiting: pure diagnosis, zero behavior change.
319
+ * Deadline-bounded commit: a commit hanging on a dead channel would otherwise keep the
320
+ * transaction's run function pending forever, and with it the session (runTransactionAsync
321
+ * only releases the session once the run function settles).
322
+ */
323
+ private async commit(transaction: Transaction): Promise<void> {
324
+ await this.withDeadline(
325
+ 'spanner commit',
326
+ '(commit)',
327
+ transaction.commit({ gaxOptions: { timeout: this.operationDeadlineMs() } })
328
+ );
329
+ }
330
+
331
+ /**
332
+ * Release a transaction whose work errored. The client library's runner does NOT roll back on
333
+ * non-retryable errors — it just rethrows — so a thrown `fn` (e.g. an application rollback, or
334
+ * a failed statement) left the read-write transaction OPEN until session timeout. Real Spanner
335
+ * tolerates that (locks expire); the emulator serializes on its single read-write transaction,
336
+ * so one leaked transaction blocks every subsequent DDL with FAILED_PRECONDITION ("a
337
+ * read-write transaction is already in progress") — poisoning whole test runs. Rollback of an
338
+ * already-invalid transaction (e.g. ABORTED, about to be retried by the runner with a fresh
339
+ * transaction, or one whose commit was already called) is expected to fail; that failure is
340
+ * logged at debug and swallowed so the ORIGINAL error — the one that carries retry semantics —
341
+ * always propagates. Deadline-bounded like commit: an unbounded rollback on a dead channel
342
+ * would keep the run function (and its session) pending forever.
343
+ */
344
+ private async rollbackQuietly(transaction: Transaction): Promise<void> {
345
+ try {
346
+ await this.withDeadline(
347
+ 'spanner rollback',
348
+ '(rollback)',
349
+ transaction.rollback({ timeout: this.operationDeadlineMs() })
350
+ );
351
+ } catch (rollbackError: any) {
352
+ this.logger.debug({ message: `Rollback after transaction error failed`, obj: { rollbackError } });
353
+ }
354
+ }
355
+
356
+ /**
357
+ * The one choke point every query/dml/transaction op awaits through. Two jobs:
358
+ *
359
+ * 1. Stall diagnostics (2026-07-10 flow-hang investigation): logs ops still pending at 30s
360
+ * and 120s with the op and statement.
361
+ * 2. OP DEADLINE (2026-08-06 overnight wedge): an op that has not settled by
362
+ * `operationDeadlineMs` (default 60s) FAILS with an error naming the deadline instead of
363
+ * hanging forever on a dead gRPC channel. This race covers hangs the gRPC deadline cannot
364
+ * see (session-pool acquisition, library internals before the RPC starts); the
365
+ * wire-level cancellation + session return is the gRPC deadline attached per call
366
+ * (`gaxOptions.timeout`, same value) — see the call sites. Deadline failures feed the
367
+ * channel-death recycle counter; any success resets it.
293
368
  */
294
- private logIfStalled<T>(op: string, sql: string, promise: PromiseLike<T>, timeoutMs = 30_000): Promise<T> {
369
+ private withDeadline<T>(op: string, sql: string, promise: PromiseLike<T>): Promise<T> {
370
+ // Pool gauge (P4a): every op passes through here, so this is where waiting-on-the-pool
371
+ // becomes visible (throttled inside the monitor). The monitor exists by now — all ops
372
+ // require getSpannerDb() first.
373
+ SpannerDriver.LIVENESS_MONITOR.logPoolPressure();
374
+ const deadlineMs = this.operationDeadlineMs();
375
+ const generation = SpannerDriver.CLIENT_GENERATION;
295
376
  let settled = false;
296
377
  const logStall = (afterMs: number) =>
297
378
  this.logger.error({
298
379
  message: `Spanner op stalled: ${op}`,
299
- obj: { afterMs, sql: String(sql).slice(0, 200) },
380
+ obj: { afterMs, sql: String(sql).slice(0, 200), pool: SpannerDriver.LIVENESS_MONITOR.poolStats() },
300
381
  });
301
382
  const t1 = setTimeout(() => {
302
383
  if (!settled) {
303
- logStall(timeoutMs);
384
+ logStall(30_000);
304
385
  }
305
- }, timeoutMs);
386
+ }, 30_000);
306
387
  const t2 = setTimeout(() => {
307
388
  if (!settled) {
308
389
  logStall(120_000);
@@ -310,13 +391,119 @@ export class SpannerDriver implements DbDriver {
310
391
  }, 120_000);
311
392
  t1.unref?.();
312
393
  t2.unref?.();
313
- return Promise.resolve(promise).finally(() => {
314
- settled = true;
315
- clearTimeout(t1);
316
- clearTimeout(t2);
394
+ return new Promise<T>((resolve, reject) => {
395
+ const clear = () => {
396
+ settled = true;
397
+ clearTimeout(t1);
398
+ clearTimeout(t2);
399
+ clearTimeout(deadlineTimer);
400
+ };
401
+ const deadlineTimer = setTimeout(() => {
402
+ if (settled) {
403
+ return;
404
+ }
405
+ clear();
406
+ this.logger.error({
407
+ message: `Spanner op exceeded its ${deadlineMs}ms deadline: ${op} — failing the op`,
408
+ obj: { sql: String(sql).slice(0, 200), pool: SpannerDriver.LIVENESS_MONITOR.poolStats() },
409
+ });
410
+ this.recordDeadlineFailure(generation);
411
+ // Deliberately no grpc `code` on this error: the liveness monitor's probe/exit
412
+ // escalation stays owned by genuine grpc errors; hang-shaped death is owned by the
413
+ // recycle counter.
414
+ reject(
415
+ new Error(
416
+ `Spanner op exceeded its ${deadlineMs}ms deadline: ${op} (configure via SpannerConfig.operationDeadlineMs)`
417
+ )
418
+ );
419
+ }, deadlineMs);
420
+ deadlineTimer.unref?.();
421
+ Promise.resolve(promise).then(
422
+ (value) => {
423
+ if (settled) {
424
+ return; // deadline already failed the caller; the library handles the late settle
425
+ }
426
+ clear();
427
+ this.recordOpSuccess(generation);
428
+ resolve(value);
429
+ },
430
+ (error) => {
431
+ if (settled) {
432
+ return;
433
+ }
434
+ clear();
435
+ // A grpc DEADLINE_EXCEEDED (code 4) is the same dead-channel signal arriving via the
436
+ // per-call gRPC deadline — classification for the one counter, not a second path.
437
+ if (error?.code === 4) {
438
+ this.recordDeadlineFailure(generation);
439
+ }
440
+ reject(error);
441
+ }
442
+ );
317
443
  });
318
444
  }
319
445
 
446
+ // ── Channel-death recycle (one owner for the process-wide client) ─────────
447
+
448
+ private recordOpSuccess(generation: number): void {
449
+ if (generation !== SpannerDriver.CLIENT_GENERATION) {
450
+ return; // outcome from a recycled client says nothing about the fresh channel
451
+ }
452
+ SpannerDriver.CONSECUTIVE_DEADLINE_FAILURES = 0;
453
+ }
454
+
455
+ private recordDeadlineFailure(generation: number): void {
456
+ if (generation !== SpannerDriver.CLIENT_GENERATION) {
457
+ return;
458
+ }
459
+ SpannerDriver.CONSECUTIVE_DEADLINE_FAILURES += 1;
460
+ if (SpannerDriver.CONSECUTIVE_DEADLINE_FAILURES < this.deadlineFailuresBeforeRecycle()) {
461
+ return;
462
+ }
463
+ // Reset BEFORE recycling: the counter and generation swap happen in this same synchronous
464
+ // frame, so a burst of ops all timing out together triggers exactly one recycle.
465
+ SpannerDriver.CONSECUTIVE_DEADLINE_FAILURES = 0;
466
+ this.recycleClient();
467
+ }
468
+
469
+ /**
470
+ * Drop the process-wide Spanner client so the next op builds a fresh client/channel — the
471
+ * categorical cure for a dead gRPC channel (Mac sleep, NAT drop) that deadlines every op.
472
+ * The old liveness monitor is stopped so its probe/exit escalation can't kill the process
473
+ * for a channel we just abandoned; LIVENESS_MONITOR itself stays pointed at it (stopped)
474
+ * until getSpannerDb() installs the new client's monitor, keeping in-flight ops' error
475
+ * paths callable.
476
+ */
477
+ private recycleClient(): void {
478
+ this.logger.error({
479
+ message: `Recycling Spanner client after ${this.deadlineFailuresBeforeRecycle()} consecutive op-deadline failures (dead gRPC channel suspected) — a fresh client/channel will be created on the next op`,
480
+ });
481
+ SpannerDriver.CLIENT_GENERATION += 1;
482
+ const oldMonitor = SpannerDriver.LIVENESS_MONITOR;
483
+ const oldDb = SpannerDriver.SPANNER_DB;
484
+ const oldSpanner = SpannerDriver.SPANNER;
485
+ SpannerDriver.SPANNER = undefined;
486
+ SpannerDriver.SPANNER_INSTANCE = undefined;
487
+ SpannerDriver.SPANNER_DB = undefined;
488
+ oldMonitor?.stop();
489
+ // Best-effort teardown of the old client: session deletes are themselves RPCs on the very
490
+ // channel we believe is dead — the recycle must not depend on them succeeding.
491
+ void oldDb?.close().catch(() => undefined);
492
+ try {
493
+ oldSpanner?.close();
494
+ } catch {
495
+ // already torn down
496
+ }
497
+ }
498
+
499
+ private operationDeadlineMs(): number {
500
+ return this.config.operationDeadlineMs ?? 60_000;
501
+ }
502
+
503
+ private deadlineFailuresBeforeRecycle(): number {
504
+ return this.config.deadlineFailuresBeforeRecycle ?? 3;
505
+ }
506
+
320
507
  /**
321
508
  * Execute a schema write operation.
322
509
  */
@@ -1,39 +1,101 @@
1
- import { Database } from '@google-cloud/spanner';
1
+ import { Database, SessionPool } from '@google-cloud/spanner';
2
2
  import { Logger } from '@proteinjs/logger';
3
3
 
4
4
  /** grpc status codes that indicate connectivity trouble rather than an application error */
5
5
  const CONNECTIVITY_GRPC_CODES = [4 /* DEADLINE_EXCEEDED */, 14 /* UNAVAILABLE */];
6
6
 
7
+ /**
8
+ * Restart-requested exit code — the supervision contract shared with @proteinjs/build's
9
+ * serve-package (ServePackageSupervisor.RESTART_REQUEST_EXIT_CODE): a supervised process
10
+ * exiting with this code is respawned with bounded backoff instead of being treated as a plain
11
+ * failure (which the supervisor mirrors, i.e. stays down). Orchestrators that restart on any
12
+ * nonzero exit (systemd, Kubernetes) treat it like any other failure code, so it is safe
13
+ * everywhere. Hard-coded by design: the contract crosses a process boundary, like a signal.
14
+ */
15
+ const RESTART_REQUEST_EXIT_CODE = 86;
16
+
17
+ /** Session pool gauge (P4a): the four numbers that make pool exhaustion observable. */
18
+ export type SpannerSessionPoolStats = {
19
+ size: number;
20
+ available: number;
21
+ borrowed: number;
22
+ totalWaiters: number;
23
+ };
24
+
7
25
  export class SpannerLivenessMonitor {
8
26
  private static readonly PROBE_SQL = 'SELECT 1';
9
27
  private static readonly PROBE_TIMEOUT_MS = 10_000;
10
28
  /** delay before each attempt; 5 attempts spanning ~2 min (fast failures) to ~4.5 min (30s-deadline failures) */
11
29
  private static readonly PROBE_DELAYS_MS = [0, 5_000, 15_000, 30_000, 60_000];
30
+ /** waiters > 0 is the wedge signature — warn on sight, but at most once per interval per process */
31
+ private static readonly POOL_PRESSURE_WARN_INTERVAL_MS = 10_000;
12
32
  private logger = new Logger({ name: this.constructor.name });
13
33
  private checkInFlight = false;
34
+ private stopped = false;
35
+ private lastPoolPressureWarnMs = Number.NEGATIVE_INFINITY;
14
36
 
15
37
  constructor(private db: Database) {}
16
38
 
17
39
  /** Attach the single 'error' listener; called once when the Database singleton is created. */
18
40
  start(): this {
19
41
  this.db.on('error', (error: any) => {
42
+ if (this.stopped) {
43
+ return;
44
+ }
20
45
  this.logger.warn({
21
46
  message: `Spanner session pool emitted a background error; verifying db connectivity`,
22
- obj: { code: error?.code, errorDetails: error?.details ?? String(error) },
47
+ obj: { code: error?.code, errorDetails: error?.details ?? String(error), pool: this.poolStats() },
23
48
  });
24
49
  void this.verifyLiveness();
25
50
  });
26
51
  return this;
27
52
  }
28
53
 
54
+ /**
55
+ * Retire this monitor when its client is recycled (channel-death recycle in SpannerDriver):
56
+ * a stopped monitor must never escalate to process exit for a channel the driver has already
57
+ * abandoned — the replacement client gets a fresh monitor.
58
+ */
59
+ stop(): void {
60
+ this.stopped = true;
61
+ }
62
+
29
63
  /** Called from driver catch blocks; probes only for connectivity-shaped errors. */
30
64
  reportError(error: any): void {
31
- if (!CONNECTIVITY_GRPC_CODES.includes(error?.code)) {
65
+ if (this.stopped || !CONNECTIVITY_GRPC_CODES.includes(error?.code)) {
32
66
  return;
33
67
  }
34
68
  void this.verifyLiveness();
35
69
  }
36
70
 
71
+ poolStats(): SpannerSessionPoolStats {
72
+ const pool = (this.db as unknown as { pool_: SessionPool }).pool_;
73
+ return { size: pool.size, available: pool.available, borrowed: pool.borrowed, totalWaiters: pool.totalWaiters };
74
+ }
75
+
76
+ /**
77
+ * Warn whenever operations are queued waiting on the session pool (the silent-wedge
78
+ * signature — with an infinite acquireTimeout a wedged pool otherwise produces no signal at
79
+ * all). Called by the driver as ops are issued; throttled so a contended burst emits one
80
+ * line per interval, not one per queued op.
81
+ */
82
+ logPoolPressure(nowMs = Date.now()): void {
83
+ const stats = this.poolStats();
84
+ if (stats.totalWaiters === 0) {
85
+ return;
86
+ }
87
+
88
+ if (nowMs - this.lastPoolPressureWarnMs < SpannerLivenessMonitor.POOL_PRESSURE_WARN_INTERVAL_MS) {
89
+ return;
90
+ }
91
+
92
+ this.lastPoolPressureWarnMs = nowMs;
93
+ this.logger.warn({
94
+ message: `Spanner session pool under pressure: operations are waiting for a session`,
95
+ obj: stats,
96
+ });
97
+ }
98
+
37
99
  // --- helpers last ---
38
100
 
39
101
  private async verifyLiveness(): Promise<void> {
@@ -44,6 +106,9 @@ export class SpannerLivenessMonitor {
44
106
  try {
45
107
  for (let attempt = 0; attempt < SpannerLivenessMonitor.PROBE_DELAYS_MS.length; attempt++) {
46
108
  await this.sleep(SpannerLivenessMonitor.PROBE_DELAYS_MS[attempt]);
109
+ if (this.stopped) {
110
+ return; // client recycled mid-cycle — this channel's fate no longer matters
111
+ }
47
112
  try {
48
113
  await this.probe();
49
114
  this.logger.info({ message: `Db connectivity verified`, obj: { attempt: attempt + 1 } });
@@ -55,8 +120,11 @@ export class SpannerLivenessMonitor {
55
120
  });
56
121
  }
57
122
  }
123
+ if (this.stopped) {
124
+ return;
125
+ }
58
126
  this.logger.error({
59
- message: `Db unreachable after sustained probing; exiting so supervision can restart into a valid state`,
127
+ message: `Db unreachable after sustained probing; exiting restart-requested (code ${RESTART_REQUEST_EXIT_CODE}) so supervision respawns into a valid state`,
60
128
  });
61
129
  this.exit();
62
130
  } finally {
@@ -76,6 +144,6 @@ export class SpannerLivenessMonitor {
76
144
  }
77
145
 
78
146
  private exit(): void {
79
- process.exit(1);
147
+ process.exit(RESTART_REQUEST_EXIT_CODE);
80
148
  }
81
149
  }
@@ -0,0 +1,188 @@
1
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
2
+ import {
3
+ ArrayMembershipUpdate,
4
+ computeArrayMembershipOps,
5
+ Db,
6
+ isTable,
7
+ PreservedPath,
8
+ ReferenceArray,
9
+ Table,
10
+ tableByName,
11
+ } from '@proteinjs/db';
12
+ import { TransactionContext } from '@proteinjs/db-transaction-context';
13
+ import { getDropTestTable } from './util/getDropTestTable';
14
+ import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
15
+ import {
16
+ ServiceVerbsDoc,
17
+ ServiceVerbsDocTable,
18
+ serviceVerbsScopeContext as scope,
19
+ } from './util/serviceUpdateVerbsTestTables';
20
+ import '../generated/test/index';
21
+
22
+ /**
23
+ * The RMW update verbs (`updateArrayMembership`, `updatePreserving`) over the DbService RPC
24
+ * path, against real Spanner transaction semantics.
25
+ *
26
+ * The service path differs from bespoke server code in three load-bearing ways, each pinned here:
27
+ * - ONE long-lived `Db` instance serves every request (ServiceRouter builds its executor map
28
+ * once), so the verbs' self-wrapped transactions must not couple concurrent callers through
29
+ * instance state.
30
+ * - args cross a serialization boundary: `Table` as `{ tableName }` resolved via `tableByName`,
31
+ * the op payloads (`ArrayMembershipUpdate`, `PreservedPath`) as plain JSON.
32
+ * - authorization is the `TableServiceAuth.canAccess(methodName, args)` gate plus scoped/column
33
+ * query injection inside the verb's read-modify-write — an out-of-scope row must behave as
34
+ * nonexistent.
35
+ */
36
+
37
+ const spannerDriver = new SpannerDriver({
38
+ projectId: 'proteinjs-test',
39
+ instanceName: 'proteinjs-test',
40
+ databaseName: 'test',
41
+ });
42
+
43
+ describe('DbService RMW update verbs (updateArrayMembership / updatePreserving)', () => {
44
+ const docTable = new ServiceVerbsDocTable() as Table<ServiceVerbsDoc>;
45
+ const dropTable = getDropTestTable(spannerDriver);
46
+ // Service-singleton shaped: one shared instance, default table resolution (tableByName).
47
+ const db = new Db<ServiceVerbsDoc>(spannerDriver, undefined, new TransactionContext());
48
+
49
+ /**
50
+ * The wire forms the Serializer produces for DbService args: `Table` crosses as its name and is
51
+ * resolved via `tableByName` server-side (TableSerializer); the op payloads are plain JSON.
52
+ */
53
+ const overWire = (args: any[]) =>
54
+ args.map((arg) => (isTable(arg) ? tableByName((arg as Table<any>).name) : JSON.parse(JSON.stringify(arg))));
55
+
56
+ /** Invoke like ServiceExecutor does: deserialize wire args, run the canAccess gate, call the method. */
57
+ const rpc = async (methodName: string, args: any[]) => {
58
+ const wireArgs = overWire(args);
59
+ if (!db.serviceMetadata!.auth!.canAccess!(methodName, wireArgs)) {
60
+ throw new Error(`User not authorized to run service: DbService.${methodName}`);
61
+ }
62
+ return await (db as any)[methodName](...wireArgs);
63
+ };
64
+
65
+ const memberIds = async (id: string) => {
66
+ const row = await db.get(docTable, { id });
67
+ return row?.members?._ids;
68
+ };
69
+
70
+ beforeAll(async () => {
71
+ await SpannerEmulatorProvisioner.ensureProvisioned({
72
+ projectId: 'proteinjs-test',
73
+ instanceName: 'proteinjs-test',
74
+ databaseName: 'test',
75
+ });
76
+ await dropTable(docTable);
77
+ await spannerDriver.getTableManager().loadTable(docTable);
78
+ }, 60000);
79
+
80
+ afterAll(async () => {
81
+ await dropTable(docTable);
82
+ await SpannerEmulatorProvisioner.release();
83
+ }, 30000);
84
+
85
+ beforeEach(() => {
86
+ scope.current = 'scope-a';
87
+ });
88
+
89
+ test('membership ops apply against committed truth, not the caller list snapshot', async () => {
90
+ const doc = await db.insert(docTable, {
91
+ title: 'd1',
92
+ members: new ReferenceArray(docTable.name, ['a', 'b', 'c']),
93
+ });
94
+ // Another writer's committed wholesale update adds `d` — the caller's snapshot is now stale.
95
+ await db.update(docTable, { id: doc.id, members: new ReferenceArray(docTable.name, ['a', 'b', 'c', 'd']) });
96
+
97
+ // The caller computed remove(b) against the stale base [a, b, c].
98
+ const update: ArrayMembershipUpdate = {
99
+ recordId: doc.id,
100
+ columnPropertyName: 'members',
101
+ ops: computeArrayMembershipOps(['a', 'b', 'c'], ['a', 'c']),
102
+ };
103
+ expect(await rpc('updateArrayMembership', [docTable, update])).toBe(1);
104
+
105
+ // Both writers' effects survive; a wholesale write of the stale list would have erased `d`.
106
+ expect(await memberIds(doc.id)).toEqual(['a', 'c', 'd']);
107
+ });
108
+
109
+ test('a scoped caller cannot touch another scope’s row via updateArrayMembership', async () => {
110
+ const doc = await db.insert(docTable, { title: 'd2', members: new ReferenceArray(docTable.name, ['a', 'b']) });
111
+
112
+ scope.current = 'scope-b';
113
+ const removeA: ArrayMembershipUpdate = {
114
+ recordId: doc.id,
115
+ columnPropertyName: 'members',
116
+ ops: [{ op: 'remove', id: 'a' }],
117
+ };
118
+ expect(await rpc('updateArrayMembership', [docTable, removeA])).toBe(0);
119
+
120
+ scope.current = 'scope-a';
121
+ expect(await memberIds(doc.id)).toEqual(['a', 'b']);
122
+
123
+ // The owning scope can perform the identical op.
124
+ expect(await rpc('updateArrayMembership', [docTable, removeA])).toBe(1);
125
+ expect(await memberIds(doc.id)).toEqual(['b']);
126
+ });
127
+
128
+ test('a scoped caller cannot touch another scope’s row via updatePreserving', async () => {
129
+ const doc = await db.insert(docTable, { title: 'd3', body: { content: 'v1', style: { color: 'blue' } } });
130
+
131
+ scope.current = 'scope-b';
132
+ const preserve: PreservedPath[] = [{ columnPropertyName: 'body', paths: ['content'], whenType: 'string' }];
133
+ const foreignWrite = { id: doc.id, body: { content: 'hijack', style: { color: 'red' } } };
134
+ expect(await rpc('updatePreserving', [docTable, foreignWrite, preserve])).toBe(0);
135
+
136
+ scope.current = 'scope-a';
137
+ const row = await db.get(docTable, { id: doc.id });
138
+ expect(row.body).toEqual({ content: 'v1', style: { color: 'blue' } });
139
+ });
140
+
141
+ test('updatePreserving over the service preserves the committed sub-path while writing owned paths', async () => {
142
+ const doc = await db.insert(docTable, { title: 'd4', body: { content: 'v1', style: { color: 'blue' } } });
143
+ // The owning writer's committed text save; the structural writer's payload still carries v1.
144
+ await db.update(docTable, { id: doc.id, body: { content: 'v2', style: { color: 'blue' } } });
145
+
146
+ const preserve: PreservedPath[] = [{ columnPropertyName: 'body', paths: ['content'], whenType: 'string' }];
147
+ const stalePayload = { id: doc.id, body: { content: 'v1', style: { color: 'red' } } };
148
+ expect(await rpc('updatePreserving', [docTable, stalePayload, preserve])).toBe(1);
149
+
150
+ const row = await db.get(docTable, { id: doc.id });
151
+ expect(row.body).toEqual({ content: 'v2', style: { color: 'red' } });
152
+ });
153
+
154
+ test('concurrent RPCs on the shared service instance get isolated transactions', async () => {
155
+ const doc = await db.insert(docTable, { title: 'd5', members: new ReferenceArray(docTable.name, ['seed']) });
156
+
157
+ // The service singleton serves concurrent requests; each self-wrapped RMW transaction must be
158
+ // isolated (no cross-request transaction bleed through shared instance state), and every
159
+ // membership add must land. Issuance is STAGGERED so later requests arrive while an earlier
160
+ // request's transaction is open — the shape that makes shared instance state bleed: a
161
+ // simultaneous burst would let every call check the (still unset) transaction state before
162
+ // any transaction opens, masking the coupling.
163
+ const addedIds = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'];
164
+ const inFlight: Promise<unknown>[] = [];
165
+ for (const id of addedIds) {
166
+ inFlight.push(
167
+ rpc('updateArrayMembership', [
168
+ docTable,
169
+ { recordId: doc.id, columnPropertyName: 'members', ops: [{ op: 'add', id, afterId: 'seed' }] },
170
+ ])
171
+ );
172
+ await new Promise((resolve) => setTimeout(resolve, 5));
173
+ }
174
+ await Promise.all(inFlight);
175
+
176
+ expect([...((await memberIds(doc.id)) ?? [])].sort()).toEqual([...addedIds, 'seed'].sort());
177
+ }, 60000);
178
+
179
+ test('updateArrayMembership rejects a non-ReferenceArrayColumn target', async () => {
180
+ const doc = await db.insert(docTable, { title: 'd6' });
181
+ await expect(
182
+ rpc('updateArrayMembership', [
183
+ docTable,
184
+ { recordId: doc.id, columnPropertyName: 'title', ops: [{ op: 'add', id: 'x', afterId: null }] },
185
+ ])
186
+ ).rejects.toThrow('requires a ReferenceArrayColumn');
187
+ });
188
+ });