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 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 | Module value |
38
- | ---------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
39
- | Workerd / Wrangler | Map the package's `wasm-module.wasm` export as a compiled Wasm module; do not install the Vite plugin | Cloudflare `CompiledWasm` |
40
- | Bun | Preload `orez-sync-cf-host/bun-wasm-loader` | `WebAssembly.Module` compiled by the Bun loader |
41
- | Vite serve / SSR development | Add `orezSyncCfHostWasm()` from `orez-sync-cf-host/vite-wasm-loader` | `WebAssembly.Module` built from package bytes by Vite |
42
- | Direct Node >= 22.15 | Preload `orez-sync-cf-host/node-wasm-loader` with `NODE_OPTIONS=--import` | `WebAssembly.Module` compiled by the Node loader |
43
- | Node production bundle | Keep the same Vite plugin active for the production SSR build | `WebAssembly.Module` built from bytes embedded in the bundle |
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, include this plugin only for
88
- the Node target. The Workerd target uses the `CompiledWasm` mapping above and
89
- must not run the Vite loader.
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 config.authenticate(request, env);
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
- headers.set(NAMESPACE_HEADER, await namespaceHash(namespace));
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 env.SYNC_DO.get(id).fetch(forwarded);
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: AbortSignal.timeout(provisioning ? Math.max(delegateTimeoutMs, 25_000) : delegateTimeoutMs),
1079
+ signal: timeout,
1020
1080
  });
1021
1081
  }
1022
1082
  catch (error) {
1023
1083
  lastError = error;
1024
1084
  }
1025
- if (!shouldRetryDelegatedPush(response?.status ?? null, attempt, delegateMaxAttempts)) {
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 for Vite's Node serve, SSR, and production build paths. */
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 {};
@@ -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 for Vite's Node serve, SSR, and production build paths. */
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
- return source === wasmModuleID ? resolvedWasmModuleID : null;
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
- export declare function shouldRetryDelegatedPush(responseStatus: number | null, attempt: number, maxAttempts: number): boolean;
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;
@@ -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
- export function shouldRetryDelegatedPush(responseStatus, attempt, maxAttempts) {
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.0",
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.0"
61
+ "orez-sync-executor": "0.14.1"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@cloudflare/workers-types": "4.20260617.1",