Skip to content

Fix XPENDING reply schema for empty pending lists - #4653

Merged
zuiderkwast merged 1 commit into
valkey-io:unstablefrom
sarthakaggarwal97:fix-xpending-empty-reply-schema
Sep 11, 2026
Merged

zuiderkwast merged 1 commit into
valkey-io:unstablefrom
sarthakaggarwal97:fix-xpending-empty-reply-schema

Conversation

@sarthakaggarwal97

@sarthakaggarwal97 sarthakaggarwal97 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

When a consumer group has no pending messages, XPENDING <key> <group> returns:

[0, null, null, null]

The reply schema only described non-empty summaries and extended replies, so the reply-schema validator rejected this valid response.

This adds a dedicated schema variant for the empty summary while preserving strict validation for non-empty responses. There is no change to command behavior.

This started surfacing consistently after #4629 added stream tests that call XPENDING after clearing the pending entries list.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 3ef67d1f-6464-45d5-b06b-525cb8fa7567

📥 Commits

Reviewing files that changed from the base of the PR and between 95e0e9a and 457ef99.

📒 Files selected for processing (1)
  • src/commands/xpending.json

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The XPENDING command declaration now includes a reply schema for empty pending-message summaries. The schema defines a zero count and null values for the minimum ID, maximum ID, and consumer list.

Changes

XPENDING reply schema

Layer / File(s) Summary
Empty summary reply schema
src/commands/xpending.json
Adds a reply-schema alternative for empty pending-message summaries. It defines a count of 0 and nullable minimum ID, maximum ID, and consumer-list fields.

Priority: ⬇️ Low

Estimated code review effort: 1 (Trivial) | ~5 minutes

Merge Risk: ⚪ Minimal · up to 457ef

This change documents the valid empty XPENDING response without altering command behavior.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: correcting the XPENDING reply schema for empty pending lists.
Description check ✅ Passed The description accurately explains the valid empty response, the schema gap, the added schema variant, and the unchanged command behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sarthakaggarwal97 sarthakaggarwal97 added the run-extra-tests Run extra tests on this PR (Runs all tests from daily except valgrind and RESP) label Sep 10, 2026
@sarthakaggarwal97 sarthakaggarwal97 changed the title Fix XPENDING reply schema for empty PEL Fix XPENDING reply schema for empty pending lists Sep 10, 2026
XPENDING returns a zero count followed by three null values when the consumer group's pending entries list is empty. Model that response as a separate schema alternative so the reply validator accepts it without weakening validation of non-empty summaries.

Signed-off-by: Sarthak Aggarwal <sarthagg@amazon.com>
@sarthakaggarwal97
sarthakaggarwal97 force-pushed the fix-xpending-empty-reply-schema branch from fa21b7c to 457ef99 Compare September 10, 2026 23:37
@sarthakaggarwal97
sarthakaggarwal97 marked this pull request as ready for review September 10, 2026 23:41
@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.03%. Comparing base (95e0e9a) to head (457ef99).

Additional details and impacted files
@@             Coverage Diff              @@
##           unstable    #4653      +/-   ##
============================================
- Coverage     80.12%   80.03%   -0.09%     
============================================
  Files           189      189              
  Lines         97253    97253              
============================================
- Hits          77923    77837      -86     
- Misses        19330    19416      +86     

see 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@roshkhatri roshkhatri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@zuiderkwast
zuiderkwast merged commit 98f9152 into valkey-io:unstable Sep 11, 2026
137 of 140 checks passed
zuiderkwast pushed a commit to Tarte12/valkey that referenced this pull request Sep 15, 2026
When a consumer group has no pending messages, `XPENDING key group`
returns:

    [0, null, null, null]

The reply schema only described non-empty summaries and extended
replies, so the reply-schema validator rejected this valid response.

This adds a dedicated schema variant for the empty summary while
preserving strict validation for non-empty responses. There is no change
to command behavior.

This started surfacing consistently after valkey-io#4629 added stream tests that
call `XPENDING` after clearing the pending entries list.

Signed-off-by: Sarthak Aggarwal <sarthagg@amazon.com>
madolson added a commit that referenced this pull request Sep 16, 2026
`process_file` runs inside a `multiprocessing.Pool` worker, and on a
reply schema mismatch it re-raised the `jsonschema` exception. That
exception holds a reference to a `TypeChecker` lambda in
`jsonschema._types`, which cannot be pickled, so the parent process
reports `MaybeEncodingError`/`PicklingError` and the actual mismatch is
only visible in the worker's own output, hundreds of lines earlier in
the CI log. This raises a `RuntimeError` carrying the message instead,
so the failure the validator exits on is the failure it found.

<details>
<summary>Details</summary>

## Problem

`utils/req-res-log-validator.py:239` ends the schema-mismatch handler
with a bare `raise`. The pool is created at `:334` and `process_file` is
the worker body from `:186`.

Reproduced standalone with the pinned `jsonschema==4.17.3`:

