Skip to content

Commit aa7bff1

Browse files
committedApr 22, 2024
Enable TTL index to auto-purge of expired cache and lock items
1 parent d0978a8 commit aa7bff1

File tree

4 files changed

+78
-23
lines changed

4 files changed

+78
-23
lines changed
 

‎src/Cache/MongoLock.php

+25-8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace MongoDB\Laravel\Cache;
44

55
use Illuminate\Cache\Lock;
6+
use Illuminate\Support\Carbon;
7+
use MongoDB\BSON\UTCDateTime;
68
use MongoDB\Laravel\Collection;
79
use MongoDB\Operation\FindOneAndUpdate;
810
use Override;
@@ -41,11 +43,11 @@ public function acquire(): bool
4143
// or it is already owned by the same lock instance.
4244
$isExpiredOrAlreadyOwned = [
4345
'$or' => [
44-
['$lte' => ['$expiration', $this->currentTime()]],
46+
['$lte' => ['$expiration', $this->getUTCDateTime()]],
4547
['$eq' => ['$owner', $this->owner]],
4648
],
4749
];
48-
$result = $this->collection->findOneAndUpdate(
50+
$result = $this->collection->updateOne(
4951
['_id' => $this->name],
5052
[
5153
[
@@ -74,11 +76,11 @@ public function acquire(): bool
7476
],
7577
);
7678

77-
if (random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
78-
$this->collection->deleteMany(['expiration' => ['$lte' => $this->currentTime()]]);
79+
if (! empty($this->lottery[0]) && random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
80+
$this->collection->deleteMany(['expiration' => ['$lte' => $this->getUTCDateTime()]]);
7981
}
8082

81-
return $result['owner'] === $this->owner;
83+
return $result->getModifiedCount() > 0 || $result->getUpsertedCount() > 0;
8284
}
8385

8486
/**
@@ -107,6 +109,16 @@ public function forceRelease(): void
107109
]);
108110
}
109111

112+
public function createTTLIndex(): void
113+
{
114+
$this->collection->createIndex(
115+
// UTCDateTime field that holds the expiration date
116+
['expiration' => 1],
117+
// Delay to remove items after expiration
118+
['expireAfterSeconds' => 0],
119+
);
120+
}
121+
110122
/**
111123
* Returns the owner value written into the driver for this lock.
112124
*/
@@ -116,7 +128,7 @@ protected function getCurrentOwner(): ?string
116128
return $this->collection->findOne(
117129
[
118130
'_id' => $this->name,
119-
'expiration' => ['$gte' => $this->currentTime()],
131+
'expiration' => ['$gte' => $this->getUTCDateTime()],
120132
],
121133
['projection' => ['owner' => 1]],
122134
)['owner'] ?? null;
@@ -125,10 +137,15 @@ protected function getCurrentOwner(): ?string
125137
/**
126138
* Get the UNIX timestamp indicating when the lock should expire.
127139
*/
128-
private function expiresAt(): int
140+
private function expiresAt(): UTCDateTime
129141
{
130142
$lockTimeout = $this->seconds > 0 ? $this->seconds : $this->defaultTimeoutInSeconds;
131143

132-
return $this->currentTime() + $lockTimeout;
144+
return $this->getUTCDateTime($lockTimeout);
145+
}
146+
147+
protected function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime
148+
{
149+
return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds));
133150
}
134151
}

‎src/Cache/MongoStore.php

+28-10
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

@@ -95,7 +95,7 @@ public function put($key, $value, $seconds): bool
9595
[
9696
'$set' => [
9797
'value' => $this->serialize($value),
98-
'expiration' => $this->currentTime() + $seconds,
98+
'expiration' => $this->getUTCDateTime($seconds),
9999
],
100100
],
101101
[
@@ -116,6 +116,8 @@ public function put($key, $value, $seconds): bool
116116
*/
117117
public function add($key, $value, $seconds): bool
118118
{
119+
$isExpired = ['$lte' => ['$expiration', $this->getUTCDateTime()]];
120+
119121
$result = $this->collection->updateOne(
120122
[
121123
'_id' => $this->prefix . $key,
@@ -125,15 +127,15 @@ public function add($key, $value, $seconds): bool
125127
'$set' => [
126128
'value' => [
127129
'$cond' => [
128-
'if' => ['$lte' => ['$expiration', $this->currentTime()]],
130+
'if' => $isExpired,
129131
'then' => $this->serialize($value),
130132
'else' => '$value',
131133
],
132134
],
133135
'expiration' => [
134136
'$cond' => [
135-
'if' => ['$lte' => ['$expiration', $this->currentTime()]],
136-
'then' => $this->currentTime() + $seconds,
137+
'if' => $isExpired,
138+
'then' => $this->getUTCDateTime($seconds),
137139
'else' => '$expiration',
138140
],
139141
],
@@ -163,7 +165,7 @@ public function get($key): mixed
163165
return null;
164166
}
165167

166-
if ($result['expiration'] <= $this->currentTime()) {
168+
if ($result['expiration'] <= $this->getUTCDateTime()) {
167169
$this->forgetIfExpired($key);
168170

169171
return null;
@@ -186,7 +188,7 @@ public function increment($key, $value = 1): int|float|false
186188
$result = $this->collection->findOneAndUpdate(
187189
[
188190
'_id' => $this->prefix . $key,
189-
'expiration' => ['$gte' => $this->currentTime()],
191+
'expiration' => ['$gte' => $this->getUTCDateTime()],
190192
],
191193
[
192194
'$inc' => ['value' => $value],
@@ -200,7 +202,7 @@ public function increment($key, $value = 1): int|float|false
200202
return false;
201203
}
202204

203-
if ($result['expiration'] <= $this->currentTime()) {
205+
if ($result['expiration'] <= $this->getUTCDateTime()) {
204206
$this->forgetIfExpired($key);
205207

206208
return false;
@@ -257,7 +259,7 @@ public function forgetIfExpired($key): bool
257259
{
258260
$result = $this->collection->deleteOne([
259261
'_id' => $this->prefix . $key,
260-
'expiration' => ['$lte' => $this->currentTime()],
262+
'expiration' => ['$lte' => $this->getUTCDateTime()],
261263
]);
262264

263265
return $result->getDeletedCount() > 0;
@@ -275,6 +277,17 @@ public function getPrefix(): string
275277
return $this->prefix;
276278
}
277279

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

294307
return unserialize($value);
295308
}
309+
310+
protected function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime
311+
{
312+
return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds));
313+
}
296314
}

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

‎tests/Cache/MongoLockTest.php

+9
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ public function testRestoreLock()
8888
$this->assertFalse($resoredLock->isOwnedByCurrentProcess());
8989
}
9090

91+
public function testTTLIndex()
92+
{
93+
$store = $this->getCache()->lock('')->createTTLIndex();
94+
95+
// TTL index remove expired items asynchronously, this test would be very slow
96+
$indexes = DB::connection('mongodb')->getCollection('foo_cache_locks')->listIndexes();
97+
$this->assertCount(2, $indexes);
98+
}
99+
91100
private function getCache(): Repository
92101
{
93102
$repository = Cache::driver('mongodb');

0 commit comments

Comments
 (0)