diff --git a/mbo/container/BUILD.bazel b/mbo/container/BUILD.bazel index 38c9d6488..e91c3ae6e 100644 --- a/mbo/container/BUILD.bazel +++ b/mbo/container/BUILD.bazel @@ -154,6 +154,30 @@ cc_library( ], ) +cc_library( + name = "hamt_replace_cc", + hdrs = ["internal/hamt_replace.h"], + deps = [ + ":hamt_hash_path_cc", + ":hamt_lookup_cc", + ":hamt_shared_node_cc", + "//mbo/memory:block_source_cc", + ], +) + +cc_test( + name = "hamt_replace_test", + size = "small", + srcs = ["internal/hamt_replace_test.cc"], + deps = [ + ":hamt_insert_cc", + ":hamt_replace_cc", + "//mbo/memory:block_source_cc", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) + cc_test( name = "hamt_clone_test", size = "small", diff --git a/mbo/container/HAMT_STORAGE.md b/mbo/container/HAMT_STORAGE.md index ec13458e5..e9754c1fa 100644 --- a/mbo/container/HAMT_STORAGE.md +++ b/mbo/container/HAMT_STORAGE.md @@ -241,3 +241,16 @@ Child staging uses bounded stack storage rather than another allocator. Repeated references are copied independently; this primitive does not preserve graph aliasing or perform cross-domain structural sharing. Public `clone_to(source)` wrappers also preserve hash/equality state and container-level metadata; those responsibilities are not handled by this node primitive. + +## Persistent value replacement + +`TryReplaceHamtEntry` finds an existing key and copies only its affected path, replacing the +stored entry without changing the original snapshot. The replacement must preserve the key +and full hash; public map wrappers enforce immutable keys while replacing mapped values. +Collision siblings and unaffected branches remain unchanged. + +Success returns an owned root and `replaced == true`. A missing key returns a retained original +root and `replaced == false`, without allocating; a null root is a successful missing-key result. +`std::nullopt` means allocation failure, with temporary nodes reclaimed and original ownership +unchanged. Release successful roots through their original block source. Copies, destruction, +hash/key extraction, and equality must be non-throwing; access remains externally synchronized. diff --git a/mbo/container/internal/hamt_replace.h b/mbo/container/internal/hamt_replace.h new file mode 100644 index 000000000..aa7dce8d3 --- /dev/null +++ b/mbo/container/internal/hamt_replace.h @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +// SPDX-License-Identifier: Apache-2.0 + +#ifndef MBO_CONTAINER_INTERNAL_HAMT_REPLACE_H_ +#define MBO_CONTAINER_INTERNAL_HAMT_REPLACE_H_ + +#include +#include +#include +#include +#include + +#include "mbo/container/internal/hamt_hash_path.h" +#include "mbo/container/internal/hamt_lookup.h" +#include "mbo/container/internal/hamt_shared_node.h" +#include "mbo/memory/block_source.h" + +namespace mbo::container::container_internal { + +template +struct HamtReplaceResult final { + Node* root; + bool replaced; +}; + +namespace hamt_replace_internal { + +// The target has already been found in this root using this hash. +template +[[nodiscard]] std::optional*> TryReplaceAt( + Source& source, + const HamtSharedNode& original, + const HamtHashPath& path, + std::size_t level, + const Entry* target, + const Entry& replacement) noexcept +requires std::is_nothrow_copy_constructible_v +{ + using Node = HamtSharedNode; + if (original.is_collision()) { + const auto position = static_cast(std::distance(original.entries().data(), target)); + return Node::TryReplaceEntry(source, original, position, replacement); + } + const std::size_t fragment = path.Fragment(level); + if (original.index().Kind(fragment) == HamtSlotKind::kData) { + return Node::TryReplaceEntry(source, original, original.index().DataIndex(fragment), replacement); + } + const std::size_t position = original.index().NodeIndex(fragment); + const Node* const child = original.children().subspan(position).front(); + auto* const replaced = TryReplaceAt(source, *child, path, level + 1, target, replacement).value_or(nullptr); + if (replaced == nullptr) { + return std::nullopt; + } + const auto result = Node::TryReplaceChild(source, original, position, replaced); + Node::Release(source, replaced); + return result; +} + +} // namespace hamt_replace_internal + +// Replacement must preserve the existing key and full hash. Borrows original; +// success owns one root reference, including a retained root for missing keys. +template< + std::unsigned_integral Hash, + std::size_t FragmentBits, + typename Entry, + typename Key, + mbo::memory::BlockSource Source, + typename HashOf, + typename KeyOf, + typename Equal> +requires( + std::is_nothrow_copy_constructible_v && std::is_nothrow_invocable_r_v + && std::is_nothrow_invocable_v + && std::is_nothrow_invocable_r_v, const Key&>) +[[nodiscard]] std::optional>> TryReplaceHamtEntry( + Source& source, + HamtSharedNode* original, + Hash hash, + const Key& key, + const Entry& replacement, + const HashOf& hash_of, + const KeyOf& key_of, + const Equal& equal) noexcept { + using Node = HamtSharedNode; + const Entry* const target = FindHamtEntry(original, hash, key, hash_of, key_of, equal); + if (target == nullptr) { + Node::Retain(original); + return HamtReplaceResult{.root = original, .replaced = false}; + } + const HamtHashPath path(hash); + auto* const replaced = + hamt_replace_internal::TryReplaceAt(source, *original, path, 0, target, replacement).value_or(nullptr); + if (replaced == nullptr) { + return std::nullopt; + } + return HamtReplaceResult{.root = replaced, .replaced = true}; +} + +} // namespace mbo::container::container_internal + +#endif // MBO_CONTAINER_INTERNAL_HAMT_REPLACE_H_ diff --git a/mbo/container/internal/hamt_replace_test.cc b/mbo/container/internal/hamt_replace_test.cc new file mode 100644 index 000000000..26f12b1b4 --- /dev/null +++ b/mbo/container/internal/hamt_replace_test.cc @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +// SPDX-License-Identifier: Apache-2.0 + +#include "mbo/container/internal/hamt_replace.h" + +#include + +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "mbo/container/internal/hamt_insert.h" +#include "mbo/memory/block_source.h" + +namespace mbo::container::container_internal { +namespace { + +using ::testing::Eq; +using ::testing::Field; +using ::testing::IsNull; +using ::testing::NotNull; +using ::testing::Optional; + +struct Entry final { + std::uint64_t hash; + int key; + int value; +}; + +struct HashOf final { + constexpr std::uint64_t operator()(const Entry& entry) const noexcept { return entry.hash; } +}; + +struct KeyOf final { + constexpr int operator()(const Entry& entry) const noexcept { return entry.key; } +}; + +struct Equal final { + constexpr bool operator()(int lhs, int rhs) const noexcept { return lhs == rhs; } +}; + +using Node = HamtSharedNode<5, Entry>; + +struct HamtReplaceTest : ::testing::Test { + protected: + void TearDown() override { Node::Release(source, root); } + + public: + void Insert(Entry entry) { + const auto inserted = TryInsertHamtEntry(source, root, entry.hash, entry.key, entry, HashOf{}, KeyOf{}, Equal{}) + .value_or(HamtInsertResult{.root = nullptr, .inserted = false}); + ASSERT_THAT(inserted.root, NotNull()); + Node::Release(source, root); + root = inserted.root; + } + + static const Entry* Find(const Node* node, std::uint64_t hash, int key) { + return FindHamtEntry(node, hash, key, HashOf{}, KeyOf{}, Equal{}); + } + + mbo::memory::NewDeleteBlockSource source; + Node* root = nullptr; +}; + +TEST_F(HamtReplaceTest, NullRootAndMissingKeysSucceedWithoutAllocation) { + mbo::memory::InlineBlockSource<1> exhausted; + constexpr Entry kReplacement{.hash = 1, .key = 10, .value = 99}; + const auto empty = TryReplaceHamtEntry( + exhausted, root, kReplacement.hash, kReplacement.key, kReplacement, HashOf{}, KeyOf{}, Equal{}) + .value_or(HamtReplaceResult{.root = nullptr, .replaced = true}); + EXPECT_THAT(empty.root, IsNull()); + EXPECT_THAT(empty.replaced, Eq(false)); + Insert(Entry{.hash = 2, .key = 20, .value = 20}); + const auto missing = + TryReplaceHamtEntry( + exhausted, root, kReplacement.hash, kReplacement.key, kReplacement, HashOf{}, KeyOf{}, Equal{}) + .value_or(HamtReplaceResult{.root = nullptr, .replaced = true}); + ASSERT_THAT(missing.root, NotNull()); + EXPECT_THAT(missing.root, Eq(root)); + EXPECT_THAT(missing.replaced, Eq(false)); + EXPECT_THAT(root->use_count(), Eq(2)); + Node::Release(source, missing.root); +} + +TEST_F(HamtReplaceTest, ReplacesDeepCollisionValueWithoutChangingTheOriginalSnapshot) { + constexpr std::uint64_t kHash = 1 + (std::uint64_t{3} << 15); + Insert(Entry{.hash = 1, .key = 10, .value = 10}); + Insert(Entry{.hash = kHash, .key = 20, .value = 20}); + Insert(Entry{.hash = kHash, .key = 30, .value = 30}); + constexpr Entry kReplacement{.hash = kHash, .key = 20, .value = 99}; + const auto changed = TryReplaceHamtEntry(source, root, kHash, 20, kReplacement, HashOf{}, KeyOf{}, Equal{}) + .value_or(HamtReplaceResult{.root = nullptr, .replaced = false}); + ASSERT_THAT(changed.root, NotNull()); + EXPECT_THAT(changed.replaced, Eq(true)); + const Entry* original = Find(root, kHash, 20); + const Entry* updated = Find(changed.root, kHash, 20); + const Entry* other = Find(changed.root, kHash, 30); + ASSERT_THAT(original, NotNull()); + ASSERT_THAT(updated, NotNull()); + ASSERT_THAT(other, NotNull()); + EXPECT_THAT(original->value, Eq(20)); + EXPECT_THAT(updated->value, Eq(99)); + EXPECT_THAT(other->value, Eq(30)); + Node::Release(source, changed.root); +} + +TEST_F(HamtReplaceTest, AllocationFailurePreservesDirectEntry) { + Insert(Entry{.hash = 1, .key = 10, .value = 10}); + mbo::memory::InlineBlockSource<1> exhausted; + constexpr Entry kReplacement{.hash = 1, .key = 10, .value = 99}; + EXPECT_THAT( + TryReplaceHamtEntry(exhausted, root, std::uint64_t{1}, 10, kReplacement, HashOf{}, KeyOf{}, Equal{}), + Eq(std::nullopt)); + const Entry* unchanged = Find(root, 1, 10); + ASSERT_THAT(unchanged, NotNull()); + EXPECT_THAT(unchanged->value, Eq(10)); + EXPECT_THAT(root->use_count(), Eq(1)); +} + +TEST_F(HamtReplaceTest, DirectReplacementCanAliasTheExistingEntry) { + Insert(Entry{.hash = 1, .key = 10, .value = 10}); + const Entry* const existing = Find(root, 1, 10); + ASSERT_THAT(existing, NotNull()); + const auto changed = TryReplaceHamtEntry(source, root, std::uint64_t{1}, 10, *existing, HashOf{}, KeyOf{}, Equal{}) + .value_or(HamtReplaceResult{.root = nullptr, .replaced = false}); + ASSERT_THAT(changed.root, NotNull()); + EXPECT_THAT(changed.replaced, Eq(true)); + const Entry* const copied = Find(changed.root, 1, 10); + ASSERT_THAT(copied, NotNull()); + EXPECT_THAT(copied == existing, Eq(false)); + EXPECT_THAT(copied->value, Eq(10)); + EXPECT_THAT(existing->value, Eq(10)); + Node::Release(source, changed.root); +} + +TEST_F(HamtReplaceTest, FailedChildCopyPreservesTheEntireOriginalPath) { + Insert(Entry{.hash = 1, .key = 10, .value = 10}); + Insert(Entry{.hash = 33, .key = 20, .value = 20}); + mbo::memory::InlineBlockSource<1> exhausted; + constexpr Entry kReplacement{.hash = 33, .key = 20, .value = 99}; + EXPECT_THAT( + TryReplaceHamtEntry(exhausted, root, std::uint64_t{33}, 20, kReplacement, HashOf{}, KeyOf{}, Equal{}), + Eq(std::nullopt)); + const Entry* const unchanged = Find(root, 33, 20); + const Entry* const sibling = Find(root, 1, 10); + ASSERT_THAT(unchanged, NotNull()); + ASSERT_THAT(sibling, NotNull()); + EXPECT_THAT(unchanged->value, Eq(20)); + EXPECT_THAT(sibling->value, Eq(10)); + EXPECT_THAT(root->use_count(), Eq(1)); + EXPECT_THAT(root->children().front()->use_count(), Eq(1)); +} + +TEST_F(HamtReplaceTest, FailedParentCopyReclaimsTheAlreadyCopiedChild) { + Insert(Entry{.hash = 1, .key = 10, .value = 10}); + Insert(Entry{.hash = 33, .key = 20, .value = 20}); + mbo::memory::InlineBlockSource<4'096> single_allocation; + constexpr Entry kReplacement{.hash = 33, .key = 20, .value = 99}; + EXPECT_THAT( + TryReplaceHamtEntry(single_allocation, root, std::uint64_t{33}, 20, kReplacement, HashOf{}, KeyOf{}, Equal{}), + Eq(std::nullopt)); + const Entry* const unchanged = Find(root, 33, 20); + ASSERT_THAT(unchanged, NotNull()); + EXPECT_THAT(unchanged->value, Eq(20)); + EXPECT_THAT(root->use_count(), Eq(1)); + EXPECT_THAT(single_allocation.TryAcquire(1, 1), Optional(Field("data", &mbo::memory::MemoryBlock::data, NotNull()))); +} + +} // namespace +} // namespace mbo::container::container_internal