@rocicorp/zero 1.10.0-canary.1 → 1.10.0-canary.10

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 (215) hide show
  1. package/out/shared/src/promise-race.d.ts +14 -0
  2. package/out/shared/src/promise-race.d.ts.map +1 -1
  3. package/out/shared/src/promise-race.js +32 -1
  4. package/out/shared/src/promise-race.js.map +1 -1
  5. package/out/zero/package.js +1 -1
  6. package/out/zero/package.js.map +1 -1
  7. package/out/zero-cache/src/auth/write-authorizer.d.ts.map +1 -1
  8. package/out/zero-cache/src/auth/write-authorizer.js +8 -1
  9. package/out/zero-cache/src/auth/write-authorizer.js.map +1 -1
  10. package/out/zero-cache/src/config/normalize.d.ts +5 -0
  11. package/out/zero-cache/src/config/normalize.d.ts.map +1 -1
  12. package/out/zero-cache/src/config/normalize.js +16 -3
  13. package/out/zero-cache/src/config/normalize.js.map +1 -1
  14. package/out/zero-cache/src/config/zero-config.d.ts +14 -4
  15. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  16. package/out/zero-cache/src/config/zero-config.js +31 -13
  17. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  18. package/out/zero-cache/src/db/migration-lite.d.ts.map +1 -1
  19. package/out/zero-cache/src/db/migration-lite.js +5 -3
  20. package/out/zero-cache/src/db/migration-lite.js.map +1 -1
  21. package/out/zero-cache/src/db/transaction-pool.d.ts +5 -15
  22. package/out/zero-cache/src/db/transaction-pool.d.ts.map +1 -1
  23. package/out/zero-cache/src/db/transaction-pool.js +8 -72
  24. package/out/zero-cache/src/db/transaction-pool.js.map +1 -1
  25. package/out/zero-cache/src/server/change-streamer.d.ts.map +1 -1
  26. package/out/zero-cache/src/server/change-streamer.js +24 -13
  27. package/out/zero-cache/src/server/change-streamer.js.map +1 -1
  28. package/out/zero-cache/src/server/main.d.ts.map +1 -1
  29. package/out/zero-cache/src/server/main.js +4 -4
  30. package/out/zero-cache/src/server/main.js.map +1 -1
  31. package/out/zero-cache/src/server/replicator.d.ts.map +1 -1
  32. package/out/zero-cache/src/server/replicator.js +1 -1
  33. package/out/zero-cache/src/server/replicator.js.map +1 -1
  34. package/out/zero-cache/src/server/runner/run-worker.js +1 -1
  35. package/out/zero-cache/src/server/worker-urls.d.ts +0 -1
  36. package/out/zero-cache/src/server/worker-urls.d.ts.map +1 -1
  37. package/out/zero-cache/src/server/worker-urls.js +1 -2
  38. package/out/zero-cache/src/server/worker-urls.js.map +1 -1
  39. package/out/zero-cache/src/services/change-source/common/replica-schema.d.ts.map +1 -1
  40. package/out/zero-cache/src/services/change-source/common/replica-schema.js +8 -2
  41. package/out/zero-cache/src/services/change-source/common/replica-schema.js.map +1 -1
  42. package/out/zero-cache/src/services/change-source/pg/backfill-stream.d.ts.map +1 -1
  43. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +18 -4
  44. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  45. package/out/zero-cache/src/services/change-source/pg/change-source.d.ts.map +1 -1
  46. package/out/zero-cache/src/services/change-source/pg/change-source.js +12 -10
  47. package/out/zero-cache/src/services/change-source/pg/change-source.js.map +1 -1
  48. package/out/zero-cache/src/services/change-source/pg/decommission.d.ts.map +1 -1
  49. package/out/zero-cache/src/services/change-source/pg/decommission.js +20 -11
  50. package/out/zero-cache/src/services/change-source/pg/decommission.js.map +1 -1
  51. package/out/zero-cache/src/services/change-source/pg/initial-sync.d.ts.map +1 -1
  52. package/out/zero-cache/src/services/change-source/pg/initial-sync.js +51 -74
  53. package/out/zero-cache/src/services/change-source/pg/initial-sync.js.map +1 -1
  54. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts +20 -0
  55. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts.map +1 -1
  56. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js +43 -3
  57. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js.map +1 -1
  58. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts +24 -1
  59. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts.map +1 -1
  60. package/out/zero-cache/src/services/change-source/pg/replication-slots.js +35 -5
  61. package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
  62. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts +1 -4
  63. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts.map +1 -1
  64. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js +11 -18
  65. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js.map +1 -1
  66. package/out/zero-cache/src/services/change-streamer/backup-monitor.d.ts.map +1 -1
  67. package/out/zero-cache/src/services/change-streamer/backup-monitor.js +8 -3
  68. package/out/zero-cache/src/services/change-streamer/backup-monitor.js.map +1 -1
  69. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts +34 -0
  70. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts.map +1 -0
  71. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js +67 -0
  72. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js.map +1 -0
  73. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts +2 -4
  74. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts.map +1 -1
  75. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +8 -2
  76. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
  77. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts +34 -13
  78. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts.map +1 -1
  79. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +152 -29
  80. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  81. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts +3 -9
  82. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts.map +1 -1
  83. package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
  84. package/out/zero-cache/src/services/change-streamer/forwarder.js +1 -1
  85. package/out/zero-cache/src/services/change-streamer/forwarder.js.map +1 -1
  86. package/out/zero-cache/src/services/change-streamer/litestream3-prometheus-poller.d.ts.map +1 -1
  87. package/out/zero-cache/src/services/change-streamer/litestream3-prometheus-poller.js +11 -5
  88. package/out/zero-cache/src/services/change-streamer/litestream3-prometheus-poller.js.map +1 -1
  89. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts +11 -0
  90. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts.map +1 -0
  91. package/out/zero-cache/src/services/change-streamer/shard-sampling.js +19 -0
  92. package/out/zero-cache/src/services/change-streamer/shard-sampling.js.map +1 -0
  93. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts +11 -1
  94. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts.map +1 -1
  95. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js +38 -5
  96. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js.map +1 -1
  97. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts +25 -1
  98. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts.map +1 -1
  99. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js +95 -12
  100. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js.map +1 -1
  101. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts +83 -0
  102. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts.map +1 -0
  103. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js +417 -0
  104. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js.map +1 -0
  105. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts +73 -0
  106. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts.map +1 -0
  107. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js +157 -0
  108. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js.map +1 -0
  109. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts +1 -1
  110. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts.map +1 -1
  111. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js +8 -3
  112. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js.map +1 -1
  113. package/out/zero-cache/src/services/change-streamer/storer.d.ts +57 -0
  114. package/out/zero-cache/src/services/change-streamer/storer.d.ts.map +1 -1
  115. package/out/zero-cache/src/services/change-streamer/storer.js +190 -40
  116. package/out/zero-cache/src/services/change-streamer/storer.js.map +1 -1
  117. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts +29 -0
  118. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts.map +1 -0
  119. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js +154 -0
  120. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js.map +1 -0
  121. package/out/zero-cache/src/services/http-service.d.ts +1 -0
  122. package/out/zero-cache/src/services/http-service.d.ts.map +1 -1
  123. package/out/zero-cache/src/services/http-service.js +7 -3
  124. package/out/zero-cache/src/services/http-service.js.map +1 -1
  125. package/out/zero-cache/src/services/litestream/commands.d.ts.map +1 -1
  126. package/out/zero-cache/src/services/litestream/commands.js +8 -4
  127. package/out/zero-cache/src/services/litestream/commands.js.map +1 -1
  128. package/out/zero-cache/src/services/litestream/metrics.d.ts +1 -0
  129. package/out/zero-cache/src/services/litestream/metrics.d.ts.map +1 -1
  130. package/out/zero-cache/src/services/litestream/metrics.js +4 -1
  131. package/out/zero-cache/src/services/litestream/metrics.js.map +1 -1
  132. package/out/zero-cache/src/services/mutagen/mutagen.d.ts.map +1 -1
  133. package/out/zero-cache/src/services/mutagen/mutagen.js +1 -1
  134. package/out/zero-cache/src/services/mutagen/mutagen.js.map +1 -1
  135. package/out/zero-cache/src/services/replicator/change-log-db.d.ts +10 -0
  136. package/out/zero-cache/src/services/replicator/change-log-db.d.ts.map +1 -1
  137. package/out/zero-cache/src/services/replicator/change-log-db.js +15 -1
  138. package/out/zero-cache/src/services/replicator/change-log-db.js.map +1 -1
  139. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts +3 -2
  140. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts.map +1 -1
  141. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js +8 -6
  142. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js.map +1 -1
  143. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts +3 -2
  144. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts.map +1 -1
  145. package/out/zero-cache/src/services/view-syncer/client-handler.js +25 -17
  146. package/out/zero-cache/src/services/view-syncer/client-handler.js.map +1 -1
  147. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts.map +1 -1
  148. package/out/zero-cache/src/services/view-syncer/cvr-store.js +14 -1
  149. package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
  150. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +3 -3
  151. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  152. package/out/zero-cache/src/services/view-syncer/view-syncer.js +6 -2
  153. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  154. package/out/zero-cache/src/types/downstream.d.ts +11 -0
  155. package/out/zero-cache/src/types/downstream.d.ts.map +1 -0
  156. package/out/zero-cache/src/types/pg.d.ts.map +1 -1
  157. package/out/zero-cache/src/types/pg.js +14 -4
  158. package/out/zero-cache/src/types/pg.js.map +1 -1
  159. package/out/zero-cache/src/types/poke-chunk.d.ts +18 -0
  160. package/out/zero-cache/src/types/poke-chunk.d.ts.map +1 -0
  161. package/out/zero-cache/src/types/poke-chunk.js +57 -0
  162. package/out/zero-cache/src/types/poke-chunk.js.map +1 -0
  163. package/out/zero-cache/src/types/processes.js +1 -4
  164. package/out/zero-cache/src/types/processes.js.map +1 -1
  165. package/out/zero-cache/src/types/streams.d.ts +4 -9
  166. package/out/zero-cache/src/types/streams.d.ts.map +1 -1
  167. package/out/zero-cache/src/types/streams.js.map +1 -1
  168. package/out/zero-cache/src/types/subscription.d.ts +2 -11
  169. package/out/zero-cache/src/types/subscription.d.ts.map +1 -1
  170. package/out/zero-cache/src/types/subscription.js +4 -25
  171. package/out/zero-cache/src/types/subscription.js.map +1 -1
  172. package/out/zero-cache/src/workers/connection.d.ts +22 -4
  173. package/out/zero-cache/src/workers/connection.d.ts.map +1 -1
  174. package/out/zero-cache/src/workers/connection.js +112 -14
  175. package/out/zero-cache/src/workers/connection.js.map +1 -1
  176. package/out/zero-client/src/client/version.js +1 -1
  177. package/out/zero-client/src/client/zero-poke-handler.d.ts +7 -2
  178. package/out/zero-client/src/client/zero-poke-handler.d.ts.map +1 -1
  179. package/out/zero-client/src/client/zero-poke-handler.js +52 -2
  180. package/out/zero-client/src/client/zero-poke-handler.js.map +1 -1
  181. package/out/zero-client/src/client/zero.d.ts.map +1 -1
  182. package/out/zero-client/src/client/zero.js +38 -6
  183. package/out/zero-client/src/client/zero.js.map +1 -1
  184. package/out/zero-protocol/src/down.d.ts +1 -1
  185. package/out/zero-protocol/src/poke.d.ts +89 -7
  186. package/out/zero-protocol/src/poke.d.ts.map +1 -1
  187. package/out/zero-protocol/src/poke.js +16 -8
  188. package/out/zero-protocol/src/poke.js.map +1 -1
  189. package/out/zero-protocol/src/protocol-version.d.ts +12 -7
  190. package/out/zero-protocol/src/protocol-version.d.ts.map +1 -1
  191. package/out/zero-protocol/src/protocol-version.js.map +1 -1
  192. package/out/zql/src/ivm/memory-source.d.ts +10 -2
  193. package/out/zql/src/ivm/memory-source.d.ts.map +1 -1
  194. package/out/zql/src/ivm/memory-source.js +14 -6
  195. package/out/zql/src/ivm/memory-source.js.map +1 -1
  196. package/out/zql/src/ivm/stream.d.ts +0 -1
  197. package/out/zql/src/ivm/stream.d.ts.map +1 -1
  198. package/out/zql/src/ivm/stream.js +1 -7
  199. package/out/zql/src/ivm/stream.js.map +1 -1
  200. package/out/zql/src/ivm/union-fan-in.d.ts +1 -1
  201. package/out/zql/src/ivm/union-fan-in.d.ts.map +1 -1
  202. package/out/zql/src/ivm/union-fan-in.js +11 -2
  203. package/out/zql/src/ivm/union-fan-in.js.map +1 -1
  204. package/out/zqlite/src/db.js +2 -2
  205. package/out/zqlite/src/sqlite-cost-model.js +5 -5
  206. package/out/zqlite/src/table-source.d.ts.map +1 -1
  207. package/out/zqlite/src/table-source.js +4 -4
  208. package/out/zqlite/src/table-source.js.map +1 -1
  209. package/package.json +1 -1
  210. package/out/zero-cache/src/server/backup-watermark-poller.js +0 -31
  211. package/out/zero-cache/src/server/backup-watermark-poller.js.map +0 -1
  212. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts +0 -57
  213. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts.map +0 -1
  214. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js +0 -137
  215. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js.map +0 -1
