Skip to content

Commit 460f601

Browse files
authored
Merge pull request #314 from ABHIRAMSHIBU/fix/replay-cached-metrics-on-mci-defer
Handler: replay cached metrics when minimal_collect_interval defers a scrape
2 parents a406a8c + bb1e25c commit 460f601

2 files changed

Lines changed: 110 additions & 4 deletions

File tree

mktxp/flow/collector_handler.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from concurrent.futures import ThreadPoolExecutor, as_completed
1515
from timeit import default_timer
1616
from datetime import datetime
17-
from threading import Event, Timer
17+
from threading import Event, Lock, Timer
1818
from mktxp.cli.config.config import config_handler
1919
from mktxp.cli.config.config import MKTXPConfigKeys
2020

@@ -26,6 +26,8 @@ def __init__(self, entries_handler, collector_registry):
2626
self.entries_handler = entries_handler
2727
self.collector_registry = collector_registry
2828
self.last_collect_timestamp = 0
29+
self._metrics_cache = []
30+
self._cache_lock = Lock()
2931

3032

3133
def collect_sync(self):
@@ -118,19 +120,32 @@ def timeout(timeout_event):
118120

119121
def collect(self):
120122
if not self._valid_collect_interval():
123+
# Within minimal_collect_interval: replay the last successful collection
124+
# so concurrent or closely-spaced scrapers do not see an empty registry
125+
# (which would otherwise drop every mktxp_* series for that scrape and
126+
# surface as phantom "down" gaps in dashboards).
127+
with self._cache_lock:
128+
cached = list(self._metrics_cache)
129+
yield from cached
121130
return
122131

123132
# bandwidth collector
124-
yield from self.collector_registry.bandwidthCollector.collect()
133+
collected = list(self.collector_registry.bandwidthCollector.collect())
125134

126135
# all other collectors
127136
# Check whether to run in parallel by looking at the mktxp system configuration
128137
parallel = config_handler.system_entry.fetch_routers_in_parallel
129138
max_worker_threads = config_handler.system_entry.max_worker_threads
130139
if parallel:
131-
yield from self.collect_async(max_worker_threads=max_worker_threads)
140+
collected.extend(self.collect_async(max_worker_threads=max_worker_threads))
132141
else:
133-
yield from self.collect_sync()
142+
collected.extend(self.collect_sync())
143+
144+
if collected:
145+
with self._cache_lock:
146+
self._metrics_cache = collected
147+
148+
yield from collected
134149

135150
def _valid_collect_interval(self):
136151
now = datetime.now().timestamp()

tests/flow/test_collector_handler.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)