Skip to content

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.

<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
}
<behavior name="optimistic_lock">
<parameter name="version_column" value="row_version" />
</behavior>
ParameterDefaultMeaning
version_columnversionName 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.

Three pieces of generated code, all spliced in at build time:

  1. Before the UPDATE is built, save()’s update branch stashes the version the object was loaded with into a $optimisticLockPreviousVersion property, then bumps the real column through its own setter. This step is gated on isModified(): a save() 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 real UPDATE.

  2. When the UPDATE’s WHERE clause is built, the Peer adds version = <the stashed pre-bump value> alongside the primary-key condition. A stale writer’s statement therefore matches no row.

  3. Right after the UPDATE runs, an affected-row count of zero throws Propulsion\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.

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.
}

Despite the similar name, this behavior has nothing to do with versionable:

optimistic_lockversionable
Detects a lost-update race on the current rowKeeps a full history/audit table of every past row state
Keeps no history at allDoesn’t detect conflicts
One integer column on the tableA separate *_version table

They’re independent and can be used together.

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.

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.