@rocicorp/zero 0.6.2024112101 → 0.7.2024112700

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 (223) hide show
  1. package/out/advanced.js +1 -1
  2. package/out/{chunk-5UY46OAF.js → chunk-C7M3BJ3Z.js} +16 -8
  3. package/out/chunk-C7M3BJ3Z.js.map +7 -0
  4. package/out/{chunk-MPEWBBGZ.js → chunk-HDEKBM3G.js} +686 -660
  5. package/out/{chunk-MPEWBBGZ.js.map → chunk-HDEKBM3G.js.map} +4 -4
  6. package/out/shared/src/expand.js +2 -0
  7. package/out/shared/src/expand.js.map +1 -0
  8. package/out/shared/src/immutable.js +2 -0
  9. package/out/shared/src/immutable.js.map +1 -0
  10. package/out/{zero-cache/src/config/config.d.ts → shared/src/options.d.ts} +3 -5
  11. package/out/shared/src/options.d.ts.map +1 -0
  12. package/out/{zero-cache/src/config/config.js → shared/src/options.js} +26 -26
  13. package/out/shared/src/options.js.map +1 -0
  14. package/out/shared/src/sorted-entries.js +6 -0
  15. package/out/shared/src/sorted-entries.js.map +1 -0
  16. package/out/shared/src/writable.js +2 -0
  17. package/out/shared/src/writable.js.map +1 -0
  18. package/out/solid.js +2 -2
  19. package/out/zero/src/build-schema.d.ts +3 -0
  20. package/out/zero/src/build-schema.d.ts.map +1 -0
  21. package/out/zero/src/build-schema.js +3 -0
  22. package/out/zero/src/build-schema.js.map +1 -0
  23. package/out/zero-cache/src/auth/load-schema.d.ts +8 -0
  24. package/out/zero-cache/src/auth/load-schema.d.ts.map +1 -0
  25. package/out/zero-cache/src/auth/load-schema.js +34 -0
  26. package/out/zero-cache/src/auth/load-schema.js.map +1 -0
  27. package/out/zero-cache/src/auth/write-authorizer.d.ts +20 -0
  28. package/out/zero-cache/src/auth/write-authorizer.d.ts.map +1 -0
  29. package/out/zero-cache/src/auth/write-authorizer.js +320 -0
  30. package/out/zero-cache/src/auth/write-authorizer.js.map +1 -0
  31. package/out/zero-cache/src/config/zero-config.d.ts +14 -4
  32. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  33. package/out/zero-cache/src/config/zero-config.js +27 -14
  34. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  35. package/out/zero-cache/src/server/main.js +7 -0
  36. package/out/zero-cache/src/server/main.js.map +1 -1
  37. package/out/zero-cache/src/server/replicator.d.ts.map +1 -1
  38. package/out/zero-cache/src/server/replicator.js +4 -3
  39. package/out/zero-cache/src/server/replicator.js.map +1 -1
  40. package/out/zero-cache/src/server/runtime.d.ts +3 -0
  41. package/out/zero-cache/src/server/runtime.d.ts.map +1 -0
  42. package/out/zero-cache/src/server/runtime.js +19 -0
  43. package/out/zero-cache/src/server/runtime.js.map +1 -0
  44. package/out/zero-cache/src/server/syncer.d.ts.map +1 -1
  45. package/out/zero-cache/src/server/syncer.js +7 -6
  46. package/out/zero-cache/src/server/syncer.js.map +1 -1
  47. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +1 -1
  48. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  49. package/out/zero-cache/src/services/mutagen/mutagen.d.ts +6 -5
  50. package/out/zero-cache/src/services/mutagen/mutagen.d.ts.map +1 -1
  51. package/out/zero-cache/src/services/mutagen/mutagen.js +19 -33
  52. package/out/zero-cache/src/services/mutagen/mutagen.js.map +1 -1
  53. package/out/zero-cache/src/services/replicator/replicator.d.ts +1 -1
  54. package/out/zero-cache/src/services/replicator/replicator.d.ts.map +1 -1
  55. package/out/zero-cache/src/services/replicator/replicator.js +2 -2
  56. package/out/zero-cache/src/services/replicator/replicator.js.map +1 -1
  57. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts +1 -1
  58. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts.map +1 -1
  59. package/out/zero-cache/src/services/view-syncer/client-handler.js +11 -8
  60. package/out/zero-cache/src/services/view-syncer/client-handler.js.map +1 -1
  61. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts +17 -8
  62. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts.map +1 -1
  63. package/out/zero-cache/src/services/view-syncer/cvr-store.js +357 -94
  64. package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
  65. package/out/zero-cache/src/services/view-syncer/cvr.d.ts +6 -9
  66. package/out/zero-cache/src/services/view-syncer/cvr.d.ts.map +1 -1
  67. package/out/zero-cache/src/services/view-syncer/cvr.js +36 -22
  68. package/out/zero-cache/src/services/view-syncer/cvr.js.map +1 -1
  69. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts.map +1 -1
  70. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js +2 -2
  71. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js.map +1 -1
  72. package/out/zero-cache/src/services/view-syncer/schema/cvr.d.ts +22 -0
  73. package/out/zero-cache/src/services/view-syncer/schema/cvr.d.ts.map +1 -1
  74. package/out/zero-cache/src/services/view-syncer/schema/cvr.js +33 -7
  75. package/out/zero-cache/src/services/view-syncer/schema/cvr.js.map +1 -1
  76. package/out/zero-cache/src/services/view-syncer/schema/init.d.ts.map +1 -1
  77. package/out/zero-cache/src/services/view-syncer/schema/init.js +44 -5
  78. package/out/zero-cache/src/services/view-syncer/schema/init.js.map +1 -1
  79. package/out/zero-cache/src/services/view-syncer/schema/types.d.ts +1 -0
  80. package/out/zero-cache/src/services/view-syncer/schema/types.d.ts.map +1 -1
  81. package/out/zero-cache/src/services/view-syncer/schema/types.js +3 -0
  82. package/out/zero-cache/src/services/view-syncer/schema/types.js.map +1 -1
  83. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts.map +1 -1
  84. package/out/zero-cache/src/services/view-syncer/snapshotter.js +5 -0
  85. package/out/zero-cache/src/services/view-syncer/snapshotter.js.map +1 -1
  86. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +1 -1
  87. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  88. package/out/zero-cache/src/services/view-syncer/view-syncer.js +69 -19
  89. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  90. package/out/zero-cache/src/types/row-key.d.ts +6 -0
  91. package/out/zero-cache/src/types/row-key.d.ts.map +1 -1
  92. package/out/zero-cache/src/types/row-key.js +16 -1
  93. package/out/zero-cache/src/types/row-key.js.map +1 -1
  94. package/out/zero-cache/src/workers/connection.d.ts +1 -1
  95. package/out/zero-cache/src/workers/connection.d.ts.map +1 -1
  96. package/out/zero-cache/src/workers/connection.js.map +1 -1
  97. package/out/zero-cache/src/workers/replicator.d.ts +3 -1
  98. package/out/zero-cache/src/workers/replicator.d.ts.map +1 -1
  99. package/out/zero-cache/src/workers/replicator.js +2 -0
  100. package/out/zero-cache/src/workers/replicator.js.map +1 -1
  101. package/out/zero-cache/src/workers/syncer.d.ts.map +1 -1
  102. package/out/zero-cache/src/workers/syncer.js +8 -3
  103. package/out/zero-cache/src/workers/syncer.js.map +1 -1
  104. package/out/zero-client/src/client/options.d.ts +37 -8
  105. package/out/zero-client/src/client/options.d.ts.map +1 -1
  106. package/out/zero-client/src/client/reload-error-handler.d.ts +16 -1
  107. package/out/zero-client/src/client/reload-error-handler.d.ts.map +1 -1
  108. package/out/zero-client/src/client/zero.d.ts +7 -55
  109. package/out/zero-client/src/client/zero.d.ts.map +1 -1
  110. package/out/zero-client/src/mod.d.ts +1 -1
  111. package/out/zero-client/src/mod.d.ts.map +1 -1
  112. package/out/zero-protocol/src/ast-hash.js +14 -0
  113. package/out/zero-protocol/src/ast-hash.js.map +1 -0
  114. package/out/zero-protocol/src/down.d.ts +4 -4
  115. package/out/zero-protocol/src/poke.d.ts +16 -8
  116. package/out/zero-protocol/src/poke.d.ts.map +1 -1
  117. package/out/zero-protocol/src/poke.js +8 -2
  118. package/out/zero-protocol/src/poke.js.map +1 -1
  119. package/out/zero-schema/src/authorization.d.ts +12 -5
  120. package/out/zero-schema/src/authorization.d.ts.map +1 -1
  121. package/out/zero-schema/src/build-schema.d.ts +14 -0
  122. package/out/zero-schema/src/build-schema.d.ts.map +1 -0
  123. package/out/zero-schema/src/build-schema.js +55 -0
  124. package/out/zero-schema/src/build-schema.js.map +1 -0
  125. package/out/zero-schema/src/compiled-authorization.d.ts +213 -294
  126. package/out/zero-schema/src/compiled-authorization.d.ts.map +1 -1
  127. package/out/zero-schema/src/compiled-authorization.js +9 -4
  128. package/out/zero-schema/src/compiled-authorization.js.map +1 -1
  129. package/out/zero-schema/src/normalize-table-schema.d.ts +2 -11
  130. package/out/zero-schema/src/normalize-table-schema.d.ts.map +1 -1
  131. package/out/zero-schema/src/normalize-table-schema.js +108 -0
  132. package/out/zero-schema/src/normalize-table-schema.js.map +1 -0
  133. package/out/zero-schema/src/normalized-schema.js +31 -0
  134. package/out/zero-schema/src/normalized-schema.js.map +1 -0
  135. package/out/zero-schema/src/schema-config.d.ts +325 -0
  136. package/out/zero-schema/src/schema-config.d.ts.map +1 -0
  137. package/out/zero-schema/src/schema-config.js +41 -0
  138. package/out/zero-schema/src/schema-config.js.map +1 -0
  139. package/out/zero-schema/src/schema.js +4 -0
  140. package/out/zero-schema/src/schema.js.map +1 -0
  141. package/out/zero-schema/src/table-schema.d.ts +6 -15
  142. package/out/zero-schema/src/table-schema.d.ts.map +1 -1
  143. package/out/zero-schema/src/table-schema.js.map +1 -1
  144. package/out/zero.js +2 -2
  145. package/out/zql/src/builder/builder.d.ts +2 -2
  146. package/out/zql/src/builder/builder.d.ts.map +1 -1
  147. package/out/zql/src/builder/builder.js +19 -20
  148. package/out/zql/src/builder/builder.js.map +1 -1
  149. package/out/zql/src/builder/filter.d.ts +25 -2
  150. package/out/zql/src/builder/filter.d.ts.map +1 -1
  151. package/out/zql/src/builder/filter.js +91 -1
  152. package/out/zql/src/builder/filter.js.map +1 -1
  153. package/out/zql/src/ivm/array-view.js +70 -0
  154. package/out/zql/src/ivm/array-view.js.map +1 -0
  155. package/out/zql/src/ivm/change.d.ts +18 -6
  156. package/out/zql/src/ivm/change.d.ts.map +1 -1
  157. package/out/zql/src/ivm/change.js +1 -1
  158. package/out/zql/src/ivm/change.js.map +1 -1
  159. package/out/zql/src/ivm/constraint.d.ts +14 -0
  160. package/out/zql/src/ivm/constraint.d.ts.map +1 -0
  161. package/out/zql/src/ivm/constraint.js +60 -0
  162. package/out/zql/src/ivm/constraint.js.map +1 -0
  163. package/out/zql/src/ivm/exists.d.ts.map +1 -1
  164. package/out/zql/src/ivm/exists.js +19 -2
  165. package/out/zql/src/ivm/exists.js.map +1 -1
  166. package/out/zql/src/ivm/join.d.ts +11 -5
  167. package/out/zql/src/ivm/join.d.ts.map +1 -1
  168. package/out/zql/src/ivm/join.js +49 -95
  169. package/out/zql/src/ivm/join.js.map +1 -1
  170. package/out/zql/src/ivm/maybe-split-and-push-edit-change.d.ts.map +1 -1
  171. package/out/zql/src/ivm/maybe-split-and-push-edit-change.js +4 -13
  172. package/out/zql/src/ivm/maybe-split-and-push-edit-change.js.map +1 -1
  173. package/out/zql/src/ivm/memory-source.d.ts +5 -22
  174. package/out/zql/src/ivm/memory-source.d.ts.map +1 -1
  175. package/out/zql/src/ivm/memory-source.js +58 -80
  176. package/out/zql/src/ivm/memory-source.js.map +1 -1
  177. package/out/zql/src/ivm/operator.d.ts +7 -10
  178. package/out/zql/src/ivm/operator.d.ts.map +1 -1
  179. package/out/zql/src/ivm/operator.js +1 -1
  180. package/out/zql/src/ivm/operator.js.map +1 -1
  181. package/out/zql/src/ivm/take.d.ts +3 -1
  182. package/out/zql/src/ivm/take.d.ts.map +1 -1
  183. package/out/zql/src/ivm/take.js +95 -95
  184. package/out/zql/src/ivm/take.js.map +1 -1
  185. package/out/zql/src/ivm/view-apply-change.d.ts.map +1 -1
  186. package/out/zql/src/ivm/view-apply-change.js +168 -0
  187. package/out/zql/src/ivm/view-apply-change.js.map +1 -0
  188. package/out/zql/src/ivm/view.js +2 -0
  189. package/out/zql/src/ivm/view.js.map +1 -0
  190. package/out/zql/src/query/auth-query.d.ts +3 -1
  191. package/out/zql/src/query/auth-query.d.ts.map +1 -1
  192. package/out/zql/src/query/auth-query.js +34 -0
  193. package/out/zql/src/query/auth-query.js.map +1 -0
  194. package/out/zql/src/query/dnf.js +57 -0
  195. package/out/zql/src/query/dnf.js.map +1 -0
  196. package/out/zql/src/query/expression.js +155 -0
  197. package/out/zql/src/query/expression.js.map +1 -0
  198. package/out/zql/src/query/query-impl.d.ts +4 -1
  199. package/out/zql/src/query/query-impl.d.ts.map +1 -1
  200. package/out/zql/src/query/query-impl.js +359 -0
  201. package/out/zql/src/query/query-impl.js.map +1 -0
  202. package/out/zql/src/query/query-internal.js +2 -0
  203. package/out/zql/src/query/query-internal.js.map +1 -0
  204. package/out/zql/src/query/query.js +3 -0
  205. package/out/zql/src/query/query.js.map +1 -0
  206. package/out/zql/src/query/typed-view.js +2 -0
  207. package/out/zql/src/query/typed-view.js.map +1 -0
  208. package/out/zqlite/src/table-source.d.ts +2 -11
  209. package/out/zqlite/src/table-source.d.ts.map +1 -1
  210. package/out/zqlite/src/table-source.js +28 -80
  211. package/out/zqlite/src/table-source.js.map +1 -1
  212. package/package.json +5 -3
  213. package/out/chunk-5UY46OAF.js.map +0 -7
  214. package/out/zero-cache/src/auth/load-authorization.d.ts +0 -4
  215. package/out/zero-cache/src/auth/load-authorization.d.ts.map +0 -1
  216. package/out/zero-cache/src/auth/load-authorization.js +0 -20
  217. package/out/zero-cache/src/auth/load-authorization.js.map +0 -1
  218. package/out/zero-cache/src/config/config.d.ts.map +0 -1
  219. package/out/zero-cache/src/config/config.js.map +0 -1
  220. package/out/zero-cache/src/services/mutagen/write-authorizer.d.ts +0 -21
  221. package/out/zero-cache/src/services/mutagen/write-authorizer.d.ts.map +0 -1
  222. package/out/zero-cache/src/services/mutagen/write-authorizer.js +0 -168
  223. package/out/zero-cache/src/services/mutagen/write-authorizer.js.map +0 -1
