Skip to content

Commit 4c79a30

Browse files
author
Reza Shadman
committed
Initial commit.
1 parent 4bc8490 commit 4c79a30

11 files changed

+467
-1
lines changed

.gitattributes

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Path-based git attributes
2+
# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
3+
4+
# Ignore all test and documentation with "export-ignore".
5+
/.gitattributes export-ignore
6+
/.gitignore export-ignore
7+
/.travis.yml export-ignore
8+
/phpunit.xml.dist export-ignore
9+
/tests export-ignore
10+
/.idea export-ignore

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.idea/
2+
vendor
3+
tests/temp
4+
composer.lock
5+
phpunit.xml
6+
.env

.travis.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
sudo: requirelanguage: php
2+
3+
php:
4+
- 7.1
5+
- 7.2
6+
7+
env:
8+
matrix:
9+
- COMPOSER_FLAGS="--prefer-lowest"
10+
- COMPOSER_FLAGS=""
11+
12+
before_install:
13+
- sudo apt-get update
14+
- travis_retry composer self-update
15+
16+
install:
17+
- travis_retry composer update --prefer-source $COMPOSER_FLAGS
18+
- travis_retry composer require league/flysystem-aws-s3-v3
19+
- sudo apt-get install -y ghostscript
20+
21+
script:
22+
- phpunit
23+
24+
branches:
25+
only:
26+
- master

README.md

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,87 @@
1-
# laravel-optimistic-locking
1+
# Laravel Optimistic Locking
22
Adds optimistic locking feature to eloquent models.
3+
4+
## Installation
5+
```bash
6+
composer require reshadman/laravel-optimistic-locking
7+
```
8+
9+
## Usage
10+
11+
### Basic usage
12+
use the `\Reshadman\OptimisticLocking\OptimisticLocking` trait
13+
in your model, and add the `lock_version` integer field to the
14+
table of the model:
15+
16+
```php
17+
<?php
18+
19+
class BlogPost extends Model {
20+
use OptimisticLocking;
21+
}
22+
```
23+
24+
```php
25+
<?php
26+
27+
$schema->integer('lock_version')->unsigned()->nullable();
28+
```
29+
30+
Then you are ready to go, if the same resource is edited by two
31+
different processes **CONCURRENTLY** then the following exception
32+
will be raised:
33+
34+
```
35+
\Reshadman\OptimisticLocking\StaleModelLockingException
36+
```
37+
38+
You should catch the above exception and act properly based
39+
on your business logic.
40+
41+
### Maintaining lock_version during business transactions
42+
43+
You can keep track of a lock version during a business transaction:
44+
```html
45+
<input type="hidden" name="lock_version" value="{{$blogPost->lock_version}}"
46+
```
47+
48+
So if two authors are editing the same content concurrently,
49+
you can keep track of your **Read State**, And ask the second
50+
author to rewrite his changes.
51+
52+
53+
## What is optimistic locking?
54+
For detailed explanation read the concurrency section of [*Patterns of Enterprise Application Architecture by Martin Fowler*](https://www.martinfowler.com/eaaCatalog/optimisticOfflineLock.html).
55+
56+
There are two way to approach generic concurrency race conditions:
57+
1. Do not allow other processes (or users) to read and update the same
58+
resource (Pessimistic Locking)
59+
2. Allow other processes to read the same resource concurrently, but
60+
do not allow further update, if one of the processes updated the resource before the others (Optimistic locking).
61+
62+
Laravel allows Pessimistic locking as described in the documentation,
63+
this package allows you to have Optimistic locking in a rails like way.
64+
65+
### What happens during an optimistic lock?
66+
Every time you perform an upsert action to your resource(model),
67+
the `lock_version` counter field in the table is incremented by `1`,
68+
If you read a resource and another process updates the resource
69+
after you read it, the true version counter is incremented by one,
70+
If the current process attempts to update the model, simply a
71+
`StaleModelLockingException` will be thrown, and you should
72+
handle the race condition (merge, retry, ignore) based on your
73+
business logic. That is simply via adding the following criteria
74+
to the update query of a **optimistically lockable model**:
75+
76+
```php
77+
$query->where('id', $this->id)->where('lock_version', $this->lock_version + 1)
78+
```
79+
80+
If the version has been updated before your update, it will simply
81+
update no records and means that the model has been updated before
82+
current update attempt or has been deleted.
83+
84+
## Running tests
85+
Clone the repo perform a composer install and run:
86+
87+
```vendor/bin/phpunit```

composer.json

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "reshadman/laravel-optimistic-locking",
3+
"description": "Adds optimistic locking feature to eloquent models.",
4+
"type": "library",
5+
"license": "MIT",
6+
"authors": [
7+
{
8+
"name": "Reza Shadman",
9+
"email": "pcfeeler@gmail.com"
10+
}
11+
],
12+
"require": {
13+
"php": "^7.1",
14+
"illuminate/database": "~5.5.0|~5.6.0",
15+
"illuminate/support": "~5.5.0|~5.6.0"
16+
},
17+
"require-dev": {
18+
"ext-pdo_sqlite": "*",
19+
"mockery/mockery": "^1.0.0",
20+
"orchestra/testbench": "~3.5.0|~3.6.0",
21+
"phpunit/phpunit" : "^7.0"
22+
},
23+
"autoload": {
24+
"psr-4": {
25+
"Reshadman\\OptimisticLocking\\": "src"
26+
}
27+
},
28+
"autoload-dev": {
29+
"psr-4": {
30+
"Reshadman\\OptimisticLocking\\Tests\\": "tests"
31+
}
32+
},
33+
"minimum-stability": "dev",
34+
"prefer-stable": true
35+
}

