@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +3 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/SpannerConfig.d.ts +22 -0
- package/dist/src/SpannerConfig.d.ts.map +1 -1
- package/dist/src/SpannerDriver.d.ts +56 -9
- package/dist/src/SpannerDriver.d.ts.map +1 -1
- package/dist/src/SpannerDriver.js +229 -35
- package/dist/src/SpannerDriver.js.map +1 -1
- package/dist/src/SpannerLivenessMonitor.d.ts +25 -0
- package/dist/src/SpannerLivenessMonitor.d.ts.map +1 -1
- package/dist/src/SpannerLivenessMonitor.js +59 -4
- package/dist/src/SpannerLivenessMonitor.js.map +1 -1
- package/dist/test/ServiceUpdateVerbs.test.d.ts +2 -0
- package/dist/test/ServiceUpdateVerbs.test.d.ts.map +1 -0
- package/dist/test/ServiceUpdateVerbs.test.js +325 -0
- package/dist/test/ServiceUpdateVerbs.test.js.map +1 -0
- package/dist/test/SessionPoolExhaustion.test.d.ts +2 -0
- package/dist/test/SessionPoolExhaustion.test.d.ts.map +1 -0
- package/dist/test/SessionPoolExhaustion.test.js +230 -0
- package/dist/test/SessionPoolExhaustion.test.js.map +1 -0
- package/dist/test/SessionRecordTableQuery.test.d.ts +2 -0
- package/dist/test/SessionRecordTableQuery.test.d.ts.map +1 -0
- package/dist/test/SessionRecordTableQuery.test.js +185 -0
- package/dist/test/SessionRecordTableQuery.test.js.map +1 -0
- package/dist/test/SpannerLivenessMonitor.test.js +65 -0
- package/dist/test/SpannerLivenessMonitor.test.js.map +1 -1
- package/dist/test/SpannerOperationDeadline.test.d.ts +2 -0
- package/dist/test/SpannerOperationDeadline.test.d.ts.map +1 -0
- package/dist/test/SpannerOperationDeadline.test.js +227 -0
- package/dist/test/SpannerOperationDeadline.test.js.map +1 -0
- package/dist/test/TransactionSafety.test.d.ts +2 -0
- package/dist/test/TransactionSafety.test.d.ts.map +1 -0
- package/dist/test/TransactionSafety.test.js +325 -0
- package/dist/test/TransactionSafety.test.js.map +1 -0
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/util/serviceUpdateVerbsTestTables.d.ts +45 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.d.ts.map +1 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.js +100 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.js.map +1 -0
- package/generated/index.ts +6 -9
- package/generated/test/index.ts +9 -10
- package/package.json +7 -6
- package/src/SpannerConfig.ts +22 -0
- package/src/SpannerDriver.ts +215 -28
- package/src/SpannerLivenessMonitor.ts +73 -5
- package/test/ServiceUpdateVerbs.test.ts +188 -0
- package/test/SessionPoolExhaustion.test.ts +127 -0
- package/test/SessionRecordTableQuery.test.ts +118 -0
- package/test/SpannerLivenessMonitor.test.ts +70 -0
- package/test/SpannerOperationDeadline.test.ts +156 -0
- package/test/TransactionSafety.test.ts +157 -0
- package/test/index.ts +1 -0
- package/test/util/serviceUpdateVerbsTestTables.ts +55 -0
package/src/SpannerDriver.ts
CHANGED
|
@@ -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
|
|
23
|
-
private static SPANNER_INSTANCE
|
|
24
|
-
private static SPANNER_DB
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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.
|
|
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.
|
|
302
|
+
return await this.withDeadline(
|
|
277
303
|
'spanner transaction',
|
|
278
304
|
'(runTransactionAsync)',
|
|
279
305
|
this.getSpannerDb().runTransactionAsync(async (transaction) => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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(
|
|
384
|
+
logStall(30_000);
|
|
304
385
|
}
|
|
305
|
-
},
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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(
|
|
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
|
+
});
|