Skip to content

Listpack encoding for Hash field expiration - #3212

Merged
ranshid merged 78 commits into
valkey-io:unstablefrom
frostzt:support-tagged-entries-in-listpack
Sep 10, 2026
Merged

ranshid merged 78 commits into
valkey-io:unstablefrom
frostzt:support-tagged-entries-in-listpack

Conversation

@frostzt

@frostzt frostzt commented Feb 14, 2026 •

Copy link
Copy Markdown
Contributor

Use listpacks for small hashes with field expiration.

The idea is that we'll introduce a new tagged metadata 0xF5 to encode expiry within the listpack encoded value itself.

No RDB changes are introduced in this PR. When storing a hash with volatile fields, it is stored as if it were a hashtable in the RDB format.

So far, field expiration couldn't be encoded in listpacks, so any hash with field expiration could only be stored as a hashtable, which consumes more memory.

Added vsetSize(): returns the number of entries in a vset by summing per-bucket counts (single/vector/hashtable/rax), so callers can get the element count without iterating the entries.

Closes #2618.

@frostzt
frostzt marked this pull request as draft February 14, 2026 13:26
@frostzt
frostzt force-pushed the support-tagged-entries-in-listpack branch 2 times, most recently from fde7c7b to d582d18 Compare February 15, 2026 13:53
@zuiderkwast

Copy link
Copy Markdown
Contributor

feat(listpack)

Where is this naming convention coming from? I've seen it in many projects but I haven't seen it described anywhere.

Anyway, we're not using it in Valkey. We just use plain language but we often include words _like "fix" (if it's a bug fix) or "add" or "support" if it's a feature. Here, "listpack" and "support" are already in the title, so no need for the extra prefix function call syntax.

Btw, I believe we'd prefer to have the full implementation for hashes in the same PR, if you plan to finish it all, but we can review it incrementally if you want. Just add multiple commits. We usually squash them when we merge, or we can keep them as separate commits depending on the situation.

@frostzt frostzt changed the title feat(listpack): update listpack to support tagged metadata update listpack to support tagged metadata Feb 17, 2026
@frostzt frostzt changed the title update listpack to support tagged metadata support for tagged metadata in listpack encoding Feb 17, 2026
@frostzt

frostzt commented Feb 18, 2026

Copy link
Copy Markdown
Contributor Author

feat(listpack)

Where is this naming convention coming from? I've seen it in many projects but I haven't seen it described anywhere.

Anyway, we're not using it in Valkey. We just use plain language but we often include words _like "fix" (if it's a bug fix) or "add" or "support" if it's a feature. Here, "listpack" and "support" are already in the title, so no need for the extra prefix function call syntax.

Btw, I believe we'd prefer to have the full implementation for hashes in the same PR, if you plan to finish it all, but we can review it incrementally if you want. Just add multiple commits. We usually squash them when we merge, or we can keep them as separate commits depending on the situation.

For sure updated it I'll keep making changes and pushing them here, I'd love a scrutinized review this would not only be a great learning opportunity but rather would be me getting more familiar with the repository too 😃

@frostzt
frostzt force-pushed the support-tagged-entries-in-listpack branch 2 times, most recently from 53fac8f to db4a6d5 Compare February 18, 2026 21:06
@frostzt
frostzt marked this pull request as ready for review February 26, 2026 06:57
Comment thread src/listpack.c
@frostzt
frostzt force-pushed the support-tagged-entries-in-listpack branch from 7b4ae2b to 6e20c45 Compare March 1, 2026 18:51
@frostzt

frostzt commented Mar 1, 2026

Copy link
Copy Markdown
Contributor Author

Did something related to tests went in recently? This seems to break I think tests were migrated from c -> cpp and something is probably missing from my end?

@sarthakaggarwal97

Copy link
Copy Markdown
Contributor

@frostzt the project recently introduced GTEST, the CI failures look related to those.

@frostzt
frostzt force-pushed the support-tagged-entries-in-listpack branch 2 times, most recently from 07631d6 to 5bf0aa8 Compare March 7, 2026 19:08
@frostzt

frostzt commented Mar 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Ok getting somewhere now this particular issue is in hashTypeNext and I kinda realize why @ranshid was scrutinizing the validateEntry hook, what's going on is that the hashTypeNext for Listpack should also validate the expiry cause if not the generic HGETALL fails.

Same as @sarthakaggarwal97 mentioned I think hashTypeNext is actually one of the function that uses the lpGet and lpNext but we're encountering metadata values which is good just the expiry to be added :)

@cjx-zar

cjx-zar commented May 29, 2026

Copy link
Copy Markdown
Contributor

I have two suggestions based on my review:

  1. A new RDB type is needed for forward compatibility

Currently, hash listpacks with 0xF5-tagged metadata are saved using the existing RDB_TYPE_HASH_LISTPACK. This creates a silent data corruption risk when an older Valkey version loads the RDB.

This could be used conditionally: hashes without any HFE field continue using the old RDB_TYPE_HASH_LISTPACK (downgrade-safe), while hashes with at least one volatile field use the new type (blocks downgrade, as expected when HFE is in use). This is consistent with how RDB_TYPE_HASH_2 was introduced for hashtable-encoded hashes with volatile fields.

  1. Consider adding a volatile field counter for O(1) volatile tracking

hashTypeHasVolatileFields currently scans the entire listpack to check for 0xF5 metadata entries, making it O(n). Since this function is called before and after every hash mutation to decide whether to update keys_with_volatile_items, the cost adds up.

A simple wrapper struct with a counter would make this O(1):

typedef struct {
    int64_t volatile_count;
    void *lp;
} listpackLp2;

Every metadata add/remove operation updates volatile_count. Then hashTypeHasVolatileFields becomes O(1) with no listpack traversal. This also enables fast LISTPACK2 → LISTPACK encoding transitions when the count drops to zero.

The already-defined but currently unused OBJ_ENCODING_LISTPACK2 could serve as the in-memory encoding type to distinguish listpacks wrapped in this struct.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements per-field TTLs for hash listpack entries with tagged metadata. Propagates expiry handling through listpack APIs, hash operations, RDB persistence, SCAN, AOF rewriting, volatile-set accounting, replication, and regression tests.

Changes

Hash Field Expiration with Listpack Metadata

Layer / File(s) Summary
Listpack metadata encoding infrastructure
src/listpack.c, src/listpack.h, src/unit/test_listpack.cpp
Adds tagged metadata entries, metadata access and mutation APIs, metadata-aware traversal, deletion, lookup, and integrity validation.
Hash expiry foundation and public API
src/expire.h, src/server.h, src/t_hash.c
Adds listpack hash encoding and iterator contracts, volatile-count APIs, TTL visibility checks, and listpack expiry lookup.
Hash field expiry lifecycle
src/t_hash.c
Updates listpack set, expire, persist, delete, and iterator paths to maintain expiry metadata and aggregate volatile counts.
Hash conversion and expiry-aware operations
src/t_hash.c
Preserves expiries during conversion and updates random selection, sampling, and active expiration for visible fields.
RDB serialization and loading
src/rdb.c
Serializes TTL-bearing listpack hashes as HASH_2, validates metadata, and loads fields with preserved expiries and volatile counts.
Runtime scans and AOF rewrite
src/db.c, src/aof.c
Skips expired fields during listpack scans and emits visible volatile and persistent fields during AOF rewrite.
Validation, volatile-set sizing, and regression coverage
src/vset.c, src/vset.h, src/unit/test_vset.cpp, src/t_stream.c, tests/unit/hashexpire.tcl, tests/integration/dual-channel-replication.tcl, .gitignore
Updates validation callers, adds volatile-set size accounting, and covers metadata behavior, expiry tracking, persistence, replication, and cleanup.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested reviewers: jjuleslasarte, sarthakaggarwal97

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant HashCommands
  participant Listpack
  participant RDB
  participant VolatileSet
  Client->>HashCommands: set hash field expiry
  HashCommands->>Listpack: attach tagged expiry metadata
  HashCommands->>VolatileSet: update volatile-field count
  RDB->>Listpack: load field/value/expiry records
  Listpack->>VolatileSet: restore volatile-field count
  Client->>HashCommands: read or scan hash
  HashCommands->>Listpack: filter expired fields
Loading

Merge Risk: 🟠 High · up to 65c03

The new expiry-aware listpack conversion can drop expired hash fields on a primary while replicas retain them, causing inconsistent data between nodes. This correctness issue should be fixed before merge.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
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.
Title check ✅ Passed The title clearly and concisely describes the main change: adding listpack encoding support for hash field expiration.
Description check ✅ Passed The description is directly related to the changeset. It explains tagged metadata for expiring hash fields, memory benefits, the vsetSize API, and the linked issue.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/listpack.c (2)

