From bdcce85acbe0a27130a4d5ab6553436b2c1eb6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Wed, 23 Sep 2026 18:58:30 +0200 Subject: [PATCH] fix(jmap): state tokens follow commit order per account The change log's identity value is the state token and is drawn at INSERT, so two writers of one account could commit out of order and a client holding the later token never saw the earlier change. A BEFORE INSERT trigger now takes a per-account advisory xact lock and draws the number after it. A trigger because Doctrine fires no event inside its flush transaction before the inserts. Co-Authored-By: Claude Opus 5.5 --- migrations/Version20260923140100.php | 76 +++++++++++++ src/Jmap/State/ChangeLog.php | 5 + tests/Jmap/State/ChangeLogCommitOrderTest.php | 106 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 migrations/Version20260923140100.php create mode 100644 tests/Jmap/State/ChangeLogCommitOrderTest.php diff --git a/migrations/Version20260923140100.php b/migrations/Version20260923140100.php new file mode 100644 index 00000000..97774ec6 --- /dev/null +++ b/migrations/Version20260923140100.php @@ -0,0 +1,76 @@ +addSql(<<<'SQL' + CREATE OR REPLACE FUNCTION plmail_jmap_change_log_sequence() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(1246576976, NEW.account_id); + NEW.sequence := nextval(pg_get_serial_sequence('jmap_change_log', 'sequence')); + RETURN NEW; + END + $$ + SQL); + + $this->addSql(<<<'SQL' + CREATE OR REPLACE TRIGGER jmap_change_log_sequence + BEFORE INSERT ON jmap_change_log + FOR EACH ROW EXECUTE FUNCTION plmail_jmap_change_log_sequence() + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TRIGGER IF EXISTS jmap_change_log_sequence ON jmap_change_log'); + $this->addSql('DROP FUNCTION IF EXISTS plmail_jmap_change_log_sequence()'); + } +} diff --git a/src/Jmap/State/ChangeLog.php b/src/Jmap/State/ChangeLog.php index 2a9ab239..83a3bff2 100644 --- a/src/Jmap/State/ChangeLog.php +++ b/src/Jmap/State/ChangeLog.php @@ -13,6 +13,11 @@ use Doctrine\ORM\Mapping as ORM; * the highest sequence recorded for it, and "/changes" simply returns rows * with sequence > sinceState. * + * That only holds if a later number never commits before an earlier one of the + * same account, and an identity value is drawn at INSERT, not COMMIT. A BEFORE + * INSERT trigger (Version20260923140100) takes a per-account advisory lock and + * then draws the number, so one account's writers queue until commit. + * * accountId is stored as a scalar (not a ManyToOne) on purpose: these rows are * written from long-running sync handlers where holding entity references * across flush() is the documented footgun. A plain id sidesteps it entirely. diff --git a/tests/Jmap/State/ChangeLogCommitOrderTest.php b/tests/Jmap/State/ChangeLogCommitOrderTest.php new file mode 100644 index 00000000..748c1bc5 --- /dev/null +++ b/tests/Jmap/State/ChangeLogCommitOrderTest.php @@ -0,0 +1,106 @@ +connection = self::getContainer()->get(Connection::class); + $this->connection->beginTransaction(); + + require_once \dirname(__DIR__, 3) . '/migrations/Version20260923140100.php'; + + $migration = new Version20260923140100($this->connection, new NullLogger()); + $migration->up(new Schema()); + + foreach ($migration->getSql() as $query) { + $this->connection->executeStatement($query->getStatement()); + } + } + + protected function tearDown(): void + { + $this->observer?->close(); + + if (true === $this->connection->isTransactionActive()) { + $this->connection->rollBack(); + } + + parent::tearDown(); + } + + public function testAWriterHoldsItsAccountUntilCommitAndKeepsTheStoredNumber(): void + { + $container = self::getContainer(); + $em = $container->get(EntityManagerInterface::class); + + $container->get(StateManager::class)->recordCreated(self::ACCOUNT, JmapObjectType::Email, 'e-1'); + $em->flush(); + + $row = $em->getRepository(ChangeLog::class)->findOneBy(['accountId' => self::ACCOUNT]); + self::assertNotNull($row); + + $stored = (int) $this->connection->fetchOne( + 'SELECT sequence FROM jmap_change_log WHERE account_id = ?', + [self::ACCOUNT], + ); + + self::assertSame($stored, $row->sequence, 'the entity carries the number the trigger stored'); + + // A second session, as a concurrent writer would be. + $this->observer = DriverManager::getConnection($this->connection->getParams()); + + self::assertFalse( + (bool) $this->observer->fetchOne( + 'SELECT pg_try_advisory_xact_lock(?, ?)', + [self::LOCK_NAMESPACE, self::ACCOUNT], + ), + 'another writer of this account must wait for the commit', + ); + + self::assertTrue( + (bool) $this->observer->fetchOne( + 'SELECT pg_try_advisory_xact_lock(?, ?)', + [self::LOCK_NAMESPACE, self::OTHER_ACCOUNT], + ), + 'writers of other accounts are not held up', + ); + } +}