diff --git a/.gitignore b/.gitignore index e78d75d..ea1f005 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,11 @@ build/ *.log .vscode/ .idea/ + +# Regression test framework +testing-venv/ +__pycache__/ +.pytest_cache/ + +# Generated benchmark output (regenerate via scenarios/contoso/scaling-benchmark/) +scenarios/contoso/scaling-benchmark/results.tsv diff --git a/AGENTS.md b/AGENTS.md index b954729..277023b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,31 @@ This repository is an **Agent Skills** pack for **Azure DocumentDB (with MongoDB ## How agents should use this kit -### Skill routing (do this first) +This kit has **two routes**. Decide the route *first*, from what the user said: + +| The user says… | Route | What the agent does | +|---|---|---| +| **"use toolbox"** (explicit), or clearly asks to *run the diagnostic scripts* against a live database | **Route A — Diagnostic toolbox** | Use the knowledge-base router to pick and run a **read-only** diagnostic script against the user's *local* DocumentDB container, then report the findings. | +| **anything else** (the default) | **Route B — Text skills** | Route to the best `skills/*/SKILL.md` and answer from guidance. | + +**Default to Route B.** Only take Route A when the user **explicitly** says *"use toolbox"* (or unambiguously asks to run the diagnostic scripts / inspect a live local database). Do not run any script on Route B. + +### Route A — Diagnostic toolbox (only when the user says "use toolbox") + +Deterministic, **read-only** scripts that inspect a *local* DocumentDB container (both the MongoDB API and the PostgreSQL engine underneath) and emit findings. They never modify data; no cloud, no API keys. Steps: + +1. **Prerequisites:** a running container (default name `documentdb-local`) and a password exported as `DB_PASSWORD` (or passed via `--password`); `db-config-advisor` is PG-only and needs no password. See [`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md) for the one-line `docker run` and seeding. +2. **Route the question to the exact script** with the knowledge-base router — deterministic keyword scoring, needs no LLM and no container: + ```bash + bash knowledge-base/kb-route.sh --db "" + ``` + It prints the matching tool and the exact command (append `--json` for machine-readable output). See [`knowledge-base/README.md`](knowledge-base/README.md). +3. **Run the recommended command** (all scripts are read-only) and interpret the findings/insights for the user. Present the fix as a recommendation — applying it (e.g. a schema split or dropping an index) is the user's decision. +4. **If the router is not confident** (no match / low score), fall back to Route B. + +Tools available on this route: `document-bloat-advisor`, `index-redundancy-finder`, `db-config-advisor`, `perf-advisor`, `data-integrity-check` — catalog in [`README.md`](README.md#the-tools-scripts). Regression-guarded by [`testing/`](testing/README.md). + +### Route B — Text skills (default): skill routing (do this first) This kit ships **17+ skills**, which is too many to reliably pick from a flat table. Agents should route in this order: @@ -58,6 +82,10 @@ These skills walk the user (or another agent) through a task end-to-end. ## Routing hints for agents +These map a task to the best **Route B (text) skill**. (On **Route A** — when the +user said *"use toolbox"* — route the same task through `knowledge-base/kb-route.sh` +to a diagnostic script instead.) + - **Writing / generating a query** → `documentdb-natural-language-querying` - **"Why is this query slow / how do I index this?"** → `documentdb-query-optimizer` - **"Which index type should I use / design this index"** → `documentdb-indexing` diff --git a/README.md b/README.md index 50a47da..bd1f2e1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,60 @@ Skills follow the [Agent Skills](https://agentskills.io/) format and the kit shi 👉 **Capabilities and skill catalog:** [`docs/SKILLS.md`](docs/SKILLS.md) +## Diagnostic Toolbox — Quickstart + +Beyond the text skills, the kit ships **deterministic diagnostic scripts** and a +**knowledge-base router** that inspect a *local* DocumentDB container and return +evidence-based answers (reading both the MongoDB API and the PostgreSQL engine +underneath). They need only `docker`, `bash`, and `python3` — no MCP server, no +cloud, no API keys. Full guide: [`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md). + +### The tools (`scripts/`) + +All are **read-only** (they never modify data) and **cross-layer** (MongoDB API + +PostgreSQL engine). Each takes `--db `; add `--json` for a compact +machine-readable result (what the router consumes). + +| Script | Answers | `--json` | +|--------|---------|:--:| +| `document-bloat-advisor.sh` | Which collections have large text TOASTed and detoasted on every scan; which field to split out. | ✅ | +| `index-redundancy-finder.sh` | Redundant (prefix/duplicate/reverse) or unused indexes safe to drop. | ✅ | +| `db-config-advisor.sh` | Working set vs cache, TOAST share, cache-hit ratios — evidence-based config review. | ✅ | +| `perf-advisor.sh` | Overall health: collection-scan audit, query timing, PG I/O / locks / config. | ✅ | +| `data-integrity-check.sh` | Orphaned foreign-key references and mixed-type fields (hard structural integrity). | ✅ | + +Common flags: `--container NAME`, `--password PASS`, `--port`, `--pg-port`; env +vars `DB_USER` / `DB_PASSWORD` / `PORT` / `PG_PORT` are also honored. **No password +is baked in** — set `DB_PASSWORD` (or pass `--password`). + +### Quickstart + +```bash +# 0. start a local DocumentDB container (choose any password; the scripts read it) +docker run -dt --name documentdb-local -p 10260:10260 \ + -e USERNAME=docdbadmin -e PASSWORD=Test1234 \ + ghcr.io/microsoft/documentdb/documentdb-local:latest +export DB_PASSWORD=Test1234 # the scripts require this (or --password) + +# 1. seed demo data +bash scenarios/ecommerce/seed.sh # -> "ecommerce" +bash scenarios/contoso/seed.sh # -> "contoso" (TOAST demo) + +# 2. diagnose (read-only; add --json for machine output) +bash scripts/document-bloat-advisor.sh --db contoso +bash scripts/index-redundancy-finder.sh --db ecommerce + +# 3. or ask in natural language — the router picks the tool (no LLM, no container) +bash knowledge-base/kb-route.sh --db contoso "why are my aggregations slow even though I have indexes" +``` + +Demo datasets are seeders under [`scenarios/`](scenarios/) (they plant the +problems the tools find). The regression suite in [`testing/`](testing/README.md) +guards the scripts. + +- **Router:** [`knowledge-base/README.md`](knowledge-base/README.md) · **Demo datasets:** [`scenarios/`](scenarios/) +- **Regression tests:** [`testing/README.md`](testing/README.md) · **Token study:** [`token-tests/RESULTS.md`](token-tests/RESULTS.md) + ## Repo Structure ``` @@ -25,6 +79,12 @@ skills/ / # standalone skill (mcp-setup, query-optimizer, …) SKILL.md # agent-facing activation + instructions references/ # reference docs the skill loads at runtime +scripts/ # diagnostic toolbox — read-only analyzers + seeders +knowledge-base/ # NL → script router (kb.json + kb_route.py) + demo +scenarios/contoso/ # ready-to-run TOAST demo dataset (+ optional scaling-benchmark/) +testing/ # fixture-first regression suite for the scripts (pytest) +token-tests/ # measured token savings of scripts vs text-skill workflows +docs/ # SKILLS.md (catalog) + DIAGNOSTICS.md (toolbox guide) ``` ## Installation diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8b20f7e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations. + +## Reporting Security Vulnerabilities + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Instead, report them to the Microsoft Security Response Center (MSRC): + +- Report a vulnerability: https://msrc.microsoft.com/create-report +- MSRC vulnerability reporting guidance: https://www.microsoft.com/msrc + +If you prefer to submit without logging in, use: + +- https://www.microsoft.com/msrc/report-a-vulnerability + +You should receive a response within 24 hours. If you do not receive a response, please follow up via the reporting portal. + +Please include as much information as possible to help us reproduce and investigate the issue: + +- Type of issue +- Full paths of affected files or components +- Steps to reproduce +- Proof-of-concept code (if available) +- Potential impact assessment + +## Supported Versions + +As this project is under active development, security fixes are typically provided in the latest version of the repository. + +Users are encouraged to: +- Use the latest released version. +- Keep dependencies up to date. +- Follow Azure and Microsoft security best practices when deploying solutions based on this repository. + +## Additional Resources + +For more information about Microsoft's vulnerability disclosure process, see: + +- Microsoft Security Response Center: https://www.microsoft.com/msrc +- Coordinated Vulnerability Disclosure: https://www.microsoft.com/msrc/cvd diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000..cd3cd2f --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,137 @@ +# Diagnostic Toolbox — end-to-end guide + +The kit ships **deterministic diagnostic scripts** plus a **knowledge-base router** +for a *local* DocumentDB container. They complement the text skills: where a skill +tells an agent *what to consider*, these tools **measure the live database** and +return an evidence-based answer — reading both the MongoDB API (`mongosh`) and the +PostgreSQL engine underneath (`psql`). + +Everything here runs against a Docker container and needs only `docker`, `bash`, +and `python3` on the host — no MCP server, no cloud, no API keys. + +| Piece | Path | What it is | +|-------|------|-----------| +| Diagnostic scripts | [`scripts/`](../scripts/) | 5 read-only analyzers (TOAST/bloat, index redundancy, config/cache, perf, data integrity). | +| Knowledge-base router | [`knowledge-base/`](../knowledge-base/README.md) | Deterministic NL question → exact script (no LLM at routing time). | +| Demo datasets | [`scenarios/ecommerce/`](../scenarios/ecommerce/), [`scenarios/contoso/`](../scenarios/contoso/README.md) | Seeders that plant the problems the tools find. | +| Regression tests | [`testing/`](../testing/README.md) | Fixture-first contracts that guard the scripts. | +| Token study | [`token-tests/`](../token-tests/README.md) | Measured token savings of scripts vs text-skill workflows. | + +--- + +## 0. Start a local DocumentDB container + +Use the open-source Gateway image. Name it `documentdb-local` and pick a +password; the scripts read it from `DB_USER` (default `docdbadmin`) and +`DB_PASSWORD` — **nothing is baked in**: + +```bash +docker run -dt --name documentdb-local \ + -p 10260:10260 \ + -e USERNAME=docdbadmin \ + -e PASSWORD=Test1234 \ + ghcr.io/microsoft/documentdb/documentdb-local:latest + +# the scripts require a password — export it once (or pass --password each time) +export DB_PASSWORD=Test1234 + +# preflight: confirm the engine answers (should print "1") +docker exec documentdb-local psql -h localhost -p 9712 -U documentdb -d postgres -tAc "SELECT 1" +``` + +The scripts `docker exec` into this container (MongoDB API on 10260, PostgreSQL on +9712 internally), so **publishing ports is optional**. If you use different +credentials or a different container name, pass `--container` / `--password` or set +`DB_USER` / `DB_PASSWORD` / `PORT` / `PG_PORT` env vars — every script honors them. + +## 1. Seed demo data + +```bash +bash scenarios/ecommerce/seed.sh # -> database "ecommerce" +bash scenarios/contoso/seed.sh # -> database "contoso" (TOAST demo) +``` + +## 2. Run the diagnostics + +```bash +# Large-document / TOAST detoast tax (analysis only — no data changes) +bash scripts/document-bloat-advisor.sh --db contoso + +# Redundant / unused indexes you can drop +bash scripts/index-redundancy-finder.sh --db ecommerce + +# Working set vs cache, TOAST share, cache-hit ratios +bash scripts/db-config-advisor.sh --db contoso + +# Overall health: collection-scan audit, query timing, PG I/O / locks / config +bash scripts/perf-advisor.sh --db ecommerce + +# Orphaned foreign keys + mixed field types (hard structural integrity) +bash scripts/data-integrity-check.sh --db ecommerce +``` + +Add `--json` to any of them for a compact machine-readable result (what the router +and agents consume): + +```bash +bash scripts/document-bloat-advisor.sh --db contoso --json +``` + +## 3. Natural-language routing (optional) + +Don't know which tool you need? Ask in plain language — the router maps it to the +exact script, deterministically, **without a container or an LLM**: + +```bash +bash knowledge-base/kb-route.sh --db contoso "why are my aggregations slow even though I have indexes" +# → document-bloat-advisor · run: bash scripts/document-bloat-advisor.sh --db contoso [--json] + +# see the scoring walkthrough +python3 knowledge-base/kb_route_demo.py "which indexes can I drop" +``` + +## 4. See the TOAST fix in action (optional) + +```bash +# apply the schema split the advisor recommends, then re-run the advisor +docker cp scenarios/contoso/contoso-split-fix.js documentdb-local:/tmp/fix.js +docker exec -e CONTOSO_DB=contoso documentdb-local mongosh \ + "localhost:10260/contoso" -u docdbadmin -p Test1234 \ + --authenticationMechanism SCRAM-SHA-256 --tls --tlsAllowInvalidCertificates \ + --quiet --file /tmp/fix.js +bash scripts/document-bloat-advisor.sh --db contoso # opportunities now clean +``` + +## 5. Run the regression tests (optional) + +```bash +bash testing/run.sh # fixture-first contracts; auto-creates a venv +``` + +## 6. Reproduce the token study (optional) + +```bash +cd token-tests +bash token-ab-measure.sh | python3 summarize.py # see RESULTS.md for the table +``` + +--- + +## Connection defaults + +| Setting | Default | Override | +|---|---|---| +| container | `documentdb-local` | `--container` | +| Mongo port | `10260` | `--port` / `PORT` | +| PG port | `9712` | `--pg-port` / `PG_PORT` | +| Mongo user | `docdbadmin` | `DB_USER` | +| password | *(required)* | `--password` / `DB_PASSWORD` | +| PG user | `documentdb` | `PG_USER` | + +## Notes + +- The scripts are **read-only** — they never modify data. The only script that + changes data is the explicit, opt-in `contoso-split-fix.js` demo in step 4. +- The scaling benchmark under + [`scenarios/contoso/scaling-benchmark/`](../scenarios/contoso/scaling-benchmark/README.md) + is **optional/advanced** (multi-scale x1…x16) and is **not** part of this quickstart. diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 66865ea..a657508 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -42,6 +42,30 @@ Single-purpose skills the agent loads when its trigger description matches. | [`query-optimizer/`](../skills/query-optimizer/) | "Why is this query slow?", index review, `explain()`-driven tuning (indexing deep-dive lives in its `references/`) | | [`connection/`](../skills/connection/) | Connection pool / timeout / retry tuning; serverless vs OLTP vs OLAP patterns | +## Diagnostic toolbox (scripts + router) + +Beyond the text skills, the kit ships **deterministic, read-only diagnostic +scripts** that inspect a *local* DocumentDB container across both layers (MongoDB +API + PostgreSQL engine), plus a **knowledge-base router** that maps a natural- +language question to the exact script — no LLM at routing time. Full guide: +[`DIAGNOSTICS.md`](DIAGNOSTICS.md); catalog: [`../README.md`](../README.md#the-tools-scripts). + +| Tool | Answers | +|---|---| +| [`document-bloat-advisor.sh`](../scripts/document-bloat-advisor.sh) | Which collections have large text TOASTed and detoasted on every scan; which field to split out. | +| [`index-redundancy-finder.sh`](../scripts/index-redundancy-finder.sh) | Redundant (prefix/duplicate/reverse) or unused indexes safe to drop. | +| [`db-config-advisor.sh`](../scripts/db-config-advisor.sh) | Working set vs cache, TOAST share, cache-hit ratios (evidence-based). | +| [`perf-advisor.sh`](../scripts/perf-advisor.sh) | Overall health: collection-scan audit, query timing, PG I/O / locks / config. | +| [`data-integrity-check.sh`](../scripts/data-integrity-check.sh) | Orphaned foreign keys + mixed-type fields (hard structural integrity). | +| [`knowledge-base/`](../knowledge-base/README.md) | NL question → exact script (deterministic keyword scoring, zero deps). | + +Companion to the toolbox: the `data-modeling` skill's +[`model-large-field-split`](../skills/data-modeling/model-large-field-split.md) +rule explains the TOAST anti-pattern, and its analyzer +[`scripts/toast-split-advisor.sh`](../scripts/toast-split-advisor.sh) measures it. +The scripts are guarded by the regression suite in [`../testing/`](../testing/README.md), +and their token efficiency is measured in [`../token-tests/RESULTS.md`](../token-tests/RESULTS.md). + ## Use when - Designing document schemas for Azure DocumentDB diff --git a/knowledge-base/README.md b/knowledge-base/README.md new file mode 100644 index 0000000..9213d3f --- /dev/null +++ b/knowledge-base/README.md @@ -0,0 +1,115 @@ +# DocumentDB Agent-Kit — Knowledge Base Layer + +This is the layer that sits **above** the scripts and skills: it turns a +developer's **natural-language question** into the **exact diagnostic to run**. + +``` + natural-language query + │ + ▼ + ┌──────────────────┐ + │ knowledge base │ kb.json (single source of truth) + │ + router │ kb-route.sh + └────────┬─────────┘ + one hop │ multi hop (guarded workflow) + ┌────────────────┘ └───────────────┐ + ▼ ▼ + a single script step → (result?) → step → … → conclusion + (scripts/*.sh) (scripts/*.sh at each node) +``` + +Unlike a text-only skill kit (which hands the model prose and hopes it picks the +right approach), this layer gives a **deterministic, explainable routing +decision** and the ready-to-run command — while remaining fully consumable by an +LLM agent for the semantic cases. + +## Files + +| File | Role | +|------|------| +| `kb.json` | Declarative KB: `tools` (scripts + intents), `routes_one_hop`, `workflow_schema`, and `workflows` (multi-hop, currently one scaffold). Edit this to extend the kit. | +| `kb-route.sh` | CLI wrapper (bash): arg parsing + presence checks; passes inputs to `kb_route.py` via env vars. | +| `kb_route.py` | Routing engine (stdlib python3, no deps): keyword/example scoring → best tool + exact command. Standalone so it can be linted/tested/imported. | +| `kb_route_demo.py` | Teaching/debug aid: prints the full scoring walkthrough (per-tool score + signal breakdown) and how the router lands on the winner. `python3 knowledge-base/kb_route_demo.py [query]`. | +| `README.md` | This file. | + +## One-hop routing (implemented) + +```bash +# route a question to the right script +bash knowledge-base/kb-route.sh "why are my writes slow?" +bash knowledge-base/kb-route.sh --db mydb "audit my indexes for redundancy" + +# machine-readable (for the agent / pipelines) +bash knowledge-base/kb-route.sh --json --db mydb "is my cache hit ratio ok?" + +# discovery +bash knowledge-base/kb-route.sh --list # all tools + example queries +bash knowledge-base/kb-route.sh --workflows # multi-hop workflows (schema/scaffold) +``` + +Example: + +``` +Query: "is my cache hit ratio ok, do I need more shared_buffers" +→ Route: [db-config-advisor] Config & Cache Advisor (confidence: high, score 9.5) + matched: cache, cache hit, shared_buffers + run: bash scripts/db-config-advisor.sh --db mydb [--json] +``` + +Currently routed tools (all present DocumentDB diagnostics): + +| Tool | Answers questions like | +|------|------------------------| +| `index-redundancy-finder` | "why are writes slow", "which indexes can I drop" | +| `document-bloat-advisor` | "aggregations slow despite indexes", "are my documents too big / TOAST" | +| `db-config-advisor` | "cache hit ratio", "do I need more shared_buffers", "working set" | +| `perf-advisor` | "performance checkup", "missing indexes / collection scans" | +| `data-integrity-check` | "orphaned references", "referential integrity", "type consistency" | + +**Routing is deterministic:** the router scores the query against each tool's +`keywords` / `example_queries` in `kb.json` (multi-word phrases weigh more than +single tokens) plus the `routes_one_hop` signal, and returns a ranked result +with a confidence and alternatives. No LLM required; same input → same route. + +## Multi-hop workflows (schema + scaffold) + +Troubleshooting is rarely one script. The KB models a workflow as a **guarded +diagnostic graph** (an AND/OR decision graph — the classic sequential-diagnosis +structure): each **step** runs a tool; each **edge** is conditional on the +observed result; the agent advances until it reaches a conclusion. This directly +supports "run check A; depending on the result, run B or C" with dependencies +between steps. + +The structure is defined in `kb.json → workflow_schema`, and one **illustrative +scaffold** (`slow-writes`, marked `status: scaffold`) shows the shape: + +``` +slow-writes: + check_redundant_indexes ──(findings>0)──▶ conclude: drop redundant indexes + └(findings=0)──▶ check_document_bloat + ├(bloat)──▶ conclude: split large text + └(none)──▶ check_config ──▶ conclude… +``` + +> Workflows are intentionally **not yet populated/validated** — this layer only +> provides the skeleton so real troubleshooting graphs can be added over time. +> The agent (or a future traversal script) walks the graph, running the tool at +> each node and matching the reported result to an edge. + +## Extending the KB + +- **Add a one-hop tool:** append an entry to `tools[]` in `kb.json` with its + `script`, `invocation`, `keywords`, and `example_queries`. The router picks it + up automatically — no code change. +- **Add a workflow:** append to `workflows[]` following `workflow_schema` + (`entry`, `steps`, `depends_on`, guarded `yields`). + +## How the agent should use this layer + +1. On a natural-language diagnostic question, call `kb-route.sh --json ""`. +2. If `confident`, run the emitted `command` (filling `--db`), then interpret the + script's measured output for the user. +3. If a multi-hop workflow applies, start at its `entry` step and follow the + guarded edges using each step's result. +4. If no confident route, fall back to `--list` and ask the user to clarify. diff --git a/knowledge-base/kb-route.sh b/knowledge-base/kb-route.sh new file mode 100755 index 0000000..bc9334e --- /dev/null +++ b/knowledge-base/kb-route.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# kb-route.sh — DocumentDB Agent-Kit knowledge-base router. +# +# Maps a natural-language diagnostic question to the exact agent-kit script that +# answers it (ONE HOP: query -> script). Reads knowledge-base/kb.json as the +# single source of truth. Deterministic keyword/example scoring (stdlib only, no +# deps) so it works without an LLM — and gives the LLM agent a structured, +# reproducible routing decision it can trust and explain. +# +# Multi-hop troubleshooting workflows (guarded diagnostic graph) are described by +# the kb.json workflow_schema and listed with --workflows; traversal is left to +# the agent and populated over time. +# +# Usage: +# bash knowledge-base/kb-route.sh "why are my writes slow?" # route +# bash knowledge-base/kb-route.sh --db mydb "audit my indexes" # fill +# bash knowledge-base/kb-route.sh --json "check data integrity" # machine +# bash knowledge-base/kb-route.sh --list # all tools +# bash knowledge-base/kb-route.sh --workflows # workflows +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KB="${KB_FILE:-$HERE/kb.json}" +DB="" +JSON=0 +MODE="route" +QUERY="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --db) DB="$2"; shift 2;; + --json) JSON=1; shift;; + --list) MODE="list"; shift;; + --workflows) MODE="workflows"; shift;; + --kb) KB="$2"; shift 2;; + -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0;; + *) QUERY="${QUERY:+$QUERY }$1"; shift;; + esac +done + +command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; } +[[ -f "$KB" ]] || { echo "kb.json not found at: $KB" >&2; exit 1; } + +# Routing engine lives in a separate, lint/test-able module (kb_route.py). +# Inputs are passed via environment variables so nothing is string-interpolated +# into code. Keeping the CLI here and the logic there avoids the fragile inline +# heredoc (a stray delimiter used to be able to silently break the router). +KB="$KB" DB="$DB" JSON="$JSON" MODE="$MODE" QUERY="$QUERY" \ + exec python3 "$HERE/kb_route.py" diff --git a/knowledge-base/kb.json b/knowledge-base/kb.json new file mode 100644 index 0000000..52b520b --- /dev/null +++ b/knowledge-base/kb.json @@ -0,0 +1,169 @@ +{ + "kb_version": "0.1.0", + "description": "DocumentDB Agent-Kit knowledge base. Single source of truth mapping natural-language diagnostic intents to the exact scripts that answer them (one hop), with a schema for multi-hop troubleshooting workflows (guarded diagnostic graph) to be populated later.", + "conventions": { + "db_placeholder": "", + "run_prefix": "bash", + "scripts_root": "scripts/", + "notes": "Every tool reads a live DocumentDB container locally (mongosh + psql). Recommendations are measured facts, never generic rules-of-thumb." + }, + + "tools": [ + { + "id": "index-redundancy-finder", + "title": "Index Redundancy Finder", + "script": "scripts/index-redundancy-finder.sh", + "invocation": "bash scripts/index-redundancy-finder.sh --db [--json]", + "params": [{ "name": "--db", "required": true, "placeholder": "" }], + "layer": "mongo+postgres", + "produces": "list of redundant/unused/prefix/duplicate indexes with severity and replacement", + "keywords": ["index", "indexes", "redundant", "duplicate index", "unused index", "prefix index", "write amplification", "slow writes", "too many indexes", "index bloat", "drop index", "optimize indexes", "write tax"], + "example_queries": [ + "why are my writes slow", + "audit my indexes", + "do I have redundant or unused indexes", + "which indexes can I drop" + ] + }, + { + "id": "document-bloat-advisor", + "title": "Large-Document / TOAST Advisor", + "script": "scripts/document-bloat-advisor.sh", + "invocation": "bash scripts/document-bloat-advisor.sh --db [--json]", + "params": [{ "name": "--db", "required": true, "placeholder": "" }], + "layer": "mongo+postgres", + "produces": "collections whose large text fields are TOASTed and detoasted on every scan, with the schema-split fix", + "keywords": ["large document", "big document", "toast", "detoast", "large text", "big text field", "document size", "avgobjsize", "slow scan", "slow aggregation", "bloat", "narrative", "why are reads slow", "schema split"], + "example_queries": [ + "why are my aggregations slow even with indexes", + "are my documents too big", + "is large text hurting my scans", + "diagnose TOAST overhead" + ] + }, + { + "id": "db-config-advisor", + "title": "Config & Cache Advisor", + "script": "scripts/db-config-advisor.sh", + "invocation": "bash scripts/db-config-advisor.sh --db [--json]", + "params": [{ "name": "--db", "required": true, "placeholder": "" }], + "layer": "postgres", + "produces": "shared_buffers vs measured working set, TOAST share, cache-hit ratios (evidence-based)", + "keywords": ["cache", "cache hit", "buffer", "shared_buffers", "working set", "memory", "configuration", "config", "tuning", "tune", "effective_cache_size", "resident", "eviction", "is my database configured well", "ram"], + "example_queries": [ + "is my cache big enough", + "should I increase shared_buffers", + "what is my working set and cache hit ratio", + "is my database well configured" + ] + }, + { + "id": "perf-advisor", + "title": "Performance Advisor", + "script": "scripts/perf-advisor.sh", + "invocation": "bash scripts/perf-advisor.sh --db [--json]", + "params": [{ "name": "--db", "required": true, "placeholder": "" }], + "layer": "mongo+postgres", + "produces": "overall health: collection/index overview, collection-scan audit, query timing, PG I/O/locks/config", + "keywords": ["performance", "slow", "slow query", "collection scan", "collscan", "missing index", "health", "overall", "diagnose performance", "profiling", "locks", "seq scan", "sequential scan", "general checkup"], + "example_queries": [ + "give my database a performance checkup", + "why is my database slow", + "find collection scans / missing indexes", + "overall health check" + ] + }, + { + "id": "data-integrity-check", + "title": "Data Integrity Checker", + "script": "scripts/data-integrity-check.sh", + "invocation": "bash scripts/data-integrity-check.sh --db [--json]", + "params": [{ "name": "--db", "required": true, "placeholder": "" }], + "layer": "mongo", + "produces": "orphaned foreign-key references and field type inconsistencies (hard structural integrity)", + "keywords": ["integrity", "data integrity", "orphan", "orphaned", "broken reference", "dangling reference", "foreign key", "referential", "type consistency", "mixed types", "consistency", "corrupt", "validate data"], + "example_queries": [ + "check my data integrity", + "do I have orphaned or broken references", + "are there dangling foreign keys", + "are my field types consistent" + ] + } + ], + + "routes_one_hop": { + "description": "Representative NL query -> tool id. Used for testing and as extra routing signal; the router also scores against each tool's keywords/example_queries.", + "examples": [ + { "query": "my writes got slow after adding indexes", "tool": "index-redundancy-finder" }, + { "query": "which indexes are useless", "tool": "index-redundancy-finder" }, + { "query": "aggregations are slow but I have indexes", "tool": "document-bloat-advisor" }, + { "query": "my documents have huge text fields", "tool": "document-bloat-advisor" }, + { "query": "is my cache hit ratio ok", "tool": "db-config-advisor" }, + { "query": "do I need more shared_buffers", "tool": "db-config-advisor" }, + { "query": "run a general performance checkup", "tool": "perf-advisor" }, + { "query": "find missing indexes and collection scans", "tool": "perf-advisor" }, + { "query": "check for orphaned references", "tool": "data-integrity-check" }, + { "query": "validate referential integrity", "tool": "data-integrity-check" } + ] + }, + + "workflow_schema": { + "description": "Schema for MULTI-HOP troubleshooting workflows: a guarded diagnostic graph (AND/OR). Each step runs a tool; edges are conditional on the observed result. The LLM agent presents a step, the human runs it (or the agent runs the script), and the reported result selects the next step or a conclusion. This layer is scaffolded; workflows are added over time.", + "fields": { + "id": "string", + "title": "string", + "entry": "step id to start at", + "steps": { + "": { + "tool": "tool id (from tools[])", + "args": "optional arg hints", + "question": "what the step establishes", + "depends_on": ["step ids that must run first (AND edges)"], + "yields": { + "": { "next": "", "or_conclude": "human-readable conclusion" } + } + } + } + } + }, + + "workflows": [ + { + "id": "slow-writes", + "title": "Diagnose slow writes (SCAFFOLD — illustrative, not yet validated)", + "status": "scaffold", + "entry": "check_redundant_indexes", + "steps": { + "check_redundant_indexes": { + "tool": "index-redundancy-finder", + "args": "--db --json", + "question": "Are redundant/unused indexes taxing every write?", + "yields": { + "findings_gt_0": { "or_conclude": "Redundant/unused indexes are a write tax; drop the flagged indexes." }, + "findings_eq_0": { "next": "check_document_bloat" } + } + }, + "check_document_bloat": { + "tool": "document-bloat-advisor", + "args": "--db --json", + "question": "Are large co-located text fields making each write rewrite TOAST?", + "depends_on": ["check_redundant_indexes"], + "yields": { + "bloat_found": { "or_conclude": "Large co-located text inflates write/TOAST cost; split text into a side collection." }, + "no_bloat": { "next": "check_config" } + } + }, + "check_config": { + "tool": "db-config-advisor", + "args": "--db ", + "question": "Is the working set exceeding cache, causing write-path I/O?", + "depends_on": ["check_document_bloat"], + "yields": { + "working_set_over_cache": { "or_conclude": "Working set exceeds shared_buffers; shrink documents or size cache to the measured working set." }, + "fits": { "or_conclude": "No index/bloat/cache cause found at this layer; escalate to workload/lock analysis." } + } + } + } + } + ] +} diff --git a/knowledge-base/kb_route.py b/knowledge-base/kb_route.py new file mode 100644 index 0000000..32c7949 --- /dev/null +++ b/knowledge-base/kb_route.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""kb_route.py — DocumentDB Agent-Kit knowledge-base router (routing engine). + +Maps a natural-language diagnostic question to the exact agent-kit script that +answers it (ONE HOP: query -> script). Reads knowledge-base/kb.json as the single +source of truth. Deterministic keyword/example scoring (stdlib only, no deps) so +it works without an LLM — and gives the LLM agent a structured, reproducible +routing decision it can trust and explain. + +This file is invoked by kb-route.sh (the CLI wrapper), which passes inputs via +environment variables: KB, DB, JSON, MODE, QUERY. It is a standalone module so it +can be linted, unit-tested, and imported — unlike the previous inline heredoc. + +Multi-hop troubleshooting workflows (guarded diagnostic graph) are described by +the kb.json workflow_schema and listed with MODE=workflows; traversal is left to +the agent and populated over time. +""" + +import json +import os +import re +import sys + +STOP = set( + "the a an is are my me i do does how why what which of to in on for and or " + "with can should".split() +) + + +def tokenize(s): + return set(re.findall(r"[a-z0-9_]+", s.lower())) + + +def fill(invocation, db, placeholder): + """Substitute the placeholder with the actual database name, if given.""" + if db: + return invocation.replace(placeholder, db) + return invocation + + +def score_tool(t, q_tokens, q_lower): + """Score one tool against the query. + + Signals (additive): + * multiword keyword phrase present as a substring -> +3.0 each + * single-word keyword present as a query token -> +1.5 each + * best example-query token overlap -> +2.5 * fraction + Returns (score, matched_keywords). + """ + score = 0.0 + hits = [] + for kw in t.get("keywords", []): + kwl = kw.lower() + if " " in kwl: + if kwl in q_lower: + score += 3.0 + hits.append(kw) + else: + if kwl in q_tokens: + score += 1.5 + hits.append(kw) + best_ex = 0.0 + for ex in t.get("example_queries", []): + ex_tokens = tokenize(ex) - STOP + ov = len(ex_tokens & q_tokens) + frac = ov / max(1, len(ex_tokens)) + best_ex = max(best_ex, frac) + score += 2.5 * best_ex + return score, hits + + +def rank_tools(kb, query): + """Rank all tools for a query. Returns (ranked, q_tokens, q_lower) where + ranked is a list of (score, tool, matched_keywords) sorted best-first.""" + q_tokens = tokenize(query) - STOP + q_lower = query.lower() + + # extra signal from routes_one_hop exact-ish matches + route_boost = {} + for r in kb.get("routes_one_hop", {}).get("examples", []): + r_tokens = tokenize(r["query"]) - STOP + ov = len(r_tokens & q_tokens) + frac = ov / max(1, len(r_tokens)) + if frac > route_boost.get(r["tool"], 0): + route_boost[r["tool"]] = frac + + ranked = [] + for t in kb["tools"]: + s, hits = score_tool(t, q_tokens, q_lower) + s += 2.0 * route_boost.get(t["id"], 0.0) + ranked.append((s, t, hits)) + ranked.sort(key=lambda x: -x[0]) + return ranked + + +def run_list(kb, db, placeholder, as_json): + if as_json: + print(json.dumps( + [{"id": t["id"], "title": t["title"], + "invocation": fill(t["invocation"], db, placeholder), + "layer": t.get("layer"), "produces": t.get("produces")} + for t in kb["tools"]], indent=2)) + return 0 + print("Knowledge base tools (one-hop targets):") + for t in kb["tools"]: + print(f"\n [{t['id']}] {t['title']} ({t.get('layer','')})") + print(f" run: {fill(t['invocation'], db, placeholder)}") + print(f" for: {t.get('produces','')}") + ex = t.get("example_queries", []) + if ex: + print(f" e.g. \"{ex[0]}\"") + return 0 + + +def run_workflows(kb, as_json): + wfs = kb.get("workflows", []) + if as_json: + print(json.dumps(wfs, indent=2)) + return 0 + if not wfs: + print("No multi-hop workflows defined yet. See kb.json workflow_schema to add one.") + return 0 + print("Multi-hop troubleshooting workflows (guarded diagnostic graph):") + for w in wfs: + print(f"\n [{w['id']}] {w['title']} status={w.get('status','')}") + print(f" entry: {w['entry']}") + for sid, step in w.get("steps", {}).items(): + deps = step.get("depends_on", []) + dep = f" (after: {', '.join(deps)})" if deps else "" + print(f" - {sid}: tool={step['tool']}{dep}") + for obs, edge in step.get("yields", {}).items(): + nxt = edge.get("next") + concl = edge.get("or_conclude") + arrow = f"-> {nxt}" if nxt else f"=> {concl}" + print(f" on {obs}: {arrow}") + return 0 + + +def run_route(kb, db, placeholder, as_json, query): + if not query: + print("Provide a natural-language query, or use --list / --workflows.", + file=sys.stderr) + return 2 + + ranked = rank_tools(kb, query) + best_s, best_t, best_hits = ranked[0] + alternatives = [(s, t) for s, t, _ in ranked[1:] if s > 0][:2] + + if as_json: + out = { + "query": query, + "match": None if best_s <= 0 else { + "tool": best_t["id"], "title": best_t["title"], "score": round(best_s, 2), + "matched_keywords": best_hits, + "command": fill(best_t["invocation"], db, placeholder), + "needs_db": (not db) and (placeholder in best_t["invocation"]), + }, + "alternatives": [{"tool": t["id"], "score": round(s, 2)} for s, t in alternatives], + "confident": best_s >= 2.0, + } + print(json.dumps(out, indent=2)) + return 0 + + if best_s <= 0: + print(f'No confident route for: "{query}"') + print("Available tools (use --list for details):") + for t in kb["tools"]: + print(f" - {t['id']}: {t.get('produces','')}") + return 0 + + conf = "high" if best_s >= 4 else ("medium" if best_s >= 2 else "low") + print(f'Query: "{query}"') + print(f'→ Route: [{best_t["id"]}] {best_t["title"]} (confidence: {conf}, score {best_s:.1f})') + if best_hits: + print(f' matched: {", ".join(best_hits[:6])}') + print(f' run: {fill(best_t["invocation"], db, placeholder)}') + if (not db) and (placeholder in best_t["invocation"]): + print(f' (supply the database: --db — replaces {placeholder})') + if alternatives: + print(" alternatives: " + ", ".join(f"{t['id']} ({s:.1f})" for s, t in alternatives)) + return 0 + + +def main(): + kb_path = os.environ["KB"] + db = os.environ.get("DB") or "" + as_json = os.environ.get("JSON") == "1" + mode = os.environ.get("MODE") or "route" + query = (os.environ.get("QUERY") or "").strip() + + with open(kb_path) as fh: + kb = json.load(fh) + + placeholder = kb.get("conventions", {}).get("db_placeholder", "") + + if mode == "list": + return run_list(kb, db, placeholder, as_json) + if mode == "workflows": + return run_workflows(kb, as_json) + return run_route(kb, db, placeholder, as_json, query) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scenarios/contoso/README.md b/scenarios/contoso/README.md new file mode 100644 index 0000000..b6c9360 --- /dev/null +++ b/scenarios/contoso/README.md @@ -0,0 +1,84 @@ +# Contoso demo (synthetic generator — Dynamics-365-Sales model) + +A ready-to-run **generator** that reproduces a **DocumentDB-specific** performance +anti-pattern so you can see the diagnostic toolbox work end-to-end. + +> **No dataset is bundled here.** `contoso-seed.js` *synthesizes* data locally with +> a deterministic RNG, modeled on the open-source Contoso **"Sales"** schema — it +> does **not** copy the Contoso dataset (see **Source & attribution** below). You +> generate the data on your own machine with `seed.sh`; nothing data-related is +> committed to this repo. + +The `opportunities` collection deliberately co-locates large, varied text +(`narrative` + `activity_log`, ~6 KB/doc) with the scalar fields that BI queries +actually read. In DocumentDB each document is one BSON column in PostgreSQL, so +that text is pushed **out-of-line into TOAST** and detoasted on **every** scan — +a tax that never shows up as a missing index. See +[`../../skills/data-modeling/model-large-field-split.md`](../../skills/data-modeling/model-large-field-split.md). + +## Source & attribution + +The entity model (territories, users, products, campaigns, accounts, +opportunities + line items) is modeled on the open-source **Contoso "Sales"** +sample: + +- — *Contoso - Sales - Current Release* + +We do **not** redistribute that dataset. This folder ships only **code** (a +synthetic seeder + queries + the fix), which invents generic values +(`"Territory 1"`, random text pools) via a seeded RNG and *extends* the model with +large text fields to trigger the DocumentDB TOAST scenario. If you want the real +Contoso data, pull it from the source above. + +## Files (all committed — code/docs only) + +| File | What it is | +|------|-----------| +| `contoso-seed.js` | Deterministic, resumable **generator** (entities + planted TOAST anti-pattern). | +| `seed.sh` | Quickstart wrapper — generates **one base-size** database named `contoso`. | +| `contoso-queries.js` | 7 business-demand BI aggregations (emit timing JSON). | +| `contoso-split-fix.js` | Applies the fix: move the big text into a side collection keyed by `_id`. | +| `scaling-benchmark/` | **Optional / advanced** — the multi-scale (x1…x16) TOAST scaling benchmark. Not part of the quickstart. | + +> **Not committed (generated at runtime):** `scaling-benchmark/results.tsv` is +> benchmark *output*, regenerated by the scaling harness — it is git-ignored. + +## Prerequisites + +A running DocumentDB container named `documentdb-local` (see the repo +[`README.md`](../../README.md#diagnostic-toolbox--quickstart) → *Diagnostic +Toolbox — Quickstart* for the one-line `docker run`). All commands `docker exec` +into that container, so no host ports are required. + +## Run it end-to-end + +```bash +# 0. the scripts require a password — export it once (or pass --password) +export DB_PASSWORD=Test1234 + +# 1. seed the demo database (base size ~500 opportunities) +bash scenarios/contoso/seed.sh # -> database "contoso" + +# 2. diagnose the TOAST bloat (analysis only, no data changes) +bash scripts/document-bloat-advisor.sh --db contoso +bash scripts/toast-split-advisor.sh --db contoso + +# 3. (optional) route to the right tool from a natural-language question +bash knowledge-base/kb-route.sh --db contoso "why are my aggregations slow even though I have indexes" + +# 4. (optional) apply the schema-split fix, then re-check +docker cp scenarios/contoso/contoso-split-fix.js documentdb-local:/tmp/fix.js +docker exec -e CONTOSO_DB=contoso documentdb-local mongosh \ + "localhost:10260/contoso" -u docdbadmin -p "$DB_PASSWORD" \ + --authenticationMechanism SCRAM-SHA-256 --tls --tlsAllowInvalidCertificates \ + --quiet --file /tmp/fix.js +``` + +Expected step 2 finding: `opportunities` — `TOAST ratio ~0.99`, dominant field +`narrative`, recommendation to split into `opportunities_ext` keyed by `_id`. + +## Overrides + +`seed.sh` and every script accept `--container NAME`, `--password PASS`, and read +`DB_USER` / `DB_PASSWORD` / `PORT` env vars. Default DB name for the seeder is +`contoso` (override with `--db`). diff --git a/scenarios/contoso/contoso-queries.js b/scenarios/contoso/contoso-queries.js new file mode 100644 index 0000000..b13eb40 --- /dev/null +++ b/scenarios/contoso/contoso-queries.js @@ -0,0 +1,96 @@ +// contoso-queries.js — Business-demand BI query suite for the Contoso model. +// +// Every query below needs ONLY scalar fields (est_value, state, territory_id, +// sales_stage, created_at, owner_id, account_id, line_items amounts). None of +// them read the big narrative/activity_log/profile text. Yet under the +// co-located schema each document access detoasts that text — the tax this +// scenario measures. +// +// Params via env: +// CONTOSO_DB database to run against +// QUERY_REPS times to repeat each query (default 5); we report the MIN ms +// (min = least noise from container cycling / cold cache) +// +// Emits one line per query: QRESULT {"q":"...","ms":N,"rows":M} +// and a final: QSUMMARY {"db":"...","total_min_ms":N,"queries":K} + +var DB = process.env.CONTOSO_DB || "contoso_x1"; +var REPS = parseInt(process.env.QUERY_REPS || "5"); +var d = db.getSiblingDB(DB); + +function timed(name, fn) { + var best = Infinity, rows = 0; + for (var r = 0; r < REPS; r++) { + var t = Date.now(); + rows = fn(); + var ms = Date.now() - t; + if (ms < best) best = ms; + } + print("QRESULT " + JSON.stringify({ q: name, ms: best, rows: rows })); + return best; +} + +var total = 0; + +// Q1 — Open pipeline value by territory +total += timed("pipeline_by_territory", function () { + return d.opportunities.aggregate([ + { $match: { state: "open" } }, + { $group: { _id: "$territory_id", pipeline: { $sum: "$est_value" }, deals: { $sum: 1 } } }, + { $sort: { pipeline: -1 } } + ]).toArray().length; +}); + +// Q2 — Deal count & value by sales stage +total += timed("value_by_stage", function () { + return d.opportunities.aggregate([ + { $group: { _id: "$sales_stage", n: { $sum: 1 }, value: { $sum: "$est_value" } } } + ]).toArray().length; +}); + +// Q3 — Monthly bookings trend (won) +total += timed("monthly_bookings", function () { + return d.opportunities.aggregate([ + { $match: { state: "won" } }, + { $group: { _id: { $month: "$created_at" }, booked: { $sum: "$actual_value" } } }, + { $sort: { _id: 1 } } + ]).toArray().length; +}); + +// Q4 — Top accounts by pipeline +total += timed("top_accounts", function () { + return d.opportunities.aggregate([ + { $group: { _id: "$account_id", pipeline: { $sum: "$est_value" } } }, + { $sort: { pipeline: -1 } }, { $limit: 10 } + ]).toArray().length; +}); + +// Q5 — Product mix by amount (line items) +total += timed("product_mix", function () { + return d.opportunities.aggregate([ + { $unwind: "$line_items" }, + { $group: { _id: "$line_items.product_id", revenue: { $sum: "$line_items.amount" } } }, + { $sort: { revenue: -1 } }, { $limit: 20 } + ]).toArray().length; +}); + +// Q6 — Rep leaderboard (won value by owner) +total += timed("rep_leaderboard", function () { + return d.opportunities.aggregate([ + { $match: { state: "won" } }, + { $group: { _id: "$owner_id", won: { $sum: "$actual_value" } } }, + { $sort: { won: -1 } }, { $limit: 15 } + ]).toArray().length; +}); + +// Q7 — Avg deal size by industry (join to accounts) +total += timed("avg_deal_by_industry", function () { + return d.opportunities.aggregate([ + { $lookup: { from: "accounts", localField: "account_id", foreignField: "_id", as: "acct" } }, + { $unwind: "$acct" }, + { $group: { _id: "$acct.industry", avg_deal: { $avg: "$est_value" }, n: { $sum: 1 } } }, + { $sort: { avg_deal: -1 } } + ]).toArray().length; +}); + +print("QSUMMARY " + JSON.stringify({ db: DB, total_min_ms: total, queries: 7, reps: REPS })); diff --git a/scenarios/contoso/contoso-seed.js b/scenarios/contoso/contoso-seed.js new file mode 100644 index 0000000..614b4fd --- /dev/null +++ b/scenarios/contoso/contoso-seed.js @@ -0,0 +1,78 @@ +// contoso-seed.js — Deterministic, RESUMABLE seeder for the Contoso +// "Dynamics 365 Sales" model on DocumentDB. +// +// Entities & relationships (faithful to the D365 Sales demo): +// territories (dimension) +// users (salespeople) -> territory_id +// products (dimension; moderate text: description) +// campaigns (BIG text: content) scaled +// accounts -> territory_id, owner_id(user) (BIG text: profile) scaled +// opportunities -> account_id, campaign_id, owner_id, territory_id scaled +// line_items:[ -> product_id ] +// *** ANTI-PATTERN: big text (narrative + activity_log) co-located *** +// +// Large text is VARIED (low compressibility) so it lands in PostgreSQL TOAST. +// +// RESUMABLE: does not drop. For each collection it inserts only the missing +// documents (_id from have+1..target), so repeated runs converge to the target +// even if the container is killed mid-seed. Set CONTOSO_FRESH=1 to drop first. +// +// Env: CONTOSO_DB, CONTOSO_SCALE (1,2,4,6,8,16), CONTOSO_SEED (default 42), +// CONTOSO_FRESH (1 to drop first). + +var DB = process.env.CONTOSO_DB || "contoso_x1"; +var SCALE = parseInt(process.env.CONTOSO_SCALE || "1"); +var SEEDV = parseInt(process.env.CONTOSO_SEED || "42"); +var FRESH = process.env.CONTOSO_FRESH === "1"; +var d = db.getSiblingDB(DB); + +var _s = SEEDV >>> 0; +function rnd(){ _s|=0; _s=(_s+0x6D2B79F5)|0; var t=Math.imul(_s^(_s>>>15),1|_s); t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; } +function ri(n){ return Math.floor(rnd()*n); } +function pick(a){ return a[ri(a.length)]; } + +var CH="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,;"; +var POOL=""; while(POOL.length<24000) POOL+=CH[ri(CH.length)]; +function text(n){ var o=ri(POOL.length-n-1); return POOL.substr(o,n); } + +var N_TERR=12, N_USER=30, N_PROD=50*SCALE, N_CAMP=6*SCALE, N_ACCT=150*SCALE, N_OPP=500*SCALE; +var BATCH=100; + +var INDUSTRIES=["Retail","Manufacturing","Finance","Healthcare","Technology","Energy","Education","Transport"]; +var STAGES=["Qualify","Develop","Propose","Negotiate","Close"]; +var STATES=["open","won","lost"]; +var REGIONS=["NA-East","NA-West","EU-North","EU-South","APAC","LATAM"]; +var CAMP_TYPES=["Email","Event","Webinar","Partner","Advertising"]; + +if (FRESH) ["territories","users","products","campaigns","accounts","opportunities"].forEach(function(c){ try{d[c].drop();}catch(e){} }); + +// Resumable insert: fill _id from have+1..target in batches. +function seedColl(name, target, make){ + var have = d[name].countDocuments(); + if (have >= target) return have; + for (var start = have+1; start <= target; start += BATCH){ + var end = Math.min(start+BATCH-1, target); + var b = []; + for (var id = start; id <= end; id++) b.push(make(id)); + try { d[name].insertMany(b, {ordered:false}); } catch(e) { /* dup-key on resume overlap: ignore */ } + } + return d[name].countDocuments(); +} + +print("=== Seeding "+DB+" (scale x"+SCALE+") resumable ==="); + +seedColl("territories", N_TERR, function(id){ return {_id:id, territory_id:"TERR_"+id, name:"Territory "+id, region:REGIONS[id%REGIONS.length], manager:"Manager "+id}; }); +seedColl("users", N_USER, function(id){ return {_id:id, user_id:"USR_"+id, full_name:"Rep "+id, title:pick(["AE","SAE","Manager"]), territory_id:(id%N_TERR)+1}; }); +seedColl("products", N_PROD, function(id){ return {_id:id, product_id:"PROD_"+id, name:"Product "+id, category:pick(["Hardware","Software","Services","Support"]), list_price:100+ri(9900), unit_cost:50+ri(4000), description:text(400)}; }); +seedColl("campaigns", N_CAMP, function(id){ return {_id:id, campaign_id:"CMP_"+id, name:"Campaign "+id, type:pick(CAMP_TYPES), budget:10000+ri(490000), start_date:new Date(2024,ri(12),1+ri(27)), expected_revenue:50000+ri(950000), content:text(4000)}; }); +seedColl("accounts", N_ACCT, function(id){ return {_id:id, account_id:"ACC_"+id, name:"Account "+id, industry:pick(INDUSTRIES), city:"City"+ri(500), state:"ST"+ri(50), country:pick(["US","UK","DE","JP","BR"]), annual_revenue:100000+ri(50000000), num_employees:10+ri(9990), territory_id:(id%N_TERR)+1, owner_id:(id%N_USER)+1, profile:text(3500)}; }); +seedColl("opportunities", N_OPP, function(id){ + var nItems=1+ri(4), items=[]; + for (var j=0;j TOAST; detoasted on every scan) +// After: opportunities = { ...scalars..., line_items } (small, inline) +// opportunities_text = { _id, narrative, activity_log } (fetched only for detail) +// +// Resumable & idempotent: re-running completes an interrupted migration. +// Env: CONTOSO_DB + +var DB = process.env.CONTOSO_DB || "contoso_x1"; +var d = db.getSiblingDB(DB); + +// 1) Materialize the side collection from whatever text still lives on opportunities. +// (If opportunities_text is already complete, skip the rebuild.) +var need = d.opportunities.countDocuments({ narrative: { $exists: true } }); +if (need > 0 || d.opportunities_text.countDocuments() < d.opportunities.countDocuments()) { + d.opportunities.aggregate([ + { $match: { narrative: { $exists: true } } }, + { $project: { narrative: 1, activity_log: 1 } }, + { $merge: { into: "opportunities_text", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } } + ]); +} + +// 2) Strip the big text from the hot collection (idempotent; resumes if killed). +var res = d.opportunities.updateMany( + { $or: [ { narrative: { $exists: true } }, { activity_log: { $exists: true } } ] }, + { $unset: { narrative: "", activity_log: "" } } +); +print("unset on " + (res.modifiedCount || 0) + " opportunities"); +print("opportunities=" + d.opportunities.countDocuments() + + " opportunities_text=" + d.opportunities_text.countDocuments() + + " remaining_with_text=" + d.opportunities.countDocuments({ narrative: { $exists: true } })); +print("DONE split-fix " + DB); diff --git a/scenarios/contoso/seed.sh b/scenarios/contoso/seed.sh new file mode 100755 index 0000000..ae4888b --- /dev/null +++ b/scenarios/contoso/seed.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# seed.sh — load ONE base-size Contoso (Dynamics-365-Sales) demo database. +# +# Seeds the entities territories/users/products/campaigns/accounts/opportunities +# with the DocumentDB TOAST anti-pattern deliberately planted: large, varied text +# (narrative + activity_log ~6 KB) is co-located on `opportunities`, so it lands +# in PostgreSQL TOAST and gets detoasted on every scan. This is the dataset the +# `toast-split-advisor` skill / `document-bloat-advisor.sh` diagnose. +# +# This is the QUICKSTART seeder: a single, base-size database (no scaling). For +# the optional scaling benchmark see scaling-benchmark/. +# +# Usage: +# bash scenarios/contoso/seed.sh # -> database "contoso" +# bash scenarios/contoso/seed.sh --db mycontoso +# bash scenarios/contoso/seed.sh --container NAME --password PASS +set -uo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONTAINER="${CONTAINER:-documentdb-local}" +PORT="${PORT:-10260}" +DB_USER="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="contoso" + +while [[ $# -gt 0 ]]; do + case "$1" in + --db) DB="$2"; shift 2;; + --container) CONTAINER="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + -h|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0;; + *) echo "Unknown option: $1" >&2; exit 2;; + esac +done + +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +echo "Seeding base-size Contoso into '${DB}' (container: ${CONTAINER}) ..." +docker cp "$DIR/contoso-seed.js" "${CONTAINER}:/tmp/contoso-seed.js" >/dev/null + +docker exec -e CONTOSO_DB="$DB" -e CONTOSO_SCALE=1 "$CONTAINER" mongosh \ + "localhost:${PORT}/${DB}" -u "$DB_USER" -p "$PASSWORD" \ + --authenticationMechanism SCRAM-SHA-256 --tls --tlsAllowInvalidCertificates \ + --quiet --file /tmp/contoso-seed.js 2>/dev/null + +echo +echo "Done. Try:" +echo " bash scripts/document-bloat-advisor.sh --db ${DB}" +echo " bash scripts/toast-split-advisor.sh --db ${DB}" diff --git a/scenarios/ecommerce/README.md b/scenarios/ecommerce/README.md new file mode 100644 index 0000000..87913d8 --- /dev/null +++ b/scenarios/ecommerce/README.md @@ -0,0 +1,27 @@ +# ecommerce demo dataset + +Seeds `ecommerce` — a realistic store dataset (customers, products, orders, +order_items, reviews, inventory, categories, suppliers) large enough to exercise +the diagnostic toolbox: `perf-advisor.sh`, `index-redundancy-finder.sh`, and +`data-integrity-check.sh`. + +## Files + +| File | What it is | +|------|-----------| +| `seed.sh` | Self-contained seeder (embeds the data-generation script; ~50K orders, ~150K order_items). | + +## Run + +```bash +export DB_PASSWORD=Test1234 # or pass --password +bash scenarios/ecommerce/seed.sh # -> database "ecommerce" + +bash scripts/perf-advisor.sh --db ecommerce +bash scripts/index-redundancy-finder.sh --db ecommerce +bash scripts/data-integrity-check.sh --db ecommerce +``` + +Prereq: a running `documentdb-local` container — see the repo +[`README.md`](../../README.md#quickstart) *Quickstart*. Overrides: `--container`, +`--password`, `--db`, or the `DB_USER`/`DB_PASSWORD`/`PORT` env vars. diff --git a/scenarios/ecommerce/seed.sh b/scenarios/ecommerce/seed.sh new file mode 100755 index 0000000..895f3cd --- /dev/null +++ b/scenarios/ecommerce/seed.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# seed.sh — Generate the large "ecommerce" demo dataset for perf/diagnostic testing +# Creates: customers (5K), products (2K), orders (50K), order_items (150K), +# reviews (25K), inventory (4K) +# Usage: export DB_PASSWORD=Test1234; bash scenarios/ecommerce/seed.sh [--container NAME] [--password PASS] +set -uo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-documentdb-local}" +PORT="${PORT:-10260}" +USER="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="ecommerce" + +while [[ $# -gt 0 ]]; do + case "$1" in + --container) CONTAINER_NAME="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + --db) DB="$2"; shift 2;; + -h|--help) echo "Usage: $0 [--container NAME] [--password PASS] [--port PORT] [--db NAME]"; exit 0;; + *) shift;; + esac +done + +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +run_mongosh() { + docker exec -u documentdb "$CONTAINER_NAME" mongosh \ + "localhost:${PORT}/${DB}" -u "$USER" -p "$PASSWORD" \ + --authenticationMechanism SCRAM-SHA-256 --tls --tlsAllowInvalidCertificates \ + --quiet --eval "$1" 2>/dev/null +} + +echo "═══════════════════════════════════════════════════════════════" +echo " Seeding ecommerce data into ${DB} (container: ${CONTAINER_NAME})" +echo "═══════════════════════════════════════════════════════════════" +echo "" + +# ── Categories & Suppliers (static) ────────────────────────────────── +echo "→ Creating categories and suppliers..." +run_mongosh ' +db.categories.drop(); +db.suppliers.drop(); +db.categories.insertMany([ + {category_id:"CAT_001", name:"Electronics", parent:null}, + {category_id:"CAT_002", name:"Clothing", parent:null}, + {category_id:"CAT_003", name:"Home & Garden", parent:null}, + {category_id:"CAT_004", name:"Sports", parent:null}, + {category_id:"CAT_005", name:"Books", parent:null}, + {category_id:"CAT_006", name:"Phones", parent:"CAT_001"}, + {category_id:"CAT_007", name:"Computers", parent:"CAT_001"}, + {category_id:"CAT_008", name:"Men", parent:"CAT_002"}, + {category_id:"CAT_009", name:"Women", parent:"CAT_002"}, + {category_id:"CAT_010", name:"Outdoor", parent:"CAT_004"} +]); +var suppliers = []; +for (var i = 0; i < 50; i++) { + suppliers.push({ + supplier_id: "SUP_" + String(i).padStart(3,"0"), + name: "Supplier " + i, + contact_email: "contact@supplier" + i + ".com", + rating: Math.round((3 + Math.random()*2)*10)/10, + active: Math.random() > 0.1 + }); +} +db.suppliers.insertMany(suppliers); +print(" Categories: " + db.categories.countDocuments() + ", Suppliers: " + db.suppliers.countDocuments()); +' + +# ── Customers (5000) ───────────────────────────────────────────────── +echo "→ Creating 5000 customers..." +run_mongosh ' +db.customers.drop(); +var cities = ["New York","Los Angeles","Chicago","Houston","Phoenix","Philadelphia","San Antonio","San Diego","Dallas","San Jose","Austin","Seattle","Denver","Boston","Portland"]; +var tiers = ["bronze","silver","gold","platinum"]; +var batch = []; +for (var i = 0; i < 5000; i++) { + batch.push({ + customer_id: "CUST_" + String(i).padStart(6,"0"), + name: "Customer " + i, + email: "user" + i + "@example.com", + tier: tiers[Math.floor(Math.random()*4)], + loyalty_points: Math.floor(Math.random()*10000), + address: { + city: cities[Math.floor(Math.random()*15)], + state: "US", + zipCode: String(10000 + Math.floor(Math.random()*90000)) + }, + is_active: Math.random() > 0.05, + created_at: new Date(2023, Math.floor(Math.random()*24), Math.floor(Math.random()*28)+1), + last_login: new Date(2024, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1) + }); + if (batch.length >= 1000) { db.customers.insertMany(batch); batch = []; } +} +if (batch.length > 0) db.customers.insertMany(batch); +print(" Customers: " + db.customers.countDocuments()); +' + +# ── Products (2000) ────────────────────────────────────────────────── +echo "→ Creating 2000 products..." +run_mongosh ' +db.products.drop(); +var cats = ["CAT_001","CAT_002","CAT_003","CAT_004","CAT_005","CAT_006","CAT_007","CAT_008","CAT_009","CAT_010"]; +var brands = ["TechPro","StyleMax","HomeFirst","SportElite","BookWorld","PhoneZone","CompuMax","FashionFwd","OutdoorGear","GadgetCo"]; +var batch = []; +for (var i = 0; i < 2000; i++) { + var price = Math.round((10 + Math.random()*990)*100)/100; + batch.push({ + product_id: "PROD_" + String(i).padStart(6,"0"), + name: "Product " + i, + description: "Description for product " + i + " with detailed specifications", + category_id: cats[Math.floor(Math.random()*10)], + brand: brands[Math.floor(Math.random()*10)], + supplier_id: "SUP_" + String(Math.floor(Math.random()*50)).padStart(3,"0"), + price: price, + cost: Math.round(price * (0.3 + Math.random()*0.4) * 100)/100, + currency: "USD", + active: Math.random() > 0.05, + ratings: { average: Math.round(Math.random()*5*10)/10, count: Math.floor(Math.random()*500) }, + tags: [cats[Math.floor(Math.random()*10)].toLowerCase(), Math.random()>0.5?"sale":"regular", Math.random()>0.7?"featured":"standard"], + created_at: new Date(2023, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1), + updated_at: new Date() + }); + if (batch.length >= 500) { db.products.insertMany(batch); batch = []; } +} +if (batch.length > 0) db.products.insertMany(batch); +print(" Products: " + db.products.countDocuments()); +' + +# ── Inventory (4000) ───────────────────────────────────────────────── +echo "→ Creating inventory records..." +run_mongosh ' +db.inventory.drop(); +var warehouses = ["WH_EAST","WH_WEST","WH_CENTRAL","WH_SOUTH"]; +var batch = []; +for (var i = 0; i < 2000; i++) { + var pid = "PROD_" + String(i).padStart(6,"0"); + var numWH = 1 + Math.floor(Math.random()*3); + var whs = warehouses.slice().sort(function(){return Math.random()-0.5}).slice(0, numWH); + whs.forEach(function(wh) { + var qty = Math.floor(Math.random()*200); + batch.push({ + product_id: pid, + warehouse_id: wh, + quantity: qty, + reserved: Math.min(Math.floor(Math.random()*50), qty), + reorder_point: 10 + Math.floor(Math.random()*40), + last_restocked: new Date(2024, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1) + }); + }); + if (batch.length >= 1000) { db.inventory.insertMany(batch); batch = []; } +} +if (batch.length > 0) db.inventory.insertMany(batch); +print(" Inventory: " + db.inventory.countDocuments()); +' + +# ── Orders (50K) + Order Items (150K) ──────────────────────────────── +echo "→ Creating 50000 orders + order items (this takes a minute)..." +run_mongosh ' +db.orders.drop(); +db.order_items.drop(); +var statuses = ["pending","confirmed","shipped","delivered","cancelled"]; +var methods = ["credit_card","debit_card","paypal","bank_transfer","crypto"]; +var orderBatch = []; +var itemBatch = []; +for (var i = 0; i < 50000; i++) { + var oid = "ORD_" + String(i).padStart(6,"0"); + var cid = "CUST_" + String(Math.floor(Math.random()*5000)).padStart(6,"0"); + var st = statuses[Math.floor(Math.random()*5)]; + var d = new Date(2024, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1); + var numItems = 1 + Math.floor(Math.random()*5); + var total = 0; + orderBatch.push({ + order_id: oid, + customer_id: cid, + status: st, + payment_method: methods[Math.floor(Math.random()*5)], + created_at: d, + updated_at: new Date(d.getTime() + Math.random()*86400000*3), + shipping_city: ["New York","LA","Chicago","Houston","Phoenix","Seattle","Denver","Boston","Portland","Austin"][Math.floor(Math.random()*10)] + }); + for (var j = 0; j < numItems; j++) { + var pid = "PROD_" + String(Math.floor(Math.random()*2000)).padStart(6,"0"); + var qty = 1 + Math.floor(Math.random()*5); + var price = Math.round((10 + Math.random()*490)*100)/100; + var disc = [0,0,0,0.1,0.15,0.2][Math.floor(Math.random()*6)]; + total += qty * price * (1-disc); + itemBatch.push({ + order_id: oid, + product_id: pid, + quantity: qty, + unit_price: price, + discount: disc + }); + } + // Update order total + orderBatch[orderBatch.length-1].total_amount = Math.round(total*100)/100; + + if (orderBatch.length >= 2000) { + db.orders.insertMany(orderBatch); + db.order_items.insertMany(itemBatch); + orderBatch = []; itemBatch = []; + } +} +if (orderBatch.length > 0) { db.orders.insertMany(orderBatch); db.order_items.insertMany(itemBatch); } +print(" Orders: " + db.orders.countDocuments() + ", Order Items: " + db.order_items.countDocuments()); +' + +# ── Reviews (25K) ──────────────────────────────────────────────────── +echo "→ Creating 25000 reviews..." +run_mongosh ' +db.reviews.drop(); +var batch = []; +for (var i = 0; i < 25000; i++) { + var rating = [1,2,3,3,4,4,4,5,5,5][Math.floor(Math.random()*10)]; + batch.push({ + review_id: "REV_" + String(i).padStart(6,"0"), + product_id: "PROD_" + String(Math.floor(Math.random()*2000)).padStart(6,"0"), + customer_id: "CUST_" + String(Math.floor(Math.random()*5000)).padStart(6,"0"), + order_id: "ORD_" + String(Math.floor(Math.random()*50000)).padStart(6,"0"), + rating: rating, + title: rating >= 4 ? "Great product" : rating >= 3 ? "Decent product" : "Disappointing", + text: "Review text for item " + i, + verified_purchase: Math.random() > 0.1, + created_at: new Date(2024, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1), + helpful_votes: Math.floor(Math.random()*50) + }); + if (batch.length >= 2000) { db.reviews.insertMany(batch); batch = []; } +} +if (batch.length > 0) db.reviews.insertMany(batch); +print(" Reviews: " + db.reviews.countDocuments()); +' + +echo "" +echo "═══════════════════════════════════════════════════════════════" +echo " Data seeding complete!" +echo "═══════════════════════════════════════════════════════════════" +run_mongosh ' +var colls = db.getCollectionNames().sort(); +var total = 0; +colls.forEach(function(c) { + var cnt = db[c].countDocuments(); + total += cnt; + print(" " + c + ": " + cnt + " docs"); +}); +print(" ──────────────────"); +print(" TOTAL: " + total + " documents"); +' diff --git a/scenarios/index-redundancy/README.md b/scenarios/index-redundancy/README.md new file mode 100644 index 0000000..af2f9a3 --- /dev/null +++ b/scenarios/index-redundancy/README.md @@ -0,0 +1,26 @@ +# index-redundancy demo dataset + +Seeds `idx_test` — a database with **intentionally redundant and unused indexes** +(prefix-redundant, exact-duplicate, unique-shadowed, reverse-variant, and unused) +so [`index-redundancy-finder.sh`](../../scripts/index-redundancy-finder.sh) has +findings to report. + +## Files + +| File | What it is | +|------|-----------| +| `fixture-redundant-indexes.js` | mongosh seeder: 3 collections (`users`, `orders`, `sessions`) with planted index redundancies. | +| `seed.sh` | Wrapper — copies the fixture into the container and loads it into `idx_test`. | + +## Run + +```bash +export DB_PASSWORD=Test1234 # or pass --password +bash scenarios/index-redundancy/seed.sh # -> database "idx_test" +bash scripts/index-redundancy-finder.sh --db idx_test +bash scripts/index-redundancy-finder.sh --db idx_test --json # machine output +``` + +Prereq: a running `documentdb-local` container — see the repo +[`README.md`](../../README.md#quickstart) *Quickstart*. Overrides: `--container`, +`--password`, `--db`, or the `DB_USER`/`DB_PASSWORD`/`PORT` env vars. diff --git a/scenarios/index-redundancy/fixture-redundant-indexes.js b/scenarios/index-redundancy/fixture-redundant-indexes.js new file mode 100644 index 0000000..7967018 --- /dev/null +++ b/scenarios/index-redundancy/fixture-redundant-indexes.js @@ -0,0 +1,135 @@ +// fixture-redundant-indexes.js — Seed a test database with intentional +// index redundancies to validate index-redundancy-finder.sh detection rules. +// +// Creates database "idx_test" with 3 collections, each containing one or +// more redundancy patterns: +// - users: prefix-redundant, unique-shadowed, exact-duplicate +// - orders: reverse-variant, prefix-redundant (3-level chain) +// - sessions: unused indexes (created but never queried) +// +// Recommended: use the wrapper -> bash scenarios/index-redundancy/seed.sh +// (it copies this file into the container and loads it, reading DB_PASSWORD). + +print("=== Building redundancy test fixture in 'idx_test' database ==="); + +// Wipe any previous state +["users", "orders", "sessions"].forEach(function(c) { + try { db[c].drop(); } catch(e) {} +}); + +// ────────────────────────────────────────────────────────────────────── +// users: small docs, demonstrate prefix/dup/unique-shadowed +// ────────────────────────────────────────────────────────────────────── +print("\n[users] inserting 5,000 docs"); +var bulk = []; +for (var i = 1; i <= 5000; i++) { + bulk.push({ + user_id: i, + email: "u" + i + "@example.com", + username: "user_" + i, + tenant_id: (i % 50) + 1, + status: ["active","inactive","pending"][i % 3], + created_at: new Date(2024, 0, (i % 365) + 1), + country: ["US","CA","UK","DE","FR"][i % 5] + }); + if (bulk.length === 1000) { db.users.insertMany(bulk); bulk = []; } +} +if (bulk.length) db.users.insertMany(bulk); + +print("[users] creating intentionally redundant indexes:"); +// 1. Prefix-redundant: {tenant_id} vs {tenant_id, status} +db.users.createIndex({tenant_id: 1}); // REDUNDANT (prefix of next) +db.users.createIndex({tenant_id: 1, status: 1}); // KEEP +print(" + {tenant_id} [should be flagged: prefix-redundant]"); +print(" + {tenant_id, status} [keep]"); + +// 2. Exact duplicate +db.users.createIndex({email: 1}, {name: "email_idx_a"}); // KEEP (unique below) +db.users.createIndex({email: 1}, {name: "email_idx_b"}); // REDUNDANT (exact dup) +print(" + {email} email_idx_a [will be replaced by unique below]"); +print(" + {email} email_idx_b [should be flagged: exact duplicate]"); + +// 3. Unique-shadowed: non-unique {username} + unique {username} +db.users.createIndex({username: 1}); // REDUNDANT (covered by unique) +db.users.createIndex({username: 1}, {unique: true, name: "username_unique"}); // KEEP +print(" + {username} [should be flagged: shadowed by unique]"); +print(" + {username} unique [keep]"); + +// ────────────────────────────────────────────────────────────────────── +// orders: bigger collection, prefix chains and reverse variants +// ────────────────────────────────────────────────────────────────────── +print("\n[orders] inserting 10,000 docs"); +bulk = []; +for (var i = 1; i <= 10000; i++) { + bulk.push({ + order_id: i, + customer_id: (i % 1000) + 1, + status: ["pending","paid","shipped","delivered","cancelled"][i % 5], + amount: Math.round((Math.random() * 500 + 10) * 100) / 100, + currency: ["USD","EUR","GBP"][i % 3], + created_at: new Date(2024, (i % 12), (i % 28) + 1), + region: ["NA","EU","APAC"][i % 3] + }); + if (bulk.length === 1000) { db.orders.insertMany(bulk); bulk = []; } +} +if (bulk.length) db.orders.insertMany(bulk); + +print("[orders] creating intentionally redundant indexes:"); +// 4. Prefix chain: {customer_id} ⊂ {customer_id, status} ⊂ {customer_id, status, created_at} +db.orders.createIndex({customer_id: 1}); // REDUNDANT +db.orders.createIndex({customer_id: 1, status: 1}); // REDUNDANT +db.orders.createIndex({customer_id: 1, status: 1, created_at: -1}); // KEEP +print(" + {customer_id} [prefix-redundant chain]"); +print(" + {customer_id, status} [prefix-redundant chain]"); +print(" + {customer_id, status, created_at:-1} [keep]"); + +// 5. Reverse variants +db.orders.createIndex({region: 1, created_at: 1}); // LOW (reverse below) +db.orders.createIndex({region: 1, created_at: -1}); // LOW (reverse above) +print(" + {region, created_at:1} [should be flagged: reverse-variant]"); +print(" + {region, created_at:-1} [should be flagged: reverse-variant]"); + +// ────────────────────────────────────────────────────────────────────── +// sessions: unused indexes +// ────────────────────────────────────────────────────────────────────── +print("\n[sessions] inserting 2,000 docs (with writes — will trigger WRITE_TAX)"); +bulk = []; +for (var i = 1; i <= 2000; i++) { + bulk.push({ + session_id: "sess_" + i, + user_id: (i % 500) + 1, + ip_address: "10.0." + (i % 256) + "." + ((i * 7) % 256), + user_agent: "Mozilla/5.0 fixture-test", + created_at: new Date() + }); +} +db.sessions.insertMany(bulk); +// Cause additional write activity to bump n_tup_upd / n_tup_ins beyond threshold +for (var i = 1; i <= 1500; i++) { + db.sessions.updateOne({session_id: "sess_" + i}, {$set: {last_seen: new Date()}}); +} + +print("[sessions] creating unused indexes (NOT queried, write-tax candidates):"); +db.sessions.createIndex({ip_address: 1}); // UNUSED +db.sessions.createIndex({user_agent: 1}); // UNUSED +db.sessions.createIndex({user_id: 1}); // KEEP — we'll query this to differentiate +print(" + {ip_address} [should be flagged: unused / write-tax]"); +print(" + {user_agent} [should be flagged: unused / write-tax]"); +print(" + {user_id} [we'll query this — should NOT be flagged]"); + +// Generate some query activity on selective indexes so they show ops > 0 +print("\n[users + orders + sessions] generating query traffic on KEEP indexes..."); +for (var i = 1; i <= 200; i++) { + db.users.find({tenant_id: (i % 50) + 1, status: "active"}).limit(1).toArray(); + db.users.find({email: "u" + i + "@example.com"}).limit(1).toArray(); + db.users.find({username: "user_" + i}).limit(1).toArray(); + db.orders.find({customer_id: i, status: "paid", created_at: {$lt: new Date()}}).limit(1).toArray(); + db.orders.find({region: "NA", created_at: {$gte: new Date(2024,0,1)}}).limit(1).toArray(); + db.sessions.find({user_id: i % 500 + 1}).limit(1).toArray(); +} + +print("\n=== Fixture complete ==="); +print("Collections: " + db.getCollectionNames().sort().join(", ")); +db.getCollectionNames().sort().forEach(function(c) { + print(" " + c + ": " + db[c].countDocuments() + " docs, " + db[c].getIndexes().length + " indexes"); +}); diff --git a/scenarios/index-redundancy/seed.sh b/scenarios/index-redundancy/seed.sh new file mode 100755 index 0000000..03edd72 --- /dev/null +++ b/scenarios/index-redundancy/seed.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# seed.sh — load the "idx_test" fixture: a database with intentionally redundant +# and unused indexes, so index-redundancy-finder.sh has findings to report. +# +# Usage: +# export DB_PASSWORD=Test1234 +# bash scenarios/index-redundancy/seed.sh # -> database "idx_test" +# bash scenarios/index-redundancy/seed.sh --db mydb --container NAME --password PASS +set -uo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONTAINER="${CONTAINER:-documentdb-local}" +PORT="${PORT:-10260}" +DB_USER="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="idx_test" + +while [[ $# -gt 0 ]]; do + case "$1" in + --db) DB="$2"; shift 2;; + --container) CONTAINER="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + -h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0;; + *) echo "Unknown option: $1" >&2; exit 2;; + esac +done + +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +echo "Seeding redundant-index fixture into '${DB}' (container: ${CONTAINER}) ..." +docker cp "$DIR/fixture-redundant-indexes.js" "${CONTAINER}:/tmp/fixture-redundant-indexes.js" >/dev/null + +docker exec "$CONTAINER" mongosh "localhost:${PORT}/${DB}" \ + -u "$DB_USER" -p "$PASSWORD" --authenticationMechanism SCRAM-SHA-256 \ + --tls --tlsAllowInvalidCertificates --quiet --file /tmp/fixture-redundant-indexes.js 2>/dev/null + +echo +echo "Done. Try: bash scripts/index-redundancy-finder.sh --db ${DB}" diff --git a/scripts/data-integrity-check.sh b/scripts/data-integrity-check.sh new file mode 100755 index 0000000..50af17a --- /dev/null +++ b/scripts/data-integrity-check.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# data-integrity-check.sh — Generic Data Integrity Checker for DocumentDB +# +# Auto-discovers collections and validates data integrity without hardcoded +# schema knowledge. Works with ANY DocumentDB database. +# +# Checks performed — HARD structural integrity only (no business-semantic guessing): +# 1. Referential integrity — *_id field values with no matching document in +# the referenced collection (broken / orphaned references) +# 2. Type consistency — a field holding conflicting scalar BSON types across +# documents in the same collection (breaks indexes / comparisons) +# +# Intentionally NOT checked (these are business rules, not structural integrity, +# and cannot be inferred safely from field names): value ranges, sign (>= 0), +# required / non-null, and uniqueness. Enforce those with a $jsonSchema +# validator or a unique index instead. +# +# Usage: +# bash scripts/data-integrity-check.sh --db [--container NAME] [--json] +# +# --json emit a compact machine-readable summary on stdout (nothing else), so +# an agent/router can consume the verdict without the full human report. +set -uo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-documentdb-local}" +PORT="${PORT:-10260}" +USER="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="" +JSON=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --container) CONTAINER_NAME="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + --db) DB="$2"; shift 2;; + --json) JSON=1; shift;; + -h|--help) + cat < [OPTIONS] + +Options: + --db NAME Target database (required) + --container NAME Docker container name (default: documentdb-local) + --password PASS DocumentDB password (required; or set DB_PASSWORD) + --port PORT DocumentDB gateway port (default: 10260) + --json Emit a compact JSON summary only (no human report) +EOF + exit 0;; + *) shift;; + esac +done + +[[ -z "$DB" ]] && { echo "Error: --db is required"; exit 1; } +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +run_mongosh() { + docker exec -u documentdb "$CONTAINER_NAME" mongosh \ + "localhost:${PORT}/${DB}" -u "$USER" -p "$PASSWORD" \ + --authenticationMechanism SCRAM-SHA-256 --tls --tlsAllowInvalidCertificates \ + --quiet --eval "$1" 2>/dev/null +} + +# human-only echo (suppressed in --json mode so stdout stays pure JSON) +hecho() { [[ "$JSON" == "1" ]] || echo "$@"; } + +# JS prelude: JSON_MODE toggles whether the shared check bodies print the human +# report (out) or only their machine-readable fragment. +JS_MODE="var JSON_MODE=$([[ "$JSON" == "1" ]] && echo true || echo false); function out(s){ if(!JSON_MODE) print(s); }" + +# ── Shared check bodies (single source of truth for both modes) ───────────── +read -r -d '' CHECK1_JS <<'JS' +var findings = 0; +var refFindings = []; +var colls = db.getCollectionNames().sort(); +var collSet = new Set(colls); + +// Build a map of collection → fields that look like foreign keys +// Strategy: find fields ending in _id (but not _id itself), and check +// if there is a matching collection (singular or plural) +colls.forEach(function(c) { + var sample = db[c].aggregate([{$sample:{size:20}}]).toArray(); + if (sample.length === 0) return; + + // Gather all top-level fields that end with "_id" (excluding _id) + var fkFields = {}; + sample.forEach(function(doc) { + Object.keys(doc).forEach(function(k) { + if (k === "_id") return; + if (k.match(/_id$/)) fkFields[k] = true; + }); + }); + + Object.keys(fkFields).forEach(function(fk) { + // Guess the target collection from the FK name + // e.g. "customer_id" → "customers", "order_id" → "orders", "product_id" → "products" + var base = fk.replace(/_id$/, ""); + var candidates = [base, base + "s", base + "es"]; + var targetColl = null; + for (var i = 0; i < candidates.length; i++) { + if (collSet.has(candidates[i]) && candidates[i] !== c) { + targetColl = candidates[i]; + break; + } + } + if (!targetColl) return; + + // Check if the target collection actually has this field (or _id) + var targetSample = db[targetColl].findOne(); + if (!targetSample) return; + var targetField = targetSample[fk] !== undefined ? fk : null; + if (!targetField && fk === targetColl.replace(/s$/, "") + "_id") { + // Also check the singular form + "_id" in target + targetField = targetSample[fk] !== undefined ? fk : null; + } + if (!targetField) return; + + // Validate: get distinct source values, check against target + var sourceVals = db[c].distinct(fk); + if (sourceVals.length === 0) return; + var targetVals = new Set(db[targetColl].distinct(targetField).map(String)); + var orphans = sourceVals.filter(function(v) { return v != null && !targetVals.has(String(v)); }); + + if (orphans.length > 0) { + out(" ⚠️ ORPHAN FK: " + c + "." + fk + " → " + targetColl + "." + targetField); + out(" " + orphans.length + "/" + sourceVals.length + " values not found in target"); + if (orphans.length <= 5) out(" Examples: " + orphans.slice(0,5).join(", ")); + else out(" Examples: " + orphans.slice(0,3).join(", ") + " ... (+" + (orphans.length-3) + " more)"); + findings++; + refFindings.push({ source: c, fk: fk, target: targetColl, target_field: targetField, + orphan_count: orphans.length, total: sourceVals.length, + examples: orphans.slice(0,5).map(String) }); + } else { + out(" ✅ " + c + "." + fk + " → " + targetColl + "." + targetField + " (" + sourceVals.length + " refs OK)"); + } + }); +}); + +out(""); +if (findings === 0) out(" ✅ All auto-discovered FK relationships are valid"); +else out(" Total referential integrity issues: " + findings); +if (JSON_MODE) print("REFJSON " + JSON.stringify({ issues: findings, findings: refFindings })); +JS + +read -r -d '' CHECK2_JS <<'JS' +var findings = 0; +var typeFindings = []; +var colls = db.getCollectionNames().sort(); + +colls.forEach(function(c) { + var count = db[c].estimatedDocumentCount(); + if (count < 5) return; + + var sampleSize = Math.min(100, count); + var sample = db[c].aggregate([{$sample:{size:sampleSize}}]).toArray(); + if (sample.length === 0) return; + + // Record the scalar BSON type seen for each field across sampled docs + var fieldTypes = {}; + sample.forEach(function(doc) { + Object.keys(doc).forEach(function(k) { + if (k === "_id") return; + var t = Array.isArray(doc[k]) ? "array" : (doc[k] instanceof Date ? "date" : typeof doc[k]); + if (!fieldTypes[k]) fieldTypes[k] = {}; + fieldTypes[k][t] = (fieldTypes[k][t] || 0) + 1; + }); + }); + + var issues = []; + + // HARD: flag a field that holds conflicting scalar types across documents + // (nested objects excluded). Mixed types break comparisons and indexes. + Object.keys(fieldTypes).forEach(function(k) { + var types = Object.keys(fieldTypes[k]); + if (types.length > 1 && types.indexOf("object") === -1) { + issues.push("\"" + k + "\" has mixed types: " + types.map(function(t){return t+"("+fieldTypes[k][t]+")";}).join(", ")); + typeFindings.push({ collection: c, field: k, types: fieldTypes[k] }); + } + }); + + if (issues.length > 0) { + out(" ⚠️ " + c + " (" + count + " docs):"); + issues.forEach(function(i) { out(" " + i); findings++; }); + } else { + out(" ✅ " + c + " — consistent field types across " + sample.length + " sampled docs"); + } +}); + +out(""); +if (findings === 0) out(" ✅ All field types are consistent"); +else out(" Total type-consistency issues: " + findings); +if (JSON_MODE) print("TYPEJSON " + JSON.stringify({ issues: findings, findings: typeFindings })); +JS + +# ── JSON mode: emit ONLY a compact summary object ─────────────────────────── +if [[ "$JSON" == "1" ]]; then + REF=$(run_mongosh "$JS_MODE $CHECK1_JS" | sed -n 's/^REFJSON //p') + TYPE=$(run_mongosh "$JS_MODE $CHECK2_JS" | sed -n 's/^TYPEJSON //p') + [[ -z "$REF" ]] && REF='{"issues":0,"findings":[]}' + [[ -z "$TYPE" ]] && TYPE='{"issues":0,"findings":[]}' + ref_issues=$(printf '%s' "$REF" | sed -n 's/.*"issues":\([0-9]*\).*/\1/p'); ref_issues="${ref_issues:-0}" + typ_issues=$(printf '%s' "$TYPE" | sed -n 's/.*"issues":\([0-9]*\).*/\1/p'); typ_issues="${typ_issues:-0}" + ok=true; (( ref_issues + typ_issues > 0 )) && ok=false + printf '{"db":"%s","referential_integrity":%s,"type_consistency":%s,"total_issues":%d,"ok":%s}\n' \ + "$DB" "$REF" "$TYPE" "$((ref_issues + typ_issues))" "$ok" + exit 0 +fi + +# ── Human mode: full report (unchanged) ───────────────────────────────────── +TIMESTAMP=$(date +%Y%m%d%H%M%S) + +hecho "╔══════════════════════════════════════════════════════════════════╗" +hecho "║ DocumentDB Data Integrity Checker (Generic) ║" +hecho "║ Database: $DB" +hecho "║ Container: $CONTAINER_NAME" +hecho "║ Timestamp: $TIMESTAMP" +hecho "╚══════════════════════════════════════════════════════════════════╝" +hecho "" + +# CHECK 1: Auto-Discovered Referential Integrity +hecho "══ CHECK 1: Referential Integrity (auto-discovered) ═══════════" +hecho "" +run_mongosh "$JS_MODE $CHECK1_JS" +hecho "" + +# CHECK 2: Type Consistency +hecho "══ CHECK 2: Type Consistency (sampled) ════════════════════════" +hecho "" +run_mongosh "$JS_MODE $CHECK2_JS" +hecho "" + +hecho "╔══════════════════════════════════════════════════════════════════╗" +hecho "║ Data Integrity Check Complete (Generic) ║" +hecho "╚══════════════════════════════════════════════════════════════════╝" diff --git a/scripts/db-config-advisor.sh b/scripts/db-config-advisor.sh new file mode 100755 index 0000000..2aa5314 --- /dev/null +++ b/scripts/db-config-advisor.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# db-config-advisor.sh — Evidence-based configuration & cache advisor for DocumentDB. +# +# Reports MEASURED facts and ties every observation to a number. It does NOT +# emit generic rules-of-thumb ("set shared_buffers to 25% of RAM"). Instead it +# computes, for a target database: +# - the WORKING SET = bytes that must be cached for this workload to be +# memory-resident (heap + TOAST + indexes of its tables) +# - the current shared_buffers / effective_cache_size (factual, with source) +# - the MEASURED buffer-cache hit ratios (heap / TOAST / index) from pg_statio +# - the TOAST share of the working set (links to document-bloat-advisor) +# and states the evidence-derived implication (e.g. "working set is 4.1x +# shared_buffers and TOAST is 92% of it; shrinking documents or raising +# shared_buffers toward the measured working set would improve residency"). +# +# Usage: +# bash scripts/db-config-advisor.sh --db [--json] +# [--container NAME] [--pg-port 9712] +# +# Read-only: this tool only SELECTs from pg_statio_* / pg_settings / catalog views; +# it never resets statistics or alters any database state. Cache-hit ratios are +# cumulative since the server's stats were last reset. If you want a clean +# measurement window, reset stats yourself out-of-band (e.g. psql +# "SELECT pg_stat_reset()"), run your workload, then re-run this tool. +set -uo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-documentdb-local}" +PG_PORT="${PG_PORT:-9712}" +PG_USER="${PG_USER:-documentdb}" +PG_DB="${PG_DB:-postgres}" +DB="" +JSON=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --db) DB="$2"; shift 2;; + --container) CONTAINER_NAME="$2"; shift 2;; + --pg-port) PG_PORT="$2"; shift 2;; + --json) JSON=1; shift;; + -h|--help) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0;; + *) echo "Unknown option: $1" >&2; exit 2;; + esac +done +[[ -z "$DB" ]] && { echo "Error: --db is required" >&2; exit 1; } + +run_psql() { + docker exec "$CONTAINER_NAME" psql -h localhost -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -t --no-align -F $'\t' -c "$1" 2>/dev/null | grep -vE '^SET$' +} + +# ── Config (factual) ──────────────────────────────────────────────────────── +SB_BYTES=$(run_psql "SELECT pg_size_bytes(current_setting('shared_buffers'))") +EC_BYTES=$(run_psql "SELECT pg_size_bytes(current_setting('effective_cache_size'))") +SB_H=$(run_psql "SELECT pg_size_pretty(pg_size_bytes(current_setting('shared_buffers')))") +EC_H=$(run_psql "SELECT pg_size_pretty(pg_size_bytes(current_setting('effective_cache_size')))") +SB_SRC=$(run_psql "SELECT source FROM pg_settings WHERE name='shared_buffers'") + +# ── Working set for this DB (heap+toast+indexes of its collections) ────────── +# returns: total_bytesheap_bytestoast_bytesindex_bytes +WS=$(run_psql " +WITH tabs AS ( + SELECT ('documentdb_data.documents_'||c.collection_id)::regclass AS oid + FROM documentdb_api_catalog.collections c + WHERE c.database_name = '${DB}' +) +SELECT + COALESCE(SUM(pg_total_relation_size(oid)),0), + COALESCE(SUM(pg_relation_size(oid)),0), + COALESCE(SUM(pg_relation_size(NULLIF((SELECT reltoastrelid FROM pg_class WHERE pg_class.oid=tabs.oid),0))),0), + COALESCE(SUM(pg_indexes_size(oid)),0) +FROM tabs; +") +WS_TOTAL=$(echo "$WS" | cut -f1); WS_HEAP=$(echo "$WS" | cut -f2) +WS_TOAST=$(echo "$WS" | cut -f3); WS_IDX=$(echo "$WS" | cut -f4) +[[ -z "$WS_TOTAL" || "$WS_TOTAL" == "0" ]] && { echo "No collections for '${DB}' (seeded? container up?)" >&2; exit 1; } + +# ── Measured cache-hit ratios (pg_statio, this DB's tables) ────────────────── +# returns: heap_hitheap_readtoast_hittoast_readidx_hitidx_read +IO=$(run_psql " +SELECT COALESCE(SUM(s.heap_blks_hit),0), COALESCE(SUM(s.heap_blks_read),0), + COALESCE(SUM(s.toast_blks_hit),0), COALESCE(SUM(s.toast_blks_read),0), + COALESCE(SUM(s.idx_blks_hit),0), COALESCE(SUM(s.idx_blks_read),0) +FROM pg_statio_user_tables s +JOIN documentdb_api_catalog.collections c + ON s.relname = 'documents_' || c.collection_id +WHERE s.schemaname='documentdb_data' AND c.database_name='${DB}'; +") +HH=$(echo "$IO"|cut -f1); HR=$(echo "$IO"|cut -f2); TH=$(echo "$IO"|cut -f3) +TR=$(echo "$IO"|cut -f4); IH=$(echo "$IO"|cut -f5); IR=$(echo "$IO"|cut -f6) + +pct() { awk -v h="$1" -v r="$2" 'BEGIN{ d=h+r; if(d<=0){print "n/a"}else{printf "%.1f%%", 100*h/d} }'; } +ratio_x() { awk -v a="$1" -v b="$2" 'BEGIN{ if(b<=0){print "n/a"}else{printf "%.1fx", a/b} }'; } +mb() { awk -v b="$1" 'BEGIN{ printf "%.1f", b/1048576 }'; } +share() { awk -v a="$1" -v b="$2" 'BEGIN{ if(b<=0){print "0"}else{printf "%.0f", 100*a/b} }'; } + +HEAP_HIT=$(pct "$HH" "$HR"); TOAST_HIT=$(pct "$TH" "$TR"); IDX_HIT=$(pct "$IH" "$IR") +WS_VS_SB=$(ratio_x "$WS_TOTAL" "$SB_BYTES") +TOAST_SHARE=$(share "$WS_TOAST" "$WS_TOTAL") +WS_MINUS_TOAST=$(( WS_TOTAL - WS_TOAST )) +WS_MINUS_TOAST_VS_SB=$(ratio_x "$WS_MINUS_TOAST" "$SB_BYTES") + +if [[ "$JSON" == "1" ]]; then + printf '{' + printf '"db":"%s",' "$DB" + printf '"shared_buffers_bytes":%s,"shared_buffers_source":"%s",' "$SB_BYTES" "$SB_SRC" + printf '"effective_cache_size_bytes":%s,' "$EC_BYTES" + printf '"working_set_bytes":%s,"working_set_heap_bytes":%s,"working_set_toast_bytes":%s,"working_set_index_bytes":%s,' "$WS_TOTAL" "$WS_HEAP" "$WS_TOAST" "$WS_IDX" + printf '"working_set_vs_shared_buffers":"%s","toast_share_pct":%s,"working_set_minus_toast_vs_shared_buffers":"%s",' "$WS_VS_SB" "$TOAST_SHARE" "$WS_MINUS_TOAST_VS_SB" + printf '"cache_hit_heap":"%s","cache_hit_toast":"%s","cache_hit_index":"%s",' "$HEAP_HIT" "$TOAST_HIT" "$IDX_HIT" + printf '"toast_blks_read":%s,"heap_blks_read":%s' "$TR" "$HR" + printf '}\n' + exit 0 +fi + +echo "══════════════════════════════════════════════════════════════════" +echo " DocumentDB Config & Cache Advisor (evidence-based)" +echo " Database: ${DB}" +echo "══════════════════════════════════════════════════════════════════" +echo "" +echo " Configuration (factual):" +echo " shared_buffers = ${SB_H} (source: ${SB_SRC})" +echo " effective_cache_size = ${EC_H}" +echo "" +echo " Working set for '${DB}' (must be cached to avoid disk I/O):" +echo " total = $(mb "$WS_TOTAL") MB (heap $(mb "$WS_HEAP") + TOAST $(mb "$WS_TOAST") + idx $(mb "$WS_IDX"))" +echo " vs shared_buffers = ${WS_VS_SB}" +echo " TOAST share of working set = ${TOAST_SHARE}%" +echo "" +echo " Measured buffer-cache hit ratio (pg_statio, cumulative):" +echo " heap = ${HEAP_HIT} (read ${HR} blocks from disk)" +echo " TOAST = ${TOAST_HIT} (read ${TR} blocks from disk)" +echo " index = ${IDX_HIT}" +echo "" +echo " Evidence-based observations:" +awk -v ws="$WS_TOTAL" -v sb="$SB_BYTES" 'BEGIN{ if(sb>0 && ws>sb) print " • Working set ('"$(mb "$WS_TOTAL")"'MB) EXCEEDS shared_buffers ('"$SB_H"') by '"$WS_VS_SB"'."; else print " • Working set fits within shared_buffers." }' +if [[ "$TOAST_SHARE" -ge 50 ]]; then + echo " • TOAST is ${TOAST_SHARE}% of the working set. This is large-text bloat:" + echo " removing it (schema split — see document-bloat-advisor) would cut the" + echo " working set to $(mb "$WS_MINUS_TOAST") MB (${WS_MINUS_TOAST_VS_SB} shared_buffers)," + echo " letting the hot data stay resident WITHOUT changing config." +fi +awk -v tr="$TR" 'BEGIN{ if(tr>0) print " • TOAST blocks are being read from disk — detoasting large text is"; }' +awk -v tr="$TR" 'BEGIN{ if(tr>0) print " causing physical I/O, not just cache churn."; }' +echo "" +echo " Note: recommendations are derived from the measured working set and hit" +echo " ratios above — not from generic rules of thumb. Prefer shrinking the" +echo " working set (document-bloat-advisor) before raising shared_buffers." diff --git a/scripts/document-bloat-advisor.sh b/scripts/document-bloat-advisor.sh new file mode 100755 index 0000000..b0c59b9 --- /dev/null +++ b/scripts/document-bloat-advisor.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# document-bloat-advisor.sh — DocumentDB large-document / TOAST tax advisor. +# +# DocumentDB stores each document as a single BSON column in PostgreSQL. When a +# document carries a large, low-compressibility field (long text, blobs), that +# value is pushed out-of-line into a TOAST table. Because the whole document is +# ONE column, reading ANY scalar field detoasts the ENTIRE document — so +# co-locating big text with fields your queries scan/aggregate imposes a +# per-access "detoast tax": huge extra I/O and buffer-cache pollution that never +# shows up as a missing index. +# +# This tool MEASURES that condition (no guessing): +# - per-collection heap vs TOAST bytes (PostgreSQL, cross-layer) +# - MongoDB avgObjSize +# - which top-level fields dominate document size (sampled) +# and recommends the DocumentDB-specific fix: SPLIT the large field(s) into a +# side collection keyed by _id, so the hot collection stays small/inline. +# +# Note: projection alone does NOT avoid the tax — the document is detoasted +# server-side before projection is applied. The fix is schema separation. +# +# Usage: +# bash scripts/document-bloat-advisor.sh --db [--json] +# [--container NAME] [--port 10260] [--pg-port 9712] +# [--toast-ratio 0.5] flag when TOAST/(heap+TOAST) exceeds this +# [--min-total-kb 256] ignore collections smaller than this +set -uo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-documentdb-local}" +PORT="${PORT:-10260}" +PG_PORT="${PG_PORT:-9712}" +PG_USER="${PG_USER:-documentdb}" +PG_DB="${PG_DB:-postgres}" +DB_USER_="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="" +JSON=0 +TOAST_RATIO="0.5" +MIN_TOTAL_KB="256" + +while [[ $# -gt 0 ]]; do + case "$1" in + --db) DB="$2"; shift 2;; + --container) CONTAINER_NAME="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + --pg-port) PG_PORT="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --toast-ratio) TOAST_RATIO="$2"; shift 2;; + --min-total-kb)MIN_TOTAL_KB="$2"; shift 2;; + --json) JSON=1; shift;; + -h|--help) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit 0;; + *) echo "Unknown option: $1" >&2; exit 2;; + esac +done +[[ -z "$DB" ]] && { echo "Error: --db is required" >&2; exit 1; } +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +run_mongosh() { + docker exec "$CONTAINER_NAME" mongosh "localhost:${PORT}/${DB}" \ + -u "$DB_USER_" -p "$PASSWORD" --authenticationMechanism SCRAM-SHA-256 \ + --tls --tlsAllowInvalidCertificates --quiet --eval "$1" 2>/dev/null +} +run_psql() { + docker exec "$CONTAINER_NAME" psql -h localhost -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -t --no-align -F $'\t' -c "$1" 2>/dev/null | grep -vE '^(SET|)$' +} + +# ── Per-collection heap/TOAST from PostgreSQL (measured facts) ────────────── +# tab-separated: collectionheap_bytestoast_bytestotal_bytes +SIZES=$(run_psql " +SELECT c.collection_name, + pg_relation_size(t.oid), + COALESCE(pg_relation_size(NULLIF(t.reltoastrelid,0)),0), + pg_total_relation_size(t.oid) +FROM documentdb_api_catalog.collections c +JOIN pg_class t ON t.oid = ('documentdb_data.documents_' || c.collection_id)::regclass +WHERE c.database_name = '${DB}' +ORDER BY pg_total_relation_size(t.oid) DESC; +") + +if [[ -z "$SIZES" ]]; then + echo "No collections found for database '${DB}' (is it seeded? is the container up?)" >&2 + exit 1 +fi + +# ── Analyze each collection; sample dominant fields for flagged ones ──────── +emit_human() { [[ "$JSON" == "0" ]] && echo "$1"; } + +emit_human "══════════════════════════════════════════════════════════════════" +emit_human " DocumentDB Large-Document / TOAST Advisor" +emit_human " Database: ${DB} (flag TOAST ratio > ${TOAST_RATIO}, min ${MIN_TOTAL_KB}KB)" +emit_human "══════════════════════════════════════════════════════════════════" +emit_human "" + +FINDINGS_JSON="[" +first=1 +flagged=0 +total_toast=0 + +while IFS=$'\t' read -r coll heap toast total; do + [[ -z "$coll" ]] && continue + total_kb=$(( total / 1024 )) + (( total < MIN_TOTAL_KB * 1024 )) && continue + # toast ratio via awk (float) + ratio=$(awk -v t="$toast" -v h="$heap" 'BEGIN{ d=t+h; if(d<=0){print 0}else{printf "%.3f", t/d} }') + over=$(awk -v r="$ratio" -v thr="$TOAST_RATIO" 'BEGIN{ print (r>thr)?1:0 }') + + heap_kb=$(( heap / 1024 )); toast_kb=$(( toast / 1024 )) + + if [[ "$over" == "1" ]]; then + flagged=$((flagged+1)) + total_toast=$(( total_toast + toast )) + # MongoDB avgObjSize + dominant top-level fields (sampled) + FIELDS=$(run_mongosh ' + var s = db.'"$coll"'.stats(); + var avg = s.avgObjSize || 0; + var docs = db.'"$coll"'.aggregate([{$sample:{size:20}}]).toArray(); + var acc = {}; + docs.forEach(function(doc){ + Object.keys(doc).forEach(function(k){ + if (k==="_id") return; + var len = 0; + try { len = JSON.stringify(doc[k]).length; } catch(e) { len = 0; } + acc[k] = (acc[k]||0) + len; + }); + }); + var n = docs.length || 1; + var arr = Object.keys(acc).map(function(k){ return {f:k, avg:Math.round(acc[k]/n)}; }); + arr.sort(function(a,b){ return b.avg - a.avg; }); + print("AVG " + avg); + arr.slice(0,3).forEach(function(x){ print("FLD " + x.f + " " + x.avg); }); + ') + avgobj=$(echo "$FIELDS" | awk '/^AVG/{print $2}') + bigfields=$(echo "$FIELDS" | awk '/^FLD/{print $2":"$3"B"}' | paste -sd, -) + topfield=$(echo "$FIELDS" | awk '/^FLD/{print $2; exit}') + + emit_human " ⚠️ ${coll}" + emit_human " heap=${heap_kb}KB TOAST=${toast_kb}KB (TOAST ratio ${ratio}) avgObjSize=${avgobj}B" + emit_human " dominant fields: ${bigfields}" + emit_human " → detoast tax: any scan/aggregate over ${coll} reads the full" + emit_human " document incl. the big text. Fix (DocumentDB-specific): move" + emit_human " '${topfield}' (and other large text) into a side collection" + emit_human " '${coll}_text' keyed by _id; keep ${coll} scalar-only." + emit_human "" + + # JSON finding + [[ $first -eq 0 ]] && FINDINGS_JSON+="," + first=0 + FINDINGS_JSON+=$(printf '{"collection":"%s","heap_bytes":%s,"toast_bytes":%s,"toast_ratio":%s,"avg_obj_size":%s,"dominant_fields":"%s","recommended_split_field":"%s","fix":"move large text to side collection %s_text keyed by _id"}' \ + "$coll" "$heap" "$toast" "$ratio" "${avgobj:-0}" "$bigfields" "$topfield" "$coll") + else + emit_human " ✅ ${coll} heap=${heap_kb}KB TOAST=${toast_kb}KB (ratio ${ratio}) — no bloat" + fi +done <<< "$SIZES" + +FINDINGS_JSON+="]" + +if [[ "$JSON" == "1" ]]; then + echo "$FINDINGS_JSON" + exit 0 +fi + +emit_human "──────────────────────────────────────────────────────────────────" +if [[ "$flagged" -eq 0 ]]; then + emit_human " ✅ No large-document/TOAST bloat detected." +else + emit_human " Flagged ${flagged} collection(s); $(( total_toast/1024 ))KB of TOASTed text" + emit_human " is detoasted on every scan of those collections." + emit_human "" + emit_human " Why this is DocumentDB-specific: the whole document is one BSON column," + emit_human " so reading any field detoasts the entire document. Projection does NOT" + emit_human " help (it runs after detoast). Splitting the large text into a side" + emit_human " collection keeps the hot documents small and inline." +fi diff --git a/scripts/index-redundancy-finder.sh b/scripts/index-redundancy-finder.sh new file mode 100755 index 0000000..8b4a714 --- /dev/null +++ b/scripts/index-redundancy-finder.sh @@ -0,0 +1,387 @@ +#!/usr/bin/env bash +# index-redundancy-finder.sh — Index Redundancy Finder for DocumentDB +# +# Generic tool that cross-references MongoDB index catalog with PostgreSQL +# index statistics to find redundant, duplicate, or unused indexes. +# Works with ANY DocumentDB database (auto-discovers collections & indexes). +# +# Detection rules (priority order): +# 1. EXACT_DUPLICATE — Two indexes with identical key spec +# 2. INVALID — Indexes flagged invalid in DocumentDB catalog +# 3. PREFIX_REDUNDANT — {a,b} subsumed by {a,b,c} +# 4. UNIQUE_SHADOWED — Non-unique idx{a} shadowed by unique idx{a} +# 5. UNUSED_VERIFIED — Zero scans at BOTH MongoDB API and PG layers +# 6. WRITE_TAX — Unused index on write-heavy table +# 7. REVERSE_VARIANT — {a:1,b:1} alongside {a:1,b:-1} (LOW: needs human review) +# +# Output: ranked findings with rationale, DROP commands, and storage estimates. +# +# Usage: +# bash scripts/index-redundancy-finder.sh --db [--container NAME] [--password PASS] +# bash scripts/index-redundancy-finder.sh --db ecommerce --container documentdb-local +# bash scripts/index-redundancy-finder.sh --all-dbs +# bash scripts/index-redundancy-finder.sh --db myapp --json # machine-readable output +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +CONTAINER_NAME="${CONTAINER_NAME:-documentdb-local}" +PORT="${PORT:-10260}" +PG_PORT="${PG_PORT:-9712}" +PG_USER="${PG_USER:-documentdb}" +PG_DB="${PG_DB:-postgres}" +USER="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="" +ALL_DBS=false +JSON_OUTPUT=false +MIN_AGE_DAYS=0 # warn-only threshold for "unused" classification + +while [[ $# -gt 0 ]]; do + case "$1" in + --container) CONTAINER_NAME="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + --pg-port) PG_PORT="$2"; shift 2;; + --db) DB="$2"; shift 2;; + --all-dbs) ALL_DBS=true; shift;; + --json) JSON_OUTPUT=true; shift;; + --min-age-days) MIN_AGE_DAYS="$2"; shift 2;; + -h|--help) + cat < [OPTIONS] + +Required: + --db NAME Target database (or use --all-dbs) + --all-dbs Scan all databases + +Optional: + --container NAME Docker container (default: documentdb-local) + --password PASS DocumentDB password (required; or set DB_PASSWORD) + --port PORT MongoDB gateway port (default: 10260) + --pg-port PORT PostgreSQL internal port (default: 9712) + --json Emit machine-readable JSON instead of report + --min-age-days N Only flag UNUSED if stats accumulated >= N days (default: 0) + +Examples: + $0 --db ecommerce + $0 --all-dbs + $0 --db myapp --json > findings.json +EOF + exit 0;; + *) shift;; + esac +done + +[[ -z "$DB" && "$ALL_DBS" != "true" ]] && { echo "Error: --db or --all-dbs is required"; exit 1; } +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +# ── Helpers ─────────────────────────────────────────────────────────── +run_mongosh() { + local target_db="${2:-$DB}" + docker exec -u documentdb "$CONTAINER_NAME" mongosh \ + "localhost:${PORT}/${target_db}" -u "$USER" -p "$PASSWORD" \ + --authenticationMechanism SCRAM-SHA-256 --tls --tlsAllowInvalidCertificates \ + --quiet --eval "$1" 2>/dev/null +} + +run_psql() { + docker exec "$CONTAINER_NAME" psql -h localhost -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -t -A -F $'\t' -c "$1" 2>/dev/null | grep -v "^SET$" +} + +# Print a banner only if not JSON mode +banner() { $JSON_OUTPUT || echo "$@"; } +section() { $JSON_OUTPUT || { echo ""; echo "$@"; }; } + +TIMESTAMP=$(date +%Y%m%d%H%M%S) + +banner "╔══════════════════════════════════════════════════════════════════╗" +banner "║ DocumentDB Index Redundancy Finder ║" +banner "║ Database: ${DB:-ALL}" +banner "║ Container: $CONTAINER_NAME" +banner "║ Timestamp: $TIMESTAMP" +banner "╚══════════════════════════════════════════════════════════════════╝" +banner "" + +# Discover databases +if [[ "$ALL_DBS" == "true" ]]; then + DATABASES=$(run_mongosh 'db.adminCommand({listDatabases:1}).databases.forEach(function(d){if(d.name!=="admin"&&d.name!=="config"&&d.name!=="local")print(d.name);})' "admin") +else + DATABASES="$DB" +fi + +# Findings accumulator (JSON array) +ALL_FINDINGS_JSON="[]" + +for CURRENT_DB in $DATABASES; do +banner "┌──────────────────────────────────────────────────────────────┐" +banner "│ Database: $CURRENT_DB" +banner "└──────────────────────────────────────────────────────────────┘" +banner "" + +# ── Step 1: Pull all index specs + usage from MongoDB API ───────────── +# Output format (one line per index, tab-separated): +# collection \t name \t keys_json \t unique \t ops \t partial \t sparse +MONGO_INDEXES=$(run_mongosh ' +var out = []; +db.getCollectionNames().sort().forEach(function(c) { + var indexes; + try { indexes = db[c].getIndexes(); } catch(e) { return; } + var statsMap = {}; + try { + db[c].aggregate([{$indexStats:{}}]).toArray().forEach(function(s) { + statsMap[s.name] = Number((s.accesses || {}).ops || 0); + }); + } catch(e) {} + indexes.forEach(function(idx) { + var line = [ + c, + idx.name, + JSON.stringify(idx.key), + idx.unique ? "1" : "0", + String(statsMap[idx.name] || 0), + idx.partialFilterExpression ? "1" : "0", + idx.sparse ? "1" : "0" + ].join("\t"); + print(line); + }); +}); +' "$CURRENT_DB") + +if [[ -z "$MONGO_INDEXES" ]]; then + banner " (no collections in $CURRENT_DB)" + continue +fi + +# ── Step 2: Pull PG-level stats for this database's indexes ────────── +# Map: collection_name -> { index_name -> {pg_index_name, idx_scan, size_bytes, valid} } +# DocumentDB stores indexes as PG indexes on documents_ tables. +# We need to: collection_id -> PG table -> PG indexes -> stats +PG_STATS=$(run_psql " +SELECT c.collection_name, + (ci.index_spec).index_name AS mongo_idx_name, + i.indexrelname AS pg_idx_name, + COALESCE(s.idx_scan, 0) AS idx_scan, + pg_relation_size(i.indexrelid) AS size_bytes, + ci.index_is_valid, + COALESCE(t.n_tup_ins + t.n_tup_upd + t.n_tup_del, 0) AS write_ops +FROM documentdb_api_catalog.collections c +JOIN documentdb_api_catalog.collection_indexes ci + ON ci.collection_id = c.collection_id +LEFT JOIN pg_stat_user_indexes i + ON i.schemaname = 'documentdb_data' + AND i.relname = 'documents_' || c.collection_id + AND i.indexrelname LIKE '%_' || ci.index_id::text +LEFT JOIN pg_stat_user_indexes s + ON s.indexrelid = i.indexrelid +LEFT JOIN pg_stat_user_tables t + ON t.schemaname = 'documentdb_data' + AND t.relname = 'documents_' || c.collection_id +WHERE c.database_name = '$CURRENT_DB' +ORDER BY c.collection_name, ci.index_id +") + +# Build associative arrays in awk for fast lookup +# We'll process in awk and emit JSON findings + +# Combined analysis pipeline: +FINDINGS=$(echo "$MONGO_INDEXES" | awk -v pg_stats="$PG_STATS" -v db="$CURRENT_DB" ' +BEGIN { + FS="\t"; + # Parse PG stats into associative arrays + n_pg = split(pg_stats, pg_lines, "\n"); + for (i=1; i<=n_pg; i++) { + line = pg_lines[i]; + if (length(line) == 0) continue; + split(line, p, "\t"); + coll = p[1]; mname = p[2]; + key = coll "::" mname; + pg_pg_name[key] = p[3]; + pg_idx_scan[key] = (p[4]+0); + pg_size[key] = (p[5]+0); + pg_valid[key] = p[6]; + pg_writes[key] = (p[7]+0); + } +} +{ + coll = $1; name = $2; keys_json = $3; uniq = $4; ops = ($5+0); + partial = $6; sparse = $7; + # Skip the _id index (special, never droppable) + if (name == "_id_") next; + idx_count[coll]++; + n = idx_count[coll]; + # Store + idx_coll[coll, n] = coll; + idx_name[coll, n] = name; + idx_keys[coll, n] = keys_json; + idx_uniq[coll, n] = uniq; + idx_ops[coll, n] = ops; + idx_partial[coll, n] = partial; + idx_sparse[coll, n] = sparse; + # Track collection list + if (!seen[coll]) { seen[coll]=1; coll_order[++ncolls] = coll; } +} +END { + # For each collection, run all rule checks pairwise / standalone + print "["; + first_finding = 1; + for (ci=1; ci<=ncolls; ci++) { + coll = coll_order[ci]; + cnt = idx_count[coll]; + + # Build a normalized key list for each index (extract field names in order) + for (i=1; i<=cnt; i++) { + kjson = idx_keys[coll, i]; + # Extract sequence of "field":value pairs preserving order + # Simple parse: split on commas, then on colons + tmp = kjson; + gsub(/^\{/, "", tmp); gsub(/\}$/, "", tmp); + n_pairs = split(tmp, pairs, ","); + fields[coll, i] = ""; + dirs[coll, i] = ""; + for (pi=1; pi<=n_pairs; pi++) { + pair = pairs[pi]; + # Pair like: "field" : 1 or "field" : -1 + # Get field name + fname = pair; + sub(/^[ \t]*"/, "", fname); + sub(/".*/, "", fname); + # Get direction value (last numeric / string) + dval = pair; + sub(/^[^:]*:[ \t]*/, "", dval); + gsub(/[ \t]/, "", dval); + fields[coll, i] = fields[coll, i] (fields[coll, i]=="" ? "" : ",") fname; + dirs[coll, i] = dirs[coll, i] (dirs[coll, i]=="" ? "" : ",") dval; + } + } + + # ── RULE 1: EXACT_DUPLICATE ───────────────────────────────── + for (i=1; i<=cnt; i++) { + for (j=i+1; j<=cnt; j++) { + if (fields[coll,i] == fields[coll,j] && dirs[coll,i] == dirs[coll,j]) { + # Keep the unique one if any, drop the other + if (idx_uniq[coll,i] == "1" && idx_uniq[coll,j] == "0") { + emit_finding(coll, idx_name[coll,j], "EXACT_DUPLICATE", "HIGH", + "Identical key spec to " idx_name[coll,i] " (which is UNIQUE); this one is redundant", + idx_name[coll,i]); + } else if (idx_uniq[coll,j] == "1" && idx_uniq[coll,i] == "0") { + emit_finding(coll, idx_name[coll,i], "EXACT_DUPLICATE", "HIGH", + "Identical key spec to " idx_name[coll,j] " (which is UNIQUE); this one is redundant", + idx_name[coll,j]); + } else { + emit_finding(coll, idx_name[coll,j], "EXACT_DUPLICATE", "HIGH", + "Identical key spec to " idx_name[coll,i] " — exact duplicate", + idx_name[coll,i]); + } + } + } + } + + # ── RULE 3: PREFIX_REDUNDANT ──────────────────────────────── + for (i=1; i<=cnt; i++) { + for (j=1; j<=cnt; j++) { + if (i == j) continue; + if (fields[coll,i] == fields[coll,j]) continue; # exact dup handled + # i is prefix of j? + f_i = fields[coll,i]; f_j = fields[coll,j]; + d_i = dirs[coll,i]; d_j = dirs[coll,j]; + prefix_i = f_i ","; + if (index(f_j ",", prefix_i) == 1) { + # Check directions match for the prefix + pref_d_i = d_i ","; + if (index(d_j ",", pref_d_i) == 1) { + # Skip if i is unique (can not be replaced) + if (idx_uniq[coll,i] == "1") continue; + # Skip if i has partial filter or sparse (special semantics) + if (idx_partial[coll,i] == "1" || idx_sparse[coll,i] == "1") continue; + emit_finding(coll, idx_name[coll,i], "PREFIX_REDUNDANT", "HIGH", + "Index {" f_i "} is a prefix of {" f_j "} (" idx_name[coll,j] ") — covered by the longer index", + idx_name[coll,j]); + } + } + } + } + + # ── RULE 7: REVERSE_VARIANT (LOW severity) ────────────────── + for (i=1; i<=cnt; i++) { + for (j=i+1; j<=cnt; j++) { + if (fields[coll,i] != fields[coll,j]) continue; + if (dirs[coll,i] == dirs[coll,j]) continue; + emit_finding(coll, idx_name[coll,j], "REVERSE_VARIANT", "LOW", + "Same fields as " idx_name[coll,i] " but reverse sort direction — MongoDB can often walk an index in either direction; review query patterns", + idx_name[coll,i]); + } + } + + # ── RULE 2: INVALID, RULE 5: UNUSED_VERIFIED, RULE 6: WRITE_TAX ── + for (i=1; i<=cnt; i++) { + key = coll "::" idx_name[coll,i]; + valid = pg_valid[key]; + pg_scans = pg_idx_scan[key]; + pg_size_b = pg_size[key]; + writes = pg_writes[key]; + + if (valid == "f") { + emit_finding(coll, idx_name[coll,i], "INVALID", "HIGH", + "Index marked invalid in DocumentDB catalog (PG idx: " pg_pg_name[key] ") — likely failed during creation, consuming disk with no benefit", + ""); + } + + mongo_ops = idx_ops[coll,i]; + # Cross-validated unused: both layers report at most a noise-floor scan. + # ($indexStats and pg_stat record index build / explain as 1 access.) + if (mongo_ops <= 1 && pg_scans <= 1) { + size_kb = int(pg_size_b/1024); + if (writes > 1000) { + emit_finding(coll, idx_name[coll,i], "WRITE_TAX", "MEDIUM", + "Zero reads (MongoDB + PG) but table has " writes " write ops — pure write amplification, ~" size_kb "KB on disk", + ""); + } else { + emit_finding(coll, idx_name[coll,i], "UNUSED_VERIFIED", "MEDIUM", + "Zero scans at both MongoDB ($indexStats.ops) and PG (pg_stat_user_indexes.idx_scan) layers — ~" size_kb "KB on disk", + ""); + } + } + } + } + print "]"; +} + +function emit_finding(coll, name, kind, severity, reason, replaces) { + if (!first_finding) print ","; + first_finding = 0; + # JSON-escape strings + gsub(/\\/, "\\\\", reason); gsub(/"/, "\\\"", reason); + printf " {\"db\":\"%s\",\"collection\":\"%s\",\"index\":\"%s\",\"rule\":\"%s\",\"severity\":\"%s\",\"reason\":\"%s\",\"replaces_with\":\"%s\"}", db, coll, name, kind, severity, reason, replaces; +} +' ) + +# Pretty-print findings unless JSON mode +if $JSON_OUTPUT; then + # Merge with overall JSON + if [[ "$ALL_FINDINGS_JSON" == "[]" ]]; then + ALL_FINDINGS_JSON="$FINDINGS" + else + # Strip closing ] of prev, opening [ of new, join with , + ALL_FINDINGS_JSON="${ALL_FINDINGS_JSON%]},${FINDINGS#[}" + fi +else + # Parse JSON findings and pretty-print (renderer lives in a standalone module) + echo "$FINDINGS" | python3 "$SCRIPT_DIR/index-redundancy-render.py" 2>&1 +fi + +done # end of for CURRENT_DB + +# Final JSON output if requested +if $JSON_OUTPUT; then + echo "$ALL_FINDINGS_JSON" +else + echo "" + echo "═══════════════════════════════════════════════════════════════════" + echo " Legend:" + echo " 🔴 HIGH — Safe to drop (validated by index spec / catalog)" + echo " 🟡 MEDIUM — Likely safe (zero usage validated at both layers)" + echo " 🔵 LOW — Review query patterns before dropping" + echo "═══════════════════════════════════════════════════════════════════" +fi diff --git a/scripts/index-redundancy-render.py b/scripts/index-redundancy-render.py new file mode 100644 index 0000000..489d40c --- /dev/null +++ b/scripts/index-redundancy-render.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""index-redundancy-render.py — pretty-print index-redundancy-finder findings. + +Reads the findings JSON array on stdin (as produced by index-redundancy-finder.sh) +and prints a grouped, severity-sorted human report. Kept as a standalone module +(instead of an inline heredoc in the shell script) so it can be read, linted, and +tested on its own. The shell script pipes findings to it in non-JSON mode. +""" + +import json +import sys +from collections import defaultdict + +SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} +SEV_ICON = {"HIGH": "🔴", "MEDIUM": "🟡", "LOW": "🔵"} + + +def main(): + data = json.load(sys.stdin) + if not data: + print(" ✅ No redundant or unused indexes found") + return + + data.sort(key=lambda f: (SEV_ORDER.get(f["severity"], 3), f["collection"], f["rule"])) + + by_coll = defaultdict(list) + for f in data: + by_coll[f["collection"]].append(f) + + counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0} + for f in data: + counts[f["severity"]] = counts.get(f["severity"], 0) + 1 + + print(f' Found {len(data)} finding(s): {counts["HIGH"]} HIGH, ' + f'{counts["MEDIUM"]} MEDIUM, {counts["LOW"]} LOW') + print() + for coll in sorted(by_coll): + print(f" ── {coll} ──") + for f in by_coll[coll]: + icon = SEV_ICON.get(f["severity"], " ") + print(f' {icon} [{f["severity"]}] {f["rule"]:18s} {f["index"]}') + print(f' → {f["reason"]}') + print(f' 💡 db.{coll}.dropIndex("{f["index"]}")') + print() + + +if __name__ == "__main__": + main() diff --git a/scripts/perf-advisor.sh b/scripts/perf-advisor.sh new file mode 100755 index 0000000..6bddea1 --- /dev/null +++ b/scripts/perf-advisor.sh @@ -0,0 +1,686 @@ +#!/usr/bin/env bash +# perf-advisor.sh — Local Performance Advisor for DocumentDB +# +# Generic performance analysis tool that works with ANY DocumentDB database. +# Combines MongoDB-level diagnostics with deep PostgreSQL-level analysis +# (DocumentDB is built on PostgreSQL). +# +# Layer 1 — MongoDB API Layer (via mongosh): +# 1. Database overview (collection sizes, index counts) +# 2. Index health (unused, redundant/overlapping, missing) +# 3. Collection scan audit (auto-discovers query patterns, flags COLLSCAN) +# 4. Query performance profiling (times representative queries) +# +# Layer 2 — PostgreSQL Engine Layer (via psql): +# 5. PG table I/O stats (sequential vs index scans, cache hit rates) +# 6. PG index efficiency (unused PG indexes, bloat indicators) +# 7. Buffer cache analysis (heap + index hit rates per table) +# 8. PG connection & lock analysis +# 9. PG configuration (current settings — FACTUAL only) +# 10. Collection-ID mapping (MongoDB name ↔ PG table) +# +# Principle: this advisor reports MEASURED facts and concrete structural issues +# (scan/cache/lock counters, unused & redundant indexes, full scans with no +# covering index). It deliberately does NOT prescribe generic tuning values +# (e.g. "set shared_buffers to 25% of RAM") — such rules-of-thumb are not +# derived from your workload, can regress performance, and second-guess the +# tuned defaults DocumentDB ships. Tune from your own measured evidence. +# +# Usage: +# bash scripts/perf-advisor.sh --db [--container NAME] [--password PASS] +# bash scripts/perf-advisor.sh --db ecommerce --container documentdb-local +# bash scripts/perf-advisor.sh --db myapp --all-dbs # scan all databases +set -uo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-documentdb-local}" +PORT="${PORT:-10260}" +PG_PORT="${PG_PORT:-9712}" +PG_USER="${PG_USER:-documentdb}" +PG_DB="${PG_DB:-postgres}" +USER="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="" +ALL_DBS=false +JSON=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --container) CONTAINER_NAME="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + --pg-port) PG_PORT="$2"; shift 2;; + --db) DB="$2"; shift 2;; + --all-dbs) ALL_DBS=true; shift;; + --json) JSON=1; shift;; + -h|--help) + cat < [OPTIONS] + +Options: + --db NAME Target database (required unless --all-dbs) + --all-dbs Scan all databases + --container NAME Docker container name (default: documentdb-local) + --password PASS DocumentDB password (required; or set DB_PASSWORD) + --port PORT DocumentDB gateway port (default: 10260) + --pg-port PORT PostgreSQL internal port (default: 9712) + --json Emit a compact JSON findings summary only (no human report) +EOF + exit 0;; + *) shift;; + esac +done + +[[ -z "$DB" && "$ALL_DBS" != "true" ]] && { echo "Error: --db or --all-dbs is required"; exit 1; } +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +# ── Helper functions ────────────────────────────────────────────────── +run_mongosh() { + local target_db="${2:-$DB}" + docker exec -u documentdb "$CONTAINER_NAME" mongosh \ + "localhost:${PORT}/${target_db}" -u "$USER" -p "$PASSWORD" \ + --authenticationMechanism SCRAM-SHA-256 --tls --tlsAllowInvalidCertificates \ + --quiet --eval "$1" 2>/dev/null +} + +run_psql() { + docker exec "$CONTAINER_NAME" psql -h localhost -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -t --no-align -c "$1" 2>/dev/null +} + +run_psql_pretty() { + docker exec "$CONTAINER_NAME" psql -h localhost -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -c "$1" 2>/dev/null | grep -v "^SET$" +} + +# psql that returns a single scalar/line with no formatting (for JSON assembly) +run_psql_raw() { + docker exec "$CONTAINER_NAME" psql -h localhost -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -t -A -c "$1" 2>/dev/null +} + +# human-only echo (suppressed in --json mode so stdout stays pure JSON) +hecho() { [[ "$JSON" == "1" ]] || echo "$@"; } + +TIMESTAMP=$(date +%Y%m%d%H%M%S) + +hecho "╔══════════════════════════════════════════════════════════════════╗" +hecho "║ DocumentDB Local Performance Advisor ║" +hecho "║ Database: ${DB:-ALL}" +hecho "║ Container: $CONTAINER_NAME" +hecho "║ Timestamp: $TIMESTAMP" +hecho "╚══════════════════════════════════════════════════════════════════╝" +hecho "" + +# If --all-dbs, discover databases +if [[ "$ALL_DBS" == "true" ]]; then + DATABASES=$(run_mongosh 'db.adminCommand({listDatabases:1}).databases.forEach(function(d){if(d.name!=="admin"&&d.name!=="config"&&d.name!=="local")print(d.name);})' "admin") +else + DATABASES="$DB" +fi + +# ══════════════════════════════════════════════════════════════════════ +# JSON MODE: emit a compact findings summary only, then exit +# ══════════════════════════════════════════════════════════════════════ +if [[ "$JSON" == "1" ]]; then + # Mongo-layer findings per database (index health, COLLSCAN audit, slow queries). + read -r -d '' MONGO_JSON_JS <<'JS' +var out = { db: db.getName(), collections: [], index_health: [], collscans: [], slow_queries: [] }; +var colls = db.getCollectionNames().sort(); + +// --- overview + index health --- +colls.forEach(function(c) { + var st; try { st = db.runCommand({collStats: c}); } catch(e) { return; } + out.collections.push({ name: c, docs: st.count||0, avg_obj_size: st.avgObjSize||0, + data_bytes: st.size||0, indexes: st.nindexes||0, index_bytes: st.totalIndexSize||0 }); + var indexes = db[c].getIndexes(); + var unused = [], redundant = []; + if (indexes.length > 1) { + try { + db[c].aggregate([{$indexStats:{}}]).toArray().forEach(function(s) { + if (s.name === "_id_") return; + if ((s.accesses ? Number(s.accesses.ops) : 0) === 0) unused.push(s.name); + }); + } catch(e) {} + var ks = indexes.map(function(idx){ return {name: idx.name, keys: Object.keys(idx.key)}; }); + for (var i=0;ia.keys.length && a.keys.every(function(k,ix){return k===b.keys[ix];})) + redundant.push({prefix: a.name, of: b.name}); + } + } + if (unused.length || redundant.length) out.index_health.push({collection: c, unused: unused, redundant: redundant}); +}); + +// --- COLLSCAN audit (same logic as the human report) --- +function effectiveIndex(node){ var g=0; while(node&&g++<50){ if(node.stage==="COLLSCAN")return "__COLLSCAN__"; if(node.stage==="IXSCAN")return node.indexName||"?"; node=node.inputStage||(node.inputStages?node.inputStages[0]:null);} return null; } +function testQuery(coll, field, label, filter, indexedFields){ + try { + var plan = db.runCommand({explain:{find:coll, filter:filter, limit:1}, verbosity:"executionStats"}); + var wp=(plan.queryPlanner||{}).winningPlan||{}, es=plan.executionStats||{}; + var idx=effectiveIndex(wp), full=(idx==="__COLLSCAN__"||idx==="_id_"); + if (full && !(indexedFields && indexedFields[field])) + out.collscans.push({collection: coll, query: label, docs_scanned: Number(es.totalDocsExamined||0)}); + } catch(e) {} +} +colls.forEach(function(c) { + var count=db[c].estimatedDocumentCount(); if (count<10) return; + var sample=db[c].findOne(); if (!sample) return; + var indexedFields={}; db[c].getIndexes().forEach(function(ix){var k=Object.keys(ix.key||{}); if(k.length)indexedFields[k[0]]=true;}); + Object.keys(sample).filter(function(k){return k!=="_id";}).forEach(function(field){ + var val=sample[field]; + if (typeof val==="string" && val.length<100) testQuery(c,field,"find {"+field+":\"...\"}", JSON.parse("{\""+field+"\":\""+val+"\"}"), indexedFields); + else if (typeof val==="number") testQuery(c,field,"find {"+field+":{$gt:...}}", JSON.parse("{\""+field+"\":{\"$gt\":"+(val/2)+"}}"), indexedFields); + else if (typeof val==="boolean") testQuery(c,field,"find {"+field+":"+val+"}", JSON.parse("{\""+field+"\":"+val+"}"), indexedFields); + }); +}); + +// --- query timing (report anything >50ms) --- +function timeQuery(coll,label,fn){ var s=Date.now(); var n=0; try{n=fn();}catch(e){n=-1;} var ms=Date.now()-s; if(ms>50) out.slow_queries.push({collection:coll, query:label, ms:ms, results:n}); } +colls.forEach(function(c){ + var count=db[c].estimatedDocumentCount(); if (count<100) return; + var sample=db[c].findOne(); if (!sample) return; + timeQuery(c,"countDocuments()",function(){return db[c].countDocuments();}); + var sf=Object.keys(sample).filter(function(k){return k!=="_id";}).find(function(f){return typeof sample[f]==="string"&&sample[f].length<50;}); + if (sf){ var v=sample[sf]; + timeQuery(c,"find({"+sf+":...})",function(){return db[c].find(JSON.parse("{\""+sf+"\":\""+v+"\"}")).count();}); + timeQuery(c,"aggregate $group by "+sf,function(){return db[c].aggregate([{$group:{_id:"$"+sf,n:{$sum:1}}}]).toArray().length;}); + } +}); + +out.summary = { collections: out.collections.length, index_health_findings: out.index_health.length, + collscan_patterns: out.collscans.length, slow_queries: out.slow_queries.length }; +print("MONGOJSON " + JSON.stringify(out)); +JS + + # PG-layer findings via PostgreSQL's own JSON builders (single line output). + PG_SQL="SELECT json_build_object( + 'config', COALESCE((SELECT json_agg(json_build_object('name',name,'setting',setting,'unit',COALESCE(unit,''),'source',source,'default',boot_val) ORDER BY name) + FROM pg_settings WHERE name IN ('shared_buffers','effective_cache_size','work_mem','maintenance_work_mem','max_connections','max_wal_size','wal_level')), '[]'::json), + 'cache_top', COALESCE((SELECT json_agg(r) FROM ( + SELECT COALESCE(c.database_name||'.'||c.collection_name, s.relname) AS collection, s.heap_blks_read AS heap_disk_reads, + round(100.0*s.heap_blks_hit/NULLIF(s.heap_blks_read+s.heap_blks_hit,0),2) AS heap_hit_pct, + round(100.0*s.idx_blks_hit/NULLIF(s.idx_blks_read+s.idx_blks_hit,0),2) AS idx_hit_pct + FROM pg_statio_user_tables s LEFT JOIN documentdb_api_catalog.collections c ON s.relname='documents_'||c.collection_id + WHERE s.schemaname='documentdb_data' AND s.relname LIKE 'documents_%' AND (s.heap_blks_read+s.heap_blks_hit)>0 + ORDER BY s.heap_blks_read DESC LIMIT 5) r), '[]'::json), + 'scan_mix', COALESCE((SELECT json_agg(r) FROM ( + SELECT COALESCE(c.database_name||'.'||c.collection_name, s.relname) AS collection, s.seq_scan, s.idx_scan, + CASE WHEN s.seq_scan+s.idx_scan>0 THEN round(100.0*s.idx_scan/(s.seq_scan+s.idx_scan),1) ELSE 0 END AS idx_scan_pct + FROM pg_stat_user_tables s LEFT JOIN documentdb_api_catalog.collections c ON s.relname='documents_'||c.collection_id + WHERE s.schemaname='documentdb_data' AND s.relname LIKE 'documents_%' AND s.seq_scan+s.idx_scan>0 + ORDER BY s.seq_tup_read DESC LIMIT 5) r), '[]'::json), + 'unused_pg_indexes', (SELECT count(*) FROM pg_stat_user_indexes WHERE schemaname='documentdb_data' AND idx_scan=0 AND relname LIKE 'documents_%'), + 'blocked_queries', (SELECT count(*) FROM pg_stat_activity WHERE wait_event_type='Lock') + );" + PG_JSON=$(run_psql_raw "$PG_SQL" | grep -vE '^SET$' | tr -d '\n') + [[ -z "$PG_JSON" ]] && PG_JSON='null' + + MONGO_ARR="" + first=1 + for CURRENT_DB in $DATABASES; do + mj=$(run_mongosh "$MONGO_JSON_JS" "$CURRENT_DB" | sed -n 's/^MONGOJSON //p') + [[ -z "$mj" ]] && continue + [[ $first -eq 0 ]] && MONGO_ARR+="," + first=0 + MONGO_ARR+="$mj" + done + + dbs_json=$(printf '%s' "$DATABASES" | awk '{printf (NR>1?",":"") "\"" $0 "\""}') + printf '{"databases":[%s],"mongo":[%s],"pg":%s}\n' "$dbs_json" "$MONGO_ARR" "$PG_JSON" + exit 0 +fi + + +# ══════════════════════════════════════════════════════════════════════ +# LAYER 1: MongoDB API Layer +# ══════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " LAYER 1: MongoDB API Diagnostics" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +for CURRENT_DB in $DATABASES; do +echo "┌──────────────────────────────────────────────────────────────┐" +echo "│ Database: $CURRENT_DB" +echo "└──────────────────────────────────────────────────────────────┘" +echo "" + +# ── CHECK 1: Database Overview ──────────────────────────────────────── +echo "══ CHECK 1: Database & Collection Overview ════════════════════" +echo "" +run_mongosh ' +var colls = db.getCollectionNames().sort(); +if (colls.length === 0) { print(" (no collections)"); } +else { + var fmt = function(s,w) { s=String(s); while(s.length 1048576 ? (size/1048576).toFixed(1)+"MB" : size > 1024 ? (size/1024).toFixed(1)+"KB" : size+"B"; + var idxStr = idxSize > 1048576 ? (idxSize/1048576).toFixed(1)+"MB" : idxSize > 1024 ? (idxSize/1024).toFixed(1)+"KB" : idxSize+"B"; + print(" " + fmtL(c,25) + fmt(cnt,10) + fmt(avg+"B",10) + fmt(sizeStr,12) + fmt(nidx,10) + fmt(idxStr,12)); + if (nidx > 15) findings.push("⚠️ " + c + ": " + nidx + " indexes (>15) — write amplification risk"); + if (cnt > 1000 && idxSize > size * 2) findings.push("⚠️ " + c + ": index size exceeds 2x data size"); + } catch(e) {} + }); + print(" " + "-".repeat(79)); + var tSize = totalSize > 1048576 ? (totalSize/1048576).toFixed(1)+"MB" : (totalSize/1024).toFixed(1)+"KB"; + var tIdx = totalIdxSize > 1048576 ? (totalIdxSize/1048576).toFixed(1)+"MB" : (totalIdxSize/1024).toFixed(1)+"KB"; + print(" " + fmtL("TOTAL",25) + fmt(totalDocs,10) + fmt("",10) + fmt(tSize,12) + fmt("",10) + fmt(tIdx,12)); + if (findings.length > 0) { print(""); findings.forEach(function(f) { print(" " + f); }); } +} +' "$CURRENT_DB" +echo "" + +# ── CHECK 2: Index Health ──────────────────────────────────────────── +echo "══ CHECK 2: Index Health Analysis ═════════════════════════════" +echo "" +run_mongosh ' +var colls = db.getCollectionNames().sort(); +var totalFindings = 0; +colls.forEach(function(c) { + var indexes = db[c].getIndexes(); + if (indexes.length <= 1) return; + print(" ── " + c + " (" + indexes.length + " indexes) ──"); + + // Unused indexes + try { + var stats = db[c].aggregate([{$indexStats:{}}]).toArray(); + stats.forEach(function(s) { + if (s.name === "_id_") return; + var ops = s.accesses ? Number(s.accesses.ops) : 0; + if (ops === 0) { + print(" ⚠️ UNUSED: " + s.name + " — 0 ops since server start"); + totalFindings++; + } + }); + } catch(e) {} + + // Redundant (prefix) indexes + var keyStrings = indexes.map(function(idx) { + return { name: idx.name, keys: Object.keys(idx.key), keyStr: JSON.stringify(idx.key) }; + }); + for (var i = 0; i < keyStrings.length; i++) { + for (var j = i+1; j < keyStrings.length; j++) { + var a = keyStrings[i], b = keyStrings[j]; + if (a.name === "_id_" || b.name === "_id_") continue; + if (b.keys.length > a.keys.length) { + var isPrefix = a.keys.every(function(k,idx) { return k === b.keys[idx]; }); + if (isPrefix) { + print(" ⚠️ REDUNDANT: " + a.name + " is prefix of " + b.name); + totalFindings++; + } + } + } + } + print(""); +}); +if (totalFindings === 0) print(" ✅ No index health issues found"); +else print(" Total findings: " + totalFindings); +' "$CURRENT_DB" +echo "" + +# ── CHECK 3: COLLSCAN Audit ───────────────────────────────────────── +echo "══ CHECK 3: Collection Scan Audit (auto-discovered) ══════════" +echo "" +run_mongosh ' +var findings = []; + +// Walk a winning plan to find the index that actually drives it. +// Returns "__COLLSCAN__" for a literal collection scan, the index name for an +// IXSCAN, or null. On DocumentDB an unindexed filter resolves to the _id_ index. +function effectiveIndex(node) { + var guard = 0; + while (node && guard++ < 50) { + if (node.stage === "COLLSCAN") return "__COLLSCAN__"; + if (node.stage === "IXSCAN") return node.indexName || "?"; + node = node.inputStage || (node.inputStages ? node.inputStages[0] : null); + } + return null; +} + +function testQuery(coll, field, label, filter, sort, indexedFields) { + var cmd = {find: coll, filter: filter, limit: 1}; + if (sort) cmd.sort = sort; + try { + var plan = db.runCommand({explain: cmd, verbosity: "executionStats"}); + var wp = (plan.queryPlanner || {}).winningPlan || {}; + var es = plan.executionStats || {}; + // DocumentDB (Postgres-backed) never emits a literal COLLSCAN stage for + // an unindexed filter — it falls back to a full IXSCAN over the _id_ + // primary key. Treat either as a full scan. Only report it as a MISSING + // INDEX when no index actually covers the filtered field (a small + // collection may resolve to _id_ even when an index exists, because the + // cost optimizer prefers the PK scan — that is not a missing index). + var idx = effectiveIndex(wp); + var fullScan = (idx === "__COLLSCAN__" || idx === "_id_"); + if (fullScan && !(indexedFields && indexedFields[field])) { + var docsEx = Number(es.totalDocsExamined || 0); + print(" ⚠️ COLLSCAN: " + coll + " — " + label + " (" + docsEx + " docs scanned)"); + findings.push({c: coll, q: label, docs: docsEx}); + } + } catch(e) {} +} + +function testAgg(coll, label, pipeline) { + try { + var plan = db[coll].aggregate(pipeline).explain("executionStats"); + var cursor = (plan.stages && plan.stages[0]) ? plan.stages[0]["$cursor"] : null; + if (cursor && (cursor.queryPlanner||{}).winningPlan && (cursor.queryPlanner.winningPlan).stage === "COLLSCAN") { + var docsEx = Number((cursor.executionStats||{}).totalDocsExamined || 0); + print(" ⚠️ COLLSCAN: " + coll + " — " + label + " (" + docsEx + " docs scanned)"); + findings.push({c: coll, q: label, docs: docsEx}); + } + } catch(e) {} +} + +// Auto-discover collections and test common patterns +var colls = db.getCollectionNames().sort(); +colls.forEach(function(c) { + var count = db[c].estimatedDocumentCount(); + if (count < 10) return; // skip tiny collections + print(" Testing " + c + " (" + count + " docs)..."); + + // Sample a document to discover fields + var sample = db[c].findOne(); + if (!sample) return; + var fields = Object.keys(sample).filter(function(k) { return k !== "_id"; }); + + // Build the set of fields that lead an existing index (first key). A query + // on such a field has a usable index even if the planner skips it on small + // data, so it is NOT a missing index. + var indexedFields = {}; + db[c].getIndexes().forEach(function(ix) { + var keys = Object.keys(ix.key || {}); + if (keys.length) indexedFields[keys[0]] = true; + }); + + // Test equality/range filter on each top-level string/number field + fields.forEach(function(field) { + var val = sample[field]; + if (typeof val === "string" && val.length < 100) { + testQuery(c, field, "find {" + field + ":\"...\"}", JSON.parse("{\"" + field + "\":\"" + val + "\"}"), null, indexedFields); + } else if (typeof val === "number") { + testQuery(c, field, "find {" + field + ":{$gt:...}}", JSON.parse("{\"" + field + "\":{\"$gt\":" + (val/2) + "}}"), null, indexedFields); + } else if (typeof val === "boolean") { + testQuery(c, field, "find {" + field + ":" + val + "}", JSON.parse("{\"" + field + "\":" + val + "}"), null, indexedFields); + } + }); + // NOTE: a full-collection $group aggregation always scans every document by + // design — that is not a missing-index signal, so it is intentionally not + // flagged here. See CHECK 4 for aggregation timing. +}); + +print(""); +if (findings.length === 0) print(" ✅ No collection scans detected"); +else { + print(" COLLSCAN total: " + findings.length + " query patterns need indexes"); +} +' "$CURRENT_DB" +echo "" + +# ── CHECK 4: Query Timing ─────────────────────────────────────────── +echo "══ CHECK 4: Query Performance Profiling ══════════════════════=" +echo "" +run_mongosh ' +var results = []; + +function timeQuery(coll, label, fn) { + var start = Date.now(); + var count = 0; + try { count = fn(); } catch(e) { count = -1; } + var ms = Date.now() - start; + var flag = ms > 200 ? " ⚠️ SLOW" : ms > 50 ? " ⚡" : ""; + print(" " + ms + "ms\t" + count + " results\t" + label + flag); + results.push({ms: ms, label: label, coll: coll}); +} + +var colls = db.getCollectionNames().sort(); +colls.forEach(function(c) { + var count = db[c].estimatedDocumentCount(); + if (count < 100) return; + print(" ── " + c + " (" + count + " docs) ──"); + + // Sample to get realistic filter values + var sample = db[c].findOne(); + if (!sample) return; + + // Test count (full scan baseline) + timeQuery(c, "countDocuments()", function() { return db[c].countDocuments(); }); + + // Test filtered find on first string field + var fields = Object.keys(sample).filter(function(k){return k!=="_id";}); + var strField = fields.find(function(f) { return typeof sample[f] === "string" && sample[f].length < 50; }); + if (strField) { + var val = sample[strField]; + timeQuery(c, "find({" + strField + ":\"" + val.substring(0,20) + "...\"})", function() { + return db[c].find(JSON.parse("{\"" + strField + "\":\"" + val + "\"}")).count(); + }); + } + + // Test aggregation + if (strField) { + timeQuery(c, "aggregate $group by " + strField, function() { + return db[c].aggregate([{$group:{_id:"$"+strField, n:{$sum:1}}}]).toArray().length; + }); + } + print(""); +}); + +var slow = results.filter(function(r){return r.ms>200;}).length; +var moderate = results.filter(function(r){return r.ms>50&&r.ms<=200;}).length; +var fast = results.length - slow - moderate; +print(" Summary: " + slow + " slow (>200ms), " + moderate + " moderate (50-200ms), " + fast + " fast (<50ms)"); +' "$CURRENT_DB" +echo "" + +done # end per-database loop + +# ══════════════════════════════════════════════════════════════════════ +# LAYER 2: PostgreSQL Engine Layer +# ══════════════════════════════════════════════════════════════════════ +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " LAYER 2: PostgreSQL Engine Diagnostics" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# ── CHECK 5: Collection-to-PG Table Mapping ────────────────────────── +echo "══ CHECK 5: Collection ↔ PG Table Mapping ════════════════════" +echo "" +run_psql_pretty " +SELECT c.collection_id AS id, c.database_name AS db, c.collection_name AS collection, + 'documents_' || c.collection_id AS pg_table +FROM documentdb_api_catalog.collections c +ORDER BY c.database_name, c.collection_name; +" +echo "" + +# ── CHECK 6: PG Table I/O — Sequential vs Index Scans ─────────────── +echo "══ CHECK 6: PG Table I/O (Sequential vs Index Scans) ═════════" +echo "" +echo " Sequential vs index scan counts per table (measured since stats reset):" +echo "" +run_psql_pretty " +SELECT s.relname AS pg_table, + c.database_name || '.' || c.collection_name AS collection, + s.seq_scan, + s.seq_tup_read AS seq_rows_read, + s.idx_scan, + s.idx_tup_fetch AS idx_rows_fetched, + s.n_live_tup AS live_rows, + CASE WHEN s.seq_scan + s.idx_scan > 0 + THEN round(100.0 * s.idx_scan / (s.seq_scan + s.idx_scan), 1) + ELSE 0 END AS idx_scan_pct +FROM pg_stat_user_tables s +LEFT JOIN documentdb_api_catalog.collections c + ON s.relname = 'documents_' || c.collection_id +WHERE s.schemaname = 'documentdb_data' + AND s.relname LIKE 'documents_%' + AND s.seq_scan + s.idx_scan > 0 +ORDER BY s.seq_tup_read DESC +LIMIT 20; +" +echo "" +echo " (idx_scan_pct = measured share of scans served by an index. Low values can" +echo " be normal for small or append-only tables; cross-check with CHECK 3.)" +echo "" + +# ── CHECK 7: PG Buffer Cache Hit Rates ────────────────────────────── +echo "══ CHECK 7: Buffer Cache Hit Rates (per collection) ══════════" +echo "" +run_psql_pretty " +SELECT s.relname AS pg_table, + c.database_name || '.' || c.collection_name AS collection, + s.heap_blks_read AS heap_disk_reads, + s.heap_blks_hit AS heap_cache_hits, + CASE WHEN s.heap_blks_read + s.heap_blks_hit > 0 + THEN round(100.0 * s.heap_blks_hit / (s.heap_blks_read + s.heap_blks_hit), 2) + ELSE 100 END AS heap_hit_pct, + s.idx_blks_read AS idx_disk_reads, + s.idx_blks_hit AS idx_cache_hits, + CASE WHEN s.idx_blks_read + s.idx_blks_hit > 0 + THEN round(100.0 * s.idx_blks_hit / (s.idx_blks_read + s.idx_blks_hit), 2) + ELSE 100 END AS idx_hit_pct +FROM pg_statio_user_tables s +LEFT JOIN documentdb_api_catalog.collections c + ON s.relname = 'documents_' || c.collection_id +WHERE s.schemaname = 'documentdb_data' + AND s.relname LIKE 'documents_%' + AND (s.heap_blks_read + s.heap_blks_hit) > 0 +ORDER BY s.heap_blks_read DESC +LIMIT 20; +" +echo "" +echo " (hit_pct = measured share of block reads served from cache since stats reset.)" +echo "" + +# ── CHECK 8: PG Index Efficiency ──────────────────────────────────── +echo "══ CHECK 8: PG Index Efficiency ══════════════════════════════=" +echo "" +echo " PG indexes with zero scans (potentially unused at engine level):" +echo "" +run_psql_pretty " +SELECT s.relname AS pg_table, + s.indexrelname AS pg_index, + s.idx_scan AS scans, + pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size +FROM pg_stat_user_indexes s +WHERE s.schemaname = 'documentdb_data' + AND s.idx_scan = 0 + AND s.relname LIKE 'documents_%' +ORDER BY pg_relation_size(s.indexrelid) DESC +LIMIT 20; +" +echo "" + +echo " PG table + index sizes:" +echo "" +run_psql_pretty " +SELECT s.relname AS pg_table, + c.database_name || '.' || c.collection_name AS collection, + pg_size_pretty(pg_relation_size(cl.oid)) AS data_size, + pg_size_pretty(pg_indexes_size(cl.oid)) AS index_size, + pg_size_pretty(pg_total_relation_size(cl.oid)) AS total_size, + CASE WHEN pg_relation_size(cl.oid) > 0 + THEN round(100.0 * pg_indexes_size(cl.oid) / pg_relation_size(cl.oid), 1) + ELSE 0 END AS idx_data_ratio_pct +FROM pg_class cl +JOIN pg_namespace n ON cl.relnamespace = n.oid +JOIN pg_stat_user_tables s ON s.relid = cl.oid +LEFT JOIN documentdb_api_catalog.collections c + ON s.relname = 'documents_' || c.collection_id +WHERE n.nspname = 'documentdb_data' + AND cl.relkind = 'r' + AND s.relname LIKE 'documents_%' +ORDER BY pg_total_relation_size(cl.oid) DESC +LIMIT 20; +" +echo "" + +# ── CHECK 9: PG Connections & Locks ───────────────────────────────── +echo "══ CHECK 9: Connections & Lock Analysis ══════════════════════=" +echo "" +echo " Active connections:" +run_psql_pretty " +SELECT state, count(*) AS count +FROM pg_stat_activity +GROUP BY state +ORDER BY count DESC; +" +echo "" + +echo " Lock summary:" +run_psql_pretty " +SELECT locktype, mode, count(*) AS count +FROM pg_locks +GROUP BY locktype, mode +ORDER BY count DESC +LIMIT 10; +" +echo "" + +# Check for blocked queries +BLOCKED=$(run_psql "SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock';" | grep -E '^[0-9]+$' | tr -d '[:space:]') +BLOCKED="${BLOCKED:-0}" +if [[ "$BLOCKED" -gt 0 ]] 2>/dev/null; then + echo " ⚠️ $BLOCKED queries currently waiting on locks!" + run_psql_pretty " + SELECT pid, state, wait_event_type, wait_event, + now() - query_start AS duration, + left(query, 80) AS query + FROM pg_stat_activity + WHERE wait_event_type = 'Lock' + LIMIT 5; + " +else + echo " ✅ No blocked queries" +fi +echo "" + +# ── CHECK 10: PG Configuration Review ─────────────────────────────── +echo "══ CHECK 10: PostgreSQL Configuration Review ═════════════════" +echo "" +echo " Current settings (FACTUAL — no generic tuning is prescribed; DocumentDB" +echo " ships tuned defaults and rules-of-thumb are not workload-aware). The" +echo " 'source' and 'default_value' columns show whether a value was changed." +echo "" +run_psql_pretty " +SELECT name, setting, COALESCE(unit, '') AS unit, source, boot_val AS default_value +FROM pg_settings +WHERE name IN ( + 'shared_buffers', 'work_mem', 'maintenance_work_mem', 'effective_cache_size', + 'max_connections', 'max_wal_size', 'checkpoint_timeout', + 'random_page_cost', 'seq_page_cost', 'max_worker_processes', + 'max_parallel_workers_per_gather', 'wal_level' +) +ORDER BY name; +" +echo "" + +# Check DocumentDB-specific settings +echo " DocumentDB-specific settings:" +run_psql_pretty " +SELECT name, setting +FROM pg_settings +WHERE name LIKE 'documentdb.%' +ORDER BY name; +" +echo "" + +echo "╔══════════════════════════════════════════════════════════════════╗" +echo "║ Performance Advisor Complete ║" +echo "║ Layer 1: MongoDB API (collections, indexes, queries) ║" +echo "║ Layer 2: PostgreSQL Engine (I/O, cache, locks, config) ║" +echo "╚══════════════════════════════════════════════════════════════════╝" diff --git a/scripts/toast-split-advisor-render.py b/scripts/toast-split-advisor-render.py new file mode 100644 index 0000000..95105c1 --- /dev/null +++ b/scripts/toast-split-advisor-render.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""toast-split-advisor-render.py — build the TOAST-split advisor report. + +Consumes the per-collection buffer that toast-split-advisor.sh assembles (one +tab-separated line per collection, either a FIELDJSON payload or "CLEAN"), passed +via environment variables, and prints either the human report or a compact JSON +object. Kept as a standalone module (not an inline heredoc) so it can be read, +linted, and tested on its own. + +Env in: REPORT_DATA, JSON_MODE (0/1), DB_NAME, FIELD_MIN, INLINE_THR, + TOAST_RATIO, MIN_KB. +""" + +import os +import json + +data = os.environ.get("REPORT_DATA", "") +json_mode = os.environ.get("JSON_MODE", "0") == "1" +db_name = os.environ.get("DB_NAME", "") +field_min = int(os.environ.get("FIELD_MIN", "1024")) +inline_thr = int(os.environ.get("INLINE_THR", "2000")) +toast_ratio= float(os.environ.get("TOAST_RATIO", "0.5")) +min_kb = os.environ.get("MIN_KB", "256") + +findings = [] # flagged collections with split guidance +clean = [] # collections with no TOAST bloat + +for line in data.splitlines(): + if not line.strip(): + continue + parts = line.split("\t", 4) + if len(parts) < 5: + continue + coll, heap, toast, total, tail = parts + heap, toast, total = int(heap), int(toast), int(total) + ratio = (toast / (heap + toast)) if (heap + toast) > 0 else 0.0 + + if tail == "CLEAN": + clean.append({"collection": coll, "heap_bytes": heap, "toast_bytes": toast, + "toast_ratio": round(ratio, 4)}) + continue + + fj = json.loads(tail) + avg = fj.get("avg_obj_size", 0) or 0 + fields = fj.get("fields", []) + + # Split candidates: the largest fields that individually exceed the threshold. + # These are what get pushed to TOAST and should move to a side collection. + candidates = [f for f in fields if f.get("b", 0) >= field_min] + # If nothing crosses the threshold, still surface the single largest field so + # the operator sees where the weight is (bloat may be spread across fields). + largest_only = (not candidates) and fields + show = candidates if candidates else (fields[:1] if fields else []) + + moved_bytes = sum(f["b"] for f in candidates) + projected_hot = max(avg - moved_bytes, 0) + stays_inline = projected_hot < inline_thr + + def pct(b): + return round(100.0 * b / avg, 1) if avg > 0 else 0.0 + + findings.append({ + "collection": coll, + "heap_bytes": heap, + "toast_bytes": toast, + "total_bytes": total, + "toast_ratio": round(ratio, 4), + "avg_obj_size": avg, + "sampled_docs": fj.get("sampled", 0), + "split_candidates": [ + {"field": f["f"], "avg_bytes": f["b"], "pct_of_doc": pct(f["b"])} + for f in show + ], + "candidate_threshold_bytes": field_min, + "no_field_over_threshold": bool(largest_only), + "projected_hot_avg_bytes": projected_hot, + "projected_stays_inline": stays_inline, + "recommended_side_collection": coll + "_ext", + "side_collection_key": "_id", + "note": "ANALYSIS ONLY — no data moved. Apply the split yourself.", + }) + +if json_mode: + print(json.dumps({ + "db": db_name, + "flagged": len(findings), + "findings": findings, + "clean": clean, + "analysis_only": True, + })) + raise SystemExit(0) + +# ── Human report ──────────────────────────────────────────────────────────── +bar = "═" * 70 +print(bar) +print(" DocumentDB TOAST Split Advisor (ANALYSIS ONLY — does not modify data)") +print(f" Database: {db_name} flag TOAST ratio > {toast_ratio}, min {min_kb}KB, " + f"candidate field >= {field_min}B") +print(bar) +print() + +for f in findings: + hk, tk = f["heap_bytes"] // 1024, f["toast_bytes"] // 1024 + print(f" ⚠️ {f['collection']}") + print(f" heap={hk}KB TOAST={tk}KB (TOAST ratio {f['toast_ratio']}) " + f"avgObjSize={f['avg_obj_size']}B [sampled {f['sampled_docs']} docs]") + if f["no_field_over_threshold"]: + big = f["split_candidates"][0] if f["split_candidates"] else None + if big: + print(f" no single field >= {f['candidate_threshold_bytes']}B; largest is " + f"'{big['field']}' ({big['avg_bytes']}B, {big['pct_of_doc']}% of doc).") + print(" Bloat is spread across fields — review the schema rather than a" + " single split.") + else: + print(f" split candidate field(s) to MOVE into a side collection:") + for c in f["split_candidates"]: + print(f" • {c['field']:<24} ~{c['avg_bytes']}B/doc " + f"({c['pct_of_doc']}% of document)") + inline = ("stays INLINE (TOAST≈0) ✅" if f["projected_stays_inline"] + else "still large — consider moving more fields ⚠️") + print(f" after moving these, hot doc ≈ {f['projected_hot_avg_bytes']}B → {inline}") + print(f" → create '{f['recommended_side_collection']}' keyed by " + f"'{f['side_collection_key']}' holding those fields; keep " + f"'{f['collection']}' scalar-only.") + print() + +for c in clean: + print(f" ✅ {c['collection']} heap={c['heap_bytes']//1024}KB " + f"TOAST={c['toast_bytes']//1024}KB (ratio {c['toast_ratio']}) — no bloat") + +print() +print("─" * 70) +if not findings: + print(" ✅ No large-document/TOAST bloat detected — nothing to split.") +else: + print(f" Flagged {len(findings)} collection(s). This tool ONLY reports guidance.") + print() + print(" Applying the split safely (DO NOT run a naive bulk update on a large") + print(" collection — it bursts WAL, holds locks, and leaves dead-tuple bloat):") + print(" 1. Copy the candidate field(s) into the side collection, keyed by _id,") + print(" in _id-range BATCHES (e.g. 10k docs) — copy BEFORE removing.") + print(" 2. Verify per batch that the side collection has the row, THEN $unset") + print(" the field(s) from the hot collection for that batch.") + print(" 3. After migration, VACUUM (FULL, ANALYZE) the hot table to reclaim") + print(" space (takes an exclusive lock + ~2x disk — schedule a window).") + print(" 4. Update the application to read the field(s) from the side") + print(" collection on demand (extra _id lookup) — split only wins when the") + print(" big field is read far less often than the scalars are scanned.") diff --git a/scripts/toast-split-advisor.sh b/scripts/toast-split-advisor.sh new file mode 100755 index 0000000..46fc3bd --- /dev/null +++ b/scripts/toast-split-advisor.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# toast-split-advisor.sh — DocumentDB large-document / TOAST *analysis* tool. +# +# ANALYSIS ONLY. This script REPORTS where a collection would benefit from +# splitting a large field into a side collection. It NEVER moves data, drops a +# field, or runs VACUUM — applying a split is an operator decision (and, at +# scale, must be done in throttled batches; see the guidance this tool prints). +# +# Why this exists (DocumentDB-specific): +# Each document is a single BSON column in PostgreSQL. A large, low- +# compressibility field (long text, blobs) pushes the value out-of-line into a +# TOAST table. Because the whole document is ONE column, reading ANY scalar +# field detoasts the ENTIRE document — so co-locating big text with fields your +# queries scan/aggregate imposes a per-access "detoast tax" that never shows up +# as a missing index. Projection does NOT help (the row is detoasted server- +# side before projection). The fix is schema separation: move the large field +# into a side collection keyed by _id, so the hot collection stays small/inline. +# +# What it measures (no guessing): +# - per-collection heap vs TOAST bytes (PostgreSQL, cross-layer) -> is there a tax? +# - MongoDB avgObjSize + per-top-level-field average size (sampled) -> WHICH field +# - projected hot-document size AFTER removing the split candidates -> will it +# drop below PostgreSQL's ~2 KB TOAST threshold and stay inline? +# +# Usage: +# bash scripts/toast-split-advisor.sh --db [--collection ] [--json] +# [--container NAME] [--port 10260] [--pg-port 9712] +# [--toast-ratio 0.5] flag when TOAST/(heap+TOAST) exceeds this +# [--min-total-kb 256] ignore collections smaller than this +# [--field-min-bytes 1024] a field must average >= this to be a split candidate +# [--sample 100] documents sampled per collection for field sizing +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +CONTAINER_NAME="${CONTAINER_NAME:-documentdb-local}" +PORT="${PORT:-10260}" +PG_PORT="${PG_PORT:-9712}" +PG_USER="${PG_USER:-documentdb}" +PG_DB="${PG_DB:-postgres}" +DB_USER_="${DB_USER:-docdbadmin}" +PASSWORD="${DB_PASSWORD:-}" +DB="" +ONE_COLL="" +JSON=0 +TOAST_RATIO="0.5" +MIN_TOTAL_KB="256" +FIELD_MIN_BYTES="1024" +SAMPLE="100" +TOAST_INLINE_THRESHOLD="2000" # PostgreSQL TOAST_TUPLE_THRESHOLD ~= 2 KB + +while [[ $# -gt 0 ]]; do + case "$1" in + --db) DB="$2"; shift 2;; + --collection) ONE_COLL="$2"; shift 2;; + --container) CONTAINER_NAME="$2"; shift 2;; + --port) PORT="$2"; shift 2;; + --pg-port) PG_PORT="$2"; shift 2;; + --password) PASSWORD="$2"; shift 2;; + --toast-ratio) TOAST_RATIO="$2"; shift 2;; + --min-total-kb) MIN_TOTAL_KB="$2"; shift 2;; + --field-min-bytes)FIELD_MIN_BYTES="$2"; shift 2;; + --sample) SAMPLE="$2"; shift 2;; + --json) JSON=1; shift;; + -h|--help) sed -n '2,44p' "$0" | sed 's/^# \{0,1\}//'; exit 0;; + *) echo "Unknown option: $1" >&2; exit 2;; + esac +done +[[ -z "$DB" ]] && { echo "Error: --db is required" >&2; exit 1; } +[[ -z "$PASSWORD" ]] && { echo "Error: no password. Set DB_PASSWORD or pass --password (local demo: export DB_PASSWORD=Test1234)." >&2; exit 1; } + +run_mongosh() { + docker exec "$CONTAINER_NAME" mongosh "localhost:${PORT}/${DB}" \ + -u "$DB_USER_" -p "$PASSWORD" --authenticationMechanism SCRAM-SHA-256 \ + --tls --tlsAllowInvalidCertificates --quiet --eval "$1" 2>/dev/null +} +run_psql() { + docker exec "$CONTAINER_NAME" psql -h localhost -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -t --no-align -F $'\t' -c "$1" 2>/dev/null | grep -vE '^(SET|)$' +} + +# ── Per-collection heap/TOAST from PostgreSQL (measured facts) ────────────── +# tab-separated: collectionheap_bytestoast_bytestotal_bytes +COLL_FILTER="" +[[ -n "$ONE_COLL" ]] && COLL_FILTER="AND c.collection_name = '${ONE_COLL}'" +SIZES=$(run_psql " +SELECT c.collection_name, + pg_relation_size(t.oid), + COALESCE(pg_relation_size(NULLIF(t.reltoastrelid,0)),0), + pg_total_relation_size(t.oid) +FROM documentdb_api_catalog.collections c +JOIN pg_class t ON t.oid = ('documentdb_data.documents_' || c.collection_id)::regclass +WHERE c.database_name = '${DB}' ${COLL_FILTER} +ORDER BY pg_total_relation_size(t.oid) DESC; +") + +if [[ -z "$SIZES" ]]; then + echo "No collections found for database '${DB}' (is it seeded? is the container up?)" >&2 + exit 1 +fi + +# ── Sample per-field average size for a collection (mongosh) ──────────────── +# Emits one line: FIELDJSON {"avg_obj_size":N,"sampled":M,"fields":[{"f":..,"b":..},...]} +# NOTE: JSON.stringify length approximates the serialized field size; it is used +# for RANKING split candidates, not as an exact byte count. +field_json() { + local coll="$1" + run_mongosh ' + var s = db.'"$coll"'.stats(); + var avg = s.avgObjSize || 0; + var docs = db.'"$coll"'.aggregate([{$sample:{size:'"$SAMPLE"'}}]).toArray(); + var acc = {}, n = docs.length || 1; + docs.forEach(function(doc){ + Object.keys(doc).forEach(function(k){ + if (k === "_id") return; + var len = 0; try { len = JSON.stringify(doc[k]).length; } catch(e) { len = 0; } + acc[k] = (acc[k]||0) + len; + }); + }); + var fields = Object.keys(acc).map(function(k){ return {f:k, b:Math.round(acc[k]/n)}; }); + fields.sort(function(a,b){ return b.b - a.b; }); + print("FIELDJSON " + JSON.stringify({avg_obj_size: avg, sampled: n, fields: fields})); + ' | sed -n 's/^FIELDJSON //p' +} + +# ── Collect one record per collection into a buffer for a single Python pass ─ +# line format: collheaptoasttotal(FIELDJSON | CLEAN) +BUFFER="" +while IFS=$'\t' read -r coll heap toast total; do + [[ -z "$coll" ]] && continue + # strip any stray non-digits (e.g. trailing CR) so arithmetic is reliable + heap="${heap//[!0-9]/}"; toast="${toast//[!0-9]/}"; total="${total//[!0-9]/}" + heap="${heap:-0}"; toast="${toast:-0}"; total="${total:-0}" + (( total < MIN_TOTAL_KB * 1024 )) && continue + ratio=$(awk -v t="$toast" -v h="$heap" 'BEGIN{ d=t+h; if(d<=0){print 0}else{printf "%.4f", t/d} }') + over=$(awk -v r="$ratio" -v thr="$TOAST_RATIO" 'BEGIN{ print (r>thr)?1:0 }') + if [[ "$over" == "1" ]]; then + fj=$(field_json "$coll") + [[ -z "$fj" ]] && fj='{"avg_obj_size":0,"sampled":0,"fields":[]}' + BUFFER+="${coll}"$'\t'"${heap}"$'\t'"${toast}"$'\t'"${total}"$'\t'"${fj}"$'\n' + else + BUFFER+="${coll}"$'\t'"${heap}"$'\t'"${toast}"$'\t'"${total}"$'\t'"CLEAN"$'\n' + fi +done <<< "$SIZES" + +if [[ "${DEBUG:-0}" == "1" ]]; then + { echo "── DEBUG: SIZES rows ──"; printf '%s\n' "$SIZES" | cat -A + echo "── DEBUG: BUFFER ──"; printf '%s' "$BUFFER" | cat -A; } >&2 +fi + +# ── Build the report in Python (robust JSON assembly + float math) ────────── +REPORT_DATA="$BUFFER" JSON_MODE="$JSON" DB_NAME="$DB" \ +FIELD_MIN="$FIELD_MIN_BYTES" INLINE_THR="$TOAST_INLINE_THRESHOLD" \ +TOAST_RATIO="$TOAST_RATIO" MIN_KB="$MIN_TOTAL_KB" \ +python3 "$SCRIPT_DIR/toast-split-advisor-render.py" diff --git a/skills/data-modeling/SKILL.md b/skills/data-modeling/SKILL.md index 82c8584..bdf0c46 100644 --- a/skills/data-modeling/SKILL.md +++ b/skills/data-modeling/SKILL.md @@ -16,6 +16,17 @@ Each rule follows the same shape — why it matters → incorrect example → co - [model-16mb-limit](model-16mb-limit.md) — Stay well under the 16 MB BSON document limit; plan for steady-state growth. - [model-denormalize-reads](model-denormalize-reads.md) — Denormalize for read-heavy workloads; pre-compute aggregates to avoid `$lookup`. - [model-schema-versioning](model-schema-versioning.md) — Add a `schemaVersion` field and migrate documents lazily. +- [model-large-field-split](model-large-field-split.md) — Split a large, low-compressibility field into a side collection keyed by `_id` to avoid the PostgreSQL TOAST detoast tax on scans. Companion tool: [`scripts/toast-split-advisor.sh`](../../scripts/toast-split-advisor.sh). + +## Companion tool (analysis only) + +[`scripts/toast-split-advisor.sh`](../../scripts/toast-split-advisor.sh) measures +heap vs TOAST bytes on a live local container and reports **where** a large field +should be split out — it never moves data: + +```bash +bash scripts/toast-split-advisor.sh --db [--json] +``` ## Decision framework diff --git a/skills/data-modeling/model-large-field-split.md b/skills/data-modeling/model-large-field-split.md new file mode 100644 index 0000000..239b542 --- /dev/null +++ b/skills/data-modeling/model-large-field-split.md @@ -0,0 +1,74 @@ +# model-large-field-split + +**Category:** Data Modeling · **Priority:** HIGH + +## Why it matters + +In DocumentDB every document is stored as a **single BSON column** in PostgreSQL. +When a document carries a large, low-compressibility field (long free text, logs, +blobs), PostgreSQL pushes the value **out-of-line into a TOAST table** once the row +exceeds ~2 KB. Because the whole document is one column, reading **any** scalar +field detoasts the **entire** document — so a scan or aggregation that only needs +`status` or `amount` still pays to read all the big text. This "detoast tax" is +invisible to `explain` plan shape and **cannot be removed with an index** (even a +covering index still `FETCH`es and detoasts the row) or with a projection +(projection is applied *after* the server detoasts the row). + +The fix is a **vertical split**: move the large field into a side collection keyed +by `_id`, so the hot collection stays small and inline, and the big field is read +only on demand. + +## Incorrect + +Large varied text co-located with the fields BI/list/aggregate queries scan: + +```javascript +// opportunities — scanned constantly for pipeline/rollup aggregations +{ + _id: 4211, + est_value: 82000, state: "open", territory_id: 7, // <- what queries read + narrative: "…3,500 chars of notes…", // <- big, rarely read + activity_log: "…2,500 chars…" // <- big, rarely read +} +// Every {$group by territory, $sum est_value} detoasts ~6 KB per document. +``` + +## Correct + +Keep the hot collection scalar-only; move the big text to a side collection: + +```javascript +// opportunities (hot, small, stays inline) +{ _id: 4211, est_value: 82000, state: "open", territory_id: 7 } + +// opportunities_ext (cold, fetched by _id only when the detail is opened) +{ _id: 4211, narrative: "…", activity_log: "…" } +``` + +Scans of `opportunities` no longer touch TOAST; the big text is read only via an +explicit `_id` lookup when a record's detail is actually needed. + +**When NOT to split:** if the big field is read *together with* the scalars on +almost every access (e.g. a detail-record workload), co-location is correct — the +split just adds a second lookup. Split only when the cold field is read far less +often than the hot scalars are scanned. + +## Companion tool + +[`scripts/toast-split-advisor.sh`](../../scripts/toast-split-advisor.sh) **measures** +this condition on a live local container and reports **where** to split — it does +**not** move data: + +```bash +bash scripts/toast-split-advisor.sh --db [--json] +``` + +It reads real heap vs TOAST bytes from PostgreSQL, ranks the per-field split +candidates, projects whether the hot document will drop below the ~2 KB TOAST +threshold after the split, and prints safe (batched, copy-before-delete, then +`VACUUM`) migration guidance. Applying the split is an operator decision. + +## References + +- Related: [model-embed-vs-reference](model-embed-vs-reference.md) (reference unbounded / independently-accessed data), [model-16mb-limit](model-16mb-limit.md). +- `storage/` skill for the PostgreSQL storage layer; `query-optimization/` for verifying scan cost with `explain("executionStats")`. diff --git a/skills/query-optimizer/SKILL.md b/skills/query-optimizer/SKILL.md index e1e5472..10000f6 100644 --- a/skills/query-optimizer/SKILL.md +++ b/skills/query-optimizer/SKILL.md @@ -30,10 +30,10 @@ with optimization, slow queries, or indexing. If the user is asking about a particular query: -1. Use `list_indexes` to get existing indexes on the collection -2. Use `optimize_find_query` (for find queries) or `explain_aggregate_query` - (for aggregation pipelines) to get explain output with execution stats -3. Use `find_documents` with limit=1 to fetch a sample document to understand the +1. Use `list_indexes` (MCP) or `db..getIndexes()` (mongosh) to get existing indexes on the collection +2. Use `explain_operation` (MCP) or `.explain("executionStats")` (mongosh) + to get explain output with execution stats +3. Use `find_documents` (MCP) or `db..findOne()` (mongosh) to fetch a sample document to understand the schema Then make an optimization suggestion based on collected information and best @@ -45,29 +45,57 @@ the query if possible. If the user wants to examine slow queries or is looking for general performance suggestions (not regarding any particular query): -1. Use `list_databases` and `get_db_info` to understand the database structure -2. Use `collection_stats` to identify large collections -3. Use `index_stats` to check existing index usage -4. Use `current_ops` to see currently running operations +1. Use `list_databases` (MCP) or `show dbs` (mongosh) to understand the database structure +2. Use `get_statistics` with scope "collection" (MCP) or `db.collection.stats()` (mongosh) to identify large collections +3. Use `get_statistics` with scope "index" (MCP) or `db.collection.aggregate([{$indexStats:{}}])` (mongosh) to check existing index usage +4. Use `current_ops` (MCP) or `db.currentOp()` (mongosh) to see currently running operations 5. Suggest reviewing the most-used collections for missing indexes ## MCP Tools Available -**Database tools** (for query optimization): +When DocumentDB MCP server is connected, these tools are available: | Tool name (exact) | Description | | :--- | :--- | | `list_indexes` | List all indexes on a collection — check if the query can use an existing index | -| `optimize_find_query` | Run explain with executionStats for a find query, returning metrics, plan shape, index stats, and collection stats in one call | -| `explain_aggregate_query` | Run explain with executionStats for an aggregation pipeline | -| `explain_find_query` | Run explain for a find query (lower-level than optimize_find_query) | -| `explain_count_query` | Run explain for a count query | +| `explain_operation` | Run explain with executionStats for any operation (find, aggregate, count) | | `find_documents` | Fetch sample documents to understand schema — use with limit=1 | -| `collection_stats` | Get collection statistics (size, document count, storage) | -| `index_stats` | Get index usage statistics ($indexStats) | +| `get_statistics` | Get collection or index statistics (use scope: "collection" or "index") | | `current_ops` | Get currently running database operations | | `create_index` | Create a new index (only after user approval) | | `drop_index` | Drop an existing index (only after user approval) | +| `sample_documents` | Sample random documents from a collection | + +## Local mongosh Commands (No MCP Required) + +All diagnostic operations can be performed directly via mongosh. Use these when +MCP is not available or for quick ad-hoc diagnostics: + +```javascript +// Explain a find query +db.collection.find({filter}).explain("executionStats") + +// Explain an aggregation pipeline +db.collection.aggregate([{$match:...}, {$group:...}]).explain("executionStats") + +// Explain a count +db.runCommand({explain: {count: "collection", query: {filter}}, verbosity: "executionStats"}) + +// Collection statistics +db.collection.stats() + +// List indexes +db.collection.getIndexes() + +// Index usage statistics +db.collection.aggregate([{$indexStats: {}}]) + +// Sample documents +db.collection.aggregate([{$sample: {size: 3}}]) + +// Currently running operations +db.currentOp() +``` ## Load References @@ -81,33 +109,52 @@ Always load: ### Step 1: Gather Information -For a specific query, run these tools (when MCP is connected): +For a specific query, run these tools: +**Via MCP (when connected):** ``` list_indexes({ db_name: "", collection_name: "" }) ``` ``` -optimize_find_query({ +explain_operation({ db_name: "", collection_name: "", - query: , - options: { sort: , projection: , limit: } + operation: { + find: "", + filter: , + sort: , + projection: , + limit: + } }) ``` For aggregation pipelines: ``` -explain_aggregate_query({ +explain_operation({ db_name: "", collection_name: "", - pipeline: + operation: { + aggregate: "", + pipeline: , + cursor: {} + } }) ``` +**Via mongosh (no MCP):** +```javascript +use +db..getIndexes() +db..find().sort().explain("executionStats") +db..aggregate().explain("executionStats") +``` + ### Step 2: Analyze Explain Output -From the `optimize_find_query` / `explain_aggregate_query` response, extract: +From the `explain("executionStats")` response (via MCP `explain_operation` or +direct mongosh), extract: - **metrics**: `totalKeysExamined`, `totalDocsExamined`, `nReturned`, `executionTimeMillis` @@ -155,7 +202,7 @@ Recommended index: `{status: 1, region: 1, date: -1}` After creating the recommended index, re-run the explain to confirm improvement: 1. Create the index (with user approval) -2. Re-run `optimize_find_query` with the same query +2. Re-run `explain_operation` (MCP) or `.explain("executionStats")` (mongosh) with the same query 3. Compare metrics before and after ## Example Workflow @@ -170,14 +217,22 @@ After creating the recommended index, re-run the explain to confirm improvement: - Result shows: `{_id: 1}`, `{status: 1}`, `{date: -1}` 2. **Run explain:** - - Call `optimize_find_query` with query=`{status: 'shipped', region: 'US'}`, - options=`{sort: {date: -1}}` + - Call `explain_operation` with operation=`{find: "orders", filter: {status: "shipped", region: "US"}, sort: {date: -1}}` - Result: Uses `{status: 1}` index, then in-memory SORT, totalKeysExamined: 50000, nReturned: 100 3. **Fetch sample:** - Call `find_documents` with limit=1 to understand the schema +**If MCP is not available**, use mongosh directly: + +```javascript +use store +db.orders.getIndexes() +db.orders.find({status: "shipped", region: "US"}).sort({date: -1}).explain("executionStats") +db.orders.findOne() +``` + 4. **Diagnose:** This query targets 100 docs but scans 50K index entries (poor selectivity: 0.002). In-memory sort adds overhead. The `{status: 1}` index doesn't support both filter fields or sort. diff --git a/skills/query-optimizer/references/core-indexing-principles.md b/skills/query-optimizer/references/core-indexing-principles.md index b83b4ad..e8ed82f 100644 --- a/skills/query-optimizer/references/core-indexing-principles.md +++ b/skills/query-optimizer/references/core-indexing-principles.md @@ -18,7 +18,7 @@ But indexes aren't free: - Too many indexes can make the planner pick a worse plan. **Rule of thumb:** keep the index count per collection well under 20. Drop -unused indexes (check with `$indexStats` / `index_stats`). +unused indexes (check with `$indexStats` aggregation stage or MCP `get_statistics` with scope "index"). ## Supported Index Types @@ -149,8 +149,8 @@ db.orders.find( - Index creation on large collections runs in the **background** on Azure DocumentDB; writes continue during the build. -- Progress can be monitored with `current_ops` (via MCP) or - `db.currentOp({ "command.createIndexes": { $exists: true } })`. +- Progress can be monitored with `current_ops` (MCP) or + `db.currentOp({ "command.createIndexes": { $exists: true } })` (mongosh). - Keep an eye on disk headroom — a large compound index can be tens of GB. ## Special Index Categories diff --git a/testing/CREATE-SCENARIO.md b/testing/CREATE-SCENARIO.md new file mode 100644 index 0000000..fa9e736 --- /dev/null +++ b/testing/CREATE-SCENARIO.md @@ -0,0 +1,81 @@ +# Recipe: Create a New Scenario + +A scenario proves that one agent-kit diagnostic script reliably detects a class +of known problems (and does not raise false positives). Follow these steps. + +## 1. Copy the template + +```bash +cp -r scenarios/_scenario-template scenarios/ +``` + +## 2. Write `fixture.js` — plant known issues deterministically + +The fixture is a mongosh script run against a fresh scenario database. Rules: + +- **Deterministic**: no randomness in the *structure* of what you plant. The + same fixture must yield the same findings every run. +- **Self-contained**: drop and recreate every collection it touches. +- **Plant a clear answer key**: each issue you create must map to an expected + finding. Add a comment naming the rule/category each line triggers. +- If your script differentiates *used* vs *unused* (like the redundancy finder), + generate query traffic on the indexes/paths that should look healthy. + +End with a `FIXTURE_READY ` print and a short summary. + +## 3. Encode the answer key in `expected-findings.yaml` + +Keep it declarative. Prefer **minimum counts** and **membership** assertions over +brittle exact-output matching, so the contract survives small fixture tweaks but +still fails if a whole category is missed. Use `rule_any: [...]` when a label may +vary by threshold (e.g. `UNUSED_VERIFIED` vs `WRITE_TAX`). + +## 4. Wire the fixture in `conftest.py` + +```python +from pathlib import Path +from conftest_base import make_seeded_db_fixture + +seeded_db = make_seeded_db_fixture("test_", Path(__file__).resolve().parent) +``` + +Use a unique database name per scenario so scenarios don't collide. + +## 5. Write `tests/test_*.py` + +Use the shared helpers: + +```python +import kit + +def test_something(seeded_db, expected): + res = kit.run_script("