Delivering one message costs O(whole mail store): 71ms at 5,000 messages, under the global lock
Nobody has claimed this yet.
- 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:
rebuildInboxes()— throws awayinboxesand reconstructs every account's threads from every message, on every delivery. Called from 8 places.save()— marshals, encrypts and rewrites the entiremail.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.
addMessageToInboxalready 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
flushUIDsalready 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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