@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Database, SessionPool } from '@google-cloud/spanner';
|
|
2
|
+
import { Db, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
3
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
4
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
5
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
6
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
7
|
+
import '../generated/test/index';
|
|
8
|
+
|
|
9
|
+
interface WedgeEmployee extends Record {
|
|
10
|
+
name: string;
|
|
11
|
+
department?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class WedgeEmployeeTestTable extends Table<WedgeEmployee> {
|
|
15
|
+
name = 'db_test_pool_wedge_employee';
|
|
16
|
+
columns = withRecordColumns<WedgeEmployee>({
|
|
17
|
+
name: new StringColumn('name'),
|
|
18
|
+
department: new StringColumn('department'),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const employeeTable: Table<WedgeEmployee> = new WedgeEmployeeTestTable();
|
|
23
|
+
// Local table — not in any reflection source graph, so thread getTable explicitly.
|
|
24
|
+
const getTable = () => employeeTable;
|
|
25
|
+
const spannerConfig = {
|
|
26
|
+
projectId: 'proteinjs-test',
|
|
27
|
+
instanceName: 'proteinjs-test',
|
|
28
|
+
databaseName: 'test',
|
|
29
|
+
};
|
|
30
|
+
// max: 1 makes pool mechanics observable in miniature. This file must stay its own jest
|
|
31
|
+
// file — SpannerDriver.SPANNER_DB is a process-wide static, so the max-1 pool would leak
|
|
32
|
+
// into other suites.
|
|
33
|
+
const spannerDriver = new SpannerDriver(
|
|
34
|
+
{
|
|
35
|
+
...spannerConfig,
|
|
36
|
+
sessionPoolOptions: { max: 1, min: 1, acquireTimeout: 2000, fail: true },
|
|
37
|
+
},
|
|
38
|
+
getTable
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const getSessionPool = (): SessionPool => {
|
|
42
|
+
const db = (spannerDriver as unknown as { getSpannerDb(): Database }).getSpannerDb();
|
|
43
|
+
return (db as unknown as { pool_: SessionPool }).pool_;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pool mechanics at max=1 (plans/DB_PERF_PLAN.md P2):
|
|
48
|
+
* 1. The historical wedge — an operation inside a transaction acquiring a SECOND session while
|
|
49
|
+
* the transaction holds the only one — is DESIGNED OUT by the stateless transaction
|
|
50
|
+
* contract: every operation inside the body rides the held session, so the shape that
|
|
51
|
+
* bricked the process cannot be expressed. Proven here at max=1.
|
|
52
|
+
* 2. The exhaustion class that remains REAL — more concurrent transactions than sessions —
|
|
53
|
+
* is documented with two parallel transactions.
|
|
54
|
+
*/
|
|
55
|
+
describe('Session pool at max=1 (wedge designed out; real exhaustion documented)', () => {
|
|
56
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
57
|
+
const preconstructedDb = new Db(spannerDriver, getTable, new TransactionContext());
|
|
58
|
+
const txnDb = new Db(spannerDriver, getTable, new TransactionContext());
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
|
|
62
|
+
await spannerDriver.createDbIfNotExists();
|
|
63
|
+
// Setup issues overlapping schema-metadata queries; with fail: true the second acquisition
|
|
64
|
+
// would error instantly at max=1. Let setup queue on the single session, then restore the
|
|
65
|
+
// constructed fail-fast posture for the tests.
|
|
66
|
+
const pool = getSessionPool();
|
|
67
|
+
pool.options.fail = false;
|
|
68
|
+
await spannerDriver.getTableManager().loadTable(employeeTable);
|
|
69
|
+
pool.options.fail = true;
|
|
70
|
+
}, 60000);
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
getSessionPool().options.fail = false; // teardown queries queue like setup's
|
|
74
|
+
await dropTable(employeeTable);
|
|
75
|
+
await SpannerEmulatorProvisioner.release();
|
|
76
|
+
}, 60000);
|
|
77
|
+
|
|
78
|
+
test('SpannerConfig.sessionPoolOptions reach the session pool', () => {
|
|
79
|
+
const pool = getSessionPool();
|
|
80
|
+
expect(pool.options.max).toBe(1);
|
|
81
|
+
expect(pool.options.min).toBe(1);
|
|
82
|
+
expect(pool.options.acquireTimeout).toBe(2000);
|
|
83
|
+
expect(pool.options.fail).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('wedge DESIGNED OUT: at max=1, a pre-constructed Db inside a transaction rides the held session and just works', async () => {
|
|
87
|
+
const employee: Omit<WedgeEmployee, keyof Record> = {
|
|
88
|
+
name: 'PoolWedgeDesignedOut',
|
|
89
|
+
department: 'Engineering',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Under construction-time binding this exact shape was the wedge: the inner query needed a
|
|
93
|
+
// second session the pool didn't have. Statelessness makes it ride the transaction's own
|
|
94
|
+
// session — no acquisition, no exhaustion, correct read.
|
|
95
|
+
const inserted = await txnDb.runTransaction(async () => {
|
|
96
|
+
const emp = await txnDb.insert(employeeTable, employee);
|
|
97
|
+
const seen = await preconstructedDb.query(employeeTable, { name: employee.name });
|
|
98
|
+
expect(seen.length).toBe(1);
|
|
99
|
+
return emp;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const committed = await preconstructedDb.query(employeeTable, { id: inserted.id });
|
|
103
|
+
expect(committed.length).toBe(1);
|
|
104
|
+
await txnDb.delete(employeeTable, { id: inserted.id });
|
|
105
|
+
}, 30000);
|
|
106
|
+
|
|
107
|
+
test('real exhaustion class: a second PARALLEL transaction errors fast when the pool is spent (fail: true)', async () => {
|
|
108
|
+
let releaseHold!: () => void;
|
|
109
|
+
const holdGate = new Promise<void>((resolve) => (releaseHold = resolve));
|
|
110
|
+
|
|
111
|
+
// First transaction takes the only session and holds it open on the gate.
|
|
112
|
+
const holder = txnDb.runTransaction(async () => {
|
|
113
|
+
await holdGate;
|
|
114
|
+
return 'held';
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Second transaction cannot get a session; with fail: true it errors immediately instead
|
|
118
|
+
// of joining the pool's silent infinite FIFO (the production-default hang this file's
|
|
119
|
+
// options exist to surface).
|
|
120
|
+
await expect(preconstructedDb.runTransaction(async () => 'never-runs')).rejects.toMatchObject({
|
|
121
|
+
name: 'SessionPoolExhaustedError',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
releaseHold();
|
|
125
|
+
await expect(holder).resolves.toBe('held');
|
|
126
|
+
}, 30000);
|
|
127
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
3
|
+
import { Db, DateColumn, QueryBuilderFactory, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
4
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
5
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
6
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
7
|
+
import '../generated/test/index';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Proves the generic record-table query path over the session table shape.
|
|
11
|
+
*
|
|
12
|
+
* The admin Sessions record table (settings menu → Sessions) runs EXACTLY this query through
|
|
13
|
+
* DbService: rows written by DbSessionStore (system db, Table layer), read back with the
|
|
14
|
+
* QueryTableLoader query — sort `updated desc`, paginate. When it showed zero rows on a server
|
|
15
|
+
* with live sessions (2026-08), the suspects were the query shape itself: a physical table the
|
|
16
|
+
* Table def can't read, scope filtering dropping every row, or a default sort on an unpopulated
|
|
17
|
+
* column. This test pins the query shape against real Spanner semantics: store-shaped rows come
|
|
18
|
+
* back, all of them, newest-first — so an empty result at that surface means the query never ran
|
|
19
|
+
* (it was denied; the UI rendered the failure as "no rows"), not that the data is unreadable.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface SessionShape extends Record {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
session: string;
|
|
25
|
+
expires: Date;
|
|
26
|
+
userEmail: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mirrors @proteinjs/user SessionTable column-for-column (namespaced test table name). */
|
|
30
|
+
class SessionShapeTable extends Table<SessionShape> {
|
|
31
|
+
name = 'db_test_session_record_table_query';
|
|
32
|
+
columns = withRecordColumns<SessionShape>({
|
|
33
|
+
sessionId: new StringColumn('session_id'),
|
|
34
|
+
session: new StringColumn('serialized_session', {}, 4000),
|
|
35
|
+
expires: new DateColumn('expires'),
|
|
36
|
+
userEmail: new StringColumn('user_email'),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const table = new SessionShapeTable();
|
|
41
|
+
const getTable = (tableName: string) => {
|
|
42
|
+
if (tableName === table.name) {
|
|
43
|
+
return table;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Unexpected table lookup in test: ${tableName}`);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const spannerDriver = new SpannerDriver(
|
|
49
|
+
{
|
|
50
|
+
projectId: 'proteinjs-test',
|
|
51
|
+
instanceName: 'proteinjs-test',
|
|
52
|
+
databaseName: 'test',
|
|
53
|
+
},
|
|
54
|
+
getTable
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What DbSessionStore serializes into a row (shape from a real dev session). `updated` is
|
|
59
|
+
* stamped explicitly for deterministic newest-first assertions: the insert-time default takes
|
|
60
|
+
* moment() per row, and rows landing in the same millisecond would make the order ambiguous.
|
|
61
|
+
*/
|
|
62
|
+
const storeShapedRow = (n: number, updatedMs: number) => ({
|
|
63
|
+
sessionId: `test-session-${n}`,
|
|
64
|
+
session: JSON.stringify({
|
|
65
|
+
cookie: { originalMaxAge: 5184000000, expires: '2026-10-01T00:00:00.000Z', httpOnly: true, path: '/' },
|
|
66
|
+
passport: { user: `user-${n}@test.local` },
|
|
67
|
+
}),
|
|
68
|
+
expires: new Date(Date.now() + 5184000000),
|
|
69
|
+
userEmail: `user-${n}@test.local`,
|
|
70
|
+
updated: moment(updatedMs),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('Session-shaped record table query (the admin Sessions table path)', () => {
|
|
74
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
75
|
+
// Writes as the session store writes (system db), reads as the record table reads.
|
|
76
|
+
const systemDb = new Db(spannerDriver, getTable, new TransactionContext(), true);
|
|
77
|
+
const db = new Db(spannerDriver, getTable, new TransactionContext());
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
81
|
+
projectId: 'proteinjs-test',
|
|
82
|
+
instanceName: 'proteinjs-test',
|
|
83
|
+
databaseName: 'test',
|
|
84
|
+
});
|
|
85
|
+
await dropTable(table);
|
|
86
|
+
await spannerDriver.getTableManager().loadTable(table);
|
|
87
|
+
}, 60000);
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await dropTable(table);
|
|
91
|
+
await SpannerEmulatorProvisioner.release();
|
|
92
|
+
}, 30000);
|
|
93
|
+
|
|
94
|
+
test('store-written session rows all come back through the record-table query, newest-first', async () => {
|
|
95
|
+
// Insert like DbSessionStore.insertOrUpdate: system db, store-shaped fields, staggered updates.
|
|
96
|
+
const base = Date.now() - 60_000;
|
|
97
|
+
for (let n = 1; n <= 3; n++) {
|
|
98
|
+
await systemDb.insert(table, storeShapedRow(n, base + n * 1000) as any);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// EXACTLY QueryTableLoader.load's query: sort updated desc, paginate the first window.
|
|
102
|
+
const qb = new QueryBuilderFactory()
|
|
103
|
+
.createQueryBuilder<SessionShape>(table)
|
|
104
|
+
.sort([{ field: 'updated', desc: true }])
|
|
105
|
+
.paginate({ start: 0, end: 10 });
|
|
106
|
+
const rows = await db.query(table, qb);
|
|
107
|
+
|
|
108
|
+
expect(rows.map((row) => row.sessionId)).toEqual(['test-session-3', 'test-session-2', 'test-session-1']);
|
|
109
|
+
// Round-trip fidelity of the store-shaped fields the table displays.
|
|
110
|
+
expect(rows[0].userEmail).toBe('user-3@test.local');
|
|
111
|
+
expect(JSON.parse(rows[0].session).passport.user).toBe('user-3@test.local');
|
|
112
|
+
expect(rows[0].expires).toBeTruthy();
|
|
113
|
+
|
|
114
|
+
// The pagination variant of the loader also asks for the row count.
|
|
115
|
+
const countQb = new QueryBuilderFactory().createQueryBuilder<SessionShape>(table);
|
|
116
|
+
expect(await db.getRowCount(table, countQb)).toBe(3);
|
|
117
|
+
}, 60000);
|
|
118
|
+
});
|
|
@@ -80,6 +80,21 @@ describe('SpannerLivenessMonitor', () => {
|
|
|
80
80
|
);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test('the sustained-failure exit is RESTART-REQUESTED (code 86) so supervision respawns instead of staying down', async () => {
|
|
84
|
+
// The serve-package contract (ServePackageSupervisor.RESTART_REQUEST_EXIT_CODE): 86 asks
|
|
85
|
+
// the supervisor for a respawn with backoff; a plain exit(1) is mirrored and stays down —
|
|
86
|
+
// observed as a dev server dead all night after a transient network outage.
|
|
87
|
+
exitSpy.mockRestore();
|
|
88
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
|
89
|
+
probeSpy.mockRejectedValue(new Error('14 UNAVAILABLE: fake'));
|
|
90
|
+
|
|
91
|
+
const check = internals.verifyLiveness();
|
|
92
|
+
await jest.advanceTimersByTimeAsync(ALL_PROBE_DELAYS_MS);
|
|
93
|
+
await check;
|
|
94
|
+
|
|
95
|
+
expect(processExitSpy).toHaveBeenCalledWith(86);
|
|
96
|
+
});
|
|
97
|
+
|
|
83
98
|
test('burst coalescing: reportError while a check is in flight triggers one probe cycle', async () => {
|
|
84
99
|
probeSpy.mockResolvedValue(undefined);
|
|
85
100
|
|
|
@@ -102,3 +117,58 @@ describe('SpannerLivenessMonitor', () => {
|
|
|
102
117
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
103
118
|
});
|
|
104
119
|
});
|
|
120
|
+
|
|
121
|
+
describe('SpannerLivenessMonitor pool gauge (P4a)', () => {
|
|
122
|
+
const fakePool = { size: 25, available: 20, borrowed: 5, totalWaiters: 0 };
|
|
123
|
+
let monitor: SpannerLivenessMonitor;
|
|
124
|
+
let warnLogSpy: jest.SpyInstance;
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
fakePool.size = 25;
|
|
128
|
+
fakePool.available = 20;
|
|
129
|
+
fakePool.borrowed = 5;
|
|
130
|
+
fakePool.totalWaiters = 0;
|
|
131
|
+
monitor = new SpannerLivenessMonitor({ pool_: fakePool } as unknown as Database);
|
|
132
|
+
warnLogSpy = jest.spyOn((monitor as unknown as MonitorInternals).logger, 'warn').mockImplementation(() => {});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
jest.restoreAllMocks();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('poolStats surfaces the four pool numbers', () => {
|
|
140
|
+
expect(monitor.poolStats()).toEqual({ size: 25, available: 20, borrowed: 5, totalWaiters: 0 });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('no waiters: no pressure warning', () => {
|
|
144
|
+
monitor.logPoolPressure(1_000);
|
|
145
|
+
expect(warnLogSpy).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('waiters > 0: warns with the four pool numbers', () => {
|
|
149
|
+
fakePool.available = 0;
|
|
150
|
+
fakePool.borrowed = 25;
|
|
151
|
+
fakePool.totalWaiters = 3;
|
|
152
|
+
|
|
153
|
+
monitor.logPoolPressure(1_000);
|
|
154
|
+
|
|
155
|
+
expect(warnLogSpy).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(warnLogSpy).toHaveBeenCalledWith(
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
message: expect.stringContaining('session pool under pressure'),
|
|
159
|
+
obj: { size: 25, available: 0, borrowed: 25, totalWaiters: 3 },
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('pressure warnings are throttled to one per interval', () => {
|
|
165
|
+
fakePool.totalWaiters = 3;
|
|
166
|
+
|
|
167
|
+
monitor.logPoolPressure(1_000);
|
|
168
|
+
monitor.logPoolPressure(2_000); // within the 10s interval — suppressed
|
|
169
|
+
expect(warnLogSpy).toHaveBeenCalledTimes(1);
|
|
170
|
+
|
|
171
|
+
monitor.logPoolPressure(11_000); // interval elapsed — warns again
|
|
172
|
+
expect(warnLogSpy).toHaveBeenCalledTimes(2);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Op-level deadline + channel-death recycle (2026-08-06 overnight wedge: dead gRPC channel
|
|
5
|
+
* after Mac sleep → every op hung forever, borrowed sessions never returned, heap OOM).
|
|
6
|
+
* Pure unit tests: fake Database/Transaction/monitor injected into the driver's process-wide
|
|
7
|
+
* statics via typed casts; no emulator involved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type DriverStatics = {
|
|
11
|
+
SPANNER?: unknown;
|
|
12
|
+
SPANNER_INSTANCE?: unknown;
|
|
13
|
+
SPANNER_DB?: unknown;
|
|
14
|
+
LIVENESS_MONITOR?: unknown;
|
|
15
|
+
CLIENT_GENERATION: number;
|
|
16
|
+
CONSECUTIVE_DEADLINE_FAILURES: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const statics = SpannerDriver as unknown as DriverStatics;
|
|
20
|
+
|
|
21
|
+
const fakeMonitor = {
|
|
22
|
+
logPoolPressure: () => undefined,
|
|
23
|
+
poolStats: () => ({ size: 0, available: 0, borrowed: 0, totalWaiters: 0 }),
|
|
24
|
+
reportError: () => undefined,
|
|
25
|
+
stop: () => undefined,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const hang = () => new Promise<never>(() => undefined);
|
|
29
|
+
|
|
30
|
+
const generateStatement = (() => ({ sql: 'UPDATE t SET x = 1', namedParams: { params: {} } })) as any;
|
|
31
|
+
|
|
32
|
+
const waitFor = async (condition: () => boolean, timeoutMs: number, label: string) => {
|
|
33
|
+
const deadline = Date.now() + timeoutMs;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
if (condition()) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Timed out waiting for: ${label}`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const makeDriver = (config: { operationDeadlineMs: number; deadlineFailuresBeforeRecycle: number }, db: unknown) => {
|
|
44
|
+
statics.SPANNER_DB = db;
|
|
45
|
+
statics.LIVENESS_MONITOR = fakeMonitor;
|
|
46
|
+
const driver = new SpannerDriver({
|
|
47
|
+
projectId: 'fake',
|
|
48
|
+
instanceName: 'fake',
|
|
49
|
+
databaseName: 'fake',
|
|
50
|
+
...config,
|
|
51
|
+
});
|
|
52
|
+
// deadline/error logs are expected output of these tests — keep the run quiet
|
|
53
|
+
jest.spyOn((driver as any).logger, 'error').mockImplementation(() => undefined);
|
|
54
|
+
jest.spyOn((driver as any).logger, 'warn').mockImplementation(() => undefined);
|
|
55
|
+
return driver;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe('Spanner op deadlines', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
statics.CLIENT_GENERATION = 0;
|
|
61
|
+
statics.CONSECUTIVE_DEADLINE_FAILURES = 0;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
statics.SPANNER = undefined;
|
|
66
|
+
statics.SPANNER_INSTANCE = undefined;
|
|
67
|
+
statics.SPANNER_DB = undefined;
|
|
68
|
+
statics.LIVENESS_MONITOR = undefined;
|
|
69
|
+
statics.CONSECUTIVE_DEADLINE_FAILURES = 0;
|
|
70
|
+
jest.restoreAllMocks();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a hanging query op fails at the deadline with an error naming it, carrying the same gRPC deadline', async () => {
|
|
74
|
+
const run = jest.fn((_request: any) => hang());
|
|
75
|
+
const driver = makeDriver({ operationDeadlineMs: 150, deadlineFailuresBeforeRecycle: 99 }, { run });
|
|
76
|
+
|
|
77
|
+
await expect(driver.runQuery(generateStatement)).rejects.toThrow(/150ms deadline.*spanner query/);
|
|
78
|
+
|
|
79
|
+
// The gRPC deadline on the request is the lever that cancels the RPC on a dead channel —
|
|
80
|
+
// the library's stream error path is what returns the borrowed session to the pool.
|
|
81
|
+
expect(run.mock.calls[0][0]).toMatchObject({ gaxOptions: { timeout: 150 } });
|
|
82
|
+
}, 5000);
|
|
83
|
+
|
|
84
|
+
test('a dml transaction hung on a dead channel (dml AND rollback hang) still RETURNS its session', async () => {
|
|
85
|
+
// Fake Database.runTransactionAsync with the library's real session contract: the session
|
|
86
|
+
// is released only when the run function settles. Every await inside the driver's run
|
|
87
|
+
// function must therefore be deadline-bounded, or the session leaks forever.
|
|
88
|
+
const released: string[] = [];
|
|
89
|
+
const transaction = {
|
|
90
|
+
runUpdate: jest.fn(() => hang()),
|
|
91
|
+
rollback: jest.fn(() => hang()),
|
|
92
|
+
commit: jest.fn(() => hang()),
|
|
93
|
+
};
|
|
94
|
+
const db = {
|
|
95
|
+
runTransactionAsync: async (fn: (transaction: unknown) => Promise<unknown>) => {
|
|
96
|
+
try {
|
|
97
|
+
return await fn(transaction);
|
|
98
|
+
} finally {
|
|
99
|
+
released.push('session');
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const driver = makeDriver({ operationDeadlineMs: 150, deadlineFailuresBeforeRecycle: 99 }, db);
|
|
104
|
+
|
|
105
|
+
await expect(driver.runDml(generateStatement)).rejects.toThrow(/deadline/);
|
|
106
|
+
|
|
107
|
+
// dml deadline (~150ms) → bounded rollback deadline (~150ms more) → run function settles
|
|
108
|
+
// → the library's finally releases the session. A leak shows up as this wait timing out.
|
|
109
|
+
await waitFor(() => released.length === 1, 2000, 'session returned to the pool');
|
|
110
|
+
expect(transaction.rollback).toHaveBeenCalled();
|
|
111
|
+
}, 5000);
|
|
112
|
+
|
|
113
|
+
test('channel-death recycle: 3 consecutive deadline failures recycle the client once; success resets the count', async () => {
|
|
114
|
+
let hangOps = true;
|
|
115
|
+
const db = { run: jest.fn(() => (hangOps ? hang() : Promise.resolve([[]]))) };
|
|
116
|
+
const driver = makeDriver({ operationDeadlineMs: 100, deadlineFailuresBeforeRecycle: 3 }, db);
|
|
117
|
+
const recycleSpy = jest.spyOn(driver as any, 'recycleClient').mockImplementation(() => {
|
|
118
|
+
statics.CLIENT_GENERATION += 1;
|
|
119
|
+
});
|
|
120
|
+
const failingOp = () => expect(driver.runQuery(generateStatement)).rejects.toThrow(/deadline/);
|
|
121
|
+
|
|
122
|
+
await failingOp();
|
|
123
|
+
await failingOp();
|
|
124
|
+
expect(recycleSpy).not.toHaveBeenCalled();
|
|
125
|
+
|
|
126
|
+
// Any success resets the consecutive count.
|
|
127
|
+
hangOps = false;
|
|
128
|
+
await driver.runQuery(generateStatement);
|
|
129
|
+
expect(statics.CONSECUTIVE_DEADLINE_FAILURES).toBe(0);
|
|
130
|
+
|
|
131
|
+
hangOps = true;
|
|
132
|
+
await failingOp();
|
|
133
|
+
await failingOp();
|
|
134
|
+
expect(recycleSpy).not.toHaveBeenCalled(); // the reset really pushed the threshold out
|
|
135
|
+
|
|
136
|
+
await failingOp();
|
|
137
|
+
expect(recycleSpy).toHaveBeenCalledTimes(1); // 3rd consecutive failure → one recycle
|
|
138
|
+
expect(statics.CONSECUTIVE_DEADLINE_FAILURES).toBe(0);
|
|
139
|
+
}, 10000);
|
|
140
|
+
|
|
141
|
+
test('deadline failures from a recycled (stale-generation) client never count against the fresh channel', async () => {
|
|
142
|
+
const db = { run: jest.fn(() => hang()) };
|
|
143
|
+
const driver = makeDriver({ operationDeadlineMs: 100, deadlineFailuresBeforeRecycle: 1 }, db);
|
|
144
|
+
const recycleSpy = jest.spyOn(driver as any, 'recycleClient').mockImplementation(() => {
|
|
145
|
+
statics.CLIENT_GENERATION += 1;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const pending = expect(driver.runQuery(generateStatement)).rejects.toThrow(/deadline/);
|
|
149
|
+
// The client is recycled while the op is in flight — its failure belongs to the old channel.
|
|
150
|
+
statics.CLIENT_GENERATION += 1;
|
|
151
|
+
await pending;
|
|
152
|
+
|
|
153
|
+
expect(recycleSpy).not.toHaveBeenCalled();
|
|
154
|
+
expect(statics.CONSECUTIVE_DEADLINE_FAILURES).toBe(0);
|
|
155
|
+
}, 5000);
|
|
156
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Db, QueryBuilderFactory, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
2
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
3
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
4
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
5
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
6
|
+
import '../generated/test/index';
|
|
7
|
+
|
|
8
|
+
interface SafetyEmployee extends Record {
|
|
9
|
+
name: string;
|
|
10
|
+
department?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class SafetyEmployeeTestTable extends Table<SafetyEmployee> {
|
|
14
|
+
name = 'db_test_txn_safety_employee';
|
|
15
|
+
columns = withRecordColumns<SafetyEmployee>({
|
|
16
|
+
name: new StringColumn('name'),
|
|
17
|
+
department: new StringColumn('department'),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const employeeTable: Table<SafetyEmployee> = new SafetyEmployeeTestTable();
|
|
22
|
+
// Local table — not in any reflection source graph, so thread getTable explicitly.
|
|
23
|
+
const getTable = () => employeeTable;
|
|
24
|
+
const spannerConfig = {
|
|
25
|
+
projectId: 'proteinjs-test',
|
|
26
|
+
instanceName: 'proteinjs-test',
|
|
27
|
+
databaseName: 'test',
|
|
28
|
+
};
|
|
29
|
+
const spannerDriver = new SpannerDriver(spannerConfig, getTable);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The STATELESS transaction contract (plans/DB_PERF_PLAN.md P2, superseding the
|
|
33
|
+
* construction-time-binding guard): Db instances carry no transaction state — every operation
|
|
34
|
+
* resolves the ambient transaction (AsyncLocalStorage) at call time. Inside a transaction body
|
|
35
|
+
* every Db rides the transaction, whenever it was constructed; outside, every Db uses the
|
|
36
|
+
* pool. The one escape shape — work spawned inside a body that outlives the transaction while
|
|
37
|
+
* holding its context — fails loudly by name (the ended-context tombstone).
|
|
38
|
+
*/
|
|
39
|
+
describe('Transaction safety (stateless contract)', () => {
|
|
40
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
41
|
+
// Constructed at describe scope, BEFORE any transaction — under the old construction-time
|
|
42
|
+
// binding this instance was the "stale Db" hazard; now it must simply ride whatever ambient
|
|
43
|
+
// transaction is active at call time.
|
|
44
|
+
const preconstructedDb = new Db(spannerDriver, getTable, new TransactionContext());
|
|
45
|
+
const txnDb = new Db(spannerDriver, getTable, new TransactionContext());
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
|
|
49
|
+
await spannerDriver.createDbIfNotExists();
|
|
50
|
+
await spannerDriver.getTableManager().loadTable(employeeTable);
|
|
51
|
+
}, 60000);
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await dropTable(employeeTable);
|
|
55
|
+
await SpannerEmulatorProvisioner.release();
|
|
56
|
+
}, 60000);
|
|
57
|
+
|
|
58
|
+
test('a PRE-CONSTRUCTED Db rides the ambient transaction: its reads see the txn write, and the txn commits', async () => {
|
|
59
|
+
const employee: Omit<SafetyEmployee, keyof Record> = {
|
|
60
|
+
name: 'TxnSafetyRides',
|
|
61
|
+
department: 'Engineering',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const inserted = await txnDb.runTransaction(async () => {
|
|
65
|
+
const emp = await txnDb.insert(employeeTable, employee);
|
|
66
|
+
// The pre-constructed instance resolves the ambient transaction at call time — it sees
|
|
67
|
+
// the uncommitted write (no second session, no stale read: the old hazard shapes).
|
|
68
|
+
const seenInside = await preconstructedDb.query(employeeTable, { id: emp.id });
|
|
69
|
+
expect(seenInside.length).toBe(1);
|
|
70
|
+
return emp;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const committed = await preconstructedDb.query(employeeTable, { id: inserted.id });
|
|
74
|
+
expect(committed.length).toBe(1);
|
|
75
|
+
await txnDb.delete(employeeTable, { id: inserted.id });
|
|
76
|
+
}, 30000);
|
|
77
|
+
|
|
78
|
+
test('a Db constructed INSIDE a transaction, used after commit, cleanly uses the pool and sees committed state', async () => {
|
|
79
|
+
let escapedDb!: Db<SafetyEmployee>;
|
|
80
|
+
const inserted = await txnDb.runTransaction(async () => {
|
|
81
|
+
escapedDb = new Db(spannerDriver, getTable, new TransactionContext());
|
|
82
|
+
return await escapedDb.insert(employeeTable, { name: 'TxnSafetyAfterCommit' } as SafetyEmployee);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// No instance binding survives the transaction — this is a plain pool-path read now.
|
|
86
|
+
const seen = await escapedDb.query(employeeTable, { id: inserted.id });
|
|
87
|
+
expect(seen.length).toBe(1);
|
|
88
|
+
await escapedDb.delete(employeeTable, { id: inserted.id });
|
|
89
|
+
}, 30000);
|
|
90
|
+
|
|
91
|
+
test('atomicity through ANY instance: a rollback takes the pre-constructed Db write with it', async () => {
|
|
92
|
+
await expect(
|
|
93
|
+
txnDb.runTransaction(async () => {
|
|
94
|
+
// If this insert did NOT ride the transaction it would self-commit in its own
|
|
95
|
+
// transaction and survive the rollback below — the exact decay a severed ambient
|
|
96
|
+
// resolution produces (a commit-path assertion can't see it; this one can).
|
|
97
|
+
await preconstructedDb.insert(employeeTable, { name: 'TxnSafetyAtomic' } as SafetyEmployee);
|
|
98
|
+
throw new Error('force-rollback');
|
|
99
|
+
})
|
|
100
|
+
).rejects.toThrow('force-rollback');
|
|
101
|
+
|
|
102
|
+
const after = await preconstructedDb.query(employeeTable, { name: 'TxnSafetyAtomic' });
|
|
103
|
+
expect(after.length).toBe(0);
|
|
104
|
+
}, 30000);
|
|
105
|
+
|
|
106
|
+
test('nested transactions throw, from ANY instance', async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
txnDb.runTransaction(async () => {
|
|
109
|
+
await preconstructedDb.runTransaction(async () => 'never-runs');
|
|
110
|
+
})
|
|
111
|
+
).rejects.toThrow(/already running in this context/);
|
|
112
|
+
}, 30000);
|
|
113
|
+
|
|
114
|
+
test('two concurrent transactions on ONE shared instance commit independently', async () => {
|
|
115
|
+
const [a, b] = await Promise.all([
|
|
116
|
+
txnDb.runTransaction(
|
|
117
|
+
async () => await txnDb.insert(employeeTable, { name: 'TxnSafetyConcurrentA' } as SafetyEmployee)
|
|
118
|
+
),
|
|
119
|
+
txnDb.runTransaction(
|
|
120
|
+
async () => await txnDb.insert(employeeTable, { name: 'TxnSafetyConcurrentB' } as SafetyEmployee)
|
|
121
|
+
),
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const seen = await txnDb.query(
|
|
125
|
+
employeeTable,
|
|
126
|
+
new QueryBuilderFactory()
|
|
127
|
+
.createQueryBuilder(employeeTable)
|
|
128
|
+
.condition({ field: 'id', operator: 'IN', value: [a.id, b.id] })
|
|
129
|
+
);
|
|
130
|
+
expect(seen.length).toBe(2);
|
|
131
|
+
await txnDb.delete(employeeTable, { id: a.id });
|
|
132
|
+
await txnDb.delete(employeeTable, { id: b.id });
|
|
133
|
+
}, 30000);
|
|
134
|
+
|
|
135
|
+
test('tombstone: work that escapes a finished transaction fails loudly by name', async () => {
|
|
136
|
+
let releaseEscape!: () => void;
|
|
137
|
+
const escapeGate = new Promise<void>((resolve) => (releaseEscape = resolve));
|
|
138
|
+
let escapedOp!: Promise<unknown>;
|
|
139
|
+
|
|
140
|
+
await txnDb.runTransaction(async () => {
|
|
141
|
+
// Spawned inside the body, NOT awaited by it — it captures the transaction's async
|
|
142
|
+
// context and outlives the transaction.
|
|
143
|
+
escapedOp = (async () => {
|
|
144
|
+
await escapeGate;
|
|
145
|
+
return await txnDb.query(employeeTable, { name: 'TxnSafetyEscapee' });
|
|
146
|
+
})();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
releaseEscape();
|
|
150
|
+
await expect(escapedOp).rejects.toThrow(/transaction that already ended/);
|
|
151
|
+
}, 30000);
|
|
152
|
+
|
|
153
|
+
test('queries outside any transaction are plain pool reads', async () => {
|
|
154
|
+
const seen = await preconstructedDb.query(employeeTable, { name: 'TxnSafetyNoTxn' });
|
|
155
|
+
expect(seen.length).toBe(0);
|
|
156
|
+
}, 30000);
|
|
157
|
+
});
|
package/test/index.ts
CHANGED