orez-sync-cf-host 0.5.19 → 0.5.21
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/README.md +49 -6
- package/dist/bun-wasm-loader.d.ts +1 -0
- package/dist/bun-wasm-loader.js +20 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +93 -0
- package/{src → dist}/generated/sync_wasm_bg.wasm +0 -0
- package/dist/host.d.ts +12 -0
- package/dist/host.js +1512 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/mutation-error.d.ts +9 -0
- package/dist/mutation-error.js +23 -0
- package/dist/node-wasm-loader.d.ts +1 -0
- package/dist/node-wasm-loader.js +22 -0
- package/dist/post-commit.d.ts +13 -0
- package/dist/post-commit.js +31 -0
- package/dist/query-compiler.d.ts +4 -0
- package/dist/query-compiler.js +4 -0
- package/dist/sql-storage-adapter.d.ts +69 -0
- package/dist/sql-storage-adapter.js +194 -0
- package/dist/transaction-query.d.ts +72 -0
- package/dist/transaction-query.js +379 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.js +5 -0
- package/dist/vite-wasm-loader.d.ts +3 -0
- package/dist/vite-wasm-loader.js +24 -0
- package/dist/wasm.d.ts +1 -0
- package/dist/wasm.js +7 -0
- package/dist/write-safeguards.d.ts +53 -0
- package/dist/write-safeguards.js +186 -0
- package/package.json +59 -19
- package/src/bun-wasm-loader.ts +0 -16
- package/src/config.ts +0 -102
- package/src/harness-config.ts +0 -487
- package/src/harness-worker.ts +0 -33
- package/src/host.ts +0 -1958
- package/src/index.ts +0 -47
- package/src/ingest-harness-worker.ts +0 -535
- package/src/mutation-error.ts +0 -28
- package/src/platform-probe-worker.ts +0 -418
- package/src/post-commit.ts +0 -40
- package/src/query-compiler.ts +0 -16
- package/src/sql-storage-adapter.ts +0 -254
- package/src/transaction-query.ts +0 -494
- package/src/types.ts +0 -195
- package/src/wasm-platform.ts +0 -10
- package/src/wasm.ts +0 -34
- package/src/write-safeguards.ts +0 -248
- /package/{src → dist}/generated/package.json +0 -0
- /package/{src → dist}/generated/sync_wasm.d.ts +0 -0
- /package/{src → dist}/generated/sync_wasm.js +0 -0
- /package/{src → dist}/generated/sync_wasm_bg.wasm.d.ts +0 -0
package/dist/host.js
ADDED
|
@@ -0,0 +1,1512 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { validatePullCaps, validateSyncHostConfig } from './config.js';
|
|
3
|
+
import { createPostCommitEffects } from './post-commit.js';
|
|
4
|
+
import { createQueryCompiler } from './query-compiler.js';
|
|
5
|
+
import { decodeSqlParams, SqlStorageDirect, SqlStorageMutatorTransaction, SqlStorageSyncDb, } from './sql-storage-adapter.js';
|
|
6
|
+
import { isMutationApplicationError } from './types.js';
|
|
7
|
+
import { engine_apply_snapshot_changes, engine_apply_snapshot_page, engine_apply_upstream, engine_assemble_push_response, engine_begin_snapshot_generation, engine_finalize, engine_finalize_snapshot_generation, engine_handle_pull, engine_handle_query_pull, engine_init_query_schema, engine_init_schema, engine_invalidate, engine_memory_bytes, engine_preflight, engine_prune, engine_push_validate, engine_read_snapshot_progress, engine_record_app_error, engine_state, engine_version, } from './wasm.js';
|
|
8
|
+
import { IngestBreakerError, IngestCircuitBreaker, retryDelayMs, shouldRetryDelegatedPush, } from './write-safeguards.js';
|
|
9
|
+
const CLAIMS_HEADER = 'x-orez-sync-claims';
|
|
10
|
+
const NAMESPACE_HEADER = 'x-orez-sync-namespace';
|
|
11
|
+
const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path';
|
|
12
|
+
const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
|
|
13
|
+
const MIN_SNAPSHOT_PAGE_ROWS = 100;
|
|
14
|
+
const DEFAULT_CAPS = {
|
|
15
|
+
maxChangeRows: 10_000,
|
|
16
|
+
maxChangeBytes: 2_000_000,
|
|
17
|
+
};
|
|
18
|
+
function freshCounters() {
|
|
19
|
+
return {
|
|
20
|
+
pulls: 0,
|
|
21
|
+
pushes: 0,
|
|
22
|
+
resets: 0,
|
|
23
|
+
applicationErrors: 0,
|
|
24
|
+
invariantFailures: 0,
|
|
25
|
+
retentionRuns: 0,
|
|
26
|
+
queryRecompilations: 0,
|
|
27
|
+
wasmBoundaryCalls: 0,
|
|
28
|
+
wakeFrames: 0,
|
|
29
|
+
wakeBatches: 0,
|
|
30
|
+
externalEffectFailures: 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function json(value, status = 200) {
|
|
34
|
+
return Response.json(value, {
|
|
35
|
+
status,
|
|
36
|
+
headers: { 'cache-control': 'no-store' },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function isStructuredPushFailed(value) {
|
|
40
|
+
if (typeof value !== 'object' || value === null)
|
|
41
|
+
return false;
|
|
42
|
+
const body = value;
|
|
43
|
+
return (body.kind === 'PushFailed' &&
|
|
44
|
+
typeof body.origin === 'string' &&
|
|
45
|
+
typeof body.reason === 'string' &&
|
|
46
|
+
typeof body.message === 'string' &&
|
|
47
|
+
Array.isArray(body.mutationIDs));
|
|
48
|
+
}
|
|
49
|
+
function statusOf(error) {
|
|
50
|
+
if (typeof error === 'object' && error !== null && 'status' in error) {
|
|
51
|
+
const status = Number(error.status);
|
|
52
|
+
if (Number.isInteger(status) && status >= 400 && status <= 599)
|
|
53
|
+
return status;
|
|
54
|
+
}
|
|
55
|
+
return 500;
|
|
56
|
+
}
|
|
57
|
+
function errorMessage(error) {
|
|
58
|
+
return error instanceof Error ? error.message : String(error);
|
|
59
|
+
}
|
|
60
|
+
function errorBody(error) {
|
|
61
|
+
if (error instanceof IngestBreakerError) {
|
|
62
|
+
return {
|
|
63
|
+
error: error.error,
|
|
64
|
+
windowRows: error.windowRows,
|
|
65
|
+
budget: error.budget,
|
|
66
|
+
retryAfterMs: error.retryAfterMs,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { error: errorMessage(error) };
|
|
70
|
+
}
|
|
71
|
+
function requestError(message, status = 400) {
|
|
72
|
+
return Object.assign(new Error(message), { status });
|
|
73
|
+
}
|
|
74
|
+
async function requestJson(request) {
|
|
75
|
+
try {
|
|
76
|
+
return await request.json();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw requestError('invalid JSON request body');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function requestObject(request) {
|
|
83
|
+
const value = await requestJson(request);
|
|
84
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
85
|
+
throw requestError('request body must be a JSON object');
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function routeAfterNamespace(pathname) {
|
|
89
|
+
const [, , ...parts] = pathname.split('/');
|
|
90
|
+
return `/${parts.join('/')}`;
|
|
91
|
+
}
|
|
92
|
+
function claimsFromRequest(request) {
|
|
93
|
+
const encoded = request.headers.get(CLAIMS_HEADER);
|
|
94
|
+
if (!encoded)
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
const value = JSON.parse(decodeURIComponent(encoded));
|
|
98
|
+
return value && typeof value.userID === 'string' && value.userID.length > 0
|
|
99
|
+
? value
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function namespaceHash(namespace) {
|
|
107
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(namespace));
|
|
108
|
+
return Array.from(new Uint8Array(bytes).slice(0, 8), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
109
|
+
}
|
|
110
|
+
function socketAttachment(socket) {
|
|
111
|
+
const value = socket.deserializeAttachment();
|
|
112
|
+
return value && typeof value.clientID === 'string' ? value : null;
|
|
113
|
+
}
|
|
114
|
+
// Closing a socket that is already closed/closing throws, and a throw inside a
|
|
115
|
+
// hibernatable WebSocket handler aborts the DO. Swallow it: the socket is going
|
|
116
|
+
// away regardless.
|
|
117
|
+
function socketCloseQuietly(socket, code, reason) {
|
|
118
|
+
try {
|
|
119
|
+
socket.close(code, reason);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// already closing/closed, or workerd rejected the code — nothing to do
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Create the consumer-facing Worker router. Authentication happens here; the
|
|
127
|
+
* Durable Object receives only normalized claims over a binding-private header.
|
|
128
|
+
*/
|
|
129
|
+
export function createSyncWorker(config) {
|
|
130
|
+
validateSyncHostConfig(config);
|
|
131
|
+
return {
|
|
132
|
+
async fetch(request, env) {
|
|
133
|
+
const namespace = config.namespace(request);
|
|
134
|
+
if (!namespace)
|
|
135
|
+
return new Response('orez sync-cf-host', { status: 200 });
|
|
136
|
+
const route = routeAfterNamespace(new URL(request.url).pathname);
|
|
137
|
+
const isAdmin = route.startsWith('/admin/');
|
|
138
|
+
if (route === '/wake') {
|
|
139
|
+
if (!(await config.authorizeWake(request, env))) {
|
|
140
|
+
return json({ error: 'missing wake capability' }, 401);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (route === '/notify') {
|
|
144
|
+
if (!(await config.authorizeNotify(request, env))) {
|
|
145
|
+
return json({ error: 'forbidden' }, 403);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (isAdmin) {
|
|
149
|
+
const authorized = config.authorizeAdmin
|
|
150
|
+
? await config.authorizeAdmin(request, env)
|
|
151
|
+
: Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY;
|
|
152
|
+
if (!authorized)
|
|
153
|
+
return json({ error: 'forbidden' }, 403);
|
|
154
|
+
}
|
|
155
|
+
const headers = new Headers(request.headers);
|
|
156
|
+
headers.delete(CLAIMS_HEADER);
|
|
157
|
+
headers.delete(NAMESPACE_HEADER);
|
|
158
|
+
headers.delete(UPSTREAM_PATH_HEADER);
|
|
159
|
+
if (!isAdmin && route !== '/wake' && route !== '/notify') {
|
|
160
|
+
const claims = await config.authenticate(request, env);
|
|
161
|
+
if (!claims)
|
|
162
|
+
return json({ error: 'missing authentication' }, 401);
|
|
163
|
+
headers.set(CLAIMS_HEADER, encodeURIComponent(JSON.stringify(claims)));
|
|
164
|
+
}
|
|
165
|
+
headers.set(NAMESPACE_HEADER, await namespaceHash(namespace));
|
|
166
|
+
if (config.upstream) {
|
|
167
|
+
const namespacePath = typeof config.upstream.namespacePath === 'function'
|
|
168
|
+
? config.upstream.namespacePath(namespace)
|
|
169
|
+
: config.upstream.namespacePath;
|
|
170
|
+
if (!namespacePath.startsWith('/')) {
|
|
171
|
+
return json({ error: 'upstream namespacePath must be an absolute path' }, 500);
|
|
172
|
+
}
|
|
173
|
+
headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''));
|
|
174
|
+
}
|
|
175
|
+
const forwarded = new Request(request, { headers });
|
|
176
|
+
const id = env.SYNC_DO.idFromName(namespace);
|
|
177
|
+
return env.SYNC_DO.get(id).fetch(forwarded);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Create the namespace Durable Object class for one bundled consumer config. */
|
|
182
|
+
export function createSyncDurableObject(config) {
|
|
183
|
+
validateSyncHostConfig(config);
|
|
184
|
+
const compileQuery = createQueryCompiler(config.schema);
|
|
185
|
+
const defaultRetainChanges = String(config.retainChanges ?? 4_096);
|
|
186
|
+
const caps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps });
|
|
187
|
+
const idleTeardownMs = config.idleTeardownMs ?? 5_000;
|
|
188
|
+
// A CF fan-out wakes every client into an HTTP pull. Give concurrent writer
|
|
189
|
+
// requests a real batching window so a storm burst creates one pull wave.
|
|
190
|
+
const wakeCoalesceMs = config.wakeCoalesceMs ?? 25;
|
|
191
|
+
const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000;
|
|
192
|
+
const upstreamLimit = config.upstream?.changeLimit ?? 1_000;
|
|
193
|
+
const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 150_000;
|
|
194
|
+
const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000;
|
|
195
|
+
const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000;
|
|
196
|
+
const ingestMaxBackoffMs = config.upstream?.ingestMaxBackoffMs ?? 60_000;
|
|
197
|
+
const delegateMaxAttempts = config.delegatedPushRetry?.maxAttempts ?? 3;
|
|
198
|
+
const delegateInitialBackoffMs = config.delegatedPushRetry?.initialBackoffMs ?? 100;
|
|
199
|
+
const delegateMaxBackoffMs = config.delegatedPushRetry?.maxBackoffMs ?? 1_000;
|
|
200
|
+
const delegateTimeoutMs = config.delegatedPushRetry?.timeoutMs ?? 5_000;
|
|
201
|
+
return class SyncDurableObject extends DurableObject {
|
|
202
|
+
#engineDb;
|
|
203
|
+
#directSql;
|
|
204
|
+
#mutatorSql;
|
|
205
|
+
#bootID = crypto.randomUUID();
|
|
206
|
+
#lastRequestAt = 0;
|
|
207
|
+
#hibernations = 0;
|
|
208
|
+
#dropNextPushResponse = false;
|
|
209
|
+
#counters = freshCounters();
|
|
210
|
+
#pulling = new Set();
|
|
211
|
+
#wakeOrigins = new Set();
|
|
212
|
+
#wakeRecipients = new Set();
|
|
213
|
+
#wakePromise = null;
|
|
214
|
+
#ingestPromise = null;
|
|
215
|
+
#queryPullLocks = new Map();
|
|
216
|
+
#recordingIngestBillable = false;
|
|
217
|
+
#ingestBreaker = new IngestCircuitBreaker({
|
|
218
|
+
budgetRows: ingestBudgetRows,
|
|
219
|
+
windowMs: ingestBudgetWindowMs,
|
|
220
|
+
initialBackoffMs: ingestBackoffMs,
|
|
221
|
+
maxBackoffMs: ingestMaxBackoffMs,
|
|
222
|
+
now: () => Date.now(),
|
|
223
|
+
});
|
|
224
|
+
constructor(ctx, env) {
|
|
225
|
+
super(ctx, env);
|
|
226
|
+
const recordRowsWritten = (rows) => {
|
|
227
|
+
if (this.#recordingIngestBillable)
|
|
228
|
+
this.#ingestBreaker.recordBillable(rows);
|
|
229
|
+
};
|
|
230
|
+
this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql, recordRowsWritten);
|
|
231
|
+
this.#directSql = new SqlStorageDirect(ctx.storage.sql, recordRowsWritten);
|
|
232
|
+
this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast, format) => this.#wasm(() => compileQuery(ast, format)), config.transactionQueryBudget);
|
|
233
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
234
|
+
ctx.storage.transactionSync(() => {
|
|
235
|
+
config.initialize(this.#directSql);
|
|
236
|
+
this.#directSql.exec(`CREATE TABLE IF NOT EXISTS _zsync_host_control (
|
|
237
|
+
key TEXT PRIMARY KEY,
|
|
238
|
+
value TEXT NOT NULL
|
|
239
|
+
)`);
|
|
240
|
+
this.#directSql.exec("INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')");
|
|
241
|
+
const ingestBreakerReason = this.#controlGet('ingestBreakerReason');
|
|
242
|
+
if (ingestBreakerReason === 'ingestBudgetExceeded' ||
|
|
243
|
+
ingestBreakerReason === 'ingestCursorStalled') {
|
|
244
|
+
this.#ingestBreaker.restore(ingestBreakerReason, Number(this.#controlGet('ingestBreakerRetryAt')), Number(this.#controlGet('ingestBreakerTrips')));
|
|
245
|
+
}
|
|
246
|
+
this.#wasm(() => engine_init_schema(this.#engineDb, config.schema));
|
|
247
|
+
if (config.queryAware || config.resolveQuery)
|
|
248
|
+
this.#wasm(() => engine_init_query_schema(this.#engineDb));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
#armUpstreamAlarm() {
|
|
253
|
+
if (!config.upstream)
|
|
254
|
+
return;
|
|
255
|
+
this.ctx.waitUntil((async () => {
|
|
256
|
+
if ((await this.ctx.storage.getAlarm()) === null) {
|
|
257
|
+
await this.ctx.storage.setAlarm(Date.now() + upstreamIntervalMs);
|
|
258
|
+
}
|
|
259
|
+
})());
|
|
260
|
+
}
|
|
261
|
+
#wasm(call) {
|
|
262
|
+
this.#counters.wasmBoundaryCalls++;
|
|
263
|
+
return call();
|
|
264
|
+
}
|
|
265
|
+
async #acquireQueryPullLock(clientGroupID) {
|
|
266
|
+
const previous = this.#queryPullLocks.get(clientGroupID);
|
|
267
|
+
let release;
|
|
268
|
+
const current = new Promise((resolve) => {
|
|
269
|
+
release = resolve;
|
|
270
|
+
});
|
|
271
|
+
this.#queryPullLocks.set(clientGroupID, current);
|
|
272
|
+
if (previous)
|
|
273
|
+
await previous;
|
|
274
|
+
return () => {
|
|
275
|
+
release();
|
|
276
|
+
if (this.#queryPullLocks.get(clientGroupID) === current) {
|
|
277
|
+
this.#queryPullLocks.delete(clientGroupID);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
#simulateIdleTeardown(now) {
|
|
282
|
+
if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= idleTeardownMs) {
|
|
283
|
+
this.#bootID = crypto.randomUUID();
|
|
284
|
+
this.#hibernations++;
|
|
285
|
+
this.#counters = freshCounters();
|
|
286
|
+
this.#pulling.clear();
|
|
287
|
+
this.#wakeOrigins.clear();
|
|
288
|
+
this.#wakeRecipients.clear();
|
|
289
|
+
this.#wakePromise = null;
|
|
290
|
+
}
|
|
291
|
+
this.#lastRequestAt = now;
|
|
292
|
+
}
|
|
293
|
+
#visibility(claims) {
|
|
294
|
+
if (!config.visibility || !this.#visibilityEnabled())
|
|
295
|
+
return null;
|
|
296
|
+
return {
|
|
297
|
+
rowLocal: typeof config.visibility.rowLocal === 'function'
|
|
298
|
+
? config.visibility.rowLocal(claims)
|
|
299
|
+
: config.visibility.rowLocal,
|
|
300
|
+
filters: Object.keys(config.schema.tables).flatMap((table) => {
|
|
301
|
+
const filter = config.visibility?.filter(table, claims);
|
|
302
|
+
return filter
|
|
303
|
+
? [{ table, sql: filter.sql, params: [...(filter.params ?? [])] }]
|
|
304
|
+
: [];
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// the admin-set namespace knobs (writer, visibility, query-aware,
|
|
309
|
+
// retention) live in _zsync_host_control, NOT in instance fields: a real
|
|
310
|
+
// eviction recreates this instance, and an in-memory override silently
|
|
311
|
+
// reverting to the config default mid-run turns a query-aware namespace
|
|
312
|
+
// back into a baseline one — the client keeps re-sending its desired-query
|
|
313
|
+
// patch, the host ignores it, and every pull answers {unchanged:true}
|
|
314
|
+
// forever.
|
|
315
|
+
#controlGet(key) {
|
|
316
|
+
const row = this.#directSql.query('SELECT value FROM _zsync_host_control WHERE key = ?', [key])[0];
|
|
317
|
+
return row?.value ?? null;
|
|
318
|
+
}
|
|
319
|
+
#controlSet(key, value) {
|
|
320
|
+
this.#directSql.exec('INSERT INTO _zsync_host_control (key, value) VALUES (?, ?) ' +
|
|
321
|
+
'ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value]);
|
|
322
|
+
}
|
|
323
|
+
#controlDelete(...keys) {
|
|
324
|
+
if (keys.length === 0)
|
|
325
|
+
return;
|
|
326
|
+
this.#directSql.exec(`DELETE FROM _zsync_host_control WHERE key IN (${keys.map(() => '?').join(', ')})`, keys);
|
|
327
|
+
}
|
|
328
|
+
#persistIngestBreaker() {
|
|
329
|
+
const status = this.#ingestBreaker.status();
|
|
330
|
+
if (!status.reason || status.retryAt === null)
|
|
331
|
+
return;
|
|
332
|
+
this.#controlSet('ingestBreakerReason', status.reason);
|
|
333
|
+
this.#controlSet('ingestBreakerRetryAt', String(status.retryAt));
|
|
334
|
+
this.#controlSet('ingestBreakerTrips', String(status.consecutiveTrips));
|
|
335
|
+
}
|
|
336
|
+
#recoverIngestBreaker() {
|
|
337
|
+
const wasTripped = this.#ingestBreaker.status().reason !== null;
|
|
338
|
+
this.#ingestBreaker.recovered();
|
|
339
|
+
if (wasTripped) {
|
|
340
|
+
this.#controlDelete('ingestBreakerReason', 'ingestBreakerRetryAt', 'ingestBreakerTrips');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
#writerEnabled() {
|
|
344
|
+
return this.#controlGet('writerEnabled') === '1';
|
|
345
|
+
}
|
|
346
|
+
#visibilityEnabled() {
|
|
347
|
+
const value = this.#controlGet('visibilityEnabled');
|
|
348
|
+
return value === null ? (config.visibilityEnabled ?? false) : value === '1';
|
|
349
|
+
}
|
|
350
|
+
#queryAwareOverride() {
|
|
351
|
+
const value = this.#controlGet('queryAwareOverride');
|
|
352
|
+
return value === null ? null : value === '1';
|
|
353
|
+
}
|
|
354
|
+
#retainChanges() {
|
|
355
|
+
return this.#controlGet('retainChanges') ?? defaultRetainChanges;
|
|
356
|
+
}
|
|
357
|
+
#takeFault(point) {
|
|
358
|
+
if (this.#controlGet('faultPoint') !== point)
|
|
359
|
+
return null;
|
|
360
|
+
const kind = this.#controlGet('faultKind');
|
|
361
|
+
this.#directSql.exec("DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')");
|
|
362
|
+
return kind === 'quota' ? 'quota' : 'error';
|
|
363
|
+
}
|
|
364
|
+
#faultError(kind, point) {
|
|
365
|
+
return requestError(`injected ${kind} fault at ${point}`, kind === 'quota' ? 507 : 500);
|
|
366
|
+
}
|
|
367
|
+
#engineState() {
|
|
368
|
+
return this.#wasm(() => engine_state(this.#engineDb));
|
|
369
|
+
}
|
|
370
|
+
#engineStateBestEffort() {
|
|
371
|
+
try {
|
|
372
|
+
return this.#engineState();
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
#serviceBinding(name = config.upstream?.binding) {
|
|
379
|
+
const value = name
|
|
380
|
+
? this.env[name]
|
|
381
|
+
: undefined;
|
|
382
|
+
if (!value || typeof value.fetch !== 'function') {
|
|
383
|
+
throw requestError(`missing upstream service binding: ${name ?? '(not configured)'}`, 500);
|
|
384
|
+
}
|
|
385
|
+
return value;
|
|
386
|
+
}
|
|
387
|
+
async #upstreamWriteBudgetStatus() {
|
|
388
|
+
if (!config.upstream)
|
|
389
|
+
return json({ error: 'upstream is not configured' }, 404);
|
|
390
|
+
const path = this.#controlGet('upstreamPath');
|
|
391
|
+
if (path === null)
|
|
392
|
+
return json({ error: 'upstream path is not known yet' }, 409);
|
|
393
|
+
const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid');
|
|
394
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
395
|
+
headers: { host: endpoint.host },
|
|
396
|
+
});
|
|
397
|
+
if (!response.ok) {
|
|
398
|
+
return json({
|
|
399
|
+
error: 'upstream write-budget status unavailable',
|
|
400
|
+
upstreamStatus: response.status,
|
|
401
|
+
}, 502);
|
|
402
|
+
}
|
|
403
|
+
return json(await response.json());
|
|
404
|
+
}
|
|
405
|
+
#rememberUpstreamPath(request) {
|
|
406
|
+
if (!config.upstream)
|
|
407
|
+
return null;
|
|
408
|
+
const path = request.headers.get(UPSTREAM_PATH_HEADER);
|
|
409
|
+
if (path !== null) {
|
|
410
|
+
this.#controlSet('upstreamPath', path);
|
|
411
|
+
return path;
|
|
412
|
+
}
|
|
413
|
+
return this.#controlGet('upstreamPath');
|
|
414
|
+
}
|
|
415
|
+
#tripIngest(reason, fields) {
|
|
416
|
+
try {
|
|
417
|
+
return this.#ingestBreaker.trip(reason);
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
this.#persistIngestBreaker();
|
|
421
|
+
const status = this.#ingestBreaker.status();
|
|
422
|
+
console.error(JSON.stringify({
|
|
423
|
+
event: 'sync_upstream_ingest_breaker_tripped',
|
|
424
|
+
...status,
|
|
425
|
+
reason,
|
|
426
|
+
...fields,
|
|
427
|
+
}));
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
#recordIngestLogicalRows(rows) {
|
|
432
|
+
this.#ingestBreaker.recordLogical(rows);
|
|
433
|
+
}
|
|
434
|
+
#withIngestBilling(fields, apply) {
|
|
435
|
+
this.#recordingIngestBillable = true;
|
|
436
|
+
try {
|
|
437
|
+
return apply();
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
this.#recordingIngestBillable = false;
|
|
441
|
+
const status = this.#ingestBreaker.status();
|
|
442
|
+
// a breaker thrown by the sql adapter crosses rust as an engine error,
|
|
443
|
+
// so the durable breaker state is the authoritative classification.
|
|
444
|
+
if (error instanceof IngestBreakerError ||
|
|
445
|
+
(status.tripped && status.reason === 'ingestBudgetExceeded')) {
|
|
446
|
+
this.#persistIngestBreaker();
|
|
447
|
+
console.error(JSON.stringify({
|
|
448
|
+
event: 'sync_upstream_ingest_breaker_tripped',
|
|
449
|
+
...status,
|
|
450
|
+
reason: 'ingestBudgetExceeded',
|
|
451
|
+
...fields,
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
this.#recordingIngestBillable = false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
#snapshotProgress() {
|
|
461
|
+
return this.#wasm(() => engine_read_snapshot_progress(this.#engineDb));
|
|
462
|
+
}
|
|
463
|
+
#resetSnapshotBillingWindow() {
|
|
464
|
+
// every page is an independently committed write unit. metering it in a
|
|
465
|
+
// fresh window keeps a rebuild larger than the breaker ceiling resumable
|
|
466
|
+
// while preserving the ceiling for each transaction.
|
|
467
|
+
this.#ingestBreaker.reopen();
|
|
468
|
+
this.#controlDelete('ingestBreakerReason', 'ingestBreakerRetryAt', 'ingestBreakerTrips');
|
|
469
|
+
}
|
|
470
|
+
#snapshotRetryLimit(error, limit, fields) {
|
|
471
|
+
const status = statusOf(error);
|
|
472
|
+
if (!(error instanceof IngestBreakerError) && status < 500)
|
|
473
|
+
throw error;
|
|
474
|
+
if (limit <= MIN_SNAPSHOT_PAGE_ROWS) {
|
|
475
|
+
throw Object.assign(new Error(`snapshot page failed at minimum limit ${MIN_SNAPSHOT_PAGE_ROWS}: ${errorMessage(error)}`), { status, cause: error });
|
|
476
|
+
}
|
|
477
|
+
const nextLimit = Math.max(MIN_SNAPSHOT_PAGE_ROWS, Math.floor(limit / 2));
|
|
478
|
+
console.warn(JSON.stringify({
|
|
479
|
+
event: 'sync_upstream_snapshot_page_retry',
|
|
480
|
+
...fields,
|
|
481
|
+
limit,
|
|
482
|
+
nextLimit,
|
|
483
|
+
status,
|
|
484
|
+
error: errorMessage(error),
|
|
485
|
+
}));
|
|
486
|
+
return nextLimit;
|
|
487
|
+
}
|
|
488
|
+
async #fetchSnapshotPage(path, table, cursor, limit) {
|
|
489
|
+
const endpoint = new URL(`${path}/snapshot`, 'https://upstream.invalid');
|
|
490
|
+
endpoint.searchParams.set('table', table);
|
|
491
|
+
endpoint.searchParams.set('limit', String(limit));
|
|
492
|
+
if (cursor !== null)
|
|
493
|
+
endpoint.searchParams.set('cursor', cursor);
|
|
494
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
495
|
+
headers: { host: endpoint.host },
|
|
496
|
+
});
|
|
497
|
+
if (!response.ok) {
|
|
498
|
+
throw requestError(`upstream snapshot page returned ${response.status}`, response.status >= 500 ? 502 : response.status);
|
|
499
|
+
}
|
|
500
|
+
const page = (await response.json());
|
|
501
|
+
if (!Number.isSafeInteger(page.watermark) ||
|
|
502
|
+
Number(page.watermark) < 0 ||
|
|
503
|
+
!Array.isArray(page.rows) ||
|
|
504
|
+
(page.nextCursor !== null && typeof page.nextCursor !== 'string')) {
|
|
505
|
+
throw new Error('invalid upstream snapshot page response');
|
|
506
|
+
}
|
|
507
|
+
return page;
|
|
508
|
+
}
|
|
509
|
+
async #beginSnapshotGeneration(path) {
|
|
510
|
+
const table = Object.keys(config.schema.tables).sort()[0];
|
|
511
|
+
if (!table)
|
|
512
|
+
throw requestError('paged snapshots require a modeled table', 500);
|
|
513
|
+
let pageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS;
|
|
514
|
+
let page;
|
|
515
|
+
for (;;) {
|
|
516
|
+
try {
|
|
517
|
+
page = await this.#fetchSnapshotPage(path, table, null, pageLimit);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
pageLimit = this.#snapshotRetryLimit(error, pageLimit, {
|
|
522
|
+
phase: 'snapshot_page_fetch',
|
|
523
|
+
table,
|
|
524
|
+
cursor: null,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
this.#resetSnapshotBillingWindow();
|
|
529
|
+
const progress = this.#withIngestBilling({
|
|
530
|
+
phase: 'snapshot_begin',
|
|
531
|
+
table,
|
|
532
|
+
startWatermark: page.watermark,
|
|
533
|
+
}, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_begin_snapshot_generation(this.#engineDb, config.schema, String(page.watermark)))));
|
|
534
|
+
return { progress, page, pageLimit };
|
|
535
|
+
}
|
|
536
|
+
#ingest(upstreamPath, forceSnapshot = false) {
|
|
537
|
+
if (!config.upstream) {
|
|
538
|
+
return forceSnapshot
|
|
539
|
+
? Promise.reject(requestError('upstream is not configured'))
|
|
540
|
+
: Promise.resolve(0);
|
|
541
|
+
}
|
|
542
|
+
if (this.#ingestPromise) {
|
|
543
|
+
return forceSnapshot
|
|
544
|
+
? this.#ingestPromise.then(() => this.#ingest(upstreamPath, true))
|
|
545
|
+
: this.#ingestPromise;
|
|
546
|
+
}
|
|
547
|
+
const path = upstreamPath ?? this.#controlGet('upstreamPath');
|
|
548
|
+
if (path === null) {
|
|
549
|
+
return forceSnapshot
|
|
550
|
+
? Promise.reject(requestError('upstream path is not available'))
|
|
551
|
+
: Promise.resolve(0);
|
|
552
|
+
}
|
|
553
|
+
this.#ingestPromise = (async () => {
|
|
554
|
+
let progress = this.#snapshotProgress();
|
|
555
|
+
this.#ingestBreaker.assertReady();
|
|
556
|
+
const startingWatermark = this.#engineState().watermark;
|
|
557
|
+
let total = 0;
|
|
558
|
+
let pendingPage = null;
|
|
559
|
+
let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS;
|
|
560
|
+
let snapshotCompleted = false;
|
|
561
|
+
for (;;) {
|
|
562
|
+
if (progress?.state === 'paging') {
|
|
563
|
+
const activeProgress = progress;
|
|
564
|
+
const table = activeProgress.table;
|
|
565
|
+
if (table === null) {
|
|
566
|
+
throw new Error(`snapshot generation ${activeProgress.generation} is paging without a table`);
|
|
567
|
+
}
|
|
568
|
+
let page = pendingPage;
|
|
569
|
+
try {
|
|
570
|
+
page ??= await this.#fetchSnapshotPage(path, table, activeProgress.cursor, snapshotPageLimit);
|
|
571
|
+
const pageToApply = page;
|
|
572
|
+
this.#resetSnapshotBillingWindow();
|
|
573
|
+
const nextProgress = this.#withIngestBilling({
|
|
574
|
+
phase: 'snapshot_page_apply',
|
|
575
|
+
generation: activeProgress.generation,
|
|
576
|
+
table,
|
|
577
|
+
cursor: activeProgress.cursor,
|
|
578
|
+
pageRows: pageToApply.rows.length,
|
|
579
|
+
pageLimit: snapshotPageLimit,
|
|
580
|
+
}, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_apply_snapshot_page(this.#engineDb, config.schema, activeProgress.generation, table, pageToApply.rows, pageToApply.nextCursor))));
|
|
581
|
+
total += pageToApply.rows.length;
|
|
582
|
+
this.#recordIngestLogicalRows(pageToApply.rows.length);
|
|
583
|
+
progress = nextProgress;
|
|
584
|
+
pendingPage = null;
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
snapshotPageLimit = this.#snapshotRetryLimit(error, snapshotPageLimit, {
|
|
588
|
+
phase: page === null ? 'snapshot_page_fetch' : 'snapshot_page_apply',
|
|
589
|
+
generation: activeProgress.generation,
|
|
590
|
+
table,
|
|
591
|
+
cursor: activeProgress.cursor,
|
|
592
|
+
});
|
|
593
|
+
pendingPage = null;
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (progress?.state === 'catching_up') {
|
|
598
|
+
const activeProgress = progress;
|
|
599
|
+
const cursor = activeProgress.catchupWatermark;
|
|
600
|
+
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
|
|
601
|
+
endpoint.searchParams.set('since', cursor);
|
|
602
|
+
endpoint.searchParams.set('limit', String(upstreamLimit));
|
|
603
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
604
|
+
headers: { host: endpoint.host },
|
|
605
|
+
});
|
|
606
|
+
if (response.status === 410) {
|
|
607
|
+
const begun = await this.#beginSnapshotGeneration(path);
|
|
608
|
+
progress = begun.progress;
|
|
609
|
+
pendingPage = begun.page;
|
|
610
|
+
snapshotPageLimit = begun.pageLimit;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (!response.ok) {
|
|
614
|
+
throw new Error(`upstream snapshot catch-up returned ${response.status}`);
|
|
615
|
+
}
|
|
616
|
+
const batch = (await response.json());
|
|
617
|
+
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
618
|
+
throw new Error('invalid upstream changes response');
|
|
619
|
+
}
|
|
620
|
+
this.#resetSnapshotBillingWindow();
|
|
621
|
+
const result = this.#withIngestBilling({
|
|
622
|
+
phase: 'snapshot_catchup',
|
|
623
|
+
generation: activeProgress.generation,
|
|
624
|
+
cursor,
|
|
625
|
+
batchWatermark: batch.watermark,
|
|
626
|
+
changeRows: batch.changes.length,
|
|
627
|
+
}, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_apply_snapshot_changes(this.#engineDb, config.schema, activeProgress.generation, batch))));
|
|
628
|
+
total += result.applied;
|
|
629
|
+
this.#recordIngestLogicalRows(result.applied);
|
|
630
|
+
if (result.caughtUp) {
|
|
631
|
+
this.#resetSnapshotBillingWindow();
|
|
632
|
+
this.#withIngestBilling({
|
|
633
|
+
phase: 'snapshot_finalize',
|
|
634
|
+
generation: activeProgress.generation,
|
|
635
|
+
watermark: result.watermark,
|
|
636
|
+
}, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_finalize_snapshot_generation(this.#engineDb, config.schema, activeProgress.generation, String(result.watermark)))));
|
|
637
|
+
progress = null;
|
|
638
|
+
snapshotCompleted = true;
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
if (String(result.watermark) === cursor) {
|
|
642
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
643
|
+
phase: 'snapshot_catchup',
|
|
644
|
+
generation: activeProgress.generation,
|
|
645
|
+
cursor,
|
|
646
|
+
batchWatermark: batch.watermark,
|
|
647
|
+
changeRows: batch.changes.length,
|
|
648
|
+
applied: result.applied,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
progress = {
|
|
652
|
+
...progress,
|
|
653
|
+
catchupWatermark: String(result.watermark),
|
|
654
|
+
};
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const cursor = this.#engineState().upstreamWatermark;
|
|
658
|
+
if (forceSnapshot) {
|
|
659
|
+
forceSnapshot = false;
|
|
660
|
+
const begun = await this.#beginSnapshotGeneration(path);
|
|
661
|
+
progress = begun.progress;
|
|
662
|
+
pendingPage = begun.page;
|
|
663
|
+
snapshotPageLimit = begun.pageLimit;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
|
|
667
|
+
endpoint.searchParams.set('watermark', cursor);
|
|
668
|
+
endpoint.searchParams.set('limit', String(upstreamLimit));
|
|
669
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
670
|
+
headers: { host: endpoint.host },
|
|
671
|
+
});
|
|
672
|
+
if (response.status === 410) {
|
|
673
|
+
const begun = await this.#beginSnapshotGeneration(path);
|
|
674
|
+
progress = begun.progress;
|
|
675
|
+
pendingPage = begun.page;
|
|
676
|
+
snapshotPageLimit = begun.pageLimit;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (!response.ok) {
|
|
680
|
+
throw new Error(`upstream changes returned ${response.status}`);
|
|
681
|
+
}
|
|
682
|
+
const batch = (await response.json());
|
|
683
|
+
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
684
|
+
throw new Error('invalid upstream changes response');
|
|
685
|
+
}
|
|
686
|
+
const result = this.#withIngestBilling({
|
|
687
|
+
phase: 'changes',
|
|
688
|
+
cursor,
|
|
689
|
+
batchWatermark: batch.watermark,
|
|
690
|
+
changeRows: batch.changes.length,
|
|
691
|
+
}, () => this.ctx.storage.transactionSync(() => this.#wasm(() => engine_apply_upstream(this.#engineDb, config.schema, batch))));
|
|
692
|
+
total += result.applied;
|
|
693
|
+
this.#recordIngestLogicalRows(result.applied);
|
|
694
|
+
const nextCursor = this.#engineState().upstreamWatermark;
|
|
695
|
+
if (batch.changes.length > 0 && String(nextCursor) === String(cursor)) {
|
|
696
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
697
|
+
phase: 'changes',
|
|
698
|
+
cursor,
|
|
699
|
+
batchWatermark: batch.watermark,
|
|
700
|
+
changeRows: batch.changes.length,
|
|
701
|
+
applied: result.applied,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
if (result.caughtUp)
|
|
705
|
+
break;
|
|
706
|
+
// a page can legitimately apply zero rows while still advancing the
|
|
707
|
+
// watermark: the engine consumes changes for tables this host does not
|
|
708
|
+
// model (subset replica) without materializing them. only a page that
|
|
709
|
+
// neither applied nor advanced is genuinely stalled.
|
|
710
|
+
if (result.applied === 0 && String(nextCursor) === String(cursor)) {
|
|
711
|
+
this.#tripIngest('ingestCursorStalled', {
|
|
712
|
+
phase: 'changes',
|
|
713
|
+
cursor,
|
|
714
|
+
batchWatermark: batch.watermark,
|
|
715
|
+
changeRows: batch.changes.length,
|
|
716
|
+
applied: result.applied,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
this.#recoverIngestBreaker();
|
|
721
|
+
const endingWatermark = this.#engineState().watermark;
|
|
722
|
+
if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
|
|
723
|
+
await this.#enqueueWake('__upstream__');
|
|
724
|
+
}
|
|
725
|
+
return total;
|
|
726
|
+
})().finally(() => {
|
|
727
|
+
this.#ingestPromise = null;
|
|
728
|
+
});
|
|
729
|
+
return this.#ingestPromise;
|
|
730
|
+
}
|
|
731
|
+
#ingestAfterCurrent(upstreamPath) {
|
|
732
|
+
const current = this.#ingestPromise;
|
|
733
|
+
return current
|
|
734
|
+
? current.then(() => this.#ingest(upstreamPath))
|
|
735
|
+
: this.#ingest(upstreamPath);
|
|
736
|
+
}
|
|
737
|
+
async #fetchDelegatedPush(endpoint, headers, body, provisioning = false) {
|
|
738
|
+
const binding = this.#serviceBinding(config.mutateBinding ?? config.upstream?.binding);
|
|
739
|
+
let lastError = null;
|
|
740
|
+
for (let attempt = 1; attempt <= delegateMaxAttempts; attempt++) {
|
|
741
|
+
let response = null;
|
|
742
|
+
try {
|
|
743
|
+
response = await binding.fetch(endpoint.toString(), {
|
|
744
|
+
method: 'POST',
|
|
745
|
+
headers,
|
|
746
|
+
body,
|
|
747
|
+
signal: AbortSignal.timeout(provisioning ? Math.max(delegateTimeoutMs, 25_000) : delegateTimeoutMs),
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
lastError = error;
|
|
752
|
+
}
|
|
753
|
+
if (!shouldRetryDelegatedPush(response?.status ?? null, attempt, delegateMaxAttempts)) {
|
|
754
|
+
if (response)
|
|
755
|
+
return response;
|
|
756
|
+
throw lastError;
|
|
757
|
+
}
|
|
758
|
+
await response?.body?.cancel();
|
|
759
|
+
const delayMs = retryDelayMs(attempt, delegateInitialBackoffMs, delegateMaxBackoffMs);
|
|
760
|
+
console.warn(JSON.stringify({
|
|
761
|
+
event: 'sync_delegated_push_retry',
|
|
762
|
+
attempt,
|
|
763
|
+
maxAttempts: delegateMaxAttempts,
|
|
764
|
+
status: response?.status ?? null,
|
|
765
|
+
delayMs,
|
|
766
|
+
error: response ? null : errorMessage(lastError),
|
|
767
|
+
}));
|
|
768
|
+
await scheduler.wait(delayMs);
|
|
769
|
+
}
|
|
770
|
+
throw lastError ?? new Error('delegated push retry exhausted');
|
|
771
|
+
}
|
|
772
|
+
#log(fields) {
|
|
773
|
+
console.log(JSON.stringify({
|
|
774
|
+
event: 'sync_request',
|
|
775
|
+
hostVersion: config.hostVersion,
|
|
776
|
+
engineVersion: engine_version(),
|
|
777
|
+
...fields,
|
|
778
|
+
}));
|
|
779
|
+
}
|
|
780
|
+
#enqueueWake(originClientID) {
|
|
781
|
+
this.#wakeOrigins.add(originClientID);
|
|
782
|
+
for (const socket of this.ctx.getWebSockets()) {
|
|
783
|
+
const attachment = socketAttachment(socket);
|
|
784
|
+
if (!attachment ||
|
|
785
|
+
attachment.clientID === originClientID ||
|
|
786
|
+
this.#pulling.has(attachment.clientID)) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
this.#wakeRecipients.add(socket);
|
|
790
|
+
}
|
|
791
|
+
return this.#scheduleWake();
|
|
792
|
+
}
|
|
793
|
+
#scheduleWake() {
|
|
794
|
+
if (!this.#wakePromise) {
|
|
795
|
+
const queuedAt = performance.now();
|
|
796
|
+
this.#wakePromise = (async () => {
|
|
797
|
+
await scheduler.wait(wakeCoalesceMs);
|
|
798
|
+
const fanoutStarted = performance.now();
|
|
799
|
+
const origins = this.#wakeOrigins;
|
|
800
|
+
this.#wakeOrigins = new Set();
|
|
801
|
+
const recipients = this.#wakeRecipients;
|
|
802
|
+
this.#wakeRecipients = new Set();
|
|
803
|
+
this.#counters.wakeBatches++;
|
|
804
|
+
let sent = 0;
|
|
805
|
+
const sockets = this.ctx.getWebSockets();
|
|
806
|
+
for (const socket of recipients) {
|
|
807
|
+
try {
|
|
808
|
+
socket.send('wake');
|
|
809
|
+
sent++;
|
|
810
|
+
this.#counters.wakeFrames++;
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
// A closing hibernating socket disappears from getWebSockets;
|
|
814
|
+
// a race here is advisory and carries no correctness weight.
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
console.log(JSON.stringify({
|
|
818
|
+
event: 'sync_wake',
|
|
819
|
+
hostVersion: config.hostVersion,
|
|
820
|
+
socketCount: sockets.length,
|
|
821
|
+
originCount: origins.size,
|
|
822
|
+
sent,
|
|
823
|
+
eligibleRecipients: recipients.size,
|
|
824
|
+
coalesceMs: fanoutStarted - queuedAt,
|
|
825
|
+
fanoutMs: performance.now() - fanoutStarted,
|
|
826
|
+
}));
|
|
827
|
+
})().finally(() => {
|
|
828
|
+
this.#wakePromise = null;
|
|
829
|
+
if (this.#wakeOrigins.size > 0)
|
|
830
|
+
void this.#scheduleWake();
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
return this.#wakePromise;
|
|
834
|
+
}
|
|
835
|
+
async #pull(request, claims, namespace) {
|
|
836
|
+
this.#counters.pulls++;
|
|
837
|
+
this.#engineDb.resetStats();
|
|
838
|
+
const started = performance.now();
|
|
839
|
+
let transactionMs = 0;
|
|
840
|
+
let body;
|
|
841
|
+
try {
|
|
842
|
+
body = await requestObject(request);
|
|
843
|
+
const queryAware = this.#queryAwareOverride() ??
|
|
844
|
+
(typeof config.queryAware === 'function'
|
|
845
|
+
? config.queryAware(claims)
|
|
846
|
+
: (config.queryAware ?? Boolean(config.resolveQuery)));
|
|
847
|
+
const transformVersion = queryAware
|
|
848
|
+
? typeof config.queryTransformVersion === 'function'
|
|
849
|
+
? config.queryTransformVersion(claims)
|
|
850
|
+
: (config.queryTransformVersion ?? 0)
|
|
851
|
+
: 0;
|
|
852
|
+
if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
|
|
853
|
+
throw new TypeError('queryTransformVersion must be a non-negative safe integer');
|
|
854
|
+
}
|
|
855
|
+
const releaseQueryPull = queryAware && config.resolveQuery
|
|
856
|
+
? await this.#acquireQueryPullLock(typeof body.clientGroupID === 'string' ? body.clientGroupID : '')
|
|
857
|
+
: null;
|
|
858
|
+
let response;
|
|
859
|
+
try {
|
|
860
|
+
if (queryAware && body.queries) {
|
|
861
|
+
const queries = body.queries;
|
|
862
|
+
if (Array.isArray(queries.patch)) {
|
|
863
|
+
const patch = [];
|
|
864
|
+
for (const operation of queries.patch) {
|
|
865
|
+
if (!operation || typeof operation !== 'object') {
|
|
866
|
+
patch.push(operation);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
const op = operation;
|
|
870
|
+
if (op.op === 'put') {
|
|
871
|
+
if (!config.resolveQuery || typeof op.name !== 'string') {
|
|
872
|
+
throw requestError('query put requires a server-resolved named query');
|
|
873
|
+
}
|
|
874
|
+
if (!Array.isArray(op.args)) {
|
|
875
|
+
throw requestError('named query args must be an array');
|
|
876
|
+
}
|
|
877
|
+
const args = op.args;
|
|
878
|
+
let ast;
|
|
879
|
+
try {
|
|
880
|
+
// resolveQuery may be async and needs `env` (a consumer can
|
|
881
|
+
// delegate the transform to its app's real synced-queries
|
|
882
|
+
// endpoint over an app service binding — authenticate runs in the
|
|
883
|
+
// worker isolate, but the query loop runs here in the DO, so the
|
|
884
|
+
// binding must come from the DO's own env, not a shared global).
|
|
885
|
+
ast = await config.resolveQuery(op.name, args, claims, this.env);
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
throw requestError(`unknown or unsupported named query: ${op.name}`);
|
|
889
|
+
}
|
|
890
|
+
patch.push({
|
|
891
|
+
op: 'put',
|
|
892
|
+
hash: op.hash,
|
|
893
|
+
ast,
|
|
894
|
+
transformVersion,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
else
|
|
898
|
+
patch.push(operation);
|
|
899
|
+
}
|
|
900
|
+
body = { ...body, queries: { ...queries, patch } };
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (queryAware) {
|
|
904
|
+
body = { ...body, _serverQueryTransformVersion: transformVersion };
|
|
905
|
+
}
|
|
906
|
+
const clientID = typeof body.clientID === 'string' ? body.clientID : '';
|
|
907
|
+
this.#pulling.add(clientID);
|
|
908
|
+
try {
|
|
909
|
+
const txStarted = performance.now();
|
|
910
|
+
const duringFault = this.#takeFault('pull_during_tx');
|
|
911
|
+
response = this.ctx.storage.transactionSync(() => {
|
|
912
|
+
const result = this.#wasm(() => queryAware
|
|
913
|
+
? engine_handle_query_pull(this.#engineDb, config.schema, this.#retainChanges(), body, claims.userID)
|
|
914
|
+
: engine_handle_pull(this.#engineDb, config.schema, this.#visibility(claims), caps, this.#retainChanges(), body, claims.userID));
|
|
915
|
+
if (duringFault)
|
|
916
|
+
throw this.#faultError(duringFault, 'pull_during_tx');
|
|
917
|
+
return result;
|
|
918
|
+
});
|
|
919
|
+
transactionMs = performance.now() - txStarted;
|
|
920
|
+
}
|
|
921
|
+
finally {
|
|
922
|
+
this.#pulling.delete(clientID);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
finally {
|
|
926
|
+
releaseQueryPull?.();
|
|
927
|
+
}
|
|
928
|
+
const afterPullFault = this.#takeFault('pull_after_commit');
|
|
929
|
+
if (afterPullFault)
|
|
930
|
+
throw this.#faultError(afterPullFault, 'pull_after_commit');
|
|
931
|
+
const patch = Array.isArray(response.rowsPatch) ? response.rowsPatch : [];
|
|
932
|
+
const queriesBody = body.queries;
|
|
933
|
+
const queryPuts = queryAware && Array.isArray(queriesBody?.patch)
|
|
934
|
+
? queriesBody.patch.filter((entry) => entry?.op === 'put').length
|
|
935
|
+
: 0;
|
|
936
|
+
this.#counters.queryRecompilations += queryPuts;
|
|
937
|
+
const state = this.#engineStateBestEffort();
|
|
938
|
+
this.#log({
|
|
939
|
+
namespaceHash: namespace,
|
|
940
|
+
requestKind: 'pull',
|
|
941
|
+
resultClass: response.unchanged === true ? 'unchanged' : 'success',
|
|
942
|
+
inputCookie: body.cookie ?? null,
|
|
943
|
+
outputCookie: response.cookie ?? null,
|
|
944
|
+
retainedFloor: state?.floor ?? null,
|
|
945
|
+
currentWatermark: state?.watermark ?? null,
|
|
946
|
+
changeRowsScanned: null,
|
|
947
|
+
changeRowsIncluded: null,
|
|
948
|
+
queriesRecomputed: queryPuts,
|
|
949
|
+
rowPuts: patch.filter((entry) => entry?.op === 'put').length,
|
|
950
|
+
rowDeletes: patch.filter((entry) => entry?.op === 'del').length,
|
|
951
|
+
lmidAdvances: 0,
|
|
952
|
+
transactionMs,
|
|
953
|
+
totalMs: performance.now() - started,
|
|
954
|
+
resetReason: null,
|
|
955
|
+
wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
|
|
956
|
+
sql: this.#engineDb.stats,
|
|
957
|
+
});
|
|
958
|
+
return json(response);
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
const status = statusOf(error);
|
|
962
|
+
if (status === 409)
|
|
963
|
+
this.#counters.resets++;
|
|
964
|
+
if (status === 500)
|
|
965
|
+
this.#counters.invariantFailures++;
|
|
966
|
+
const state = this.#engineStateBestEffort();
|
|
967
|
+
this.#log({
|
|
968
|
+
namespaceHash: namespace,
|
|
969
|
+
requestKind: 'pull',
|
|
970
|
+
resultClass: status === 409 ? 'reset' : 'error',
|
|
971
|
+
inputCookie: body?.cookie ?? null,
|
|
972
|
+
outputCookie: null,
|
|
973
|
+
retainedFloor: state?.floor ?? null,
|
|
974
|
+
currentWatermark: state?.watermark ?? null,
|
|
975
|
+
changeRowsScanned: null,
|
|
976
|
+
changeRowsIncluded: 0,
|
|
977
|
+
queriesRecomputed: 0,
|
|
978
|
+
rowPuts: 0,
|
|
979
|
+
rowDeletes: 0,
|
|
980
|
+
lmidAdvances: 0,
|
|
981
|
+
transactionMs,
|
|
982
|
+
totalMs: performance.now() - started,
|
|
983
|
+
resetReason: status === 409 ? errorMessage(error) : null,
|
|
984
|
+
});
|
|
985
|
+
return json({ error: errorMessage(error) }, status);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async #push(request, claims, namespace, upstreamPath) {
|
|
989
|
+
this.#counters.pushes++;
|
|
990
|
+
this.#engineDb.resetStats();
|
|
991
|
+
const started = performance.now();
|
|
992
|
+
let transactionMs = 0;
|
|
993
|
+
let lmidAdvances = 0;
|
|
994
|
+
let resultClass = 'success';
|
|
995
|
+
if (!this.#writerEnabled()) {
|
|
996
|
+
// Workerd requires the request stream to be consumed before the DO
|
|
997
|
+
// returns a response. Discard it without parsing or logging payloads.
|
|
998
|
+
await request.arrayBuffer();
|
|
999
|
+
const state = this.#engineStateBestEffort();
|
|
1000
|
+
this.#log({
|
|
1001
|
+
namespaceHash: namespace,
|
|
1002
|
+
requestKind: 'push',
|
|
1003
|
+
resultClass: 'writer_disabled',
|
|
1004
|
+
inputCookie: null,
|
|
1005
|
+
outputCookie: null,
|
|
1006
|
+
retainedFloor: state?.floor ?? null,
|
|
1007
|
+
currentWatermark: state?.watermark ?? null,
|
|
1008
|
+
changeRowsScanned: 0,
|
|
1009
|
+
changeRowsIncluded: 0,
|
|
1010
|
+
queriesRecomputed: 0,
|
|
1011
|
+
rowPuts: 0,
|
|
1012
|
+
rowDeletes: 0,
|
|
1013
|
+
lmidAdvances: 0,
|
|
1014
|
+
transactionMs: 0,
|
|
1015
|
+
totalMs: performance.now() - started,
|
|
1016
|
+
resetReason: 'writer disabled by operator',
|
|
1017
|
+
wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
|
|
1018
|
+
sql: this.#engineDb.stats,
|
|
1019
|
+
});
|
|
1020
|
+
return json({ error: 'writer disabled by operator' }, 503);
|
|
1021
|
+
}
|
|
1022
|
+
if (config.mutateUrl) {
|
|
1023
|
+
try {
|
|
1024
|
+
const bytes = await request.arrayBuffer();
|
|
1025
|
+
const body = JSON.parse(new TextDecoder().decode(bytes));
|
|
1026
|
+
const plan = this.#wasm(() => engine_push_validate(body));
|
|
1027
|
+
if (plan.kind === 'respond')
|
|
1028
|
+
return json(plan.response);
|
|
1029
|
+
const endpoint = new URL(`${upstreamPath ?? ''}${config.mutateUrl}`, config.mutateOrigin ?? 'https://upstream.invalid');
|
|
1030
|
+
const headers = new Headers(request.headers);
|
|
1031
|
+
headers.delete(CLAIMS_HEADER);
|
|
1032
|
+
headers.delete(NAMESPACE_HEADER);
|
|
1033
|
+
headers.delete(UPSTREAM_PATH_HEADER);
|
|
1034
|
+
headers.set('host', endpoint.host);
|
|
1035
|
+
const upstreamResponse = await this.#fetchDelegatedPush(endpoint, headers, bytes, this.#engineState().upstreamWatermark === '0');
|
|
1036
|
+
if (!upstreamResponse.ok) {
|
|
1037
|
+
return new Response(upstreamResponse.body, upstreamResponse);
|
|
1038
|
+
}
|
|
1039
|
+
const upstreamBody = (await upstreamResponse.json());
|
|
1040
|
+
const delegatedResponse = upstreamBody.pushResponse ?? upstreamBody;
|
|
1041
|
+
if (isStructuredPushFailed(delegatedResponse)) {
|
|
1042
|
+
// PushFailed is a successful protocol response describing an
|
|
1043
|
+
// application-level failure. There are intentionally no mutation
|
|
1044
|
+
// acknowledgements to finalize in the host; preserve the body so
|
|
1045
|
+
// the Zero client can apply its retry/error policy.
|
|
1046
|
+
return json({ pushResponse: delegatedResponse });
|
|
1047
|
+
}
|
|
1048
|
+
const acknowledged = typeof upstreamBody.pushResponse === 'object' &&
|
|
1049
|
+
upstreamBody.pushResponse !== null &&
|
|
1050
|
+
'mutations' in upstreamBody.pushResponse
|
|
1051
|
+
? upstreamBody.pushResponse.mutations
|
|
1052
|
+
: upstreamBody.mutations;
|
|
1053
|
+
if (!Array.isArray(acknowledged)) {
|
|
1054
|
+
throw new Error('delegated push returned no mutation results');
|
|
1055
|
+
}
|
|
1056
|
+
for (const mutation of plan.mutations) {
|
|
1057
|
+
const ack = acknowledged.some((result) => result.id?.clientID === mutation.clientID &&
|
|
1058
|
+
String(result.id?.id) === mutation.id);
|
|
1059
|
+
if (!ack) {
|
|
1060
|
+
throw new Error(`delegated push did not acknowledge ${mutation.clientID}:${mutation.id}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// the delegated app response is causally visible through DATA by
|
|
1064
|
+
// contract. start an ingest round after that response, even if an
|
|
1065
|
+
// older round is still in flight, then journal lmids. every capped
|
|
1066
|
+
// log prefix therefore preserves effects-before-ack.
|
|
1067
|
+
await this.#ingestAfterCurrent(upstreamPath);
|
|
1068
|
+
for (const mutation of plan.mutations) {
|
|
1069
|
+
this.ctx.storage.transactionSync(() => {
|
|
1070
|
+
const decision = this.#wasm(() => engine_preflight(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id, claims.userID));
|
|
1071
|
+
if (decision.kind === 'applied') {
|
|
1072
|
+
this.#wasm(() => engine_finalize(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id));
|
|
1073
|
+
lmidAdvances++;
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
if (lmidAdvances > 0) {
|
|
1078
|
+
this.ctx.storage.transactionSync(() => this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges())));
|
|
1079
|
+
}
|
|
1080
|
+
return json({ pushResponse: delegatedResponse });
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
const status = statusOf(error);
|
|
1084
|
+
return json(errorBody(error), status);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
1088
|
+
const body = await requestObject(request);
|
|
1089
|
+
const beforeMutationFault = this.#takeFault('push_before_mutation');
|
|
1090
|
+
if (beforeMutationFault)
|
|
1091
|
+
throw this.#faultError(beforeMutationFault, 'push_before_mutation');
|
|
1092
|
+
const plan = this.#wasm(() => engine_push_validate(body));
|
|
1093
|
+
if (plan.kind === 'respond')
|
|
1094
|
+
return json(plan.response);
|
|
1095
|
+
const results = [];
|
|
1096
|
+
for (const mutation of plan.mutations) {
|
|
1097
|
+
const deferred = createPostCommitEffects();
|
|
1098
|
+
try {
|
|
1099
|
+
const txStarted = performance.now();
|
|
1100
|
+
// consume the before-commit fault OUTSIDE the transaction it is
|
|
1101
|
+
// about to abort: taken inside, the control-table delete rolls
|
|
1102
|
+
// back with the abort and the fault re-fires on every retry
|
|
1103
|
+
// instead of being one-shot.
|
|
1104
|
+
const beforeCommitFault = this.#takeFault('push_after_write_before_commit');
|
|
1105
|
+
const preflight = await this.ctx.storage.transaction(async () => {
|
|
1106
|
+
// Storage transactions may retry their closure. Never carry a
|
|
1107
|
+
// deferred effect from an abandoned attempt into the commit.
|
|
1108
|
+
deferred.beginAttempt();
|
|
1109
|
+
const decision = this.#wasm(() => engine_preflight(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id, claims.userID));
|
|
1110
|
+
if (decision.kind === 'replay')
|
|
1111
|
+
return decision;
|
|
1112
|
+
const mutator = config.mutators?.[mutation.name];
|
|
1113
|
+
if (!mutator)
|
|
1114
|
+
throw new Error(`unknown mutator: ${mutation.name}`);
|
|
1115
|
+
await mutator(this.#mutatorSql, mutation.args[0] ?? null, {
|
|
1116
|
+
claims,
|
|
1117
|
+
clientID: mutation.clientID,
|
|
1118
|
+
mutationID: mutation.id,
|
|
1119
|
+
defer: deferred.defer,
|
|
1120
|
+
});
|
|
1121
|
+
if (beforeCommitFault)
|
|
1122
|
+
throw this.#faultError(beforeCommitFault, 'push_after_write_before_commit');
|
|
1123
|
+
this.#wasm(() => engine_finalize(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id));
|
|
1124
|
+
return decision;
|
|
1125
|
+
});
|
|
1126
|
+
transactionMs += performance.now() - txStarted;
|
|
1127
|
+
if (preflight.kind === 'replay') {
|
|
1128
|
+
const expected = preflight
|
|
1129
|
+
.expected;
|
|
1130
|
+
results.push({
|
|
1131
|
+
clientID: mutation.clientID,
|
|
1132
|
+
id: mutation.id,
|
|
1133
|
+
result: {
|
|
1134
|
+
error: 'alreadyProcessed',
|
|
1135
|
+
details: `Ignoring mutation from ${mutation.clientID} with ID ${mutation.id} as it was already processed. Expected: ${expected}`,
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
lmidAdvances++;
|
|
1141
|
+
results.push({
|
|
1142
|
+
clientID: mutation.clientID,
|
|
1143
|
+
id: mutation.id,
|
|
1144
|
+
result: {},
|
|
1145
|
+
});
|
|
1146
|
+
// Keep the advisory fan-out off the push response's critical path.
|
|
1147
|
+
// waitUntil anchors the coalescing timer across request completion,
|
|
1148
|
+
// while the next serialized client push can join the same batch.
|
|
1149
|
+
this.ctx.waitUntil(this.#enqueueWake(mutation.clientID));
|
|
1150
|
+
await deferred.runAfterCommit((error) => {
|
|
1151
|
+
this.#counters.externalEffectFailures++;
|
|
1152
|
+
console.error(JSON.stringify({
|
|
1153
|
+
event: 'sync_external_effect_error',
|
|
1154
|
+
hostVersion: config.hostVersion,
|
|
1155
|
+
error: errorMessage(error),
|
|
1156
|
+
}));
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
const isAppError = isMutationApplicationError(error);
|
|
1161
|
+
if (!isAppError)
|
|
1162
|
+
throw error;
|
|
1163
|
+
this.#counters.applicationErrors++;
|
|
1164
|
+
resultClass = 'application_error';
|
|
1165
|
+
const appError = error;
|
|
1166
|
+
const txStarted = performance.now();
|
|
1167
|
+
await this.ctx.storage.transaction(async () => {
|
|
1168
|
+
this.#wasm(() => engine_record_app_error(this.#engineDb, plan.clientGroupID, mutation.clientID, mutation.id, claims.userID));
|
|
1169
|
+
});
|
|
1170
|
+
transactionMs += performance.now() - txStarted;
|
|
1171
|
+
lmidAdvances++;
|
|
1172
|
+
results.push({
|
|
1173
|
+
clientID: mutation.clientID,
|
|
1174
|
+
id: mutation.id,
|
|
1175
|
+
result: {
|
|
1176
|
+
error: 'app',
|
|
1177
|
+
message: appError.message,
|
|
1178
|
+
details: appError.details ?? appError.message,
|
|
1179
|
+
},
|
|
1180
|
+
});
|
|
1181
|
+
this.ctx.waitUntil(this.#enqueueWake(mutation.clientID));
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (plan.mutations.length > 0) {
|
|
1185
|
+
const txStarted = performance.now();
|
|
1186
|
+
await this.ctx.storage.transaction(async () => {
|
|
1187
|
+
this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()));
|
|
1188
|
+
});
|
|
1189
|
+
transactionMs += performance.now() - txStarted;
|
|
1190
|
+
this.#counters.retentionRuns++;
|
|
1191
|
+
}
|
|
1192
|
+
const afterCommitFault = this.#takeFault('push_after_commit_before_response');
|
|
1193
|
+
if (afterCommitFault)
|
|
1194
|
+
throw this.#faultError(afterCommitFault, 'push_after_commit_before_response');
|
|
1195
|
+
const response = this.#wasm(() => engine_assemble_push_response(results));
|
|
1196
|
+
const state = this.#engineStateBestEffort();
|
|
1197
|
+
this.#log({
|
|
1198
|
+
namespaceHash: namespace,
|
|
1199
|
+
requestKind: 'push',
|
|
1200
|
+
resultClass,
|
|
1201
|
+
inputCookie: null,
|
|
1202
|
+
outputCookie: null,
|
|
1203
|
+
retainedFloor: state?.floor ?? null,
|
|
1204
|
+
currentWatermark: state?.watermark ?? null,
|
|
1205
|
+
changeRowsScanned: 0,
|
|
1206
|
+
changeRowsIncluded: 0,
|
|
1207
|
+
queriesRecomputed: 0,
|
|
1208
|
+
rowPuts: 0,
|
|
1209
|
+
rowDeletes: 0,
|
|
1210
|
+
lmidAdvances,
|
|
1211
|
+
transactionMs,
|
|
1212
|
+
totalMs: performance.now() - started,
|
|
1213
|
+
resetReason: null,
|
|
1214
|
+
wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
|
|
1215
|
+
sql: this.#engineDb.stats,
|
|
1216
|
+
});
|
|
1217
|
+
if (this.#dropNextPushResponse) {
|
|
1218
|
+
this.#dropNextPushResponse = false;
|
|
1219
|
+
return json({ error: 'intentionally dropped push response' }, 503);
|
|
1220
|
+
}
|
|
1221
|
+
return json(response);
|
|
1222
|
+
}
|
|
1223
|
+
catch (error) {
|
|
1224
|
+
const status = statusOf(error);
|
|
1225
|
+
if (status === 500)
|
|
1226
|
+
this.#counters.invariantFailures++;
|
|
1227
|
+
const state = this.#engineStateBestEffort();
|
|
1228
|
+
this.#log({
|
|
1229
|
+
namespaceHash: namespace,
|
|
1230
|
+
requestKind: 'push',
|
|
1231
|
+
resultClass: 'error',
|
|
1232
|
+
inputCookie: null,
|
|
1233
|
+
outputCookie: null,
|
|
1234
|
+
retainedFloor: state?.floor ?? null,
|
|
1235
|
+
currentWatermark: state?.watermark ?? null,
|
|
1236
|
+
changeRowsScanned: 0,
|
|
1237
|
+
changeRowsIncluded: 0,
|
|
1238
|
+
queriesRecomputed: 0,
|
|
1239
|
+
rowPuts: 0,
|
|
1240
|
+
rowDeletes: 0,
|
|
1241
|
+
lmidAdvances,
|
|
1242
|
+
transactionMs,
|
|
1243
|
+
totalMs: performance.now() - started,
|
|
1244
|
+
resetReason: null,
|
|
1245
|
+
});
|
|
1246
|
+
return json({ error: errorMessage(error) }, status);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
#wake(request) {
|
|
1250
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
1251
|
+
return json({ error: 'websocket upgrade required' }, 426);
|
|
1252
|
+
}
|
|
1253
|
+
const clientID = new URL(request.url).searchParams.get('clientID');
|
|
1254
|
+
if (!clientID)
|
|
1255
|
+
return json({ error: 'clientID is required' }, 400);
|
|
1256
|
+
const pair = new WebSocketPair();
|
|
1257
|
+
const [client, server] = Object.values(pair);
|
|
1258
|
+
server.serializeAttachment({ clientID });
|
|
1259
|
+
this.ctx.acceptWebSocket(server, [`client:${clientID}`]);
|
|
1260
|
+
// The alarm is only a safety net for an actively connected consumer.
|
|
1261
|
+
// A namespace with no wake socket has nobody to notify; its next pull or
|
|
1262
|
+
// push ingests synchronously. Arming from construction made every
|
|
1263
|
+
// namespace poll upstream forever after its first request, even across
|
|
1264
|
+
// DO eviction, producing a permanent rows-written floor at zero traffic.
|
|
1265
|
+
this.#armUpstreamAlarm();
|
|
1266
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
1267
|
+
}
|
|
1268
|
+
#admin(route, request, upstreamPath) {
|
|
1269
|
+
if (route === '/admin/health')
|
|
1270
|
+
return json({ ok: true });
|
|
1271
|
+
if (route === '/admin/upstream-write-budget' && request.method === 'GET')
|
|
1272
|
+
return this.#upstreamWriteBudgetStatus();
|
|
1273
|
+
if (route === '/admin/status') {
|
|
1274
|
+
const heap = performance.memory;
|
|
1275
|
+
return this.ctx.storage.getAlarm().then((upstreamAlarmAt) => json({
|
|
1276
|
+
bootID: this.#bootID,
|
|
1277
|
+
idleTeardownMs,
|
|
1278
|
+
hibernations: this.#hibernations,
|
|
1279
|
+
databaseSizeBytes: this.ctx.storage.sql.databaseSize,
|
|
1280
|
+
connectedWakeSockets: this.ctx.getWebSockets().length,
|
|
1281
|
+
upstreamAlarmAt,
|
|
1282
|
+
writerEnabled: this.#writerEnabled(),
|
|
1283
|
+
wasmMemoryBytes: engine_memory_bytes(),
|
|
1284
|
+
heapUsedBytes: heap?.usedJSHeapSize ?? null,
|
|
1285
|
+
heapTotalBytes: heap?.totalJSHeapSize ?? null,
|
|
1286
|
+
heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
|
|
1287
|
+
engine: this.#engineStateBestEffort(),
|
|
1288
|
+
counters: this.#counters,
|
|
1289
|
+
ingestBreaker: this.#ingestBreaker.status(),
|
|
1290
|
+
}));
|
|
1291
|
+
}
|
|
1292
|
+
if (route === '/admin/sql') {
|
|
1293
|
+
return request.json().then((body) => {
|
|
1294
|
+
const { params, query } = body;
|
|
1295
|
+
if (typeof query !== 'string')
|
|
1296
|
+
return json({ error: 'query is required' }, 400);
|
|
1297
|
+
try {
|
|
1298
|
+
return json({ rows: this.#directSql.query(query, decodeSqlParams(params)) });
|
|
1299
|
+
}
|
|
1300
|
+
catch (error) {
|
|
1301
|
+
if (error instanceof TypeError && error.message.startsWith('params')) {
|
|
1302
|
+
return json({ error: `invalid params: ${error.message}` }, 400);
|
|
1303
|
+
}
|
|
1304
|
+
if (error instanceof TypeError &&
|
|
1305
|
+
error.message === 'transaction SQL is host-owned and forbidden') {
|
|
1306
|
+
return json({ error: error.message }, 400);
|
|
1307
|
+
}
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
if (route === '/admin/invalidate') {
|
|
1313
|
+
this.ctx.storage.transactionSync(() => this.#wasm(() => engine_invalidate(this.#engineDb)));
|
|
1314
|
+
return json({ ok: true, engine: this.#engineState() });
|
|
1315
|
+
}
|
|
1316
|
+
if (route === '/admin/resnapshot') {
|
|
1317
|
+
if (request.method !== 'POST') {
|
|
1318
|
+
return json({ error: 'method not allowed' }, 405);
|
|
1319
|
+
}
|
|
1320
|
+
return (async () => {
|
|
1321
|
+
try {
|
|
1322
|
+
const beforeUpstreamWatermark = this.#engineState().upstreamWatermark;
|
|
1323
|
+
const applied = await this.#ingest(upstreamPath, true);
|
|
1324
|
+
const engine = this.#engineState();
|
|
1325
|
+
return json({
|
|
1326
|
+
ok: true,
|
|
1327
|
+
applied,
|
|
1328
|
+
beforeUpstreamWatermark,
|
|
1329
|
+
afterUpstreamWatermark: engine.upstreamWatermark,
|
|
1330
|
+
engine,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
catch (error) {
|
|
1334
|
+
return json(errorBody(error), statusOf(error));
|
|
1335
|
+
}
|
|
1336
|
+
})();
|
|
1337
|
+
}
|
|
1338
|
+
if (route === '/admin/drop-next-push-response') {
|
|
1339
|
+
this.#dropNextPushResponse = true;
|
|
1340
|
+
return json({ ok: true });
|
|
1341
|
+
}
|
|
1342
|
+
if (route === '/admin/restart') {
|
|
1343
|
+
this.ctx.abort('admin requested durable object restart');
|
|
1344
|
+
return json({ ok: true, bootID: this.#bootID });
|
|
1345
|
+
}
|
|
1346
|
+
if (route === '/admin/visibility') {
|
|
1347
|
+
return request.json().then((body) => {
|
|
1348
|
+
const enabled = Boolean(body.enabled);
|
|
1349
|
+
this.#controlSet('visibilityEnabled', enabled ? '1' : '0');
|
|
1350
|
+
return json({ ok: true, enabled });
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
if (route === '/admin/query-aware') {
|
|
1354
|
+
return request.json().then((body) => {
|
|
1355
|
+
const enabled = Boolean(body.enabled);
|
|
1356
|
+
this.#controlSet('queryAwareOverride', enabled ? '1' : '0');
|
|
1357
|
+
return json({ ok: true, enabled });
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
if (route === '/admin/retention') {
|
|
1361
|
+
return request
|
|
1362
|
+
.json()
|
|
1363
|
+
.catch(() => ({}))
|
|
1364
|
+
.then((body) => {
|
|
1365
|
+
const value = Number(body.retainChanges);
|
|
1366
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
1367
|
+
return json({ error: 'invalid retainChanges' }, 400);
|
|
1368
|
+
this.#controlSet('retainChanges', String(value));
|
|
1369
|
+
return json({ ok: true, retainChanges: value });
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
if (route === '/admin/writer') {
|
|
1373
|
+
if (request.method === 'GET')
|
|
1374
|
+
return json({ writerEnabled: this.#writerEnabled() });
|
|
1375
|
+
return request
|
|
1376
|
+
.json()
|
|
1377
|
+
.catch(() => ({}))
|
|
1378
|
+
.then((body) => {
|
|
1379
|
+
const enabled = body.enabled;
|
|
1380
|
+
if (typeof enabled !== 'boolean')
|
|
1381
|
+
return json({ error: 'enabled must be a boolean' }, 400);
|
|
1382
|
+
this.#controlSet('writerEnabled', enabled ? '1' : '0');
|
|
1383
|
+
return json({ ok: true, writerEnabled: enabled });
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
if (route === '/admin/ingest-breaker') {
|
|
1387
|
+
if (request.method === 'GET')
|
|
1388
|
+
return json(this.#ingestBreaker.status());
|
|
1389
|
+
this.#ingestBreaker.reopen();
|
|
1390
|
+
this.#controlDelete('ingestBreakerReason', 'ingestBreakerRetryAt', 'ingestBreakerTrips');
|
|
1391
|
+
console.log(JSON.stringify({ event: 'sync_upstream_ingest_breaker_reopened' }));
|
|
1392
|
+
return json({ ok: true, ...this.#ingestBreaker.status() });
|
|
1393
|
+
}
|
|
1394
|
+
if (route === '/admin/fault') {
|
|
1395
|
+
return request
|
|
1396
|
+
.json()
|
|
1397
|
+
.catch(() => ({}))
|
|
1398
|
+
.then((body) => {
|
|
1399
|
+
const value = body;
|
|
1400
|
+
if (value.clear === true) {
|
|
1401
|
+
this.#directSql.exec("DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')");
|
|
1402
|
+
return json({ ok: true, armed: null });
|
|
1403
|
+
}
|
|
1404
|
+
const points = [
|
|
1405
|
+
'push_before_mutation',
|
|
1406
|
+
'push_after_write_before_commit',
|
|
1407
|
+
'push_after_commit_before_response',
|
|
1408
|
+
'pull_during_tx',
|
|
1409
|
+
'pull_after_commit',
|
|
1410
|
+
];
|
|
1411
|
+
if (!points.includes(value.point))
|
|
1412
|
+
return json({ error: 'invalid fault point' }, 400);
|
|
1413
|
+
if (value.kind !== 'error' && value.kind !== 'quota')
|
|
1414
|
+
return json({ error: 'invalid fault kind' }, 400);
|
|
1415
|
+
this.#controlSet('faultPoint', value.point);
|
|
1416
|
+
this.#controlSet('faultKind', value.kind);
|
|
1417
|
+
return json({
|
|
1418
|
+
ok: true,
|
|
1419
|
+
armed: { point: value.point, kind: value.kind },
|
|
1420
|
+
});
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
return json({ error: 'not found' }, 404);
|
|
1424
|
+
}
|
|
1425
|
+
async fetch(request) {
|
|
1426
|
+
this.#simulateIdleTeardown(Date.now());
|
|
1427
|
+
const route = routeAfterNamespace(new URL(request.url).pathname);
|
|
1428
|
+
const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown';
|
|
1429
|
+
const upstreamPath = this.#rememberUpstreamPath(request);
|
|
1430
|
+
if (route.startsWith('/admin/'))
|
|
1431
|
+
return this.#admin(route, request, upstreamPath);
|
|
1432
|
+
if (route === '/wake' && request.method === 'GET')
|
|
1433
|
+
return this.#wake(request);
|
|
1434
|
+
if (route === '/notify' && request.method === 'POST') {
|
|
1435
|
+
try {
|
|
1436
|
+
const applied = await this.#ingest(upstreamPath);
|
|
1437
|
+
return json({ ok: true, applied });
|
|
1438
|
+
}
|
|
1439
|
+
catch (error) {
|
|
1440
|
+
return json(errorBody(error), statusOf(error));
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
const claims = claimsFromRequest(request);
|
|
1444
|
+
if (!claims)
|
|
1445
|
+
return json({ error: 'missing normalized claims' }, 401);
|
|
1446
|
+
if (route === '/pull' && request.method === 'POST') {
|
|
1447
|
+
try {
|
|
1448
|
+
await this.#ingest(upstreamPath);
|
|
1449
|
+
}
|
|
1450
|
+
catch (error) {
|
|
1451
|
+
// Workerd requires a forwarded request body to be consumed even when
|
|
1452
|
+
// ingest fails before the pull handler parses it.
|
|
1453
|
+
await request.arrayBuffer();
|
|
1454
|
+
return json(errorBody(error), statusOf(error));
|
|
1455
|
+
}
|
|
1456
|
+
return this.#pull(request, claims, namespace);
|
|
1457
|
+
}
|
|
1458
|
+
if (route === '/push' && request.method === 'POST') {
|
|
1459
|
+
// A push may be the first request for a fresh namespace. DATA's
|
|
1460
|
+
// /changes call is also its schema-provisioning barrier, so complete it
|
|
1461
|
+
// before delegating the mutation to APP. Pull already enforces this
|
|
1462
|
+
// ordering above; skipping it here let APP race half-created tables and
|
|
1463
|
+
// surface a terminal, bodyless 500 to Zero.
|
|
1464
|
+
try {
|
|
1465
|
+
await this.#ingest(upstreamPath);
|
|
1466
|
+
}
|
|
1467
|
+
catch (error) {
|
|
1468
|
+
await request.arrayBuffer();
|
|
1469
|
+
return json(errorBody(error), statusOf(error));
|
|
1470
|
+
}
|
|
1471
|
+
return this.#push(request, claims, namespace, upstreamPath);
|
|
1472
|
+
}
|
|
1473
|
+
return json({ error: 'not found' }, 404);
|
|
1474
|
+
}
|
|
1475
|
+
async alarm() {
|
|
1476
|
+
if (this.ctx.getWebSockets().length === 0)
|
|
1477
|
+
return;
|
|
1478
|
+
try {
|
|
1479
|
+
await this.#ingest();
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
console.error(JSON.stringify({
|
|
1483
|
+
event: 'sync_upstream_ingest_error',
|
|
1484
|
+
status: statusOf(error),
|
|
1485
|
+
error: errorMessage(error),
|
|
1486
|
+
}));
|
|
1487
|
+
}
|
|
1488
|
+
finally {
|
|
1489
|
+
if (this.ctx.getWebSockets().length > 0) {
|
|
1490
|
+
const retryAfterMs = this.#ingestBreaker.status().retryAfterMs;
|
|
1491
|
+
await this.ctx.storage.setAlarm(Date.now() + Math.max(upstreamIntervalMs, retryAfterMs));
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
webSocketMessage(socket, message) {
|
|
1496
|
+
if (message === 'ping')
|
|
1497
|
+
socket.send('pong');
|
|
1498
|
+
}
|
|
1499
|
+
webSocketClose(socket, code, reason, _wasClean) {
|
|
1500
|
+
// The peer already closed, so echo the close to release the socket — but
|
|
1501
|
+
// WebSocket.close() rejects reserved/absent codes (1005 "no status", 1006
|
|
1502
|
+
// abnormal, 1015) with InvalidAccessError, and a real browser routinely
|
|
1503
|
+
// closes with 1001/1005. An uncaught throw here aborts the DO, so only
|
|
1504
|
+
// echo an application-permitted code and otherwise close cleanly.
|
|
1505
|
+
const echoable = code === 1000 || (code >= 3000 && code <= 4999);
|
|
1506
|
+
socketCloseQuietly(socket, echoable ? code : 1000, echoable ? reason : '');
|
|
1507
|
+
}
|
|
1508
|
+
webSocketError(socket, _error) {
|
|
1509
|
+
socketCloseQuietly(socket, 1011, 'wake socket error');
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
}
|