@runappsai/sdk 0.7.5 → 0.7.6

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.
@@ -176,6 +176,12 @@ interface StoreSchema {
176
176
  }
177
177
 
178
178
  const TOMBSTONE_TTL_MS = 30 * 24 * 3600 * 1000;
179
+ /** Shards one push writes at a time. Each is a read-merge-write against
180
+ * the files API, so the push is latency bound: done one by one, a few
181
+ * hundred records over every shard took a minute and a half. */
182
+ const PUSH_CONCURRENCY = 6;
183
+ /** Shard copies kept, with their etags, so a write needs no read first. */
184
+ const SHARD_CACHE_MAX = 256;
179
185
  const MAX_PUSH_DELAY_MS = 10_000;
180
186
  const BACKOFF_MIN_MS = 5_000;
181
187
  const BACKOFF_MAX_MS = 5 * 60_000;
@@ -208,6 +214,15 @@ export class SyncEngine {
208
214
  private readonly clock = new Clock();
209
215
  private readonly schemas = new Map<string, Map<string, StoreSchema>>();
210
216
  private readonly etags = new Map<string, string>();
217
+ /** The last copy of a shard this engine read or wrote, with its etag:
218
+ * the next write merges into it and sends If-Match, and only a 412
219
+ * (someone else wrote in between) costs a read. Oldest first. */
220
+ private readonly shardCache = new Map<string, { etag: string; shard: Shard }>();
221
+ /** Every shard path the last listing showed, and the prefixes that
222
+ * listing covered: a path under a covered prefix that isn't listed
223
+ * does not exist, and is created with If-None-Match, unread. */
224
+ private readonly listedPaths = new Set<string>();
225
+ private readonly listedPrefixes = new Set<string>();
211
226
  private readonly conns = new Map<string, Promise<IDBDatabase>>();
212
227
  private state: SyncState = { state: "off", pending: 0 };
213
228
  private paused = false;
@@ -576,6 +591,9 @@ export class SyncEngine {
576
591
  }
577
592
  await this.log.clearAll();
578
593
  this.etags.clear();
594
+ this.shardCache.clear();
595
+ this.listedPaths.clear();
596
+ this.listedPrefixes.clear();
579
597
  this.metaLoaded = null;
580
598
  await this.loadMeta();
581
599
  }
@@ -647,13 +665,23 @@ export class SyncEngine {
647
665
  if (!g) groups.set(k, (g = []));
648
666
  g.push(op);
649
667
  }
650
- const done: number[] = [];
651
- const etagUpdates: Record<string, unknown> = {};
668
+ const idsOf = (list: LoggedOp[]) => list.map((op) => op.id).filter((id): id is number => id !== undefined);
669
+ // Ops leave the log as soon as the shard holding them is written, so
670
+ // `pending` counts down while a long push runs, and a push cut short
671
+ // keeps only what really is still unsent.
672
+ const settle = async (ids: number[]) => {
673
+ if (ids.length === 0) return;
674
+ await this.log.remove(ids);
675
+ this.moved = true;
676
+ await this.refreshPending();
677
+ };
678
+ const limit = limiter(PUSH_CONCURRENCY);
679
+ const work: Promise<void>[] = [];
652
680
  for (const [, g] of groups) {
653
681
  const db = g[0]!.db;
654
682
  const store = g[0]!.store;
655
683
  if (this.storeExcluded(db, store)) {
656
- for (const op of g) if (op.id !== undefined) done.push(op.id);
684
+ work.push(settle(idsOf(g)));
657
685
  continue;
658
686
  }
659
687
  // A clear drops everything written before it; ops after the last
@@ -675,19 +703,22 @@ export class SyncEngine {
675
703
  list.push(op);
676
704
  }
677
705
  const shards = clearedAt > 0 ? Array.from({ length: this.options.shards }, (_, i) => i) : Array.from(byShard.keys());
678
- for (const n of shards) {
679
- const path = this.shardPath(db, store, n);
680
- const shardOps = byShard.get(n) ?? [];
681
- const encoded = await Promise.all(shardOps.map((op) => this.encodeOp(op)));
682
- const etag = await this.writeShard(path, clearedAt, encoded);
683
- etagUpdates["etag:" + path] = etag;
684
- }
685
- for (const op of g) if (op.id !== undefined) done.push(op.id);
706
+ const written = shards.map((n) =>
707
+ limit(async () => {
708
+ const path = this.shardPath(db, store, n);
709
+ const shardOps = byShard.get(n) ?? [];
710
+ const encoded = await Promise.all(shardOps.map((op) => this.encodeOp(op)));
711
+ const etag = await this.writeShard(path, clearedAt, encoded);
712
+ await this.log.setMeta({ ["etag:" + path]: etag });
713
+ await settle(idsOf(shardOps));
714
+ }),
715
+ );
716
+ // The clear itself, and what it superseded, once every shard is.
717
+ work.push(Promise.all(written).then(() => settle(idsOf(g.slice(0, lastClear + 1)))));
686
718
  }
687
- await this.log.setMeta(etagUpdates);
688
- await this.log.remove(done);
689
- if (done.length > 0) this.moved = true;
690
- await this.refreshPending();
719
+ const results = await Promise.allSettled(work);
720
+ const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
721
+ if (failed) throw failed.reason;
691
722
  }
692
723
 
