Skip to content

A full IMAP enumeration rewrites every membership row it already holds #749

Description

@exactmike

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:

  1. reads the message_id of every saved membership row,
  2. deletes every one of those rows,
  3. inserts them again, each insert preceded by two capture queries,
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions