micro / micro/mu

Delivering one message costs O(whole mail store): 71ms at 5,000 messages, under the global lock

Open
#1,464 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Go
Stars
434
Forks
21
Avg merge
18m
Merged PRs (30d)
326

Description

Measured, not asserted

Benchmark added in b1a69d3a (service/mail/bench_test.go):

go test ./service/mail/ -run xxx -bench AsTheStoreGrows -benchtime 20x
messages already stored one delivery one mail_inbox
100 1.4 ms 0.46 ms
1,000 10.4 ms 4.4 ms
5,000 71.3 ms 20.6 ms

Worse than linear — 5x the messages costs 6.9x the time.

Why

SendMessageTo does two things over the whole store, not over the message:

  1. rebuildInboxes() — throws away inboxes and reconstructs every account's threads from every message, on every delivery. Called from 8 places.
  2. save() — marshals, encrypts and rewrites the entire mail.json.

Both run holding the package's write mutex, so every reader and every other writer on the instance waits behind them. At 5,000 messages that is a 71ms global stall per message delivered. At 50,000 it is closer to a second.

Reads are linear too: messages is one flat slice with no index, so ListMessages, MessageUnlocked, byMessageIDUnlocked, imapFolder and about twenty other loops each scan all of it.

Why it is invisible until it isn't

Nothing gets slower in a way anyone notices — it degrades by a millisecond a week. A test store never reaches the size where it matters, and the instance that does has no benchmark watching it. The numbers above exist so this argues from measurement.

Directions, cheapest first

  • Do not rebuild everything to add one message. addMessageToInbox already exists and takes a single message; delivery can update the affected inboxes incrementally instead of discarding the map. This is the single biggest win and does not change the storage format.
  • Do not rewrite the file per message. Coalesce saves on a short timer, as flushUIDs already does for the IMAP numbering — the pattern is in the package with a comment explaining exactly this reasoning.
  • Index by what is looked up. id → *Message, Message-ID → *Message, account → []*Message. Turns the common lookups from O(n) into O(1) and removes the ~20 hand-written scans.
  • Then consider the store. Whole-file JSON is the underlying hazard and it is also #1458's subject — an append-only log or SQLite would fix both, and neither is worth doing before the three above.

Related

  • #1458 — same file, different failure: whole-file rewrite is also why one bad write destroyed a live store.
  • No cap on a stored message body from the non-SMTP doors, so a large body is permanent and makes every number above worse. Filed separately.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with service/mail/bench_test.go and run the AsTheStoreGrows benchmark, then trace SendMessageTo through rebuildInboxes, save, addMessageToInbox, and the flushUIDs pattern. Compare the affected inbox and persistence paths against the measured timings; done means the selected optimization is benchmarked without changing the storage format or behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
backend, performance
Issue type
Refactor
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.