perf(imap): make the ghost reaper free to run when there is nothing to reap

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 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Fable 5 committed 2026-08-11 17:32:44 +02:00
1 parent 8aeca04f0b
commit 580e6a5bc6
2 files changed
+65

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Makes "are there any ghost rows left" free to ask.
*
* GhostMessageReaper runs with the other self-repairs on every mailbox sync,
* looking for the epoch-dated corpses a failed IMAP fetch used to leave behind
* (see MessageSyncer::isUsableFetch). The answer is no on every install that has
* run it once — which, after the first sync, is every install — so the cost that
* matters is not the cleanup but the asking, repeated per mailbox per poll
* forever.
*
* Without an index that is a sequential scan of the message table each time, on
* a table that is the largest one here by a wide margin. A partial index makes
* it an index probe of a relation that is normally empty: nothing real is dated
* 1970, so this indexes the rows that should not exist and nothing else. On a
* healthy database it occupies essentially no space.
*
* The predicate is a literal rather than a parameter so the planner can match
* it against the reaper's own literal probe — 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.
*/
final class Version20260811160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Partial index over epoch-dated message rows, so the ghost reaper costs nothing when there is nothing to reap';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+16
View File
@@ -49,6 +49,22 @@ readonly class GhostMessageReaper
*/ */
public function reap(): int 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); $ghosts = $this->messages->findEpochGhosts(self::REAP_BATCH);
if ([] === $ghosts) { if ([] === $ghosts) {