Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 3de2876

Browse files
authored
PHPORM-99 Enable TTL index to auto-purge of expired cache and lock items (#2891)
* Enable TTL index to auto-purge of expired cache and lock items * Simplify constructor arguments of MongoLock * Remove useless expiration condition in cache increment * Rename expiration field to expires_at for naming consistency * Validate lottery value * Fix test using UTCDateTime
1 parent eaa4de9 commit 3de2876

File tree

4 files changed

+115
-47
lines changed

4 files changed

+115
-47
lines changed

‎src/Cache/MongoLock.php‎

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,38 @@
33
namespace MongoDB\Laravel\Cache;
44

55
use Illuminate\Cache\Lock;
6+
use Illuminate\Support\Carbon;
7+
use InvalidArgumentException;
8+
use MongoDB\BSON\UTCDateTime;
69
use MongoDB\Laravel\Collection;
710
use MongoDB\Operation\FindOneAndUpdate;
811
use Override;
912

13+
use function is_numeric;
1014
use function random_int;
1115

1216
final class MongoLock extends Lock
1317
{
1418
/**
1519
* Create a new lock instance.
1620
*
17-
* @param Collection $collection The MongoDB collection
18-
* @param string $name Name of the lock
19-
* @param int $seconds Time-to-live of the lock in seconds
20-
* @param string|null $owner A unique string that identifies the owner. Random if not set
21-
* @param array $lottery The prune probability odds
22-
* @param int $defaultTimeoutInSeconds The default number of seconds that a lock should be held
21+
* @param Collection $collection The MongoDB collection
22+
* @param string $name Name of the lock
23+
* @param int $seconds Time-to-live of the lock in seconds
24+
* @param string|null $owner A unique string that identifies the owner. Random if not set
25+
* @param array{int, int} $lottery Probability [chance, total] of pruning expired cache items. Set to [0, 0] to disable
2326
*/
2427
public function __construct(
2528
private readonly Collection $collection,
2629
string $name,
2730
int $seconds,
2831
?string $owner = null,
2932
private readonly array $lottery = [2, 100],
30-
private readonly int $defaultTimeoutInSeconds = 86400,
3133
) {
34+
if (! is_numeric($this->lottery[0] ?? null) || ! is_numeric($this->lottery[1] ?? null) || $this->lottery[0] > $this->lottery[1]) {
35+
throw new InvalidArgumentException('Lock lottery must be a couple of integers [$chance, $total] where $chance <= $total. Example [2, 100]');
36+
}
37+
3238
parent::__construct($name, $seconds, $owner);
3339
}
3440

@@ -41,7 +47,7 @@ public function acquire(): bool
4147
// or it is already owned by the same lock instance.
4248
$isExpiredOrAlreadyOwned = [
4349
'$or' => [
44-
['$lte' => ['$expiration', $this->currentTime()]],
50+
['$lte' => ['$expires_at', $this->getUTCDateTime()]],
4551
['$eq' => ['$owner', $this->owner]],
4652
],
4753
];
@@ -57,11 +63,11 @@ public function acquire(): bool
5763
'else' => '$owner',
5864
],
5965
],
60-
'expiration' => [
66+
'expires_at' => [
6167
'$cond' => [
6268
'if' => $isExpiredOrAlreadyOwned,
63-
'then' => $this->expiresAt(),
64-
'else' => '$expiration',
69+
'then' => $this->getUTCDateTime($this->seconds),
70+
'else' => '$expires_at',
6571
],
6672
],
6773
],
@@ -74,10 +80,12 @@ public function acquire(): bool
7480
],
7581
);
7682

77-
if (random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
78-
$this->collection->deleteMany(['expiration' => ['$lte' => $this->currentTime()]]);
83+
if ($this->lottery[0] <= 0 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
84+
$this->collection->deleteMany(['expires_at' => ['$lte' => $this->getUTCDateTime()]]);
7985
}
8086

87+
// Compare the owner to check if the lock is owned. Acquiring the same lock
88+
// with the same owner at the same instant would lead to not update the document
8189
return $result['owner'] === $this->owner;
8290
}
8391

@@ -107,6 +115,17 @@ public function forceRelease(): void
107115
]);
108116
}
109117

