#include "serif/utils/dependencies/dependencies.hpp" #include "serif/utils/dependencies/timing.hpp" #include "serif/utils/error/errors.hpp" #include #include #include #include #include #include #include namespace { namespace dependencies = serif::utils::dependencies; using Outcome = dependencies::NodeOutcome; struct Source {}; struct Offset {}; struct Shared {}; struct Total {}; struct Unrequested {}; /// Simulate exceptional application failures through the library's error entry point. void fail_observed_work() { serif::utils::errors::serif_error("observed work failed"); } } TEST_CASE("Graph observers follow nested node scopes and declared input order only during execution", "[unit][utils][dependencies][observers]") { int source = 2; int offset = 3; std::vector events; events.reserve(128); const auto context = dependencies::make_dependency_context(dependencies::borrow(source), dependencies::borrow(offset)); auto graph = dependencies::make_computation_graph( dependencies::make_computation>([&events](int shift, int value) { events.push_back(70); return value + shift; }), dependencies::make_computation>([&events](int value) { events.push_back(30); return 2 * value; }), dependencies::make_computation>([&events](int value) { events.push_back(99); return value; }) ); auto observers = dependencies::make_observers( dependencies::observe_node([&events] { events.push_back(10); return std::make_unique(40); }, [&events](std::unique_ptr& state, Outcome outcome) noexcept { events.push_back(*state); CHECK(outcome == Outcome::completed); }), dependencies::observe_node([&events] { events.push_back(11); return 41; }, [&events](int& state, Outcome outcome) noexcept { events.push_back(state); CHECK(outcome == Outcome::completed); }), dependencies::observe_node([&events] { events.push_back(50); return 80; }, [&events](int& state, Outcome outcome) noexcept { events.push_back(state); CHECK(outcome == Outcome::completed); }), dependencies::observe_edge([&events](const int&) { events.push_back(61); }), dependencies::observe_edge([&events](const int&) { events.push_back(60); }), dependencies::observe_edge([&events](const int&) { events.push_back(20); }), dependencies::observe_edge([&events](const int&) { events.push_back(21); }) ); auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80}); CHECK(cache.get() == 7); const auto first = cache.share(); events.clear(); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{})); CHECK(events.empty()); source = 4; REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40}); CHECK_FALSE(cache.contains()); CHECK(*first == 4); events.clear(); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); CHECK(events == std::vector{50, 60, 61, 70, 80}); CHECK(cache.get() == 11); events.clear(); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{}, dependencies::PreparationMode::rebuild)); CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80}); events.clear(); cache.invalidate(); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40}); } TEST_CASE("Node outcomes describe attempted computation while cache publication remains transactional", "[unit][utils][dependencies][observers]") { int source = 2; std::vector> outcomes; outcomes.reserve(32); const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); auto graph = dependencies::make_computation_graph( dependencies::make_computation>([](int value) { return value; }), dependencies::make_computation>([](int value) -> std::expected { if (value == -2) { fail_observed_work(); } if (value < 0) { return std::unexpected("negative input"); } return 2 * value; }), dependencies::make_computation>([](int value) { return value + 1; }) ); auto observers = dependencies::make_observers( dependencies::observe_node([] { return 1; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }), dependencies::observe_node([] { return 2; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }), dependencies::observe_node([] { return 3; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }) ); auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); REQUIRE(cache.try_prepare(context)); const auto accepted = cache.share(); CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::completed}}); outcomes.clear(); SECTION("A typed rejection completes earlier nodes but does not publish them") { source = -1; const auto rejected = cache.try_prepare(context); REQUIRE_FALSE(rejected); CHECK(rejected.error().error == "negative input"); CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::rejected}}); } SECTION("An escaping exception closes the active node and propagates") { source = -2; CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error); CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::exception}}); } CHECK_FALSE(cache.ready()); CHECK_FALSE(cache.contains()); CHECK(*accepted == 2); outcomes.clear(); source = 2; const auto recovered = cache.try_prepare(context); REQUIRE(recovered); CHECK(recovered->reused_count() == 3); CHECK(cache.share() == accepted); CHECK(outcomes.empty()); } TEST_CASE("Observer exceptions close only scopes whose before callback completed", "[unit][utils][dependencies][observers]") { bool fail_before = false; int computations = 0; std::vector events; events.reserve(16); const auto context = dependencies::make_dependency_context(dependencies::own(2)); auto graph = dependencies::make_computation_graph( dependencies::make_computation>([&computations](int value) { ++computations; return value; }) ); auto observers = dependencies::make_observers( dependencies::observe_node([&events] { events.push_back(1); return std::make_unique(-1); }, [&events](std::unique_ptr& state, Outcome outcome) noexcept { events.push_back(*state); CHECK(outcome == Outcome::exception); }), dependencies::observe_node([&events, &fail_before] { events.push_back(2); if (fail_before) { fail_observed_work(); } return -2; }, [&events](int& state, Outcome outcome) noexcept { events.push_back(state); CHECK(outcome == Outcome::exception); }), dependencies::observe_edge([&events](const int&) { events.push_back(3); fail_observed_work(); }) ); auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); SECTION("A failed before callback has no matching after callback") { fail_before = true; CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error); CHECK(events == std::vector{1, 2, -1}); } SECTION("An edge failure unwinds every entered node scope") { CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error); CHECK(events == std::vector{1, 2, 3, -2, -1}); } CHECK(computations == 0); CHECK_FALSE(cache.ready()); } TEST_CASE("Observers follow moved caches and read owning edges without copying move-only results", "[unit][utils][dependencies][observers]") { using Value = std::unique_ptr; using Inputs = dependencies::InputList; using Snapshot = std::shared_ptr; int source = 4; const Value* observed = nullptr; std::vector> outcomes; outcomes.reserve(16); const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); auto graph = dependencies::make_computation_graph( dependencies::make_computation([](int value) -> std::expected { if (value < 0) { return std::unexpected("negative input"); } return std::make_unique(value); }), dependencies::make_owning_computation>([](Snapshot value) { return value; }) ); auto observers = dependencies::make_observers( dependencies::observe_node([calls = std::make_unique(0)] { return ++*calls; }, [&outcomes](int& invocation, Outcome outcome) noexcept { outcomes.emplace_back(invocation, outcome); }), dependencies::observe_edge([&observed](const Value& value) { observed = &value; }) ); auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); REQUIRE(cache.try_prepare(context)); const Snapshot first = cache.share(); CHECK(observed == first.get()); CHECK(cache.get() == first); REQUIRE(cache.try_prepare(context)); CHECK(outcomes == std::vector>{{1, Outcome::completed}}); auto moved = std::move(cache); CHECK_FALSE(cache.ready()); source = 9; REQUIRE(moved.try_prepare(context)); const Snapshot accepted = moved.share(); CHECK(observed == accepted.get()); CHECK(**first == 4); CHECK(**accepted == 9); source = -1; REQUIRE_FALSE(moved.try_prepare(context)); CHECK(observed == accepted.get()); // The owning consumer and its edge never ran for the rejected candidate. source = 9; REQUIRE(moved.try_prepare(context)); CHECK(moved.share() == accepted); REQUIRE(moved.try_prepare(context, dependencies::RequestedResults{}, dependencies::PreparationMode::rebuild)); CHECK(observed == moved.share().get()); CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::rejected}, {4, Outcome::completed}}); } TEST_CASE("A generic node observer composes with selected scopes without observing cache hits", "[unit][utils][dependencies][observers][timing]") { int source = 3; std::vector events; events.reserve(32); const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); auto graph = dependencies::make_computation_graph( dependencies::make_computation>([](int value) { return value * 2; }), dependencies::make_computation>([](int value) { return value + 1; })); auto observers = dependencies::make_observers( dependencies::observe_nodes([&events](std::type_identity) { const int identity = std::same_as ? 1 : 2; events.push_back(identity); return identity; }, [&events](int& identity, Outcome outcome) noexcept { CHECK(outcome == Outcome::completed); events.push_back(-identity); }), dependencies::observe_node([&events] { events.push_back(3); return 3; }, [&events](int& identity, Outcome) noexcept { events.push_back(-identity); })); auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); REQUIRE(cache.try_prepare(context)); CHECK(events == std::vector{1, 3, -3, -1, 2, -2}); CHECK(cache.get() == 7); events.clear(); REQUIRE(cache.try_prepare(context)); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{})); CHECK(events.empty()); } TEST_CASE("Graph timing counts attempted work and preserves rollback with a deterministic clock", "[unit][utils][dependencies][observers][timing]") { using TimePoint = std::chrono::steady_clock::time_point; TimePoint now{}; int clock_reads = 0; int source = 2; const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); auto graph = dependencies::make_computation_graph( dependencies::make_computation>([&now](int value) -> std::expected { now += std::chrono::seconds{3}; if (value == -2) { fail_observed_work(); } if (value < 0) { return std::unexpected("negative input"); } return 2 * value; }), dependencies::make_computation>([&now](int value) { now += std::chrono::seconds{2}; return value; }), dependencies::make_computation>([&now](int value) { now += std::chrono::seconds{7}; return value; })); dependencies::GraphTiming timing; const auto factory = dependencies::time_graph_nodes(timing, [&]() noexcept { ++clock_reads; return now; }); auto observers = factory(std::type_identity{}); auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); REQUIRE(timing.runs().size() == 1); const auto& nodes = timing.runs()[0]->nodes; REQUIRE(nodes.size() == 3); CHECK(nodes[0].index == 0); CHECK(nodes[1].index == 1); CHECK(nodes[0].key_name != nodes[1].key_name); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); const auto accepted = cache.share(); CHECK(nodes[0].seconds == 3.0); CHECK(nodes[1].seconds == 2.0); CHECK(nodes[0].calls == 1); CHECK(nodes[0].completed == 1); CHECK(nodes[2].calls == 0); REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); CHECK(clock_reads == 4); source = -1; const auto rejected = cache.try_prepare(context, dependencies::RequestedResults{}); REQUIRE_FALSE(rejected); CHECK(rejected.error().computation_index == 0); CHECK(nodes[0].rejected == 1); CHECK(nodes[1].completed == 2); CHECK(*accepted == 2); source = 2; REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); CHECK(clock_reads == 8); CHECK(cache.share() == accepted); auto moved = std::move(cache); source = -2; CHECK_THROWS_AS(moved.try_prepare(context, dependencies::RequestedResults{}), std::runtime_error); CHECK(nodes[0].exceptions == 1); CHECK(nodes[0].calls == 3); CHECK(nodes[0].seconds == 9.0); CHECK(nodes[0].maximum_seconds == 3.0); CHECK(nodes[1].completed == 3); CHECK(nodes[2].calls == 0); CHECK(clock_reads == 12); source = 2; REQUIRE(moved.try_prepare(context, dependencies::RequestedResults{})); CHECK(clock_reads == 12); REQUIRE(moved.try_prepare(context, dependencies::RequestedResults{})); CHECK(nodes[2].calls == 1); CHECK(nodes[2].seconds == 7.0); CHECK(clock_reads == 14); } TEST_CASE("Timing observers own their records after the collector is released", "[unit][utils][dependencies][observers][timing]") { const auto context = dependencies::make_dependency_context(); auto graph = dependencies::make_computation_graph(dependencies::make_computation>([] { return 7; })); std::shared_ptr run; auto observers = [&] { dependencies::GraphTiming timing; auto result = dependencies::time_graph_nodes(timing)(std::type_identity{}); run = timing.runs()[0]; return result; }(); auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); REQUIRE(cache.try_prepare(context)); CHECK(cache.get() == 7); CHECK(run->nodes[0].calls == 1); CHECK(run->nodes[0].completed == 1); CHECK(run->nodes[0].seconds >= 0.0); }