Conversation
…ader hashTypeConvertListpack() decided whether to register the new hashtable entries in the volatile set by asking hashTypeHasVolatileFields(), which for a listpack is an O(1) peek at the leading aggregate volatile-count header. The RDB_TYPE_HASH_2 loader appends that header only after its listpack loop finishes, so a payload whose later field crosses hash-max-listpack-value converts a half-built listpack that has no header yet. Conversion copied each field's expiry into the new entry but skipped the registration loop, leaving a hashtable entry with an expiry that no vset knows about. The fields then never expire, the key is missing from keys_with_volatile_items, and HDEL of such a field dereferences a NULL vset. Compute the flag from the expiry of the entries actually carried over instead. That makes the decision independent of the header, so the loader's deferred header install is no longer load-bearing, and it stays O(1) for the common conversion of a hash with no field TTLs. Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
HTTL reports whole seconds, so asserting exactly 1000 makes the test depend on the DUMP/CONFIG/RESTORE sequence finishing inside the same second as the HEXPIRE. Assert a range, like the neighbouring tests in the file do. Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
Take hash-max-listpack-value from the running config instead of hardcoding the default, matching the idiom the file already uses for hash-max-listpack-entries, so the test does not depend on the threshold being absent from default.conf. Also assert that the field TTL survives a save. An expiry that no volatile set knows about makes rdbObjectType() pick RDB_TYPE_HASH over RDB_TYPE_HASH_2, so the TTL is dropped on reload rather than crashing. That is the quiet half of the same desync and it deserves its own assertion. Signed-off-by: Madelyn Olson <madelyneolson@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Loading a
HASH_2payload 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, andhashTypeConvertListpack()decides whether to register the converted entries in the volatile set by peeking at exactly that header. A payload whose later field crosseshash-max-listpack-valuetherefore 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, andHDELof 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
Problem
rdbLoadObject()builds aRDB_TYPE_HASH_2hash 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-valueconverts the partially built object (src/rdb.c:2404-2409). That call site assumes conversion carries the already-loaded TTLs across on its own:It did not.
hashTypeConvertListpack()gated its volatile-set registration loop onhashTypeHasVolatileFields(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 novsetknew about.Three consequences, all confirmed on a build of
da91ccd12:keys_with_volatile_itemsstays 0 and the fields are never reaped or lazily hidden.dbTrackKeyWithVolatileItems()asks the samehashTypeHasVolatileFields()(src/db.c:549), which for a hashtable isset && !vsetIsEmpty(set), so the key is not tracked; and becausehashTypeTrackEntry()never ran,hashTypeIgnoreTTL(o, false)never swapped inhashWithVolatileItemsHashtableType, so reads have no validate callback either.The field TTLs are silently dropped by the next RDB save.
rdbObjectType()selectsRDB_TYPE_HASH_2only whenhashTypeHasVolatileFields(o)(src/rdb.c:775-783), so a desynced hash is written as plainRDB_TYPE_HASH:HDELof a volatile field crashes.hashTypeUntrackEntry()takeshashTypeGetVolatileSet(), which returns NULL for uninitialized metadata (src/t_hash.c:67-71); thedebugServerAssert(set)atsrc/t_hash.c:201is compiled out of a release build andvsetRemoveEntryWithExpiry()dereferences it: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-valueis smaller than the one in effect when the payload was written:Both halves of the mismatch, the deferred header install and the header-based gate, came from c80da01 (#3212). No tag contains that commit.
Fix
Set
has_volatilefrom the expiry of each entry the conversion carries over, in the same statement that hands that expiry toentryCreate(). The flag can no longer disagree with what landed in the hashtable, and it is unreachable from the header, so the loader's deferred install stops being load-bearing. The common case, converting a hash with no field TTLs, still skips the second pass.Behavior of
RDB_TYPE_HASH_2loads, per input:len > hash-max-listpack-entriesrdb.c:2526-2528hash-max-listpack-valuevsetemptyvsetTwo commits: the behavior change, then a test-only follow-up that loosens the new
HTTLassertion to a range so it does not depend on the DUMP/CONFIG/RESTORE sequence finishing inside the same second as theHEXPIRE.Alternative considered
Install the aggregate header in
rdb.cbefore the mid-loadhashTypeConvert()call. It is the same line count and it fixes this caller, but it keepshashTypeConvertListpack()depending on a header that every caller has to remember to maintain, which is the thing that broke. It also spends alpInsertMetadata()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.The report this came from also suggested making
hashTypeUntrackEntry()fail gracefully on a NULL set. Not doing that: with the invariant repaired, a NULL set there means a new desync, and turning that into a returned error would hide it. The existingdebugServerAssert()is the right shape.Testing
The new test fails on
da91ccd12'st_hash.cwith the test in place:hashexpire.tclalready 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.The new guard's false branch, skipping the second pass when nothing carries an expiry, is a performance property only. Replacing
if (expiry != EXPIRY_NONE) has_volatile = true;with an unconditional assignment leaveshashexpire.tclandtype/hash.tclgreen, as it should.This was generated by AI but verified, with love, by a human.