Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 78 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -210,23 +213,30 @@ 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 |
|-----------------------------------------|------------------------|
| `Cache::store('redis')` | yes |
| `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

Expand Down Expand Up @@ -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. |
Expand All @@ -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 \<CMD\>" 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 \<CMD\>" 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`.

---

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
"autoload-dev": {
"psr-4": {
"Ephpm\\Predis\\Tests\\": "tests/"
}
},
"files": [
"tests/sapi_kv_stubs.php"
]
},
"config": {
"sort-packages": true
Expand Down
32 changes: 32 additions & 0 deletions src/InMemoryKvOps.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,32 @@ final class InMemoryKvOps implements KvOpsInterface
/** @var array<string, int> 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);
}

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);
Expand All @@ -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) {
Expand Down
70 changes: 61 additions & 9 deletions src/KvConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +43,7 @@ final class KvConnection implements NodeConnectionInterface
public const SUPPORTED_COMMANDS = [
'GET',
'SET',
'SETNX',
'SETEX',
'PSETEX',
'DEL',
Expand Down Expand Up @@ -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),
Expand All @@ -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<mixed> $args
*/
Expand All @@ -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);
Expand All @@ -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':
Expand All @@ -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<mixed> $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<mixed> $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<mixed> $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.
Expand Down
14 changes: 14 additions & 0 deletions src/KvOpsInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading
Loading