Repository navigation
fix: announce only what concurrent override writes stored - #15
Merged
JDCyllene merged 2 commits intoOct 15, 2026
Merged
Conversation
…e it at once OverrideWriter::remove() read the override, then deleted it, without a lock. Two removals of the same key at once (two admins resetting the same entry) both found the row: the second DELETE matched nothing, but both invalidated the cache and dispatched OverrideRemovedEvent, so the host purged and notified twice for one removal. The row is now read locked (TranslationOverrideRepository::lockOneByKey()) in a transaction of the writer, and the effects follow its commit: the second removal waits for the first, finds nothing, and announces nothing. The signature of removeOverride() does not change. The test runs a second PHP process that deletes the row in a transaction left open while this one removes it; it fails on the previous code with MariaDB and PostgreSQL, and is skipped on SQLite, which has no row lock.
…same key OverrideWriter::save() read the override without a lock. A save meeting a removal of the same key found the row, its UPDATE waited for the removal and matched nothing, and OverrideSavedEvent announced a value that was not stored: Doctrine does not check how many rows a write touched. A row the save finds is now read again locked (lockOneByKey()): the save waits for the removal, finds nothing, and stores its value in a new row. An absent key is never read locked: on MySQL and MariaDB that takes a gap lock, and two first writes of different keys would wait for each other, or deadlock. The race of record 0016 is unchanged. The batch writes (saveMany(), saveAndRemoveMany(), removeMany()) still read without a lock: locking every row of an import would make concurrent imports wait for each other. The limit is documented in manual-override.md. The concurrency test now also covers a save during a removal (fails on the previous code) and two first writes of different keys through save() in two processes (fails on MariaDB when the absent key is read locked). Each test checks how long the write took, so a slow runner fails it instead of passing it without proving anything, and the pipes are closed before proc_close().
JDCyllene
force-pushed
the
fix/atomic-override-removal
branch
from
October 15, 2026 13:56
4486184 to
741bf04
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this pull request does
Point 4 quater of the changes the Sylius integration asked for, engine side only.
OverrideWriter::remove()read the override, then deleted it, without a lock. Two removals of the same key at once (two admins resetting the same entry) both found the row: the secondDELETEmatched nothing, but both invalidated the cache and dispatchedOverrideRemovedEvent, so the host purged and notified twice for one removal.The row is now read locked (
TranslationOverrideRepository::lockOneByKey(),PESSIMISTIC_WRITE) in a transaction of the writer, and the effects follow its commit (withEffectsAfter(), assave()does): the second removal waits for the first, finds nothing, and announces nothing.After review,
save()is fixed the same way: it read without a lock, so a save meeting a removal of the same key found the row, itsUPDATEwaited and matched nothing (Doctrine does not check how many rows a write touched), andOverrideSavedEventannounced a value that was not stored. A row the save finds is now read again withlockOneByKey(): the save waits for the removal and stores its value in a new row. An absent key is never read locked: on MySQL and MariaDB a locked read of an absent key takes a gap lock, and two first writes of different keys would wait for each other, or deadlock. The race of ADR 0016 (two first writes of the same key) is unchanged. The batch writes (writeMany(): import, batch approval,removeMany()) keep reading without a lock, a limit documented indocs/concepts/manual-override.md.removeOverride()keeps its signature (void): telling the caller whether a row was removed would change an interface a host may implement, so it is left for later.docs/concepts/overrides.md,docs/concepts/manual-override.mdandCLAUDE.mdare updated.Points to watch
vendor/bin/phpunit): SQLite, MariaDB 11.8, PostgreSQL 17, withoutintland with the lowest dependenciesvendor/bin/phpstan analyse,vendor/bin/php-cs-fixer check)UPGRADE.mdis up to date if the public contract changes: the contract does not changedocs/adr/: none, this applies 0013 (stale entities never decide)No change to the entity mapping nor to the configuration tree: the Flex recipe stays as it is.
For review:
ConcurrentOverrideWritesTestruns a second PHP process (proc_open) that writes in a transaction it keeps open for one second while this process writes. Three cases: two removals of the same key (fails on the previous code with MariaDB and PostgreSQL: two events), a save during a removal (fails on the previous code: nothing stored), and two first writes of different keys, both through the bundle'ssave()(fails on MariaDB when the absent key is read locked: the second waits one second). Each case checks how long the write took, so a slow runner fails the test instead of passing it without proving anything. Skipped on SQLite, which has no row lock: it runs in thedatabasesjob of the CI.save()already did.