Repository navigation
Conversation
A map arrives from hiredis as a flat key/value sequence, so the key has to
be held somewhere until its value is parsed. We were parking it in the
target dict under a Py_None placeholder and recovering it with
PyObject_CallMethod(parent, "popitem").
That is incorrect whenever a key repeats and is not the most recently
inserted entry. Re-setting an existing key updates it in place rather than
moving it to the end, so popitem() then removes somebody else's pair: the
value lands on the wrong key and the Py_None placeholder is left behind in
the result.
%3 a 1 b 2 a 3 -> {b'a': None, b'b': 3} (expected {b'a': 3, b'b': 2})
Keys that merely hash equal collide the same way, so a map containing both
0 and False, or 1 and True, is enough to trigger it.
It is also slow. Every pair costs a hash and insert, a name-based method
lookup, a tuple allocation, a delete, and a second hash and insert.
Hold the key in the reader instead and insert once when its value arrives,
which is both correct and cheaper. Keys are kept on a stack because maps
nest, and they have to outlive a gets() call since a reply can be split
across feeds. The stack grows on demand: a map in key position can nest
without bound, and although such a reply always ends in a TypeError for an
unhashable key, we must not overflow before getting there.
Parsing a 1000-field map drops from 226us to 63us. RESP3 maps were 2-2.7x
slower to parse than the equivalent RESP2 array plus a dict() conversion in
Python; they are now consistently faster, which is what RESP3 map replies
are supposed to buy.
One behavioural note: for a map with an unhashable key the TypeError is now
raised when the value is parsed rather than when the key is parsed. It is
still raised out of the same gets() call. On input that is both malformed
and contains an unhashable key, a different error may therefore surface
first.
Co-authored-by: Cursor <cursoragent@cursor.com>
mmick78
marked this pull request as ready for review
August 4, 2026 09:02
Contributor
|
Hello @mmick78 |
Contributor
Author
hi @uglide, we did see in our production application a regression of performance when switching to redis-py 8.0.1 from previous version 6.4.0, which uses RESP3 by default instead of RESP2. EDIT: So i checked again which regressions we faced, and it was only for HGETALL as mentioned in the PR description |
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.
Summary
tryParentizebuilds RESP3 map replies by parking the key in the target dict under aPy_Noneplaceholder and recovering it withpopitem():This is incorrect whenever a key repeats and is not the most recently inserted entry. Re-setting an existing key updates it in place rather than moving it to the end, so
popitem()removes somebody else's pair: the value lands on the wrong key, and thePy_Noneplaceholder is left behind in the returned dict.Keys that merely hash equal collide the same way, so a map containing both
0andFalse, or1andTrue, is enough to trigger it.It is also slow: every pair costs a hash and insert, a name-based method lookup on the dict, a tuple allocation, a delete, and a second hash and insert — where one insert would do.
This PR holds the pending key in the reader and inserts it once, when its value arrives. That is both correct and substantially faster.
Correctness
I validated both builds against an independent pure-Python RESP3 parser that builds maps with ordinary dict assignment, over a randomized corpus of 20,000 replies (every RESP3 type, nesting to depth 5, both
decode_responsesmodes):masterEvery one of the 952 is the mis-pairing above.
I also diffed this build directly against
masteron 20,000 randomized replies fed at random chunk boundaries. There is no case where both builds parse successfully and return different data, other than the ones wheremasteris wrong. The remaining differences are confined to input that fails to parse anyway — see the behavioural note below.Performance
Reader.gets()on a map reply, best of 5 runs (script below). "RESP2 arr+dict" is the same data as a RESP2 flat array converted withdict(zip(it, it))in Python, i.e. what a client gets by staying on RESP2.RESP3 map parsing goes from consistently slower than the RESP2 equivalent to consistently faster. We found this while investigating a latency regression after
redis-py8 made RESP3 the default:HGETALL-heavy workloads got noticeably more expensive, and pinningprotocol=2recovered the throughput.Implementation notes
TypeErrorfor an unhashable key, and it must not be able to overflow a fixed buffer before getting there.gets()calls, since a reply can be split over severalfeed()calls. They are therefore visited inReader_traverseand released inReader_dealloc.Reader_getsdrops the whole stack on its error path — not just the key in hand, since a reply nesting maps in key position keeps one buffered per level. That single site also covers the failures inside the map path itself (a failed push, or an unhashable key failingPyDict_SetItem): returning NULL is how a reply-object callback reports failure, so hiredis marks the reader as errored and stops, and the error path always runs.PushNotificationType_New.assert: releases build with-DNDEBUG, where a push/pop imbalance would otherwise turn into a silent leak. It clears the stack and raisesRuntimeError, matching the guard on the empty-stack case. Neither is reachable by construction; both are there so a future edit fails loudly.Behavioural note
For a map with an unhashable key, the
TypeErroris now raised when the value is parsed rather than when the key is parsed. It is still raised out of the samegets()call. Two consequences, both limited to replies that were going to fail anyway:ProtocolErrorinstead ofTypeError).gets()now returns "not enough data" instead of raising, and raises once the value arrives. This seems the more defensible behaviour: the reply has not been fully read yet.Testing
Existing suite passes unchanged, including
test_dict_memory_leaksunderpytest --memray(50 KB limit) andtest_gc.py.Ten new tests. Three are regression tests for the mis-pairing bug and fail on
master:test_dict_repeated_key_not_lasttest_dict_repeated_key_interleavedtest_dict_hash_equal_keysSeven guard the paths this change introduces:
test_dict_split_between_key_and_value— key and value delivered in separatefeed()calls.test_dict_reused_reader— consecutive maps on one reader, each leaving a key buffered across agets()that reports not-enough-data, locking in the cross-gets()key lifetime.test_dict_protocol_error_after_key— reply aborted between a key and its value.test_dict_pending_key_is_traversed— a buffered key is reported to the GC, so a cycle running through one stays collectable.test_dict_protocol_error_releases_pending_key— the buffered key is released, checked with the reader still alive so it covers the release ingets()rather than the one indealloc.test_dict_deeply_nested_unhashable_keys— 12 levels of maps in key position, forcing the stack to grow past its initial capacity.test_dict_deeply_nested_unhashable_keys_release— the same reply, asserting the whole stack is unwound: ten maps are still buffered when the failing pair is inserted, so releasing only the key in hand would strand the rest.The three tests that inspect the stack do so through
gc.get_referents(reader), which reflectstp_traversedirectly, so they assert on the exact object rather than on a memory-usage threshold. They are skipped off CPython, whereget_referentsdoes not report a C type's referents.No new warnings under
-Wall -Wextra; the sixreader.cemits are all pre-existing, in lines this PR does not touch.Reproduction
Note
Medium Risk
Touches core reply parsing in the C extension (map assembly, error paths, GC traversal); behavior changes only for failing or edge-case map input, but correctness fixes affect all RESP3 map replies.
Overview
RESP3 maps are built by buffering each key on a growable reader-owned stack until its value arrives, then doing a single
PyDict_SetItem—replacing the oldPy_Noneplaceholder +popitem()approach that mis-paired repeated or hash-equal keys and did extra work per entry.The reader now owns pending keys across chunked
feed()/gets(), visits them intp_traverse, frees them indealloc, clears the full stack on parse errors, and raisesRuntimeErrorif any keys remain after a supposedly complete reply.Tests add regressions for repeated/hash-equal keys, split feeds, protocol errors, deep nested maps, GC visibility of buffered keys, and reuse of one reader across multiple maps.
Reviewed by Cursor Bugbot for commit 7e9f011. Bugbot is set up for automated code reviews on this repo. Configure here.