@tyroneross/build-loop 0.36.0 → 0.43.0
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.
- package/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/build-loop-surface.mdc +12 -11
- package/.cursor/rules/skill-index.mdc +33 -0
- package/AGENTS.md +213 -34
- package/README.md +99 -31
- package/agents/advisor.md +4 -4
- package/agents/alignment-checker.md +2 -2
- package/agents/architecture-scout.md +4 -4
- package/agents/build-orchestrator.md +38 -36
- package/agents/database-assessor.md +11 -5
- package/agents/design-contract-specialist.md +8 -8
- package/agents/fact-checker.md +13 -3
- package/agents/fix-critique.md +2 -2
- package/agents/independent-auditor.md +60 -7
- package/agents/leak-scanner.md +82 -0
- package/agents/overfitting-reviewer.md +2 -2
- package/agents/plan-critic.md +1 -1
- package/agents/promotion-reviewer.md +5 -5
- package/agents/retrospective-synthesizer.md +138 -35
- package/agents/scope-auditor.md +82 -11
- package/agents/security-reviewer.md +56 -2
- package/agents/self-improvement-architect.md +17 -3
- package/agents/transcript-pattern-miner.md +5 -5
- package/agents/ui-validator.md +1 -1
- package/bin/build-loop-debugger.js +143 -0
- package/bin/build-loop-install.js +1 -4
- package/bin/build-loop-load-probe.js +345 -0
- package/codex-skills/build-loop/SKILL.md +28 -6
- package/commands/feedback.md +37 -0
- package/dist/src/interactive-verifier.d.ts +1 -14
- package/dist/src/interactive-verifier.d.ts.map +1 -1
- package/dist/src/interactive-verifier.js +6 -113
- package/dist/src/interactive-verifier.js.map +1 -1
- package/dist/src/quality.d.ts +5 -0
- package/dist/src/quality.d.ts.map +1 -0
- package/dist/src/quality.js +81 -0
- package/dist/src/quality.js.map +1 -0
- package/dist/src/storage.d.ts.map +1 -1
- package/dist/src/storage.js +37 -3
- package/dist/src/storage.js.map +1 -1
- package/docs/agent-surface-policy.md +35 -31
- package/docs/memory-setup.md +19 -0
- package/hooks/git/pre-push +65 -4
- package/hooks/hooks.json +95 -38
- package/hooks/pre-commit +20 -1
- package/hooks/pre-edit-rally-point.sh +10 -3
- package/hooks/session-start-codex-hook-trust.sh +30 -0
- package/hooks/session-start-git-hooks.sh +3 -1
- package/hooks/session-start-rally-point.sh +52 -4
- package/hooks/session-start-worktree-gc.sh +47 -94
- package/hooks/stop-transcript-sweep.sh +173 -0
- package/hooks/test_closeout.sh +14 -2
- package/package.json +8 -7
- package/scripts/README.md +1 -1
- package/scripts/_paths.py +65 -0
- package/scripts/groundwork_exchange.py +1012 -0
- package/scripts/install_memory.py +33 -1
- package/scripts/lessons_index/ingest.py +13 -2
- package/scripts/lessons_index/query.py +36 -13
- package/scripts/memory_context/__init__.py +108 -14
- package/scripts/memory_graph/__init__.py +5 -1
- package/scripts/project_resolver.py +42 -36
- package/scripts/sync_plugin_cache.py +37 -2
- package/skills/agent-rally-point/SKILL.md +46 -0
- package/skills/api-registry-bridge/SKILL.md +1 -1
- package/skills/architecture/dead/SKILL.md +1 -1
- package/skills/architecture/impact/SKILL.md +1 -1
- package/skills/architecture/review/SKILL.md +1 -1
- package/skills/architecture/rules/SKILL.md +3 -3
- package/skills/architecture/scan/SKILL.md +1 -1
- package/skills/architecture/trace/SKILL.md +1 -1
- package/skills/attribution-standard/SKILL.md +6 -6
- package/skills/auto-decision-capture/SKILL.md +31 -2
- package/skills/auto-finding-capture/SKILL.md +28 -1
- package/skills/build-loop/SKILL.md +131 -23
- package/skills/build-loop/fallbacks.md +16 -21
- package/skills/build-loop/phases/ui-validation.md +2 -2
- package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
- package/skills/build-loop/references/apple-native-planning.md +1 -1
- package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
- package/skills/build-loop/references/autonomy-dashboard.md +115 -0
- package/skills/build-loop/references/capability-routing.md +24 -2
- package/skills/build-loop/references/coordination.md +24 -6
- package/skills/build-loop/references/experiment-results-template.md +15 -3
- package/skills/build-loop/references/leadership.md +1 -1
- package/skills/build-loop/references/memory.md +14 -3
- package/skills/build-loop/references/modular-systems-pack.md +8 -0
- package/skills/build-loop/references/output-style.md +86 -0
- package/skills/build-loop/references/phase-1-assess.md +102 -2
- package/skills/build-loop/references/phase-2-plan.md +9 -1
- package/skills/build-loop/references/phase-3-execute.md +5 -2
- package/skills/build-loop/references/phase-4-review.md +85 -8
- package/skills/build-loop/references/phase-5-iterate.md +76 -8
- package/skills/build-loop/references/phase-6-learn.md +10 -17
- package/skills/build-loop/references/privileged-request-broker.md +254 -0
- package/skills/build-loop/references/resource-aware-execution.md +183 -0
- package/skills/build-loop/references/self-recursive-dev.md +2 -2
- package/skills/build-loop/references/status-output-format.md +207 -0
- package/skills/build-loop/references/verify-dispatch.md +56 -2
- package/skills/building-with-deepagents/SKILL.md +1 -1
- package/skills/claim-scope/SKILL.md +185 -0
- package/skills/color-engine/SKILL.md +103 -0
- package/skills/color-engine/_core.py +464 -0
- package/skills/color-engine/color_engine.py +175 -0
- package/skills/cost-rca/SKILL.md +61 -0
- package/skills/data-plane-worktrees/SKILL.md +139 -0
- package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
- package/skills/database-practice/SKILL.md +200 -0
- package/skills/database-practice/references/diagnostic-queries.sql +126 -0
- package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
- package/skills/database-practice/scripts/db_table_map.py +1244 -0
- package/skills/database-practice/scripts/test_db_table_map.py +514 -0
- package/skills/debug-loop/SKILL.md +36 -6
- package/skills/debugging-memory/SKILL.md +32 -430
- package/skills/debugging-memory/references/pattern-extraction.md +4 -4
- package/skills/debugging-memory/references/search.md +32 -120
- package/skills/debugging-memory/references/store.md +32 -126
- package/skills/debugging-memory/references/subagent-integration.md +1 -1
- package/skills/decision-queue/SKILL.md +251 -0
- package/skills/decision-queue/assets/template.html +1242 -0
- package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
- package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
- package/skills/defenseclaw-bridge/SKILL.md +2 -2
- package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
- package/skills/drain-proposals/SKILL.md +53 -0
- package/skills/focused-loop-builder/SKILL.md +31 -0
- package/skills/focused-loop-builder/references/spec-format.md +27 -0
- package/skills/handoff/SKILL.md +169 -8
- package/skills/ibr-bridge/SKILL.md +4 -1
- package/skills/knowledge/SKILL.md +26 -14
- package/skills/knowledge/references/review-mode.md +2 -3
- package/skills/knowledge/templates/madr-minimal.md +1 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/model-bakeoff/SKILL.md +48 -10
- package/skills/model-tiering/SKILL.md +92 -31
- package/skills/native-ax-driver/SKILL.md +38 -5
- package/skills/native-ax-driver/scripts/native_driver.py +278 -22
- package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
- package/skills/optimize/SKILL.md +1 -1
- package/skills/plugin-builder/SKILL.md +48 -1
- package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
- package/skills/plugin-builder/references/distribution.md +13 -2
- package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
- package/skills/plugin-tests/SKILL.md +2 -2
- package/skills/recursive-retrospective/SKILL.md +1 -1
- package/skills/repo-closeout/SKILL.md +17 -0
- package/skills/repo-closeout/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/SKILL.md +179 -0
- package/skills/repo-maintenance/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
- package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
- package/skills/repo-maintenance/references/safety-protocol.md +106 -0
- package/skills/repo-maintenance/references/stack-profiles.md +138 -0
- package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
- package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
- package/skills/repository-intelligence/SKILL.md +189 -0
- package/skills/repository-intelligence/agents/openai.yaml +4 -0
- package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
- package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
- package/skills/research/SKILL.md +12 -2
- package/skills/root-cause-analysis/SKILL.md +1 -1
- package/skills/runtime-parity-verification/SKILL.md +36 -1
- package/skills/security-methodology/SKILL.md +23 -10
- package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
- package/skills/security-methodology/references/cross-source-matrix.md +1 -1
- package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
- package/skills/security-scan/SKILL.md +55 -15
- package/skills/self-improve/SKILL.md +70 -50
- package/skills/silent-assumptions/SKILL.md +341 -0
- package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
- package/skills/spec-writing/SKILL.md +128 -24
- package/skills/spec-writing/scripts/check_checklist.py +114 -15
- package/skills/ui-design/SKILL.md +6 -4
- package/skills/ui-design/references/color-engine.md +132 -0
- package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
- package/skills/ui-design/references/ui-guidance-sources.md +1 -1
- package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
- package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
- package/plugin-artifacts/codex/AGENTS.md +0 -560
- package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
- package/plugin-artifacts/codex/LICENSE +0 -202
- package/plugin-artifacts/codex/README.md +0 -313
- package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
- package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
- package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
- package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
- package/plugin-artifacts/codex/references/backlog-system.md +0 -285
- package/plugin-artifacts/codex/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
- package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
- package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
- package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
- package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
- package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/references/leadership.md +0 -72
- package/plugin-artifacts/codex/references/memory-systems.md +0 -261
- package/plugin-artifacts/codex/references/memory.md +0 -313
- package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
- package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
- package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
- package/plugin-artifacts/codex/references/self-review.md +0 -234
- package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
- package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
- package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
- package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
- package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
- package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
- package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
- package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
- package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
- package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
- package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
- package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
- package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
- package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
- package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
- package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
- package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
- package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
- package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
- package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
- package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
- package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
- package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
- package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
- package/scripts/build_codex_plugin_artifact.py +0 -321
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""db_table_map.py — read-only PostgreSQL table map, tracked over time.
|
|
3
|
+
|
|
4
|
+
Produces a per-table map (domain, size, counters, indexes, FKs, liveness) plus
|
|
5
|
+
the global sections a cost claim needs (counter window, function GUCs, vector
|
|
6
|
+
index cost vs benefit, top statements, temp spill), as JSON and as Markdown.
|
|
7
|
+
|
|
8
|
+
The JSON is the tracking artifact: re-run with ``--prev <old.json>`` and the
|
|
9
|
+
map reports what changed — new and removed tables, size moves over 10%, indexes
|
|
10
|
+
whose ``idx_scan`` stayed flat while their table kept taking inserts, and
|
|
11
|
+
liveness verdicts that flipped.
|
|
12
|
+
|
|
13
|
+
Every statement runs inside ``BEGIN READ ONLY`` with a ``SET LOCAL
|
|
14
|
+
statement_timeout``, and the generated script is whitelist-checked before it is
|
|
15
|
+
handed to ``psql``: only ``select``, ``with``, ``begin``, ``set local
|
|
16
|
+
statement_timeout`` and ``rollback`` may start a statement. The script cannot
|
|
17
|
+
issue DDL or DML.
|
|
18
|
+
|
|
19
|
+
Usage::
|
|
20
|
+
|
|
21
|
+
python3 db_table_map.py --dsn "$DIRECT_URL" \
|
|
22
|
+
--out-json docs/04-operations/database-audits/2026-09-05-database-map.json \
|
|
23
|
+
--out-md docs/04-operations/database-audits/2026-09-05-database-map.md \
|
|
24
|
+
--prev docs/04-operations/database-audits/2026-08-01-database-map.json
|
|
25
|
+
|
|
26
|
+
With no ``--dsn`` the DSN is read from ``DATABASE_URL`` or ``DIRECT_URL``.
|
|
27
|
+
With no ``--out-md`` the Markdown map is written to stdout.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import time
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
42
|
+
|
|
43
|
+
# psql refuses to connect when the URI carries client-library-only parameters.
|
|
44
|
+
# Prisma and Supabase pooler URLs routinely carry all three.
|
|
45
|
+
UNSUPPORTED_URI_PARAMS = ("pgbouncer", "connection_limit", "pool_timeout")
|
|
46
|
+
|
|
47
|
+
SECTION_MARK = "@@bl-section:"
|
|
48
|
+
SECTION_END = "@@"
|
|
49
|
+
|
|
50
|
+
# Only these tokens may begin a statement in the generated script.
|
|
51
|
+
ALLOWED_STATEMENT_STARTS = ("select", "with", "begin", "rollback", "set")
|
|
52
|
+
|
|
53
|
+
DEFAULT_DOMAIN_RULES: list[list[str]] = [
|
|
54
|
+
["auth", r"^(auth|users?|accounts?|sessions?|roles?|permissions?|api_keys?)"],
|
|
55
|
+
["queue", r"(queue|job|task|worker|outbox|dead_letter|retry)"],
|
|
56
|
+
["embedding", r"(embedding|vector|chunk)"],
|
|
57
|
+
["graph", r"(entit|node|edge|relation|graph|mention|pair|triple)"],
|
|
58
|
+
["audit", r"(audit|event|log|history|snapshot|ledger)"],
|
|
59
|
+
["cache", r"(cache|summary|rollup|agg|_mv$|materialized|pack)"],
|
|
60
|
+
["taxonomy", r"(categor|topic|taxonom|classif|(^|_)(tag|label)s?($|_))"],
|
|
61
|
+
["content", r"(article|post|document|content|feed|source|podcast|story|page)"],
|
|
62
|
+
]
|
|
63
|
+
DEFAULT_DOMAIN = "other"
|
|
64
|
+
|
|
65
|
+
VECTOR_ACCESS_METHODS = ("hnsw", "ivfflat")
|
|
66
|
+
SPECIAL_ACCESS_METHODS = VECTOR_ACCESS_METHODS + ("gin", "gist", "brin")
|
|
67
|
+
|
|
68
|
+
# An index is "cold" when the workload almost never reads it but every insert
|
|
69
|
+
# maintains it. 100 scans over a multi-month window is noise, not a read path.
|
|
70
|
+
COLD_INDEX_SCAN_CEILING = 100
|
|
71
|
+
COLD_INDEX_INSERT_FLOOR = 10_000
|
|
72
|
+
# Size delta that counts as a real move between two maps.
|
|
73
|
+
SIZE_DELTA_PCT = 10.0
|
|
74
|
+
|
|
75
|
+
SHAPES: dict[str, str] = {
|
|
76
|
+
"vector-insert-above-cache": "Insert against a vector index larger than cache",
|
|
77
|
+
"toast-predicate": "Predicate on a TOASTed column",
|
|
78
|
+
"per-row-jsonb-trigram": "Per-row lookup through jsonb + trigram",
|
|
79
|
+
"vector-read-above-cache": "Vector similarity read",
|
|
80
|
+
"index-maintenance-on-writes": "Index maintenance charged to writes",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
MD_SECTIONS = (
|
|
84
|
+
"Counter window",
|
|
85
|
+
"Server settings",
|
|
86
|
+
"Domain rollup",
|
|
87
|
+
"Tables",
|
|
88
|
+
"Liveness",
|
|
89
|
+
"Vector, text, and GIN indexes",
|
|
90
|
+
"Function GUCs",
|
|
91
|
+
"Top statements by total_exec_time",
|
|
92
|
+
"Temp spill",
|
|
93
|
+
"Shapes",
|
|
94
|
+
)
|
|
95
|
+
MD_DIFF_SECTION = "Diff vs previous map"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# DSN handling
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def sanitize_dsn(dsn: str) -> str:
|
|
103
|
+
"""Drop client-library-only URI parameters psql rejects.
|
|
104
|
+
|
|
105
|
+
``pgbouncer``, ``connection_limit`` and ``pool_timeout`` are Prisma/pooler
|
|
106
|
+
parameters. libpq treats an unknown URI parameter as a fatal connection
|
|
107
|
+
error, so a DSN copied out of ``.env`` fails before it reaches the server.
|
|
108
|
+
Non-URI (keyword/value) DSNs are returned untouched.
|
|
109
|
+
"""
|
|
110
|
+
if "://" not in dsn:
|
|
111
|
+
return dsn
|
|
112
|
+
parts = urlsplit(dsn)
|
|
113
|
+
if not parts.query:
|
|
114
|
+
return dsn
|
|
115
|
+
kept = [
|
|
116
|
+
(key, value)
|
|
117
|
+
for key, value in parse_qsl(parts.query, keep_blank_values=True)
|
|
118
|
+
if key.lower() not in UNSUPPORTED_URI_PARAMS
|
|
119
|
+
]
|
|
120
|
+
query = urlencode(kept)
|
|
121
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def redact_dsn(dsn: str) -> str:
|
|
125
|
+
"""Describe the connection for a report as host[:port]/database only.
|
|
126
|
+
|
|
127
|
+
No scheme, no user, no password. A report line shaped like
|
|
128
|
+
``postgresql://user:***@host/db`` still trips secret scanners (measured
|
|
129
|
+
2026-09-05: the in-house scanner in a consuming repo failed CI on the
|
|
130
|
+
redacted form), and the user segment of a hosted DSN carries the
|
|
131
|
+
project identifier, which is not the report's business either.
|
|
132
|
+
"""
|
|
133
|
+
if "://" not in dsn:
|
|
134
|
+
host = re.search(r"host=(\S+)", dsn)
|
|
135
|
+
port = re.search(r"port=(\S+)", dsn)
|
|
136
|
+
dbname = re.search(r"dbname=(\S+)", dsn)
|
|
137
|
+
out = host.group(1) if host else "?"
|
|
138
|
+
if port:
|
|
139
|
+
out += f":{port.group(1)}"
|
|
140
|
+
if dbname:
|
|
141
|
+
out += f"/{dbname.group(1)}"
|
|
142
|
+
return out
|
|
143
|
+
parts = urlsplit(dsn)
|
|
144
|
+
host = parts.hostname or "?"
|
|
145
|
+
if parts.port:
|
|
146
|
+
host += f":{parts.port}"
|
|
147
|
+
return f"{host}{parts.path}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_dsn(explicit: str | None, env: dict[str, str] | None = None) -> str:
|
|
151
|
+
env = os.environ if env is None else env
|
|
152
|
+
for candidate in (explicit, env.get("DATABASE_URL"), env.get("DIRECT_URL")):
|
|
153
|
+
if candidate:
|
|
154
|
+
return candidate
|
|
155
|
+
raise SystemExit(
|
|
156
|
+
"no DSN: pass --dsn, or set DATABASE_URL or DIRECT_URL in the environment"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# SQL
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
USER_SCHEMA_FILTER = "n.nspname not in ('pg_catalog', 'information_schema') and n.nspname !~ '^pg_'"
|
|
165
|
+
|
|
166
|
+
# `<+>` as a bare regex means "one or more '<' then '>'", which matches the
|
|
167
|
+
# plpgsql not-equals operator `<>` and floods the map with every platform
|
|
168
|
+
# function. The plus is escaped; the standard_conforming_strings default keeps
|
|
169
|
+
# the backslash literal on its way to the regex engine.
|
|
170
|
+
VECTOR_OPERATOR_REGEX = r"<=>|<->|<#>|<\+>"
|
|
171
|
+
|
|
172
|
+
# Platform-managed schemas. A function here is only interesting when it
|
|
173
|
+
# genuinely touches vectors; its search_path GUC is the provider's business.
|
|
174
|
+
SYSTEM_MANAGED_SCHEMAS = (
|
|
175
|
+
"auth", "cron", "extensions", "graphql", "graphql_public", "net",
|
|
176
|
+
"pgbouncer", "pgsodium", "realtime", "storage", "supabase_functions",
|
|
177
|
+
"supabase_migrations", "vault",
|
|
178
|
+
)
|
|
179
|
+
SYSTEM_MANAGED_SCHEMA_LIST = ", ".join(f"'{name}'" for name in SYSTEM_MANAGED_SCHEMAS)
|
|
180
|
+
|
|
181
|
+
SECTION_SQL: list[tuple[str, str]] = [
|
|
182
|
+
(
|
|
183
|
+
"window",
|
|
184
|
+
"""
|
|
185
|
+
select pg_postmaster_start_time()::text,
|
|
186
|
+
now()::text,
|
|
187
|
+
coalesce((select stats_reset::text from pg_stat_database
|
|
188
|
+
where datname = current_database()), ''),
|
|
189
|
+
current_database(),
|
|
190
|
+
version();
|
|
191
|
+
""",
|
|
192
|
+
),
|
|
193
|
+
(
|
|
194
|
+
"settings",
|
|
195
|
+
"""
|
|
196
|
+
select name, setting, coalesce(unit, '')
|
|
197
|
+
from pg_settings
|
|
198
|
+
where name in ('shared_buffers', 'work_mem', 'maintenance_work_mem',
|
|
199
|
+
'effective_cache_size', 'max_connections',
|
|
200
|
+
'max_parallel_workers_per_gather', 'server_version')
|
|
201
|
+
order by name;
|
|
202
|
+
""",
|
|
203
|
+
),
|
|
204
|
+
(
|
|
205
|
+
"tables",
|
|
206
|
+
"""
|
|
207
|
+
select s.schemaname,
|
|
208
|
+
s.relname,
|
|
209
|
+
pg_total_relation_size(c.oid),
|
|
210
|
+
pg_relation_size(c.oid),
|
|
211
|
+
pg_indexes_size(c.oid),
|
|
212
|
+
greatest(pg_total_relation_size(c.oid)
|
|
213
|
+
- pg_relation_size(c.oid)
|
|
214
|
+
- pg_indexes_size(c.oid), 0),
|
|
215
|
+
s.n_live_tup, s.n_dead_tup,
|
|
216
|
+
s.n_tup_ins, s.n_tup_upd, s.n_tup_del,
|
|
217
|
+
s.seq_scan, coalesce(s.idx_scan, 0),
|
|
218
|
+
coalesce(s.last_autovacuum::text, ''),
|
|
219
|
+
coalesce(s.last_autoanalyze::text, ''),
|
|
220
|
+
coalesce(array_to_string(c.reloptions, ' '), '')
|
|
221
|
+
from pg_stat_user_tables s
|
|
222
|
+
join pg_class c on c.oid = s.relid
|
|
223
|
+
order by pg_total_relation_size(c.oid) desc;
|
|
224
|
+
""",
|
|
225
|
+
),
|
|
226
|
+
(
|
|
227
|
+
"indexes",
|
|
228
|
+
f"""
|
|
229
|
+
select n.nspname,
|
|
230
|
+
t.relname,
|
|
231
|
+
i.relname,
|
|
232
|
+
am.amname,
|
|
233
|
+
pg_relation_size(i.oid),
|
|
234
|
+
coalesce(si.idx_scan, 0),
|
|
235
|
+
replace(replace(pg_get_indexdef(i.oid), chr(9), ' '), chr(10), ' ')
|
|
236
|
+
from pg_class i
|
|
237
|
+
join pg_index x on x.indexrelid = i.oid
|
|
238
|
+
join pg_class t on t.oid = x.indrelid
|
|
239
|
+
join pg_namespace n on n.oid = i.relnamespace
|
|
240
|
+
join pg_am am on am.oid = i.relam
|
|
241
|
+
left join pg_stat_user_indexes si on si.indexrelid = i.oid
|
|
242
|
+
where {USER_SCHEMA_FILTER}
|
|
243
|
+
order by pg_relation_size(i.oid) desc;
|
|
244
|
+
""",
|
|
245
|
+
),
|
|
246
|
+
(
|
|
247
|
+
"special_columns",
|
|
248
|
+
"""
|
|
249
|
+
select table_schema, table_name, column_name, udt_name
|
|
250
|
+
from information_schema.columns
|
|
251
|
+
where udt_name in ('vector', 'halfvec', 'sparsevec', 'tsvector')
|
|
252
|
+
and table_schema not in ('pg_catalog', 'information_schema')
|
|
253
|
+
order by table_schema, table_name, ordinal_position;
|
|
254
|
+
""",
|
|
255
|
+
),
|
|
256
|
+
(
|
|
257
|
+
"foreign_keys",
|
|
258
|
+
f"""
|
|
259
|
+
select con.conname,
|
|
260
|
+
sn.nspname, src.relname,
|
|
261
|
+
tn.nspname, tgt.relname
|
|
262
|
+
from pg_constraint con
|
|
263
|
+
join pg_class src on src.oid = con.conrelid
|
|
264
|
+
join pg_namespace sn on sn.oid = src.relnamespace
|
|
265
|
+
join pg_class tgt on tgt.oid = con.confrelid
|
|
266
|
+
join pg_namespace tn on tn.oid = tgt.relnamespace
|
|
267
|
+
join pg_namespace n on n.oid = src.relnamespace
|
|
268
|
+
where con.contype = 'f' and {USER_SCHEMA_FILTER}
|
|
269
|
+
order by src.relname, con.conname;
|
|
270
|
+
""",
|
|
271
|
+
),
|
|
272
|
+
(
|
|
273
|
+
"functions",
|
|
274
|
+
f"""
|
|
275
|
+
with candidate as (
|
|
276
|
+
select n.nspname as schema_name,
|
|
277
|
+
p.proname as function_name,
|
|
278
|
+
coalesce(array_to_string(p.proconfig, ' '), '') as proconfig,
|
|
279
|
+
(p.prosrc ~ '{VECTOR_OPERATOR_REGEX}'
|
|
280
|
+
or exists (select 1 from pg_type ty
|
|
281
|
+
where ty.typname in ('vector', 'halfvec', 'sparsevec')
|
|
282
|
+
and (ty.oid = p.prorettype
|
|
283
|
+
or ty.oid = any(coalesce(p.proallargtypes,
|
|
284
|
+
p.proargtypes::oid[]))))) as uses_vector
|
|
285
|
+
from pg_proc p
|
|
286
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
287
|
+
join pg_language l on l.oid = p.prolang
|
|
288
|
+
where {USER_SCHEMA_FILTER}
|
|
289
|
+
-- only functions someone wrote: extension internals (pgvector's own
|
|
290
|
+
-- vector_add, halfvec_in, ...) are C code that no ALTER FUNCTION should touch
|
|
291
|
+
and l.lanname in ('sql', 'plpgsql')
|
|
292
|
+
and not exists (select 1 from pg_depend d
|
|
293
|
+
where d.classid = 'pg_proc'::regclass
|
|
294
|
+
and d.objid = p.oid and d.deptype = 'e')
|
|
295
|
+
)
|
|
296
|
+
select distinct schema_name,
|
|
297
|
+
function_name,
|
|
298
|
+
proconfig,
|
|
299
|
+
case when uses_vector then 'vector' else '' end
|
|
300
|
+
from candidate
|
|
301
|
+
where uses_vector
|
|
302
|
+
or (proconfig <> '' and schema_name not in ({SYSTEM_MANAGED_SCHEMA_LIST}))
|
|
303
|
+
order by schema_name, function_name;
|
|
304
|
+
""",
|
|
305
|
+
),
|
|
306
|
+
(
|
|
307
|
+
"database_temp",
|
|
308
|
+
"""
|
|
309
|
+
select temp_files, temp_bytes, blks_read, blks_hit, deadlocks
|
|
310
|
+
from pg_stat_database
|
|
311
|
+
where datname = current_database();
|
|
312
|
+
""",
|
|
313
|
+
),
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
STATEMENTS_SQL = """
|
|
317
|
+
select calls,
|
|
318
|
+
round(total_exec_time::numeric, 0),
|
|
319
|
+
round(mean_exec_time::numeric, 1),
|
|
320
|
+
round((100.0 * total_exec_time / nullif(sum(total_exec_time) over (), 0))::numeric, 2),
|
|
321
|
+
round((rows::numeric / nullif(calls, 0)), 2),
|
|
322
|
+
temp_blks_written,
|
|
323
|
+
left(regexp_replace(query, '\\s+', ' ', 'g'), 200)
|
|
324
|
+
from pg_stat_statements
|
|
325
|
+
where dbid = (select oid from pg_database where datname = current_database())
|
|
326
|
+
order by total_exec_time desc
|
|
327
|
+
limit 25;
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
STATEMENT_TEMP_SQL = """
|
|
331
|
+
select calls,
|
|
332
|
+
temp_blks_written,
|
|
333
|
+
left(regexp_replace(query, '\\s+', ' ', 'g'), 160)
|
|
334
|
+
from pg_stat_statements
|
|
335
|
+
where temp_blks_written > 0
|
|
336
|
+
and dbid = (select oid from pg_database where datname = current_database())
|
|
337
|
+
order by temp_blks_written desc
|
|
338
|
+
limit 10;
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
PROBE_SQL = """
|
|
342
|
+
select count(*) from pg_extension where extname = 'pg_stat_statements';
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def build_script(sections: list[tuple[str, str]], statement_timeout_s: int) -> str:
|
|
347
|
+
"""Wrap the section queries in a read-only, timeout-bounded transaction."""
|
|
348
|
+
parts = [
|
|
349
|
+
"BEGIN READ ONLY;",
|
|
350
|
+
f"SET LOCAL statement_timeout = '{statement_timeout_s}s';",
|
|
351
|
+
]
|
|
352
|
+
for name, sql in sections:
|
|
353
|
+
parts.append(f"select '{SECTION_MARK}{name}{SECTION_END}';")
|
|
354
|
+
parts.append(sql.strip())
|
|
355
|
+
parts.append("ROLLBACK;")
|
|
356
|
+
return "\n".join(parts) + "\n"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def assert_read_only(script: str) -> None:
|
|
360
|
+
"""Reject any statement that is not a read.
|
|
361
|
+
|
|
362
|
+
Whitelist, not blacklist: a statement may only begin with ``select``,
|
|
363
|
+
``with``, ``begin``, ``rollback``, or ``set local statement_timeout``.
|
|
364
|
+
"""
|
|
365
|
+
for raw in script.split(";"):
|
|
366
|
+
stripped = "\n".join(
|
|
367
|
+
line for line in raw.splitlines() if not line.strip().startswith("--")
|
|
368
|
+
).strip()
|
|
369
|
+
if not stripped:
|
|
370
|
+
continue
|
|
371
|
+
head = stripped.lower().split(None, 1)[0]
|
|
372
|
+
if head not in ALLOWED_STATEMENT_STARTS:
|
|
373
|
+
raise ValueError(f"refusing to run a non-read statement: {stripped[:60]!r}")
|
|
374
|
+
if head == "set" and not stripped.lower().startswith("set local statement_timeout"):
|
|
375
|
+
raise ValueError(f"refusing to run a non-read statement: {stripped[:60]!r}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def run_psql(dsn: str, script: str, timeout_s: int, psql: str = "psql") -> str:
|
|
379
|
+
"""Execute a read-only script through psql and return raw tab-separated rows."""
|
|
380
|
+
assert_read_only(script)
|
|
381
|
+
cmd = [
|
|
382
|
+
psql,
|
|
383
|
+
sanitize_dsn(dsn),
|
|
384
|
+
"-X",
|
|
385
|
+
"-A",
|
|
386
|
+
"-t",
|
|
387
|
+
"-F",
|
|
388
|
+
"\t",
|
|
389
|
+
"-v",
|
|
390
|
+
"ON_ERROR_STOP=1",
|
|
391
|
+
"-f",
|
|
392
|
+
"-",
|
|
393
|
+
]
|
|
394
|
+
env = dict(os.environ)
|
|
395
|
+
env.setdefault("PGCONNECT_TIMEOUT", "10")
|
|
396
|
+
try:
|
|
397
|
+
proc = subprocess.run(
|
|
398
|
+
cmd,
|
|
399
|
+
input=script,
|
|
400
|
+
text=True,
|
|
401
|
+
capture_output=True,
|
|
402
|
+
timeout=timeout_s + 30,
|
|
403
|
+
env=env,
|
|
404
|
+
)
|
|
405
|
+
except FileNotFoundError as exc: # pragma: no cover - environment dependent
|
|
406
|
+
raise SystemExit(f"psql not found on PATH: {exc}") from exc
|
|
407
|
+
except subprocess.TimeoutExpired as exc: # pragma: no cover - timing dependent
|
|
408
|
+
raise SystemExit(f"psql timed out after {timeout_s + 30}s") from exc
|
|
409
|
+
if proc.returncode != 0:
|
|
410
|
+
raise SystemExit(f"psql failed ({proc.returncode}): {proc.stderr.strip()[:800]}")
|
|
411
|
+
return proc.stdout
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def parse_sections(output: str) -> dict[str, list[list[str]]]:
|
|
415
|
+
"""Split marker-delimited psql output into ``{section: [row, ...]}``."""
|
|
416
|
+
sections: dict[str, list[list[str]]] = {}
|
|
417
|
+
current: str | None = None
|
|
418
|
+
for line in output.splitlines():
|
|
419
|
+
if line.startswith(SECTION_MARK) and line.endswith(SECTION_END):
|
|
420
|
+
current = line[len(SECTION_MARK) : -len(SECTION_END)]
|
|
421
|
+
sections.setdefault(current, [])
|
|
422
|
+
continue
|
|
423
|
+
if current is None or not line.strip():
|
|
424
|
+
continue
|
|
425
|
+
sections[current].append(line.split("\t"))
|
|
426
|
+
return sections
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
# Derivation
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
def _int(value: str) -> int:
|
|
434
|
+
try:
|
|
435
|
+
return int(float(value))
|
|
436
|
+
except (TypeError, ValueError):
|
|
437
|
+
return 0
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _float(value: str) -> float:
|
|
441
|
+
try:
|
|
442
|
+
return float(value)
|
|
443
|
+
except (TypeError, ValueError):
|
|
444
|
+
return 0.0
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def classify_domain(name: str, rules: list[list[str]]) -> str:
|
|
448
|
+
for domain, pattern in rules:
|
|
449
|
+
if re.search(pattern, name, re.IGNORECASE):
|
|
450
|
+
return domain
|
|
451
|
+
return DEFAULT_DOMAIN
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def liveness(table: dict[str, Any]) -> tuple[str, str]:
|
|
455
|
+
"""Return ``(verdict, reason)`` from the runtime counters alone.
|
|
456
|
+
|
|
457
|
+
Per the constitution: ``idx_scan > 0`` means an application issued a
|
|
458
|
+
filtered query (audit scripts produce ``seq_scan`` only), and ``n_live_tup``
|
|
459
|
+
is a stale planner estimate that must never decide emptiness.
|
|
460
|
+
"""
|
|
461
|
+
idx_scan = table["idx_scan"]
|
|
462
|
+
ins = table["n_tup_ins"]
|
|
463
|
+
upd = table["n_tup_upd"]
|
|
464
|
+
dele = table["n_tup_del"]
|
|
465
|
+
writes = ins + upd + dele
|
|
466
|
+
live_est = table["n_live_tup"]
|
|
467
|
+
|
|
468
|
+
if idx_scan > 0:
|
|
469
|
+
return "live", f"{idx_scan:,} index scans in the window"
|
|
470
|
+
if ins > 0:
|
|
471
|
+
if live_est <= 0:
|
|
472
|
+
return "written-only", f"{ins:,} inserts, no index reads, ~0 live rows (drained queue)"
|
|
473
|
+
return "written-only", f"{ins:,} inserts, no index reads (write-only sink)"
|
|
474
|
+
if writes > 0 or live_est > 0 or table["seq_scan"] > 0:
|
|
475
|
+
return "idle", (
|
|
476
|
+
f"no index reads and no inserts; {table['seq_scan']:,} seq scans, "
|
|
477
|
+
f"~{live_est:,} live rows (estimate)"
|
|
478
|
+
)
|
|
479
|
+
return "never-written", "no reads and no writes since the counters started"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def build_map(
|
|
483
|
+
sections: dict[str, list[list[str]]],
|
|
484
|
+
domain_rules: list[list[str]],
|
|
485
|
+
dsn_label: str,
|
|
486
|
+
has_pg_stat_statements: bool,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
window_rows = sections.get("window", [[]])
|
|
489
|
+
window_row = window_rows[0] if window_rows and window_rows[0] else ["", "", "", "", ""]
|
|
490
|
+
window = {
|
|
491
|
+
"postmaster_start_time": window_row[0] if len(window_row) > 0 else "",
|
|
492
|
+
"collected_at": window_row[1] if len(window_row) > 1 else "",
|
|
493
|
+
"stats_reset": window_row[2] if len(window_row) > 2 else "",
|
|
494
|
+
"database": window_row[3] if len(window_row) > 3 else "",
|
|
495
|
+
"server_version": window_row[4] if len(window_row) > 4 else "",
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
settings = {row[0]: {"setting": row[1], "unit": row[2] if len(row) > 2 else ""}
|
|
499
|
+
for row in sections.get("settings", []) if len(row) >= 2}
|
|
500
|
+
shared_buffers_bytes = _setting_bytes(settings.get("shared_buffers"))
|
|
501
|
+
|
|
502
|
+
tables: dict[str, dict[str, Any]] = {}
|
|
503
|
+
for row in sections.get("tables", []):
|
|
504
|
+
if len(row) < 16:
|
|
505
|
+
continue
|
|
506
|
+
schema, name = row[0], row[1]
|
|
507
|
+
key = name if schema == "public" else f"{schema}.{name}"
|
|
508
|
+
table = {
|
|
509
|
+
"key": key,
|
|
510
|
+
"schema": schema,
|
|
511
|
+
"name": name,
|
|
512
|
+
"domain": classify_domain(name, domain_rules),
|
|
513
|
+
"total_bytes": _int(row[2]),
|
|
514
|
+
"heap_bytes": _int(row[3]),
|
|
515
|
+
"index_bytes": _int(row[4]),
|
|
516
|
+
"toast_bytes": _int(row[5]),
|
|
517
|
+
"n_live_tup": _int(row[6]),
|
|
518
|
+
"n_dead_tup": _int(row[7]),
|
|
519
|
+
"n_tup_ins": _int(row[8]),
|
|
520
|
+
"n_tup_upd": _int(row[9]),
|
|
521
|
+
"n_tup_del": _int(row[10]),
|
|
522
|
+
"seq_scan": _int(row[11]),
|
|
523
|
+
"idx_scan": _int(row[12]),
|
|
524
|
+
"last_autovacuum": row[13],
|
|
525
|
+
"last_autoanalyze": row[14],
|
|
526
|
+
"reloptions": row[15],
|
|
527
|
+
"indexes": [],
|
|
528
|
+
"vector_columns": [],
|
|
529
|
+
"tsvector_columns": [],
|
|
530
|
+
"fk_out": [],
|
|
531
|
+
"fk_in": [],
|
|
532
|
+
"estimates_note": "n_live_tup / n_dead_tup are planner estimates, not counts",
|
|
533
|
+
}
|
|
534
|
+
verdict, reason = liveness(table)
|
|
535
|
+
table["liveness"] = verdict
|
|
536
|
+
table["liveness_reason"] = reason
|
|
537
|
+
tables[key] = table
|
|
538
|
+
|
|
539
|
+
indexes: list[dict[str, Any]] = []
|
|
540
|
+
for row in sections.get("indexes", []):
|
|
541
|
+
if len(row) < 7:
|
|
542
|
+
continue
|
|
543
|
+
schema, table_name, index_name = row[0], row[1], row[2]
|
|
544
|
+
key = table_name if schema == "public" else f"{schema}.{table_name}"
|
|
545
|
+
entry = {
|
|
546
|
+
"table": key,
|
|
547
|
+
"name": index_name,
|
|
548
|
+
"access_method": row[3],
|
|
549
|
+
"size_bytes": _int(row[4]),
|
|
550
|
+
"idx_scan": _int(row[5]),
|
|
551
|
+
"definition": row[6],
|
|
552
|
+
}
|
|
553
|
+
indexes.append(entry)
|
|
554
|
+
if key in tables:
|
|
555
|
+
tables[key]["indexes"].append(entry)
|
|
556
|
+
|
|
557
|
+
for row in sections.get("special_columns", []):
|
|
558
|
+
if len(row) < 4:
|
|
559
|
+
continue
|
|
560
|
+
schema, table_name, column, udt = row
|
|
561
|
+
key = table_name if schema == "public" else f"{schema}.{table_name}"
|
|
562
|
+
if key not in tables:
|
|
563
|
+
continue
|
|
564
|
+
bucket = "tsvector_columns" if udt == "tsvector" else "vector_columns"
|
|
565
|
+
tables[key][bucket].append({"column": column, "type": udt})
|
|
566
|
+
|
|
567
|
+
for row in sections.get("foreign_keys", []):
|
|
568
|
+
if len(row) < 5:
|
|
569
|
+
continue
|
|
570
|
+
conname, src_schema, src, tgt_schema, tgt = row
|
|
571
|
+
src_key = src if src_schema == "public" else f"{src_schema}.{src}"
|
|
572
|
+
tgt_key = tgt if tgt_schema == "public" else f"{tgt_schema}.{tgt}"
|
|
573
|
+
if src_key in tables:
|
|
574
|
+
tables[src_key]["fk_out"].append({"constraint": conname, "references": tgt_key})
|
|
575
|
+
if tgt_key in tables:
|
|
576
|
+
tables[tgt_key]["fk_in"].append({"constraint": conname, "from": src_key})
|
|
577
|
+
|
|
578
|
+
functions = []
|
|
579
|
+
for row in sections.get("functions", []):
|
|
580
|
+
if len(row) < 4:
|
|
581
|
+
continue
|
|
582
|
+
proconfig = row[2]
|
|
583
|
+
functions.append(
|
|
584
|
+
{
|
|
585
|
+
"schema": row[0],
|
|
586
|
+
"name": row[1],
|
|
587
|
+
"proconfig": [item for item in proconfig.split(" ") if item],
|
|
588
|
+
"uses_vector_ops": row[3] == "vector",
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
statements = []
|
|
593
|
+
for row in sections.get("statements", []):
|
|
594
|
+
if len(row) < 7:
|
|
595
|
+
continue
|
|
596
|
+
statements.append(
|
|
597
|
+
{
|
|
598
|
+
"calls": _int(row[0]),
|
|
599
|
+
"total_exec_ms": _float(row[1]),
|
|
600
|
+
"mean_exec_ms": _float(row[2]),
|
|
601
|
+
"pct_of_db_time": _float(row[3]),
|
|
602
|
+
"rows_per_call": _float(row[4]),
|
|
603
|
+
"temp_blks_written": _int(row[5]),
|
|
604
|
+
"statement": row[6],
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
temp_rows = sections.get("database_temp", [])
|
|
609
|
+
temp = {}
|
|
610
|
+
if temp_rows and len(temp_rows[0]) >= 5:
|
|
611
|
+
row = temp_rows[0]
|
|
612
|
+
blks_read, blks_hit = _int(row[2]), _int(row[3])
|
|
613
|
+
total = blks_read + blks_hit
|
|
614
|
+
temp = {
|
|
615
|
+
"temp_files": _int(row[0]),
|
|
616
|
+
"temp_bytes": _int(row[1]),
|
|
617
|
+
"blks_read": blks_read,
|
|
618
|
+
"blks_hit": blks_hit,
|
|
619
|
+
"cache_hit_pct": round(100.0 * blks_hit / total, 2) if total else None,
|
|
620
|
+
"deadlocks": _int(row[4]),
|
|
621
|
+
}
|
|
622
|
+
temp["top_spilling_statements"] = [
|
|
623
|
+
{"calls": _int(r[0]), "temp_blks_written": _int(r[1]), "statement": r[2]}
|
|
624
|
+
for r in sections.get("statement_temp", [])
|
|
625
|
+
if len(r) >= 3
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
domains: dict[str, dict[str, Any]] = {}
|
|
629
|
+
for table in tables.values():
|
|
630
|
+
bucket = domains.setdefault(
|
|
631
|
+
table["domain"],
|
|
632
|
+
{"tables": 0, "total_bytes": 0, "idx_scan": 0, "writes": 0},
|
|
633
|
+
)
|
|
634
|
+
bucket["tables"] += 1
|
|
635
|
+
bucket["total_bytes"] += table["total_bytes"]
|
|
636
|
+
bucket["idx_scan"] += table["idx_scan"]
|
|
637
|
+
bucket["writes"] += table["n_tup_ins"] + table["n_tup_upd"] + table["n_tup_del"]
|
|
638
|
+
|
|
639
|
+
data: dict[str, Any] = {
|
|
640
|
+
"schema_version": 1,
|
|
641
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
642
|
+
"dsn": dsn_label,
|
|
643
|
+
"window": window,
|
|
644
|
+
"settings": settings,
|
|
645
|
+
"shared_buffers_bytes": shared_buffers_bytes,
|
|
646
|
+
"pg_stat_statements": has_pg_stat_statements,
|
|
647
|
+
"table_count": len(tables),
|
|
648
|
+
"domains": domains,
|
|
649
|
+
"tables": [tables[key] for key in sorted(tables)],
|
|
650
|
+
"indexes": indexes,
|
|
651
|
+
"functions": functions,
|
|
652
|
+
"statements": statements if has_pg_stat_statements else None,
|
|
653
|
+
"temp": temp,
|
|
654
|
+
}
|
|
655
|
+
data["shapes"] = detect_shapes(data)
|
|
656
|
+
return data
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _setting_bytes(setting: dict[str, str] | None) -> int | None:
|
|
660
|
+
"""Convert a pg_settings row to bytes. Blocks are 8kB unless stated."""
|
|
661
|
+
if not setting:
|
|
662
|
+
return None
|
|
663
|
+
try:
|
|
664
|
+
value = int(setting["setting"])
|
|
665
|
+
except (KeyError, TypeError, ValueError):
|
|
666
|
+
return None
|
|
667
|
+
unit = (setting.get("unit") or "").strip()
|
|
668
|
+
factors = {"": 1, "B": 1, "kB": 1024, "8kB": 8192, "MB": 1024**2, "GB": 1024**3}
|
|
669
|
+
if unit not in factors:
|
|
670
|
+
return None
|
|
671
|
+
return value * factors[unit]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
# Shape matching
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
def detect_shapes(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
679
|
+
"""Match collected facts against the five cost shapes in the skill body."""
|
|
680
|
+
findings: list[dict[str, Any]] = []
|
|
681
|
+
shared_buffers = data.get("shared_buffers_bytes")
|
|
682
|
+
tables = {table["key"]: table for table in data["tables"]}
|
|
683
|
+
|
|
684
|
+
for index in sorted(data["indexes"], key=lambda i: -i["size_bytes"]):
|
|
685
|
+
table = tables.get(index["table"])
|
|
686
|
+
if table is None:
|
|
687
|
+
continue
|
|
688
|
+
am = index["access_method"]
|
|
689
|
+
ins = table["n_tup_ins"]
|
|
690
|
+
oversized = shared_buffers is not None and index["size_bytes"] > shared_buffers
|
|
691
|
+
|
|
692
|
+
cold = (
|
|
693
|
+
am in SPECIAL_ACCESS_METHODS
|
|
694
|
+
and index["idx_scan"] <= COLD_INDEX_SCAN_CEILING
|
|
695
|
+
and ins >= COLD_INDEX_INSERT_FLOOR
|
|
696
|
+
)
|
|
697
|
+
if cold:
|
|
698
|
+
findings.append(
|
|
699
|
+
{
|
|
700
|
+
"shape": "index-maintenance-on-writes",
|
|
701
|
+
"title": SHAPES["index-maintenance-on-writes"],
|
|
702
|
+
"object": f"{index['table']}.{index['name']}",
|
|
703
|
+
"evidence": (
|
|
704
|
+
f"{am} index of {human_bytes(index['size_bytes'])} with "
|
|
705
|
+
f"{index['idx_scan']:,} lifetime scans against "
|
|
706
|
+
f"{ins:,} table inserts"
|
|
707
|
+
),
|
|
708
|
+
"action": "Drop it, or accept the write cost explicitly and record why",
|
|
709
|
+
}
|
|
710
|
+
)
|
|
711
|
+
if am in VECTOR_ACCESS_METHODS and oversized and ins >= COLD_INDEX_INSERT_FLOOR:
|
|
712
|
+
findings.append(
|
|
713
|
+
{
|
|
714
|
+
"shape": "vector-insert-above-cache",
|
|
715
|
+
"title": SHAPES["vector-insert-above-cache"],
|
|
716
|
+
"object": f"{index['table']}.{index['name']}",
|
|
717
|
+
"evidence": (
|
|
718
|
+
f"{human_bytes(index['size_bytes'])} {am} graph against "
|
|
719
|
+
f"shared_buffers {human_bytes(shared_buffers)}; "
|
|
720
|
+
f"{ins:,} inserts each maintain it as random I/O"
|
|
721
|
+
),
|
|
722
|
+
"action": (
|
|
723
|
+
"Split fixed from marginal insert cost, then batch harder "
|
|
724
|
+
"or shrink the graph (m / dimensions) to fit cache"
|
|
725
|
+
),
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
if am in VECTOR_ACCESS_METHODS and oversized and index["idx_scan"] > COLD_INDEX_SCAN_CEILING:
|
|
729
|
+
findings.append(
|
|
730
|
+
{
|
|
731
|
+
"shape": "vector-read-above-cache",
|
|
732
|
+
"title": SHAPES["vector-read-above-cache"],
|
|
733
|
+
"object": f"{index['table']}.{index['name']}",
|
|
734
|
+
"evidence": (
|
|
735
|
+
f"{index['idx_scan']:,} scans against a "
|
|
736
|
+
f"{human_bytes(index['size_bytes'])} graph that cannot fit "
|
|
737
|
+
f"shared_buffers ({human_bytes(shared_buffers)})"
|
|
738
|
+
),
|
|
739
|
+
"action": "Size the graph to cache, or raise the cache",
|
|
740
|
+
}
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
for table in data["tables"]:
|
|
744
|
+
if (
|
|
745
|
+
table["toast_bytes"] > table["heap_bytes"]
|
|
746
|
+
and table["toast_bytes"] > 64 * 1024 * 1024
|
|
747
|
+
and (table["seq_scan"] > 0 or table["idx_scan"] > 0)
|
|
748
|
+
):
|
|
749
|
+
findings.append(
|
|
750
|
+
{
|
|
751
|
+
"shape": "toast-predicate",
|
|
752
|
+
"title": SHAPES["toast-predicate"],
|
|
753
|
+
"object": table["key"],
|
|
754
|
+
"evidence": (
|
|
755
|
+
f"{human_bytes(table['toast_bytes'])} TOAST against "
|
|
756
|
+
f"{human_bytes(table['heap_bytes'])} heap on "
|
|
757
|
+
f"~{table['n_live_tup']:,} rows (estimate)"
|
|
758
|
+
),
|
|
759
|
+
"action": (
|
|
760
|
+
"Check every predicate and sort touching the wide column; "
|
|
761
|
+
"maintain a derived scalar beside it and filter on that"
|
|
762
|
+
),
|
|
763
|
+
}
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
for statement in data.get("statements") or []:
|
|
767
|
+
text = statement["statement"].lower()
|
|
768
|
+
jsonb = "->>" in text or "->" in text or "@>" in text or "jsonb" in text
|
|
769
|
+
fuzzy = "similarity(" in text or " ilike " in text or "%" in text or "trgm" in text
|
|
770
|
+
if jsonb and fuzzy and statement["calls"] > 1000 and statement["mean_exec_ms"] >= 50:
|
|
771
|
+
findings.append(
|
|
772
|
+
{
|
|
773
|
+
"shape": "per-row-jsonb-trigram",
|
|
774
|
+
"title": SHAPES["per-row-jsonb-trigram"],
|
|
775
|
+
"object": statement["statement"][:80],
|
|
776
|
+
"evidence": (
|
|
777
|
+
f"{statement['calls']:,} calls at "
|
|
778
|
+
f"{statement['mean_exec_ms']:.0f} ms mean "
|
|
779
|
+
f"({statement['pct_of_db_time']:.1f}% of DB time)"
|
|
780
|
+
),
|
|
781
|
+
"action": (
|
|
782
|
+
"Composite index on the real filter columns; move the fuzzy "
|
|
783
|
+
"match behind an exact one"
|
|
784
|
+
),
|
|
785
|
+
}
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
vector_functions = [f for f in data["functions"] if f["uses_vector_ops"]]
|
|
789
|
+
for function in vector_functions:
|
|
790
|
+
config = " ".join(function["proconfig"])
|
|
791
|
+
if "hnsw.ef_search" not in config and "hnsw.iterative_scan" not in config:
|
|
792
|
+
findings.append(
|
|
793
|
+
{
|
|
794
|
+
"shape": "vector-read-above-cache",
|
|
795
|
+
"title": "Vector function carries no HNSW GUCs",
|
|
796
|
+
"object": f"{function['schema']}.{function['name']}",
|
|
797
|
+
"evidence": (
|
|
798
|
+
f"proconfig = {config or '(none)'} — no ef_search and no "
|
|
799
|
+
"iterative_scan, so the function runs at the pgvector defaults"
|
|
800
|
+
),
|
|
801
|
+
"action": (
|
|
802
|
+
"ALTER FUNCTION ... SET hnsw.ef_search / hnsw.iterative_scan; "
|
|
803
|
+
"session SET is lost under a transaction-mode pooler"
|
|
804
|
+
),
|
|
805
|
+
}
|
|
806
|
+
)
|
|
807
|
+
return findings
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# ---------------------------------------------------------------------------
|
|
811
|
+
# Diff
|
|
812
|
+
# ---------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
def diff_maps(previous: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
|
|
815
|
+
"""Compare two maps: new/removed tables, size moves, stalled indexes, liveness."""
|
|
816
|
+
prev_tables = {t["key"]: t for t in previous.get("tables", [])}
|
|
817
|
+
curr_tables = {t["key"]: t for t in current.get("tables", [])}
|
|
818
|
+
|
|
819
|
+
counters_reset = (
|
|
820
|
+
previous.get("window", {}).get("stats_reset")
|
|
821
|
+
!= current.get("window", {}).get("stats_reset")
|
|
822
|
+
) or (
|
|
823
|
+
previous.get("window", {}).get("postmaster_start_time")
|
|
824
|
+
!= current.get("window", {}).get("postmaster_start_time")
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
size_deltas = []
|
|
828
|
+
liveness_changes = []
|
|
829
|
+
for key, curr in curr_tables.items():
|
|
830
|
+
prev = prev_tables.get(key)
|
|
831
|
+
if prev is None:
|
|
832
|
+
continue
|
|
833
|
+
before, after = prev["total_bytes"], curr["total_bytes"]
|
|
834
|
+
if before > 0:
|
|
835
|
+
pct = 100.0 * (after - before) / before
|
|
836
|
+
if abs(pct) >= SIZE_DELTA_PCT:
|
|
837
|
+
size_deltas.append(
|
|
838
|
+
{
|
|
839
|
+
"table": key,
|
|
840
|
+
"before_bytes": before,
|
|
841
|
+
"after_bytes": after,
|
|
842
|
+
"pct": round(pct, 1),
|
|
843
|
+
}
|
|
844
|
+
)
|
|
845
|
+
if prev.get("liveness") != curr.get("liveness"):
|
|
846
|
+
liveness_changes.append(
|
|
847
|
+
{
|
|
848
|
+
"table": key,
|
|
849
|
+
"before": prev.get("liveness"),
|
|
850
|
+
"after": curr.get("liveness"),
|
|
851
|
+
"reason": curr.get("liveness_reason", ""),
|
|
852
|
+
}
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
prev_indexes = {(i["table"], i["name"]): i for i in previous.get("indexes", [])}
|
|
856
|
+
stalled = []
|
|
857
|
+
for index in current.get("indexes", []):
|
|
858
|
+
prev_index = prev_indexes.get((index["table"], index["name"]))
|
|
859
|
+
if prev_index is None:
|
|
860
|
+
continue
|
|
861
|
+
scan_delta = index["idx_scan"] - prev_index["idx_scan"]
|
|
862
|
+
prev_table = prev_tables.get(index["table"])
|
|
863
|
+
curr_table = curr_tables.get(index["table"])
|
|
864
|
+
if prev_table is None or curr_table is None:
|
|
865
|
+
continue
|
|
866
|
+
ins_delta = curr_table["n_tup_ins"] - prev_table["n_tup_ins"]
|
|
867
|
+
if scan_delta <= 0 and ins_delta > 0:
|
|
868
|
+
stalled.append(
|
|
869
|
+
{
|
|
870
|
+
"index": index["name"],
|
|
871
|
+
"table": index["table"],
|
|
872
|
+
"access_method": index["access_method"],
|
|
873
|
+
"scan_delta": scan_delta,
|
|
874
|
+
"insert_delta": ins_delta,
|
|
875
|
+
"size_bytes": index["size_bytes"],
|
|
876
|
+
}
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
return {
|
|
880
|
+
"previous_generated_at": previous.get("generated_at"),
|
|
881
|
+
"counters_reset_between_maps": counters_reset,
|
|
882
|
+
"new_tables": sorted(set(curr_tables) - set(prev_tables)),
|
|
883
|
+
"removed_tables": sorted(set(prev_tables) - set(curr_tables)),
|
|
884
|
+
"size_deltas": sorted(size_deltas, key=lambda d: -abs(d["pct"])),
|
|
885
|
+
"stalled_indexes": sorted(stalled, key=lambda d: -d["insert_delta"]),
|
|
886
|
+
"liveness_changes": sorted(liveness_changes, key=lambda d: d["table"]),
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
# ---------------------------------------------------------------------------
|
|
891
|
+
# Rendering
|
|
892
|
+
# ---------------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
def human_bytes(value: int | None) -> str:
|
|
895
|
+
if value is None:
|
|
896
|
+
return "n/a"
|
|
897
|
+
step = 1024.0
|
|
898
|
+
amount = float(value)
|
|
899
|
+
for unit in ("B", "kB", "MB", "GB", "TB"):
|
|
900
|
+
if abs(amount) < step or unit == "TB":
|
|
901
|
+
return f"{amount:.0f} {unit}" if unit == "B" else f"{amount:.1f} {unit}"
|
|
902
|
+
amount /= step
|
|
903
|
+
return f"{amount:.1f} TB"
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def render_markdown(data: dict[str, Any], diff: dict[str, Any] | None = None) -> str:
|
|
907
|
+
window = data["window"]
|
|
908
|
+
out: list[str] = []
|
|
909
|
+
out.append(f"# Database map — {window.get('database') or 'database'}")
|
|
910
|
+
out.append("")
|
|
911
|
+
out.append(
|
|
912
|
+
f"Generated {data['generated_at']} from `{data['dsn']}`. "
|
|
913
|
+
f"{data['table_count']} user tables. Read-only collection; no DDL or DML was issued."
|
|
914
|
+
)
|
|
915
|
+
out.append("")
|
|
916
|
+
|
|
917
|
+
out.append("## Counter window")
|
|
918
|
+
out.append("")
|
|
919
|
+
out.append("Every counter below is *since* these timestamps. State the window with any claim.")
|
|
920
|
+
out.append("")
|
|
921
|
+
out.append("| Field | Value |")
|
|
922
|
+
out.append("|---|---|")
|
|
923
|
+
out.append(f"| postmaster start | {window.get('postmaster_start_time', '')} |")
|
|
924
|
+
out.append(f"| stats_reset | {window.get('stats_reset') or 'never reset'} |")
|
|
925
|
+
out.append(f"| collected at | {window.get('collected_at', '')} |")
|
|
926
|
+
out.append(f"| server | {window.get('server_version', '')[:80]} |")
|
|
927
|
+
out.append(
|
|
928
|
+
f"| pg_stat_statements | {'available' if data['pg_stat_statements'] else 'NOT INSTALLED — time attribution skipped'} |"
|
|
929
|
+
)
|
|
930
|
+
out.append("")
|
|
931
|
+
|
|
932
|
+
out.append("## Server settings")
|
|
933
|
+
out.append("")
|
|
934
|
+
out.append("| Setting | Value |")
|
|
935
|
+
out.append("|---|---|")
|
|
936
|
+
for name in sorted(data["settings"]):
|
|
937
|
+
entry = data["settings"][name]
|
|
938
|
+
unit = entry.get("unit") or ""
|
|
939
|
+
out.append(f"| {name} | {entry['setting']}{(' ' + unit) if unit else ''} |")
|
|
940
|
+
out.append("")
|
|
941
|
+
if data.get("shared_buffers_bytes"):
|
|
942
|
+
out.append(
|
|
943
|
+
f"`shared_buffers` resolves to {human_bytes(data['shared_buffers_bytes'])}. "
|
|
944
|
+
"Any index larger than that is traversed from disk on every maintenance write."
|
|
945
|
+
)
|
|
946
|
+
out.append("")
|
|
947
|
+
|
|
948
|
+
out.append("## Domain rollup")
|
|
949
|
+
out.append("")
|
|
950
|
+
out.append("| Domain | Tables | Total size | Index scans | Writes |")
|
|
951
|
+
out.append("|---|---:|---:|---:|---:|")
|
|
952
|
+
for domain in sorted(data["domains"], key=lambda d: -data["domains"][d]["total_bytes"]):
|
|
953
|
+
bucket = data["domains"][domain]
|
|
954
|
+
out.append(
|
|
955
|
+
f"| {domain} | {bucket['tables']} | {human_bytes(bucket['total_bytes'])} "
|
|
956
|
+
f"| {bucket['idx_scan']:,} | {bucket['writes']:,} |"
|
|
957
|
+
)
|
|
958
|
+
out.append("")
|
|
959
|
+
|
|
960
|
+
out.append("## Tables")
|
|
961
|
+
out.append("")
|
|
962
|
+
out.append(
|
|
963
|
+
"`live~` and `dead~` are planner estimates and never decide emptiness "
|
|
964
|
+
"(constitution, Cost Attribution rule 7). Use `count(*)` for that."
|
|
965
|
+
)
|
|
966
|
+
out.append("")
|
|
967
|
+
out.append("| Table | Domain | Total | Heap | Idx | TOAST | live~ | ins/upd/del | seq/idx scan | Liveness |")
|
|
968
|
+
out.append("|---|---|---:|---:|---:|---:|---:|---|---|---|")
|
|
969
|
+
for table in sorted(data["tables"], key=lambda t: -t["total_bytes"]):
|
|
970
|
+
out.append(
|
|
971
|
+
"| {key} | {domain} | {total} | {heap} | {idx} | {toast} | {live:,} "
|
|
972
|
+
"| {ins:,}/{upd:,}/{dele:,} | {seq:,}/{idx_scan:,} | {liveness} |".format(
|
|
973
|
+
key=table["key"],
|
|
974
|
+
domain=table["domain"],
|
|
975
|
+
total=human_bytes(table["total_bytes"]),
|
|
976
|
+
heap=human_bytes(table["heap_bytes"]),
|
|
977
|
+
idx=human_bytes(table["index_bytes"]),
|
|
978
|
+
toast=human_bytes(table["toast_bytes"]),
|
|
979
|
+
live=table["n_live_tup"],
|
|
980
|
+
ins=table["n_tup_ins"],
|
|
981
|
+
upd=table["n_tup_upd"],
|
|
982
|
+
dele=table["n_tup_del"],
|
|
983
|
+
seq=table["seq_scan"],
|
|
984
|
+
idx_scan=table["idx_scan"],
|
|
985
|
+
liveness=table["liveness"],
|
|
986
|
+
)
|
|
987
|
+
)
|
|
988
|
+
out.append("")
|
|
989
|
+
|
|
990
|
+
out.append("## Liveness")
|
|
991
|
+
out.append("")
|
|
992
|
+
buckets: dict[str, list[dict[str, Any]]] = {}
|
|
993
|
+
for table in data["tables"]:
|
|
994
|
+
buckets.setdefault(table["liveness"], []).append(table)
|
|
995
|
+
for verdict in ("live", "written-only", "idle", "never-written"):
|
|
996
|
+
entries = buckets.get(verdict, [])
|
|
997
|
+
out.append(f"**{verdict}** — {len(entries)} table(s)")
|
|
998
|
+
out.append("")
|
|
999
|
+
if verdict != "live":
|
|
1000
|
+
for table in sorted(entries, key=lambda t: t["key"]):
|
|
1001
|
+
out.append(f"- `{table['key']}` — {table['liveness_reason']}")
|
|
1002
|
+
if entries:
|
|
1003
|
+
out.append("")
|
|
1004
|
+
out.append(
|
|
1005
|
+
"No verdict here retires anything. A retirement still needs the full gate in the skill body."
|
|
1006
|
+
)
|
|
1007
|
+
out.append("")
|
|
1008
|
+
|
|
1009
|
+
out.append("## Vector, text, and GIN indexes")
|
|
1010
|
+
out.append("")
|
|
1011
|
+
out.append("| Index | Table | AM | Size | Scans | Table inserts |")
|
|
1012
|
+
out.append("|---|---|---|---:|---:|---:|")
|
|
1013
|
+
tables_by_key = {t["key"]: t for t in data["tables"]}
|
|
1014
|
+
special = [i for i in data["indexes"] if i["access_method"] in SPECIAL_ACCESS_METHODS]
|
|
1015
|
+
for index in sorted(special, key=lambda i: -i["size_bytes"]):
|
|
1016
|
+
table = tables_by_key.get(index["table"], {})
|
|
1017
|
+
out.append(
|
|
1018
|
+
f"| {index['name']} | {index['table']} | {index['access_method']} "
|
|
1019
|
+
f"| {human_bytes(index['size_bytes'])} | {index['idx_scan']:,} "
|
|
1020
|
+
f"| {table.get('n_tup_ins', 0):,} |"
|
|
1021
|
+
)
|
|
1022
|
+
if not special:
|
|
1023
|
+
out.append("| _none_ | | | | | |")
|
|
1024
|
+
out.append("")
|
|
1025
|
+
|
|
1026
|
+
out.append("## Function GUCs")
|
|
1027
|
+
out.append("")
|
|
1028
|
+
out.append("| Function | proconfig | Uses vector ops |")
|
|
1029
|
+
out.append("|---|---|---|")
|
|
1030
|
+
for function in data["functions"]:
|
|
1031
|
+
config = " ".join(function["proconfig"]) or "_none_"
|
|
1032
|
+
out.append(
|
|
1033
|
+
f"| {function['schema']}.{function['name']} | {config} "
|
|
1034
|
+
f"| {'yes' if function['uses_vector_ops'] else 'no'} |"
|
|
1035
|
+
)
|
|
1036
|
+
if not data["functions"]:
|
|
1037
|
+
out.append("| _none_ | | |")
|
|
1038
|
+
out.append("")
|
|
1039
|
+
out.append(
|
|
1040
|
+
"A vector-search function with no `hnsw.ef_search` / `hnsw.iterative_scan` in "
|
|
1041
|
+
"`proconfig` runs at the pgvector defaults regardless of what the repo's SQL "
|
|
1042
|
+
"file says. That gap is drift, and only `ALTER FUNCTION ... SET` survives a "
|
|
1043
|
+
"transaction-mode pooler."
|
|
1044
|
+
)
|
|
1045
|
+
out.append("")
|
|
1046
|
+
|
|
1047
|
+
out.append("## Top statements by total_exec_time")
|
|
1048
|
+
out.append("")
|
|
1049
|
+
if data["pg_stat_statements"]:
|
|
1050
|
+
out.append("| % DB time | Calls | Mean ms | rows/call | temp blks | Statement |")
|
|
1051
|
+
out.append("|---:|---:|---:|---:|---:|---|")
|
|
1052
|
+
for statement in data["statements"] or []:
|
|
1053
|
+
out.append(
|
|
1054
|
+
f"| {statement['pct_of_db_time']:.2f} | {statement['calls']:,} "
|
|
1055
|
+
f"| {statement['mean_exec_ms']:.1f} | {statement['rows_per_call']:.2f} "
|
|
1056
|
+
f"| {statement['temp_blks_written']:,} | `{statement['statement'][:120]}` |"
|
|
1057
|
+
)
|
|
1058
|
+
else:
|
|
1059
|
+
out.append(
|
|
1060
|
+
"`pg_stat_statements` is not installed, so no time attribution was collected. "
|
|
1061
|
+
"Every cost claim in a plan needs a measured share — install the extension "
|
|
1062
|
+
"before proposing a performance fix."
|
|
1063
|
+
)
|
|
1064
|
+
out.append("")
|
|
1065
|
+
|
|
1066
|
+
out.append("## Temp spill")
|
|
1067
|
+
out.append("")
|
|
1068
|
+
temp = data["temp"]
|
|
1069
|
+
out.append("| Field | Value |")
|
|
1070
|
+
out.append("|---|---|")
|
|
1071
|
+
out.append(f"| temp_files | {temp.get('temp_files', 0):,} |")
|
|
1072
|
+
out.append(f"| temp_bytes | {human_bytes(temp.get('temp_bytes', 0))} |")
|
|
1073
|
+
out.append(f"| cache hit | {temp.get('cache_hit_pct')}% |")
|
|
1074
|
+
out.append(f"| deadlocks | {temp.get('deadlocks', 0):,} |")
|
|
1075
|
+
out.append("")
|
|
1076
|
+
for entry in temp.get("top_spilling_statements", []):
|
|
1077
|
+
out.append(
|
|
1078
|
+
f"- {entry['temp_blks_written']:,} temp blocks over {entry['calls']:,} calls: "
|
|
1079
|
+
f"`{entry['statement'][:100]}`"
|
|
1080
|
+
)
|
|
1081
|
+
if temp.get("top_spilling_statements"):
|
|
1082
|
+
out.append("")
|
|
1083
|
+
|
|
1084
|
+
out.append("## Shapes")
|
|
1085
|
+
out.append("")
|
|
1086
|
+
out.append(
|
|
1087
|
+
"Each finding names which of the five cost shapes it matches. The shape picks "
|
|
1088
|
+
"the first action; it does not authorize the change."
|
|
1089
|
+
)
|
|
1090
|
+
out.append("")
|
|
1091
|
+
if data["shapes"]:
|
|
1092
|
+
out.append("| Shape | Object | Evidence | First action |")
|
|
1093
|
+
out.append("|---|---|---|---|")
|
|
1094
|
+
for finding in data["shapes"]:
|
|
1095
|
+
out.append(
|
|
1096
|
+
f"| {finding['title']} | `{finding['object']}` | {finding['evidence']} "
|
|
1097
|
+
f"| {finding['action']} |"
|
|
1098
|
+
)
|
|
1099
|
+
else:
|
|
1100
|
+
out.append("No shape matched the collected counters.")
|
|
1101
|
+
out.append("")
|
|
1102
|
+
|
|
1103
|
+
if diff is not None:
|
|
1104
|
+
out.extend(_render_diff(diff))
|
|
1105
|
+
return "\n".join(out) + "\n"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _render_diff(diff: dict[str, Any]) -> list[str]:
|
|
1109
|
+
out = [f"## {MD_DIFF_SECTION}", ""]
|
|
1110
|
+
out.append(f"Previous map generated {diff.get('previous_generated_at')}.")
|
|
1111
|
+
out.append("")
|
|
1112
|
+
if diff.get("counters_reset_between_maps"):
|
|
1113
|
+
out.append(
|
|
1114
|
+
"**The counters reset between the two maps.** Every delta below is a floor, "
|
|
1115
|
+
"not a measurement — the server restarted or statistics were reset."
|
|
1116
|
+
)
|
|
1117
|
+
out.append("")
|
|
1118
|
+
out.append(f"- New tables: {', '.join(diff['new_tables']) or 'none'}")
|
|
1119
|
+
out.append(f"- Removed tables: {', '.join(diff['removed_tables']) or 'none'}")
|
|
1120
|
+
out.append("")
|
|
1121
|
+
out.append(f"### Size moves over {SIZE_DELTA_PCT:.0f}%")
|
|
1122
|
+
out.append("")
|
|
1123
|
+
if diff["size_deltas"]:
|
|
1124
|
+
out.append("| Table | Before | After | Change |")
|
|
1125
|
+
out.append("|---|---:|---:|---:|")
|
|
1126
|
+
for entry in diff["size_deltas"]:
|
|
1127
|
+
out.append(
|
|
1128
|
+
f"| {entry['table']} | {human_bytes(entry['before_bytes'])} "
|
|
1129
|
+
f"| {human_bytes(entry['after_bytes'])} | {entry['pct']:+.1f}% |"
|
|
1130
|
+
)
|
|
1131
|
+
else:
|
|
1132
|
+
out.append("None.")
|
|
1133
|
+
out.append("")
|
|
1134
|
+
out.append("### Indexes maintained but not read since the last map")
|
|
1135
|
+
out.append("")
|
|
1136
|
+
if diff["stalled_indexes"]:
|
|
1137
|
+
out.append("| Index | Table | AM | New scans | New inserts | Size |")
|
|
1138
|
+
out.append("|---|---|---|---:|---:|---:|")
|
|
1139
|
+
for entry in diff["stalled_indexes"]:
|
|
1140
|
+
out.append(
|
|
1141
|
+
f"| {entry['index']} | {entry['table']} | {entry['access_method']} "
|
|
1142
|
+
f"| {entry['scan_delta']:,} | {entry['insert_delta']:,} "
|
|
1143
|
+
f"| {human_bytes(entry['size_bytes'])} |"
|
|
1144
|
+
)
|
|
1145
|
+
else:
|
|
1146
|
+
out.append("None.")
|
|
1147
|
+
out.append("")
|
|
1148
|
+
out.append("### Liveness changes")
|
|
1149
|
+
out.append("")
|
|
1150
|
+
if diff["liveness_changes"]:
|
|
1151
|
+
for entry in diff["liveness_changes"]:
|
|
1152
|
+
out.append(
|
|
1153
|
+
f"- `{entry['table']}`: {entry['before']} → {entry['after']} ({entry['reason']})"
|
|
1154
|
+
)
|
|
1155
|
+
else:
|
|
1156
|
+
out.append("None.")
|
|
1157
|
+
out.append("")
|
|
1158
|
+
return out
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
# ---------------------------------------------------------------------------
|
|
1162
|
+
# Collection
|
|
1163
|
+
# ---------------------------------------------------------------------------
|
|
1164
|
+
|
|
1165
|
+
def collect(dsn: str, statement_timeout_s: int, psql: str = "psql") -> tuple[dict[str, list[list[str]]], bool]:
|
|
1166
|
+
"""Run the probe, then the main script. Returns parsed sections + extension flag."""
|
|
1167
|
+
probe_out = run_psql(
|
|
1168
|
+
dsn, build_script([("probe", PROBE_SQL)], statement_timeout_s), statement_timeout_s, psql
|
|
1169
|
+
)
|
|
1170
|
+
probe = parse_sections(probe_out).get("probe", [])
|
|
1171
|
+
has_pgss = bool(probe and probe[0] and _int(probe[0][0]) > 0)
|
|
1172
|
+
|
|
1173
|
+
sections = list(SECTION_SQL)
|
|
1174
|
+
if has_pgss:
|
|
1175
|
+
sections.append(("statements", STATEMENTS_SQL))
|
|
1176
|
+
sections.append(("statement_temp", STATEMENT_TEMP_SQL))
|
|
1177
|
+
output = run_psql(dsn, build_script(sections, statement_timeout_s), statement_timeout_s, psql)
|
|
1178
|
+
return parse_sections(output), has_pgss
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def load_domain_rules(path: str | None) -> list[list[str]]:
|
|
1182
|
+
if not path:
|
|
1183
|
+
return DEFAULT_DOMAIN_RULES
|
|
1184
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
1185
|
+
if not isinstance(raw, list) or any(len(item) != 2 for item in raw):
|
|
1186
|
+
raise SystemExit("--domain-rules must be a JSON list of [domain, regex] pairs")
|
|
1187
|
+
for _, pattern in raw:
|
|
1188
|
+
re.compile(pattern)
|
|
1189
|
+
return [[str(domain), str(pattern)] for domain, pattern in raw]
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
1193
|
+
parser = argparse.ArgumentParser(
|
|
1194
|
+
description="Read-only PostgreSQL table map for build-loop:database-practice.",
|
|
1195
|
+
)
|
|
1196
|
+
parser.add_argument("--dsn", help="Postgres DSN (default: $DATABASE_URL, then $DIRECT_URL)")
|
|
1197
|
+
parser.add_argument("--out-json", help="Write the machine-readable map here")
|
|
1198
|
+
parser.add_argument("--out-md", help="Write the Markdown map here (default: stdout)")
|
|
1199
|
+
parser.add_argument("--domain-rules", help="JSON file: list of [domain, regex] pairs")
|
|
1200
|
+
parser.add_argument("--prev", help="Previous --out-json map to diff against")
|
|
1201
|
+
parser.add_argument(
|
|
1202
|
+
"--statement-timeout",
|
|
1203
|
+
type=int,
|
|
1204
|
+
default=20,
|
|
1205
|
+
help="SET LOCAL statement_timeout, in seconds (default 20)",
|
|
1206
|
+
)
|
|
1207
|
+
parser.add_argument("--psql", default="psql", help="psql binary to use (default: psql)")
|
|
1208
|
+
return parser.parse_args(argv)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1212
|
+
args = parse_args(list(sys.argv[1:] if argv is None else argv))
|
|
1213
|
+
dsn = resolve_dsn(args.dsn)
|
|
1214
|
+
rules = load_domain_rules(args.domain_rules)
|
|
1215
|
+
|
|
1216
|
+
sections, has_pgss = collect(dsn, args.statement_timeout, args.psql)
|
|
1217
|
+
data = build_map(sections, rules, redact_dsn(dsn), has_pgss)
|
|
1218
|
+
|
|
1219
|
+
diff = None
|
|
1220
|
+
if args.prev:
|
|
1221
|
+
previous = json.loads(Path(args.prev).read_text(encoding="utf-8"))
|
|
1222
|
+
diff = diff_maps(previous, data)
|
|
1223
|
+
data["diff"] = diff
|
|
1224
|
+
|
|
1225
|
+
markdown = render_markdown(data, diff)
|
|
1226
|
+
if args.out_json:
|
|
1227
|
+
Path(args.out_json).parent.mkdir(parents=True, exist_ok=True)
|
|
1228
|
+
Path(args.out_json).write_text(json.dumps(data, indent=2, sort_keys=False) + "\n", encoding="utf-8")
|
|
1229
|
+
if args.out_md:
|
|
1230
|
+
Path(args.out_md).parent.mkdir(parents=True, exist_ok=True)
|
|
1231
|
+
Path(args.out_md).write_text(markdown, encoding="utf-8")
|
|
1232
|
+
else:
|
|
1233
|
+
sys.stdout.write(markdown)
|
|
1234
|
+
|
|
1235
|
+
print(
|
|
1236
|
+
f"[db_table_map] {data['table_count']} tables, {len(data['indexes'])} indexes, "
|
|
1237
|
+
f"{len(data['shapes'])} shape findings",
|
|
1238
|
+
file=sys.stderr,
|
|
1239
|
+
)
|
|
1240
|
+
return 0
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1244
|
+
raise SystemExit(main())
|