diff --git a/README.md b/README.md index 74943d4..e53852b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ written in Rust. - [Verifying the connection is live](#verifying-the-connection-is-live) - [Supported commands](#supported-commands) - [SET modifier matrix](#set-modifier-matrix) +- [Predis 3 compatibility](#predis-3-compatibility) - [Testing without ePHPm](#testing-without-ephpm) - [Troubleshooting](#troubleshooting) - [How it works](#how-it-works) @@ -47,11 +48,13 @@ written in Rust. ## Requirements - **PHP 8.2+** -- **`predis/predis` ^2.2** (Composer pulls this in automatically) +- **`predis/predis` ^2.2** (Composer pulls this in automatically). Predis 3 + is **not** supported yet — see [Predis 3 compatibility](#predis-3-compatibility). - **The ePHPm runtime** — any tagged release works for the core commands (the `ephpm_kv_*` SAPI functions have shipped since ePHPm v0.1.0; `FLUSHDB`/`FLUSHALL` need `ephpm_kv_flush_all()`, added in - v0.1.2; current release: v0.8.6). The functions are + v0.1.2; `SETNX` / `SET … NX` need `ephpm_kv_setnx()`; current release: + v0.10.2). The functions are registered by ePHPm's embedded PHP. If you're running your code under PHP-FPM, Apache mod_php, or the stock PHP CLI, those functions don't exist and `SapiKvOps::__construct()` throws on instantiation. For @@ -210,7 +213,8 @@ register this connection class as a custom scheme. ### What now works through ePHPm's KV store -Anything in Laravel that goes through Predis. With the config above: +The string/counter/TTL parts of Laravel that go through Predis. With the +config above: | Laravel feature | Now backed by ePHPm KV | |-----------------------------------------|------------------------| @@ -218,15 +222,21 @@ Anything in Laravel that goes through Predis. With the config above: | `Redis::get(...)`, `Redis::set(...)` | yes | | `Cache::remember('key', 60, fn …)` | yes | | Session driver = `redis` | yes | -| Queue throttling / rate limiters | yes | -| Broadcast presence channels (counters) | yes | - -What doesn't (yet): queue *workers* on the `redis` connection (uses -`BLPOP` from the `lists` family), broadcasting via Redis pub/sub -(`SUBSCRIBE`/`PUBLISH`), and any explicit list/hash/set ops you've -written. Those will throw `CommandNotSupportedException` and you'll need -to either keep a real Redis available for those workloads or move them -to a different transport. +| `Cache::add(...)` (SET NX / SETNX lock) | yes | +| `Cache::lock(...)` atomic locks | yes (uses SET NX) | + +What does **not** work — these throw `CommandNotSupportedException`, so +keep them on a real Redis (or a different transport): + +- **Queue workers** on the `redis` connection — the `redis` queue driver + uses blocking list ops (`BLPOP`) from the `lists` family. +- **Broadcasting via Redis pub/sub** (`SUBSCRIBE` / `PUBLISH`), including + presence-channel fan-out. +- **`Redis::throttle(...)` / `funnel(...)` rate limiters** and anything else + that ships a Lua script — they run via `EVAL`, which this connection does + not implement. (Laravel's *cache-based* limiter — `RateLimiter` / the + `throttle` middleware — is fine: it only uses counter ops.) +- Any explicit list / hash / set / sorted-set / stream ops you've written. ### Sanity check from artisan @@ -341,7 +351,8 @@ If this round-trips successfully you've confirmed: | Command(s) | Behavior | | ------------------------------------------------ | ----------------------------------------------------------------------- | -| `GET`, `SET`, `SETEX`, `PSETEX` | Strings with optional `EX`/`PX` TTL. `PX`/`PSETEX` round up to seconds. | +| `GET`, `SET`, `SETEX`, `PSETEX` | Strings with optional `EX`/`PX` TTL. `PX`/`PSETEX` round up to seconds. `SET … NX` supported; `XX`/`GET`/`KEEPTTL`/`EXAT`/`PXAT` are not. | +| `SETNX`, `SET … NX` | Atomic insert-if-absent via `ephpm_kv_setnx` (Redis lock recipes). `SETNX` returns `1`/`0`; `SET … NX` returns `OK`/nil. See the [OOM note](#oom-and-setnx) below. | | `DEL`, `UNLINK`, `EXISTS` | Multi-key, return count. | | `INCR`, `DECR`, `INCRBY`, `DECRBY` | Atomic counter ops via the SAPI's `ephpm_kv_incr_by`. | | `EXPIRE`, `PEXPIRE`, `TTL`, `PTTL` | TTL management. `PEXPIRE` rounds up. | @@ -350,11 +361,14 @@ If this round-trips successfully you've confirmed: | `SELECT`, `AUTH`, `QUIT` | Tolerated as no-ops so framework handshakes don't break. | | `FLUSHDB`, `FLUSHALL` | Clear the entire effective store. ephpm's KV is a single keyspace, so both are equivalent. Needs ePHPm v0.1.2+ (`ephpm_kv_flush_all`); on v0.1.0/v0.1.1 this is a no-op returning `null`. | -Everything else — lists, sets, hashes, sorted sets, streams, scripting, -pub/sub, `MULTI`/`EXEC` — raises `Ephpm\Predis\CommandNotSupportedException` -with a clear "ephpm KV does not implement \" message. ePHPm's KV -store is intentionally a string + counter store; if you need anything -beyond that, point Predis at a real Redis for those calls. +Everything else — lists, sets, hashes, sorted sets, streams, scripting +(`EVAL`), pub/sub (`SUBSCRIBE`/`PUBLISH`), `MULTI`/`EXEC` — raises +`Ephpm\Predis\CommandNotSupportedException` with a clear "ephpm KV does not +implement \" message. ePHPm's KV store is intentionally a string + +counter store; if you need anything beyond that, point Predis at a real +Redis for those calls. In particular, **Lua-scripted rate limiters, +pub/sub, and broadcasting do not work through this connection** — they +need `EVAL`/`SUBSCRIBE`/`PUBLISH`. --- @@ -364,11 +378,53 @@ beyond that, point Predis at a real Redis for those calls. | -------------------- | --------------- | | `EX seconds` | supported | | `PX milliseconds` | supported (rounded up to seconds) | -| `NX`, `XX` | unsupported (`SET NX`/`XX` is not yet wired to the SAPI's `ephpm_kv_setnx()`) | +| `NX` | supported (atomic insert-if-absent via `ephpm_kv_setnx()`) | +| `XX` | unsupported | | `GET` | unsupported | | `KEEPTTL` | unsupported | | `EXAT`, `PXAT` | unsupported | +### OOM and SETNX + +Two boundary behaviors worth knowing: + +- **OOM surfaces as a `Predis\Response\ServerException`.** A plain `SET`, + `SETEX`, or `PSETEX` always overwrites, so if the SAPI refuses the write + the only cause is the store hitting `maxmemory` under `noeviction`. This + connection raises the same `ServerException` + (`OOM command not allowed when used memory > maxmemory.`, error type + `OOM`) you'd get over the real RESP wire, rather than silently returning + a false `OK`. + +- **On the NX path, OOM is indistinguishable from "key already exists."** + `ephpm_kv_setnx` returns a single bool whose false means *either* a live + entry already exists *or* the write was refused (OOM). This connection + reports the common case: `SETNX` returns `0` and `SET … NX` returns nil. + A lock you fail to acquire under memory pressure is therefore reported as + "already held" — acceptable for lock recipes, but do not rely on the NX + reply to detect OOM. + +--- + +## Predis 3 compatibility + +This package requires **`predis/predis` ^2.2** and is **not compatible with +Predis 3** yet. `KvConnection` implements +`Predis\Connection\NodeConnectionInterface`, and Predis 3 added three abstract +methods to that interface that Predis 2 doesn't declare: + +```php +public function getClientId(): ?int; +public function write(string $buffer): void; +public function hasDataToRead(): bool; +``` + +Loading `KvConnection` under Predis 3 therefore fatals with +*"contains 3 abstract methods and must therefore be declared abstract"*. +Widening the constraint to `^2.2 || ^3.0` is intentionally **not** done — +it would require implementing those three methods (and re-testing the wire +semantics they imply). Track this in the issue tracker if you need Predis 3. + --- ## Testing without ePHPm @@ -428,8 +484,9 @@ single connection at it) or switch the queue to the database driver. ePHPm runs PHP inside the same OS process as the KV store via the embed SAPI. The store itself is a Rust [`DashMap`](https://docs.rs/dashmap/) plus TTL management. ePHPm registers a small set of host functions -(`ephpm_kv_get`, `ephpm_kv_set`, `ephpm_kv_incr_by`, `ephpm_kv_expire`, -`ephpm_kv_ttl`, `ephpm_kv_pttl`, `ephpm_kv_del`, `ephpm_kv_exists`) into +(`ephpm_kv_get`, `ephpm_kv_set`, `ephpm_kv_setnx`, `ephpm_kv_incr_by`, +`ephpm_kv_expire`, `ephpm_kv_ttl`, `ephpm_kv_pttl`, `ephpm_kv_del`, +`ephpm_kv_exists`) into PHP's global function table. Calling one is a direct C function call into Rust — no socket, no protocol parser, no value serialization beyond what userland code already does. diff --git a/composer.json b/composer.json index e927f21..9a7e5e7 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,10 @@ "autoload-dev": { "psr-4": { "Ephpm\\Predis\\Tests\\": "tests/" - } + }, + "files": [ + "tests/sapi_kv_stubs.php" + ] }, "config": { "sort-packages": true diff --git a/src/InMemoryKvOps.php b/src/InMemoryKvOps.php index 6781247..30c82cb 100644 --- a/src/InMemoryKvOps.php +++ b/src/InMemoryKvOps.php @@ -21,6 +21,22 @@ final class InMemoryKvOps implements KvOpsInterface /** @var array deadline in milliseconds since epoch */ private array $deadlines = []; + /** + * Test hook: when true, every write (`set`/`setnx`) refuses like a store + * that has hit `maxmemory` under `noeviction` — the SAPI returns `false` + * without storing. Lets the connection's OOM handling be exercised without + * a real out-of-memory condition. + */ + private bool $oom = false; + + /** + * Simulate (or clear) an out-of-memory store for the next writes. Test-only. + */ + public function simulateOom(bool $on = true): void + { + $this->oom = $on; + } + public function get(string $key): ?string { return $this->liveValue($key); @@ -28,6 +44,9 @@ public function get(string $key): ?string public function set(string $key, string $value, int $ttlSeconds = 0): bool { + if ($this->oom) { + return false; + } $this->values[$key] = $value; if ($ttlSeconds > 0) { $this->deadlines[$key] = $this->nowMs() + ($ttlSeconds * 1000); @@ -37,6 +56,19 @@ public function set(string $key, string $value, int $ttlSeconds = 0): bool return true; } + public function setnx(string $key, string $value, int $ttlSeconds = 0): bool + { + // Mirrors the SAPI: false when a live entry already exists OR the store + // refuses the write (OOM) — both collapse to the same bool. + if ($this->oom) { + return false; + } + if ($this->liveValue($key) !== null) { + return false; + } + return $this->set($key, $value, $ttlSeconds); + } + public function del(string $key): int { if ($this->liveValue($key) === null) { diff --git a/src/KvConnection.php b/src/KvConnection.php index e697f18..5190d96 100644 --- a/src/KvConnection.php +++ b/src/KvConnection.php @@ -8,6 +8,7 @@ use Predis\Connection\NodeConnectionInterface; use Predis\Connection\Parameters; use Predis\Connection\ParametersInterface; +use Predis\Response\ServerException; /** * Predis Connection that routes Redis-shaped commands directly to the @@ -42,6 +43,7 @@ final class KvConnection implements NodeConnectionInterface public const SUPPORTED_COMMANDS = [ 'GET', 'SET', + 'SETNX', 'SETEX', 'PSETEX', 'DEL', @@ -165,6 +167,7 @@ private function dispatch(CommandInterface $command): mixed return match ($id) { 'GET' => $this->ops->get((string) $args[0]), 'SET' => $this->doSet($args), + 'SETNX' => $this->doSetnx($args), 'SETEX' => $this->doSetex($args), 'PSETEX' => $this->doPsetex($args), 'DEL', 'UNLINK' => $this->doDel($args), @@ -189,10 +192,16 @@ private function dispatch(CommandInterface $command): mixed } /** - * SET key value [EX seconds | PX milliseconds] — the modifiers we - * can honour map directly to the SAPI's TTL parameter. NX/XX/GET/ - * KEEPTTL/EXAT/PXAT need atomicity or features the store doesn't - * expose, so they raise rather than silently degrading. + * SET key value [EX seconds | PX milliseconds] [NX] — the TTL modifiers + * map directly to the SAPI's TTL parameter, and NX routes to the atomic + * `ephpm_kv_setnx` primitive. XX/GET/KEEPTTL/EXAT/PXAT need features the + * store doesn't expose, so they raise rather than silently degrading. + * + * Return shape matches Redis: `OK` when the value is stored, and — on the + * NX path only — `null` (nil) when it isn't. Because `setnx`'s false + * conflates "key already exists" with an OOM refusal, the nil we return on + * that path is correct for the common (key-exists) case but cannot single + * out OOM; this indistinguishability is documented in the README. * * @param list $args */ @@ -201,6 +210,7 @@ private function doSet(array $args): ?string $key = (string) $args[0]; $value = (string) $args[1]; $ttl = 0; + $nx = false; $i = 2; $count = \count($args); @@ -218,6 +228,9 @@ private function doSet(array $args): ?string $i += 2; break; case 'NX': + $nx = true; + $i += 1; + break; case 'XX': case 'GET': case 'KEEPTTL': @@ -229,33 +242,72 @@ private function doSet(array $args): ?string } } - return $this->ops->set($key, $value, $ttl) ? 'OK' : null; + if ($nx) { + // nil when not set (key already existed — the common case); + // OOM is indistinguishable here and also surfaces as nil. + return $this->ops->setnx($key, $value, $ttl) ? 'OK' : null; + } + + // Plain SET always overwrites, so a false is unambiguously an OOM + // refusal — surface it the way the real RESP wire would. + if (!$this->ops->set($key, $value, $ttl)) { + throw $this->oomException(); + } + return 'OK'; + } + + /** + * SETNX key value — insert only if absent. Redis integer reply: 1 when + * inserted, 0 when the key already existed. A refused write (OOM) is also + * reported as 0, since `setnx`'s false conflates the two outcomes. + * + * @param list $args + */ + private function doSetnx(array $args): int + { + return $this->ops->setnx((string) $args[0], (string) $args[1], 0) ? 1 : 0; } /** - * SETEX key seconds value + * SETEX key seconds value. `set` false is unambiguously OOM (SETEX always + * overwrites), so raise the server error rather than reporting a false OK. * * @param list $args */ private function doSetex(array $args): string { - $this->ops->set((string) $args[0], (string) $args[2], (int) $args[1]); + if (!$this->ops->set((string) $args[0], (string) $args[2], (int) $args[1])) { + throw $this->oomException(); + } return 'OK'; } /** * PSETEX key milliseconds value — TTL rounded up to whole seconds - * because the SAPI takes seconds. + * because the SAPI takes seconds. OOM raises, as for SETEX. * * @param list $args */ private function doPsetex(array $args): string { $ms = (int) $args[1]; - $this->ops->set((string) $args[0], (string) $args[2], (int) \ceil($ms / 1000)); + if (!$this->ops->set((string) $args[0], (string) $args[2], (int) \ceil($ms / 1000))) { + throw $this->oomException(); + } return 'OK'; } + /** + * The server-side error a real Redis raises when a write is rejected for + * `maxmemory` — reproduced verbatim so callers see the identical + * {@see ServerException} they would over the RESP wire (its `getErrorType()` + * is `OOM`). + */ + private function oomException(): ServerException + { + return new ServerException('OOM command not allowed when used memory > maxmemory.'); + } + /** * DEL/UNLINK can take any number of keys; Redis returns the total * removed count. diff --git a/src/KvOpsInterface.php b/src/KvOpsInterface.php index 02413ba..4f9fc04 100644 --- a/src/KvOpsInterface.php +++ b/src/KvOpsInterface.php @@ -33,6 +33,20 @@ public function get(string $key): ?string; */ public function set(string $key, string $value, int $ttlSeconds = 0): bool; + /** + * Atomically set a key to a value only if it does not already exist + * (the Redis `SETNX` / `SET … NX` primitive). Backed by the SAPI's + * `ephpm_kv_setnx`, which is atomic under a per-shard lock. + * + * @param int $ttlSeconds 0 means no expiry; positive values are seconds + * + * @return bool true if the value was inserted; false if a live entry + * already exists OR the store refused the write (OOM). The + * underlying SAPI bool conflates those two outcomes and they + * cannot be distinguished on this path. + */ + public function setnx(string $key, string $value, int $ttlSeconds = 0): bool; + /** * Delete a key. * diff --git a/src/SapiKvOps.php b/src/SapiKvOps.php index bbbca0f..1531da6 100644 --- a/src/SapiKvOps.php +++ b/src/SapiKvOps.php @@ -34,6 +34,13 @@ public function set(string $key, string $value, int $ttlSeconds = 0): bool return (bool) \ephpm_kv_set($key, $value, $ttlSeconds); } + public function setnx(string $key, string $value, int $ttlSeconds = 0): bool + { + // false = a live entry already exists OR an OOM refusal; the SAPI + // bool conflates the two and the caller cannot distinguish them. + return (bool) \ephpm_kv_setnx($key, $value, $ttlSeconds); + } + public function del(string $key): int { return (int) \ephpm_kv_del($key); @@ -46,7 +53,16 @@ public function exists(string $key): bool public function incrBy(string $key, int $delta): int { - return (int) \ephpm_kv_incr_by($key, $delta); + // ephpm_kv_incr_by returns false when the stored value is not an + // integer. A blind `(int) false` would collapse that to 0 and silently + // corrupt the counter, so capture and distinguish the sentinel first. + $result = \ephpm_kv_incr_by($key, $delta); + if ($result === false) { + throw new \RuntimeException( + "value at key '{$key}' is not an integer" + ); + } + return (int) $result; } public function expire(string $key, int $ttlSeconds): bool diff --git a/tests/InMemoryKvOpsTest.php b/tests/InMemoryKvOpsTest.php index dba0e4e..e0987ab 100644 --- a/tests/InMemoryKvOpsTest.php +++ b/tests/InMemoryKvOpsTest.php @@ -59,6 +59,46 @@ public function test_incr_throws_on_non_integer_value(): void $ops->incrBy('label', 1); } + public function test_setnx_inserts_only_when_absent(): void + { + $ops = new InMemoryKvOps(); + self::assertTrue($ops->setnx('lock', 'first')); + self::assertFalse($ops->setnx('lock', 'second')); + // Existing value is preserved — SETNX never overwrites. + self::assertSame('first', $ops->get('lock')); + } + + public function test_setnx_applies_ttl_on_insert(): void + { + $ops = new InMemoryKvOps(); + self::assertTrue($ops->setnx('lock', 'v', 30)); + $pttl = $ops->pttl('lock'); + self::assertGreaterThan(0, $pttl); + self::assertLessThanOrEqual(30_000, $pttl); + } + + public function test_set_returns_false_under_simulated_oom(): void + { + $ops = new InMemoryKvOps(); + $ops->simulateOom(true); + self::assertFalse($ops->set('k', 'v')); + // Nothing was stored. + self::assertNull($ops->get('k')); + + // Clearing OOM restores normal writes. + $ops->simulateOom(false); + self::assertTrue($ops->set('k', 'v')); + self::assertSame('v', $ops->get('k')); + } + + public function test_setnx_returns_false_under_simulated_oom(): void + { + $ops = new InMemoryKvOps(); + $ops->simulateOom(true); + self::assertFalse($ops->setnx('k', 'v')); + self::assertNull($ops->get('k')); + } + public function test_set_with_ttl_then_pttl_within_window(): void { $ops = new InMemoryKvOps(); diff --git a/tests/KvConnectionTest.php b/tests/KvConnectionTest.php index febba99..d8414d7 100644 --- a/tests/KvConnectionTest.php +++ b/tests/KvConnectionTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; use Predis\Client; use Predis\Command\RawCommand; +use Predis\Response\ServerException; #[CoversClass(KvConnection::class)] #[CoversClass(CommandNotSupportedException::class)] @@ -61,9 +62,77 @@ public function test_set_with_px_rounds_up_to_seconds(): void public function test_set_with_unsupported_modifier_throws(): void { $client = $this->client(); + // XX (and GET/KEEPTTL/EXAT/PXAT) stay unsupported; only NX is wired. $this->expectException(CommandNotSupportedException::class); - $this->expectExceptionMessageMatches('/SET NX/'); - $client->set('foo', 'bar', 'NX'); + $this->expectExceptionMessageMatches('/SET XX/'); + $client->set('foo', 'bar', 'XX'); + } + + // ── NX / SETNX (locking primitives) ─────────────────────────────────────── + + public function test_setnx_returns_one_when_inserted_zero_when_present(): void + { + $client = $this->client(); + self::assertSame(1, $client->setnx('lock', 'a')); + self::assertSame(0, $client->setnx('lock', 'b')); + // First writer wins; value is not overwritten. + self::assertSame('a', $client->get('lock')); + } + + public function test_set_nx_returns_ok_when_absent_nil_when_present(): void + { + $client = $this->client(); + // SET key value NX → OK on first write, nil when the key already exists. + self::assertSame('OK', $client->set('lock', 'a', 'NX')); + self::assertNull($client->set('lock', 'b', 'NX')); + self::assertSame('a', $client->get('lock')); + } + + public function test_set_nx_with_ex_applies_ttl_on_insert(): void + { + $client = $this->client(); + self::assertSame('OK', $client->set('lock', 'a', 'EX', 30, 'NX')); + $ttl = $client->ttl('lock'); + self::assertGreaterThan(0, $ttl); + self::assertLessThanOrEqual(30, $ttl); + } + + // ── OOM handling ────────────────────────────────────────────────────────── + + public function test_plain_set_throws_server_exception_on_oom(): void + { + $ops = new InMemoryKvOps(); + $ops->simulateOom(true); + $client = new Client(new KvConnection(null, $ops)); + + try { + $client->set('k', 'v'); + self::fail('expected ServerException on OOM'); + } catch (ServerException $e) { + self::assertSame('OOM', $e->getErrorType()); + self::assertStringContainsString('maxmemory', $e->getMessage()); + } + } + + public function test_setex_throws_server_exception_on_oom(): void + { + $ops = new InMemoryKvOps(); + $ops->simulateOom(true); + $client = new Client(new KvConnection(null, $ops)); + + $this->expectException(ServerException::class); + $client->setex('k', 30, 'v'); + } + + public function test_psetex_throws_server_exception_on_oom(): void + { + $ops = new InMemoryKvOps(); + $ops->simulateOom(true); + $client = new Client(new KvConnection(null, $ops)); + + $this->expectException(ServerException::class); + $psetex = $client->createCommand('PSETEX', ['k', 1000, 'v']); + $client->executeCommand($psetex); } public function test_setex_writes_key_with_ttl(): void diff --git a/tests/SapiKvOpsTest.php b/tests/SapiKvOpsTest.php new file mode 100644 index 0000000..59f170e --- /dev/null +++ b/tests/SapiKvOpsTest.php @@ -0,0 +1,71 @@ +incrBy('counter', 5)); + self::assertSame(7, $ops->incrBy('counter', 2)); + } + + public function test_incr_by_throws_when_sapi_signals_non_integer(): void + { + // The SAPI returns false when the stored value is not an integer. + // A blind (int) cast would collapse that to 0 and silently corrupt the + // counter — the bug this test guards against. + SapiStubKv::$incrByReturn = false; + $ops = new SapiKvOps(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches("/is not an integer/"); + $ops->incrBy('label', 1); + } + + public function test_incr_by_does_not_swallow_a_legitimate_zero(): void + { + // Distinguish false from a real 0 result: a counter that lands on 0 + // must return 0, not be mistaken for the non-integer sentinel. + SapiStubKv::$incrByReturn = 0; + self::assertSame(0, (new SapiKvOps())->incrBy('counter', 0)); + } + + public function test_setnx_inserts_when_absent_and_refuses_when_present(): void + { + $ops = new SapiKvOps(); + self::assertTrue($ops->setnx('lock', '1')); + self::assertFalse($ops->setnx('lock', '2')); + self::assertSame('1', $ops->get('lock')); + } + + public function test_setnx_reports_false_under_oom(): void + { + SapiStubKv::$oom = true; + self::assertFalse((new SapiKvOps())->setnx('lock', '1')); + } + + public function test_set_reports_false_under_oom(): void + { + SapiStubKv::$oom = true; + self::assertFalse((new SapiKvOps())->set('k', 'v')); + } +} diff --git a/tests/sapi_kv_stubs.php b/tests/sapi_kv_stubs.php new file mode 100644 index 0000000..f43378c --- /dev/null +++ b/tests/sapi_kv_stubs.php @@ -0,0 +1,85 @@ + */ + public static array $values = []; + + /** + * When set, ephpm_kv_incr_by returns this exact value (e.g. `false` + * to model the "stored value is not an integer" sentinel). + * + * @var mixed + */ + public static mixed $incrByReturn = null; + + /** When true, ephpm_kv_set / ephpm_kv_setnx refuse the write (OOM). */ + public static bool $oom = false; + + public static function reset(): void + { + self::$values = []; + self::$incrByReturn = null; + self::$oom = false; + } + } +} + +namespace { + use Ephpm\Predis\Tests\SapiStubKv; + + if (!\function_exists('ephpm_kv_get')) { + function ephpm_kv_get(string $key): ?string + { + return SapiStubKv::$values[$key] ?? null; + } + + function ephpm_kv_set(string $key, string $value, int $ttlSeconds = 0): bool + { + if (SapiStubKv::$oom) { + return false; + } + SapiStubKv::$values[$key] = $value; + return true; + } + + function ephpm_kv_setnx(string $key, string $value, int $ttlSeconds = 0): bool + { + if (SapiStubKv::$oom) { + return false; + } + if (isset(SapiStubKv::$values[$key])) { + return false; + } + SapiStubKv::$values[$key] = $value; + return true; + } + + function ephpm_kv_incr_by(string $key, int $delta): int|false + { + if (SapiStubKv::$incrByReturn !== null) { + /** @var int|false */ + return SapiStubKv::$incrByReturn; + } + $current = (int) (SapiStubKv::$values[$key] ?? 0); + $next = $current + $delta; + SapiStubKv::$values[$key] = (string) $next; + return $next; + } + } +}