368-392: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the lpCurrentEncodedSizeBytes() → lpCurrentEncodedSizeUnsafe() safety contract.

lpValidateNext() only bounds-checks p + lenbytes before calling the unsafe decoder. For tagged entries, lenbytes == 1 validates only the outer tag, but lpCurrentEncodedSizeUnsafe() immediately parses the inner header and can read past the buffer on truncated/corrupt tagged values. This also leaves nested tags unbounded in the recursive path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/listpack.c` around lines 368 - 392, lpCurrentEncodedSizeBytes currently
returns 1 for tagged encodings which lets callers (like lpValidateNext) validate
only the outer tag byte before lpCurrentEncodedSizeUnsafe reads the inner header
and may overrun; update lpCurrentEncodedSizeBytes (and callers' expectations) so
that for LP_ENCODING_IS_TAGGED it returns the total number of header bytes
required including the inner element's header(s) (i.e., 1 + the result of
recursively computing the inner header size using the safe bytes-only routine),
thereby preserving the safety contract that callers can bound-check p + lenbytes
before any unsafe parsing and preventing unbounded recursion on nested tags;
reference lpCurrentEncodedSizeBytes, lpCurrentEncodedSizeUnsafe, and
lpValidateNext when making this change.

767-848: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tagged insertion currently changes the meaning of elestr.

The normal lpInsert() contract treats elestr as raw input that still needs listpack encoding. The tagged branch copies elestr verbatim, so lpInsertMetadata(..., elestr, ...) can serialize an invalid inner entry. That becomes especially dangerous because lpGetMetadataValue() later assumes an integer payload and will silently misread a tagged string.

Possible containment if metadata is intentionally integer-only
     if (tagged) {
+        serverAssert(elestr == NULL);
+        serverAssert(eleint != NULL);
         enctype = LP_ENCODING_TAGGED;
-        enclen = size + 1; /* size is the encoded value +1 for the prefixed tag */
+        enclen = size + 1; /* size is the encoded inner value */
     } else if (elestr) {
🧹 Nitpick comments (2)
src/expire.h (1)

4-7: ⚡ Quick win

Move fmacros.h ahead of dict.h.

The added comment says feature-test macros must be visible early, but the new include order does the opposite. Keeping fmacros.h first avoids reintroducing the same include-order problem if dict.h ever gains a system-header dependency.

Suggested reorder
-#include "dict.h"
 `#include` "fmacros.h"
+#include "dict.h"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/expire.h` around lines 4 - 7, The include order in expire.h is reversed:
feature-test macros from fmacros.h must be defined before any headers like
dict.h; move the fmacros.h include so it appears before dict.h (i.e., include
"fmacros.h" first, then "dict.h") to ensure feature-test macros are visible to
any system headers pulled in by dict.h or future changes.
src/t_hash.c (1)

77-84: 🏗️ Heavy lift

Track volatile listpack fields in O(1).

hashTypeHasVolatileFields() now walks the full listpack looking for metadata. This helper is called before/after many write paths in this file, so volatile listpack hashes pick up an extra O(n) scan on hot mutations. A wrapper encoding that stores a volatile-field count would avoid repeatedly rescanning the same payload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/t_hash.c` around lines 77 - 84, hashTypeHasVolatileFields() currently
scans the entire listpack for metadata (O(n)); add a wrapper encoding that
stores a volatile-field count and use that count for O(1) checks. Concretely:
introduce a volatile_count field on the listpack-backed hash object (the
OBJ_ENCODING_LISTPACK wrapper), update hashTypeHasVolatileFields() to return
true when volatile_count>0 (fall back to the full scan only if volatile_count is
absent/zero for compatibility), and update every write/mutation path that
touches listpack entries (the functions that call lpFirst, lpNext, lpIsMetadata
and all hash mutation helpers in this file) to increment/decrement
volatile_count on insert/delete/update when lpIsMetadata would change; ensure
creation/decoding of existing listpacks initializes volatile_count by performing
a single scan once.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aof.c`:
- Around line 2088-2206: The HMSET arity is miscomputed for listpack-encoded
hashes: non_volatile_items is derived from hashTypeLength(o) but the listpack
emission loop skips any field/value pair that has a metadata node (meta &&
lpIsMetadata(meta)), so the HMSET bulk count can be too large; in
rewriteHashObject adjust the listpack branch to compute non_volatile_items by
scanning the listpack (using lpFirst, lpNext, lpGet, lpIsMetadata) and counting
only the field/value pairs that will actually be emitted (i.e., where meta is
NULL or not lpIsMetadata(meta)), or alternatively compute cmd_items/arity
on-the-fly from that scan before writing each HMSET; ensure the same skip
predicate is used when counting so the computed 2 + cmd_items * 2 matches
emitted fields.

In `@src/t_hash.c`:
- Around line 673-688: The HPERSIST branch currently uses
lpFind()/lpNext()/lpDelete() directly and removes metadata without rechecking
expirations, which can resurrect an expired field; modify the branch (around
objectGetVal, lpFirst, lpFind, lpNext, lpIsMetadata, lpDelete, objectSetVal) to
first detect and handle expiry the same way
hashTypeGetFromListpack()/hashTypeGetExpiry() do (i.e., interpret the metadata,
call the same expiry-check logic and treat expired entries as non-existent), and
only proceed to delete metadata and update the object if the field is confirmed
non-expired.
- Around line 2402-2408: The loop in the count-reply path uses listpackEntry
structs (field, value) returned by hashTypeRandomElement but always calls
addWritePreparedReplyBulkCBuffer which will pass NULL for integer-encoded
entries; update the replies to branch on entry type like
hrandfieldReplyWithListpack does: when field.sval is non-NULL call
addWritePreparedReplyBulkCBuffer(field.sval, field.slen) else call
addWritePreparedReplyBulkLongLong(field.lval); do the same for value when
withvalues is true (use value.sval/value.slen vs value.lval). Keep existing
addWritePreparedReplyArrayLen and error/loop logic intact.
- Around line 2646-2658: The code uses lpGet(fptr, &flen, NULL) which doesn't
materialize integer-encoded listpack entries, so when building the object for
expired field names you must use the integer-safe accessor flow (lpGetValue() /
listpackEntry) to obtain a proper char* and length before calling
createStringObject; replace the lpGet/fptr->field usage in this expired-entry
path with listpackEntry/listpackGetValue-based retrieval (honoring
flen/encoding) and then build the string object from the materialized value and
set out_entries[expired_count] accordingly to avoid corrupt copies/crashes.
- Around line 753-761: hashTypeLength() currently iterates the listpack
(OBJ_ENCODING_LISTPACK via objectGetVal, lpFirst, lpNext and lpIsMetadata) and
counts non-metadata entries but does not skip field/value pairs whose metadata
has expired; update the loop in hashTypeLength() so that when you encounter a
metadata entry you check the metadata's expiration with the same listpack-hash
expiration helper used elsewhere (the helper that determines if a metadata entry
is expired) and if that metadata is expired skip both the field and value
entries (advance p twice via lpNext without incrementing count), otherwise count
the field/value pair as before; ensure you continue to use lpIsMetadata, lpFirst
and lpNext to traverse.

---

Outside diff comments:
In `@src/listpack.c`:
- Around line 368-392: lpCurrentEncodedSizeBytes currently returns 1 for tagged
encodings which lets callers (like lpValidateNext) validate only the outer tag
byte before lpCurrentEncodedSizeUnsafe reads the inner header and may overrun;
update lpCurrentEncodedSizeBytes (and callers' expectations) so that for
LP_ENCODING_IS_TAGGED it returns the total number of header bytes required
including the inner element's header(s) (i.e., 1 + the result of recursively
computing the inner header size using the safe bytes-only routine), thereby
preserving the safety contract that callers can bound-check p + lenbytes before
any unsafe parsing and preventing unbounded recursion on nested tags; reference
lpCurrentEncodedSizeBytes, lpCurrentEncodedSizeUnsafe, and lpValidateNext when
making this change.

---

Nitpick comments:
In `@src/expire.h`:
- Around line 4-7: The include order in expire.h is reversed: feature-test
macros from fmacros.h must be defined before any headers like dict.h; move the
fmacros.h include so it appears before dict.h (i.e., include "fmacros.h" first,
then "dict.h") to ensure feature-test macros are visible to any system headers
pulled in by dict.h or future changes.

