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,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)}