@@ -0,0 +1,67 @@
1
+ import { extractChangeSubstring } from "./change-log-codec.js";
2
+ import { ChangeLogTransactionHasher } from "./change-log-transaction-hash.js";
3
+ //#region ../zero-cache/src/services/change-streamer/change-log-compare-digest.ts
4
+ /**
5
+ * Pure building blocks for comparing catchup output between the Postgres and
6
+ * SQLite change logs: how to reduce a catchup range to a single comparable
7
+ * digest. Which transactions to sample is `isSampledForShard`, in
8
+ * `shard-sampling.ts`.
9
+ *
10
+ * Nothing here touches either store. The comparator service supplies the
11
+ * batches.
12
+ */
13
+ /**
14
+ * Builds one ordered digest for a catchup range.
15
+ *
16
+ * The row position makes missing, extra, changed, and reordered rows change the digest.
17
+ * The digest omits `precommit` because a commit already includes its watermark.
18
+ *
19
+ * The change text is hashed as stored. Both logs write the same
20
+ * `extractChangeSubstring` output, and the Postgres column is `json`, which
21
+ * keeps the input text exactly, so the two sides are byte-comparable without
22
+ * reparsing. Comparing the stored text also catches an encoding difference
23
+ * that a semantic comparison would hide.
24
+ *
25
+ * The read stops at `maxRows` or at `maxBytes`, whichever comes first.
26
+ */
27
+ async function digestCatchupRange(batches, throughWatermark, maxRows, maxBytes) {
28
+ const hasher = new ChangeLogTransactionHasher();
29
+ let rows = 0;
30
+ let bytes = 0;
31
+ let servedClosingCommit = false;
32
+ for await (const batch of batches) for (const [watermark, tag, json] of batch) {
33
+ if (rows === maxRows || bytes >= maxBytes) return {
34
+ digest: hasher.digest(),
35
+ rows,
36
+ bytes,
37
+ limitReached: true
38
+ };
39
+ if (json.length > maxBytes) return {
40
+ digest: hasher.digest(),
41
+ rows,
42
+ bytes,
43
+ limitReached: true
44
+ };
45
+ const change = extractChangeSubstring(json, tag);
46
+ hasher.add({
47
+ watermark,
48
+ pos: rows,
49
+ tag,
50
+ change,
51
+ precommit: null
52
+ });
53
+ rows++;
54
+ bytes += change.length;
55
+ if (tag === "commit" && watermark === throughWatermark) servedClosingCommit = true;
56
+ }
57
+ return {
58
+ digest: hasher.digest(),
59
+ rows,
60
+ bytes,
61
+ limitReached: (rows === maxRows || bytes >= maxBytes) && !servedClosingCommit
62
+ };
63
+ }
64
+ //#endregion
65
+ export { digestCatchupRange };
66
+
67
+ //# sourceMappingURL=change-log-compare-digest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-log-compare-digest.js","names":[],"sources":["../../../../../../zero-cache/src/services/change-streamer/change-log-compare-digest.ts"],"sourcesContent":["/**\n * Pure building blocks for comparing catchup output between the Postgres and\n * SQLite change logs: how to reduce a catchup range to a single comparable\n * digest. Which transactions to sample is `isSampledForShard`, in\n * `shard-sampling.ts`.\n *\n * Nothing here touches either store. The comparator service supplies the\n * batches.\n */\n\nimport {extractChangeSubstring} from './change-log-codec.ts';\nimport {ChangeLogTransactionHasher} from './change-log-transaction-hash.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\n\nexport type CatchupRangeDigest = {\n readonly digest: string;\n readonly rows: number;\n /** Change text hashed. This is the payload cost of the range. */\n readonly bytes: number;\n /** A row or byte limit was reached before the closing commit. */\n readonly limitReached: boolean;\n};\n\n/**\n * Builds one ordered digest for a catchup range.\n *\n * The row position makes missing, extra, changed, and reordered rows change the digest.\n * The digest omits `precommit` because a commit already includes its watermark.\n *\n * The change text is hashed as stored. Both logs write the same\n * `extractChangeSubstring` output, and the Postgres column is `json`, which\n * keeps the input text exactly, so the two sides are byte-comparable without\n * reparsing. Comparing the stored text also catches an encoding difference\n * that a semantic comparison would hide.\n *\n * The read stops at `maxRows` or at `maxBytes`, whichever comes first.\n */\nexport async function digestCatchupRange(\n batches: AsyncIterable<readonly WatermarkedChange[]>,\n throughWatermark: string,\n maxRows: number,\n maxBytes: number,\n): Promise<CatchupRangeDigest> {\n const hasher = new ChangeLogTransactionHasher();\n let rows = 0;\n let bytes = 0;\n let servedClosingCommit = false;\n\n for await (const batch of batches) {\n for (const [watermark, tag, json] of batch) {\n if (rows === maxRows || bytes >= maxBytes) {\n return {digest: hasher.digest(), rows, bytes, limitReached: true};\n }\n // Check the whole stream message first. A row wider than the entire\n // budget never fits, so it is rejected before any per-row work.\n if (json.length > maxBytes) {\n return {digest: hasher.digest(), rows, bytes, limitReached: true};\n }\n const change = extractChangeSubstring(json, tag);\n hasher.add({\n watermark,\n pos: rows,\n tag,\n change,\n precommit: null,\n });\n rows++;\n bytes += change.length;\n if (tag === 'commit' && watermark === throughWatermark) {\n servedClosingCommit = true;\n }\n }\n }\n return {\n digest: hasher.digest(),\n rows,\n bytes,\n limitReached:\n (rows === maxRows || bytes >= maxBytes) && !servedClosingCommit,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,eAAsB,mBACpB,SACA,kBACA,SACA,UAC6B;CAC7B,MAAM,SAAS,IAAI,2BAA2B;CAC9C,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,sBAAsB;CAE1B,WAAW,MAAM,SAAS,SACxB,KAAK,MAAM,CAAC,WAAW,KAAK,SAAS,OAAO;EAC1C,IAAI,SAAS,WAAW,SAAS,UAC/B,OAAO;GAAC,QAAQ,OAAO,OAAO;GAAG;GAAM;GAAO,cAAc;EAAI;EAIlE,IAAI,KAAK,SAAS,UAChB,OAAO;GAAC,QAAQ,OAAO,OAAO;GAAG;GAAM;GAAO,cAAc;EAAI;EAElE,MAAM,SAAS,uBAAuB,MAAM,GAAG;EAC/C,OAAO,IAAI;GACT;GACA,KAAK;GACL;GACA;GACA,WAAW;EACb,CAAC;EACD;EACA,SAAS,OAAO;EAChB,IAAI,QAAQ,YAAY,cAAc,kBACpC,sBAAsB;CAE1B;CAEF,OAAO;EACL,QAAQ,OAAO,OAAO;EACtB;EACA;EACA,eACG,SAAS,WAAW,SAAS,aAAa,CAAC;CAChD;AACF"}
@@ -3,12 +3,10 @@ import type { LogContext } from '@rocicorp/logger';
3
3
  import { type Worker } from '../../types/processes.ts';
4
4
  import { type ShardID } from '../../types/shards.ts';
5
5
  import { type Source } from '../../types/streams.ts';
6
- import { HttpService } from '../http-service.ts';
6
+ import { HttpService, type Options as HttpOptions } from '../http-service.ts';
7
7
  import { type ChangeStreamer, type ChangeStreamerService, type SerializedDownstream, type SubscriberContext } from './change-streamer.ts';
8
8
  import { type SnapshotMessage } from './snapshot.ts';
