From 1397dd9018d04ec1c8136045fbe98889e41b0f41 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:06:50 -0700 Subject: [PATCH 1/3] fix: avoid entry author deadlocks Fixes #15768 --- CHANGELOG.md | 4 ++ src/elements/Entry.php | 2 +- tests/unit/services/EntriesTest.php | 99 +++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c82ba25ec85..3f9993950d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes for Craft CMS 5 +## Unreleased + +- Fixed a bug where entries could deadlock when saving their authors. ([#15768](https://github.com/craftcms/cms/issues/15768)) + ## 5.10.13.2 - 2026-08-05 - Fixed a SQL error that could occur when viewing an element with an empty Categories field. ([#19372](https://github.com/craftcms/cms/issues/19372)) diff --git a/src/elements/Entry.php b/src/elements/Entry.php index 9bf451da6bc..49e877aa57b 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -3147,7 +3147,7 @@ private function _saveAuthors(): void $this->_oldAuthorIds = array_map(fn($id) => (int)$id, $oldAuthorIds); } - Db::delete(Table::ENTRIES_AUTHORS, ['entryId' => $this->id]); + Db::deleteIfExists(Table::ENTRIES_AUTHORS, ['entryId' => $this->id]); if (!empty($this->_authorIds)) { $data = []; diff --git a/tests/unit/services/EntriesTest.php b/tests/unit/services/EntriesTest.php index 2ff4ae42b13..dc0b09b3d17 100644 --- a/tests/unit/services/EntriesTest.php +++ b/tests/unit/services/EntriesTest.php @@ -8,6 +8,9 @@ namespace crafttests\unit\services; use Craft; +use craft\db\Command; +use craft\db\Query; +use craft\db\Table; use craft\elements\Entry; use craft\elements\User; use craft\services\Entries; @@ -15,6 +18,7 @@ use crafttests\fixtures\EntryFixture; use crafttests\fixtures\UserFixture; use UnitTester; +use yii\base\Event; /** * Unit tests for the Entries service. @@ -86,6 +90,101 @@ public function testReassignEntries(): void self::assertSame($this->userB->id, $untouchedEntry->getAuthorId()); } + /** + * @throws \Throwable + */ + public function testSaveAuthorsAvoidsDeletingMissingRows(): void + { + $entry = new Entry([ + 'sectionId' => 1000, + 'typeId' => 1000, + 'title' => 'Save Authors Test', + 'authorId' => $this->userA->id, + ]); + + $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($entry)); + + self::assertSame(['INSERT'], $commands); + self::assertSame([ + [$this->userA->id, 1], + ], $this->_savedAuthors($entry->id)); + + $entry->authorId = $this->userB->id; + $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($entry)); + + self::assertSame(['DELETE', 'INSERT'], $commands); + self::assertSame([ + [$this->userB->id, 1], + ], $this->_savedAuthors($entry->id)); + + $section = $entry->getSection(); + self::assertNotNull($section); + $oldMinAuthors = $section->minAuthors; + $section->minAuthors = 0; + $authorlessEntry = new Entry([ + 'sectionId' => 1000, + 'typeId' => 1000, + 'title' => 'Save Without Authors Test', + 'authorIds' => [], + ]); + + try { + $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($authorlessEntry)); + } finally { + $section->minAuthors = $oldMinAuthors; + } + + self::assertSame([], $commands); + self::assertSame([], $this->_savedAuthors($authorlessEntry->id)); + } + + /** + * @param callable(): mixed $callback + * @return string[] + */ + private function _captureAuthorWriteCommands(callable $callback): array + { + $commands = []; + $handler = static function(Event $event) use (&$commands): void { + /** @var Command $command */ + $command = $event->sender; + $sql = ltrim($command->getSql()); + if ( + str_contains($sql, 'entries_authors') && + preg_match('/^(DELETE|INSERT)\b/i', $sql, $matches) + ) { + $commands[] = strtoupper($matches[1]); + } + }; + + Event::on(Command::class, Command::EVENT_BEFORE_EXECUTE, $handler); + try { + $callback(); + } finally { + Event::off(Command::class, Command::EVENT_BEFORE_EXECUTE, $handler); + } + + return $commands; + } + + /** + * @return int[][] + */ + private function _savedAuthors(int $entryId): array + { + $rows = (new Query()) + ->select(['authorId', 'sortOrder']) + ->from(Table::ENTRIES_AUTHORS) + ->where(['entryId' => $entryId]) + ->orderBy(['sortOrder' => SORT_ASC]) + ->all(); + + return array_map( + fn(array $row) => [(int)$row['authorId'], (int)$row['sortOrder']], + $rows, + ); + } + /** * @inheritdoc */ From c1328440ed539db84bd26d2d57f39b9a43425bc2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:31:33 -0700 Subject: [PATCH 2/3] fix: resolve PhpStan issues in testSaveAuthorsAvoidsDeletingMissingRows Hold the saved entry ids in locals asserted non-null before passing them to _savedAuthors, so the nullable id property no longer trips PhpStan. --- tests/unit/services/EntriesTest.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/services/EntriesTest.php b/tests/unit/services/EntriesTest.php index dc0b09b3d17..b294a458c72 100644 --- a/tests/unit/services/EntriesTest.php +++ b/tests/unit/services/EntriesTest.php @@ -103,11 +103,13 @@ public function testSaveAuthorsAvoidsDeletingMissingRows(): void ]); $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($entry)); + $entryId = $entry->id; + self::assertNotNull($entryId); self::assertSame(['INSERT'], $commands); self::assertSame([ [$this->userA->id, 1], - ], $this->_savedAuthors($entry->id)); + ], $this->_savedAuthors($entryId)); $entry->authorId = $this->userB->id; $commands = $this->_captureAuthorWriteCommands(fn() => $this->tester->saveElement($entry)); @@ -115,7 +117,7 @@ public function testSaveAuthorsAvoidsDeletingMissingRows(): void self::assertSame(['DELETE', 'INSERT'], $commands); self::assertSame([ [$this->userB->id, 1], - ], $this->_savedAuthors($entry->id)); + ], $this->_savedAuthors($entryId)); $section = $entry->getSection(); self::assertNotNull($section); @@ -133,9 +135,11 @@ public function testSaveAuthorsAvoidsDeletingMissingRows(): void } finally { $section->minAuthors = $oldMinAuthors; } + $authorlessEntryId = $authorlessEntry->id; + self::assertNotNull($authorlessEntryId); self::assertSame([], $commands); - self::assertSame([], $this->_savedAuthors($authorlessEntry->id)); + self::assertSame([], $this->_savedAuthors($authorlessEntryId)); } /** From ef0ed8f946c7be91393255201c71e49cf7c6e01b Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:27:48 -0700 Subject: [PATCH 3/3] fix: capture author write statements via the query log craft\db\Command has no EVENT_BEFORE_EXECUTE, and neither does yii\db\Command, so the helper referenced a constant that does not exist and PHPStan failed on it. Yii logs every executed statement under the yii\db\Command::execute category, so read the statements from there instead. The assertions are unchanged. --- tests/unit/services/EntriesTest.php | 39 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/unit/services/EntriesTest.php b/tests/unit/services/EntriesTest.php index b294a458c72..6f70a7ce118 100644 --- a/tests/unit/services/EntriesTest.php +++ b/tests/unit/services/EntriesTest.php @@ -8,7 +8,6 @@ namespace crafttests\unit\services; use Craft; -use craft\db\Command; use craft\db\Query; use craft\db\Table; use craft\elements\Entry; @@ -18,7 +17,6 @@ use crafttests\fixtures\EntryFixture; use crafttests\fixtures\UserFixture; use UnitTester; -use yii\base\Event; /** * Unit tests for the Entries service. @@ -148,24 +146,39 @@ public function testSaveAuthorsAvoidsDeletingMissingRows(): void */ private function _captureAuthorWriteCommands(callable $callback): array { + // `yii\db\Command` doesn’t fire an event per statement, but it does log each + // one under the `yii\db\Command::execute` category when logging is enabled. + $db = Craft::$app->getDb(); + $logger = Craft::getLogger(); + $wasLogging = $db->enableLogging; + $oldFlushInterval = $logger->flushInterval; + $db->enableLogging = true; + // Keep the messages in memory for the duration of the callback. + $logger->flushInterval = PHP_INT_MAX; + $startIndex = count($logger->messages); + + try { + $callback(); + } finally { + $db->enableLogging = $wasLogging; + $logger->flushInterval = $oldFlushInterval; + } + $commands = []; - $handler = static function(Event $event) use (&$commands): void { - /** @var Command $command */ - $command = $event->sender; - $sql = ltrim($command->getSql()); + + foreach (array_slice($logger->messages, $startIndex) as $message) { + if (($message[2] ?? null) !== 'yii\db\Command::execute') { + continue; + } + + $sql = ltrim(preg_replace('/^SQL query:\s*/', '', (string)$message[0])); + if ( str_contains($sql, 'entries_authors') && preg_match('/^(DELETE|INSERT)\b/i', $sql, $matches) ) { $commands[] = strtoupper($matches[1]); } - }; - - Event::on(Command::class, Command::EVENT_BEFORE_EXECUTE, $handler); - try { - $callback(); - } finally { - Event::off(Command::class, Command::EVENT_BEFORE_EXECUTE, $handler); } return $commands;