@@ -59,3 +59,94 @@ def test_collect_sync_lifecycle(is_ready, is_done_called, mock_entries_handler,
5959 mock_router_entry .is_done .assert_called_once ()
6060 else :
6161 mock_router_entry .is_done .assert_not_called ()
62+
63+
64+
65+ class _StubMetric :
66+ """Identifiable placeholder for a yielded metric family."""
67+ def __init__ (self , label ):
68+ self .label = label
69+ def __eq__ (self , other ):
70+ return isinstance (other , _StubMetric ) and other .label == self .label
71+ def __hash__ (self ):
72+ return hash ((type (self ), self .label ))
73+ def __repr__ (self ):
74+ return f'_StubMetric({ self .label !r} )'
75+
76+
77+ def _system_entry_stub (mci ):
78+ """Build a stand-in for config_handler.system_entry that the handler reads."""
79+ return type ('SysEntry' , (), {
80+ 'minimal_collect_interval' : mci ,
81+ 'fetch_routers_in_parallel' : False ,
82+ 'max_worker_threads' : 1 ,
83+ 'verbose_mode' : False ,
84+ })()
85+
86+
87+ def _make_handler (per_call_metrics ):
88+ from mktxp .flow .collector_handler import CollectorHandler
89+
90+ router_entry = MagicMock ()
91+ router_entry .is_ready .return_value = True
92+ router_entry .time_spent = {}
93+
94+ entries_handler = MagicMock ()
95+ entries_handler .router_entries = [router_entry ]
96+
97+ registry = MagicMock ()
98+ registry .bandwidthCollector .collect .return_value = []
99+
100+ iterator = iter (per_call_metrics )
101+ def collect_func (_entry ):
102+ try :
103+ yield from next (iterator )
104+ except StopIteration :
105+ return
106+
107+ registry .registered_collectors = {'mock_collector' : collect_func }
108+ return CollectorHandler (entries_handler , registry )
109+
110+
111+ def test_collect_caches_and_replays_on_mci_defer (monkeypatch ):
112+ """When MCI defers, the handler must yield the cached metric families
113+ instead of an empty registry — otherwise Prometheus drops every mktxp_*
114+ series for that scrape and dashboards show false-down."""
115+ from mktxp .flow import collector_handler as ch_mod
116+
117+ fresh = [_StubMetric ('a' ), _StubMetric ('b' )]
118+ handler = _make_handler ([fresh ])
119+
120+ fake_cfg = MagicMock ()
121+ fake_cfg .system_entry = _system_entry_stub (mci = 5 )
122+ monkeypatch .setattr (ch_mod , 'config_handler' , fake_cfg )
123+
124+ first = list (handler .collect ())
125+ assert first == fresh , 'first scrape should yield freshly collected metrics'
126+
127+ # Second call lands well within MCI; the inner collector iterator is
128+ # exhausted, so without the cache the result would be empty.
129+ second = list (handler .collect ())
130+ assert second == fresh , 'within-MCI scrape must replay the cached metrics'
131+
132+
133+ def test_empty_collection_does_not_clobber_cache (monkeypatch ):
134+ """If a fresh collection produces nothing (e.g., all routers not_ready),
135+ the cache must be preserved so the next within-MCI scrape still has data."""
136+ from mktxp .flow import collector_handler as ch_mod
137+
138+ seed = [_StubMetric ('seed' )]
139+ handler = _make_handler ([seed , []])
140+
141+ fake_cfg = MagicMock ()
142+ fake_cfg .system_entry = _system_entry_stub (mci = 0 )
143+ monkeypatch .setattr (ch_mod , 'config_handler' , fake_cfg )
144+
145+ assert list (handler .collect ()) == seed
146+
147+ # Force a successful but empty collection (MCI=0 → always runs fresh).
148+ assert list (handler .collect ()) == []
149+
150+ # Bump MCI so the next call defers; cache should still hold the seed.
151+ fake_cfg .system_entry = _system_entry_stub (mci = 60 )
152+ assert list (handler .collect ()) == seed
0 commit comments