Conversation
PHDELPREFIX with an empty prefix and PHDEL of a whole path both released the deleted memory on the main thread, unlike every other collection type. Deleting 1M paths blocked the event loop for 40 ms. Both sites already unlink the memory before freeing it, so the detached rax or payload can be handed to the bio lazy free thread instead. Gated on lazyfree-lazy-user-del and on the existing free effort threshold, so small trees still free inline. 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.
PHDELPREFIX key ""andPHDEL key pathrelease the deleted memory on the main thread, unlike every other collection type in Valkey. Deleting 1M paths withPHDELPREFIX key ""blocks the event loop for 40 ms, and deleting a single path holding 2M fields withPHDELblocks it for 121 ms, whileUNLINKof the identical object takes 13 us. Both sites already unlink the memory from the keyspace before freeing it, so the detached rax or the detached payload can be handed to the bio lazy free thread instead of being freed inline. Gated onlazyfree-lazy-user-deland on the existing free effort threshold, so small trees and listpack payloads still free inline.AI generated details
Problem
phdelprefixCommand's empty-prefix branch replaced the index with a fresh rax and then freed the old one inline (src/t_path_hash.c:572-575on agents/unstable):phdelCommanddid the same for a whole path, freeing the unlinked payload with a baredecrRefCount(src/t_path_hash.c:389on agents/unstable).lazyfreeGetFreeEffortalready understands the type (src/lazyfree.c:189-192), but that only serves whole-object frees through UNLINK, eviction and FLUSHALL. Neither command reached it.The Path Hash design document names this as deferred work: "If truly nonblocking deletion of a large subtree is needed in the future, rax can gain a subtree-detach capability and hand detached nodes to the lazy-free thread. This is not required for v1."
Fix
freePathHashIndex(rax *index)insrc/t_path_hash.c:54-58wraps theraxFreeWithCallback(index, freePathHashPayload)call. The free function has to live int_path_hash.cbecausefreePathHashPayloadis static there and is the only thing that knows a rax value is a refcounted payload.freePathHashObjectnow calls it, so there is one definition of how to free an index.freePathHashIndexAsync(rax *index, uint64_t num_fields)insrc/lazyfree.c:224-233applies the threshold and queueslazyFreePathHashIndex(src/lazyfree.c:98-104) viabioCreateLazyFreeJob. The effort calculation moved intopathHashFreeEffort(src/lazyfree.c:131-135) so the async decision andlazyfreeGetFreeEffortcannot drift apart.phdelprefixCommandreassignspath_hash->indexbefore handing off (src/t_path_hash.c:579-590).phdelCommandreusesfreeObjAsyncfor the unlinked payload (src/t_path_hash.c:395-397), matching the existingfreeObjAsync(NULL, obj, -1)pattern atsrc/t_set.c:1665andsrc/t_zset.c:2277.Accounting
lazyFreePathHashIndexdecrementslazyfree_objectsby 1 and incrementslazyfreed_objectsby 1, exactly likelazyfreeFreeObject(src/lazyfree.c:15-20). A detached index is one pending object, the same as the whole object would be under UNLINK, solazyfree_pending_objectskeeps its existing meaning. The PHDEL path goes throughfreeObjAsync, which already does its own accounting.Config decision
Gated on
lazyfree-lazy-user-del. PHDELPREFIX and PHDEL are a user explicitly asking to delete, which is the DEL/UNLINK shape that config governs, and its default is already yes (src/config.c:3436). Unconditional-when-effort-is-high was rejected: an operator who setlazyfree-lazy-user-del nodid so to keep user deletions synchronous and accounted to the calling command, and silently ignoring that for one type would be surprising. No new config.Why the handoff cannot race
Payloads in a Path Hash index are referenced only by the rax.
t_path_hash.cnever callsincrRefCounton a payload, so no payload pointer outlives a single command invocation, and every reader outside the file reaches payloads throughpath_hash->index(src/rdb.c:1208,src/rdb.c:2596,src/debug.c:1269,src/module.c:4470,src/valkey-check-rdb.c:395).path_hash->indexis reassigned to a fresh rax before the job is created, so by the time the bio thread can see the detached rax it is reachable from nothing else. A subsequent command on the same key,FLUSHALL,DEBUG RELOAD, orDELof the key all operate on thepathHashObjectand therefore on the new empty index. Freeing the object later frees only that new index. A snapshot child gets a copy-on-write image and is unaffected by a job queued in the parent.freeObjAsyncadditionally re-checksrefcount == 1before handing an object off (src/lazyfree.c:216), so the PHDEL path is safe even if a future caller does retain a payload.Measurements
1M paths, 2 fields each, 87.6 MB. Main thread time is
usec_per_callfromINFO commandstats, which is the time the command holds the event loop.PHDELPREFIX key "":lazyfree-lazy-user-del noUNLINKof the identical object, for reference: 13 us.PHDEL key pathon one path holding 2M fields, 98.2 MB: 121109 us before, 12 us after.lazyfree_pending_objects:1was observable after that one.DEBUG DIGEST-VALUEof the resulting empty tree is892cc1e58fdd61b3796fec8aaa1cb849d2de3ebfin every run above, sync and async.EXISTS1,TYPEpathhash,PHCARD0.Not done: the non-empty-prefix path
PHDELPREFIX key <prefix>still deletes synchronously. The design document suggests batching the payload frees, but measured on 500k paths with a 1-byte prefix, the field count barely moves the cost:Tripling the memory adds 5.7% to the duration, so payload teardown is not the dominant cost and moving it to bio would buy almost nothing. The cost is in the per-path
raxRemoveplus theraxSeekrestart per 256-path chunk (src/t_path_hash.c:601-626). Making that nonblocking needs the real rax subtree-detach capability the design document names, which touches path-compression invariants and needs its own differential testing. Left out deliberately.Testing
Four tests in
tests/unit/type/path_hash.tcl.lazyfree_pending_objectsdraining to zero pluslazyfreed_objectsbecoming 1 is the assertion, rather than catchinglazyfree_pending_objects > 0. Catching the rise is inherently a race against the bio thread on the small trees a test can build, and there is no hook to pause the lazy free worker, onlyDEBUG BIO-DRAINto wait for it.lazyfreed_objects == 1proves the free went through the thread deterministically, which is what the flaky check was trying to establish.Coverage: async on a 200-path tree, async on a payload with
hash-max-listpack-entries + 1fields, inline withlazyfree-lazy-user-del no, and inline for a 5-path tree under the effort threshold. The inline cases matter because a policy bug that always went async would otherwise pass every other test.Existing assertions were not modified, including the empty-object round-trip at
tests/unit/type/path_hash.tcl:658.ASAN plus LeakSanitizer,
HAVE_IFUNC 0, overunit/type/path_hashandunit/lazyfree: clean. Also clean on a manual run that interleavesPHDELPREFIXwith a 1-byte prefix,PHDELPREFIX "",PHDELof a large payload,DEBUG RELOADimmediately after an asyncPHDELPREFIX, andFLUSHALLwith a free still pending.Could not verify formatting: this host has clang-format 11, which rejects
src/.clang-format(unknown key 'InsertNewlineAtEOF'), and clang-format-18 is not installed. Added lines are under 120 columns and follow the surrounding style. Also did not test under a replica or with AOF enabled beyond the existing suite, since propagation is unchanged: both commands still propagate verbatim andserver.dirtyand the keyspace notifications are untouched.This was generated by AI but verified, with love, by a human.