Skip to content

fix: announce only what concurrent override writes stored - #15

Merged
JDCyllene merged 2 commits into
CylleneDigital:mainfrom
JDCyllene:fix/atomic-override-removal
Oct 15, 2026
Merged

JDCyllene merged 2 commits into
CylleneDigital:mainfrom
JDCyllene:fix/atomic-override-removal

Conversation

@JDCyllene

@JDCyllene JDCyllene commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

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 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(), PESSIMISTIC_WRITE) in a transaction of the writer, and the effects follow its commit (withEffectsAfter(), as save() 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, its UPDATE waited and matched nothing (Doctrine does not check how many rows a write touched), and OverrideSavedEvent announced a value that was not stored. A row the save finds is now read again with lockOneByKey(): 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 in docs/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.md and CLAUDE.md are updated.

Points to watch

  • Tests pass (vendor/bin/phpunit): SQLite, MariaDB 11.8, PostgreSQL 17, without intl and with the lowest dependencies
  • PHPStan and PHP-CS-Fixer are green (vendor/bin/phpstan analyse, vendor/bin/php-cs-fixer check)
  • UPGRADE.md is up to date if the public contract changes: the contract does not change
  • A decision taken or reversed has its record in docs/adr/: none, this applies 0013 (stale entities never decide)
  • No real API key appears in the code, the tests or this description

No change to the entity mapping nor to the configuration tree: the Flex recipe stays as it is.

For review:

  • ConcurrentOverrideWritesTest runs 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's save() (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 the databases job of the CI.
  • A removal now opens a transaction of its own; called inside the host's transaction, it nests in it, as save() already did.

@JDCyllene
JDCyllene requested a review from a team as a code owner October 15, 2026 11:22
@JDCyllene JDCyllene changed the title fix: announce the removal of an override once when two requests remove it at once fix: announce only what concurrent override writes stored Oct 15, 2026
…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
JDCyllene force-pushed the fix/atomic-override-removal branch from 4486184 to 741bf04 Compare October 15, 2026 13:56
@JDCyllene
JDCyllene merged commit 10eda85 into CylleneDigital:main Oct 15, 2026
12 checks passed
@JDCyllene
JDCyllene deleted the fix/atomic-override-removal branch October 15, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant