Skip to content

docs(objects): failIfExists is NOT a lock — say so where it is read - #2213

Merged
rubenvdlinde merged 1 commit into
developmentfrom
docs/failifexists-not-atomic
Jul 30, 2026
Merged

docs(objects): failIfExists is NOT a lock — say so where it is read#2213
rubenvdlinde merged 1 commit into
developmentfrom
docs/failifexists-not-atomic

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Follow-up to #2211, which I merged with a guarantee stronger than it delivers.

The concurrent test fails

10 simultaneous POST …?_failIfExists=true on one identifier, three runs:

run1: 201=6  409=2  rows=1
run2: 201=4  409=6  rows=1
run3: 201=2  409=8  rows=1

Multiple callers receive 201. One row survives, so the extra 201s are lost updates that reported success. The guard sits between the existence lookup and the write — two separate operations — so N callers can all pass the lookup before any writes.

My sequential test (claim1 → 201, claim2 → 409) passes precisely because it serialises the calls. That was the wrong test, and it is the one that convinced me.

What still holds

  • The default path is untouched — no existing caller is affected.
  • A sequential duplicate is correctly refused.

What does not

_failIfExists / onConflict: fail must not be relied on for mutual exclusion. Closing it means letting the database arbitrate: a real INSERT against the existing _uuid unique constraint, translated into ObjectExistsException. Tracked in #2212.

This PR

Puts that warning in all three places someone reads before trusting it — the exception docblock, the guard in SaveObject, and the node's onConflict constant. Docs only; no behaviour change.


The acceptance criterion in hydra task 3.5 said "prove this with two flows started simultaneously, not last". I wrote that criterion, then verified sequentially anyway.

#2211 shipped with a guarantee stronger than it delivers. I verified it
sequentially (claim1 -> 201, claim2 -> 409) and merged. The concurrent test
fails:

  10 simultaneous claims on one identifier, three runs
  run1: 201=6  409=2  rows=1
  run2: 201=4  409=6  rows=1
  run3: 201=2  409=8  rows=1

Multiple callers receive 201. Exactly one row survives, so the extra 201s are
lost updates reporting success. The guard sits between the existence lookup and
the write — two separate operations — so N callers can all pass the lookup
before any of them writes. It narrows the window; it does not close it.

What holds: the DEFAULT path is untouched, so no existing caller is affected,
and a sequential duplicate is still correctly refused.

What does not: `_failIfExists` / `onConflict: fail` must not be relied on for
mutual exclusion. Closing it means letting the database arbitrate — a real
INSERT against the existing _uuid unique constraint, translated into
ObjectExistsException.

Adds that warning to all three places someone will read before trusting it: the
exception's own docblock, the guard in SaveObject, and the node's onConflict
constant. Tracked as #2212.

The acceptance criterion in hydra task 3.5 said "prove this with two flows
started simultaneously, not last". I wrote that criterion and then verified
sequentially anyway.
@rubenvdlinde
rubenvdlinde merged commit 37ee611 into development Jul 30, 2026
17 checks passed
@rubenvdlinde
rubenvdlinde deleted the docs/failifexists-not-atomic branch July 30, 2026 22:16
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ f43374a

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
composer ✅ 174/174
npm ✅ 555/555
PHPUnit
Newman
Playwright ⏭️

Quality workflow — 2026-07-30 22:24 UTC

Download the full PDF report from the workflow artifacts.

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