cojson 0.20.0 → 0.20.1

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 (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/dist/PeerState.d.ts +6 -1
  4. package/dist/PeerState.d.ts.map +1 -1
  5. package/dist/PeerState.js +18 -3
  6. package/dist/PeerState.js.map +1 -1
  7. package/dist/coValueCore/coValueCore.d.ts +3 -2
  8. package/dist/coValueCore/coValueCore.d.ts.map +1 -1
  9. package/dist/coValueCore/coValueCore.js +13 -19
  10. package/dist/coValueCore/coValueCore.js.map +1 -1
  11. package/dist/coValues/coList.d.ts +1 -0
  12. package/dist/coValues/coList.d.ts.map +1 -1
  13. package/dist/coValues/coList.js +3 -0
  14. package/dist/coValues/coList.js.map +1 -1
  15. package/dist/config.d.ts +2 -2
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +4 -4
  18. package/dist/config.js.map +1 -1
  19. package/dist/exports.d.ts +3 -3
  20. package/dist/exports.d.ts.map +1 -1
  21. package/dist/exports.js +2 -2
  22. package/dist/exports.js.map +1 -1
  23. package/dist/queue/LinkedList.d.ts +9 -3
  24. package/dist/queue/LinkedList.d.ts.map +1 -1
  25. package/dist/queue/LinkedList.js +30 -1
  26. package/dist/queue/LinkedList.js.map +1 -1
  27. package/dist/queue/OutgoingLoadQueue.d.ts +95 -0
  28. package/dist/queue/OutgoingLoadQueue.d.ts.map +1 -0
  29. package/dist/queue/OutgoingLoadQueue.js +240 -0
  30. package/dist/queue/OutgoingLoadQueue.js.map +1 -0
  31. package/dist/sync.d.ts.map +1 -1
  32. package/dist/sync.js +22 -32
  33. package/dist/sync.js.map +1 -1
  34. package/dist/tests/LinkedList.test.js +90 -0
  35. package/dist/tests/LinkedList.test.js.map +1 -1
  36. package/dist/tests/OutgoingLoadQueue.test.d.ts +2 -0
  37. package/dist/tests/OutgoingLoadQueue.test.d.ts.map +1 -0
  38. package/dist/tests/OutgoingLoadQueue.test.js +814 -0
  39. package/dist/tests/OutgoingLoadQueue.test.js.map +1 -0
  40. package/dist/tests/sync.concurrentLoad.test.d.ts +2 -0
  41. package/dist/tests/sync.concurrentLoad.test.d.ts.map +1 -0
  42. package/dist/tests/sync.concurrentLoad.test.js +481 -0
  43. package/dist/tests/sync.concurrentLoad.test.js.map +1 -0
  44. package/dist/tests/sync.storage.test.js +1 -1
  45. package/dist/tests/testStorage.d.ts.map +1 -1
  46. package/dist/tests/testStorage.js +3 -1
  47. package/dist/tests/testStorage.js.map +1 -1
  48. package/dist/tests/testUtils.d.ts +1 -0
  49. package/dist/tests/testUtils.d.ts.map +1 -1
  50. package/dist/tests/testUtils.js +2 -1
  51. package/dist/tests/testUtils.js.map +1 -1
  52. package/package.json +4 -4
  53. package/src/PeerState.ts +26 -3
  54. package/src/coValueCore/coValueCore.ts +15 -19
  55. package/src/coValues/coList.ts +4 -0
  56. package/src/config.ts +4 -4
  57. package/src/exports.ts +2 -2
  58. package/src/queue/LinkedList.ts +34 -4
  59. package/src/queue/OutgoingLoadQueue.ts +307 -0
  60. package/src/sync.ts +27 -35
  61. package/src/tests/LinkedList.test.ts +111 -0
  62. package/src/tests/OutgoingLoadQueue.test.ts +1129 -0
  63. package/src/tests/sync.concurrentLoad.test.ts +650 -0
  64. package/src/tests/sync.storage.test.ts +1 -1
  65. package/src/tests/testStorage.ts +3 -1
  66. package/src/tests/testUtils.ts +3 -1
@@ -0,0 +1,307 @@
1
+ import { CO_VALUE_LOADING_CONFIG } from "../config.js";
2
+ import { CoValueCore } from "../exports.js";
3
+ import type { RawCoID } from "../ids.js";
4
+ import { logger } from "../logger.js";
5
+ import type { PeerID } from "../sync.js";
6
+ import { LinkedList, type LinkedListNode, meteredList } from "./LinkedList.js";
7
+
8
+ interface PendingLoad {
9
+ value: CoValueCore;
10
+ sendCallback: () => void;
11
+ }
12
+
13
+ /**
14
+ * Mode for enqueuing load requests:
15
+ * - "high-priority" (default): high priority, processed in order
16
+ * - "low-priority": processed after all high priority requests
17
+ * - "immediate": bypasses the queue entirely, executes immediately
18
+ */
19
+ export type LoadMode = "low-priority" | "immediate" | "high-priority";
20
+
21
+ /**
22
+ * A queue that manages outgoing load requests with throttling.
23
+ *
24
+ * Features:
25
+ * - Limits concurrent in-flight load requests per peer
26
+ * - FIFO order for pending requests
27
+ * - O(1) enqueue and dequeue operations using LinkedList
28
+ * - Manages timeouts for in-flight loads with a single timer
29
+ */
30
+ export class OutgoingLoadQueue {
31
+ private inFlightLoads: Map<CoValueCore, number> = new Map();
32
+ private highPriorityPending: LinkedList<PendingLoad> = meteredList(
33
+ "load-requests-queue",
34
+ { priority: "high" },
35
+ );
36
+ private lowPriorityPending: LinkedList<PendingLoad> = meteredList(
37
+ "load-requests-queue",
38
+ { priority: "low" },
39
+ );
40
+ /**
41
+ * Tracks nodes in the low-priority queue by CoValue ID for O(1) upgrade lookup.
42
+ */
43
+ private lowPriorityNodes: Map<RawCoID, LinkedListNode<PendingLoad>> =
44
+ new Map();
45
+ /**
46
+ * Tracks nodes in the high-priority queue by CoValue ID for O(1) immediate mode lookup.
47
+ */
48
+ private highPriorityNodes: Map<RawCoID, LinkedListNode<PendingLoad>> =
49
+ new Map();
50
+ private timeoutHandle: ReturnType<typeof setTimeout> | null = null;
51
+
52
+ constructor(private peerId: PeerID) {}
53
+
54
+ /**
55
+ * Check if we can send another load request.
56
+ */
57
+ private canSend(): boolean {
58
+ return (
59
+ this.inFlightLoads.size <
60
+ CO_VALUE_LOADING_CONFIG.MAX_IN_FLIGHT_LOADS_PER_PEER
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Track that a load request has been sent.
66
+ */
67
+ private trackSent(coValue: CoValueCore): void {
68
+ const now = performance.now();
69
+ this.inFlightLoads.set(coValue, now);
70
+ this.scheduleTimeoutCheck(CO_VALUE_LOADING_CONFIG.TIMEOUT);
71
+ }
72
+
73
+ /**
74
+ * Schedule a timeout check if not already scheduled.
75
+ * Uses a single timer to check all in-flight loads.
76
+ */
77
+ private scheduleTimeoutCheck(nextTimeout: number): void {
78
+ if (this.timeoutHandle !== null) {
79
+ return;
80
+ }
81
+
82
+ this.timeoutHandle = setTimeout(() => {
83
+ this.timeoutHandle = null;
84
+ this.checkTimeouts();
85
+ }, nextTimeout);
86
+ }
87
+
88
+ /**
89
+ * Check all in-flight loads for timeouts and handle them.
90
+ */
91
+ private checkTimeouts(): void {
92
+ const now = performance.now();
93
+
94
+ let nextTimeout: number | undefined;
95
+ for (const [coValue, sentAt] of this.inFlightLoads.entries()) {
96
+ const timeout = sentAt + CO_VALUE_LOADING_CONFIG.TIMEOUT;
97
+
98
+ if (now >= timeout) {
99
+ if (!coValue.isAvailable()) {
100
+ logger.warn("Load request timed out", {
101
+ id: coValue.id,
102
+ peerId: this.peerId,
103
+ });
104
+ coValue.markNotFoundInPeer(this.peerId);
105
+ } else if (coValue.isStreaming()) {
106
+ logger.warn(
107
+ "Content streaming is taking more than " +
108
+ CO_VALUE_LOADING_CONFIG.TIMEOUT / 1000 +
109
+ "s",
110
+ {
111
+ id: coValue.id,
112
+ peerId: this.peerId,
113
+ knownState: coValue.knownState().sessions,
114
+ streamingTarget: coValue.knownStateWithStreaming().sessions,
115
+ },
116
+ );
117
+ }
118
+
119
+ this.inFlightLoads.delete(coValue);
120
+ this.processQueue();
121
+ } else {
122
+ nextTimeout = Math.min(nextTimeout ?? Infinity, timeout - now);
123
+ }
124
+ }
125
+
126
+ // Reschedule if there are still in-flight loads
127
+ if (nextTimeout) {
128
+ this.scheduleTimeoutCheck(nextTimeout);
129
+ }
130
+ }
131
+
132
+ trackUpdate(coValue: CoValueCore): void {
133
+ if (!this.inFlightLoads.has(coValue)) {
134
+ return;
135
+ }
136
+
137
+ // Refresh the timeout for the in-flight load
138
+ this.inFlightLoads.set(coValue, performance.now());
139
+ }
140
+
141
+ /**
142
+ * Track that a load request has completed.
143
+ * Triggers processing of pending requests.
144
+ */
145
+ trackComplete(coValue: CoValueCore): void {
146
+ if (!this.inFlightLoads.has(coValue)) {
147
+ return;
148
+ }
149
+
150
+ if (coValue.isStreaming()) {
151
+ // wait for the next chunk
152
+ return;
153
+ }
154
+
155
+ this.inFlightLoads.delete(coValue);
156
+ this.processQueue();
157
+ }
158
+
159
+ /**
160
+ * Enqueue a load request.
161
+ * Immediately processes the queue to send requests if capacity is available.
162
+ * Skips CoValues that are already in-flight or pending.
163
+ *
164
+ * @param coValue - The CoValue to load
165
+ * @param sendCallback - Callback to send the request when ready
166
+ * @param mode - Optional mode: "low-priority" for background loads, "immediate" to bypass queue
167
+ */
168
+ enqueue(
169
+ value: CoValueCore,
170
+ sendCallback: () => void,
171
+ mode: LoadMode = "high-priority",
172
+ ): void {
173
+ if (this.inFlightLoads.has(value)) {
174
+ return;
175
+ }
176
+
177
+ const lowPriorityNode = this.lowPriorityNodes.get(value.id);
178
+ const highPriorityNode = this.highPriorityNodes.get(value.id);
179
+
180
+ switch (mode) {
181
+ case "immediate":
182
+ // Upgrade any low-priority or high-priority requests to immediate priority
183
+ if (lowPriorityNode) {
184
+ this.lowPriorityPending.remove(lowPriorityNode);
185
+ this.lowPriorityNodes.delete(value.id);
186
+ }
187
+ if (highPriorityNode) {
188
+ this.highPriorityPending.remove(highPriorityNode);
189
+ this.highPriorityNodes.delete(value.id);
190
+ }
191
+
192
+ this.trackSent(value);
193
+ sendCallback();
194
+ break;
195
+ case "high-priority":
196
+ if (highPriorityNode) {
197
+ return;
198
+ }
199
+
200
+ // Upgrade any low-priority requests to high-priority
201
+ if (lowPriorityNode) {
202
+ this.lowPriorityPending.remove(lowPriorityNode);
203
+ this.lowPriorityNodes.delete(value.id);
204
+ }
205
+
206
+ this.highPriorityNodes.set(
207
+ value.id,
208
+ this.highPriorityPending.push({ value, sendCallback }),
209
+ );
210
+ this.processQueue();
211
+ break;
212
+ case "low-priority":
213
+ if (lowPriorityNode || highPriorityNode) {
214
+ return;
215
+ }
216
+
217
+ this.lowPriorityNodes.set(
218
+ value.id,
219
+ this.lowPriorityPending.push({ value, sendCallback }),
220
+ );
221
+ this.processQueue();
222
+ break;
223
+ }
224
+ }
225
+
226
+ private processing = false;
227
+ /**
228
+ * Process all pending load requests while capacity is available.
229
+ * High-priority requests are processed first, then low-priority.
230
+ */
231
+ private processQueue(): void {
232
+ if (this.processing || !this.canSend()) {
233
+ return;
234
+ }
235
+ this.processing = true;
236
+
237
+ while (this.canSend()) {
238
+ // Try high-priority first
239
+ let next = this.highPriorityPending.shift();
240
+
241
+ if (next) {
242
+ // Remove from the tracking map since we're processing it
243
+ this.highPriorityNodes.delete(next.value.id);
244
+ } else {
245
+ // Fall back to low-priority if high-priority is empty
246
+ next = this.lowPriorityPending.shift();
247
+ if (next) {
248
+ // Remove from the tracking map since we're processing it
249
+ this.lowPriorityNodes.delete(next.value.id);
250
+ }
251
+ }
252
+
253
+ if (!next) {
254
+ break;
255
+ }
256
+
257
+ this.trackSent(next.value);
258
+ next.sendCallback();
259
+ }
260
+
261
+ this.processing = false;
262
+ }
263
+
264
+ /**
265
+ * Clear all state. Called on disconnect.
266
+ * Clears the timeout and all pending/in-flight loads.
267
+ */
268
+ clear(): void {
269
+ if (this.timeoutHandle !== null) {
270
+ clearTimeout(this.timeoutHandle);
271
+ this.timeoutHandle = null;
272
+ }
273
+ this.inFlightLoads.clear();
274
+ this.highPriorityPending = new LinkedList();
275
+ this.lowPriorityPending = new LinkedList();
276
+ this.highPriorityNodes.clear();
277
+ this.lowPriorityNodes.clear();
278
+ }
279
+
280
+ /**
281
+ * Get the number of in-flight loads (for testing/debugging).
282
+ */
283
+ get inFlightCount(): number {
284
+ return this.inFlightLoads.size;
285
+ }
286
+
287
+ /**
288
+ * Get the number of pending loads (for testing/debugging).
289
+ */
290
+ get pendingCount(): number {
291
+ return this.highPriorityPending.length + this.lowPriorityPending.length;
292
+ }
293
+
294
+ /**
295
+ * Get the number of high-priority pending loads (for testing/debugging).
296
+ */
297
+ get highPriorityPendingCount(): number {
298
+ return this.highPriorityPending.length;
299
+ }
300
+
301
+ /**
302
+ * Get the number of low-priority pending loads (for testing/debugging).
303
+ */
304
+ get lowPriorityPendingCount(): number {
305
+ return this.lowPriorityPending.length;
306
+ }
307
+ }
package/src/sync.ts CHANGED
@@ -405,13 +405,7 @@ export class SyncManager {
405
405
  // If the coValue is unavailable and we never tried this peer
406
406
  // we try to load it from the peer
407
407
  if (!peer.loadRequestSent.has(coValue.id)) {
408
- peer.trackLoadRequestSent(coValue.id);
409
- this.trySendToPeer(peer, {
410
- action: "load",
411
- header: false,
412
- id: coValue.id,
413
- sessions: {},
414
- });
408
+ peer.sendLoadRequest(coValue, "low-priority");
415
409
  }
416
410
  } else {
417
411
  // Build the list of coValues ordered by dependency
@@ -431,12 +425,11 @@ export class SyncManager {
431
425
  * - Subscribe to the coValue updates
432
426
  * - Start the sync process in case we or the other peer
433
427
  * lacks some transactions
428
+ *
429
+ * Use low priority for reconciliation loads so that user-initiated
430
+ * loads take precedence.
434
431
  */
435
- peer.trackLoadRequestSent(coValue.id);
436
- this.trySendToPeer(peer, {
437
- action: "load",
438
- ...coValue.knownState(),
439
- });
432
+ peer.sendLoadRequest(coValue, "low-priority");
440
433
  }
441
434
  }
442
435
 
@@ -515,7 +508,7 @@ export class SyncManager {
515
508
  currentTimer - lastTimer >
516
509
  SYNC_SCHEDULER_CONFIG.INCOMING_MESSAGES_TIME_BUDGET
517
510
  ) {
518
- await new Promise<void>((resolve) => setTimeout(resolve));
511
+ await waitForNextTick();
519
512
  lastTimer = performance.now();
520
513
  }
521
514
  }
@@ -733,6 +726,8 @@ export class SyncManager {
733
726
  if (coValue.isAvailable()) {
734
727
  this.sendNewContent(msg.id, peer);
735
728
  }
729
+
730
+ peer.trackLoadRequestComplete(coValue);
736
731
  }
737
732
 
738
733
  recordTransactionsSize(newTransactions: Transaction[], source: string) {
@@ -751,6 +746,7 @@ export class SyncManager {
751
746
  ) {
752
747
  const coValue = this.local.getCoValue(msg.id);
753
748
  const peer = from === "storage" || from === "import" ? undefined : from;
749
+
754
750
  const sourceRole =
755
751
  from === "storage"
756
752
  ? "storage"
@@ -767,6 +763,7 @@ export class SyncManager {
767
763
  };
768
764
  }
769
765
 
766
+ peer?.trackLoadRequestUpdate(coValue);
770
767
  coValue.addDependenciesFromContentMessage(msg);
771
768
 
772
769
  // If some of the dependencies are missing, we wait for them to be available
@@ -786,7 +783,12 @@ export class SyncManager {
786
783
  peers.push(peer);
787
784
  }
788
785
 
789
- dependencyCoValue.load(peers);
786
+ // Use immediate mode to bypass the concurrency limit for dependencies
787
+ // We do this to avoid that the dependency load is blocked
788
+ // by the pending dependendant load
789
+ // Also these should be done with the highest priority, because we need to
790
+ // unblock the coValue wait
791
+ dependencyCoValue.load(peers, "immediate");
790
792
  }
791
793
  }
792
794
 
@@ -1014,8 +1016,6 @@ export class SyncManager {
1014
1016
  peer.trackToldKnownState(msg.id);
1015
1017
  }
1016
1018
 
1017
- const syncedPeers = [];
1018
-
1019
1019
  /**
1020
1020
  * Store the content and propagate it to the server peers and the subscribed client peers
1021
1021
  */
@@ -1029,6 +1029,8 @@ export class SyncManager {
1029
1029
  }
1030
1030
  }