118+
/** Creates a TTL index that automatically deletes expired objects. */
119+
public function createTTLIndex(): void
120+
{
121+
$this->collection->createIndex(
122+
// UTCDateTime field that holds the expiration date
123+
['expires_at' => 1],
124+
// Delay to remove items after expiration
125+
['expireAfterSeconds' => 0],
126+
);
127+
}
128+
110129
/**
111130
* Returns the owner value written into the driver for this lock.
112131
*/
@@ -116,19 +135,14 @@ protected function getCurrentOwner(): ?string
116135
return $this->collection->findOne(
117136
[
118137
'_id' => $this->name,
119-
'expiration' => ['$gte' => $this->currentTime()],
138+
'expires_at' => ['$gte' => $this->getUTCDateTime()],
120139
],
121140
['projection' => ['owner' => 1]],
122141
)['owner'] ?? null;
123142
}
124143

125-
/**
126-
* Get the UNIX timestamp indicating when the lock should expire.
127-
*/
128-
private function expiresAt(): int
144+
private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime
129145
{
130-
$lockTimeout = $this->seconds > 0 ? $this->seconds : $this->defaultTimeoutInSeconds;
131-
132-
return $this->currentTime() + $lockTimeout;
146+
return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds));
133147
}
134148
}

‎src/Cache/MongoStore.php‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
use Illuminate\Cache\RetrievesMultipleKeys;
66
use Illuminate\Contracts\Cache\LockProvider;
77
use Illuminate\Contracts\Cache\Store;
8-
use Illuminate\Support\InteractsWithTime;
8+
use Illuminate\Support\Carbon;
9+
use MongoDB\BSON\UTCDateTime;
910
use MongoDB\Laravel\Collection;
1011
use MongoDB\Laravel\Connection;
1112
use MongoDB\Operation\FindOneAndUpdate;
@@ -20,7 +21,6 @@
2021

