authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 22:48:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 22:48:55-07:00
log8a1fc4149ae7b41e7d3d93f1b51dd9aeb38c4282
tree1b6f52ae364884d1a29983fb544604d6a1623c78
parent12a4c057f3d6880c611630ccdc0c8c7f2cd23f72
signature Signed by PGP key B5690EEEBB952194

perf(mobile): reuse encoded rows in shell cache saves (#16129)


3 files changed, 121 insertions(+), 31 deletions(-)

apps/mobile/src/connection/environment-cache-store.test.ts+72-3
......@@ -25,7 +25,7 @@ import * as Option from "effect/Option";
2525
2626import * as MobileDatabase from "../persistence/mobile-database";
2727import { make } from "./environment-cache-store";
28import { encodeStoredShellSnapshot } from "./shell-cache-encoding";
28import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";
2929
3030const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
3131const PROJECT_ID = ProjectId.make("project-1");
......@@ -409,6 +409,9 @@ describe("mobile SQLite environment cache store", () => {
409409});
410410
411411describe("cooperative shell cache encoding", () => {
412 // A fresh encoder per call keeps these cases cold; the cache has its own cases below.
413 const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) =>
414 makeStoredShellSnapshotEncoder()(stored);
412415 const encodeOriginal = Schema.encodeEffect(
413416 Schema.fromJsonString(StoredOrchestrationShellSnapshot),
414417 );
......@@ -511,8 +514,10 @@ describe("cooperative shell cache encoding", () => {
511514 ...stored,
512515 snapshot: {
513516 ...stored.snapshot,
514 threads: Array.from({ length: 65 }, () => stored.snapshot.threads[0]!),
515 archivedThreads: Array.from({ length: 33 }, () => stored.snapshot.archivedThreads[0]!),
517 threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })),
518 archivedThreads: Array.from({ length: 33 }, () => ({
519 ...stored.snapshot.archivedThreads[0]!,
520 })),
516521 },
517522 });
518523 // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope.
......@@ -592,4 +597,68 @@ describe("cooperative shell cache encoding", () => {
592597 }
593598 }),
594599 );
600
601 it.effect("reuses encoded rows across saves without changing the payload", () =>
602 Effect.gen(function* () {
603 const encode = makeStoredShellSnapshotEncoder();
604 expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored));
605
606 const setTimer = vi.spyOn(globalThis, "setTimeout");
607 try {
608 // Same row references, new envelope: no row work, so no host yields.
609 const resaved = {
610 ...stored,
611 snapshot: { ...stored.snapshot, snapshotSequence: 4 },
612 };
613 expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved));
614 expect(setTimer).not.toHaveBeenCalled();
615 } finally {
616 vi.restoreAllMocks();
617 }
618 }),
619 );
620
621 it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () =>
622 Effect.gen(function* () {
623 const encode = makeStoredShellSnapshotEncoder();
624 yield* encode(stored);
625 const [first, second, ...rest] = stored.snapshot.threads;
626 const next = {
627 ...stored,
628 snapshot: {
629 ...stored.snapshot,
630 threads: [
631 { ...first!, title: "Renamed", itemCount: 9 },
632 ...rest,
633 { ...first!, id: ThreadId.make("thread-new"), branch: " feature " },
634 ],
635 archivedThreads: [
636 ...stored.snapshot.archivedThreads,
637 { ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) },
638 ],
639 },
640 };
641 const actual = yield* encode(next);
642 expect(actual).toBe(yield* encodeOriginal(next));
643 const parsed = JSON.parse(actual);
644 expect(parsed.snapshot.threads[0].title).toBe("Renamed");
645 expect(parsed.snapshot.threads.at(-1).branch).toBe("feature");
646 expect(parsed.snapshot.archivedThreads).toHaveLength(2);
647 }),
648 );
649
650 it.effect("does not cache rows from a failed encode", () =>
651 Effect.gen(function* () {
652 const encode = makeStoredShellSnapshotEncoder();
653 const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 };
654 const invalid = {
655 ...stored,
656 snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] },
657 };
658 expect(yield* Effect.isFailure(encode(invalid))).toBe(true);
659 expect(yield* Effect.isFailure(encode(invalid))).toBe(true);
660 // Valid rows from the failed save still encode correctly afterwards.
661 expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored));
662 }),
663 );
595664});
apps/mobile/src/connection/environment-cache-store.ts+2-1
......@@ -10,7 +10,7 @@ import * as Option from "effect/Option";
1010import * as Schema from "effect/Schema";
1111
1212import * as MobileDatabase from "../persistence/mobile-database";
13import { encodeStoredShellSnapshot } from "./shell-cache-encoding";
13import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";
1414import {
1515 attachProjectFaviconDatabase,
1616 projectFaviconDatabaseCache,
......@@ -99,6 +99,7 @@ function loadDecodedCache<A, B>(input: {
9999export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
100100 const database = yield* MobileDatabase.MobileDatabase;
101101 attachProjectFaviconDatabase(database);
102 const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder();
102103 return Persistence.EnvironmentCacheStore.of({
103104 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
104105 loadDecodedCache({
apps/mobile/src/connection/shell-cache-encoding.ts+47-27
......@@ -30,33 +30,53 @@ const encodeEnvelope = Schema.encodeEffect(
3030);
3131
3232/**
33 * Encode the shell cache payload exactly like `fromJsonString(StoredOrchestrationShellSnapshot)`,
34 * yielding to the host between bounded chunks of thread rows. The final envelope encode and
35 * JSON stringify still run synchronously over the whole payload.
33 * Make an encoder for the shell cache payload that produces exactly what
34 * `fromJsonString(StoredOrchestrationShellSnapshot)` would, yielding to the host
35 * between bounded chunks of thread rows. The final envelope encode and JSON
36 * stringify still run synchronously over the whole payload.
37 *
38 * Shell rows are immutable and the shell reducer keeps unchanged rows by
39 * reference, so each encoder remembers the canonical encoding of rows it has
40 * already encoded successfully and only runs the row codec for new rows.
3641 */
37export const encodeStoredShellSnapshot = Effect.fnUntraced(function* (
38 stored: typeof StoredOrchestrationShellSnapshot.Type,
39) {
40 let hasWorked = false;
41 const yieldBetweenChunks = Effect.suspend(() => {
42 if (hasWorked) return yieldToHost;
43 hasWorked = true;
44 return Effect.void;
45 });
46 const encodeRows = (rows: ReadonlyArray<OrchestrationV2ThreadShellJson>) =>
47 Effect.gen(function* () {
48 const encoded: Array<unknown> = [];
49 for (let start = 0; start < rows.length; start += ROWS_PER_CHUNK) {
50 yield* yieldBetweenChunks;
51 const chunk = yield* encodeThreadChunk(rows.slice(start, start + ROWS_PER_CHUNK));
52 for (const row of chunk) encoded.push(row);
53 }
54 return encoded;
42export function makeStoredShellSnapshotEncoder() {
43 const encodedRows = new WeakMap<OrchestrationV2ThreadShellJson, unknown>();
44
45 return Effect.fnUntraced(function* (stored: typeof StoredOrchestrationShellSnapshot.Type) {
46 let hasWorked = false;
47 const yieldBetweenChunks = Effect.suspend(() => {
48 if (hasWorked) return yieldToHost;
49 hasWorked = true;
50 return Effect.void;
5551 });
52 const encodeMisses = (misses: ReadonlyArray<OrchestrationV2ThreadShellJson>) =>
53 Effect.gen(function* () {
54 yield* yieldBetweenChunks;
55 const chunk = yield* encodeThreadChunk(misses);
56 chunk.forEach((row, index) => encodedRows.set(misses[index]!, row));
57 });
58 const encodeRows = (rows: ReadonlyArray<OrchestrationV2ThreadShellJson>) =>
59 Effect.gen(function* () {
60 let misses: Array<OrchestrationV2ThreadShellJson> = [];
61 for (const row of rows) {
62 if (encodedRows.has(row)) continue;
63 misses.push(row);
64 if (misses.length === ROWS_PER_CHUNK) {
65 yield* encodeMisses(misses);
66 misses = [];
67 }
68 }
69 if (misses.length > 0) yield* encodeMisses(misses);
70 return rows.map((row) => encodedRows.get(row));
71 });
5672
57 const { snapshot } = stored;
58 const threads = yield* encodeRows(snapshot.threads);
59 const archivedThreads = yield* encodeRows(snapshot.archivedThreads);
60 yield* yieldBetweenChunks;
61 return yield* encodeEnvelope({ ...stored, snapshot: { ...snapshot, threads, archivedThreads } });
62});
73 const { snapshot } = stored;
74 const threads = yield* encodeRows(snapshot.threads);
75 const archivedThreads = yield* encodeRows(snapshot.archivedThreads);
76 if (hasWorked) yield* yieldToHost;
77 return yield* encodeEnvelope({
78 ...stored,
79 snapshot: { ...snapshot, threads, archivedThreads },
80 });
81 });
82}