Skip to content

Commit 057e83e

Browse files
pablomhclaude
andcommitted
feat(cluster_read): Batch Grafana queries via look-ahead in iterator
Reduce Grafana HTTP round-trips by batching consecutive targets into a single render request. Plugins opt in by implementing measure_many() and batch_size; the iterator groups and delegates automatically. On batch failure, falls back to individual measure() calls with per-target @Retry, preserving the original resilience. - Targets sent as POST body (data=) to avoid URI length limits - requests.Session() reuses connections across requests - --grafana-chunk-size default raised to 50 (configurable) - NoDataException restored for Prometheus plugin 28s → 9s per node in production (49 requests → 1). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7b674f3 commit 057e83e

4 files changed

Lines changed: 395 additions & 61 deletions

File tree

core/opl/cluster_read.py

Lines changed: 164 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -191,69 +191,144 @@ def add_args(parser):
191191

192192

193193
class GrafanaMeasurementsPlugin(BasePlugin):
194+
def __init__(self, args):
195+
super().__init__(args)
196+
self._session = requests.Session()
197+
198+
@property
199+
def batch_size(self):
200+
return getattr(self.args, "grafana_chunk_size", 50)
201+
202+
@staticmethod
203+
def _empty_timerange(ri):
204+
return (
205+
ri.start is None
206+
or ri.end is None
207+
or int(ri.start.timestamp()) == int(ri.end.timestamp())
208+
)
209+
194210
def _sanitize_target(self, target):
195211
target = target.replace("$Node", self.args.grafana_node)
196212
target = target.replace("$Interface", self.args.grafana_interface)
197213
target = target.replace("$Cloud", self.args.grafana_prefix)
198214
return target
199215