693
724
  private async encodeOp(op: LoggedOp): Promise<ShardOp> {
@@ -696,10 +727,17 @@ export class SyncEngine {
696
727
  return { kid: op.kid, rec: { k: op.key, v, t: op.ts }, changed: op.changed };
697
728
  }
698
729
 
699
- /** Merge ops into one shard and write it; retried on a 412. */
730
+ /** Merge ops into one shard and write it; retried on a 412. The first
731
+ * try starts from the cached copy, or from nothing when the last
732
+ * listing showed no such shard — no read either way. */
700
733
  private async writeShard(path: string, clearedAt: number, ops: ShardOp[]): Promise<string> {
701
734
  for (let attempt = 0; attempt < 5; attempt++) {
702
- const { shard, etag } = await this.readShard(path);
735
+ const cached = attempt === 0 ? this.shardCache.get(path) : undefined;
736
+ const { shard, etag } = cached
737
+ ? { shard: copyShard(cached.shard), etag: cached.etag }
738
+ : attempt === 0 && this.knownAbsent(path)
739
+ ? { shard: { v: 1 as const, cleared: 0, r: {} }, etag: undefined }
740
+ : await this.readShard(path);
703
741
  const now = this.clock.now();
704
742
  if (clearedAt > shard.cleared) {
705
743
  shard.cleared = clearedAt;
@@ -721,13 +759,40 @@ export class SyncEngine {
721
759
  if (r.status === "ok") {
722
760
  if (r.serverTime) this.clock.observe(r.serverTime, before, Date.now());
723
761
  this.etags.set(path, r.etag);
762
+ this.cacheShard(path, r.etag, shard);
763
+ this.listedPaths.add(path);
724
764
  return r.etag;
725
765
  }
726
766
  this.etags.delete(path);
767
+ this.shardCache.delete(path);
768
+ this.listedPaths.add(path); // it exists after all
727
769
  }
728
770
  throw new Error(`could not write ${path}: too many concurrent writers`);
729
771
  }
730
772
 
773
+ private cacheShard(path: string, etag: string, shard: Shard): void {
774
+ this.shardCache.delete(path);
775
+ this.shardCache.set(path, { etag, shard });
776
+ while (this.shardCache.size > SHARD_CACHE_MAX) {
777
+ const oldest = this.shardCache.keys().next().value;
778
+ if (oldest === undefined) break;
779
+ this.shardCache.delete(oldest);
780
+ }
781
+ }
782
+
783
+ private knownAbsent(path: string): boolean {
784
+ if (this.listedPaths.has(path)) return false;
785
+ for (const p of this.listedPrefixes) if (path.startsWith(p)) return true;
786
+ return false;
787
+ }
788
+
789
+ /** Remember what a listing of `prefix` showed (see listedPaths). */
790
+ private rememberListing(prefix: string, listing: { path: string }[]): void {
791
+ for (const p of [...this.listedPaths]) if (p.startsWith(prefix)) this.listedPaths.delete(p);
792
+ for (const e of listing) this.listedPaths.add(e.path);
793
+ this.listedPrefixes.add(prefix);
794
+ }
795
+
731
796
  private async storeBlob(blob: Blob): Promise<BlobRef> {
732
797
  const bytes = new Uint8Array(await blob.arrayBuffer());
733
798
  const digest = await crypto.subtle.digest("SHA-256", bytes);
@@ -740,14 +805,18 @@ export class SyncEngine {
740
805
  // ── Pull ─────────────────────────────────────────────────────────
741
806
 
742
807
  private async pull(): Promise<void> {
743
- const listing = await this.deps.remote.list(this.options.prefix + "/");
808
+ const prefix = this.options.prefix + "/";
809
+ const listing = await this.deps.remote.list(prefix);
810
+ this.rememberListing(prefix, listing);
744
811
  await this.applyListing(listing);
745
812
  }
746
813
 
747
814
  private async pullDatabase(name: string): Promise<void> {
748
815
  if (!this.deps.userId()) return;
749
816
  await this.loadMeta();
750
- const listing = await this.deps.remote.list(`${this.options.prefix}/${pathSeg(name)}/`);
817
+ const prefix = `${this.options.prefix}/${pathSeg(name)}/`;
818
+ const listing = await this.deps.remote.list(prefix);
819
+ this.rememberListing(prefix, listing);
751
820
  await this.applyListing(listing);
752
821
  }
753
822
 
@@ -775,6 +844,8 @@ export class SyncEngine {
775
844
  }
776
845
  const s = r.value as Partial<Shard> | null;
777
846
  const shard: Shard = { v: 1, cleared: s?.cleared ?? 0, r: s?.r ?? {} };
847
+ // Cached before applying: applyShard annotates the records.
848
+ this.cacheShard(entry.path, r.etag, copyShard(shard));
778
849
  await this.applyShard(db, store, shard, pending);
779
850
  this.etags.set(entry.path, r.etag);
780
851
  etagUpdates["etag:" + entry.path] = r.etag;
@@ -894,3 +965,32 @@ export class SyncEngine {
894
965
  }
895
966
 
896
967
  export { idbRequest };
968
+
969
+ /** A shard is plain JSON: a deep copy is a JSON round trip. */
970
+ function copyShard(shard: Shard): Shard {
971
+ return JSON.parse(JSON.stringify(shard)) as Shard;
972
+ }
973
+
974
+ /** Run at most `n` of the given tasks at once. */
975
+ function limiter(n: number): <T>(task: () => Promise<T>) => Promise<T> {
976
+ let active = 0;
977
+ const queue: (() => void)[] = [];
978
+ const next = () => {
979
+ if (active >= n) return;
980
+ const start = queue.shift();
981
+ if (start) start();
982
+ };
983
+ return (task) =>
984
+ new Promise((resolve, reject) => {
985
+ queue.push(() => {
986
+ active++;
987
+ task()
988
+ .then(resolve, reject)
989
+ .finally(() => {
990
+ active--;
991
+ next();
992
+ });
993
+ });
994
+ next();
995
+ });
996
+ }