Skip to content

perf(messages): cut redundant work when opening a message - #13359

Open
alexdimarco wants to merge 2 commits into
nextcloud:mainfrom
alexdimarco:perf/message-open-redundant-work
Open

perf(messages): cut redundant work when opening a message#13359
alexdimarco wants to merge 2 commits into
nextcloud:mainfrom
alexdimarco:perf/message-open-redundant-work

Conversation

@alexdimarco

Copy link
Copy Markdown

Problem

Profiling the message-open path (click → rendered body) on a deployment with healthy Redis/APCu caching and a LAN IMAP server showed three pieces of pure redundant work dominating server-side cost per click. Related to the broader efforts in #13162 / #13163 but independent of them — these are strict waste-removals with no behavior change.

What this PR does

  1. IMAPMessage::getHtmlBody() is memoized. MessagesController::getBody calls it twice per request — once for the 600s HTML cache write and again inside getFullMessage() — running the full HTMLPurifier sanitization twice on identical input. For newsletter-sized HTML that is hundreds of milliseconds of pure CPU per click. The memo is per-instance and id-keyed; IMAPMessage is constructed per message fetch, and its inputs are immutable after construction.
  2. ItineraryService::extract() checks adapter availability first. Without a KItinerary adapter installed (the common case), the service still opened an IMAP connection and downloaded the message body plus every raw attachment, only to feed a no-op extractor. It now probes availability before any IMAP work and caches the empty result exactly like a real one (same key, same 7-day TTL) — pre-patch cache semantics are unchanged.
  3. downloadAttachment responses are browser-cacheable (private, immutable, max-age=86400, matching what core's PreviewController does for file previews). Attachment content is immutable for a given message id + attachment id (moves/uidvalidity changes produce new database ids), yet inline cid: images were re-downloaded on every message open — each one paying a fresh IMAP connection + LOGIN.

Test plan

  • New unit tests: memoization (sanitizer invoked exactly once for repeated calls, recomputed for a different id), itinerary no-adapter short-circuit (asserts zero IMAP calls and that the empty result is cached), itinerary cache-hit on second extract (zero IMAP work), and Cache-Control assertions on both attachment branches (named and embedded-message).
  • Full unit suite green against a Nextcloud 34 server checkout.
  • Running in production since 2026-07-25 (backported to 5.10.9) with the expected latency reduction on HTML-heavy messages and instant re-opens of messages with inline images.

@kesselb kesselb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks

If you used AI for the pull request, please add the Assisted-By trailer (c.f. https://github.com/nextcloud/mail/blob/main/AGENTS.md#commit-message-format).

$attachmentName = $attachment->getName();
if ($attachmentName === null) {
return new AttachmentDownloadResponse(
$response = new AttachmentDownloadResponse(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Move $response behind the if block and just set the attachmentName to the default.

// Without an adapter, extraction is a no-op. Skip the expensive
// IMAP download of the message body and all attachments.
$this->logger->debug('No KItinerary adapter is available, skipping itinerary extraction');
$itinerary = new Itinerary();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Move Itinerary before the if-block and remove it in line 70.

// IMAP download of the message body and all attachments.
$this->logger->debug('No KItinerary adapter is available, skipping itinerary extraction');
$itinerary = new Itinerary();
$this->cache->set($this->buildCacheKey($account, $mailbox, $id), json_encode($itinerary), self::CACHE_TTL);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Move $this->buildCacheKey before the if block. Re-use it here and in line 90.

@kesselb

kesselb commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Thanks for your pr.

Three targeted reductions of per-click cost, based on profiling the
message-open path:

- IMAPMessage::getHtmlBody now memoizes the sanitized HTML. getBody
  called it twice per request (cache write + full message), running
  the expensive HTMLPurifier pass twice on the same input
- ItineraryService::extract checks adapter availability BEFORE opening
  an IMAP connection and downloading the message body plus all raw
  attachments. Without a KItinerary adapter installed (the common
  case), all of that work fed an extractor that is a no-op; the empty
  result is now computed upfront and cached like a real one
- downloadAttachment responses are browser-cacheable for 24h (private,
  immutable, matching what core's PreviewController does for file
  previews): attachment content never changes for a given message and
  attachment id, yet inline images were re-downloaded on every message
  open, each one paying a fresh IMAP login

Covered by new unit tests: memoization (sanitizer runs once per id),
itinerary cache hits and the no-adapter skip performing zero IMAP
work, and the Cache-Control headers on both attachment branches.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Alex DiMarco <alex@dimarcotech.com>
@alexdimarco
alexdimarco force-pushed the perf/message-open-redundant-work branch from 2210caa to a9b72dc Compare July 25, 2026 20:58
@alexdimarco

alexdimarco commented Jul 25, 2026 via email

Copy link
Copy Markdown
Author

hasAdapter() duplicated the memoization from extract(), so the
$this->findAvailableAdapter() ?? false assignment appeared twice while
tests/psalm-baseline.xml only carries one entry for it — static analysis
failed on the second occurrence.

Look the adapter up in one private helper that hands out a nullable
Adapter. Behaviour is unchanged, the false sentinel no longer leaks into
the public methods and the psalm baseline stays accurate.

Assisted-by: Claude:claude-opus-5

Signed-off-by: Alex DiMarco <alex@dimarcotech.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants