@proteinjs/db-driver-spanner 1.20.0 → 1.20.2
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 +22 -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/SpannerSchemaOperations.d.ts +23 -0
- package/dist/src/SpannerSchemaOperations.d.ts.map +1 -1
- package/dist/src/SpannerSchemaOperations.js +33 -0
- package/dist/src/SpannerSchemaOperations.js.map +1 -1
- package/dist/test/ConcurrentSchemaReconcile.test.d.ts +2 -0
- package/dist/test/ConcurrentSchemaReconcile.test.d.ts.map +1 -0
- package/dist/test/ConcurrentSchemaReconcile.test.js +381 -0
- package/dist/test/ConcurrentSchemaReconcile.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/package.json +5 -5
- package/src/SpannerSchemaOperations.ts +36 -0
- package/test/ConcurrentSchemaReconcile.test.ts +268 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { IntegerColumn, Record, StringColumn, Table, TableChanges, withRecordColumns } from '@proteinjs/db';
|
|
2
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
3
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
4
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
5
|
+
import '../generated/test/index';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The concurrent-schema-reconcile race (closed by TableManager.reconcileConcurrentSchemaChange +
|
|
9
|
+
* SpannerSchemaOperations.isAlreadyExistsError).
|
|
10
|
+
*
|
|
11
|
+
* On a schema-changing release the migration Job, booting pods, and multiple replicas all run
|
|
12
|
+
* Db.init -> loadTables at once. loadTable/loadTables is check-then-act: two actors both observe a
|
|
13
|
+
* column/table as absent and both issue the CREATE/ALTER; Spanner serializes the DDL so the object
|
|
14
|
+
* lands EXACTLY once, and the loser's operation fails with a duplicate-name / duplicate-column
|
|
15
|
+
* error. Before the fix the loser rethrew — a booting pod exited (CrashLoopBackOff) and the
|
|
16
|
+
* migration Job exited 1 (a spurious migration-gate failure).
|
|
17
|
+
*
|
|
18
|
+
* The tolerance is not a blanket swallow: it fires only for the already-exists error CLASS and only
|
|
19
|
+
* after RE-READING the live schema and confirming it matches the INTENDED definition.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const spannerConfig = {
|
|
23
|
+
projectId: 'proteinjs-test',
|
|
24
|
+
instanceName: 'proteinjs-test',
|
|
25
|
+
databaseName: 'test',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const spannerDriver = new SpannerDriver(spannerConfig);
|
|
29
|
+
|
|
30
|
+
interface ReconcileRow extends Record {
|
|
31
|
+
name?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface GrownRow extends ReconcileRow {
|
|
35
|
+
nickname?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Base table, `name` only — the pre-change schema. */
|
|
39
|
+
const baseTable = (): Table<ReconcileRow> =>
|
|
40
|
+
new (class extends Table<ReconcileRow> {
|
|
41
|
+
name = 'db_test_reconcile';
|
|
42
|
+
columns = withRecordColumns<ReconcileRow>({
|
|
43
|
+
name: new StringColumn('name'),
|
|
44
|
+
});
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
/** Same table redeclared with an added `nickname` column — the alter under contention. */
|
|
48
|
+
const grownTable = (): Table<GrownRow> =>
|
|
49
|
+
new (class extends Table<GrownRow> {
|
|
50
|
+
name = 'db_test_reconcile';
|
|
51
|
+
columns = withRecordColumns<GrownRow>({
|
|
52
|
+
name: new StringColumn('name'),
|
|
53
|
+
nickname: new StringColumn('nickname'),
|
|
54
|
+
});
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
interface ConflictRow extends Record {
|
|
58
|
+
qty?: any;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `qty` declared as a STRING — the definition the "winner" lands. */
|
|
62
|
+
const conflictStringTable = (): Table<ConflictRow> =>
|
|
63
|
+
new (class extends Table<ConflictRow> {
|
|
64
|
+
name = 'db_test_reconcile_conflict';
|
|
65
|
+
columns = withRecordColumns<ConflictRow>({
|
|
66
|
+
qty: new StringColumn('qty'),
|
|
67
|
+
});
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
/** Same table+column redeclared as INT64 — a GENUINE conflict with what landed. */
|
|
71
|
+
const conflictIntegerTable = (): Table<ConflictRow> =>
|
|
72
|
+
new (class extends Table<ConflictRow> {
|
|
73
|
+
name = 'db_test_reconcile_conflict';
|
|
74
|
+
columns = withRecordColumns<ConflictRow>({
|
|
75
|
+
qty: new IntegerColumn('qty'),
|
|
76
|
+
});
|
|
77
|
+
})();
|
|
78
|
+
|
|
79
|
+
interface TableManagerInternals {
|
|
80
|
+
getTableChanges(table: Table<any>): Promise<TableChanges>;
|
|
81
|
+
reconcileConcurrentSchemaChange(tables: Table<any>[], error: unknown): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type SpannerTableManager = ReturnType<SpannerDriver['getTableManager']>;
|
|
85
|
+
|
|
86
|
+
/** House-style access to reconcile internals: a typed cast on the instance, not a public method. */
|
|
87
|
+
const internals = (tableManager: SpannerTableManager) => tableManager as unknown as TableManagerInternals;
|
|
88
|
+
|
|
89
|
+
/** House-style access to the driver-specific classifier. */
|
|
90
|
+
const classifier = (tableManager: SpannerTableManager) =>
|
|
91
|
+
tableManager.schemaOperations as unknown as { isAlreadyExistsError(error: unknown): boolean };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Spy on THIS TableManager instance's own logger.warn. TableManager (@proteinjs/db) and this test
|
|
95
|
+
* (@proteinjs/db-driver-spanner) resolve separate physical @proteinjs/logger copies, so a
|
|
96
|
+
* Logger.prototype spy here would never intercept the reconcile's warns — spying the instance's
|
|
97
|
+
* method does, regardless of module identity.
|
|
98
|
+
*/
|
|
99
|
+
const spyOnReconcileWarn = (tableManager: SpannerTableManager) =>
|
|
100
|
+
jest.spyOn((tableManager as unknown as { logger: { warn: (log: { message?: unknown }) => void } }).logger, 'warn');
|
|
101
|
+
|
|
102
|
+
describe('Concurrent schema reconcile', () => {
|
|
103
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
104
|
+
const tableManager = spannerDriver.getTableManager();
|
|
105
|
+
|
|
106
|
+
beforeAll(async () => {
|
|
107
|
+
await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
|
|
108
|
+
}, 60000);
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
await dropTable(baseTable());
|
|
112
|
+
await dropTable(conflictStringTable());
|
|
113
|
+
}, 60000);
|
|
114
|
+
|
|
115
|
+
afterAll(async () => {
|
|
116
|
+
await dropTable(baseTable());
|
|
117
|
+
await dropTable(conflictStringTable());
|
|
118
|
+
SpannerEmulatorProvisioner.release();
|
|
119
|
+
}, 60000);
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
jest.restoreAllMocks();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('two actors adding the SAME new column both succeed; the column lands once with the intended type', async () => {
|
|
126
|
+
// Winner: create the base table, then add `nickname` for real.
|
|
127
|
+
await tableManager.loadTable(baseTable());
|
|
128
|
+
await tableManager.loadTable(grownTable());
|
|
129
|
+
|
|
130
|
+
// Loser: a second reconciler whose PLANNING read is forced stale (nickname still absent), so it
|
|
131
|
+
// issues the real `ALTER TABLE ... ADD COLUMN nickname` that the backend rejects as a
|
|
132
|
+
// duplicate. mockImplementationOnce affects ONLY the planning read; the reconcile's own
|
|
133
|
+
// verification re-read sees the true (post-winner) schema.
|
|
134
|
+
const loserTm = spannerDriver.getTableManager();
|
|
135
|
+
const realGetColumnMetadata = loserTm.schemaMetadata.getColumnMetadata.bind(loserTm.schemaMetadata);
|
|
136
|
+
jest.spyOn(loserTm.schemaMetadata, 'getColumnMetadata').mockImplementationOnce(async (table) => {
|
|
137
|
+
const columnMetadata = await realGetColumnMetadata(table);
|
|
138
|
+
delete columnMetadata['nickname'];
|
|
139
|
+
return columnMetadata;
|
|
140
|
+
});
|
|
141
|
+
const loserWarnSpy = spyOnReconcileWarn(loserTm);
|
|
142
|
+
|
|
143
|
+
// OUTCOME: the loser's Db.init-equivalent does NOT reject — the duplicate DDL error is
|
|
144
|
+
// reconciled to success. (Pre-fix, this rejects with "Duplicate column name" — the red run.)
|
|
145
|
+
await expect(loserTm.loadTable(grownTable())).resolves.toBeUndefined();
|
|
146
|
+
|
|
147
|
+
// OBSERVABILITY: the tolerance fired LOUDLY (WARN), naming the table so an unexpected
|
|
148
|
+
// activation is never silent in prod.
|
|
149
|
+
const toleratedWarn = loserWarnSpy.mock.calls.find(([entry]) =>
|
|
150
|
+
/\[schema reconcile\] tolerated concurrent ALREADY_EXISTS/.test(String(entry.message))
|
|
151
|
+
);
|
|
152
|
+
expect(toleratedWarn).toBeDefined();
|
|
153
|
+
expect(String(toleratedWarn![0].message)).toContain('db_test_reconcile');
|
|
154
|
+
|
|
155
|
+
// The column exists exactly once, with the intended type (StringColumn defaults to STRING(255)).
|
|
156
|
+
const columnMetadata = await tableManager.schemaMetadata.getColumnMetadata(grownTable());
|
|
157
|
+
expect(columnMetadata['nickname']).toBeDefined();
|
|
158
|
+
expect(columnMetadata['nickname'].type).toBe('STRING(255)');
|
|
159
|
+
|
|
160
|
+
// HAPPY PATH UNTOUCHED: a clean pass (DDL succeeds, nothing thrown) issues ZERO DDL and never
|
|
161
|
+
// reaches the reconcile path, so it emits no reconcile WARN.
|
|
162
|
+
const cleanWarnSpy = spyOnReconcileWarn(tableManager);
|
|
163
|
+
const runUpdateSchemaSpy = jest.spyOn(spannerDriver, 'runUpdateSchema');
|
|
164
|
+
await tableManager.loadTable(grownTable());
|
|
165
|
+
expect(runUpdateSchemaSpy).not.toHaveBeenCalled();
|
|
166
|
+
expect(
|
|
167
|
+
cleanWarnSpy.mock.calls.filter(([entry]) => /\[schema reconcile\]/.test(String(entry.message)))
|
|
168
|
+
).toHaveLength(0);
|
|
169
|
+
}, 60000);
|
|
170
|
+
|
|
171
|
+
test('two actors creating the SAME absent table both succeed; the table lands once', async () => {
|
|
172
|
+
// Winner: create the table for real.
|
|
173
|
+
await tableManager.loadTable(baseTable());
|
|
174
|
+
|
|
175
|
+
// Loser: a second reconciler forced to see the table as ABSENT at planning, so it issues the
|
|
176
|
+
// real `CREATE TABLE` that the backend rejects as a duplicate name.
|
|
177
|
+
const loserTm = spannerDriver.getTableManager();
|
|
178
|
+
jest.spyOn(loserTm.schemaMetadata, 'tableExists').mockImplementationOnce(async () => false);
|
|
179
|
+
|
|
180
|
+
// OUTCOME: the loser's create does NOT reject. (Pre-fix, this rejects with "Duplicate name in
|
|
181
|
+
// schema" — the red run.)
|
|
182
|
+
await expect(loserTm.loadTable(baseTable())).resolves.toBeUndefined();
|
|
183
|
+
|
|
184
|
+
expect(await tableManager.tableExists(baseTable())).toBe(true);
|
|
185
|
+
}, 60000);
|
|
186
|
+
|
|
187
|
+
test('GENUINE CONFLICT: an already-exists error whose live definition differs from intent STILL throws', async () => {
|
|
188
|
+
// Winner landed `qty` as STRING(MAX).
|
|
189
|
+
await tableManager.loadTable(conflictStringTable());
|
|
190
|
+
|
|
191
|
+
// Produce a REAL duplicate-column error by adding `qty` again as INT64 (a loser intending a
|
|
192
|
+
// different type). It IS the already-exists class...
|
|
193
|
+
let duplicateError: unknown;
|
|
194
|
+
try {
|
|
195
|
+
await spannerDriver.runUpdateSchema('ALTER TABLE `db_test_reconcile_conflict` ADD COLUMN `qty` INT64');
|
|
196
|
+
} catch (error) {
|
|
197
|
+
duplicateError = error;
|
|
198
|
+
}
|
|
199
|
+
expect(duplicateError).toBeDefined();
|
|
200
|
+
expect(classifier(tableManager).isAlreadyExistsError(duplicateError)).toBe(true);
|
|
201
|
+
|
|
202
|
+
// ...but the live schema (qty STRING(MAX)) does NOT match the intended definition (qty INT64),
|
|
203
|
+
// so reconcile must RETHROW the original error rather than mask a genuine conflict.
|
|
204
|
+
// (Bite: turn reconcile into a blanket swallow and this rejection disappears.)
|
|
205
|
+
const warnSpy = spyOnReconcileWarn(tableManager);
|
|
206
|
+
await expect(
|
|
207
|
+
internals(tableManager).reconcileConcurrentSchemaChange([conflictIntegerTable()], duplicateError)
|
|
208
|
+
).rejects.toBe(duplicateError);
|
|
209
|
+
|
|
210
|
+
// OBSERVABILITY: a genuine conflict is ALSO loud (WARN) before it re-throws.
|
|
211
|
+
const conflictWarn = warnSpy.mock.calls.find(([entry]) =>
|
|
212
|
+
/\[schema reconcile\].*genuine conflict/.test(String(entry.message))
|
|
213
|
+
);
|
|
214
|
+
expect(conflictWarn).toBeDefined();
|
|
215
|
+
expect(String(conflictWarn![0].message)).toContain('db_test_reconcile_conflict');
|
|
216
|
+
}, 60000);
|
|
217
|
+
|
|
218
|
+
test('UNRELATED ERROR CLASS: a non-already-exists DDL error propagates unchanged', async () => {
|
|
219
|
+
// A table that already matches the live schema — verification alone would find nothing to do.
|
|
220
|
+
await tableManager.loadTable(baseTable());
|
|
221
|
+
|
|
222
|
+
// An error from a different class (INVALID_ARGUMENT: index on a missing column).
|
|
223
|
+
let unrelatedError: unknown;
|
|
224
|
+
try {
|
|
225
|
+
await spannerDriver.runUpdateSchema('CREATE INDEX db_test_reconcile_bad ON db_test_reconcile(no_such_col)');
|
|
226
|
+
} catch (error) {
|
|
227
|
+
unrelatedError = error;
|
|
228
|
+
}
|
|
229
|
+
expect(unrelatedError).toBeDefined();
|
|
230
|
+
expect(classifier(tableManager).isAlreadyExistsError(unrelatedError)).toBe(false);
|
|
231
|
+
|
|
232
|
+
// reconcile must NOT touch this — it is not the already-exists class, so it propagates
|
|
233
|
+
// unchanged. (Bite: broaden the classifier to match every error and this rejection disappears,
|
|
234
|
+
// because verification finds no pending changes on the up-to-date table and would swallow it.)
|
|
235
|
+
await expect(internals(tableManager).reconcileConcurrentSchemaChange([baseTable()], unrelatedError)).rejects.toBe(
|
|
236
|
+
unrelatedError
|
|
237
|
+
);
|
|
238
|
+
}, 60000);
|
|
239
|
+
|
|
240
|
+
test('classifier matches ONLY the already-exists class — by code family AND message class', () => {
|
|
241
|
+
const isAlreadyExists = (code: number | undefined, message: string) =>
|
|
242
|
+
classifier(tableManager).isAlreadyExistsError({ code, message });
|
|
243
|
+
|
|
244
|
+
// Matched: the duplicate/already-exists phrasings under the ALREADY_EXISTS code family {6, 9}.
|
|
245
|
+
expect(isAlreadyExists(9, '9 FAILED_PRECONDITION: Duplicate column name db_test.qty.')).toBe(true);
|
|
246
|
+
expect(isAlreadyExists(9, '9 FAILED_PRECONDITION: Duplicate name in schema: db_test.')).toBe(true);
|
|
247
|
+
expect(isAlreadyExists(6, '6 ALREADY_EXISTS: Table db_test already exists')).toBe(true);
|
|
248
|
+
|
|
249
|
+
// NOT matched — other code-9 (FAILED_PRECONDITION) errors that must never be swallowed.
|
|
250
|
+
expect(
|
|
251
|
+
isAlreadyExists(
|
|
252
|
+
9,
|
|
253
|
+
'9 FAILED_PRECONDITION: a concurrent schema change operation or read-write transaction is already in progress'
|
|
254
|
+
)
|
|
255
|
+
).toBe(false);
|
|
256
|
+
expect(isAlreadyExists(9, '9 FAILED_PRECONDITION: Index backfill failed: uniqueness violation')).toBe(false);
|
|
257
|
+
|
|
258
|
+
// NOT matched — right message, wrong code family.
|
|
259
|
+
expect(isAlreadyExists(3, '3 INVALID_ARGUMENT: Duplicate column name db_test.qty.')).toBe(false);
|
|
260
|
+
expect(isAlreadyExists(5, '5 NOT_FOUND: Table not found: db_test')).toBe(false);
|
|
261
|
+
|
|
262
|
+
// NOT matched — reconcile-layer errors are plain Errors with no `code`.
|
|
263
|
+
expect(classifier(tableManager).isAlreadyExistsError(new Error('Unable to change column types in Spanner'))).toBe(
|
|
264
|
+
false
|
|
265
|
+
);
|
|
266
|
+
expect(classifier(tableManager).isAlreadyExistsError(undefined)).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|