#pragma once #include "channel.hpp" #include "diagnostics.hpp" #include "inode.hpp" #include "port.hpp" #include "traits.hpp" #include #include #include #include #include #include namespace kpn { // ── Lossless single-output delivery ─────────────────────────────────────────── // // Shared by RouterNode and FilterNode, which each deliver a value to exactly one // channel. Both previously did // // try { ch->push(val); } catch (const ChannelOverflowError&) {} // // which discards the value whenever the consumer is behind. 6595e6e made node // outputs lossless, 28e0667 stopped them parking a worker, and a8cfe73 did the // same for FanoutNode — these two were in none of them, and were the last // remaining users of the throwing push() on a data path. // // A dropped item does not degrade a downstream result, it silently changes one. // Worse, a dropped *sentinel* wedges the pipeline outright: EOF is what tells // every downstream node to shut down, and there is nothing after it to retry. // A filter that passes EOF by predicate but drops it by backpressure is a // pipeline that never terminates. // // So sentinels go out-of-band via push_sentinel (a dedicated slot that consumes // no ring capacity and cannot overflow), and everything else is retried until // taken. Like FanoutNode and unlike a pool node, these own a private thread, so // waiting here costs no scheduler worker and needs no space-callback park. // stop_flag_ is rechecked every pass so teardown cannot hang on a full output. // // `parked` receives the time spent waiting, which the caller charges to blocked // rather than exec — a parked node is idle, and charging it to exec reports the // node as busy exactly when it is the one being held up. // // Returns false if stopped with the value undelivered. template bool deliver_one(Channel* ch, T& val, const std::atomic& stop_flag, duration_t& parked) { if (is_sentinel_value(val)) { ch->push_sentinel(std::move(val)); return true; } const auto park_from = clock_t::now(); for (;;) { switch (ch->try_push(val)) { case Channel::PushResult::Taken: parked = duration_t(clock_t::now() - park_from); return true; case Channel::PushResult::Closed: // Nobody is listening any more; the channel has recorded the // drop. Retrying would spin until teardown noticed. parked = duration_t(clock_t::now() - park_from); return false; case Channel::PushResult::Full: break; // fall through to the retry logic } if (stop_flag.load(std::memory_order_relaxed)) { // Teardown with work in hand and the output still full. One last // throwing push, purely so the channel's own stats record the // overflow — the point of the lossless path is that a loss is never // invisible, and a silent return here would reintroduce exactly the // hole this function exists to close. try { ch->push(std::move(val)); } catch (const ChannelOverflowError&) {} parked = duration_t(clock_t::now() - park_from); return false; } std::this_thread::sleep_for(std::chrono::microseconds(50)); } } // ── RouterNode ──────────────────────────────────────────────────────────────── // // Reads one item and pushes it to exactly one of N output channels, chosen by // selector(item). If selector returns >= N the item is silently dropped. // // Usage: // auto router = make_router( // [](const Image& img) -> std::size_t { return img.stream_id % 3; }); // net.connect("src", src.output<0>(), "router", router.input<0>()) // .connect("router", router.output<0>(), "nodeA", nodeA.input<0>()) // .connect("router", router.output<1>(), "nodeB", nodeB.input<0>()) // .connect("router", router.output<2>(), "nodeC", nodeC.input<0>()); template class RouterNode : public INode { public: using Selector = std::function; using args_tuple = std::tuple; using return_tuple = repeat_tuple_t; using return_raw = return_tuple; static constexpr std::size_t input_count = 1; static constexpr std::size_t output_count = N; static constexpr std::size_t unique_tag = Id; static constexpr bool is_router_node = true; explicit RouterNode(Selector sel, std::size_t fifo_capacity = 5) : selector_(std::move(sel)) , fifo_capacity_(fifo_capacity) { input_ch_ = std::make_shared>(fifo_capacity); } ~RouterNode() override { stop(); } // ── INode ───────────────────────────────────────────────────────────────── void start() override { input_ch_->enable(); stop_flag_.store(false, std::memory_order_relaxed); thread_ = std::jthread([this](std::stop_token) { run_loop(); }); } void stop() override { stop_flag_.store(true, std::memory_order_relaxed); input_ch_->disable(); if (thread_.joinable()) thread_.request_stop(), thread_.join(); } bool running() const override { return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed); } void set_name(std::string name) override { name_ = std::move(name); } const NodeStats& stats() const override { return stats_; } NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override { uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed); double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0; double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0; double total_ms = exec_ms + blocked_ms; return {name, frames, exec_ms, stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0, blocked_ms, elapsed_s > 0 ? frames / elapsed_s : 0.0, stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0, total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0}; } // ── Port access ─────────────────────────────────────────────────────────── template InputPort input() { static_assert(I == 0, "RouterNode has exactly one input"); return {*this}; } template OutputPort output() { static_assert(I < N, "RouterNode output index out of range"); return {*this}; } // ── Internal channel accessors (called by Network::connect) ─────────────── template Channel& input_channel() { static_assert(I == 0); return *input_ch_; } template void set_input_channel(std::shared_ptr> ch) { static_assert(I == 0); input_ch_ = std::move(ch); } template void set_output_channel(Channel* ch) { static_assert(I < N); out_channels_[I] = ch; } private: void run_loop() { while (!stop_flag_.load(std::memory_order_relaxed)) { try { auto t0 = clock_t::now(); T val = input_ch_->pop(); auto t1 = clock_t::now(); auto cpu0 = NodeStats::cpu_now(); // An out-of-range selector still drops by design (documented on // the class): the item was routed nowhere, not lost to a full // channel. Only the latter is what deliver_one exists to stop. std::size_t idx = selector_(val); duration_t parked{0}; bool delivered = true; if (idx < N && out_channels_[idx]) delivered = deliver_one(out_channels_[idx], val, stop_flag_, parked); auto cpu1 = NodeStats::cpu_now(); auto t2 = clock_t::now(); stats_.record_exec(duration_t(t2 - t1) - parked, duration_t(t1 - t0) + parked, cpu0, cpu1); if (!delivered) break; } catch (const ChannelClosedError&) { break; } } } std::string name_; std::size_t fifo_capacity_; Selector selector_; std::shared_ptr> input_ch_; std::array*, N> out_channels_{}; std::atomic stop_flag_{false}; std::jthread thread_; NodeStats stats_; }; // ── FilterNode ──────────────────────────────────────────────────────────────── // // Reads one item and pushes it downstream only when pred(item) is true. // Dropped items are not counted as processed frames. // // Usage: // auto filt = make_filter([](const Frame& f) { return f.valid; }); // net.connect("src", src.output<0>(), "filt", filt.input<0>()) // .connect("filt", filt.output<0>(), "dst", dst.input<0>()); template class FilterNode : public INode { public: using Predicate = std::function; using args_tuple = std::tuple; using return_tuple = std::tuple; using return_raw = return_tuple; static constexpr std::size_t input_count = 1; static constexpr std::size_t output_count = 1; static constexpr std::size_t unique_tag = Id; static constexpr bool is_filter_node = true; explicit FilterNode(Predicate pred, std::size_t fifo_capacity = 5) : pred_(std::move(pred)) , fifo_capacity_(fifo_capacity) { input_ch_ = std::make_shared>(fifo_capacity); } ~FilterNode() override { stop(); } // ── INode ───────────────────────────────────────────────────────────────── void start() override { input_ch_->enable(); stop_flag_.store(false, std::memory_order_relaxed); thread_ = std::jthread([this](std::stop_token) { run_loop(); }); } void stop() override { stop_flag_.store(true, std::memory_order_relaxed); input_ch_->disable(); if (thread_.joinable()) thread_.request_stop(), thread_.join(); } bool running() const override { return thread_.joinable() && !stop_flag_.load(std::memory_order_relaxed); } void set_name(std::string name) override { name_ = std::move(name); } const NodeStats& stats() const override { return stats_; } NodeSnapshot node_snapshot(const std::string& name, double elapsed_s) const override { uint64_t frames = stats_.frames_processed.load(std::memory_order_relaxed); double exec_ms = stats_.ema_exec_us.load(std::memory_order_relaxed) / 1000.0; double blocked_ms = stats_.total_blocked_us.load(std::memory_order_relaxed) / 1000.0; double total_ms = exec_ms + blocked_ms; return {name, frames, exec_ms, stats_.max_exec_us.load(std::memory_order_relaxed) / 1000.0, blocked_ms, elapsed_s > 0 ? frames / elapsed_s : 0.0, stats_.total_cpu_us.load(std::memory_order_relaxed) / 1000.0, total_ms > 0 ? 100.0 * exec_ms / total_ms : 0.0}; } // ── Port access ─────────────────────────────────────────────────────────── template InputPort input() { static_assert(I == 0, "FilterNode has exactly one input"); return {*this}; } template OutputPort output() { static_assert(I == 0, "FilterNode has exactly one output"); return {*this}; } // ── Internal channel accessors (called by Network::connect) ─────────────── template Channel& input_channel() { static_assert(I == 0); return *input_ch_; } template void set_input_channel(std::shared_ptr> ch) { static_assert(I == 0); input_ch_ = std::move(ch); } template void set_output_channel(Channel* ch) { static_assert(I == 0); out_ch_ = ch; } private: void run_loop() { while (!stop_flag_.load(std::memory_order_relaxed)) { try { auto t0 = clock_t::now(); T val = input_ch_->pop(); auto t1 = clock_t::now(); auto cpu0 = NodeStats::cpu_now(); // A value the predicate rejects is dropped by design and is not // counted as a processed frame. One it accepts is now delivered // losslessly — including a sentinel, which a filter typically // passes unconditionally so downstream can shut down, and which // the old throwing push discarded whenever the output was full. if (pred_(val) && out_ch_) { duration_t parked{0}; const bool delivered = deliver_one(out_ch_, val, stop_flag_, parked); auto cpu1 = NodeStats::cpu_now(); auto t2 = clock_t::now(); stats_.record_exec(duration_t(t2 - t1) - parked, duration_t(t1 - t0) + parked, cpu0, cpu1); if (!delivered) break; } } catch (const ChannelClosedError&) { break; } } } std::string name_; std::size_t fifo_capacity_; Predicate pred_; std::shared_ptr> input_ch_; Channel* out_ch_{nullptr}; std::atomic stop_flag_{false}; std::jthread thread_; NodeStats stats_; }; // ── Factories ───────────────────────────────────────────────────────────────── template RouterNode make_router(std::function sel, std::size_t capacity = 5) { return RouterNode(std::move(sel), capacity); } template FilterNode make_filter(std::function pred, std::size_t capacity = 5) { return FilterNode(std::move(pred), capacity); } } // namespace kpn