1031
1031
 
1032
+ peer?.trackLoadRequestComplete(coValue);
1033
+
1032
1034
  for (const peer of this.getPeers(coValue.id)) {
1033
1035
  /**
1034
1036
  * We sync the content against the source peer if it is a client or server peers
@@ -1042,25 +1044,8 @@ export class SyncManager {
1042
1044
  // We directly forward the new content to peers that have an active subscription
1043
1045
  if (peer.isCoValueSubscribedToPeer(coValue.id)) {
1044
1046
  this.sendNewContent(coValue.id, peer);
1045
- syncedPeers.push(peer);
1046
- } else if (
1047
- peer.role === "server" &&
1048
- !peer.loadRequestSent.has(coValue.id)
1049
- ) {
1050
- const state = coValue.getLoadingStateForPeer(peer.id);
1051
-
1052
- // Check if there is a inflight load operation and we
1053
- // are waiting for other peers to send the load request
1054
- if (state === "unknown") {
1055
- // Sending a load message to the peer to get to know how much content is missing
1056
- // before sending the new content
1057
- this.trySendToPeer(peer, {
1058
- action: "load",
1059
- ...coValue.knownStateWithStreaming(),
1060
- });
1061
- peer.trackLoadRequestSent(coValue.id);
1062
- syncedPeers.push(peer);
1063
- }
1047
+ } else if (peer.role === "server") {
1048
+ peer.sendLoadRequest(coValue);
1064
1049
  }
1065
1050
  }
1066
1051
  }
@@ -1344,3 +1329,10 @@ export function hwrServerPeerSelector(n: number): ServerPeerSelector {
1344
1329
  .map((wp) => wp.peer);
1345
1330
  };
1346
1331
  }
1332
+
1333
+ let waitForNextTick = () =>
1334
+ new Promise<void>((resolve) => queueMicrotask(resolve));
1335
+
1336
+ if (typeof setImmediate === "function") {
1337
+ waitForNextTick = () => new Promise<void>((resolve) => setImmediate(resolve));
1338
+ }
@@ -76,6 +76,117 @@ describe("LinkedList", () => {
76
76
  });
77
77
  });
78
78
 
79
+ describe("remove", () => {
80
+ it("should remove the only element in the list", () => {
81
+ const node = list.push(1);
82
+ list.remove(node);
83
+
84
+ expect(list.length).toBe(0);
85
+ expect(list.head).toBeUndefined();
86
+ expect(list.tail).toBeUndefined();
87
+ expect(node.prev).toBeUndefined();
88
+ expect(node.next).toBeUndefined();
89
+ });
90
+
91
+ it("should remove the head element", () => {
92
+ const node1 = list.push(1);
93
+ const node2 = list.push(2);
94
+ const node3 = list.push(3);
95
+
96
+ list.remove(node1);
97
+
98
+ expect(list.length).toBe(2);
99
+ expect(list.head).toBe(node2);
100
+ expect(list.tail).toBe(node3);
101
+ expect(node2.prev).toBeUndefined();
102
+ expect(node1.prev).toBeUndefined();
103
+ expect(node1.next).toBeUndefined();
104
+ });
105
+
106
+ it("should remove the tail element", () => {
107
+ const node1 = list.push(1);
108
+ const node2 = list.push(2);
109
+ const node3 = list.push(3);
110
+
111
+ list.remove(node3);
112
+
113
+ expect(list.length).toBe(2);
114
+ expect(list.head).toBe(node1);
115
+ expect(list.tail).toBe(node2);
116
+ expect(node2.next).toBeUndefined();
117
+ expect(node3.prev).toBeUndefined();
118
+ expect(node3.next).toBeUndefined();
119
+ });
120
+
121
+ it("should remove a middle element", () => {
122
+ const node1 = list.push(1);
123
+ const node2 = list.push(2);
124
+ const node3 = list.push(3);
125
+
126
+ list.remove(node2);
127
+
128
+ expect(list.length).toBe(2);
129
+ expect(list.head).toBe(node1);
130
+ expect(list.tail).toBe(node3);
131
+ expect(node1.next).toBe(node3);
132
+ expect(node3.prev).toBe(node1);
133
+ expect(node2.prev).toBeUndefined();
134
+ expect(node2.next).toBeUndefined();
135
+ });
136
+
137
+ it("should maintain correct state after multiple removes", () => {
138
+ const node1 = list.push(1);
139
+ const node2 = list.push(2);
140
+ const node3 = list.push(3);
141
+ const node4 = list.push(4);
142
+
143
+ // Remove middle
144
+ list.remove(node2);
145
+ expect(list.length).toBe(3);
146
+ expect(node1.next).toBe(node3);
147
+ expect(node3.prev).toBe(node1);
148
+
149
+ // Remove head
150
+ list.remove(node1);
151
+ expect(list.length).toBe(2);
152
+ expect(list.head).toBe(node3);
153
+
154
+ // Remove tail
155
+ list.remove(node4);
156
+ expect(list.length).toBe(1);
157
+ expect(list.head).toBe(node3);
158
+ expect(list.tail).toBe(node3);
159
+
160
+ // Remove last
161
+ list.remove(node3);
162
+ expect(list.length).toBe(0);
163
+ expect(list.head).toBeUndefined();
164
+ expect(list.tail).toBeUndefined();
165
+ });
166
+
167
+ it("should allow shift after remove", () => {
168
+ const node1 = list.push(1);
169
+ list.push(2);
170
+ list.push(3);
171
+
172
+ list.remove(node1);
173
+
174
+ expect(list.shift()).toBe(2);
175
+ expect(list.shift()).toBe(3);
176
+ expect(list.length).toBe(0);
177
+ });
178
+
179
+ it("should allow push after remove", () => {
180
+ const node1 = list.push(1);
181
+ list.remove(node1);
182
+
183
+ list.push(2);
184
+ expect(list.length).toBe(1);
185
+ expect(list.head?.value).toBe(2);
186
+ expect(list.tail?.value).toBe(2);
187
+ });
188
+ });
189
+
79
190
  describe("edge cases", () => {
80
191
  it("should handle push after all elements have been shifted", () => {
81
192
  list.push(1);