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

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 (180) 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/runner/run-worker.js +1 -1
  21. package/out/zero-cache/src/server/worker-urls.d.ts +0 -1
  22. package/out/zero-cache/src/server/worker-urls.d.ts.map +1 -1
  23. package/out/zero-cache/src/server/worker-urls.js +1 -2
  24. package/out/zero-cache/src/server/worker-urls.js.map +1 -1
  25. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +1 -1
  26. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  27. package/out/zero-cache/src/services/change-source/pg/change-source.js +1 -1
  28. package/out/zero-cache/src/services/change-source/pg/decommission.d.ts.map +1 -1
  29. package/out/zero-cache/src/services/change-source/pg/decommission.js +20 -11
  30. package/out/zero-cache/src/services/change-source/pg/decommission.js.map +1 -1
  31. package/out/zero-cache/src/services/change-source/pg/initial-sync.d.ts.map +1 -1
  32. package/out/zero-cache/src/services/change-source/pg/initial-sync.js +51 -74
  33. package/out/zero-cache/src/services/change-source/pg/initial-sync.js.map +1 -1
  34. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts +20 -0
  35. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts.map +1 -1
  36. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js +43 -3
  37. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js.map +1 -1
  38. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts +24 -1
  39. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts.map +1 -1
  40. package/out/zero-cache/src/services/change-source/pg/replication-slots.js +35 -5
  41. package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
  42. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts +1 -4
  43. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts.map +1 -1
  44. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js +11 -18
  45. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js.map +1 -1
  46. package/out/zero-cache/src/services/change-streamer/backup-monitor.d.ts.map +1 -1
  47. package/out/zero-cache/src/services/change-streamer/backup-monitor.js +8 -3
  48. package/out/zero-cache/src/services/change-streamer/backup-monitor.js.map +1 -1
  49. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts +34 -0
  50. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts.map +1 -0
  51. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js +67 -0
  52. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js.map +1 -0
  53. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts +2 -4
  54. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts.map +1 -1
  55. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +8 -2
  56. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
  57. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts +34 -13
  58. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts.map +1 -1
  59. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +149 -27
  60. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  61. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts +3 -9
  62. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts.map +1 -1
  63. package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
  64. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts +11 -0
  65. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts.map +1 -0
  66. package/out/zero-cache/src/services/change-streamer/shard-sampling.js +19 -0
  67. package/out/zero-cache/src/services/change-streamer/shard-sampling.js.map +1 -0
  68. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts +11 -1
  69. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts.map +1 -1
  70. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js +38 -5
  71. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js.map +1 -1
  72. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts +25 -1
  73. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts.map +1 -1
  74. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js +95 -12
  75. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js.map +1 -1
  76. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts +83 -0
  77. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts.map +1 -0
  78. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js +417 -0
  79. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js.map +1 -0
  80. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts +73 -0
  81. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts.map +1 -0
  82. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js +157 -0
  83. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js.map +1 -0
  84. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts +1 -1
  85. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts.map +1 -1
  86. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js +8 -3
  87. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js.map +1 -1
  88. package/out/zero-cache/src/services/change-streamer/storer.d.ts +23 -0
  89. package/out/zero-cache/src/services/change-streamer/storer.d.ts.map +1 -1
  90. package/out/zero-cache/src/services/change-streamer/storer.js +40 -0
  91. package/out/zero-cache/src/services/change-streamer/storer.js.map +1 -1
  92. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts +29 -0
  93. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts.map +1 -0
  94. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js +154 -0
  95. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js.map +1 -0
  96. package/out/zero-cache/src/services/http-service.d.ts +1 -0
  97. package/out/zero-cache/src/services/http-service.d.ts.map +1 -1
  98. package/out/zero-cache/src/services/http-service.js +7 -3
  99. package/out/zero-cache/src/services/http-service.js.map +1 -1
  100. package/out/zero-cache/src/services/litestream/metrics.d.ts +1 -0
  101. package/out/zero-cache/src/services/litestream/metrics.d.ts.map +1 -1
  102. package/out/zero-cache/src/services/litestream/metrics.js +4 -1
  103. package/out/zero-cache/src/services/litestream/metrics.js.map +1 -1
  104. package/out/zero-cache/src/services/mutagen/mutagen.d.ts.map +1 -1
  105. package/out/zero-cache/src/services/mutagen/mutagen.js +1 -1
  106. package/out/zero-cache/src/services/mutagen/mutagen.js.map +1 -1
  107. package/out/zero-cache/src/services/replicator/change-log-db.d.ts +10 -0
  108. package/out/zero-cache/src/services/replicator/change-log-db.d.ts.map +1 -1
  109. package/out/zero-cache/src/services/replicator/change-log-db.js +15 -1
  110. package/out/zero-cache/src/services/replicator/change-log-db.js.map +1 -1
  111. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts +3 -2
  112. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts.map +1 -1
  113. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js +8 -6
  114. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js.map +1 -1
  115. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts +3 -2
  116. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts.map +1 -1
  117. package/out/zero-cache/src/services/view-syncer/client-handler.js +25 -17
  118. package/out/zero-cache/src/services/view-syncer/client-handler.js.map +1 -1
  119. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts.map +1 -1
  120. package/out/zero-cache/src/services/view-syncer/cvr-store.js +14 -1
  121. package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
  122. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +3 -3
  123. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  124. package/out/zero-cache/src/services/view-syncer/view-syncer.js +6 -2
  125. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  126. package/out/zero-cache/src/types/downstream.d.ts +11 -0
  127. package/out/zero-cache/src/types/downstream.d.ts.map +1 -0
  128. package/out/zero-cache/src/types/pg.d.ts.map +1 -1
  129. package/out/zero-cache/src/types/pg.js +14 -4
  130. package/out/zero-cache/src/types/pg.js.map +1 -1
  131. package/out/zero-cache/src/types/poke-chunk.d.ts +18 -0
  132. package/out/zero-cache/src/types/poke-chunk.d.ts.map +1 -0
  133. package/out/zero-cache/src/types/poke-chunk.js +57 -0
  134. package/out/zero-cache/src/types/poke-chunk.js.map +1 -0
  135. package/out/zero-cache/src/types/processes.js +1 -4
  136. package/out/zero-cache/src/types/processes.js.map +1 -1
  137. package/out/zero-cache/src/workers/connection.d.ts +22 -4
  138. package/out/zero-cache/src/workers/connection.d.ts.map +1 -1
  139. package/out/zero-cache/src/workers/connection.js +112 -14
  140. package/out/zero-cache/src/workers/connection.js.map +1 -1
  141. package/out/zero-client/src/client/version.js +1 -1
  142. package/out/zero-client/src/client/zero-poke-handler.d.ts +7 -2
  143. package/out/zero-client/src/client/zero-poke-handler.d.ts.map +1 -1
  144. package/out/zero-client/src/client/zero-poke-handler.js +52 -2
  145. package/out/zero-client/src/client/zero-poke-handler.js.map +1 -1
  146. package/out/zero-client/src/client/zero.d.ts.map +1 -1
  147. package/out/zero-client/src/client/zero.js +38 -6
  148. package/out/zero-client/src/client/zero.js.map +1 -1
  149. package/out/zero-protocol/src/down.d.ts +1 -1
  150. package/out/zero-protocol/src/poke.d.ts +89 -7
  151. package/out/zero-protocol/src/poke.d.ts.map +1 -1
  152. package/out/zero-protocol/src/poke.js +16 -8
  153. package/out/zero-protocol/src/poke.js.map +1 -1
  154. package/out/zero-protocol/src/protocol-version.d.ts +12 -7
  155. package/out/zero-protocol/src/protocol-version.d.ts.map +1 -1
  156. package/out/zero-protocol/src/protocol-version.js.map +1 -1
  157. package/out/zql/src/ivm/memory-source.d.ts +10 -2
  158. package/out/zql/src/ivm/memory-source.d.ts.map +1 -1
  159. package/out/zql/src/ivm/memory-source.js +14 -6
  160. package/out/zql/src/ivm/memory-source.js.map +1 -1
  161. package/out/zql/src/ivm/stream.d.ts +0 -1
  162. package/out/zql/src/ivm/stream.d.ts.map +1 -1
  163. package/out/zql/src/ivm/stream.js +1 -7
  164. package/out/zql/src/ivm/stream.js.map +1 -1
  165. package/out/zql/src/ivm/union-fan-in.d.ts +1 -1
  166. package/out/zql/src/ivm/union-fan-in.d.ts.map +1 -1
  167. package/out/zql/src/ivm/union-fan-in.js +11 -2
  168. package/out/zql/src/ivm/union-fan-in.js.map +1 -1
  169. package/out/zqlite/src/db.js +2 -2
  170. package/out/zqlite/src/sqlite-cost-model.js +5 -5
  171. package/out/zqlite/src/table-source.d.ts.map +1 -1
  172. package/out/zqlite/src/table-source.js +4 -4
  173. package/out/zqlite/src/table-source.js.map +1 -1
  174. package/package.json +1 -1
  175. package/out/zero-cache/src/server/backup-watermark-poller.js +0 -31
  176. package/out/zero-cache/src/server/backup-watermark-poller.js.map +0 -1
  177. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts +0 -57
  178. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts.map +0 -1
  179. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js +0 -137
  180. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"snapshot-reservations.js","names":["#lc","#backupConfig","#onClose","#reservations","#close","#downstream","#watermark"],"sources":["../../../../../../zero-cache/src/services/change-streamer/snapshot-reservations.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport type {Source} from '../../types/streams.ts';\nimport {Subscription} from '../../types/subscription.ts';\nimport {\n litestreamMonitorMetricAttrs,\n litestreamSnapshotReservationDuration,\n} from '../litestream/metrics.ts';\nimport type {BackupConfig} from './change-streamer-service.ts';\nimport type {SnapshotMessage} from './snapshot.ts';\n\nexport class SnapshotReservations {\n readonly #lc: LogContext;\n readonly #backupConfig: BackupConfig;\n readonly #onClose: ((taskID: string) => void) | undefined;\n readonly #reservations = new Map<string, Reservation>();\n\n constructor(\n lc: LogContext,\n backupConfig: BackupConfig,\n onClose?: ((taskID: string) => void) | undefined,\n ) {\n this.#lc = lc.withContext('component', 'snapshot-reserver');\n this.#backupConfig = backupConfig;\n this.#onClose = onClose;\n }\n\n open(taskID: string): Source<SnapshotMessage> {\n this.close(taskID);\n\n const instanceID = {};\n const downstream = Subscription.create<SnapshotMessage>({\n cleanup: () => this.#close(taskID, instanceID),\n });\n this.#reservations.set(taskID, new Reservation(instanceID, downstream));\n this.#lc.info?.(`created snasphot reservation for ${taskID}`);\n return downstream;\n }\n\n close(taskID: string) {\n this.#close(taskID, undefined);\n }\n\n #close(taskID: string, cancelledInstanceID: InstanceID | undefined) {\n const res = this.#reservations.get(taskID);\n if (\n res &&\n (!cancelledInstanceID || res.instanceID === cancelledInstanceID)\n ) {\n // Note: delete first, so that the reservation is gone when close() is called.\n this.#reservations.delete(taskID);\n this.#onClose?.(taskID);\n res.close();\n\n const duration = Date.now() - res.startTime.getTime();\n this.#lc.info?.(\n `ended snapshot reservation for ${taskID} (${duration} ms)`,\n );\n litestreamSnapshotReservationDuration().recordMs(duration, {\n ...litestreamMonitorMetricAttrs(\n this.#backupConfig.backupURL,\n this.#backupConfig.litestreamVersion,\n 'view_syncer',\n ),\n result:\n cancelledInstanceID || !res.confirmed() ? 'cancelled' : 'closed',\n });\n }\n }\n\n confirmationsRequired() {\n for (const res of this.#reservations.values()) {\n if (!res.confirmed()) {\n return true;\n }\n }\n return false;\n }\n\n confirm(replicaVersion: string, minWatermark: string) {\n for (const [taskID, res] of this.#reservations.entries()) {\n if (!res.confirmed()) {\n this.#lc.info?.(\n `reserving change-log entries since ${minWatermark} for ${taskID}`,\n );\n res.confirm(this.#backupConfig.backupURL, replicaVersion, minWatermark);\n }\n }\n }\n\n getReservedWatermarks() {\n return Array.from(\n this.#reservations.values(),\n ({reservedWatermark}) => reservedWatermark,\n ).filter(watermark => watermark !== null);\n }\n}\n\ntype InstanceID = {};\n\nclass Reservation {\n readonly instanceID: InstanceID;\n readonly startTime: Date = new Date();\n readonly #downstream: Subscription<SnapshotMessage>;\n #watermark: string | null = null;\n\n constructor(\n instanceID: InstanceID,\n downstream: Subscription<SnapshotMessage>,\n ) {\n this.instanceID = instanceID;\n this.#downstream = downstream;\n }\n\n get reservedWatermark() {\n return this.#watermark;\n }\n\n confirmed() {\n return this.#watermark !== null;\n }\n\n confirm(backupURL: string, replicaVersion: string, minWatermark: string) {\n if (this.#watermark === null) {\n if (this.#downstream.active) {\n this.#downstream.push([\n 'status',\n {tag: 'status', backupURL, replicaVersion, minWatermark},\n ]);\n }\n this.#watermark = minWatermark;\n }\n }\n\n close() {\n this.#downstream.cancel();\n }\n}\n"],"mappings":";;;AAUA,IAAa,uBAAb,MAAkC;CAChC;CACA;CACA;CACA,gCAAyB,IAAI,IAAyB;CAEtD,YACE,IACA,cACA,SACA;EACA,KAAKA,MAAM,GAAG,YAAY,aAAa,mBAAmB;EAC1D,KAAKC,gBAAgB;EACrB,KAAKC,WAAW;CAClB;CAEA,KAAK,QAAyC;EAC5C,KAAK,MAAM,MAAM;EAEjB,MAAM,aAAa,CAAC;EACpB,MAAM,aAAa,aAAa,OAAwB,EACtD,eAAe,KAAKE,OAAO,QAAQ,UAAU,EAC/C,CAAC;EACD,KAAKD,cAAc,IAAI,QAAQ,IAAI,YAAY,YAAY,UAAU,CAAC;EACtE,KAAKH,IAAI,OAAO,oCAAoC,QAAQ;EAC5D,OAAO;CACT;CAEA,MAAM,QAAgB;EACpB,KAAKI,OAAO,QAAQ,KAAA,CAAS;CAC/B;CAEA,OAAO,QAAgB,qBAA6C;EAClE,MAAM,MAAM,KAAKD,cAAc,IAAI,MAAM;EACzC,IACE,QACC,CAAC,uBAAuB,IAAI,eAAe,sBAC5C;GAEA,KAAKA,cAAc,OAAO,MAAM;GAChC,KAAKD,WAAW,MAAM;GACtB,IAAI,MAAM;GAEV,MAAM,WAAW,KAAK,IAAI,IAAI,IAAI,UAAU,QAAQ;GACpD,KAAKF,IAAI,OACP,kCAAkC,OAAO,IAAI,SAAS,KACxD;GACA,sCAAsC,EAAE,SAAS,UAAU;IACzD,GAAG,6BACD,KAAKC,cAAc,WACnB,KAAKA,cAAc,mBACnB,aACF;IACA,QACE,uBAAuB,CAAC,IAAI,UAAU,IAAI,cAAc;GAC5D,CAAC;EACH;CACF;CAEA,wBAAwB;EACtB,KAAK,MAAM,OAAO,KAAKE,cAAc,OAAO,GAC1C,IAAI,CAAC,IAAI,UAAU,GACjB,OAAO;EAGX,OAAO;CACT;CAEA,QAAQ,gBAAwB,cAAsB;EACpD,KAAK,MAAM,CAAC,QAAQ,QAAQ,KAAKA,cAAc,QAAQ,GACrD,IAAI,CAAC,IAAI,UAAU,GAAG;GACpB,KAAKH,IAAI,OACP,sCAAsC,aAAa,OAAO,QAC5D;GACA,IAAI,QAAQ,KAAKC,cAAc,WAAW,gBAAgB,YAAY;EACxE;CAEJ;CAEA,wBAAwB;EACtB,OAAO,MAAM,KACX,KAAKE,cAAc,OAAO,IACzB,EAAC,wBAAuB,iBAC3B,EAAE,QAAO,cAAa,cAAc,IAAI;CAC1C;AACF;AAIA,IAAM,cAAN,MAAkB;CAChB;CACA,4BAA2B,IAAI,KAAK;CACpC;CACA,aAA4B;CAE5B,YACE,YACA,YACA;EACA,KAAK,aAAa;EAClB,KAAKE,cAAc;CACrB;CAEA,IAAI,oBAAoB;EACtB,OAAO,KAAKC;CACd;CAEA,YAAY;EACV,OAAO,KAAKA,eAAe;CAC7B;CAEA,QAAQ,WAAmB,gBAAwB,cAAsB;EACvE,IAAI,KAAKA,eAAe,MAAM;GAC5B,IAAI,KAAKD,YAAY,QACnB,KAAKA,YAAY,KAAK,CACpB,UACA;IAAC,KAAK;IAAU;IAAW;IAAgB;GAAY,CACzD,CAAC;GAEH,KAAKC,aAAa;EACpB;CACF;CAEA,QAAQ;EACN,KAAKD,YAAY,OAAO;CAC1B;AACF"}
