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
57 changes: 47 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ written in Rust.
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; `SETNX` / `SET … NX` need `ephpm_kv_setnx()`; current release:
v0.10.2). The functions are
v0.10.2). The hash, multi-key and other commands listed under
[ePHPm v0.13+ commands](#ephpm-v013-commands) need ePHPm **v0.13.0+**;
each is feature-detected, so on an older runtime those commands keep
raising `CommandNotSupportedException` exactly as before. 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 @@ -236,7 +239,8 @@ keep them on a real Redis (or a different transport):
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.
- Any explicit list / set / sorted-set / stream ops you've written (and hash
ops on ePHPm older than v0.13.0).

### Sanity check from artisan

Expand Down Expand Up @@ -356,20 +360,49 @@ If this round-trips successfully you've confirmed:
| `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. |
| `TYPE` | Returns `string` if the key exists, `none` if not. |
| `TYPE` | `string`, `hash` or `none` via `ephpm_kv_type` on ePHPm v0.13+; on older runtimes `string` if the key exists, `none` if not. |
| `PING`, `ECHO` | Connection liveness, payload echo. |
| `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
(`EVAL`), pub/sub (`SUBSCRIBE`/`PUBLISH`), `MULTI`/`EXEC` — raises
Everything else — lists, sets, sorted sets, streams, scripting
(`EVAL`), pub/sub (`SUBSCRIBE`/`PUBLISH`), `MULTI`/`EXEC`, and the commands
below on an ePHPm older than v0.13.0 — 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
implement \<CMD\>" message (listing the commands that *are* available on the
running ePHPm). ePHPm's KV store is intentionally a string + counter + hash
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`.

### ePHPm v0.13+ commands

These map onto the `ephpm_kv_*` functions added in ePHPm v0.13.0. Each one is
feature-detected with `function_exists()` (`KvConnection::EXTENDED_COMMANDS`
lists the functions each command needs); when the running ePHPm lacks them the
command raises `CommandNotSupportedException`, as it always did.

| Command(s) | Behavior |
| ------------------------------------------------ | ----------------------------------------------------------------------- |
| `HSET`, `HMSET` | Any number of field/value pairs in one write. `HSET` returns the number of fields created, `HMSET` `OK`. A refused write raises `ServerException`. |
| `HGET`, `HMGET`, `HGETALL` | nil for a missing field/hash. `HGETALL` returns an associative array (Predis folds the flat reply). |
| `HDEL`, `HEXISTS`, `HLEN`, `HKEYS`, `HVALS` | As in Redis. |
| `HSETNX`, `HINCRBY` | `1`/`0`; new value (non-integer field raises `ServerException`). `HINCRBY` keeps the field's TTL. |
| `HEXPIRE key seconds FIELDS n f …` | Per-field reply: `1` set, `-2` no such field, `2` deleted (`seconds` = 0). The `NX`/`XX`/`GT`/`LT` conditions are **not supported** (they would need a non-atomic read-then-write) and raise `CommandNotSupportedException`; so do `HPEXPIRE`, `HEXPIREAT`, `HPEXPIREAT`. |
| `HTTL`, `HPTTL`, `HPERSIST` | Per-field replies as in Redis (`-2` no field, `-1` no TTL). |
| `MGET`, `MSET` | Multi-key read / write (no TTL). `MSET` refused under the memory limit raises `ServerException`; keys before the refused one may already be stored. |
| `GETDEL` | Read and delete. |
| `GETEX` | No option (plain read), `EX`, `PERSIST`, and `PX` when it is a whole number of seconds. Sub-second `PX`, `EXAT` and `PXAT` raise `CommandNotSupportedException`. |
| `PERSIST`, `APPEND`, `STRLEN` | As in Redis. |
| `SCAN cursor [MATCH p] [COUNT n]` | Reply `[cursor-as-string, keys]`; works with Predis' `Keyspace` iterator. `TYPE` filter unsupported. **In a cluster SCAN lists only this node's keyspace**: every hash and every string up to `[cluster.kv] small_key_threshold` is on every node, but larger string values only on their replica nodes. |
| `SET … IFEQ expected` | Compare-and-set (`ephpm_kv_set_if_eq`): `OK` if the current value equals `expected`, nil otherwise; never creates the key. `EX`/`PX` set a TTL, `KEEPTTL` keeps it, neither clears it. Not combinable with `NX`. |

Hash notes: hash field values in a **cluster** are limited to
`[cluster.kv] small_key_threshold - 17` bytes (495 by default); a larger
value makes `HSET` raise `ServerException`. Whole-key `EXPIRE`/`TTL`/`DEL` work
on hash keys too.

---

## SET modifier matrix
Expand All @@ -379,9 +412,10 @@ need `EVAL`/`SUBSCRIBE`/`PUBLISH`.
| `EX seconds` | supported |
| `PX milliseconds` | supported (rounded up to seconds) |
| `NX` | supported (atomic insert-if-absent via `ephpm_kv_setnx()`) |
| `IFEQ expected` | supported on ePHPm v0.13+ (compare-and-set via `ephpm_kv_set_if_eq()`) |
| `XX` | unsupported |
| `GET` | unsupported |
| `KEEPTTL` | unsupported |
| `KEEPTTL` | only together with `IFEQ` |
| `EXAT`, `PXAT` | unsupported |

### OOM and SETNX
Expand Down Expand Up @@ -445,7 +479,9 @@ assert($client->get('foo') === 'bar');

`InMemoryKvOps` is for tests only — values live in PHP arrays, there is
no eviction policy, no memory limit, and TTL is best-effort lazy
expiry. Don't use it in production.
expiry. Don't use it in production. It implements the v0.13 commands too;
pass a list of operation names to emulate an older ePHPm — `new
InMemoryKvOps([])` behaves like a runtime without any of them.

---

Expand All @@ -460,7 +496,8 @@ the second arg of `KvConnection` (tests).
### `CommandNotSupportedException: ephpm KV does not implement 'LPUSH'`

You called a Redis command outside the supported subset (lists/sets/
hashes/streams/scripts/pub-sub). Either change the call to use the
streams/scripts/pub-sub, or hashes and the other
[v0.13 commands](#ephpm-v013-commands) on an older ePHPm). Either change the call to use the
string + counter ops your data model can be expressed with, or keep
that workload on a real Redis (you can wire two clients in your app and
route per-key-pattern).
Expand Down
14 changes: 10 additions & 4 deletions src/CommandNotSupportedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@

/**
* Thrown when client code dispatches a Predis command that ePHPm's KV
* store does not implement (lists, hashes, sets, sorted sets, streams,
* scripting, pub/sub, MULTI/EXEC, …). Surfaces the unsupported command
* store does not implement (lists, sets, sorted sets, streams, scripting,
* pub/sub, MULTI/EXEC, … — and hashes / the other v0.13 commands when the
* running ePHPm predates them). Surfaces the unsupported command
* name in the message so callers can switch their code over to the
* supported subset or move that workload to a real Redis.
*/
final class CommandNotSupportedException extends PredisException
{
public function __construct(string $commandId)
/**
* @param list<string>|null $supported the commands the connection does
* execute (defaults to the base set,
* {@see KvConnection::SUPPORTED_COMMANDS})
*/
public function __construct(string $commandId, ?array $supported = null)
{
parent::__construct(
"ephpm KV does not implement the Redis command '{$commandId}'. "
. 'Supported commands: ' . \implode(', ', KvConnection::SUPPORTED_COMMANDS) . '.'
. 'Supported commands: ' . \implode(', ', $supported ?? KvConnection::SUPPORTED_COMMANDS) . '.'
);
}
}
162 changes: 162 additions & 0 deletions src/ExtendedKvOpsInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

declare(strict_types=1);

namespace Ephpm\Predis;

/**
* Optional backend capabilities that map onto the `ephpm_kv_*` functions
* added in ePHPm v0.13.0 (hashes, multi-key strings, GETDEL/GETEX, PERSIST,
* APPEND/STRLEN/TYPE, SCAN and compare-and-set).
*
* Kept separate from {@see KvOpsInterface} so existing custom backends keep
* compiling. {@see KvConnection} only calls a method here after
* {@see supports()} has confirmed the matching operation is available; when
* it is not (an older ePHPm, or a backend that does not implement this
* interface) the connection keeps its pre-v0.13 behavior for that command.
*
* Method semantics mirror the SAPI functions one-to-one (see each method);
* TTLs are seconds unless the name says otherwise.
*/
interface ExtendedKvOpsInterface extends KvOpsInterface
{
/**
* Every operation name {@see supports()} may be asked about. Each is the
* suffix of the SAPI function that backs it (`hset` = `ephpm_kv_hset`).
*/
public const OPS = [
'hget', 'hset', 'hsetnx', 'hmget', 'hgetall', 'hdel', 'hexists',
'hlen', 'hkeys', 'hvals', 'hincr_by', 'hexpire', 'httl', 'hpttl',
'hpersist', 'mget', 'mset', 'getdel', 'getex', 'persist',
'set_if_eq', 'append', 'strlen', 'type', 'scan',
];

/**
* Whether the operation named `$op` (one of {@see OPS}) is available.
*/
public function supports(string $op): bool;

/** `ephpm_kv_hget`: the field's value, or null if absent (or on failure). */
public function hget(string $key, string $field): ?string;

/**
* `ephpm_kv_hset` (array form): write every field.
*
* @param array<array-key, string> $fields field => value
*
* @return int|false number of fields created, false on failure
*/
public function hset(string $key, array $fields): int|false;

/** `ephpm_kv_hsetnx`: true if set, false if the field existed (or failure). */
public function hsetnx(string $key, string $field, string $value): bool;

/**
* `ephpm_kv_hmget`.
*
* @param list<string> $fields
*
* @return list<string|null>|false aligned with `$fields`; false on failure
*/
public function hmget(string $key, array $fields): array|false;

/**
* `ephpm_kv_hgetall`.
*
* @return array<array-key, string>|false field => value; false on failure
*/
public function hgetall(string $key): array|false;

/**
* `ephpm_kv_hdel`.
*
* @param list<string> $fields
*/
public function hdel(string $key, array $fields): int;

/** `ephpm_kv_hexists`. */
public function hexists(string $key, string $field): bool;

/** `ephpm_kv_hlen`: 0 for a missing hash. */
public function hlen(string $key): int;

/**
* `ephpm_kv_hkeys`.
*
* @return list<string|int>|false
*/
public function hkeys(string $key): array|false;

/**
* `ephpm_kv_hvals`.
*
* @return list<string>|false
*/
public function hvals(string $key): array|false;

/** `ephpm_kv_hincr_by`: new value; false for a non-integer field, overflow or failure. */
public function hincrBy(string $key, string $field, int $delta): int|false;

/**
* `ephpm_kv_hexpire` for one field: 1 if the field got the TTL, 0 if it
* does not exist, false if `$ttlSeconds` is out of range or on failure.
*/
public function hexpire(string $key, string $field, int $ttlSeconds): int|false;

/** `ephpm_kv_httl`: seconds remaining, -1 no TTL, -2 no such field. */
public function httl(string $key, string $field): int;

/** `ephpm_kv_hpttl`: milliseconds remaining, -1 no TTL, -2 no such field. */
public function hpttl(string $key, string $field): int;

/** `ephpm_kv_hpersist`: true if a field TTL was removed. */
public function hpersist(string $key, string $field): bool;

/**
* `ephpm_kv_mget`.
*
* @param list<string> $keys
*
* @return list<string|null>|false aligned with `$keys`; false on failure
*/
public function mget(array $keys): array|false;

/**
* `ephpm_kv_mset`: write every pair with the same TTL (0 = none).
*
* @param array<array-key, string> $values key => value
*/
public function mset(array $values, int $ttlSeconds = 0): bool;

/** `ephpm_kv_getdel`: the value (then deleted), or null if missing. */
public function getdel(string $key): ?string;

/** `ephpm_kv_getex`: read and set the TTL (> 0 seconds) or remove it (0). */
public function getex(string $key, int $ttlSeconds): ?string;

/** `ephpm_kv_persist`: true if a TTL was removed. */
public function persist(string $key): bool;

/**
* `ephpm_kv_set_if_eq`: replace the value only while it equals
* `$expected`; never creates the key. `$ttlSeconds` > 0 = new TTL,
* 0 = no TTL, -1 = keep the current TTL.
*/
public function setIfEq(string $key, string $expected, string $value, int $ttlSeconds = 0): bool;

/** `ephpm_kv_append`: the new length, false on failure. */
public function append(string $key, string $value): int|false;

/** `ephpm_kv_strlen`: 0 if missing. */
public function strlen(string $key): int;

/** `ephpm_kv_type`: "string", "hash" or "none". */
public function type(string $key): string;

/**
* `ephpm_kv_scan`: one step of an incremental keyspace scan.
*
* @return array{0: int, 1: list<string>}|false [next cursor (0 = done), keys]
*/
public function scan(int $cursor, ?string $match = null, int $count = 10): array|false;
}
Loading
Loading