200-
@retry.retry_on_traceback(max_attempts=10, wait_seconds=1)
201-
def measure(
202-
self,
203-
ri,
204-
name,
205-
grafana_target,
206-
grafana_enritchment={},
207-
grafana_include_vars=False,
208-
):
209-
assert (
210-
ri.start is not None and ri.end is not None
211-
), "We need timerange to approach Grafana"
212-
if ri.start.strftime("%s") == ri.end.strftime("%s"):
213-
return name, None
214-
215-
# Metadata for the request
216-
headers = {
217-
"Accept": "application/json, text/plain, */*",
218-
}
216+
def _fetch_targets(self, ri, targets):
217+
"""Fetch one or more targets in a single Graphite render request."""
218+
headers = {"Accept": "application/json, text/plain, */*"}
219219
if self.args.grafana_token is not None:
220220
headers["Authorization"] = "Bearer %s" % self.args.grafana_token
221221
params = {
222-
"target": [self._sanitize_target(grafana_target)],
222+
"target": targets,
223223
"from": int(ri.start.timestamp()),
224224
"until": round(ri.end.timestamp()),
225225
"format": "json",
226226
}
227-
url = f"{self.args.grafana_host}:{self.args.grafana_port}/api/datasources/proxy/{self.args.grafana_datasource}/render"
228-
227+
url = (
228+
f"{self.args.grafana_host}:{self.args.grafana_port}"
229+
f"/api/datasources/proxy/{self.args.grafana_datasource}/render"
230+
)
229231
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
230-
r = requests.post(
231-
url=url, headers=headers, params=params, timeout=60, verify=False
232+
r = self._session.post(
233+
url=url, headers=headers, data=params, timeout=60, verify=False
232234
)
233235
if (
234236
not r.ok
235237
or r.headers["Content-Type"] != "application/json"
236238
or r.json() == []
237239
):
238240
_debug_response(r)
239-
logging.debug("Response: %s" % r.json())
241+
return r.json()
240242

241-
points = [float(i[0]) for i in r.json()[0]["datapoints"] if i[0] is not None]
242-
stats = data.data_stats(points)
243+
@retry.retry_on_traceback(max_attempts=10, wait_seconds=1)
244+
def measure(self, ri, name, grafana_target, grafana_enritchment={}, grafana_include_vars=False):
245+
assert (
246+
ri.start is not None and ri.end is not None
247+
), "We need timerange to approach Grafana"
248+
if self._empty_timerange(ri):
249+
return name, None
243250

244-
# Add user defined data to the computed stats
245-
if grafana_enritchment is not None and grafana_enritchment != {}:
246-
stats["enritchment"] = grafana_enritchment
251+
response = self._fetch_targets(ri, [self._sanitize_target(grafana_target)])
252+
logging.debug("Response: %s" % response)
247253

248-
# If requested, add info about what variables were used to the computed stats
249-
if grafana_include_vars:
254+
points = [float(i[0]) for i in response[0]["datapoints"] if i[0] is not None]
255+
item = {
256+
"grafana_enritchment": grafana_enritchment,
257+
"grafana_include_vars": grafana_include_vars,
258+
}
259+
stats = self._apply_item_extras(data.data_stats(points), item)
260+
261+
return name, stats
262+
263+
def _apply_item_extras(self, stats, item):
264+
"""Apply per-item enritchment and variables to computed stats."""
265+
if stats is None:
266+
return stats
267+
268+
enritchment = item.get("grafana_enritchment", {})
269+
if enritchment:
270+
stats["enritchment"] = enritchment
271+
272+
if item.get("grafana_include_vars", False):
250273
stats["variables"] = {
251274
"$Node": self.args.grafana_node,
252275
"$Interface": self.args.grafana_interface,
253276
"$Cloud": self.args.grafana_prefix,
254277
}
255278

256-
return name, stats
279+
return stats
280+
281+
def _measure_batched(self, ri, config_items):
282+
"""Fetch multiple Grafana items in a single HTTP request.
283+
284+
We rely on Graphite preserving request order here.
285+
A malformed response that silently omits an interior target
286+
could cause positional mismatches for later items.
287+
"""
288+
targets = [self._sanitize_target(item["grafana_target"]) for item in config_items]
289+
response = self._fetch_targets(ri, targets)
290+
291+
results = []
292+
for idx, item in enumerate(config_items):
293+
if idx < len(response):
294+
points = [
295+
float(p[0]) for p in response[idx]["datapoints"] if p[0] is not None
296+
]
297+
stats = self._apply_item_extras(data.data_stats(points), item)
298+
else:
299+
stats = self._apply_item_extras(data.data_stats([]), item)
300+
results.append((item["name"], stats))
301+
302+
logging.debug(f"Batched {len(results)} Grafana targets in one request")
303+
return results
304+
305+
def measure_many(self, ri, config_items):
306+
"""Execute a group of Grafana items with batch-first, per-target-fallback.
307+
308+
Tries a single batched HTTP request for all items. On failure,
309+
degrades to individual measure() calls (each with its own retry)
310+
to isolate failures per target.
311+
"""
312+
if self._empty_timerange(ri):
313+
return [(item["name"], None) for item in config_items]
314+
315+
try:
316+
return self._measure_batched(ri, config_items)
317+
except Exception as e:
318+
logging.warning(
319+
f"Batch request failed ({e}), falling back to "
320+
f"individual queries for {len(config_items)} targets"
321+
)
322+
results = []
323+
for item in config_items:
324+
try:
325+
results.append(self.measure(ri, **item))
326+
except Exception as e2:
327+
logging.exception(
328+
f"Failed to measure {item['name']}: {e2}"
329+
)
330+
results.append((None, None))
331+
return results
257332

258333
@staticmethod
259334
def add_args(parser):
@@ -263,8 +338,8 @@ def add_args(parser):
263338
parser.add_argument(
264339
"--grafana-chunk-size",
265340
type=int,
266-
default=10,
267-
help="How many metrices to obtain from Grafana at one request",
341+
default=50,
342+
help="How many metrics to obtain from Grafana in one request",
268343
)
269344
parser.add_argument(
270345
"--grafana-port",
@@ -540,6 +615,7 @@ def __init__(
540615
self.sd = sd
541616

542617
self._index = 0 # which config item are we processing?
618+
self._batch_buffer = [] # buffered results from a batched request
543619
self._token = None # OCP token - we will take it from `oc whoami -t` if needed
544620
self.measurement_plugins = (
545621
{}
@@ -569,34 +645,63 @@ def _find_plugin(self, keys):
569645

570646
def __next__(self):
571647
"""
572-
Gives tuple of key and value for every item in the config file
648+
Gives tuple of key and value for every item in the config file.
649+
650+
Plugins that implement measure_many() get consecutive items of
651+
the same type grouped and executed in a single call. Results are
652+
buffered and yielded one at a time.
573653
"""
654+
# Drain buffer from a previous batch
655+
if self._batch_buffer:
656+
return self._batch_buffer.pop(0)
657+
574658
i = self._index
659+
if i >= len(self.config):
660+
raise StopIteration
661+
662+
instance = self._find_plugin(self.config[i].keys())
663+
if not instance:
664+
self._index += 1
665+
raise Exception(f"Unknown config '{self.config[i]}'")
666+
667+
# Group consecutive items handled by the same plugin if it
668+
# supports measure_many()
669+
if hasattr(instance, "measure_many") and self.start and self.end:
670+
chunk_size = getattr(instance, "batch_size", 10)
671+
chunk_items = []
672+
j = i
673+
while (
674+
j < len(self.config)
675+
and self._find_plugin(self.config[j].keys()) is instance
676+
and len(chunk_items) < chunk_size
677+
):
678+
chunk_items.append(self.config[j])
679+
j += 1
680+
self._index = j
681+
682+
results = instance.measure_many(self, chunk_items)
683+
self._batch_buffer = results[1:]
684+
return results[0]
685+
686+
# Non-Grafana item: process individually (unchanged)
575687
self._index += 1
576-
if i < len(self.config):
577-
if self._find_plugin(self.config[i].keys()):
578-
instance = self._find_plugin(self.config[i].keys())
579-
name = list(self.config[i].keys())[1]
580-
try:
581-
if name == "log_source_command":
582-
output = instance.measure(self, self.config[i])
583-
else:
584-
output = instance.measure(self, **self.config[i])
585-
except NoDataException as e:
586-
logging.warning(
587-
f"Failed to measure {self.config[i]['name']}: {e}"
588-
)
589-
output = (None, None)
590-
except Exception as e:
591-
logging.exception(
592-
f"Failed to measure {self.config[i]['name']}: {e}"
593-
)
594-
output = (None, None)
595-
return output
688+
name = list(self.config[i].keys())[1]
689+
try:
690+
if name == "log_source_command":
691+
output = instance.measure(self, self.config[i])
596692
else:
597-
raise Exception(f"Unknown config '{self.config[i]}'")
598-
else:
599-
raise StopIteration
693+
output = instance.measure(self, **self.config[i])
694+
except NoDataException as e:
695+
logging.warning(
696+
f"Failed to measure {self.config[i]['name']}: {e}"
697+
)
698+
output = (None, None)
699+
except Exception as e:
700+
logging.exception(
701+
f"Failed to measure {self.config[i]['name']}: {e}"
702+
)
703+
output = (None, None)
704+
return output
600705

601706

602707
def doit(args):

core/opl/status_data.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import os.path
88
import pprint
9+
import re
910
import tempfile
1011

1112
import deepdiff
@@ -322,7 +323,12 @@ def doit_set(status_data, set_this):
322323
logging.warning("Got empty key=value pair to set - ignoring it")
323324
continue
324325

325-
key, value = item.split("=")
326+
if not re.match(r"^[^=]+=.*$", item):
327+
msg = f"Invalid format for --set argument: '{item}'. Expected format: 'key=value'"
328+
logging.error(msg)
329+
raise ValueError(msg)
330+
331+
key, value = item.split("=", 1)
326332

327333
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
328334
value = value[1:-1]
@@ -353,7 +359,12 @@ def doit_set_subtree_json(status_data, set_this):
353359
logging.warning("Got empty key=value pair to set - ignoring it")
354360
continue
355361

356-
key, value = item.split("=")
362+
if not re.match(r"^[^=]+=.*$", item):
363+
msg = f"Invalid format for --set argument: '{item}'. Expected format: 'key=value' with non-empty key"
364+
logging.error(msg)
365+
raise ValueError(msg)
366+
367+
key, value = item.split("=", 1)
357368

358369
logging.debug(f"Setting {key} = {value} (JSON file)")
359370
status_data.set_subtree_json(key, value)

0 commit comments

Comments
 (0)