-
Notifications
You must be signed in to change notification settings - Fork 1.4k
PHPORM-99 Enable TTL index to auto-purge of expired cache and lock items #2891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c7fce86
087e8fd
e0da8e9
4a5a3d8
33eb655
42e890c
8d02b31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,32 +3,38 @@ | |
namespace MongoDB\Laravel\Cache; | ||
|
||
use Illuminate\Cache\Lock; | ||
use Illuminate\Support\Carbon; | ||
use InvalidArgumentException; | ||
use MongoDB\BSON\UTCDateTime; | ||
use MongoDB\Laravel\Collection; | ||
use MongoDB\Operation\FindOneAndUpdate; | ||
use Override; | ||
|
||
use function is_numeric; | ||
use function random_int; | ||
|
||
final class MongoLock extends Lock | ||
{ | ||
/** | ||
* Create a new lock instance. | ||
* | ||
* @param Collection $collection The MongoDB collection | ||
* @param string $name Name of the lock | ||
* @param int $seconds Time-to-live of the lock in seconds | ||
* @param string|null $owner A unique string that identifies the owner. Random if not set | ||
* @param array $lottery The prune probability odds | ||
* @param int $defaultTimeoutInSeconds The default number of seconds that a lock should be held | ||
* @param Collection $collection The MongoDB collection | ||
* @param string $name Name of the lock | ||
* @param int $seconds Time-to-live of the lock in seconds | ||
* @param string|null $owner A unique string that identifies the owner. Random if not set | ||
* @param array{int, int} $lottery Probability [chance, total] of pruning expired cache items. Set to [0, 0] to disable | ||
*/ | ||
public function __construct( | ||
private readonly Collection $collection, | ||
string $name, | ||
int $seconds, | ||
?string $owner = null, | ||
private readonly array $lottery = [2, 100], | ||
private readonly int $defaultTimeoutInSeconds = 86400, | ||
) { | ||
if (! is_numeric($this->lottery[0] ?? null) || ! is_numeric($this->lottery[1] ?? null) || $this->lottery[0] > $this->lottery[1]) { | ||
throw new InvalidArgumentException('Lock lottery must be a couple of integers [$chance, $total] where $chance <= $total. Example [2, 100]'); | ||
} | ||
|
||
parent::__construct($name, $seconds, $owner); | ||
} | ||
|
||
|
@@ -41,7 +47,7 @@ public function acquire(): bool | |
// or it is already owned by the same lock instance. | ||
$isExpiredOrAlreadyOwned = [ | ||
'$or' => [ | ||
['$lte' => ['$expiration', $this->currentTime()]], | ||
['$lte' => ['$expires_at', $this->getUTCDateTime()]], | ||
['$eq' => ['$owner', $this->owner]], | ||
], | ||
]; | ||
|
@@ -57,11 +63,11 @@ public function acquire(): bool | |
'else' => '$owner', | ||
], | ||
], | ||
'expiration' => [ | ||
'expires_at' => [ | ||
'$cond' => [ | ||
'if' => $isExpiredOrAlreadyOwned, | ||
'then' => $this->expiresAt(), | ||
'else' => '$expiration', | ||
'then' => $this->getUTCDateTime($this->seconds), | ||
'else' => '$expires_at', | ||
], | ||
], | ||
], | ||
|
@@ -74,10 +80,12 @@ public function acquire(): bool | |
], | ||
); | ||
|
||
if (random_int(1, $this->lottery[1]) <= $this->lottery[0]) { | ||
$this->collection->deleteMany(['expiration' => ['$lte' => $this->currentTime()]]); | ||
if ($this->lottery[0] <= 0 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) { | ||
$this->collection->deleteMany(['expires_at' => ['$lte' => $this->getUTCDateTime()]]); | ||
} | ||
|
||
// Compare the owner to check if the lock is owned. Acquiring the same lock | ||
// with the same owner at the same instant would lead to not update the document | ||
return $result['owner'] === $this->owner; | ||
} | ||
|
||
|
@@ -107,6 +115,17 @@ public function forceRelease(): void | |
]); | ||
} | ||
|
||
/** Creates a TTL index that automatically deletes expired objects. */ | ||
public function createTTLIndex(): void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I intentionally didn't suggest using a TTL index in #2877 because it seemed redundant with the Given that these are just public methods on the MongoLock and MongoStore classes, how do you expect users to interact with them? My understanding is that both classes would typically be used while handling a request, and this method seems more like schema setup. It makes sense for MongoDB, of course, but doesn't fit very well into whatever common API Laravel intended for these classes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is an additional feature that should be documented as I have described. The methods will probably be called in a migration. I think that for applications with a significant volume of traffic, using TTL indexes will have a positive impact on performance without having to search for the correct lottery value. |
||
{ | ||
$this->collection->createIndex( | ||
// UTCDateTime field that holds the expiration date | ||
['expires_at' => 1], | ||
// Delay to remove items after expiration | ||
['expireAfterSeconds' => 0], | ||
); | ||
} | ||
|
||
/** | ||
* Returns the owner value written into the driver for this lock. | ||
*/ | ||
|
@@ -116,19 +135,14 @@ protected function getCurrentOwner(): ?string | |
return $this->collection->findOne( | ||
[ | ||
'_id' => $this->name, | ||
'expiration' => ['$gte' => $this->currentTime()], | ||
'expires_at' => ['$gte' => $this->getUTCDateTime()], | ||
], | ||
['projection' => ['owner' => 1]], | ||
)['owner'] ?? null; | ||
} | ||
|
||
/** | ||
* Get the UNIX timestamp indicating when the lock should expire. | ||
*/ | ||
private function expiresAt(): int | ||
private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime | ||
{ | ||
$lockTimeout = $this->seconds > 0 ? $this->seconds : $this->defaultTimeoutInSeconds; | ||
|
||
return $this->currentTime() + $lockTimeout; | ||
return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What allows UTCDateTime to be constructed from a Carbon instance? Is this relying on Carbon's string representation? I looked at the Addition and Subtraction docs for Carbon and the strings in the examples don't look correct. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, I missed Carbon's inheritance. SGTM! |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,7 +5,8 @@ | |
use Illuminate\Cache\RetrievesMultipleKeys; | ||
use Illuminate\Contracts\Cache\LockProvider; | ||
use Illuminate\Contracts\Cache\Store; | ||
use Illuminate\Support\InteractsWithTime; | ||
use Illuminate\Support\Carbon; | ||
use MongoDB\BSON\UTCDateTime; | ||
use MongoDB\Laravel\Collection; | ||
use MongoDB\Laravel\Connection; | ||
use MongoDB\Operation\FindOneAndUpdate; | ||
|
@@ -20,7 +21,6 @@ | |
|
||
final class MongoStore implements LockProvider, Store | ||
{ | ||
use InteractsWithTime; | ||
// Provides "many" and "putMany" in a non-optimized way | ||
use RetrievesMultipleKeys; | ||
|
||
|
@@ -34,7 +34,7 @@ final class MongoStore implements LockProvider, Store | |
* @param string $prefix Prefix for the name of cache items | ||
* @param Connection|null $lockConnection The MongoDB connection to use for the lock, if different from the cache connection | ||
* @param string $lockCollectionName Name of the collection where locks are stored | ||
* @param array{int, int} $lockLottery Probability [chance, total] of pruning expired cache items | ||
* @param array{int, int} $lockLottery Probability [chance, total] of pruning expired cache items. Set to [0, 0] to disable | ||
* @param int $defaultLockTimeoutInSeconds Time-to-live of the locks in seconds | ||
*/ | ||
public function __construct( | ||
|
@@ -62,10 +62,9 @@ public function lock($name, $seconds = 0, $owner = null): MongoLock | |
return new MongoLock( | ||
($this->lockConnection ?? $this->connection)->getCollection($this->lockCollectionName), | ||
$this->prefix . $name, | ||
$seconds, | ||
$seconds ?: $this->defaultLockTimeoutInSeconds, | ||
$owner, | ||
$this->lockLottery, | ||
$this->defaultLockTimeoutInSeconds, | ||
); | ||
} | ||
|
||
|
@@ -95,7 +94,7 @@ public function put($key, $value, $seconds): bool | |
[ | ||
'$set' => [ | ||
'value' => $this->serialize($value), | ||
'expiration' => $this->currentTime() + $seconds, | ||
'expires_at' => $this->getUTCDateTime($seconds), | ||
], | ||
], | ||
[ | ||
|
@@ -116,6 +115,8 @@ public function put($key, $value, $seconds): bool | |
*/ | ||
public function add($key, $value, $seconds): bool | ||
{ | ||
$isExpired = ['$lte' => ['$expires_at', $this->getUTCDateTime()]]; | ||
|
||
$result = $this->collection->updateOne( | ||
[ | ||
'_id' => $this->prefix . $key, | ||
|
@@ -125,16 +126,16 @@ public function add($key, $value, $seconds): bool | |
'$set' => [ | ||
'value' => [ | ||
'$cond' => [ | ||
'if' => ['$lte' => ['$expiration', $this->currentTime()]], | ||
'if' => $isExpired, | ||
'then' => $this->serialize($value), | ||
'else' => '$value', | ||
], | ||
], | ||
'expiration' => [ | ||
'expires_at' => [ | ||
'$cond' => [ | ||
'if' => ['$lte' => ['$expiration', $this->currentTime()]], | ||
'then' => $this->currentTime() + $seconds, | ||
'else' => '$expiration', | ||
'if' => $isExpired, | ||
'then' => $this->getUTCDateTime($seconds), | ||
'else' => '$expires_at', | ||
], | ||
], | ||
], | ||
|
@@ -156,14 +157,14 @@ public function get($key): mixed | |
{ | ||
$result = $this->collection->findOne( | ||
['_id' => $this->prefix . $key], | ||
['projection' => ['value' => 1, 'expiration' => 1]], | ||
['projection' => ['value' => 1, 'expires_at' => 1]], | ||
); | ||
|
||
if (! $result) { | ||
return null; | ||
} | ||
|
||
if ($result['expiration'] <= $this->currentTime()) { | ||
if ($result['expires_at'] <= $this->getUTCDateTime()) { | ||
$this->forgetIfExpired($key); | ||
|
||
return null; | ||
|
@@ -181,12 +182,9 @@ public function get($key): mixed | |
#[Override] | ||
public function increment($key, $value = 1): int|float|false | ||
{ | ||
$this->forgetIfExpired($key); | ||
|
||
$result = $this->collection->findOneAndUpdate( | ||
[ | ||
'_id' => $this->prefix . $key, | ||
'expiration' => ['$gte' => $this->currentTime()], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By removing this criteria and the preceding call to Since the TTL index is optional, I wouldn't have expected this to change. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've removed the previous Even if the expired item is incremented here, it is deleted just after. |
||
], | ||
[ | ||
'$inc' => ['value' => $value], | ||
|
@@ -200,7 +198,7 @@ public function increment($key, $value = 1): int|float|false | |
return false; | ||
} | ||
|
||
if ($result['expiration'] <= $this->currentTime()) { | ||
if ($result['expires_at'] <= $this->getUTCDateTime()) { | ||
$this->forgetIfExpired($key); | ||
|
||
return false; | ||
|
@@ -257,7 +255,7 @@ public function forgetIfExpired($key): bool | |
{ | ||
$result = $this->collection->deleteOne([ | ||
'_id' => $this->prefix . $key, | ||
'expiration' => ['$lte' => $this->currentTime()], | ||
'expires_at' => ['$lte' => $this->getUTCDateTime()], | ||
]); | ||
|
||
return $result->getDeletedCount() > 0; | ||
|
@@ -275,6 +273,17 @@ public function getPrefix(): string | |
return $this->prefix; | ||
} | ||
|
||
/** Creates a TTL index that automatically deletes expired objects. */ | ||
public function createTTLIndex(): void | ||
{ | ||
$this->collection->createIndex( | ||
// UTCDateTime field that holds the expiration date | ||
['expires_at' => 1], | ||
// Delay to remove items after expiration | ||
['expireAfterSeconds' => 0], | ||
); | ||
} | ||
|
||
private function serialize($value): string|int|float | ||
{ | ||
// Don't serialize numbers, so they can be incremented | ||
|
@@ -293,4 +302,9 @@ private function unserialize($value): mixed | |
|
||
return unserialize($value); | ||
} | ||
|
||
private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime | ||
{ | ||
return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why was this removed entirely? It looks like it previously served a purpose if
$seconds
was zero. Now, specifying zero for$seconds
would cause locks to immediately expire.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
$defaultTimeoutInSeconds
was only a default value, I moved the processing toMongoStore::lock
. It is not possible to disable lock expiration.