Skip to content

perf(ui): parallelize session decryption on cold start#124

Open
lucharo wants to merge 2 commits intohappier-dev:devfrom
lucharo:fix/parallelize-session-decryption
Open

perf(ui): parallelize session decryption on cold start#124
lucharo wants to merge 2 commits intohappier-dev:devfrom
lucharo:fix/parallelize-session-decryption

Conversation

@lucharo
Copy link

@lucharo lucharo commented Mar 10, 2026

Summary

  • Replaces sequential for-await loops with Promise.all for both encryption key decryption and session metadata/agentState decryption in sessionSnapshot.ts
  • Reduces cold start time proportionally to the number of sessions (previously O(n) sequential awaits, now parallelized)

Changes

  • Key decryption loop (lines 75-90): for + awaitPromise.all(sessions.map(...))
  • Session decryption loop (lines 95-149): for + awaitPromise.all(sessions.map(...))
  • Hoisted parsePlainMetadata and parsePlainAgentState helpers out of the loop since they're pure functions

Test plan

  • Open the iOS app after force-quitting (cold start) with 20+ sessions
  • Verify sessions load noticeably faster than before
  • Verify all session metadata (titles, agent state) renders correctly
  • Verify shared sessions with various access levels display properly
  • Verify plain-text (non-E2EE) sessions still work

Closes #123

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Faster and more reliable session syncing: decryption now runs in parallel for improved performance.
    • Improved error handling during session key decryption; failed decryptions are tracked without blocking others.
    • More robust recovery of encrypted and unencrypted session data, preserving session integrity and repair attempts.

Replace sequential for-await loops with Promise.all for both encryption
key decryption and session metadata/agentState decryption, significantly
reducing cold start time when loading many sessions.

Closes happier-dev#123

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR optimises cold-start performance in sessionSnapshot.ts by replacing two sequential for-await loops — key decryption and session decryption — with Promise.all, reducing total decryption time from O(n × latency) to O(latency). The parsePlainMetadata and parsePlainAgentState helpers are correctly hoisted outside the parallel map. The logic for handling null-return failures (decryption returning null rather than throwing) is faithfully preserved.

Key observations:

  • The parallelisation is correct and the performance benefit is real.
  • However, both Promise.all calls use fail-fast semantics: a single rejected promise (i.e. a crypto call that throws rather than returns null) causes the entire batch to fail, preventing any sessions from loading. Promise.allSettled would give per-session error isolation and is the idiomatic choice when processing independent items.
  • The repair loop at the bottom (repairInvalidReadStateV1) remains sequential — not a problem for this PR's scope, but worth noting for future work.

Confidence Score: 3/5

  • Safe to merge but the fail-fast Promise.all semantics introduce a latent regression risk for session loading under crypto errors.
  • The null-return failure paths are correctly handled and the overall structure is sound. The use of Promise.all instead of Promise.allSettled means any single thrown rejection will silently drop all sessions rather than just the failing one, which is a regression in resilience compared to what the sequential code could have been improved to. The change is not likely to manifest in normal operation, but it is a real edge-case correctness concern in a security-critical path.
  • apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts — review the Promise.all usage on lines 75 and 121.

Important Files Changed

Filename Overview
apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts Parallelises key and session decryption with Promise.all; logic is preserved for null-return failure paths, but any thrown rejection in either Promise.all block will discard all sessions — Promise.allSettled would be more resilient.

Sequence Diagram

sequenceDiagram
    participant C as fetchAndApplySessions
    participant E as encryption
    participant S as sessions[]

    Note over C,S: Phase 1 — Key decryption (parallelised)
    C->>+E: Promise.all: decryptEncryptionKey(session[0..n].dataEncryptionKey)
    E-->>-C: keyResults[0..n] { id, decrypted, hasKey }
    C->>C: Build sessionKeys Map
    C->>E: initializeSessions(sessionKeys)

    Note over C,S: Phase 2 — Session decryption (parallelised)
    loop For each session in parallel
        C->>E: getSessionEncryption(session.id)
        alt encryptionMode === 'e2ee'
            C->>E: decryptMetadata(version, metadata)
            C->>E: decryptAgentState(version, agentState)
        else encryptionMode === 'plain'
            C->>C: parsePlainMetadata(metadata)
            C->>C: parsePlainAgentState(agentState)
        end
    end
    C->>C: filter(s !== null) → decryptedSessions

    Note over C,S: Phase 3 — Apply & repair
    C->>S: applySessions(decryptedSessions)
    C-->>C: fire-and-forget repairInvalidReadStateV1 loop
