What happens
A full enumeration publishes one Reset delta for each mailbox. The delta
carries the mailbox's complete membership set, which is almost always the set
already saved.
applyIMAPMailboxDeltas (internal/store/imap_memberships.go) treats a Reset
as a rewrite. For one mailbox it:
- reads the
message_id of every saved membership row,
- deletes every one of those rows,
- inserts them again, each insert preceded by two capture queries,
- rebuilds the labels and the tombstone state of every message it captured.
That is about nine statements for every message in the mailbox, and they all run
in one write transaction. On this Microsoft 365 account the INBOX holds
~96,000 messages and the source holds ~118,000. One apply runs more than a
million statements to store rows that did not change.
The database logger reports the transaction:
sql tx slow (duration_ms=230461)
The WARN threshold is 10x the 100 ms default in init
(internal/store/db_logger.go). A second run measured 231,099 ms. Both figures
come from main at d2a47d45 on 2026-08-28. A Dovecot server with 100,066
messages reproduces it at 235,393 ms. The same function on an incremental delta
takes 191 ms.
The write lock is held for the whole transaction, so every other writer waits.
Why it still matters after #699 and #711
Those two changes make a full enumeration rare, not impossible. A UIDVALIDITY
change, a first sync of a mailbox, and a listing that the delta path cannot
resolve all still produce a Reset. When one fires, it costs minutes of write
lock.
Reproduction against a live server
A Dovecot server with 100,066 messages in three folders, and a vault already
synced against it. msgvault sync-full --noresume forces the full enumeration,
which publishes a Reset delta for every folder. Nothing changed on the server
between the two runs except one message that arrived earlier.
Two copies of the same vault, two builds, run one after the other:
|
main at 4b5327bb |
with the fix below |
| wall clock |
266s |
36s |
| sync phase, as reported by the command |
26s |
26s |
| slow transaction |
sql tx slow (duration_ms=235393) |
none reported |
| membership rows rewritten |
100,066 |
1 |
The rewritten count is updated_at later than the pre-run maximum. The one row
is the message the vault did not hold yet. The two vaults finish with the same
memberships, folder states, message-label pairs, tombstones and message count,
by MD5 over the sorted rows of each table.
The sync phase costs the same in both arms. The difference is the transaction
that stores the result.
The same measurement on the production vault
A copy of the Microsoft 365 vault: 40 mailboxes, 118,115 membership rows,
12.4 GB SQLite file. Each arm applies one authoritative topology of 40 Reset
deltas built from the memberships read out of that vault, so nothing changes.
| arm |
apply duration |
main at 4b5327bb, cold page cache |
4m40.7s |
main at 4b5327bb, warm |
4m39.6s |
| the change below, warm |
1.1s |
After the three runs, the copy still matches an untouched copy of the same
vault on every table above.
The same comparison also ran as a forced full enumeration against the live
Microsoft 365 account, not a replayed transaction. Two copies started from a
clean sync, main at 4b5327bb then the change below, one after the other.
Real mail moved between the two runs, so the two copies do not match each
other afterward. Each still passed its own consistency check. Every
message's labels match the mailboxes named by its stored memberships, and
every message with no stored membership is tombstoned.
| arm |
store transaction |
membership rows rewritten |
main at 4b5327bb |
282.3s |
118,136 |
| the change below |
2.9s |
339 |
Suggested fix
Diff, do not rewrite. Read the mailbox's saved rows once, delete the UIDs the
reset does not republish, and write only the memberships whose message or flags
moved. Only those messages need their labels rebuilt: a membership set that did
not change cannot change a label.
Three conditions have to hold.
- Flags must be compared, not only UIDs. A diff that keys on UID alone stops
persisting flag changes.
- The comparison must read the saved rows, not the client's prior baseline. The
reset exists to correct a baseline that drifted.
captureUntrackedIMAPMessageIDs must keep running. It reconciles messages
that have no membership row at all, and the diff cannot see them.
What the fix gives up
Today a Reset rewrites the label rows of every message in the mailbox,
whether or not its membership changed. That is a repair as well as a write: it
corrects label drift that this code path did not cause.
A diff stops doing that. Only the messages whose membership changed have their
labels rebuilt.
Tombstones are not affected. For an IMAP source the only writers of
deleted_from_source_at are the two statements at the end of
applyIMAPMailboxDeltas. The tombstone state of an unchanged membership is
already correct. Label rows deleted or corrupted outside this path are the
part that a full enumeration stops repairing.
Keeping the repair is possible, at a cost. Rewriting every message's labels
while still skipping the membership writes would keep the reconciliation. The
cost would land somewhere between the two numbers above.
The specific gap, and a proposed explicit repair command in place of the
blanket rebuild, are in #748.
PR: #750
What happens
A full enumeration publishes one
Resetdelta for each mailbox. The deltacarries the mailbox's complete membership set, which is almost always the set
already saved.
applyIMAPMailboxDeltas(internal/store/imap_memberships.go) treats aResetas a rewrite. For one mailbox it:
message_idof every saved membership row,That is about nine statements for every message in the mailbox, and they all run
in one write transaction. On this Microsoft 365 account the INBOX holds
~96,000 messages and the source holds ~118,000. One apply runs more than a
million statements to store rows that did not change.
The database logger reports the transaction:
The WARN threshold is 10x the 100 ms default in
init(
internal/store/db_logger.go). A second run measured 231,099 ms. Both figurescome from
mainatd2a47d45on 2026-08-28. A Dovecot server with 100,066messages reproduces it at 235,393 ms. The same function on an incremental delta
takes 191 ms.
The write lock is held for the whole transaction, so every other writer waits.
Why it still matters after #699 and #711
Those two changes make a full enumeration rare, not impossible. A UIDVALIDITY
change, a first sync of a mailbox, and a listing that the delta path cannot
resolve all still produce a
Reset. When one fires, it costs minutes of writelock.
Reproduction against a live server
A Dovecot server with 100,066 messages in three folders, and a vault already
synced against it.
msgvault sync-full --noresumeforces the full enumeration,which publishes a
Resetdelta for every folder. Nothing changed on the serverbetween the two runs except one message that arrived earlier.
Two copies of the same vault, two builds, run one after the other:
mainat4b5327bbsql tx slow (duration_ms=235393)The rewritten count is
updated_atlater than the pre-run maximum. The one rowis the message the vault did not hold yet. The two vaults finish with the same
memberships, folder states, message-label pairs, tombstones and message count,
by MD5 over the sorted rows of each table.
The sync phase costs the same in both arms. The difference is the transaction
that stores the result.
The same measurement on the production vault
A copy of the Microsoft 365 vault: 40 mailboxes, 118,115 membership rows,
12.4 GB SQLite file. Each arm applies one authoritative topology of 40
Resetdeltas built from the memberships read out of that vault, so nothing changes.
mainat4b5327bb, cold page cachemainat4b5327bb, warmAfter the three runs, the copy still matches an untouched copy of the same
vault on every table above.
The same comparison also ran as a forced full enumeration against the live
Microsoft 365 account, not a replayed transaction. Two copies started from a
clean sync,
mainat4b5327bbthen the change below, one after the other.Real mail moved between the two runs, so the two copies do not match each
other afterward. Each still passed its own consistency check. Every
message's labels match the mailboxes named by its stored memberships, and
every message with no stored membership is tombstoned.
mainat4b5327bbSuggested fix
Diff, do not rewrite. Read the mailbox's saved rows once, delete the UIDs the
reset does not republish, and write only the memberships whose message or flags
moved. Only those messages need their labels rebuilt: a membership set that did
not change cannot change a label.
Three conditions have to hold.
persisting flag changes.
reset exists to correct a baseline that drifted.
captureUntrackedIMAPMessageIDsmust keep running. It reconciles messagesthat have no membership row at all, and the diff cannot see them.
What the fix gives up
Today a
Resetrewrites the label rows of every message in the mailbox,whether or not its membership changed. That is a repair as well as a write: it
corrects label drift that this code path did not cause.
A diff stops doing that. Only the messages whose membership changed have their
labels rebuilt.
Tombstones are not affected. For an IMAP source the only writers of
deleted_from_source_atare the two statements at the end ofapplyIMAPMailboxDeltas. The tombstone state of an unchanged membership isalready correct. Label rows deleted or corrupted outside this path are the
part that a full enumeration stops repairing.
Keeping the repair is possible, at a cost. Rewriting every message's labels
while still skipping the membership writes would keep the reconciliation. The
cost would land somewhere between the two numbers above.
The specific gap, and a proposed explicit repair command in place of the
blanket rebuild, are in #748.
PR: #750