Skip to content

feat: load the stories of foundry:load-fixtures in a transaction - #1173

Open
Amoifr wants to merge 5 commits into
zenstruck:2.xfrom
Amoifr:feat-1078-load-fixtures-transaction
Open

Amoifr wants to merge 5 commits into
zenstruck:2.xfrom
Amoifr:feat-1078-load-fixtures-transaction

Conversation

@Amoifr

@Amoifr Amoifr commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1078.

foundry:load-fixtures loads its stories in a transaction, so that a story failing halfway doesn't leave the stories loaded before it in the database. The transaction is opened on each ORM connection, MongoDB is not covered, and the database reset stays outside of it.

@nikophil nikophil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for this @Amoifr

I think we should also introduce a --no-transaction option to the command, so that if the transaction is a problem for anyone, it could be skipped

Comment thread config/persistence.php Outdated
->arg('$fixtureStoryResolver', service('.zenstruck_foundry.story.fixture_resolver'))
->arg('$databaseResetters', tagged_iterator('.foundry.persistence.database_resetter'))
->arg('$kernel', service('kernel'))
->arg('$registry', service('doctrine')->nullOnInvalid())

@nikophil nikophil Sep 26, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of injecting the doctrine registry, could we inject .zenstruck_foundry.persistence_manager?

I'd suggest a transactional(callable) method on PersistenceStrategy: the ORM strategy wraps the callback in a transaction on its connections, and the Mongo strategy just calls it as a no-op operation (I'm planning to refactor this whole part of persistence some day, and the no-op operation won't exist anymore, but for now it is ok-ish I think.

PersistenceManager::transactional() would then nest the strategies, and the command would boil down to $this->persistenceManager->transactional(fn() => $this->loadStories(...)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: PersistenceStrategy::transactional() (the class is @internal), one transaction per ORM connection in AbstractORMPersistenceStrategy, a plain call in MongoPersistenceStrategy, and PersistenceManager::transactional() nests the strategies. The command only gets the persistence manager now.

Comment thread src/Command/LoadFixturesCommand.php Outdated
} catch (\Throwable $e) {
foreach ($connections as $connection) {
if ($connection->isTransactionActive()) {
$connection->rollBack();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here, if an error occurs while rolling-back, the original error would be swallowed and hidden

could we do this in a finally block, the same way it is done here?
https://github.com/symfony/symfony/blob/8.2/src/Symfony/Bridge/Doctrine/Messenger/DoctrineTransactionMiddleware.php

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to a finally, like DoctrineTransactionMiddleware: PHP then attaches the original exception as the previous one of the rollback error. OrmTransactionalTest::it_keeps_the_original_error_when_the_rollback_fails covers it, and fails with the former catch.

Comment thread docs/index.rst Outdated
Comment on lines +1769 to +1770
With Doctrine ORM, the stories are loaded in a single transaction: if one of them fails, none of them is kept in the
database.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you reword it to say it applies per ORM connection, and that MongoDB is not covered?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded: a transaction per ORM connection, and MongoDB is not covered.

@Amoifr

Amoifr commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Added in the last commit, @nikophil: --no-transaction loads the stories directly, without the transaction. There's a test for it (the stories loaded before the failure are kept), and a line in the docs. Good call, an escape hatch is cheaper than a bug report. 😄

// in "finally", so that an error while rolling back doesn't hide the original one
if (!$success) {
foreach ($connections as $connection) {
if ($connection->isTransactionActive()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rollback relies on isTransactionActive(), which tells whether a transaction is active, not whether ours still is.

When the call is nested in an outer transaction (DAMA, or any caller already in a transaction), if the first connection commits (which only releases the savepoint, so it stays active at level 1) and the second one fails, this finally rolls back the first connection again, i.e. the outer transaction.

Could we track the connections we actually opened (add after beginTransaction(), remove after commit()) and only roll those back? It would also make isTransactionActive() unnecessary. The unit test doesn't catch it since the mock always returns true for isTransactionActive().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks! It now keeps the transactions it opened and hasn't committed yet, and rolls back only those, so isTransactionActive() is gone. it_only_rolls_back_the_transactions_it_still_has_open reproduces your scenario (it fails on the previous code).

$connection->method('rollBack')->willThrowException(new \LogicException('rollback failed'));

try {
$this->strategy($connection)->transactional(static fn() => throw new \RuntimeException('story failed'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if no exception is thrown, $e is undefined and the test fails with a confusing error. A self::fail('...') right after this call would make it explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. PHPStan flagged it as unreachable since the callback always throws, so the callback now comes from a small failingStory() helper typed as returning a string.

Comment thread src/Command/LoadFixturesCommand.php Outdated
if ($input->getOption('no-transaction')) {
$this->loadStories($io, $resolvedStories);
} else {
// All the stories are loaded, or none of them: a story failing halfway must not

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this comment paraphrases what transactional() already says, I think it can be removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

*
* @return T
*/
public function transactional(callable $callback): mixed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: there's no direct test for this method, the nesting of the strategies is only covered indirectly by the mysql|mongo jobs. Not blocking, but a small unit test would document the behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added PersistenceManagerTransactionalTest: two strategies, it checks the nesting order and that the callback runs once and its result comes back.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

foundry:load-fixtures: wrap into a transaction

2 participants