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 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
- for (const table of upstreamBatchTables(batch))
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
- for (const table of upstreamBatchTables(batch))
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.14",
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.14"
63
+ "orez-sync-executor": "0.16.15"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@cloudflare/workers-types": "4.20260617.1",