@rocicorp/zero 1.10.0-canary.2 → 1.10.0-canary.4

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.
Files changed (181) hide show
  1. package/out/zero/package.js +1 -1
  2. package/out/zero/package.js.map +1 -1
  3. package/out/zero-cache/src/auth/write-authorizer.d.ts.map +1 -1
  4. package/out/zero-cache/src/auth/write-authorizer.js +8 -1
  5. package/out/zero-cache/src/auth/write-authorizer.js.map +1 -1
  6. package/out/zero-cache/src/config/normalize.d.ts.map +1 -1
  7. package/out/zero-cache/src/config/normalize.js +7 -2
  8. package/out/zero-cache/src/config/normalize.js.map +1 -1
  9. package/out/zero-cache/src/config/zero-config.d.ts +14 -4
  10. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  11. package/out/zero-cache/src/config/zero-config.js +26 -8
  12. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  13. package/out/zero-cache/src/db/transaction-pool.d.ts +5 -2
  14. package/out/zero-cache/src/db/transaction-pool.d.ts.map +1 -1
  15. package/out/zero-cache/src/db/transaction-pool.js +5 -3
  16. package/out/zero-cache/src/db/transaction-pool.js.map +1 -1
  17. package/out/zero-cache/src/server/change-streamer.d.ts.map +1 -1
  18. package/out/zero-cache/src/server/change-streamer.js +24 -13
  19. package/out/zero-cache/src/server/change-streamer.js.map +1 -1
  20. package/out/zero-cache/src/server/replicator.d.ts.map +1 -1
  21. package/out/zero-cache/src/server/replicator.js +1 -1
  22. package/out/zero-cache/src/server/replicator.js.map +1 -1
  23. package/out/zero-cache/src/server/runner/run-worker.js +1 -1
  24. package/out/zero-cache/src/server/worker-urls.d.ts +0 -1
  25. package/out/zero-cache/src/server/worker-urls.d.ts.map +1 -1
  26. package/out/zero-cache/src/server/worker-urls.js +1 -2
  27. package/out/zero-cache/src/server/worker-urls.js.map +1 -1
  28. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +1 -1
  29. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  30. package/out/zero-cache/src/services/change-source/pg/change-source.js +1 -1
  31. package/out/zero-cache/src/services/change-source/pg/decommission.d.ts.map +1 -1
  32. package/out/zero-cache/src/services/change-source/pg/decommission.js +20 -11
  33. package/out/zero-cache/src/services/change-source/pg/decommission.js.map +1 -1
  34. package/out/zero-cache/src/services/change-source/pg/initial-sync.d.ts.map +1 -1
  35. package/out/zero-cache/src/services/change-source/pg/initial-sync.js +51 -74
  36. package/out/zero-cache/src/services/change-source/pg/initial-sync.js.map +1 -1
  37. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts +20 -0
  38. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts.map +1 -1
  39. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js +43 -3
  40. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js.map +1 -1
  41. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts +24 -1
  42. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts.map +1 -1
  43. package/out/zero-cache/src/services/change-source/pg/replication-slots.js +35 -5
  44. package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
  45. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts +1 -4
  46. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts.map +1 -1
  47. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js +11 -18
  48. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js.map +1 -1
  49. package/out/zero-cache/src/services/change-streamer/backup-monitor.d.ts.map +1 -1
  50. package/out/zero-cache/src/services/change-streamer/backup-monitor.js +8 -3
  51. package/out/zero-cache/src/services/change-streamer/backup-monitor.js.map +1 -1
  52. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts +34 -0
  53. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts.map +1 -0
  54. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js +67 -0
  55. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js.map +1 -0
  56. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts +2 -4
  57. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts.map +1 -1
  58. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +8 -2
  59. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
  60. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts +34 -13
  61. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts.map +1 -1
  62. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +149 -27
  63. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  64. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts +3 -9
  65. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts.map +1 -1
  66. package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
  67. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts +11 -0
  68. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts.map +1 -0
  69. package/out/zero-cache/src/services/change-streamer/shard-sampling.js +19 -0
  70. package/out/zero-cache/src/services/change-streamer/shard-sampling.js.map +1 -0
  71. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts +11 -1
  72. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts.map +1 -1
  73. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js +38 -5
  74. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js.map +1 -1
  75. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts +25 -1
  76. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts.map +1 -1
  77. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js +95 -12
  78. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js.map +1 -1
  79. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts +83 -0
  80. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts.map +1 -0
  81. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js +417 -0
  82. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js.map +1 -0
  83. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts +73 -0
  84. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts.map +1 -0
  85. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js +157 -0
  86. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js.map +1 -0
  87. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts +1 -1
  88. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts.map +1 -1
  89. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js +8 -3
  90. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js.map +1 -1
  91. package/out/zero-cache/src/services/change-streamer/storer.d.ts +23 -0
  92. package/out/zero-cache/src/services/change-streamer/storer.d.ts.map +1 -1
  93. package/out/zero-cache/src/services/change-streamer/storer.js +40 -0
  94. package/out/zero-cache/src/services/change-streamer/storer.js.map +1 -1
  95. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts +29 -0
  96. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts.map +1 -0
  97. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js +154 -0
  98. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js.map +1 -0
  99. package/out/zero-cache/src/services/http-service.d.ts +1 -0
  100. package/out/zero-cache/src/services/http-service.d.ts.map +1 -1
  101. package/out/zero-cache/src/services/http-service.js +7 -3
  102. package/out/zero-cache/src/services/http-service.js.map +1 -1
  103. package/out/zero-cache/src/services/litestream/metrics.d.ts +1 -0
  104. package/out/zero-cache/src/services/litestream/metrics.d.ts.map +1 -1
  105. package/out/zero-cache/src/services/litestream/metrics.js +4 -1
  106. package/out/zero-cache/src/services/litestream/metrics.js.map +1 -1
  107. package/out/zero-cache/src/services/mutagen/mutagen.d.ts.map +1 -1
  108. package/out/zero-cache/src/services/mutagen/mutagen.js +1 -1
  109. package/out/zero-cache/src/services/mutagen/mutagen.js.map +1 -1
  110. package/out/zero-cache/src/services/replicator/change-log-db.d.ts +10 -0
  111. package/out/zero-cache/src/services/replicator/change-log-db.d.ts.map +1 -1
  112. package/out/zero-cache/src/services/replicator/change-log-db.js +15 -1
  113. package/out/zero-cache/src/services/replicator/change-log-db.js.map +1 -1
  114. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts +3 -2
  115. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts.map +1 -1
  116. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js +8 -6
  117. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js.map +1 -1
  118. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts +3 -2
  119. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts.map +1 -1
  120. package/out/zero-cache/src/services/view-syncer/client-handler.js +25 -17
  121. package/out/zero-cache/src/services/view-syncer/client-handler.js.map +1 -1
  122. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts.map +1 -1
  123. package/out/zero-cache/src/services/view-syncer/cvr-store.js +14 -1
  124. package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
  125. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +3 -3
  126. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  127. package/out/zero-cache/src/services/view-syncer/view-syncer.js +6 -2
  128. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  129. package/out/zero-cache/src/types/downstream.d.ts +11 -0
  130. package/out/zero-cache/src/types/downstream.d.ts.map +1 -0
  131. package/out/zero-cache/src/types/pg.d.ts.map +1 -1
  132. package/out/zero-cache/src/types/pg.js +14 -4
  133. package/out/zero-cache/src/types/pg.js.map +1 -1
  134. package/out/zero-cache/src/types/poke-chunk.d.ts +18 -0
  135. package/out/zero-cache/src/types/poke-chunk.d.ts.map +1 -0
  136. package/out/zero-cache/src/types/poke-chunk.js +57 -0
  137. package/out/zero-cache/src/types/poke-chunk.js.map +1 -0
  138. package/out/zero-cache/src/types/processes.js +1 -4
  139. package/out/zero-cache/src/types/processes.js.map +1 -1
  140. package/out/zero-cache/src/workers/connection.d.ts +22 -4
  141. package/out/zero-cache/src/workers/connection.d.ts.map +1 -1
  142. package/out/zero-cache/src/workers/connection.js +112 -14
  143. package/out/zero-cache/src/workers/connection.js.map +1 -1
  144. package/out/zero-client/src/client/version.js +1 -1
  145. package/out/zero-client/src/client/zero-poke-handler.d.ts +7 -2
  146. package/out/zero-client/src/client/zero-poke-handler.d.ts.map +1 -1
  147. package/out/zero-client/src/client/zero-poke-handler.js +52 -2
  148. package/out/zero-client/src/client/zero-poke-handler.js.map +1 -1
  149. package/out/zero-client/src/client/zero.d.ts.map +1 -1
  150. package/out/zero-client/src/client/zero.js +38 -6
  151. package/out/zero-client/src/client/zero.js.map +1 -1
  152. package/out/zero-protocol/src/down.d.ts +1 -1
  153. package/out/zero-protocol/src/poke.d.ts +89 -7
  154. package/out/zero-protocol/src/poke.d.ts.map +1 -1
  155. package/out/zero-protocol/src/poke.js +16 -8
  156. package/out/zero-protocol/src/poke.js.map +1 -1
  157. package/out/zero-protocol/src/protocol-version.d.ts +12 -7
  158. package/out/zero-protocol/src/protocol-version.d.ts.map +1 -1
  159. package/out/zero-protocol/src/protocol-version.js.map +1 -1
  160. package/out/zql/src/ivm/memory-source.d.ts +10 -2
  161. package/out/zql/src/ivm/memory-source.d.ts.map +1 -1
  162. package/out/zql/src/ivm/memory-source.js +14 -6
  163. package/out/zql/src/ivm/memory-source.js.map +1 -1
  164. package/out/zql/src/ivm/stream.d.ts +0 -1
  165. package/out/zql/src/ivm/stream.d.ts.map +1 -1
  166. package/out/zql/src/ivm/stream.js +1 -7
  167. package/out/zql/src/ivm/stream.js.map +1 -1
  168. package/out/zql/src/ivm/union-fan-in.d.ts +1 -1
  169. package/out/zql/src/ivm/union-fan-in.d.ts.map +1 -1
  170. package/out/zql/src/ivm/union-fan-in.js +11 -2
  171. package/out/zql/src/ivm/union-fan-in.js.map +1 -1
  172. package/out/zqlite/src/table-source.d.ts.map +1 -1
  173. package/out/zqlite/src/table-source.js +1 -1
  174. package/out/zqlite/src/table-source.js.map +1 -1
  175. package/package.json +1 -1
  176. package/out/zero-cache/src/server/backup-watermark-poller.js +0 -31
  177. package/out/zero-cache/src/server/backup-watermark-poller.js.map +0 -1
  178. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts +0 -57
  179. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts.map +0 -1
  180. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js +0 -137
  181. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js.map +0 -1