```
pickle of jsonschema.ValidationError: PicklingError: Can't pickle <function <lambda> at 0x7f6d8c90add0>: attribute lookup <lambda> on jsonschema._types failed
real message the worker saw: None is not of type 'string'

bare `raise`  (upstream today): parent saw MaybeEncodingError: Error sending result: '<multiprocessing.pool.ExceptionWithTraceback object at 0x7f6d8c73b450>'. Reason: 'PicklingError("Can't pickle <function <lambda> at 0x7f6d8c90add0>: attribute lookup <lambda> on jsonschema._types failed")'
RuntimeError  (this patch)   : parent saw RuntimeError: JSON schema validation error on fake.log: None is not of type 'string'
```

This is not specific to any one command. Every reply-schema mismatch
presents the same way, which is why the `reply-schemas-validator` job's
failure output does not name the schema that failed. Contrast #2676,
where the worker raised a `ValueError` and the parent reported it
verbatim.

It surfaced while triaging a Daily failure where `XPENDING` on an empty
PEL was rejected by a stale schema (fixed separately in #4653). The
schema was the bug; the pickling error was what made it expensive to
find.

## Scope

Only the `jsonschema` handler at `:239` is changed. The two handlers at
`:210` and `:214` also re-raise from the worker, but they carry
`json.decoder.JSONDecodeError` and whatever `Request`/`Response`
construction throws, which pickle fine, so they are left alone rather
than widened speculatively.

## Alternative rejected

Catching `MaybeEncodingError` in the parent and reformatting it would
keep the worker code unchanged, but the parent has no access to the
original exception's fields by then, only the pickling failure's text.
The information has to be flattened before it crosses the process
boundary.

</details>

This was generated by AI but verified, with love, by a human.

---------

Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
arshidkv12 pushed a commit to arshidkv12/valkey that referenced this pull request Sep 16, 2026
…#4703)

`process_file` runs inside a `multiprocessing.Pool` worker, and on a
reply schema mismatch it re-raised the `jsonschema` exception. That
exception holds a reference to a `TypeChecker` lambda in
`jsonschema._types`, which cannot be pickled, so the parent process
reports `MaybeEncodingError`/`PicklingError` and the actual mismatch is
only visible in the worker's own output, hundreds of lines earlier in
the CI log. This raises a `RuntimeError` carrying the message instead,
so the failure the validator exits on is the failure it found.

<details>
<summary>Details</summary>

## Problem

`utils/req-res-log-validator.py:239` ends the schema-mismatch handler
with a bare `raise`. The pool is created at `:334` and `process_file` is
the worker body from `:186`.

Reproduced standalone with the pinned `jsonschema==4.17.3`:

```
pickle of jsonschema.ValidationError: PicklingError: Can't pickle <function <lambda> at 0x7f6d8c90add0>: attribute lookup <lambda> on jsonschema._types failed
real message the worker saw: None is not of type 'string'

bare `raise`  (upstream today): parent saw MaybeEncodingError: Error sending result: '<multiprocessing.pool.ExceptionWithTraceback object at 0x7f6d8c73b450>'. Reason: 'PicklingError("Can't pickle <function <lambda> at 0x7f6d8c90add0>: attribute lookup <lambda> on jsonschema._types failed")'
RuntimeError  (this patch)   : parent saw RuntimeError: JSON schema validation error on fake.log: None is not of type 'string'
```

This is not specific to any one command. Every reply-schema mismatch
presents the same way, which is why the `reply-schemas-validator` job's
failure output does not name the schema that failed. Contrast valkey-io#2676,
where the worker raised a `ValueError` and the parent reported it
verbatim.

It surfaced while triaging a Daily failure where `XPENDING` on an empty
PEL was rejected by a stale schema (fixed separately in valkey-io#4653). The
schema was the bug; the pickling error was what made it expensive to
find.

## Scope

Only the `jsonschema` handler at `:239` is changed. The two handlers at
`:210` and `:214` also re-raise from the worker, but they carry
`json.decoder.JSONDecodeError` and whatever `Request`/`Response`
construction throws, which pickle fine, so they are left alone rather
than widened speculatively.

## Alternative rejected

Catching `MaybeEncodingError` in the parent and reformatting it would
keep the worker code unchanged, but the parent has no access to the
original exception's fields by then, only the pickling failure's text.
The information has to be flattened before it crosses the process
boundary.

</details>

This was generated by AI but verified, with love, by a human.

---------

Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
Signed-off-by: arshidkv12 <arshidkv12@gmail.com>
arshidkv12 added a commit to arshidkv12/valkey that referenced this pull request Sep 16, 2026
Signed-off-by: arshidkv12 <arshidkv12@gmail.com>

Bump minimum cmake version to 3.24 (valkey-io#4232)

Currently the `src/CMakeLists.txt` is using [Link
Features](https://cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html#link-features)
which was added in version 3.24.

Increase `cmake_minimum_required` to 3.24 to reflect the actual minimum
version required to run `cmake`.

Signed-off-by: Bara' Hasheesh <bara.hasheesh@gmail.com>
Signed-off-by: arshidkv12 <arshidkv12@gmail.com>

Feature valkey path hash (valkey-io#4506)

This document proposes adding **Path Hash**, a general-purpose,
binary-safe data type backed by a radix tree, to Valkey. Each Valkey key
stores one Path Hash, and each logical path maps to a `field -> value`
map. The type provides exact lookup, longest-prefix matching, matching
of all stored ancestor prefixes, atomic updates, and prefix traversal.
It gains persistence and high availability through Valkey's existing
RDB, AOF, primary-replica replication, and Cluster capabilities.

The first target use case is a KV Cache placement index for AI
inference: Worker KV Cache events continuously update which Workers
contain which cached prefixes. A Router queries all reusable prefixes
along a request's cumulative block-hash path and then combines the
results with real-time load to select a Worker. The type itself does not
interpret tokens, models, Workers, or cache tiers. Applications encode
those semantics into binary-safe paths, fields, and values, so the type
can also serve hierarchical routing, longest-prefix matching,
autocomplete indexes, and other prefix-oriented workloads.

Large-scale model inference services commonly reuse previously computed
KV Cache through a prefix cache. When a request reaches a Router, the
Router needs to know more than whether the prompt hits the cache. It
also needs to know:

1. How deep the request's contiguous prefix matches on each Worker;
2. Whether the same prefix exists simultaneously on multiple Workers or
cache tiers;
3. How placement changes continuously as blocks are created, migrated,
evicted, and as Workers restart;
4. That multiple Routers must observe the same persistent and
recoverable placement index;

A regular Hash can store `block_hash -> workers`, but it cannot express
"find all stored ancestor prefixes of this query" in a single query.
Storing every prefix as a separate top-level key also inflates the key
count, makes atomic updates across keys difficult, and introduces many
network round trips. The ordering of a Sorted Set is likewise not
equivalent to a byte-prefix relationship.

A radix tree compresses paths by their shared byte prefixes, making it
naturally suitable for longest-prefix matching and ancestor-prefix
enumeration. Valkey already contains a mature internal `rax`
implementation that supports path compression, binary-safe keys, values
on internal nodes, insertion, deletion, and ordered iteration.
Therefore, no new tree implementation dependency is required.

- Provide a general-purpose, binary-safe prefix-index data type.
- Query the longest prefix or all stored ancestor prefixes with one
command.
- Allow multiple fields on the same path to be updated independently.
- Make writes idempotent and support atomic single-key batch application
for event streams.
- Use existing Valkey persistence, replication, failover, and Cluster
semantics.
- Reuse the path-compression and iteration capabilities of the current
internal `rax`.
- Provide stable and explicit response structures for RESP2 and RESP3
clients.

- The type does not provide vector similarity or semantic-similarity
matching; all matching is exact byte-prefix matching.
- The type does not provide a global Path Hash spanning multiple Valkey
keys.
- The type does not replace the inference system's Worker snapshot,
live-event, or event-reconciliation protocols.
- The type does not guarantee synchronous replication or zero RPO; data
reliability still follows the selected Valkey deployment model.
- v1 does not provide a separate TTL for an individual path; TTL still
applies to the top-level Valkey key.

```mermaid
flowchart LR
    Client[Client] --> Router[Router]
    Router -->|prefix query| Valkey[Valkey Path Hash]
    Router -->|inference request| Worker[Inference Worker]
    Worker -->|KV events| Bridge[KV Event Bridge]
    Bridge -->|atomic batch| Valkey
    Worker -->|load report| Router
```

The data plane has two paths:

- Update path: A Worker produces KV events such as block created,
stored, and evicted. The Bridge converts each event into a complete path
and writes it idempotently to Valkey through `PHSET` or batches updates
to multiple paths through `PHMSET`.
- Query path: The Router converts a prompt into a cumulative block-hash
path, retrieves all matching prefixes and placements in one `PHPREFIXES`
call, and then combines them with Worker load for routing.

The Valkey Path Hash is a placement index shared by multiple Routers.
The Bridge is responsible for protocol adaptation, event ordering, batch
coalescing, retries, generation management, and short-term in-memory
buffering rather than serving queries.

Path Hash data is persisted through RDB, AOF, and primary-replica
replication. The Bridge uses bounded in-memory buffering and retries for
unacknowledged events. If it detects a sequence gap or buffer overflow,
it should reconcile against a Worker snapshot before continuing to
consume live events.

A Path Hash placement index is generally rebuildable soft state. Losing
an add usually produces a false miss and therefore reuses less cache.
Losing a delete can produce a more dangerous stale positive.
Applications should therefore include the Worker generation in the value
and have the Router accept only the currently live generation. After a
Worker restarts, an old generation will not participate in routing even
if an old delete was lost; stale records can be reclaimed later by a
background scan.

This strategy reduces application-level risk during a failure window but
does not change the RPO of Valkey's asynchronous replication itself.

Each top-level Valkey key stores one tree:

```text
Valkey key
└── Path Hash
    ├── path P1
    │   ├── field F1 -> value V1
    │   └── field F2 -> value V2
    ├── path P1 || P2
    │   └── field F1 -> value V3
    └── path P1 || P3
        └── field F3 -> value V4
```

Formally:

```text
Tree: BinaryString -> Map<BinaryString, BinaryString>
```

- Top-level key: A regular Valkey key responsible for the namespace,
Cluster slot, and tree-wide TTL.
- path: A binary-safe byte string and the logical key in the Path Hash.
- field: A binary-safe byte string identifying an owner or property that
can be updated independently under the same path.
- value: A binary-safe opaque value encoded by the application.

An empty path is valid and represents the root payload. An internal
navigation node is a "stored logical path" only when it has a non-empty
field/value map. After its last field is deleted, the logical path
disappears automatically, and `rax` can subsequently compress internal
nodes that are no longer needed.

The top-level Path Hash key may remain present with zero logical paths.
Deleting the final path, or clearing the tree with `PHDELPREFIX key ""`,
leaves an empty Path Hash object whose `PHCARD` is zero. This follows
Stream semantics, where `XTRIM` may leave an empty stream; only `DEL` or
`UNLINK` removes the top-level key. Empty Path Hash objects are
preserved by RDB, AOF rewrite, replication, and `COPY`.

If each path stored only one opaque value, updating placement for
multiple Workers would require reading the entire value, modifying the
list, and writing it back. Two Bridges or two event batches could then
cause a lost update. A field/value map reduces the conflict granularity
to an individual field:

```text
path  = cumulative-hash-path
field = worker-a | hbm
value = generation 7 | component mask | metadata
```

`worker-a` and `worker-b` can independently execute `PHSET` or `PHDEL`
operations, and the commands are naturally idempotent.

Let `p` and `q` be byte strings. `p` is a prefix of `q`, written `p ⪯
q`, if and only if there is a byte string `s` such that `q = p || s`.
Matching does not interpret UTF-8, integers, tokens, or segments.

For the set `S` of stored paths in the tree:

```text
Exact(q)    = q, provided q belongs to S
Longest(q)  = arg max |p|, where p belongs to S and p ⪯ q
Prefixes(q) = all p in S satisfying p ⪯ q, ordered from shortest to longest
```

If an empty path is stored, it is the first match for every query.
`PREFIXES` returns only ancestors of the query, not descendants for
which the query is a prefix.

The application first divides the prompt into blocks using a fixed page
size. Suppose there are three blocks, `B1`, `B2`, and `B3`, and a
chained cumulative hash is used:

```text
C1 = Hash(B1)
C2 = Hash(C1 || B2)
C3 = Hash(C2 || B3)
```

Each cumulative hash is encoded as a fixed-width big-endian byte string.
For example, a 64-bit hash uses 8 bytes:

```text
P1 = BE64(C1)
P2 = BE64(C1) || BE64(C2)
P3 = BE64(C1) || BE64(C2) || BE64(C3)
```

The request query path is:

```text
Q = BE64(C1) || BE64(C2) || BE64(C3)
```

The stored representation is therefore a "cumulative hash sequence." If
`Hash(B1)`, `Hash(B2)`, and `Hash(B3)` were treated as three unrelated
keys, they would have no ancestor relationship at the byte level, and
the Path Hash could not derive the block order.

The server does not enforce an 8-byte segment size. Fixed-width encoding
is simply how the application ensures that no logical path ends in the
middle of a hash.

Suppose `page_size = 4` and the request contains 12 tokens:

```text
B1 = token 1..4
B2 = token 5..8
B3 = token 9..12
```

The placement tree has the following logical content:

```text
P1
├── worker-a|hbm -> generation=7, components=1111
└── worker-b|hbm -> generation=3, components=1111

P2
├── worker-a|hbm -> generation=7, components=1111
└── worker-b|hbm -> generation=3, components=1111

P3
└── worker-b|hbm -> generation=3, components=1111
```

The shared-prefix relationship in the tree is:

```mermaid
flowchart TD
    Root[Root]
    P1[P1 cached by A and B]
    P2[P1 plus P2 cached by A and B]
    P3[P1 plus P2 plus P3 cached by B]
    Root --> P1
    P1 --> P2
    P2 --> P3
```

The Router executes the following operation for `Q`. `LENGTHS` avoids
repeatedly returning the shared bytes of `P1`, `P2`, and `P3`; the
Router can divide the matched length by 8 to obtain the page depth
directly:

```text
PHPREFIXES kv:{model-id}:placement Q
    LENGTHS
    WITHVALUES
    COUNT 3
    MAXLEN 24
```

The response is conceptually equivalent to:

```text
[
  [8,  [worker-a|hbm, value-a7, worker-b|hbm, value-b3]],
  [16, [worker-a|hbm, value-a7, worker-b|hbm, value-b3]],
  [24, [worker-b|hbm, value-b3]]
]
```

The Router aggregates by path length and obtains:

```text
worker-a has a contiguous match of 2 pages = 8 tokens
worker-b has a contiguous match of 3 pages = 12 tokens
```

It then combines those results with signals such as load, queue length,
and cross-machine communication cost for routing. The largest cache hit
does not necessarily determine the final Worker. This is also why the
Router needs all ancestor prefixes rather than only a global `LONGEST`
result.

If the Router is considering only a set of healthy Workers, it can use
`FIELDS` filtering to reduce the response size:

```text
PHPREFIXES kv:{model-id}:placement Q
    LENGTHS
    FIELDS 2 worker-a|hbm worker-b|hbm
    COUNT 3
    MAXLEN 24
```

The Path Hash API accepts a complete path. It does not accept a
`parent_hash` and then query the parent chain on the server. If a Worker
event contains only the current block hash and parent hash, the Bridge
has two options:

1. Maintain a short-lived `hash -> full path` cache and use it to
construct the complete path;
2. Modify the event protocol so that the Worker carries either the
cumulative hash-segment sequence or the fully encoded path directly.

The second approach makes a stateless Bridge easier to implement, while
the first reduces event size. This trade-off belongs to the inference
event protocol and is not part of the general-purpose Path Hash data
type.

The `PH` command prefix stands for Path Hash. The public type name is
Path Hash; the command group, ACL category, and `TYPE`/`SCAN TYPE` name
use `path-hash`. The keyspace-notification configuration character
remains `r`.

In every command, `key` is the top-level Valkey key, while `path`,
`query`, `field`, and `value` are binary-safe bulk strings. A command
returns the standard `WRONGTYPE` error when an existing key has the
wrong type.

| Command | Syntax | Purpose | Time Complexity |
|---|---|---|---|
| `PHSET` | `PHSET key path [FNX \| FXX] FIELDS numfields field value
[field value ...]` | Atomically set one or more fields under one path |
`O(L + F·U)` |
| `PHMSET` | `PHMSET key path FIELDS numfields field value [field value
...] [path FIELDS numfields field value ...]` | Atomically set fields
grouped by path | `O(ΣL + M·U)` |
| `PHGET` | `PHGET key path field [field ...]` | Read one or more fields
by exact path | `O(L + F·U)` |
| `PHMGET` | `PHMGET key path FIELDS numfields field [field ...] [path
FIELDS numfields field ...]` | Read fields grouped by exact path | `O(ΣL
+ M·U)` |
| `PHGETALL` | `PHGETALL key path` | Read all field/value pairs under a
path | `O(L + O)` |
| `PHEXISTS` | `PHEXISTS key path` | Test whether an exact logical path
exists | `O(L)` |
| `PHDEL` | `PHDEL key path [field [field ...]]` | Delete fields or an
entire logical path | `O(L + F·U)` for specified fields; `O(L + S)` for
the entire payload |
| `PHLONGEST` | `PHLONGEST key query [LENGTH] [WITHVALUES \| FIELDS
numfields field [field ...]]` | Return the longest stored prefix of a
query | `O(L + O)` |
| `PHPREFIXES` | `PHPREFIXES key query [LENGTHS] [WITHVALUES \| FIELDS
numfields field [field ...]] [COUNT count] [MAXLEN max-path-bytes]` |
Return all stored ancestor prefixes of a query | `O(L + O)` |
| `PHDELPREFIX` | `PHDELPREFIX key prefix` | Delete all logical paths
under a prefix | `O(L + Σ(Pᵢ + Fᵢ))` |
| `PHSCAN` | `PHSCAN key cursor [PREFIX prefix] [COUNT count]
[WITHVALUES]` | Incrementally traverse logical paths | `O(L + C + O)` |
| `PHCARD` | `PHCARD key` | Return the number of logical paths | `O(1)`
|

Where:

- `L` is the byte length of the input path or query;
- `F` is the number of requested fields;
- `S` is the number of stored fields in a payload removed in full;
- `M` is the total number of fields across all path groups in a
multi-path command;
- `U` is the lookup cost within a node payload: linear for a small
listpack and amortized constant time for a dict;
- `O` is the number of bytes or fields actually returned;
- `Pᵢ` is the byte length of the i-th path matched by a subtree
deletion;
- `Fᵢ` is the number of fields in that path's payload;
- `C` is the number of paths examined by the current scan.

```text
PHSET key path [FNX | FXX]
    FIELDS numfields field value [field value ...]
```

Atomically set one or more field/value pairs in the field map for
`path`. `numfields` must be greater than zero and must equal the number
of field/value pairs that follow. Without a condition, a missing tree or
path is created automatically and existing fields are overwritten.

- `FNX`: Apply the entire field/value group only if none of the
specified fields exists under the path.
- `FXX`: Apply the entire field/value group only if every specified
field exists under the path.
- `FNX` and `FXX` are mutually exclusive.
- Conditions are evaluated against all specified fields before any write
is performed. If any field fails the condition, none of the supplied
field/value pairs is written.
- If the key or path does not exist, `FNX` succeeds and creates it,
while `FXX` fails without creating it.
- If the same field is specified more than once, assignments are applied
from left to right and the last value wins.

Return: `OK` if the write succeeds; null if the condition is not
satisfied.

For example, the following command writes both fields only if neither
`field-1` nor `field-2` exists under `path-a`. The path may already
exist with other fields. If either specified field exists, neither field
is modified:

```text
PHSET tree path-a FNX FIELDS 2 field-1 value-1 field-2 value-2
```

```text
PHMSET key
    path FIELDS numfields field value [field value ...]
    [path FIELDS numfields field value ...]
```

Atomically apply one or more path groups within one Path Hash. Each path
is followed by `FIELDS`, a positive `numfields`, and exactly that many
field/value pairs. Grouping reflects the underlying `path -> field/value
map`, avoids repeating path bytes, and allows one path lookup per path
group. Missing paths are created automatically and existing fields are
overwritten.

`PHMSET` does not support `FNX` or `FXX`; every assignment is
unconditional. The complete grouped argument structure is validated
before any mutation is applied. If the same `(path, field)` target
appears more than once within or across groups, assignments are applied
from left to right and the last value wins.

Return: `OK` after all assignments have been applied.

For example:

```text
PHMSET tree
    path-a FIELDS 2 field-1 value-1 field-2 value-2
    path-b FIELDS 1 field-3 value-3
```

```text
PHGET key path field [field ...]
```

Perform one exact path lookup and read one or more fields from its
payload.

- When one field is requested, return its value as a bulk string, or
null if the key, path, or field does not exist.
- When multiple fields are requested, return an array of values in
request order. A missing key or path produces null for every requested
field, and each missing field produces null at its corresponding
position.

```text
PHMGET key
    path FIELDS numfields field [field ...]
    [path FIELDS numfields field ...]
```

Read one or more field groups from one Path Hash. Each path is followed
by `FIELDS`, a positive `numfields`, and exactly that many fields. Each
path lookup is exact and performed once per group; the command does not
perform ancestor-prefix or descendant-prefix matching. The complete
grouped argument structure is validated before reading results.

Return one flat array whose elements correspond to all requested fields
in path-group and field order. A missing key, path, or field produces
null at the corresponding position. The command always returns an array,
including when only one field is requested. Paths and fields do not need
to be distinct, and duplicate requests are preserved.

For example, table-cache rows stored under a `row` field can be fetched
by primary key in one command:

```text
PHMGET student:pk
    student-10001 FIELDS 2 name grade
    student-10002 FIELDS 1 name
    student-10003 FIELDS 1 name
```

```text
PHGETALL key path
```

Return all field/value pairs for the specified path. RESP2 returns a
flat array:

```text
[field-1, value-1, field-2, value-2, ...]
```

RESP3 returns a map:

```text
{field-1: value-1, field-2: value-2, ...}
```

Field order is undefined in both protocols. If the path or key does not
exist, return an empty array in RESP2 or an empty map in RESP3.

```text
PHEXISTS key path
```

Perform an exact path lookup and return 1 if `path` is a stored logical
path, or 0 if the key or path does not exist. Prefix ancestors and
descendants do not count as an exact match.

```text
PHDEL key path [field [field ...]]
```

- When fields are provided, delete only those fields and return the
number of fields actually deleted.
- When no field is provided, delete the entire payload for the path and
return 1 or 0.
- Deleting a path payload does not delete descendant paths.
- After the last field is deleted, the path is automatically removed
from the logical tree.
- Deleting the final logical path leaves an empty Path Hash key. Use
`DEL` or `UNLINK` to remove the top-level key.

The command is an idempotent no-op for a target that does not exist.

When fields are provided, the command takes `O(L + F·U)` time. When no
field is provided, removing the complete payload synchronously releases
all of its stored fields and takes `O(L + S)` time in the worst case.
`PHDEL` is therefore an `@slow` command.

```text
PHLONGEST key query [LENGTH] [WITHVALUES | FIELDS numfields field [field ...]]
```

Find the longest stored path that is a byte prefix of `query`.

- By default, return the matching path; return null if there is no
match.
- `LENGTH`: Replace the matching path with its byte length, for cases
where the caller already holds the query.
- `WITHVALUES`: Return `[path, [field, value, ...]]` in RESP2 or `[path,
{field: value, ...}]` in RESP3. Field order is undefined.
- `FIELDS`: Read only the specified fields. The inner value array
preserves request order and places null for a missing field. Return
`[path, [value-or-null, ...]]` in both RESP2 and RESP3.
- `WITHVALUES` and `FIELDS` are mutually exclusive.

An empty path can be returned as a match if it is stored.

```text
PHPREFIXES key query
    [LENGTHS]
    [WITHVALUES | FIELDS numfields field [field ...]]
    [COUNT count]
    [MAXLEN max-path-bytes]
```

Return all stored paths satisfying `path ⪯ query`, ordered from shortest
to longest.

- By default, return `[path-1, path-2, ...]`.
- `LENGTHS`: Replace each matching path with its byte length to avoid
repeating the shared bytes of a long query in the response.
- `WITHVALUES`: Return `[[path-1, [field, value, ...]], ...]` in RESP2
or `[[path-1, {field: value, ...}], ...]` in RESP3. Field order within
each payload is undefined.
- `FIELDS`: Return `[[path-1, [value-or-null, ...]], ...]` in both RESP2
and RESP3, with each value array aligned to the requested field order
and null for each missing field.
- `COUNT`: Return at most `count` matches. This is a hard limit, not a
hint. If more matches exist, retain the deepest `count` prefixes; return
the selected results in ascending path-length order.
- `MAXLEN`: Match only paths whose byte length does not exceed
`max-path-bytes`. Apply this limit before `COUNT`.
- Return an empty array when there is no match.

`COUNT` must be an integer from `1` to `LONG_MAX`, and `MAXLEN` must be
an integer from `0` to `LONG_MAX`, using the server build's `long` range
(maximum `2147483647` on 32-bit builds or `9223372036854775807` on
64-bit builds). Invalid or out-of-range values return an error, even
when the key does not exist. Omitting either option imposes no
corresponding limit; `MAXLEN 0` permits only a stored empty root path.

The command visits only ancestor nodes along the query path and does not
scan the entire tree.

For example, suppose the matching path lengths are `[8, 16, 24, 32]`:

```text
PHPREFIXES key query LENGTHS COUNT 2 MAXLEN 24
```

`MAXLEN 24` first produces `[8, 16, 24]`; `COUNT 2` then retains the two
deepest matches, so the final result is `[16, 24]`.

```text
PHDELPREFIX key prefix
```

Delete every logical path satisfying `prefix ⪯ path` and return the
number of paths deleted. An empty prefix clears the entire tree but
preserves the empty top-level Path Hash key. Use `DEL` or `UNLINK` to
remove the key itself.

This is an `@slow` command with time complexity `O(L + Σ(Pᵢ + Fᵢ))`:
deleting each match copies and removes its complete path and
synchronously releases every field in its payload.

The implementation may collect matching paths in bounded-size chunks to
avoid iterator invalidation and limit the number of temporary path
copies. This chunking does not make the operation incremental: the
complete matching subtree is deleted synchronously and atomically in one
command invocation, without yielding between chunks. A large subtree can
therefore cause high latency.

```text
PHSCAN key cursor [PREFIX prefix] [COUNT count] [WITHVALUES]
```

Incrementally traverse paths in lexicographic order. The cursor is an
opaque bulk string: pass `0` on the first call; the server returns
`[next-cursor, entries]`, where `next-cursor = 0` means the traversal is
complete. A nonzero cursor encodes the previous position and must not be
interpreted by the client.

- `PREFIX` restricts traversal to the specified subtree.
- `COUNT` is a hint for the number of paths to examine in each call and
does not guarantee an exact result count.
- `WITHVALUES` returns each entry as `[path, [field, value, ...]]` in
RESP2 or `[path, {field: value, ...}]` in RESP3. Field order within each
payload is undefined. The outer response remains `[next-cursor,
entries]` in both protocols.
- As with Valkey `SCAN`, concurrent modifications can cause duplicates
or omissions, and clients must process results idempotently.

The implementation can encode a version number and the last returned
path in the cursor, then use `raxSeek` to resume at the lexicographic
position without retaining an iterator session on the server.

```text
PHCARD key
```

Return the number of logical paths in the tree that carry a non-empty
payload. Return 0 for either an empty Path Hash or a missing key; use
`EXISTS` or `TYPE` when the caller needs to distinguish those states.

```c
typedef struct RadixPayload {
    uint8_t encoding;          /* listpack or dict */
    uint32_t num_fields;
    void *data;
} RadixPayload;

typedef struct RadixObject {
    rax *index;                /* path -> RadixPayload* */
    uint64_t num_paths;
    uint64_t num_fields;
} RadixObject;
```

```text
Valkey key
└── RadixObject
    ├── rax index
    │   └── data pointer ─────────┐
    └── counters                  │
                                 ▼
                         RadixPayload
                         └── listpack or dict
                             └── field -> value
```

The `rax` data pointer points to a `RadixPayload`. `rax` already allows
a key to terminate at an internal node that still has descendants, so
`P1` and `P1 || P2` can both carry payloads. A small payload uses a
listpack to reduce memory overhead when each block belongs to only a few
Workers. After the entry or value threshold is exceeded, the payload is
promoted one-way to a dict to provide near-`O(1)` field updates. It is
not automatically demoted after deletion, avoiding encoding oscillation.

- `raxNew` creates an empty tree;
- `raxInsert` and `raxTryInsert` insert a path and payload pointer;
- `raxFind` supports exact-path operations such as `PHGET` and
`PHEXISTS`;
- `raxRemove` removes a logical path after its payload becomes empty and
recompresses compressible nodes;
- `raxStart`, `raxSeek`, `raxNext`, and `raxPrev` support lexicographic
scan, RDB save, and AOF rewrite;
- `raxSize` provides the number of logical keys;
- `raxAllocSize` can be included in `MEMORY USAGE`;
- Path compression stores the shared bytes of many cumulative hash
prefixes only once.

Currently, `raxFind` returns data only when the query exactly matches a
stored key. `raxSeek` provides lexicographic positioning but does not
efficiently enumerate ancestors of the query. If a client calls
`raxFind` separately for every byte prefix of the query, the worst-case
complexity degrades from `O(L)` to `O(L²)`.

Two helpers should be added at the internal `rax` layer, or a
callback-based walk should be added and shared by both:

```c
void *raxFindLongestPrefix(
    rax *rt,
    const unsigned char *query,
    size_t len,
    size_t *matched_len);

int raxForEachPrefix(
    rax *rt,
    const unsigned char *query,
    size_t len,
    raxPrefixCallback callback,
    void *context);
```

The algorithm walks the query only once:

```text
1. Start at the head; if the root is a logical key, record length 0.
2. At a regular node, select the child for the next query byte.
3. At a compressed node, compare the entire compressed edge at once; stop immediately if any byte differs.
4. Whenever an iskey node is reached, record the number of bytes consumed and the data pointer.
5. LONGEST retains only the final record; PREFIXES invokes the callback for every record in order.
6. Stop when the query is exhausted or the required branch does not exist.
```

The time complexity is `O(L + K)`, where `K` is the number of logical
paths actually matched; siblings and descendants are not scanned. The
implementation must correctly handle a root key, a mismatch in the
middle of a compressed edge, a key that is also an ancestor of another
key, an empty query, and binary zero.

The current `raxLowWalk` already contains most of the path-descent
logic, but it is `static inline` and returns only the stopping node and
split position. The implementation can extract a shared internal walker
without changing the existing insertion semantics, or add a separate
read-only prefix walker. The latter has a smaller change surface and is
easier to validate with differential tests.

`PHSCAN PREFIX p` can use `raxSeek(">=", p)` to locate the first
candidate, then call `raxNext` until a key no longer starts with `p`.

`PHDELPREFIX` cannot iterate and delete without accounting for iterator
invalidation. The v1 implementation collects a bounded number of
complete path copies, closes the iterator, removes those paths, and
repeats until no match remains. The bound applies only to the number of
temporary path copies. The outer loop still deletes the complete subtree
synchronously and atomically in one event-loop turn, so chunking does
not bound command latency. Its work includes copying and removing every
matched path and releasing every field in the associated payloads.

If truly nonblocking deletion of a large subtree is needed in the
future, `rax` can gain a subtree-detach capability and hand detached
nodes to the lazy-free thread. This is not required for v1.

---------

Signed-off-by: bodong.ybd <bodong.ybd@alibaba-inc.com>
Co-authored-by: Jacob Murphy <jkmurphy@google.com>
Signed-off-by: arshidkv12 <arshidkv12@gmail.com>

Report the real error when a reply schema fails validation (valkey-io#4703)

`process_file` runs inside a `multiprocessing.Pool` worker, and on a
reply schema mismatch it re-raised the `jsonschema` exception. That
exception holds a reference to a `TypeChecker` lambda in
`jsonschema._types`, which cannot be pickled, so the parent process
reports `MaybeEncodingError`/`PicklingError` and the actual mismatch is
only visible in the worker's own output, hundreds of lines earlier in
the CI log. This raises a `RuntimeError` carrying the message instead,
so the failure the validator exits on is the failure it found.

<details>
<summary>Details</summary>

`utils/req-res-log-validator.py:239` ends the schema-mismatch handler
with a bare `raise`. The pool is created at `:334` and `process_file` is
the worker body from `:186`.

Reproduced standalone with the pinned `jsonschema==4.17.3`:

```
pickle of jsonschema.ValidationError: PicklingError: Can't pickle <function <lambda> at 0x7f6d8c90add0>: attribute lookup <lambda> on jsonschema._types failed
real message the worker saw: None is not of type 'string'

bare `raise`  (upstream today): parent saw MaybeEncodingError: Error sending result: '<multiprocessing.pool.ExceptionWithTraceback object at 0x7f6d8c73b450>'. Reason: 'PicklingError("Can't pickle <function <lambda> at 0x7f6d8c90add0>: attribute lookup <lambda> on jsonschema._types failed")'
RuntimeError  (this patch)   : parent saw RuntimeError: JSON schema validation error on fake.log: None is not of type 'string'
```

This is not specific to any one command. Every reply-schema mismatch
presents the same way, which is why the `reply-schemas-validator` job's
failure output does not name the schema that failed. Contrast valkey-io#2676,
where the worker raised a `ValueError` and the parent reported it
verbatim.

It surfaced while triaging a Daily failure where `XPENDING` on an empty
PEL was rejected by a stale schema (fixed separately in valkey-io#4653). The
schema was the bug; the pickling error was what made it expensive to
find.

Only the `jsonschema` handler at `:239` is changed. The two handlers at
`:210` and `:214` also re-raise from the worker, but they carry
`json.decoder.JSONDecodeError` and whatever `Request`/`Response`
construction throws, which pickle fine, so they are left alone rather
than widened speculatively.

Catching `MaybeEncodingError` in the parent and reformatting it would
keep the worker code unchanged, but the parent has no access to the
original exception's fields by then, only the pickling failure's text.
The information has to be flattened before it crosses the process
boundary.

</details>

This was generated by AI but verified, with love, by a human.

---------

Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
Signed-off-by: arshidkv12 <arshidkv12@gmail.com>

Support filtering ACL LIST by user and role

Signed-off-by: arshidkv12 <arshidkv12@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extra-tests Run extra tests on this PR (Runs all tests from daily except valgrind and RESP)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants