diff --git a/docs/project/code-style.md b/docs/project/code-style.md index ed1f2cd75..7ce0b8b68 100644 --- a/docs/project/code-style.md +++ b/docs/project/code-style.md @@ -202,6 +202,30 @@ keys that reject copying, exact raw-pointer destruction and shared/borrowed valu 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`. +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 +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, 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 `` 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 514024093..eedfc8bea 100644 --- a/docs/project/code-style_CN.md +++ b/docs/project/code-style_CN.md @@ -182,6 +182,24 @@ clang-tidy 需要已配置构建的编译数据库及兼容的编译器工具链 递增或调用 `get_key()`/`get_data()` 前,先检查 `it != values.end()`。空范围满足 `begin() == end()`,不要解引用或递增 end。推进一个迭代器不改变已保存的旧位置,但移除对应节点、清空或销毁容器后,不再使用原借用。其余自定义迭代器与 const 迭代接口保持不变。Core 头文件检查实例化标量与指针映射;`check-core` 覆盖后置递增旧值、存储修改、空范围/end 比较,以及现有 `array_map` 查询路径。 +## LRU 缓存复制与插入 + +`` 将值、访问顺序键和内部链接作为一个缓存管理。 +复制保留容量和访问顺序,并将链接重建到自己的键列表;源对象改变或销毁后, +复制体仍可使用。值保持原有复制语义,包括 `shared_ptr` 的共享所有权。 +复制先准备空容器的内部管理状态,再从最久未使用的条目开始重建值和链接。 +复制赋值先准备替换状态,再释放原有条目;分配或键/值复制向外传播的异常 +不会改变两个缓存。这不保证能从标准库 `noexcept` 路径内的分配失败中恢复; +例如,MSVC Debug 会在此类构造函数中分配哈希表的迭代器代理。 + +插入已有键时,保留原值和访问顺序。分配或键/值复制失败时,异常向外传播, +不完整的新条目会被移除;原有值和淘汰顺序保持不变。`find()` 仍将命中条目移到 +最近使用位置,满容量后的成功插入仍淘汰最久未使用的条目。公开签名和成员布局 +保持不变;重新编译头文件使用方即可采用修复。`check-lru-map` 纳入 `check-core`, +覆盖独立复制、淘汰、键复制异常和隔离的分配失败。插入测试逐个分配注入失败; +复制测试在空容器内部管理状态就绪后,从首次查询源缓存开始逐个内容分配注入, +不向 MSVC 的 `noexcept` 代理构造过程注入失败。 + ## POD 数组存储与失败约定 `` 保留 `void` 修改接口及内联/堆存储布局。它用于正内联容量的平凡标量或指针元素,不管理非平凡元素生命周期。`push_back`、带填充值的 `resize` 和 `insert` 会在扩容或移动前保存元素参数,因此 `values[0]` 等别名参数保留原值。扩容仍会使借用的元素指针和引用失效。 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/src/core/container/lru_map.hpp b/src/core/container/lru_map.hpp index 95765e31d..e693e5a72 100644 --- a/src/core/container/lru_map.hpp +++ b/src/core/container/lru_map.hpp @@ -34,6 +34,25 @@ class lru_map capacity_ = capacity > 0 ? capacity : 1024; } + lru_map(const lru_map& other) + : 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) + { + insert(*it, other.kv_.at(*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 +90,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 +161,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 b6cc375d1..b0a78b34a 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -208,6 +208,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 0934683c3..b46cc37cd 100644 --- a/tests/core/README.md +++ b/tests/core/README.md @@ -275,3 +275,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 fd7274c54..60181bdab 100644 --- a/tests/core/README_CN.md +++ b/tests/core/README_CN.md @@ -212,3 +212,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..e21f5116c --- /dev/null +++ b/tests/core/lru_map_failure.cpp @@ -0,0 +1,285 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include +#include +#include +#include + +#include "../network/doctest.h" + +#include + +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; + 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") { + allocation_diagnostic diagnostic; + 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{}; + phase = "insertion"; + failure_limit = limit; + allocation_count = 0; + 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(allocation_count == limit + 1); + 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(allocation_count == limit); + 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") { + 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)); + const auto destination_owner = std::make_shared(90); + ark::lru_map destination(1); + destination.insert(9, destination_owner); + bool caught{}; + 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.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()); + break; + } + } + 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 new file mode 100644 index 000000000..815cf8116 --- /dev/null +++ b/tests/core/lru_maps.cpp @@ -0,0 +1,213 @@ +// 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 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); + 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); +} 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(); } }