@proteinjs/db-driver-spanner 1.10.17 → 1.10.18

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.
@@ -8,6 +8,7 @@ import {
8
8
  tableByName,
9
9
  } from '@proteinjs/db';
10
10
  import { SpannerConfig } from './SpannerConfig';
11
+ import { SpannerLivenessMonitor } from './SpannerLivenessMonitor';
11
12
  import { Logger } from '@proteinjs/logger';
12
13
  import { Statement } from '@proteinjs/db-query';
13
14
  import { SpannerSchemaOperations } from './SpannerSchemaOperations';
@@ -21,6 +22,7 @@ export class SpannerDriver implements DbDriver {
21
22
  private static SPANNER: Spanner;
22
23
  private static SPANNER_INSTANCE: Instance;
23
24
  private static SPANNER_DB: Database;
25
+ private static LIVENESS_MONITOR: SpannerLivenessMonitor;
24
26
  private logger = new Logger({ name: this.constructor.name });
25
27
  private config: SpannerConfig;
26
28
  public getTable: ((name: string) => Table<any>) | undefined;
@@ -55,6 +57,7 @@ export class SpannerDriver implements DbDriver {
55
57
  private getSpannerDb(): Database {
56
58
  if (!SpannerDriver.SPANNER_DB) {
57
59
  SpannerDriver.SPANNER_DB = this.getSpannerInstance().database(this.config.databaseName);
60
+ SpannerDriver.LIVENESS_MONITOR = new SpannerLivenessMonitor(SpannerDriver.SPANNER_DB).start();
58
61
  }
59
62
 
60
63
  return SpannerDriver.SPANNER_DB;
@@ -169,6 +172,7 @@ export class SpannerDriver implements DbDriver {
169
172
  message: `Failed when executing query`,
170
173
  obj: { sql, params: namedParams, errorDetails: error.details, durationMs },
171
174
  });
175
+ SpannerDriver.LIVENESS_MONITOR.reportError(error);
172
176
  throw error;
173
177
  }
174
178
  }
@@ -225,6 +229,7 @@ export class SpannerDriver implements DbDriver {
225
229
  message: `Failed when executing dml`,
226
230
  obj: { sql, params: namedParams, errorDetails: error.details, durationMs },
227
231
  });
232
+ SpannerDriver.LIVENESS_MONITOR.reportError(error);
228
233
  throw error;
229
234
  }
230
235
  }