@@ -3,25 +3,96 @@ import { assert } from '../../../../shared/src/asserts.js';
3
3
  import { CustomKeyMap } from '../../../../shared/src/custom-key-map.js';
4
4
  import { deepEqual, } from '../../../../shared/src/json.js';
5
5
  import { must } from '../../../../shared/src/must.js';
6
+ import { promiseVoid } from '../../../../shared/src/resolved-promises.js';
7
+ import { sleep } from '../../../../shared/src/sleep.js';
6
8
  import { astSchema } from '../../../../zero-protocol/src/ast.js';
7
- import { versionToLexi } from '../../types/lexi-version.js';
8
- import { rowIDHash } from '../../types/row-key.js';
9
+ import { ErrorKind } from '../../../../zero-protocol/src/error.js';
10
+ import { ErrorForClient } from '../../types/error-for-client.js';
11
+ import { rowIDString } from '../../types/row-key.js';
9
12
  import { rowRecordToRowsRow, rowsRowToRowRecord, } from './schema/cvr.js';
10
- import { cmpVersions, versionFromString, versionString, } from './schema/types.js';
13
+ import { cmpVersions, EMPTY_CVR_VERSION, versionFromString, versionString, versionToNullableCookie, } from './schema/types.js';
14
+ /**
15
+ * The RowRecordCache is an in-memory cache of the `cvr.rows` tables that
16
+ * operates as both a write-through and write-back cache.
17
+ *
18
+ * For "small" CVR updates (i.e. zero or small numbers of rows) the
19
+ * RowRecordCache operates as write-through, executing commits in
20
+ * {@link executeRowUpdates()} before they are {@link apply}-ed to the
21
+ * in-memory state.
22
+ *
23
+ * For "large" CVR updates (i.e. with many rows), the cache switches to a
24
+ * write-back mode of operation, in which {@link executeRowUpdates()} is a
25
+ * no-op, and {@link apply()} initiates a background task to flush the pending
26
+ * row changes to the store. This allows the client poke to be completed and
27
+ * committed on the client without waiting for the heavyweight operation of
28
+ * committing the row records to the CVR store.
29
+ *
30
+ * Note that when the cache is in write-back mode, all updates become
31
+ * write-back (i.e. asynchronously flushed) until the pending update queue is
32
+ * fully flushed. This is required because updates must be applied in version
33
+ * order. As with all pending work systems in zero-cache, multiple pending
34
+ * updates are coalesced to reduce buildup of work.
35
+ *
36
+ * ### High level consistency
37
+ *
38
+ * Note that the above caching scheme only applies to the row data in `cvr.rows`
39
+ * and corresponding `cvr.rowsVersion` tables. CVR metadata and query
40
+ * information, on the other hand, are always committed before completing the
41
+ * client poke. In this manner, the difference between the `version` column in
42
+ * `cvr.instances` and the analogous column in `cvr.rowsVersion` determines
43
+ * whether the data in the store is consistent, or whether it is awaiting a
44
+ * pending update.
45
+ *
46
+ * The logic in {@link CVRStore.load()} takes this into account by loading both
47
+ * the `cvr.instances` version and the `cvr.rowsVersion` version and checking
48
+ * if they are in sync, waiting for a configurable delay until they are.
49
+ *
50
+ * ### Eventual conversion
51
+ *
52
+ * In the event of a continual stream of mutations (e.g. an animation-style
53
+ * app), it is conceivable that the row record data be continually behind
54
+ * the CVR metadata. In order to effect eventual convergence, a new view-syncer
55
+ * signals the current view-syncer to stop updating by writing new `owner`
56
+ * information to the `cvr.instances` row. This effectively stops the mutation
57
+ * processing (in {@link CVRStore.#checkVersionAndOwnership}) so that the row
58
+ * data can eventually catch up, allowing the new view-syncer to take over.
59
+ *
60
+ * Of course, there is the pathological situation in which a view-syncer
61
+ * process crashes before the pending row updates are flushed. In this case,
62
+ * the wait timeout will elapse and the CVR considered invalid.
63
+ */
11
64
  class RowRecordCache {
65
+ // The state in the #cache is always in sync with the CVR metadata
66
+ // (i.e. cvr.instances). It may contain information that has not yet
67
+ // been flushed to cvr.rows.
12
68
  #cache;
69
+ #lc;
13
70
  #db;
14
71
  #cvrID;
15
- constructor(db, cvrID) {
72
+ #failService;
73
+ #deferredRowFlushThreshold;
74
+ #setTimeout;
75
+ // Write-back cache state.
76
+ #pending = new CustomKeyMap(rowIDString);
77
+ #pendingRowsVersion = null;
78
+ #flushing = null;
79
+ constructor(lc, db, cvrID, failService, deferredRowFlushThreshold = 100, setTimeoutFn = setTimeout) {
80
+ this.#lc = lc;
16
81
  this.#db = db;
17
82
  this.#cvrID = cvrID;
83
+ this.#failService = failService;
84
+ this.#deferredRowFlushThreshold = deferredRowFlushThreshold;
85
+ this.#setTimeout = setTimeoutFn;
18
86
  }
19
87
  async #ensureLoaded() {
20
88
  if (this.#cache) {
21
89
  return this.#cache;
22
90
  }
23
91
  const r = resolver();
24
- const cache = new CustomKeyMap(rowIDHash);
92
+ // Set this.#cache immediately (before await) so that only one db
93
+ // query is made even if there are multiple callers.
94
+ this.#cache = r.promise;
95
+ const cache = new CustomKeyMap(rowIDString);
25
96
  for await (const rows of this.#db `SELECT * FROM cvr.rows WHERE "clientGroupID" = ${this.#cvrID} AND "refCounts" IS NOT NULL`
26
97
  // TODO(arv): Arbitrary page size
27
98
  .cursor(5000)) {
@@ -31,13 +102,27 @@ class RowRecordCache {
31
102
  }
32
103
  }
33
104
  r.resolve(cache);
34
- this.#cache = r.promise;
35
105
  return this.#cache;
36
106
  }
37
107
  getRowRecords() {
38
108
  return this.#ensureLoaded();
39
109
  }
40
- async flush(rowRecords) {
110
+ /**
111
+ * Applies the `rowRecords` corresponding to the `rowsVersion`
112
+ * to the cache, indicating whether the corresponding updates
113
+ * (generated by {@link executeRowUpdates}) were `flushed`.
114
+ *
115
+ * If `flushed` is false, the RowRecordCache will flush the records
116
+ * asynchronously.
117
+ *
118
+ * Note that `apply()` indicates that the CVR metadata associated with
119
+ * the `rowRecords` was successfully committed, which essentially means
120
+ * that this process has the unconditional right (and responsibility) of
121
+ * following up with a flush of the `rowRecords`. In particular, the
122
+ * commit of row records are not conditioned on the version or ownership
123
+ * columns of the `cvr.instances` row.
124
+ */
125
+ async apply(rowRecords, rowsVersion, flushed) {
41
126
  const cache = await this.#ensureLoaded();
42
127
  for (const row of rowRecords) {
43
128
  if (row.refCounts === null) {
@@ -46,11 +131,114 @@ class RowRecordCache {
46
131
  else {
47
132
  cache.set(row.id, row);
48
133
  }
134
+ if (!flushed) {
135
+ this.#pending.set(row.id, row);
136
+ }
137
+ }
138
+ this.#pendingRowsVersion = rowsVersion;
139
+ // Initiate a flush if not already flushing.
140
+ if (!flushed && this.#flushing === null) {
141
+ this.#flushing = resolver();
142
+ this.#setTimeout(() => this.#flush(), 0);
49
143
  }
50
144
  }
145
+ async #flush() {
146
+ const flushing = must(this.#flushing);
147
+ try {
148
+ while (this.#pending.size) {
149
+ const start = Date.now();
150
+ const { rows, rowsVersion } = await this.#db.begin(tx => {
151
+ // Note: This code block is synchronous, guaranteeing that the
152
+ // #pendingRowsVersion is consistent with the #pending rows.
153
+ const rows = this.#pending.size;
154
+ const rowsVersion = must(this.#pendingRowsVersion);
155
+ this.executeRowUpdates(tx, rowsVersion, [...this.#pending.values()], 'force');
156
+ this.#pending.clear();
157
+ return { rows, rowsVersion };
158
+ });
159
+ this.#lc.debug?.(`flushed ${rows} rows to ${versionString(rowsVersion)} (${Date.now() - start} ms)`);
160
+ // Note: apply() may have called while the transaction was committing,
161
+ // which will result in looping to commit the next #pending batch.
162
+ }
163
+ this.#lc.debug?.(`pending rows flushed to ${versionToNullableCookie(this.#pendingRowsVersion)}`);
164
+ flushing.resolve();
165
+ this.#flushing = null;
166
+ }
167
+ catch (e) {
168
+ flushing.reject(e);
169
+ this.#failService(e);
170
+ }
171
+ }
172
+ /**
173
+ * Returns a promise that resolves when all outstanding row-records
174
+ * have been committed.
175
+ */
176
+ flushed() {
177
+ return this.#flushing ? this.#flushing.promise : promiseVoid;
178
+ }
51
179
  clear() {
180
+ // Note: Only the #cache is cleared. #pending updates, on the other hand,
181
+ // comprise canonical (i.e. already flushed) data and must be flushed
182
+ // even if the snapshot of the present state (the #cache) is cleared.
52
183
  this.#cache = undefined;
53
184
  }
185
+ async *catchupRowPatches(lc, afterVersion, upToCVR, excludeQueryHashes = []) {
186
+ if (cmpVersions(afterVersion, upToCVR.version) >= 0) {
187
+ return;
188
+ }
189
+ const startMs = Date.now();
190
+ const sql = this.#db;
191
+ const start = afterVersion ? versionString(afterVersion) : '';
192
+ const end = versionString(upToCVR.version);
193
+ lc.debug?.(`scanning row patches for clients from ${start}`);
194
+ // Before accessing the CVR db, pending row records must be flushed.
195
+ // Note that because catchupRowPatches() is called from within the
196
+ // view syncer lock, this flush is guaranteed to complete since no
197
+ // new CVR updates can happen while the lock is held.
198
+ await this.flushed();
199
+ const flushMs = Date.now() - startMs;
200
+ const query = excludeQueryHashes.length === 0
201
+ ? sql `SELECT * FROM cvr.rows
202
+ WHERE "clientGroupID" = ${this.#cvrID}
203
+ AND "patchVersion" > ${start}
204
+ AND "patchVersion" <= ${end}`
205
+ : // Exclude rows that were already sent as part of query hydration.
206
+ sql `SELECT * FROM cvr.rows
207
+ WHERE "clientGroupID" = ${this.#cvrID}
208
+ AND "patchVersion" > ${start}
209
+ AND "patchVersion" <= ${end}
210
+ AND ("refCounts" IS NULL OR NOT "refCounts" ?| ${excludeQueryHashes})`;
211
+ yield* query.cursor(10000);
212
+ const totalMs = Date.now() - startMs;
213
+ lc.debug?.(`finished row catchup (flush: ${flushMs} ms, total: ${totalMs} ms)`);
214
+ }
215
+ executeRowUpdates(tx, version, rowRecordsToFlush, mode) {
216
+ if (mode === 'allow-defer' && // defer if there are pending updates or
217
+ (this.#pending.size > 0 || // the new batch is above the limit.
218
+ rowRecordsToFlush.length > this.#deferredRowFlushThreshold)) {
219
+ return [];
220
+ }
221
+ const rowRecordRows = rowRecordsToFlush.map(r => rowRecordToRowsRow(this.#cvrID, r));
222
+ const rowsVersion = {
223
+ clientGroupID: this.#cvrID,
224
+ version: versionString(version),
225
+ };
226
+ const pending = [
227
+ tx `INSERT INTO cvr."rowsVersion" ${tx(rowsVersion)}
228
+ ON CONFLICT ("clientGroupID")
229
+ DO UPDATE SET ${tx(rowsVersion)}`.execute(),
230
+ ];
231
+ let i = 0;
232
+ while (i < rowRecordRows.length) {
233
+ pending.push(tx `INSERT INTO cvr.rows ${tx(rowRecordRows.slice(i, i + ROW_RECORD_UPSERT_BATCH_SIZE))}
234
+ ON CONFLICT ("clientGroupID", "schema", "table", "rowKey")
235
+ DO UPDATE SET "rowVersion" = excluded."rowVersion",
236
+ "patchVersion" = excluded."patchVersion",
237
+ "refCounts" = excluded."refCounts"`.execute());
238
+ i += ROW_RECORD_UPSERT_BATCH_SIZE;
239
+ }
240
+ return pending;
241
+ }
54
242
  }
55
243
  function asQuery(row) {
56
244
  const ast = astSchema.parse(row.clientAST);
@@ -72,56 +260,120 @@ function asQuery(row) {
72
260
  transformationVersion: maybeVersion(row.transformationVersion),
73
261
  };
74
262
  }
263
+ // The time to wait between load attempts.
264
+ const LOAD_ATTEMPT_INTERVAL_MS = 500;
265
+ // The maximum number of load() attempts if the rowsVersion is behind.
266
+ // This currently results in a maximum catchup time of ~5 seconds, after
267
+ // which we give up and consider the CVR invalid.
268
+ //
269
+ // TODO: Make this configurable with something like --max-catchup-wait-ms,
270
+ // as it is technically application specific.
271
+ const MAX_LOAD_ATTEMPTS = 10;
75
272
  export class CVRStore {
76
273
  #lc;
274
+ #taskID;
77
275
  #id;
78
276
  #db;
79
277
  #writes = new Set();
80
- #pendingRowRecordPuts = new CustomKeyMap(rowIDHash);
278
+ #pendingRowRecordPuts = new CustomKeyMap(rowIDString);
81
279
  #rowCache;
82
- constructor(lc, db, cvrID) {
280
+ #loadAttemptIntervalMs;
281
+ #maxLoadAttempts;
282
+ constructor(lc, db, taskID, cvrID, failService, loadAttemptIntervalMs = LOAD_ATTEMPT_INTERVAL_MS, maxLoadAttempts = MAX_LOAD_ATTEMPTS, deferredRowFlushThreshold = 100, // somewhat arbitrary
283
+ setTimeoutFn = setTimeout) {
83
284
  this.#lc = lc;
84
285
  this.#db = db;
286
+ this.#taskID = taskID;
85
287
  this.#id = cvrID;
86
- this.#rowCache = new RowRecordCache(db, cvrID);
288
+ this.#rowCache = new RowRecordCache(lc, db, cvrID, failService, deferredRowFlushThreshold, setTimeoutFn);
289
+ this.#loadAttemptIntervalMs = loadAttemptIntervalMs;
290
+ this.#maxLoadAttempts = maxLoadAttempts;
87
291
  }
88
- async load() {
292
+ async load(lastConnectTime) {
293
+ let err;
294
+ for (let i = 0; i < this.#maxLoadAttempts; i++) {
295
+ if (i > 0) {
296
+ await sleep(this.#loadAttemptIntervalMs);
297
+ }
298
+ const result = await this.#load(lastConnectTime);
299
+ if (result instanceof RowsVersionBehindError) {
300
+ this.#lc.info?.(`attempt ${i + 1}: ${String(result)}`);
301
+ err = result;
302
+ continue;
303
+ }
304
+ return result;
305
+ }
306
+ assert(err);
307
+ throw new ErrorForClient([
308
+ 'error',
309
+ ErrorKind.ClientNotFound,
310
+ `max attempts exceeded waiting for CVR@${err.cvrVersion} to catch up from ${err.rowsVersion}`,
311
+ ]);
312
+ }
313
+ async #load(lastConnectTime) {
89
314
  const start = Date.now();
90
315
  const id = this.#id;
91
316
  const cvr = {
92
317
  id,
93
- version: { stateVersion: versionToLexi(0) },
318
+ version: EMPTY_CVR_VERSION,
94
319
  lastActive: 0,
95
320
  replicaVersion: null,
96
321
  clients: {},
97
322
  queries: {},
98
323
  };
99
324
  const [instance, clientsRows, queryRows, desiresRows] = await this.#db.begin(tx => [
100
- tx `SELECT "version", "lastActive", "replicaVersion" FROM cvr.instances WHERE "clientGroupID" = ${id}`,
325
+ tx `SELECT cvr."version",
326
+ "lastActive",
327
+ "replicaVersion",
328
+ "owner",
329
+ "grantedAt",
330
+ rows."version" as "rowsVersion"
331
+ FROM cvr.instances AS cvr
332
+ LEFT JOIN cvr."rowsVersion" AS rows
333
+ ON cvr."clientGroupID" = rows."clientGroupID"
334
+ WHERE cvr."clientGroupID" = ${id}`,
101
335
  tx `SELECT "clientID", "patchVersion" FROM cvr.clients WHERE "clientGroupID" = ${id}`,
102
336
  tx `SELECT * FROM cvr.queries WHERE "clientGroupID" = ${id} AND (deleted IS NULL OR deleted = FALSE)`,
103
337
  tx `SELECT * FROM cvr.desires WHERE "clientGroupID" = ${id} AND (deleted IS NULL OR deleted = FALSE)`,
104
338
  ]);
105
- if (instance.length !== 0) {
106
- assert(instance.length === 1);
107
- const { version, lastActive, replicaVersion } = instance[0];
108
- cvr.version = versionFromString(version);
109
- cvr.lastActive = lastActive;
110
- cvr.replicaVersion = replicaVersion;
111
- }
112
- else {
339
+ if (instance.length === 0) {
113
340
  // This is the first time we see this CVR.
114
- const change = {
115
- clientGroupID: id,
116
- version: versionString(cvr.version),
341
+ this.putInstance({
342
+ version: cvr.version,
117
343
  lastActive: 0,
118
344
  replicaVersion: null,
119
- };
120
- this.#writes.add({
121
- stats: { instances: 1 },
122
- write: tx => tx `INSERT INTO cvr.instances ${tx(change)}`,
123
345
  });
124
346
  }
347
+ else {
348
+ assert(instance.length === 1);
349
+ const { version, lastActive, replicaVersion, owner, grantedAt, rowsVersion, } = instance[0];
350
+ if (owner !== this.#taskID) {
351
+ if ((grantedAt ?? 0) > lastConnectTime) {
352
+ throw new OwnershipError(owner, grantedAt);
353
+ }
354
+ else {
355
+ // Fire-and-forget an ownership change to signal the current owner.
356
+ // Note that the query is structured such that it only succeeds in the
357
+ // correct conditions (i.e. gated on `grantedAt`).
358
+ void this.#db `
359
+ UPDATE cvr.instances SET "owner" = ${this.#taskID},
360
+ "grantedAt" = ${lastConnectTime}
361
+ WHERE "clientGroupID" = ${this.#id} AND
362
+ ("grantedAt" IS NULL OR
363
+ "grantedAt" <= to_timestamp(${lastConnectTime / 1000}))
364
+ `.execute();
365
+ }
366
+ }
367
+ if (version !== (rowsVersion ?? EMPTY_CVR_VERSION.stateVersion)) {
368
+ // This will cause the load() method to wait for row catchup and retry.
369
+ // Assuming the ownership signal succeeds, the current owner will stop
370
+ // modifying the CVR and flush its pending row changes.
371
+ return new RowsVersionBehindError(version, rowsVersion);
372
+ }
373
+ cvr.version = versionFromString(version);
374
+ cvr.lastActive = lastActive;
375
+ cvr.replicaVersion = replicaVersion;
376
+ }
125
377
  for (const row of clientsRows) {
126
378
  const version = versionFromString(row.patchVersion);
127
379
  cvr.clients[row.clientID] = {
@@ -156,15 +408,21 @@ export class CVRStore {
156
408
  this.#pendingRowRecordPuts.set(row.id, row);
157
409
  }
158
410
  putInstance({ version, replicaVersion, lastActive, }) {
159
- const change = {
160
- clientGroupID: this.#id,
161
- version: versionString(version),
162
- lastActive,
163
- replicaVersion,
164
- };
165
411
  this.#writes.add({
166
412
  stats: { instances: 1 },
167
- write: tx => tx `INSERT INTO cvr.instances ${tx(change)} ON CONFLICT ("clientGroupID") DO UPDATE SET ${tx(change)}`,
413
+ write: (tx, lastConnectTime) => {
414
+ const change = {
415
+ clientGroupID: this.#id,
416
+ version: versionString(version),
417
+ lastActive,
418
+ replicaVersion,
419
+ owner: this.#taskID,
420
+ grantedAt: lastConnectTime,
421
+ };
422
+ return tx `
423
+ INSERT INTO cvr.instances ${tx(change)}
424
+ ON CONFLICT ("clientGroupID") DO UPDATE SET ${tx(change)}`;
425
+ },
168
426
  });
169
427
  }
170
428
  markQueryAsDeleted(version, queryPatch) {
@@ -263,45 +521,18 @@ export class CVRStore {
263
521
  `,
264
522
  });
265
523
  }
266
- delDesiredQuery(oldPutVersion, query, client) {
267
- this.#writes.add({
268
- stats: { desires: 1 },
269
- write: tx => tx `DELETE FROM cvr.desires WHERE "clientGroupID" = ${this.#id} AND "clientID" = ${client.id} AND "queryHash" = ${query.id} AND "patchVersion" = ${versionString(oldPutVersion)}`,
270
- });
271
- }
272
- async *catchupRowPatches(lc, afterVersion, upToCVR, excludeQueryHashes = []) {
273
- if (cmpVersions(afterVersion, upToCVR.version) >= 0) {
274
- lc.debug?.('all clients up to date. no config catchup.');
275
- return;
276
- }
277
- const startMs = Date.now();
278
- const sql = this.#db;
279
- const start = afterVersion ? versionString(afterVersion) : '';
280
- const end = versionString(upToCVR.version);
281
- lc.debug?.(`catching up clients from ${start}`);
282
- const query = excludeQueryHashes.length === 0
283
- ? sql `SELECT * FROM cvr.rows
284
- WHERE "clientGroupID" = ${this.#id}
285
- AND "patchVersion" > ${start}
286
- AND "patchVersion" <= ${end}`
287
- : // Exclude rows that were already sent as part of query hydration.
288
- sql `SELECT * FROM cvr.rows
289
- WHERE "clientGroupID" = ${this.#id}
290
- AND "patchVersion" > ${start}
291
- AND "patchVersion" <= ${end}
292
- AND ("refCounts" IS NULL OR NOT "refCounts" ?| ${excludeQueryHashes})`;
293
- yield* query.cursor(10000);
294
- lc.debug?.(`finished row catchup (${Date.now() - startMs} ms)`);
524
+ catchupRowPatches(lc, afterVersion, upToCVR, excludeQueryHashes = []) {
525
+ return this.#rowCache.catchupRowPatches(lc, afterVersion, upToCVR, excludeQueryHashes);
295
526
  }
296
527
  async catchupConfigPatches(lc, afterVersion, upToCVR) {
297
528
  if (cmpVersions(afterVersion, upToCVR.version) >= 0) {
298
- lc.debug?.('all clients up to date. no config catchup.');
299
529
  return [];
300
530
  }
301
531
  const startMs = Date.now();
302
532
  const sql = this.#db;
303
533
  const start = afterVersion ? versionString(afterVersion) : '';
304
534
  const end = versionString(upToCVR.version);
535
+ lc.debug?.(`scanning config patches for clients from ${start}`);
305
536
  const [allDesires, clientRows, queryRows] = await Promise.all([
306
537
  sql `SELECT * FROM cvr.desires
307
538
  WHERE "clientGroupID" = ${this.#id}
@@ -345,21 +576,33 @@ export class CVRStore {
345
576
  lc.debug?.(`${patches.length} config patches (${Date.now() - startMs} ms)`);
346
577
  return patches;
347
578
  }
348
- async #abortIfNotVersion(tx, expectedCurrentVersion) {
579
+ async #checkVersionAndOwnership(tx, expectedCurrentVersion, lastConnectTime) {
349
580
  const expected = versionString(expectedCurrentVersion);
350
- const result = await tx `SELECT version FROM cvr.instances WHERE "clientGroupID" = ${this.#id}`.execute(); // Note: execute() immediately to send the query before others.
351
- const currVersion = result.length === 0 ? versionToLexi(0) : result[0].version;
352
- if (currVersion !== expected) {
353
- throw new ConcurrentModificationException(expected, currVersion);
581
+ const result = await tx `SELECT "version", "owner", "grantedAt" FROM cvr.instances
582
+ WHERE "clientGroupID" = ${this.#id}
583
+ FOR UPDATE`.execute(); // Note: execute() immediately to send the query before others.
584
+ const { version, owner, grantedAt } = result.length > 0
585
+ ? result[0]
586
+ : {
587
+ version: EMPTY_CVR_VERSION.stateVersion,
588
+ owner: null,
589
+ grantedAt: null,
590
+ };
591
+ if (version !== expected) {
592
+ throw new ConcurrentModificationException(expected, version);
593
+ }
594
+ if (owner !== this.#taskID && (grantedAt ?? 0) > lastConnectTime) {
595
+ throw new OwnershipError(owner, grantedAt);
354
596
  }
355
597
  }
356
- async #flush(expectedCurrentVersion) {
598
+ async #flush(expectedCurrentVersion, newVersion, lastConnectTime) {
357
599
  const stats = {
358
600
  instances: 0,
359
601
  queries: 0,
360
602
  desires: 0,
361
603
  clients: 0,
362
604
  rows: 0,
605
+ rowsDeferred: 0,
363
606
  statements: 0,
364
607
  };
365
608
  const existingRowRecords = await this.getRowRecords();
@@ -368,44 +611,44 @@ export class CVRStore {
368
611
  return ((existing !== undefined || row.refCounts !== null) &&
369
612
  !deepEqual(row, existing));
370
613
  });
371
- stats.rows = rowRecordsToFlush.length;
372
- await this.#db.begin(tx => {
614
+ const rowsFlushed = await this.#db.begin(async (tx) => {
373
615
  const pipelined = [
374
- // Test-and-set the version to guard against concurrent writes.
375
- // TODO: Add homing logic.
376
- this.#abortIfNotVersion(tx, expectedCurrentVersion),
616
+ // #checkVersionAndOwnership() executes a `SELECT ... FOR UPDATE`
617
+ // query to acquire a row-level lock so that version-updating
618
+ // transactions are effectively serialized per cvr.instance.
619
+ //
620
+ // Note that `rowsVersion` updates, on the other hand, are not subject
621
+ // to this lock and can thus commit / be-committed independently of
622
+ // cvr.instances.
623
+ this.#checkVersionAndOwnership(tx, expectedCurrentVersion, lastConnectTime),
377
624
  ];
378
- if (this.#pendingRowRecordPuts.size > 0) {
379
- const rowRecordRows = rowRecordsToFlush.map(r => rowRecordToRowsRow(this.#id, r));
380
- let i = 0;
381
- while (i < rowRecordRows.length) {
382
- pipelined.push(tx `INSERT INTO cvr.rows ${tx(rowRecordRows.slice(i, i + ROW_RECORD_UPSERT_BATCH_SIZE))}
383
- ON CONFLICT ("clientGroupID", "schema", "table", "rowKey")
384
- DO UPDATE SET "rowVersion" = excluded."rowVersion",
385
- "patchVersion" = excluded."patchVersion",
386
- "refCounts" = excluded."refCounts"`.execute());
387
- i += ROW_RECORD_UPSERT_BATCH_SIZE;
388
- stats.statements++;
389
- }
390
- }
391
625
  for (const write of this.#writes) {
392
626
  stats.instances += write.stats.instances ?? 0;
393
627
  stats.queries += write.stats.queries ?? 0;
394
628
  stats.desires += write.stats.desires ?? 0;
395
629
  stats.clients += write.stats.clients ?? 0;
396
- pipelined.push(write.write(tx).execute());
630
+ pipelined.push(write.write(tx, lastConnectTime).execute());
397
631
  stats.statements++;
398
632
  }
633
+ const rowUpdates = this.#rowCache.executeRowUpdates(tx, newVersion, rowRecordsToFlush, 'allow-defer');
634
+ pipelined.push(...rowUpdates);
635
+ stats.statements += rowUpdates.length;
399
636
  // Make sure Errors thrown by pipelined statements
400
637
  // are propagated up the stack.
401
- return Promise.all(pipelined);
638
+ await Promise.all(pipelined);
639
+ if (rowUpdates.length === 0) {
640
+ stats.rowsDeferred = rowRecordsToFlush.length;
641
+ return false;
642
+ }
643
+ stats.rows = rowRecordsToFlush.length;
644
+ return true;
402
645
  });
403
- await this.#rowCache.flush(rowRecordsToFlush);
646
+ await this.#rowCache.apply(rowRecordsToFlush, newVersion, rowsFlushed);
404
647
  return stats;
405
648
  }
406
- async flush(expectedCurrentVersion) {
649
+ async flush(expectedCurrentVersion, newVersion, lastConnectTime) {
407
650
  try {
408
- return await this.#flush(expectedCurrentVersion);
651
+ return await this.#flush(expectedCurrentVersion, newVersion, lastConnectTime);
409
652
  }
410
653
  catch (e) {
411
654
  // Clear cached state if an error (e.g. ConcurrentModificationException) is encountered.
@@ -417,6 +660,10 @@ export class CVRStore {
417
660
  this.#pendingRowRecordPuts.clear();
418
661
  }
419
662
  }
663
+ /** Resolves when all pending updates are flushed. */
664
+ flushed() {
665
+ return this.#rowCache.flushed();
666
+ }
420
667
  }
421
668
  // Max number of parameters for our sqlite build is 65534.
422
669
  // Each row record has 7 parameters (1 per column).
@@ -428,4 +675,20 @@ export class ConcurrentModificationException extends Error {
428
675
  super(`CVR has been concurrently modified. Expected ${expectedVersion}, got ${actualVersion}`);
429
676
  }
430
677
  }
678
+ export class OwnershipError extends Error {
679
+ name = 'OwnershipError';
680
+ constructor(owner, grantedAt) {
681
+ super(`CVR ownership was transferred to ${owner} at ${new Date(grantedAt ?? 0).toISOString()}`);
682
+ }
683
+ }
684
+ export class RowsVersionBehindError extends Error {
685
+ name = 'RowsVersionBehindError';
686
+ cvrVersion;
687
+ rowsVersion;
688
+ constructor(cvrVersion, rowsVersion) {
689
+ super(`rowsVersion (${rowsVersion}) is behind CVR ${cvrVersion}`);
690
+ this.cvrVersion = cvrVersion;
691
+ this.rowsVersion = rowsVersion;
692
+ }
693
+ }
431
694
  //# sourceMappingURL=cvr-store.js.map