In `@src/t_hash.c`:
- Around line 77-84: hashTypeHasVolatileFields() currently scans the entire
listpack for metadata (O(n)); add a wrapper encoding that stores a
volatile-field count and use that count for O(1) checks. Concretely: introduce a
volatile_count field on the listpack-backed hash object (the
OBJ_ENCODING_LISTPACK wrapper), update hashTypeHasVolatileFields() to return
true when volatile_count>0 (fall back to the full scan only if volatile_count is
absent/zero for compatibility), and update every write/mutation path that
touches listpack entries (the functions that call lpFirst, lpNext, lpIsMetadata
and all hash mutation helpers in this file) to increment/decrement
volatile_count on insert/delete/update when lpIsMetadata would change; ensure
creation/decoding of existing listpacks initializes volatile_count by performing
a single scan once.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 367f9150-d70a-43d7-8d61-a3114d6aef2a

📥 Commits

Reviewing files that changed from the base of the PR and between 7abe49a and 548a04e.

📒 Files selected for processing (10)
  • src/aof.c
  • src/db.c
  • src/expire.h
  • src/listpack.c
  • src/listpack.h
  • src/server.h
  • src/t_hash.c
  • src/unit/test_listpack.cpp
  • src/valkey-unit-tests
  • tests/unit/hashexpire.tcl

Comment thread src/aof.c Outdated
Comment thread src/t_hash.c Outdated
Comment thread src/t_hash.c Outdated
Comment thread src/t_hash.c Outdated
Comment thread src/t_hash.c Outdated
Add support for tagged (metadata) entries in listpack to enable hash
field expiration on listpack-encoded hashes. Updates hashType functions,
listpack encoding/iteration, active expiry, AOF rewrite, and adds unit
and integration tests.

Signed-off-by: frostzt <aidenfrostbite@gmail.com>
@frostzt
frostzt force-pushed the support-tagged-entries-in-listpack branch from a28e277 to f3ac82b Compare June 5, 2026 10:03
frostzt added 2 commits June 5, 2026 15:47
Signed-off-by: frostzt <aidenfrostbite@gmail.com>
Signed-off-by: frostzt <aidenfrostbite@gmail.com>
@ranshid
ranshid requested a review from jjuleslasarte June 5, 2026 10:52
Signed-off-by: frostzt <aidenfrostbite@gmail.com>
@frostzt

frostzt commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thank you @ranshid I'll go through - this would months of work finally 😭

@ranshid

ranshid commented Sep 10, 2026

Copy link
Copy Markdown
Member

@frostzt I see the fuzzer located assertion related to the hash not being removed from the volatile mapping: https://github.com/valkey-io/valkey/actions/runs/34470551920/job/102849189811?pr=3212 maybe you have cycles to TAL?

@frostzt

frostzt commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

@ranshid I checked this and I am not able to repro this tried all the sites - my idea here is that what I can do instead is push a change where if the assertion fails (in CI) I can log this key name encoding hashtypelen and hashtpyevolatilecount and weather the leading meta data exists or not. That way at least we cna get an idea as to what caused this. What do you think?

Signed-off-by: Sourav Singh Rawat <aidenfrostbite@gmail.com>

@ranshid ranshid 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.

all tests which are failing seems unrelated.

Would like to thank @jjuleslasarte, @zuiderkwast and @sarthakaggarwal97 and @cjx-zar for helping with the review!

Special thank you to @frostzt. He took on this "non-trivial" task and it has been a long journey from an idea to implementation. While the work sometimes linger, @frostzt lost no hope and we are finally ready to merge it. Thank you @frostzt !

@ranshid

ranshid commented Sep 10, 2026

Copy link
Copy Markdown
Member

the test-ubuntu-jemalloc probably stuck due to this missing fix: #4643

IMO all is green to merge