1
+ {"version":3,"file":"snapshot-reservations.js","names":["#lc","#backupConfig","#onClose","#reservations","#close","#metricAttrs","#downstream","#watermark","#delayNoted"],"sources":["../../../../../../zero-cache/src/services/change-streamer/snapshot-reservations.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport type {Source} from '../../types/streams.ts';\nimport {Subscription} from '../../types/subscription.ts';\nimport {\n litestreamMonitorMetricAttrs,\n litestreamSnapshotReservationConfirmDuration,\n litestreamSnapshotReservationDuration,\n} from '../litestream/metrics.ts';\nimport type {BackupConfig} from './change-streamer-service.ts';\nimport type {SnapshotMessage} from './snapshot.ts';\nimport type {ChangeLogReadSource} from './sqlite-change-log-read-router.ts';\n\nexport class SnapshotReservations {\n readonly #lc: LogContext;\n readonly #backupConfig: BackupConfig;\n readonly #onClose: ((taskID: string) => void) | undefined;\n readonly #reservations = new Map<string, Reservation>();\n\n constructor(\n lc: LogContext,\n backupConfig: BackupConfig,\n onClose?: ((taskID: string) => void) | undefined,\n ) {\n this.#lc = lc.withContext('component', 'snapshot-reserver');\n this.#backupConfig = backupConfig;\n this.#onClose = onClose;\n }\n\n open(taskID: string): Source<SnapshotMessage> {\n this.close(taskID);\n\n const instanceID = {};\n const downstream = Subscription.create<SnapshotMessage>({\n cleanup: () => this.#close(taskID, instanceID),\n });\n this.#reservations.set(taskID, new Reservation(instanceID, downstream));\n this.#lc.info?.(`created snasphot reservation for ${taskID}`);\n return downstream;\n }\n\n close(taskID: string) {\n this.#close(taskID, undefined);\n }\n\n isCurrent(taskID: string, source: Source<SnapshotMessage>): boolean {\n return this.#reservations.get(taskID)?.owns(source) ?? false;\n }\n\n #metricAttrs() {\n return litestreamMonitorMetricAttrs(\n this.#backupConfig.backupURL,\n this.#backupConfig.litestreamVersion,\n 'view_syncer',\n );\n }\n\n #close(taskID: string, cancelledInstanceID: InstanceID | undefined) {\n const res = this.#reservations.get(taskID);\n if (\n res &&\n (!cancelledInstanceID || res.instanceID === cancelledInstanceID)\n ) {\n // Note: delete first, so that the reservation is gone when close() is called.\n this.#reservations.delete(taskID);\n this.#onClose?.(taskID);\n res.close();\n\n const duration = Date.now() - res.startTime.getTime();\n this.#lc.info?.(\n `ended snapshot reservation for ${taskID} (${duration} ms)`,\n );\n // `result` reports how the reservation ended and `confirmed` whether it\n // ever received its bounds. They are separate dimensions: a follower\n // that gives up while waiting for a confirmation is\n // `result=cancelled, confirmed=false`, which a single collapsed\n // attribute cannot distinguish from a client that simply went away.\n litestreamSnapshotReservationDuration().recordMs(duration, {\n ...this.#metricAttrs(),\n result: cancelledInstanceID ? 'cancelled' : 'closed',\n confirmed: res.confirmed(),\n });\n }\n }\n\n confirmationsRequired() {\n for (const res of this.#reservations.values()) {\n if (!res.confirmed()) {\n return true;\n }\n }\n return false;\n }\n\n unconfirmedTaskIDs(): string[] {\n return [...this.#reservations.entries()]\n .filter(([, reservation]) => !reservation.confirmed())\n .map(([taskID]) => taskID);\n }\n\n /** Confirms one reservation with the bounds of its pinned read source. */\n confirmFor(\n taskID: string,\n replicaVersion: string,\n minWatermark: string,\n source: ChangeLogReadSource,\n ): void {\n const res = this.#reservations.get(taskID);\n if (res && !res.confirmed()) {\n this.#lc.info?.(\n `reserving change-log entries since ${minWatermark} for ${taskID}`,\n );\n res.confirm(this.#backupConfig.backupURL, replicaVersion, minWatermark);\n // Measured from `open()`, not from the first confirmation attempt: what\n // matters is how long the follower waited before it could restore.\n litestreamSnapshotReservationConfirmDuration().recordMs(\n Date.now() - res.startTime.getTime(),\n {...this.#metricAttrs(), source},\n );\n }\n }\n\n /**\n * Notes that a confirmation was deferred, returning true only the first time\n * for this reservation. Confirmation is retried on every backup, so an\n * undeduplicated count would measure backups rather than delayed followers.\n */\n noteConfirmationDelayed(taskID: string): boolean {\n return this.#reservations.get(taskID)?.noteDelayed() ?? false;\n }\n\n getReservedWatermarks() {\n return Array.from(\n this.#reservations.values(),\n ({reservedWatermark}) => reservedWatermark,\n ).filter(watermark => watermark !== null);\n }\n}\n\ntype InstanceID = {};\n\nclass Reservation {\n readonly instanceID: InstanceID;\n readonly startTime: Date = new Date();\n readonly #downstream: Subscription<SnapshotMessage>;\n #watermark: string | null = null;\n #delayNoted = false;\n\n constructor(\n instanceID: InstanceID,\n downstream: Subscription<SnapshotMessage>,\n ) {\n this.instanceID = instanceID;\n this.#downstream = downstream;\n }\n\n get reservedWatermark() {\n return this.#watermark;\n }\n\n confirmed() {\n return this.#watermark !== null;\n }\n\n owns(source: Source<SnapshotMessage>): boolean {\n return this.#downstream === source;\n }\n\n noteDelayed(): boolean {\n if (this.#delayNoted) {\n return false;\n }\n this.#delayNoted = true;\n return true;\n }\n\n confirm(backupURL: string, replicaVersion: string, minWatermark: string) {\n if (this.#watermark === null) {\n if (this.#downstream.active) {\n this.#downstream.push([\n 'status',\n {tag: 'status', backupURL, replicaVersion, minWatermark},\n ]);\n }\n this.#watermark = minWatermark;\n }\n }\n\n close() {\n this.#downstream.cancel();\n }\n}\n"],"mappings":"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"}
@@ -24,6 +24,30 @@ export type SQLiteChangeLogCatchupOptions = {
24
24
  cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;
25
25
  sleep?: typeof sleep | undefined;
26
26
  now?: (() => number) | undefined;
27
+ /** Opens slice 11's process-local breaker after registration has committed. */
28
+ onFailure?: ((failure: SQLiteChangeLogCatchupFailure) => void) | undefined;
29
+ };
30
+ export type SQLiteChangeLogCatchupRequest = {
31
+ /**
32
+ * Whether the routing warm-up gate classified the log as warm *for this
33
+ * subscriber*. The coordinator is opened once and reused, so this cannot be
34
+ * a property of it: `coldReadPercent` serves some subscribers from a log
35
+ * that is still inside its warm-up window, and the catchup metrics have to
36
+ * separate those from steady-state serving.
37
+ */
38
+ readonly logWarm?: boolean | undefined;
39
+ };
40
+ export type SQLiteChangeLogCatchupFailure = 'barrier-timeout' | 'reader-error';
41
+ export type SQLiteChangeLogCatchupRegistration = {
42
+ readonly kind: 'registered';
43
+ } | {
44
+ readonly kind: 'uncovered';
45
+ readonly minWatermark: string;
46
+ } | {
47
+ readonly kind: 'declined';
48
+ readonly error: unknown;
49
+ } | {
50
+ readonly kind: 'handled';
27
51
  };
