@spooky-sync/core 0.0.1-canary.334 → 0.0.1-canary.336
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/AGENTS.md +8 -2
- package/README.md +4 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +140 -25
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +6 -1
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/push.saga.test.ts +74 -14
- package/src/mutation/push.saga.ts +25 -5
- package/src/mutation/rows.test.ts +49 -2
- package/src/mutation/rows.ts +93 -21
- package/src/query/session-guard.integration.test.ts +3 -1
- package/src/state/client-state.ts +8 -0
- package/src/state/reducers.test.ts +3 -0
- package/src/state/reducers.ts +9 -0
- package/src/utils/error-classification.test.ts +33 -0
- package/src/utils/error-classification.ts +27 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import { Socket } from 'node:net';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { RecordId, Surreal } from 'surrealdb';
|
|
8
|
+
import { liveEndpoint } from '../live/index';
|
|
9
|
+
import { defaultEnv } from '../query/env';
|
|
10
|
+
import { RemoteDatabaseService } from '../services/database/remote';
|
|
11
|
+
import { buildState } from '../testing/build';
|
|
12
|
+
import { runPure } from '../testing/run-pure';
|
|
13
|
+
import type { ClientState } from '../state/client-state';
|
|
14
|
+
import type { OutEvent } from '../kernel/events';
|
|
15
|
+
import type { StatementResult } from '../kernel/effects';
|
|
16
|
+
import { mintMutationId } from './mutation-id';
|
|
17
|
+
import { drain, loadOutbox } from './push.saga';
|
|
18
|
+
import { BATCH_SKIPPED } from './rows';
|
|
19
|
+
import { write } from './write.saga';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Integration: a queued write that SurrealDB refuses because sp00ky's OWN
|
|
23
|
+
* ingest post failed (scheduler/SSP down, slow past the event TIMEOUT, or
|
|
24
|
+
* answering 5xx) must stay in the outbox and land once `/ingest` is back. It
|
|
25
|
+
* used to read as an application error, so the client rolled the write back
|
|
26
|
+
* and discarded it: silent data loss on every scheduler outage.
|
|
27
|
+
*
|
|
28
|
+
* Runs the real `write` and `drain` sagas, the client's real remote adapter,
|
|
29
|
+
* and the `http::post` line `spky migrate` generates (read from the Rust
|
|
30
|
+
* source, so the two cannot drift) against a real SurrealDB, with an HTTP sink
|
|
31
|
+
* in this process playing the scheduler. Boot one first, e.g.:
|
|
32
|
+
* docker run -d --name spooky-surreal-it -p 18011:8000 \
|
|
33
|
+
* surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
|
|
34
|
+
*
|
|
35
|
+
* Override the endpoint with SURREAL_IT_ENDPOINT and the host SurrealDB reaches
|
|
36
|
+
* this process at with SURREAL_IT_SINK_HOST (default `host.docker.internal`;
|
|
37
|
+
* `127.0.0.1` for a SurrealDB running on the host). Skips if unreachable.
|
|
38
|
+
*/
|
|
39
|
+
const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
|
|
40
|
+
const sinkHost = process.env.SURREAL_IT_SINK_HOST ?? 'host.docker.internal';
|
|
41
|
+
const NS = 'it';
|
|
42
|
+
/** Short so the hang case stays quick; the shipped value is 10s. */
|
|
43
|
+
const EVENT_TIMEOUT_SECS = 2;
|
|
44
|
+
|
|
45
|
+
const cliSource = (file: string) =>
|
|
46
|
+
readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
|
|
47
|
+
|
|
48
|
+
/** The `/ingest` post every generated event makes (`sp00ky.rs::ingest_post`). */
|
|
49
|
+
function ingestPost(): string {
|
|
50
|
+
const src = cliSource('sp00ky.rs');
|
|
51
|
+
const m = /fn ingest_post\(\) -> String \{\s*format!\(\s*"(.+?)\\n",/s.exec(src);
|
|
52
|
+
if (!m) throw new Error('ingest_post format string not found');
|
|
53
|
+
if (!src.includes('"DEFINE EVENT OVERWRITE _00_{}_mutation ON TABLE {}\\n"')) {
|
|
54
|
+
throw new Error('the mutation event is no longer named _00_<table>_mutation');
|
|
55
|
+
}
|
|
56
|
+
return m[1]
|
|
57
|
+
.replaceAll('\\"', '"')
|
|
58
|
+
.replaceAll('{{', '{')
|
|
59
|
+
.replaceAll('}}', '}')
|
|
60
|
+
.replace('{}', String(EVENT_TIMEOUT_SECS));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `flap` answers the next post 503, then switches itself back to `ok`. */
|
|
64
|
+
type SinkMode = 'ok' | '500' | '503' | 'hang' | 'flap';
|
|
65
|
+
|
|
66
|
+
/** Plays the scheduler's `/ingest` (and an app's own third-party endpoint). */
|
|
67
|
+
function startSink(): Promise<{ server: Server; port: number; mode: { current: SinkMode }; hits: string[] }> {
|
|
68
|
+
const mode = { current: 'ok' as SinkMode };
|
|
69
|
+
const hits: string[] = [];
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
req.resume();
|
|
72
|
+
hits.push(`${req.method} ${req.url} ${mode.current}`);
|
|
73
|
+
if (mode.current === 'hang') return; // never answers; SurrealDB's TIMEOUT fires
|
|
74
|
+
if (mode.current === 'flap') {
|
|
75
|
+
mode.current = 'ok';
|
|
76
|
+
res.writeHead(503);
|
|
77
|
+
res.end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (mode.current === 'ok') {
|
|
81
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
82
|
+
res.end('{}');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(Number(mode.current));
|
|
86
|
+
res.end();
|
|
87
|
+
});
|
|
88
|
+
return new Promise((resolve) =>
|
|
89
|
+
server.listen(0, '127.0.0.1', () => {
|
|
90
|
+
const address = server.address();
|
|
91
|
+
resolve({ server, port: typeof address === 'object' && address ? address.port : 0, mode, hits });
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A port nothing listens on: a refused connection. */
|
|
97
|
+
async function closedPort(): Promise<number> {
|
|
98
|
+
const server = createServer();
|
|
99
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
100
|
+
const address = server.address();
|
|
101
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
102
|
+
return typeof address === 'object' && address ? address.port : 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const silent: any = { debug() {}, info() {}, warn() {}, error() {}, trace() {} };
|
|
106
|
+
silent.child = () => silent;
|
|
107
|
+
|
|
108
|
+
const env = defaultEnv({
|
|
109
|
+
tables: [
|
|
110
|
+
{ name: 'item', columns: { title: { type: 'string', optional: true } } },
|
|
111
|
+
{ name: 'lead', columns: { title: { type: 'string', optional: true } } },
|
|
112
|
+
],
|
|
113
|
+
} as any);
|
|
114
|
+
|
|
115
|
+
describe('a write refused by a failed ingest post is retried, not rolled back (real SurrealDB)', () => {
|
|
116
|
+
let reachable = false;
|
|
117
|
+
let local: Surreal;
|
|
118
|
+
let remote: RemoteDatabaseService;
|
|
119
|
+
let sink: Awaited<ReturnType<typeof startSink>>;
|
|
120
|
+
let sinkUrl = '';
|
|
121
|
+
let database = '';
|
|
122
|
+
/** Every per-statement error the remote answered, in order. */
|
|
123
|
+
const statementErrors: string[] = [];
|
|
124
|
+
|
|
125
|
+
beforeAll(async () => {
|
|
126
|
+
const url = new URL(endpoint);
|
|
127
|
+
reachable = await new Promise<boolean>((resolve) => {
|
|
128
|
+
const socket = new Socket();
|
|
129
|
+
const done = (ok: boolean) => {
|
|
130
|
+
socket.destroy();
|
|
131
|
+
resolve(ok);
|
|
132
|
+
};
|
|
133
|
+
socket.setTimeout(1_000, () => done(false));
|
|
134
|
+
socket.once('error', () => done(false));
|
|
135
|
+
socket.connect(Number(url.port || 80), url.hostname, () => done(true));
|
|
136
|
+
});
|
|
137
|
+
if (!reachable) return;
|
|
138
|
+
|
|
139
|
+
sink = await startSink();
|
|
140
|
+
sinkUrl = `http://${sinkHost}:${sink.port}`;
|
|
141
|
+
|
|
142
|
+
// The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
|
|
143
|
+
// and Node's fetch has no `file:` scheme: serve that one read from disk.
|
|
144
|
+
const realFetch = globalThis.fetch;
|
|
145
|
+
vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
|
|
146
|
+
const target = input instanceof URL ? input : new URL(String(input));
|
|
147
|
+
if (target.protocol === 'file:') return new Response(await readFile(fileURLToPath(target)));
|
|
148
|
+
return realFetch(input as RequestInfo, init);
|
|
149
|
+
});
|
|
150
|
+
const { createWasmEngines } = await import('@surrealdb/wasm');
|
|
151
|
+
local = new Surreal({ engines: createWasmEngines() });
|
|
152
|
+
await local.connect('mem://');
|
|
153
|
+
await local.use({ namespace: 'n', database: 'd' });
|
|
154
|
+
await local.query('DEFINE TABLE item SCHEMALESS; DEFINE TABLE lead SCHEMALESS; DEFINE TABLE _00_pending_mutations SCHEMALESS;');
|
|
155
|
+
|
|
156
|
+
database = `ingest_outage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
157
|
+
remote = new RemoteDatabaseService({ endpoint, namespace: NS, database } as any, silent);
|
|
158
|
+
await remote.connect();
|
|
159
|
+
await remote.signin({ username: 'root', password: 'root' });
|
|
160
|
+
await remote.query(`
|
|
161
|
+
DEFINE PARAM OVERWRITE $sp00ky_endpoint VALUE '${sinkUrl}' PERMISSIONS NONE;
|
|
162
|
+
DEFINE PARAM OVERWRITE $sp00ky_secret VALUE 'it-secret' PERMISSIONS NONE;
|
|
163
|
+
DEFINE TABLE item SCHEMALESS;
|
|
164
|
+
DEFINE EVENT OVERWRITE _00_item_mutation ON TABLE item
|
|
165
|
+
WHEN $before != $after AND $event != "DELETE"
|
|
166
|
+
THEN {
|
|
167
|
+
LET $payload = { table: 'item', op: $event, id: <string>$after.id, record: $after, hash: "" };
|
|
168
|
+
${ingestPost()}
|
|
169
|
+
};
|
|
170
|
+
DEFINE TABLE lead SCHEMALESS;
|
|
171
|
+
`);
|
|
172
|
+
}, 60_000);
|
|
173
|
+
|
|
174
|
+
afterAll(async () => {
|
|
175
|
+
if (!reachable) return;
|
|
176
|
+
sink.mode.current = 'ok';
|
|
177
|
+
await remote?.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
|
|
178
|
+
await remote?.close().catch(() => undefined);
|
|
179
|
+
await local?.close().catch(() => undefined);
|
|
180
|
+
sink?.server.closeAllConnections();
|
|
181
|
+
await new Promise<void>((resolve) => sink?.server.close(() => resolve()));
|
|
182
|
+
vi.unstubAllGlobals();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* With `loseAnswer` the push reaches the server and commits, then the client
|
|
187
|
+
* sees the push timeout instead of the answer (a slow server, a socket that
|
|
188
|
+
* dropped on the way back).
|
|
189
|
+
*/
|
|
190
|
+
const run = (saga: Generator<any, any, any>, state: ClientState, opts: { loseAnswer?: boolean } = {}) =>
|
|
191
|
+
runPure(saga, {
|
|
192
|
+
state,
|
|
193
|
+
now: Date.now(),
|
|
194
|
+
handlers: {
|
|
195
|
+
'local.query': (e: any) => local.query(e.sql, e.vars) as unknown as Promise<unknown>,
|
|
196
|
+
'local.execute': async (e: any) => e.query.extract((await local.query(e.query.sql, e.vars)) as unknown[]),
|
|
197
|
+
'remote.query': async (e: any) => {
|
|
198
|
+
const results = (await remote.queryResponses(e.sql, e.vars)) as StatementResult[];
|
|
199
|
+
for (const r of results) if (r.status === 'ERR') statementErrors.push(r.error);
|
|
200
|
+
if (opts.loseAnswer) throw new Error(`Remote request timed out after ${e.timeoutMs}ms`);
|
|
201
|
+
return results;
|
|
202
|
+
},
|
|
203
|
+
'ssp.ingest': () => undefined,
|
|
204
|
+
id: () => mintMutationId(state.tabId),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const remoteRow = async (id: RecordId<string>) =>
|
|
209
|
+
((await remote.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown> | null])[0] ?? null;
|
|
210
|
+
const localRow = async (id: RecordId<string>) =>
|
|
211
|
+
((await local.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown> | null])[0] ?? null;
|
|
212
|
+
const rolledBack = (emitted: OutEvent[]) => emitted.filter((e) => e.type === 'mutation:rolled-back');
|
|
213
|
+
const rid = (recordId: string) => new RecordId('item', recordId.slice('item:'.length));
|
|
214
|
+
const setEndpoint = (url: string) =>
|
|
215
|
+
remote.query(`DEFINE PARAM OVERWRITE $sp00ky_endpoint VALUE '${url}' PERMISSIONS NONE`);
|
|
216
|
+
|
|
217
|
+
/** Queue creates locally, the way `db.create` does, and return the state with them in the outbox. */
|
|
218
|
+
async function queueCreate(...recordIds: string[]): Promise<ClientState> {
|
|
219
|
+
let state = buildState();
|
|
220
|
+
for (const recordId of recordIds) {
|
|
221
|
+
state = (await run(write(env, { kind: 'create', recordId, data: { title: recordId } }), state)).state;
|
|
222
|
+
}
|
|
223
|
+
expect(state.outbox).toHaveLength(recordIds.length);
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The outbox of `state`, loaded again from the local store the way a
|
|
229
|
+
* restarted client does (without what other tests left in the store).
|
|
230
|
+
*/
|
|
231
|
+
async function reloaded(state: ClientState): Promise<ClientState> {
|
|
232
|
+
const ids = new Set(state.outbox.map((i) => i.id));
|
|
233
|
+
const loaded = (await run(loadOutbox(env), { ...buildState(), tabId: state.tabId })).state;
|
|
234
|
+
return { ...loaded, outbox: loaded.outbox.filter((i) => ids.has(i.id)) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
it.for([
|
|
238
|
+
['a refused connection', 'refused'],
|
|
239
|
+
['a post that hangs past the event TIMEOUT', 'hang'],
|
|
240
|
+
['a 500 from /ingest', '500'],
|
|
241
|
+
['a 503 from a cold SSP', '503'],
|
|
242
|
+
] as const)('%s: the write stays queued, then lands once /ingest answers', { timeout: 30_000 }, async ([, failure], ctx) => {
|
|
243
|
+
if (!reachable) ctx.skip();
|
|
244
|
+
const id = new RecordId('item', `outage_${failure}`);
|
|
245
|
+
const state = await queueCreate(`item:outage_${failure}`);
|
|
246
|
+
const errorsBefore = statementErrors.length;
|
|
247
|
+
|
|
248
|
+
await setEndpoint(failure === 'refused' ? `http://${sinkHost}:${await closedPort()}` : sinkUrl);
|
|
249
|
+
sink.mode.current = failure === 'refused' ? 'ok' : failure;
|
|
250
|
+
const failed = await run(drain(env), state);
|
|
251
|
+
|
|
252
|
+
const seen = statementErrors.slice(errorsBefore);
|
|
253
|
+
expect(seen).toHaveLength(1);
|
|
254
|
+
expect(seen[0]).toMatch(
|
|
255
|
+
/Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: /
|
|
256
|
+
);
|
|
257
|
+
expect(rolledBack(failed.emitted)).toEqual([]);
|
|
258
|
+
expect(failed.state.outbox).toEqual([expect.objectContaining({ recordId: `item:outage_${failure}`, attempts: 1 })]);
|
|
259
|
+
expect(failed.timers.get('outbox')).toEqual({ ms: expect.any(Number), event: { type: 'Drain' } });
|
|
260
|
+
expect(await localRow(id)).toMatchObject({ title: `item:outage_${failure}` });
|
|
261
|
+
expect(await remoteRow(id)).toBeNull();
|
|
262
|
+
|
|
263
|
+
// The scheduler is back: the retry lands the write exactly once.
|
|
264
|
+
await setEndpoint(sinkUrl);
|
|
265
|
+
sink.mode.current = 'ok';
|
|
266
|
+
const landed = await run(drain(env), failed.state);
|
|
267
|
+
expect(rolledBack(landed.emitted)).toEqual([]);
|
|
268
|
+
expect(landed.emitted).toContainEqual(expect.objectContaining({ type: 'mutation:settled', recordId: `item:outage_${failure}` }));
|
|
269
|
+
expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
|
|
270
|
+
expect(await remoteRow(id)).toMatchObject({ title: `item:outage_${failure}` });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('an ingest post that fails once mid-batch: the batch stops there, then every write lands once', { timeout: 30_000 }, async (ctx) => {
|
|
274
|
+
if (!reachable) ctx.skip();
|
|
275
|
+
const tag = Math.random().toString(36).slice(2, 8);
|
|
276
|
+
const [a, b] = [`item:flap_a_${tag}`, `item:flap_b_${tag}`];
|
|
277
|
+
const state = await queueCreate(a, b);
|
|
278
|
+
const errorsBefore = statementErrors.length;
|
|
279
|
+
|
|
280
|
+
// The first post (item a's) answers 503, every later one 200. Statements
|
|
281
|
+
// used to run independently, so b committed while a was refused, stayed
|
|
282
|
+
// queued behind a's cut, and its re-sent CREATE then failed "already
|
|
283
|
+
// exists" and was rolled back locally while the server kept the row.
|
|
284
|
+
await setEndpoint(sinkUrl);
|
|
285
|
+
sink.mode.current = 'flap';
|
|
286
|
+
const cut = await run(drain(env), state);
|
|
287
|
+
const seen = statementErrors.slice(errorsBefore);
|
|
288
|
+
expect(seen).toHaveLength(2);
|
|
289
|
+
expect(seen[0]).toMatch(/Error while processing event _00_item_mutation: .*503 Service Unavailable/);
|
|
290
|
+
expect(seen[1]).toContain(BATCH_SKIPPED);
|
|
291
|
+
expect(rolledBack(cut.emitted)).toEqual([]);
|
|
292
|
+
expect(cut.state.outbox.map((i) => [i.recordId, i.status])).toEqual([
|
|
293
|
+
[a, 'pending'],
|
|
294
|
+
[b, 'pending'],
|
|
295
|
+
]);
|
|
296
|
+
expect(await remoteRow(rid(a))).toBeNull();
|
|
297
|
+
expect(await remoteRow(rid(b))).toBeNull();
|
|
298
|
+
|
|
299
|
+
const landed = await run(drain(env), cut.state);
|
|
300
|
+
expect(statementErrors.slice(errorsBefore + 2)).toEqual([]);
|
|
301
|
+
expect(rolledBack(landed.emitted)).toEqual([]);
|
|
302
|
+
expect(landed.emitted.filter((e) => e.type === 'mutation:settled').map((e: any) => e.recordId)).toEqual([a, b]);
|
|
303
|
+
expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
|
|
304
|
+
expect(landed.state.failedCount).toBe(0);
|
|
305
|
+
for (const id of [a, b]) {
|
|
306
|
+
expect(await remoteRow(rid(id))).toMatchObject({ title: id });
|
|
307
|
+
expect(await localRow(rid(id))).toMatchObject({ title: id });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it.for([['in the same session'], ['after a restart']] as const)(
|
|
312
|
+
'a push whose answer is lost after the server committed, re-sent %s: nothing rolls back, every write lands once',
|
|
313
|
+
{ timeout: 30_000 },
|
|
314
|
+
async ([when], ctx) => {
|
|
315
|
+
if (!reachable) ctx.skip();
|
|
316
|
+
await setEndpoint(sinkUrl);
|
|
317
|
+
sink.mode.current = 'ok';
|
|
318
|
+
const tag = Math.random().toString(36).slice(2, 8);
|
|
319
|
+
const [a, b] = [`item:lost_a_${tag}`, `item:lost_b_${tag}`];
|
|
320
|
+
let state = await queueCreate(a, b);
|
|
321
|
+
// Edited before the push: the batch carries a's CREATE and then its UPDATE.
|
|
322
|
+
state = (await run(write(env, { kind: 'update', recordId: a, data: { title: 'edited' } }), state)).state;
|
|
323
|
+
const [errorsBefore, postsBefore] = [statementErrors.length, sink.hits.length];
|
|
324
|
+
|
|
325
|
+
const lost = await run(drain(env), state, { loseAnswer: true });
|
|
326
|
+
expect(statementErrors.slice(errorsBefore)).toEqual([]);
|
|
327
|
+
expect(sink.hits.length - postsBefore).toBe(3); // the server ran CREATE a, CREATE b, UPDATE a
|
|
328
|
+
expect(await remoteRow(rid(a))).toMatchObject({ title: 'edited' });
|
|
329
|
+
expect(await remoteRow(rid(b))).toMatchObject({ title: b });
|
|
330
|
+
expect(rolledBack(lost.emitted)).toEqual([]);
|
|
331
|
+
expect(lost.state.outbox.map((i) => [i.recordId, i.status, i.inDoubt])).toEqual([
|
|
332
|
+
[a, 'pending', true],
|
|
333
|
+
[b, 'pending', true],
|
|
334
|
+
[a, 'pending', true],
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
// The re-sent CREATEs used to fail "already exists" and roll back: the
|
|
338
|
+
// local rows deleted, two false entries in the tray, the server keeping both.
|
|
339
|
+
const retry = when === 'after a restart' ? await reloaded(lost.state) : lost.state;
|
|
340
|
+
const landed = await run(drain(env), retry);
|
|
341
|
+
expect(statementErrors.slice(errorsBefore)).toEqual([]);
|
|
342
|
+
expect(rolledBack(landed.emitted)).toEqual([]);
|
|
343
|
+
expect(landed.state.failedCount).toBe(retry.failedCount);
|
|
344
|
+
expect(landed.emitted.filter((e) => e.type === 'mutation:settled').map((e: any) => e.recordId)).toEqual([a, b, a]);
|
|
345
|
+
expect(landed.state.outbox.filter((i) => i.status === 'pending')).toEqual([]);
|
|
346
|
+
// Once: the re-send changed nothing on the server, so no event fired again.
|
|
347
|
+
expect(sink.hits.length - postsBefore).toBe(3);
|
|
348
|
+
expect(await remoteRow(rid(a))).toMatchObject({ title: 'edited' });
|
|
349
|
+
expect(await remoteRow(rid(b))).toMatchObject({ title: b });
|
|
350
|
+
expect(await localRow(rid(a))).toMatchObject({ title: 'edited' });
|
|
351
|
+
expect(await localRow(rid(b))).toMatchObject({ title: b });
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
it('an in-doubt CREATE whose id someone else holds with other content still rolls back', { timeout: 30_000 }, async (ctx) => {
|
|
356
|
+
if (!reachable) ctx.skip();
|
|
357
|
+
await setEndpoint(sinkUrl);
|
|
358
|
+
sink.mode.current = 'ok';
|
|
359
|
+
const recordId = `item:taken_${Math.random().toString(36).slice(2, 8)}`;
|
|
360
|
+
await remote.query('CREATE ONLY $id SET title = "theirs"', { id: rid(recordId) });
|
|
361
|
+
const state = await queueCreate(recordId);
|
|
362
|
+
const errorsBefore = statementErrors.length;
|
|
363
|
+
|
|
364
|
+
const lost = await run(drain(env), state, { loseAnswer: true });
|
|
365
|
+
expect(lost.state.outbox).toEqual([expect.objectContaining({ recordId, inDoubt: true })]);
|
|
366
|
+
const res = await run(drain(env), lost.state);
|
|
367
|
+
expect(statementErrors.slice(errorsBefore)).toEqual([
|
|
368
|
+
expect.stringMatching(/already exists/),
|
|
369
|
+
expect.stringMatching(/already exists/),
|
|
370
|
+
]);
|
|
371
|
+
expect(rolledBack(res.emitted)).toEqual([expect.objectContaining({ recordId, error: expect.stringMatching(/already exists/) })]);
|
|
372
|
+
expect(res.state.outbox).toEqual([]);
|
|
373
|
+
expect(await remoteRow(rid(recordId))).toMatchObject({ title: 'theirs' });
|
|
374
|
+
expect(await localRow(rid(recordId))).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it.for([
|
|
378
|
+
[
|
|
379
|
+
"a failing http::post in the app's own event",
|
|
380
|
+
`DEFINE EVENT OVERWRITE notify_crm ON TABLE lead THEN { SELECT * FROM http::post('${'SINK'}/crm', {}) TIMEOUT 2s; };`,
|
|
381
|
+
/Error while processing event notify_crm: There was an error processing a remote HTTP request: 500/,
|
|
382
|
+
],
|
|
383
|
+
[
|
|
384
|
+
"a THROW from the app's own event",
|
|
385
|
+
`DEFINE EVENT OVERWRITE notify_crm ON TABLE lead THEN { THROW 'lead refused'; };`,
|
|
386
|
+
/lead refused/,
|
|
387
|
+
],
|
|
388
|
+
] as const)('%s still rolls the write back', { timeout: 30_000 }, async ([, ddl, expected], ctx) => {
|
|
389
|
+
if (!reachable) ctx.skip();
|
|
390
|
+
await setEndpoint(sinkUrl);
|
|
391
|
+
await remote.query(ddl.replace('SINK', sinkUrl));
|
|
392
|
+
sink.mode.current = '500';
|
|
393
|
+
const recordId = `lead:${Math.random().toString(36).slice(2, 8)}`;
|
|
394
|
+
const state = await queueCreate(recordId);
|
|
395
|
+
const res = await run(drain(env), state);
|
|
396
|
+
sink.mode.current = 'ok';
|
|
397
|
+
expect(rolledBack(res.emitted)).toEqual([expect.objectContaining({ recordId, error: expect.stringMatching(expected) })]);
|
|
398
|
+
expect(res.state.outbox).toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -6,11 +6,18 @@ import * as R from '../state/reducers';
|
|
|
6
6
|
import { defaultEnv } from '../query/env';
|
|
7
7
|
import { drain, loadOutbox, refreshFailedCount, rollback } from './push.saga';
|
|
8
8
|
import type { StatementResult } from '../kernel/effects';
|
|
9
|
-
import type
|
|
9
|
+
import { BATCH_SKIPPED, type PendingMutationRow } from './rows';
|
|
10
10
|
|
|
11
11
|
const env = defaultEnv({ tables: [{ name: 'thing', columns: {} }] } as any, { outboxBatchSize: 2 });
|
|
12
12
|
const ok = (result: unknown = null): StatementResult => ({ status: 'OK', result });
|
|
13
13
|
const err = (error: string): StatementResult => ({ status: 'ERR', error });
|
|
14
|
+
/** What SurrealDB answers for a chained statement whose predecessor failed. */
|
|
15
|
+
const skipped = (): StatementResult => err(`An error occurred: ${BATCH_SKIPPED}`);
|
|
16
|
+
/** The row numbers a pushed batch carries, from its `$id<i>` vars. */
|
|
17
|
+
const pushedRows = (vars: Record<string, unknown>) =>
|
|
18
|
+
Object.keys(vars)
|
|
19
|
+
.filter((k) => /^id\d+$/.test(k))
|
|
20
|
+
.map((k) => String((vars[k] as RecordId).id));
|
|
14
21
|
const pendingRow = (n: number, type: PendingMutationRow['mutationType'] = 'create', extra: Partial<PendingMutationRow> = {}) => ({
|
|
15
22
|
id: `_00_pending_mutations:m${n}`,
|
|
16
23
|
mutationType: type,
|
|
@@ -56,7 +63,10 @@ describe('drain', () => {
|
|
|
56
63
|
},
|
|
57
64
|
},
|
|
58
65
|
});
|
|
59
|
-
expect(pushed).toEqual([
|
|
66
|
+
expect(pushed).toEqual([
|
|
67
|
+
`LET $ok0 = { CREATE ONLY $id0 SET a = $d0_a; true };\nLET $ok1 = { IF !$ok0 { THROW "${BATCH_SKIPPED}" }; CREATE ONLY $id1 SET a = $d1_a; true }`,
|
|
68
|
+
'LET $ok0 = { CREATE ONLY $id0 SET a = $d0_a; true }',
|
|
69
|
+
]);
|
|
60
70
|
expect(out.state.outbox.every((i) => i.status === 'acked' && i.ackedAt === 500)).toBe(true);
|
|
61
71
|
expect(out.log.filter((e) => e.kind === 'local.query' && (e as any).sql === 'DELETE $mid')).toHaveLength(3);
|
|
62
72
|
expect(out.emitted.filter((e) => e.type === 'mutation:settled')).toHaveLength(3);
|
|
@@ -64,34 +74,49 @@ describe('drain', () => {
|
|
|
64
74
|
expect(out.dispatched.filter((d) => d.type === 'SyncOutcome')).toEqual([{ type: 'SyncOutcome', ok: true, error: undefined }, { type: 'SyncOutcome', ok: true, error: undefined }]);
|
|
65
75
|
});
|
|
66
76
|
|
|
67
|
-
it('a rejected statement rolls back only that row; the
|
|
77
|
+
it('a rejected statement rolls back only that row; the rows the server skipped after it go out again at once', async () => {
|
|
78
|
+
const pushed: string[][] = [];
|
|
68
79
|
const out = await runPure(drain(env), {
|
|
69
|
-
state: stateWith([1, 2]),
|
|
80
|
+
state: stateWith([1, 2, 3]),
|
|
70
81
|
handlers: {
|
|
71
|
-
'local.query': (e: any) =>
|
|
72
|
-
|
|
82
|
+
'local.query': (e: any) =>
|
|
83
|
+
e.sql === 'SELECT * FROM $ids' ? [e.vars.ids.map((id: RecordId) => pendingRow(Number(String(id.id).slice(1))))] : [],
|
|
84
|
+
'remote.query': (e: any) => {
|
|
85
|
+
pushed.push(pushedRows(e.vars));
|
|
86
|
+
return pushed.length === 1 ? [err('permission denied'), skipped()] : e.sql.split(';\n').map(() => ok());
|
|
87
|
+
},
|
|
73
88
|
'local.execute': () => undefined,
|
|
74
89
|
'ssp.ingest': () => undefined,
|
|
75
90
|
},
|
|
76
91
|
});
|
|
77
|
-
expect(
|
|
92
|
+
expect(pushed).toEqual([['1', '2'], ['2', '3']]);
|
|
93
|
+
expect(out.state.outbox.map((i) => [i.id, i.status, i.attempts])).toEqual([
|
|
94
|
+
['_00_pending_mutations:m2', 'acked', 0],
|
|
95
|
+
['_00_pending_mutations:m3', 'acked', 0],
|
|
96
|
+
]);
|
|
78
97
|
expect(out.state.failedCount).toBe(1);
|
|
79
|
-
expect(out.emitted
|
|
80
|
-
|
|
98
|
+
expect(out.emitted.filter((e) => e.type === 'mutation:rolled-back')).toEqual([
|
|
99
|
+
expect.objectContaining({ mutationId: '_00_pending_mutations:m1', error: 'permission denied' }),
|
|
100
|
+
]);
|
|
101
|
+
expect(out.timers.has('outbox')).toBe(false);
|
|
102
|
+
expect(out.dispatched.filter((d) => d.type === 'SyncOutcome').every((d: any) => d.ok)).toBe(true);
|
|
81
103
|
});
|
|
82
104
|
|
|
83
105
|
it('a CRDT edit-log CREATE that finds its row already there is acked, any other duplicate rolls back', async () => {
|
|
84
106
|
const logEnv = defaultEnv({ tables: [{ name: 'thing', columns: {} }, { name: 'doc__crdt_log', columns: {} }] } as any, { outboxBatchSize: 2 });
|
|
107
|
+
const logRow = pendingRow(1, 'create', { tableName: 'doc__crdt_log', recordId: 'doc__crdt_log:1' });
|
|
108
|
+
let calls = 0;
|
|
85
109
|
const out = await runPure(drain(logEnv), {
|
|
86
110
|
state: stateWith([1, 2]),
|
|
87
111
|
handlers: {
|
|
88
112
|
'local.query': (e: any) =>
|
|
89
|
-
e.sql === 'SELECT * FROM $ids'
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
e.sql === 'SELECT * FROM $ids' ? [[logRow, pendingRow(2)].filter((r) => e.vars.ids.some((id: RecordId) => r.id.endsWith(String(id.id))))] : [],
|
|
114
|
+
// The duplicate log row still stops the chain on the server: row 2 goes again on its own.
|
|
115
|
+
'remote.query': () =>
|
|
116
|
+
++calls === 1 ? [err('Database record `doc__crdt_log:1` already exists'), skipped()] : [err('Database record `thing:2` already exists')],
|
|
93
117
|
},
|
|
94
118
|
});
|
|
119
|
+
expect(calls).toBe(2);
|
|
95
120
|
expect(out.state.outbox.find((i) => i.id === '_00_pending_mutations:m1')?.status).toBe('acked');
|
|
96
121
|
expect(out.state.outbox.some((i) => i.id === '_00_pending_mutations:m2')).toBe(false);
|
|
97
122
|
});
|
|
@@ -108,6 +133,8 @@ describe('drain', () => {
|
|
|
108
133
|
});
|
|
109
134
|
expect(thrown.state.outbox.map((i) => i.status)).toEqual(['pending', 'pending']);
|
|
110
135
|
expect(thrown.state.outbox[0].attempts).toBe(1);
|
|
136
|
+
// No answer: the server may have committed any of them.
|
|
137
|
+
expect(thrown.state.outbox.map((i) => i.inDoubt)).toEqual([true, true]);
|
|
111
138
|
expect(thrown.timers.get('outbox')).toEqual({ ms: 500, event: { type: 'Drain' } });
|
|
112
139
|
expect(thrown.dispatched).toEqual([{ type: 'SyncOutcome', ok: false, error: new Error('socket closed') }]);
|
|
113
140
|
const mid = await runPure(drain(env), {
|
|
@@ -115,12 +142,14 @@ describe('drain', () => {
|
|
|
115
142
|
handlers: { 'local.query': () => [[pendingRow(1), pendingRow(2)]], 'remote.query': () => [ok(), err('connection reset')] },
|
|
116
143
|
});
|
|
117
144
|
expect(mid.state.outbox.map((i) => [i.status, i.attempts])).toEqual([['acked', 0], ['pending', 1]]);
|
|
145
|
+
// A failed statement did not commit, so its row is not in doubt.
|
|
146
|
+
expect(mid.state.outbox[1].inDoubt).toBeUndefined();
|
|
118
147
|
expect(mid.timers.get('outbox')!.ms).toBe(500);
|
|
119
148
|
const short = await runPure(drain(env), {
|
|
120
149
|
state: stateWith([1, 2]),
|
|
121
150
|
handlers: { 'local.query': () => [[pendingRow(1), pendingRow(2)]], 'remote.query': () => [ok()] },
|
|
122
151
|
});
|
|
123
|
-
expect(short.state.outbox.map((i) => i.status)).toEqual(['acked', 'pending']);
|
|
152
|
+
expect(short.state.outbox.map((i) => [i.status, i.inDoubt])).toEqual([['acked', undefined], ['pending', true]]);
|
|
124
153
|
const undefinedRows = await runPure(drain(env), { state: stateWith([1]), handlers: { 'local.query': () => undefined } });
|
|
125
154
|
expect(undefinedRows.state.outbox).toEqual([]);
|
|
126
155
|
const notArray = await runPure(drain(env), {
|
|
@@ -130,6 +159,37 @@ describe('drain', () => {
|
|
|
130
159
|
expect(notArray.timers.has('outbox')).toBe(true);
|
|
131
160
|
});
|
|
132
161
|
|
|
162
|
+
it('a push whose answer was lost goes out again with CREATEs that accept their own earlier commit', async () => {
|
|
163
|
+
const pushed: string[] = [];
|
|
164
|
+
const rowsOf = (e: any) => [e.vars.ids.map((id: RecordId) => pendingRow(Number(String(id.id).slice(1))))];
|
|
165
|
+
const out = await runPure(drain(env), {
|
|
166
|
+
state: stateWith([1, 2]),
|
|
167
|
+
handlers: {
|
|
168
|
+
'local.query': (e: any) => (e.sql === 'SELECT * FROM $ids' ? rowsOf(e) : []),
|
|
169
|
+
'remote.query': (e: any) => {
|
|
170
|
+
pushed.push(e.sql);
|
|
171
|
+
// The server ran the batch; the answer never came back.
|
|
172
|
+
if (pushed.length === 1) throw new Error('Remote request timed out after 15000ms');
|
|
173
|
+
return [ok(), ok()];
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
expect(out.timers.get('outbox')).toEqual({ ms: 500, event: { type: 'Drain' } });
|
|
178
|
+
const again = await runPure(drain(env), {
|
|
179
|
+
state: out.state,
|
|
180
|
+
handlers: {
|
|
181
|
+
'local.query': (e: any) => (e.sql === 'SELECT * FROM $ids' ? rowsOf(e) : []),
|
|
182
|
+
'remote.query': (e: any) => (pushed.push(e.sql), [ok(), ok()]),
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const tolerant = (i: number) =>
|
|
186
|
+
`LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ($was${i} = NONE) OR ($was${i}.a != $d${i}_a) { CREATE ONLY $id${i} SET a = $d${i}_a }`;
|
|
187
|
+
expect(pushed[0]).not.toContain('$was');
|
|
188
|
+
expect(pushed[1]).toBe(`LET $ok0 = { ${tolerant(0)}; true };\nLET $ok1 = { IF !$ok0 { THROW "${BATCH_SKIPPED}" }; ${tolerant(1)}; true }`);
|
|
189
|
+
expect(again.emitted.filter((e) => e.type === 'mutation:rolled-back')).toEqual([]);
|
|
190
|
+
expect(again.state.outbox.map((i) => i.status)).toEqual(['acked', 'acked']);
|
|
191
|
+
});
|
|
192
|
+
|
|
133
193
|
it('a request rejected as a whole with an application error rolls back the head and continues', async () => {
|
|
134
194
|
let calls = 0;
|
|
135
195
|
const out = await runPure(drain(env), {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
planRevert,
|
|
20
20
|
remoteBatch,
|
|
21
21
|
toOutboxItem,
|
|
22
|
+
BATCH_SKIPPED,
|
|
22
23
|
FAILED_TABLE,
|
|
23
24
|
type PendingMutationRow,
|
|
24
25
|
} from './rows';
|
|
@@ -71,6 +72,8 @@ const ERR = (r: StatementResult | undefined): string | null => (r && r.status ==
|
|
|
71
72
|
* A CRDT edit-log row (`<table>__crdt_log`) is written once and never changes,
|
|
72
73
|
* so a CREATE retried after its first attempt committed (and only the answer
|
|
73
74
|
* was lost) finds it already there: that is a success, not a rejection.
|
|
75
|
+
* `remoteBatch` already makes such a CREATE a no-op on the server; this covers
|
|
76
|
+
* a row the user may create but not read back, where that check cannot see it.
|
|
74
77
|
*/
|
|
75
78
|
const isRepeatedLogCreate = (row: { mutationType: string; tableName: string }, err: string): boolean =>
|
|
76
79
|
row.mutationType === 'create' && row.tableName.endsWith('__crdt_log') && /already exists/i.test(err);
|
|
@@ -80,6 +83,17 @@ const isRepeatedLogCreate = (row: { mutationType: string; tableName: string }, e
|
|
|
80
83
|
* per request, per-statement outcome. Accepted rows are acked (they stay in
|
|
81
84
|
* the overlay until membership names them), rejected rows are rolled back,
|
|
82
85
|
* a transport failure leaves the unsent tail queued behind a backoff timer.
|
|
86
|
+
* The server stops a batch at its first failed statement (`remoteBatch`), so
|
|
87
|
+
* the rows after a rejection are sent again at once, and a re-sent row behind
|
|
88
|
+
* a retryable failure never ran before, so it cannot read "already exists".
|
|
89
|
+
*
|
|
90
|
+
* A request that throws a transport error (the push timeout, a dropped
|
|
91
|
+
* socket) may still have committed on the server, and so may one sent by a
|
|
92
|
+
* session that died before its answer. Those rows are marked in doubt
|
|
93
|
+
* (`OutboxItem.inDoubt`, every row loaded from the store is), and their
|
|
94
|
+
* re-sent CREATE accepts the row already on the server when it holds what the
|
|
95
|
+
* CREATE would write, instead of failing "already exists" and rolling back a
|
|
96
|
+
* write the server kept.
|
|
83
97
|
*/
|
|
84
98
|
export function* drain(env: SagaEnv): Saga<void> {
|
|
85
99
|
for (;;) {
|
|
@@ -102,12 +116,12 @@ export function* drain(env: SagaEnv): Saga<void> {
|
|
|
102
116
|
}
|
|
103
117
|
if (rows.length === 0) continue;
|
|
104
118
|
|
|
105
|
-
const req = remoteBatch(rows);
|
|
119
|
+
const req = remoteBatch(rows, new Set(batch.filter((i) => i.inDoubt).map((i) => i.id)));
|
|
106
120
|
let results: StatementResult[];
|
|
107
121
|
try {
|
|
108
122
|
const raw = (yield fx.remote.query(req.sql, req.vars, env.pushTimeoutMs)) as unknown;
|
|
109
|
-
// A response without statement results is a transport fault:
|
|
110
|
-
//
|
|
123
|
+
// A response without statement results is a transport fault: every
|
|
124
|
+
// row stays queued, in doubt (handled as `!r` below).
|
|
111
125
|
results = Array.isArray(raw) ? (raw as StatementResult[]) : [];
|
|
112
126
|
} catch (error) {
|
|
113
127
|
if (classifySyncError(error) === 'application') {
|
|
@@ -117,8 +131,9 @@ export function* drain(env: SagaEnv): Saga<void> {
|
|
|
117
131
|
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
118
132
|
continue;
|
|
119
133
|
}
|
|
134
|
+
// No answer: the server may have committed any prefix of the batch.
|
|
120
135
|
const attempts = batch.find((i) => i.id === rows[0].id)!.attempts;
|
|
121
|
-
yield fx.state.update(R.outboxBumpAttempts(rows[0].id));
|
|
136
|
+
yield fx.state.update(R.compose(R.outboxMarkInDoubt(rows.map((row) => row.id)), R.outboxBumpAttempts(rows[0].id)));
|
|
122
137
|
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
123
138
|
yield fx.timer.set('outbox', backoffMs(attempts), { type: 'Drain' });
|
|
124
139
|
return;
|
|
@@ -130,7 +145,9 @@ export function* drain(env: SagaEnv): Saga<void> {
|
|
|
130
145
|
const row = rows[i];
|
|
131
146
|
const r = results[i];
|
|
132
147
|
if (!r) {
|
|
133
|
-
// Statements after a transport-level cut:
|
|
148
|
+
// Statements after a transport-level cut: no answer says whether
|
|
149
|
+
// they ran, so they are in doubt.
|
|
150
|
+
yield fx.state.update(R.outboxMarkInDoubt(rows.slice(i).map((unanswered) => unanswered.id)));
|
|
134
151
|
cutAt = i;
|
|
135
152
|
break;
|
|
136
153
|
}
|
|
@@ -147,6 +164,9 @@ export function* drain(env: SagaEnv): Saga<void> {
|
|
|
147
164
|
yield fx.emit({ type: 'tabs:broadcast', message: { type: 'mutation-settled', mutationId: row.id, recordId: row.recordId, eventType: row.mutationType } });
|
|
148
165
|
continue;
|
|
149
166
|
}
|
|
167
|
+
// An earlier statement in this batch was rejected (or was a repeated
|
|
168
|
+
// log row), so this one never ran: the next round sends it again.
|
|
169
|
+
if (err.includes(BATCH_SKIPPED)) break;
|
|
150
170
|
if (classifySyncError(new Error(err)) === 'network') {
|
|
151
171
|
cutAt = i;
|
|
152
172
|
break;
|