@ranshid
ranshid merged commit c80da01 into valkey-io:unstable Sep 10, 2026
71 of 74 checks passed
@github-project-automation github-project-automation Bot moved this from Needs Review to Merged in Valkey 9.2 Sep 10, 2026
@madolson madolson added breaking-change Indicates a possible backwards incompatible change and removed breaking-change Indicates a possible backwards incompatible change labels Sep 11, 2026
ranshid pushed a commit that referenced this pull request Sep 14, 2026
…listpack (#4669)

Loading a `HASH_2` payload can build a hash whose field-expiry metadata
is inconsistent with its volatile set. The loader appends each field's
expiry to the listpack as it goes but installs the aggregate
volatile-count header only after the loop, and
`hashTypeConvertListpack()` decides whether to register the converted
entries in the volatile set by peeking at exactly that header. A payload
whose later field crosses `hash-max-listpack-value` therefore converts a
header-less listpack, and the resulting hashtable entries carry an
expiry while belonging to no volatile set: the fields never expire,
their TTLs are dropped by the next RDB save, and `HDEL` of one of them
dereferences a NULL set. This derives the decision from the expiries of
the entries the conversion actually carried over, so it no longer
depends on the header being installed.

<details>
<summary>Details</summary>

## Problem

`rdbLoadObject()` builds a `RDB_TYPE_HASH_2` hash incrementally. Each
field's expiry goes in as a trailing tagged metadata entry inside the
loop (`src/rdb.c:2428-2436`), but the leading aggregate volatile-count
header is installed once, after the loop (`src/rdb.c:2449-2454`), to
avoid rewriting it per field.

Inside the loop, a field or value over `hash-max-listpack-value`
converts the partially built object (`src/rdb.c:2404-2409`). That call
site assumes conversion carries the already-loaded TTLs across on its
own:

```c
/* hashTypeConvert carries the TTLs of the pairs already in the
 * listpack into the volatile set; no header is needed for that. */
hashTypeConvert(o, OBJ_ENCODING_HASHTABLE);
```

It did not. `hashTypeConvertListpack()` gated its volatile-set
registration loop on `hashTypeHasVolatileFields(o)`
(`src/t_hash.c:998`), which for a listpack is an O(1) peek at the
leading header (`src/t_hash.c:128`). Mid-load, the header does not exist
yet, so the gate was false. The conversion loop still copied each
field's expiry into the new entry (`src/t_hash.c:1010-1011`), so the
hashtable ended up holding entries with an expiry that no `vset` knew
about.

Three consequences, all confirmed on a build of unstable at `da91ccd12`:

`keys_with_volatile_items` stays 0 and the fields are never reaped or
lazily hidden. `dbTrackKeyWithVolatileItems()` asks the same
`hashTypeHasVolatileFields()` (`src/db.c:549`), which for a hashtable is
`set && !vsetIsEmpty(set)`, so the key is not tracked; and because
`hashTypeTrackEntry()` never ran, `hashTypeIgnoreTTL(o, false)` never
swapped in `hashWithVolatileItemsHashtableType`, so reads have no
validate callback either.

The field TTLs are silently dropped by the next RDB save.
`rdbObjectType()` selects `RDB_TYPE_HASH_2` only when
`hashTypeHasVolatileFields(o)` (`src/rdb.c:775-783`), so a desynced hash
is written as plain `RDB_TYPE_HASH`:

```
httl before reload: 1000
OK
httl after reload:  -1
```

`HDEL` of a volatile field crashes. `hashTypeUntrackEntry()` takes
`hashTypeGetVolatileSet()`, which returns NULL for uninitialized
metadata (`src/t_hash.c:67-71`); the `debugServerAssert(set)` at
`src/t_hash.c:201` is compiled out of a release build and
`vsetRemoveEntryWithExpiry()` dereferences it one line before its own
`assert(bucket)` (`src/vset.c:1839`):

```
EIP:
0   valkey-server                       0x0000000104c7f5f4 vsetRemoveEntryWithExpiry + 40

Backtrace:
0   libsystem_platform.dylib            0x0000000181c81744 _sigtramp + 56
1   valkey-server                       0x0000000104c29a1c hashTypeUntrackEntry + 188
2   valkey-server                       0x0000000104c296d8 hashTypeDelete + 168
3   valkey-server                       0x0000000104c2bf28 hdelCommand + 376
4   valkey-server                       0x0000000104c10230 call + 1048
```

Reproduction, with no crafted payload; the source hash is one the server
itself produced, and the threshold change stands in for any load whose
`hash-max-listpack-value` is smaller than the one in effect when the
payload was written:

```
config set hash-max-listpack-value 64
hset myhash a b cc dd
hexpire myhash 1000 FIELDS 1 a          # listpack, 'a' first and volatile
dump myhash
config set hash-max-listpack-value 1    # 'a'/'b' still fit, field 'cc' does not
restore myhash 0 <payload>              # converts after 'a' landed in the listpack
hdel myhash a                           # SIGSEGV
```

Both halves of the mismatch, the deferred header install and the
header-based gate, came from #3212. No tag contains that commit.

## Fix

Set `has_volatile` from the expiry of each entry the conversion carries
over, in the same statement that hands that expiry to `entryCreate()`.
The flag can no longer disagree with what landed in the hashtable, and
it no longer reads the header at all, so the loader's deferred install
stops being load-bearing. `hashTypeCurrentExpiry()` was already called
on that line, so this adds no pass over the entries, and the
registration pass is still skipped entirely when nothing carries an
expiry.

`hashTypeHasVolatileFields()` documented its listpack fast path as "the
aggregate header exists iff at least one field carries an expiry", which
is false for the duration of the loader's loop. That comment now names
the exception, so the next header consumer added on a path reachable
from the loop does not reintroduce this.

Behavior of `RDB_TYPE_HASH_2` loads, per input:

| payload | before | after |
| --- | --- | --- |
| `len > hash-max-listpack-entries` | correct: converts an empty
listpack, every field tracked by the hashtable loop at `rdb.c:2526-2528`
| unchanged |
| fits the listpack, no field over `hash-max-listpack-value` | correct:
header installed after the loop | unchanged |
| first field over the value threshold | correct: converts before any
expiry is appended, nothing to register | unchanged |
| a volatile field appended, then a field over the value threshold |
entry keeps its expiry, `vset` empty | entry registered in the `vset` |

## Alternative considered

Install the aggregate header in `rdb.c` before the mid-load
`hashTypeConvert()` call. It is the same line count and it fixes this
caller, but it keeps `hashTypeConvertListpack()` depending on a header
that every caller has to remember to maintain, which is the thing that
broke. It also spends a `lpInsertMetadata()` realloc writing a header
into a listpack the very next call frees. The conversion is already
reading every field's expiry; asking it to consult a summary of data it
holds in its hand buys nothing.

Making `hashTypeUntrackEntry()` return an error on a NULL set is the
other tempting fix. Not doing that here: with the invariant repaired, a
NULL set at that point means a fresh desync, and converting it into a
returned error would hide it. Promoting `debugServerAssert(set)` to
`serverAssert(set)` so release builds fail the same way the test suite
does is defensible, but it is a separate change and it belongs at
`vset.c:1839`, where the dereference precedes the existing assert and
defeats it.

## Testing

Two tests, because the missing registration has two independent
observable consequences and the second one is not a crash. Both fail
with the `t_hash.c` hunk reverted:

```
[err]: RESTORE tracks field TTLs when the load converts mid-listpack
Expected '1' to be equal to '0' (context: type eval line 6 cmd {assert_equal 1 [get_keys_with_volatile_items r]} proc ::test)
[err]: Field TTLs survive a save after a mid-listpack conversion
Expected '-1' to be between to '1' and '1000' (context: type eval line 7 cmd {assert_range [lindex [r httl myhash FIELDS 1 a] 0] 1 1000} proc ::test)
Test Summary: 449 passed, 2 failed
```

The discriminating assertion in the first test is
`keys_with_volatile_items`, not the `HTTL` line above it: `HTTL` reads
the entry's own expiry and is independent of the vset, so it reports
1000 either way. The second test is tagged `needs:debug` for `debug
reload` and is separate rather than folded into the first so the crash
and vset assertions still run when that tag is denied.

`hashexpire.tcl` already covered DUMP/RESTORE of a listpack hash with
field TTLs (`tests/unit/hashexpire.tcl:5044`) and loads that go straight
to a hashtable on the entry count. Neither reaches a mid-load
conversion: the first keeps the listpack encoding end to end, and the
second converts an empty listpack before reading a single field. Nothing
exercised a load that converts after a volatile field had already landed
in the listpack.

</details>

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

Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
Co-authored-by: Binbin <binloveplay1314@qq.com>
zuiderkwast pushed a commit to Tarte12/valkey that referenced this pull request Sep 15, 2026
Use listpacks for small hashes with field expiration.

The idea is that we'll introduce a new tagged metadata `0xF5` to encode
expiry within the listpack encoded value itself.

No RDB changes are introduced in this PR. When storing a hash with
volatile fields, it is stored as if it were a hashtable in the RDB
format.

So far, field expiration couldn't be encoded in listpacks, so any hash
with field expiration could only be stored as a hashtable, which
consumes more memory.

Added vsetSize(): returns the number of entries in a vset by summing
per-bucket counts (single/vector/hashtable/rax), so callers can get the
element count without iterating the entries.

Closes valkey-io#2618.

---------

Signed-off-by: frostzt <aidenfrostbite@gmail.com>
Signed-off-by: Sourav Singh Rawat <aidenfrostbite@gmail.com>
Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
Co-authored-by: Ran Shidlansik <ranshid@amazon.com>
sarthakaggarwal97 added a commit that referenced this pull request Sep 16, 2026
Remove three entries whose changes shipped before 9.2.0, so 9.1 users
don't see them advertised as new:
- #3533 (dismissHashtable madvise size) was backported to 9.1 and is in
  the 9.1.0, 9.1.1 and 9.1.2 tags. It was added here by mistake.
- #3551 (hashtable iterator invalidate on exhaustion) is likewise in
  9.1.0; it just never made the 9.1 notes.
- #3586 (valkey-cli --cluster fix slot spreading) is already documented
  in both the 9.0 and 9.1 release notes and shipped in 9.0.6 and 9.1.0.

Fix the #4076 config name: the registered config is
priority-preemptive-poll-interval-us, not preemptive-poll-interval-us,
so the name as written would be rejected by CONFIG SET.

Rewrite the #4005 entry, which described only the internal tagging. The
user-facing surface is two new configs, priority-subnets and
maxclients-reserved, which together cap normal clients at maxclients
minus the reserved count so administrative clients keep guaranteed
slots, plus a connected_priority_clients field in INFO clients.

Move the sorted set B+ tree note (#4359) back to Performance. Listing it
as a behavior change for its OBJECT ENCODING output while #3212 changes
hash encoding output from hashtable to listpack in the Performance
section was inconsistent; both now sit together.

Drop the Redis comparison from the #4629 entry, which no other entry in
the 9.0, 9.1 or 9.2 notes makes.

Signed-off-by: Sarthak Aggarwal <sarthagg@amazon.com>
ranshid added a commit that referenced this pull request Oct 15, 2026
Since #3212, a listpack hash can hold a field that has expired but has
not yet been reaped by active expire. `HGETALL`, `HSCAN`, and the
hashtable branch of `ValkeyModule_ScanKey` all hide that field, but the
listpack branches of `ValkeyModule_ScanKey` and
`ValkeyModule_ScanKeyRawBorrowed` pass it to the module callback. A
module that indexes hashes through ScanKey, such as valkey-search, sees
data the client can no longer read. The fix applies the visibility check
`HSCAN` already uses to both listpack loops.

<details>
<summary>AI generated details</summary>

## Problem

#3212 (c80da01) stores a field's TTL as a metadata entry after its
value in the listpack. Readers have to check it explicitly with
`hashTypeListpackFieldIsValid()`. `hashTypeNext()` (`t_hash.c:908`) and
`HSCAN` (`db.c:1393`) do. The hashtable branch of ScanKey gets the same
filtering from `hashtableScan`'s `validateEntry` callback.

The listpack loops in `VM_ScanKey` (`module.c:12331`) and
`VM_ScanKeyRawBorrowed` (`module.c:12480`, added in #4403) walk
field/value pairs with `lpNext()`. `lpNext()` skips over metadata
entries, so neither loop ever reads the expiry.

Both commits are in 9.2.0-rc1 only.

The existing `scan.tcl` tests never set a field TTL, so nothing
exercised this.

| Case | ScanKey | ScanKeyRawBorrowed | After fix |
|---|---|---|---|
| hashtable hash, one field expired | hidden | hidden | unchanged |
| listpack hash, one field expired | **returned** | **returned** |
hidden |
| listpack hash, every field expired (key still exists) | **returned** |
**returned** | empty |
| listpack hash, integer field expired | **returned** | **returned** |
hidden |
| listpack hash, TTL in the future | returned | returned | unchanged |
| listpack zset | no metadata | no metadata | unchanged |

## Fix

Once each loop reaches the value entry, it checks
`hashTypeListpackFieldIsValid(hashTypeListpackGetExpiry(lp, vptr))` and
skips the pair if the field is not visible. This is the same check
`HSCAN` uses, so expiration policy (`POLICY_IGNORE_EXPIRE` during
loading, on replicas, and so on) behaves the same way on both encodings.
Zset listpacks carry no metadata, so `hashTypeListpackGetExpiry()`
returns `EXPIRY_NONE` for them and they are never skipped.

## Alternative considered

Rewrite the hash case on top of
`hashTypeInitIterator()`/`hashTypeNext()`, which already filter. That
would split the loop, which is shared with listpack zsets, and the
RawBorrowed variant would still have to unpack integer-encoded entries
on its own. Two added checks are a smaller cherry-pick into 9.2.

## Testing

The new tests in `tests/unit/moduleapi/scan.tcl` turn off active expire,
let one field of a two-field listpack hash expire, and confirm it was
not reaped (`HLEN` is still 2, encoding is still listpack). With the
`module.c` hunks reverted:

```
*** [err]: Module scan hash listpack skips expired fields in tests/unit/moduleapi/scan.tcl
Expected '{f2 v2}' to equal or match '{f1 v1} {f2 v2}'
*** [err]: Module scan_key_raw hash listpack skips expired fields in tests/unit/moduleapi/scan.tcl
Expected '{f2 v2}' to equal or match '{f1 v1} {f2 v2}'
```

Removing either guard on its own fails only the test for that function.

</details>

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

---------

Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
Co-authored-by: Ran Shidlansik <ranshid@amazon.com>
valkeyrie-ops Bot pushed a commit that referenced this pull request Oct 15, 2026
Since #3212, a listpack hash can hold a field that has expired but has
not yet been reaped by active expire. `HGETALL`, `HSCAN`, and the
hashtable branch of `ValkeyModule_ScanKey` all hide that field, but the
listpack branches of `ValkeyModule_ScanKey` and
`ValkeyModule_ScanKeyRawBorrowed` pass it to the module callback. A
module that indexes hashes through ScanKey, such as valkey-search, sees
data the client can no longer read. The fix applies the visibility check
`HSCAN` already uses to both listpack loops.

<details>
<summary>AI generated details</summary>

## Problem

#3212 (c80da01) stores a field's TTL as a metadata entry after its
value in the listpack. Readers have to check it explicitly with
`hashTypeListpackFieldIsValid()`. `hashTypeNext()` (`t_hash.c:908`) and
`HSCAN` (`db.c:1393`) do. The hashtable branch of ScanKey gets the same
filtering from `hashtableScan`'s `validateEntry` callback.

The listpack loops in `VM_ScanKey` (`module.c:12331`) and
`VM_ScanKeyRawBorrowed` (`module.c:12480`, added in #4403) walk
field/value pairs with `lpNext()`. `lpNext()` skips over metadata
entries, so neither loop ever reads the expiry.

Both commits are in 9.2.0-rc1 only.

The existing `scan.tcl` tests never set a field TTL, so nothing
exercised this.

| Case | ScanKey | ScanKeyRawBorrowed | After fix |
|---|---|---|---|
| hashtable hash, one field expired | hidden | hidden | unchanged |
| listpack hash, one field expired | **returned** | **returned** |
hidden |
| listpack hash, every field expired (key still exists) | **returned** |
**returned** | empty |
| listpack hash, integer field expired | **returned** | **returned** |
hidden |
| listpack hash, TTL in the future | returned | returned | unchanged |
| listpack zset | no metadata | no metadata | unchanged |

## Fix

Once each loop reaches the value entry, it checks
`hashTypeListpackFieldIsValid(hashTypeListpackGetExpiry(lp, vptr))` and
skips the pair if the field is not visible. This is the same check
`HSCAN` uses, so expiration policy (`POLICY_IGNORE_EXPIRE` during
loading, on replicas, and so on) behaves the same way on both encodings.
Zset listpacks carry no metadata, so `hashTypeListpackGetExpiry()`
returns `EXPIRY_NONE` for them and they are never skipped.

## Alternative considered

Rewrite the hash case on top of
`hashTypeInitIterator()`/`hashTypeNext()`, which already filter. That
would split the loop, which is shared with listpack zsets, and the
RawBorrowed variant would still have to unpack integer-encoded entries
on its own. Two added checks are a smaller cherry-pick into 9.2.

## Testing

The new tests in `tests/unit/moduleapi/scan.tcl` turn off active expire,
let one field of a two-field listpack hash expire, and confirm it was
not reaped (`HLEN` is still 2, encoding is still listpack). With the
`module.c` hunks reverted:

```
*** [err]: Module scan hash listpack skips expired fields in tests/unit/moduleapi/scan.tcl
Expected '{f2 v2}' to equal or match '{f1 v1} {f2 v2}'
*** [err]: Module scan_key_raw hash listpack skips expired fields in tests/unit/moduleapi/scan.tcl
Expected '{f2 v2}' to equal or match '{f1 v1} {f2 v2}'
```

Removing either guard on its own fails only the test for that function.

</details>

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

---------

Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
Co-authored-by: Ran Shidlansik <ranshid@amazon.com>
Backport-Source-PR: 4885
JimB123 pushed a commit that referenced this pull request Oct 16, 2026
Since #3212, a listpack hash can hold a field that has expired but has
not yet been reaped by active expire. `HGETALL`, `HSCAN`, and the
hashtable branch of `ValkeyModule_ScanKey` all hide that field, but the
listpack branches of `ValkeyModule_ScanKey` and
`ValkeyModule_ScanKeyRawBorrowed` pass it to the module callback. A
module that indexes hashes through ScanKey, such as valkey-search, sees
data the client can no longer read. The fix applies the visibility check
`HSCAN` already uses to both listpack loops.

<details>
<summary>AI generated details</summary>

## Problem

#3212 (c80da01) stores a field's TTL as a metadata entry after its
value in the listpack. Readers have to check it explicitly with
`hashTypeListpackFieldIsValid()`. `hashTypeNext()` (`t_hash.c:908`) and
`HSCAN` (`db.c:1393`) do. The hashtable branch of ScanKey gets the same
filtering from `hashtableScan`'s `validateEntry` callback.

The listpack loops in `VM_ScanKey` (`module.c:12331`) and
`VM_ScanKeyRawBorrowed` (`module.c:12480`, added in #4403) walk
field/value pairs with `lpNext()`. `lpNext()` skips over metadata
entries, so neither loop ever reads the expiry.

Both commits are in 9.2.0-rc1 only.

The existing `scan.tcl` tests never set a field TTL, so nothing
exercised this.

| Case | ScanKey | ScanKeyRawBorrowed | After fix |
|---|---|---|---|
| hashtable hash, one field expired | hidden | hidden | unchanged |
| listpack hash, one field expired | **returned** | **returned** |
hidden |
| listpack hash, every field expired (key still exists) | **returned** |
**returned** | empty |
| listpack hash, integer field expired | **returned** | **returned** |
hidden |
| listpack hash, TTL in the future | returned | returned | unchanged |
| listpack zset | no metadata | no metadata | unchanged |

## Fix

Once each loop reaches the value entry, it checks
`hashTypeListpackFieldIsValid(hashTypeListpackGetExpiry(lp, vptr))` and
skips the pair if the field is not visible. This is the same check
`HSCAN` uses, so expiration policy (`POLICY_IGNORE_EXPIRE` during
loading, on replicas, and so on) behaves the same way on both encodings.
Zset listpacks carry no metadata, so `hashTypeListpackGetExpiry()`
returns `EXPIRY_NONE` for them and they are never skipped.

## Alternative considered

Rewrite the hash case on top of
`hashTypeInitIterator()`/`hashTypeNext()`, which already filter. That
would split the loop, which is shared with listpack zsets, and the
RawBorrowed variant would still have to unpack integer-encoded entries
on its own. Two added checks are a smaller cherry-pick into 9.2.

## Testing

The new tests in `tests/unit/moduleapi/scan.tcl` turn off active expire,
let one field of a two-field listpack hash expire, and confirm it was
not reaped (`HLEN` is still 2, encoding is still listpack). With the
`module.c` hunks reverted:

```
*** [err]: Module scan hash listpack skips expired fields in tests/unit/moduleapi/scan.tcl
Expected '{f2 v2}' to equal or match '{f1 v1} {f2 v2}'
*** [err]: Module scan_key_raw hash listpack skips expired fields in tests/unit/moduleapi/scan.tcl
Expected '{f2 v2}' to equal or match '{f1 v1} {f2 v2}'
```

Removing either guard on its own fails only the test for that function.

</details>

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

---------

Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
Signed-off-by: Ran Shidlansik <ranshid@amazon.com>
Co-authored-by: Ran Shidlansik <ranshid@amazon.com>
Backport-Source-PR: 4885
sarthakaggarwal97 added a commit that referenced this pull request Oct 21, 2026
… bulk replies (#4417)

# Speed up HGETALL, HKEYS and HVALS with exact-length headers and fused
bulk replies

Perf improvement for workloads where many clients fetch moderately-sized
hashes (#3451). This is a rework of #4390, addressing the review
findings raised there.


## Changes

### 1. Exact-length reply headers

RESP requires the element count *before* the elements (`%200\r\n` for a
200-pair map). Previously, `genericHgetallCommand()` did not know the
count up front, so it reserved a placeholder with
`addReplyDeferredLen()` and patched it at the end. Once that placeholder
is there, every subsequent reply byte bypasses the static output buffer
and goes through heap-allocated reply blocks.

#### before

```
  c->reply list:  [placeholder node] --> forces ALL reply bytes into
                        |                heap-allocated clientReplyBlocks
                        v
                  [16KB block] -> [16KB block] -> ...   (malloc'd + freed
                                                         per command)
  c->buf (static 16KB): unused
```

#### after
```
  c->buf (static 16KB): [hdr|f1|v1|f2|v2|...]  <- first ~16KB, zero allocations
  c->reply list:        [16KB block]           <- only the overflow, if any
```

We don't know the count only if fields can expire mid-iteration. When a
hash has no volatile (TTL-bearing) fields, `hashTypeLength()` is exact,
so the real header is emitted immediately. This applies to
listpack-encoded hashes (which cannot hold volatile fields at all) as
well as hashtable-encoded ones without volatile fields, so the default
hash encoding benefits too. A `serverAssert` verifies the emitted count
matches the header. Hashes with volatile fields keep old path.

For the hashtable encoding, entries are additionally iterated in batches
of 16 with prefetching.

#### before

```
BEFORE - serial pointer chase, one stall per entry:
  entry1 --(miss, ~100ns stall)--> emit --> entry2 --(miss)--> emit --> ...

```

#### after

``` 
  Phase 1: collect 16 entry ptrs, prefetch entry structs   -+ misses in
  Phase 2: prefetch their field/value sds buffers           -| flight
  Phase 3: emit all 16 against cache-warm data              -+ concurrently
```

Listpack skips the batching since its entries are in one contiguous
allocation.

### 2. merged bulk string writes (`networking.c`)

`addWritePreparedReplyBulkCBuffer()` now attempts to emit the complete
bulk string (`$<len>\r\n<data>\r\n`) into the client's static reply
buffer with a single bounds check and two memcpy calls, instead of three
separate appends that each re-walk the buffer-or-list routing logic:

#### before

```
  addWritePreparedReplyBulkCBuffer("valuedata", 9)
    +- append "$9\r\n"      --> [checks: replica? deferred? push? list? fits?]
    +- append "valuedata"   --> [same checks again]
    +- append "\r\n"        --> [same checks again]
                                 x 400 bulk strings per HGETALL = 1200 walks

```

#### after

```
  tryAddBulkCBufferFast("valuedata", 9)
    +- bail-outs (replica/deferred/push/encoded/full) -> generic path
    +- reqresSaveClientReplyOffset()        <- before bufpos moves
    +- c->buf: ...[$9\r\nvaluedata\r\n]     <- one fused write
```

It falls back to the generic path for encoded (copy-avoidance) buffers,
deferred replies, push messages, replica clients and when the reply does
not fit in the static buffer.


## Benchmarks

Environment: aarch64/64 cores. I pinned valkey to one core (`taskset -c
2`), `valkey-benchmark` to others to cores 16-47 (`-c 50 --threads 8`).

Baseline is unstable at `917de6ac1`.

### synthetic hashes, TCP

`hth` = hashtable-encoded, 200 fields x 128B values (~27KB reply).
`hlp` = listpack-encoded, 100 fields, small values.

```
=== RUN 1: hgetall hth (hashtable 200fx128B), TCP, -n 200000 ===
unstable:
  throughput summary: 21006.20 requests per second
          avg       min       p50       p95       p99       max
        2.287     0.456     2.095     3.455     3.807     7.247
clean:
  throughput summary: 28502.21 requests per second
          avg       min       p50       p95       p99       max
        1.652     0.304     1.543     2.383     2.551     4.167

=== RUN 2: hgetall hlp (listpack 100f), TCP, -n 200000 ===
unstable:
  throughput summary: 31928.48 requests per second
          avg       min       p50       p95       p99       max
        1.497     0.288     1.455     2.103     2.239     3.223
clean:
  throughput summary: 42069.84 requests per second
          avg       min       p50       p95       p99       max
        1.100     0.112     1.087     1.375     1.551     5.351

=== RUN 3: hgetall hth pipelined, TCP, -P 10 -n 400000 ===
unstable:
  throughput summary: 27517.89 requests per second
          avg       min       p50       p95       p99       max
        6.470     0.504     6.575     8.311    11.743    19.807
clean:
  throughput summary: 33252.97 requests per second
          avg       min       p50       p95       p99       max
        6.481     0.512     6.495     7.359     7.975    16.591
```

`perf stat` on the server process (8s window during RUN 1):

```
unstable:
    12,181,864,694      cycles:u
    56,922,768,381      instructions:u            #    4.67  insn per cycle
       205,615,849      cache-misses:u
        32,479,163      branch-misses:u

clean:
     8,039,421,649      cycles:u
    36,808,357,246      instructions:u            #    4.58  insn per cycle
       224,631,859      cache-misses:u
        11,828,499      branch-misses:u
```

Per request that is roughly 344k -> 163k user instructions (-53%) and
74.5k -> 35.6k user cycles (-52%).

### op's workload

A single client serially HGETALLing every key (a SCAN loop). Hashes with
one `chunk` blob field (10-150KB) plus 99 `metricN` fields (~100B)

```
=== serial scan loop: -c 1 --threads 1 --sequential -r 1000 -n 20000 ===
unstable (run A):
  throughput summary: 13651.88 requests per second
          avg       min       p50       p95       p99       max
        0.038     0.024     0.039     0.047     0.047     0.287
  wall time: 1.47 s; server CPU time consumed: 0.84 s
unstable (run B):
  throughput summary: 13633.26 requests per second
          avg       min       p50       p95       p99       max
        0.037     0.024     0.039     0.047     0.047     0.831
  wall time: 1.47 s; server CPU time consumed: 0.86 s
clean (run A):
  throughput summary: 14947.68 requests per second
          avg       min       p50       p95       p99       max
        0.032     0.024     0.031     0.039     0.039     0.359
  wall time: 1.35 s; server CPU time consumed: 0.73 s
clean (run B):
  throughput summary: 15003.75 requests per second
          avg       min       p50       p95       p99       max
        0.031     0.024     0.031     0.039     0.039     0.311
  wall time: 1.34 s; server CPU time consumed: 0.71 s
```


### Reading the results

| Workload | unstable | this pr | Delta |
|---|---|---|---|
| hashtable hash, 200f x 128B, TCP | 21.0k rps | 28.5k rps | +36% |
| listpack hash, 100f, TCP | 31.9k rps | 42.1k rps | +32% |
| hashtable hash, TCP, -P 10 | 27.5k rps | 33.3k rps | +21% |
| issue shape, serial scan loop (1 client) | 13.6k rps | 15.0k rps |
+10% wall, -15% CPU |


## Interaction with #3212 (listpack TTL support)

#3212 lets listpack-encoded hashes carry TTL-bearing fields, which
touches the assumption behind the listpack inclusion here. The two
changes should work fine together:

- This PR's fast path does not gate on encoding; it gates on
`hashTypeHasVolatileFields(o)`. #3212 updates that same function to
detect
listpack TTLs with an O(1) header peek (`lpIsMetadata(lpStart(...))`),
so after both merge, a listpack hash without TTLs still takes the
exact-header fast path, and one with TTLs automatically falls back to
the deferred-header path (correct, since its fields can expire
mid-iteration).
- If the gate were ever bypassed incorrectly, the `serverAssert` on the
emitted count would fire rather than emitting a malformed reply.
- (AI tells me) the test merge of the two branches auto-merges cleanly
in `t_hash.c` and`networking.c`


## Testing

- All existing hash tests pass (90/90 including the new ones).
- New Tcl tests in `tests/unit/type/hash.tcl` cover:
  - the exact-header path for both encodings in RESP2 and RESP3,
- a reply large enough to overflow the static buffer and spill into the
reply list (exercising the fast-path bail-out mid-command),
  - a hash with volatile fields taking the deferred-header path.
- `clang-format-18` clean.

Fixes #3451

---------

Signed-off-by: jjuleslasarte <jules.lasarte@gmail.com>
Signed-off-by: Sarthak Aggarwal <sarthagg@amazon.com>
Co-authored-by: Sarthak Aggarwal <sarthagg@amazon.com>
valkeyrie-ops Bot pushed a commit that referenced this pull request Oct 22, 2026
… bulk replies (#4417)

# Speed up HGETALL, HKEYS and HVALS with exact-length headers and fused
bulk replies

Perf improvement for workloads where many clients fetch moderately-sized
hashes (#3451). This is a rework of #4390, addressing the review
findings raised there.

## Changes

### 1. Exact-length reply headers

RESP requires the element count *before* the elements (`%200\r\n` for a
200-pair map). Previously, `genericHgetallCommand()` did not know the
count up front, so it reserved a placeholder with
`addReplyDeferredLen()` and patched it at the end. Once that placeholder
is there, every subsequent reply byte bypasses the static output buffer
and goes through heap-allocated reply blocks.

#### before

```
  c->reply list:  [placeholder node] --> forces ALL reply bytes into
                        |                heap-allocated clientReplyBlocks
                        v
                  [16KB block] -> [16KB block] -> ...   (malloc'd + freed
                                                         per command)
  c->buf (static 16KB): unused
```

#### after
```
  c->buf (static 16KB): [hdr|f1|v1|f2|v2|...]  <- first ~16KB, zero allocations
  c->reply list:        [16KB block]           <- only the overflow, if any
```

We don't know the count only if fields can expire mid-iteration. When a
hash has no volatile (TTL-bearing) fields, `hashTypeLength()` is exact,
so the real header is emitted immediately. This applies to
listpack-encoded hashes (which cannot hold volatile fields at all) as
well as hashtable-encoded ones without volatile fields, so the default
hash encoding benefits too. A `serverAssert` verifies the emitted count
matches the header. Hashes with volatile fields keep old path.

For the hashtable encoding, entries are additionally iterated in batches
of 16 with prefetching.

#### before

```
BEFORE - serial pointer chase, one stall per entry:
  entry1 --(miss, ~100ns stall)--> emit --> entry2 --(miss)--> emit --> ...

```

#### after

```
  Phase 1: collect 16 entry ptrs, prefetch entry structs   -+ misses in
  Phase 2: prefetch their field/value sds buffers           -| flight
  Phase 3: emit all 16 against cache-warm data              -+ concurrently
```

Listpack skips the batching since its entries are in one contiguous
allocation.

### 2. merged bulk string writes (`networking.c`)

`addWritePreparedReplyBulkCBuffer()` now attempts to emit the complete
bulk string (`$<len>\r\n<data>\r\n`) into the client's static reply
buffer with a single bounds check and two memcpy calls, instead of three
separate appends that each re-walk the buffer-or-list routing logic:

#### before

```
  addWritePreparedReplyBulkCBuffer("valuedata", 9)
    +- append "$9\r\n"      --> [checks: replica? deferred? push? list? fits?]
    +- append "valuedata"   --> [same checks again]
    +- append "\r\n"        --> [same checks again]
                                 x 400 bulk strings per HGETALL = 1200 walks

```

#### after

```
  tryAddBulkCBufferFast("valuedata", 9)
    +- bail-outs (replica/deferred/push/encoded/full) -> generic path
    +- reqresSaveClientReplyOffset()        <- before bufpos moves
    +- c->buf: ...[$9\r\nvaluedata\r\n]     <- one fused write
```

It falls back to the generic path for encoded (copy-avoidance) buffers,
deferred replies, push messages, replica clients and when the reply does
not fit in the static buffer.

## Benchmarks

Environment: aarch64/64 cores. I pinned valkey to one core (`taskset -c
2`), `valkey-benchmark` to others to cores 16-47 (`-c 50 --threads 8`).

Baseline is unstable at `917de6ac1`.

### synthetic hashes, TCP

`hth` = hashtable-encoded, 200 fields x 128B values (~27KB reply).
`hlp` = listpack-encoded, 100 fields, small values.

```
=== RUN 1: hgetall hth (hashtable 200fx128B), TCP, -n 200000 ===
unstable:
  throughput summary: 21006.20 requests per second
          avg       min       p50       p95       p99       max
        2.287     0.456     2.095     3.455     3.807     7.247
clean:
  throughput summary: 28502.21 requests per second
          avg       min       p50       p95       p99       max
        1.652     0.304     1.543     2.383     2.551     4.167

=== RUN 2: hgetall hlp (listpack 100f), TCP, -n 200000 ===
unstable:
  throughput summary: 31928.48 requests per second
          avg       min       p50       p95       p99       max
        1.497     0.288     1.455     2.103     2.239     3.223
clean:
  throughput summary: 42069.84 requests per second
          avg       min       p50       p95       p99       max
        1.100     0.112     1.087     1.375     1.551     5.351

=== RUN 3: hgetall hth pipelined, TCP, -P 10 -n 400000 ===
unstable:
  throughput summary: 27517.89 requests per second
          avg       min       p50       p95       p99       max
        6.470     0.504     6.575     8.311    11.743    19.807
clean:
  throughput summary: 33252.97 requests per second
          avg       min       p50       p95       p99       max
        6.481     0.512     6.495     7.359     7.975    16.591
```

`perf stat` on the server process (8s window during RUN 1):

```
unstable:
    12,181,864,694      cycles:u
    56,922,768,381      instructions:u            #    4.67  insn per cycle
       205,615,849      cache-misses:u
        32,479,163      branch-misses:u

clean:
     8,039,421,649      cycles:u
    36,808,357,246      instructions:u            #    4.58  insn per cycle
       224,631,859      cache-misses:u
        11,828,499      branch-misses:u
```

Per request that is roughly 344k -> 163k user instructions (-53%) and
74.5k -> 35.6k user cycles (-52%).

### op's workload

A single client serially HGETALLing every key (a SCAN loop). Hashes with
one `chunk` blob field (10-150KB) plus 99 `metricN` fields (~100B)

```
=== serial scan loop: -c 1 --threads 1 --sequential -r 1000 -n 20000 ===
unstable (run A):
  throughput summary: 13651.88 requests per second
          avg       min       p50       p95       p99       max
        0.038     0.024     0.039     0.047     0.047     0.287
  wall time: 1.47 s; server CPU time consumed: 0.84 s
unstable (run B):
  throughput summary: 13633.26 requests per second
          avg       min       p50       p95       p99       max
        0.037     0.024     0.039     0.047     0.047     0.831
  wall time: 1.47 s; server CPU time consumed: 0.86 s
clean (run A):
  throughput summary: 14947.68 requests per second
          avg       min       p50       p95       p99       max
        0.032     0.024     0.031     0.039     0.039     0.359
  wall time: 1.35 s; server CPU time consumed: 0.73 s
clean (run B):
  throughput summary: 15003.75 requests per second
          avg       min       p50       p95       p99       max
        0.031     0.024     0.031     0.039     0.039     0.311
  wall time: 1.34 s; server CPU time consumed: 0.71 s
```

### Reading the results

| Workload | unstable | this pr | Delta |
|---|---|---|---|
| hashtable hash, 200f x 128B, TCP | 21.0k rps | 28.5k rps | +36% |
| listpack hash, 100f, TCP | 31.9k rps | 42.1k rps | +32% |
| hashtable hash, TCP, -P 10 | 27.5k rps | 33.3k rps | +21% |
| issue shape, serial scan loop (1 client) | 13.6k rps | 15.0k rps |
+10% wall, -15% CPU |

## Interaction with #3212 (listpack TTL support)

#3212 lets listpack-encoded hashes carry TTL-bearing fields, which
touches the assumption behind the listpack inclusion here. The two
changes should work fine together:

- This PR's fast path does not gate on encoding; it gates on
`hashTypeHasVolatileFields(o)`. #3212 updates that same function to
detect
listpack TTLs with an O(1) header peek (`lpIsMetadata(lpStart(...))`),
so after both merge, a listpack hash without TTLs still takes the
exact-header fast path, and one with TTLs automatically falls back to
the deferred-header path (correct, since its fields can expire
mid-iteration).
- If the gate were ever bypassed incorrectly, the `serverAssert` on the
emitted count would fire rather than emitting a malformed reply.
- (AI tells me) the test merge of the two branches auto-merges cleanly
in `t_hash.c` and`networking.c`

## Testing

- All existing hash tests pass (90/90 including the new ones).
- New Tcl tests in `tests/unit/type/hash.tcl` cover:
  - the exact-header path for both encodings in RESP2 and RESP3,
- a reply large enough to overflow the static buffer and spill into the
reply list (exercising the fast-path bail-out mid-command),
  - a hash with volatile fields taking the deferred-header path.
- `clang-format-18` clean.

Fixes #3451

---------

Signed-off-by: jjuleslasarte <jules.lasarte@gmail.com>
Signed-off-by: Sarthak Aggarwal <sarthagg@amazon.com>
Co-authored-by: Sarthak Aggarwal <sarthagg@amazon.com>
Backport-Source-PR: 4417
JimB123 pushed a commit that referenced this pull request Oct 23, 2026
… bulk replies (#4417)

# Speed up HGETALL, HKEYS and HVALS with exact-length headers and fused
bulk replies

Perf improvement for workloads where many clients fetch moderately-sized
hashes (#3451). This is a rework of #4390, addressing the review
findings raised there.

## Changes

### 1. Exact-length reply headers

RESP requires the element count *before* the elements (`%200\r\n` for a
200-pair map). Previously, `genericHgetallCommand()` did not know the
count up front, so it reserved a placeholder with
`addReplyDeferredLen()` and patched it at the end. Once that placeholder
is there, every subsequent reply byte bypasses the static output buffer
and goes through heap-allocated reply blocks.

#### before

```
  c->reply list:  [placeholder node] --> forces ALL reply bytes into
                        |                heap-allocated clientReplyBlocks
                        v
                  [16KB block] -> [16KB block] -> ...   (malloc'd + freed
                                                         per command)
  c->buf (static 16KB): unused
```

#### after
```
  c->buf (static 16KB): [hdr|f1|v1|f2|v2|...]  <- first ~16KB, zero allocations
  c->reply list:        [16KB block]           <- only the overflow, if any
```

We don't know the count only if fields can expire mid-iteration. When a
hash has no volatile (TTL-bearing) fields, `hashTypeLength()` is exact,
so the real header is emitted immediately. This applies to
listpack-encoded hashes (which cannot hold volatile fields at all) as
well as hashtable-encoded ones without volatile fields, so the default
hash encoding benefits too. A `serverAssert` verifies the emitted count
matches the header. Hashes with volatile fields keep old path.

For the hashtable encoding, entries are additionally iterated in batches
of 16 with prefetching.

#### before

```
BEFORE - serial pointer chase, one stall per entry:
  entry1 --(miss, ~100ns stall)--> emit --> entry2 --(miss)--> emit --> ...

```

#### after

```
  Phase 1: collect 16 entry ptrs, prefetch entry structs   -+ misses in
  Phase 2: prefetch their field/value sds buffers           -| flight
  Phase 3: emit all 16 against cache-warm data              -+ concurrently
```

Listpack skips the batching since its entries are in one contiguous
allocation.

### 2. merged bulk string writes (`networking.c`)

`addWritePreparedReplyBulkCBuffer()` now attempts to emit the complete
bulk string (`$<len>\r\n<data>\r\n`) into the client's static reply
buffer with a single bounds check and two memcpy calls, instead of three
separate appends that each re-walk the buffer-or-list routing logic:

#### before

```
  addWritePreparedReplyBulkCBuffer("valuedata", 9)
    +- append "$9\r\n"      --> [checks: replica? deferred? push? list? fits?]
    +- append "valuedata"   --> [same checks again]
    +- append "\r\n"        --> [same checks again]
                                 x 400 bulk strings per HGETALL = 1200 walks

```

#### after

```
  tryAddBulkCBufferFast("valuedata", 9)
    +- bail-outs (replica/deferred/push/encoded/full) -> generic path
    +- reqresSaveClientReplyOffset()        <- before bufpos moves
    +- c->buf: ...[$9\r\nvaluedata\r\n]     <- one fused write
```

It falls back to the generic path for encoded (copy-avoidance) buffers,
deferred replies, push messages, replica clients and when the reply does
not fit in the static buffer.

## Benchmarks

Environment: aarch64/64 cores. I pinned valkey to one core (`taskset -c
2`), `valkey-benchmark` to others to cores 16-47 (`-c 50 --threads 8`).

Baseline is unstable at `917de6ac1`.

### synthetic hashes, TCP

`hth` = hashtable-encoded, 200 fields x 128B values (~27KB reply).
`hlp` = listpack-encoded, 100 fields, small values.

```
=== RUN 1: hgetall hth (hashtable 200fx128B), TCP, -n 200000 ===
unstable:
  throughput summary: 21006.20 requests per second
          avg       min       p50       p95       p99       max
        2.287     0.456     2.095     3.455     3.807     7.247
clean:
  throughput summary: 28502.21 requests per second
          avg       min       p50       p95       p99       max
        1.652     0.304     1.543     2.383     2.551     4.167

=== RUN 2: hgetall hlp (listpack 100f), TCP, -n 200000 ===
unstable:
  throughput summary: 31928.48 requests per second
          avg       min       p50       p95       p99       max
        1.497     0.288     1.455     2.103     2.239     3.223
clean:
  throughput summary: 42069.84 requests per second
          avg       min       p50       p95       p99       max
        1.100     0.112     1.087     1.375     1.551     5.351

=== RUN 3: hgetall hth pipelined, TCP, -P 10 -n 400000 ===
unstable:
  throughput summary: 27517.89 requests per second
          avg       min       p50       p95       p99       max
        6.470     0.504     6.575     8.311    11.743    19.807
clean:
  throughput summary: 33252.97 requests per second
          avg       min       p50       p95       p99       max
        6.481     0.512     6.495     7.359     7.975    16.591
```

`perf stat` on the server process (8s window during RUN 1):

```
unstable:
    12,181,864,694      cycles:u
    56,922,768,381      instructions:u            #    4.67  insn per cycle
       205,615,849      cache-misses:u
        32,479,163      branch-misses:u

clean:
     8,039,421,649      cycles:u
    36,808,357,246      instructions:u            #    4.58  insn per cycle
       224,631,859      cache-misses:u
        11,828,499      branch-misses:u
```

Per request that is roughly 344k -> 163k user instructions (-53%) and
74.5k -> 35.6k user cycles (-52%).

### op's workload

A single client serially HGETALLing every key (a SCAN loop). Hashes with
one `chunk` blob field (10-150KB) plus 99 `metricN` fields (~100B)

```
=== serial scan loop: -c 1 --threads 1 --sequential -r 1000 -n 20000 ===
unstable (run A):
  throughput summary: 13651.88 requests per second
          avg       min       p50       p95       p99       max
        0.038     0.024     0.039     0.047     0.047     0.287
  wall time: 1.47 s; server CPU time consumed: 0.84 s
unstable (run B):
  throughput summary: 13633.26 requests per second
          avg       min       p50       p95       p99       max
        0.037     0.024     0.039     0.047     0.047     0.831
  wall time: 1.47 s; server CPU time consumed: 0.86 s
clean (run A):
  throughput summary: 14947.68 requests per second
          avg       min       p50       p95       p99       max
        0.032     0.024     0.031     0.039     0.039     0.359
  wall time: 1.35 s; server CPU time consumed: 0.73 s
clean (run B):
  throughput summary: 15003.75 requests per second
          avg       min       p50       p95       p99       max
        0.031     0.024     0.031     0.039     0.039     0.311
  wall time: 1.34 s; server CPU time consumed: 0.71 s
```

### Reading the results

| Workload | unstable | this pr | Delta |
|---|---|---|---|
| hashtable hash, 200f x 128B, TCP | 21.0k rps | 28.5k rps | +36% |
| listpack hash, 100f, TCP | 31.9k rps | 42.1k rps | +32% |
| hashtable hash, TCP, -P 10 | 27.5k rps | 33.3k rps | +21% |
| issue shape, serial scan loop (1 client) | 13.6k rps | 15.0k rps |
+10% wall, -15% CPU |

## Interaction with #3212 (listpack TTL support)

#3212 lets listpack-encoded hashes carry TTL-bearing fields, which
touches the assumption behind the listpack inclusion here. The two
changes should work fine together:

- This PR's fast path does not gate on encoding; it gates on
`hashTypeHasVolatileFields(o)`. #3212 updates that same function to
detect
listpack TTLs with an O(1) header peek (`lpIsMetadata(lpStart(...))`),
so after both merge, a listpack hash without TTLs still takes the
exact-header fast path, and one with TTLs automatically falls back to
the deferred-header path (correct, since its fields can expire
mid-iteration).
- If the gate were ever bypassed incorrectly, the `serverAssert` on the
emitted count would fire rather than emitting a malformed reply.
- (AI tells me) the test merge of the two branches auto-merges cleanly
in `t_hash.c` and`networking.c`

## Testing

- All existing hash tests pass (90/90 including the new ones).
- New Tcl tests in `tests/unit/type/hash.tcl` cover:
  - the exact-header path for both encodings in RESP2 and RESP3,
- a reply large enough to overflow the static buffer and spill into the
reply list (exercising the fast-path bail-out mid-command),
  - a hash with volatile fields taking the deferred-header path.
- `clang-format-18` clean.

Fixes #3451

---------

Signed-off-by: jjuleslasarte <jules.lasarte@gmail.com>
Signed-off-by: Sarthak Aggarwal <sarthagg@amazon.com>
Co-authored-by: Sarthak Aggarwal <sarthagg@amazon.com>
Backport-Source-PR: 4417
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

major-decision-approved Major decision approved by TSC team run-extra-tests Run extra tests on this PR (Runs all tests from daily except valgrind and RESP)

Projects

Status: Merged

Development

Successfully merging this pull request may close these issues.

HFE backlog - reduce memory consumption for small hash objects

9 participants