9
- type Options = {
10
- port: number;
11
- keepaliveTimeoutMs: number | undefined;
9
+ type Options = HttpOptions & {
12
10
  startupDelayMs: number;
13
11
  };
14
12
  export declare class ChangeStreamerHttpServer extends HttpService {
@@ -1 +1 @@
1
- {"version":3,"file":"change-streamer-http.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,WAAW,CAAC;AAE/C,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAKjD,OAAO,EAAC,KAAK,MAAM,EAAC,MAAM,0BAA0B,CAAC;AACrD,OAAO,EAAC,KAAK,OAAO,EAAC,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,wBAAwB,CAAC;AAIhC,OAAO,EAAC,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAC/C,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAwB,KAAK,eAAe,EAAC,MAAM,eAAe,CAAC;AAW1E,KAAK,OAAO,GAAG;IACb,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,qBAAa,wBAAyB,SAAQ,WAAW;;IACvD,QAAQ,CAAC,EAAE,iCAAiC;gBAM1C,EAAE,EAAE,UAAU,EACd,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,qBAAqB;cAgGpB,QAAQ,IAAI,IAAI;cAWV,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAKlD;AAED,qBAAa,wBAAyB,YAAW,cAAc;;gBAO3D,EAAE,EAAE,UAAU,EACd,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,MAAM,EAChB,iBAAiB,EAAE,MAAM,GAAG,SAAS;IA8BjC,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;IASjE,SAAS,CACb,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;CAQzC;AAED,KAAK,cAAc,GAAG,IAAI,CAAC,eAAe,EAAE,KAAK,GAAG,SAAS,CAAC,CAAC;AAE/D,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,iBAAiB,CAkB3E"}
1
+ {"version":3,"file":"change-streamer-http.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,WAAW,CAAC;AAE/C,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAMjD,OAAO,EAAC,KAAK,MAAM,EAAC,MAAM,0BAA0B,CAAC;AACrD,OAAO,EAAC,KAAK,OAAO,EAAC,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,wBAAwB,CAAC;AAIhC,OAAO,EAAC,WAAW,EAAE,KAAK,OAAO,IAAI,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAwB,KAAK,eAAe,EAAC,MAAM,eAAe,CAAC;AAW1E,KAAK,OAAO,GAAG,WAAW,GAAG;IAC3B,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,qBAAa,wBAAyB,SAAQ,WAAW;;IACvD,QAAQ,CAAC,EAAE,iCAAiC;gBAM1C,EAAE,EAAE,UAAU,EACd,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,qBAAqB;cAgGpB,QAAQ,IAAI,IAAI;cA6BV,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAKlD;AAED,qBAAa,wBAAyB,YAAW,cAAc;;gBAO3D,EAAE,EAAE,UAAU,EACd,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,MAAM,EAChB,iBAAiB,EAAE,MAAM,GAAG,SAAS;IA8BjC,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;IASjE,SAAS,CACb,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;CAQzC;AAED,KAAK,cAAc,GAAG,IAAI,CAAC,eAAe,EAAE,KAAK,GAAG,SAAS,CAAC,CAAC;AAE/D,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,iBAAiB,CAkB3E"}
@@ -1,4 +1,5 @@
1
1
  import { assert } from "../../../../shared/src/asserts.js";
2
+ import { promiseVoid } from "../../../../shared/src/resolved-promises.js";
2
3
  import "../../types/processes.js";
3
4
  import { pgClient } from "../../types/pg.js";
4
5
  import { HttpService } from "../http-service.js";
@@ -75,8 +76,13 @@ var ChangeStreamerHttpServer = class extends HttpService {
75
76
  }
76
77
  }
77
78
  _onStart() {
78
- const { startupDelayMs } = this.#opts;
79
- this._state.setTimeout(() => this.#ensureChangeStreamerStarted(`startup delay elapsed (${startupDelayMs} ms)`), startupDelayMs);
79
+ const { startupDelayMs, readinessGate = promiseVoid } = this.#opts;
80
+ readinessGate.then(() => {
81
+ if (startupDelayMs > 0) {
82
+ this.#lc.info?.(`waiting ${startupDelayMs}ms before taking over the change log`);
83
+ this._state.setTimeout(() => this.#ensureChangeStreamerStarted(`startup delay elapsed (${startupDelayMs} ms)`), startupDelayMs);
84
+ } else this.#ensureChangeStreamerStarted("no startup delay configured");
85
+ });
80
86
  }
81
87
  async _onStop() {
82
88
  if (this.#changeStreamerStarted) await this.#changeStreamer.stop();
@@ -1 +1 @@
1
- {"version":3,"file":"change-streamer-http.js","names":["#lc","#opts","#changeStreamer","#subscribe","#reserveSnapshot","#receiveWebsocket","#ensureChangeStreamerStarted","#changeStreamerStarted","#shardID","#changeDB","#changeStreamerURI","#resolveChangeStreamer"],"sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"sourcesContent":["import type {IncomingMessage} from 'node:http';\nimport websocket from '@fastify/websocket';\nimport type {LogContext} from '@rocicorp/logger';\nimport WebSocket from 'ws';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport type {IncomingMessageSubset} from '../../types/http.ts';\nimport {pgClient, type PostgresDB} from '../../types/pg.ts';\nimport {type Worker} from '../../types/processes.ts';\nimport {type ShardID} from '../../types/shards.ts';\nimport {\n streamIn,\n streamInStringified,\n streamOut,\n streamOutStringified,\n type Source,\n} from '../../types/streams.ts';\nimport {URLParams} from '../../types/url-params.ts';\nimport {installWebSocketReceiver} from '../../types/websocket-handoff.ts';\nimport {closeWithError, PROTOCOL_ERROR} from '../../types/ws.ts';\nimport {HttpService} from '../http-service.ts';\nimport {\n downstreamSchema,\n PROTOCOL_VERSION,\n type ChangeStreamer,\n type ChangeStreamerService,\n type SerializedDownstream,\n type SubscriberContext,\n} from './change-streamer.ts';\nimport {discoverChangeStreamerAddress} from './schema/tables.ts';\nimport {snapshotMessageSchema, type SnapshotMessage} from './snapshot.ts';\n\nconst MIN_SUPPORTED_PROTOCOL_VERSION = 4;\n\nconst SNAPSHOT_PATH_PATTERN = '/replication/:version/snapshot';\nconst CHANGES_PATH_PATTERN = '/replication/:version/changes';\nconst PATH_REGEX = /\\/replication\\/v(?<version>\\d+)\\/(changes|snapshot)$/;\n\nconst SNAPSHOT_PATH = `/replication/v${PROTOCOL_VERSION}/snapshot`;\nconst CHANGES_PATH = `/replication/v${PROTOCOL_VERSION}/changes`;\n\ntype Options = {\n port: number;\n keepaliveTimeoutMs: number | undefined;\n startupDelayMs: number;\n};\n\nexport class ChangeStreamerHttpServer extends HttpService {\n readonly id = 'change-streamer-http-server';\n readonly #lc: LogContext;\n readonly #opts: Options;\n readonly #changeStreamer: ChangeStreamerService;\n\n constructor(\n lc: LogContext,\n opts: Options,\n parent: Worker,\n changeStreamer: ChangeStreamerService,\n ) {\n super('change-streamer-http-server', lc, opts, async fastify => {\n await fastify.register(websocket);\n\n fastify.get(CHANGES_PATH_PATTERN, {websocket: true}, this.#subscribe);\n fastify.get(\n SNAPSHOT_PATH_PATTERN,\n {websocket: true},\n this.#reserveSnapshot,\n );\n\n installWebSocketReceiver<'snapshot' | 'changes'>(\n lc,\n fastify.websocketServer,\n this.#receiveWebsocket,\n parent,\n );\n });\n\n this.#lc = lc;\n this.#opts = opts;\n this.#changeStreamer = changeStreamer;\n }\n\n // Called when receiving a web socket via the main dispatcher handoff.\n readonly #receiveWebsocket = (\n ws: WebSocket,\n action: 'changes' | 'snapshot',\n msg: IncomingMessageSubset,\n ) => {\n switch (action) {\n case 'snapshot':\n return this.#reserveSnapshot(ws, msg);\n case 'changes':\n return this.#subscribe(ws, msg);\n default:\n closeWithError(\n this._lc,\n ws,\n `invalid action \"${action}\" received in handoff`,\n );\n return;\n }\n };\n\n readonly #reserveSnapshot = async (ws: WebSocket, req: RequestHeaders) => {\n this.#ensureChangeStreamerStarted('incoming snapshot reservation');\n try {\n const url = new URL(\n req.url ?? '',\n req.headers.origin ?? 'http://localhost',\n );\n checkProtocolVersion(url.pathname);\n const taskID = url.searchParams.get('taskID');\n if (!taskID) {\n throw new Error('Missing taskID in snapshot request');\n }\n const downstream =\n await this.#changeStreamer.startSnapshotReservation(taskID);\n void streamOut(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n readonly #subscribe = async (ws: WebSocket, req: RequestHeaders) => {\n try {\n const ctx = getSubscriberContext(req);\n if (ctx.mode === 'serving') {\n this.#ensureChangeStreamerStarted('incoming subscription');\n }\n\n const downstream = await this.#changeStreamer.subscribe(ctx);\n void streamOutStringified(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n #changeStreamerStarted = false;\n\n #ensureChangeStreamerStarted(reason: string) {\n if (!this.#changeStreamerStarted && this._state.shouldRun()) {\n this.#lc.info?.(`starting ChangeStreamerService: ${reason}`);\n void this.#changeStreamer\n .run()\n .catch(e =>\n this.#lc.warn?.(`ChangeStreamerService ended with error`, e),\n )\n .finally(() => this.stop());\n\n this.#changeStreamerStarted = true;\n }\n }\n\n protected override _onStart(): void {\n const {startupDelayMs} = this.#opts;\n this._state.setTimeout(\n () =>\n this.#ensureChangeStreamerStarted(\n `startup delay elapsed (${startupDelayMs} ms)`,\n ),\n startupDelayMs,\n );\n }\n\n protected override async _onStop(): Promise<void> {\n if (this.#changeStreamerStarted) {\n await this.#changeStreamer.stop();\n }\n }\n}\n\nexport class ChangeStreamerHttpClient implements ChangeStreamer {\n readonly #lc: LogContext;\n readonly #shardID: ShardID;\n readonly #changeDB: PostgresDB;\n readonly #changeStreamerURI: string | undefined;\n\n constructor(\n lc: LogContext,\n shardID: ShardID,\n changeDB: string,\n changeStreamerURI: string | undefined,\n ) {\n this.#lc = lc;\n this.#shardID = shardID;\n // Create a pg client with a single short-lived connection for the purpose\n // of change-streamer discovery (i.e. ChangeDB as DNS).\n this.#changeDB = pgClient(lc, changeDB, 'change-streamer-discovery', {\n max: 1,\n ['idle_timeout']: 15,\n });\n this.#changeStreamerURI = changeStreamerURI;\n }\n\n async #resolveChangeStreamer(path: string) {\n let baseURL = this.#changeStreamerURI;\n if (!baseURL) {\n const address = await discoverChangeStreamerAddress(\n this.#shardID,\n this.#changeDB,\n );\n if (!address) {\n throw new Error(`no change-streamer is running`);\n }\n baseURL = address.includes('://') ? `${address}/` : `ws://${address}/`;\n }\n const uri = new URL(path, baseURL);\n this.#lc.info?.(`connecting to change-streamer@${uri}`);\n return uri;\n }\n\n async reserveSnapshot(taskID: string): Promise<Source<SnapshotMessage>> {\n const uri = await this.#resolveChangeStreamer(SNAPSHOT_PATH);\n\n const params = new URLSearchParams({taskID});\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamIn(this.#lc, ws, snapshotMessageSchema);\n }\n\n async subscribe(\n ctx: SubscriberContext,\n ): Promise<Source<SerializedDownstream>> {\n const uri = await this.#resolveChangeStreamer(CHANGES_PATH);\n\n const params = getParams(ctx);\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamInStringified(this.#lc, ws, downstreamSchema);\n }\n}\n\ntype RequestHeaders = Pick<IncomingMessage, 'url' | 'headers'>;\n\nexport function getSubscriberContext(req: RequestHeaders): SubscriberContext {\n const url = new URL(req.url ?? '', req.headers.origin ?? 'http://localhost');\n const protocolVersion = checkProtocolVersion(url.pathname);\n const params = new URLParams(url);\n\n return {\n protocolVersion,\n id: params.get('id', true),\n taskID: params.get('taskID', true),\n mode: params.get('mode', false) === 'backup' ? 'backup' : 'serving',\n replicaVersion: params.get('replicaVersion', true),\n watermark: params.get('watermark', true),\n initial: params.getBoolean('initial'),\n // Absent for subscribers that predate the parameter, which is the safe\n // default: the barrier falls back to polling rather than waiting on an\n // ACK that would never be attributed to a writer.\n logsChangeStream: params.getBoolean('logsChangeStream'),\n };\n}\n\nfunction checkProtocolVersion(pathname: string): number {\n const match = PATH_REGEX.exec(pathname);\n if (!match) {\n throw new Error(`invalid path: ${pathname}`);\n }\n const v = Number(match.groups?.version);\n if (\n Number.isNaN(v) ||\n v > PROTOCOL_VERSION ||\n v < MIN_SUPPORTED_PROTOCOL_VERSION\n ) {\n throw new Error(\n `Cannot service client at protocol v${v}. ` +\n `Supported protocols: [v${MIN_SUPPORTED_PROTOCOL_VERSION} ... v${PROTOCOL_VERSION}]`,\n );\n }\n return v;\n}\n\n// This is called from the client-side (i.e. the replicator).\nfunction getParams(ctx: SubscriberContext): URLSearchParams {\n // The protocolVersion is hard-coded into the CHANGES_PATH.\n const {protocolVersion, ...stringParams} = ctx;\n assert(\n protocolVersion === PROTOCOL_VERSION,\n `replicator should be setting protocolVersion to ${PROTOCOL_VERSION}`,\n );\n return new URLSearchParams({\n ...stringParams,\n initial: ctx.initial ? 'true' : 'false',\n logsChangeStream: ctx.logsChangeStream ? 'true' : 'false',\n });\n}\n"],"mappings":";;;;;;;;;;;;;;AA+BA,IAAM,iCAAiC;AAEvC,IAAM,wBAAwB;AAC9B,IAAM,uBAAuB;AAC7B,IAAM,aAAa;AAEnB,IAAM,gBAAgB;AACtB,IAAM,eAAe;AAQrB,IAAa,2BAAb,cAA8C,YAAY;CACxD,KAAc;CACd;CACA;CACA;CAEA,YACE,IACA,MACA,QACA,gBACA;EACA,MAAM,+BAA+B,IAAI,MAAM,OAAM,YAAW;GAC9D,MAAM,QAAQ,SAAS,SAAS;GAEhC,QAAQ,IAAI,sBAAsB,EAAC,WAAW,KAAI,GAAG,KAAKG,UAAU;GACpE,QAAQ,IACN,uBACA,EAAC,WAAW,KAAI,GAChB,KAAKC,gBACP;GAEA,yBACE,IACA,QAAQ,iBACR,KAAKC,mBACL,MACF;EACF,CAAC;EAED,KAAKL,MAAM;EACX,KAAKC,QAAQ;EACb,KAAKC,kBAAkB;CACzB;CAGA,qBACE,IACA,QACA,QACG;EACH,QAAQ,QAAR;GACE,KAAK,YACH,OAAO,KAAKE,iBAAiB,IAAI,GAAG;GACtC,KAAK,WACH,OAAO,KAAKD,WAAW,IAAI,GAAG;GAChC;IACE,eACE,KAAK,KACL,IACA,mBAAmB,OAAO,sBAC5B;IACA;EACJ;CACF;CAEA,mBAA4B,OAAO,IAAe,QAAwB;EACxE,KAAKG,6BAA6B,+BAA+B;EACjE,IAAI;GACF,MAAM,MAAM,IAAI,IACd,IAAI,OAAO,IACX,IAAI,QAAQ,UAAU,kBACxB;GACA,qBAAqB,IAAI,QAAQ;GACjC,MAAM,SAAS,IAAI,aAAa,IAAI,QAAQ;GAC5C,IAAI,CAAC,QACH,MAAM,IAAI,MAAM,oCAAoC;GAEtD,MAAM,aACJ,MAAM,KAAKJ,gBAAgB,yBAAyB,MAAM;GAC5D,UAAe,KAAK,KAAK,YAAY,EAAE;EACzC,SAAS,KAAK;GACZ,eAAe,KAAK,KAAK,IAAI,KAAK,cAAc;EAClD;CACF;CAEA,aAAsB,OAAO,IAAe,QAAwB;EAClE,IAAI;GACF,MAAM,MAAM,qBAAqB,GAAG;GACpC,IAAI,IAAI,SAAS,WACf,KAAKI,6BAA6B,uBAAuB;GAG3D,MAAM,aAAa,MAAM,KAAKJ,gBAAgB,UAAU,GAAG;GAC3D,qBAA0B,KAAK,KAAK,YAAY,EAAE;EACpD,SAAS,KAAK;GACZ,eAAe,KAAK,KAAK,IAAI,KAAK,cAAc;EAClD;CACF;CAEA,yBAAyB;CAEzB,6BAA6B,QAAgB;EAC3C,IAAI,CAAC,KAAKK,0BAA0B,KAAK,OAAO,UAAU,GAAG;GAC3D,KAAKP,IAAI,OAAO,mCAAmC,QAAQ;GAC3D,KAAUE,gBACP,IAAI,EACJ,OAAM,MACL,KAAKF,IAAI,OAAO,0CAA0C,CAAC,CAC7D,EACC,cAAc,KAAK,KAAK,CAAC;GAE5B,KAAKO,yBAAyB;EAChC;CACF;CAEA,WAAoC;EAClC,MAAM,EAAC,mBAAkB,KAAKN;EAC9B,KAAK,OAAO,iBAER,KAAKK,6BACH,0BAA0B,eAAe,KAC3C,GACF,cACF;CACF;CAEA,MAAyB,UAAyB;EAChD,IAAI,KAAKC,wBACP,MAAM,KAAKL,gBAAgB,KAAK;CAEpC;AACF;AAEA,IAAa,2BAAb,MAAgE;CAC9D;CACA;CACA;CACA;CAEA,YACE,IACA,SACA,UACA,mBACA;EACA,KAAKF,MAAM;EACX,KAAKQ,WAAW;EAGhB,KAAKC,YAAY,SAAS,IAAI,UAAU,6BAA6B;GACnE,KAAK;IACJ,iBAAiB;EACpB,CAAC;EACD,KAAKC,qBAAqB;CAC5B;CAEA,MAAMC,uBAAuB,MAAc;EACzC,IAAI,UAAU,KAAKD;EACnB,IAAI,CAAC,SAAS;GACZ,MAAM,UAAU,MAAM,8BACpB,KAAKF,UACL,KAAKC,SACP;GACA,IAAI,CAAC,SACH,MAAM,IAAI,MAAM,+BAA+B;GAEjD,UAAU,QAAQ,SAAS,KAAK,IAAI,GAAG,QAAQ,KAAK,QAAQ,QAAQ;EACtE;EACA,MAAM,MAAM,IAAI,IAAI,MAAM,OAAO;EACjC,KAAKT,IAAI,OAAO,iCAAiC,KAAK;EACtD,OAAO;CACT;CAEA,MAAM,gBAAgB,QAAkD;EAItE,MAAM,KAAK,IAAI,YAAU,MAHP,KAAKW,uBAAuB,aAAa,IAG5B,IAAI,IADhB,gBAAgB,EAAC,OAAM,CACP,EAAO,SAAS,GAAG;EAEtD,OAAO,SAAS,KAAKX,KAAK,IAAI,qBAAqB;CACrD;CAEA,MAAM,UACJ,KACuC;EAIvC,MAAM,KAAK,IAAI,YAAU,MAHP,KAAKW,uBAAuB,YAAY,IAG3B,IADhB,UAAU,GACU,EAAO,SAAS,GAAG;EAEtD,OAAO,oBAAoB,KAAKX,KAAK,IAAI,gBAAgB;CAC3D;AACF;AAIA,SAAgB,qBAAqB,KAAwC;CAC3E,MAAM,MAAM,IAAI,IAAI,IAAI,OAAO,IAAI,IAAI,QAAQ,UAAU,kBAAkB;CAC3E,MAAM,kBAAkB,qBAAqB,IAAI,QAAQ;CACzD,MAAM,SAAS,IAAI,UAAU,GAAG;CAEhC,OAAO;EACL;EACA,IAAI,OAAO,IAAI,MAAM,IAAI;EACzB,QAAQ,OAAO,IAAI,UAAU,IAAI;EACjC,MAAM,OAAO,IAAI,QAAQ,KAAK,MAAM,WAAW,WAAW;EAC1D,gBAAgB,OAAO,IAAI,kBAAkB,IAAI;EACjD,WAAW,OAAO,IAAI,aAAa,IAAI;EACvC,SAAS,OAAO,WAAW,SAAS;EAIpC,kBAAkB,OAAO,WAAW,kBAAkB;CACxD;AACF;AAEA,SAAS,qBAAqB,UAA0B;CACtD,MAAM,QAAQ,WAAW,KAAK,QAAQ;CACtC,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,iBAAiB,UAAU;CAE7C,MAAM,IAAI,OAAO,MAAM,QAAQ,OAAO;CACtC,IACE,OAAO,MAAM,CAAC,KACd,IAAA,KACA,IAAI,gCAEJ,MAAM,IAAI,MACR,sCAAsC,EAAE,2BACZ,+BAA+B,SAC7D;CAEF,OAAO;AACT;AAGA,SAAS,UAAU,KAAyC;CAE1D,MAAM,EAAC,iBAAiB,GAAG,iBAAgB;CAC3C,OACE,oBAAA,GACA,mDACF;CACA,OAAO,IAAI,gBAAgB;EACzB,GAAG;EACH,SAAS,IAAI,UAAU,SAAS;EAChC,kBAAkB,IAAI,mBAAmB,SAAS;CACpD,CAAC;AACH"}
1
+ {"version":3,"file":"change-streamer-http.js","names":["#lc","#opts","#changeStreamer","#subscribe","#reserveSnapshot","#receiveWebsocket","#ensureChangeStreamerStarted","#changeStreamerStarted","#shardID","#changeDB","#changeStreamerURI","#resolveChangeStreamer"],"sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"sourcesContent":["import type {IncomingMessage} from 'node:http';\nimport websocket from '@fastify/websocket';\nimport type {LogContext} from '@rocicorp/logger';\nimport WebSocket from 'ws';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {promiseVoid} from '../../../../shared/src/resolved-promises.ts';\nimport type {IncomingMessageSubset} from '../../types/http.ts';\nimport {pgClient, type PostgresDB} from '../../types/pg.ts';\nimport {type Worker} from '../../types/processes.ts';\nimport {type ShardID} from '../../types/shards.ts';\nimport {\n streamIn,\n streamInStringified,\n streamOut,\n streamOutStringified,\n type Source,\n} from '../../types/streams.ts';\nimport {URLParams} from '../../types/url-params.ts';\nimport {installWebSocketReceiver} from '../../types/websocket-handoff.ts';\nimport {closeWithError, PROTOCOL_ERROR} from '../../types/ws.ts';\nimport {HttpService, type Options as HttpOptions} from '../http-service.ts';\nimport {\n downstreamSchema,\n PROTOCOL_VERSION,\n type ChangeStreamer,\n type ChangeStreamerService,\n type SerializedDownstream,\n type SubscriberContext,\n} from './change-streamer.ts';\nimport {discoverChangeStreamerAddress} from './schema/tables.ts';\nimport {snapshotMessageSchema, type SnapshotMessage} from './snapshot.ts';\n\nconst MIN_SUPPORTED_PROTOCOL_VERSION = 4;\n\nconst SNAPSHOT_PATH_PATTERN = '/replication/:version/snapshot';\nconst CHANGES_PATH_PATTERN = '/replication/:version/changes';\nconst PATH_REGEX = /\\/replication\\/v(?<version>\\d+)\\/(changes|snapshot)$/;\n\nconst SNAPSHOT_PATH = `/replication/v${PROTOCOL_VERSION}/snapshot`;\nconst CHANGES_PATH = `/replication/v${PROTOCOL_VERSION}/changes`;\n\ntype Options = HttpOptions & {\n startupDelayMs: number;\n};\n\nexport class ChangeStreamerHttpServer extends HttpService {\n readonly id = 'change-streamer-http-server';\n readonly #lc: LogContext;\n readonly #opts: Options;\n readonly #changeStreamer: ChangeStreamerService;\n\n constructor(\n lc: LogContext,\n opts: Options,\n parent: Worker,\n changeStreamer: ChangeStreamerService,\n ) {\n super('change-streamer-http-server', lc, opts, async fastify => {\n await fastify.register(websocket);\n\n fastify.get(CHANGES_PATH_PATTERN, {websocket: true}, this.#subscribe);\n fastify.get(\n SNAPSHOT_PATH_PATTERN,\n {websocket: true},\n this.#reserveSnapshot,\n );\n\n installWebSocketReceiver<'snapshot' | 'changes'>(\n lc,\n fastify.websocketServer,\n this.#receiveWebsocket,\n parent,\n );\n });\n\n this.#lc = lc;\n this.#opts = opts;\n this.#changeStreamer = changeStreamer;\n }\n\n // Called when receiving a web socket via the main dispatcher handoff.\n readonly #receiveWebsocket = (\n ws: WebSocket,\n action: 'changes' | 'snapshot',\n msg: IncomingMessageSubset,\n ) => {\n switch (action) {\n case 'snapshot':\n return this.#reserveSnapshot(ws, msg);\n case 'changes':\n return this.#subscribe(ws, msg);\n default:\n closeWithError(\n this._lc,\n ws,\n `invalid action \"${action}\" received in handoff`,\n );\n return;\n }\n };\n\n readonly #reserveSnapshot = async (ws: WebSocket, req: RequestHeaders) => {\n this.#ensureChangeStreamerStarted('incoming snapshot reservation');\n try {\n const url = new URL(\n req.url ?? '',\n req.headers.origin ?? 'http://localhost',\n );\n checkProtocolVersion(url.pathname);\n const taskID = url.searchParams.get('taskID');\n if (!taskID) {\n throw new Error('Missing taskID in snapshot request');\n }\n const downstream =\n await this.#changeStreamer.startSnapshotReservation(taskID);\n void streamOut(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n readonly #subscribe = async (ws: WebSocket, req: RequestHeaders) => {\n try {\n const ctx = getSubscriberContext(req);\n if (ctx.mode === 'serving') {\n this.#ensureChangeStreamerStarted('incoming subscription');\n }\n\n const downstream = await this.#changeStreamer.subscribe(ctx);\n void streamOutStringified(this._lc, downstream, ws);\n } catch (err) {\n closeWithError(this._lc, ws, err, PROTOCOL_ERROR);\n }\n };\n\n #changeStreamerStarted = false;\n\n #ensureChangeStreamerStarted(reason: string) {\n if (!this.#changeStreamerStarted && this._state.shouldRun()) {\n this.#lc.info?.(`starting ChangeStreamerService: ${reason}`);\n void this.#changeStreamer\n .run()\n .catch(e =>\n this.#lc.warn?.(`ChangeStreamerService ended with error`, e),\n )\n .finally(() => this.stop());\n\n this.#changeStreamerStarted = true;\n }\n }\n\n protected override _onStart(): void {\n const {startupDelayMs, readinessGate = promiseVoid} = this.#opts;\n void readinessGate.then(() => {\n if (startupDelayMs > 0) {\n // In RMv1, starting the change-streamer forcibly shuts down the\n // previous change-streamer, causing view-syncers to reconnect.\n // If this replication-manager has just started, the routing layer may\n // not have registered it with DNS, as that only happens after it\n // confirms health checks. To minimize downtime, the takeover is\n // delayed for the configured startupDelayMs _after_ beginning to\n // advertise readiness.\n this.#lc.info?.(\n `waiting ${startupDelayMs}ms before taking over the change log`,\n );\n this._state.setTimeout(\n () =>\n this.#ensureChangeStreamerStarted(\n `startup delay elapsed (${startupDelayMs} ms)`,\n ),\n startupDelayMs,\n );\n } else {\n // In RMv2, this is not necessary because a new RM does not kill the\n // old one; it is started as soon as possible.\n this.#ensureChangeStreamerStarted('no startup delay configured');\n }\n });\n }\n\n protected override async _onStop(): Promise<void> {\n if (this.#changeStreamerStarted) {\n await this.#changeStreamer.stop();\n }\n }\n}\n\nexport class ChangeStreamerHttpClient implements ChangeStreamer {\n readonly #lc: LogContext;\n readonly #shardID: ShardID;\n readonly #changeDB: PostgresDB;\n readonly #changeStreamerURI: string | undefined;\n\n constructor(\n lc: LogContext,\n shardID: ShardID,\n changeDB: string,\n changeStreamerURI: string | undefined,\n ) {\n this.#lc = lc;\n this.#shardID = shardID;\n // Create a pg client with a single short-lived connection for the purpose\n // of change-streamer discovery (i.e. ChangeDB as DNS).\n this.#changeDB = pgClient(lc, changeDB, 'change-streamer-discovery', {\n max: 1,\n ['idle_timeout']: 15,\n });\n this.#changeStreamerURI = changeStreamerURI;\n }\n\n async #resolveChangeStreamer(path: string) {\n let baseURL = this.#changeStreamerURI;\n if (!baseURL) {\n const address = await discoverChangeStreamerAddress(\n this.#shardID,\n this.#changeDB,\n );\n if (!address) {\n throw new Error(`no change-streamer is running`);\n }\n baseURL = address.includes('://') ? `${address}/` : `ws://${address}/`;\n }\n const uri = new URL(path, baseURL);\n this.#lc.info?.(`connecting to change-streamer@${uri}`);\n return uri;\n }\n\n async reserveSnapshot(taskID: string): Promise<Source<SnapshotMessage>> {\n const uri = await this.#resolveChangeStreamer(SNAPSHOT_PATH);\n\n const params = new URLSearchParams({taskID});\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamIn(this.#lc, ws, snapshotMessageSchema);\n }\n\n async subscribe(\n ctx: SubscriberContext,\n ): Promise<Source<SerializedDownstream>> {\n const uri = await this.#resolveChangeStreamer(CHANGES_PATH);\n\n const params = getParams(ctx);\n const ws = new WebSocket(uri + `?${params.toString()}`);\n\n return streamInStringified(this.#lc, ws, downstreamSchema);\n }\n}\n\ntype RequestHeaders = Pick<IncomingMessage, 'url' | 'headers'>;\n\nexport function getSubscriberContext(req: RequestHeaders): SubscriberContext {\n const url = new URL(req.url ?? '', req.headers.origin ?? 'http://localhost');\n const protocolVersion = checkProtocolVersion(url.pathname);\n const params = new URLParams(url);\n\n return {\n protocolVersion,\n id: params.get('id', true),\n taskID: params.get('taskID', true),\n mode: params.get('mode', false) === 'backup' ? 'backup' : 'serving',\n replicaVersion: params.get('replicaVersion', true),\n watermark: params.get('watermark', true),\n initial: params.getBoolean('initial'),\n // Absent for subscribers that predate the parameter, which is the safe\n // default: the barrier falls back to polling rather than waiting on an\n // ACK that would never be attributed to a writer.\n logsChangeStream: params.getBoolean('logsChangeStream'),\n };\n}\n\nfunction checkProtocolVersion(pathname: string): number {\n const match = PATH_REGEX.exec(pathname);\n if (!match) {\n throw new Error(`invalid path: ${pathname}`);\n }\n const v = Number(match.groups?.version);\n if (\n Number.isNaN(v) ||\n v > PROTOCOL_VERSION ||\n v < MIN_SUPPORTED_PROTOCOL_VERSION\n ) {\n throw new Error(\n `Cannot service client at protocol v${v}. ` +\n `Supported protocols: [v${MIN_SUPPORTED_PROTOCOL_VERSION} ... v${PROTOCOL_VERSION}]`,\n );\n }\n return v;\n}\n\n// This is called from the client-side (i.e. the replicator).\nfunction getParams(ctx: SubscriberContext): URLSearchParams {\n // The protocolVersion is hard-coded into the CHANGES_PATH.\n const {protocolVersion, ...stringParams} = ctx;\n assert(\n protocolVersion === PROTOCOL_VERSION,\n `replicator should be setting protocolVersion to ${PROTOCOL_VERSION}`,\n );\n return new URLSearchParams({\n ...stringParams,\n initial: ctx.initial ? 'true' : 'false',\n logsChangeStream: ctx.logsChangeStream ? 'true' : 'false',\n });\n}\n"],"mappings":"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"}
@@ -7,7 +7,9 @@ import { type ReplicationStatusPublisher } from '../replicator/replication-statu
7
7
  import type { SubscriptionState } from '../replicator/schema/replication-state.ts';
8
8
  import { type ChangeStreamerService, type SubscriberContext } from './change-streamer.ts';
9
9
  import { type SQLiteChangeLogCleanupGuard } from './sqlite-change-log-catchup.ts';
10
+ import { type SQLiteChangeLogCompareOptions } from './sqlite-change-log-comparator.ts';
10
11
  import { type SQLiteChangeLogPurgeSchedulerOptions } from './sqlite-change-log-purge-scheduler.ts';
12
+ import { type SQLiteChangeLogCoverage } from './sqlite-change-log-read-router.ts';
11
13
  import { type SQLiteChangeLogWriterOptions } from './sqlite-change-log-writer.ts';
12
14
  import { type PurgeLock, type TuningOptions as StorerOptions } from './storer.ts';
13
15
  export type BackupConfig = {
@@ -25,11 +27,9 @@ export type SQLiteCatchupOptions = {
25
27
  */
26
28
  barrierPollIntervalMs?: number | undefined;
27
29
  /**
28
- * Slice 11 supplies the production canary selector.
29
- *
30
- * It does not need to exclude backup subscribers or the change-log writer:
31
- * eligibility checks reject both before invoking this selector. Serving a
32
- * writer from SQLite would make it wait on its own ACK.
30
+ * Optional policy hook used by focused tests. Production canary selection is
31
+ * supplied by `sqliteChangeLogServe` and is stable by shard plus task ID.
32
+ * Backup subscribers are rejected before either selector is invoked.
33
33
  */
34
34
  shouldUse?: ((ctx: SubscriberContext) => boolean) | undefined;
35
35
  /**
@@ -45,6 +45,26 @@ export type SQLiteCatchupOptions = {
45
45
  */
46
46
  notReadyWarnThresholdMs?: number | undefined;
47
47
  };
48
+ export type SQLiteChangeLogServeOptions = {
49
+ /** Stable percentage of eligible serving tasks routed to SQLite. */
50
+ readPercent: number;
51
+ /**
52
+ * Stable percentage of eligible serving tasks routed to a log that has not
53
+ * yet aged through {@link retentionMs}. Zero keeps a reseeded log on PG for
54
+ * that whole window; above zero it serves that share of tasks, and a
55
+ * reservation it cannot cover is demoted rather than held pending.
56
+ * Defaults to zero.
57
+ */
58
+ coldReadPercent?: number | undefined;
59
+ /** The warm-up window a reseeded log ages through. */
60
+ retentionMs: number;
61
+ /** Injectable for deterministic breaker tests. */
62
+ failureCooldownMs?: number | undefined;
63
+ /** Injectable for deterministic warm-up and breaker tests. */
64
+ now?: (() => number) | undefined;
65
+ /** Injectable readiness inspection for service-level tests. */
66
+ inspect?: (() => SQLiteChangeLogCoverage | undefined) | undefined;
67
+ };
48
68
  export type TuningOptions = StorerOptions & {
49
69
  flowControlConsensusTimeoutProportion: number;
50
70
  flowControlSlowSubscriberGracePeriodMs?: number | undefined;
@@ -64,16 +84,17 @@ export type TuningOptions = StorerOptions & {
64
84
  */
65
85
  sqliteChangeLogPurge?: SQLiteChangeLogPurgeSchedulerOptions | undefined;
66
86
  /**
67
- * Supplied at `sqliteChangeLogMode >= compare`, and the gate on deriving the
68
- * stream's initialization parameters from the replica as well as from
69
- * Postgres. Postgres stays authoritative; the two are compared and the
70
- * result is reported. Not a new configuration option -- the mode ladder
71
- * carries it, because a state in which the log's buffer and its cookies were
72
- * at different rollout stages is what invariant 15 exists to forbid.
87
+ * Supplied in `compare` mode and later modes.
88
+ * `replicaFile` enables the initialization comparison.
89
+ * The other fields configure sampled catchup comparisons.
90
+ * Both checks are advisory, and Postgres remains authoritative.
91
+ * The comparator also requires the writer and catchup options.
73
92
  */
74
- sqliteChangeLogCompare?: {
93
+ sqliteChangeLogCompare?: (SQLiteChangeLogCompareOptions & {
75
94
  replicaFile: string;
76
- } | undefined;
95
+ }) | undefined;
96
+ /** Supplied only in `serve` mode. A zero percentage keeps every read on PG. */
97
+ sqliteChangeLogServe?: SQLiteChangeLogServeOptions | undefined;
77
98
  };
78
99
  /**
79
100
  * Performs initialization and schema migrations to initialize a ChangeStreamerImpl.
@@ -1 +1 @@
1
- {"version":3,"file":"change-streamer-service.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAWjD,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,mBAAmB,CAAC;AAClD,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,uBAAuB,CAAC;AAGnD,OAAO,KAAK,EACV,YAAY,EAEb,MAAM,mCAAmC,CAAC;AAM3C,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAGL,KAAK,0BAA0B,EAChC,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,2CAA2C,CAAC;AAUjF,OAAO,EACL,KAAK,qBAAqB,EAE1B,KAAK,iBAAiB,EAEvB,MAAM,sBAAsB,CAAC;AAU9B,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAGL,KAAK,oCAAoC,EAC1C,MAAM,wCAAwC,CAAC;AAEhD,OAAO,EAEL,KAAK,4BAA4B,EAClC,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,aAAa,IAAI,aAAa,EACpC,MAAM,aAAa,CAAC;AAIrB,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,iBAAiB,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,iBAAiB,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9D;;;;OAIG;IACH,YAAY,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACvD;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG;IAC1C,qCAAqC,EAAE,MAAM,CAAC;IAC9C,sCAAsC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5D,aAAa,CAAC,EAAE,oBAAoB,GAAG,SAAS,CAAC;IACjD;;;OAGG;IACH,qBAAqB,CAAC,EAClB,IAAI,CACF,4BAA4B,EAC5B,UAAU,GAAG,YAAY,GAAG,WAAW,CACxC,GACD,SAAS,CAAC;IACd;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,oCAAoC,GAAG,SAAS,CAAC;IACxE;;;;;;;OAOG;IACH,sBAAsB,CAAC,EAAE;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,GAAG,SAAS,CAAC;CAC5D,CAAC;AAEF;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,gBAAgB,EAAE,MAAM,EACxB,iBAAiB,EAAE,MAAM,EACzB,QAAQ,EAAE,UAAU,EACpB,YAAY,EAAE,YAAY,EAC1B,0BAA0B,EAAE,0BAA0B,EACtD,iBAAiB,EAAE,iBAAiB,EACpC,YAAY,EAAE,YAAY,GAAG,IAAI,EACjC,SAAS,EAAE,SAAS,GAAG,IAAI,EAC3B,SAAS,EAAE,OAAO,EAClB,IAAI,EAAE,aAAa,EACnB,YAAY,oBAAa,GACxB,OAAO,CAAC,qBAAqB,CAAC,CA4BhC"}
1
+ {"version":3,"file":"change-streamer-service.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAYjD,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,mBAAmB,CAAC;AAClD,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,uBAAuB,CAAC;AAGnD,OAAO,KAAK,EACV,YAAY,EAEb,MAAM,mCAAmC,CAAC;AAM3C,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAGL,KAAK,0BAA0B,EAChC,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,2CAA2C,CAAC;AAUjF,OAAO,EACL,KAAK,qBAAqB,EAE1B,KAAK,iBAAiB,EAEvB,MAAM,sBAAsB,CAAC;AAU9B,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAEL,KAAK,6BAA6B,EACnC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAGL,KAAK,oCAAoC,EAC1C,MAAM,wCAAwC,CAAC;AAChD,OAAO,EAIL,KAAK,uBAAuB,EAC7B,MAAM,oCAAoC,CAAC;AAE5C,OAAO,EAEL,KAAK,4BAA4B,EAClC,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,aAAa,IAAI,aAAa,EACpC,MAAM,aAAa,CAAC;AAIrB,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,iBAAiB,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,qBAAqB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,iBAAiB,KAAK,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9D;;;;OAIG;IACH,YAAY,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACvD;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG;IACxC,oEAAoE;IACpE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,iBAAiB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,8DAA8D;IAC9D,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;IACjC,+DAA+D;IAC/D,OAAO,CAAC,EAAE,CAAC,MAAM,uBAAuB,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;CACnE,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG;IAC1C,qCAAqC,EAAE,MAAM,CAAC;IAC9C,sCAAsC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5D,aAAa,CAAC,EAAE,oBAAoB,GAAG,SAAS,CAAC;IACjD;;;OAGG;IACH,qBAAqB,CAAC,EAClB,IAAI,CACF,4BAA4B,EAC5B,UAAU,GAAG,YAAY,GAAG,WAAW,CACxC,GACD,SAAS,CAAC;IACd;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,oCAAoC,GAAG,SAAS,CAAC;IACxE;;;;;;OAMG;IACH,sBAAsB,CAAC,EACnB,CAAC,6BAA6B,GAAG;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,CAAC,GACvD,SAAS,CAAC;IACd,+EAA+E;IAC/E,oBAAoB,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;CAChE,CAAC;AAEF;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,gBAAgB,EAAE,MAAM,EACxB,iBAAiB,EAAE,MAAM,EACzB,QAAQ,EAAE,UAAU,EACpB,YAAY,EAAE,YAAY,EAC1B,0BAA0B,EAAE,0BAA0B,EACtD,iBAAiB,EAAE,iBAAiB,EACpC,YAAY,EAAE,YAAY,GAAG,IAAI,EACjC,SAAS,EAAE,SAAS,GAAG,IAAI,EAC3B,SAAS,EAAE,OAAO,EAClB,IAAI,EAAE,aAAa,EACnB,YAAY,oBAAa,GACxB,OAAO,CAAC,qBAAqB,CAAC,CA4BhC"}
@@ -1,6 +1,7 @@
1
1
  import { assert, unreachable } from "../../../../shared/src/asserts.js";
2
2
  import { promiseVoid } from "../../../../shared/src/resolved-promises.js";
3
3
  import { must } from "../../../../shared/src/must.js";
4
+ import { promiseOrAbort } from "../../../../shared/src/promise-race.js";
4
5
  import "../change-source/protocol/current/downstream.js";
5
6
  import { getOrCreateCounter, getOrCreateLatencyHistogram } from "../../observability/metrics.js";
6
7
  import { DEFAULT_MAX_RETRY_DELAY_MS, RunningState, UnrecoverableError } from "../running-state.js";
@@ -15,7 +16,9 @@ import { ChangeLogInitializer, replicaInitializationSource } from "./change-log-
15
16
  import { Forwarder } from "./forwarder.js";
16
17
  import { SnapshotReservations } from "./snapshot-reservations.js";
17
18
  import { SQLiteChangeLogCatchup } from "./sqlite-change-log-catchup.js";
19
+ import { SQLiteChangeLogComparator } from "./sqlite-change-log-comparator.js";
18
20
  import { SQLiteChangeLogPurgeScheduler } from "./sqlite-change-log-purge-scheduler.js";
21
+ import { SQLiteChangeLogReadRouter, inspectSQLiteChangeLog } from "./sqlite-change-log-read-router.js";
19
22
  import { SQLiteChangeLogWriter } from "./sqlite-change-log-writer.js";
20
23
  import { Storer } from "./storer.js";
21
24
  import { Subscriber } from "./subscriber.js";
@@ -185,14 +188,19 @@ var ChangeStreamerImpl = class {
185
188
  #sqliteCatchupOptions;
186
189
  #changeLogWriter;
187
190
  #purgeScheduler;
191
+ #comparator;
188
192
  #acker;
189
193
  #initializer;
194
+ #readRouter;
190
195
  #autoReset;
191
196
  #state;
192
197
  #serving = resolver();
193
198
  #txCounter = getOrCreateCounter("replication", "transactions", "Count of replicated transactions");
194
199
  #changeCounter = getOrCreateCounter("replication", "changes", "Count of replicated changes (DML or DDL statements)");
195
200
  #transactionForwardDuration = getOrCreateLatencyHistogram("replication", "transaction_forward_duration", "Time from receiving a transaction's `begin` to forwarding its `commit`, i.e. the change-streamer half of forward-to-subscriber latency.");
201
+ #catchupRoutes = getOrCreateCounter("replication", "sqlite_change_log.catchup_routes", "Catchup subscriptions by selected source and low-cardinality reason.");
202
+ #reservationDemotions = getOrCreateCounter("replication", "sqlite_change_log.reservation_demotions", "Snapshot reservations demoted from SQLite to PG because the change log could not cover the backup being restored. With PG retired these followers have no fallback, so this is the rate at which one would instead have to wait for a later backup.");
203
+ #reservationConfirmDelays = getOrCreateCounter("replication", "sqlite_change_log.reservation_confirm_delays", "Snapshot reservations whose confirmation was deferred because the selected source's change-log minimum was later than the backup watermark. Counted once per reservation, by that source.");
196
204
  #latestStatus;
197
205
  #latestLagReportCommitTimeMs = 0;
198
206
  #backupWatermark;
@@ -228,7 +236,22 @@ var ChangeStreamerImpl = class {
228
236
  flowControlConsensusTimeoutProportion: opts.flowControlConsensusTimeoutProportion,
229
237
  flowControlSlowSubscriberGracePeriodMs: opts.flowControlSlowSubscriberGracePeriodMs
230
238
  });
231
- this.#reservations = backupConfig ? new SnapshotReservations(lc, backupConfig, (taskID) => this.#purgeScheduler?.resume(taskID)) : void 0;
239
+ const serveOptions = opts.sqliteChangeLogServe;
240
+ const writerOptions = opts.sqliteChangeLogWriter;
241
+ const catchupOptions = opts.sqliteCatchup;
242
+ this.#readRouter = serveOptions && writerOptions && catchupOptions ? new SQLiteChangeLogReadRouter({
243
+ shard,
244
+ readPercent: serveOptions.readPercent,
245
+ coldReadPercent: serveOptions.coldReadPercent,
246
+ retentionMs: serveOptions.retentionMs,
247
+ failureCooldownMs: serveOptions.failureCooldownMs,
248
+ now: serveOptions.now,
249
+ inspect: serveOptions.inspect ?? (() => inspectSQLiteChangeLog(lc, catchupOptions.changeLogFile, writerOptions.identity))
250
+ }) : void 0;
251
+ this.#reservations = backupConfig ? new SnapshotReservations(lc, backupConfig, (taskID) => {
252
+ this.#readRouter?.release(taskID);
253
+ this.#purgeScheduler?.resume(taskID);
254
+ }) : void 0;
232
255
  this.#replicationStatusPublisher = replicationStatusPublisher;
233
256
  this.#changeLogWriter = opts.sqliteChangeLogWriter ? new SQLiteChangeLogWriter(lc, {
234
257
  ...opts.sqliteChangeLogWriter,
@@ -236,8 +259,15 @@ var ChangeStreamerImpl = class {
236
259
  this.#sqliteCatchup?.onChangeLogCommit(watermark);
237
260
  this.#purgeScheduler?.onWriterIdle();
238
261
  },
239
- onDisabled: () => this.#closeSQLiteCatchup(),
240
- onRebuilt: () => this.#closeSQLiteCatchup()
262
+ onDisabled: () => {
263
+ this.#readRouter?.trip(true);
264
+ this.#closeSQLiteCatchup();
265
+ this.#comparator?.stop();
266
+ },
267
+ onRebuilt: () => {
268
+ this.#closeSQLiteCatchup();
269
+ this.#comparator?.invalidate();
270
+ }
241
271
  }) : void 0;
242
272
  this.#purgeScheduler = opts.sqliteChangeLogPurge && this.#changeLogWriter ? new SQLiteChangeLogPurgeScheduler(lc, () => this.#changeLogWriter?.connection, () => this.#forwarder.getAcks(), opts.sqliteChangeLogPurge) : void 0;
243
273
  this.#acker = new UpstreamAcker({
@@ -258,6 +288,10 @@ var ChangeStreamerImpl = class {
258
288
  ...opts.sqliteCatchup,
259
289
  cleanupGuard: opts.sqliteCatchup.cleanupGuard ?? this.#purgeScheduler?.cleanupGuard
260
290
  } : void 0;
291
+ this.#comparator = opts.sqliteChangeLogCompare && opts.sqliteChangeLogWriter && opts.sqliteCatchup ? new SQLiteChangeLogComparator(lc, shard, opts.sqliteCatchup.changeLogFile, opts.sqliteChangeLogWriter.identity, this.#storer, {
292
+ setTimeoutFn,
293
+ ...opts.sqliteChangeLogCompare
294
+ }) : void 0;
261
295
  this.#purgeLock = initialPurgeLock;
262
296
  this.#autoReset = autoReset;
263
297
  this.#state = new RunningState(this.id, void 0, setTimeoutFn);
@@ -327,12 +361,12 @@ var ChangeStreamerImpl = class {
327
361
  } else {
328
362
  const forwarded = this.#forwarder.forwardWithFlowControl(entry);
329
363
  this.#recordForwardedTransactionBoundary(type, entry[0]);
330
- await stream.changes.doneOr(forwarded);
364
+ await promiseOrAbort(forwarded, stream.changes.signal, this.#state.signal);
331
365
  unflushedBytes = 0;
332
366
  }
333
367
  if (type === "commit" || type === "rollback") watermark = null;
334
368
  const readyForMore = this.#storer.readyForMore();
335
- if (readyForMore) await stream.changes.doneOr(readyForMore);
369
+ if (readyForMore) await promiseOrAbort(readyForMore, stream.changes.signal, this.#state.signal);
336
370
  }
337
371
  } catch (e) {
338
372
  err = e;
@@ -383,22 +417,47 @@ var ChangeStreamerImpl = class {
383
417
  const downstream = Subscription.create({ cleanup: () => cleanupSubscriber() });
384
418
  const subscriber = new Subscriber(protocolVersion, id, watermark, downstream, () => this.#latestStatus, {});
385
419
  const lc = this.#lc.withContext("subscriber", subscriber.id);
386
- cleanupSubscriber = () => {
420
+ const removeFromForwarder = () => {
387
421
  lc.info?.(`removing subscriber ${subscriber.id}`);
388
422
  this.#forwarder.remove(subscriber);
389
423
  };
424
+ cleanupSubscriber = removeFromForwarder;
390
425
  if (replicaVersion !== this.#replicaVersion) {
391
426
  lc.warn?.(`rejecting subscriber at replica version ${replicaVersion}`);
392
427
  subscriber.close(1, `current replica version is ${this.#replicaVersion} (requested ${replicaVersion})`);
393
428
  } else {
394
429
  lc.info?.(`adding subscriber ${subscriber.id}`);
395
- const sqliteCatchup = this.#selectSQLiteCatchup(ctx);
396
- if (sqliteCatchup) {
397
- cleanupSubscriber = () => sqliteCatchup.remove(subscriber);
398
- await sqliteCatchup.catchup(subscriber, () => this.#captureRequiredHead());
399
- } else {
430
+ const catchupFromPG = () => {
431
+ cleanupSubscriber = removeFromForwarder;
400
432
  this.#forwarder.add(subscriber);
401
433
  this.#storer.catchup(subscriber, mode);
434
+ };
435
+ const sqliteSelection = this.#selectSQLiteCatchup(lc, ctx);
436
+ if (!sqliteSelection) catchupFromPG();
437
+ else {
438
+ const { catchup, reason, coverage, logWarm } = sqliteSelection;
439
+ cleanupSubscriber = () => catchup.remove(subscriber);
440
+ const registration = await catchup.catchup(subscriber, () => this.#captureRequiredHead(), { logWarm });
441
+ switch (registration.kind) {
442
+ case "registered":
443
+ lc.debug?.(`serving ${ctx.id} from SQLite catchup`, ...coverage ? [{ sqliteChangeLogCoverage: coverage }] : []);
444
+ this.#recordCatchupRoute("sqlite", reason);
445
+ break;
446
+ case "uncovered":
447
+ lc.info?.(`serving ${ctx.id} from PG catchup: subscriber watermark ${ctx.watermark} is below the SQLite change-log minimum ` + registration.minWatermark);
448
+ this.#recordCatchupRoute("pg", "watermark-uncovered");
449
+ catchupFromPG();
450
+ break;
451
+ case "declined":
452
+ lc.error?.(`serving ${ctx.id} from PG catchup: SQLite catchup registration failed`, registration.error);
453
+ this.#recordCatchupRoute("pg", "registration-failed");
454
+ catchupFromPG();
455
+ break;
456
+ case "handled":
457
+ this.#recordCatchupRoute("sqlite", "registration-handled");
458
+ break;
459
+ default: unreachable(registration);
460
+ }
402
461
  }
403
462
  }
404
463
  this.#reservations?.close(ctx.taskID);
@@ -409,6 +468,8 @@ var ChangeStreamerImpl = class {
409
468
  const downstream = this.#reservations.open(taskID);
410
469
  try {
411
470
  await (this.#purgeScheduler?.pause(taskID) ?? promiseVoid);
471
+ if (!this.#reservations.isCurrent(taskID, downstream)) return downstream;
472
+ this.#readRouter?.pin(taskID);
412
473
  await this.#confirmReservations();
413
474
  return downstream;
414
475
  } catch (e) {
@@ -425,10 +486,30 @@ var ChangeStreamerImpl = class {
425
486
  this.#confirmReservations().catch((e) => this.#lc.warn?.(`error confirming snapshot reservation`, e));
426
487
  }
427
488
  async #confirmReservations() {
428
- if (this.#backupWatermark && this.#reservations?.confirmationsRequired()) {
429
- const { replicaVersion, minWatermark } = await this.#getChangeLogState();
430
- if (minWatermark <= this.#backupWatermark) this.#reservations?.confirm(replicaVersion, this.#backupWatermark);
431
- else this.#lc.error?.(`change-log minWatermark ${minWatermark} is later than backupWatermark ${this.#backupWatermark}. Delaying confirmation of snapshot reservation until next backup.`);
489
+ const backupWatermark = this.#backupWatermark;
490
+ const reservations = this.#reservations;
491
+ if (backupWatermark === void 0 || !reservations?.confirmationsRequired()) return;
492
+ const pgState = await this.#getChangeLogState();
493
+ for (const taskID of reservations.unconfirmedTaskIDs()) {
494
+ let route = this.#readRouter?.peek(taskID);
495
+ if (this.#readRouter && route === void 0) continue;
496
+ if (route?.source === "sqlite") {
497
+ const coverage = must(route.coverage, "a pinned SQLite route must carry its covered range");
498
+ if (coverage.minWatermark > backupWatermark) {
499
+ this.#lc.info?.(`demoting ${taskID} to PG catchup: SQLite change-log minimum ${coverage.minWatermark} is later than backupWatermark ` + backupWatermark);
500
+ this.#reservationDemotions.add(1);
501
+ route = must(this.#readRouter).demote(taskID);
502
+ }
503
+ }
504
+ const source = route?.source ?? "pg";
505
+ let minWatermark;
506
+ if (route?.source === "sqlite") minWatermark = must(route.coverage, "a pinned SQLite route must carry its covered range").minWatermark;
507
+ else ({minWatermark} = pgState);
508
+ if (minWatermark <= backupWatermark) reservations.confirmFor(taskID, pgState.replicaVersion, backupWatermark, source);
509
+ else {
510
+ if (reservations.noteConfirmationDelayed(taskID)) this.#reservationConfirmDelays.add(1);
511
+ this.#lc.error?.(`pg change-log minWatermark ${minWatermark} is later than backupWatermark ${backupWatermark}. Delaying confirmation of snapshot reservation until next backup.`);
512
+ }
432
513
  }
433
514
  }
434
515
  #maybeSchedulePGPurge() {
@@ -517,6 +598,7 @@ var ChangeStreamerImpl = class {
517
598
  this.#state.stop(this.#lc, err);
518
599
  this.#stream?.changes.cancel();
519
600
  this.#purgeScheduler?.stop();
601
+ this.#comparator?.stop();
520
602
  this.#sqliteCatchup?.close();
521
603
  this.#changeLogWriter?.close();
522
604
  await Promise.allSettled([this.#storer.stop(), this.#source.stop()]);
@@ -574,19 +656,57 @@ var ChangeStreamerImpl = class {
574
656
  assert(committed, "last forwarded commit watermark is not initialized");
575
657
  return this.#currentTransactionCompletion?.promise.then((completion) => completion.watermark) ?? committed;
576
658
  }
577
- #selectSQLiteCatchup(ctx) {
659
+ #selectSQLiteCatchup(lc, ctx) {
578
660
  const opts = this.#sqliteCatchupOptions;
579
- if (this.#lastForwardedCommitWatermark === void 0 || !opts) return;
661
+ if (this.#lastForwardedCommitWatermark === void 0 || !opts) {
662
+ this.#recordCatchupRoute("pg", "not-ready");
663
+ return;
664
+ }
580
665
  if (ctx.mode !== "serving") {
581
- this.#lc.info?.(`not serving backup subscriber ${ctx.id} from SQLite catchup`);
666
+ lc.info?.(`not serving backup subscriber ${ctx.id} from SQLite catchup`);
667
+ this.#recordCatchupRoute("pg", "ineligible-mode");
582
668
  return;
583
669
  }
584
- if (ctx.logsChangeStream) {
585
- this.#lc.warn?.(`not serving legacy change-log writer ${ctx.id} from SQLite catchup`);
670
+ let route;
671
+ if (this.#readRouter) {
672
+ route = this.#readRouter.consume(ctx.taskID);
673
+ if (route.source === "pg") {
674
+ if (route.reason === "cold-log") lc.info?.(`serving ${ctx.id} from PG catchup: SQLite change log is still warming`, { sqliteChangeLogCoverage: route.coverage });
675
+ else if (route.reason === "log-unavailable") this.#declineSQLiteCatchup(lc, ctx, opts, `${opts.changeLogFile} is unavailable or incompatible`);
676
+ else lc.debug?.(`serving ${ctx.id} from PG catchup: SQLite route ${route.reason}`, ...route.coverage ? [{ sqliteChangeLogCoverage: route.coverage }] : []);
677
+ this.#recordCatchupRoute("pg", route.reason);
678
+ return;
679
+ }
680
+ const coverage = route.coverage;
681
+ assert(coverage, "a SQLite route must carry its covered range");
682
+ if (ctx.watermark < coverage.minWatermark) {
683
+ lc.info?.(`serving ${ctx.id} from PG catchup: subscriber watermark ${ctx.watermark} is below the SQLite change-log minimum ` + coverage.minWatermark, { sqliteChangeLogCoverage: coverage });
684
+ this.#recordCatchupRoute("pg", "watermark-uncovered");
685
+ return;
686
+ }
687
+ }
688
+ if (!this.#readRouter && !opts.shouldUse?.(ctx)) {
689
+ this.#recordCatchupRoute("pg", "selector");
586
690
  return;
587
691
  }
588
- if (!opts.shouldUse?.(ctx)) return;
589
- return this.#sqliteCatchup ?? this.#openSQLiteCatchup(opts, ctx);
692
+ if (this.#readRouter && opts.shouldUse && !opts.shouldUse(ctx)) {
693
+ this.#recordCatchupRoute("pg", "selector");
694
+ return;
695
+ }
696
+ const catchup = this.#sqliteCatchup ?? this.#openSQLiteCatchup(lc, opts, ctx);
697
+ if (catchup) return {
698
+ catchup,
699
+ reason: route?.reason ?? "selector",
700
+ coverage: route?.coverage,
701
+ logWarm: route === void 0 ? void 0 : route.reason !== "selected-cold"
702
+ };
703
+ else this.#recordCatchupRoute("pg", "log-unavailable");
704
+ }
705
+ #recordCatchupRoute(source, reason) {
706
+ this.#catchupRoutes.add(1, {
707
+ source,
708
+ reason
709
+ });
590
710
  }
591
711
  /**
592
712
  * Opens the change log and, if it can serve, wraps it in the coordinator that
@@ -601,18 +721,18 @@ var ChangeStreamerImpl = class {
601
721
  * here instead. Neither the failure nor the reader is retained, so a later
602
722
  * subscription retries from scratch.
603
723
  */
604
- #openSQLiteCatchup(opts, ctx) {
724
+ #openSQLiteCatchup(lc, opts, ctx) {
605
725
  let reader;
606
726
  try {
607
727
  reader = new SQLiteChangeLogReader(this.#lc, opts.changeLogFile);
608
728
  if (reader.plan(ctx.watermark).kind === "not-ready") {
609
729
  reader.close();
610
- this.#declineSQLiteCatchup(ctx, opts, `${opts.changeLogFile} has no changes to serve yet`);
730
+ this.#declineSQLiteCatchup(lc, ctx, opts, `${opts.changeLogFile} has no changes to serve yet`);
611
731
  return;
612
732
  }
613
733
  } catch (e) {
614
734
  reader?.close();
615
- this.#declineSQLiteCatchup(ctx, opts, `cannot read ${opts.changeLogFile}`, e);
735
+ this.#declineSQLiteCatchup(lc, ctx, opts, `cannot read ${opts.changeLogFile}`, e);
616
736
  return;
617
737
  }
618
738
  this.#changeLogUnavailableSince = void 0;
@@ -620,7 +740,11 @@ var ChangeStreamerImpl = class {
620
740
  batchSize: opts.readBatchRows,
621
741
  barrierTimeoutMs: opts.barrierTimeoutMs,
622
742
  barrierPollIntervalMs: opts.barrierPollIntervalMs,
623
- cleanupGuard: opts.cleanupGuard
743
+ cleanupGuard: opts.cleanupGuard,
744
+ onFailure: (failure) => {
745
+ this.#lc.warn?.(`temporarily disabling SQLite catchup after ${failure}`);
746
+ this.#readRouter?.trip();
747
+ }
624
748
  });
625
749
  return this.#sqliteCatchup;
626
750
  }
@@ -629,12 +753,11 @@ var ChangeStreamerImpl = class {
629
753
  * unavailable so that a subscription that merely arrived before the writer's
630
754
  * first reconcile does not look like an incident.
631
755
  */
632
- #declineSQLiteCatchup(ctx, opts, reason, error) {
756
+ #declineSQLiteCatchup(lc, ctx, opts, reason, error) {
633
757
  const now = Date.now();
634
758
  this.#changeLogUnavailableSince ??= now;
635
759
  const unavailableMs = now - this.#changeLogUnavailableSince;
636
- const threshold = opts.notReadyWarnThresholdMs ?? DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS;
637
- this.#lc[unavailableMs >= threshold ? "warn" : "debug"]?.(`serving ${ctx.id} from PG catchup: ${reason} (unavailable for ${unavailableMs} ms)`, ...error === void 0 ? [] : [error]);
760
+ lc[unavailableMs >= (opts.notReadyWarnThresholdMs ?? DEFAULT_CHANGE_LOG_UNAVAILABLE_WARN_THRESHOLD_MS) ? "warn" : "debug"]?.(`serving ${ctx.id} from PG catchup: ${reason} (unavailable for ${unavailableMs} ms)`, ...error === void 0 ? [] : [error]);
638
761
  }
639
762
  };
640
763
  var CLEANUP_DELAY_MS = DEFAULT_MAX_RETRY_DELAY_MS * 3;