28
52
  /**
29
53
  * Coordinates a serving subscriber's gap-free transition from replica-local
@@ -42,7 +66,7 @@ export declare class SQLiteChangeLogCatchup implements Disposable {
42
66
  * the background so subscribe() does not wait for SQLite to reach the
43
67
  * required head.
44
68
  */
45
- catchup(subscriber: Subscriber, captureRequiredHead: () => string | Promise<string>): Promise<void>;
69
+ catchup(subscriber: Subscriber, captureRequiredHead: () => string | Promise<string>, request?: SQLiteChangeLogCatchupRequest): Promise<SQLiteChangeLogCatchupRegistration>;
46
70
  /**
47
71
  * Notes that the change-log writer has committed `watermark`, i.e. that the
48
72
  * log's head has advanced to it. The writer runs in this process, so this is
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-catchup.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAGjD,OAAO,EAAC,KAAK,EAAC,MAAM,iCAAiC,CAAC;AAEtD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAE5D,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,+BAA+B,CAAC;AAC/D,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAEhD,MAAM,WAAW,4BAA4B;IAC3C,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW,CAAC;IACzC,IAAI,CACF,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GACnB,aAAa,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAAC;IAC/C,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,2BAA2B;IAC1C,oBAAoB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CACxD;AAQD,MAAM,MAAM,6BAA6B,GAAG;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,qBAAqB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C,YAAY,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACvD,KAAK,CAAC,EAAE,OAAO,KAAK,GAAG,SAAS,CAAC;IACjC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;CAClC,CAAC;AAMF;;;;;;;;GAQG;AACH,qBAAa,sBAAuB,YAAW,UAAU;;gBAiCrD,EAAE,EAAE,UAAU,EACd,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,4BAA4B,EACpC,IAAI,EAAE,6BAA6B;IAcrC;;;;OAIG;IACG,OAAO,CACX,UAAU,EAAE,UAAU,EACtB,mBAAmB,EAAE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,GAClD,OAAO,CAAC,IAAI,CAAC;IA8BhB;;;;;;;;OAQG;IACH,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAS1C,MAAM,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI;IAMpC,KAAK,IAAI,IAAI;IAab,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAoRzB;AAID;;;;GAIG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;CAAG;AAEzD,uEAAuE;AACvE,qBAAa,kCAAmC,SAAQ,2BAA2B;IACjF,QAAQ,CAAC,IAAI,wCAAwC;CACtD;AAED,iEAAiE;AACjE,qBAAa,kCAAmC,SAAQ,2BAA2B;IACjF,QAAQ,CAAC,IAAI,wCAAwC;CACtD"}
1
+ {"version":3,"file":"sqlite-change-log-catchup.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { AbortError } from "../../../../shared/src/abort-error.js";
2
2
  import { sleep } from "../../../../shared/src/sleep.js";
3
- import { getOrCreateCounter } from "../../observability/metrics.js";
3
+ import { getOrCreateCounter, getOrCreateLatencyHistogram, getOrCreateValueHistogram } from "../../observability/metrics.js";
4
4
  import { resolver } from "@rocicorp/resolver";
5
5
  //#region ../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts
6
6
  var DEFAULT_BARRIER_POLL_INTERVAL_MS = 1e3;
@@ -24,9 +24,32 @@ var SQLiteChangeLogCatchup = class {
24
24
  #cleanupGuard;
25
25
  #sleep;
26
26
  #now;
27
+ #onFailure;
27
28
  #barrierTimeouts = getOrCreateCounter("replication", "sqlite_change_log.barrier_timeouts", "SQLite change-log catchups that timed out waiting for the required head.");
28
29
  #barrierBacklogOverflows = getOrCreateCounter("replication", "sqlite_change_log.barrier_backlog_overflows", "SQLite change-log catchups abandoned because the subscriber backlog reached its high water mark while waiting for the required head.");
29
30
  #barrierWakeups = getOrCreateCounter("replication", "sqlite_change_log.barrier_wakeups", "SQLite catchup barrier waits, labeled by what ended the wait. A population dominated by \"poll\" means the change-log writer's commit notification is not reaching the barrier.");
31
+ #catchupResults = getOrCreateCounter("replication", "sqlite_change_log.catchup_results", "SQLite catchup subscriptions by outcome.");
32
+ #catchupRows = getOrCreateCounter("replication", "sqlite_change_log.catchup_rows", "Rows served from the SQLite change log during catchup.");
33
+ #catchupBytes = getOrCreateCounter("replication", "sqlite_change_log.catchup_bytes", {
34
+ description: "Bytes served from the SQLite change log during catchup.",
35
+ unit: "By"
36
+ });
37
+ #catchupDuration = getOrCreateLatencyHistogram("replication", "sqlite_change_log.catchup_duration", "Duration of a SQLite change-log catchup.");
38
+ #barrierWaitDuration = getOrCreateLatencyHistogram("replication", "sqlite_change_log.barrier_wait_duration", "Time SQLite catchup waits for its pinned required head.");
39
+ #catchupBacklogPeak = getOrCreateValueHistogram("replication", "sqlite_change_log.catchup_backlog_peak_bytes", {
40
+ description: "Peak live backlog bytes buffered while a subscriber catches up from SQLite.",
41
+ unit: "By",
42
+ bucketBoundaries: [
43
+ 0,
44
+ 1024,
45
+ 16384,
46
+ 65536,
47
+ 262144,
48
+ 1048576,
49
+ 4194304,
50
+ 16777216
51
+ ]
52
+ });
30
53
  #catchups = /* @__PURE__ */ new Map();
31
54
  #commitWaiters = /* @__PURE__ */ new Set();
32
55
  #closed = false;
@@ -40,35 +63,58 @@ var SQLiteChangeLogCatchup = class {
40
63
  this.#cleanupGuard = opts.cleanupGuard ?? NOOP_CLEANUP_GUARD;
41
64
  this.#sleep = opts.sleep ?? sleep;
42
65
  this.#now = opts.now ?? Date.now;
66
+ this.#onFailure = opts.onFailure;
43
67
  }
44
68
  /**
45
69
  * Registers the subscriber before resolving. Catchup itself continues in
46
70
  * the background so subscribe() does not wait for SQLite to reach the
47
71
  * required head.
48
72
  */
