cojson 0.12.0 → 0.13.0

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.
@@ -33,10 +33,70 @@ type Tuple<T, N extends number, A extends unknown[] = []> = A extends {
33
33
  }
34
34
  ? A
35
35
  : Tuple<T, N, [...A, T]>;
36
- type QueueTuple = Tuple<QueueEntry[], 8>;
36
+
37
+ type QueueTuple = Tuple<LinkedList<QueueEntry>, 8>;
38
+
39
+ type LinkedListNode<T> = {
40
+ value: T;
41
+ next: LinkedListNode<T> | undefined;
42
+ };
43
+
44
+ /**
45
+ * Using a linked list to make the shift operation O(1) instead of O(n)
46
+ * as our queues can grow very large when the system is under pressure.
47
+ */
48
+ export class LinkedList<T> {
49
+ head: LinkedListNode<T> | undefined = undefined;
50
+ tail: LinkedListNode<T> | undefined = undefined;
51
+ length = 0;
52
+
53
+ push(value: T) {
54
+ const node = { value, next: undefined };
55
+
56
+ if (this.head === undefined) {
57
+ this.head = node;
58
+ this.tail = node;
59
+ } else if (this.tail) {
60
+ this.tail.next = node;
61
+ this.tail = node;
62
+ } else {
63
+ throw new Error("LinkedList is corrupted");
64
+ }
65
+
66
+ this.length++;
67
+ }
68
+
69
+ shift() {
70
+ if (!this.head) {
71
+ return undefined;
72
+ }
73
+
74
+ const node = this.head;
75
+ const value = node.value;
76
+ this.head = node.next;
77
+ node.next = undefined;
78
+
79
+ if (this.head === undefined) {
80
+ this.tail = undefined;
81
+ }
82
+
83
+ this.length--;
84
+
85
+ return value;
86
+ }
87
+ }
37
88
 
