hashtable: add bounds check in hashtableNext iterator#2611
Conversation
e54a9cf to
31e5445
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## unstable #2611 +/- ##
============================================
- Coverage 72.24% 72.03% -0.22%
============================================
Files 127 128 +1
Lines 70820 71041 +221
============================================
+ Hits 51167 51175 +8
- Misses 19653 19866 +213
🚀 New features to boost your workflow:
|
zuiderkwast
left a comment
There was a problem hiding this comment.
Thanks Uri!
Note that this is just for making it safe to call hashtableNext again when it has already returned false. It's not really needed, but it makes it easier to detect bad usage, or is it for convenience?
Isn't calling hashtableNext() after the end a bug that we want to detect? Why not do an assert instead of graceful return then?
Prevent iteration beyond hashtable bounds by checking if iterator is already at the end before proceeding with the main iteration loop. This adds defensive bounds checking for table index and bucket index. Signed-off-by: Uri Yagelnik <uriy@amazon.com>
31e5445 to
6df1a9a
Compare
|
@uriyage let's also add unittest for this? |
Signed-off-by: Uri Yagelnik <uriy@amazon.com>
39f159b to
c57878c
Compare
Done |
|
Many times I've tried to reject adding dependencies from the low-level datastructures to the whole of the server globals and everything. Dependencies should be in the other direction, from high level to lower level components. |
We can just use Debug assert depends on a server config. That would prevent usage of this hashtable in non-server code (valkey-cli, valkey-benchmark). |
|
@madolson According to a commment above, Uri explained why:
I suppose we could wait for this use case to be upstreamed and then include this change at the same time. |
|
Couldn't they then just save the state? I agree, I feel like I would rather close this and re-open if that use case becomes public. I'm not sure what that case is. |
| #define HASHTABLE_ITER_PRIMARY_TABLE 0 | ||
| #define HASHTABLE_ITER_REHASH_TABLE 1 |
There was a problem hiding this comment.
These defines are unnecessary and create an inconsistency with the rest of the code. If we want to create theses (which I don't recommend) the entire file should be refactored to use them consistently.
|
Can someone more clearly lay out the scenario here? I'm thinking that this is a case of: 1) creating a safe iterator on the hashtable, 2) deleting or moving the underlying table, 3) continuing to call hashtableNext. Is this right? If so, I don't think this solves the problem. With a safe iterator, it's necessary to ensure that the iterator gets deleted before the underlying hashtable is deleted. If not, when we delete the iterator, we will try to re-enable rehashing on the (deleted) hashtable. One possible way to address this is to keep a list of all "safe" iterators associated with each hashtable. When the hashtable is deleted, it could then mark the iterators as invalid. This could also be done for unsafe iterators, but I don't think that's necessary as unsafe iterators, by their nature, are created/used/deleted in a short time. |
|
I've done a review on the iterator, and these are the things that need to be addressed. There are a few potential issues with long-lived (safe) iterators. One use case for this is for defrag which iterates over hashes in an incremental manner - spanning across timer invocations. Issue 1: Rehashing isn't paused immediatelyRef: Lines 2036 to 2037 in 1cf0df9 At this code reference, the logic indicates that if it's a safe iterator, that once we perform the first call to Issue 2: Empty hashtables remain in inconsistent stateRef: Lines 2042 to 2044 in 1cf0df9 With an empty hash table, the iterator returns false (indicating that iteration has completed). However, index/table still remain in the initial state - as if iteration has not yet begun. This makes it possible for an iterator to return false (indicating completion) but then later, if called again, begin returning newly added values. This likely isn't dangerous, because the caller is unlikely to call Issue 3: Deletion of hashtable makes
|
|
That's a pretty thorough analysis Jim! I guess there are maybe a few scenarios in OSS where it feels like we might delete the hashtable while the iterator exists, though I haven't fully investigated these:
Adding tracking for safe iterator invalidation should be pretty lightweight in terms of performance and memory. I think we should add it, with unit tests. Also, I'm pretty sure this issue exists for dict's safe iterator as well - dict will be worth fixing if hashtable is. |
rainsupreme
left a comment
There was a problem hiding this comment.
We should invalidate safe iterators for hashtable and dict when the underlying collection is deleted, to prevent invalid memory access and undefined states.
|
One other potential issue is with defragmentation. If the hashtable is moved during defragmentation, any active safe iterators will access the deleted memory. I don't think this can happen with defrag's own iterator, but if there's a potential for other iterators, this might be a concern. (e.g. forkless save?) |
|
I think we should fix these bugs found by Jim in a separate PR. Essentially, each hashtable needs to keep track of a list of the safe iterators. (The same problems exist with dict, I imagine.) However, I don't think we allow long-lived iterators currently. Only a few operations are allowed while iterating (i.e. while the iterator exists) so letting the event loop run and later resume iteration is not currently supported, as I read these doc comments: https://github.com/valkey-io/valkey/blob/9.0/src/hashtable.c#L1960-L1965 |
|
I think this falls under the same category as ensuring someone doesn't call hashtableNext after it's returned false, but I don't mind if it's a separate PR. At least then let's fix the doc comments to clarify this: Other activities (like event loop, defrag, threads) that could defrag/delete the hashtable must not be allowed to run while the iterator is considered valid. |
… hashtable delete (#2807) This makes it safe to delete hashtable while a safe iterator is iterating it. This currently isn't possible, but this improvement is required for fork-less replication #1754 which is being actively worked on. We discussed these issues in #2611 which guards against a different but related issue: calling hashtableNext again after it has already returned false. I implemented a singly linked list that hashtable uses to track its current safe iterators. It is used to invalidate all associated safe iterators when the hashtable is released. A singly linked list is acceptable because the list length is always very small - typically zero and no more than a handful. Also, renames the internal functions: hashtableReinitIterator -> hashtableRetargetIterator hashtableResetIterator -> hashtableCleanupIterator --------- Signed-off-by: Rain Valentine <rsg000@gmail.com> Signed-off-by: Viktor Söderqvist <viktor.soderqvist@est.tech> Co-authored-by: Viktor Söderqvist <viktor.soderqvist@est.tech>
… hashtable delete (valkey-io#2807) This makes it safe to delete hashtable while a safe iterator is iterating it. This currently isn't possible, but this improvement is required for fork-less replication valkey-io#1754 which is being actively worked on. We discussed these issues in valkey-io#2611 which guards against a different but related issue: calling hashtableNext again after it has already returned false. I implemented a singly linked list that hashtable uses to track its current safe iterators. It is used to invalidate all associated safe iterators when the hashtable is released. A singly linked list is acceptable because the list length is always very small - typically zero and no more than a handful. Also, renames the internal functions: hashtableReinitIterator -> hashtableRetargetIterator hashtableResetIterator -> hashtableCleanupIterator --------- Signed-off-by: Rain Valentine <rsg000@gmail.com> Signed-off-by: Viktor Söderqvist <viktor.soderqvist@est.tech> Co-authored-by: Viktor Söderqvist <viktor.soderqvist@est.tech>
… hashtable delete (valkey-io#2807) This makes it safe to delete hashtable while a safe iterator is iterating it. This currently isn't possible, but this improvement is required for fork-less replication valkey-io#1754 which is being actively worked on. We discussed these issues in valkey-io#2611 which guards against a different but related issue: calling hashtableNext again after it has already returned false. I implemented a singly linked list that hashtable uses to track its current safe iterators. It is used to invalidate all associated safe iterators when the hashtable is released. A singly linked list is acceptable because the list length is always very small - typically zero and no more than a handful. Also, renames the internal functions: hashtableReinitIterator -> hashtableRetargetIterator hashtableResetIterator -> hashtableCleanupIterator --------- Signed-off-by: Rain Valentine <rsg000@gmail.com> Signed-off-by: Viktor Söderqvist <viktor.soderqvist@est.tech> Co-authored-by: Viktor Söderqvist <viktor.soderqvist@est.tech>
rainsupreme
left a comment
There was a problem hiding this comment.
Added checks to avoid use-after-free in the other PR, so I take back my previous feedback - no longer needed :)
| */ | ||
| #include "hashtable.h" | ||
| #include "serverassert.h" | ||
| #include "server.h" |
There was a problem hiding this comment.
Don't add server.h. This creates dependency on the whole valkey blob and prevents hashtable from being reusable e.g. in valkey-cli and valkey-benchmark as well as in external projects. We already included serverassert.h and other minimal includes which are easy to replace by standard <assert.h> and similar.
| * Call hashtableNext to fetch each entry. You must call hashtableResetIterator | ||
| * when you are done with the iterator. | ||
| */ | ||
|
|
There was a problem hiding this comment.
Don't add blank line between comment and function.
| } | ||
|
|
||
| /* Check for unexpected iterator states that indicate bugs */ | ||
| debugServerAssert(iter->table < HASHTABLE_ITER_FINISHED && |
There was a problem hiding this comment.
debugServerAssert checks a config in the global server object. I don't think checking the config is faster than checking these other values so we can just use simple assert as defined in serverassert.h and avoid the dependency on the globals.
… hashtable delete (valkey-io#2807) This makes it safe to delete hashtable while a safe iterator is iterating it. This currently isn't possible, but this improvement is required for fork-less replication valkey-io#1754 which is being actively worked on. We discussed these issues in valkey-io#2611 which guards against a different but related issue: calling hashtableNext again after it has already returned false. I implemented a singly linked list that hashtable uses to track its current safe iterators. It is used to invalidate all associated safe iterators when the hashtable is released. A singly linked list is acceptable because the list length is always very small - typically zero and no more than a handful. Also, renames the internal functions: hashtableReinitIterator -> hashtableRetargetIterator hashtableResetIterator -> hashtableCleanupIterator --------- Signed-off-by: Rain Valentine <rsg000@gmail.com> Signed-off-by: Viktor Söderqvist <viktor.soderqvist@est.tech> Co-authored-by: Viktor Söderqvist <viktor.soderqvist@est.tech> Signed-off-by: Harkrishn Patro <bunty.hari@gmail.com>
|
This has been addressed more properly in #3551. Closing. |
Prevent iteration beyond hashtable bounds by checking if iterator is already at the end before proceeding with the main iteration loop. This adds defensive bounds checking for table index and bucket index.