Optimistic Lock Behavior
The optimistic_lock behavior adds a version column to a table and guards every UPDATE with it. A writer whose copy of the row is stale affects zero rows and gets a ConcurrencyException instead of silently overwriting a change it never saw.
This is the lock-free half of Locking — no locks are held, no reader ever blocks, and the cost is only paid when a conflict actually happens.
Basic usage
Section titled “Basic usage”<table name="book"> <column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" /> <column name="title" type="varchar" required="true" /> <behavior name="optimistic_lock" /></table>The behavior adds a version column (INTEGER, required, default 0) if the table doesn’t already have one. Rebuild your model, and add the column to your database — see Schema migrations.
use Propulsion\Exception\ConcurrencyException;
$book = BookQuery::create()->findPk(1); // version is 3$book->setTitle('Anna Karenina');
try { $book->save(); // UPDATE book SET title = ?, version = 4 WHERE id = 1 AND version = 3} catch (ConcurrencyException $e) { // Another writer already changed (or deleted) this row since we loaded it. $conflicted = $e->getEntity(); // the BaseObject that failed to save}Parameters
Section titled “Parameters”<behavior name="optimistic_lock"> <parameter name="version_column" value="row_version" /></behavior>| Parameter | Default | Meaning |
|---|---|---|
version_column | version | Name of the version column. If a column with this name already exists on the table, it’s used as-is rather than being added — so you can declare it yourself to control its type or phpName. |
How it works
Section titled “How it works”Three pieces of generated code, all spliced in at build time:
-
Before the
UPDATEis built,save()’s update branch stashes the version the object was loaded with into a$optimisticLockPreviousVersionproperty, then bumps the real column through its own setter. This step is gated onisModified(): asave()call with nothing to persist must not bump the version merely because the behavior touched the column, which would turn every no-op save into a realUPDATE. -
When the
UPDATE’sWHEREclause is built, the Peer addsversion = <the stashed pre-bump value>alongside the primary-key condition. A stale writer’s statement therefore matches no row. -
Right after the
UPDATEruns, an affected-row count of zero throwsPropulsion\Exception\ConcurrencyException, carrying the entity ($e->getEntity()) and naming the version it expected.
Reading $book->getOptimisticLockPreviousVersion() gives you the stashed value if you need it for diagnostics.
Handling a conflict
Section titled “Handling a conflict”There’s no single right recovery — it depends on what the data means. The three usual shapes:
// 1. Re-read and retry, for a mechanical change that doesn't depend on what// the previous value was. (Better still: see if a database-computed value// fits — then no conflict is possible at all.)for ($attempt = 0; $attempt < 3; $attempt++) { BookPeer::clearInstancePool(); $book = BookQuery::create()->findPk(1); $book->setViewCount($book->getViewCount() + 1); try { $book->save(); break; } catch (ConcurrencyException $e) { continue; }}// 2. Surface the conflict, for a user-authored change where silently// discarding either version would be wrong.try { $book->save();} catch (ConcurrencyException $e) { return $this->render('conflict', ['submitted' => $book, 'current' => BookQuery::create()->findPk(1)]);}// 3. Abandon it, for a best-effort write where losing the race is fine.try { $book->save();} catch (ConcurrencyException $e) { // Someone else got there first; nothing to do.}Not the same as versionable
Section titled “Not the same as versionable”Despite the similar name, this behavior has nothing to do with versionable:
optimistic_lock | versionable |
|---|---|
| Detects a lost-update race on the current row | Keeps a full history/audit table of every past row state |
| Keeps no history at all | Doesn’t detect conflicts |
| One integer column on the table | A separate *_version table |
They’re independent and can be used together.
Platform notes
Section titled “Platform notes”The mechanism is plain SQL — a condition in a WHERE clause and an affected-row count — so it works identically on all five supported platforms, verified live against each.
MSSQL additionally has a native ROWVERSION column type, available via the rowVersion column attribute. That gives you a database-maintained concurrency token in the DDL, but the runtime hydration and comparison for using it as this behavior’s token isn’t implemented — the integer version column above is the supported path on every platform, MSSQL included.
Interaction with Unit of Work
Section titled “Interaction with Unit of Work”A ConcurrencyException raised inside UnitOfWork::flush() rolls back the whole batch and is rethrown, with tracked entities left tracked so you can inspect and retry. An otherwise-valid insert earlier in the same flush is rolled back along with it.