phpunit.xml.dist

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit bootstrap="vendor/autoload.php"
3+
backupGlobals="false"
4+
backupStaticAttributes="false"
5+
colors="true"
6+
verbose="true"
7+
convertErrorsToExceptions="true"
8+
convertNoticesToExceptions="true"
9+
convertWarningsToExceptions="true"
10+
processIsolation="false"
11+
stopOnFailure="false">
12+
<testsuites>
13+
<testsuite name="OptimisticLocking Test Suite">
14+
<directory>tests</directory>
15+
</testsuite>
16+
</testsuites>
17+
<filter>
18+
<whitelist>
19+
<directory suffix=".php">src/</directory>
20+
</whitelist>
21+
</filter>
22+
</phpunit>

src/OptimisticLocking.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace Reshadman\OptimisticLocking;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
use Illuminate\Database\Eloquent\Model;
7+
8+
trait OptimisticLocking
9+
{
10+
/**
11+
* Indicates that models uses locking or not?
12+
*
13+
* @var bool
14+
*/
15+
protected $lock = true;
16+
17+
/**
18+
* Hooks model events to add lock version if not set.
19+
*
20+
* @return void
21+
*/
22+
protected static function boot()
23+
{
24+
static::creating(function (Model $model) {
25+
26+
if ($model->currentLockVersion() === null) {
27+
28+
$model->{static::lockVersionColumn()} = static::defaultLockVersion();
29+
30+
}
31+
32+
return $model;
33+
});
34+
35+
parent::boot();
36+
}
37+
38+
/**
39+
* Perform a model update operation respecting optimistic locking.
40+
* If the lock fails it will throw a "StaleModelLockingException"
41+
*
42+
* @param Builder $query
43+
* @return bool
44+
*/
45+
protected function performUpdate(Builder $query)
46+
{
47+
// If the updating event returns false, we will cancel the update operation so
48+
// developers can hook Validation systems into their models and cancel this
49+
// operation if the model does not pass validation. Otherwise, we update.
50+
if ($this->fireModelEvent('updating') === false) {
51+
return false;
52+
}
53+
54+
// First we need to create a fresh query instance and touch the creation and
55+
// update timestamp on the model which are maintained by us for developer
56+
// convenience. Then we will just continue saving the model instances.
57+
if ($this->usesTimestamps()) {
58+
$this->updateTimestamps();
59+
}
60+
61+
// Once we have run the update operation, we will fire the "updated" event for
62+
// this model instance. This will allow developers to hook into these after
63+
// models are updated, giving them a chance to do any special processing.
64+
$dirty = $this->getDirty();
65+
66+
if (count($dirty) > 0) {
67+
68+
$versionColumn = static::lockVersionColumn();
69+
70+
$this->setKeysForSaveQuery($query);
71+
72+
// If model locking is enabled, the lock version check constraint is
73+
// added to the update query, as every update on the model increments the version
74+
// by exactly "1" we will increment the value by one for update, then.
75+
if ($this->lockingEnabled()) {
76+
$query->where($versionColumn, '=', $this->currentLockVersion());
77+
}
78+
79+
$beforeUpdateVersion = $this->currentLockVersion();
80+
81+
$this->setAttribute($versionColumn, $newVersion = $beforeUpdateVersion + 1);
82+
$dirty[$versionColumn] = $newVersion;
83+
84+
// If there is no record affected by our update query,
85+
// It means that the record has been updated by another process,
86+
// Or has been deleted, as we treat "delete" as an act of update
87+
// we throw the exception in this situation anyway.
88+
$affected = $query->update($dirty);
89+
90+
if ($affected === 0) {
91+
$this->setAttribute($versionColumn, $beforeUpdateVersion);
92+
93+
throw new StaleModelLockingException("Model has been changed during update.");
94+
}
95+
96+
$this->fireModelEvent('updated', false);
97+
98+
$this->syncChanges();
99+
}
100+
101+
return true;
102+
}
103+
104+
/**
105+
* Name of the lock version column.
106+
*
107+
* @return string
108+
*/
109+
protected static function lockVersionColumn()
110+
{
111+
return 'lock_version';
112+
}
113+
114+
/**
115+
* Current lock version value.
116+
*
117+
* @return int
118+
*/
119+
public function currentLockVersion()
120+
{
121+
return $this->getAttribute(static::lockVersionColumn());
122+
}
123+
124+
/**
125+
* Default lock version value.
126+
*
127+
* @return int
128+
*/
129+
protected static function defaultLockVersion()
130+
{
131+
return 1;
132+
}
133+
134+
/**
135+
* Indicates that optimistic locking is enabled for this model
136+
* instance or not.
137+
*
138+
* @return bool
139+
*/
140+
protected function lockingEnabled()
141+
{
142+
return $this->lock === null ? true : $this->lock;
143+
}
144+
145+
/**
146+
* Disables optimistic locking for this model instance.
147+
*
148+
* @return $this
149+
*/
150+
protected function disableLocking()
151+
{
152+
$this->lock = false;
153+
return $this;
154+
}
155+
156+
/**
157+
* Enables optimistic locking for this model instance.
158+
*
159+
* @return $this
160+
*/
161+
public function enableLocking()
162+
{
163+
$this->lock = true;
164+
return $this;
165+
}
166+
}

src/StaleModelLockingException.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Reshadman\OptimisticLocking;
4+
5+
use RuntimeException;
6+
7+
class StaleModelLockingException extends RuntimeException
8+
{
9+
10+
}

0 commit comments

Comments
 (0)