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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""One-shot broker overview (read-only).
|
|
2
|
+
|
|
3
|
+
A single call an operator can lead with, platform-dispatched: for a redis
|
|
4
|
+
target — version, memory posture, clients, ops/sec, hit rate; for a rabbitmq
|
|
5
|
+
target — version, queue/message totals, connection/channel counts, rates.
|
|
6
|
+
Resilient — a failing sub-call degrades to a partial summary with an
|
|
7
|
+
``errors`` list.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from queue_aiops.ops import rabbit_reads, redis_reads
|
|
15
|
+
from queue_aiops.platform import REDIS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def queue_overview(conn: Any) -> dict:
|
|
19
|
+
"""[READ] Summary: platform + version + backlog/memory/client health."""
|
|
20
|
+
if conn.target.platform == REDIS:
|
|
21
|
+
return _redis_overview(conn)
|
|
22
|
+
return _rabbitmq_overview(conn)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _redis_overview(conn: Any) -> dict:
|
|
26
|
+
errors: list[str] = []
|
|
27
|
+
|
|
28
|
+
info = redis_reads.server_info(conn)
|
|
29
|
+
if "error" in info:
|
|
30
|
+
errors.append(f"server: {info['error']}")
|
|
31
|
+
info = {}
|
|
32
|
+
mem = redis_reads.memory_stats(conn)
|
|
33
|
+
if "error" in mem:
|
|
34
|
+
errors.append(f"memory: {mem['error']}")
|
|
35
|
+
mem = {}
|
|
36
|
+
keys = redis_reads.keyspace(conn)
|
|
37
|
+
if "error" in keys:
|
|
38
|
+
errors.append(f"keyspace: {keys['error']}")
|
|
39
|
+
keys = {}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"platform": conn.target.platform,
|
|
43
|
+
"target": conn.target.name,
|
|
44
|
+
"version": info.get("version"),
|
|
45
|
+
"role": info.get("role"),
|
|
46
|
+
"uptimeSeconds": info.get("uptimeSeconds"),
|
|
47
|
+
"connectedClients": info.get("connectedClients"),
|
|
48
|
+
"blockedClients": info.get("blockedClients"),
|
|
49
|
+
"opsPerSec": info.get("opsPerSec"),
|
|
50
|
+
"hitRatePct": info.get("hitRatePct"),
|
|
51
|
+
"usedMemoryBytes": mem.get("usedBytes"),
|
|
52
|
+
"maxmemoryBytes": mem.get("maxmemoryBytes"),
|
|
53
|
+
"usedPctOfMax": mem.get("usedPctOfMax"),
|
|
54
|
+
"maxmemoryPolicy": mem.get("maxmemoryPolicy"),
|
|
55
|
+
"fragmentationRatio": mem.get("fragmentationRatio"),
|
|
56
|
+
"totalKeys": keys.get("totalKeys"),
|
|
57
|
+
"errors": errors,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _rabbitmq_overview(conn: Any) -> dict:
|
|
62
|
+
errors: list[str] = []
|
|
63
|
+
|
|
64
|
+
ov = rabbit_reads.broker_overview(conn)
|
|
65
|
+
if "error" in ov:
|
|
66
|
+
errors.append(f"overview: {ov['error']}")
|
|
67
|
+
ov = {}
|
|
68
|
+
nodes = rabbit_reads.node_health(conn)
|
|
69
|
+
node_list = nodes.get("nodes", []) if "error" not in nodes else []
|
|
70
|
+
if "error" in nodes:
|
|
71
|
+
errors.append(f"nodes: {nodes['error']}")
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"platform": conn.target.platform,
|
|
75
|
+
"target": conn.target.name,
|
|
76
|
+
"version": ov.get("version"),
|
|
77
|
+
"clusterName": ov.get("clusterName"),
|
|
78
|
+
"queues": ov.get("queues"),
|
|
79
|
+
"messagesReady": ov.get("messagesReady"),
|
|
80
|
+
"messagesUnacked": ov.get("messagesUnacked"),
|
|
81
|
+
"connections": ov.get("connections"),
|
|
82
|
+
"channels": ov.get("channels"),
|
|
83
|
+
"consumers": ov.get("consumers"),
|
|
84
|
+
"publishRate": ov.get("publishRate"),
|
|
85
|
+
"deliverRate": ov.get("deliverRate"),
|
|
86
|
+
"nodesTotal": len(node_list),
|
|
87
|
+
"nodesRunning": sum(1 for n in node_list if n.get("running")),
|
|
88
|
+
"nodeAlarms": nodes.get("alarms") if "error" not in nodes else None,
|
|
89
|
+
"errors": errors,
|
|
90
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""rabbitmq reads — overview, queues, connections, channels, policies, nodes.
|
|
2
|
+
|
|
3
|
+
The day-to-day "is this broker healthy?" surface for rabbitmq targets, all via
|
|
4
|
+
the management HTTP API. Every call is resilient — a transport/parse failure
|
|
5
|
+
surfaces as ``{"error": ...}`` instead of raising — and all broker text is
|
|
6
|
+
sanitised via ``s`` / the platform normaliser.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from queue_aiops.ops._util import as_obj, num, pick, rate, s
|
|
14
|
+
|
|
15
|
+
MAX_ROWS = 200
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def broker_overview(conn: Any) -> dict:
|
|
19
|
+
"""[READ] Broker identity + totals from /api/overview."""
|
|
20
|
+
try:
|
|
21
|
+
obj = as_obj(conn.platform.normalise(conn.get(conn.platform.path("overview"))))
|
|
22
|
+
totals = as_obj(obj.get("queue_totals"))
|
|
23
|
+
objects = as_obj(obj.get("object_totals"))
|
|
24
|
+
stats = as_obj(obj.get("message_stats"))
|
|
25
|
+
churn = as_obj(obj.get("churn_rates"))
|
|
26
|
+
return {
|
|
27
|
+
"platform": conn.target.platform,
|
|
28
|
+
"version": s(pick(obj, "rabbitmq_version", "product_version")),
|
|
29
|
+
"clusterName": s(obj.get("cluster_name")),
|
|
30
|
+
"node": s(obj.get("node")),
|
|
31
|
+
"messagesReady": num(totals.get("messages_ready")),
|
|
32
|
+
"messagesUnacked": num(totals.get("messages_unacknowledged")),
|
|
33
|
+
"messagesTotal": num(totals.get("messages")),
|
|
34
|
+
"queues": num(objects.get("queues")),
|
|
35
|
+
"connections": num(objects.get("connections")),
|
|
36
|
+
"channels": num(objects.get("channels")),
|
|
37
|
+
"consumers": num(objects.get("consumers")),
|
|
38
|
+
"publishRate": rate(stats.get("publish_details")),
|
|
39
|
+
"deliverRate": rate(stats.get("deliver_get_details")),
|
|
40
|
+
"ackRate": rate(stats.get("ack_details")),
|
|
41
|
+
"connectionChurn": {
|
|
42
|
+
"createdRate": rate(churn.get("connection_created_details")),
|
|
43
|
+
"closedRate": rate(churn.get("connection_closed_details")),
|
|
44
|
+
"channelCreatedRate": rate(churn.get("channel_created_details")),
|
|
45
|
+
"channelClosedRate": rate(churn.get("channel_closed_details")),
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
49
|
+
return {"error": s(exc, 200)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _queue_row(q: dict) -> dict:
|
|
53
|
+
stats = as_obj(q.get("message_stats"))
|
|
54
|
+
return {
|
|
55
|
+
"name": s(q.get("name"), 128),
|
|
56
|
+
"vhost": s(q.get("vhost"), 64),
|
|
57
|
+
"state": s(q.get("state"), 32),
|
|
58
|
+
"durable": bool(q.get("durable")),
|
|
59
|
+
"messages": num(q.get("messages")),
|
|
60
|
+
"messagesReady": num(q.get("messages_ready")),
|
|
61
|
+
"messagesUnacked": num(q.get("messages_unacknowledged")),
|
|
62
|
+
"consumers": num(q.get("consumers")),
|
|
63
|
+
"memoryBytes": num(q.get("memory")),
|
|
64
|
+
"publishRate": rate(stats.get("publish_details")),
|
|
65
|
+
"deliverRate": rate(stats.get("deliver_get_details")),
|
|
66
|
+
"ackRate": rate(stats.get("ack_details")),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_queues(conn: Any, vhost: str | None = None) -> dict:
|
|
71
|
+
"""[READ] Queues (optionally one vhost), deepest backlog first."""
|
|
72
|
+
try:
|
|
73
|
+
path = (
|
|
74
|
+
conn.platform.path("queues_vhost", vhost=vhost)
|
|
75
|
+
if vhost
|
|
76
|
+
else conn.platform.path("queues")
|
|
77
|
+
)
|
|
78
|
+
rows = [_queue_row(q) for q in conn.platform.rows(conn.get(path))]
|
|
79
|
+
rows.sort(key=lambda r: r["messages"], reverse=True)
|
|
80
|
+
return {"total": len(rows), "queues": rows[:MAX_ROWS]}
|
|
81
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
82
|
+
return {"error": s(exc, 200)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def queue_detail(conn: Any, vhost: str, name: str) -> dict:
|
|
86
|
+
"""[READ] One queue's full detail (rates, consumers, memory, args)."""
|
|
87
|
+
try:
|
|
88
|
+
obj = as_obj(
|
|
89
|
+
conn.platform.normalise(
|
|
90
|
+
conn.get(conn.platform.path("queue", vhost=vhost, name=name))
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
row = _queue_row(obj)
|
|
94
|
+
row.update(
|
|
95
|
+
{
|
|
96
|
+
"autoDelete": bool(obj.get("auto_delete")),
|
|
97
|
+
"exclusive": bool(obj.get("exclusive")),
|
|
98
|
+
"arguments": as_obj(obj.get("arguments")),
|
|
99
|
+
"node": s(obj.get("node"), 64),
|
|
100
|
+
"idleSince": s(obj.get("idle_since"), 64),
|
|
101
|
+
"consumerUtilisation": num(
|
|
102
|
+
pick(obj, "consumer_capacity", "consumer_utilisation", default=0)
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
return row
|
|
107
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
108
|
+
return {"error": s(exc, 200)}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_connections(conn: Any) -> dict:
|
|
112
|
+
"""[READ] Client connections grouped by peer host, busiest first."""
|
|
113
|
+
try:
|
|
114
|
+
rows = [
|
|
115
|
+
{
|
|
116
|
+
"name": s(c.get("name"), 128),
|
|
117
|
+
"peerHost": s(pick(c, "peer_host", default="(unknown)"), 64),
|
|
118
|
+
"user": s(c.get("user"), 64),
|
|
119
|
+
"state": s(c.get("state"), 32),
|
|
120
|
+
"channels": num(c.get("channels")),
|
|
121
|
+
"connectedAt": num(c.get("connected_at")),
|
|
122
|
+
"clientProduct": s(as_obj(c.get("client_properties")).get("product"), 64),
|
|
123
|
+
}
|
|
124
|
+
for c in conn.platform.rows(conn.get(conn.platform.path("connections")))
|
|
125
|
+
]
|
|
126
|
+
by_host: dict[str, dict] = {}
|
|
127
|
+
for r in rows:
|
|
128
|
+
bucket = by_host.setdefault(
|
|
129
|
+
r["peerHost"], {"peerHost": r["peerHost"], "connections": 0, "channels": 0.0}
|
|
130
|
+
)
|
|
131
|
+
bucket["connections"] += 1
|
|
132
|
+
bucket["channels"] += r["channels"]
|
|
133
|
+
sources = sorted(by_host.values(), key=lambda e: e["connections"], reverse=True)
|
|
134
|
+
return {
|
|
135
|
+
"total": len(rows),
|
|
136
|
+
"byPeerHost": sources[:50],
|
|
137
|
+
"connections": rows[:MAX_ROWS],
|
|
138
|
+
}
|
|
139
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
140
|
+
return {"error": s(exc, 200)}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def list_channels(conn: Any) -> dict:
|
|
144
|
+
"""[READ] Channels with unacked/prefetch pressure, most unacked first."""
|
|
145
|
+
try:
|
|
146
|
+
rows = [
|
|
147
|
+
{
|
|
148
|
+
"name": s(c.get("name"), 128),
|
|
149
|
+
"connection": s(as_obj(c.get("connection_details")).get("name"), 128),
|
|
150
|
+
"user": s(c.get("user"), 64),
|
|
151
|
+
"state": s(c.get("state"), 32),
|
|
152
|
+
"unacked": num(c.get("messages_unacknowledged")),
|
|
153
|
+
"prefetch": num(c.get("prefetch_count")),
|
|
154
|
+
"consumers": num(c.get("consumer_count")),
|
|
155
|
+
}
|
|
156
|
+
for c in conn.platform.rows(conn.get(conn.platform.path("channels")))
|
|
157
|
+
]
|
|
158
|
+
rows.sort(key=lambda r: r["unacked"], reverse=True)
|
|
159
|
+
return {"total": len(rows), "channels": rows[:MAX_ROWS]}
|
|
160
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
161
|
+
return {"error": s(exc, 200)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def list_policies(conn: Any, vhost: str | None = None) -> dict:
|
|
165
|
+
"""[READ] Policies (optionally one vhost)."""
|
|
166
|
+
try:
|
|
167
|
+
path = (
|
|
168
|
+
conn.platform.path("policies_vhost", vhost=vhost)
|
|
169
|
+
if vhost
|
|
170
|
+
else conn.platform.path("policies")
|
|
171
|
+
)
|
|
172
|
+
rows = [
|
|
173
|
+
{
|
|
174
|
+
"name": s(p.get("name"), 128),
|
|
175
|
+
"vhost": s(p.get("vhost"), 64),
|
|
176
|
+
"pattern": s(p.get("pattern"), 128),
|
|
177
|
+
"applyTo": s(pick(p, "apply-to", "apply_to", default="all"), 32),
|
|
178
|
+
"priority": num(p.get("priority")),
|
|
179
|
+
"definition": as_obj(p.get("definition")),
|
|
180
|
+
}
|
|
181
|
+
for p in conn.platform.rows(conn.get(path))
|
|
182
|
+
]
|
|
183
|
+
return {"total": len(rows), "policies": rows[:MAX_ROWS]}
|
|
184
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
185
|
+
return {"error": s(exc, 200)}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def node_health(conn: Any) -> dict:
|
|
189
|
+
"""[READ] Node memory/disk/fd posture + alarms, most loaded first."""
|
|
190
|
+
try:
|
|
191
|
+
rows = []
|
|
192
|
+
for n in conn.platform.rows(conn.get(conn.platform.path("nodes"))):
|
|
193
|
+
mem_used = num(n.get("mem_used"))
|
|
194
|
+
mem_limit = num(n.get("mem_limit"))
|
|
195
|
+
rows.append(
|
|
196
|
+
{
|
|
197
|
+
"name": s(n.get("name"), 64),
|
|
198
|
+
"running": bool(n.get("running", True)),
|
|
199
|
+
"memUsedBytes": mem_used,
|
|
200
|
+
"memLimitBytes": mem_limit,
|
|
201
|
+
"memAlarm": bool(n.get("mem_alarm")),
|
|
202
|
+
"diskFreeBytes": num(n.get("disk_free")),
|
|
203
|
+
"diskFreeLimitBytes": num(n.get("disk_free_limit")),
|
|
204
|
+
"diskAlarm": bool(n.get("disk_free_alarm")),
|
|
205
|
+
"fdUsed": num(n.get("fd_used")),
|
|
206
|
+
"fdTotal": num(n.get("fd_total")),
|
|
207
|
+
"socketsUsed": num(n.get("sockets_used")),
|
|
208
|
+
"socketsTotal": num(n.get("sockets_total")),
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
rows.sort(key=lambda r: r["memUsedBytes"], reverse=True)
|
|
212
|
+
return {
|
|
213
|
+
"total": len(rows),
|
|
214
|
+
"alarms": sum(1 for r in rows if r["memAlarm"] or r["diskAlarm"]),
|
|
215
|
+
"nodes": rows,
|
|
216
|
+
}
|
|
217
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
218
|
+
return {"error": s(exc, 200)}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""redis reads — INFO, memory, clients, slowlog, config, keyspace, big keys.
|
|
2
|
+
|
|
3
|
+
The day-to-day "is this cache healthy?" surface for redis targets. Every call
|
|
4
|
+
is resilient — a transport/parse failure surfaces as ``{"error": ...}`` instead
|
|
5
|
+
of raising — and all broker text is sanitised via ``s``.
|
|
6
|
+
|
|
7
|
+
Big-key sampling is **SCAN-based with a hard budget** (never ``KEYS *``): at
|
|
8
|
+
most ``SCAN_BUDGET_KEYS`` keys are walked in ``SCAN_PAGE``-sized pages and at
|
|
9
|
+
most ``MEMORY_SAMPLE_MAX`` of them are sized with ``MEMORY USAGE``, so the read
|
|
10
|
+
can never stall a production instance. Coverage is reported so the caller knows
|
|
11
|
+
how partial the sample is.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from queue_aiops.ops._util import as_obj, num, pct, s
|
|
19
|
+
|
|
20
|
+
# Hard bounds for the SCAN-based big-key sample (never KEYS *).
|
|
21
|
+
SCAN_BUDGET_KEYS = 10_000 # max keys walked per call
|
|
22
|
+
SCAN_PAGE = 500 # SCAN COUNT hint per page
|
|
23
|
+
MEMORY_SAMPLE_MAX = 200 # max MEMORY USAGE calls per big-key sample
|
|
24
|
+
TOP_KEYS = 20 # rows returned, largest first
|
|
25
|
+
|
|
26
|
+
MAX_CLIENT_ROWS = 200
|
|
27
|
+
MAX_SLOWLOG_ROWS = 128
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def server_info(conn: Any) -> dict:
|
|
31
|
+
"""[READ] Server identity + health basics from INFO server/clients/stats."""
|
|
32
|
+
try:
|
|
33
|
+
info = as_obj(conn.redis_info())
|
|
34
|
+
return {
|
|
35
|
+
"platform": conn.target.platform,
|
|
36
|
+
"version": s(info.get("redis_version")),
|
|
37
|
+
"mode": s(info.get("redis_mode")),
|
|
38
|
+
"uptimeSeconds": num(info.get("uptime_in_seconds")),
|
|
39
|
+
"role": s(info.get("role")),
|
|
40
|
+
"connectedClients": num(info.get("connected_clients")),
|
|
41
|
+
"blockedClients": num(info.get("blocked_clients")),
|
|
42
|
+
"opsPerSec": num(info.get("instantaneous_ops_per_sec")),
|
|
43
|
+
"totalCommands": num(info.get("total_commands_processed")),
|
|
44
|
+
"keyspaceHits": num(info.get("keyspace_hits")),
|
|
45
|
+
"keyspaceMisses": num(info.get("keyspace_misses")),
|
|
46
|
+
"hitRatePct": pct(
|
|
47
|
+
num(info.get("keyspace_hits")),
|
|
48
|
+
num(info.get("keyspace_hits")) + num(info.get("keyspace_misses")),
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
52
|
+
return {"error": s(exc, 200)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def memory_stats(conn: Any) -> dict:
|
|
56
|
+
"""[READ] Memory posture: used vs maxmemory, policy, fragmentation, peaks."""
|
|
57
|
+
try:
|
|
58
|
+
info = as_obj(conn.redis_info("memory"))
|
|
59
|
+
used = num(info.get("used_memory"))
|
|
60
|
+
maxmem = num(info.get("maxmemory"))
|
|
61
|
+
stats = as_obj(conn.redis_memory_stats())
|
|
62
|
+
return {
|
|
63
|
+
"usedBytes": used,
|
|
64
|
+
"usedHuman": s(info.get("used_memory_human")),
|
|
65
|
+
"maxmemoryBytes": maxmem,
|
|
66
|
+
"usedPctOfMax": pct(used, maxmem),
|
|
67
|
+
"maxmemoryPolicy": s(info.get("maxmemory_policy")),
|
|
68
|
+
"fragmentationRatio": num(info.get("mem_fragmentation_ratio")),
|
|
69
|
+
"rssBytes": num(info.get("used_memory_rss")),
|
|
70
|
+
"peakBytes": num(info.get("used_memory_peak")),
|
|
71
|
+
"keysCount": num(stats.get("keys.count")),
|
|
72
|
+
"overheadBytes": num(stats.get("overhead.total")),
|
|
73
|
+
"datasetBytes": num(stats.get("dataset.bytes")),
|
|
74
|
+
}
|
|
75
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
76
|
+
return {"error": s(exc, 200)}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_clients(conn: Any) -> dict:
|
|
80
|
+
"""[READ] Connected clients grouped by source address, busiest first."""
|
|
81
|
+
try:
|
|
82
|
+
clients = conn.redis_client_list()
|
|
83
|
+
rows = [
|
|
84
|
+
{
|
|
85
|
+
"id": s(c.get("id"), 32),
|
|
86
|
+
"addr": s(c.get("addr"), 64),
|
|
87
|
+
"name": s(c.get("name"), 64),
|
|
88
|
+
"ageSeconds": num(c.get("age")),
|
|
89
|
+
"idleSeconds": num(c.get("idle")),
|
|
90
|
+
"lastCommand": s(c.get("cmd"), 64),
|
|
91
|
+
"db": s(c.get("db"), 8),
|
|
92
|
+
"flags": s(c.get("flags"), 16),
|
|
93
|
+
}
|
|
94
|
+
for c in clients
|
|
95
|
+
]
|
|
96
|
+
by_source: dict[str, int] = {}
|
|
97
|
+
for r in rows:
|
|
98
|
+
source = r["addr"].rsplit(":", 1)[0] or "(unknown)"
|
|
99
|
+
by_source[source] = by_source.get(source, 0) + 1
|
|
100
|
+
sources = sorted(
|
|
101
|
+
({"source": k, "clients": v} for k, v in by_source.items()),
|
|
102
|
+
key=lambda e: e["clients"],
|
|
103
|
+
reverse=True,
|
|
104
|
+
)
|
|
105
|
+
return {
|
|
106
|
+
"total": len(rows),
|
|
107
|
+
"bySource": sources[:50],
|
|
108
|
+
"clients": rows[:MAX_CLIENT_ROWS],
|
|
109
|
+
}
|
|
110
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
111
|
+
return {"error": s(exc, 200)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _command_text(raw: Any) -> str:
|
|
115
|
+
"""Fold a slowlog command cell (str/bytes/list) into one bounded string."""
|
|
116
|
+
if isinstance(raw, bytes):
|
|
117
|
+
raw = raw.decode("utf-8", "replace")
|
|
118
|
+
if isinstance(raw, (list, tuple)):
|
|
119
|
+
raw = " ".join(
|
|
120
|
+
p.decode("utf-8", "replace") if isinstance(p, bytes) else str(p) for p in raw
|
|
121
|
+
)
|
|
122
|
+
return s(raw, 128)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def slowlog(conn: Any, count: int = MAX_SLOWLOG_ROWS) -> dict:
|
|
126
|
+
"""[READ] Recent SLOWLOG entries, slowest first."""
|
|
127
|
+
try:
|
|
128
|
+
entries = conn.redis_slowlog(min(int(count), MAX_SLOWLOG_ROWS))
|
|
129
|
+
rows = [
|
|
130
|
+
{
|
|
131
|
+
"id": num(e.get("id")),
|
|
132
|
+
"startTime": num(e.get("start_time")),
|
|
133
|
+
"durationUs": num(e.get("duration")),
|
|
134
|
+
"command": _command_text(e.get("command")),
|
|
135
|
+
"clientAddr": s(e.get("client_address"), 64),
|
|
136
|
+
"clientName": s(e.get("client_name"), 64),
|
|
137
|
+
}
|
|
138
|
+
for e in entries
|
|
139
|
+
if isinstance(e, dict)
|
|
140
|
+
]
|
|
141
|
+
rows.sort(key=lambda r: r["durationUs"], reverse=True)
|
|
142
|
+
return {"total": len(rows), "entries": rows}
|
|
143
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
144
|
+
return {"error": s(exc, 200)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def config_get(conn: Any, pattern: str = "*") -> dict:
|
|
148
|
+
"""[READ] CONFIG GET for a glob pattern (bounded, values sanitised)."""
|
|
149
|
+
try:
|
|
150
|
+
raw = conn.redis_config_get(s(pattern, 64) or "*")
|
|
151
|
+
params = {s(k, 64): s(v, 128) for k, v in sorted(raw.items())}
|
|
152
|
+
return {"total": len(params), "parameters": params}
|
|
153
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
154
|
+
return {"error": s(exc, 200)}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def keyspace(conn: Any) -> dict:
|
|
158
|
+
"""[READ] Per-db key counts + expiry coverage from INFO keyspace."""
|
|
159
|
+
try:
|
|
160
|
+
info = as_obj(conn.redis_info("keyspace"))
|
|
161
|
+
dbs = []
|
|
162
|
+
for name, cell in info.items():
|
|
163
|
+
cell = as_obj(cell) if isinstance(cell, dict) else _parse_keyspace_cell(cell)
|
|
164
|
+
keys = num(cell.get("keys"))
|
|
165
|
+
expires = num(cell.get("expires"))
|
|
166
|
+
dbs.append(
|
|
167
|
+
{
|
|
168
|
+
"db": s(name, 16),
|
|
169
|
+
"keys": keys,
|
|
170
|
+
"expires": expires,
|
|
171
|
+
"expiresPct": pct(expires, keys),
|
|
172
|
+
"avgTtlMs": num(cell.get("avg_ttl")),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
return {"databases": dbs, "totalKeys": sum(d["keys"] for d in dbs)}
|
|
176
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
177
|
+
return {"error": s(exc, 200)}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_keyspace_cell(cell: Any) -> dict:
|
|
181
|
+
"""Parse a raw ``keys=1,expires=2,avg_ttl=3`` keyspace string."""
|
|
182
|
+
out: dict[str, str] = {}
|
|
183
|
+
for part in str(cell or "").split(","):
|
|
184
|
+
if "=" in part:
|
|
185
|
+
k, _, v = part.partition("=")
|
|
186
|
+
out[k.strip()] = v.strip()
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def big_key_sample(conn: Any, top: int = TOP_KEYS) -> dict:
|
|
191
|
+
"""[READ] SCAN-budgeted big-key sample — largest sampled keys first.
|
|
192
|
+
|
|
193
|
+
Walks at most ``SCAN_BUDGET_KEYS`` keys with SCAN (pages of ``SCAN_PAGE``),
|
|
194
|
+
sizes an evenly-spaced subset of at most ``MEMORY_SAMPLE_MAX`` keys with
|
|
195
|
+
MEMORY USAGE, and returns the largest ones. Never ``KEYS *`` — the budget
|
|
196
|
+
makes this safe on a big production keyspace, and ``coveragePct`` says how
|
|
197
|
+
partial the sample is.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
dbsize = conn.redis_dbsize()
|
|
201
|
+
scanned: list[str] = []
|
|
202
|
+
cursor = 0
|
|
203
|
+
while True:
|
|
204
|
+
cursor, keys = conn.redis_scan(cursor=cursor, count=SCAN_PAGE)
|
|
205
|
+
scanned.extend(keys)
|
|
206
|
+
if cursor == 0 or len(scanned) >= SCAN_BUDGET_KEYS:
|
|
207
|
+
break
|
|
208
|
+
scanned = scanned[:SCAN_BUDGET_KEYS]
|
|
209
|
+
|
|
210
|
+
step = max(1, len(scanned) // MEMORY_SAMPLE_MAX)
|
|
211
|
+
sampled = scanned[::step][:MEMORY_SAMPLE_MAX]
|
|
212
|
+
sized = []
|
|
213
|
+
for key in sampled:
|
|
214
|
+
usage = conn.redis_memory_usage(key)
|
|
215
|
+
if usage is not None:
|
|
216
|
+
sized.append({"key": s(key, 128), "bytes": float(usage)})
|
|
217
|
+
sized.sort(key=lambda e: e["bytes"], reverse=True)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
"dbsize": dbsize,
|
|
221
|
+
"scannedKeys": len(scanned),
|
|
222
|
+
"sampledKeys": len(sized),
|
|
223
|
+
"coveragePct": pct(len(scanned), dbsize),
|
|
224
|
+
"budget": {
|
|
225
|
+
"scanBudgetKeys": SCAN_BUDGET_KEYS,
|
|
226
|
+
"scanPage": SCAN_PAGE,
|
|
227
|
+
"memorySampleMax": MEMORY_SAMPLE_MAX,
|
|
228
|
+
},
|
|
229
|
+
"topKeys": sized[: max(1, int(top))],
|
|
230
|
+
"note": (
|
|
231
|
+
"SCAN-based sample under a hard budget (never KEYS *): sizes are "
|
|
232
|
+
"MEMORY USAGE on an evenly-spaced subset; coveragePct shows how "
|
|
233
|
+
"partial the walk was."
|
|
234
|
+
),
|
|
235
|
+
}
|
|
236
|
+
except Exception as exc: # noqa: BLE001 — report as partial
|
|
237
|
+
return {"error": s(exc, 200)}
|