Skip to content

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

+35-21
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

+32-18
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

+16-5
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' => new UTCDateTime(Carbon::now()->addSeconds($ttl)),
229240
]);
230241
}
231242
}

0 commit comments

Comments
 (0)