orez-sync-cf-host 0.16.14 → 0.16.15
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/host.js +93 -3
- package/dist/write-safeguards.d.ts +1 -1
- package/package.json +2 -2
package/dist/host.js
CHANGED
|
@@ -22,6 +22,26 @@ const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
|
|
|
22
22
|
const MIN_SNAPSHOT_PAGE_ROWS = 100;
|
|
23
23
|
const WORKER_STAGE_TELEMETRY_SAMPLE_RATE = 0.01;
|
|
24
24
|
const WORKER_STAGE_WAITING_MS = 5_000;
|
|
25
|
+
// a feed that STOPS publishing a table this replica already syncs is a
|
|
26
|
+
// publication misconfiguration, and it is invisible otherwise: the page applies
|
|
27
|
+
// nothing, the cursor still advances on the trailing syncCursor marker, and
|
|
28
|
+
// every query keeps answering from the last row that made it through, so
|
|
29
|
+
// clients see a table frozen at the moment of the bad deploy with no error
|
|
30
|
+
// anywhere.
|
|
31
|
+
//
|
|
32
|
+
// an app may also model tables it deliberately never publishes, capturing them
|
|
33
|
+
// for rollback only. in any single page those look identical to a demotion, so
|
|
34
|
+
// the test is not "modelled but dropped" but "dropped after this replica had
|
|
35
|
+
// already been receiving it": compare against the tables this replica has
|
|
36
|
+
// actually ingested and name only the ones that left.
|
|
37
|
+
function unpublishedRegressions(batch, schema, publishedSeen) {
|
|
38
|
+
const reported = batch.unpublishedTables;
|
|
39
|
+
if (!Array.isArray(reported) || reported.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
return reported.filter((table) => typeof table === 'string' &&
|
|
42
|
+
Object.hasOwn(schema.tables, table) &&
|
|
43
|
+
publishedSeen.has(table));
|
|
44
|
+
}
|
|
25
45
|
function upstreamBatchTables(batch) {
|
|
26
46
|
const tables = new Set();
|
|
27
47
|
for (const change of batch.changes) {
|
|
@@ -632,7 +652,8 @@ export function createSyncDurableObject(config) {
|
|
|
632
652
|
}
|
|
633
653
|
const ingestBreakerReason = this.#controlGet('ingestBreakerReason');
|
|
634
654
|
if (ingestBreakerReason === 'ingestBudgetExceeded' ||
|
|
635
|
-
ingestBreakerReason === 'ingestCursorStalled'
|
|
655
|
+
ingestBreakerReason === 'ingestCursorStalled' ||
|
|
656
|
+
ingestBreakerReason === 'ingestTableUnpublished') {
|
|
636
657
|
this.#ingestBreaker.restore(ingestBreakerReason, Number(this.#controlGet('ingestBreakerRetryAt')), Number(this.#controlGet('ingestBreakerTrips')));
|
|
637
658
|
}
|
|
638
659
|
});
|
|
@@ -690,6 +711,44 @@ export function createSyncDurableObject(config) {
|
|
|
690
711
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value ' +
|
|
691
712
|
'WHERE value <> excluded.value', [key, value]);
|
|
692
713
|
}
|
|
714
|
+
// the set of tables this replica has actually received published changes
|
|
715
|
+
// for. it is the baseline that separates "the app never publishes this
|
|
716
|
+
// table" from "the feed stopped publishing a table we were syncing", and it
|
|
717
|
+
// has to survive eviction, so it lives in _zsync_host_control rather than
|
|
718
|
+
// an instance field. it only ever grows, and a write only happens the first
|
|
719
|
+
// time a table shows up.
|
|
720
|
+
#publishedSeenCache = null;
|
|
721
|
+
#publishedSeen() {
|
|
722
|
+
if (!this.#publishedSeenCache) {
|
|
723
|
+
const stored = this.#controlGet('publishedTablesSeen');
|
|
724
|
+
let names = null;
|
|
725
|
+
if (stored !== null) {
|
|
726
|
+
try {
|
|
727
|
+
names = JSON.parse(stored);
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
names = null;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
this.#publishedSeenCache = new Set(Array.isArray(names)
|
|
734
|
+
? names.filter((name) => typeof name === 'string')
|
|
735
|
+
: []);
|
|
736
|
+
}
|
|
737
|
+
return this.#publishedSeenCache;
|
|
738
|
+
}
|
|
739
|
+
#notePublishedTables(tables) {
|
|
740
|
+
const seen = this.#publishedSeen();
|
|
741
|
+
let added = false;
|
|
742
|
+
for (const table of tables) {
|
|
743
|
+
if (seen.has(table))
|
|
744
|
+
continue;
|
|
745
|
+
seen.add(table);
|
|
746
|
+
added = true;
|
|
747
|
+
}
|
|
748
|
+
if (added) {
|
|
749
|
+
this.#controlSet('publishedTablesSeen', JSON.stringify([...seen].sort()));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
693
752
|
#controlDelete(...keys) {
|
|
694
753
|
if (keys.length === 0)
|
|
695
754
|
return;
|
|
@@ -897,6 +956,12 @@ export function createSyncDurableObject(config) {
|
|
|
897
956
|
(page.nextCursor !== null && typeof page.nextCursor !== 'string')) {
|
|
898
957
|
throw new Error('invalid upstream snapshot page response');
|
|
899
958
|
}
|
|
959
|
+
// a snapshot answer is upstream stating outright whether it publishes this
|
|
960
|
+
// table, which makes it the one place that can build a complete baseline:
|
|
961
|
+
// every modelled table is paged here, not just the ones that happen to be
|
|
962
|
+
// written while this replica is watching.
|
|
963
|
+
if (!page.unpublishedTables?.includes(table))
|
|
964
|
+
this.#notePublishedTables([table]);
|
|
900
965
|
return page;
|
|
901
966
|
}
|
|
902
967
|
async #beginSnapshotGeneration(path) {
|
|
@@ -1011,8 +1076,21 @@ export function createSyncDurableObject(config) {
|
|
|
1011
1076
|
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
1012
1077
|
throw new Error('invalid upstream changes response');
|
|
1013
1078
|
}
|
|
1014
|
-
|
|
1079
|
+
const catchupUnpublished = unpublishedRegressions(batch, config.schema, this.#publishedSeen());
|
|
1080
|
+
if (catchupUnpublished.length > 0) {
|
|
1081
|
+
this.#tripIngest('ingestTableUnpublished', {
|
|
1082
|
+
phase: 'snapshot_catchup',
|
|
1083
|
+
generation: activeProgress.generation,
|
|
1084
|
+
cursor,
|
|
1085
|
+
batchWatermark: batch.watermark,
|
|
1086
|
+
changeRows: batch.changes.length,
|
|
1087
|
+
tables: catchupUnpublished,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
const catchupTables = upstreamBatchTables(batch);
|
|
1091
|
+
for (const table of catchupTables)
|
|
1015
1092
|
changedTables.add(table);
|
|
1093
|
+
this.#notePublishedTables(catchupTables);
|
|
1016
1094
|
this.#resetSnapshotBillingWindow();
|
|
1017
1095
|
const result = this.#withIngestBilling({
|
|
1018
1096
|
phase: 'snapshot_catchup',
|
|
@@ -1085,8 +1163,20 @@ export function createSyncDurableObject(config) {
|
|
|
1085
1163
|
? batchCommitTimeMs
|
|
1086
1164
|
: Math.min(oldestLiveCommitTimeMs, batchCommitTimeMs);
|
|
1087
1165
|
}
|
|
1088
|
-
|
|
1166
|
+
const unpublished = unpublishedRegressions(batch, config.schema, this.#publishedSeen());
|
|
1167
|
+
if (unpublished.length > 0) {
|
|
1168
|
+
this.#tripIngest('ingestTableUnpublished', {
|
|
1169
|
+
phase: 'changes',
|
|
1170
|
+
cursor,
|
|
1171
|
+
batchWatermark: batch.watermark,
|
|
1172
|
+
changeRows: batch.changes.length,
|
|
1173
|
+
tables: unpublished,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
const batchTables = upstreamBatchTables(batch);
|
|
1177
|
+
for (const table of batchTables)
|
|
1089
1178
|
changedTables.add(table);
|
|
1179
|
+
this.#notePublishedTables(batchTables);
|
|
1090
1180
|
const result = this.#withIngestBilling({
|
|
1091
1181
|
phase: 'changes',
|
|
1092
1182
|
cursor,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type IngestBreakerReason = 'ingestBudgetExceeded' | 'ingestCursorStalled';
|
|
1
|
+
export type IngestBreakerReason = 'ingestBudgetExceeded' | 'ingestCursorStalled' | 'ingestTableUnpublished';
|
|
2
2
|
export type IngestBreakerOptions = {
|
|
3
3
|
budgetRows: number;
|
|
4
4
|
windowMs: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orez-sync-cf-host",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.15",
|
|
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": {
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@opentelemetry/api": "^1.9.0",
|
|
63
|
-
"orez-sync-executor": "0.16.
|
|
63
|
+
"orez-sync-executor": "0.16.15"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@cloudflare/workers-types": "4.20260617.1",
|