2040d4abce
* 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>
229 lines
8.5 KiB
C#
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);
|
|
}
|
|
}
|