49
- async catchup(subscriber, captureRequiredHead) {
73
+ async catchup(subscriber, captureRequiredHead, request = {}) {
50
74
  if (this.#closed) {
51
75
  subscriber.fail(new AbortError("SQLite change-log catchup is closed"));
52
- return;
76
+ return { kind: "handled" };
53
77
  }
54
78
  const abort = new AbortController();
55
79
  this.#catchups.set(subscriber, abort);
56
80
  let requiredHead;
81
+ let uncoveredMinWatermark;
82
+ let committed = false;
57
83
  try {
58
84
  await this.#cleanupGuard.runWhilePurgeBlocked(() => {
59
85
  this.#throwIfAborted(abort.signal);
86
+ const plan = this.#reader.plan(subscriber.watermark);
87
+ if (plan.kind === "too-old") {
88
+ uncoveredMinWatermark = plan.minWatermark;
89
+ return;
90
+ }
60
91
  requiredHead = captureRequiredHead();
61
92
  this.#forwarder.add(subscriber);
93
+ committed = true;
62
94
  });
63
95
  } catch (error) {
64
96
  this.#catchups.delete(subscriber);
65
- if (!abort.signal.aborted) {
66
- this.#lc.error?.(`error while registering SQLite catchup subscriber ${subscriber.id}`, error);
67
- subscriber.fail(error);
97
+ if (abort.signal.aborted) return { kind: "handled" };
98
+ if (!committed) {
99
+ this.#onFailure?.("reader-error");
100
+ return {
101
+ kind: "declined",
102
+ error
103
+ };
68
104
  }
69
- return;
105
+ this.#lc.error?.(`error while registering SQLite catchup subscriber ${subscriber.id}`, error);
106
+ subscriber.fail(error);
107
+ return { kind: "handled" };
108
+ }
109
+ if (uncoveredMinWatermark !== void 0) {
110
+ this.#catchups.delete(subscriber);
111
+ return {
112
+ kind: "uncovered",
113
+ minWatermark: uncoveredMinWatermark
114
+ };
70
115
  }
71
- this.#run(subscriber, requiredHead, abort);
116
+ this.#run(subscriber, requiredHead, abort, request.logWarm);
117
+ return { kind: "registered" };
72
118
  }
