Bug: Concurrent updates can bypass optimistic locking and silently overwrite each other #20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
AbstractRepository.update()(src/repositories/AbstractRepository.ts) does not reliably reject a stale write. Two saves of the same entity that overlap in time can both succeed, and the later one silently overwrites the earlier one. Recipes and meal plans use this path.Cause
update()checks the version and saves in two separate statements within one transaction:findOneOrFail(..., { lock: { mode: "optimistic", version } })reads the row and compares its version.em.save(entity)writes the row.@VersionColumnaddsversion = version + 1to theUPDATE, but TypeORM does not add the version to theWHEREclause.Under PostgreSQL's default isolation (READ COMMITTED), the check does not lock the row. Two transactions that both read version N both pass the check, and both
UPDATEs run. The second waits for the first's row lock, then applies on top of it.Example
version: 3.recipe-platform/docs/api-conventions.md, "Optimistic Locking") promises.The window is small (the time between the check and the save), so this is rare in practice, but the guarantee does not hold.
Possible fix
Put the version check into the write itself, as a conditional
UPDATE ... WHERE id = :id AND version = :version. If no row is affected, a follow-up existence check decides between 404 and 409.docs/adr/2026-10-02-shopping-list-item-writes.md(spec 001) takes this approach for shopping list items. Alternatives would beSELECT ... FOR UPDATEbefore the check, or a pessimistic lock inupdate().Found
During the recipe-backend repo architecture step for spec 001 (shopping lists). The new shopping list item writes avoid the problem. Recipes and meal plans were left unchanged on purpose and need this fix separately.
Two additions from the spec 001 repo architect, for whoever picks this up:
AbstractRepositorytake anEntityManager(defaultAppDataSource.manager) instead of binding toAppDataSource.getRepositoryin its constructor. Its methods can then run inside a caller's transaction, e.g. withSELECT … FOR UPDATE. Onceupdate()is a race-free conditional update,ShoppingListRepository(spec 001) could extend the base class safely: a list or share row has no columns thatversionleaves unguarded.ShoppingListItemRepositorymust never extend it (seedocs/adr/2026-10-02-shopping-list-item-writes.md).AbstractEntityJSDoc. It claimssave()throwsOptimisticLockVersionMismatchErrorwhen the version does not match. That is false:save()only incrementsversion, and only afindOnewithlock: { mode: "optimistic" }throws. The false claim is probably how the check-then-save pattern came about, so it belongs with this fix.Also:
update()currently throws 409 whenversionis missing. The conventions want 400VALIDATION_FAILED.