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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +107 -0
- mcp_server/server.py +38 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/analysis.py +148 -0
- mcp_server/tools/overview.py +23 -0
- mcp_server/tools/rabbit_reads.py +95 -0
- mcp_server/tools/redis_reads.py +98 -0
- mcp_server/tools/undo.py +121 -0
- mcp_server/tools/writes.py +355 -0
- queue_aiops/__init__.py +14 -0
- queue_aiops/cli/__init__.py +9 -0
- queue_aiops/cli/_common.py +78 -0
- queue_aiops/cli/_root.py +62 -0
- queue_aiops/cli/analyze.py +85 -0
- queue_aiops/cli/doctor.py +21 -0
- queue_aiops/cli/init.py +176 -0
- queue_aiops/cli/overview.py +16 -0
- queue_aiops/cli/rabbit_cmds.py +234 -0
- queue_aiops/cli/redis_cmds.py +147 -0
- queue_aiops/cli/secret.py +105 -0
- queue_aiops/cli/undo.py +62 -0
- queue_aiops/config.py +175 -0
- queue_aiops/connection.py +277 -0
- queue_aiops/doctor.py +98 -0
- queue_aiops/governance/__init__.py +40 -0
- queue_aiops/governance/audit.py +377 -0
- queue_aiops/governance/budget.py +225 -0
- queue_aiops/governance/decorators.py +482 -0
- queue_aiops/governance/paths.py +23 -0
- queue_aiops/governance/patterns.py +378 -0
- queue_aiops/governance/policy.py +430 -0
- queue_aiops/governance/sanitize.py +45 -0
- queue_aiops/governance/undo.py +218 -0
- queue_aiops/ops/__init__.py +1 -0
- queue_aiops/ops/_util.py +55 -0
- queue_aiops/ops/analysis.py +591 -0
- queue_aiops/ops/overview.py +90 -0
- queue_aiops/ops/rabbit_reads.py +218 -0
- queue_aiops/ops/redis_reads.py +237 -0
- queue_aiops/ops/writes.py +284 -0
- queue_aiops/platform.py +217 -0
- queue_aiops/secretstore.py +304 -0
- queue_aiops-0.1.0.dist-info/METADATA +168 -0
- queue_aiops-0.1.0.dist-info/RECORD +48 -0
- queue_aiops-0.1.0.dist-info/WHEEL +4 -0
- queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- queue_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
queue_aiops/ops/_util.py
ADDED
|
@@ -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
|
+
}
|