postgres-aiops 0.10.2__tar.gz → 0.11.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.
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/.claude-plugin/plugin.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/.mcp.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/CHANGELOG.md +29 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/PKG-INFO +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/RELEASE_NOTES.md +3 -0
- postgres_aiops-0.11.0/mcp_server/_annotations.py +66 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/server.py +5 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/openclaw.plugin.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/package.json +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/_common.py +40 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/activity.py +4 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/analyze.py +4 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/index.py +5 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/overview.py +2 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/query.py +3 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/replication.py +4 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/server.py +6 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/table.py +4 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/analysis.py +28 -8
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/pyproject.toml +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/server.json +2 -2
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/agent-guardrails.md +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/capabilities.md +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_analysis.py +45 -0
- postgres_aiops-0.11.0/tests/test_cli_audited.py +131 -0
- postgres_aiops-0.11.0/tests/test_tool_annotations.py +128 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/uv.lock +1 -1
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/.github/workflows/mcp-publish.yml +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/.gitignore +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/Dockerfile +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/LICENSE +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/README.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/SECURITY.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/docs/VERIFICATION.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/glama.json +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/_shared.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/remediation.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/mcp_server/tools/undo.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/_root.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/remediate.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/cli/undo.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/decorators.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/outcome.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/remediation.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/skills/postgres-aiops/SKILL.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/smithery.yaml +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/conftest.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_cli_governed_exit.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_cli_reads.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_cli_remediate.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_cli_secret.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_config.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_connection_lost.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_connection_more.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_gov_audit.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_gov_decorators.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_gov_patterns.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_gov_policy.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_governance_persistence.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_init.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_mcp_tools.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_ops_more.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_optional_fields.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_reads.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_secretstore_more.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_self_lockout.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_smoke.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_truncation.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_undo_executor.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_undo_replay.py +0 -0
- {postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/tests/test_writes.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "postgres-aiops",
|
|
4
4
|
"displayName": "Postgres AIops",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.11.0",
|
|
6
6
|
"description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Wei Zhou",
|
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.11.0 — 2026-10-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- MCP tool annotations derived from each tool's governance `risk_level`, so
|
|
7
|
+
clients can gate approval from `tools/list` alone. `low` tools advertise
|
|
8
|
+
`readOnlyHint: true`; every other tool advertises `readOnlyHint: false` and
|
|
9
|
+
`destructiveHint: true`. The `medium` tier includes deletes and hard
|
|
10
|
+
power-offs, so a tool is never advertised as non-destructive on the strength
|
|
11
|
+
of its tier. `idempotentHint` only repeats an explicit declaration. The exact
|
|
12
|
+
level is published in `_meta` under `io.github.AIops-tools/riskLevel`. These
|
|
13
|
+
are hints for the client; audit and undo are unchanged.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `blocking_lock_chain_rca`: the chain root's state was dropped and a missing
|
|
17
|
+
query became `""`. For a backend that is `idle in transaction`, `rootQuery` is
|
|
18
|
+
the last statement it ran, not a running one, so the recommended
|
|
19
|
+
`cancel_query` had nothing to cancel. Roots now carry `rootState` and
|
|
20
|
+
`rootIdle` (true for any `idle*` state, including plain `idle` with session-level locks), a missing query stays `null`, and the action for an
|
|
21
|
+
idle root points at `terminate_backend` (which rolls the transaction back).
|
|
22
|
+
|
|
23
|
+
## v0.10.3 — 2026-09-15
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- CLI commands that read the engine without calling an MCP tool now write an
|
|
27
|
+
audit row, as every MCP call does: they run through the same `@governed_tool`
|
|
28
|
+
harness, budget and runaway guard included. Before, an operator's CLI reads
|
|
29
|
+
left no trace in `audit.db`, contrary to the documented guarantee that MCP
|
|
30
|
+
and CLI are audited alike. A test fails if any engine command escapes it.
|
|
31
|
+
|
|
3
32
|
## v0.10.2 — 2026-09-12
|
|
4
33
|
|
|
5
34
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)
|
|
5
5
|
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Release notes — postgres-aiops 0.6.0
|
|
2
2
|
|
|
3
|
+
> Historical — this file covers the 0.6.0 release only and is not updated.
|
|
4
|
+
> For every release since, see [CHANGELOG.md](CHANGELOG.md) and the GitHub releases.
|
|
5
|
+
|
|
3
6
|
Previous release: 0.5.0.
|
|
4
7
|
|
|
5
8
|
## BREAKING — the authorization layer is removed
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Derive MCP tool annotations from the governance risk level.
|
|
2
|
+
|
|
3
|
+
Every tool already declares ``risk_level`` on ``@governed_tool``; this module
|
|
4
|
+
publishes it to MCP clients as the standard ``ToolAnnotations`` hints plus the
|
|
5
|
+
exact level under ``_meta``, so a client can gate approval without reading our
|
|
6
|
+
docstrings.
|
|
7
|
+
|
|
8
|
+
Only the safe direction is ever asserted:
|
|
9
|
+
|
|
10
|
+
- ``low`` → ``readOnlyHint: true``. Backed by the smoke-test invariant that
|
|
11
|
+
``risk_level == "low"`` exactly when the docstring says ``[READ]``.
|
|
12
|
+
- anything else → ``readOnlyHint: false, destructiveHint: true``. The
|
|
13
|
+
``medium`` tier is NOT non-destructive — it holds deletes, hard power-offs,
|
|
14
|
+
state kills and stat resets — so claiming ``destructiveHint: false`` from the
|
|
15
|
+
tier would tell a client it may skip approval for a delete. Over-warning
|
|
16
|
+
costs a confirmation prompt; under-warning costs data.
|
|
17
|
+
- ``idempotentHint`` only echoes an explicit ``@governed_tool(idempotent=True)``
|
|
18
|
+
declaration; it is never inferred.
|
|
19
|
+
- ``openWorldHint`` is left unset (spec default ``true``): every tool talks to
|
|
20
|
+
an external system.
|
|
21
|
+
|
|
22
|
+
This file is identical across the AIops line; keep it that way.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from mcp.types import ToolAnnotations
|
|
28
|
+
|
|
29
|
+
RISK_META_KEY = "io.github.AIops-tools/riskLevel"
|
|
30
|
+
RISK_LEVELS = frozenset({"low", "medium", "high", "critical"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def annotations_for(risk_level: str, idempotent: bool) -> ToolAnnotations:
|
|
34
|
+
"""Return the MCP hints that a tool of ``risk_level`` may honestly claim."""
|
|
35
|
+
if risk_level not in RISK_LEVELS:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"unknown risk_level {risk_level!r}; expected one of {sorted(RISK_LEVELS)}"
|
|
38
|
+
)
|
|
39
|
+
if risk_level == "low":
|
|
40
|
+
return ToolAnnotations(read_only_hint=True)
|
|
41
|
+
return ToolAnnotations(
|
|
42
|
+
read_only_hint=False,
|
|
43
|
+
destructive_hint=True,
|
|
44
|
+
idempotent_hint=idempotent,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def apply_risk_annotations(server: Any) -> None:
|
|
49
|
+
"""Replace every registered tool with a copy carrying its risk annotations.
|
|
50
|
+
|
|
51
|
+
Fails fast on a tool without ``@governed_tool`` metadata: an unannotated
|
|
52
|
+
tool would be advertised with the spec defaults, i.e. as a destructive
|
|
53
|
+
write with no stated risk level — wrong for a read, silent for a write.
|
|
54
|
+
"""
|
|
55
|
+
tools = server._tool_manager._tools # same private registry undo.py relies on
|
|
56
|
+
for name, tool in list(tools.items()):
|
|
57
|
+
risk_level = getattr(tool.fn, "_risk_level", None)
|
|
58
|
+
if risk_level is None:
|
|
59
|
+
raise RuntimeError(f"tool {name!r} has no @governed_tool risk_level")
|
|
60
|
+
idempotent = bool(getattr(tool.fn, "_idempotent", False))
|
|
61
|
+
tools[name] = tool.model_copy(
|
|
62
|
+
update={
|
|
63
|
+
"annotations": annotations_for(risk_level, idempotent),
|
|
64
|
+
"meta": {**(tool.meta or {}), RISK_META_KEY: risk_level},
|
|
65
|
+
}
|
|
66
|
+
)
|
|
@@ -14,6 +14,7 @@ License: MIT
|
|
|
14
14
|
import logging
|
|
15
15
|
import os
|
|
16
16
|
|
|
17
|
+
from mcp_server._annotations import apply_risk_annotations
|
|
17
18
|
from mcp_server._shared import _safe_error, mcp, tool_errors
|
|
18
19
|
|
|
19
20
|
# Importing the tool modules registers every @mcp.tool() onto the shared
|
|
@@ -30,6 +31,10 @@ from mcp_server.tools import ( # noqa: F401 — side effects
|
|
|
30
31
|
undo,
|
|
31
32
|
)
|
|
32
33
|
|
|
34
|
+
# Publish each tool's governance risk level as MCP annotations. Runs after
|
|
35
|
+
# every tool module has registered, so no tool is advertised unannotated.
|
|
36
|
+
apply_risk_annotations(mcp)
|
|
37
|
+
|
|
33
38
|
__all__ = ["mcp", "main", "_safe_error", "tool_errors"]
|
|
34
39
|
|
|
35
40
|
logger = logging.getLogger(__name__)
|
|
@@ -180,3 +180,43 @@ def double_confirm(action: str, resource: str) -> None:
|
|
|
180
180
|
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
181
181
|
abort=True,
|
|
182
182
|
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def audited(fn: Callable) -> Callable:
|
|
186
|
+
"""Audit a CLI command that reaches the engine without calling an MCP tool.
|
|
187
|
+
|
|
188
|
+
MCP tools write their audit row through ``@governed_tool``; a CLI command that
|
|
189
|
+
called the ops layer directly used to write none, so an operator's CLI reads
|
|
190
|
+
left no trail. This runs the command through the same harness: one audit row per
|
|
191
|
+
invocation (tool = the command function, params = its options), plus the budget
|
|
192
|
+
and runaway guard. ``typer.Exit`` is how a command ends: exit code 0 is audited
|
|
193
|
+
``ok``, a non-zero code ``error``, and the exit is re-raised unchanged. Any other
|
|
194
|
+
exception is audited ``error`` and propagates to ``cli_errors`` as before.
|
|
195
|
+
"""
|
|
196
|
+
import functools as _functools
|
|
197
|
+
|
|
198
|
+
from postgres_aiops.governance import governed_tool
|
|
199
|
+
|
|
200
|
+
@_functools.wraps(fn)
|
|
201
|
+
def body(*args: Any, **kwargs: Any) -> Any:
|
|
202
|
+
try:
|
|
203
|
+
fn(*args, **kwargs)
|
|
204
|
+
except typer.Exit as exc:
|
|
205
|
+
code = exc.exit_code
|
|
206
|
+
outcome: dict[str, Any] = {"_exitCode": code}
|
|
207
|
+
if code:
|
|
208
|
+
outcome["error"] = f"command exited with code {code}"
|
|
209
|
+
return outcome
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
governed = governed_tool(risk_level="low")(body)
|
|
213
|
+
|
|
214
|
+
@_functools.wraps(fn)
|
|
215
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
216
|
+
result = governed(*args, **kwargs)
|
|
217
|
+
if isinstance(result, dict) and "_exitCode" in result:
|
|
218
|
+
raise typer.Exit(result["_exitCode"])
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
wrapper._is_audited_cli = True # type: ignore[attr-defined]
|
|
222
|
+
return wrapper
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
activity_app = typer.Typer(
|
|
13
13
|
name="activity",
|
|
@@ -18,6 +18,7 @@ activity_app = typer.Typer(
|
|
|
18
18
|
|
|
19
19
|
@activity_app.command("list")
|
|
20
20
|
@cli_errors
|
|
21
|
+
@audited
|
|
21
22
|
def activity_list(
|
|
22
23
|
state: Annotated[str | None, typer.Option("--state", help="Filter by state")] = None,
|
|
23
24
|
target: TargetOption = None,
|
|
@@ -31,6 +32,7 @@ def activity_list(
|
|
|
31
32
|
|
|
32
33
|
@activity_app.command("long")
|
|
33
34
|
@cli_errors
|
|
35
|
+
@audited
|
|
34
36
|
def activity_long(
|
|
35
37
|
min_seconds: Annotated[int, typer.Option("--min-seconds", help="Minimum age")] = 60,
|
|
36
38
|
target: TargetOption = None,
|
|
@@ -44,6 +46,7 @@ def activity_long(
|
|
|
44
46
|
|
|
45
47
|
@activity_app.command("locks")
|
|
46
48
|
@cli_errors
|
|
49
|
+
@audited
|
|
47
50
|
def activity_locks(target: TargetOption = None) -> None:
|
|
48
51
|
"""List held/awaited locks."""
|
|
49
52
|
from postgres_aiops.ops import activity as ops
|
|
@@ -8,6 +8,7 @@ import typer
|
|
|
8
8
|
|
|
9
9
|
from postgres_aiops.cli._common import (
|
|
10
10
|
TargetOption,
|
|
11
|
+
audited,
|
|
11
12
|
cli_errors,
|
|
12
13
|
get_connection,
|
|
13
14
|
print_result,
|
|
@@ -22,6 +23,7 @@ analyze_app = typer.Typer(
|
|
|
22
23
|
|
|
23
24
|
@analyze_app.command("slow-query")
|
|
24
25
|
@cli_errors
|
|
26
|
+
@audited
|
|
25
27
|
def analyze_slow_query(
|
|
26
28
|
explain_sql: Annotated[str | None, typer.Option("--explain", help="SQL to EXPLAIN")] = None,
|
|
27
29
|
limit: Annotated[int, typer.Option("--limit", help="Statements to consider")] = 20,
|
|
@@ -41,6 +43,7 @@ def analyze_slow_query(
|
|
|
41
43
|
|
|
42
44
|
@analyze_app.command("bloat-vacuum")
|
|
43
45
|
@cli_errors
|
|
46
|
+
@audited
|
|
44
47
|
def analyze_bloat_vacuum(
|
|
45
48
|
limit: Annotated[int, typer.Option("--limit", help="Tables to consider")] = 50,
|
|
46
49
|
target: TargetOption = None,
|
|
@@ -58,6 +61,7 @@ def analyze_bloat_vacuum(
|
|
|
58
61
|
|
|
59
62
|
@analyze_app.command("blocking")
|
|
60
63
|
@cli_errors
|
|
64
|
+
@audited
|
|
61
65
|
def analyze_blocking(target: TargetOption = None) -> None:
|
|
62
66
|
"""Build the blocking-lock chain and name the root blocker."""
|
|
63
67
|
from postgres_aiops.ops import activity, analysis
|
|
@@ -9,6 +9,7 @@ import typer
|
|
|
9
9
|
|
|
10
10
|
from postgres_aiops.cli._common import (
|
|
11
11
|
TargetOption,
|
|
12
|
+
audited,
|
|
12
13
|
cli_errors,
|
|
13
14
|
console,
|
|
14
15
|
get_connection,
|
|
@@ -24,6 +25,7 @@ index_app = typer.Typer(
|
|
|
24
25
|
|
|
25
26
|
@index_app.command("unused")
|
|
26
27
|
@cli_errors
|
|
28
|
+
@audited
|
|
27
29
|
def index_unused(target: TargetOption = None) -> None:
|
|
28
30
|
"""Non-unique, non-primary indexes with zero scans."""
|
|
29
31
|
from postgres_aiops.ops import indexes as ops
|
|
@@ -34,6 +36,7 @@ def index_unused(target: TargetOption = None) -> None:
|
|
|
34
36
|
|
|
35
37
|
@index_app.command("missing")
|
|
36
38
|
@cli_errors
|
|
39
|
+
@audited
|
|
37
40
|
def index_missing(target: TargetOption = None) -> None:
|
|
38
41
|
"""Tables with heavy sequential scans (missing-index hints)."""
|
|
39
42
|
from postgres_aiops.ops import indexes as ops
|
|
@@ -44,6 +47,7 @@ def index_missing(target: TargetOption = None) -> None:
|
|
|
44
47
|
|
|
45
48
|
@index_app.command("bloat")
|
|
46
49
|
@cli_errors
|
|
50
|
+
@audited
|
|
47
51
|
def index_bloat(
|
|
48
52
|
limit: Annotated[int, typer.Option("--limit", help="Indexes to inspect")] = 50,
|
|
49
53
|
target: TargetOption = None,
|
|
@@ -57,6 +61,7 @@ def index_bloat(
|
|
|
57
61
|
|
|
58
62
|
@index_app.command("invalid")
|
|
59
63
|
@cli_errors
|
|
64
|
+
@audited
|
|
60
65
|
def index_invalid(target: TargetOption = None) -> None:
|
|
61
66
|
"""Invalid and duplicate indexes."""
|
|
62
67
|
from postgres_aiops.ops import indexes as ops
|
|
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
7
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@cli_errors
|
|
11
|
+
@audited
|
|
11
12
|
def overview_cmd(target: TargetOption = None) -> None:
|
|
12
13
|
"""One-shot cluster health: version, connections, long queries, bloat, replication."""
|
|
13
14
|
from postgres_aiops.ops import overview as ops
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from postgres_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
audited,
|
|
13
14
|
checked,
|
|
14
15
|
cli_errors,
|
|
15
16
|
console,
|
|
@@ -28,6 +29,7 @@ query_app = typer.Typer(
|
|
|
28
29
|
|
|
29
30
|
@query_app.command("top")
|
|
30
31
|
@cli_errors
|
|
32
|
+
@audited
|
|
31
33
|
def query_top(
|
|
32
34
|
order_by: Annotated[
|
|
33
35
|
str, typer.Option("--order-by", help="total_time|mean_time|calls|rows|io")
|
|
@@ -44,6 +46,7 @@ def query_top(
|
|
|
44
46
|
|
|
45
47
|
@query_app.command("explain")
|
|
46
48
|
@cli_errors
|
|
49
|
+
@audited
|
|
47
50
|
def query_explain(
|
|
48
51
|
sql: Annotated[str, typer.Argument(help="A single SQL statement to EXPLAIN")],
|
|
49
52
|
analyze: Annotated[
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
9
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
10
10
|
|
|
11
11
|
repl_app = typer.Typer(
|
|
12
12
|
name="repl",
|
|
@@ -17,6 +17,7 @@ repl_app = typer.Typer(
|
|
|
17
17
|
|
|
18
18
|
@repl_app.command("status")
|
|
19
19
|
@cli_errors
|
|
20
|
+
@audited
|
|
20
21
|
def repl_status(target: TargetOption = None) -> None:
|
|
21
22
|
"""Connected standbys and replay lag."""
|
|
22
23
|
from postgres_aiops.ops import replication as ops
|
|
@@ -27,6 +28,7 @@ def repl_status(target: TargetOption = None) -> None:
|
|
|
27
28
|
|
|
28
29
|
@repl_app.command("slots")
|
|
29
30
|
@cli_errors
|
|
31
|
+
@audited
|
|
30
32
|
def repl_slots(target: TargetOption = None) -> None:
|
|
31
33
|
"""Replication slots (inactive slots flagged)."""
|
|
32
34
|
from postgres_aiops.ops import replication as ops
|
|
@@ -37,6 +39,7 @@ def repl_slots(target: TargetOption = None) -> None:
|
|
|
37
39
|
|
|
38
40
|
@repl_app.command("wal")
|
|
39
41
|
@cli_errors
|
|
42
|
+
@audited
|
|
40
43
|
def repl_wal(target: TargetOption = None) -> None:
|
|
41
44
|
"""WAL position, level and archiver health."""
|
|
42
45
|
from postgres_aiops.ops import replication as ops
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from postgres_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
server_app = typer.Typer(
|
|
13
13
|
name="server",
|
|
@@ -18,6 +18,7 @@ server_app = typer.Typer(
|
|
|
18
18
|
|
|
19
19
|
@server_app.command("version")
|
|
20
20
|
@cli_errors
|
|
21
|
+
@audited
|
|
21
22
|
def server_version(target: TargetOption = None) -> None:
|
|
22
23
|
"""Server version, uptime and recovery state."""
|
|
23
24
|
from postgres_aiops.ops import server as ops
|
|
@@ -28,6 +29,7 @@ def server_version(target: TargetOption = None) -> None:
|
|
|
28
29
|
|
|
29
30
|
@server_app.command("settings")
|
|
30
31
|
@cli_errors
|
|
32
|
+
@audited
|
|
31
33
|
def server_settings(
|
|
32
34
|
pattern: Annotated[str | None, typer.Argument(help="Name substring filter")] = None,
|
|
33
35
|
target: TargetOption = None,
|
|
@@ -41,6 +43,7 @@ def server_settings(
|
|
|
41
43
|
|
|
42
44
|
@server_app.command("extensions")
|
|
43
45
|
@cli_errors
|
|
46
|
+
@audited
|
|
44
47
|
def server_extensions(target: TargetOption = None) -> None:
|
|
45
48
|
"""Installed extensions."""
|
|
46
49
|
from postgres_aiops.ops import server as ops
|
|
@@ -51,6 +54,7 @@ def server_extensions(target: TargetOption = None) -> None:
|
|
|
51
54
|
|
|
52
55
|
@server_app.command("databases")
|
|
53
56
|
@cli_errors
|
|
57
|
+
@audited
|
|
54
58
|
def server_databases(target: TargetOption = None) -> None:
|
|
55
59
|
"""Databases with owner, encoding and size."""
|
|
56
60
|
from postgres_aiops.ops import server as ops
|
|
@@ -61,6 +65,7 @@ def server_databases(target: TargetOption = None) -> None:
|
|
|
61
65
|
|
|
62
66
|
@server_app.command("roles")
|
|
63
67
|
@cli_errors
|
|
68
|
+
@audited
|
|
64
69
|
def server_roles(target: TargetOption = None) -> None:
|
|
65
70
|
"""Roles and their attributes."""
|
|
66
71
|
from postgres_aiops.ops import server as ops
|
|
@@ -8,6 +8,7 @@ import typer
|
|
|
8
8
|
|
|
9
9
|
from postgres_aiops.cli._common import (
|
|
10
10
|
TargetOption,
|
|
11
|
+
audited,
|
|
11
12
|
cli_errors,
|
|
12
13
|
get_connection,
|
|
13
14
|
print_result,
|
|
@@ -22,6 +23,7 @@ table_app = typer.Typer(
|
|
|
22
23
|
|
|
23
24
|
@table_app.command("sizes")
|
|
24
25
|
@cli_errors
|
|
26
|
+
@audited
|
|
25
27
|
def table_sizes(
|
|
26
28
|
limit: Annotated[int, typer.Option("--limit", help="Tables to return")] = 20,
|
|
27
29
|
target: TargetOption = None,
|
|
@@ -35,6 +37,7 @@ def table_sizes(
|
|
|
35
37
|
|
|
36
38
|
@table_app.command("bloat")
|
|
37
39
|
@cli_errors
|
|
40
|
+
@audited
|
|
38
41
|
def table_bloat(
|
|
39
42
|
limit: Annotated[int, typer.Option("--limit", help="Tables to inspect")] = 50,
|
|
40
43
|
target: TargetOption = None,
|
|
@@ -48,6 +51,7 @@ def table_bloat(
|
|
|
48
51
|
|
|
49
52
|
@table_app.command("autovacuum")
|
|
50
53
|
@cli_errors
|
|
54
|
+
@audited
|
|
51
55
|
def table_autovacuum(
|
|
52
56
|
limit: Annotated[int, typer.Option("--limit", help="Tables to inspect")] = 50,
|
|
53
57
|
target: TargetOption = None,
|
|
@@ -201,6 +201,22 @@ def _descendants(root: int, children: dict[int, list[int]]) -> set[int]:
|
|
|
201
201
|
return seen
|
|
202
202
|
|
|
203
203
|
|
|
204
|
+
def _lock_root_action(pid: int, idle: bool | None) -> str:
|
|
205
|
+
if idle:
|
|
206
|
+
return (
|
|
207
|
+
f"Backend {pid} heads the chain but is not running a statement (see "
|
|
208
|
+
"rootState: an open transaction, or session-level locks): rootQuery is "
|
|
209
|
+
"the last statement it ran, not a running one. cancel_query has nothing "
|
|
210
|
+
f"to cancel; terminate_backend({pid}) ends the session, rolling back any "
|
|
211
|
+
"transaction and releasing its locks — confirm with its owner first."
|
|
212
|
+
)
|
|
213
|
+
return (
|
|
214
|
+
f"Backend {pid} is the head of the chain — investigate its transaction; "
|
|
215
|
+
f"cancel_query({pid}) to stop its statement, or terminate_backend({pid}) to "
|
|
216
|
+
"end the session and release the locks."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
204
220
|
def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
|
|
205
221
|
"""[READ] Build the wait-for tree from blocking pairs and name the root blocker.
|
|
206
222
|
|
|
@@ -217,7 +233,8 @@ def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
|
|
|
217
233
|
return {"blockedBackends": 0, "roots": [], "note": "No blocking detected."}
|
|
218
234
|
|
|
219
235
|
children: dict[int, list[int]] = {}
|
|
220
|
-
blocking_query: dict[int, str] = {}
|
|
236
|
+
blocking_query: dict[int, str | None] = {}
|
|
237
|
+
blocking_state: dict[int, str | None] = {}
|
|
221
238
|
blocked_pids: set[int] = set()
|
|
222
239
|
blocking_pids: set[int] = set()
|
|
223
240
|
for e in edges:
|
|
@@ -225,7 +242,8 @@ def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
|
|
|
225
242
|
children.setdefault(g, []).append(b)
|
|
226
243
|
blocked_pids.add(b)
|
|
227
244
|
blocking_pids.add(g)
|
|
228
|
-
blocking_query.setdefault(g, e.get("blockingQuery")
|
|
245
|
+
blocking_query.setdefault(g, e.get("blockingQuery"))
|
|
246
|
+
blocking_state.setdefault(g, e.get("blockingState"))
|
|
229
247
|
|
|
230
248
|
root_pids = [pid for pid in blocking_pids if pid not in blocked_pids]
|
|
231
249
|
if not root_pids:
|
|
@@ -242,16 +260,18 @@ def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
|
|
|
242
260
|
roots = []
|
|
243
261
|
for pid in root_pids:
|
|
244
262
|
blocked = _descendants(pid, children)
|
|
263
|
+
state = blocking_state.get(pid)
|
|
264
|
+
# Any idle state means no statement is running: "idle in transaction"
|
|
265
|
+
# holds row locks, plain "idle" can still hold session-level advisory locks.
|
|
266
|
+
idle = None if state is None else str(state).startswith("idle")
|
|
245
267
|
roots.append({
|
|
246
268
|
"rootPid": pid,
|
|
247
269
|
"blockedCount": len(blocked),
|
|
248
270
|
"blockedPids": sorted(blocked),
|
|
249
|
-
"rootQuery": blocking_query.get(pid
|
|
250
|
-
"
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
"terminate_backend(pid) to end the session and release the locks."
|
|
254
|
-
),
|
|
271
|
+
"rootQuery": blocking_query.get(pid),
|
|
272
|
+
"rootState": state,
|
|
273
|
+
"rootIdle": idle,
|
|
274
|
+
"action": _lock_root_action(pid, idle),
|
|
255
275
|
})
|
|
256
276
|
roots.sort(key=lambda r: r["blockedCount"], reverse=True)
|
|
257
277
|
return {
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "postgres-aiops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
description = "Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/AIops-tools/Postgres-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.11.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "postgres-aiops",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.11.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
{postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/agent-guardrails.md
RENAMED
|
@@ -28,7 +28,7 @@ recorded on the audit row; they are never required and never block a call.
|
|
|
28
28
|
## What still needs a prompt
|
|
29
29
|
|
|
30
30
|
These are model-behaviour problems the harness cannot fix from the outside.
|
|
31
|
-
|
|
31
|
+
Recommended setup for a local model (adapt it to your agent client):
|
|
32
32
|
|
|
33
33
|
```text
|
|
34
34
|
You operate a PostgreSQL server through the postgres-aiops MCP tools.
|
{postgres_aiops-0.10.2 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/capabilities.md
RENAMED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
| `wal_status` | WAL fns + `pg_stat_archiver` | currentLsn, walLevel, max/minWalSize, archiver |
|
|
33
33
|
| `slow_query_rca` | pg_stat_statements + EXPLAIN | worst{}, findings[] (cited cause/action) |
|
|
34
34
|
| `bloat_and_vacuum_analysis` | table-bloat rows | recommendations[] (cited reasons + action) |
|
|
35
|
-
| `blocking_lock_chain_rca` | `pg_blocking_pids` pairs | roots[], worstRootPid, deadlockSuspected |
|
|
35
|
+
| `blocking_lock_chain_rca` | `pg_blocking_pids` pairs | roots[] (rootQuery, rootState, rootIdle — true for any `idle*` state; rootQuery is then the last statement run, not a running one — and action), worstRootPid, deadlockSuspected |
|
|
36
36
|
| `undo_list` | local undo store | recorded, not-yet-applied reversible writes: undoId, ts, originalTool, inverseTool, note |
|
|
37
37
|
|
|
38
38
|
The flagship analyses accept injected records (`statements=` / `tables=` /
|
|
@@ -82,3 +82,48 @@ def test_blocking_chain_detects_cycle():
|
|
|
82
82
|
def test_blocking_chain_no_blocking():
|
|
83
83
|
out = analysis.blocking_lock_chain_rca([])
|
|
84
84
|
assert out["blockedBackends"] == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── lock-chain root that is idle inside an open transaction ──────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pg_pairs(state, query="UPDATE t SET v=1 WHERE id=1"):
|
|
91
|
+
return [{"blockedPid": 201, "blockingPid": 100, "blockedQuery": "DELETE FROM t",
|
|
92
|
+
"blockingQuery": query, "blockingState": state}]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_idle_in_transaction_root_is_named_and_cancel_is_ruled_out():
|
|
96
|
+
from postgres_aiops.ops import analysis as a
|
|
97
|
+
|
|
98
|
+
(root,) = a.blocking_lock_chain_rca(_pg_pairs("idle in transaction"))["roots"]
|
|
99
|
+
assert root["rootState"] == "idle in transaction"
|
|
100
|
+
assert root["rootIdle"] is True
|
|
101
|
+
# The query is the LAST statement it ran, not a running one.
|
|
102
|
+
assert "last statement" in root["action"]
|
|
103
|
+
assert "terminate_backend(100)" in root["action"]
|
|
104
|
+
assert "nothing to cancel" in root["action"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_active_root_offers_cancel_first():
|
|
108
|
+
from postgres_aiops.ops import analysis as a
|
|
109
|
+
|
|
110
|
+
(root,) = a.blocking_lock_chain_rca(_pg_pairs("active"))["roots"]
|
|
111
|
+
assert root["rootIdle"] is False
|
|
112
|
+
assert "cancel_query(100)" in root["action"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_missing_root_query_stays_null():
|
|
116
|
+
from postgres_aiops.ops import analysis as a
|
|
117
|
+
|
|
118
|
+
(root,) = a.blocking_lock_chain_rca(_pg_pairs(None, query=None))["roots"]
|
|
119
|
+
assert root["rootQuery"] is None
|
|
120
|
+
assert root["rootState"] is None
|
|
121
|
+
assert root["rootIdle"] is None # state unknown
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_plain_idle_root_with_session_locks_is_not_offered_cancel():
|
|
125
|
+
from postgres_aiops.ops import analysis as a
|
|
126
|
+
|
|
127
|
+
(root,) = a.blocking_lock_chain_rca(_pg_pairs("idle"))["roots"]
|
|
128
|
+
assert root["rootIdle"] is True
|
|
129
|
+
assert "terminate_backend(100)" in root["action"]
|