cortexdb-cli 0.5.3__tar.gz → 0.6.0__tar.gz
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.
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/PKG-INFO +1 -1
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/__init__.py +1 -1
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/client.py +8 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/__init__.py +1 -1
- cortexdb_cli-0.6.0/cortexdb_cli/commands/admin.py +612 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/auth.py +38 -5
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/code.py +60 -4
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/config_cmd.py +50 -50
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/errors.py +96 -96
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/pyproject.toml +1 -1
- cortexdb_cli-0.5.3/.gitignore +0 -101
- cortexdb_cli-0.5.3/cortexdb_cli/commands/admin.py +0 -83
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/README.md +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/answer.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/audit.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/beliefs.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/claims.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/compose.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/conflicts.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/entities.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/episodes.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/experience.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/export_cmd.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/facts.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/forget.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/import_cmd.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/init.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/interactive.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/onboard.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/policy.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/recall.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/scopes.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/search.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/understanding.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/wire.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/config.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/harness_packs_data.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/main.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/onboarding.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/output.py +0 -0
- {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/state.py +0 -0
|
@@ -63,6 +63,14 @@ class CortexClient:
|
|
|
63
63
|
) -> httpx.Response:
|
|
64
64
|
return self._http.post(path, json=json, params=params)
|
|
65
65
|
|
|
66
|
+
def put(
|
|
67
|
+
self,
|
|
68
|
+
path: str,
|
|
69
|
+
json: dict[str, Any] | None = None,
|
|
70
|
+
params: dict[str, Any] | None = None,
|
|
71
|
+
) -> httpx.Response:
|
|
72
|
+
return self._http.put(path, json=json, params=params)
|
|
73
|
+
|
|
66
74
|
def delete(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
|
|
67
75
|
return self._http.delete(path, params=params)
|
|
68
76
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"""CortexDB CLI command modules."""
|
|
1
|
+
"""CortexDB CLI command modules."""
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""cortexdb admin - health, usage, layer stats, retention, unified WAL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from cortexdb_cli.client import get_client
|
|
10
|
+
from cortexdb_cli.errors import handle_errors
|
|
11
|
+
from cortexdb_cli.output import (
|
|
12
|
+
console,
|
|
13
|
+
escape,
|
|
14
|
+
is_json_mode,
|
|
15
|
+
print_health,
|
|
16
|
+
print_json,
|
|
17
|
+
print_ontology,
|
|
18
|
+
print_success,
|
|
19
|
+
print_usage,
|
|
20
|
+
spinner,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group("admin")
|
|
25
|
+
def admin_group() -> None:
|
|
26
|
+
"""Diagnostics - reachability, identity, rate-limit, layer status."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@admin_group.command("health")
|
|
30
|
+
@click.pass_context
|
|
31
|
+
@handle_errors
|
|
32
|
+
def admin_health(ctx: click.Context) -> None:
|
|
33
|
+
"""Auth + reachability check (GET /v1/auth/whoami)."""
|
|
34
|
+
cfg = ctx.obj
|
|
35
|
+
with get_client(cfg) as client:
|
|
36
|
+
resp = client.get("/v1/auth/whoami")
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
body = resp.json()
|
|
39
|
+
# Normalise into the shape print_health expects.
|
|
40
|
+
body.setdefault("status", "ok")
|
|
41
|
+
print_health(body, cfg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@admin_group.command("usage")
|
|
45
|
+
@click.pass_context
|
|
46
|
+
@handle_errors
|
|
47
|
+
def admin_usage(ctx: click.Context) -> None:
|
|
48
|
+
"""Tier, capabilities, rate-limit headroom, token expiry."""
|
|
49
|
+
cfg = ctx.obj
|
|
50
|
+
with get_client(cfg) as client:
|
|
51
|
+
if not is_json_mode(cfg):
|
|
52
|
+
with spinner("Loading usage..."):
|
|
53
|
+
resp = client.get("/v1/auth/whoami")
|
|
54
|
+
else:
|
|
55
|
+
resp = client.get("/v1/auth/whoami")
|
|
56
|
+
resp.raise_for_status()
|
|
57
|
+
body = resp.json()
|
|
58
|
+
# Fold rate-limit + expiry response headers into the body so the
|
|
59
|
+
# output formatter can render them in one block.
|
|
60
|
+
for k in (
|
|
61
|
+
"X-RateLimit-Limit",
|
|
62
|
+
"X-RateLimit-Reset",
|
|
63
|
+
"X-RateLimit-Remaining",
|
|
64
|
+
"X-Cortex-Token-Expires-In",
|
|
65
|
+
"X-Cortex-Token-Expires-At",
|
|
66
|
+
):
|
|
67
|
+
if k in resp.headers:
|
|
68
|
+
body[k] = resp.headers[k]
|
|
69
|
+
print_usage(body, cfg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@admin_group.command("layers")
|
|
73
|
+
@click.pass_context
|
|
74
|
+
@handle_errors
|
|
75
|
+
def admin_layers(ctx: click.Context) -> None:
|
|
76
|
+
"""Per-layer counts and synthesis lag (GET /v1/admin/layers/stats).
|
|
77
|
+
|
|
78
|
+
Stability: experimental - the endpoint is gated by the
|
|
79
|
+
`admin.layers.stats` capability and may not be present on the free tier.
|
|
80
|
+
"""
|
|
81
|
+
cfg = ctx.obj
|
|
82
|
+
with get_client(cfg) as client:
|
|
83
|
+
if not is_json_mode(cfg):
|
|
84
|
+
with spinner("Loading layer stats..."):
|
|
85
|
+
resp = client.get("/v1/admin/layers/stats")
|
|
86
|
+
else:
|
|
87
|
+
resp = client.get("/v1/admin/layers/stats")
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
print_ontology(resp.json(), cfg)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# -- retention (unified WAL) -------------------------------------------------
|
|
93
|
+
#
|
|
94
|
+
# The operator surface over the D1 retention plane. Every write here is
|
|
95
|
+
# irreversible in effect - an applied sweep ERASES expired captured events
|
|
96
|
+
# through the ordinary erasure plane - so reporting is the default on both
|
|
97
|
+
# mutating commands and `--apply` is the only thing that sends
|
|
98
|
+
# `dry_run: false`.
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_source_days(pairs: tuple[str, ...]) -> dict[str, int]:
|
|
102
|
+
"""Parse repeated ``--source-days KEY=N`` options into the wire map.
|
|
103
|
+
|
|
104
|
+
Only the option's own grammar is checked here (KEY=VALUE, N a positive
|
|
105
|
+
integer). The ``source:`` prefix rule and the 1..=36500 ceiling belong to
|
|
106
|
+
the server's governance validator, which answers 422
|
|
107
|
+
INVALID_RETENTION_POLICY - duplicating them here would let the CLI and
|
|
108
|
+
the server disagree about what a valid rule is.
|
|
109
|
+
"""
|
|
110
|
+
out: dict[str, int] = {}
|
|
111
|
+
for pair in pairs:
|
|
112
|
+
if "=" not in pair:
|
|
113
|
+
raise click.UsageError(f"--source-days must be KEY=DAYS, got {pair!r}")
|
|
114
|
+
key, raw = pair.split("=", 1)
|
|
115
|
+
key = key.strip()
|
|
116
|
+
if not key:
|
|
117
|
+
raise click.UsageError(f"--source-days needs a key, got {pair!r}")
|
|
118
|
+
try:
|
|
119
|
+
days = int(raw)
|
|
120
|
+
except ValueError:
|
|
121
|
+
raise click.UsageError(
|
|
122
|
+
f"--source-days value must be a whole number of days, got {raw!r}"
|
|
123
|
+
) from None
|
|
124
|
+
if days < 1:
|
|
125
|
+
raise click.UsageError(
|
|
126
|
+
f"--source-days must be at least 1 day, got {days} for {key!r}"
|
|
127
|
+
)
|
|
128
|
+
if key in out:
|
|
129
|
+
raise click.UsageError(f"--source-days {key!r} given twice")
|
|
130
|
+
out[key] = days
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _print_retention_status(data: dict[str, Any], cfg: dict[str, Any]) -> None:
|
|
135
|
+
"""Render GET /v1/admin/retention."""
|
|
136
|
+
if is_json_mode(cfg):
|
|
137
|
+
print_json(data)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
from rich.table import Table
|
|
141
|
+
|
|
142
|
+
config = Table(title="[header]Retention[/header]", border_style="cyan")
|
|
143
|
+
config.add_column("Setting", style="bold")
|
|
144
|
+
config.add_column("Value")
|
|
145
|
+
interval = data.get("sweep_interval_secs", 0)
|
|
146
|
+
if data.get("enabled"):
|
|
147
|
+
scheduled = "yes"
|
|
148
|
+
elif data.get("scheduler_disabled"):
|
|
149
|
+
scheduled = "[warning]no (CORTEX_SCHEDULER_DISABLE)[/warning]"
|
|
150
|
+
else:
|
|
151
|
+
scheduled = "no (operator-triggered only)"
|
|
152
|
+
config.add_row("scheduled", scheduled)
|
|
153
|
+
config.add_row("sweep interval", f"{interval}s" if interval else "-")
|
|
154
|
+
default_days = data.get("deployment_default_days")
|
|
155
|
+
config.add_row("deployment default", f"{default_days}d" if default_days else "keep")
|
|
156
|
+
config.add_row("max events/tick", str(data.get("max_events_per_tick", "")))
|
|
157
|
+
config.add_row("tick budget", str(data.get("tick_budget_secs", "")) + "s")
|
|
158
|
+
config.add_row(
|
|
159
|
+
"compact after sweep", "yes" if data.get("compact_after_sweep") else "no"
|
|
160
|
+
)
|
|
161
|
+
config.add_row("cursor", str(data.get("cursor", "")))
|
|
162
|
+
config.add_row("watermark", str(data.get("watermark", "")))
|
|
163
|
+
config.add_row(
|
|
164
|
+
"cursor trustworthy",
|
|
165
|
+
"yes" if data.get("fingerprint_ok") else "no (next applied tick rescans)",
|
|
166
|
+
)
|
|
167
|
+
config.add_row("running", "yes" if data.get("running") else "no")
|
|
168
|
+
if data.get("native_profile_blocked"):
|
|
169
|
+
config.add_row("native profile", "[warning]retention refused[/warning]")
|
|
170
|
+
console.print(config)
|
|
171
|
+
|
|
172
|
+
policies = data.get("policies") or []
|
|
173
|
+
if policies:
|
|
174
|
+
table = Table(title="[header]Policies[/header]", border_style="cyan")
|
|
175
|
+
table.add_column("Scope", style="bold")
|
|
176
|
+
table.add_column("Default", justify="right")
|
|
177
|
+
table.add_column("Per-source", style="dim")
|
|
178
|
+
for entry in policies:
|
|
179
|
+
policy = entry.get("policy") or {}
|
|
180
|
+
days = policy.get("default_retention_days")
|
|
181
|
+
per_source = policy.get("per_source_days") or {}
|
|
182
|
+
table.add_row(
|
|
183
|
+
escape(str(entry.get("scope", ""))),
|
|
184
|
+
f"{days}d" if days else "keep",
|
|
185
|
+
escape(", ".join(f"{k}={v}d" for k, v in sorted(per_source.items()))),
|
|
186
|
+
)
|
|
187
|
+
console.print(table)
|
|
188
|
+
else:
|
|
189
|
+
console.print("[dim]No per-scope retention rules stored.[/dim]")
|
|
190
|
+
|
|
191
|
+
last = data.get("last")
|
|
192
|
+
if isinstance(last, dict):
|
|
193
|
+
note = " [warning](dry run)[/warning]" if last.get("dry_run") else ""
|
|
194
|
+
console.print(
|
|
195
|
+
"[dim]Last tick:[/dim] "
|
|
196
|
+
f"scanned {last.get('scanned', 0)}, "
|
|
197
|
+
f"expired {last.get('expired', 0)}, "
|
|
198
|
+
f"erased {last.get('erased', 0)}, "
|
|
199
|
+
f"stopped {escape(str(last.get('stopped', '')))}" + note
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _print_sweep_report(data: dict[str, Any], cfg: dict[str, Any]) -> None:
|
|
204
|
+
"""Render POST /v1/admin/retention/sweep (or its 202 still-running body)."""
|
|
205
|
+
if is_json_mode(cfg):
|
|
206
|
+
print_json(data)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
from rich.panel import Panel
|
|
210
|
+
|
|
211
|
+
if "erased" not in data:
|
|
212
|
+
# 202: the tick outlived the blocking-lane deadline and is still going.
|
|
213
|
+
poll = escape(str(data.get("poll", "/v1/admin/retention")))
|
|
214
|
+
console.print(
|
|
215
|
+
Panel(
|
|
216
|
+
f"Sweep still running; poll [bold]{poll}[/bold]",
|
|
217
|
+
title="[header]Retention sweep[/header]",
|
|
218
|
+
border_style="yellow",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
dry = data.get("dry_run", True)
|
|
224
|
+
lines = [
|
|
225
|
+
f"scanned [bold]{data.get('scanned', 0)}[/bold] captured row(s)",
|
|
226
|
+
f"expired [bold]{data.get('expired', 0)}[/bold], "
|
|
227
|
+
f"erased [bold]{data.get('erased', 0)}[/bold], "
|
|
228
|
+
f"kept forever [bold]{data.get('kept_forever', 0)}[/bold]",
|
|
229
|
+
f"cursor {data.get('cursor_before', 0)} -> {data.get('cursor_after', 0)} "
|
|
230
|
+
f"(watermark {data.get('watermark', 0)})",
|
|
231
|
+
f"stopped: {escape(str(data.get('stopped', '')))} "
|
|
232
|
+
f"in {data.get('elapsed_ms', 0)}ms",
|
|
233
|
+
]
|
|
234
|
+
held = data.get("held") or {}
|
|
235
|
+
if isinstance(held, dict) and any(held.values()):
|
|
236
|
+
lines.append(f"[dim]held: {escape(str(held))}[/dim]")
|
|
237
|
+
for error in data.get("errors") or []:
|
|
238
|
+
lines.append(f"[error]{escape(str(error))}[/error]")
|
|
239
|
+
title = "[header]Retention sweep" + (" (dry run)" if dry else " (APPLIED)")
|
|
240
|
+
console.print(
|
|
241
|
+
Panel(
|
|
242
|
+
"\n".join(lines),
|
|
243
|
+
title=title + "[/header]",
|
|
244
|
+
border_style="yellow" if dry else "red",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _print_wal_status(data: dict[str, Any], cfg: dict[str, Any]) -> None:
|
|
250
|
+
"""Render GET /v1/admin/wal."""
|
|
251
|
+
if is_json_mode(cfg):
|
|
252
|
+
print_json(data)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
from rich.table import Table
|
|
256
|
+
|
|
257
|
+
table = Table(title="[header]Unified WAL[/header]", border_style="cyan")
|
|
258
|
+
table.add_column("Field", style="bold")
|
|
259
|
+
table.add_column("Value", justify="right")
|
|
260
|
+
for label, key in (
|
|
261
|
+
("live rows", "live_count"),
|
|
262
|
+
("next offset", "next_offset"),
|
|
263
|
+
("head offset", "head_offset"),
|
|
264
|
+
("captured head", "captured_head_offset"),
|
|
265
|
+
("partitions", "partitions"),
|
|
266
|
+
("retired markers", "retired_marker_count"),
|
|
267
|
+
):
|
|
268
|
+
value = data.get(key)
|
|
269
|
+
table.add_row(label, "-" if value is None else str(value))
|
|
270
|
+
if data.get("native_profile_blocked"):
|
|
271
|
+
table.add_row("native profile", "[warning]mutations refused[/warning]")
|
|
272
|
+
console.print(table)
|
|
273
|
+
|
|
274
|
+
consumers = data.get("consumer_offsets") or {}
|
|
275
|
+
if consumers:
|
|
276
|
+
floors = Table(title="[header]Consumer floors[/header]", border_style="cyan")
|
|
277
|
+
floors.add_column("Consumer", style="bold")
|
|
278
|
+
floors.add_column("Offset", justify="right")
|
|
279
|
+
for name, offset in sorted(consumers.items()):
|
|
280
|
+
floors.add_row(escape(str(name)), str(offset))
|
|
281
|
+
console.print(floors)
|
|
282
|
+
|
|
283
|
+
verify = data.get("verify")
|
|
284
|
+
if isinstance(verify, dict):
|
|
285
|
+
ok = verify.get("ok")
|
|
286
|
+
if ok is None:
|
|
287
|
+
# Not a verdict: the self-check is bounded and a production-sized
|
|
288
|
+
# log is past that bound, so nothing was checked. Rendering this
|
|
289
|
+
# as a failure is how a healthy store gets restored from backup.
|
|
290
|
+
skipped = escape(
|
|
291
|
+
str(verify.get("skipped", "log exceeds the bounded self-check"))
|
|
292
|
+
)
|
|
293
|
+
console.print(f"[warning]Integrity check not run:[/warning] {skipped}")
|
|
294
|
+
elif ok:
|
|
295
|
+
console.print("[success]Integrity check passed.[/success]")
|
|
296
|
+
else:
|
|
297
|
+
reason = escape(str(verify.get("reason", "unknown")))
|
|
298
|
+
console.print(f"[error]Integrity check failed:[/error] {reason}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _print_compaction(data: dict[str, Any], cfg: dict[str, Any]) -> None:
|
|
302
|
+
"""Render POST /v1/admin/wal/compact (or its 202 still-running body)."""
|
|
303
|
+
if is_json_mode(cfg):
|
|
304
|
+
print_json(data)
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
from rich.panel import Panel
|
|
308
|
+
|
|
309
|
+
if "removed_tombstones" not in data:
|
|
310
|
+
poll = escape(str(data.get("poll", "/v1/admin/wal")))
|
|
311
|
+
console.print(
|
|
312
|
+
Panel(
|
|
313
|
+
f"Compaction still running; poll [bold]{poll}[/bold]",
|
|
314
|
+
title="[header]WAL compaction[/header]",
|
|
315
|
+
border_style="yellow",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
dry = data.get("dry_run", True)
|
|
321
|
+
lines = [
|
|
322
|
+
f"scanned [bold]{data.get('scanned', 0)}[/bold] row(s)",
|
|
323
|
+
f"removed {data.get('removed_tombstones', 0)} tombstone(s), "
|
|
324
|
+
f"{data.get('removed_orphaned_twins', 0)} orphaned twin(s)",
|
|
325
|
+
f"skipped {data.get('skipped_in_use', 0)} in use, "
|
|
326
|
+
f"{data.get('skipped_above_bound', 0)} above the bound",
|
|
327
|
+
f"live rows {data.get('live_count_before', 0)} -> "
|
|
328
|
+
f"{data.get('live_count_after', 0)}, "
|
|
329
|
+
f"{data.get('bytes_removed', 0)} byte(s) reclaimed",
|
|
330
|
+
f"ranges compacted: {data.get('ranges_compacted', 0)} "
|
|
331
|
+
f"in {data.get('elapsed_ms', 0)}ms",
|
|
332
|
+
]
|
|
333
|
+
if data.get("truncated_by_budget"):
|
|
334
|
+
lines.append("[warning]Budget reached - rerun to continue.[/warning]")
|
|
335
|
+
title = "[header]WAL compaction" + (" (dry run)" if dry else " (APPLIED)")
|
|
336
|
+
console.print(
|
|
337
|
+
Panel(
|
|
338
|
+
"\n".join(lines),
|
|
339
|
+
title=title + "[/header]",
|
|
340
|
+
border_style="yellow" if dry else "red",
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@admin_group.group("retention")
|
|
346
|
+
def retention_group() -> None:
|
|
347
|
+
"""Unified-WAL retention - per-scope rules and the expiry sweep.
|
|
348
|
+
|
|
349
|
+
Requires trusted-operator access. Reporting needs `diagnostics.read`;
|
|
350
|
+
storing a rule or applying a sweep needs `admin.compact`.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@retention_group.command("show")
|
|
355
|
+
@click.pass_context
|
|
356
|
+
@handle_errors
|
|
357
|
+
def retention_show(ctx: click.Context) -> None:
|
|
358
|
+
"""Config, stored rules, cursor and last tick (GET /v1/admin/retention).
|
|
359
|
+
|
|
360
|
+
503 NOT_AVAILABLE when this process runs no retention sweeper (legacy
|
|
361
|
+
WAL, no coordinator, or a configuration the sweeper refused).
|
|
362
|
+
"""
|
|
363
|
+
cfg = ctx.obj
|
|
364
|
+
with get_client(cfg) as client:
|
|
365
|
+
if not is_json_mode(cfg):
|
|
366
|
+
with spinner("Loading retention status..."):
|
|
367
|
+
resp = client.get("/v1/admin/retention")
|
|
368
|
+
else:
|
|
369
|
+
resp = client.get("/v1/admin/retention")
|
|
370
|
+
resp.raise_for_status()
|
|
371
|
+
_print_retention_status(resp.json(), cfg)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@retention_group.command("set")
|
|
375
|
+
@click.option("--scope", "-S", required=True, help="Scope path the rule is keyed on.")
|
|
376
|
+
@click.option(
|
|
377
|
+
"--days",
|
|
378
|
+
type=click.IntRange(min=1),
|
|
379
|
+
default=None,
|
|
380
|
+
help="Expire captured events after this many days. Omit for an explicit KEEP.",
|
|
381
|
+
)
|
|
382
|
+
@click.option(
|
|
383
|
+
"--source-days",
|
|
384
|
+
"source_days",
|
|
385
|
+
multiple=True,
|
|
386
|
+
metavar="KEY=DAYS",
|
|
387
|
+
help="Per-source override, e.g. source:slack=30. Repeatable.",
|
|
388
|
+
)
|
|
389
|
+
@click.pass_context
|
|
390
|
+
@handle_errors
|
|
391
|
+
def retention_set(
|
|
392
|
+
ctx: click.Context,
|
|
393
|
+
scope: str,
|
|
394
|
+
days: int | None,
|
|
395
|
+
source_days: tuple[str, ...],
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Store the retention rule for one scope (PUT /v1/admin/retention/policies).
|
|
398
|
+
|
|
399
|
+
Irreversible in effect: an applied sweep under this rule ERASES expired
|
|
400
|
+
events from the scope and every scope beneath it. Omitting `--days`
|
|
401
|
+
stores an explicit KEEP, which overrides the deployment default for the
|
|
402
|
+
subtree but never a shorter rule inherited from an ancestor scope.
|
|
403
|
+
Storing a rule always resets the durable sweep cursor.
|
|
404
|
+
"""
|
|
405
|
+
cfg = ctx.obj
|
|
406
|
+
body: dict[str, Any] = {"scope": scope, "default_retention_days": days}
|
|
407
|
+
per_source = _parse_source_days(source_days)
|
|
408
|
+
if per_source:
|
|
409
|
+
body["per_source_days"] = per_source
|
|
410
|
+
with get_client(cfg) as client:
|
|
411
|
+
resp = client.put("/v1/admin/retention/policies", json=body)
|
|
412
|
+
resp.raise_for_status()
|
|
413
|
+
data = resp.json()
|
|
414
|
+
if is_json_mode(cfg):
|
|
415
|
+
print_json(data)
|
|
416
|
+
return
|
|
417
|
+
policy = data.get("policy") or {}
|
|
418
|
+
stored = policy.get("default_retention_days")
|
|
419
|
+
reset = " (sweep cursor reset)" if data.get("cursor_reset") else ""
|
|
420
|
+
print_success(
|
|
421
|
+
f"Retention rule stored for {scope}: "
|
|
422
|
+
+ (f"{stored} day(s)" if stored else "keep")
|
|
423
|
+
+ reset
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@retention_group.command("delete")
|
|
428
|
+
@click.option("--scope", "-S", required=True, help="Scope path whose rule to remove.")
|
|
429
|
+
@click.pass_context
|
|
430
|
+
@handle_errors
|
|
431
|
+
def retention_delete(ctx: click.Context, scope: str) -> None:
|
|
432
|
+
"""Remove one scope's rule (DELETE /v1/admin/retention/policies).
|
|
433
|
+
|
|
434
|
+
404 when a well-formed store held no rule for exactly this scope -
|
|
435
|
+
inheritance is resolution, not storage, so a descendant that expires by
|
|
436
|
+
an ancestor's rule has no rule of its own to delete. Resets the durable
|
|
437
|
+
sweep cursor, like `set`.
|
|
438
|
+
"""
|
|
439
|
+
cfg = ctx.obj
|
|
440
|
+
with get_client(cfg) as client:
|
|
441
|
+
resp = client.delete("/v1/admin/retention/policies", params={"scope": scope})
|
|
442
|
+
resp.raise_for_status()
|
|
443
|
+
if is_json_mode(cfg):
|
|
444
|
+
print_json({"scope": scope, "deleted": True, "cursor_reset": True})
|
|
445
|
+
return
|
|
446
|
+
print_success(f"Retention rule removed for {scope} (sweep cursor reset)")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@retention_group.command("sweep")
|
|
450
|
+
@click.option(
|
|
451
|
+
"--apply",
|
|
452
|
+
"apply_",
|
|
453
|
+
is_flag=True,
|
|
454
|
+
default=False,
|
|
455
|
+
help="ERASE expired events. Without it the tick only reports.",
|
|
456
|
+
)
|
|
457
|
+
@click.option("--scope", "-S", default=None, help="Confine the scan to this subtree.")
|
|
458
|
+
@click.option(
|
|
459
|
+
"--older-than-secs",
|
|
460
|
+
type=click.IntRange(min=1),
|
|
461
|
+
default=None,
|
|
462
|
+
help="Request-only TTL override for --scope. Never persisted.",
|
|
463
|
+
)
|
|
464
|
+
@click.option(
|
|
465
|
+
"--max-events",
|
|
466
|
+
type=click.IntRange(min=1),
|
|
467
|
+
default=None,
|
|
468
|
+
help="Override the per-tick expiry budget.",
|
|
469
|
+
)
|
|
470
|
+
@click.option(
|
|
471
|
+
"--rescan",
|
|
472
|
+
is_flag=True,
|
|
473
|
+
default=False,
|
|
474
|
+
help="Ignore the durable cursor and scan from the start of the log.",
|
|
475
|
+
)
|
|
476
|
+
@click.pass_context
|
|
477
|
+
@handle_errors
|
|
478
|
+
def retention_sweep(
|
|
479
|
+
ctx: click.Context,
|
|
480
|
+
apply_: bool,
|
|
481
|
+
scope: str | None,
|
|
482
|
+
older_than_secs: int | None,
|
|
483
|
+
max_events: int | None,
|
|
484
|
+
rescan: bool,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Run one retention tick (POST /v1/admin/retention/sweep).
|
|
487
|
+
|
|
488
|
+
DRY RUN IS THE DEFAULT - the tick reports what would be erased and
|
|
489
|
+
writes nothing. `--apply` is what sends `dry_run: false`, which erases
|
|
490
|
+
expired captured events irreversibly and needs `admin.compact`.
|
|
491
|
+
|
|
492
|
+
Single-flight: a second concurrent tick is refused with 409
|
|
493
|
+
RETENTION_SWEEP_RUNNING rather than queued.
|
|
494
|
+
"""
|
|
495
|
+
cfg = ctx.obj
|
|
496
|
+
if older_than_secs is not None and not scope:
|
|
497
|
+
raise click.UsageError("--older-than-secs requires --scope.")
|
|
498
|
+
body: dict[str, Any] = {"dry_run": not apply_}
|
|
499
|
+
if scope:
|
|
500
|
+
body["scope"] = scope
|
|
501
|
+
if older_than_secs is not None:
|
|
502
|
+
body["older_than_secs"] = older_than_secs
|
|
503
|
+
if max_events is not None:
|
|
504
|
+
body["max_events"] = max_events
|
|
505
|
+
if rescan:
|
|
506
|
+
body["rescan"] = True
|
|
507
|
+
with get_client(cfg) as client:
|
|
508
|
+
if not is_json_mode(cfg):
|
|
509
|
+
with spinner("Sweeping..."):
|
|
510
|
+
resp = client.post("/v1/admin/retention/sweep", json=body)
|
|
511
|
+
else:
|
|
512
|
+
resp = client.post("/v1/admin/retention/sweep", json=body)
|
|
513
|
+
resp.raise_for_status()
|
|
514
|
+
_print_sweep_report(resp.json(), cfg)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@admin_group.group("wal")
|
|
518
|
+
def wal_group() -> None:
|
|
519
|
+
"""Unified write-ahead log - census and dead-row compaction.
|
|
520
|
+
|
|
521
|
+
Requires trusted-operator access. A legacy data dir is refused with 409
|
|
522
|
+
RETENTION_UNSUPPORTED_BACKEND naming the offline migrator.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@wal_group.command("status")
|
|
527
|
+
@click.option(
|
|
528
|
+
"--verify",
|
|
529
|
+
is_flag=True,
|
|
530
|
+
default=False,
|
|
531
|
+
help=(
|
|
532
|
+
"Also run the bounded row-level self-check (clamped server-side). "
|
|
533
|
+
"The check is bounded at 1M rows / 512 MiB, which a production-sized "
|
|
534
|
+
"log exceeds: it then reports 'not run', which is not a failure."
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
@click.pass_context
|
|
538
|
+
@handle_errors
|
|
539
|
+
def wal_status(ctx: click.Context, verify: bool) -> None:
|
|
540
|
+
"""Live rows, offsets, consumer floors, markers (GET /v1/admin/wal)."""
|
|
541
|
+
cfg = ctx.obj
|
|
542
|
+
params = {"verify": "true"} if verify else None
|
|
543
|
+
with get_client(cfg) as client:
|
|
544
|
+
if not is_json_mode(cfg):
|
|
545
|
+
with spinner("Loading WAL census..."):
|
|
546
|
+
resp = client.get("/v1/admin/wal", params=params)
|
|
547
|
+
else:
|
|
548
|
+
resp = client.get("/v1/admin/wal", params=params)
|
|
549
|
+
resp.raise_for_status()
|
|
550
|
+
_print_wal_status(resp.json(), cfg)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@wal_group.command("compact")
|
|
554
|
+
@click.option(
|
|
555
|
+
"--apply",
|
|
556
|
+
"apply_",
|
|
557
|
+
is_flag=True,
|
|
558
|
+
default=False,
|
|
559
|
+
help="Physically remove the dead rows. Without it the run only reports.",
|
|
560
|
+
)
|
|
561
|
+
@click.option(
|
|
562
|
+
"--max-rows",
|
|
563
|
+
type=click.IntRange(min=1),
|
|
564
|
+
default=None,
|
|
565
|
+
help="Upper bound on dead rows removed in one sweep.",
|
|
566
|
+
)
|
|
567
|
+
@click.option(
|
|
568
|
+
"--below-offset",
|
|
569
|
+
type=click.IntRange(min=0),
|
|
570
|
+
default=None,
|
|
571
|
+
help="Exclusive offset bound. Defaults to the indexer watermark.",
|
|
572
|
+
)
|
|
573
|
+
@click.option(
|
|
574
|
+
"--compaction",
|
|
575
|
+
type=click.Choice(["none", "touched_ranges", "full"]),
|
|
576
|
+
default=None,
|
|
577
|
+
help="Physical reclamation mode. `full` is a whole-CF compaction.",
|
|
578
|
+
)
|
|
579
|
+
@click.pass_context
|
|
580
|
+
@handle_errors
|
|
581
|
+
def wal_compact(
|
|
582
|
+
ctx: click.Context,
|
|
583
|
+
apply_: bool,
|
|
584
|
+
max_rows: int | None,
|
|
585
|
+
below_offset: int | None,
|
|
586
|
+
compaction: str | None,
|
|
587
|
+
) -> None:
|
|
588
|
+
"""Reclaim already-dead WAL rows (POST /v1/admin/wal/compact).
|
|
589
|
+
|
|
590
|
+
Removes only rows that are ALREADY logically dead - redaction
|
|
591
|
+
tombstones and orphaned Derived twins. It never removes a live row and
|
|
592
|
+
never touches derived-artifact history.
|
|
593
|
+
|
|
594
|
+
DRY RUN IS THE DEFAULT; `--apply` is what sends `dry_run: false` and
|
|
595
|
+
needs `admin.compact`. Single-flight: 409 WAL_COMPACTION_RUNNING.
|
|
596
|
+
"""
|
|
597
|
+
cfg = ctx.obj
|
|
598
|
+
body: dict[str, Any] = {"dry_run": not apply_}
|
|
599
|
+
if max_rows is not None:
|
|
600
|
+
body["max_rows"] = max_rows
|
|
601
|
+
if below_offset is not None:
|
|
602
|
+
body["below_offset"] = below_offset
|
|
603
|
+
if compaction is not None:
|
|
604
|
+
body["compaction"] = compaction
|
|
605
|
+
with get_client(cfg) as client:
|
|
606
|
+
if not is_json_mode(cfg):
|
|
607
|
+
with spinner("Compacting..."):
|
|
608
|
+
resp = client.post("/v1/admin/wal/compact", json=body)
|
|
609
|
+
else:
|
|
610
|
+
resp = client.post("/v1/admin/wal/compact", json=body)
|
|
611
|
+
resp.raise_for_status()
|
|
612
|
+
_print_compaction(resp.json(), cfg)
|