@@ -0,0 +1,81 @@
1
+ import { Database } from '@google-cloud/spanner';
2
+ import { Logger } from '@proteinjs/logger';
3
+
4
+ /** grpc status codes that indicate connectivity trouble rather than an application error */
5
+ const CONNECTIVITY_GRPC_CODES = [4 /* DEADLINE_EXCEEDED */, 14 /* UNAVAILABLE */];
6
+
7
+ export class SpannerLivenessMonitor {
8
+ private static readonly PROBE_SQL = 'SELECT 1';
9
+ private static readonly PROBE_TIMEOUT_MS = 10_000;
10
+ /** delay before each attempt; 5 attempts spanning ~2 min (fast failures) to ~4.5 min (30s-deadline failures) */
11
+ private static readonly PROBE_DELAYS_MS = [0, 5_000, 15_000, 30_000, 60_000];
12
+ private logger = new Logger({ name: this.constructor.name });
13
+ private checkInFlight = false;
14
+
15
+ constructor(private db: Database) {}
16
+
17
+ /** Attach the single 'error' listener; called once when the Database singleton is created. */
18
+ start(): this {
19
+ this.db.on('error', (error: any) => {
20
+ this.logger.warn({
21
+ message: `Spanner session pool emitted a background error; verifying db connectivity`,
22
+ obj: { code: error?.code, errorDetails: error?.details ?? String(error) },
23
+ });
24
+ void this.verifyLiveness();
25
+ });
26
+ return this;
27
+ }
28
+
29
+ /** Called from driver catch blocks; probes only for connectivity-shaped errors. */
30
+ reportError(error: any): void {
31
+ if (!CONNECTIVITY_GRPC_CODES.includes(error?.code)) {
32
+ return;
33
+ }
34
+ void this.verifyLiveness();
35
+ }
36
+
37
+ // --- helpers last ---
38
+
39
+ private async verifyLiveness(): Promise<void> {
40
+ if (this.checkInFlight) {
41
+ return; // coalesce: eviction sweeps emit bursts of _destroy errors (the incident evicted several sessions)
42
+ }
43
+ this.checkInFlight = true;
44
+ try {
45
+ for (let attempt = 0; attempt < SpannerLivenessMonitor.PROBE_DELAYS_MS.length; attempt++) {
46
+ await this.sleep(SpannerLivenessMonitor.PROBE_DELAYS_MS[attempt]);
47
+ try {
48
+ await this.probe();
49
+ this.logger.info({ message: `Db connectivity verified`, obj: { attempt: attempt + 1 } });
50
+ return;
51
+ } catch (error: any) {
52
+ this.logger.warn({
53
+ message: `Db connectivity probe failed`,
54
+ obj: { attempt: attempt + 1, errorDetails: error?.details ?? String(error) },
55
+ });
56
+ }
57
+ }
58
+ this.logger.error({
59
+ message: `Db unreachable after sustained probing; exiting so supervision can restart into a valid state`,
60
+ });
61
+ this.exit();
62
+ } finally {
63
+ this.checkInFlight = false;
64
+ }
65
+ }
66
+
67
+ private async probe(): Promise<void> {
68
+ await this.db.run({
69
+ sql: SpannerLivenessMonitor.PROBE_SQL,
70
+ gaxOptions: { timeout: SpannerLivenessMonitor.PROBE_TIMEOUT_MS },
71
+ });
72
+ }
73
+
74
+ private sleep(ms: number): Promise<void> {
75
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
+ }
77
+
78
+ private exit(): void {
79
+ process.exit(1);
80
+ }
81
+ }
@@ -0,0 +1,49 @@
1
+ import { EventEmitter } from 'events';
2
+ import { Database } from '@google-cloud/spanner';
3
+ import { SpannerDriver, SpannerLivenessMonitor } from '@proteinjs/db-driver-spanner';
4
+
5
+ const spannerDriver = new SpannerDriver({
6
+ projectId: 'proteinjs-test',
7
+ instanceName: 'proteinjs-test',
8
+ databaseName: 'test',
9
+ });
10
+
11
+ const waitFor = async (condition: () => boolean, timeoutMs = 15_000): Promise<void> => {
12
+ const start = Date.now();
13
+ while (!condition()) {
14
+ if (Date.now() - start > timeoutMs) {
15
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
16
+ }
17
+ await new Promise((resolve) => setTimeout(resolve, 25));
18
+ }
19
+ };
20
+
21
+ describe('Liveness', () => {
22
+ afterEach(() => {
23
+ jest.restoreAllMocks();
24
+ });
25
+
26
+ test('session-pool background error does not crash the process; liveness probe runs and succeeds', async () => {
27
+ await spannerDriver.createDbIfNotExists();
28
+ const db = (spannerDriver as unknown as { getSpannerDb(): Database }).getSpannerDb();
29
+ const monitor = (SpannerDriver as unknown as { LIVENESS_MONITOR: SpannerLivenessMonitor }).LIVENESS_MONITOR;
30
+ const exitSpy = jest.spyOn(monitor as any, 'exit').mockImplementation(() => {});
31
+ const probeSpy = jest.spyOn(monitor as any, 'probe');
32
+
33
+ // Exercise the real library-side path: SessionPool.emit('error') is forwarded to the
34
+ // Database (database.js:156), which our monitor listens on. Before this fix, this emit
35
+ // was an unhandled 'error' event and killed the process — this test completing at all
36
+ // is the assertion that the crash is fixed.
37
+ (db as unknown as { pool_: EventEmitter }).pool_.emit(
38
+ 'error',
39
+ Object.assign(new Error('4 DEADLINE_EXCEEDED: fake'), { code: 4 })
40
+ );
41
+
42
+ await waitFor(() => probeSpy.mock.calls.length > 0);
43
+ // the probe ran against the live emulator and must resolve
44
+ await probeSpy.mock.results[0].value;
45
+
46
+ expect(probeSpy).toHaveBeenCalledTimes(1);
47
+ expect(exitSpy).not.toHaveBeenCalled();
48
+ });
49
+ });
@@ -0,0 +1,104 @@
1
+ import { Database } from '@google-cloud/spanner';
2
+ import { SpannerLivenessMonitor } from '@proteinjs/db-driver-spanner';
3
+
4
+ type MonitorInternals = {
5
+ probe(): Promise<void>;
6
+ exit(): void;
7
+ verifyLiveness(): Promise<void>;
8
+ checkInFlight: boolean;
9
+ logger: {
10
+ info(args: { message: string; obj?: any }): void;
11
+ warn(args: { message: string; obj?: any }): void;
12
+ error(args: { message: string; obj?: any }): void;
13
+ };
14
+ };
15
+
16
+ /** total of PROBE_DELAYS_MS [0, 5_000, 15_000, 30_000, 60_000] */
17
+ const ALL_PROBE_DELAYS_MS = 110_000;
18
+
19
+ describe('SpannerLivenessMonitor', () => {
20
+ let monitor: SpannerLivenessMonitor;
21
+ let internals: MonitorInternals;
22
+ let probeSpy: jest.SpyInstance;
23
+ let exitSpy: jest.SpyInstance;
24
+ let errorLogSpy: jest.SpyInstance;
25
+
26
+ beforeEach(() => {
27
+ jest.useFakeTimers();
28
+ monitor = new SpannerLivenessMonitor({} as Database);
29
+ internals = monitor as unknown as MonitorInternals;
30
+ probeSpy = jest.spyOn(monitor as any, 'probe');
31
+ exitSpy = jest.spyOn(monitor as any, 'exit').mockImplementation(() => {});
32
+ jest.spyOn(internals.logger, 'info').mockImplementation(() => {});
33
+ jest.spyOn(internals.logger, 'warn').mockImplementation(() => {});
34
+ errorLogSpy = jest.spyOn(internals.logger, 'error').mockImplementation(() => {});
35
+ });
36
+
37
+ afterEach(() => {
38
+ jest.useRealTimers();
39
+ jest.restoreAllMocks();
40
+ });
41
+
42
+ test('first probe succeeds: no exit, checkInFlight reset', async () => {
43
+ probeSpy.mockResolvedValue(undefined);
44
+
45
+ const check = internals.verifyLiveness();
46
+ await jest.advanceTimersByTimeAsync(0);
47
+ await check;
48
+
49
+ expect(probeSpy).toHaveBeenCalledTimes(1);
50
+ expect(exitSpy).not.toHaveBeenCalled();
51
+ expect(internals.checkInFlight).toBe(false);
52
+ });
53
+
54
+ test('probe fails twice then succeeds: recovers, no exit', async () => {
55
+ probeSpy
56
+ .mockRejectedValueOnce(new Error('4 DEADLINE_EXCEEDED: fake'))
57
+ .mockRejectedValueOnce(new Error('4 DEADLINE_EXCEEDED: fake'))
58
+ .mockResolvedValue(undefined);
59
+
60
+ const check = internals.verifyLiveness();
61
+ await jest.advanceTimersByTimeAsync(ALL_PROBE_DELAYS_MS);
62
+ await check;
63
+
64
+ expect(probeSpy).toHaveBeenCalledTimes(3);
65
+ expect(exitSpy).not.toHaveBeenCalled();
66
+ expect(internals.checkInFlight).toBe(false);
67
+ });
68
+
69
+ test('all 5 probes fail: exit called exactly once, fatal log emitted', async () => {
70
+ probeSpy.mockRejectedValue(new Error('4 DEADLINE_EXCEEDED: fake'));
71
+
72
+ const check = internals.verifyLiveness();
73
+ await jest.advanceTimersByTimeAsync(ALL_PROBE_DELAYS_MS);
74
+ await check;
75
+
76
+ expect(probeSpy).toHaveBeenCalledTimes(5);
77
+ expect(exitSpy).toHaveBeenCalledTimes(1);
78
+ expect(errorLogSpy).toHaveBeenCalledWith(
79
+ expect.objectContaining({ message: expect.stringContaining('Db unreachable after sustained probing') })
80
+ );
81
+ });
82
+
83
+ test('burst coalescing: reportError while a check is in flight triggers one probe cycle', async () => {
84
+ probeSpy.mockResolvedValue(undefined);
85
+
86
+ monitor.reportError(Object.assign(new Error('4 DEADLINE_EXCEEDED: fake'), { code: 4 }));
87
+ monitor.reportError(Object.assign(new Error('14 UNAVAILABLE: fake'), { code: 14 }));
88
+ await jest.advanceTimersByTimeAsync(0);
89
+
90
+ expect(probeSpy).toHaveBeenCalledTimes(1);
91
+ expect(exitSpy).not.toHaveBeenCalled();
92
+ expect(internals.checkInFlight).toBe(false);
93
+ });
94
+
95
+ test('non-connectivity error code: no probe', async () => {
96
+ probeSpy.mockResolvedValue(undefined);
97
+
98
+ monitor.reportError(Object.assign(new Error('6 ALREADY_EXISTS: fake'), { code: 6 }));
99
+ await jest.advanceTimersByTimeAsync(0);
100
+
101
+ expect(probeSpy).not.toHaveBeenCalled();
102
+ expect(exitSpy).not.toHaveBeenCalled();
103
+ });
104
+ });