authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 09:52:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 09:52:04-07:00
log1c33f3b2b2976b7f4e26698ed69c549de687a97e
tree9421093df85dff75a96651018e73d2a1035dd894
parent31f9d832f4f019ff565beafbd6bb18451fa7039d
signature Signed by PGP key B5690EEEBB952194

perf(mobile): reduce shell cache encoding work (#15096)


2 files changed, 140 insertions(+), 27 deletions(-)

apps/mobile/src/connection/environment-cache-store.test.ts+93-4
......@@ -388,16 +388,105 @@ describe("cooperative shell cache encoding", () => {
388388 }),
389389 );
390390
391 it.effect("still rejects invalid rows after an encoding batch yields", () =>
391 it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () =>
392392 Effect.gen(function* () {
393 const invalid = {
393 const base = SHELL_SNAPSHOT.threads[0]!;
394 const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base;
395 const row = (index: number) =>
396 index % 3 === 0
397 ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) }
398 : {
399 ...base,
400 id: ThreadId.make(`row-${index}`),
401 snoozedUntil: DateTime.add(NOW, { minutes: index }),
402 pinnedAt: index % 2 === 0 ? null : NOW,
403 };
404 const mixed = {
394405 ...stored,
395406 snapshot: {
396407 ...stored.snapshot,
397 threads: [...stored.snapshot.threads, { ...stored.snapshot.threads[0]!, itemCount: -1 }],
408 threads: Array.from({ length: 65 }, (_, index) => row(index)),
409 archivedThreads: Array.from({ length: 33 }, (_, index) => ({
410 ...row(index + 100),
411 archivedAt: DateTime.add(NOW, { hours: index }),
412 })),
398413 },
399414 };
400 expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalid))).toBe(true);
415 const actual = yield* encodeStoredShellSnapshot(mixed);
416 expect(actual).toBe(yield* encodeOriginal(mixed));
417 const parsed = JSON.parse(actual);
418 expect(parsed.snapshot.threads).toHaveLength(65);
419 expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration");
420 expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt");
421 expect(parsed.snapshot.threads[64].snoozedUntil).toBe(
422 DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })),
423 );
424 expect(parsed.snapshot.archivedThreads).toHaveLength(33);
425 expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe(
426 DateTime.formatIso(DateTime.add(NOW, { hours: 32 })),
427 );
428 }),
429 );
430
431 it.effect("yields to a host timer between every 32-row chunk", () =>
432 Effect.gen(function* () {
433 const setTimer = vi.spyOn(globalThis, "setTimeout");
434 try {
435 yield* encodeStoredShellSnapshot({
436 ...stored,
437 snapshot: {
438 ...stored.snapshot,
439 threads: Array.from({ length: 65 }, () => stored.snapshot.threads[0]!),
440 archivedThreads: Array.from({ length: 33 }, () => stored.snapshot.archivedThreads[0]!),
441 },
442 });
443 // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope.
444 expect(setTimer).toHaveBeenCalledTimes(5);
445 } finally {
446 vi.restoreAllMocks();
447 }
448 }),
449 );
450
451 it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () =>
452 Effect.gen(function* () {
453 const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 };
454 const invalidActive = {
455 ...stored,
456 snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] },
457 };
458 const invalidArchived = {
459 ...stored,
460 snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] },
461 };
462 const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } };
463 expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true);
464 expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true);
465 expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true);
466 expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true);
467 }),
468 );
469
470 it.effect("keeps concurrent saves independent", () =>
471 Effect.gen(function* () {
472 const other = {
473 ...stored,
474 environmentId: EnvironmentId.make("environment-2"),
475 snapshot: {
476 ...stored.snapshot,
477 threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({
478 ...thread,
479 title: `Other ${thread.id}`,
480 })),
481 archivedThreads: [],
482 },
483 };
484 const [first, second] = yield* Effect.all(
485 [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)],
486 { concurrency: "unbounded" },
487 );
488 expect(first).toBe(yield* encodeOriginal(stored));
489 expect(second).toBe(yield* encodeOriginal(other));
401490 }),
402491 );
403492
apps/mobile/src/connection/shell-cache-encoding.ts+47-23
......@@ -3,36 +3,60 @@ import { OrchestrationV2ThreadShellJson } from "@t3tools/contracts";
33import * as Effect from "effect/Effect";
44import * as Schema from "effect/Schema";
55
6const ROWS_PER_CHUNK = 32;
7
68// Effect's cooperative yield resumes through microtasks on React Native. A host
7// timer lets input and rendering run between batches of shell rows instead.
9// timer lets input and rendering run between chunks of shell rows instead.
810const yieldToHost = Effect.callback<void>((resume) => {
911 const timer = setTimeout(() => resume(Effect.void), 0);
1012 return Effect.sync(() => clearTimeout(timer));
1113});
1214
13// Share the row budget across saves and environments; this bounds batches, not elapsed time.
14let encodedRows = 0;
15const CooperativeThreadShell = OrchestrationV2ThreadShellJson.pipe(
16 Schema.middlewareEncoding<typeof OrchestrationV2ThreadShellJson, never>((effect) =>
17 Effect.flatMap(effect, (value) => {
18 encodedRows++;
19 if (encodedRows < 32) return Effect.succeed(value);
20 encodedRows = 0;
21 return Effect.as(yieldToHost, value);
22 }),
15// Rows go through the canonical row codec in chunks. The envelope codec then
16// validates and encodes everything else, passing the encoded rows through as-is.
17const encodeThreadChunk = Schema.encodeEffect(Schema.Array(OrchestrationV2ThreadShellJson));
18const EncodedRows = Schema.Array(Schema.Unknown);
19const encodeEnvelope = Schema.encodeEffect(
20 Schema.fromJsonString(
21 StoredOrchestrationShellSnapshot.mapFields((fields) => ({
22 ...fields,
23 snapshot: fields.snapshot.mapFields((snapshotFields) => ({
24 ...snapshotFields,
25 threads: EncodedRows,
26 archivedThreads: EncodedRows,
27 })),
28 })),
2329 ),
2430);
2531
26const CooperativeStoredShellSnapshot = StoredOrchestrationShellSnapshot.mapFields((fields) => ({
27 ...fields,
28 snapshot: fields.snapshot.mapFields((snapshotFields) => ({
29 ...snapshotFields,
30 threads: Schema.Array(CooperativeThreadShell),
31 archivedThreads: Schema.Array(CooperativeThreadShell),
32 })),
33}));
32/**
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.
36 */
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;
55 });
3456
35/** Preserve the cache codec's validation and transforms while yielding during large saves. */
36export const encodeStoredShellSnapshot = Schema.encodeEffect(
37 Schema.fromJsonString(CooperativeStoredShellSnapshot),
38);
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});