Skip to content

Optimize mail fetching with batch DB queries and in-memory caching#827

Merged
bkaankose merged 2 commits into
feature/vNextfrom
claude/optimize-mail-threads-l8RYx
Mar 1, 2026
Merged

Optimize mail fetching with batch DB queries and in-memory caching#827
bkaankose merged 2 commits into
feature/vNextfrom
claude/optimize-mail-threads-l8RYx

Conversation

@bkaankose

Copy link
Copy Markdown
Owner

Summary

Refactored the FetchMailsAsync method in MailService to significantly reduce database round-trips by implementing batch queries and pre-loading data into in-memory caches. This improves performance when fetching large numbers of mails, especially when threading is enabled.

Key Changes

  • Batch folder loading: Instead of loading folders one-per-mail, folders are now loaded in batches using Task.WhenAll, with initial seeding from options to avoid redundant DB calls
  • Batch account loading: All accounts are fetched once via GetAccountsAsync() instead of per-mail lookups
  • Batch contact loading: Implemented new GetContactsByAddressesAsync() method in ContactService to fetch all sender contacts in a single SQL IN(...) query instead of individual queries per mail
  • Refactored property assignment: Extracted property assignment logic into new AssignPropertiesFromCaches() method that works entirely from in-memory dictionaries with no DB calls
  • Optimized thread expansion: Thread mail fetching now uses batch queries and reuses the same caching strategy as initial mail loading
  • Early exit optimization: Added early returns when mail lists are empty to avoid unnecessary processing

Implementation Details

  • Removed ConcurrentDictionary usage in favor of regular Dictionary since all operations are now synchronous after pre-loading
  • Changed from per-mail async property loading to a two-phase approach: batch pre-load all data, then synchronously assign from caches
  • Self-sent mails (e.g., in Sent folder) now construct contacts directly from account metadata to avoid unnecessary DB lookups
  • Updated GetMailItemsAsync() to use the same optimized caching pattern
  • Improved cancellation token handling with additional ThrowIfCancellationRequested() calls

Performance Impact

Reduces DB round-trips from O(n) per mail to O(1) for folders, accounts, and contacts, resulting in significantly faster mail list loading especially for large mailboxes and merged views.

https://claude.ai/code/session_018bqahGc6zi95JJhc2aARKS

Replace the sequential per-mail property-loading loop with a three-step
batch pre-load strategy, eliminating the N+1 DB call pattern that was
the main bottleneck when building the mail list with threading enabled.

Changes:
- Pre-seed the folder cache from MailListInitializationOptions.Folders
  so that the most common folders (inbox, sent, etc.) never trigger a DB
  lookup at all.
- Load all accounts in a single GetAccountsAsync() call instead of one
  GetAccountAsync() call per mail (typically 1–5 accounts total).
- Fetch all sender contacts in a single SQL IN(...) query via the new
  GetContactsByAddressesAsync() method instead of one query per address.
- Property assignment is now fully synchronous (no awaits in the loop)
  since all data is pre-loaded into plain Dictionary<K,V>.
- Thread-expansion follows the same pattern: new folder IDs are loaded
  in parallel via Task.WhenAll; new contact addresses are batch-fetched
  with a second IN(...) query.
- Also apply batch pre-loading to GetMailItemsAsync (used by merge-inbox
  sync path) which had the same sequential issue.
- Remove the now-unused LoadAssignedPropertiesWithCacheAsync helper and
  the ConcurrentDictionary dependency it required.
- Tighten GetMailsByThreadIdsAsync to skip the Id NOT IN clause entirely
  when the exclusion set is empty.

https://claude.ai/code/session_018bqahGc6zi95JJhc2aARKS
Adds integration tests for MailService.FetchMailsAsync that exercise the
full real-service stack (MailService → FolderService / AccountService /
ContactService) backed by the shared in-memory SQLite helper.

Four tests are included:

• ExpandsSiblingsOutsidePage – proves thread expansion fetches mails that
  fall beyond the initial SQL page (6 mails, page=4, expects 6 returned).

• NeverExpandsSiblings – proves threading is truly opt-in; with
  CreateThreads=false the result exactly matches the raw page size.

• ResolvesFromAllThreeSources – verifies contact resolution for a known
  contact (from the AccountContact table), an unknown sender (ad-hoc
  fallback), and a self-sent mail (built from account metadata).

• 1000Mails_70Threads_CompletesWithinBudget – the performance scenario:
  1 000 mails (70 threads × 7 + 510 standalone), 40 rotating sender
  addresses (20 with DB contacts). Times and reports two scenarios:
    - Default first-page fetch (100 mails) + expansion of one partial
      thread (expects > 100 mails returned).
    - Full load of all 1 000 mails with threading enabled (expects
      exactly 1 000 mails returned, all 70 threads intact, < 5 s).

  Elapsed times for both scenarios are written to xUnit test output so
  they appear in CI logs and can be tracked across builds.

https://claude.ai/code/session_018bqahGc6zi95JJhc2aARKS
@bkaankose
bkaankose marked this pull request as ready for review March 1, 2026 08:13
@bkaankose
bkaankose merged commit 2040d4a into feature/vNext Mar 1, 2026
7 checks passed
@bkaankose
bkaankose deleted the claude/optimize-mail-threads-l8RYx branch March 1, 2026 08:14
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.

2 participants