Repository: mongodb/laravel-mongodb (PHP)

Issue:
Can't store cache lock 
- Laravel-mongodb Version: #.#.#
- PHP Version: #.#.#
- Database Driver & Version:

### Description:

I have issue when store data to cache lock with MongoDB driver. error also occurs when dispatch a job implements ShouldBeUnique.

Method acquire Illuminate\Cache\DatabaseLock catch QueryException to update record cache lock table. But with mongodb driver connection throw MongoDB\Driver\Exception\BulkWriteException so can not excute snippet code in catch block


Illuminate\Cache\DatabaseLock

```
public function acquire()
    {
        $acquired = false;

        try {
            $this->connection->table($this->table)->insert([
                'key' => $this->name,
                'owner' => $this->owner,
                'expiration' => $this->expiresAt(),
            ]);

            $acquired = true;
        } catch (QueryException $e) {
            $updated = $this->connection->table($this->table)
                ->where('key', $this->name)
                ->where(function ($query) {
                    return $query->where('owner', $this->owner)->orWhere('expiration', '<=', time());
                })->update([
                    'owner' => $this->owner,
                    'expiration' => $this->expiresAt(),
                ]);

            $acquired = $updated >= 1;
        }

        if (random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
            $this->connection->table($this->table)->where('expiration', '<=', time())->delete();
        }

        return $acquired;
    }
```

### Steps to reproduce
$lock = Cache::lock('foo', 10);
$lock->block(5);

Job:
```
class ExampleJob implements ShouldQueue, ShouldBeUnique {
        use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, UsesBackoffStrategy;

        public RequestQueue $request;
        public $tries = 2;

        /**
         * The number of seconds the job can run before timing out.
         *
         * @var int
         */
        public $timeout = 600;

        /**
         * Indicate if the job should be marked as failed on timeout.
         *
         * @var bool
         */
        public $failOnTimeout = true;

        /**
         * Create a new job instance.
         *
         * @return void
         */
        public function __construct() {
            //
        }

        /**
         * Execute the job.
         *
         * @return void
         */
        public function handle() {
       }
```
dispatch job:
`ExampleJob ::dispatch(); `


<details><summary><b>Logs</b>:</summary>
Exception message like:

`local.ERROR: E11000 duplicate key error collection: mes.cache_locks index: key_1 dup key: { key: "_cachefoo" } {"userId":"admin","exception":"[object] (MongoDB\\Driver\\Exception\\BulkWriteException(code: 11000): E11000 duplicate key error collection: mes.cache_locks index: key_1 dup key: { key: \"_cachefoo\" } at /var/www/vendor/mongodb/mongodb/src/Operation/InsertMany.php:157)`

</details>

Could you suggest any ways solve this issue ?

Need to support upsert function in Laravel 10
- Laravel-mongodb Version: 4.1
- PHP Version: 8.3
- Database Driver & Version: 4

