@proteinjs/db-driver-spanner 1.18.0 → 1.20.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 +34 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/SpannerDriver.d.ts +52 -3
- package/dist/src/SpannerDriver.d.ts.map +1 -1
- package/dist/src/SpannerDriver.js +141 -33
- package/dist/src/SpannerDriver.js.map +1 -1
- package/dist/src/SpannerSchemaOperations.d.ts +9 -1
- package/dist/src/SpannerSchemaOperations.d.ts.map +1 -1
- package/dist/src/SpannerSchemaOperations.js +88 -124
- package/dist/src/SpannerSchemaOperations.js.map +1 -1
- package/dist/test/BatchedDdl.test.d.ts +2 -0
- package/dist/test/BatchedDdl.test.d.ts.map +1 -0
- package/dist/test/BatchedDdl.test.js +496 -0
- package/dist/test/BatchedDdl.test.js.map +1 -0
- package/dist/test/DmlRetrySafety.test.d.ts +2 -0
- package/dist/test/DmlRetrySafety.test.d.ts.map +1 -0
- package/dist/test/DmlRetrySafety.test.js +472 -0
- package/dist/test/DmlRetrySafety.test.js.map +1 -0
- package/dist/test/RecordIterator.test.d.ts +2 -0
- package/dist/test/RecordIterator.test.d.ts.map +1 -0
- package/dist/test/RecordIterator.test.js +14 -0
- package/dist/test/RecordIterator.test.js.map +1 -0
- package/dist/test/RunPendingMigrations.test.d.ts +2 -0
- package/dist/test/RunPendingMigrations.test.d.ts.map +1 -0
- package/dist/test/RunPendingMigrations.test.js +354 -0
- package/dist/test/RunPendingMigrations.test.js.map +1 -0
- package/dist/test/SpannerOperationDeadline.test.js +1 -1
- package/dist/test/SpannerOperationDeadline.test.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +6 -6
- package/src/SpannerDriver.ts +110 -18
- package/src/SpannerSchemaOperations.ts +69 -55
- package/test/BatchedDdl.test.ts +293 -0
- package/test/DmlRetrySafety.test.ts +305 -0
- package/test/RecordIterator.test.ts +16 -0
- package/test/RunPendingMigrations.test.ts +201 -0
- package/test/SpannerOperationDeadline.test.ts +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Database, Spanner } from '@google-cloud/spanner';
|
|
2
|
+
import { Logger } from '@proteinjs/logger';
|
|
3
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
4
|
+
import { getTables, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
5
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
6
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
7
|
+
import '../generated/test/index';
|
|
8
|
+
|
|
9
|
+
const spannerConfig = {
|
|
10
|
+
projectId: 'proteinjs-test',
|
|
11
|
+
instanceName: 'proteinjs-test',
|
|
12
|
+
databaseName: 'test',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const spannerDriver = new SpannerDriver(spannerConfig);
|
|
16
|
+
|
|
17
|
+
interface BatchDdlParent extends Record {
|
|
18
|
+
name?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface BatchDdlChild extends Record {
|
|
22
|
+
label?: string;
|
|
23
|
+
parentId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parent with a declared index — its create is CREATE TABLE + CREATE INDEX. */
|
|
27
|
+
const parentTable = (): Table<BatchDdlParent> => {
|
|
28
|
+
return new (class extends Table<BatchDdlParent> {
|
|
29
|
+
name = 'db_test_batchddl_parent';
|
|
30
|
+
columns = withRecordColumns<BatchDdlParent>({
|
|
31
|
+
name: new StringColumn('name'),
|
|
32
|
+
});
|
|
33
|
+
indexes = [{ name: 'db_test_batchddl_parent_name_index', columns: ['name'] as (keyof BatchDdlParent)[] }];
|
|
34
|
+
})();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Child whose FK references the parent — creation order across the two tables is load-bearing. */
|
|
38
|
+
const childTable = (): Table<BatchDdlChild> => {
|
|
39
|
+
return new (class extends Table<BatchDdlChild> {
|
|
40
|
+
name = 'db_test_batchddl_child';
|
|
41
|
+
columns = withRecordColumns<BatchDdlChild>({
|
|
42
|
+
label: new StringColumn('label'),
|
|
43
|
+
parentId: new StringColumn('parent_id', { references: { table: 'db_test_batchddl_parent' } }),
|
|
44
|
+
});
|
|
45
|
+
indexes = [{ name: 'db_test_batchddl_child_label_index', columns: ['label'] as (keyof BatchDdlChild)[] }];
|
|
46
|
+
})();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Parent redeclared with an extra column + extra index — the alter pass under test. */
|
|
50
|
+
const grownParentTable = (): Table<BatchDdlParent & { nickname?: string }> => {
|
|
51
|
+
return new (class extends Table<BatchDdlParent & { nickname?: string }> {
|
|
52
|
+
name = 'db_test_batchddl_parent';
|
|
53
|
+
columns = withRecordColumns<BatchDdlParent & { nickname?: string }>({
|
|
54
|
+
name: new StringColumn('name'),
|
|
55
|
+
nickname: new StringColumn('nickname'),
|
|
56
|
+
});
|
|
57
|
+
indexes = [
|
|
58
|
+
{ name: 'db_test_batchddl_parent_name_index', columns: ['name'] as (keyof BatchDdlParent)[] },
|
|
59
|
+
{ name: 'db_test_batchddl_parent_nickname_index', columns: ['nickname'] as any },
|
|
60
|
+
];
|
|
61
|
+
})();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('Batched DDL', () => {
|
|
65
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
66
|
+
const tableManager = spannerDriver.getTableManager();
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
|
|
70
|
+
await dropTable(childTable());
|
|
71
|
+
await dropTable(parentTable());
|
|
72
|
+
}, 60000);
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await dropTable(childTable());
|
|
76
|
+
await dropTable(parentTable());
|
|
77
|
+
SpannerEmulatorProvisioner.release();
|
|
78
|
+
}, 60000);
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
jest.restoreAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a new table lands via one runUpdateSchema call (CREATE TABLE + its indexes in one batch)', async () => {
|
|
85
|
+
const parent = parentTable();
|
|
86
|
+
const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
|
|
87
|
+
|
|
88
|
+
await tableManager.loadTable(parent);
|
|
89
|
+
|
|
90
|
+
// Call-shape at the driver seam: the table's whole schema rides ONE schema-update operation.
|
|
91
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
92
|
+
|
|
93
|
+
// Outcome: the schema actually landed (INFORMATION_SCHEMA-backed metadata).
|
|
94
|
+
expect(await tableManager.tableExists(parent)).toBe(true);
|
|
95
|
+
const indexes = await tableManager.schemaMetadata.getIndexes(parent);
|
|
96
|
+
expect(indexes['db_test_batchddl_parent_name_index']).toEqual(['name']);
|
|
97
|
+
|
|
98
|
+
await dropTable(parent);
|
|
99
|
+
}, 60000);
|
|
100
|
+
|
|
101
|
+
test('an alter pass (add column + add index) lands via one runUpdateSchema call', async () => {
|
|
102
|
+
await tableManager.loadTable(parentTable());
|
|
103
|
+
const grown = grownParentTable();
|
|
104
|
+
const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
|
|
105
|
+
|
|
106
|
+
await tableManager.loadTable(grown);
|
|
107
|
+
|
|
108
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
109
|
+
|
|
110
|
+
const columnMetadata = await tableManager.schemaMetadata.getColumnMetadata(grown);
|
|
111
|
+
expect(columnMetadata['nickname']).toBeDefined();
|
|
112
|
+
const indexes = await tableManager.schemaMetadata.getIndexes(grown);
|
|
113
|
+
expect(indexes['db_test_batchddl_parent_nickname_index']).toEqual(['nickname']);
|
|
114
|
+
|
|
115
|
+
await dropTable(grown);
|
|
116
|
+
}, 60000);
|
|
117
|
+
|
|
118
|
+
test('parent and FK child land in one ordered batch, and the FK is live', async () => {
|
|
119
|
+
const parent = parentTable();
|
|
120
|
+
const child = childTable();
|
|
121
|
+
const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
|
|
122
|
+
|
|
123
|
+
// The one-LRO claim is pinned at the CLIENT seam, not just the driver seam: a
|
|
124
|
+
// runUpdateSchema that quietly looped per-statement operations would still count 1 on the
|
|
125
|
+
// driver spy but N here.
|
|
126
|
+
const updateSchemaSpy = jest.spyOn(Database.prototype, 'updateSchema');
|
|
127
|
+
|
|
128
|
+
await tableManager.schemaOperations.createTables([parent, child]);
|
|
129
|
+
|
|
130
|
+
// One schema-update operation carrying the whole set: 2 CREATE TABLE + 2 CREATE INDEX,
|
|
131
|
+
// parent's CREATE before the child's (the child's inline FK resolves against it in-batch).
|
|
132
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(updateSchemaSpy).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(updateSchemaSpy.mock.calls[0][0]).toHaveLength(4);
|
|
135
|
+
const statements = spy.mock.calls[0][0] as string[];
|
|
136
|
+
expect(Array.isArray(statements)).toBe(true);
|
|
137
|
+
expect(statements).toHaveLength(4);
|
|
138
|
+
const parentCreateIndex = statements.findIndex((sql) => sql.includes('CREATE TABLE `db_test_batchddl_parent`'));
|
|
139
|
+
const childCreateIndex = statements.findIndex((sql) => sql.includes('CREATE TABLE `db_test_batchddl_child`'));
|
|
140
|
+
expect(parentCreateIndex).toBeGreaterThanOrEqual(0);
|
|
141
|
+
expect(childCreateIndex).toBeGreaterThan(parentCreateIndex);
|
|
142
|
+
|
|
143
|
+
// Outcome: both tables live, the child's foreign key and index actually exist.
|
|
144
|
+
expect(await tableManager.tableExists(parent)).toBe(true);
|
|
145
|
+
expect(await tableManager.tableExists(child)).toBe(true);
|
|
146
|
+
const foreignKeys = await tableManager.schemaMetadata.getForeignKeys(child);
|
|
147
|
+
expect(foreignKeys['parent_id']).toEqual({
|
|
148
|
+
referencedTableName: 'db_test_batchddl_parent',
|
|
149
|
+
referencedColumnName: 'id',
|
|
150
|
+
});
|
|
151
|
+
const childIndexes = await tableManager.schemaMetadata.getIndexes(child);
|
|
152
|
+
expect(childIndexes['db_test_batchddl_child_label_index']).toEqual(['label']);
|
|
153
|
+
|
|
154
|
+
await dropTable(child);
|
|
155
|
+
await dropTable(parent);
|
|
156
|
+
}, 60000);
|
|
157
|
+
|
|
158
|
+
test('a wrong-ordered batch (FK child before parent) is REJECTED by the backend — ordering is load-bearing', async () => {
|
|
159
|
+
const parent = parentTable();
|
|
160
|
+
const child = childTable();
|
|
161
|
+
// House-style access to the statement assembler: typed cast on the instance, not a public method.
|
|
162
|
+
const ops = tableManager.schemaOperations as unknown as { createTableStatements(table: Table<any>): string[] };
|
|
163
|
+
const childFirst = [...ops.createTableStatements(child), ...ops.createTableStatements(parent)];
|
|
164
|
+
|
|
165
|
+
// The child's CREATE TABLE carries an inline FK to a parent that is not yet in the
|
|
166
|
+
// projected schema at its position in the batch — validation rejects the batch and NOTHING
|
|
167
|
+
// applies. This is the failure our ordered assembly exists to prevent.
|
|
168
|
+
await expect(spannerDriver.runUpdateSchema(childFirst)).rejects.toThrow();
|
|
169
|
+
expect(await tableManager.tableExists(child)).toBe(false);
|
|
170
|
+
expect(await tableManager.tableExists(parent)).toBe(false);
|
|
171
|
+
}, 60000);
|
|
172
|
+
|
|
173
|
+
test('a schema-invalid statement mid-batch rejects the WHOLE batch upfront — nothing applied (validation phase)', async () => {
|
|
174
|
+
const parent = parentTable();
|
|
175
|
+
const ops = tableManager.schemaOperations as unknown as { createTableStatements(table: Table<any>): string[] };
|
|
176
|
+
const [createParentSql, createNameIndexSql] = ops.createTableStatements(parent);
|
|
177
|
+
// Statement 2 parses but is schema-invalid (index on a column the table does not have).
|
|
178
|
+
// Schema-shape errors are caught in the batch's upfront VALIDATION pass — run in order
|
|
179
|
+
// against the projected schema (the error names the missing column, so statement 1's table
|
|
180
|
+
// WAS in the validation context) — and reject the batch before anything applies. Strictly
|
|
181
|
+
// SAFER than the old serial path, which would have left statement 1's table behind.
|
|
182
|
+
const badIndexSql = 'CREATE INDEX db_test_batchddl_parent_bogus_index ON db_test_batchddl_parent(no_such_column)';
|
|
183
|
+
|
|
184
|
+
await expect(spannerDriver.runUpdateSchema([createParentSql, badIndexSql, createNameIndexSql])).rejects.toThrow(
|
|
185
|
+
/no_such_column/
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(await tableManager.tableExists(parent)).toBe(false);
|
|
189
|
+
}, 60000);
|
|
190
|
+
|
|
191
|
+
test('a data-dependent failure mid-batch leaves EARLIER statements applied, LATER unapplied (apply phase)', async () => {
|
|
192
|
+
const parent = parentTable();
|
|
193
|
+
await tableManager.loadTable(parent);
|
|
194
|
+
// Two rows with the same `name`: schema validation cannot see this — only the APPLY phase
|
|
195
|
+
// (index backfill) can fail on it.
|
|
196
|
+
const client = new Spanner({ projectId: spannerConfig.projectId });
|
|
197
|
+
const database = client.instance(spannerConfig.instanceName).database(spannerConfig.databaseName);
|
|
198
|
+
database.on('error', () => undefined);
|
|
199
|
+
try {
|
|
200
|
+
await database.table(parent.name).insert([
|
|
201
|
+
{ id: 'dup-1', name: 'dup', created: new Date(), updated: new Date() },
|
|
202
|
+
{ id: 'dup-2', name: 'dup', created: new Date(), updated: new Date() },
|
|
203
|
+
]);
|
|
204
|
+
} finally {
|
|
205
|
+
await database.close().catch(() => undefined);
|
|
206
|
+
client.close();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const statements = [
|
|
210
|
+
'CREATE INDEX db_test_batchddl_parent_pre_index ON db_test_batchddl_parent(name, id)',
|
|
211
|
+
'CREATE UNIQUE INDEX db_test_batchddl_parent_dup_unique ON db_test_batchddl_parent(name)',
|
|
212
|
+
'CREATE INDEX db_test_batchddl_parent_post_index ON db_test_batchddl_parent(id, name)',
|
|
213
|
+
];
|
|
214
|
+
const logErrorSpy = jest.spyOn(Logger.prototype, 'error');
|
|
215
|
+
await expect(spannerDriver.runUpdateSchema(statements)).rejects.toThrow(/uniqueness violation/);
|
|
216
|
+
|
|
217
|
+
// The failure LOG must carry the backend's reason too. Apply-phase LRO errors put it in
|
|
218
|
+
// `error.message` and leave `error.details` UNDEFINED — logging details alone records an
|
|
219
|
+
// empty reason for exactly the failure class that leaves partial schema state behind.
|
|
220
|
+
const failureLog = logErrorSpy.mock.calls.find(
|
|
221
|
+
([entry]) => entry.message === 'Failed when executing schema update'
|
|
222
|
+
);
|
|
223
|
+
expect(failureLog).toBeDefined();
|
|
224
|
+
expect(String((failureLog![0].obj as { errorDetails?: unknown }).errorDetails)).toMatch(/uniqueness violation/);
|
|
225
|
+
|
|
226
|
+
// Honest partial-failure semantics of the apply phase: NOT atomic. Statement 1 stays
|
|
227
|
+
// applied; statement 3, ordered after the failure, is cancelled.
|
|
228
|
+
const indexes = await tableManager.schemaMetadata.getIndexes(parent);
|
|
229
|
+
expect(indexes['db_test_batchddl_parent_pre_index']).toEqual(['name', 'id']);
|
|
230
|
+
expect(indexes['db_test_batchddl_parent_dup_unique']).toBeUndefined();
|
|
231
|
+
expect(indexes['db_test_batchddl_parent_post_index']).toBeUndefined();
|
|
232
|
+
|
|
233
|
+
await dropTable(parent);
|
|
234
|
+
}, 60000);
|
|
235
|
+
|
|
236
|
+
test('loadTables creates the whole absent set via one runUpdateSchema call; a second pass issues none', async () => {
|
|
237
|
+
const registeredTables = getTables();
|
|
238
|
+
// This package registers the migration table (@proteinjs/db) and the service-verbs doc table.
|
|
239
|
+
expect(registeredTables.length).toBeGreaterThanOrEqual(2);
|
|
240
|
+
for (const table of [...registeredTables].reverse()) {
|
|
241
|
+
await dropTable(table);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
|
|
245
|
+
await tableManager.loadTables();
|
|
246
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
247
|
+
for (const table of registeredTables) {
|
|
248
|
+
expect(await tableManager.tableExists(table)).toBe(true);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Reconcile pass on an up-to-date schema issues zero DDL.
|
|
252
|
+
spy.mockClear();
|
|
253
|
+
await tableManager.loadTables();
|
|
254
|
+
expect(spy).not.toHaveBeenCalled();
|
|
255
|
+
}, 60000);
|
|
256
|
+
|
|
257
|
+
test('createDb with ddl births a queryable database; dropDb removes it', async () => {
|
|
258
|
+
const databaseName = 'batchddl-born';
|
|
259
|
+
if (await spannerDriver.dbExists(databaseName)) {
|
|
260
|
+
await spannerDriver.dropDb(databaseName);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await spannerDriver.createDb(databaseName, {
|
|
264
|
+
ddl: [
|
|
265
|
+
'CREATE TABLE born_row (id STRING(36) NOT NULL, label STRING(MAX)) PRIMARY KEY (id)',
|
|
266
|
+
'CREATE INDEX born_row_label_index ON born_row(label)',
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
expect(await spannerDriver.dbExists(databaseName)).toBe(true);
|
|
270
|
+
|
|
271
|
+
// Queryability check rides a dedicated client: SpannerDriver's process-wide Database handle
|
|
272
|
+
// is pinned to the suite's database, and this test is about the NEW database.
|
|
273
|
+
const client = new Spanner({ projectId: spannerConfig.projectId });
|
|
274
|
+
const database = client.instance(spannerConfig.instanceName).database(databaseName);
|
|
275
|
+
database.on('error', () => undefined);
|
|
276
|
+
try {
|
|
277
|
+
await database.table('born_row').insert({ id: 'r1', label: 'born' });
|
|
278
|
+
const [rows] = await database.run({ sql: 'SELECT id, label FROM born_row', json: true });
|
|
279
|
+
expect(rows).toEqual([{ id: 'r1', label: 'born' }]);
|
|
280
|
+
const [indexRows] = await database.run({
|
|
281
|
+
sql: `SELECT i.INDEX_NAME FROM INFORMATION_SCHEMA.INDEXES i WHERE i.TABLE_NAME = 'born_row' AND i.INDEX_NAME = 'born_row_label_index'`,
|
|
282
|
+
json: true,
|
|
283
|
+
});
|
|
284
|
+
expect(indexRows).toHaveLength(1);
|
|
285
|
+
} finally {
|
|
286
|
+
await database.close().catch(() => undefined);
|
|
287
|
+
client.close();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await spannerDriver.dropDb(databaseName);
|
|
291
|
+
expect(await spannerDriver.dbExists(databaseName)).toBe(false);
|
|
292
|
+
}, 60000);
|
|
293
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { PassThrough } from 'stream';
|
|
2
|
+
import { Db, Record, StringColumn, Table, tableByName, withRecordColumns } from '@proteinjs/db';
|
|
3
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
4
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
5
|
+
import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
|
|
6
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
7
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
8
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
9
|
+
import '../generated/test/index';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DML retry safety (the 2026-08-13 splice incident class): a DML request whose response is LOST
|
|
13
|
+
* in transport must NEVER be transparently re-sent by the client. On the pre-fix streaming DML
|
|
14
|
+
* path (`runUpdate` → ExecuteStreamingSql), gax wraps every attempt in retry-request, which
|
|
15
|
+
* silently replays on ANY pre-response error. Seqno replay protection only covers the
|
|
16
|
+
* same-transaction geometry; the replay geometries it cannot cover are where the incident lived:
|
|
17
|
+
* an inline-begin replay BEGINS A FRESH TRANSACTION per attempt (abandoned applied-but-
|
|
18
|
+
* uncommitted siblings churn locks and collide with rows committed by other paths — the
|
|
19
|
+
* spurious `6 ALREADY_EXISTS` under pool pressure), and the stream-resumption layer re-mints a
|
|
20
|
+
* NEW seqno into the SAME transaction after inline-begin learned its id (unprotected even on
|
|
21
|
+
* real Spanner).
|
|
22
|
+
*
|
|
23
|
+
* These tests drive the retry deterministically by injecting loss at the TRANSPORT SEAM — the
|
|
24
|
+
* resolved gRPC stub method, which the gapic layer looks up per attempt
|
|
25
|
+
* (`stub[methodName].apply`), i.e. the exact function every transparent retry re-invokes.
|
|
26
|
+
*
|
|
27
|
+
* Contract under test (the fix): DML rides the unary ExecuteBatchDml RPC, and under the emulator
|
|
28
|
+
* the call is single-attempt (`retry: null`) — a loss surfaces as the loss itself, after at most
|
|
29
|
+
* ONE wire execution.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
interface RetryAnchor extends Record {
|
|
33
|
+
name: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface RetryTarget extends Record {
|
|
37
|
+
name: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class RetryAnchorTestTable extends Table<RetryAnchor> {
|
|
41
|
+
name = 'db_test_dml_retry_anchor';
|
|
42
|
+
columns = withRecordColumns<RetryAnchor>({
|
|
43
|
+
name: new StringColumn('name'),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class RetryTargetTestTable extends Table<RetryTarget> {
|
|
48
|
+
name = 'db_test_dml_retry_target';
|
|
49
|
+
columns = withRecordColumns<RetryTarget>({
|
|
50
|
+
name: new StringColumn('name'),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const anchorTable: Table<RetryAnchor> = new RetryAnchorTestTable();
|
|
55
|
+
const targetTable: Table<RetryTarget> = new RetryTargetTestTable();
|
|
56
|
+
// Local tables — not in any reflection source graph, so thread getTable explicitly (the
|
|
57
|
+
// TransactionSafety pattern); other lookups resolve normally.
|
|
58
|
+
const getTable = (tableName: string) => {
|
|
59
|
+
if (tableName === anchorTable.name) {
|
|
60
|
+
return anchorTable;
|
|
61
|
+
}
|
|
62
|
+
if (tableName === targetTable.name) {
|
|
63
|
+
return targetTable;
|
|
64
|
+
}
|
|
65
|
+
return tableByName(tableName);
|
|
66
|
+
};
|
|
67
|
+
const spannerConfig = {
|
|
68
|
+
projectId: 'proteinjs-test',
|
|
69
|
+
instanceName: 'proteinjs-test',
|
|
70
|
+
databaseName: 'test',
|
|
71
|
+
};
|
|
72
|
+
const spannerDriver = new SpannerDriver(spannerConfig, getTable);
|
|
73
|
+
|
|
74
|
+
const INJECTED_LOSS = 'injected response loss';
|
|
75
|
+
const targetInsertSqlMarker = `INSERT INTO \`db_test_dml_retry_target\``;
|
|
76
|
+
const isTargetInsertSql = (sql: string) => sql.includes(targetInsertSqlMarker);
|
|
77
|
+
|
|
78
|
+
type StubPatch = { attempts: () => number; restore: () => void };
|
|
79
|
+
/**
|
|
80
|
+
* 'applied-then-lost': the first attempt reaches the emulator (the statement EXECUTES
|
|
81
|
+
* server-side) but its response is dropped — the incident's loss shape. 'lost-in-flight': the
|
|
82
|
+
* first attempt never reaches the emulator — a transparent replay makes the op silently
|
|
83
|
+
* SUCCEED behind a "failed" attempt (the smoking gun that a replay layer exists).
|
|
84
|
+
*/
|
|
85
|
+
type LossMode = 'applied-then-lost' | 'lost-in-flight';
|
|
86
|
+
|
|
87
|
+
/** The resolved gRPC stub the gapic layer re-invokes per attempt — the transport seam. */
|
|
88
|
+
const getSpannerStub = async (): Promise<any> => {
|
|
89
|
+
const spanner = (SpannerDriver as unknown as { SPANNER?: any }).SPANNER;
|
|
90
|
+
if (!spanner) {
|
|
91
|
+
throw new Error('SpannerDriver.SPANNER not initialized — run an op first');
|
|
92
|
+
}
|
|
93
|
+
const gapicClient = spanner.clients_.get('SpannerClient');
|
|
94
|
+
if (!gapicClient) {
|
|
95
|
+
throw new Error('SpannerClient gapic client not created — run a data op first');
|
|
96
|
+
}
|
|
97
|
+
return await gapicClient.spannerStub;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const makeLossError = (): Error => {
|
|
101
|
+
const error: any = new Error(INJECTED_LOSS);
|
|
102
|
+
error.code = 14; // UNAVAILABLE — the shape of a dropped connection / lost response
|
|
103
|
+
return error;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Inject loss on the FIRST matching ExecuteStreamingSql attempt; later attempts pass through. */
|
|
107
|
+
const patchStreamingLoss = (stub: any, isTargetSql: (sql: string) => boolean, mode: LossMode): StubPatch => {
|
|
108
|
+
const original = stub.executeStreamingSql;
|
|
109
|
+
let attempts = 0;
|
|
110
|
+
stub.executeStreamingSql = function (this: any, ...args: any[]) {
|
|
111
|
+
const request = args[0];
|
|
112
|
+
if (typeof request?.sql !== 'string' || !isTargetSql(request.sql)) {
|
|
113
|
+
return original.apply(this, args);
|
|
114
|
+
}
|
|
115
|
+
attempts += 1;
|
|
116
|
+
if (attempts > 1) {
|
|
117
|
+
return original.apply(this, args);
|
|
118
|
+
}
|
|
119
|
+
const wrapper: any = new PassThrough({ objectMode: true });
|
|
120
|
+
let injected = false;
|
|
121
|
+
const inject = () => {
|
|
122
|
+
if (injected) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
injected = true;
|
|
126
|
+
wrapper.emit('error', makeLossError());
|
|
127
|
+
};
|
|
128
|
+
if (mode === 'lost-in-flight') {
|
|
129
|
+
wrapper.cancel = () => undefined;
|
|
130
|
+
setImmediate(inject);
|
|
131
|
+
return wrapper;
|
|
132
|
+
}
|
|
133
|
+
const real = original.apply(this, args);
|
|
134
|
+
wrapper.cancel = () => real.cancel?.();
|
|
135
|
+
real.on('data', () => undefined); // drain — the response is consumed and dropped
|
|
136
|
+
real.on('metadata', () => undefined);
|
|
137
|
+
real.on('error', inject);
|
|
138
|
+
real.on('status', inject);
|
|
139
|
+
real.on('end', inject);
|
|
140
|
+
return wrapper;
|
|
141
|
+
};
|
|
142
|
+
return { attempts: () => attempts, restore: () => (stub.executeStreamingSql = original) };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Inject loss on the FIRST matching ExecuteBatchDml attempt; later attempts pass through. */
|
|
146
|
+
const patchUnaryLoss = (stub: any, isTargetSql: (sql: string) => boolean, mode: LossMode): StubPatch => {
|
|
147
|
+
const original = stub.executeBatchDml;
|
|
148
|
+
let attempts = 0;
|
|
149
|
+
stub.executeBatchDml = function (this: any, ...args: any[]) {
|
|
150
|
+
const request = args[0];
|
|
151
|
+
const statements: any[] = request?.statements ?? [];
|
|
152
|
+
if (!statements.some((statement) => typeof statement?.sql === 'string' && isTargetSql(statement.sql))) {
|
|
153
|
+
return original.apply(this, args);
|
|
154
|
+
}
|
|
155
|
+
attempts += 1;
|
|
156
|
+
if (attempts > 1) {
|
|
157
|
+
return original.apply(this, args);
|
|
158
|
+
}
|
|
159
|
+
const callback = args[args.length - 1];
|
|
160
|
+
if (typeof callback !== 'function') {
|
|
161
|
+
throw new Error('expected trailing callback on unary stub call');
|
|
162
|
+
}
|
|
163
|
+
if (mode === 'lost-in-flight') {
|
|
164
|
+
setImmediate(() => callback(makeLossError()));
|
|
165
|
+
return { cancel: () => undefined };
|
|
166
|
+
}
|
|
167
|
+
const lossyCallback = () => callback(makeLossError());
|
|
168
|
+
return original.apply(this, [...args.slice(0, -1), lossyCallback]);
|
|
169
|
+
};
|
|
170
|
+
return { attempts: () => attempts, restore: () => (stub.executeBatchDml = original) };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Pass-through counter for DML on the streaming transport — must stay ZERO post-fix. */
|
|
174
|
+
const patchStreamingDmlCounter = (stub: any, isTargetSql: (sql: string) => boolean): StubPatch => {
|
|
175
|
+
const original = stub.executeStreamingSql;
|
|
176
|
+
let attempts = 0;
|
|
177
|
+
stub.executeStreamingSql = function (this: any, ...args: any[]) {
|
|
178
|
+
if (typeof args[0]?.sql === 'string' && isTargetSql(args[0].sql)) {
|
|
179
|
+
attempts += 1;
|
|
180
|
+
}
|
|
181
|
+
return original.apply(this, args);
|
|
182
|
+
};
|
|
183
|
+
return { attempts: () => attempts, restore: () => (stub.executeStreamingSql = original) };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
describe('DML retry safety (a lost response must never re-execute a statement)', () => {
|
|
187
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
188
|
+
const db = new Db(spannerDriver, getTable, new TransactionContext());
|
|
189
|
+
|
|
190
|
+
beforeAll(async () => {
|
|
191
|
+
registerTestUser();
|
|
192
|
+
// HERMETIC two-table world (the TransactionSafety pattern): the delete path's
|
|
193
|
+
// reverse-cascade scan walks getTables(); scope the registry to the local tables.
|
|
194
|
+
(SourceRepository.get() as unknown as { objectCache: { [key: string]: unknown[] } }).objectCache[
|
|
195
|
+
'@proteinjs/db/Table'
|
|
196
|
+
] = [anchorTable, targetTable];
|
|
197
|
+
await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
|
|
198
|
+
await spannerDriver.createDbIfNotExists();
|
|
199
|
+
await spannerDriver.getTableManager().loadTable(anchorTable);
|
|
200
|
+
await spannerDriver.getTableManager().loadTable(targetTable);
|
|
201
|
+
// Warm the data client so the gapic SpannerClient + resolved stub exist to patch.
|
|
202
|
+
await db.query(targetTable, { name: 'warmup' });
|
|
203
|
+
}, 60000);
|
|
204
|
+
|
|
205
|
+
afterAll(async () => {
|
|
206
|
+
await dropTable(targetTable);
|
|
207
|
+
await dropTable(anchorTable);
|
|
208
|
+
await SpannerEmulatorProvisioner.release();
|
|
209
|
+
delete (SourceRepository.get() as unknown as { objectCache: { [key: string]: unknown[] } }).objectCache[
|
|
210
|
+
'@proteinjs/db/Table'
|
|
211
|
+
];
|
|
212
|
+
clearTestUser();
|
|
213
|
+
}, 60000);
|
|
214
|
+
|
|
215
|
+
afterEach(async () => {
|
|
216
|
+
// Tests assert their own outcomes; this just keeps rows from leaking across tests.
|
|
217
|
+
for (const table of [targetTable, anchorTable]) {
|
|
218
|
+
const leftovers = await db.query(table as Table<any>, {});
|
|
219
|
+
for (const row of leftovers) {
|
|
220
|
+
await db.delete(table as Table<any>, { id: row.id } as any);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}, 30000);
|
|
224
|
+
|
|
225
|
+
test('in-transaction DML, response lost after the emulator applied it: the loss surfaces (no self-collision), one wire execution, nothing commits', async () => {
|
|
226
|
+
const stub = await getSpannerStub();
|
|
227
|
+
const streamingPatch = patchStreamingLoss(stub, isTargetInsertSql, 'applied-then-lost');
|
|
228
|
+
const unaryPatch = patchUnaryLoss(stub, isTargetInsertSql, 'applied-then-lost');
|
|
229
|
+
let outcome: unknown;
|
|
230
|
+
try {
|
|
231
|
+
outcome = await db
|
|
232
|
+
.runTransaction(async () => {
|
|
233
|
+
// Statement 1 establishes the transaction id; statement 2 is the target DML whose
|
|
234
|
+
// response is lost AFTER the emulator applied it. Pre-fix, retry-request transparently
|
|
235
|
+
// replayed it (2 wire attempts; the op only survived because same-txn same-seqno
|
|
236
|
+
// replays happen to be absorbed by backend replay protection — the inline-begin
|
|
237
|
+
// geometry has no such shield). The contract: ONE wire execution, the LOSS surfaces.
|
|
238
|
+
await db.insert(anchorTable, { name: 'RetryAnchorA' });
|
|
239
|
+
await db.insert(targetTable, { name: 'RetryTargetA' });
|
|
240
|
+
})
|
|
241
|
+
.then(() => 'resolved' as const)
|
|
242
|
+
.catch((error: Error) => error);
|
|
243
|
+
} finally {
|
|
244
|
+
streamingPatch.restore();
|
|
245
|
+
unaryPatch.restore();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The surfaced failure is the injected loss — NOT a replay self-collision (6 ALREADY_EXISTS).
|
|
249
|
+
expect(outcome).toBeInstanceOf(Error);
|
|
250
|
+
expect(String((outcome as Error).message)).toContain(INJECTED_LOSS);
|
|
251
|
+
expect((outcome as any).code).not.toBe(6);
|
|
252
|
+
// Exactly one wire execution of the target DML across both transports.
|
|
253
|
+
expect(streamingPatch.attempts() + unaryPatch.attempts()).toBe(1);
|
|
254
|
+
// The failed transaction committed nothing.
|
|
255
|
+
expect(await db.query(targetTable, { name: 'RetryTargetA' })).toHaveLength(0);
|
|
256
|
+
expect(await db.query(anchorTable, { name: 'RetryAnchorA' })).toHaveLength(0);
|
|
257
|
+
}, 30000);
|
|
258
|
+
|
|
259
|
+
test('standalone DML (inline begin), request lost in flight: the loss surfaces, one wire attempt, no row materializes behind the failure', async () => {
|
|
260
|
+
const stub = await getSpannerStub();
|
|
261
|
+
const streamingPatch = patchStreamingLoss(stub, isTargetInsertSql, 'lost-in-flight');
|
|
262
|
+
const unaryPatch = patchUnaryLoss(stub, isTargetInsertSql, 'lost-in-flight');
|
|
263
|
+
let outcome: unknown;
|
|
264
|
+
try {
|
|
265
|
+
outcome = await db
|
|
266
|
+
.insert(targetTable, { name: 'RetryTargetB' })
|
|
267
|
+
.then(() => 'resolved' as const)
|
|
268
|
+
.catch((error: Error) => error);
|
|
269
|
+
} finally {
|
|
270
|
+
streamingPatch.restore();
|
|
271
|
+
unaryPatch.restore();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The op fails with the injected loss — it is NOT silently healed by a transparent replay
|
|
275
|
+
// (a replay resolves the op and materializes a row behind a "failed" attempt).
|
|
276
|
+
expect(outcome).toBeInstanceOf(Error);
|
|
277
|
+
expect(String((outcome as Error).message)).toContain(INJECTED_LOSS);
|
|
278
|
+
expect(streamingPatch.attempts() + unaryPatch.attempts()).toBe(1);
|
|
279
|
+
expect(await db.query(targetTable, { name: 'RetryTargetB' })).toHaveLength(0);
|
|
280
|
+
}, 30000);
|
|
281
|
+
|
|
282
|
+
test('happy path: DML rides the unary RPC (streaming carries none), commits exactly once with faithful row counts', async () => {
|
|
283
|
+
const stub = await getSpannerStub();
|
|
284
|
+
const isTargetDmlSql = (sql: string) =>
|
|
285
|
+
sql.includes('`db_test_dml_retry_target`') && !sql.trim().startsWith('SELECT');
|
|
286
|
+
const streamingCounter = patchStreamingDmlCounter(stub, isTargetDmlSql);
|
|
287
|
+
try {
|
|
288
|
+
const inserted = await db.runTransaction(async () => {
|
|
289
|
+
await db.insert(anchorTable, { name: 'RetryAnchorC' });
|
|
290
|
+
return await db.insert(targetTable, { name: 'RetryTargetC' });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const committed = await db.query(targetTable, { name: 'RetryTargetC' });
|
|
294
|
+
expect(committed).toHaveLength(1);
|
|
295
|
+
// Row counts flow faithfully through the unary path.
|
|
296
|
+
expect(await db.update(targetTable, { name: 'RetryTargetC-updated' }, { id: inserted.id })).toBe(1);
|
|
297
|
+
expect(await db.delete(targetTable, { id: inserted.id })).toBe(1);
|
|
298
|
+
// No DML (insert/update/delete) ever touched the streaming transport, whose retry
|
|
299
|
+
// layers transparently replay lost responses.
|
|
300
|
+
expect(streamingCounter.attempts()).toBe(0);
|
|
301
|
+
} finally {
|
|
302
|
+
streamingCounter.restore();
|
|
303
|
+
}
|
|
304
|
+
}, 30000);
|
|
305
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { recordIteratorTests } from '@proteinjs/db/test';
|
|
2
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
3
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
4
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
5
|
+
import '../generated/test/index';
|
|
6
|
+
|
|
7
|
+
const spannerDriver = new SpannerDriver({
|
|
8
|
+
projectId: 'proteinjs-test',
|
|
9
|
+
instanceName: 'proteinjs-test',
|
|
10
|
+
databaseName: 'test',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe(
|
|
14
|
+
'RecordIterator cursor-window tests',
|
|
15
|
+
recordIteratorTests(spannerDriver, new TransactionContext(), getDropTestTable(spannerDriver))
|
|
16
|
+
);
|