From 580e6a5bc65c5a8318ea858e9176fc14ea625f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Tue, 11 Aug 2026 17:32:44 +0200 Subject: [PATCH] perf(imap): make the ghost reaper free to run when there is nothing to reap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reaper runs with the other self-repairs on every mailbox sync, and the answer is "nothing" on every install that has run it once — so the cost that matters is the asking, repeated per mailbox per poll forever, over the largest table here. A partial index over epoch-dated rows makes that a probe of a relation that is normally empty, and a cheap literal existence check in front of the eight-way predicate is what actually hits it. The literal is deliberate: a bound parameter cannot be proven to imply a partial index's condition at plan time, which would have left the index built and unused. Co-Authored-By: Claude Fable 5 --- migrations/Version20260811160000.php | 49 +++++++++++++++++++++++++ src/Service/Imap/GhostMessageReaper.php | 16 ++++++++ 2 files changed, 65 insertions(+) create mode 100644 migrations/Version20260811160000.php diff --git a/migrations/Version20260811160000.php b/migrations/Version20260811160000.php new file mode 100644 index 00000000..4648c371 --- /dev/null +++ b/migrations/Version20260811160000.php @@ -0,0 +1,49 @@ +addSql( + "CREATE INDEX idx_message_epoch_ghost ON message (id) WHERE received_at < TIMESTAMP '1971-01-01'", + ); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_message_epoch_ghost'); + } +} diff --git a/src/Service/Imap/GhostMessageReaper.php b/src/Service/Imap/GhostMessageReaper.php index 2974d1d9..91acdcc1 100644 --- a/src/Service/Imap/GhostMessageReaper.php +++ b/src/Service/Imap/GhostMessageReaper.php @@ -49,6 +49,22 @@ readonly class GhostMessageReaper */ public function reap(): int { + // Asked before the real query, and this is the only reason the reaper + // can afford to run on every mailbox sync. The full predicate is eight + // conditions over the largest table here; this is one probe of a partial + // index that is empty on any install that has already been cleaned — + // which is every install, after the first run. See + // Version20260811160000, and note the literal: a bound parameter cannot + // be proven to imply the index's condition, so the index would be built + // and then not used. + $suspects = $this->em->getConnection()->fetchOne( + "SELECT 1 FROM message WHERE received_at < TIMESTAMP '1971-01-01' LIMIT 1", + ); + + if (false === $suspects) { + return 0; + } + $ghosts = $this->messages->findEpochGhosts(self::REAP_BATCH); if ([] === $ghosts) {