queue-aiops 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +107 -0
  3. mcp_server/server.py +38 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/analysis.py +148 -0
  6. mcp_server/tools/overview.py +23 -0
  7. mcp_server/tools/rabbit_reads.py +95 -0
  8. mcp_server/tools/redis_reads.py +98 -0
  9. mcp_server/tools/undo.py +121 -0
  10. mcp_server/tools/writes.py +355 -0
  11. queue_aiops/__init__.py +14 -0
  12. queue_aiops/cli/__init__.py +9 -0
  13. queue_aiops/cli/_common.py +78 -0
  14. queue_aiops/cli/_root.py +62 -0
  15. queue_aiops/cli/analyze.py +85 -0
  16. queue_aiops/cli/doctor.py +21 -0
  17. queue_aiops/cli/init.py +176 -0
  18. queue_aiops/cli/overview.py +16 -0
  19. queue_aiops/cli/rabbit_cmds.py +234 -0
  20. queue_aiops/cli/redis_cmds.py +147 -0
  21. queue_aiops/cli/secret.py +105 -0
  22. queue_aiops/cli/undo.py +62 -0
  23. queue_aiops/config.py +175 -0
  24. queue_aiops/connection.py +277 -0
  25. queue_aiops/doctor.py +98 -0
  26. queue_aiops/governance/__init__.py +40 -0
  27. queue_aiops/governance/audit.py +377 -0
  28. queue_aiops/governance/budget.py +225 -0
  29. queue_aiops/governance/decorators.py +482 -0
  30. queue_aiops/governance/paths.py +23 -0
  31. queue_aiops/governance/patterns.py +378 -0
  32. queue_aiops/governance/policy.py +430 -0
  33. queue_aiops/governance/sanitize.py +45 -0
  34. queue_aiops/governance/undo.py +218 -0
  35. queue_aiops/ops/__init__.py +1 -0
  36. queue_aiops/ops/_util.py +55 -0
  37. queue_aiops/ops/analysis.py +591 -0
  38. queue_aiops/ops/overview.py +90 -0
  39. queue_aiops/ops/rabbit_reads.py +218 -0
  40. queue_aiops/ops/redis_reads.py +237 -0
  41. queue_aiops/ops/writes.py +284 -0
  42. queue_aiops/platform.py +217 -0
  43. queue_aiops/secretstore.py +304 -0
  44. queue_aiops-0.1.0.dist-info/METADATA +168 -0
  45. queue_aiops-0.1.0.dist-info/RECORD +48 -0
  46. queue_aiops-0.1.0.dist-info/WHEEL +4 -0
  47. queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  48. queue_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,55 @@
