orez-sync-cf-host 0.14.0 → 0.14.1
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 +25 -10
- package/dist/host.js +77 -5
- package/dist/vite-wasm-loader.d.ts +2 -1
- package/dist/vite-wasm-loader.js +10 -2
- package/dist/write-safeguards.d.ts +15 -1
- package/dist/write-safeguards.js +17 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,6 +20,17 @@ belong in
|
|
|
20
20
|
`ctx.defer`, which runs only after commit. Application failures use the
|
|
21
21
|
required second transaction to advance the LMID marker.
|
|
22
22
|
|
|
23
|
+
The Worker emits structured `sync_worker_stage` logs around `authenticate` and
|
|
24
|
+
the namespace Durable Object `fetch`. Successful requests are sampled at 1%.
|
|
25
|
+
Errors are always logged, and a stage still waiting after five seconds emits an
|
|
26
|
+
unsampled `waiting` event before the request can be terminated externally. A
|
|
27
|
+
slow stage that later completes also logs its final duration. The event carries
|
|
28
|
+
request kind, host version, duration, outcome, response status, and the existing
|
|
29
|
+
hashed namespace once forwarding begins. Healthy requests pay
|
|
30
|
+
one random comparison per request plus one timer arm, timer cancellation, and
|
|
31
|
+
one clock read per measured stage. The sampled completion reads the clock once
|
|
32
|
+
more. No extra fetch, storage operation, or await is added.
|
|
33
|
+
|
|
23
34
|
Every pull is query-driven. Set `config.queries` to the same registry used by
|
|
24
35
|
the client. The host resolves desired named queries in-process and passes
|
|
25
36
|
authenticated claims as their context, so permission scoping remains inside the
|
|
@@ -34,13 +45,14 @@ non-ASCII case pairs can diverge from PostgreSQL.
|
|
|
34
45
|
Every runtime uses the same `orez-sync-cf-host/wasm-module.wasm` import and the same
|
|
35
46
|
`initSync` path. Configure the loader that matches the host:
|
|
36
47
|
|
|
37
|
-
| Host | Configuration
|
|
38
|
-
| ---------------------------- |
|
|
39
|
-
| Workerd / Wrangler | Map the package's `wasm-module.wasm` export as a compiled Wasm module
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
| Node
|
|
48
|
+
| Host | Configuration | Module value |
|
|
49
|
+
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
50
|
+
| Workerd / Wrangler | Map the package's `wasm-module.wasm` export as a compiled Wasm module | Cloudflare `CompiledWasm` |
|
|
51
|
+
| Workerd build through Vite | Add `orezSyncCfHostWasm({ runtime: 'workerd' })`, preload the Node or Bun loader during the build, and map the export as a compiled Wasm module | Cloudflare `CompiledWasm` |
|
|
52
|
+
| Bun | Preload `orez-sync-cf-host/bun-wasm-loader` | `WebAssembly.Module` compiled by the Bun loader |
|
|
53
|
+
| Vite serve / SSR development | Add `orezSyncCfHostWasm()` from `orez-sync-cf-host/vite-wasm-loader` | `WebAssembly.Module` built from package bytes by Vite |
|
|
54
|
+
| Direct Node >= 22.15 | Preload `orez-sync-cf-host/node-wasm-loader` with `NODE_OPTIONS=--import` | `WebAssembly.Module` compiled by the Node loader |
|
|
55
|
+
| Node production bundle | Keep the same Vite plugin active for the production SSR build | `WebAssembly.Module` built from bytes embedded in the bundle |
|
|
44
56
|
|
|
45
57
|
The Vite plugin also keeps `orez-sync-cf-host` inside Vite's SSR pipeline.
|
|
46
58
|
|
|
@@ -84,9 +96,12 @@ export default {
|
|
|
84
96
|
}
|
|
85
97
|
```
|
|
86
98
|
|
|
87
|
-
If one Vite config targets both Node and Workerd,
|
|
88
|
-
the
|
|
89
|
-
|
|
99
|
+
If one Vite config targets both Node and Workerd, pass `runtime: 'workerd'` for
|
|
100
|
+
the Workerd build. Serve mode still embeds the module for Vite's Node SSR
|
|
101
|
+
runtime. Build mode preserves the Wasm import for the platform's `CompiledWasm`
|
|
102
|
+
mapping instead of embedding bytes that Workerd cannot compile. Because Vite
|
|
103
|
+
imports the generated server chunks while rendering static routes, start that
|
|
104
|
+
build with the Node or Bun loader shown above.
|
|
90
105
|
|
|
91
106
|
## Wake channel and eviction
|
|
92
107
|
|
package/dist/host.js
CHANGED
|
@@ -18,6 +18,8 @@ const WAKE_SUBSCRIBER_TAG = 'orez:wake-subscriber';
|
|
|
18
18
|
const IDENTITY_HEADER = 'x-orez-sync-identity';
|
|
19
19
|
const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
|
|
20
20
|
const MIN_SNAPSHOT_PAGE_ROWS = 100;
|
|
21
|
+
const WORKER_STAGE_TELEMETRY_SAMPLE_RATE = 0.01;
|
|
22
|
+
const WORKER_STAGE_WAITING_MS = 5_000;
|
|
21
23
|
function upstreamBatchTables(batch) {
|
|
22
24
|
const tables = new Set();
|
|
23
25
|
for (const change of batch.changes) {
|
|
@@ -165,6 +167,42 @@ async function namespaceHash(namespace) {
|
|
|
165
167
|
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(namespace));
|
|
166
168
|
return Array.from(new Uint8Array(bytes).slice(0, 8), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
167
169
|
}
|
|
170
|
+
async function timeWorkerStage(fields, work) {
|
|
171
|
+
const started = performance.now();
|
|
172
|
+
let waited = false;
|
|
173
|
+
const emit = (outcome, value) => {
|
|
174
|
+
try {
|
|
175
|
+
console.log(JSON.stringify({
|
|
176
|
+
event: 'sync_worker_stage',
|
|
177
|
+
hostVersion: fields.hostVersion,
|
|
178
|
+
requestKind: fields.requestKind,
|
|
179
|
+
stage: fields.stage,
|
|
180
|
+
outcome,
|
|
181
|
+
durationMs: Math.round((performance.now() - started) * 1_000) / 1_000,
|
|
182
|
+
namespaceHash: fields.namespaceHash,
|
|
183
|
+
status: value instanceof Response ? value.status : null,
|
|
184
|
+
sampleRate: outcome === 'success' && !waited ? WORKER_STAGE_TELEMETRY_SAMPLE_RATE : 1,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
catch { }
|
|
188
|
+
};
|
|
189
|
+
const waitingTimer = setTimeout(() => {
|
|
190
|
+
waited = true;
|
|
191
|
+
emit('waiting');
|
|
192
|
+
}, WORKER_STAGE_WAITING_MS);
|
|
193
|
+
try {
|
|
194
|
+
const value = await work();
|
|
195
|
+
clearTimeout(waitingTimer);
|
|
196
|
+
if (fields.sampled || waited)
|
|
197
|
+
emit('success', value);
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
clearTimeout(waitingTimer);
|
|
202
|
+
emit('error');
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
168
206
|
function socketAttachment(socket) {
|
|
169
207
|
const value = socket.deserializeAttachment();
|
|
170
208
|
return value && typeof value.clientID === 'string' ? value : null;
|
|
@@ -239,6 +277,8 @@ export function createSyncWorker(config) {
|
|
|
239
277
|
if (!namespace)
|
|
240
278
|
return new Response('orez sync-cf-host', { status: 200 });
|
|
241
279
|
const route = routeAfterNamespace(new URL(request.url).pathname);
|
|
280
|
+
const requestKind = route.startsWith('/') ? route.slice(1) : route;
|
|
281
|
+
const sampled = Math.random() < WORKER_STAGE_TELEMETRY_SAMPLE_RATE;
|
|
242
282
|
const isAdmin = route.startsWith('/admin/');
|
|
243
283
|
let wakeUserID = null;
|
|
244
284
|
if (route === '/wake') {
|
|
@@ -313,7 +353,13 @@ export function createSyncWorker(config) {
|
|
|
313
353
|
// the callback attached survives to the wire.
|
|
314
354
|
let claims;
|
|
315
355
|
try {
|
|
316
|
-
claims = await
|
|
356
|
+
claims = await timeWorkerStage({
|
|
357
|
+
hostVersion: config.hostVersion,
|
|
358
|
+
requestKind,
|
|
359
|
+
stage: 'authenticate',
|
|
360
|
+
namespaceHash: null,
|
|
361
|
+
sampled,
|
|
362
|
+
}, () => config.authenticate(request, env));
|
|
317
363
|
}
|
|
318
364
|
catch (error) {
|
|
319
365
|
return errorResponse(error);
|
|
@@ -343,7 +389,8 @@ export function createSyncWorker(config) {
|
|
|
343
389
|
// nothing gained by moving them here.
|
|
344
390
|
if (wakeUserID)
|
|
345
391
|
headers.set(IDENTITY_HEADER, encodeURIComponent(wakeUserID));
|
|
346
|
-
|
|
392
|
+
const hashedNamespace = await namespaceHash(namespace);
|
|
393
|
+
headers.set(NAMESPACE_HEADER, hashedNamespace);
|
|
347
394
|
try {
|
|
348
395
|
const path = upstreamPath(namespace);
|
|
349
396
|
if (path !== null)
|
|
@@ -356,7 +403,13 @@ export function createSyncWorker(config) {
|
|
|
356
403
|
? jsonBodyRequest(request, headers, forwardedBody)
|
|
357
404
|
: new Request(request, { headers });
|
|
358
405
|
const id = env.SYNC_DO.idFromName(namespace);
|
|
359
|
-
return
|
|
406
|
+
return timeWorkerStage({
|
|
407
|
+
hostVersion: config.hostVersion,
|
|
408
|
+
requestKind,
|
|
409
|
+
stage: 'sync_do_forward',
|
|
410
|
+
namespaceHash: hashedNamespace,
|
|
411
|
+
sampled,
|
|
412
|
+
}, () => env.SYNC_DO.get(id).fetch(forwarded));
|
|
360
413
|
};
|
|
361
414
|
return withCors(await handle());
|
|
362
415
|
},
|
|
@@ -1011,20 +1064,39 @@ export function createSyncDurableObject(config) {
|
|
|
1011
1064
|
let lastError = null;
|
|
1012
1065
|
for (let attempt = 1; attempt <= delegateMaxAttempts; attempt++) {
|
|
1013
1066
|
let response = null;
|
|
1067
|
+
const attemptTimeoutMs = provisioning
|
|
1068
|
+
? Math.max(delegateTimeoutMs, 25_000)
|
|
1069
|
+
: delegateTimeoutMs;
|
|
1070
|
+
// ask the signal, not the rejection: this signal aborts for exactly one
|
|
1071
|
+
// reason, so `aborted` names a timeout without depending on how the
|
|
1072
|
+
// runtime shapes its DOMException.
|
|
1073
|
+
const timeout = AbortSignal.timeout(attemptTimeoutMs);
|
|
1014
1074
|
try {
|
|
1015
1075
|
response = await binding.fetch(endpoint.toString(), {
|
|
1016
1076
|
method: 'POST',
|
|
1017
1077
|
headers,
|
|
1018
1078
|
body,
|
|
1019
|
-
signal:
|
|
1079
|
+
signal: timeout,
|
|
1020
1080
|
});
|
|
1021
1081
|
}
|
|
1022
1082
|
catch (error) {
|
|
1023
1083
|
lastError = error;
|
|
1024
1084
|
}
|
|
1025
|
-
|
|
1085
|
+
const timedOut = response === null && timeout.aborted;
|
|
1086
|
+
if (!shouldRetryDelegatedPush(response?.status ?? null, attempt, delegateMaxAttempts, timedOut)) {
|
|
1026
1087
|
if (response)
|
|
1027
1088
|
return response;
|
|
1089
|
+
// the retry log below is the only place a delegated push failure was
|
|
1090
|
+
// ever named, and a terminal failure skips it. log here too, or the
|
|
1091
|
+
// host answers 500 and says nothing about why.
|
|
1092
|
+
console.warn(JSON.stringify({
|
|
1093
|
+
event: 'sync_delegated_push_failed',
|
|
1094
|
+
attempt,
|
|
1095
|
+
maxAttempts: delegateMaxAttempts,
|
|
1096
|
+
timedOut,
|
|
1097
|
+
timeoutMs: attemptTimeoutMs,
|
|
1098
|
+
error: errorMessage(lastError),
|
|
1099
|
+
}));
|
|
1028
1100
|
throw lastError;
|
|
1029
1101
|
}
|
|
1030
1102
|
await response?.body?.cancel();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
type OrezSyncCfHostWasmOptions = {
|
|
3
3
|
noExternal?: string[];
|
|
4
|
+
runtime?: 'node' | 'workerd';
|
|
4
5
|
};
|
|
5
|
-
/** load the sync engine
|
|
6
|
+
/** load the sync engine into a Vite SSR graph for Node or Workerd. */
|
|
6
7
|
export declare function orezSyncCfHostWasm(options?: OrezSyncCfHostWasmOptions): Plugin[];
|
|
7
8
|
export {};
|
package/dist/vite-wasm-loader.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
const wasmModuleID = 'orez-sync-cf-host/wasm-module.wasm';
|
|
3
3
|
const resolvedWasmModuleID = `\0${wasmModuleID}`;
|
|
4
|
-
/** load the sync engine
|
|
4
|
+
/** load the sync engine into a Vite SSR graph for Node or Workerd. */
|
|
5
5
|
export function orezSyncCfHostWasm(options = {}) {
|
|
6
6
|
const noExternal = ['orez-sync-cf-host', ...(options.noExternal ?? [])];
|
|
7
|
+
let command = 'serve';
|
|
7
8
|
return [
|
|
8
9
|
{
|
|
9
10
|
name: 'orez-sync-cf-host-wasm',
|
|
10
11
|
enforce: 'pre',
|
|
12
|
+
configResolved(config) {
|
|
13
|
+
command = config.command;
|
|
14
|
+
},
|
|
11
15
|
resolveId(source) {
|
|
12
|
-
|
|
16
|
+
if (source !== wasmModuleID)
|
|
17
|
+
return null;
|
|
18
|
+
return options.runtime === 'workerd' && command === 'build'
|
|
19
|
+
? { id: wasmModuleID, external: true }
|
|
20
|
+
: resolvedWasmModuleID;
|
|
13
21
|
},
|
|
14
22
|
async load(id) {
|
|
15
23
|
if (id !== resolvedWasmModuleID)
|
|
@@ -40,4 +40,18 @@ export declare class IngestCircuitBreaker {
|
|
|
40
40
|
reopen(): void;
|
|
41
41
|
}
|
|
42
42
|
export declare function retryDelayMs(attempt: number, initialBackoffMs: number, maxBackoffMs: number): number;
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* A timeout is terminal, every other transport failure is retryable.
|
|
45
|
+
*
|
|
46
|
+
* Retrying a 429 or a transient 5xx is cheap: the app answered in
|
|
47
|
+
* milliseconds and the second attempt usually lands. Retrying a TIMEOUT is
|
|
48
|
+
* not. The work was killed mid-flight, so the retry repeats its full cost and
|
|
49
|
+
* the caller's total budget becomes `maxAttempts * timeoutMs`. The client is
|
|
50
|
+
* what that total has to fit inside: orez-lite's browser transport aborts any
|
|
51
|
+
* push whose response headers miss its own 60s deadline, and any push failure
|
|
52
|
+
* closes its socket, so a doubled budget turns one slow mutation into a full
|
|
53
|
+
* sync teardown. Contrast ran 30s x 2 attempts against that 60s deadline and
|
|
54
|
+
* on 2026-08-15 a real user's connection was torn down 19 times in 40 minutes,
|
|
55
|
+
* once every ~68s, on a push the host answered at ~60.2s.
|
|
56
|
+
*/
|
|
57
|
+
export declare function shouldRetryDelegatedPush(responseStatus: number | null, attempt: number, maxAttempts: number, timedOut: boolean): boolean;
|
package/dist/write-safeguards.js
CHANGED
|
@@ -125,8 +125,24 @@ export class IngestCircuitBreaker {
|
|
|
125
125
|
export function retryDelayMs(attempt, initialBackoffMs, maxBackoffMs) {
|
|
126
126
|
return Math.min(maxBackoffMs, initialBackoffMs * 2 ** Math.max(0, attempt - 1));
|
|
127
127
|
}
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* A timeout is terminal, every other transport failure is retryable.
|
|
130
|
+
*
|
|
131
|
+
* Retrying a 429 or a transient 5xx is cheap: the app answered in
|
|
132
|
+
* milliseconds and the second attempt usually lands. Retrying a TIMEOUT is
|
|
133
|
+
* not. The work was killed mid-flight, so the retry repeats its full cost and
|
|
134
|
+
* the caller's total budget becomes `maxAttempts * timeoutMs`. The client is
|
|
135
|
+
* what that total has to fit inside: orez-lite's browser transport aborts any
|
|
136
|
+
* push whose response headers miss its own 60s deadline, and any push failure
|
|
137
|
+
* closes its socket, so a doubled budget turns one slow mutation into a full
|
|
138
|
+
* sync teardown. Contrast ran 30s x 2 attempts against that 60s deadline and
|
|
139
|
+
* on 2026-08-15 a real user's connection was torn down 19 times in 40 minutes,
|
|
140
|
+
* once every ~68s, on a push the host answered at ~60.2s.
|
|
141
|
+
*/
|
|
142
|
+
export function shouldRetryDelegatedPush(responseStatus, attempt, maxAttempts, timedOut) {
|
|
129
143
|
if (attempt >= maxAttempts)
|
|
130
144
|
return false;
|
|
145
|
+
if (timedOut)
|
|
146
|
+
return false;
|
|
131
147
|
return responseStatus === null || responseStatus === 429 || responseStatus >= 500;
|
|
132
148
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orez-sync-cf-host",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Internal orez Cloudflare sync host. Do not import directly: consumers use orez-lite/cloudflare/sync.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"test:large-data": "bun run build:dist && bun large-data-test.mjs"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"orez-sync-executor": "0.14.
|
|
61
|
+
"orez-sync-executor": "0.14.1"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@cloudflare/workers-types": "4.20260617.1",
|