38
89
  export class PriorityBasedMessageQueue {
39
- private queues: QueueTuple = [[], [], [], [], [], [], [], []];
90
+ private queues: QueueTuple = [
91
+ new LinkedList<QueueEntry>(),
92
+ new LinkedList<QueueEntry>(),
93
+ new LinkedList<QueueEntry>(),
94
+ new LinkedList<QueueEntry>(),
95
+ new LinkedList<QueueEntry>(),
96
+ new LinkedList<QueueEntry>(),
97
+ new LinkedList<QueueEntry>(),
98
+ new LinkedList<QueueEntry>(),
99
+ ];
40
100
  queueSizeCounter = metrics
41
101
  .getMeter("cojson")
42
102
  .createUpDownCounter("jazz.messagequeue.size", {
@@ -199,6 +199,7 @@ export class CoValueCore {
199
199
  givenExpectedNewHash: Hash | undefined,
200
200
  newSignature: Signature,
201
201
  skipVerify: boolean = false,
202
+ givenNewStreamingHash?: StreamingHash,
202
203
  ): Result<true, TryAddTransactionsError> {
203
204
  return this.node
204
205
  .resolveAccountAgent(
@@ -208,42 +209,55 @@ export class CoValueCore {
208
209
  .andThen((agent) => {
209
210
  const signerID = this.crypto.getAgentSignerID(agent);
210
211
 
211
- const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
212
- sessionID,
213
- newTransactions,
214
- );
215
-
216
- if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
217
- return err({
218
- type: "InvalidHash",
219
- id: this.id,
220
- expectedNewHash,
221
- givenExpectedNewHash,
222
- } satisfies InvalidHashError);
223
- }
224
-
225
212
  if (
226
- skipVerify !== true &&
227
- !this.crypto.verify(newSignature, expectedNewHash, signerID)
213
+ skipVerify === true &&
214
+ givenNewStreamingHash &&
215
+ givenExpectedNewHash
228
216
  ) {
229
- return err({
230
- type: "InvalidSignature",
231
- id: this.id,
217
+ this.doAddTransactions(
218
+ sessionID,
219
+ newTransactions,
232
220
  newSignature,
221
+ givenExpectedNewHash,
222
+ givenNewStreamingHash,
223
+ "immediate",
224
+ );
225
+ } else {
226
+ const { expectedNewHash, newStreamingHash } =
227
+ this.expectedNewHashAfter(sessionID, newTransactions);
228
+
229
+ if (
230
+ givenExpectedNewHash &&
231
+ givenExpectedNewHash !== expectedNewHash
232
+ ) {
233
+ return err({
234
+ type: "InvalidHash",
235
+ id: this.id,
236
+ expectedNewHash,
237
+ givenExpectedNewHash,
238
+ } satisfies InvalidHashError);
239
+ }
240
+
241
+ if (!this.crypto.verify(newSignature, expectedNewHash, signerID)) {
242
+ return err({
243
+ type: "InvalidSignature",
244
+ id: this.id,
245
+ newSignature,
246
+ sessionID,
247
+ signerID,
248
+ } satisfies InvalidSignatureError);
249
+ }
250
+
251
+ this.doAddTransactions(
233
252
  sessionID,
234
- signerID,
235
- } satisfies InvalidSignatureError);
253
+ newTransactions,
254
+ newSignature,
255
+ expectedNewHash,
256
+ newStreamingHash,
257
+ "immediate",
258
+ );
236
259
  }
237
260
 
238
- this.doAddTransactions(
239
- sessionID,
240
- newTransactions,
241
- newSignature,
242
- expectedNewHash,
243
- newStreamingHash,
244
- "immediate",
245
- );
246
-
247
261
  return ok(true as const);
248
262
  });
249
263
  }
@@ -370,40 +384,14 @@ export class CoValueCore {
370
384
  const streamingHash =
371
385
  this.sessionLogs.get(sessionID)?.streamingHash.clone() ??
372
386
  new StreamingHash(this.crypto);
373
- for (const transaction of newTransactions) {
374
- streamingHash.update(transaction);
375
- }
376
-
377
- const newStreamingHash = streamingHash.clone();
378
387
 
379
- return {
380
- expectedNewHash: streamingHash.digest(),
381
- newStreamingHash,
382
- };
383
- }
384
-
385
- async expectedNewHashAfterAsync(
386
- sessionID: SessionID,
387
- newTransactions: Transaction[],
388
- ): Promise<{ expectedNewHash: Hash; newStreamingHash: StreamingHash }> {
389
- const streamingHash =
390
- this.sessionLogs.get(sessionID)?.streamingHash.clone() ??
391
- new StreamingHash(this.crypto);
392
- let before = performance.now();
393
388
  for (const transaction of newTransactions) {
394
389
  streamingHash.update(transaction);
395
- const after = performance.now();
396
- if (after - before > 1) {
397
- await new Promise((resolve) => setTimeout(resolve, 0));
398
- before = performance.now();
399
- }
400
390
  }
401
391
 
402
- const newStreamingHash = streamingHash.clone();
403
-
404
392
  return {
405
393
  expectedNewHash: streamingHash.digest(),
406
- newStreamingHash,
394
+ newStreamingHash: streamingHash,
407
395
  };
408
396
  }
409
397
 
@@ -452,9 +440,10 @@ export class CoValueCore {
452
440
  ) as SessionID)
453
441
  : this.node.currentSessionID;
454
442
 
455
- const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
456
- transaction,
457
- ]);
443
+ const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
444
+ sessionID,
445
+ [transaction],
446
+ );
458
447
 