Loading

Last reviewed commit: 8ef72d3

Comment on lines +121 to +158
const decryptedSessionResults = await Promise.all(
sessions.map(async (session) => {
const encryptionMode: 'e2ee' | 'plain' = session.encryptionMode === 'plain' ? 'plain' : 'e2ee';

const sessionEncryption = encryption.getSessionEncryption(session.id);
if (encryptionMode === 'e2ee' && !sessionEncryption) {
console.error(`Session encryption not found for ${session.id} - this should never happen`);
return null;
}
};

const parsePlainAgentState = (value: string | null): unknown => {
if (!value) return {};
try {
const parsedJson = JSON.parse(value);
const parsed = AgentStateSchema.safeParse(parsedJson);
return parsed.success ? parsed.data : {};
} catch {
return {};
}
};

const metadata =
encryptionMode === 'plain'
? parsePlainMetadata(session.metadata)
: await sessionEncryption!.decryptMetadata(session.metadataVersion, session.metadata);

const agentState =
encryptionMode === 'plain'
? parsePlainAgentState(session.agentState)
: await sessionEncryption!.decryptAgentState(session.agentStateVersion, session.agentState);

// Put it all together
const accessLevel = session.share?.accessLevel;
const normalizedAccessLevel =
accessLevel === 'view' || accessLevel === 'edit' || accessLevel === 'admin' ? accessLevel : undefined;
decryptedSessions.push({
...session,
encryptionMode,
thinking: false,
thinkingAt: 0,
metadata,
agentState,
accessLevel: normalizedAccessLevel,
canApprovePermissions: session.share?.canApprovePermissions ?? undefined,
});
}
const metadata =
encryptionMode === 'plain'
? parsePlainMetadata(session.metadata)
: await sessionEncryption!.decryptMetadata(session.metadataVersion, session.metadata);

const agentState =
encryptionMode === 'plain'
? parsePlainAgentState(session.agentState)
: await sessionEncryption!.decryptAgentState(session.agentStateVersion, session.agentState);

const accessLevel = session.share?.accessLevel;
const normalizedAccessLevel =
accessLevel === 'view' || accessLevel === 'edit' || accessLevel === 'admin' ? accessLevel : undefined;
return {
...session,
encryptionMode,
thinking: false,
thinkingAt: 0,
metadata,
agentState,
accessLevel: normalizedAccessLevel,
canApprovePermissions: session.share?.canApprovePermissions ?? undefined,
};
}),
);
const decryptedSessions = decryptedSessionResults.filter(
(s): s is NonNullable<typeof s> => s !== null,
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.all fail-fast drops all sessions on a single decryption error

Promise.all rejects immediately if any of the async callbacks throws. This means that if sessionEncryption!.decryptMetadata(...) or sessionEncryption!.decryptAgentState(...) rejects for even one session, the entire decryptedSessionResults promise rejects — no sessions will be applied, and applySessions is never called.

In the original sequential for-await loop the behaviour was the same (a throw would propagate), but migrating to parallel execution is the natural moment to also make error handling more resilient. Switching to Promise.allSettled and filtering out rejected outcomes would let successfully-decrypted sessions still load even when one session's crypto call fails:

const decryptedSessionResults = await Promise.allSettled(
    sessions.map(async (session) => {
        // ... same body ...
    }),
);
const decryptedSessions = decryptedSessionResults
    .filter((r): r is PromiseFulfilledResult<NonNullable<...>> =>
        r.status === 'fulfilled' && r.value !== null,
    )
    .map((r) => r.value);

The same concern applies to the first Promise.all at lines 75–83: if encryption.decryptEncryptionKey rejects for any session, the for loop on line 84 never runs, initializeSessions is never called, and the function throws — potentially leaving sessionDataKeys in whatever state it was before this call.

@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2026

Walkthrough

Parallelizes session decryption by converting sequential per-session key and metadata/agentState decryptions to parallel Promise.all flows, adds per-session error handling that preserves nulls for failed decryptions, introduces parsing helpers for plain sessions, and produces normalized decrypted session objects before applying them.

Changes

Cohort / File(s) Summary
Session Decryption Optimization
apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts
Replaced sequential decryption with parallel Promise.all for session encryption keys and session metadata/agentState. Added parsePlainMetadata / parsePlainAgentState helpers. Per-session error handling logs failures and preserves null entries in sessionKeys/sessionDataKeys. Assembles decrypted session objects in parallel (normalizes accessLevel, encryptionMode, includes canApprovePermissions) and applies sessions via applySessions. ReadState repair flow retained and run asynchronously per session with centralized logging.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: parallelizing session decryption on cold start, which is the primary objective of this PR.
Description check ✅ Passed The description covers Summary, Why/Changes, and Test plan sections; however, the checklist items are all unchecked and marked as incomplete, and some optional sections are missing.
Linked Issues check ✅ Passed The PR implements Phase 1 of the proposed fix to improve cold-start performance by parallelizing decryption via Promise.all for both key decryption and session metadata/agentState decryption loops [#123].
Out of Scope Changes check ✅ Passed All changes in sessionSnapshot.ts are directly scoped to Phase 1 parallelization: replacing sequential for-await loops with Promise.all and hoisting pure helper functions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts`:
- Around line 121-155: The current sessions.map used inside
decryptedSessionResults can throw if sessionEncryption.decryptMetadata or
decryptAgentState throws, causing Promise.all to reject; wrap the async callback
body (the logic that computes metadata and agentState for each session) in a
try-catch so individual session failures are caught, log the error with
identifying info (e.g., session.id and which step failed), and return null for
that session so Promise.all resolves with other sessions; keep existing checks
(encryption.getSessionEncryption, handling of 'plain' mode, normalization of
accessLevel) and only catch errors around decryptMetadata/decryptAgentState
within the async function that builds the returned session object.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ae5139a1-77ad-4b91-991b-dea505e17055

📥 Commits

Reviewing files that changed from the base of the PR and between cd992ae and 8ef72d3.

📒 Files selected for processing (1)
  • apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts

Wrap both Promise.all callbacks in try-catch so a single session's
decryption failure (e.g. malformed base64 from server) doesn't prevent
all other sessions from loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts (3)

89-101: Redundant error logging for decryption failures.

When decryptEncryptionKey throws, the error is logged at line 84. Then, the post-processing loop at line 91 logs another error for the same session. Consider removing one of the duplicate logs for cleaner output.

♻️ Proposed fix to remove redundant logging
     for (const { id, decrypted, hasKey } of keyResults) {
         if (hasKey && !decrypted) {
-            console.error(`Failed to decrypt data encryption key for session ${id}`);
             sessionKeys.set(id, null);
             sessionDataKeys.delete(id);
         } else if (hasKey && decrypted) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts` around lines 89 -
101, The loop over keyResults in sessionSnapshot.ts currently logs decryption
failures again even though decryptEncryptionKey already logs the error; remove
the duplicate console.error(`Failed to decrypt data encryption key for session
${id}`) inside the for-loop and leave the rest of the handling (setting
sessionKeys.set(id, null) and sessionDataKeys.delete(id)) intact so decryption
failures are only logged once by decryptEncryptionKey.

115-124: Consider using explicit return type for type consistency.

The parsePlainAgentState function returns unknown, but it would be more precise to return AgentState (or the inferred type from AgentStateSchema) for consistency with schema-driven parsing. This would improve type safety downstream.

♻️ Proposed type improvement
-    const parsePlainAgentState = (value: string | null): unknown => {
+    const parsePlainAgentState = (value: string | null): Record<string, unknown> => {

Or import and use the AgentState type if available:

import { AgentState } from '@/sync/domains/state/storageTypes';
// ...
const parsePlainAgentState = (value: string | null): AgentState => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts` around lines 115 -
124, The parsePlainAgentState function currently has an imprecise return type of
unknown; change its signature to return the concrete AgentState (or the type
exported alongside AgentStateSchema) by importing AgentState from its module and
updating the function signature (parsePlainAgentState(value: string | null):
AgentState). Ensure all early-return paths (falsy value, schema failure, and
catch) return a valid AgentState value (use a typed default or casted
empty/default object consistent with AgentState) so callers get correct typing
from AgentStateSchema.safeParse usage.

174-187: Consider parallelizing the repair loop for consistency.

The repair loop still runs sequentially with for...of and await. Given the PR's goal of improving cold-start performance through parallelization, this loop could also benefit from Promise.all with per-session error handling. However, reviewing the repairInvalidReadStateV1 function shows it already tracks inFlight sessions to prevent duplicate repairs, so concurrent calls would safely no-op for the same session.

♻️ Optional: Parallelize repair loop
     void (async () => {
-        for (const session of decryptedSessions) {
-            try {
+        await Promise.all(decryptedSessions.map(async (session) => {
+            try {
                 const readState = (session.metadata as Metadata | null)?.readStateV1;
                 if (!readState) continue;
-                if (readState.sessionSeq <= (session.seq ?? 0)) continue;
+                if (!readState) return;
+                if (readState.sessionSeq <= (session.seq ?? 0)) return;
                 await repairInvalidReadStateV1({ sessionId: session.id, sessionSeqUpperBound: session.seq ?? 0 });
             } catch (err) {
                 console.error('[sessionsSnapshot] Failed to repair invalid readStateV1', { sessionId: session.id, err });
             }
-        }
+        }));
     })().catch((err) => {
         console.error('[sessionsSnapshot] Invalid readStateV1 repair loop failed', { err });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts` around lines 174 -
187, The sequential repair loop over decryptedSessions should be converted to
run repairs in parallel: replace the for...of/await loop with Promise.all over
decryptedSessions.map(...) where each mapped async function reads
(session.metadata as Metadata | null)?.readStateV1 and skips when absent or when
readState.sessionSeq <= (session.seq ?? 0), then calls
repairInvalidReadStateV1({ sessionId: session.id, sessionSeqUpperBound:
session.seq ?? 0 }) and wraps that call in a try/catch to log failures
per-session (preserving the current console.error usage and message), relying on
repairInvalidReadStateV1's inFlight tracking to prevent duplicate repairs; keep
the outer IIFE and its .catch to log any top-level errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts`:
- Around line 89-101: The loop over keyResults in sessionSnapshot.ts currently
logs decryption failures again even though decryptEncryptionKey already logs the
error; remove the duplicate console.error(`Failed to decrypt data encryption key
for session ${id}`) inside the for-loop and leave the rest of the handling
(setting sessionKeys.set(id, null) and sessionDataKeys.delete(id)) intact so
decryption failures are only logged once by decryptEncryptionKey.
- Around line 115-124: The parsePlainAgentState function currently has an
imprecise return type of unknown; change its signature to return the concrete
AgentState (or the type exported alongside AgentStateSchema) by importing
AgentState from its module and updating the function signature
(parsePlainAgentState(value: string | null): AgentState). Ensure all
early-return paths (falsy value, schema failure, and catch) return a valid
AgentState value (use a typed default or casted empty/default object consistent
with AgentState) so callers get correct typing from AgentStateSchema.safeParse
usage.
- Around line 174-187: The sequential repair loop over decryptedSessions should
be converted to run repairs in parallel: replace the for...of/await loop with
Promise.all over decryptedSessions.map(...) where each mapped async function
reads (session.metadata as Metadata | null)?.readStateV1 and skips when absent
or when readState.sessionSeq <= (session.seq ?? 0), then calls
repairInvalidReadStateV1({ sessionId: session.id, sessionSeqUpperBound:
session.seq ?? 0 }) and wraps that call in a try/catch to log failures
per-session (preserving the current console.error usage and message), relying on
repairInvalidReadStateV1's inFlight tracking to prevent duplicate repairs; keep
the outer IIFE and its .catch to log any top-level errors.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6f81dd55-c9dd-480b-8d7b-bba52ddb2b6e

📥 Commits

Reviewing files that changed from the base of the PR and between 8ef72d3 and 27d1646.

📒 Files selected for processing (1)
  • apps/ui/sources/sync/engine/sessions/sessionSnapshot.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve cold start performance: cache session list for instant display

1 participant