Files
Wino-Mail/Wino.Services/ContactService.cs
T
Burak Kaan Köse 2040d4abce Optimize mail fetching with batch DB queries and in-memory caching (#827)
* perf: batch-load folders, accounts, and contacts in FetchMailsAsync

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

* test: add MailFetchingTests with correctness and performance coverage

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-01 09:14:02 +01:00

229 lines
8.5 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MimeKit;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Contacts;
using Wino.Services.Extensions;
namespace Wino.Services;
public class ContactService : BaseDatabaseService, IContactService
{
public ContactService(IDatabaseService databaseService) : base(databaseService) { }
public async Task<AccountContact> CreateNewContactAsync(string address, string displayName)
{
var contact = new AccountContact() { Address = address, Name = displayName };
await Connection.InsertAsync(contact, typeof(AccountContact)).ConfigureAwait(false);
return contact;
}
public Task<List<AccountContact>> GetAddressInformationAsync(string queryText)
{
if (queryText == null || queryText.Length < 2)
return Task.FromResult<List<AccountContact>>(null);
const string query = "SELECT * FROM AccountContact WHERE Address LIKE ? OR Name LIKE ?";
var pattern = $"%{queryText}%";
return Connection.QueryAsync<AccountContact>(query, pattern, pattern);
}
public Task<AccountContact> GetAddressInformationByAddressAsync(string address)
=> Connection.Table<AccountContact>().FirstOrDefaultAsync(a => a.Address == address);
public Task<List<AccountContact>> GetContactsByAddressesAsync(IEnumerable<string> addresses)
{
var addressList = addresses?.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList();
if (addressList == null || addressList.Count == 0)
return Task.FromResult(new List<AccountContact>());
var placeholders = string.Join(",", addressList.Select(_ => "?"));
return Connection.QueryAsync<AccountContact>(
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
addressList.Cast<object>().ToArray());
}
public async Task SaveAddressInformationAsync(MimeMessage message)
{
if (message == null) return;
var contacts = message
.GetRecipients(true)
.Where(a => !string.IsNullOrWhiteSpace(a.Address))
.Select(a => new AccountContact
{
Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address : a.Name,
Address = a.Address
});
await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false);
}
public async Task SaveAddressInformationAsync(IEnumerable<AccountContact> contacts)
{
if (contacts == null) return;
await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false);
}
private async Task SaveAddressInformationInternalAsync(IEnumerable<AccountContact> contacts)
{
var addressInformations = contacts
.Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address))
.Select(a => new AccountContact
{
Address = a.Address.Trim(),
Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address.Trim() : a.Name.Trim()
})
.GroupBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
if (addressInformations.Count == 0) return;
try
{
// Batch-fetch all existing contacts in one query.
var addresses = addressInformations.Select(a => a.Address).ToList();
var placeholders = string.Join(",", addresses.Select((_, i) => "?"));
var existingContacts = await Connection.QueryAsync<AccountContact>(
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
addresses.Cast<object>().ToArray()
).ConfigureAwait(false);
var existingLookup = existingContacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase);
var toInsert = new List<AccountContact>();
var toUpdate = new List<AccountContact>();
foreach (var info in addressInformations)
{
if (!existingLookup.TryGetValue(info.Address, out var existing))
{
toInsert.Add(info);
}
else if (!existing.IsRootContact && !existing.IsOverridden)
{
// Only update if the new name is more informative (not just the email address)
// and actually different from the current name.
if (info.Name != info.Address && existing.Name != info.Name)
{
existing.Name = info.Name;
toUpdate.Add(existing);
}
}
}
if (toInsert.Count > 0 || toUpdate.Count > 0)
{
await Connection.RunInTransactionAsync(conn =>
{
if (toInsert.Count > 0)
conn.InsertAll(toInsert, typeof(AccountContact));
foreach (var contact in toUpdate)
conn.Update(contact, typeof(AccountContact));
}).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to batch save contact information to the database.");
}
}
public Task<List<AccountContact>> GetAllContactsAsync()
{
return Connection.Table<AccountContact>().ToListAsync();
}
public Task<List<AccountContact>> SearchContactsAsync(string searchQuery)
{
if (string.IsNullOrWhiteSpace(searchQuery))
return GetAllContactsAsync();
const string query = "SELECT * FROM AccountContact WHERE Address LIKE ? OR Name LIKE ?";
var pattern = $"%{searchQuery.Trim()}%";
return Connection.QueryAsync<AccountContact>(query, pattern, pattern);
}
public async Task<PagedContactsResult> GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false)
{
offset = Math.Max(0, offset);
pageSize = Math.Max(1, pageSize);
var whereClauses = new List<string>();
var parameters = new List<object>();
if (excludeRootContacts)
{
whereClauses.Add("IsRootContact = 0");
}
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var pattern = $"%{searchQuery.Trim()}%";
whereClauses.Add("(Address LIKE ? OR Name LIKE ?)");
parameters.Add(pattern);
parameters.Add(pattern);
}
var whereSql = whereClauses.Count > 0
? $" WHERE {string.Join(" AND ", whereClauses)}"
: string.Empty;
var countQuery = $"SELECT COUNT(*) FROM AccountContact{whereSql}";
var totalCount = await Connection.ExecuteScalarAsync<int>(countQuery, parameters.ToArray()).ConfigureAwait(false);
var pageParameters = new List<object>(parameters)
{
pageSize,
offset
};
var pageQuery = $"SELECT * FROM AccountContact{whereSql} ORDER BY COALESCE(Name, Address) COLLATE NOCASE, Address COLLATE NOCASE LIMIT ? OFFSET ?";
var contacts = await Connection.QueryAsync<AccountContact>(pageQuery, pageParameters.ToArray()).ConfigureAwait(false);
var hasMore = offset + contacts.Count < totalCount;
return new PagedContactsResult(contacts, totalCount, hasMore, offset, pageSize);
}
public async Task<AccountContact> UpdateContactAsync(AccountContact contact)
{
// Mark the contact as overridden when manually updated
contact.IsOverridden = true;
await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false);
return contact;
}
public async Task DeleteContactAsync(string address)
{
var contact = await GetAddressInformationByAddressAsync(address).ConfigureAwait(false);
if (contact != null && !contact.IsRootContact)
{
await Connection.DeleteAsync<AccountContact>(contact.Address).ConfigureAwait(false);
}
}
public async Task DeleteContactsAsync(IEnumerable<string> addresses)
{
var addressList = addresses.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (addressList.Count == 0) return;
var placeholders = string.Join(",", addressList.Select((_, i) => "?"));
await Connection.ExecuteAsync(
$"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0",
addressList.Cast<object>().ToArray()
).ConfigureAwait(false);
}
}