459
448
  const signature = this.crypto.sign(
460
449
  this.node.account.currentSignerSecret(),
@@ -467,6 +456,7 @@ export class CoValueCore {
467
456
  expectedNewHash,
468
457
  signature,
469
458
  true,
459
+ newStreamingHash,
470
460
  )._unsafeUnwrap({ withStackTrace: true });
471
461
 
472
462
  if (success) {
@@ -176,11 +176,12 @@ export class CoValueState {
176
176
  async loadFromPeers(peers: PeerState[]) {
177
177
  const state = this.state;
178
178
 
179
- if (state.type !== "unknown" && state.type !== "unavailable") {
179
+ if (state.type === "loading" || state.type === "available") {
180
180
  return;
181
181
  }
182
182
 
183
183
  if (peers.length === 0) {
184
+ this.moveToState(new CoValueUnavailableState());
184
185
  return;
185
186
  }
186
187
 
@@ -192,7 +193,11 @@ export class CoValueState {
192
193
 
193
194
  // If we are in the loading state we move to a new loading state
194
195
  // to reset all the loading promises
195
- if (this.state.type === "loading" || this.state.type === "unknown") {
196
+ if (
197
+ this.state.type === "loading" ||
198
+ this.state.type === "unknown" ||
199
+ this.state.type === "unavailable"
200
+ ) {
196
201
  this.moveToState(
197
202
  new CoValueLoadingState(peersWithoutErrors.map((p) => p.id)),
198
203
  );
@@ -308,6 +313,19 @@ async function loadCoValueFromPeers(
308
313
  }
309
314
 
310
315
  if (coValueEntry.state.type === "loading") {
316
+ const { promise, resolve } = createResolvablePromise<void>();
317
+
318
+ /**
319
+ * Use a very long timeout for storage peers, because under pressure
320
+ * they may take a long time to consume the messages queue
321
+ *
322
+ * TODO: Track errors on storage and do not rely on timeout
323
+ */
324
+ const timeoutDuration =
325
+ peer.role === "storage"
326
+ ? CO_VALUE_LOADING_CONFIG.TIMEOUT * 10
327
+ : CO_VALUE_LOADING_CONFIG.TIMEOUT;
328
+
311
329
  const timeout = setTimeout(() => {
312
330
  if (coValueEntry.state.type === "loading") {
313
331
  logger.warn("Failed to load coValue from peer", {
@@ -319,9 +337,10 @@ async function loadCoValueFromPeers(
319
337
  type: "not-found-in-peer",
320
338
  peerId: peer.id,
321
339
  });
340
+ resolve();
322
341
  }
323
- }, CO_VALUE_LOADING_CONFIG.TIMEOUT);
324
- await coValueEntry.state.waitForPeer(peer.id);
342
+ }, timeoutDuration);
343
+ await Promise.race([promise, coValueEntry.state.waitForPeer(peer.id)]);
325
344
  clearTimeout(timeout);
326
345
  }
327
346
  }
package/src/sync.ts CHANGED
@@ -23,10 +23,6 @@ export function emptyKnownState(id: RawCoID): CoValueKnownState {
23
23
  };
24
24
  }
25
25
 
26
- function getErrorMessage(e: unknown) {
27
- return e instanceof Error ? e.message : "Unknown error";
28
- }
29
-
30
26
  export type SyncMessage =
31
27
  | LoadMessage
32
28
  | KnownStateMessage
@@ -415,7 +411,20 @@ export class SyncManager {
415
411
  entry.loadFromPeers([peer]).catch((e) => {
416
412
  logger.error("Error loading coValue in handleLoad", { err: e });
417
413
  });
414
+ } else {
415
+ // We don't have any eligible peers to load the coValue from
416
+ // so we send a known state back to the sender to let it know
417
+ // that the coValue is unavailable
418
+ this.trySendToPeer(peer, {
419
+ action: "known",
420
+ id: msg.id,
421
+ header: false,
422
+ sessions: {},
423
+ }).catch((e) => {
424
+ logger.error("Error sending known state back", { err: e });
425
+ });
418
426
  }
427
+
419
428
  return;
420
429
  } else {
421
430
  this.local.loadCoValueCore(msg.id, peer.id).catch((e) => {
@@ -460,6 +469,13 @@ export class SyncManager {
460
469
  err: e,
461
470
  });
462
471
  });
472
+ } else if (entry.state.type === "unavailable") {
473
+ this.trySendToPeer(peer, {
474
+ action: "known",
475
+ id: msg.id,
476
+ header: false,
477
+ sessions: {},
478
+ });
463
479
  }
464
480
 
465
481
  if (entry.state.type === "available") {
@@ -604,39 +620,12 @@ export class SyncManager {
604
620
  continue;
605
621
  }
606
622
 
607
- const before = performance.now();
608
- // eslint-disable-next-line neverthrow/must-use-result
609
623
  const result = coValue.tryAddTransactions(
610
624
  sessionID,
611
625
  newTransactions,
612
626
  undefined,
613
627
  newContentForSession.lastSignature,
614
628
  );
615
- const after = performance.now();
616
- if (after - before > 80) {
617
- const totalTxLength = newTransactions
618
- .map((t) =>
619
- t.privacy === "private"
620
- ? t.encryptedChanges.length
621
- : t.changes.length,
622
- )
623
- .reduce((a, b) => a + b, 0);
624
- logger.debug(
625
- `Adding incoming transactions took ${(after - before).toFixed(
626
- 2,
627
- )}ms for ${totalTxLength} bytes = bandwidth: ${(
628
- (1000 * totalTxLength) / (after - before) / (1024 * 1024)
629
- ).toFixed(2)} MB/s`,
630
- );
631
- }
632
-
633
- // const theirTotalnTxs = Object.values(
634
- // peer.optimisticKnownStates[msg.id]?.sessions || {},
635
- // ).reduce((sum, nTxs) => sum + nTxs, 0);
636
- // const ourTotalnTxs = [...coValue.sessionLogs.values()].reduce(
637
- // (sum, session) => sum + session.transactions.length,
638
- // 0,
639
- // );
640
629
 
641
630
  if (result.isErr()) {
642
631
  logger.error("Failed to add transactions", {
@@ -735,16 +724,10 @@ export class SyncManager {
735
724
  }
736
725
 
737
726
  async actuallySyncCoValue(coValue: CoValueCore) {
738
- // let blockingSince = performance.now();
739
727
  for (const peer of this.peersInPriorityOrder()) {
740
728
  if (peer.closed) continue;
741
729
  if (peer.erroredCoValues.has(coValue.id)) continue;
742
- // if (performance.now() - blockingSince > 5) {
743
- // await new Promise<void>((resolve) => {
744
- // setTimeout(resolve, 0);
745
- // });
746
- // blockingSince = performance.now();
747
- // }
730
+
748
731
  if (peer.optimisticKnownStates.has(coValue.id)) {
749
732
  await this.tellUntoldKnownStateIncludingDependencies(coValue.id, peer);
750
733
  await this.sendNewContentIncludingDependencies(coValue.id, peer);
@@ -0,0 +1,96 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { LinkedList } from "../PriorityBasedMessageQueue";
3
+
4
+ describe("LinkedList", () => {
5
+ let list: LinkedList<number>;
6
+
7
+ beforeEach(() => {
8
+ list = new LinkedList<number>();
9
+ });
10
+
11
+ describe("initialization", () => {
12
+ it("should create an empty list", () => {
13
+ expect(list.length).toBe(0);
14
+ expect(list.head).toBeUndefined();
15
+ expect(list.tail).toBeUndefined();
16
+ });
17
+ });
18
+
19
+ describe("push", () => {
20
+ it("should add an element to an empty list", () => {
21
+ list.push(1);
22
+ expect(list.length).toBe(1);
23
+ expect(list.head?.value).toBe(1);
24
+ expect(list.tail?.value).toBe(1);
25
+ });
26
+
27
+ it("should add multiple elements in sequence", () => {
28
+ list.push(1);
29
+ list.push(2);
30
+ list.push(3);
31
+ expect(list.length).toBe(3);
32
+ expect(list.head?.value).toBe(1);
33
+ expect(list.tail?.value).toBe(3);
34
+ });
35
+ });
36
+
37
+ describe("shift", () => {
38
+ it("should return undefined for empty list", () => {
39
+ expect(list.shift()).toBeUndefined();
40
+ expect(list.length).toBe(0);
41
+ expect(list.head).toBeUndefined();
42
+ expect(list.tail).toBeUndefined();
43
+ });
44
+
45
+ it("should remove and return the first element", () => {
46
+ list.push(1);
47
+ list.push(2);
48
+
49
+ const shifted = list.shift();
50
+ expect(shifted).toBe(1);
51
+ expect(list.length).toBe(1);
52
+ expect(list.head?.value).toBe(2);
53
+ expect(list.tail?.value).toBe(2);
54
+ });
55
+
56
+ it("should maintain correct order when shifting multiple times", () => {
57
+ list.push(1);
58
+ list.push(2);
59
+ list.push(3);
60
+
61
+ expect(list.shift()).toBe(1);
62
+ expect(list.shift()).toBe(2);
63
+ expect(list.shift()).toBe(3);
64
+ expect(list.length).toBe(0);
65
+ expect(list.head).toBeUndefined();
66
+ expect(list.tail).toBeUndefined();
67
+ });
68
+
69
+ it("should handle shift after last element is removed", () => {
70
+ list.push(1);
71
+ list.shift();
72
+ expect(list.shift()).toBeUndefined();
73
+ expect(list.length).toBe(0);
74
+ expect(list.head).toBeUndefined();
75
+ expect(list.tail).toBeUndefined();
76
+ });
77
+ });
78
+
79
+ describe("edge cases", () => {
80
+ it("should handle push after all elements have been shifted", () => {
81
+ list.push(1);
82
+ list.shift();
83
+ list.push(2);
84
+ expect(list.length).toBe(1);
85
+ expect(list.shift()).toBe(2);
86
+ });
87
+
88
+ it("should handle alternating push and shift operations", () => {
89
+ list.push(1);
90
+ expect(list.shift()).toBe(1);
91
+ list.push(2);
92
+ expect(list.shift()).toBe(2);
93
+ expect(list.length).toBe(0);
94
+ });
95
+ });
96
+ });
@@ -956,27 +956,6 @@ test.skip("When a peer's incoming/readable stream closes, we remove the peer", a
956
956
  */
957
957
  });
958
958
 
959
- test("If we start loading a coValue before connecting to a peer that has it, it will load it once we connect", async () => {
960
- const { node: node1 } = await createConnectedTestNode();
961
-
962
- const group = node1.createGroup();
963
-
964
- const map = group.createMap();
965
- map.set("hello", "world", "trusting");
966
-
967
- const node2 = createTestNode();
968
-
969
- const mapOnNode2Promise = loadCoValueOrFail(node2, map.id);
970
-
971
- expect(node2.coValuesStore.get(map.core.id).state.type).toEqual("unknown");
972
-
973
- connectNodeToSyncServer(node2);
974
-
975
- const mapOnNode2 = await mapOnNode2Promise;
976
-
977
- expect(mapOnNode2.get("hello")).toEqual("world");
978
- });
979
-
980
959
  test("should keep the peer state when the peer closes", async () => {
981
960
  const client = createTestNode();
982
961
 
@@ -1726,6 +1705,45 @@ describe("loadCoValueCore with retry", () => {
1726
1705
  await expect(promise1).resolves.not.toBe("unavailable");
1727
1706
  await expect(promise2).resolves.not.toBe("unavailable");
1728
1707
  });
1708
+
1709
+ test("should load unavailable coValues after they are synced", async () => {
1710
+ const bob = createTestNode();
1711
+ const alice = createTestNode();
1712
+
1713
+ // Create a group and map on anotherClient
1714
+ const group = alice.createGroup();
1715
+ const map = group.createMap();
1716
+ map.set("key1", "value1", "trusting");
1717
+
1718
+ // Start loading before syncing
1719
+ const result = await bob.loadCoValueCore(map.id);
1720
+
1721
+ expect(result).toBe("unavailable");
1722
+
1723
+ connectTwoPeers(alice, bob, "server", "server");
1724
+
1725
+ const result2 = await bob.loadCoValueCore(map.id);
1726
+
1727
+ expect(result2).not.toBe("unavailable");
1728
+ });
1729
+
1730
+ test("should successfully mark a coValue as unavailable if the server does not have it", async () => {
1731
+ const bob = createTestNode();
1732
+ const alice = createTestNode();
1733
+ const charlie = createTestNode();
1734
+
1735
+ connectTwoPeers(bob, charlie, "client", "server");
1736
+
1737
+ // Create a group and map on anotherClient
1738
+ const group = alice.createGroup();
1739
+ const map = group.createMap();
1740
+ map.set("key1", "value1", "trusting");
1741
+
1742
+ // Start loading before syncing
1743
+ const result = await bob.loadCoValueCore(map.id);
1744
+
1745
+ expect(result).toBe("unavailable");
1746
+ });
1729
1747
  });
1730
1748
 
1731
1749
  describe("waitForSyncWithPeer", () => {
@@ -1894,6 +1912,8 @@ describe("sync protocol", () => {
1894
1912
  const map = group.createMap();
1895
1913
  map.set("hello", "world", "trusting");
1896
1914
 
1915
+ await map.core.waitForSync();
1916
+
1897
1917
  const mapOnJazzCloud = await loadCoValueOrFail(jazzCloud, map.id);
1898
1918
  expect(mapOnJazzCloud.get("hello")).toEqual("world");
1899
1919
 
@@ -2038,6 +2058,29 @@ describe("sync protocol", () => {
2038
2058
  },
2039
2059
  },
2040
2060
  },
2061
+ {
2062
+ from: "server",
2063
+ msg: {
2064
+ action: "known",
2065
+ header: true,
2066
+ id: map.id,
2067
+ sessions: {
2068
+ [client.currentSessionID]: 1,
2069
+ },
2070
+ },
2071
+ },
2072
+ {
2073
+ from: "server",
2074
+ msg: {
2075
+ action: "known",
2076
+ asDependencyOf: undefined,
2077
+ header: true,
2078
+ id: map.id,
2079
+ sessions: {
2080
+ [client.currentSessionID]: 1,
2081
+ },
2082
+ },
2083
+ },
2041
2084
  ]);
2042
2085
  });
2043
2086
  });