73
119
  /**
74
120
  * Notes that the change-log writer has committed `watermark`, i.e. that the
@@ -103,23 +149,34 @@ var SQLiteChangeLogCatchup = class {
103
149
  [Symbol.dispose]() {
104
150
  this.close();
105
151
  }
106
- async #run(subscriber, requiredHead, abort) {
152
+ async #run(subscriber, requiredHead, abort, logWarm) {
107
153
  const { signal } = abort;
154
+ const catchupStart = this.#now();
155
+ let outcome = "aborted";
156
+ let backlogPeakBytes = 0;
108
157
  try {
109
158
  const requiredHeadStart = this.#now();
110
159
  const required = await this.#awaitRequiredHead(requiredHead, signal);
111
160
  const requiredHeadWaitMs = this.#now() - requiredHeadStart;
112
161
  if (requiredHeadWaitMs > this.#barrierTimeoutMs) this.#lc.info?.(`waited ${requiredHeadWaitMs} ms for the forwarded transaction to commit before starting the SQLite barrier for ${subscriber.id}`);
113
162
  const deadline = this.#now() + this.#barrierTimeoutMs;
114
- const plan = await this.#waitForPlan(subscriber, required, deadline, signal);
163
+ const barrierStart = this.#now();
164
+ let plan;
165
+ try {
166
+ plan = await this.#waitForPlan(subscriber, required, deadline, signal);
167
+ } finally {
168
+ this.#barrierWaitDuration.recordMs(this.#now() - barrierStart);
169
+ }
115
170
  this.#throwIfAborted(signal);
116
171
  if (plan.kind === "too-old") {
172
+ outcome = "too-old";
117
173
  const message = `earliest supported watermark is ${plan.minWatermark} (requested ${subscriber.watermark})`;
118
174
  this.#lc.warn?.(`rejecting subscriber at watermark ${subscriber.watermark} (earliest watermark: ${plan.minWatermark})`);
119
175
  subscriber.close(2, message);
120
176
  return;
121
177
  }
122
178
  let count = 0;
179
+ let bytes = 0;
123
180
  const start = this.#now();
124
181
  if (plan.kind === "range") {
125
182
  let lastBatchConsumed;
@@ -132,24 +189,50 @@ var SQLiteChangeLogCatchup = class {
132
189
  for (const change of changes) {
133
190
  lastBatchConsumed = subscriber.catchup(change);
134
191
  count++;
192
+ bytes += Buffer.byteLength(change[2]);
135
193
  }
194
+ backlogPeakBytes = Math.max(backlogPeakBytes, subscriber.getStats().backlogBytes);
136
195
  }
137
196
  await lastBatchConsumed;
138
- } else this.#lc.warn?.(`subscriber ${subscriber.id} at watermark ${subscriber.watermark} is ahead of the SQLite change-log head ${plan.headWatermark}; waiting for the replica to catch up`);
197
+ outcome = "range";
198
+ } else {
199
+ outcome = "ahead";
200
+ this.#lc.warn?.(`subscriber ${subscriber.id} at watermark ${subscriber.watermark} is ahead of the SQLite change-log head ${plan.headWatermark}; waiting for the replica to catch up`);
201
+ }
139
202
  this.#throwIfAborted(signal);
140
203
  this.#lc.info?.(`caught up ${subscriber.id} from SQLite with ${count} changes (${this.#now() - start} ms)`);
204
+ const attributes = {
205
+ classification: outcome,
206
+ log_warm: logWarm ?? "unknown"
207
+ };
208
+ this.#catchupRows.add(count, attributes);
209
+ this.#catchupBytes.add(bytes, attributes);
141
210
  subscriber.setCaughtUp();
142
211
  } catch (error) {
143
212
  if (signal.aborted) return;
144
213
  if (error instanceof SQLiteChangeLogBarrierError) {
145
- if (error instanceof SQLiteChangeLogBarrierTimeoutError) this.#barrierTimeouts.add(1);
214
+ if (error instanceof SQLiteChangeLogBarrierTimeoutError) {
215
+ this.#barrierTimeouts.add(1);
216
+ outcome = "barrier-timeout";
217
+ this.#onFailure?.("barrier-timeout");
218
+ } else outcome = "barrier-backlog";
146
219
  this.#lc.warn?.(`ending subscription for ${subscriber.id} to retry SQLite catchup`, error);
147
220
  subscriber.close();
148
221
  return;
149
222
  }
223
+ outcome = "reader-error";
224
+ this.#onFailure?.("reader-error");
150
225
  this.#lc.error?.(`error while catching up subscriber ${subscriber.id} from SQLite`, error);
151
226
  subscriber.fail(error);
152
227
  } finally {
228
+ backlogPeakBytes = Math.max(backlogPeakBytes, subscriber.getStats().backlogBytes);
229
+ const attributes = {
230
+ classification: outcome,
231
+ log_warm: logWarm ?? "unknown"
232
+ };
233
+ this.#catchupResults.add(1, attributes);
234
+ this.#catchupDuration.recordMs(this.#now() - catchupStart, attributes);
235
+ this.#catchupBacklogPeak.record(backlogPeakBytes, attributes);
153
236
  if (this.#catchups.get(subscriber) === abort) this.#catchups.delete(subscriber);
154
237
  }
155
238
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-catchup.js","names":["#lc","#forwarder","#reader","#batchSize","#barrierTimeoutMs","#barrierPollIntervalMs","#cleanupGuard","#sleep","#now","#barrierTimeouts","#barrierBacklogOverflows","#barrierWakeups","#catchups","#commitWaiters","#closed","#throwIfAborted","#run","#awaitRequiredHead","#waitForPlan","#awaitChangeLogProgress"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {resolver} from '@rocicorp/resolver';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {sleep} from '../../../../shared/src/sleep.ts';\nimport {getOrCreateCounter} from '../../observability/metrics.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\nimport * as ErrorType from './error-type-enum.ts';\nimport type {Forwarder} from './forwarder.ts';\nimport type {CatchupPlan} from './sqlite-change-log-reader.ts';\nimport type {Subscriber} from './subscriber.ts';\n\nexport interface SQLiteChangeLogCatchupReader {\n plan(fromWatermark: string): CatchupPlan;\n read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal,\n ): AsyncIterable<readonly WatermarkedChange[]>;\n close(): void;\n}\n\n/**\n * Slice 9 replaces this no-op implementation with a guard that waits for an\n * in-flight purge and blocks new purge dispatch while the subscriber's ACK is\n * made visible to the Forwarder.\n */\nexport interface SQLiteChangeLogCleanupGuard {\n runWhilePurgeBlocked<T>(register: () => T): Promise<T>;\n}\n\n// How long the barrier waits for the change-log writer's commit notification\n// before re-reading the log itself. The notification normally arrives first, so\n// this is a backstop for the windows in which none is coming: the writer is\n// disabled, or it is between stream connections.\nconst DEFAULT_BARRIER_POLL_INTERVAL_MS = 1000;\n\nexport type SQLiteChangeLogCatchupOptions = {\n batchSize: number;\n barrierTimeoutMs: number;\n barrierPollIntervalMs?: number | undefined;\n cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;\n sleep?: typeof sleep | undefined;\n now?: (() => number) | undefined;\n};\n\nconst NOOP_CLEANUP_GUARD: SQLiteChangeLogCleanupGuard = {\n runWhilePurgeBlocked: register => Promise.resolve(register()),\n};\n\n/**\n * Coordinates a serving subscriber's gap-free transition from replica-local\n * SQLite catchup to the Forwarder's live stream.\n *\n * Registration and required-head capture happen in one synchronous callback.\n * The subscriber therefore either sees a transaction in SQLite catchup or in\n * its live backlog (duplicates across that boundary are filtered by\n * Subscriber), never in neither place.\n */\nexport class SQLiteChangeLogCatchup implements Disposable {\n readonly #lc: LogContext;\n readonly #forwarder: Forwarder;\n readonly #reader: SQLiteChangeLogCatchupReader;\n readonly #batchSize: number;\n readonly #barrierTimeoutMs: number;\n readonly #barrierPollIntervalMs: number;\n readonly #cleanupGuard: SQLiteChangeLogCleanupGuard;\n readonly #sleep: typeof sleep;\n readonly #now: () => number;\n readonly #barrierTimeouts = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_timeouts',\n 'SQLite change-log catchups that timed out waiting for the required head.',\n );\n readonly #barrierBacklogOverflows = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_backlog_overflows',\n 'SQLite change-log catchups abandoned because the subscriber backlog ' +\n 'reached its high water mark while waiting for the required head.',\n );\n readonly #barrierWakeups = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_wakeups',\n 'SQLite catchup barrier waits, labeled by what ended the wait. A ' +\n 'population dominated by \"poll\" means the change-log writer\\'s commit ' +\n 'notification is not reaching the barrier.',\n );\n readonly #catchups = new Map<Subscriber, AbortController>();\n readonly #commitWaiters = new Set<CommitWaiter>();\n #closed = false;\n\n constructor(\n lc: LogContext,\n forwarder: Forwarder,\n reader: SQLiteChangeLogCatchupReader,\n opts: SQLiteChangeLogCatchupOptions,\n ) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-catchup');\n this.#forwarder = forwarder;\n this.#reader = reader;\n this.#batchSize = opts.batchSize;\n this.#barrierTimeoutMs = opts.barrierTimeoutMs;\n this.#barrierPollIntervalMs =\n opts.barrierPollIntervalMs ?? DEFAULT_BARRIER_POLL_INTERVAL_MS;\n this.#cleanupGuard = opts.cleanupGuard ?? NOOP_CLEANUP_GUARD;\n this.#sleep = opts.sleep ?? sleep;\n this.#now = opts.now ?? Date.now;\n }\n\n /**\n * Registers the subscriber before resolving. Catchup itself continues in\n * the background so subscribe() does not wait for SQLite to reach the\n * required head.\n */\n async catchup(\n subscriber: Subscriber,\n captureRequiredHead: () => string | Promise<string>,\n ): Promise<void> {\n if (this.#closed) {\n subscriber.fail(new AbortError('SQLite change-log catchup is closed'));\n return;\n }\n\n const abort = new AbortController();\n this.#catchups.set(subscriber, abort);\n let requiredHead: string | Promise<string> | undefined;\n try {\n await this.#cleanupGuard.runWhilePurgeBlocked(() => {\n this.#throwIfAborted(abort.signal);\n requiredHead = captureRequiredHead();\n this.#forwarder.add(subscriber);\n });\n } catch (error) {\n this.#catchups.delete(subscriber);\n if (!abort.signal.aborted) {\n this.#lc.error?.(\n `error while registering SQLite catchup subscriber ${subscriber.id}`,\n error,\n );\n subscriber.fail(error);\n }\n return;\n }\n\n void this.#run(subscriber, requiredHead as string | Promise<string>, abort);\n }\n\n /**\n * Notes that the change-log writer has committed `watermark`, i.e. that the\n * log's head has advanced to it. The writer runs in this process, so this is\n * the event itself rather than a subscriber's ACK of it.\n *\n * It is a wakeup, not an authority: `plan()` still decides what can be read.\n * A notification that never arrives costs the barrier a poll interval, and one\n * that arrives early costs an extra `plan()` call.\n */\n onChangeLogCommit(watermark: string): void {\n for (const waiter of this.#commitWaiters) {\n if (watermark >= waiter.watermark) {\n this.#commitWaiters.delete(waiter);\n waiter.resolve();\n }\n }\n }\n\n remove(subscriber: Subscriber): void {\n this.#catchups.get(subscriber)?.abort();\n this.#catchups.delete(subscriber);\n this.#forwarder.remove(subscriber);\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n for (const [subscriber, abort] of this.#catchups) {\n abort.abort();\n this.#forwarder.remove(subscriber);\n }\n this.#catchups.clear();\n this.#reader.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n async #run(\n subscriber: Subscriber,\n requiredHead: string | Promise<string>,\n abort: AbortController,\n ): Promise<void> {\n const {signal} = abort;\n try {\n const requiredHeadStart = this.#now();\n const required = await this.#awaitRequiredHead(requiredHead, signal);\n const requiredHeadWaitMs = this.#now() - requiredHeadStart;\n if (requiredHeadWaitMs > this.#barrierTimeoutMs) {\n this.#lc.info?.(\n `waited ${requiredHeadWaitMs} ms for the forwarded transaction to ` +\n `commit before starting the SQLite barrier for ${subscriber.id}`,\n );\n }\n // The barrier bounds replica lag, not upstream transaction duration, so\n // its deadline starts once the required head is known. Sharing one\n // deadline with the wait above would fail subscribers that registered\n // during a transaction longer than barrierTimeoutMs, however current\n // the replica is.\n const deadline = this.#now() + this.#barrierTimeoutMs;\n const plan = await this.#waitForPlan(\n subscriber,\n required,\n deadline,\n signal,\n );\n this.#throwIfAborted(signal);\n\n if (plan.kind === 'too-old') {\n const message =\n `earliest supported watermark is ${plan.minWatermark} ` +\n `(requested ${subscriber.watermark})`;\n this.#lc.warn?.(\n `rejecting subscriber at watermark ${subscriber.watermark} ` +\n `(earliest watermark: ${plan.minWatermark})`,\n );\n subscriber.close(ErrorType.WatermarkTooOld, message);\n return;\n }\n\n let count = 0;\n const start = this.#now();\n if (plan.kind === 'range') {\n let lastBatchConsumed: Promise<unknown> | undefined;\n for await (const changes of this.#reader.read(\n subscriber.watermark,\n plan.headWatermark,\n this.#batchSize,\n signal,\n )) {\n const waitStart = this.#now();\n await lastBatchConsumed;\n const elapsed = this.#now() - waitStart;\n if (lastBatchConsumed) {\n this.#lc[elapsed > 100 ? 'info' : 'debug']?.(\n `waited ${elapsed.toFixed(3)} ms for ${subscriber.id} to consume ` +\n `the previous SQLite catchup batch`,\n );\n }\n this.#throwIfAborted(signal);\n for (const change of changes) {\n lastBatchConsumed = subscriber.catchup(change);\n count++;\n }\n }\n await lastBatchConsumed;\n } else {\n this.#lc.warn?.(\n `subscriber ${subscriber.id} at watermark ${subscriber.watermark} ` +\n `is ahead of the SQLite change-log head ${plan.headWatermark}; ` +\n `waiting for the replica to catch up`,\n );\n }\n\n this.#throwIfAborted(signal);\n this.#lc.info?.(\n `caught up ${subscriber.id} from SQLite with ${count} changes ` +\n `(${this.#now() - start} ms)`,\n );\n // Keep buffering live sends until the asynchronous backlog drain has\n // established its ordering boundary.\n void subscriber.setCaughtUp();\n } catch (error) {\n if (signal.aborted) {\n return;\n }\n if (error instanceof SQLiteChangeLogBarrierError) {\n if (error instanceof SQLiteChangeLogBarrierTimeoutError) {\n this.#barrierTimeouts.add(1);\n }\n // Giving up on the barrier means the replica has not caught up *yet*,\n // not that it cannot serve this subscriber. End the subscription\n // cleanly so the client reconnects and retries: IncrementalSyncer\n // treats any ['error', ...] message as terminal and restores a fresh\n // replica from litestream, whereas a clean end backs off and\n // re-subscribes. If retries keep failing until the change log is purged\n // past the subscriber's watermark, plan() returns 'too-old', which is\n // terminal by design.\n this.#lc.warn?.(\n `ending subscription for ${subscriber.id} to retry SQLite catchup`,\n error,\n );\n subscriber.close();\n return;\n }\n this.#lc.error?.(\n `error while catching up subscriber ${subscriber.id} from SQLite`,\n error,\n );\n subscriber.fail(error);\n } finally {\n if (this.#catchups.get(subscriber) === abort) {\n this.#catchups.delete(subscriber);\n }\n }\n }\n\n /**\n * Waits for the transaction that was in flight at registration to finish.\n *\n * This wait is bounded by the upstream transaction rather than by\n * `barrierTimeoutMs`: the completion settles on both commit and rollback,\n * and an interrupted change stream forwards a synthetic rollback, so it\n * always settles. Aborting -- the subscriber disconnecting or the service\n * shutting down -- is what releases it early.\n */\n async #awaitRequiredHead(\n requiredHead: string | Promise<string>,\n signal: AbortSignal,\n ): Promise<string> {\n if (typeof requiredHead === 'string') {\n return requiredHead;\n }\n this.#throwIfAborted(signal);\n let onAbort: (() => void) | undefined;\n try {\n return await Promise.race([\n requiredHead,\n new Promise<never>((_, reject) => {\n onAbort = () =>\n reject(new AbortError('SQLite change-log catchup aborted'));\n signal.addEventListener('abort', onAbort, {once: true});\n }),\n ]);\n } finally {\n if (onAbort) {\n signal.removeEventListener('abort', onAbort);\n }\n }\n }\n\n async #waitForPlan(\n subscriber: Subscriber,\n requiredHead: string,\n deadline: number,\n signal: AbortSignal,\n ): Promise<Exclude<CatchupPlan, {kind: 'not-ready'}>> {\n // Race backlog growth once around the whole polling loop. Attaching the\n // same unresolved promise to every per-poll race would retain one reaction\n // per iteration until the subscriber's backlog eventually filled.\n const backlogFull = subscriber.whenBacklogFull();\n const barrier = new AbortController();\n const abortBarrier = () => barrier.abort();\n signal.addEventListener('abort', abortBarrier, {once: true});\n if (signal.aborted) {\n barrier.abort();\n }\n\n let observedHead = subscriber.watermark;\n try {\n const planReady = (async () => {\n while (true) {\n this.#throwIfAborted(barrier.signal);\n const plan = this.#reader.plan(subscriber.watermark);\n // A not-ready log has no head to compare, so it can never satisfy\n // the required head: keep waiting for the writer to create and\n // reconcile it, and let the barrier deadline end the subscription\n // cleanly for a retry. Selection declines a not-ready log before a\n // subscriber gets here, and neither purging nor reconciliation can\n // empty a log that had content, so this is a belt-and-braces path.\n if (plan.kind !== 'not-ready') {\n observedHead = plan.headWatermark;\n if (plan.headWatermark >= requiredHead) {\n return plan;\n }\n }\n const remaining = deadline - this.#now();\n if (remaining <= 0) {\n throw new SQLiteChangeLogBarrierTimeoutError(\n `timed out waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n }\n await this.#awaitChangeLogProgress(\n requiredHead,\n remaining,\n barrier.signal,\n );\n }\n })();\n\n const backlogOverflow = backlogFull.promise.then(() => {\n this.#throwIfAborted(signal);\n // close() also resolves backlog waiters so none are stranded. The\n // backlog is cleared first, which distinguishes close from overflow.\n if (!subscriber.backlogFull) {\n return new Promise<never>(() => {});\n }\n this.#barrierWakeups.add(1, {'wakeup.source': 'backlog'});\n // Bytes, not time, are what the barrier actually costs: past the high\n // water mark this subscriber's send() no longer resolves, so it holds\n // up every flush and can stall the replica this barrier waits on.\n this.#barrierBacklogOverflows.add(1);\n throw new SQLiteChangeLogBarrierBacklogError(\n `backlog for subscriber ${subscriber.id} reached its high water ` +\n `mark while waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n });\n\n return await Promise.race([planReady, backlogOverflow]);\n } finally {\n signal.removeEventListener('abort', abortBarrier);\n barrier.abort();\n backlogFull.cancel();\n }\n }\n\n /**\n * Waits for the change-log writer to commit `requiredHead`, or the poll\n * interval as a backstop.\n *\n * No notification can be missed by registering after `plan()` was read: the\n * notification follows the commit it reports, so if the writer had already\n * committed `requiredHead`, `plan()` would have seen it.\n */\n async #awaitChangeLogProgress(\n requiredHead: string,\n remainingMs: number,\n signal: AbortSignal,\n ): Promise<void> {\n const committed = resolver<void>();\n const waiter = {watermark: requiredHead, resolve: committed.resolve};\n this.#commitWaiters.add(waiter);\n // Scopes the backstop timer to this wait so that a commit cancels it rather\n // than leaving it to fire against a barrier that has already moved on.\n const poll = new AbortController();\n const abortPoll = () => poll.abort();\n signal.addEventListener('abort', abortPoll, {once: true});\n try {\n const wokenBy = await Promise.race([\n committed.promise.then(() => 'commit' as const),\n this.#sleep(\n Math.min(this.#barrierPollIntervalMs, remainingMs),\n poll.signal,\n ).then(() => 'poll' as const),\n ]);\n this.#barrierWakeups.add(1, {'wakeup.source': wokenBy});\n } finally {\n this.#commitWaiters.delete(waiter);\n signal.removeEventListener('abort', abortPoll);\n poll.abort();\n }\n }\n\n #throwIfAborted(signal: AbortSignal): void {\n if (this.#closed || signal.aborted) {\n throw new AbortError('SQLite change-log catchup aborted');\n }\n }\n}\n\ntype CommitWaiter = {watermark: string; resolve: () => void};\n\n/**\n * A barrier that gave up on the replica reaching the required head. Both\n * reasons mean \"not yet\", not \"never\", so both end the subscription cleanly\n * for a retry rather than failing it.\n */\nexport class SQLiteChangeLogBarrierError extends Error {}\n\n/** The replica did not reach the required head before the deadline. */\nexport class SQLiteChangeLogBarrierTimeoutError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierTimeoutError';\n}\n\n/** Waiting any longer would have cost more than reconnecting. */\nexport class SQLiteChangeLogBarrierBacklogError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierBacklogError';\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sqlite-change-log-catchup.js","names":["#lc","#forwarder","#reader","#batchSize","#barrierTimeoutMs","#barrierPollIntervalMs","#cleanupGuard","#sleep","#now","#onFailure","#barrierTimeouts","#barrierBacklogOverflows","#barrierWakeups","#catchupResults","#catchupRows","#catchupBytes","#catchupDuration","#barrierWaitDuration","#catchupBacklogPeak","#catchups","#commitWaiters","#closed","#throwIfAborted","#run","#awaitRequiredHead","#waitForPlan","#awaitChangeLogProgress"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-catchup.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {resolver} from '@rocicorp/resolver';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {sleep} from '../../../../shared/src/sleep.ts';\nimport {\n getOrCreateCounter,\n getOrCreateLatencyHistogram,\n getOrCreateValueHistogram,\n} from '../../observability/metrics.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\nimport * as ErrorType from './error-type-enum.ts';\nimport type {Forwarder} from './forwarder.ts';\nimport type {CatchupPlan} from './sqlite-change-log-reader.ts';\nimport type {Subscriber} from './subscriber.ts';\n\nexport interface SQLiteChangeLogCatchupReader {\n plan(fromWatermark: string): CatchupPlan;\n read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal,\n ): AsyncIterable<readonly WatermarkedChange[]>;\n close(): void;\n}\n\n/**\n * Slice 9 replaces this no-op implementation with a guard that waits for an\n * in-flight purge and blocks new purge dispatch while the subscriber's ACK is\n * made visible to the Forwarder.\n */\nexport interface SQLiteChangeLogCleanupGuard {\n runWhilePurgeBlocked<T>(register: () => T): Promise<T>;\n}\n\n// How long the barrier waits for the change-log writer's commit notification\n// before re-reading the log itself. The notification normally arrives first, so\n// this is a backstop for the windows in which none is coming: the writer is\n// disabled, or it is between stream connections.\nconst DEFAULT_BARRIER_POLL_INTERVAL_MS = 1000;\n\nexport type SQLiteChangeLogCatchupOptions = {\n batchSize: number;\n barrierTimeoutMs: number;\n barrierPollIntervalMs?: number | undefined;\n cleanupGuard?: SQLiteChangeLogCleanupGuard | undefined;\n sleep?: typeof sleep | undefined;\n now?: (() => number) | undefined;\n /** Opens slice 11's process-local breaker after registration has committed. */\n onFailure?: ((failure: SQLiteChangeLogCatchupFailure) => void) | undefined;\n};\n\nexport type SQLiteChangeLogCatchupRequest = {\n /**\n * Whether the routing warm-up gate classified the log as warm *for this\n * subscriber*. The coordinator is opened once and reused, so this cannot be\n * a property of it: `coldReadPercent` serves some subscribers from a log\n * that is still inside its warm-up window, and the catchup metrics have to\n * separate those from steady-state serving.\n */\n readonly logWarm?: boolean | undefined;\n};\n\nexport type SQLiteChangeLogCatchupFailure = 'barrier-timeout' | 'reader-error';\n\nexport type SQLiteChangeLogCatchupRegistration =\n | {readonly kind: 'registered'}\n | {readonly kind: 'uncovered'; readonly minWatermark: string}\n // Registration failed before the subscriber was committed to SQLite, so PG\n // catchup is still available and the caller falls back to it.\n | {readonly kind: 'declined'; readonly error: unknown}\n // The coordinator closed or failed the subscriber itself.\n | {readonly kind: 'handled'};\n\nconst NOOP_CLEANUP_GUARD: SQLiteChangeLogCleanupGuard = {\n runWhilePurgeBlocked: register => Promise.resolve(register()),\n};\n\n/**\n * Coordinates a serving subscriber's gap-free transition from replica-local\n * SQLite catchup to the Forwarder's live stream.\n *\n * Registration and required-head capture happen in one synchronous callback.\n * The subscriber therefore either sees a transaction in SQLite catchup or in\n * its live backlog (duplicates across that boundary are filtered by\n * Subscriber), never in neither place.\n */\nexport class SQLiteChangeLogCatchup implements Disposable {\n readonly #lc: LogContext;\n readonly #forwarder: Forwarder;\n readonly #reader: SQLiteChangeLogCatchupReader;\n readonly #batchSize: number;\n readonly #barrierTimeoutMs: number;\n readonly #barrierPollIntervalMs: number;\n readonly #cleanupGuard: SQLiteChangeLogCleanupGuard;\n readonly #sleep: typeof sleep;\n readonly #now: () => number;\n readonly #onFailure:\n | ((failure: SQLiteChangeLogCatchupFailure) => void)\n | undefined;\n readonly #barrierTimeouts = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_timeouts',\n 'SQLite change-log catchups that timed out waiting for the required head.',\n );\n readonly #barrierBacklogOverflows = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_backlog_overflows',\n 'SQLite change-log catchups abandoned because the subscriber backlog ' +\n 'reached its high water mark while waiting for the required head.',\n );\n readonly #barrierWakeups = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.barrier_wakeups',\n 'SQLite catchup barrier waits, labeled by what ended the wait. A ' +\n 'population dominated by \"poll\" means the change-log writer\\'s commit ' +\n 'notification is not reaching the barrier.',\n );\n readonly #catchupResults = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.catchup_results',\n 'SQLite catchup subscriptions by outcome.',\n );\n readonly #catchupRows = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.catchup_rows',\n 'Rows served from the SQLite change log during catchup.',\n );\n readonly #catchupBytes = getOrCreateCounter(\n 'replication',\n 'sqlite_change_log.catchup_bytes',\n {\n description: 'Bytes served from the SQLite change log during catchup.',\n unit: 'By',\n },\n );\n readonly #catchupDuration = getOrCreateLatencyHistogram(\n 'replication',\n 'sqlite_change_log.catchup_duration',\n 'Duration of a SQLite change-log catchup.',\n );\n readonly #barrierWaitDuration = getOrCreateLatencyHistogram(\n 'replication',\n 'sqlite_change_log.barrier_wait_duration',\n 'Time SQLite catchup waits for its pinned required head.',\n );\n readonly #catchupBacklogPeak = getOrCreateValueHistogram(\n 'replication',\n 'sqlite_change_log.catchup_backlog_peak_bytes',\n {\n description:\n 'Peak live backlog bytes buffered while a subscriber catches up from SQLite.',\n unit: 'By',\n bucketBoundaries: [\n 0, 1024, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216,\n ],\n },\n );\n readonly #catchups = new Map<Subscriber, AbortController>();\n readonly #commitWaiters = new Set<CommitWaiter>();\n #closed = false;\n\n constructor(\n lc: LogContext,\n forwarder: Forwarder,\n reader: SQLiteChangeLogCatchupReader,\n opts: SQLiteChangeLogCatchupOptions,\n ) {\n this.#lc = lc.withContext('component', 'sqlite-change-log-catchup');\n this.#forwarder = forwarder;\n this.#reader = reader;\n this.#batchSize = opts.batchSize;\n this.#barrierTimeoutMs = opts.barrierTimeoutMs;\n this.#barrierPollIntervalMs =\n opts.barrierPollIntervalMs ?? DEFAULT_BARRIER_POLL_INTERVAL_MS;\n this.#cleanupGuard = opts.cleanupGuard ?? NOOP_CLEANUP_GUARD;\n this.#sleep = opts.sleep ?? sleep;\n this.#now = opts.now ?? Date.now;\n this.#onFailure = opts.onFailure;\n }\n\n /**\n * Registers the subscriber before resolving. Catchup itself continues in\n * the background so subscribe() does not wait for SQLite to reach the\n * required head.\n */\n async catchup(\n subscriber: Subscriber,\n captureRequiredHead: () => string | Promise<string>,\n request: SQLiteChangeLogCatchupRequest = {},\n ): Promise<SQLiteChangeLogCatchupRegistration> {\n if (this.#closed) {\n subscriber.fail(new AbortError('SQLite change-log catchup is closed'));\n return {kind: 'handled'};\n }\n\n const abort = new AbortController();\n this.#catchups.set(subscriber, abort);\n let requiredHead: string | Promise<string> | undefined;\n let uncoveredMinWatermark: string | undefined;\n let committed = false;\n try {\n await this.#cleanupGuard.runWhilePurgeBlocked(() => {\n this.#throwIfAborted(abort.signal);\n // The router's eligibility inspection happens before this async guard\n // is acquired. Recheck the exact boundary here: an in-flight purge can\n // have advanced the minimum while registration waited. Once added to\n // the Forwarder, the subscriber's ACK prevents any later purge from\n // crossing this boundary.\n const plan = this.#reader.plan(subscriber.watermark);\n if (plan.kind === 'too-old') {\n uncoveredMinWatermark = plan.minWatermark;\n return;\n }\n requiredHead = captureRequiredHead();\n this.#forwarder.add(subscriber);\n committed = true;\n });\n } catch (error) {\n this.#catchups.delete(subscriber);\n if (abort.signal.aborted) {\n return {kind: 'handled'};\n }\n if (!committed) {\n // Nothing was committed to SQLite -- the subscriber never reached\n // Forwarder.add() -- so PG catchup is still available. Failing here\n // would send the client a terminal ['error', ...], which\n // IncrementalSyncer answers with a full litestream replica restore,\n // for a fallback that was still open. Trip the breaker as well: this\n // is a reader failure like any other, and without it the retry picks\n // SQLite again and the reset repeats.\n this.#onFailure?.('reader-error');\n return {kind: 'declined', error};\n }\n this.#lc.error?.(\n `error while registering SQLite catchup subscriber ${subscriber.id}`,\n error,\n );\n subscriber.fail(error);\n return {kind: 'handled'};\n }\n if (uncoveredMinWatermark !== undefined) {\n this.#catchups.delete(subscriber);\n return {kind: 'uncovered', minWatermark: uncoveredMinWatermark};\n }\n\n void this.#run(\n subscriber,\n requiredHead as string | Promise<string>,\n abort,\n request.logWarm,\n );\n return {kind: 'registered'};\n }\n\n /**\n * Notes that the change-log writer has committed `watermark`, i.e. that the\n * log's head has advanced to it. The writer runs in this process, so this is\n * the event itself rather than a subscriber's ACK of it.\n *\n * It is a wakeup, not an authority: `plan()` still decides what can be read.\n * A notification that never arrives costs the barrier a poll interval, and one\n * that arrives early costs an extra `plan()` call.\n */\n onChangeLogCommit(watermark: string): void {\n for (const waiter of this.#commitWaiters) {\n if (watermark >= waiter.watermark) {\n this.#commitWaiters.delete(waiter);\n waiter.resolve();\n }\n }\n }\n\n remove(subscriber: Subscriber): void {\n this.#catchups.get(subscriber)?.abort();\n this.#catchups.delete(subscriber);\n this.#forwarder.remove(subscriber);\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n for (const [subscriber, abort] of this.#catchups) {\n abort.abort();\n this.#forwarder.remove(subscriber);\n }\n this.#catchups.clear();\n this.#reader.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n async #run(\n subscriber: Subscriber,\n requiredHead: string | Promise<string>,\n abort: AbortController,\n logWarm: boolean | undefined,\n ): Promise<void> {\n const {signal} = abort;\n const catchupStart = this.#now();\n let outcome = 'aborted';\n let backlogPeakBytes = 0;\n try {\n const requiredHeadStart = this.#now();\n const required = await this.#awaitRequiredHead(requiredHead, signal);\n const requiredHeadWaitMs = this.#now() - requiredHeadStart;\n if (requiredHeadWaitMs > this.#barrierTimeoutMs) {\n this.#lc.info?.(\n `waited ${requiredHeadWaitMs} ms for the forwarded transaction to ` +\n `commit before starting the SQLite barrier for ${subscriber.id}`,\n );\n }\n // The barrier bounds replica lag, not upstream transaction duration, so\n // its deadline starts once the required head is known. Sharing one\n // deadline with the wait above would fail subscribers that registered\n // during a transaction longer than barrierTimeoutMs, however current\n // the replica is.\n const deadline = this.#now() + this.#barrierTimeoutMs;\n const barrierStart = this.#now();\n let plan: CatchupPlan;\n try {\n plan = await this.#waitForPlan(subscriber, required, deadline, signal);\n } finally {\n // Record failed and aborted barriers too; their wait distribution is\n // at least as operationally important as the successful path.\n this.#barrierWaitDuration.recordMs(this.#now() - barrierStart);\n }\n this.#throwIfAborted(signal);\n\n if (plan.kind === 'too-old') {\n outcome = 'too-old';\n const message =\n `earliest supported watermark is ${plan.minWatermark} ` +\n `(requested ${subscriber.watermark})`;\n this.#lc.warn?.(\n `rejecting subscriber at watermark ${subscriber.watermark} ` +\n `(earliest watermark: ${plan.minWatermark})`,\n );\n subscriber.close(ErrorType.WatermarkTooOld, message);\n return;\n }\n\n let count = 0;\n let bytes = 0;\n const start = this.#now();\n if (plan.kind === 'range') {\n let lastBatchConsumed: Promise<unknown> | undefined;\n for await (const changes of this.#reader.read(\n subscriber.watermark,\n plan.headWatermark,\n this.#batchSize,\n signal,\n )) {\n const waitStart = this.#now();\n await lastBatchConsumed;\n const elapsed = this.#now() - waitStart;\n if (lastBatchConsumed) {\n this.#lc[elapsed > 100 ? 'info' : 'debug']?.(\n `waited ${elapsed.toFixed(3)} ms for ${subscriber.id} to consume ` +\n `the previous SQLite catchup batch`,\n );\n }\n this.#throwIfAborted(signal);\n for (const change of changes) {\n lastBatchConsumed = subscriber.catchup(change);\n count++;\n bytes += Buffer.byteLength(change[2]);\n }\n backlogPeakBytes = Math.max(\n backlogPeakBytes,\n subscriber.getStats().backlogBytes,\n );\n }\n await lastBatchConsumed;\n outcome = 'range';\n } else {\n outcome = 'ahead';\n this.#lc.warn?.(\n `subscriber ${subscriber.id} at watermark ${subscriber.watermark} ` +\n `is ahead of the SQLite change-log head ${plan.headWatermark}; ` +\n `waiting for the replica to catch up`,\n );\n }\n\n this.#throwIfAborted(signal);\n this.#lc.info?.(\n `caught up ${subscriber.id} from SQLite with ${count} changes ` +\n `(${this.#now() - start} ms)`,\n );\n const attributes = {\n classification: outcome,\n log_warm: logWarm ?? 'unknown',\n };\n this.#catchupRows.add(count, attributes);\n this.#catchupBytes.add(bytes, attributes);\n // Keep buffering live sends until the asynchronous backlog drain has\n // established its ordering boundary.\n void subscriber.setCaughtUp();\n } catch (error) {\n if (signal.aborted) {\n return;\n }\n if (error instanceof SQLiteChangeLogBarrierError) {\n if (error instanceof SQLiteChangeLogBarrierTimeoutError) {\n this.#barrierTimeouts.add(1);\n outcome = 'barrier-timeout';\n this.#onFailure?.('barrier-timeout');\n } else {\n // No #onFailure: a backlog high water mark is a property of this\n // one subscriber's consumption, not of the log. Tripping the\n // process-wide breaker for it would take every task in the shard\n // off SQLite, which a client that reconnects and stalls again\n // could hold open indefinitely.\n outcome = 'barrier-backlog';\n }\n // Giving up on the barrier means the replica has not caught up *yet*,\n // not that it cannot serve this subscriber. End the subscription\n // cleanly so the client reconnects and retries: IncrementalSyncer\n // treats any ['error', ...] message as terminal and restores a fresh\n // replica from litestream, whereas a clean end backs off and\n // re-subscribes. If retries keep failing until the change log is purged\n // past the subscriber's watermark, plan() returns 'too-old', which is\n // terminal by design.\n this.#lc.warn?.(\n `ending subscription for ${subscriber.id} to retry SQLite catchup`,\n error,\n );\n subscriber.close();\n return;\n }\n outcome = 'reader-error';\n this.#onFailure?.('reader-error');\n this.#lc.error?.(\n `error while catching up subscriber ${subscriber.id} from SQLite`,\n error,\n );\n subscriber.fail(error);\n } finally {\n backlogPeakBytes = Math.max(\n backlogPeakBytes,\n subscriber.getStats().backlogBytes,\n );\n const attributes = {\n classification: outcome,\n log_warm: logWarm ?? 'unknown',\n };\n this.#catchupResults.add(1, attributes);\n this.#catchupDuration.recordMs(this.#now() - catchupStart, attributes);\n this.#catchupBacklogPeak.record(backlogPeakBytes, attributes);\n if (this.#catchups.get(subscriber) === abort) {\n this.#catchups.delete(subscriber);\n }\n }\n }\n\n /**\n * Waits for the transaction that was in flight at registration to finish.\n *\n * This wait is bounded by the upstream transaction rather than by\n * `barrierTimeoutMs`: the completion settles on both commit and rollback,\n * and an interrupted change stream forwards a synthetic rollback, so it\n * always settles. Aborting -- the subscriber disconnecting or the service\n * shutting down -- is what releases it early.\n */\n async #awaitRequiredHead(\n requiredHead: string | Promise<string>,\n signal: AbortSignal,\n ): Promise<string> {\n if (typeof requiredHead === 'string') {\n return requiredHead;\n }\n this.#throwIfAborted(signal);\n let onAbort: (() => void) | undefined;\n try {\n return await Promise.race([\n requiredHead,\n new Promise<never>((_, reject) => {\n onAbort = () =>\n reject(new AbortError('SQLite change-log catchup aborted'));\n signal.addEventListener('abort', onAbort, {once: true});\n }),\n ]);\n } finally {\n if (onAbort) {\n signal.removeEventListener('abort', onAbort);\n }\n }\n }\n\n async #waitForPlan(\n subscriber: Subscriber,\n requiredHead: string,\n deadline: number,\n signal: AbortSignal,\n ): Promise<Exclude<CatchupPlan, {kind: 'not-ready'}>> {\n // Race backlog growth once around the whole polling loop. Attaching the\n // same unresolved promise to every per-poll race would retain one reaction\n // per iteration until the subscriber's backlog eventually filled.\n const backlogFull = subscriber.whenBacklogFull();\n const barrier = new AbortController();\n const abortBarrier = () => barrier.abort();\n signal.addEventListener('abort', abortBarrier, {once: true});\n if (signal.aborted) {\n barrier.abort();\n }\n\n let observedHead = subscriber.watermark;\n try {\n const planReady = (async () => {\n while (true) {\n this.#throwIfAborted(barrier.signal);\n const plan = this.#reader.plan(subscriber.watermark);\n // A not-ready log has no head to compare, so it can never satisfy\n // the required head: keep waiting for the writer to create and\n // reconcile it, and let the barrier deadline end the subscription\n // cleanly for a retry. Selection declines a not-ready log before a\n // subscriber gets here, and neither purging nor reconciliation can\n // empty a log that had content, so this is a belt-and-braces path.\n if (plan.kind !== 'not-ready') {\n observedHead = plan.headWatermark;\n if (plan.headWatermark >= requiredHead) {\n return plan;\n }\n }\n const remaining = deadline - this.#now();\n if (remaining <= 0) {\n throw new SQLiteChangeLogBarrierTimeoutError(\n `timed out waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n }\n await this.#awaitChangeLogProgress(\n requiredHead,\n remaining,\n barrier.signal,\n );\n }\n })();\n\n const backlogOverflow = backlogFull.promise.then(() => {\n this.#throwIfAborted(signal);\n // close() also resolves backlog waiters so none are stranded. The\n // backlog is cleared first, which distinguishes close from overflow.\n if (!subscriber.backlogFull) {\n return new Promise<never>(() => {});\n }\n this.#barrierWakeups.add(1, {'wakeup.source': 'backlog'});\n // Bytes, not time, are what the barrier actually costs: past the high\n // water mark this subscriber's send() no longer resolves, so it holds\n // up every flush and can stall the replica this barrier waits on.\n this.#barrierBacklogOverflows.add(1);\n throw new SQLiteChangeLogBarrierBacklogError(\n `backlog for subscriber ${subscriber.id} reached its high water ` +\n `mark while waiting for SQLite head ${observedHead} to ` +\n `reach required head ${requiredHead}`,\n );\n });\n\n return await Promise.race([planReady, backlogOverflow]);\n } finally {\n signal.removeEventListener('abort', abortBarrier);\n barrier.abort();\n backlogFull.cancel();\n }\n }\n\n /**\n * Waits for the change-log writer to commit `requiredHead`, or the poll\n * interval as a backstop.\n *\n * No notification can be missed by registering after `plan()` was read: the\n * notification follows the commit it reports, so if the writer had already\n * committed `requiredHead`, `plan()` would have seen it.\n */\n async #awaitChangeLogProgress(\n requiredHead: string,\n remainingMs: number,\n signal: AbortSignal,\n ): Promise<void> {\n const committed = resolver<void>();\n const waiter = {watermark: requiredHead, resolve: committed.resolve};\n this.#commitWaiters.add(waiter);\n // Scopes the backstop timer to this wait so that a commit cancels it rather\n // than leaving it to fire against a barrier that has already moved on.\n const poll = new AbortController();\n const abortPoll = () => poll.abort();\n signal.addEventListener('abort', abortPoll, {once: true});\n try {\n const wokenBy = await Promise.race([\n committed.promise.then(() => 'commit' as const),\n this.#sleep(\n Math.min(this.#barrierPollIntervalMs, remainingMs),\n poll.signal,\n ).then(() => 'poll' as const),\n ]);\n this.#barrierWakeups.add(1, {'wakeup.source': wokenBy});\n } finally {\n this.#commitWaiters.delete(waiter);\n signal.removeEventListener('abort', abortPoll);\n poll.abort();\n }\n }\n\n #throwIfAborted(signal: AbortSignal): void {\n if (this.#closed || signal.aborted) {\n throw new AbortError('SQLite change-log catchup aborted');\n }\n }\n}\n\ntype CommitWaiter = {watermark: string; resolve: () => void};\n\n/**\n * A barrier that gave up on the replica reaching the required head. Both\n * reasons mean \"not yet\", not \"never\", so both end the subscription cleanly\n * for a retry rather than failing it.\n */\nexport class SQLiteChangeLogBarrierError extends Error {}\n\n/** The replica did not reach the required head before the deadline. */\nexport class SQLiteChangeLogBarrierTimeoutError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierTimeoutError';\n}\n\n/** Waiting any longer would have cost more than reconnecting. */\nexport class SQLiteChangeLogBarrierBacklogError extends SQLiteChangeLogBarrierError {\n readonly name = 'SQLiteChangeLogBarrierBacklogError';\n}\n"],"mappings":"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"}
@@ -0,0 +1,83 @@
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
+ import { type ChangeLogEntry } from './change-log-codec.ts';
5
+ /**
6
+ * Result for one committed transaction.
7
+ *
8
+ * `mismatch` covers missing, extra, changed, or reordered rows.
9
+ * `inconclusive` means that store bounds changed during the comparison.
10
+ */
11
+ export type CompareOutcome = 'match' | 'mismatch' | 'inconclusive' | 'deferred' | 'oversized';
12
+ export type CompareSkipReason = 'stopped' | 'log-rebuilt' | 'log-unavailable' | 'ineligible-schema' | 'ineligible-identity' | 'cold-log' | 'nothing-to-compare' | 'error';
13
+ export type CompareCycleResult = {
14
+ readonly kind: 'skipped';
15
+ readonly reason: CompareSkipReason;
16
+ } | {
17
+ readonly kind: 'compared';
18
+ /** Exclusive lower bound of the compared range. */
19
+ readonly fromWatermark: string;
20
+ /** Inclusive upper bound handled. The next cycle resumes from here. */
21
+ readonly throughWatermark: string;
22
+ /** Committed transactions handled through `throughWatermark`. */
23
+ readonly transactions: number;
24
+ /** Transactions with compared catchup output. */
25
+ readonly sampled: number;
26
+ readonly matched: number;
27
+ readonly mismatched: number;
28
+ /** Suspects rejected because store bounds changed. */
29
+ readonly inconclusive: number;
30
+ /** Sampled transactions deferred to a fresh budget. */
31
+ readonly deferred: number;
32
+ /** Sampled transactions that exceed a fresh budget. */
33
+ readonly oversized: number;
34
+ };
35
+ /** Provides the Postgres side of the catchup comparison. */
36
+ export interface PGChangeLogRangeReader {
37
+ getCatchupBounds(): Promise<{
38
+ minWatermark: string | null;
39
+ lastWatermark: string;
40
+ }>;
41
+ listCommitWatermarks(afterWatermark: string, throughWatermark: string, limit: number): Promise<string[]>;
42
+ readCatchupRange(afterWatermark: string, throughWatermark: string, batchRows?: number | undefined): AsyncIterable<ChangeLogEntry[]>;
43
+ }
44
+ export type SQLiteChangeLogCompareOptions = {
45
+ /** Stable percentage of transactions selected for payload comparison. */
46
+ readonly comparePercent: number;
47
+ /** Warm-up time after a seed. The comparator skips a younger log. */
48
+ readonly retentionMs: number;
49
+ readonly intervalMs?: number | undefined;
50
+ readonly maxTransactionsPerCycle?: number | undefined;
51
+ /** Maximum catchup rows read from each store in one cycle. */
52
+ readonly maxRowsPerSourcePerCycle?: number | undefined;
53
+ /** Maximum catchup bytes read from each store in one cycle. */
54
+ readonly maxBytesPerSourcePerCycle?: number | undefined;
55
+ readonly readBatchRows?: number | undefined;
56
+ /** Clock for the warm-up period. Defaults to `Date.now`. */
57
+ readonly now?: (() => number) | undefined;
58
+ readonly setTimeoutFn?: typeof setTimeout | undefined;
59
+ readonly clearTimeoutFn?: typeof clearTimeout | undefined;
60
+ /** Yields between sampled transactions. Defaults to `setImmediate`. */
61
+ readonly yieldFn?: (() => Promise<void>) | undefined;
62
+ };
63
+ /**
64
+ * Compares SQLite and Postgres catchup output without affecting subscribers.
65
+ *
66
+ * Each cycle compares complete ranges through the lower store head.
67
+ * It checks every transaction for presence and samples payloads.
68
+ * A bounds change makes the affected result `inconclusive`.
69
+ * Postgres remains authoritative.
70
+ */
71
+ export declare class SQLiteChangeLogComparator {
72
+ #private;
73
+ constructor(lc: LogContext, shard: ShardID, changeLogFile: string, identity: ChangeLogIdentity, pg: PGChangeLogRangeReader, opts: SQLiteChangeLogCompareOptions);
74
+ /**
75
+ * Runs one cycle. Concurrent calls share the same promise.
76
+ * Errors produce `skipped: 'error'`.
77
+ */
78
+ compareOnce(): Promise<CompareCycleResult>;
79
+ stop(): void;
80
+ /** Invalidates cycles that can still read a replaced file. */
81
+ invalidate(): void;
82
+ }
83
+ //# sourceMappingURL=sqlite-change-log-comparator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-change-log-comparator.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-comparator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAMjD,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAIL,KAAK,iBAAiB,EAEvB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAEL,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AA8B/B;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GACtB,OAAO,GACP,UAAU,GACV,cAAc,GACd,UAAU,GACV,WAAW,CAAC;AAEhB,MAAM,MAAM,iBAAiB,GAEzB,SAAS,GAET,aAAa,GAEb,iBAAiB,GAEjB,mBAAmB,GAEnB,qBAAqB,GAErB,UAAU,GAEV,oBAAoB,GAEpB,OAAO,CAAC;AAEZ,MAAM,MAAM,kBAAkB,GAC1B;IAAC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAA;CAAC,GAC9D;IACE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,mDAAmD;IACnD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,uEAAuE;IACvE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,iEAAiE;IACjE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,sDAAsD;IACtD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEN,4DAA4D;AAC5D,MAAM,WAAW,sBAAsB;IACrC,gBAAgB,IAAI,OAAO,CAAC;QAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,aAAa,EAAE,MAAM,CAAC;KACvB,CAAC,CAAC;IACH,oBAAoB,CAClB,cAAc,EAAE,MAAM,EACtB,gBAAgB,EAAE,MAAM,EACxB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACrB,gBAAgB,CACd,cAAc,EAAE,MAAM,EACtB,gBAAgB,EAAE,MAAM,EACxB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,GAC7B,aAAa,CAAC,cAAc,EAAE,CAAC,CAAC;CACpC;AAED,MAAM,MAAM,6BAA6B,GAAG;IAC1C,yEAAyE;IACzE,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,qEAAqE;IACrE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtD,8DAA8D;IAC9D,QAAQ,CAAC,wBAAwB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvD,+DAA+D;IAC/D,QAAQ,CAAC,yBAAyB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxD,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C,4DAA4D;IAC5D,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,UAAU,GAAG,SAAS,CAAC;IACtD,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,YAAY,GAAG,SAAS,CAAC;IAC1D,uEAAuE;IACvE,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,CAAC;CACtD,CAAC;AAyCF;;;;;;;GAOG;AACH,qBAAa,yBAAyB;;gBAuClC,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,OAAO,EACd,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,iBAAiB,EAC3B,EAAE,EAAE,sBAAsB,EAC1B,IAAI,EAAE,6BAA6B;IAiBrC;;;OAGG;IACH,WAAW,IAAI,OAAO,CAAC,kBAAkB,CAAC;IAmB1C,IAAI,IAAI,IAAI;IAQZ,8DAA8D;IAC9D,UAAU,IAAI,IAAI;CAifnB"}