Repository navigation
Listpack encoding for Hash field expiration - #3212
Conversation
fde7c7b to
d582d18
Compare
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 😃 |
53fac8f to
db4a6d5
Compare
7b4ae2b to
6e20c45
Compare
|
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? |
|
@frostzt the project recently introduced GTEST, the CI failures look related to those. |
07631d6 to
5bf0aa8
Compare
|
Ok getting somewhere now this particular issue is in Same as @sarthakaggarwal97 mentioned I think |
98bc6a4 to
fe85ea6
Compare
|
I have two suggestions based on my review:
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.
A simple wrapper struct with a counter would make this O(1): Every metadata add/remove operation updates The already-defined but currently unused OBJ_ENCODING_LISTPACK2 could serve as the in-memory encoding type to distinguish listpacks wrapped in this struct. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughImplements 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. ChangesHash Field Expiration with Listpack Metadata
Estimated code review effort: 5 (Critical) | ~120 minutes Suggested reviewers: 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
Merge Risk: 🟠 High · up to 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)
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. Comment |
There was a problem hiding this comment.
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 winPreserve the
lpCurrentEncodedSizeBytes()→lpCurrentEncodedSizeUnsafe()safety contract.
lpValidateNext()only bounds-checksp + lenbytesbefore calling the unsafe decoder. For tagged entries,lenbytes == 1validates only the outer tag, butlpCurrentEncodedSizeUnsafe()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 winTagged insertion currently changes the meaning of
elestr.The normal
lpInsert()contract treatselestras raw input that still needs listpack encoding. The tagged branch copieselestrverbatim, solpInsertMetadata(..., elestr, ...)can serialize an invalid inner entry. That becomes especially dangerous becauselpGetMetadataValue()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 winMove
fmacros.hahead ofdict.h.The added comment says feature-test macros must be visible early, but the new include order does the opposite. Keeping
fmacros.hfirst avoids reintroducing the same include-order problem ifdict.hever 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 liftTrack 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
📒 Files selected for processing (10)
src/aof.csrc/db.csrc/expire.hsrc/listpack.csrc/listpack.hsrc/server.hsrc/t_hash.csrc/unit/test_listpack.cppsrc/valkey-unit-teststests/unit/hashexpire.tcl
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>
a28e277 to
f3ac82b
Compare
Signed-off-by: frostzt <aidenfrostbite@gmail.com>
Signed-off-by: frostzt <aidenfrostbite@gmail.com>
Signed-off-by: frostzt <aidenfrostbite@gmail.com>
|
Thank you @ranshid I'll go through - this would months of work finally 😭 |
|
@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? |
|
@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>
There was a problem hiding this comment.
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 !
|
the test-ubuntu-jemalloc probably stuck due to this missing fix: #4643 IMO all is green to merge |
…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>
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>
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>
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>
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
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
… 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>
… 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
… 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
Use listpacks for small hashes with field expiration.
The idea is that we'll introduce a new tagged metadata
0xF5to 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.