2122
final class MongoStore implements LockProvider, Store
2223
{
23-
use InteractsWithTime;
2424
// Provides "many" and "putMany" in a non-optimized way
2525
use RetrievesMultipleKeys;
2626

@@ -34,7 +34,7 @@ final class MongoStore implements LockProvider, Store
3434
* @param string $prefix Prefix for the name of cache items
3535
* @param Connection|null $lockConnection The MongoDB connection to use for the lock, if different from the cache connection
3636
* @param string $lockCollectionName Name of the collection where locks are stored
37-
* @param array{int, int} $lockLottery Probability [chance, total] of pruning expired cache items
37+
* @param array{int, int} $lockLottery Probability [chance, total] of pruning expired cache items. Set to [0, 0] to disable
3838
* @param int $defaultLockTimeoutInSeconds Time-to-live of the locks in seconds
3939
*/
4040
public function __construct(
@@ -62,10 +62,9 @@ public function lock($name, $seconds = 0, $owner = null): MongoLock
6262
return new MongoLock(
6363
($this->lockConnection ?? $this->connection)->getCollection($this->lockCollectionName),
6464
$this->prefix . $name,
65-
$seconds,
65+
$seconds ?: $this->defaultLockTimeoutInSeconds,
6666
$owner,
6767
$this->lockLottery,
68-
$this->defaultLockTimeoutInSeconds,
6968
);
7069
}
7170

@@ -95,7 +94,7 @@ public function put($key, $value, $seconds): bool
9594
[
9695
'$set' => [
9796
'value' => $this->serialize($value),
98-
'expiration' => $this->currentTime() + $seconds,
97+
'expires_at' => $this->getUTCDateTime($seconds),
9998
],
10099
],
101100
[
@@ -116,6 +115,8 @@ public function put($key, $value, $seconds): bool
116115
*/
117116
public function add($key, $value, $seconds): bool
118117
{
118+
$isExpired = ['$lte' => ['$expires_at', $this->getUTCDateTime()]];
119+
119120
$result = $this->collection->updateOne(
120121
[
121122
'_id' => $this->prefix . $key,
@@ -125,16 +126,16 @@ public function add($key, $value, $seconds): bool
125126
'$set' => [
126127
'value' => [
127128
'$cond' => [
128-
'if' => ['$lte' => ['$expiration', $this->currentTime()]],
129+
'if' => $isExpired,
129130
'then' => $this->serialize($value),
130131
'else' => '$value',
131132
],
132133
],
133-
'expiration' => [
134+
'expires_at' => [
134135
'$cond' => [
135-
'if' => ['$lte' => ['$expiration', $this->currentTime()]],
136-
'then' => $this->currentTime() + $seconds,
137-
'else' => '$expiration',
136+
'if' => $isExpired,
137+
'then' => $this->getUTCDateTime($seconds),
138+
'else' => '$expires_at',
138139
],
139140
],
140141
],
@@ -156,14 +157,14 @@ public function get($key): mixed
156157
{
157158
$result = $this->collection->findOne(
158159
['_id' => $this->prefix . $key],
159-
['projection' => ['value' => 1, 'expiration' => 1]],
160+
['projection' => ['value' => 1, 'expires_at' => 1]],
160161
);
161162

162163
if (! $result) {
163164
return null;
164165
}
165166

166-
if ($result['expiration'] <= $this->currentTime()) {
167+
if ($result['expires_at'] <= $this->getUTCDateTime()) {
167168
$this->forgetIfExpired($key);
168169

169170
return null;
@@ -181,12 +182,9 @@ public function get($key): mixed
181182
#[Override]
182183
public function increment($key, $value = 1): int|float|false
183184
{
184-
$this->forgetIfExpired($key);
185-
186185
$result = $this->collection->findOneAndUpdate(
187186
[
188187
'_id' => $this->prefix . $key,
189-
'expiration' => ['$gte' => $this->currentTime()],
190188
],
191189
[
192190
'$inc' => ['value' => $value],
@@ -200,7 +198,7 @@ public function increment($key, $value = 1): int|float|false
200198
return false;
201199
}
202200

203-
if ($result['expiration'] <= $this->currentTime()) {
201+
if ($result['expires_at'] <= $this->getUTCDateTime()) {
204202
$this->forgetIfExpired($key);
205203

206204
return false;
@@ -257,7 +255,7 @@ public function forgetIfExpired($key): bool
257255
{
258256
$result = $this->collection->deleteOne([
259257
'_id' => $this->prefix . $key,
260-
'expiration' => ['$lte' => $this->currentTime()],
258+
'expires_at' => ['$lte' => $this->getUTCDateTime()],
261259
]);
262260

263261
return $result->getDeletedCount() > 0;
@@ -275,6 +273,17 @@ public function getPrefix(): string
275273
return $this->prefix;
276274
}
277275

276+
/** Creates a TTL index that automatically deletes expired objects. */
277+
public function createTTLIndex(): void
278+
{
279+
$this->collection->createIndex(
280+
// UTCDateTime field that holds the expiration date
281+
['expires_at' => 1],
282+
// Delay to remove items after expiration
283+
['expireAfterSeconds' => 0],
284+
);
285+
}
286+
278287
private function serialize($value): string|int|float
279288
{
280289
// Don't serialize numbers, so they can be incremented
@@ -293,4 +302,9 @@ private function unserialize($value): mixed
293302

294303
return unserialize($value);
295304
}
305+
306+
private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime
307+
{
308+
return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds));
309+
}
296310
}

‎tests/Cache/MongoCacheStoreTest.php‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Support\Carbon;
77
use Illuminate\Support\Facades\Cache;
88
use Illuminate\Support\Facades\DB;
9+
use MongoDB\BSON\UTCDateTime;
910
use MongoDB\Laravel\Tests\TestCase;
1011

1112
use function assert;
@@ -200,32 +201,42 @@ public function testIncrementDecrement()
200201
$this->assertFalse($store->increment('foo', 5));
201202
}
202203

203-
protected function getStore(): Repository
204+
public function testTTLIndex()
205+
{
206+
$store = $this->getStore();
207+
$store->createTTLIndex();
208+
209+
// TTL index remove expired items asynchronously, this test would be very slow
210+
$indexes = DB::connection('mongodb')->getCollection($this->getCacheCollectionName())->listIndexes();
211+
$this->assertCount(2, $indexes);
212+
}
213+
214+
private function getStore(): Repository
204215
{
205216
$repository = Cache::store('mongodb');
206217
assert($repository instanceof Repository);
207218

208219
return $repository;
209220
}
210221

211-
protected function getCacheCollectionName(): string
222+
private function getCacheCollectionName(): string
212223
{
213224
return config('cache.stores.mongodb.collection');
214225
}
215226

216-
protected function withCachePrefix(string $key): string
227+
private function withCachePrefix(string $key): string
217228
{
218229
return config('cache.prefix') . $key;
219230
}
220231

221-
protected function insertToCacheTable(string $key, $value, $ttl = 60)
232+
private function insertToCacheTable(string $key, $value, $ttl = 60)
222233
{
223234
DB::connection('mongodb')
224235
->getCollection($this->getCacheCollectionName())
225236
->insertOne([
226237
'_id' => $this->withCachePrefix($key),
227238
'value' => $value,
228-
'expiration' => Carbon::now()->addSeconds($ttl)->getTimestamp(),
239+
'expires_at' => newUTCDateTime(Carbon::now()->addSeconds($ttl)),
229240
]);
230241
}
231242
}

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /