Skip to content

Commit ec399f9

Browse files
authored
Add OptimisticLockInterface and OptimisticLockException (#411)
1 parent 711f4cc commit ec399f9

File tree

9 files changed

+226
-45
lines changed

9 files changed

+226
-45
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ $email = $user->get('email');
180180

181181
## Documentation
182182

183+
- [Optimistic Locking](docs/optimistic-locking.md)
183184
- [Internals](docs/internals.md)
184185

185186
If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that.

docs/optimistic-locking.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Optimistic Locking
2+
3+
Optimistic locking is a concurrency control mechanism that allows multiple transactions to proceed without locking
4+
resources but checks for conflicts before committing changes. This approach is useful in scenarios where contention
5+
for resources is low, and it helps to improve performance by reducing the overhead of locking.
6+
7+
## How It Works
8+
9+
In optimistic locking a version number is used to track changes to a record.
10+
11+
1. When a record is updated the version number is incremented;
12+
2. Before committing the changes the application checks if the version number in the database matches the version number
13+
in the application;
14+
3. If they match the changes are committed otherwise an exception is thrown indicating that the record has been modified
15+
by another transaction since it was read.
16+
17+
```mermaid
18+
sequenceDiagram
19+
participant A as Application 1
20+
participant DB as Database
21+
participant B as Application 2
22+
23+
Note over A,DB: Initial state: Record with version=1
24+
25+
A->>DB: Read record (version=1)
26+
B->>DB: Read same record (version=1)
27+
28+
Note over A: Modify record locally
29+
Note over B: Modify record locally
30+
31+
A->>DB: Update record with changes<br/>(Check version=1)
32+
Note over DB: Versions match!<br/>Update succeeds<br/>Increment version to 2
33+
DB-->>A: Commit successful
34+
35+
B->>DB: Update record with changes<br/>(Check version=1)
36+
Note over DB: Versions don't match!<br/>(DB has version=2)
37+
DB-->>B: Throw OptimisticLockException<br/>"Record modified by another transaction"
38+
39+
Note over B: Must re-read record<br/>and retry operation
40+
B->>DB: Read record (version=2)
41+
Note over B: Resolve conflict and<br/>apply changes
42+
B->>DB: Update record with new changes<br/>(Check version=2)
43+
Note over DB: Versions match!<br/>Update succeeds<br/>Increment version to 3
44+
DB-->>B: Commit successful
45+
```
46+
47+
## Implementation
48+
49+
To implement optimistic locking in your Active Record model you need to follow these steps:
50+
51+
### 1. Add a version column
52+
53+
Add a version column to your database table. This column will be used to track changes to the record.
54+
For example, you can add a `version` column of type `bigint`.
55+
56+
```sql
57+
ALTER TABLE document ADD COLUMN version BIGINT DEFAULT 0;
58+
```
59+
### 2. Define the version property
60+
61+
In your Active Record model, define a property for the version column and implement the `OptimisticLockInterface`
62+
interface.
63+
64+
```php
65+
use Yiisoft\ActiveRecord\ActiveRecord;
66+
use Yiisoft\ActiveRecord\OptimisticLockInterface;
67+
68+
final class Document extends ActiveRecord implements OptimisticLockInterface
69+
{
70+
public int $id;
71+
public string $title;
72+
public string $content;
73+
public int $version;
74+
75+
public function optimisticLockPropertyName(): string
76+
{
77+
return 'version';
78+
}
79+
}
80+
```
81+
82+
### 3. Handle optimistic locking exceptions
83+
When saving, updating or deleting the record handle the `OptimisticLockException` exception that is thrown
84+
if the version number does not match.
85+
86+
```php
87+
use Yiisoft\ActiveRecord\OptimisticLockException;
88+
89+
try {
90+
$document->save();
91+
} catch (OptimisticLockException $e) {
92+
// Handle the exception, e.g. reload the record and retry logic or inform the user about the conflict
93+
94+
$document->refresh();
95+
// Retry logic
96+
}
97+
```
98+
99+
## Usage with Web Application
100+
101+
In a web application, you can use optimistic locking in your controllers or services where you handle the business logic.
102+
103+
For example, when updating a document:
104+
105+
```php
106+
use Psr\Http\Message\ResponseInterface;
107+
use Psr\Http\Message\ServerRequestInterface;
108+
use Yiisoft\ActiveRecord\ActiveQuery;
109+
110+
final class DocumentController
111+
{
112+
public function edit(
113+
ServerRequestInterface $request,
114+
): ResponseInterface {
115+
$id = (int) $request->getAttribute('id');
116+
117+
$document = (new ActiveQuery(Document::class))->findOne($id);
118+
119+
if ($document === null) {
120+
throw new NotFoundException('Document not found.');
121+
}
122+
123+
// Render the document edit form with the current version
124+
}
125+
126+
public function save(
127+
ServerRequestInterface $request,
128+
): ResponseInterface {
129+
$data = $request->getParsedBody();
130+
131+
$id = (int) $data['id'];
132+
$document = (new ActiveQuery(Document::class))->findOne($id);
133+
134+
if ($document === null) {
135+
throw new NotFoundException('Document not found.');
136+
}
137+
138+
$document->title = $data['title'] ?? '';
139+
$document->content = $data['content'] ?? '';
140+
$document->version = (int) ($data['version'] ?? 0);
141+
142+
try {
143+
$document->save();
144+
} catch (OptimisticLockException $e) {
145+
// Handle the exception, e.g. reload the record and retry logic or inform the user about the conflict
146+
147+
$document->refresh();
148+
// Retry logic
149+
}
150+
151+
// Redirect or render success message
152+
}
153+
}
154+
```

src/AbstractActiveRecord.php

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -513,32 +513,6 @@ public function markPropertyChanged(string $name): void
513513
}
514514
}
515515

516-
/**
517-
* Returns the name of the column that stores the lock version for implementing optimistic locking.
518-
*
519-
* Optimistic locking allows multiple users to access the same record for edits and avoids potential conflicts. In
520-
* case when a user attempts to save the record upon some staled data (because another user has modified the data),
521-
* a {@see StaleObjectException} exception will be thrown, and the update or deletion is skipped.
522-
*
523-
* Optimistic locking is only supported by {@see update()} and {@see delete()}.
524-
*
525-
* To use Optimistic locking:
526-
*
527-
* 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`.
528-
* Override this method to return the name of this column.
529-
* 2. In the Web form that collects the user input, add a hidden field that stores the lock version of the recording
530-
* being updated.
531-
* 3. In the controller action that does the data updating, try to catch the {@see StaleObjectException} and
532-
* implement necessary business logic (e.g., merging the changes, prompting stated data) to resolve the conflict.
533-
*
534-
* @return string|null The column name that stores the lock version of a table row. If `null` is returned (default
535-
* implemented), optimistic locking will not be supported.
536-
*/
537-
public function optimisticLock(): string|null
538-
{
539-
return null;
540-
}
541-
542516
/**
543517
* Populates an active record object using a row of data from the database/storage.
544518
*
@@ -1096,15 +1070,17 @@ protected function deleteInternal(): int
10961070
* the database and thus the method will return 0
10971071
*/
10981072
$condition = $this->getOldPrimaryKey(true);
1099-
$lock = $this->optimisticLock();
11001073

1101-
if ($lock !== null) {
1074+
if ($this instanceof OptimisticLockInterface) {
1075+
$lock = $this->optimisticLockPropertyName();
11021076
$condition[$lock] = $this->get($lock);
11031077

11041078
$result = $this->deleteAll($condition);
11051079

11061080
if ($result === 0) {
1107-
throw new StaleObjectException('The object being deleted is outdated.');
1081+
throw new OptimisticLockException(
1082+
'The object being deleted is outdated.'
1083+
);
11081084
}
11091085
} else {
11101086
$result = $this->deleteAll($condition);
@@ -1161,9 +1137,9 @@ protected function updateInternal(array|null $propertyNames = null): int
11611137
}
11621138

11631139
$condition = $this->getOldPrimaryKey(true);
1164-
$lock = $this->optimisticLock();
11651140

1166-
if ($lock !== null) {
1141+
if ($this instanceof OptimisticLockInterface) {
1142+
$lock = $this->optimisticLockPropertyName();
11671143
$lockValue = $this->get($lock);
11681144

11691145
$condition[$lock] = $lockValue;
@@ -1172,7 +1148,9 @@ protected function updateInternal(array|null $propertyNames = null): int
11721148
$rows = $this->updateAll($values, $condition);
11731149

11741150
if ($rows === 0) {
1175-
throw new StaleObjectException('The object being updated is outdated.');
1151+
throw new OptimisticLockException(
1152+
'The object being updated is outdated.'
1153+
);
11761154
}
11771155

11781156
$this->populateProperty($lock, $lockValue);

src/ActiveRecordInterface.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
use Yiisoft\Db\Exception\InvalidArgumentException;
1212
use Yiisoft\Db\Exception\InvalidConfigException;
1313
use Yiisoft\Db\Exception\NotSupportedException;
14-
use Yiisoft\Db\Exception\StaleObjectException;
1514

1615
interface ActiveRecordInterface
1716
{
@@ -39,8 +38,8 @@ public function db(): ConnectionInterface;
3938
/**
4039
* Deletes the table row corresponding to this active record.
4140
*
42-
* @throws StaleObjectException If {@see optimisticLock|optimistic locking} is enabled and the data being deleted
43-
* is outdated.
41+
* @throws OptimisticLockException If the instance implements {@see OptimisticLockInterface} and the data being
42+
* deleted is outdated.
4443
* @throws Throwable In case delete failed.
4544
*
4645
* @return int The number of rows deleted.
@@ -442,8 +441,8 @@ public function set(string $propertyName, mixed $value): void;
442441
* @param array|null $propertyNames List of property names that need to be saved. Defaults to `null`, meaning all
443442
* changed property values will be saved.
444443
*
445-
* @throws StaleObjectException If {@see optimisticLock() optimistic locking} is enabled and the data being updated is
446-
* outdated.
444+
* @throws OptimisticLockException If the instance implements {@see OptimisticLockInterface} and the data being
445+
* updated is outdated.
447446
* @throws Throwable In case update failed.
448447
*
449448
* @return int The number of rows affected.

src/OptimisticLockException.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\ActiveRecord;
6+
7+
use Yiisoft\Db\Exception\Exception;
8+
9+
/**
10+
* Represents an exception caused by optimistic locking failure.
11+
*/
12+
final class OptimisticLockException extends Exception
13+
{
14+
}

src/OptimisticLockInterface.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\ActiveRecord;
6+
7+
/**
8+
* The interface should be implemented by Active Record classes to support optimistic locking.
9+
*
10+
* Optimistic locking allows multiple users to access the same record for edits and avoids potential conflicts.
11+
* If a user attempts to save the record upon some stale data (because another user has modified the data), an
12+
* {@see OptimisticLockException} exception will be thrown, and the update or deletion is skipped.
13+
*
14+
* Optimistic locking is only supported by {@see update()} and {@see delete()} methods.
15+
*
16+
* To use optimistic locking:
17+
*
18+
* 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`.
19+
* Implement {@see optimisticLockPropertyName()} method to return the name of this column.
20+
* 2. In the Web form that collects the user input, add a hidden field that stores the lock version of the recording
21+
* being updated.
22+
* 3. In the controller action that does the data updating, try to catch the {@see OptimisticLockException} and
23+
* implement necessary business logic (e.g., merging the changes, prompting stated data) to resolve the conflict.
24+
*/
25+
interface OptimisticLockInterface
26+
{
27+
/**
28+
* Returns the name of the property that stores the lock version for implementing optimistic locking.
29+
*
30+
* @return string The property name that stores the lock version of a table row.
31+
*/
32+
public function optimisticLockPropertyName(): string;
33+
}

tests/ActiveQueryTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Throwable;
99
use Yiisoft\ActiveRecord\ActiveQuery;
1010
use Yiisoft\ActiveRecord\ArArrayHelper;
11+
use Yiisoft\ActiveRecord\OptimisticLockException;
1112
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues;
1213
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Category;
1314
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer;
@@ -27,7 +28,6 @@
2728
use Yiisoft\Db\Exception\InvalidArgumentException;
2829
use Yiisoft\Db\Exception\InvalidCallException;
2930
use Yiisoft\Db\Exception\InvalidConfigException;
30-
use Yiisoft\Db\Exception\StaleObjectException;
3131
use Yiisoft\Db\Exception\UnknownPropertyException;
3232
use Yiisoft\Db\Query\QueryInterface;
3333

@@ -2019,7 +2019,7 @@ public function testOptimisticLock(): void
20192019

20202020
$record->content = 'Rewrite attempt content';
20212021
$record->version = 0;
2022-
$this->expectException(StaleObjectException::class);
2022+
$this->expectException(OptimisticLockException::class);
20232023
$record->save();
20242024
}
20252025

@@ -2034,7 +2034,7 @@ public function testOptimisticLockOnDelete(): void
20342034

20352035
$document->version = 1;
20362036

2037-
$this->expectException(StaleObjectException::class);
2037+
$this->expectException(OptimisticLockException::class);
20382038
$document->delete();
20392039
}
20402040

@@ -2049,7 +2049,7 @@ public function testOptimisticLockAfterDelete(): void
20492049
$this->assertSame(1, $document->delete());
20502050
$this->assertTrue($document->getIsNewRecord());
20512051

2052-
$this->expectException(StaleObjectException::class);
2052+
$this->expectException(OptimisticLockException::class);
20532053
$document->delete();
20542054
}
20552055

tests/Stubs/ActiveRecord/Document.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@
55
namespace Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord;
66

77
use Yiisoft\ActiveRecord\ActiveRecord;
8+
use Yiisoft\ActiveRecord\OptimisticLockInterface;
89

9-
final class Document extends ActiveRecord
10+
final class Document extends ActiveRecord implements OptimisticLockInterface
1011
{
1112
public int $id;
1213
public string $title;
1314
public string $content;
1415
public int $version;
1516
public array $properties;
1617

17-
public function optimisticLock(): ?string
18+
public function optimisticLockPropertyName(): string
1819
{
1920
return 'version';
2021
}

0 commit comments

Comments
 (0)