Compare commits

...
1 Commits
Author SHA1 Message Date
weaselbot 6db080a34c Fix hash_table setOldestVersion infinite loop on empty map
CI / pre-commit (pull_request) Successful in 2m2s
CI / test (arm64, -DCMAKE_BUILD_TYPE=Debug -DMSAN_TOOLCHAIN_PATH=/opt/msan, 22, https://minio.weaselab.dev/public/aarch64/msan-toolchain-22.1.8.tar.zst, debug-arm64, ubuntu-latest-arm64) (pull_request) Successful in 3m46s
CI / test (amd64, -DCMAKE_BUILD_TYPE=Debug -DMSAN_TOOLCHAIN_PATH=/opt/msan, 21, https://minio.weaselab.dev/public/x86_64/msan-toolchain-21.1.8.tar.zst, debug, ubuntu-latest-amd64) (pull_request) Successful in 3m44s
CI / release (arm64, , ubuntu-latest-arm64) (pull_request) Successful in 3m19s
CI / test (amd64, -DCMAKE_CXX_FLAGS=-DUSE_64_BIT=1, 21, , 64-bit-versions, ubuntu-latest-amd64) (pull_request) Successful in 3m16s
CI / test (amd64, -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, 21, , gcc, ubuntu-latest-amd64) (pull_request) Successful in 3m9s
CI / test (amd64, -DUSE_SIMD_FALLBACK=ON, 21, , simd-fallback, ubuntu-latest-amd64) (pull_request) Successful in 3m15s
CI / release (amd64, -DMSAN_TOOLCHAIN_PATH=/opt/msan, ubuntu-latest-amd64) (pull_request) Successful in 5m30s
CI / coverage (pull_request) Successful in 3m40s
setOldestVersion's GC pass charges keyUpdates at 2x the entry count, so
when every entry has version <= oldestVersion and is erased, keyUpdates
remains > 0 while the map is empty. The outer while(keyUpdates > 0) loop
then reset iter to map.begin() (== end() for an empty map) and the inner
for loop never executed, spinning forever.

Break out of the while loop when the map is exhausted, so the call
returns normally.

Closes #78
2026-08-16 13:02:50 -04:00
2 changed files with 25 additions and 0 deletions
+6
View File
@@ -59,6 +59,12 @@ struct __attribute__((visibility("hidden"))) ConflictSet::Impl {
while (keyUpdates > 0) {
if (iter == map.end()) {
iter = map.begin();
// The map is empty, so there are no more entries to garbage-collect.
// Break rather than spinning forever (keyUpdates is charged at 2x
// the entry count, so it may still be > 0 after emptying the map).
if (iter == map.end()) {
break;
}
}
for (; iter != map.end(); --keyUpdates) {
if (iter->second <= oldestVersion) {
+19
View File
@@ -68,6 +68,25 @@ def test_hash_table_getBytes():
assert cs.check(read(0, b"key")) == [Result.CONFLICT]
def test_hash_table_setOldestVersion_empties_map():
# Regression test for issue #78: setOldestVersion must terminate when
# its garbage-collection pass erases every entry in the map. Each write
# charges keyUpdates += 2, so with 50 writes keyUpdates = 100 and GC is
# enabled; erasing all entries leaves keyUpdates > 0 while the map is
# empty, which previously caused the GC loop to spin forever.
with ConflictSet(0, build_dir=build_dir, implementation="hash_table") as cs:
for i in range(50):
cs.addWrites(1, write(("k%06d" % i).encode()))
# All entries have version 1 <= oldestVersion 1 -> all erased.
cs.setOldestVersion(1)
# If we reach this point, setOldestVersion returned instead of hanging.
# The keys were erased, so reads at a valid version commit.
assert cs.check(read(1, b"k000000")) == [Result.COMMIT]
assert cs.check(read(2, b"k000000")) == [Result.COMMIT]
# A read older than oldestVersion is TooOld.
assert cs.check(read(0, b"k000000")) == [Result.TOO_OLD]
def test_write_read_without_outer_reference():
# Regression test for issue #42: WriteRange/ReadRange must keep their
# backing key buffers alive, because the C library reads the pointer