@@ -0,0 +1,417 @@
1
+ import { Database } from "../../../../zqlite/src/db.js";
2
+ import { getOrCreateCounter, getOrCreateLatencyHistogram } from "../../observability/metrics.js";
3
+ import { CHANGE_LOG_STREAM_TABLE, readChangeLogMeta } from "../replicator/change-log-db.js";
4
+ import { reconstructWatermarkedChange } from "./change-log-codec.js";
5
+ import { SQLiteChangeLogReader } from "./sqlite-change-log-reader.js";
6
+ import { digestCatchupRange } from "./change-log-compare-digest.js";
7
+ import { isSampledForShard } from "./shard-sampling.js";
8
+ //#region ../zero-cache/src/services/change-streamer/sqlite-change-log-comparator.ts
9
+ /** Interval between comparison cycles. */
10
+ var DEFAULT_COMPARE_INTERVAL_MS = 3e4;
11
+ /** Maximum transactions enumerated per cycle. */
12
+ var DEFAULT_MAX_TRANSACTIONS_PER_CYCLE = 256;
13
+ /** Maximum catchup rows read from each store per cycle. */
14
+ var DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE = 1e3;
15
+ /**
16
+ * Maximum catchup bytes read from each store per cycle.
17
+ *
18
+ * Rows bound the per-row overhead. Bytes bound the read and hash work,
19
+ * which scales with payload size instead of row count. Normal traffic
20
+ * reaches the row limit first. Wide payloads reach this one.
21
+ */
22
+ var DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE = 32 * 1024 * 1024;
23
+ /** Rows per SQLite catchup batch. */
24
+ var DEFAULT_READ_BATCH_ROWS = 1e3;
25
+ /** Uses separate aggregates so SQLite can use an index for each value. */
26
+ var SQLITE_BOUNDS_SQL = `
27
+ SELECT
28
+ (SELECT min("watermark") FROM "${CHANGE_LOG_STREAM_TABLE}")
29
+ AS "minWatermark",
30
+ (SELECT max("watermark") FROM "${CHANGE_LOG_STREAM_TABLE}")
31
+ AS "headWatermark"
32
+ `;
33
+ /** Lists committed transaction watermarks in `(@after, @through]`. */
34
+ var SQLITE_COMMITS_SQL = `
35
+ SELECT "watermark" FROM "${CHANGE_LOG_STREAM_TABLE}"
36
+ WHERE "precommit" IS NOT NULL
37
+ AND "watermark" > @after
38
+ AND "watermark" <= @through
39
+ ORDER BY "watermark"
40
+ LIMIT @limit
41
+ `;
42
+ /**
43
+ * Compares SQLite and Postgres catchup output without affecting subscribers.
44
+ *
45
+ * Each cycle compares complete ranges through the lower store head.
46
+ * It checks every transaction for presence and samples payloads.
47
+ * A bounds change makes the affected result `inconclusive`.
48
+ * Postgres remains authoritative.
49
+ */
50
+ var SQLiteChangeLogComparator = class {
51
+ #lc;
52
+ #shard;
53
+ #changeLogFile;
54
+ #identity;
55
+ #pg;
56
+ #opts;
57
+ #now;
58
+ #setTimeoutFn;
59
+ #clearTimeoutFn;
60
+ #yield;
61
+ #compareResults = getOrCreateCounter("replica", "sqlite_change_log.compare_result", "Outcomes of the dark comparison between what SQLite catchup serves and what PG catchup serves, per committed transaction. `inconclusive` means a purge or reseed moved the pinned bounds mid-comparison.");
62
+ #compareCycles = getOrCreateCounter("replica", "sqlite_change_log.compare_cycles", "SQLite change-log comparison cycles, by result.");
63
+ #cycleDuration = getOrCreateLatencyHistogram("replica", "sqlite_change_log.compare_cycle_duration", "Duration of one SQLite change-log comparison cycle.");
64
+ /** Last handled commit watermark. A restart derives it from current bounds. */
65
+ #cursor;
66
+ #running;
67
+ #timer;
68
+ #stopped = false;
69
+ #continueWithoutDelay = false;
70
+ #fileGeneration = 0;
71
+ constructor(lc, shard, changeLogFile, identity, pg, opts) {
72
+ this.#lc = lc.withContext("component", "sqlite-change-log-compare");
73
+ this.#shard = shard;
74
+ this.#changeLogFile = changeLogFile;
75
+ this.#identity = identity;
76
+ this.#pg = pg;
77
+ this.#opts = opts;
78
+ this.#now = opts.now ?? Date.now;
79
+ this.#setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
80
+ this.#clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
81
+ this.#yield = opts.yieldFn ?? (() => new Promise((resolve) => setImmediate(resolve)));
82
+ this.#scheduleNext();
83
+ }
84
+ /**
85
+ * Runs one cycle. Concurrent calls share the same promise.
86
+ * Errors produce `skipped: 'error'`.
87
+ */
88
+ compareOnce() {
89
+ if (this.#stopped) return Promise.resolve({
90
+ kind: "skipped",
91
+ reason: "stopped"
92
+ });
93
+ if (this.#running === void 0) this.#running = this.#runCycle().then((result) => {
94
+ if (this.#continueWithoutDelay) this.#rescheduleNext(0);
95
+ return result;
96
+ }).finally(() => {
97
+ this.#running = void 0;
98
+ });
99
+ return this.#running;
100
+ }
101
+ stop() {
102
+ this.#stopped = true;
103
+ if (this.#timer !== void 0) {
104
+ this.#clearTimeoutFn(this.#timer);
105
+ this.#timer = void 0;
106
+ }
107
+ }
108
+ /** Invalidates cycles that can still read a replaced file. */
109
+ invalidate() {
110
+ this.#fileGeneration++;
111
+ this.#cursor = void 0;
112
+ this.#continueWithoutDelay = false;
113
+ }
114
+ #scheduleNext(delayMs = this.#opts.intervalMs ?? DEFAULT_COMPARE_INTERVAL_MS) {
115
+ if (this.#stopped || this.#timer !== void 0) return;
116
+ this.#timer = this.#setTimeoutFn(() => {
117
+ this.#timer = void 0;
118
+ this.compareOnce().finally(() => this.#scheduleNext());
119
+ }, delayMs);
120
+ }
121
+ #rescheduleNext(delayMs) {
122
+ if (this.#timer !== void 0) {
123
+ this.#clearTimeoutFn(this.#timer);
124
+ this.#timer = void 0;
125
+ }
126
+ this.#scheduleNext(delayMs);
127
+ }
128
+ async #runCycle() {
129
+ const startedAt = performance.now();
130
+ const fileGeneration = this.#fileGeneration;
131
+ this.#continueWithoutDelay = false;
132
+ let db;
133
+ let reader;
134
+ try {
135
+ try {
136
+ db = new Database(this.#lc, this.#changeLogFile, { readonly: true });
137
+ } catch {
138
+ return this.#skipped("log-unavailable");
139
+ }
140
+ const pinned = await this.#pinBounds(db);
141
+ if (fileGeneration !== this.#fileGeneration) return this.#skipped("log-rebuilt");
142
+ if (typeof pinned === "string") return this.#skipped(pinned);
143
+ const { meta } = pinned;
144
+ const from = maxWatermark(this.#cursor ?? meta.seedWatermark, meta.seedWatermark, pinned.pgMinWatermark, pinned.sqlite.minWatermark);
145
+ const through = minWatermark(pinned.pgLastWatermark, pinned.sqlite.headWatermark);
146
+ if (through <= from) return this.#skipped("nothing-to-compare");
147
+ let transactions = 0;
148
+ let sampled = 0;
149
+ const outcomes = {
150
+ match: 0,
151
+ mismatch: 0,
152
+ inconclusive: 0,
153
+ deferred: 0,
154
+ oversized: 0
155
+ };
156
+ const record = (watermark, outcome) => {
157
+ this.#compareResults.add(1, { outcome });
158
+ outcomes[outcome]++;
159
+ if (outcome === "mismatch") this.#lc.error?.("SQLite change-log catchup output diverged from Postgres", { sqliteChangeLogCompare: { watermark } });
160
+ };
161
+ reader = new SQLiteChangeLogReader(this.#lc, this.#changeLogFile);
162
+ const plan = reader.plan(from);
163
+ if (plan.kind === "not-ready") return this.#skipped("log-unavailable");
164
+ if (plan.kind === "ahead") return this.#skipped("nothing-to-compare");
165
+ if (plan.kind === "too-old") {
166
+ const reconfirmed = await this.#reconfirm(db, pinned, from);
167
+ const outcome = fileGeneration === this.#fileGeneration ? reconfirmed : "inconclusive";
168
+ record(from, outcome);
169
+ if (outcome === "inconclusive") return this.#compared(from, from, transactions, sampled, outcomes);
170
+ }
171
+ const limit = Math.max(1, this.#opts.maxTransactionsPerCycle ?? DEFAULT_MAX_TRANSACTIONS_PER_CYCLE);
172
+ const union = await this.#enumerateCommits(db, from, through, limit);
173
+ if (fileGeneration !== this.#fileGeneration) return this.#skipped("log-rebuilt");
174
+ const maxRowsPerSource = Math.max(1, this.#opts.maxRowsPerSourcePerCycle ?? DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE);
175
+ const maxBytesPerSource = Math.max(1, this.#opts.maxBytesPerSourcePerCycle ?? DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE);
176
+ let sqliteRowsRead = 0;
177
+ let pgRowsRead = 0;
178
+ let sqliteBytesRead = 0;
179
+ let pgBytesRead = 0;
180
+ let previousBoundary = from;
181
+ let handledThrough = from;
182
+ for (const { watermark, inSqlite, inPg } of union) {
183
+ if (this.#stopped) break;
184
+ let stopAfterTransaction = false;
185
+ if (!inSqlite || !inPg) {
186
+ const outcome = await this.#reconfirm(db, pinned, watermark);
187
+ record(watermark, fileGeneration === this.#fileGeneration ? outcome : "inconclusive");
188
+ } else if (isSampledForShard(this.#shard, watermark, this.#opts.comparePercent)) {
189
+ const sqliteRowsRemaining = maxRowsPerSource - sqliteRowsRead;
190
+ const pgRowsRemaining = maxRowsPerSource - pgRowsRead;
191
+ const sqliteBytesRemaining = maxBytesPerSource - sqliteBytesRead;
192
+ const pgBytesRemaining = maxBytesPerSource - pgBytesRead;
193
+ if (sqliteRowsRemaining === 0 || pgRowsRemaining === 0 || sqliteBytesRemaining <= 0 || pgBytesRemaining <= 0) {
194
+ record(watermark, "deferred");
195
+ break;
196
+ }
197
+ sampled++;
198
+ const comparison = await this.#compareTransaction(db, reader, pinned, previousBoundary, watermark, sqliteRowsRemaining, pgRowsRemaining, sqliteBytesRemaining, pgBytesRemaining, maxRowsPerSource, maxBytesPerSource);
199
+ sqliteRowsRead += comparison.sqliteRowsRead;
200
+ pgRowsRead += comparison.pgRowsRead;
201
+ sqliteBytesRead += comparison.sqliteBytesRead;
202
+ pgBytesRead += comparison.pgBytesRead;
203
+ const outcome = fileGeneration === this.#fileGeneration ? comparison.outcome : "inconclusive";
204
+ record(watermark, outcome);
205
+ if (outcome === "inconclusive") break;
206
+ if (outcome === "deferred") break;
207
+ if (outcome === "oversized") stopAfterTransaction = true;
208
+ await this.#yield();
209
+ }
210
+ previousBoundary = watermark;
211
+ handledThrough = watermark;
212
+ transactions++;
213
+ if (stopAfterTransaction) break;
214
+ }
215
+ if (!this.#stopped && fileGeneration === this.#fileGeneration && outcomes.inconclusive === 0) {
216
+ this.#cursor = handledThrough;
217
+ this.#continueWithoutDelay = handledThrough < through;
218
+ }
219
+ return this.#compared(from, handledThrough, transactions, sampled, outcomes);
220
+ } catch (e) {
221
+ this.#lc.warn?.("error comparing the SQLite change log", e);
222
+ return this.#skipped("error");
223
+ } finally {
224
+ reader?.close();
225
+ db?.close();
226
+ this.#cycleDuration.recordMs(performance.now() - startedAt);
227
+ }
228
+ }
229
+ /** Checks log eligibility and reads the bounds from both stores. */
230
+ async #pinBounds(db) {
231
+ let meta;
232
+ try {
233
+ meta = readChangeLogMeta(db);
234
+ } catch {
235
+ return "log-unavailable";
236
+ }
237
+ if (meta.schemaVersion !== 4) return "ineligible-schema";
238
+ const { epoch, generation, replicaID } = this.#identity;
239
+ if (meta.epoch !== epoch || meta.generation !== generation || meta.replicaID !== replicaID) return "ineligible-identity";
240
+ if (this.#now() - meta.seededAtMs < this.#opts.retentionMs) return "cold-log";
241
+ const sqlite = this.#readSQLiteBounds(db);
242
+ if (sqlite === void 0) return "log-unavailable";
243
+ const { minWatermark, lastWatermark } = await this.#pg.getCatchupBounds();
244
+ if (minWatermark === null) return "nothing-to-compare";
245
+ return {
246
+ meta,
247
+ sqlite,
248
+ pgMinWatermark: minWatermark,
249
+ pgLastWatermark: lastWatermark
250
+ };
251
+ }
252
+ #readSQLiteBounds(db) {
253
+ const bounds = db.prepare(SQLITE_BOUNDS_SQL).get();
254
+ return bounds.minWatermark === null || bounds.headWatermark === null ? void 0 : {
255
+ minWatermark: bounds.minWatermark,
256
+ headWatermark: bounds.headWatermark
257
+ };
258
+ }
259
+ /**
260
+ * Merges committed transaction watermarks from both stores.
261
+ * If a list reaches the limit, the result ends at the last complete common range.
262
+ */
263
+ async #enumerateCommits(db, from, through, limit) {
264
+ let sqlite = db.prepare(SQLITE_COMMITS_SQL).all({
265
+ after: from,
266
+ through,
267
+ limit: limit + 1
268
+ }).map(({ watermark }) => watermark);
269
+ let pg = await this.#pg.listCommitWatermarks(from, through, limit + 1);
270
+ let effectiveThrough = through;
271
+ if (sqlite.length > limit) {
272
+ sqlite.length = limit;
273
+ effectiveThrough = minWatermark(effectiveThrough, sqlite[limit - 1]);
274
+ }
275
+ if (pg.length > limit) {
276
+ pg.length = limit;
277
+ effectiveThrough = minWatermark(effectiveThrough, pg[limit - 1]);
278
+ }
279
+ if (effectiveThrough !== through) {
280
+ sqlite = sqlite.filter((w) => w <= effectiveThrough);
281
+ pg = pg.filter((w) => w <= effectiveThrough);
282
+ }
283
+ const union = [];
284
+ let s = 0;
285
+ let p = 0;
286
+ while (s < sqlite.length || p < pg.length) {
287
+ const sw = s < sqlite.length ? sqlite[s] : void 0;
288
+ const pw = p < pg.length ? pg[p] : void 0;
289
+ if (sw !== void 0 && (pw === void 0 || sw < pw)) {
290
+ union.push({
291
+ watermark: sw,
292
+ inSqlite: true,
293
+ inPg: false
294
+ });
295
+ s++;
296
+ } else if (pw !== void 0 && (sw === void 0 || pw < sw)) {
297
+ union.push({
298
+ watermark: pw,
299
+ inSqlite: false,
300
+ inPg: true
301
+ });
302
+ p++;
303
+ } else if (sw !== void 0) {
304
+ union.push({
305
+ watermark: sw,
306
+ inSqlite: true,
307
+ inPg: true
308
+ });
309
+ s++;
310
+ p++;
311
+ }
312
+ }
313
+ return union;
314
+ }
315
+ /**
316
+ * Compares the output in `(previousBoundary, watermark]`.
317
+ * Positional reads do not require either store to contain the boundary row.
318
+ */
319
+ async #compareTransaction(db, reader, pinned, previousBoundary, watermark, sqliteMaxRows, pgMaxRows, sqliteMaxBytes, pgMaxBytes, maxRowsPerSource, maxBytesPerSource) {
320
+ let sqlite;
321
+ let pg;
322
+ try {
323
+ const readBatchRows = this.#opts.readBatchRows ?? DEFAULT_READ_BATCH_ROWS;
324
+ sqlite = await digestCatchupRange(reader.read(previousBoundary, watermark, Math.min(readBatchRows, sqliteMaxRows), void 0, sqliteMaxRows), watermark, sqliteMaxRows, sqliteMaxBytes);
325
+ pg = await digestCatchupRange(reconstructBatches(this.#pg.readCatchupRange(previousBoundary, watermark, pgMaxRows)), watermark, pgMaxRows, pgMaxBytes);
326
+ } catch (e) {
327
+ this.#lc.warn?.(`error reading transaction ${watermark} for comparison`, e);
328
+ return {
329
+ outcome: await this.#reconfirm(db, pinned, watermark),
330
+ sqliteRowsRead: 0,
331
+ pgRowsRead: 0,
332
+ sqliteBytesRead: 0,
333
+ pgBytesRead: 0
334
+ };
335
+ }
336
+ const read = {
337
+ sqliteRowsRead: sqlite.rows,
338
+ pgRowsRead: pg.rows,
339
+ sqliteBytesRead: sqlite.bytes,
340
+ pgBytesRead: pg.bytes
341
+ };
342
+ if (sqlite.limitReached || pg.limitReached) {
343
+ const sqliteFresh = sqliteMaxRows === maxRowsPerSource && sqliteMaxBytes === maxBytesPerSource;
344
+ const pgFresh = pgMaxRows === maxRowsPerSource && pgMaxBytes === maxBytesPerSource;
345
+ return {
346
+ outcome: sqlite.limitReached && sqliteFresh || pg.limitReached && pgFresh ? "oversized" : "deferred",
347
+ ...read
348
+ };
349
+ }
350
+ if (sqlite.digest === pg.digest) return {
351
+ outcome: "match",
352
+ ...read
353
+ };
354
+ return {
355
+ outcome: await this.#reconfirm(db, pinned, watermark),
356
+ ...read
357
+ };
358
+ }
359
+ /**
360
+ * Rechecks a suspect result against current store bounds.
361
+ * A moved bound, changed seed, failed read, or lower head makes the result inconclusive.
362
+ */
363
+ async #reconfirm(db, pinned, watermark) {
364
+ try {
365
+ const meta = readChangeLogMeta(db);
366
+ if (meta.seedWatermark !== pinned.meta.seedWatermark || meta.seededAtMs !== pinned.meta.seededAtMs) return "inconclusive";
367
+ const sqlite = this.#readSQLiteBounds(db);
368
+ const { minWatermark, lastWatermark } = await this.#pg.getCatchupBounds();
369
+ if (sqlite === void 0 || minWatermark === null) return "inconclusive";
370
+ if (sqlite.minWatermark > pinned.sqlite.minWatermark && watermark <= sqlite.minWatermark || minWatermark > pinned.pgMinWatermark && watermark <= minWatermark || watermark > sqlite.headWatermark || watermark > lastWatermark) return "inconclusive";
371
+ return "mismatch";
372
+ } catch {
373
+ return "inconclusive";
374
+ }
375
+ }
376
+ #skipped(reason) {
377
+ this.#compareCycles.add(1, { result: reason });
378
+ if (reason === "error") {} else if (reason !== "nothing-to-compare") this.#lc.debug?.(`skipping SQLite change-log comparison: ${reason}`);
379
+ return {
380
+ kind: "skipped",
381
+ reason
382
+ };
383
+ }
384
+ #compared(fromWatermark, throughWatermark, transactions, sampled, outcomes) {
385
+ this.#compareCycles.add(1, { result: "compared" });
386
+ const result = {
387
+ kind: "compared",
388
+ fromWatermark,
389
+ throughWatermark,
390
+ transactions,
391
+ sampled,
392
+ matched: outcomes.match,
393
+ mismatched: outcomes.mismatch,
394
+ inconclusive: outcomes.inconclusive,
395
+ deferred: outcomes.deferred,
396
+ oversized: outcomes.oversized
397
+ };
398
+ this.#lc.debug?.("SQLite change-log comparison cycle", { sqliteChangeLogCompare: result });
399
+ return result;
400
+ }
401
+ };
402
+ /** Applies the same reconstruction as Postgres catchup. */
403
+ async function* reconstructBatches(batches) {
404
+ for await (const batch of batches) yield batch.map(reconstructWatermarkedChange);
405
+ }
406
+ function minWatermark(a, b) {
407
+ return a < b ? a : b;
408
+ }
409
+ function maxWatermark(...watermarks) {
410
+ let max = watermarks[0];
411
+ for (const w of watermarks) if (w > max) max = w;
412
+ return max;
413
+ }
414
+ //#endregion
415
+ export { SQLiteChangeLogComparator };
416
+
417
+ //# sourceMappingURL=sqlite-change-log-comparator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-change-log-comparator.js","names":["#lc","#shard","#changeLogFile","#identity","#pg","#opts","#now","#setTimeoutFn","#clearTimeoutFn","#yield","#compareResults","#compareCycles","#cycleDuration","#scheduleNext","#stopped","#running","#runCycle","#continueWithoutDelay","#rescheduleNext","#timer","#fileGeneration","#cursor","#skipped","#pinBounds","#reconfirm","#compared","#enumerateCommits","#compareTransaction","#readSQLiteBounds"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-comparator.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {Database} from '../../../../zqlite/src/db.ts';\nimport {\n getOrCreateCounter,\n getOrCreateLatencyHistogram,\n} from '../../observability/metrics.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport {\n CHANGE_LOG_DB_SCHEMA_VERSION,\n CHANGE_LOG_STREAM_TABLE,\n readChangeLogMeta,\n type ChangeLogIdentity,\n type ChangeLogMeta,\n} from '../replicator/change-log-db.ts';\nimport {\n reconstructWatermarkedChange,\n type ChangeLogEntry,\n} from './change-log-codec.ts';\nimport {\n digestCatchupRange,\n type CatchupRangeDigest,\n} from './change-log-compare-digest.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\nimport {isSampledForShard} from './shard-sampling.ts';\nimport {SQLiteChangeLogReader} from './sqlite-change-log-reader.ts';\n\n/** Interval between comparison cycles. */\nconst DEFAULT_COMPARE_INTERVAL_MS = 30_000;\n\n/** Maximum transactions enumerated per cycle. */\nconst DEFAULT_MAX_TRANSACTIONS_PER_CYCLE = 256;\n\n/** Maximum catchup rows read from each store per cycle. */\nconst DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE = 1000;\n\n/**\n * Maximum catchup bytes read from each store per cycle.\n *\n * Rows bound the per-row overhead. Bytes bound the read and hash work,\n * which scales with payload size instead of row count. Normal traffic\n * reaches the row limit first. Wide payloads reach this one.\n */\nconst DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE = 32 * 1024 * 1024;\n\n/** Rows per SQLite catchup batch. */\nconst DEFAULT_READ_BATCH_ROWS = 1000;\n\n/**\n * Result for one committed transaction.\n *\n * `mismatch` covers missing, extra, changed, or reordered rows.\n * `inconclusive` means that store bounds changed during the comparison.\n */\nexport type CompareOutcome =\n | 'match'\n | 'mismatch'\n | 'inconclusive'\n | 'deferred'\n | 'oversized';\n\nexport type CompareSkipReason =\n // The comparator was stopped.\n | 'stopped'\n // The writer replaced the file during this cycle.\n | 'log-rebuilt'\n // The file is absent, unreadable, or uninitialized.\n | 'log-unavailable'\n // The schema version does not match this build.\n | 'ineligible-schema'\n // The log belongs to a different replica.\n | 'ineligible-identity'\n // The log is younger than the warm-up period.\n | 'cold-log'\n // The stores have no complete committed range in common.\n | 'nothing-to-compare'\n // The cycle failed. The error was logged and counted.\n | 'error';\n\nexport type CompareCycleResult =\n | {readonly kind: 'skipped'; readonly reason: CompareSkipReason}\n | {\n readonly kind: 'compared';\n /** Exclusive lower bound of the compared range. */\n readonly fromWatermark: string;\n /** Inclusive upper bound handled. The next cycle resumes from here. */\n readonly throughWatermark: string;\n /** Committed transactions handled through `throughWatermark`. */\n readonly transactions: number;\n /** Transactions with compared catchup output. */\n readonly sampled: number;\n readonly matched: number;\n readonly mismatched: number;\n /** Suspects rejected because store bounds changed. */\n readonly inconclusive: number;\n /** Sampled transactions deferred to a fresh budget. */\n readonly deferred: number;\n /** Sampled transactions that exceed a fresh budget. */\n readonly oversized: number;\n };\n\n/** Provides the Postgres side of the catchup comparison. */\nexport interface PGChangeLogRangeReader {\n getCatchupBounds(): Promise<{\n minWatermark: string | null;\n lastWatermark: string;\n }>;\n listCommitWatermarks(\n afterWatermark: string,\n throughWatermark: string,\n limit: number,\n ): Promise<string[]>;\n readCatchupRange(\n afterWatermark: string,\n throughWatermark: string,\n batchRows?: number | undefined,\n ): AsyncIterable<ChangeLogEntry[]>;\n}\n\nexport type SQLiteChangeLogCompareOptions = {\n /** Stable percentage of transactions selected for payload comparison. */\n readonly comparePercent: number;\n /** Warm-up time after a seed. The comparator skips a younger log. */\n readonly retentionMs: number;\n readonly intervalMs?: number | undefined;\n readonly maxTransactionsPerCycle?: number | undefined;\n /** Maximum catchup rows read from each store in one cycle. */\n readonly maxRowsPerSourcePerCycle?: number | undefined;\n /** Maximum catchup bytes read from each store in one cycle. */\n readonly maxBytesPerSourcePerCycle?: number | undefined;\n readonly readBatchRows?: number | undefined;\n /** Clock for the warm-up period. Defaults to `Date.now`. */\n readonly now?: (() => number) | undefined;\n readonly setTimeoutFn?: typeof setTimeout | undefined;\n readonly clearTimeoutFn?: typeof clearTimeout | undefined;\n /** Yields between sampled transactions. Defaults to `setImmediate`. */\n readonly yieldFn?: (() => Promise<void>) | undefined;\n};\n\n/** Uses separate aggregates so SQLite can use an index for each value. */\nconst SQLITE_BOUNDS_SQL = /*sql*/ `\n SELECT\n (SELECT min(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"minWatermark\",\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"headWatermark\"\n`;\n\n/** Lists committed transaction watermarks in `(@after, @through]`. */\nconst SQLITE_COMMITS_SQL = /*sql*/ `\n SELECT \"watermark\" FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"precommit\" IS NOT NULL\n AND \"watermark\" > @after\n AND \"watermark\" <= @through\n ORDER BY \"watermark\"\n LIMIT @limit\n`;\n\ntype SQLiteBounds = {\n readonly minWatermark: string;\n readonly headWatermark: string;\n};\n\ntype PinnedBounds = {\n readonly meta: ChangeLogMeta;\n readonly sqlite: SQLiteBounds;\n readonly pgMinWatermark: string;\n readonly pgLastWatermark: string;\n};\n\ntype SampledComparison = {\n readonly outcome: CompareOutcome;\n readonly sqliteRowsRead: number;\n readonly pgRowsRead: number;\n readonly sqliteBytesRead: number;\n readonly pgBytesRead: number;\n};\n\n/**\n * Compares SQLite and Postgres catchup output without affecting subscribers.\n *\n * Each cycle compares complete ranges through the lower store head.\n * It checks every transaction for presence and samples payloads.\n * A bounds change makes the affected result `inconclusive`.\n * Postgres remains authoritative.\n */\nexport class SQLiteChangeLogComparator {\n readonly #lc: LogContext;\n readonly #shard: ShardID;\n readonly #changeLogFile: string;\n readonly #identity: ChangeLogIdentity;\n readonly #pg: PGChangeLogRangeReader;\n readonly #opts: SQLiteChangeLogCompareOptions;\n readonly #now: () => number;\n readonly #setTimeoutFn: typeof setTimeout;\n readonly #clearTimeoutFn: typeof clearTimeout;\n readonly #yield: () => Promise<void>;\n\n readonly #compareResults = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.compare_result',\n 'Outcomes of the dark comparison between what SQLite catchup serves and ' +\n 'what PG catchup serves, per committed transaction. `inconclusive` ' +\n 'means a purge or reseed moved the pinned bounds mid-comparison.',\n );\n readonly #compareCycles = getOrCreateCounter(\n 'replica',\n 'sqlite_change_log.compare_cycles',\n 'SQLite change-log comparison cycles, by result.',\n );\n readonly #cycleDuration = getOrCreateLatencyHistogram(\n 'replica',\n 'sqlite_change_log.compare_cycle_duration',\n 'Duration of one SQLite change-log comparison cycle.',\n );\n\n /** Last handled commit watermark. A restart derives it from current bounds. */\n #cursor: string | undefined;\n #running: Promise<CompareCycleResult> | undefined;\n #timer: ReturnType<typeof setTimeout> | undefined;\n #stopped = false;\n #continueWithoutDelay = false;\n #fileGeneration = 0;\n\n constructor(\n lc: LogContext,\n shard: ShardID,\n changeLogFile: string,\n identity: ChangeLogIdentity,\n pg: PGChangeLogRangeReader,\n opts: SQLiteChangeLogCompareOptions,\n ) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-compare');\n this.#shard = shard;\n this.#changeLogFile = changeLogFile;\n this.#identity = identity;\n this.#pg = pg;\n this.#opts = opts;\n this.#now = opts.now ?? Date.now;\n this.#setTimeoutFn = opts.setTimeoutFn ?? setTimeout;\n this.#clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;\n this.#yield =\n opts.yieldFn ??\n (() => new Promise<void>(resolve => setImmediate(resolve)));\n this.#scheduleNext();\n }\n\n /**\n * Runs one cycle. Concurrent calls share the same promise.\n * Errors produce `skipped: 'error'`.\n */\n compareOnce(): Promise<CompareCycleResult> {\n if (this.#stopped) {\n return Promise.resolve({kind: 'skipped', reason: 'stopped'});\n }\n if (this.#running === undefined) {\n this.#running = this.#runCycle()\n .then(result => {\n if (this.#continueWithoutDelay) {\n this.#rescheduleNext(0);\n }\n return result;\n })\n .finally(() => {\n this.#running = undefined;\n });\n }\n return this.#running;\n }\n\n stop(): void {\n this.#stopped = true;\n if (this.#timer !== undefined) {\n this.#clearTimeoutFn(this.#timer);\n this.#timer = undefined;\n }\n }\n\n /** Invalidates cycles that can still read a replaced file. */\n invalidate(): void {\n this.#fileGeneration++;\n this.#cursor = undefined;\n this.#continueWithoutDelay = false;\n }\n\n #scheduleNext(\n delayMs = this.#opts.intervalMs ?? DEFAULT_COMPARE_INTERVAL_MS,\n ): void {\n if (this.#stopped || this.#timer !== undefined) {\n return;\n }\n this.#timer = this.#setTimeoutFn(() => {\n this.#timer = undefined;\n void this.compareOnce().finally(() => this.#scheduleNext());\n }, delayMs);\n }\n\n #rescheduleNext(delayMs: number): void {\n if (this.#timer !== undefined) {\n this.#clearTimeoutFn(this.#timer);\n this.#timer = undefined;\n }\n this.#scheduleNext(delayMs);\n }\n\n async #runCycle(): Promise<CompareCycleResult> {\n const startedAt = performance.now();\n const fileGeneration = this.#fileGeneration;\n this.#continueWithoutDelay = false;\n let db: Database | undefined;\n let reader: SQLiteChangeLogReader | undefined;\n try {\n try {\n // A readonly handle does not create a missing file.\n db = new Database(this.#lc, this.#changeLogFile, {readonly: true});\n } catch {\n return this.#skipped('log-unavailable');\n }\n\n const pinned = await this.#pinBounds(db);\n if (fileGeneration !== this.#fileGeneration) {\n return this.#skipped('log-rebuilt');\n }\n if (typeof pinned === 'string') {\n return this.#skipped(pinned);\n }\n const {meta} = pinned;\n\n const from = maxWatermark(\n this.#cursor ?? meta.seedWatermark,\n // Exclude the synthetic SQLite seed transaction.\n // The Postgres seed has no `precommit`, so enumeration also excludes it.\n meta.seedWatermark,\n // A store cannot serve the transaction at its minimum watermark.\n pinned.pgMinWatermark,\n pinned.sqlite.minWatermark,\n );\n const through = minWatermark(\n pinned.pgLastWatermark,\n pinned.sqlite.headWatermark,\n );\n if (through <= from) {\n return this.#skipped('nothing-to-compare');\n }\n\n let transactions = 0;\n let sampled = 0;\n const outcomes: Record<CompareOutcome, number> = {\n match: 0,\n mismatch: 0,\n inconclusive: 0,\n deferred: 0,\n oversized: 0,\n };\n const record = (watermark: string, outcome: CompareOutcome) => {\n this.#compareResults.add(1, {outcome});\n outcomes[outcome]++;\n if (outcome === 'mismatch') {\n // Log only the watermark because payloads contain customer data.\n this.#lc.error?.(\n 'SQLite change-log catchup output diverged from Postgres',\n {sqliteChangeLogCompare: {watermark}},\n );\n }\n };\n\n reader = new SQLiteChangeLogReader(this.#lc, this.#changeLogFile);\n const plan = reader.plan(from);\n if (plan.kind === 'not-ready') {\n return this.#skipped('log-unavailable');\n }\n if (plan.kind === 'ahead') {\n return this.#skipped('nothing-to-compare');\n }\n if (plan.kind === 'too-old') {\n // A fixed lower bound makes this result a mismatch.\n // A changed lower bound makes it inconclusive.\n const reconfirmed = await this.#reconfirm(db, pinned, from);\n const outcome =\n fileGeneration === this.#fileGeneration\n ? reconfirmed\n : 'inconclusive';\n record(from, outcome);\n if (outcome === 'inconclusive') {\n // Keep the cursor unchanged because the bounds moved.\n return this.#compared(from, from, transactions, sampled, outcomes);\n }\n // Positional reads can continue after a missing boundary.\n // Advancing avoids reporting the same boundary in every cycle.\n }\n\n const limit = Math.max(\n 1,\n this.#opts.maxTransactionsPerCycle ??\n DEFAULT_MAX_TRANSACTIONS_PER_CYCLE,\n );\n const union = await this.#enumerateCommits(db, from, through, limit);\n if (fileGeneration !== this.#fileGeneration) {\n return this.#skipped('log-rebuilt');\n }\n const maxRowsPerSource = Math.max(\n 1,\n this.#opts.maxRowsPerSourcePerCycle ??\n DEFAULT_MAX_ROWS_PER_SOURCE_PER_CYCLE,\n );\n const maxBytesPerSource = Math.max(\n 1,\n this.#opts.maxBytesPerSourcePerCycle ??\n DEFAULT_MAX_BYTES_PER_SOURCE_PER_CYCLE,\n );\n\n let sqliteRowsRead = 0;\n let pgRowsRead = 0;\n let sqliteBytesRead = 0;\n let pgBytesRead = 0;\n let previousBoundary = from;\n let handledThrough = from;\n for (const {watermark, inSqlite, inPg} of union) {\n if (this.#stopped) {\n break;\n }\n let stopAfterTransaction = false;\n if (!inSqlite || !inPg) {\n // Check every transaction for presence because this needs no payload read.\n const outcome = await this.#reconfirm(db, pinned, watermark);\n record(\n watermark,\n fileGeneration === this.#fileGeneration ? outcome : 'inconclusive',\n );\n } else if (\n isSampledForShard(this.#shard, watermark, this.#opts.comparePercent)\n ) {\n const sqliteRowsRemaining = maxRowsPerSource - sqliteRowsRead;\n const pgRowsRemaining = maxRowsPerSource - pgRowsRead;\n // A read may pass the byte budget by its last row, so this\n // compares against zero rather than checking for equality.\n const sqliteBytesRemaining = maxBytesPerSource - sqliteBytesRead;\n const pgBytesRemaining = maxBytesPerSource - pgBytesRead;\n if (\n sqliteRowsRemaining === 0 ||\n pgRowsRemaining === 0 ||\n sqliteBytesRemaining <= 0 ||\n pgBytesRemaining <= 0\n ) {\n record(watermark, 'deferred');\n break;\n }\n sampled++;\n const comparison = await this.#compareTransaction(\n db,\n reader,\n pinned,\n previousBoundary,\n watermark,\n sqliteRowsRemaining,\n pgRowsRemaining,\n sqliteBytesRemaining,\n pgBytesRemaining,\n maxRowsPerSource,\n maxBytesPerSource,\n );\n sqliteRowsRead += comparison.sqliteRowsRead;\n pgRowsRead += comparison.pgRowsRead;\n sqliteBytesRead += comparison.sqliteBytesRead;\n pgBytesRead += comparison.pgBytesRead;\n const outcome =\n fileGeneration === this.#fileGeneration\n ? comparison.outcome\n : 'inconclusive';\n record(watermark, outcome);\n if (outcome === 'inconclusive') {\n break;\n }\n if (outcome === 'deferred') {\n break;\n }\n if (outcome === 'oversized') {\n // Advance past a transaction that cannot fit in a fresh budget.\n stopAfterTransaction = true;\n }\n // Yield between sampled reads so other stream work can continue.\n await this.#yield();\n }\n previousBoundary = watermark;\n handledThrough = watermark;\n transactions++;\n if (stopAfterTransaction) {\n break;\n }\n }\n\n if (\n !this.#stopped &&\n fileGeneration === this.#fileGeneration &&\n outcomes.inconclusive === 0\n ) {\n this.#cursor = handledThrough;\n this.#continueWithoutDelay = handledThrough < through;\n }\n return this.#compared(\n from,\n handledThrough,\n transactions,\n sampled,\n outcomes,\n );\n } catch (e) {\n this.#lc.warn?.('error comparing the SQLite change log', e);\n return this.#skipped('error');\n } finally {\n reader?.close();\n db?.close();\n this.#cycleDuration.recordMs(performance.now() - startedAt);\n }\n }\n\n /** Checks log eligibility and reads the bounds from both stores. */\n async #pinBounds(db: Database): Promise<PinnedBounds | CompareSkipReason> {\n let meta: ChangeLogMeta;\n try {\n meta = readChangeLogMeta(db);\n } catch {\n // The writer has not initialized the log.\n return 'log-unavailable';\n }\n if (meta.schemaVersion !== CHANGE_LOG_DB_SCHEMA_VERSION) {\n return 'ineligible-schema';\n }\n const {epoch, generation, replicaID} = this.#identity;\n if (\n meta.epoch !== epoch ||\n meta.generation !== generation ||\n meta.replicaID !== replicaID\n ) {\n return 'ineligible-identity';\n }\n if (this.#now() - meta.seededAtMs < this.#opts.retentionMs) {\n return 'cold-log';\n }\n const sqlite = this.#readSQLiteBounds(db);\n if (sqlite === undefined) {\n return 'log-unavailable';\n }\n const {minWatermark, lastWatermark} = await this.#pg.getCatchupBounds();\n if (minWatermark === null) {\n // Only a new replica has an empty Postgres change log.\n return 'nothing-to-compare';\n }\n return {\n meta,\n sqlite,\n pgMinWatermark: minWatermark,\n pgLastWatermark: lastWatermark,\n };\n }\n\n #readSQLiteBounds(db: Database): SQLiteBounds | undefined {\n const bounds = db\n .prepare(SQLITE_BOUNDS_SQL)\n .get<{minWatermark: string | null; headWatermark: string | null}>();\n return bounds.minWatermark === null || bounds.headWatermark === null\n ? undefined\n : {\n minWatermark: bounds.minWatermark,\n headWatermark: bounds.headWatermark,\n };\n }\n\n /**\n * Merges committed transaction watermarks from both stores.\n * If a list reaches the limit, the result ends at the last complete common range.\n */\n async #enumerateCommits(\n db: Database,\n from: string,\n through: string,\n limit: number,\n ): Promise<{watermark: string; inSqlite: boolean; inPg: boolean}[]> {\n let sqlite: string[] = db\n .prepare(SQLITE_COMMITS_SQL)\n .all<{watermark: string}>({after: from, through, limit: limit + 1})\n .map(({watermark}) => watermark);\n let pg = await this.#pg.listCommitWatermarks(from, through, limit + 1);\n\n let effectiveThrough = through;\n if (sqlite.length > limit) {\n sqlite.length = limit;\n effectiveThrough = minWatermark(effectiveThrough, sqlite[limit - 1]);\n }\n if (pg.length > limit) {\n pg.length = limit;\n effectiveThrough = minWatermark(effectiveThrough, pg[limit - 1]);\n }\n if (effectiveThrough !== through) {\n sqlite = sqlite.filter(w => w <= effectiveThrough);\n pg = pg.filter(w => w <= effectiveThrough);\n }\n\n const union: {watermark: string; inSqlite: boolean; inPg: boolean}[] = [];\n let s = 0;\n let p = 0;\n while (s < sqlite.length || p < pg.length) {\n const sw = s < sqlite.length ? sqlite[s] : undefined;\n const pw = p < pg.length ? pg[p] : undefined;\n if (sw !== undefined && (pw === undefined || sw < pw)) {\n union.push({watermark: sw, inSqlite: true, inPg: false});\n s++;\n } else if (pw !== undefined && (sw === undefined || pw < sw)) {\n union.push({watermark: pw, inSqlite: false, inPg: true});\n p++;\n } else if (sw !== undefined) {\n union.push({watermark: sw, inSqlite: true, inPg: true});\n s++;\n p++;\n }\n }\n return union;\n }\n\n /**\n * Compares the output in `(previousBoundary, watermark]`.\n * Positional reads do not require either store to contain the boundary row.\n */\n async #compareTransaction(\n db: Database,\n reader: SQLiteChangeLogReader,\n pinned: PinnedBounds,\n previousBoundary: string,\n watermark: string,\n sqliteMaxRows: number,\n pgMaxRows: number,\n sqliteMaxBytes: number,\n pgMaxBytes: number,\n maxRowsPerSource: number,\n maxBytesPerSource: number,\n ): Promise<SampledComparison> {\n let sqlite: CatchupRangeDigest;\n let pg: CatchupRangeDigest;\n try {\n const readBatchRows = this.#opts.readBatchRows ?? DEFAULT_READ_BATCH_ROWS;\n sqlite = await digestCatchupRange(\n reader.read(\n previousBoundary,\n watermark,\n Math.min(readBatchRows, sqliteMaxRows),\n undefined,\n sqliteMaxRows,\n ),\n watermark,\n sqliteMaxRows,\n sqliteMaxBytes,\n );\n pg = await digestCatchupRange(\n reconstructBatches(\n this.#pg.readCatchupRange(previousBoundary, watermark, pgMaxRows),\n ),\n watermark,\n pgMaxRows,\n pgMaxBytes,\n );\n } catch (e) {\n // A failed catchup read makes the transaction suspect.\n this.#lc.warn?.(\n `error reading transaction ${watermark} for comparison`,\n e,\n );\n return {\n outcome: await this.#reconfirm(db, pinned, watermark),\n sqliteRowsRead: 0,\n pgRowsRead: 0,\n sqliteBytesRead: 0,\n pgBytesRead: 0,\n };\n }\n const read = {\n sqliteRowsRead: sqlite.rows,\n pgRowsRead: pg.rows,\n sqliteBytesRead: sqlite.bytes,\n pgBytesRead: pg.bytes,\n };\n if (sqlite.limitReached || pg.limitReached) {\n // Skip a transaction that cannot fit in a fresh budget.\n // Defer one that does not fit only in the remaining budget.\n // A budget is fresh when neither dimension has been spent.\n const sqliteFresh =\n sqliteMaxRows === maxRowsPerSource &&\n sqliteMaxBytes === maxBytesPerSource;\n const pgFresh =\n pgMaxRows === maxRowsPerSource && pgMaxBytes === maxBytesPerSource;\n const outcome =\n (sqlite.limitReached && sqliteFresh) || (pg.limitReached && pgFresh)\n ? 'oversized'\n : 'deferred';\n return {outcome, ...read};\n }\n if (sqlite.digest === pg.digest) {\n return {outcome: 'match', ...read};\n }\n return {\n outcome: await this.#reconfirm(db, pinned, watermark),\n ...read,\n };\n }\n\n /**\n * Rechecks a suspect result against current store bounds.\n * A moved bound, changed seed, failed read, or lower head makes the result inconclusive.\n */\n async #reconfirm(\n db: Database,\n pinned: PinnedBounds,\n watermark: string,\n ): Promise<'mismatch' | 'inconclusive'> {\n try {\n const meta = readChangeLogMeta(db);\n if (\n meta.seedWatermark !== pinned.meta.seedWatermark ||\n meta.seededAtMs !== pinned.meta.seededAtMs\n ) {\n return 'inconclusive';\n }\n const sqlite = this.#readSQLiteBounds(db);\n const {minWatermark, lastWatermark} = await this.#pg.getCatchupBounds();\n if (sqlite === undefined || minWatermark === null) {\n return 'inconclusive';\n }\n if (\n (sqlite.minWatermark > pinned.sqlite.minWatermark &&\n watermark <= sqlite.minWatermark) ||\n (minWatermark > pinned.pgMinWatermark && watermark <= minWatermark) ||\n watermark > sqlite.headWatermark ||\n watermark > lastWatermark\n ) {\n return 'inconclusive';\n }\n return 'mismatch';\n } catch {\n return 'inconclusive';\n }\n }\n\n #skipped(reason: CompareSkipReason): CompareCycleResult {\n this.#compareCycles.add(1, {result: reason});\n if (reason === 'error') {\n // The caller logged the error.\n } else if (reason !== 'nothing-to-compare') {\n this.#lc.debug?.(`skipping SQLite change-log comparison: ${reason}`);\n }\n return {kind: 'skipped', reason};\n }\n\n #compared(\n fromWatermark: string,\n throughWatermark: string,\n transactions: number,\n sampled: number,\n outcomes: Record<CompareOutcome, number>,\n ): CompareCycleResult {\n this.#compareCycles.add(1, {result: 'compared'});\n const result = {\n kind: 'compared',\n fromWatermark,\n throughWatermark,\n transactions,\n sampled,\n matched: outcomes.match,\n mismatched: outcomes.mismatch,\n inconclusive: outcomes.inconclusive,\n deferred: outcomes.deferred,\n oversized: outcomes.oversized,\n } as const;\n this.#lc.debug?.('SQLite change-log comparison cycle', {\n sqliteChangeLogCompare: result,\n });\n return result;\n }\n}\n\n/** Applies the same reconstruction as Postgres catchup. */\nasync function* reconstructBatches(\n batches: AsyncIterable<ChangeLogEntry[]>,\n): AsyncIterable<WatermarkedChange[]> {\n for await (const batch of batches) {\n yield batch.map(reconstructWatermarkedChange);\n }\n}\n\nfunction minWatermark(a: string, b: string): string {\n return a < b ? a : b;\n}\n\nfunction maxWatermark(...watermarks: string[]): string {\n let max = watermarks[0];\n for (const w of watermarks) {\n if (w > max) {\n max = w;\n }\n }\n return max;\n}\n"],"mappings":"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"}
@@ -0,0 +1,73 @@
1
+ import type { LogContext } from '@rocicorp/logger';
2
+ import type { ShardID } from '../../types/shards.ts';
3
+ import { type ChangeLogIdentity } from '../replicator/change-log-db.ts';
4
+ export type ChangeLogReadSource = 'pg' | 'sqlite';
5
+ export type SQLiteChangeLogCoverage = {
6
+ readonly seededAtMs: number;
7
+ readonly seedWatermark: string;
8
+ readonly minWatermark: string;
9
+ readonly headWatermark: string;
10
+ };
11
+ export type ChangeLogReadRouteReason = 'selected' | 'selected-cold' | 'percentage' | 'cold-log' | 'log-unavailable' | 'breaker-open' | 'backup-uncovered';
12
+ export type ChangeLogReadRoute = {
13
+ readonly source: ChangeLogReadSource;
14
+ readonly reason: ChangeLogReadRouteReason;
15
+ readonly pinned?: boolean | undefined;
16
+ readonly coverage?: SQLiteChangeLogCoverage | undefined;
17
+ };
18
+ export type SQLiteChangeLogReadRouterOptions = {
19
+ readonly shard: ShardID;
20
+ readonly readPercent: number;
21
+ /**
22
+ * Percentage of eligible tasks served from a log that has not yet aged
23
+ * through {@link retentionMs}. Defaults to zero, which keeps every such
24
+ * task on PG.
25
+ */
26
+ readonly coldReadPercent?: number | undefined;
27
+ readonly retentionMs: number;
28
+ readonly inspect: () => SQLiteChangeLogCoverage | undefined;
29
+ readonly failureCooldownMs?: number | undefined;
30
+ readonly now?: (() => number) | undefined;
31
+ };
32
+ /**
33
+ * Selects and pins the catchup source shared by `/snapshot` and `/changes`.
34
+ *
35
+ * A post-registration SQLite failure opens a process-local breaker. Requests
36
+ * use PG during the cooldown; the first request after it expires is the
37
+ * bounded probe. A successful readiness inspection closes the breaker, while
38
+ * another unavailable result rearms it. A writer failure opens it permanently
39
+ * because the writer remains disabled and its file stays absent for the life
40
+ * of the process.
41
+ */
42
+ export declare class SQLiteChangeLogReadRouter {
43
+ #private;
44
+ constructor(opts: SQLiteChangeLogReadRouterOptions);
45
+ /** Chooses and pins a source for a snapshot reservation. */
46
+ pin(taskID: string): ChangeLogReadRoute;
47
+ /** Returns the source pinned by an active snapshot reservation. */
48
+ peek(taskID: string): ChangeLogReadRoute | undefined;
49
+ /** Consumes a snapshot pin, or makes the stable choice for a direct retry. */
50
+ consume(taskID: string): ChangeLogReadRoute;
51
+ /** Releases a pin whose snapshot reservation ended before subscribing. */
52
+ release(taskID: string): void;
53
+ /**
54
+ * Replaces a pinned SQLite route with PG, for a reservation whose backup
55
+ * the log cannot cover -- most often a log seeded after that backup.
56
+ *
57
+ * The pin moves, not just the reservation's advertised bounds. Confirming
58
+ * with PG bounds while the task's `/changes` request still resolves to
59
+ * SQLite is exactly the mismatch the reservation path exists to prevent,
60
+ * so the two have to change together.
61
+ *
62
+ * A task with no pin is left alone: `consume` makes it a fresh choice.
63
+ */
64
+ demote(taskID: string): ChangeLogReadRoute;
65
+ /** Opens the post-registration failure breaker. */
66
+ trip(permanent?: boolean): void;
67
+ }
68
+ /**
69
+ * Reads the coverage and validates the v2 schema and RMv2 identity in one
70
+ * short-lived readonly connection. It never creates the log.
71
+ */
72
+ export declare function inspectSQLiteChangeLog(lc: LogContext, changeLogFile: string, identity: ChangeLogIdentity): SQLiteChangeLogCoverage | undefined;
73
+ //# sourceMappingURL=sqlite-change-log-read-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-change-log-read-router.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-read-router.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAEjD,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,gCAAgC,CAAC;AAGxC,MAAM,MAAM,mBAAmB,GAAG,IAAI,GAAG,QAAQ,CAAC;AAElD,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAChC,UAAU,GAIV,eAAe,GACf,YAAY,GACZ,UAAU,GACV,iBAAiB,GACjB,cAAc,GAId,kBAAkB,CAAC;AAEvB,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,MAAM,EAAE,mBAAmB,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,wBAAwB,CAAC;IAC1C,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,uBAAuB,GAAG,SAAS,CAAC;CACzD,CAAC;AAEF,MAAM,MAAM,gCAAgC,GAAG;IAC7C,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,uBAAuB,GAAG,SAAS,CAAC;IAC5D,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;CAC3C,CAAC;AAIF;;;;;;;;;GASG;AACH,qBAAa,yBAAyB;;gBAaxB,IAAI,EAAE,gCAAgC;IAWlD,4DAA4D;IAC5D,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB;IAMvC,mEAAmE;IACnE,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAKpD,8EAA8E;IAC9E,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB;IAS3C,0EAA0E;IAC1E,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI7B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,kBAAkB;IAW1C,mDAAmD;IACnD,IAAI,CAAC,SAAS,UAAQ,GAAG,IAAI;CAsE9B;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,UAAU,EACd,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,iBAAiB,GAC1B,uBAAuB,GAAG,SAAS,CAgCrC"}