From 858c0d3efc6b745c9f087fa7f5ec38117c436d6e Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 4 Nov 2026 10:30:45 +0800 Subject: [PATCH 1/3] fix(container): preserve LRU links and insertion state --- docs/project/code-style.md | 16 ++++ docs/project/code-style_CN.md | 13 +++ src/core/container/lru_map.hpp | 63 +++++++++++-- tests/core/CMakeLists.txt | 12 +++ tests/core/README.md | 6 ++ tests/core/README_CN.md | 4 + tests/core/lru_map_failure.cpp | 129 ++++++++++++++++++++++++++ tests/core/lru_maps.cpp | 160 +++++++++++++++++++++++++++++++++ 8 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 tests/core/lru_map_failure.cpp create mode 100644 tests/core/lru_maps.cpp diff --git a/docs/project/code-style.md b/docs/project/code-style.md index 4a3a93535..fef1ac6d8 100644 --- a/docs/project/code-style.md +++ b/docs/project/code-style.md @@ -185,6 +185,22 @@ network timeouts, remain unchanged. Check `it != values.end()` before incrementing or calling `get_key()` / `get_data()`. Empty ranges have `begin() == end()`; do not dereference or increment end. Advancing one iterator does not change a saved prior position, but removing its node, clearing or destroying its container ends that borrowed lifetime. The custom iterator and const-iteration APIs are otherwise unchanged. Core header checks instantiate scalar and pointer mappings; `check-core` covers saved postfix values, storage mutation, empty/end comparisons and the existing `array_map` lookup path. +## LRU cache copying and insertion + +`` stores values, recency keys and internal links as one +cache. Copies retain capacity and recency order, rebuild links into their own key +list, and remain usable after the source changes or is destroyed. Mapped values +retain their normal copy semantics, including shared ownership for `shared_ptr`. +Copy assignment prepares the replacement before releasing existing entries. + +Insertion of an existing key retains its original value and recency. Insertion +failure from allocation or key/value copying propagates the exception and removes +the incomplete entry; existing values and eviction order remain intact. `find()` +still promotes a found entry and successful insertion evicts the least recently +used entry when full. Public signatures and class fields are unchanged; rebuild +header consumers to use the fix. `check-lru-map`, also included in `check-core`, +covers independent copies, eviction and isolated allocation failures. + ## POD array storage and failure contract `` retains its `void` mutation APIs and inline/heap storage layout. Use it for trivial scalar or pointer elements with a positive inline capacity; it does not manage nontrivial element lifetimes. `push_back`, fill `resize` and `insert` snapshot an element argument before growing or shifting storage, so an argument such as `values[0]` keeps its original value. Growth can still invalidate borrowed element pointers and references. diff --git a/docs/project/code-style_CN.md b/docs/project/code-style_CN.md index b966c9a9c..bcded551f 100644 --- a/docs/project/code-style_CN.md +++ b/docs/project/code-style_CN.md @@ -169,6 +169,19 @@ clang-tidy 需要已配置构建的编译数据库及兼容的编译器工具链 递增或调用 `get_key()`/`get_data()` 前,先检查 `it != values.end()`。空范围满足 `begin() == end()`,不要解引用或递增 end。推进一个迭代器不改变已保存的旧位置,但移除对应节点、清空或销毁容器后,不再使用原借用。其余自定义迭代器与 const 迭代接口保持不变。Core 头文件检查实例化标量与指针映射;`check-core` 覆盖后置递增旧值、存储修改、空范围/end 比较,以及现有 `array_map` 查询路径。 +## LRU 缓存复制与插入 + +`` 将值、访问顺序键和内部链接作为一个缓存管理。 +复制保留容量和访问顺序,并将链接重建到自己的键列表;源对象改变或销毁后, +复制体仍可使用。值保持原有复制语义,包括 `shared_ptr` 的共享所有权。 +复制赋值先准备替换状态,再释放原有条目。 + +插入已有键时,保留原值和访问顺序。分配或键/值复制失败时,异常向外传播, +不完整的新条目会被移除;原有值和淘汰顺序保持不变。`find()` 仍将命中条目移到 +最近使用位置,满容量后的成功插入仍淘汰最久未使用的条目。公开签名和成员布局 +保持不变;重新编译头文件使用方即可采用修复。`check-lru-map` 纳入 `check-core`, +覆盖独立复制、淘汰和隔离的分配失败。 + ## POD 数组存储与失败约定 `` 保留 `void` 修改接口及内联/堆存储布局。它用于正内联容量的平凡标量或指针元素,不管理非平凡元素生命周期。`push_back`、带填充值的 `resize` 和 `insert` 会在扩容或移动前保存元素参数,因此 `values[0]` 等别名参数保留原值。扩容仍会使借用的元素指针和引用失效。 diff --git a/src/core/container/lru_map.hpp b/src/core/container/lru_map.hpp index 95765e31d..2f06ccae9 100644 --- a/src/core/container/lru_map.hpp +++ b/src/core/container/lru_map.hpp @@ -34,6 +34,26 @@ class lru_map capacity_ = capacity > 0 ? capacity : 1024; } + lru_map(const lru_map& other) + : kv_(other.kv_) + , ki_(other.ki_) + , kl_(other.kl_) + , capacity_(other.capacity_) { + for (auto it = kl_.begin(); it != kl_.end(); ++it) + { + ki_.find(*it)->second = it; + } + } + + lru_map& operator=(const lru_map& other) { + if (this != &other) + { + lru_map candidate(other); + swap(candidate); + } + return *this; + } + ~lru_map() = default; bool empty() const { @@ -71,15 +91,40 @@ class lru_map return; } - kl_.push_front(key); - ki_[key] = kl_.begin(); - - if (kv_.size() > capacity_) + try + { + kl_.push_front(key); + try + { + const auto index = ki_.emplace(key, kl_.begin()).first; + try + { + if (kv_.size() > capacity_) + { + // Resolve both lookups before removing any existing entry. + const auto oldest_value = kv_.find(kl_.back()); + const auto oldest_index = ki_.find(kl_.back()); + kl_.pop_back(); + kv_.erase(oldest_value); + ki_.erase(oldest_index); + } + } + catch (...) + { + ki_.erase(index); + throw; + } + } + catch (...) + { + kl_.pop_front(); + throw; + } + } + catch (...) { - key_type k = kl_.back(); - kl_.pop_back(); - kv_.erase(k); - ki_.erase(k); + kv_.erase(r.first); + throw; } } @@ -117,4 +162,4 @@ class lru_map size_t capacity_; // max capacity }; -} // namespace ark \ No newline at end of file +} // namespace ark diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index 2ed010d68..68e496de4 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -200,6 +200,18 @@ add_custom_target(check-buffer USES_TERMINAL VERBATIM) add_dependencies(check-core check-buffer) +# Allocation injection must not affect other Core runners. +add_executable(ark_lru_map_tests main.cpp lru_maps.cpp) +set_property(TARGET ark_lru_map_tests PROPERTY LINK_LIBRARIES ark::core) +add_executable(ark_lru_map_failure_tests main.cpp lru_map_failure.cpp) +set_property(TARGET ark_lru_map_failure_tests PROPERTY LINK_LIBRARIES ark::core) +add_custom_target(check-lru-map + COMMAND $ + COMMAND $ + DEPENDS ark_lru_map_tests ark_lru_map_failure_tests + USES_TERMINAL VERBATIM) +add_dependencies(check-core check-lru-map) + # Isolate timer allocation hooks and Release-mode assertion fallback checks. add_executable(ark_timer_tests main.cpp timers.cpp) set_property(TARGET ark_timer_tests PROPERTY LINK_LIBRARIES ark::core) diff --git a/tests/core/README.md b/tests/core/README.md index 979ca05bd..eaaee2e5e 100644 --- a/tests/core/README.md +++ b/tests/core/README.md @@ -264,3 +264,9 @@ for the public-layout rebuild requirement and service-validation limits. `check-startup-cleanup` verifies one prepared owning capsule, a private wheel, four-stage failure, one-shot cleanup and emergency exit under persistent allocation failure. Six isolated allocation-failure cases distinguish failures before call entry from failures inside startup; a separate termination control checks the diagnostic and failure exit. Scalar/array throwing and nothrow allocation probes also verify matching deallocation, constructor-failure cleanup and persistent null results. Application builds also run 14 actual-launcher/separate-DSO cases. Shared Utility/IO and business rollback remain outside this scope; see [startup-failure cleanup](../../docs/project/startup-failure-cleanup.md). `check-scalar-stream-allocation` isolates global allocation failure from ordinary tests and visits every allocation in scalar publisher construction, baseline copying, updates, first/replacement baseline reception and delta reception. Each failure preserves the committed state and retained values; retry advances exactly one revision. It is included in `check-entity-stream` and exercises long/binary strings plus all scalar alternatives. + +`check-lru-map`, included in `check-core`, covers independent copy links, duplicate +insertion, capacity/recency behavior after swap, key-copy exceptions and isolated +allocation failures at every insertion and copy-assignment allocation. Failed +operations preserve existing entries, eviction order and retained value owners. +See [LRU cache contracts](../../docs/project/code-style.md#lru-cache-copying-and-insertion). diff --git a/tests/core/README_CN.md b/tests/core/README_CN.md index fe5c72f8e..dd5e821a9 100644 --- a/tests/core/README_CN.md +++ b/tests/core/README_CN.md @@ -203,3 +203,7 @@ XXTEA 用例覆盖精确长度分配的短密钥、16 字节与内嵌零字节 `check-startup-cleanup` 验证一个预登记 owning capsule、私有时间轮、四阶段失败、一次性清理与持续分配故障紧急退出;六种隔离分配故障区分调用前失败与启动内部失败,另设异常终止对照用例检查诊断与失败退出。额外验证单对象及数组的 `new` 和 `new (std::nothrow)` 分配与释放匹配,并覆盖构造异常、持续分配失败返回空指针。启用应用时,还运行真实 launcher/独立测试 DSO 的 14 个场景。它不接入共享 Utility 或 IO,也不证明业务回滚;见[启动失败清理](../../docs/project/startup-failure-cleanup_CN.md)。 `check-scalar-stream-allocation` 独立运行全局分配失败注入,逐个遍历标量发布端构造、基线复制、更新、首次/替换基线接收和增量接收中的分配。失败保留已提交状态与旧拥有值,重试仅推进一次修订;用例包含长/二进制字符串及全部标量类型,已纳入 `check-entity-stream`。 + +`check-lru-map` 纳入 `check-core`,覆盖独立复制链接、重复插入、交换后的容量/ +访问顺序、键复制异常,以及插入和复制赋值各分配点的隔离失败。失败操作保留原有 +条目、淘汰顺序和值的拥有关系。见 [LRU 缓存契约](../../docs/project/code-style_CN.md#lru-缓存复制与插入)。 diff --git a/tests/core/lru_map_failure.cpp b/tests/core/lru_map_failure.cpp new file mode 100644 index 000000000..152a76b64 --- /dev/null +++ b/tests/core/lru_map_failure.cpp @@ -0,0 +1,129 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include + +#include "../network/doctest.h" + +#include + +namespace { + +int allocations_before_failure{-1}; + +} // namespace + +void* operator new(std::size_t size) { + if (allocations_before_failure == 0) + { + allocations_before_failure = -1; + throw std::bad_alloc{}; + } + if (allocations_before_failure > 0) + { + --allocations_before_failure; + } + if (auto* result = std::malloc(size ? size : 1)) + { + return result; + } + throw std::bad_alloc{}; +} + +void operator delete(void* value) noexcept { + std::free(value); +} + +void operator delete(void* value, std::size_t) noexcept { + std::free(value); +} + +TEST_CASE("LRU allocation failure rolls back insertion and releases candidate value owners") { + bool saw_failure{}; + bool saw_success{}; + for (int limit = 0; limit < 64; ++limit) + { + ark::lru_map> values(2); + values.insert(1, std::make_shared(10)); + values.insert(2, std::make_shared(20)); + const auto candidate = std::make_shared(30); + bool caught{}; + allocations_before_failure = limit; + try + { values.insert(3, candidate); } + catch (const std::bad_alloc&) + { caught = true; } + allocations_before_failure = -1; + REQUIRE(values.size() == 2); + if (caught) + { + saw_failure = true; + CHECK(candidate.use_count() == 1); + CHECK(values.find(3) == values.end()); + values.insert(4, std::make_shared(40)); + CHECK(values.find(1) == values.end()); + REQUIRE(values.find(2) != values.end()); + values.erase(2); + CHECK(values.size() == 1); + } + else + { + saw_success = true; + CHECK(candidate.use_count() == 2); + CHECK(values.find(1) == values.end()); + REQUIRE(values.find(3) != values.end()); + break; + } + } + CHECK(saw_failure); + CHECK(saw_success); +} + +TEST_CASE("LRU allocation failure during copy assignment retains both caches and their owners") { + bool saw_failure{}; + bool saw_success{}; + for (int limit = 0; limit < 64; ++limit) + { + const auto source_owner = std::make_shared(10); + ark::lru_map> source(2); + source.insert(1, source_owner); + source.insert(2, std::make_shared(20)); + const auto destination_owner = std::make_shared(90); + ark::lru_map> destination(1); + destination.insert(9, destination_owner); + bool caught{}; + allocations_before_failure = limit; + try + { destination = source; } + catch (const std::bad_alloc&) + { caught = true; } + allocations_before_failure = -1; + REQUIRE(source.size() == 2); + if (caught) + { + saw_failure = true; + REQUIRE(destination.size() == 1); + REQUIRE(destination.find(9) != destination.end()); + CHECK(destination.find(9)->second == destination_owner); + CHECK(destination_owner.use_count() == 2); + CHECK(source_owner.use_count() == 2); + destination.insert(8, std::make_shared(80)); + CHECK(destination.find(9) == destination.end()); + CHECK(destination.size() == 1); + } + else + { + saw_success = true; + CHECK(destination_owner.use_count() == 1); + CHECK(source_owner.use_count() == 3); + source.clear(); + REQUIRE(destination.find(1) != destination.end()); + destination.insert(3, std::make_shared(30)); + CHECK(destination.size() == 2); + CHECK(destination.find(2) == destination.end()); + break; + } + } + CHECK(saw_failure); + CHECK(saw_success); +} diff --git a/tests/core/lru_maps.cpp b/tests/core/lru_maps.cpp new file mode 100644 index 000000000..2c49b0521 --- /dev/null +++ b/tests/core/lru_maps.cpp @@ -0,0 +1,160 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include + +#include "../network/doctest.h" + +#include + +namespace { + +struct checked_key +{ + int value{}; + static inline int copies_before_failure{-1}; + + explicit checked_key(int value) + : value(value) { + } + + checked_key(const checked_key& other) + : value(other.value) { + if (copies_before_failure == 0) + { + throw std::runtime_error("key copy failed"); + } + if (copies_before_failure > 0) + { + --copies_before_failure; + } + } + + checked_key(checked_key&&) = default; + checked_key& operator=(const checked_key&) = default; + bool operator==(const checked_key&) const = default; +}; + +} // namespace + +template<> +struct std::hash +{ + std::size_t operator()(const checked_key& key) const noexcept { + return std::hash{}(key.value); + } +}; + +TEST_CASE("LRU copy rebuilds its own recency links after the source is cleared") { + ark::lru_map source(3); + source.insert(1, 10); + source.insert(2, 20); + source.insert(3, 30); + REQUIRE(source.find(1) != source.end()); + ark::lru_map copied(source); + source.clear(); + REQUIRE(copied.find(2) != copied.end()); + copied.insert(4, 40); + CHECK(copied.size() == 3); + CHECK(copied.find(3) == copied.end()); + REQUIRE(copied.find(1) != copied.end()); + CHECK(copied.find(1)->second == 10); + copied.erase(2); + CHECK(copied.size() == 2); +} + +TEST_CASE("LRU copy assignment replaces entries and preserves independent eviction order") { + ark::lru_map assigned(1); + assigned.insert(9, 90); + { + ark::lru_map source(2); + source.insert(1, 10); + source.insert(2, 20); + assigned = source; + source.erase(1); + } + CHECK(assigned.find(9) == assigned.end()); + REQUIRE(assigned.find(1) != assigned.end()); + assigned.insert(3, 30); + CHECK(assigned.size() == 2); + CHECK(assigned.find(2) == assigned.end()); + auto& self = assigned; + assigned = self; + REQUIRE(assigned.find(1) != assigned.end()); + assigned.insert(4, 40); + CHECK(assigned.size() == 2); + CHECK(assigned.find(3) == assigned.end()); +} + +TEST_CASE("LRU failed insertion preserves recency and can be followed by lookup erase and eviction") { + bool saw_failure{}; + bool saw_success{}; + for (int limit = 0; limit < 12; ++limit) + { + ark::lru_map values(2); + values.insert(checked_key{1}, 10); + values.insert(checked_key{2}, 20); + const checked_key candidate{3}; + checked_key::copies_before_failure = limit; + bool caught{}; + try + { values.insert(candidate, 30); } + catch (const std::runtime_error&) + { caught = true; } + checked_key::copies_before_failure = -1; + if (caught) + { + saw_failure = true; + REQUIRE(values.size() == 2); + CHECK(values.find(candidate) == values.end()); + values.insert(checked_key{4}, 40); + CHECK(values.find(checked_key{1}) == values.end()); + REQUIRE(values.find(checked_key{2}) != values.end()); + values.erase(checked_key{2}); + CHECK(values.size() == 1); + } + else + { + saw_success = true; + REQUIRE(values.size() == 2); + CHECK(values.find(checked_key{1}) == values.end()); + REQUIRE(values.find(candidate) != values.end()); + CHECK(values.find(candidate)->second == 30); + break; + } + } + CHECK(saw_failure); + CHECK(saw_success); +} + +TEST_CASE("LRU duplicate insertion retains the stored value and recency") { + ark::lru_map> values(2); + const auto first = std::make_shared(10); + values.insert(1, first); + values.insert(2, std::make_shared(20)); + const auto rejected = std::make_shared(99); + values.insert(1, rejected); + CHECK(rejected.use_count() == 1); + values.insert(3, std::make_shared(30)); + CHECK(values.find(1) == values.end()); + CHECK(first.use_count() == 1); + CHECK(values.size() == 2); +} + +TEST_CASE("LRU swap keeps recency links with their values and capacities") { + ark::lru_map first(2); + first.insert(1, 10); + first.insert(2, 20); + ark::lru_map second(1); + second.insert(9, 90); + first.swap(second); + first.insert(8, 80); + CHECK(first.size() == 1); + CHECK(first.find(9) == first.end()); + REQUIRE(second.find(1) != second.end()); + second.insert(3, 30); + CHECK(second.size() == 2); + CHECK(second.find(2) == second.end()); + second.erase(1); + CHECK(second.size() == 1); +} From 66674675922a43e7d45ce6e6c550675a14fffb86 Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 4 Nov 2026 15:37:16 +0800 Subject: [PATCH 2/3] fix(container): stage LRU contents after container setup Prepare empty containers before rebuilding values and independent recency links. Keep full insertion allocation injection and sweep copy content allocations from the first source lookup, including allocating value copies and throwing keys. Add allocation-free terminate diagnostics and document the unrecoverable MSVC Debug noexcept proxy-construction boundary in both languages. --- docs/project/code-style.md | 12 ++- docs/project/code-style_CN.md | 9 +- src/core/container/lru_map.hpp | 11 +-- tests/core/lru_map_failure.cpp | 164 ++++++++++++++++++++++++++++++++- tests/core/lru_maps.cpp | 53 +++++++++++ 5 files changed, 235 insertions(+), 14 deletions(-) diff --git a/docs/project/code-style.md b/docs/project/code-style.md index fef1ac6d8..ec92bb1be 100644 --- a/docs/project/code-style.md +++ b/docs/project/code-style.md @@ -191,7 +191,12 @@ Check `it != values.end()` before incrementing or calling `get_key()` / `get_dat cache. Copies retain capacity and recency order, rebuild links into their own key list, and remain usable after the source changes or is destroyed. Mapped values retain their normal copy semantics, including shared ownership for `shared_ptr`. -Copy assignment prepares the replacement before releasing existing entries. +Copying prepares empty container bookkeeping before rebuilding entries and links +from the oldest entry to the newest. Copy assignment prepares the replacement +before releasing existing entries; exceptions that propagate from allocation or +key/value copying leave both caches intact. This does not guarantee recovery from +allocation inside a standard-library `noexcept` path: MSVC Debug, for example, +allocates a hash-table iterator proxy in such a constructor. Insertion of an existing key retains its original value and recency. Insertion failure from allocation or key/value copying propagates the exception and removes @@ -199,7 +204,10 @@ the incomplete entry; existing values and eviction order remain intact. `find()` still promotes a found entry and successful insertion evicts the least recently used entry when full. Public signatures and class fields are unchanged; rebuild header consumers to use the fix. `check-lru-map`, also included in `check-core`, -covers independent copies, eviction and isolated allocation failures. +covers independent copies, eviction, key-copy exceptions and isolated allocation +failures. Insertion injects failure at every allocation. Copying injects at every +content allocation starting with the first source lookup, after empty container +bookkeeping is ready; it does not inject into MSVC's `noexcept` proxy construction. ## POD array storage and failure contract diff --git a/docs/project/code-style_CN.md b/docs/project/code-style_CN.md index bcded551f..d796997ac 100644 --- a/docs/project/code-style_CN.md +++ b/docs/project/code-style_CN.md @@ -174,13 +174,18 @@ clang-tidy 需要已配置构建的编译数据库及兼容的编译器工具链 `` 将值、访问顺序键和内部链接作为一个缓存管理。 复制保留容量和访问顺序,并将链接重建到自己的键列表;源对象改变或销毁后, 复制体仍可使用。值保持原有复制语义,包括 `shared_ptr` 的共享所有权。 -复制赋值先准备替换状态,再释放原有条目。 +复制先准备空容器的内部管理状态,再从最久未使用的条目开始重建值和链接。 +复制赋值先准备替换状态,再释放原有条目;分配或键/值复制向外传播的异常 +不会改变两个缓存。这不保证能从标准库 `noexcept` 路径内的分配失败中恢复; +例如,MSVC Debug 会在此类构造函数中分配哈希表的迭代器代理。 插入已有键时,保留原值和访问顺序。分配或键/值复制失败时,异常向外传播, 不完整的新条目会被移除;原有值和淘汰顺序保持不变。`find()` 仍将命中条目移到 最近使用位置,满容量后的成功插入仍淘汰最久未使用的条目。公开签名和成员布局 保持不变;重新编译头文件使用方即可采用修复。`check-lru-map` 纳入 `check-core`, -覆盖独立复制、淘汰和隔离的分配失败。 +覆盖独立复制、淘汰、键复制异常和隔离的分配失败。插入测试逐个分配注入失败; +复制测试在空容器内部管理状态就绪后,从首次查询源缓存开始逐个内容分配注入, +不向 MSVC 的 `noexcept` 代理构造过程注入失败。 ## POD 数组存储与失败约定 diff --git a/src/core/container/lru_map.hpp b/src/core/container/lru_map.hpp index 2f06ccae9..e693e5a72 100644 --- a/src/core/container/lru_map.hpp +++ b/src/core/container/lru_map.hpp @@ -35,13 +35,12 @@ class lru_map } lru_map(const lru_map& other) - : kv_(other.kv_) - , ki_(other.ki_) - , kl_(other.kl_) - , capacity_(other.capacity_) { - for (auto it = kl_.begin(); it != kl_.end(); ++it) + : lru_map(other.capacity_) { + // Prepare all container bookkeeping before copying entries. Rebuild each + // link in its final list instead of temporarily borrowing source links. + for (auto it = other.kl_.rbegin(); it != other.kl_.rend(); ++it) { - ki_.find(*it)->second = it; + insert(*it, other.kv_.at(*it)); } } diff --git a/tests/core/lru_map_failure.cpp b/tests/core/lru_map_failure.cpp index 152a76b64..e21f5116c 100644 --- a/tests/core/lru_map_failure.cpp +++ b/tests/core/lru_map_failure.cpp @@ -1,7 +1,10 @@ // Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include #include +#include #include #include +#include #include "../network/doctest.h" @@ -10,10 +13,93 @@ namespace { int allocations_before_failure{-1}; +int deferred_allocations_before_failure{-1}; +int failure_limit{-1}; +int allocation_count{}; +const char* phase{"setup"}; + +class allocation_diagnostic final +{ +public: + allocation_diagnostic() + : previous_handler_(std::set_terminate(terminate)) { + active_ = this; + } + + ~allocation_diagnostic() { + active_ = nullptr; + std::set_terminate(previous_handler_); + } + +private: + [[noreturn]] static void terminate() noexcept { + std::fprintf(stderr, "LRU allocation terminate: phase=%s fail_after=%d allocation_count=%d deferred=%d\n", + phase, failure_limit, allocation_count, deferred_allocations_before_failure); + std::fflush(stderr); + if (active_ && active_->previous_handler_) + { + active_->previous_handler_(); + } + std::_Exit(EXIT_FAILURE); + } + + std::terminate_handler previous_handler_; + inline static allocation_diagnostic* active_{}; +}; + +struct allocation_key +{ + int value{}; + + allocation_key(int value) + : value(value) { + } + + bool operator==(const allocation_key&) const = default; +}; + +struct allocating_value +{ + std::shared_ptr owner; + std::unique_ptr payload; + + allocating_value(std::shared_ptr owner) + : owner(std::move(owner)) + , payload(std::make_unique(*this->owner)) { + } + + allocating_value(const allocating_value& other) + : owner(other.owner) + , payload(std::make_unique(*other.payload)) { + } + + allocating_value(allocating_value&&) = default; +}; } // namespace +template<> +struct std::hash +{ + std::size_t operator()(const allocation_key& key) const noexcept { + // Copying first constructs empty standard containers. MSVC Debug's + // _Hash_vec allocator constructor allocates its proxy inside noexcept. + // Arm at the first source lookup, before any entry is allocated, so every + // content allocation is covered without claiming that proxy OOM unwinds. + if (deferred_allocations_before_failure >= 0) + { + allocations_before_failure = deferred_allocations_before_failure; + deferred_allocations_before_failure = -1; + } + return std::hash{}(key.value); + } +}; + void* operator new(std::size_t size) { + if (allocations_before_failure >= 0) + { + ++allocation_count; + } if (allocations_before_failure == 0) { allocations_before_failure = -1; @@ -39,6 +125,7 @@ void operator delete(void* value, std::size_t) noexcept { } TEST_CASE("LRU allocation failure rolls back insertion and releases candidate value owners") { + allocation_diagnostic diagnostic; bool saw_failure{}; bool saw_success{}; for (int limit = 0; limit < 64; ++limit) @@ -48,6 +135,9 @@ TEST_CASE("LRU allocation failure rolls back insertion and releases candidate va values.insert(2, std::make_shared(20)); const auto candidate = std::make_shared(30); bool caught{}; + phase = "insertion"; + failure_limit = limit; + allocation_count = 0; allocations_before_failure = limit; try { values.insert(3, candidate); } @@ -58,6 +148,7 @@ TEST_CASE("LRU allocation failure rolls back insertion and releases candidate va if (caught) { saw_failure = true; + CHECK(allocation_count == limit + 1); CHECK(candidate.use_count() == 1); CHECK(values.find(3) == values.end()); values.insert(4, std::make_shared(40)); @@ -69,6 +160,7 @@ TEST_CASE("LRU allocation failure rolls back insertion and releases candidate va else { saw_success = true; + CHECK(allocation_count == limit); CHECK(candidate.use_count() == 2); CHECK(values.find(1) == values.end()); REQUIRE(values.find(3) != values.end()); @@ -80,44 +172,56 @@ TEST_CASE("LRU allocation failure rolls back insertion and releases candidate va } TEST_CASE("LRU allocation failure during copy assignment retains both caches and their owners") { + allocation_diagnostic diagnostic; bool saw_failure{}; bool saw_success{}; for (int limit = 0; limit < 64; ++limit) { const auto source_owner = std::make_shared(10); - ark::lru_map> source(2); + ark::lru_map source(2); source.insert(1, source_owner); source.insert(2, std::make_shared(20)); const auto destination_owner = std::make_shared(90); - ark::lru_map> destination(1); + ark::lru_map destination(1); destination.insert(9, destination_owner); bool caught{}; - allocations_before_failure = limit; + phase = "copy-assignment-contents"; + failure_limit = limit; + allocation_count = 0; + deferred_allocations_before_failure = limit; try { destination = source; } catch (const std::bad_alloc&) { caught = true; } allocations_before_failure = -1; + REQUIRE(deferred_allocations_before_failure == -1); REQUIRE(source.size() == 2); if (caught) { saw_failure = true; + CHECK(allocation_count == limit + 1); REQUIRE(destination.size() == 1); REQUIRE(destination.find(9) != destination.end()); - CHECK(destination.find(9)->second == destination_owner); + CHECK(destination.find(9)->second.owner == destination_owner); + CHECK(*destination.find(9)->second.payload == 90); CHECK(destination_owner.use_count() == 2); CHECK(source_owner.use_count() == 2); destination.insert(8, std::make_shared(80)); CHECK(destination.find(9) == destination.end()); CHECK(destination.size() == 1); + source.insert(3, std::make_shared(30)); + CHECK(source.find(1) == source.end()); + CHECK(source.size() == 2); } else { saw_success = true; + CHECK(allocation_count == limit); CHECK(destination_owner.use_count() == 1); CHECK(source_owner.use_count() == 3); source.clear(); REQUIRE(destination.find(1) != destination.end()); + CHECK(*destination.find(1)->second.payload == 10); destination.insert(3, std::make_shared(30)); CHECK(destination.size() == 2); CHECK(destination.find(2) == destination.end()); @@ -127,3 +231,55 @@ TEST_CASE("LRU allocation failure during copy assignment retains both caches and CHECK(saw_failure); CHECK(saw_success); } + +TEST_CASE("LRU allocation failure during copy construction releases partial copies and preserves the source") { + allocation_diagnostic diagnostic; + bool saw_failure{}; + bool saw_success{}; + for (int limit = 0; limit < 64; ++limit) + { + const auto source_owner = std::make_shared(10); + ark::lru_map source(2); + source.insert(1, source_owner); + source.insert(2, std::make_shared(20)); + std::optional> copied; + bool caught{}; + phase = "copy-construction-contents"; + failure_limit = limit; + allocation_count = 0; + deferred_allocations_before_failure = limit; + try + { copied.emplace(source); } + catch (const std::bad_alloc&) + { caught = true; } + allocations_before_failure = -1; + REQUIRE(deferred_allocations_before_failure == -1); + REQUIRE(source.size() == 2); + if (caught) + { + saw_failure = true; + CHECK(allocation_count == limit + 1); + CHECK_FALSE(copied.has_value()); + CHECK(source_owner.use_count() == 2); + source.insert(3, std::make_shared(30)); + CHECK(source.find(1) == source.end()); + CHECK(source.size() == 2); + } + else + { + saw_success = true; + CHECK(allocation_count == limit); + REQUIRE(copied.has_value()); + CHECK(source_owner.use_count() == 3); + source.clear(); + REQUIRE(copied->find(1) != copied->end()); + CHECK(*copied->find(1)->second.payload == 10); + copied->insert(3, std::make_shared(30)); + CHECK(copied->size() == 2); + CHECK(copied->find(2) == copied->end()); + break; + } + } + CHECK(saw_failure); + CHECK(saw_success); +} diff --git a/tests/core/lru_maps.cpp b/tests/core/lru_maps.cpp index 2c49b0521..815cf8116 100644 --- a/tests/core/lru_maps.cpp +++ b/tests/core/lru_maps.cpp @@ -127,6 +127,59 @@ TEST_CASE("LRU failed insertion preserves recency and can be followed by lookup CHECK(saw_success); } +TEST_CASE("LRU key copy failure during assignment retains both caches and their eviction order") { + bool saw_failure{}; + bool saw_success{}; + for (int limit = 0; limit < 12; ++limit) + { + const auto source_owner = std::make_shared(10); + ark::lru_map> source(2); + source.insert(checked_key{1}, source_owner); + source.insert(checked_key{2}, std::make_shared(20)); + REQUIRE(source.find(checked_key{1}) != source.end()); + const auto destination_owner = std::make_shared(90); + ark::lru_map> destination(1); + destination.insert(checked_key{9}, destination_owner); + bool caught{}; + checked_key::copies_before_failure = limit; + try + { destination = source; } + catch (const std::runtime_error&) + { caught = true; } + checked_key::copies_before_failure = -1; + REQUIRE(source.size() == 2); + if (caught) + { + saw_failure = true; + REQUIRE(destination.size() == 1); + REQUIRE(destination.find(checked_key{9}) != destination.end()); + CHECK(destination.find(checked_key{9})->second == destination_owner); + CHECK(destination_owner.use_count() == 2); + CHECK(source_owner.use_count() == 2); + destination.insert(checked_key{8}, std::make_shared(80)); + CHECK(destination.find(checked_key{9}) == destination.end()); + CHECK(destination.size() == 1); + source.insert(checked_key{3}, std::make_shared(30)); + CHECK(source.find(checked_key{2}) == source.end()); + REQUIRE(source.find(checked_key{1}) != source.end()); + } + else + { + saw_success = true; + CHECK(destination_owner.use_count() == 1); + CHECK(source_owner.use_count() == 3); + source.clear(); + destination.insert(checked_key{3}, std::make_shared(30)); + CHECK(destination.size() == 2); + CHECK(destination.find(checked_key{2}) == destination.end()); + REQUIRE(destination.find(checked_key{1}) != destination.end()); + break; + } + } + CHECK(saw_failure); + CHECK(saw_success); +} + TEST_CASE("LRU duplicate insertion retains the stored value and recency") { ark::lru_map> values(2); const auto first = std::make_shared(10); From 8a6535bc243d882726cae03912db730764976258 Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 4 Nov 2026 17:40:29 +0800 Subject: [PATCH 3/3] test(mysql): handle transaction expiry before owner delivery --- docs/reference/mysql.md | 7 +- docs/reference/mysql_CN.md | 7 +- tests/database/public_mysql_module.cpp | 193 +++++++++++++++++++++++-- 3 files changed, 192 insertions(+), 15 deletions(-) diff --git a/docs/reference/mysql.md b/docs/reference/mysql.md index 59a3becc6..0403badeb 100644 --- a/docs/reference/mysql.md +++ b/docs/reference/mysql.md @@ -63,7 +63,10 @@ See [build/runtime activation](build-options.md#native-database-plugins) and [ma ```cpp ark::task<> update_player() { auto begun = co_await database_->begin("primary", {std::chrono::seconds(5), std::chrono::seconds(1)}); - if (!begun) { co_return; } + if (!begun || begun.transaction == 0 || + begun.transaction_status.outcome != ark::mysql_transaction_outcome::active) { + co_return; + } const auto id = begun.transaction; std::vector values{std::int64_t{42}}; auto changed = co_await database_->execute(id, "UPDATE players SET score=?", std::move(values)); @@ -79,6 +82,8 @@ ark::task<> update_player() { Transactions hold one native lease. Monotonic IDs belong only to the issuing module instance, owning no implementation object. One operation per transaction; unknown/expired IDs or overlapping steps throw. The original timeout covers acquire, BEGIN, steps and commit without renewal. Unused transactions roll back at deadline; `cleanup_timeout` separately bounds rollback. Failed/uncertain cleanup discards the connection; no automatic retry or cross-connection replay. +Converting `mysql_reply` or `mysql_transaction_reply` to `bool` checks business success. A successful BEGIN can expire and roll back before owner delivery, returning no transaction ID; check both `transaction` and `transaction_status.outcome` before submitting a step. + `mysql_reply` retains the owning `arknet::mysql::query_result` and separate statement-close diagnostics. `mysql_transaction_reply` additionally retains rollback results, initiation failures and the final transaction status. Successful execution alone does not prove cleanup or commit succeeded. Native database/transport failures are returned; local C++ and bridge admission failures propagate as exceptions. Cancellation requests reach the native request and preserve `outcome_unknown` for a potentially executed statement. ## Execution, limits and shutdown diff --git a/docs/reference/mysql_CN.md b/docs/reference/mysql_CN.md index ac6823337..a3932d0fe 100644 --- a/docs/reference/mysql_CN.md +++ b/docs/reference/mysql_CN.md @@ -63,7 +63,10 @@ ark::task<> run() { ```cpp ark::task<> update_player() { auto begun = co_await database_->begin("primary", {std::chrono::seconds(5), std::chrono::seconds(1)}); - if (!begun) { co_return; } + if (!begun || begun.transaction == 0 || + begun.transaction_status.outcome != ark::mysql_transaction_outcome::active) { + co_return; + } const auto id = begun.transaction; std::vector values{std::int64_t{42}}; auto changed = co_await database_->execute(id, "UPDATE players SET score=?", std::move(values)); @@ -79,6 +82,8 @@ ark::task<> update_player() { 事务各步骤持有同一原生租约;递增标识只对签发模块实例有效,不拥有插件实现对象。同一事务仅允许一个操作,未知/过期标识或重叠步骤抛异常。原超时覆盖取连接、BEGIN、各步骤与提交,不续期;未使用事务到期回滚,`cleanup_timeout` 单独限制回滚。清理失败或不确定则丢弃连接,不自动重试或跨连接重放。 +将 `mysql_reply` 或 `mysql_transaction_reply` 转为 `bool` 仅检查业务是否成功。BEGIN 成功后,事务仍可能在所属线程交付结果前到期并回滚,此时不返回事务标识;提交后续步骤前须同时检查 `transaction` 和 `transaction_status.outcome`。 + `mysql_reply` 保留拥有型 `arknet::mysql::query_result` 及独立的预处理语句关闭诊断。`mysql_transaction_reply` 还保留回滚结果、启动异常和事务最终状态。执行成功并不代表清理或提交成功。数据库及传输错误返回原生结果,本地 C++ 异常和协程桥限额异常向调用方传播。取消请求会转交原生操作;语句可能已经执行时保留 `outcome_unknown`。 ## 执行、限额与停机 diff --git a/tests/database/public_mysql_module.cpp b/tests/database/public_mysql_module.cpp index 0931d9d77..66e4517d7 100644 --- a/tests/database/public_mysql_module.cpp +++ b/tests/database/public_mysql_module.cpp @@ -8,6 +8,7 @@ #include #include +#include "mysql/detail/mysql_transaction.hpp" #include "mysql/mysql_plugin.hpp" #include "mysql_test_peer.hpp" @@ -18,6 +19,16 @@ using namespace mysql_test; struct observed_commands { std::atomic queries{}, prepares{}, executes{}, closes{}, commits{}, rollbacks{}; + std::atomic allow_rollback{true}; +}; + +struct rollback_guard +{ + observed_commands& seen; + + ~rollback_guard() { + seen.allow_rollback = true; + } }; asio::awaitable serve(mysql_peer& server, observed_commands& seen, bool tls = false) { @@ -69,6 +80,12 @@ asio::awaitable serve(mysql_peer& server, observed_commands& seen, bool tl else if (sql.starts_with("ROLLBACK")) { ++seen.rollbacks; + asio::steady_timer pause(co_await asio::this_coro::executor); + while (!seen.allow_rollback) + { + pause.expires_after(1ms); + co_await pause.async_wait(asio::use_awaitable); + } active = false; co_await server.send(ok_packet); } @@ -183,16 +200,12 @@ ark::task<> query_and_execute(ark::mysql_module& database, std::vector transact(ark::mysql_module& database, bool commit, bool abandon, ark::mysql_transaction_id& id) { - const auto begun = co_await database.begin("primary", {abandon ? 40ms : 2s, 1s}); +ark::task<> transact(ark::mysql_module& database, bool commit, ark::mysql_transaction_id& id) { + const auto begun = co_await database.begin("primary", {2s, 1s}); REQUIRE(begun); id = begun.transaction; REQUIRE(id != 0); REQUIRE(begun.transaction_status.outcome == ark::mysql_transaction_outcome::active); - if (abandon) - { - co_return; - } const auto query = co_await database.query(id, "UPDATE records SET value=42"); REQUIRE(query); auto result = commit ? co_await database.commit(id) : co_await database.rollback(id); @@ -201,6 +214,10 @@ ark::task<> transact(ark::mysql_module& database, bool commit, bool abandon, ark (commit ? ark::mysql_transaction_outcome::committed : ark::mysql_transaction_outcome::rolled_back)); } +ark::task<> abandon_transaction(ark::mysql_module& database, std::optional& reply) { + reply.emplace(co_await database.begin("primary", {40ms, 1s})); +} + struct queued_query_report { std::optional reply; @@ -267,8 +284,8 @@ TEST_CASE("Public MySQL coroutines own native query results and prepared cleanup } } -TEST_CASE("Public MySQL transactions retain a lease across steps and drain idle rollback") { - for (unsigned scenario = 0; scenario != 3; ++scenario) +TEST_CASE("Public MySQL transactions retain a lease across query and terminal steps") { + for (const bool commit : {false, true}) { observed_commands seen; mysql_peer peer([&seen](mysql_peer& server) { return serve(server, seen); }); @@ -277,19 +294,169 @@ TEST_CASE("Public MySQL transactions retain a lease across steps and drain idle ark::mysql_transaction_id id{}; std::exception_ptr failure; auto& tasks = owner.manager.create_task_group(owner.database); - REQUIRE(tasks.start( - transact(owner.database, scenario == 0, scenario == 2, id), [&](auto error) { failure = error; })); + REQUIRE(tasks.start(transact(owner.database, commit, id), [&](auto error) { failure = error; })); owner.drain(); CHECK_FALSE(failure); - if (scenario == 2) + owner.stop(); + peer.finish(); + CHECK(seen.commits == (commit ? 1u : 0u)); + CHECK(seen.rollbacks == (commit ? 0u : 1u)); + } +} + +TEST_CASE("Public MySQL abandoned transactions drain deadline rollback before shutdown") { + for (const bool hold_rollback : {false, true}) + { + CAPTURE(hold_rollback); + observed_commands seen; + seen.allow_rollback = !hold_rollback; + mysql_peer peer([&seen](mysql_peer& server) { return serve(server, seen); }); + module_owner owner; + owner.configure(peer); + std::optional begun; + std::exception_ptr failure; + + struct drain_guard + { + observed_commands& seen; + module_owner& owner; + + ~drain_guard() { + seen.allow_rollback = true; + owner.stop(); + } + } guard{seen, owner}; + + auto& tasks = owner.manager.create_task_group(owner.database); + REQUIRE(tasks.start(abandon_transaction(owner.database, begun), [&](auto error) { failure = error; })); + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!seen.rollbacks && std::chrono::steady_clock::now() < deadline) { + REQUIRE(owner.manager.update(0)); + std::this_thread::sleep_for(1ms); + } + REQUIRE(seen.rollbacks == 1); + if (hold_rollback) + { + // The peer has received ROLLBACK but cannot complete it yet. CHECK_FALSE(owner.manager.stop(0)); CHECK(owner.manager.get_shutdown_state() == ark::plugin_manager::shutdown_state::stopping); + seen.allow_rollback = true; + } + owner.drain(); + REQUIRE_FALSE(failure); + REQUIRE(begun); + REQUIRE(*begun); + if (begun->transaction) + { + CHECK(begun->transaction_status.outcome == ark::mysql_transaction_outcome::active); + } + else + { + CHECK(begun->transaction_status.outcome == ark::mysql_transaction_outcome::rolled_back); + REQUIRE(begun->rollback); + CHECK(*begun->rollback); } + // Cleanup may already be complete, so the first stop can return true. owner.stop(); peer.finish(); - CHECK(seen.commits == (scenario == 0 ? 1u : 0u)); - CHECK(seen.rollbacks == (scenario == 0 ? 0u : 1u)); + CHECK(seen.commits == 0); + CHECK(seen.rollbacks == 1); + } +} + +TEST_CASE("MySQL owner delivery preserves successful BEGIN results across transaction expiry") { + using namespace ark::detail; + for (const bool expire_before_delivery : {false, true}) + { + CAPTURE(expire_before_delivery); + observed_commands seen; + seen.allow_rollback = false; + mysql_peer peer([&seen](mysql_peer& server) { return serve(server, seen); }); + rollback_guard guard{seen}; + arknet::mysql::pool_options options; + options.connection = options_for(peer); + options.max_connections = 1; + auto pool = std::make_shared(std::move(options)); + auto owner = std::make_shared( + pool, mysql_transaction_limits{1, 1024}, arknet::mysql::query_options{}); + std::optional reply; + unsigned calls{}; + REQUIRE(owner->begin({2s, 1s}, [&](auto value) { + ++calls; + reply.emplace(std::move(value)); + }) != 0); + const auto state = owner->states.front(); + REQUIRE(state); + const auto wait_until = [](auto ready) { + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!ready()) + { + REQUIRE(std::chrono::steady_clock::now() < deadline); + std::this_thread::sleep_for(1ms); + } + }; + wait_until([&] { + owner->update(1); + return state->phase == mysql_transaction_phase::beginning; + }); + // Wait for the native success without advancing its owner. This makes + // expiry-before-delivery independent of OS scheduling and handshake time. + wait_until([&] { + std::lock_guard lock(state->event_mutex); + return state->completed.has_value(); + }); + { + std::lock_guard lock(state->event_mutex); + const auto* begun = std::get_if(&*state->completed); + REQUIRE(begun); + REQUIRE(*begun); + REQUIRE((begun->status_flags & 1u) != 0); + } + if (!expire_before_delivery) + { + state->deadline = std::chrono::steady_clock::time_point::max(); + REQUIRE(owner->update(1) == 1); + REQUIRE(reply); + REQUIRE(*reply); + REQUIRE(reply->transaction); + REQUIRE(reply->transaction_status.outcome == mysql_transaction_outcome::active); + } + owner->stop_accepting(); + CHECK_FALSE(owner->logically_drained()); + state->deadline = std::chrono::steady_clock::now() - 1ms; + CHECK(owner->update(1) == 0); + wait_until([&] { return seen.rollbacks == 1; }); + CHECK(state->phase == mysql_transaction_phase::cleanup); + CHECK_FALSE(owner->logically_drained()); + CHECK(calls == (expire_before_delivery ? 0u : 1u)); + seen.allow_rollback = true; + wait_until([&] { + owner->update(1); + return owner->logically_drained(); + }); + REQUIRE(reply); + REQUIRE(*reply); + CHECK(calls == 1); + if (expire_before_delivery) + { + // bool reports the business result, not a live transaction token. + CHECK_FALSE(reply->transaction); + CHECK(reply->transaction_status.outcome == mysql_transaction_outcome::rolled_back); + REQUIRE(reply->rollback); + CHECK(*reply->rollback); + } + else + { + CHECK(reply->transaction->snapshot().outcome == mysql_transaction_outcome::rolled_back); + } + CHECK_FALSE(state->status.discard_requested); + CHECK(state->status.rollback_code == arknet::mysql::connection_errc::none); + CHECK(seen.commits == 0); + CHECK(seen.rollbacks == 1); + owner->close(); + REQUIRE(pool->close_and_wait()); + peer.finish(); } }