First to Site
Release 3.7

Optimistic Lock: Iteration & Rollback

Extended the lock to the ordering portal API (v3.7.0), then rolled back the over-aggressive form-side check (v3.7.1)

Overview

Release 3.6 added three layers of stale-write protection on the Project entity. In production the third layer - the explicit $em->lock() check in ProjectCrudController::updateEntity() - fired on 70-90% of admin saves because async workers and hourly crons were bumping the version between form render and submit. This release extends the API-side protection (v3.7.0) and then rolls back the over-aggressive form-side check (v3.7.1).

Context: The Three Layers

From release 3.6:

  1. Entity-level #[ORM\Version] - Doctrine emits UPDATE ... WHERE version = X; zero affected rows throws. Catches true in-flight concurrent saves at the DB layer.
  2. EasyAdmin form hidden-version + $em->lock() - Catches the "long-held form" race where a user holds an edit form open and something else modifies the row before they submit.
  3. Missing in 3.6: API Platform path - The ordering portal uses API Platform resources, not EasyAdmin forms, so layer 2 didn't cover it.

v3.7.0: Extend to Ordering Portal API

The ordering portal API needed the same stale-write protection as the EasyAdmin form flow.

Changes

  • ProjectResource DTO gains a nullable version field (project:read + project:write).
  • ProjectResource::fromEntity() populates it so GET responses include it.
  • ProjectProcessor::updateProject() calls $em->lock($project, LockMode::OPTIMISTIC, $data->version) when the submitted payload includes a version. Clients that omit the field keep existing behaviour.
  • OptimisticLockExceptionListener returns an HTTP 409 JSON response for API / XHR / JSON-accepting requests (HTML flows keep flash + redirect).

Why It Stayed

API clients opt in by submitting a version field. Those that don't send one are not subject to the check, so the false-positive rate observed on the admin form doesn't apply here.

v3.7.1: Remove the Admin Form Lock

Users reported the "This record was modified by another user while you had it open" error on 6-9 out of every 10 saves. The root cause:

  • Every admin save dispatches a ProcessProject async message.
  • The messenger worker processes it almost immediately and touches the Project entity - bumping the version.
  • The form reloaded after save gets rendered with version = N+1, but the messenger worker then bumps to N+2 before the user's next interaction.
  • The next $em->lock($project, OPTIMISTIC, N+1) call fails because the DB is at N+2.

Hourly crons (update-title-status, update-expired-expected-bp) compound this by bumping versions on every projects they touch.

Changes

  • Removed the $em->lock() block from ProjectCrudController::updateEntity().
  • Removed the submittedVersion hidden field from both configureFields() methods (no longer read).

What Still Protects Admin Saves

The entity-level #[ORM\Version] annotation remains. Doctrine still emits:

UPDATE project SET ..., version = v+1 WHERE id = ? AND version = v

If another session updated the row between load and flush, the WHERE version = v matches zero rows and OptimisticLockException is thrown. The narrow "two requests in flight" race is still caught.

What was removed is the proactive probe that checked a form-round-tripped version against the DB at save time - the one that was firing false positives.

Follow-up Work (Not Done)

Items flagged in the v3.7.1 commit message for later:

  • ProcessProjectHandler should avoid bumping Project.version for derived/calculated data. Consider moving computed state to a side-table or using raw SQL that bypasses versioning.
  • Hourly cron endpoints (update-title-status, update-expired-expected-bp) should change-check before persisting.

If those land, the explicit form-side lock can be safely re-introduced without the false-positive storm.

Changelog Reference

  • fix: extend optimistic lock to the ordering portal API (v3.7.0) (1f109d26)
  • fix(admin): remove over-aggressive optimistic lock from Project save (v3.7.1) (496c657c)