1
+ """Shared helpers for the queue ops modules.
2
+
3
+ redis (INFO field maps) and rabbitmq (management-API JSON) return the same
4
+ broker concepts under different shapes. The ops modules stay platform-neutral
5
+ by asking the platform for paths/rows (see :mod:`queue_aiops.platform`) and by
6
+ reading fields through :func:`pick` / :func:`num`, which tolerate missing or
7
+ oddly-typed cells. All broker text reaches the caller only after ``sanitize()``
8
+ via ``s``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from queue_aiops.governance import sanitize
16
+
17
+
18
+ def as_obj(data: Any) -> dict:
19
+ """Return ``data`` as a dict (empty dict if it isn't one)."""
20
+ return data if isinstance(data, dict) else {}
21
+
22
+
23
+ def s(value: Any, limit: int = 256) -> str:
24
+ """Sanitize an arbitrary value to a bounded, injection-safe string."""
25
+ return sanitize(str(value if value is not None else ""), limit)
26
+
27
+
28
+ def pick(row: dict, *keys: str, default: Any = None) -> Any:
29
+ """Return the first present, non-None value among ``keys`` (else ``default``)."""
30
+ for key in keys:
31
+ if key in row and row[key] is not None:
32
+ return row[key]
33
+ return default
34
+
35
+
36
+ def num(value: Any) -> float:
37
+ """Coerce a numeric cell to float; 0.0 when absent/non-numeric."""
38
+ try:
39
+ return float(value)
40
+ except (TypeError, ValueError):
41
+ return 0.0
42
+
43
+
44
+ def rate(row: Any, default: float = 0.0) -> float:
45
+ """Read a management-API ``*_details.rate`` cell (``{"rate": 1.2}``)."""
46
+ if isinstance(row, dict):
47
+ return num(row.get("rate", default))
48
+ return default
49
+
50
+
51
+ def pct(part: float, whole: float) -> float:
52
+ """Percentage (1 decimal); 0.0 when the denominator is 0/absent."""
53
+ if not whole:
54
+ return 0.0
55
+ return round(100.0 * part / whole, 1)
@@ -0,0 +1,591 @@
1
+ """Flagship signature analyses over broker telemetry (pure analysis).
2
+
3
+ These are the differentiators — transparent heuristics, every flag reported
4
+ with its numbers so an operator can see *why* something was ranked, never a
5
+ black-box verdict:
6
+
7
+ 1. ``redis_memory_pressure_rca`` — used vs maxmemory, eviction policy +
8
+ evicted-keys pressure, fragmentation ratio, and the SCAN-budgeted big-key
9
+ sample → cause + action.
10
+ 2. ``redis_latency_rca`` — SLOWLOG digested by command pattern, blocked
11
+ clients, and fork/AOF stalls from INFO persistence → cause + action.
12
+ 3. ``rabbitmq_queue_backlog_rca`` — growing queues with zero/slow consumers,
13
+ unacked pileups, and memory/disk watermark blocks → cause per queue +
14
+ action.
15
+ 4. ``connection_churn_analysis`` — both platforms: connection/channel counts
16
+ vs an optional prior snapshot, plus clients grouped by source.
17
+
18
+ All four are pure functions (no I/O): pass them the telemetry (from the reads
19
+ in the other ops modules, or injected) and they return the analysis. The live
20
+ pulls that feed them live in the ``pull_*`` helpers.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ from typing import Any
27
+
28
+ from queue_aiops.ops import rabbit_reads, redis_reads
29
+ from queue_aiops.ops._util import as_obj, num, pct, s
30
+ from queue_aiops.platform import REDIS
31
+
32
+ MAX_ROWS = 100
33
+
34
+ # ── 1. redis memory-pressure RCA ─────────────────────────────────────────────
35
+ DEFAULT_USED_PCT = 85.0 # used/maxmemory % at/above which pressure is flagged
36
+ FRAG_HIGH_RATIO = 1.5 # mem_fragmentation_ratio at/above which frag is flagged
37
+ FRAG_SWAP_RATIO = 0.8 # ratio below which RSS < used ⇒ likely OS swapping
38
+ BIG_KEY_MIN_BYTES = 10 * 1024 * 1024 # 10 MiB — a sampled key this big is flagged
39
+ NOEVICTION = "noeviction"
40
+
41
+
42
+ def pull_memory_telemetry(conn: Any) -> dict:
43
+ """[READ] Live memory + eviction + big-key telemetry for the RCA."""
44
+ stats = as_obj(conn.redis_info("stats"))
45
+ return {
46
+ "memory": redis_reads.memory_stats(conn),
47
+ "evictedKeys": num(stats.get("evicted_keys")),
48
+ "expiredKeys": num(stats.get("expired_keys")),
49
+ "bigKeys": redis_reads.big_key_sample(conn),
50
+ }
51
+
52
+
53
+ def redis_memory_pressure_rca(
54
+ memory: dict,
55
+ evicted_keys: float = 0,
56
+ big_keys: dict | None = None,
57
+ used_pct: float = DEFAULT_USED_PCT,
58
+ ) -> dict:
59
+ """[READ] Diagnose redis memory pressure → cause + action, with numbers.
60
+
61
+ Pure analysis over ``memory`` (the ``memory_stats`` read: usedBytes,
62
+ maxmemoryBytes, usedPctOfMax, maxmemoryPolicy, fragmentationRatio),
63
+ ``evicted_keys`` (INFO stats), and the SCAN-budgeted ``big_keys`` sample.
64
+ Findings: near-limit + noeviction (writes will OOM), active eviction
65
+ (working set larger than maxmemory), fragmentation >= FRAG_HIGH_RATIO,
66
+ ratio < FRAG_SWAP_RATIO (likely swapping), and sampled keys over
67
+ BIG_KEY_MIN_BYTES. Every finding carries its numbers.
68
+ """
69
+ memory = as_obj(memory)
70
+ used_of_max = num(memory.get("usedPctOfMax"))
71
+ policy = s(memory.get("maxmemoryPolicy"), 64)
72
+ frag = num(memory.get("fragmentationRatio"))
73
+ maxmem = num(memory.get("maxmemoryBytes"))
74
+ findings: list[dict] = []
75
+
76
+ if maxmem and used_of_max >= used_pct and policy == NOEVICTION:
77
+ findings.append({
78
+ "cause": f"Memory {used_of_max}% of maxmemory with policy=noeviction — "
79
+ f"writes will start failing with OOM errors",
80
+ "action": "Raise maxmemory, switch to an eviction policy (e.g. "
81
+ "allkeys-lru) if this is a cache, or shrink the dataset (TTLs, "
82
+ "delete the sampled big keys).",
83
+ "evidence": {"usedPctOfMax": used_of_max, "maxmemoryPolicy": policy},
84
+ })
85
+ elif maxmem and used_of_max >= used_pct:
86
+ findings.append({
87
+ "cause": f"Memory {used_of_max}% of maxmemory — eviction pressure imminent",
88
+ "action": "Raise maxmemory or shrink the working set before latency "
89
+ "suffers from constant eviction.",
90
+ "evidence": {"usedPctOfMax": used_of_max, "maxmemoryPolicy": policy},
91
+ })
92
+ if evicted_keys > 0:
93
+ findings.append({
94
+ "cause": f"Keys are being evicted ({int(evicted_keys)} evicted_keys, "
95
+ f"policy={policy}) — the working set no longer fits in maxmemory",
96
+ "action": "Confirm evictions are acceptable for this workload; if "
97
+ "not, raise maxmemory or reduce key sizes/TTLs.",
98
+ "evidence": {"evictedKeys": evicted_keys, "maxmemoryPolicy": policy},
99
+ })
100
+ if frag >= FRAG_HIGH_RATIO:
101
+ findings.append({
102
+ "cause": f"High fragmentation (ratio {frag}) — RSS well above live data",
103
+ "action": "Enable activedefrag, or restart the instance in a "
104
+ "maintenance window to return memory to the OS.",
105
+ "evidence": {"fragmentationRatio": frag},
106
+ })
107
+ if 0 < frag < FRAG_SWAP_RATIO:
108
+ findings.append({
109
+ "cause": f"Fragmentation ratio {frag} < {FRAG_SWAP_RATIO} — RSS below "
110
+ f"used memory, the OS is likely swapping redis pages",
111
+ "action": "Check host swap usage; add RAM or lower maxmemory — a "
112
+ "swapping instance has catastrophic latency.",
113
+ "evidence": {"fragmentationRatio": frag},
114
+ })
115
+
116
+ big = [
117
+ k for k in as_obj(big_keys).get("topKeys", [])
118
+ if num(k.get("bytes")) >= BIG_KEY_MIN_BYTES
119
+ ]
120
+ if big:
121
+ findings.append({
122
+ "cause": f"{len(big)} sampled key(s) over "
123
+ f"{BIG_KEY_MIN_BYTES // (1024 * 1024)} MiB dominate memory",
124
+ "action": "Split or expire the big keys (hash/sharded lists), and "
125
+ "check the producer that grows them.",
126
+ "evidence": {"bigKeys": big[:10]},
127
+ })
128
+
129
+ if not findings:
130
+ findings.append({
131
+ "cause": "Healthy — within thresholds",
132
+ "action": "No action needed.",
133
+ "evidence": {"usedPctOfMax": used_of_max, "fragmentationRatio": frag},
134
+ })
135
+ return {
136
+ "pressure": any(f["cause"] != "Healthy — within thresholds" for f in findings),
137
+ "usedPctOfMax": used_of_max,
138
+ "maxmemoryPolicy": policy,
139
+ "fragmentationRatio": frag,
140
+ "evictedKeys": evicted_keys,
141
+ "thresholds": {
142
+ "usedPct": used_pct,
143
+ "fragHighRatio": FRAG_HIGH_RATIO,
144
+ "fragSwapRatio": FRAG_SWAP_RATIO,
145
+ "bigKeyMinBytes": BIG_KEY_MIN_BYTES,
146
+ },
147
+ "findings": findings[:MAX_ROWS],
148
+ "note": (
149
+ "Advisory read-only heuristic: pressure = near maxmemory / active "
150
+ "eviction / fragmentation out of band / oversized sampled keys. The "
151
+ "big-key sample is SCAN-budgeted and partial — see its coveragePct."
152
+ ),
153
+ }
154
+
155
+
156
+ # ── 2. redis latency / slowlog RCA ───────────────────────────────────────────
157
+ SLOW_US = 10_000 # a slowlog entry >= 10ms is genuinely slow
158
+ FORK_STALL_US = 100_000 # latest_fork_usec >= 100ms stalls the event loop
159
+ # Commands that are O(N)/blocking on big structures — the usual latency killers.
160
+ _HEAVY_COMMANDS = {
161
+ "KEYS", "SMEMBERS", "LRANGE", "HGETALL", "SORT", "SUNION", "SINTER",
162
+ "SDIFF", "ZRANGE", "ZRANGEBYSCORE", "MGET", "MSET", "FLUSHALL", "FLUSHDB",
163
+ "LPOS", "GETALL", "SCARD",
164
+ }
165
+
166
+
167
+ def pull_latency_telemetry(conn: Any) -> dict:
168
+ """[READ] Live slowlog + INFO clients/stats/persistence for the RCA."""
169
+ info_clients = as_obj(conn.redis_info("clients"))
170
+ info_stats = as_obj(conn.redis_info("stats"))
171
+ info_persist = as_obj(conn.redis_info("persistence"))
172
+ return {
173
+ "slowlog": redis_reads.slowlog(conn).get("entries", []),
174
+ "blockedClients": num(info_clients.get("blocked_clients")),
175
+ "latestForkUsec": num(info_stats.get("latest_fork_usec")),
176
+ "aofDelayedFsync": num(info_persist.get("aof_delayed_fsync")),
177
+ "aofRewriteInProgress": num(info_persist.get("aof_rewrite_in_progress")),
178
+ "rdbBgsaveInProgress": num(info_persist.get("rdb_bgsave_in_progress")),
179
+ "loading": num(info_persist.get("loading")),
180
+ }
181
+
182
+
183
+ def _digest_slowlog(entries: list[dict]) -> list[dict]:
184
+ """Group slowlog entries by leading command word; slowest pattern first."""
185
+ by_cmd: dict[str, dict] = {}
186
+ for e in entries or []:
187
+ cmd = s(e.get("command"), 128)
188
+ word = (cmd.split(" ", 1)[0] or "(unknown)").upper()
189
+ bucket = by_cmd.setdefault(
190
+ word, {"command": word, "count": 0, "totalUs": 0.0, "maxUs": 0.0, "sample": cmd}
191
+ )
192
+ dur = num(e.get("durationUs"))
193
+ bucket["count"] += 1
194
+ bucket["totalUs"] += dur
195
+ bucket["maxUs"] = max(bucket["maxUs"], dur)
196
+ digest = []
197
+ for b in by_cmd.values():
198
+ digest.append({
199
+ "command": b["command"],
200
+ "count": b["count"],
201
+ "avgUs": round(b["totalUs"] / b["count"], 1) if b["count"] else 0.0,
202
+ "maxUs": b["maxUs"],
203
+ "sample": b["sample"],
204
+ "heavy": b["command"] in _HEAVY_COMMANDS,
205
+ })
206
+ digest.sort(key=lambda d: d["maxUs"], reverse=True)
207
+ return digest
208
+
209
+
210
+ def redis_latency_rca(telemetry: dict, slow_us: float = SLOW_US) -> dict:
211
+ """[READ] Diagnose redis latency → slowlog digest + stall causes + actions.
212
+
213
+ Pure analysis over ``telemetry`` (from ``pull_latency_telemetry`` or
214
+ injected): {slowlog:[{command, durationUs, ...}], blockedClients,
215
+ latestForkUsec, aofDelayedFsync, aofRewriteInProgress,
216
+ rdbBgsaveInProgress, loading}. Findings: heavy O(N) command patterns in the
217
+ slowlog, blocked clients, fork stalls >= FORK_STALL_US from BGSAVE/AOF
218
+ rewrite, delayed AOF fsyncs (slow disk), and RDB loading. Every finding
219
+ carries its numbers.
220
+ """
221
+ telemetry = as_obj(telemetry)
222
+ digest = _digest_slowlog(list(telemetry.get("slowlog") or []))
223
+ findings: list[dict] = []
224
+
225
+ slow_patterns = [d for d in digest if d["maxUs"] >= slow_us]
226
+ for d in slow_patterns[:10]:
227
+ if d["heavy"]:
228
+ findings.append({
229
+ "cause": f"O(N)/blocking command pattern {d['command']} in the "
230
+ f"slowlog ({d['count']}x, max {int(d['maxUs'])}us)",
231
+ "action": f"Replace {d['command']} with an incremental variant "
232
+ f"(SCAN/HSCAN/SSCAN, paginated ranges) or move it off the hot path.",
233
+ "evidence": d,
234
+ })
235
+ else:
236
+ findings.append({
237
+ "cause": f"Slow command pattern {d['command']} "
238
+ f"({d['count']}x, max {int(d['maxUs'])}us)",
239
+ "action": "Check the size of the structures this command touches "
240
+ "and the concurrent load when it fires.",
241
+ "evidence": d,
242
+ })
243
+
244
+ blocked = num(telemetry.get("blockedClients"))
245
+ if blocked > 0:
246
+ findings.append({
247
+ "cause": f"{int(blocked)} blocked client(s) (BLPOP/BRPOP/WAIT/XREAD "
248
+ f"blocking calls holding connections)",
249
+ "action": "Confirm blocking consumers are intentional; use timeouts "
250
+ "so a stuck consumer cannot hold a connection forever.",
251
+ "evidence": {"blockedClients": blocked},
252
+ })
253
+ fork_us = num(telemetry.get("latestForkUsec"))
254
+ if fork_us >= FORK_STALL_US:
255
+ findings.append({
256
+ "cause": f"Fork stall — latest fork took {int(fork_us)}us "
257
+ f"(BGSAVE / AOF rewrite forking a large dataset pauses the event loop)",
258
+ "action": "Schedule persistence off-peak, reduce dataset size per "
259
+ "instance, or disable transparent hugepages on the host.",
260
+ "evidence": {"latestForkUsec": fork_us},
261
+ })
262
+ delayed = num(telemetry.get("aofDelayedFsync"))
263
+ if delayed > 0:
264
+ findings.append({
265
+ "cause": f"{int(delayed)} delayed AOF fsync(s) — the disk cannot keep "
266
+ f"up with appendfsync",
267
+ "action": "Move the AOF to faster storage or relax appendfsync to "
268
+ "everysec; check for disk contention from co-located services.",
269
+ "evidence": {"aofDelayedFsync": delayed},
270
+ })
271
+ if num(telemetry.get("rdbBgsaveInProgress")) or num(telemetry.get("aofRewriteInProgress")):
272
+ findings.append({
273
+ "cause": "A background persistence job (BGSAVE / AOF rewrite) is "
274
+ "running right now — copy-on-write doubles memory pressure and "
275
+ "competes for I/O",
276
+ "action": "Re-measure latency after it finishes; if this recurs at "
277
+ "peak, reschedule persistence.",
278
+ "evidence": {
279
+ "rdbBgsaveInProgress": num(telemetry.get("rdbBgsaveInProgress")),
280
+ "aofRewriteInProgress": num(telemetry.get("aofRewriteInProgress")),
281
+ },
282
+ })
283
+ if num(telemetry.get("loading")):
284
+ findings.append({
285
+ "cause": "Instance is loading a dataset from disk (restart/replica "
286
+ "sync) — commands are delayed until loading completes",
287
+ "action": "Wait for loading to finish; check replicationlink stability "
288
+ "if this replica keeps re-syncing.",
289
+ "evidence": {"loading": 1},
290
+ })
291
+
292
+ if not findings:
293
+ findings.append({
294
+ "cause": "Healthy — no slowlog pattern over threshold and no stall signals",
295
+ "action": "No action needed.",
296
+ "evidence": {"slowlogPatterns": len(digest)},
297
+ })
298
+ return {
299
+ "slowlogPatterns": digest[:MAX_ROWS],
300
+ "patternsOverThreshold": len(slow_patterns),
301
+ "thresholds": {"slowUs": slow_us, "forkStallUs": FORK_STALL_US},
302
+ "findings": findings[:MAX_ROWS],
303
+ "note": (
304
+ "Advisory read-only heuristic: slowlog grouped by leading command "
305
+ "word; 'heavy' marks O(N)/blocking commands. Fork/AOF signals come "
306
+ "from INFO stats/persistence."
307
+ ),
308
+ }
309
+
310
+
311
+ # ── 3. rabbitmq queue-backlog RCA ────────────────────────────────────────────
312
+ BACKLOG_MIN_MESSAGES = 1_000 # a queue this deep is worth a finding
313
+ UNACKED_PCT_HIGH = 50.0 # unacked >= this % of messages ⇒ ack problem
314
+ RATE_DEFICIT_FACTOR = 1.2 # publish > deliver * factor ⇒ consumers falling behind
315
+
316
+
317
+ def pull_backlog_telemetry(conn: Any, vhost: str | None = None) -> dict:
318
+ """[READ] Live queues + node alarms for the backlog RCA."""
319
+ return {
320
+ "queues": rabbit_reads.list_queues(conn, vhost).get("queues", []),
321
+ "nodes": rabbit_reads.node_health(conn).get("nodes", []),
322
+ }
323
+
324
+
325
+ def _classify_queue(q: dict) -> dict | None:
326
+ messages = num(q.get("messages"))
327
+ consumers = num(q.get("consumers"))
328
+ unacked = num(q.get("messagesUnacked"))
329
+ publish = num(q.get("publishRate"))
330
+ deliver = num(q.get("deliverRate"))
331
+ if messages < BACKLOG_MIN_MESSAGES:
332
+ return None
333
+ if consumers == 0:
334
+ return {
335
+ "cause": f"{int(messages)} messages and no consumers attached",
336
+ "action": "Start (or fix the crash loop of) the consumer service; "
337
+ "verify the queue's binding is still what producers publish to.",
338
+ }
339
+ if messages and pct(unacked, messages) >= UNACKED_PCT_HIGH:
340
+ return {
341
+ "cause": f"{int(unacked)} of {int(messages)} messages unacknowledged "
342
+ f"({pct(unacked, messages)}%) — consumers hold messages without acking",
343
+ "action": "Check consumer errors/timeouts and prefetch size; a "
344
+ "requeue loop or a stuck handler pins messages as unacked.",
345
+ }
346
+ if publish > deliver * RATE_DEFICIT_FACTOR:
347
+ return {
348
+ "cause": f"Publish rate {publish}/s outpaces deliver rate {deliver}/s "
349
+ f"with {int(consumers)} consumer(s)",
350
+ "action": "Scale out consumers or raise prefetch; if the backlog is "
351
+ "expected batch traffic, confirm it drains off-peak.",
352
+ }
353
+ return {
354
+ "cause": f"Deep queue ({int(messages)} messages) with consumers keeping "
355
+ f"pace — likely a residual backlog",
356
+ "action": "Watch the trend; if it is not draining, treat as a slow-consumer case.",
357
+ }
358
+
359
+
360
+ def rabbitmq_queue_backlog_rca(
361
+ queues: list[dict],
362
+ nodes: list[dict] | None = None,
363
+ top: int = 20,
364
+ ) -> dict:
365
+ """[READ] Rank backlogged queues and map each to a cause + action.
366
+
367
+ Pure analysis over ``queues`` rows (from ``list_queues`` or injected):
368
+ {name, vhost, messages, messagesReady, messagesUnacked, consumers,
369
+ publishRate, deliverRate, state} plus optional ``nodes`` (node_health rows)
370
+ for memory/disk watermark alarms. Per-queue causes: no consumers, unacked
371
+ pileup, consumers slower than publishers, or residual backlog; a node
372
+ memory/disk alarm is reported globally (it blocks *all* publishers via flow
373
+ control). Every finding carries its numbers.
374
+ """
375
+ global_findings: list[dict] = []
376
+ for n in nodes or []:
377
+ if n.get("memAlarm"):
378
+ global_findings.append({
379
+ "cause": f"Node {s(n.get('name'))} memory alarm — the memory "
380
+ f"high watermark is reached and ALL publishers are blocked",
381
+ "action": "Drain/purge the deepest queues or raise the "
382
+ "watermark/RAM; publishing resumes when usage drops.",
383
+ "evidence": {
384
+ "memUsedBytes": num(n.get("memUsedBytes")),
385
+ "memLimitBytes": num(n.get("memLimitBytes")),
386
+ },
387
+ })
388
+ if n.get("diskAlarm"):
389
+ global_findings.append({
390
+ "cause": f"Node {s(n.get('name'))} disk alarm — free disk under "
391
+ f"the limit, publishers are blocked",
392
+ "action": "Free disk space (old logs, unused vhosts) or lower "
393
+ "disk_free_limit deliberately.",
394
+ "evidence": {
395
+ "diskFreeBytes": num(n.get("diskFreeBytes")),
396
+ "diskFreeLimitBytes": num(n.get("diskFreeLimitBytes")),
397
+ },
398
+ })
399
+
400
+ flagged = []
401
+ for q in queues or []:
402
+ verdict = _classify_queue(q)
403
+ if verdict is None:
404
+ continue
405
+ flagged.append({
406
+ "name": s(q.get("name"), 128),
407
+ "vhost": s(q.get("vhost"), 64),
408
+ "messages": num(q.get("messages")),
409
+ "messagesReady": num(q.get("messagesReady")),
410
+ "messagesUnacked": num(q.get("messagesUnacked")),
411
+ "consumers": num(q.get("consumers")),
412
+ "publishRate": num(q.get("publishRate")),
413
+ "deliverRate": num(q.get("deliverRate")),
414
+ "state": s(q.get("state"), 32),
415
+ **verdict,
416
+ })
417
+ flagged.sort(key=lambda e: e["messages"], reverse=True)
418
+
419
+ return {
420
+ "queuesEvaluated": len(queues or []),
421
+ "backloggedCount": len(flagged),
422
+ "globalFindings": global_findings,
423
+ "queues": flagged[: max(1, int(top))],
424
+ "thresholds": {
425
+ "backlogMinMessages": BACKLOG_MIN_MESSAGES,
426
+ "unackedPctHigh": UNACKED_PCT_HIGH,
427
+ "rateDeficitFactor": RATE_DEFICIT_FACTOR,
428
+ },
429
+ "note": (
430
+ "Advisory read-only heuristic: queues over backlogMinMessages are "
431
+ "classified (no consumers / unacked pileup / rate deficit / "
432
+ "residual); node memory/disk alarms are global — they block every "
433
+ "publisher via flow control."
434
+ ),
435
+ }
436
+
437
+
438
+ # ── 4. connection-churn analysis (both platforms) ────────────────────────────
439
+ CHURN_RATE_HIGH = 1.0 # rabbitmq connection created+closed rate (per s)
440
+ CHANNELS_PER_CONN_HIGH = 20.0 # a leak smell: many channels per connection
441
+ REDIS_CONN_RATE_HIGH = 5.0 # new connections per second sustained
442
+
443
+
444
+ def pull_churn_snapshot(conn: Any) -> dict:
445
+ """[READ] Live connection/channel snapshot for the churn analysis."""
446
+ if conn.target.platform == REDIS:
447
+ info = as_obj(conn.redis_info())
448
+ clients = redis_reads.list_clients(conn)
449
+ return {
450
+ "platform": REDIS,
451
+ "capturedAt": time.time(),
452
+ "connectedClients": num(info.get("connected_clients")),
453
+ "totalConnectionsReceived": num(info.get("total_connections_received")),
454
+ "rejectedConnections": num(info.get("rejected_connections")),
455
+ "uptimeSeconds": num(info.get("uptime_in_seconds")),
456
+ "bySource": clients.get("bySource", []),
457
+ }
458
+ ov = rabbit_reads.broker_overview(conn)
459
+ conns = rabbit_reads.list_connections(conn)
460
+ return {
461
+ "platform": conn.target.platform,
462
+ "capturedAt": time.time(),
463
+ "connections": num(ov.get("connections")),
464
+ "channels": num(ov.get("channels")),
465
+ "churn": as_obj(ov.get("connectionChurn")),
466
+ "bySource": conns.get("byPeerHost", []),
467
+ }
468
+
469
+
470
+ def _redis_churn(snapshot: dict, history: dict | None) -> tuple[list[dict], dict]:
471
+ total = num(snapshot.get("totalConnectionsReceived"))
472
+ uptime = num(snapshot.get("uptimeSeconds"))
473
+ connected = num(snapshot.get("connectedClients"))
474
+ rejected = num(snapshot.get("rejectedConnections"))
475
+ findings: list[dict] = []
476
+
477
+ rate_per_s = round(total / uptime, 2) if uptime else 0.0
478
+ if history:
479
+ dt = num(snapshot.get("capturedAt")) - num(history.get("capturedAt"))
480
+ d_total = total - num(history.get("totalConnectionsReceived"))
481
+ if dt > 0 and d_total >= 0:
482
+ rate_per_s = round(d_total / dt, 2)
483
+ if rate_per_s >= REDIS_CONN_RATE_HIGH:
484
+ findings.append({
485
+ "cause": f"High connection churn — {rate_per_s} new connections/s "
486
+ f"against {int(connected)} steady clients (clients reconnect per "
487
+ f"operation instead of pooling)",
488
+ "action": "Enable client-side connection pooling / persistent "
489
+ "connections in the busiest source apps below.",
490
+ "evidence": {"newConnectionsPerSec": rate_per_s, "connectedClients": connected},
491
+ })
492
+ if rejected > 0:
493
+ findings.append({
494
+ "cause": f"{int(rejected)} rejected connection(s) — maxclients was hit",
495
+ "action": "Raise maxclients (and the OS fd limit) or fix the leak/"
496
+ "churn that exhausts client slots.",
497
+ "evidence": {"rejectedConnections": rejected},
498
+ })
499
+ metrics = {
500
+ "connectedClients": connected,
501
+ "newConnectionsPerSec": rate_per_s,
502
+ "rejectedConnections": rejected,
503
+ }
504
+ return findings, metrics
505
+
506
+
507
+ def _rabbitmq_churn(snapshot: dict, history: dict | None) -> tuple[list[dict], dict]:
508
+ churn = as_obj(snapshot.get("churn"))
509
+ created = num(churn.get("createdRate"))
510
+ closed = num(churn.get("closedRate"))
511
+ connections = num(snapshot.get("connections"))
512
+ channels = num(snapshot.get("channels"))
513
+ findings: list[dict] = []
514
+
515
+ if created >= CHURN_RATE_HIGH and closed >= CHURN_RATE_HIGH:
516
+ findings.append({
517
+ "cause": f"Connection churn — {created}/s created and {closed}/s "
518
+ f"closed (apps open a connection per message instead of reusing one)",
519
+ "action": "Switch the busiest source apps below to one long-lived "
520
+ "connection with channels per thread.",
521
+ "evidence": {"createdRate": created, "closedRate": closed},
522
+ })
523
+ if connections and channels / connections >= CHANNELS_PER_CONN_HIGH:
524
+ findings.append({
525
+ "cause": f"{int(channels)} channels over {int(connections)} "
526
+ f"connections ({round(channels / connections, 1)} per connection) — "
527
+ f"a channel leak smell",
528
+ "action": "Audit consumers for channels opened per operation and "
529
+ "never closed.",
530
+ "evidence": {"channels": channels, "connections": connections},
531
+ })
532
+ if history:
533
+ d_conn = connections - num(history.get("connections"))
534
+ if d_conn > 0:
535
+ findings.append({
536
+ "cause": f"Connection count grew by {int(d_conn)} since the prior "
537
+ f"snapshot ({int(num(history.get('connections')))} → {int(connections)})",
538
+ "action": "If growth is monotonic, an app is leaking connections "
539
+ "— match the growth to a source below.",
540
+ "evidence": {"delta": d_conn},
541
+ })
542
+ metrics = {
543
+ "connections": connections,
544
+ "channels": channels,
545
+ "createdRate": created,
546
+ "closedRate": closed,
547
+ }
548
+ return findings, metrics
549
+
550
+
551
+ def connection_churn_analysis(snapshot: dict, history: dict | None = None) -> dict:
552
+ """[READ] Connection/channel churn → cause + action, both platforms.
553
+
554
+ Pure analysis over a ``snapshot`` (from ``pull_churn_snapshot`` or
555
+ injected; carries ``platform``) and an optional prior snapshot for deltas.
556
+ redis: new-connections rate (INFO total_connections_received over uptime,
557
+ or the delta between snapshots), rejected connections (maxclients).
558
+ rabbitmq: overview churn_rates, channels-per-connection ratio, and
559
+ connection growth vs history. Both report clients grouped by source so a
560
+ finding can be pinned to an app. Every finding carries its numbers.
561
+ """
562
+ snapshot = as_obj(snapshot)
563
+ platform = s(snapshot.get("platform"), 32)
564
+ if platform == REDIS:
565
+ findings, metrics = _redis_churn(snapshot, history)
566
+ else:
567
+ findings, metrics = _rabbitmq_churn(snapshot, history)
568
+
569
+ if not findings:
570
+ findings.append({
571
+ "cause": "Healthy — connection churn within thresholds",
572
+ "action": "No action needed.",
573
+ "evidence": metrics,
574
+ })
575
+ return {
576
+ "platform": platform,
577
+ "metrics": metrics,
578
+ "bySource": list(snapshot.get("bySource") or [])[:50],
579
+ "comparedToHistory": bool(history),
580
+ "thresholds": {
581
+ "churnRateHigh": CHURN_RATE_HIGH,
582
+ "channelsPerConnHigh": CHANNELS_PER_CONN_HIGH,
583
+ "redisConnRateHigh": REDIS_CONN_RATE_HIGH,
584
+ },
585
+ "findings": findings[:MAX_ROWS],
586
+ "note": (
587
+ "Advisory read-only heuristic: churn compared against fixed "
588
+ "thresholds, or against the prior snapshot when one is passed; "
589
+ "bySource pins churn to client hosts."
590
+ ),
591
+ }