### Description:
The implementation of Cache's put function is modified to db's upsert function in Laravel10.
![image](https://github.com/mongodb/laravel-mongodb/assets/23162012/2ddc8705-7cfc-4315-81b6-46d3e51834ab)


### Steps to reproduce
When the driver of cache is changed to mongodb，This will lead to an error. Such as this:
```php
 \Cache::store('mongdb-conn')->forever($key, $val);

// or 
 \Cache::store('mongdb-conn')->put($key, $val);

```

The error is: This database engine does not support upserts.




Discussion: Thank you for reporting this! It looks like one way would be to only throw a `QueryException` from the MongoDB connection. However, this is not really desirable, as `QueryException` extends `PDOException`, which definitely isn't the right inheritance chain. Instead, we would either have to provide our own `DatabaseStore` for people to use with MongoDB, or teach the `DatabaseLock` class in Laravel itself that there may be other exceptions that needed to be caught (e.g. by providing a marker interface instead of a class to extend).

We'll take a look internally and discuss this further. Could you also please share your cache configuration so we have an idea how you configured the cache and can reproduce it in our own tests? Thank you!
> Thank you for reporting this! It looks like one way would be to only throw a `QueryException` from the MongoDB connection. However, this is not really desirable, as `QueryException` extends `PDOException`, which definitely isn't the right inheritance chain. Instead, we would either have to provide our own `DatabaseStore` for people to use with MongoDB, or teach the `DatabaseLock` class in Laravel itself that there may be other exceptions that needed to be caught (e.g. by providing a marker interface instead of a class to extend).
> 
> We'll take a look internally and discuss this further. Could you also please share your cache configuration so we have an idea how you configured the cache and can reproduce it in our own tests? Thank you!

Yes sure this is my cache config

```php
<?php

use Illuminate\Support\Str;

return [

    /*
    |--------------------------------------------------------------------------
    | Default Cache Store
    |--------------------------------------------------------------------------
    |
    | This option controls the default cache connection that gets used while
    | using this caching library. This connection is used when another is
    | not explicitly specified when executing a given caching function.
    |
    | Supported: "apc", "array", "database", "file",
    |            "memcached", "redis", "dynamodb"
    |
    */

    'default' => env('CACHE_DRIVER', 'file'),

    /*
    |--------------------------------------------------------------------------
    | Cache Stores
    |--------------------------------------------------------------------------
    |
    | Here you may define all of the cache "stores" for your application as
    | well as their drivers. You may even define multiple stores for the
    | same cache driver to group types of items stored in your caches.
    |
    */

    'stores' => [

        'array' => [
            'driver' => 'array',
            'serialize' => false,
        ],

        'database' => [
            'driver' => 'database',
            'table' => 'cache',
            'connection' => null,
        ],

        'file' => [
            'driver' => 'file',
            'path' => storage_path('framework/cache/data'),
        ],

    ],


    'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'),

];
```

in .env set **CACHE_DRIVER=database**

Hope we can find the way to solve it, @alcaeus .
Thanks!
Ticked by PHPORM-99
> Ticked by [PHPORM-99](https://jira.mongodb.org/browse/PHPORM-99)

Hi @GromNaN could you have any information about to resolve this ticket ?
The `DatabaseLock` can't be used. We need to create a dedicated `MongoDBStore` (cache) and `MongoDBLock` (lock), like there is for DynamoDB and Redis [in the Laravel Framework](https://github.com/laravel/framework/tree/10.x/src/Illuminate/Cache).

Are you interested in working on this topic?
> The `DatabaseLock` can't be used. We need to create a dedicated `MongoDBStore` (cache) and `MongoDBLock` (lock), like there is for DynamoDB and Redis [in the Laravel Framework](https://github.com/laravel/framework/tree/10.x/src/Illuminate/Cache).
> 
> Are you interested in working on this topic?

Yes thanks @GromNaN , I think it is the best solution. Could you detail about this topic?
How I think it should be implemented (but I never worked with Laravel lock and cache store before):

## Cache
- Create `MongoDB\Cache\` in this project, looking at [`Illuminate\Cache\RedisStore`](https://github.com/laravel/framework/blob/10.x/src/Illuminate/Cache/RedisStore.php) for the structure.
- Implement the cache logic using [`Cache\Adapter\MongoDB\MongoDBCachePool`](https://github.com/php-cache/mongodb-adapter/blob/master/MongoDBCachePool.php) as an example: create a collection with an `expireAfterSeconds` index.
- Add tests and documentation

## Lock
- Create `MongoDB\Cache\MongoDBLock` in this project, looking at [`Illuminate\Cache\RedisLock`](https://github.com/laravel/framework/blob/10.x/src/Illuminate/Cache/RedisLock.php) for the structure.
- Implement the cache logic using [`Symfony\Component\Lock\MongoDbStore`](https://github.com/symfony/symfony/blob/7.1/src/Symfony/Component/Lock/Store/MongoDbStore.php) as an example: create a collection with an `expireAfterSeconds` index.
- Add tests and documentation

Thanks for reporting this issue. It's tracked in Jira: [PHPORM-141](https://jira.mongodb.org/browse/PHPORM-141)



Fix (patch):
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55a84247e..f653604ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
 * New aggregation pipeline builder by @GromNaN in [#2738](https://github.com/mongodb/laravel-mongodb/pull/2738)
 * Drop support for Composer 1.x by @GromNaN in [#2784](https://github.com/mongodb/laravel-mongodb/pull/2784)
 * Fix `artisan query:retry` command by @GromNaN in [#2838](https://github.com/mongodb/laravel-mongodb/pull/2838)
+* Add `mongodb` cache and lock drivers by @GromNaN in [#2877](https://github.com/mongodb/laravel-mongodb/pull/2877)
 
 ## [4.2.0] - 2024-03-14
 
diff --git a/composer.json b/composer.json
index 51c7e1e43..8c038819e 100644
--- a/composer.json
+++ b/composer.json
@@ -25,10 +25,11 @@
         "php": "^8.1",
         "ext-mongodb": "^1.15",
         "composer-runtime-api": "^2.0.0",
-        "illuminate/support": "^10.0|^11",
+        "illuminate/cache": "^10.36|^11",
         "illuminate/container": "^10.0|^11",
         "illuminate/database": "^10.30|^11",
         "illuminate/events": "^10.0|^11",
+        "illuminate/support": "^10.0|^11",
         "mongodb/mongodb": "^1.15"
     },
     "require-dev": {
diff --git a/src/Cache/MongoLock.php b/src/Cache/MongoLock.php
new file mode 100644
index 000000000..105a3df40
--- /dev/null
+++ b/src/Cache/MongoLock.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace MongoDB\Laravel\Cache;
+
+use Illuminate\Cache\Lock;
+use MongoDB\Laravel\Collection;
+use MongoDB\Operation\FindOneAndUpdate;
+use Override;
+
+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
+     */
+    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,
+    ) {
+        parent::__construct($name, $seconds, $owner);
+    }
+
+    /**
+     * Attempt to acquire the lock.
+     */
+    public function acquire(): bool
+    {
+        // The lock can be acquired if: it doesn't exist, it has expired,
+        // or it is already owned by the same lock instance.
+        $isExpiredOrAlreadyOwned = [
+            '$or' => [
+                ['$lte' => ['$expiration', $this->currentTime()]],
+                ['$eq' => ['$owner', $this->owner]],
+            ],
+        ];
+        $result = $this->collection->findOneAndUpdate(
+            ['_id' => $this->name],
+            [
+                [
+                    '$set' => [
+                        'owner' => [
+                            '$cond' => [
+                                'if' => $isExpiredOrAlreadyOwned,
+                                'then' => $this->owner,
+                                'else' => '$owner',
+                            ],
+                        ],
+                        'expiration' => [
+                            '$cond' => [
+                                'if' => $isExpiredOrAlreadyOwned,
+                                'then' => $this->expiresAt(),
+                                'else' => '$expiration',
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            [
+                'upsert' => true,
+                'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER,
+                'projection' => ['owner' => 1],
+            ],
+        );
+
+        if (random_int(1, $this->lottery[1]) <= $this->lottery[0]) {
+            $this->collection->deleteMany(['expiration' => ['$lte' => $this->currentTime()]]);
+        }
+
+        return $result['owner'] === $this->owner;
+    }
+
+    /**
+     * Release the lock.
+     */
+    #[Override]
+    public function release(): bool
+    {
+        $result = $this->collection
+            ->deleteOne([
+                '_id' => $this->name,
+                'owner' => $this->owner,
+            ]);
+
+        return $result->getDeletedCount() > 0;
+    }
+
+    /**
+     * Releases this lock in disregard of ownership.
+     */
+    #[Override]
+    public function forceRelease(): void
+    {
+        $this->collection->deleteOne([
+            '_id' => $this->name,
+        ]);
+    }
+
+    /**
+     * Returns the owner value written into the driver for this lock.
+     */
+    #[Override]
+    protected function getCurrentOwner(): ?string
+    {
+        return $this->collection->findOne(
+            [
+                '_id' => $this->name,
+                'expiration' => ['$gte' => $this->currentTime()],
+            ],
+            ['projection' => ['owner' => 1]],
+        )['owner'] ?? null;
+    }
+
+    /**
+     * Get the UNIX timestamp indicating when the lock should expire.
+     */
+    private function expiresAt(): int
+    {
+        $lockTimeout = $this->seconds > 0 ? $this->seconds : $this->defaultTimeoutInSeconds;
+
+        return $this->currentTime() + $lockTimeout;
+    }
+}
diff --git a/src/Cache/MongoStore.php b/src/Cache/MongoStore.php
new file mode 100644
index 000000000..4a01c9161
--- /dev/null
+++ b/src/Cache/MongoStore.php
@@ -0,0 +1,296 @@
+<?php
+
+namespace MongoDB\Laravel\Cache;
+
+use Illuminate\Cache\RetrievesMultipleKeys;
+use Illuminate\Contracts\Cache\LockProvider;
+use Illuminate\Contracts\Cache\Store;
+use Illuminate\Support\InteractsWithTime;
+use MongoDB\Laravel\Collection;
+use MongoDB\Laravel\Connection;
+use MongoDB\Operation\FindOneAndUpdate;
+use Override;
+
+use function is_float;
+use function is_int;
+use function is_string;
+use function serialize;
+use function str_contains;
+use function unserialize;
+
+final class MongoStore implements LockProvider, Store
+{
+    use InteractsWithTime;
+    // Provides "many" and "putMany" in a non-optimized way
+    use RetrievesMultipleKeys;
+
+    private const TEN_YEARS_IN_SECONDS = 315360000;
+
+    private Collection $collection;
+
+    /**
+     * @param Connection      $connection                  The MongoDB connection to use for the cache
+     * @param string          $collectionName              Name of the collection where cache items are stored
+     * @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 int             $defaultLockTimeoutInSeconds Time-to-live of the locks in seconds
+     */
+    public function __construct(
+        private readonly Connection $connection,
+        private readonly string $collectionName = 'cache',
+        private readonly string $prefix = '',
+        private readonly ?Connection $lockConnection = null,
+        private readonly string $lockCollectionName = 'cache_locks',
+        private readonly array $lockLottery = [2, 100],
+        private readonly int $defaultLockTimeoutInSeconds = 86400,
+    ) {
+        $this->collection = $this->connection->getCollection($this->collectionName);
+    }
+
+    /**
+     * Get a lock instance.
+     *
+     * @param string      $name
+     * @param int         $seconds
+     * @param string|null $owner
+     */
+    #[Override]
+    public function lock($name, $seconds = 0, $owner = null): MongoLock
+    {
+        return new MongoLock(
+            ($this->lockConnection ?? $this->connection)->getCollection($this->lockCollectionName),
+            $this->prefix . $name,
+            $seconds,
+            $owner,
+            $this->lockLottery,
+            $this->defaultLockTimeoutInSeconds,
+        );
+    }
+
+    /**
+     * Restore a lock instance using the owner identifier.
+     */
+    #[Override]
+    public function restoreLock($name, $owner): MongoLock
+    {
+        return $this->lock($name, 0, $owner);
+    }
+
+    /**
+     * Store an item in the cache for a given number of seconds.
+     *
+     * @param string $key
+     * @param mixed  $value
+     * @param int    $seconds
+     */
+    #[Override]
+    public function put($key, $value, $seconds): bool
+    {
+        $result = $this->collection->updateOne(
+            [
+                '_id' => $this->prefix . $key,
+            ],
+            [
+                '$set' => [
+                    'value' => $this->serialize($value),
+                    'expiration' => $this->currentTime() + $seconds,
+                ],
+            ],
+            [
+                'upsert' => true,
+
+            ],
+        );
+
+        return $result->getUpsertedCount() > 0 || $result->getModifiedCount() > 0;
+    }
+
+    /**
+     * Store an item in the cache if the key doesn't exist.
+     *
+     * @param string $key
+     * @param mixed  $value
+     * @param int    $seconds
+     */
+    public function add($key, $value, $seconds): bool
+    {
+        $result = $this->collection->updateOne(
+            [
+                '_id' => $this->prefix . $key,
+            ],
+            [
+                [
+                    '$set' => [
+                        'value' => [
+                            '$cond' => [
+                                'if' => ['$lte' => ['$expiration', $this->currentTime()]],
+                                'then' => $this->serialize($value),
+                                'else' => '$value',
+                            ],
+                        ],
+                        'expiration' => [
+                            '$cond' => [
+                                'if' => ['$lte' => ['$expiration', $this->currentTime()]],
+                                'then' => $this->currentTime() + $seconds,
+                                'else' => '$expiration',
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            ['upsert' => true],
+        );
+
+        return $result->getUpsertedCount() > 0 || $result->getModifiedCount() > 0;
+    }
+
+    /**
+     * Retrieve an item from the cache by key.
+     *
+     * @param string $key
+     */
+    #[Override]
+    public function get($key): mixed
+    {
+        $result = $this->collection->findOne(
+            ['_id' => $this->prefix . $key],
+            ['projection' => ['value' => 1, 'expiration' => 1]],
+        );
+
+        if (! $result) {
+            return null;
+        }
+
+        if ($result['expiration'] <= $this->currentTime()) {
+            $this->forgetIfExpired($key);
+
+            return null;
+        }
+
+        return $this->unserialize($result['value']);
+    }
+
+    /**
+     * Increment the value of an item in the cache.
+     *
+     * @param string    $key
+     * @param int|float $value
+     */
+    #[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()],
+            ],
+            [
+                '$inc' => ['value' => $value],
+            ],
+            [
+                'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER,
+            ],
+        );
+
+        if (! $result) {
+            return false;
+        }
+
+        if ($result['expiration'] <= $this->currentTime()) {
+            $this->forgetIfExpired($key);
+
+            return false;
+        }
+
+        return $result['value'];
+    }
+
+    /**
+     * Decrement the value of an item in the cache.
+     *
+     * @param string    $key
+     * @param int|float $value
+     */
+    #[Override]
+    public function decrement($key, $value = 1): int|float|false
+    {
+        return $this->increment($key, -1 * $value);
+    }
+
+    /**
+     * Store an item in the cache indefinitely.
+     *
+     * @param string $key
+     * @param mixed  $value
+     */
+    #[Override]
+    public function forever($key, $value): bool
+    {
+        return $this->put($key, $value, self::TEN_YEARS_IN_SECONDS);
+    }
+
+    /**
+     * Remove an item from the cache.
+     *
+     * @param string $key
+     */
+    #[Override]
+    public function forget($key): bool
+    {
+        $result = $this->collection->deleteOne([
+            '_id' => $this->prefix . $key,
+        ]);
+
+        return $result->getDeletedCount() > 0;
+    }
+
+    /**
+     * Remove an item from the cache if it is expired.
+     *
+     * @param string $key
+     */
+    public function forgetIfExpired($key): bool
+    {
+        $result = $this->collection->deleteOne([
+            '_id' => $this->prefix . $key,
+            'expiration' => ['$lte' => $this->currentTime()],
+        ]);
+
+        return $result->getDeletedCount() > 0;
+    }
+
+    public function flush(): bool
+    {
+        $this->collection->deleteMany([]);
+
+        return true;
+    }
+
+    public function getPrefix(): string
+    {
+        return $this->prefix;
+    }
+
+    private function serialize($value): string|int|float
+    {
+        // Don't serialize numbers, so they can be incremented
+        if (is_int($value) || is_float($value)) {
+            return $value;
+        }
+
+        return serialize($value);
+    }
+
+    private function unserialize($value): mixed
+    {
+        if (! is_string($value) || ! str_contains($value, ';')) {
+            return $value;
+        }
+
+        return unserialize($value);
+    }
+}
diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php
index d7af0c714..50c042230 100644
--- a/src/MongoDBServiceProvider.php
+++ b/src/MongoDBServiceProvider.php
@@ -4,10 +4,16 @@
 
 namespace MongoDB\Laravel;
 
+use Illuminate\Cache\CacheManager;
+use Illuminate\Cache\Repository;
+use Illuminate\Foundation\Application;
 use Illuminate\Support\ServiceProvider;
+use MongoDB\Laravel\Cache\MongoStore;
 use MongoDB\Laravel\Eloquent\Model;
 use MongoDB\Laravel\Queue\MongoConnector;
 
+use function assert;
+
 class MongoDBServiceProvider extends ServiceProvider
 {
     /**
@@ -34,6 +40,26 @@ public function register()
             });
         });
 
+        // Add cache and lock drivers.
+        $this->app->resolving('cache', function (CacheManager $cache) {
+            $cache->extend('mongodb', function (Application $app, array $config): Repository {
+                // The closure is bound to the CacheManager
+                assert($this instanceof CacheManager);
+
+                $store = new MongoStore(
+                    $app['db']->connection($config['connection'] ?? null),
+                    $config['collection'] ?? 'cache',
+                    $this->getPrefix($config),
+                    $app['db']->connection($config['lock_connection'] ?? $config['connection'] ?? null),
+                    $config['lock_collection'] ?? ($config['collection'] ?? 'cache') . '_locks',
+                    $config['lock_lottery'] ?? [2, 100],
+                    $config['lock_timeout'] ?? 86400,
+                );
+
+                return $this->repository($store, $config);
+            });
+        });
+
         // Add connector for queue support.
         $this->app->resolving('queue', function ($queue) {
             $queue->addConnector('mongodb', function () {