orez-sync-cf-host 0.15.0 → 0.16.0
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/dist/config.js +3 -0
- package/dist/generated/package.json +1 -1
- package/dist/generated/sync_wasm.js +2 -2
- package/dist/generated/sync_wasm_bg.js +2 -2
- package/dist/generated/sync_wasm_bg.wasm +0 -0
- package/dist/host.d.ts +4 -1
- package/dist/host.js +132 -30
- package/dist/index.d.ts +1 -1
- package/dist/serving-lag.d.ts +27 -0
- package/dist/serving-lag.js +83 -0
- package/dist/types.d.ts +6 -0
- package/dist/upstream-response.d.ts +22 -0
- package/dist/upstream-response.js +101 -0
- package/package.json +6 -4
package/dist/config.js
CHANGED
|
@@ -66,6 +66,9 @@ export function validateSyncHostConfig(config) {
|
|
|
66
66
|
ingestBudgetWindowMs: config.upstream.ingestBudgetWindowMs ?? 300_000,
|
|
67
67
|
ingestBackoffMs: config.upstream.ingestBackoffMs ?? 1_000,
|
|
68
68
|
ingestMaxBackoffMs: config.upstream.ingestMaxBackoffMs ?? 60_000,
|
|
69
|
+
requestTimeoutMs: config.upstream.requestTimeoutMs ?? 30_000,
|
|
70
|
+
maxResponseBytes: config.upstream.maxResponseBytes ?? 8 * 1024 * 1024,
|
|
71
|
+
maxRequestBytes: config.upstream.maxRequestBytes ?? 1024 * 1024,
|
|
69
72
|
})) {
|
|
70
73
|
if (!Number.isSafeInteger(value) || value < 1)
|
|
71
74
|
throw new TypeError(`upstream.${name} must be a positive safe integer`);
|
|
@@ -507,7 +507,7 @@ function __wbg_get_imports() {
|
|
|
507
507
|
const ret = Object.entries(arg0);
|
|
508
508
|
return ret;
|
|
509
509
|
},
|
|
510
|
-
|
|
510
|
+
__wbg_exec_47d28d9cda4f7ff5: function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
|
511
511
|
arg0.exec(getStringFromWasm0(arg1, arg2), arg3);
|
|
512
512
|
}, arguments); },
|
|
513
513
|
__wbg_get_507a50627bffa49b: function(arg0, arg1) {
|
|
@@ -617,7 +617,7 @@ function __wbg_get_imports() {
|
|
|
617
617
|
__wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) {
|
|
618
618
|
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
|
619
619
|
},
|
|
620
|
-
|
|
620
|
+
__wbg_query_cf7ee856b3b99d70: function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
|
621
621
|
const ret = arg0.query(getStringFromWasm0(arg1, arg2), arg3);
|
|
622
622
|
return ret;
|
|
623
623
|
}, arguments); },
|
|
@@ -502,7 +502,7 @@ export function __wbg_entries_015dc610cd81ede0(arg0) {
|
|
|
502
502
|
const ret = Object.entries(arg0);
|
|
503
503
|
return ret;
|
|
504
504
|
}
|
|
505
|
-
export function
|
|
505
|
+
export function __wbg_exec_47d28d9cda4f7ff5() { return handleError(function (arg0, arg1, arg2, arg3) {
|
|
506
506
|
arg0.exec(getStringFromWasm0(arg1, arg2), arg3);
|
|
507
507
|
}, arguments); }
|
|
508
508
|
export function __wbg_get_507a50627bffa49b(arg0, arg1) {
|
|
@@ -612,7 +612,7 @@ export function __wbg_next_71f2aa1cb3d1e37e() { return handleError(function (arg
|
|
|
612
612
|
export function __wbg_prototypesetcall_4770620bbe4688a0(arg0, arg1, arg2) {
|
|
613
613
|
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
|
614
614
|
}
|
|
615
|
-
export function
|
|
615
|
+
export function __wbg_query_cf7ee856b3b99d70() { return handleError(function (arg0, arg1, arg2, arg3) {
|
|
616
616
|
const ret = arg0.query(getStringFromWasm0(arg1, arg2), arg3);
|
|
617
617
|
return ret;
|
|
618
618
|
}, arguments); }
|
|
Binary file
|
package/dist/host.d.ts
CHANGED
|
@@ -9,8 +9,11 @@ import type { Schema } from '@rocicorp/zero';
|
|
|
9
9
|
export declare function createSyncWorker<Env extends SyncHostEnv, S extends Schema = Schema>(config: SyncHostConfig<Env, S>): ExportedHandler<Env> & {
|
|
10
10
|
notify(env: Env, namespace: string): Promise<Response>;
|
|
11
11
|
};
|
|
12
|
+
export interface SyncDurableObjectInstance<Env extends SyncHostEnv> extends DurableObject<Env> {
|
|
13
|
+
fetch(request: Request): Promise<Response>;
|
|
14
|
+
}
|
|
12
15
|
export interface SyncDurableObjectConstructor<Env extends SyncHostEnv> {
|
|
13
|
-
new (ctx: DurableObjectState, env: Env):
|
|
16
|
+
new (ctx: DurableObjectState, env: Env): SyncDurableObjectInstance<Env>;
|
|
14
17
|
}
|
|
15
18
|
/** Create the namespace Durable Object class for one bundled consumer config. */
|
|
16
19
|
export declare function createSyncDurableObject<Env extends SyncHostEnv, S extends Schema = Schema>(config: SyncHostConfig<Env, S>): SyncDurableObjectConstructor<Env>;
|
package/dist/host.js
CHANGED
|
@@ -4,7 +4,9 @@ import { createSocketHost } from 'orez-sync-executor/realtime';
|
|
|
4
4
|
import { validateSyncHostConfig } from './config.js';
|
|
5
5
|
import { createQueryCompiler } from './query-compiler.js';
|
|
6
6
|
import { resolveQueryPatch } from './query-patch.js';
|
|
7
|
+
import { ServingLagTracker } from './serving-lag.js';
|
|
7
8
|
import { decodeSqlParams, SqlStorageDirect, SqlStorageMutatorTransaction, SqlStorageSyncDb, } from './sql-storage-adapter.js';
|
|
9
|
+
import { DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES, DEFAULT_UPSTREAM_MAX_REQUEST_BYTES, DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS, fetchBoundedUpstreamJson, readBoundedJsonResponse, readBoundedStream, UpstreamResponseLimitError, } from './upstream-response.js';
|
|
8
10
|
import { engine_authorize_realtime_subscription, engine_apply_snapshot_changes, engine_apply_snapshot_page, engine_apply_upstream, engine_begin_snapshot_generation, engine_finalize, engine_finalize_snapshot_generation, 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_schema_revision, engine_state, engine_version, } from './wasm.js';
|
|
9
11
|
import { IngestBreakerError, IngestCircuitBreaker, retryDelayMs, shouldRetryDelegatedPush, } from './write-safeguards.js';
|
|
10
12
|
const NAMESPACE_HEADER = 'x-orez-sync-namespace';
|
|
@@ -126,6 +128,24 @@ async function requestObject(request) {
|
|
|
126
128
|
throw requestError('request body must be a JSON object');
|
|
127
129
|
return value;
|
|
128
130
|
}
|
|
131
|
+
async function boundedRequestObject(request, maxBytes, timeoutMs) {
|
|
132
|
+
try {
|
|
133
|
+
const bytes = await readBoundedStream(request.body, maxBytes, AbortSignal.timeout(timeoutMs));
|
|
134
|
+
const value = JSON.parse(new TextDecoder().decode(bytes));
|
|
135
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
136
|
+
throw requestError('request body must be a JSON object');
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof UpstreamResponseLimitError) {
|
|
142
|
+
throw requestError(`request body exceeded ${maxBytes} bytes`, 413);
|
|
143
|
+
}
|
|
144
|
+
if (error instanceof SyntaxError)
|
|
145
|
+
throw requestError('invalid JSON request body');
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
129
149
|
function routeAfterNamespace(pathname) {
|
|
130
150
|
const [, , ...parts] = pathname.split('/');
|
|
131
151
|
return `/${parts.join('/')}`;
|
|
@@ -160,6 +180,7 @@ async function forwardedSyncRequest(request) {
|
|
|
160
180
|
const headers = new Headers(request.headers);
|
|
161
181
|
return {
|
|
162
182
|
claims: claims,
|
|
183
|
+
body: body,
|
|
163
184
|
request: jsonBodyRequest(request, headers, body),
|
|
164
185
|
};
|
|
165
186
|
}
|
|
@@ -225,6 +246,8 @@ function socketCloseQuietly(socket, code, reason) {
|
|
|
225
246
|
*/
|
|
226
247
|
export function createSyncWorker(config) {
|
|
227
248
|
validateSyncHostConfig(config);
|
|
249
|
+
const requestTimeoutMs = config.upstream?.requestTimeoutMs ?? DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS;
|
|
250
|
+
const maxRequestBytes = config.upstream?.maxRequestBytes ?? DEFAULT_UPSTREAM_MAX_REQUEST_BYTES;
|
|
228
251
|
const allowedOrigins = new Set(config.allowedOrigins ?? []);
|
|
229
252
|
const upstreamPath = (namespace) => {
|
|
230
253
|
if (!config.upstream)
|
|
@@ -374,7 +397,7 @@ export function createSyncWorker(config) {
|
|
|
374
397
|
}
|
|
375
398
|
if ((route === '/pull' || route === '/push') && request.method === 'POST') {
|
|
376
399
|
try {
|
|
377
|
-
const body = await
|
|
400
|
+
const body = await boundedRequestObject(request, maxRequestBytes, requestTimeoutMs);
|
|
378
401
|
forwardedBody = { claims, body };
|
|
379
402
|
}
|
|
380
403
|
catch (error) {
|
|
@@ -443,6 +466,9 @@ export function createSyncDurableObject(config) {
|
|
|
443
466
|
const wakeCoalesceMs = config.wakeCoalesceMs ?? 25;
|
|
444
467
|
const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000;
|
|
445
468
|
const upstreamLimit = config.upstream?.changeLimit ?? 1_000;
|
|
469
|
+
const upstreamRequestTimeoutMs = config.upstream?.requestTimeoutMs ?? DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS;
|
|
470
|
+
const upstreamMaxResponseBytes = config.upstream?.maxResponseBytes ?? DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES;
|
|
471
|
+
const upstreamMaxRequestBytes = config.upstream?.maxRequestBytes ?? DEFAULT_UPSTREAM_MAX_REQUEST_BYTES;
|
|
446
472
|
const ingestBudgetRows = config.upstream?.ingestBudgetRows ?? 150_000;
|
|
447
473
|
const ingestBudgetWindowMs = config.upstream?.ingestBudgetWindowMs ?? 5 * 60_000;
|
|
448
474
|
const ingestBackoffMs = config.upstream?.ingestBackoffMs ?? 1_000;
|
|
@@ -471,6 +497,8 @@ export function createSyncDurableObject(config) {
|
|
|
471
497
|
#counters = freshCounters();
|
|
472
498
|
#sqlBilling = { rowsRead: 0, rowsWritten: 0 };
|
|
473
499
|
#pulling = new Set();
|
|
500
|
+
#activePullGroups = new Map();
|
|
501
|
+
#servingLag = new ServingLagTracker();
|
|
474
502
|
#wakeOrigins = new Set();
|
|
475
503
|
#wakeRecipients = new Set();
|
|
476
504
|
#wakeTables = new Set();
|
|
@@ -558,6 +586,15 @@ export function createSyncDurableObject(config) {
|
|
|
558
586
|
schema: config.schema,
|
|
559
587
|
})
|
|
560
588
|
: null;
|
|
589
|
+
// Upstream-backed namespaces are initialized lazily so a notify for a
|
|
590
|
+
// namespace with no wake subscribers remains a true zero-read/write
|
|
591
|
+
// fast path. Local-execution namespaces have no such notify path, and
|
|
592
|
+
// preserving eager initialization there matters for consumers that
|
|
593
|
+
// subclass this Durable Object and handle an application route before
|
|
594
|
+
// delegating to super.fetch().
|
|
595
|
+
if (!config.upstream) {
|
|
596
|
+
ctx.blockConcurrencyWhile(async () => this.#initialize());
|
|
597
|
+
}
|
|
561
598
|
}
|
|
562
599
|
#initialize() {
|
|
563
600
|
if (this.#initialized)
|
|
@@ -703,6 +740,41 @@ export function createSyncDurableObject(config) {
|
|
|
703
740
|
}
|
|
704
741
|
return value;
|
|
705
742
|
}
|
|
743
|
+
#activeClientGroupIDs() {
|
|
744
|
+
const groups = new Set(this.#activePullGroups.keys());
|
|
745
|
+
for (const socket of this.ctx.getWebSockets(WAKE_SUBSCRIBER_TAG)) {
|
|
746
|
+
const clientGroupID = socketAttachment(socket)?.identity?.clientGroupID;
|
|
747
|
+
if (clientGroupID)
|
|
748
|
+
groups.add(clientGroupID);
|
|
749
|
+
}
|
|
750
|
+
return groups;
|
|
751
|
+
}
|
|
752
|
+
#beginActivePull(clientGroupID) {
|
|
753
|
+
this.#activePullGroups.set(clientGroupID, (this.#activePullGroups.get(clientGroupID) ?? 0) + 1);
|
|
754
|
+
}
|
|
755
|
+
#endActivePull(clientGroupID) {
|
|
756
|
+
const count = this.#activePullGroups.get(clientGroupID) ?? 0;
|
|
757
|
+
if (count <= 1)
|
|
758
|
+
this.#activePullGroups.delete(clientGroupID);
|
|
759
|
+
else
|
|
760
|
+
this.#activePullGroups.set(clientGroupID, count - 1);
|
|
761
|
+
}
|
|
762
|
+
async #fetchUpstreamJson(endpoint) {
|
|
763
|
+
const result = await fetchBoundedUpstreamJson(this.#serviceBinding(), endpoint.toString(), { headers: { host: endpoint.host } }, {
|
|
764
|
+
timeoutMs: upstreamRequestTimeoutMs,
|
|
765
|
+
maxBytes: upstreamMaxResponseBytes,
|
|
766
|
+
});
|
|
767
|
+
const sourceTimeMs = result.body &&
|
|
768
|
+
typeof result.body === 'object' &&
|
|
769
|
+
!Array.isArray(result.body) &&
|
|
770
|
+
typeof result.body.sourceTimeMs === 'number'
|
|
771
|
+
? result.body.sourceTimeMs
|
|
772
|
+
: null;
|
|
773
|
+
if (sourceTimeMs !== null) {
|
|
774
|
+
this.#servingLag.recordClockSkew(sourceTimeMs, result.sendTimeMs, result.receiveTimeMs);
|
|
775
|
+
}
|
|
776
|
+
return result;
|
|
777
|
+
}
|
|
706
778
|
async #upstreamWriteBudgetStatus() {
|
|
707
779
|
if (!config.upstream)
|
|
708
780
|
return json({ error: 'upstream is not configured' }, 404);
|
|
@@ -710,16 +782,14 @@ export function createSyncDurableObject(config) {
|
|
|
710
782
|
if (path === null)
|
|
711
783
|
return json({ error: 'upstream path is not known yet' }, 409);
|
|
712
784
|
const endpoint = new URL(`${path}/_orez/write-budget`, 'https://upstream.invalid');
|
|
713
|
-
const response = await this.#
|
|
714
|
-
headers: { host: endpoint.host },
|
|
715
|
-
});
|
|
785
|
+
const { response, body } = await this.#fetchUpstreamJson(endpoint);
|
|
716
786
|
if (!response.ok) {
|
|
717
787
|
return json({
|
|
718
788
|
error: 'upstream write-budget status unavailable',
|
|
719
789
|
upstreamStatus: response.status,
|
|
720
790
|
}, 502);
|
|
721
791
|
}
|
|
722
|
-
return json(
|
|
792
|
+
return json(body);
|
|
723
793
|
}
|
|
724
794
|
#rememberUpstreamPath(request) {
|
|
725
795
|
if (!config.upstream)
|
|
@@ -810,13 +880,11 @@ export function createSyncDurableObject(config) {
|
|
|
810
880
|
endpoint.searchParams.set('limit', String(limit));
|
|
811
881
|
if (cursor !== null)
|
|
812
882
|
endpoint.searchParams.set('cursor', cursor);
|
|
813
|
-
const response = await this.#
|
|
814
|
-
headers: { host: endpoint.host },
|
|
815
|
-
});
|
|
883
|
+
const { response, body } = await this.#fetchUpstreamJson(endpoint);
|
|
816
884
|
if (!response.ok) {
|
|
817
885
|
throw requestError(`upstream snapshot page returned ${response.status}`, response.status >= 500 ? 502 : response.status);
|
|
818
886
|
}
|
|
819
|
-
const page =
|
|
887
|
+
const page = body;
|
|
820
888
|
if (!Number.isSafeInteger(page.watermark) ||
|
|
821
889
|
Number(page.watermark) < 0 ||
|
|
822
890
|
!Array.isArray(page.rows) ||
|
|
@@ -877,6 +945,7 @@ export function createSyncDurableObject(config) {
|
|
|
877
945
|
let pendingPage = null;
|
|
878
946
|
let snapshotPageLimit = DEFAULT_SNAPSHOT_PAGE_ROWS;
|
|
879
947
|
let snapshotCompleted = false;
|
|
948
|
+
let oldestLiveCommitTimeMs;
|
|
880
949
|
const changedTables = new Set();
|
|
881
950
|
for (;;) {
|
|
882
951
|
if (progress?.state === 'paging') {
|
|
@@ -921,9 +990,7 @@ export function createSyncDurableObject(config) {
|
|
|
921
990
|
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
|
|
922
991
|
endpoint.searchParams.set('since', cursor);
|
|
923
992
|
endpoint.searchParams.set('limit', String(upstreamLimit));
|
|
924
|
-
const response = await this.#
|
|
925
|
-
headers: { host: endpoint.host },
|
|
926
|
-
});
|
|
993
|
+
const { response, body } = await this.#fetchUpstreamJson(endpoint);
|
|
927
994
|
if (response.status === 410) {
|
|
928
995
|
const begun = await this.#beginSnapshotGeneration(path);
|
|
929
996
|
progress = begun.progress;
|
|
@@ -934,7 +1001,7 @@ export function createSyncDurableObject(config) {
|
|
|
934
1001
|
if (!response.ok) {
|
|
935
1002
|
throw new Error(`upstream snapshot catch-up returned ${response.status}`);
|
|
936
1003
|
}
|
|
937
|
-
const batch =
|
|
1004
|
+
const batch = body;
|
|
938
1005
|
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
939
1006
|
throw new Error('invalid upstream changes response');
|
|
940
1007
|
}
|
|
@@ -989,9 +1056,7 @@ export function createSyncDurableObject(config) {
|
|
|
989
1056
|
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid');
|
|
990
1057
|
endpoint.searchParams.set('watermark', cursor);
|
|
991
1058
|
endpoint.searchParams.set('limit', String(upstreamLimit));
|
|
992
|
-
const response = await this.#
|
|
993
|
-
headers: { host: endpoint.host },
|
|
994
|
-
});
|
|
1059
|
+
const { response, body } = await this.#fetchUpstreamJson(endpoint);
|
|
995
1060
|
if (response.status === 410) {
|
|
996
1061
|
const begun = await this.#beginSnapshotGeneration(path);
|
|
997
1062
|
progress = begun.progress;
|
|
@@ -1002,10 +1067,18 @@ export function createSyncDurableObject(config) {
|
|
|
1002
1067
|
if (!response.ok) {
|
|
1003
1068
|
throw new Error(`upstream changes returned ${response.status}`);
|
|
1004
1069
|
}
|
|
1005
|
-
const batch =
|
|
1070
|
+
const batch = body;
|
|
1006
1071
|
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
1007
1072
|
throw new Error('invalid upstream changes response');
|
|
1008
1073
|
}
|
|
1074
|
+
const batchCommitTimeMs = batch.oldestCommitTimeMs;
|
|
1075
|
+
if (typeof batchCommitTimeMs === 'number' &&
|
|
1076
|
+
Number.isFinite(batchCommitTimeMs)) {
|
|
1077
|
+
oldestLiveCommitTimeMs =
|
|
1078
|
+
oldestLiveCommitTimeMs === undefined
|
|
1079
|
+
? batchCommitTimeMs
|
|
1080
|
+
: Math.min(oldestLiveCommitTimeMs, batchCommitTimeMs);
|
|
1081
|
+
}
|
|
1009
1082
|
for (const table of upstreamBatchTables(batch))
|
|
1010
1083
|
changedTables.add(table);
|
|
1011
1084
|
const result = this.#withIngestBilling({
|
|
@@ -1044,6 +1117,18 @@ export function createSyncDurableObject(config) {
|
|
|
1044
1117
|
}
|
|
1045
1118
|
this.#recoverIngestBreaker();
|
|
1046
1119
|
const endingWatermark = this.#engineState().watermark;
|
|
1120
|
+
if (oldestLiveCommitTimeMs !== undefined) {
|
|
1121
|
+
const activeGroups = this.#activeClientGroupIDs();
|
|
1122
|
+
if (endingWatermark !== startingWatermark || total > 0) {
|
|
1123
|
+
this.#servingLag.onVersionReady(endingWatermark, oldestLiveCommitTimeMs, activeGroups);
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
const servedAt = Date.now();
|
|
1127
|
+
for (const _clientGroupID of activeGroups) {
|
|
1128
|
+
this.#servingLag.recordNoChange(oldestLiveCommitTimeMs, servedAt);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1047
1132
|
if (snapshotCompleted || total > 0 || endingWatermark !== startingWatermark) {
|
|
1048
1133
|
await this.#enqueueWake('__upstream__', changedTables);
|
|
1049
1134
|
}
|
|
@@ -1259,7 +1344,10 @@ export function createSyncDurableObject(config) {
|
|
|
1259
1344
|
wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
|
|
1260
1345
|
sql: this.#engineDb.stats,
|
|
1261
1346
|
});
|
|
1262
|
-
|
|
1347
|
+
const result = json(response);
|
|
1348
|
+
const clientGroupID = typeof body.clientGroupID === 'string' ? body.clientGroupID : '';
|
|
1349
|
+
this.#servingLag.onVersionServed(clientGroupID, response.cookie);
|
|
1350
|
+
return result;
|
|
1263
1351
|
}
|
|
1264
1352
|
catch (error) {
|
|
1265
1353
|
const status = statusOf(error);
|
|
@@ -1299,7 +1387,7 @@ export function createSyncDurableObject(config) {
|
|
|
1299
1387
|
if (!this.#writerEnabled()) {
|
|
1300
1388
|
// Workerd requires the request stream to be consumed before the DO
|
|
1301
1389
|
// returns a response. Discard it without parsing or logging payloads.
|
|
1302
|
-
await request.
|
|
1390
|
+
await readBoundedStream(request.body, upstreamMaxRequestBytes, AbortSignal.timeout(upstreamRequestTimeoutMs));
|
|
1303
1391
|
const state = this.#engineStateBestEffort();
|
|
1304
1392
|
this.#log({
|
|
1305
1393
|
namespaceHash: namespace,
|
|
@@ -1325,7 +1413,8 @@ export function createSyncDurableObject(config) {
|
|
|
1325
1413
|
}
|
|
1326
1414
|
if (config.mutateUrl) {
|
|
1327
1415
|
try {
|
|
1328
|
-
const
|
|
1416
|
+
const boundedBytes = await readBoundedStream(request.body, upstreamMaxRequestBytes, AbortSignal.timeout(upstreamRequestTimeoutMs));
|
|
1417
|
+
const bytes = boundedBytes.buffer.slice(boundedBytes.byteOffset, boundedBytes.byteOffset + boundedBytes.byteLength);
|
|
1329
1418
|
const body = JSON.parse(new TextDecoder().decode(bytes));
|
|
1330
1419
|
const plan = this.#wasm(() => engine_push_validate(body));
|
|
1331
1420
|
if (plan.kind === 'respond')
|
|
@@ -1337,9 +1426,11 @@ export function createSyncDurableObject(config) {
|
|
|
1337
1426
|
headers.set('host', endpoint.host);
|
|
1338
1427
|
const upstreamResponse = await this.#fetchDelegatedPush(endpoint, headers, bytes, this.#engineState().upstreamWatermark === '0');
|
|
1339
1428
|
if (!upstreamResponse.ok) {
|
|
1340
|
-
|
|
1429
|
+
const upstreamErrorBody = await readBoundedStream(upstreamResponse.body, upstreamMaxResponseBytes, AbortSignal.timeout(upstreamRequestTimeoutMs));
|
|
1430
|
+
const errorBytes = upstreamErrorBody.buffer.slice(upstreamErrorBody.byteOffset, upstreamErrorBody.byteOffset + upstreamErrorBody.byteLength);
|
|
1431
|
+
return new Response(errorBytes, upstreamResponse);
|
|
1341
1432
|
}
|
|
1342
|
-
const upstreamBody = (await upstreamResponse.
|
|
1433
|
+
const upstreamBody = (await readBoundedJsonResponse(upstreamResponse, upstreamMaxResponseBytes, AbortSignal.timeout(upstreamRequestTimeoutMs)));
|
|
1343
1434
|
const delegatedResponse = upstreamBody.pushResponse ?? upstreamBody;
|
|
1344
1435
|
if (isStructuredPushFailed(delegatedResponse)) {
|
|
1345
1436
|
// PushFailed is a successful protocol response describing an
|
|
@@ -1803,17 +1894,28 @@ export function createSyncDurableObject(config) {
|
|
|
1803
1894
|
catch (error) {
|
|
1804
1895
|
return errorResponse(error);
|
|
1805
1896
|
}
|
|
1806
|
-
|
|
1897
|
+
const clientGroupID = route === '/pull' && typeof forwarded.body.clientGroupID === 'string'
|
|
1898
|
+
? forwarded.body.clientGroupID
|
|
1899
|
+
: null;
|
|
1900
|
+
if (clientGroupID)
|
|
1901
|
+
this.#beginActivePull(clientGroupID);
|
|
1807
1902
|
try {
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1903
|
+
// pull and push both establish the upstream schema and snapshot barrier.
|
|
1904
|
+
try {
|
|
1905
|
+
await this.#ingest(upstreamPath);
|
|
1906
|
+
}
|
|
1907
|
+
catch (error) {
|
|
1908
|
+
return errorResponse(error);
|
|
1909
|
+
}
|
|
1910
|
+
if (route === '/pull') {
|
|
1911
|
+
return await this.#pull(forwarded.request, forwarded.claims, namespace);
|
|
1912
|
+
}
|
|
1913
|
+
return await this.#push(forwarded.request, forwarded.claims, namespace, upstreamPath);
|
|
1812
1914
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1915
|
+
finally {
|
|
1916
|
+
if (clientGroupID)
|
|
1917
|
+
this.#endActivePull(clientGroupID);
|
|
1815
1918
|
}
|
|
1816
|
-
return this.#push(forwarded.request, forwarded.claims, namespace, upstreamPath);
|
|
1817
1919
|
}
|
|
1818
1920
|
return json({ error: 'not found' }, 404);
|
|
1819
1921
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ export { createQueryCompiler } from './query-compiler.js';
|
|
|
3
3
|
export { resolveQueryPatch } from './query-patch.js';
|
|
4
4
|
export { DEFAULT_TRANSACTION_QUERY_BUDGET, TransactionQueryBudgetError, executeTransactionQueryPlan, executeTransactionQueryPlanAsync, } from './transaction-query.js';
|
|
5
5
|
export type { SyncHostConfig, SyncHostEnv, ServiceBinding, SyncSql, UpstreamConfig, } from './types.js';
|
|
6
|
-
export type { SyncDurableObjectConstructor } from './host.js';
|
|
6
|
+
export type { SyncDurableObjectConstructor, SyncDurableObjectInstance } from './host.js';
|
|
7
7
|
export type { CompiledTransactionQueryNode, CompiledTransactionQueryPlan, CompiledTransactionQueryRelationship, TransactionQueryBinding, TransactionQueryBudget, TransactionQueryColumn, TransactionQueryColumnType, TransactionQueryExecutionOptions, TransactionQueryWireValue, } from './transaction-query.js';
|
|
8
8
|
export type { TransactionQueryCompiler } from './query-compiler.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type MetricAttributes = Record<string, string | number | boolean>;
|
|
2
|
+
type Histogram = {
|
|
3
|
+
record(value: number, attributes?: MetricAttributes): void;
|
|
4
|
+
};
|
|
5
|
+
type Counter = {
|
|
6
|
+
add(value: number, attributes?: MetricAttributes): void;
|
|
7
|
+
};
|
|
8
|
+
export type ServingLagInstruments = {
|
|
9
|
+
servingLag: Histogram;
|
|
10
|
+
servingLagClamps: Counter;
|
|
11
|
+
upstreamClockSkew: Histogram;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Coalesces upstream commits independently for each active client group. A
|
|
15
|
+
* pull clears a pending observation even when its patch is empty: being
|
|
16
|
+
* current through the version is the completion event, not receiving a row.
|
|
17
|
+
*/
|
|
18
|
+
export declare class ServingLagTracker {
|
|
19
|
+
#private;
|
|
20
|
+
private readonly metrics;
|
|
21
|
+
constructor(metrics?: ServingLagInstruments);
|
|
22
|
+
onVersionReady(watermarkValue: unknown, upstreamCommitTimeMs: number, clientGroupIDs: Iterable<string>): void;
|
|
23
|
+
onVersionServed(clientGroupID: string, watermarkValue: unknown, nowMs?: number): void;
|
|
24
|
+
recordNoChange(upstreamCommitTimeMs: number, nowMs?: number): void;
|
|
25
|
+
recordClockSkew(sourceTimeMs: number, sendTimeMs: number, receiveTimeMs: number): void;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { metrics } from '@opentelemetry/api';
|
|
2
|
+
let defaultInstruments;
|
|
3
|
+
function instruments() {
|
|
4
|
+
if (defaultInstruments)
|
|
5
|
+
return defaultInstruments;
|
|
6
|
+
const meter = metrics.getMeter('orez');
|
|
7
|
+
defaultInstruments = {
|
|
8
|
+
servingLag: meter.createHistogram('orez.sync.e2e_serving_lag', {
|
|
9
|
+
description: 'End-to-end completion lag from an upstream commit until a client group is served through that advancement.',
|
|
10
|
+
unit: 's',
|
|
11
|
+
}),
|
|
12
|
+
servingLagClamps: meter.createCounter('orez.sync.e2e_serving_lag_clamps', {
|
|
13
|
+
description: 'Negative end-to-end serving-lag observations clamped to zero because the upstream clock was ahead.',
|
|
14
|
+
unit: '{observation}',
|
|
15
|
+
}),
|
|
16
|
+
upstreamClockSkew: meter.createHistogram('orez.replication.upstream_clock_skew', {
|
|
17
|
+
description: 'Estimated upstream clock offset from the Orez host. Positive means upstream is ahead.',
|
|
18
|
+
unit: 's',
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
return defaultInstruments;
|
|
22
|
+
}
|
|
23
|
+
function counter(value) {
|
|
24
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
|
|
25
|
+
return BigInt(value);
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value)) {
|
|
28
|
+
return BigInt(value);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Coalesces upstream commits independently for each active client group. A
|
|
34
|
+
* pull clears a pending observation even when its patch is empty: being
|
|
35
|
+
* current through the version is the completion event, not receiving a row.
|
|
36
|
+
*/
|
|
37
|
+
export class ServingLagTracker {
|
|
38
|
+
metrics;
|
|
39
|
+
#pending = new Map();
|
|
40
|
+
constructor(metrics = instruments()) {
|
|
41
|
+
this.metrics = metrics;
|
|
42
|
+
}
|
|
43
|
+
onVersionReady(watermarkValue, upstreamCommitTimeMs, clientGroupIDs) {
|
|
44
|
+
const watermark = counter(watermarkValue);
|
|
45
|
+
if (watermark === null || !Number.isFinite(upstreamCommitTimeMs))
|
|
46
|
+
return;
|
|
47
|
+
for (const clientGroupID of clientGroupIDs) {
|
|
48
|
+
if (!clientGroupID)
|
|
49
|
+
continue;
|
|
50
|
+
const previous = this.#pending.get(clientGroupID);
|
|
51
|
+
this.#pending.set(clientGroupID, {
|
|
52
|
+
watermark,
|
|
53
|
+
commitTimeMs: previous
|
|
54
|
+
? Math.min(previous.commitTimeMs, upstreamCommitTimeMs)
|
|
55
|
+
: upstreamCommitTimeMs,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
onVersionServed(clientGroupID, watermarkValue, nowMs = Date.now()) {
|
|
60
|
+
const served = counter(watermarkValue);
|
|
61
|
+
const pending = this.#pending.get(clientGroupID);
|
|
62
|
+
if (served === null || !pending || served < pending.watermark)
|
|
63
|
+
return;
|
|
64
|
+
this.#pending.delete(clientGroupID);
|
|
65
|
+
this.#record(nowMs - pending.commitTimeMs, { outcome: 'advanced' });
|
|
66
|
+
}
|
|
67
|
+
recordNoChange(upstreamCommitTimeMs, nowMs = Date.now()) {
|
|
68
|
+
if (!Number.isFinite(upstreamCommitTimeMs))
|
|
69
|
+
return;
|
|
70
|
+
this.#record(nowMs - upstreamCommitTimeMs, { outcome: 'no_change' });
|
|
71
|
+
}
|
|
72
|
+
recordClockSkew(sourceTimeMs, sendTimeMs, receiveTimeMs) {
|
|
73
|
+
if (![sourceTimeMs, sendTimeMs, receiveTimeMs].every(Number.isFinite))
|
|
74
|
+
return;
|
|
75
|
+
this.metrics.upstreamClockSkew.record((sourceTimeMs - (sendTimeMs + receiveTimeMs) / 2) / 1_000);
|
|
76
|
+
}
|
|
77
|
+
#record(rawLagMs, attributes) {
|
|
78
|
+
const clamped = rawLagMs < 0;
|
|
79
|
+
this.metrics.servingLag.record(Math.max(0, rawLagMs) / 1_000, attributes);
|
|
80
|
+
if (clamped)
|
|
81
|
+
this.metrics.servingLagClamps.add(1, attributes);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export type UpstreamConfig = {
|
|
|
20
20
|
namespacePath: string | ((namespace: string) => string);
|
|
21
21
|
/** Feed page size; the cursor loop continues until the reported head is reached. */
|
|
22
22
|
changeLimit?: number;
|
|
23
|
+
/** Whole-request deadline, including the successful response body. Defaults to 30s. */
|
|
24
|
+
requestTimeoutMs?: number;
|
|
25
|
+
/** Maximum decoded JSON response size. Defaults to 8 MiB. */
|
|
26
|
+
maxResponseBytes?: number;
|
|
27
|
+
/** Maximum forwarded pull or push request size. Defaults to 1 MiB. */
|
|
28
|
+
maxRequestBytes?: number;
|
|
23
29
|
/** Active wake-socket alarm safety net. Defaults to 15 seconds. */
|
|
24
30
|
intervalMs?: number;
|
|
25
31
|
/** Billable SQLite rows written by ingest per rolling window. Defaults to 150,000. */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS = 30000;
|
|
2
|
+
export declare const DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES: number;
|
|
3
|
+
export declare const DEFAULT_UPSTREAM_MAX_REQUEST_BYTES: number;
|
|
4
|
+
export declare class UpstreamResponseLimitError extends Error {
|
|
5
|
+
readonly maxBytes: number;
|
|
6
|
+
readonly status = 502;
|
|
7
|
+
constructor(maxBytes: number);
|
|
8
|
+
}
|
|
9
|
+
export declare function readBoundedJsonResponse(response: Response, maxBytes: number, signal: AbortSignal): Promise<unknown>;
|
|
10
|
+
export declare function readBoundedStream(stream: ReadableStream<Uint8Array> | null, maxBytes: number, signal: AbortSignal): Promise<Uint8Array>;
|
|
11
|
+
export declare function fetchBoundedUpstreamJson(binding: {
|
|
12
|
+
fetch(input: string | Request, init?: RequestInit): Promise<Response>;
|
|
13
|
+
}, input: string, init: RequestInit, options: {
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
maxBytes: number;
|
|
16
|
+
now?: () => number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
response: Response;
|
|
19
|
+
body: unknown | null;
|
|
20
|
+
sendTimeMs: number;
|
|
21
|
+
receiveTimeMs: number;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export const DEFAULT_UPSTREAM_REQUEST_TIMEOUT_MS = 30_000;
|
|
2
|
+
export const DEFAULT_UPSTREAM_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
3
|
+
export const DEFAULT_UPSTREAM_MAX_REQUEST_BYTES = 1024 * 1024;
|
|
4
|
+
export class UpstreamResponseLimitError extends Error {
|
|
5
|
+
maxBytes;
|
|
6
|
+
status = 502;
|
|
7
|
+
constructor(maxBytes) {
|
|
8
|
+
super(`upstream response exceeded ${maxBytes} bytes`);
|
|
9
|
+
this.maxBytes = maxBytes;
|
|
10
|
+
this.name = 'UpstreamResponseLimitError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function abortError(signal) {
|
|
14
|
+
return signal.reason instanceof Error
|
|
15
|
+
? signal.reason
|
|
16
|
+
: new Error('upstream response was aborted');
|
|
17
|
+
}
|
|
18
|
+
async function readWithAbort(read, signal, cancel) {
|
|
19
|
+
if (signal.aborted)
|
|
20
|
+
throw abortError(signal);
|
|
21
|
+
let remove = () => { };
|
|
22
|
+
const aborted = new Promise((_, reject) => {
|
|
23
|
+
const onAbort = () => {
|
|
24
|
+
void cancel().catch(() => { });
|
|
25
|
+
reject(abortError(signal));
|
|
26
|
+
};
|
|
27
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
28
|
+
remove = () => signal.removeEventListener('abort', onAbort);
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([read, aborted]);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
remove();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function readBoundedJsonResponse(response, maxBytes, signal) {
|
|
38
|
+
const contentLength = Number(response.headers.get('content-length'));
|
|
39
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
40
|
+
await response.body?.cancel().catch(() => { });
|
|
41
|
+
throw new UpstreamResponseLimitError(maxBytes);
|
|
42
|
+
}
|
|
43
|
+
if (!response.body)
|
|
44
|
+
throw new Error('upstream response has no body');
|
|
45
|
+
const body = await readBoundedStream(response.body, maxBytes, signal);
|
|
46
|
+
return JSON.parse(new TextDecoder().decode(body));
|
|
47
|
+
}
|
|
48
|
+
export async function readBoundedStream(stream, maxBytes, signal) {
|
|
49
|
+
if (!stream)
|
|
50
|
+
return new Uint8Array();
|
|
51
|
+
const reader = stream.getReader();
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (signal.aborted) {
|
|
56
|
+
await reader.cancel().catch(() => { });
|
|
57
|
+
throw abortError(signal);
|
|
58
|
+
}
|
|
59
|
+
const result = await readWithAbort(reader.read(), signal, () => reader.cancel());
|
|
60
|
+
if (result.done)
|
|
61
|
+
break;
|
|
62
|
+
bytes += result.value.byteLength;
|
|
63
|
+
if (bytes > maxBytes) {
|
|
64
|
+
await reader.cancel().catch(() => { });
|
|
65
|
+
throw new UpstreamResponseLimitError(maxBytes);
|
|
66
|
+
}
|
|
67
|
+
chunks.push(result.value);
|
|
68
|
+
}
|
|
69
|
+
const body = new Uint8Array(bytes);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
for (const chunk of chunks) {
|
|
72
|
+
body.set(chunk, offset);
|
|
73
|
+
offset += chunk.byteLength;
|
|
74
|
+
}
|
|
75
|
+
return body;
|
|
76
|
+
}
|
|
77
|
+
export async function fetchBoundedUpstreamJson(binding, input, init, options) {
|
|
78
|
+
const now = options.now ?? Date.now;
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
controller.abort(new Error(`upstream response missed ${options.timeoutMs}ms deadline`));
|
|
82
|
+
}, options.timeoutMs);
|
|
83
|
+
const sendTimeMs = now();
|
|
84
|
+
try {
|
|
85
|
+
const response = await binding.fetch(input, { ...init, signal: controller.signal });
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
await response.body?.cancel().catch(() => { });
|
|
88
|
+
return { response, body: null, sendTimeMs, receiveTimeMs: now() };
|
|
89
|
+
}
|
|
90
|
+
const body = await readBoundedJsonResponse(response, options.maxBytes, controller.signal);
|
|
91
|
+
return { response, body, sendTimeMs, receiveTimeMs: now() };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (controller.signal.aborted)
|
|
95
|
+
throw abortError(controller.signal);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orez-sync-cf-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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": {
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
},
|
|
35
35
|
"./wasm-module.wasm": {
|
|
36
36
|
"types": "./dist/generated/sync_wasm_bg.wasm.d.ts",
|
|
37
|
-
"import": "./dist/generated/sync_wasm_bg.wasm"
|
|
37
|
+
"import": "./dist/generated/sync_wasm_bg.wasm",
|
|
38
|
+
"default": "./dist/generated/sync_wasm_bg.wasm"
|
|
38
39
|
}
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"bundle": "bun run build:dist && wrangler deploy --dry-run --outdir .wrangler/bundle",
|
|
48
49
|
"measure": "bun run build:dist && bun measure.mjs",
|
|
49
50
|
"test": "bun run test:config && bun run test:query-compiler && bun run test:package && bun run test:platform && bun run test:integration && bun run test:large-data && bun run test:ingest && bun run test:restart",
|
|
50
|
-
"test:config": "bun test config.test.mjs write-safeguards.test.mjs transaction-query.test.mjs",
|
|
51
|
+
"test:config": "bun test config.test.mjs write-safeguards.test.mjs transaction-query.test.mjs upstream-response.test.mjs serving-lag.test.mjs",
|
|
51
52
|
"test:integration": "bun run build:dist && bun integration-test.mjs",
|
|
52
53
|
"test:ingest": "bun run build:dist && bun ingest-test.mjs",
|
|
53
54
|
"test:package": "bun run build:dist && node package-runtime-test.mjs",
|
|
@@ -58,7 +59,8 @@
|
|
|
58
59
|
"test:large-data": "bun run build:dist && bun large-data-test.mjs"
|
|
59
60
|
},
|
|
60
61
|
"dependencies": {
|
|
61
|
-
"
|
|
62
|
+
"@opentelemetry/api": "^1.9.0",
|
|
63
|
+
"orez-sync-executor": "0.16.0"
|
|
62
64
|
},
|
|
63
65
|
"devDependencies": {
|
|
64
66
|
"@cloudflare/workers-types": "4.20260617.1",
|