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";...@@ -25,7 +25,7 @@ import * as Option from "effect/Option";
2525
26import * as MobileDatabase from "../persistence/mobile-database";26import * as MobileDatabase from "../persistence/mobile-database";
27import { make } from "./environment-cache-store";27import { make } from "./environment-cache-store";
28import { encodeStoredShellSnapshot } from "./shell-cache-encoding";28import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";
2929
30const ENVIRONMENT_ID = EnvironmentId.make("environment-1");30const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
31const PROJECT_ID = ProjectId.make("project-1");31const PROJECT_ID = ProjectId.make("project-1");
...@@ -409,6 +409,9 @@ describe("mobile SQLite environment cache store", () => {...@@ -409,6 +409,9 @@ describe("mobile SQLite environment cache store", () => {
409});409});
410410
411describe("cooperative shell cache encoding", () => {411describe("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);
412 const encodeOriginal = Schema.encodeEffect(415 const encodeOriginal = Schema.encodeEffect(
413 Schema.fromJsonString(StoredOrchestrationShellSnapshot),416 Schema.fromJsonString(StoredOrchestrationShellSnapshot),
414 );417 );
...@@ -511,8 +514,10 @@ describe("cooperative shell cache encoding", () => {...@@ -511,8 +514,10 @@ describe("cooperative shell cache encoding", () => {
511 ...stored,514 ...stored,
512 snapshot: {515 snapshot: {
513 ...stored.snapshot,516 ...stored.snapshot,
514 threads: Array.from({ length: 65 }, () => stored.snapshot.threads[0]!),517 threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })),
515 archivedThreads: Array.from({ length: 33 }, () => stored.snapshot.archivedThreads[0]!),518 archivedThreads: Array.from({ length: 33 }, () => ({
519 ...stored.snapshot.archivedThreads[0]!,
520 })),
516 },521 },
517 });522 });
518 // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope.523 // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope.
...@@ -592,4 +597,68 @@ describe("cooperative shell cache encoding", () => {...@@ -592,4 +597,68 @@ describe("cooperative shell cache encoding", () => {
592 }597 }
593 }),598 }),
594 );599 );
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 );
595});664});
apps/mobile/src/connection/environment-cache-store.ts+2-1
...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";...@@ -10,7 +10,7 @@ import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
1111
12import * as MobileDatabase from "../persistence/mobile-database";12import * as MobileDatabase from "../persistence/mobile-database";
13import { encodeStoredShellSnapshot } from "./shell-cache-encoding";13import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";
14import {14import {
15 attachProjectFaviconDatabase,15 attachProjectFaviconDatabase,
16 projectFaviconDatabaseCache,16 projectFaviconDatabaseCache,
...@@ -99,6 +99,7 @@ function loadDecodedCache<A, B>(input: {...@@ -99,6 +99,7 @@ function loadDecodedCache<A, B>(input: {
99export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {99export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
100 const database = yield* MobileDatabase.MobileDatabase;100 const database = yield* MobileDatabase.MobileDatabase;
101 attachProjectFaviconDatabase(database);101 attachProjectFaviconDatabase(database);
102 const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder();
102 return Persistence.EnvironmentCacheStore.of({103 return Persistence.EnvironmentCacheStore.of({
103 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>104 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
104 loadDecodedCache({105 loadDecodedCache({
apps/mobile/src/connection/shell-cache-encoding.ts+47-27
...@@ -30,33 +30,53 @@ const encodeEnvelope = Schema.encodeEffect(...@@ -30,33 +30,53 @@ const encodeEnvelope = Schema.encodeEffect(
30);30);
3131
32/**32/**
33 * Encode the shell cache payload exactly like `fromJsonString(StoredOrchestrationShellSnapshot)`,33 * Make an encoder for the shell cache payload that produces exactly what
34 * yielding to the host between bounded chunks of thread rows. The final envelope encode and34 * `fromJsonString(StoredOrchestrationShellSnapshot)` would, yielding to the host
35 * JSON stringify still run synchronously over the whole payload.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.
36 */41 */
37export const encodeStoredShellSnapshot = Effect.fnUntraced(function* (42export function makeStoredShellSnapshotEncoder() {
38 stored: typeof StoredOrchestrationShellSnapshot.Type,43 const encodedRows = new WeakMap<OrchestrationV2ThreadShellJson, unknown>();
39) {44
40 let hasWorked = false;45 return Effect.fnUntraced(function* (stored: typeof StoredOrchestrationShellSnapshot.Type) {
41 const yieldBetweenChunks = Effect.suspend(() => {46 let hasWorked = false;
42 if (hasWorked) return yieldToHost;47 const yieldBetweenChunks = Effect.suspend(() => {
43 hasWorked = true;48 if (hasWorked) return yieldToHost;
44 return Effect.void;49 hasWorked = true;
45 });50 return Effect.void;
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;
55 });51 });
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;73 const { snapshot } = stored;
58 const threads = yield* encodeRows(snapshot.threads);74 const threads = yield* encodeRows(snapshot.threads);
59 const archivedThreads = yield* encodeRows(snapshot.archivedThreads);75 const archivedThreads = yield* encodeRows(snapshot.archivedThreads);
60 yield* yieldBetweenChunks;76 if (hasWorked) yield* yieldToHost;
61 return yield* encodeEnvelope({ ...stored, snapshot: { ...snapshot, threads, archivedThreads } });77 return yield* encodeEnvelope({
62});78 ...stored,
79 snapshot: { ...snapshot, threads, archivedThreads },
80 });
81 });
82}