diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 0000000..95547c8 --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,239 @@ +# .github/workflows/demo.yml — the demo harness gates. +# +# Two jobs with very different costs, on purpose. +# +# demo-render every push and PR, ~10 s, no Postgres, no browser, no Docker. +# Re-renders the committed demos/fixtures/*.ansi and compares +# byte-for-byte against demos/fixtures/expected/*.svg. Because +# the SVG renderer is deterministic this is a genuine gate, not +# a smoke test — but it proves only that the RENDERER still +# works on frozen input. It cannot notice that pg_ash's output +# changed shape underneath it. Only demo-refresh catches that. +# +# demo-refresh nightly, on demand, and whenever demos/** or sql/** changes. +# ~3 min. Seeds a real PostgreSQL, re-captures every scene, and +# records the reel. `make -C demos rot` compares the fresh +# reader contract with fixtures/shape.tsv: a changed capture +# shape means a reader was renamed, a column moved, or the +# installer path shifted. That is the check that would have +# caught the demo pointing at an installer path the release +# stamp had emptied, and the landing page advertising a reader +# 2.0 removed — both of which shipped. +# +# ZERO container-registry traffic. The previous harness required a Docker Hub +# pull, and `auth.docker.io` returns 503 in sandboxed runners, which is the root +# cause of "I recorded it on my laptop". ubuntu-latest already ships a +# PostgreSQL cluster and postgresql-client; we start that. No `services:` block. +# +# Fonts are vendored under demos/fonts and consumed BY PATH (agg --font-dir, +# ansi2svg --font). No fontconfig, no `apt install fonts-*`, no Homebrew cask. +# A missing font is a hard exit 6, never a silent fallback into a serif face. + +name: demo + +on: + push: + pull_request: + schedule: + # 04:20 UTC nightly — after the test matrix, before anyone is awake to be + # surprised by a stale GIF. + - cron: '20 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: demo-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ========================================================================= + # Job A — the fast gate. Runs on everything. + # ========================================================================= + demo-render: + name: renderer (fixtures, no database) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install the tier-1 renderer dependencies + # fonttools + brotli are all `make check` needs. Deliberately NOT + # installing Postgres, tmux, ffmpeg or a browser here: if this job ever + # needs them, the "it only runs on my laptop" problem has come back. + run: python -m pip install --quiet fonttools brotli + + - name: Assert the vendored font is present and is JetBrains Mono + run: | + python - <<'PY' + from fontTools.ttLib import TTFont + import sys + for p in ("demos/fonts/JetBrainsMono-Regular.ttf", + "demos/fonts/JetBrainsMono-Bold.ttf"): + f = TTFont(p, lazy=True) + fam = f["name"].getDebugName(1) or "" + assert "JetBrains Mono" in fam, (p, fam) + # ash.chart() draws with these; a face without them renders tofu. + cmap = f.getBestCmap() + for cp in (0x2588, 0x2593, 0x2591, 0x2592, 0x00B7): + assert cp in cmap, (p, hex(cp)) + print("vendored font OK") + PY + + - name: No reader removed in 2.0 may be referenced under demos/ + # Assembled from fragments so this workflow file does not itself trip + # the grep it is running. + run: | + set -Eeuo pipefail + RE="top_$(printf 'waits')|query_$(printf 'waits')|top_by_$(printf 'type')|timeline_$(printf 'chart')" + if grep -rIEl "$RE" demos/ > /tmp/hits 2>/dev/null; then + echo "demos/ references a reader that 2.0 removed:" >&2 + cat /tmp/hits >&2 + exit 1 + fi + echo "no removed-reader references" + + - name: make check — re-render fixtures and compare byte-for-byte + run: ASH_SVG_ONLY=1 make -C demos check + + # ========================================================================= + # Job B — the real gate. Nightly, on demand, or when the demo/SQL changes. + # ========================================================================= + demo-refresh: + name: capture + reel (real PostgreSQL) + runs-on: ubuntu-latest + timeout-minutes: 25 + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Decide whether this run needs the full pass + # On push/PR, only when demos/** or sql/** actually changed. Schedules + # and manual dispatches always run. + id: gate + run: | + set -Eeuo pipefail + if [ "${{ github.event_name }}" = "schedule" ] \ + || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "run=yes" >> "$GITHUB_OUTPUT"; exit 0 + fi + base="${{ github.event.pull_request.base.sha || github.event.before }}" + if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then + echo "run=yes" >> "$GITHUB_OUTPUT"; exit 0 + fi + # A shallow checkout need not contain a merge base. Fetch the exact + # base tree and use a two-tree diff; any fetch/diff error must fail + # this step, never turn the expensive gate into a false green. + git fetch --no-tags --depth=1 origin "$base" + changed="$(git diff --name-only "$base" HEAD -- demos sql)" + if [ -n "$changed" ]; then echo "run=yes" >> "$GITHUB_OUTPUT" + else echo "run=no" >> "$GITHUB_OUTPUT"; fi + + - name: Start the runner's own PostgreSQL cluster + if: steps.gate.outputs.run == 'yes' + # ubuntu-latest ships PostgreSQL and postgresql-client already. Starting + # the preinstalled cluster costs about two seconds and ZERO registry + # traffic, which is the whole point. + run: | + set -Eeuo pipefail + sudo systemctl start postgresql.service + for _ in $(seq 1 30); do pg_isready -q && break; sleep 1; done + pg_isready + # Ubuntu's local socket uses peer authentication. Give the runner's + # actual OS user a matching PostgreSQL role, then publish that + # connection identity to all later steps. + ci_role="$(id -un)" + sudo -u postgres createuser --superuser "$ci_role" + # pg_stat_statements gives ash.top('query_id') its query text. pg_cron + # is deliberately NOT installed: the harness drives sampling itself, + # which is both the portable path and the honest one for the RDS / + # Cloud SQL / Supabase / Neon users who cannot have pg_cron either. + sudo -u postgres psql -v ON_ERROR_STOP=1 -c \ + "alter system set shared_preload_libraries = 'pg_stat_statements'" + sudo systemctl restart postgresql.service + for _ in $(seq 1 30); do pg_isready -q && break; sleep 1; done + pg_isready + PGHOST=/var/run/postgresql PGUSER="$ci_role" \ + psql -v ON_ERROR_STOP=1 -d postgres -Atqc \ + "select current_user" | grep -Fx "$ci_role" + { + echo "PGHOST=/var/run/postgresql" + echo "PGUSER=$ci_role" + } >> "$GITHUB_ENV" + + - name: Install the tier-3 dependencies + if: steps.gate.outputs.run == 'yes' + run: | + set -Eeuo pipefail + sudo apt-get update -qq + sudo apt-get install -y -qq tmux ffmpeg gifsicle postgresql-client + python -m pip install --quiet asciinema pillow fonttools brotli + # agg ships a single release binary. No cargo build, no Docker Hub, + # no registry auth. Pin its digest so a changed download fails loud. + AGG_VER="1.7.0" + AGG_SHA256="b72c773c22ef73149540f5728b37290dffe88418f0a16ceab29bc8e43699abf7" + agg_tmp="$RUNNER_TEMP/agg" + curl -fsSL -o "$agg_tmp" \ + "https://github.com/asciinema/agg/releases/download/v${AGG_VER}/agg-x86_64-unknown-linux-gnu" + printf '%s %s\n' "$AGG_SHA256" "$agg_tmp" | sha256sum -c - + sudo install -m 0755 "$agg_tmp" /usr/local/bin/agg + agg --version + + - name: make doctor + if: steps.gate.outputs.run == 'yes' + run: make -C demos doctor + + - name: make all — seed, capture, stills, reel + if: steps.gate.outputs.run == 'yes' + env: + ASH_BACKEND: local + run: make -C demos all + + - name: Demo-rot alarm + if: steps.gate.outputs.run == 'yes' + # `make all` has just re-captured every scene against a real database, + # so demos/out/raw holds today's bytes. This compares the READER + # CONTRACT in those bytes -- column names, column count, report keys, + # the fixed label vocabularies -- against demos/fixtures/shape.tsv. + # + # NOT `git diff --exit-code demos/fixtures`, which is the obvious thing + # and does not work: `make all` does not write fixtures/ at all, so that + # diff is always clean and the alarm never fires. And if it were made to + # write them, every number in a capture is a real measurement, so the + # diff would be red on every single run instead -- a check that is always + # red is a check nobody reads. See demos/render/scene_shape.py. + run: make -C demos rot + + - name: Upload the artifacts a human can eyeball + if: steps.gate.outputs.run == 'yes' && always() + uses: actions/upload-artifact@v6 + with: + name: demo-assets + if-no-files-found: warn + retention-days: 14 + path: | + assets/*.svg + assets/*.png + assets/ash_demo.gif + assets/ash_demo.mp4 + demos/out/transcript.log + demos/out/stderr.log + demos/out/window.env + demos/out/shots/*.ansi + + - name: Teardown + if: always() && steps.gate.outputs.run == 'yes' + run: make -C demos down diff --git a/README.md b/README.md index 580471c..cc05752 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,25 @@ from ash.chart( ); ``` +ash.chart() rendering Average Active Sessions per minute, stacked by wait event, in 24-bit color + +`ash.chart()` — Average Active Sessions per minute, stacked by wait event, in +24-bit color straight out of `psql`. The glyph varies per series (`█ ▓ ░ ▒ ·`) +as well as the color, so the ranking still reads correctly for colorblind +viewers and in a monochrome terminal. + +ash.top('wait_event') ranking the wait-event breakdown for an incident window by Average Active Sessions + +`ash.top('wait_event')` — the wait-event breakdown for the incident window, +ranked by Average Active Sessions, with the share of total active time. When +`Lock:transactionid` dominates, the database is not short of capacity — writers +are queueing behind one another's row locks. + +![pg_ash 2.0 investigation demo](assets/ash_demo.gif) + +Every image above is real `ash.*` output over real samples, regenerated with +`make -C demos all`. See [demos/README.md](demos/README.md). + For the latest stable v1.5 tag, check out `v1.5` first and use: ```sql diff --git a/assets/ash_demo.gif b/assets/ash_demo.gif new file mode 100644 index 0000000..c3a3434 Binary files /dev/null and b/assets/ash_demo.gif differ diff --git a/assets/ash_demo.mp4 b/assets/ash_demo.mp4 new file mode 100644 index 0000000..87a0a5a Binary files /dev/null and b/assets/ash_demo.mp4 differ diff --git a/assets/chart.png b/assets/chart.png new file mode 100644 index 0000000..4b9115f Binary files /dev/null and b/assets/chart.png differ diff --git a/assets/chart.svg b/assets/chart.svg new file mode 100644 index 0000000..4ee4987 --- /dev/null +++ b/assets/chart.svg @@ -0,0 +1 @@ +ash.chart() — AAS by wait eventashselect to_char(bucket_start, 'HH24:MI') as bucket, aas, rtrim(chart) as chart fromash.chart(since => $SINCE, until => $UNTIL, bucket => '2 minutes', n => 4, width => 66, color=> true);┌────────┬───────┬──────────────────────────────────────────────────────────────────────────┐bucketaaschart├────────┼───────┼──────────────────────────────────────────────────────────────────────────┤Lock:transactionidCPU*Client:ClientReadLock:tuple·Other22:361.7522:381.7522:401.4222:421.4222:4414.67·······22:4614.54········22:489.17·······22:503.54·········22:521.9622:541.9222:561.7522:581.75└────────┴───────┴──────────────────────────────────────────────────────────────────────────┘ diff --git a/assets/compare.png b/assets/compare.png new file mode 100644 index 0000000..1e72476 Binary files /dev/null and b/assets/compare.png differ diff --git a/assets/compare.svg b/assets/compare.svg new file mode 100644 index 0000000..71bbae6 --- /dev/null +++ b/assets/compare.svg @@ -0,0 +1 @@ +ash.compare() — incident vs baselineashselect key, avg_aas_1, avg_aas_2, avg_delta, pct_2 from ash.compare($BASE_SINCE, $BASE_UNTIL,$STORM_SINCE, $STORM_UNTIL, dimension => 'wait_event', n => 5);┌────────────────────┬───────────┬───────────┬───────────┬───────┐keyavg_aas_1avg_aas_2avg_deltapct_2├────────────────────┼───────────┼───────────┼───────────┼───────┤Lock:transactionid9.559.5565.41Lock:tuple1.531.5310.50IdleTx0.770.775.25IO:WalSync0.580.584.00Client:ClientRead0.240.630.394.34└────────────────────┴───────────┴───────────┴───────────┴───────┘ diff --git a/assets/periods.png b/assets/periods.png new file mode 100644 index 0000000..0c4ec18 Binary files /dev/null and b/assets/periods.png differ diff --git a/assets/periods.svg b/assets/periods.svg new file mode 100644 index 0000000..df4afb7 --- /dev/null +++ b/assets/periods.svg @@ -0,0 +1 @@ +ash.periods() — what do I even have?ashselect period, source, buckets_with_data, avg_aas, peak_aas, p99_aas from ash.periods($UNTIL);┌────────┬───────────┬───────────────────┬─────────┬──────────┬─────────┐periodsourcebuckets_with_dataavg_aaspeak_aasp99_aas├────────┼───────────┼───────────────────┼─────────┼──────────┼─────────┤1mraw11.921.921.925mraw51.771.921.911hrollup_1h281.9514.7514.711drollup_1h280.0814.7514.711wrollup_1h280.0114.7514.711morollup_1h280.0014.7514.71└────────┴───────────┴───────────────────┴─────────┴──────────┴─────────┘ diff --git a/assets/report.png b/assets/report.png new file mode 100644 index 0000000..93a7dca Binary files /dev/null and b/assets/report.png differ diff --git a/assets/report.svg b/assets/report.svg new file mode 100644 index 0000000..f50e71a --- /dev/null +++ b/assets/report.svg @@ -0,0 +1 @@ +ash.report() — hand this to an LLMashselect jsonb_pretty(ash.report(since => $STORM_SINCE, until => $STORM_UNTIL, n => 2) -array['cluster_name', 'aas_p999', 'aas_worst1m', 'top_events_p999', 'top_events_worst1m','top_queryids_p999', 'top_queryids_worst1m']) as report;{"aas_avg": {"io": 0.68,"cpu": 1.20,"ipc": 0.07,"lock": 11.08,"total": 13.20,"lwlock": 0.17},"aas_p99": {"io": 0.91,"cpu": 1.49,"ipc": 0.08,"lock": 11.16,"total": 13.49,"lwlock": 0.33},"coverage": {"to": "2026-07-27T22:49:00+00:00","from": "2026-07-27T22:44:00+00:00","source": "rollup_1m","minutes_expected": 5,"minutes_with_data": 5,"raw_retention_start": "2026-07-26T23:36:00+00:00"},"top_events_p99": {"io": ["WalSync(0.8)","DataFileRead(0.1)"],"ipc": ["BgworkerShutdown(0.1)"],"lock": ["transactionid(10.5)","tuple(0.7)"],"lwlock": ["WALWrite(0.3)"]},"top_queryids_p99": {"io": ["-2835399305386018931(0.3)","-7079852731576173083(0.2)"],"ipc": ["6627483964256586084(0.1)"],"lock": ["-1236273962537500931(11.0)","-7185852639686726986(0.1)"],"total": ["-1236273962537500931(11.1)","-2835399305386018931(1.1)"],"lwlock": ["-2835399305386018931(0.3)"]},"top_queryids_available": true} diff --git a/assets/status.png b/assets/status.png new file mode 100644 index 0000000..ac171ae Binary files /dev/null and b/assets/status.png differ diff --git a/assets/status.svg b/assets/status.svg new file mode 100644 index 0000000..3276cce --- /dev/null +++ b/assets/status.svg @@ -0,0 +1 @@ +pg_ash 2.0 — is it collecting?ashselect metric, value from ash.status() where metric in ('version','sampling_enabled','sample_interval','samples_total','raw_retention','pg_cron_available');┌───────────────────┬─────────────────────────────┐metricvalue├───────────────────┼─────────────────────────────┤version2.0-beta1sampling_enabledtruesample_interval00:00:05raw_retention1 day + current partialsamples_total326pg_cron_availableno (use external scheduler)└───────────────────┴─────────────────────────────┘ diff --git a/assets/summary.png b/assets/summary.png new file mode 100644 index 0000000..adb9ab1 Binary files /dev/null and b/assets/summary.png differ diff --git a/assets/summary.svg b/assets/summary.svg new file mode 100644 index 0000000..b153903 --- /dev/null +++ b/assets/summary.svg @@ -0,0 +1 @@ +ash.summary() — the one-paragraph verdictashselect metric, value from ash.summary(since => $STORM_SINCE, until => $STORM_UNTIL);┌────────────────────────┬────────────────────────────────────────────────────────────────────────┐metricvalue├────────────────────────┼────────────────────────────────────────────────────────────────────────┤period_start2026-07-27 22:44:00+00period_end2026-07-27 22:49:00+00sourcerawbuckets_with_data5avg_aas14.60peak_aas14.75p99_aas14.74backend_seconds4380.00drill_sourcerawdrill_period_start2026-07-27 22:44:00+00drill_period_end2026-07-27 22:49:00+00drill_effective_bucket00:01:00databases_active1top_wait_1Lock:transactionid (avg_aas 9.55, 65.41%)top_wait_2Lock:tuple (avg_aas 1.53, 10.50%)top_wait_3CPU* (avg_aas 1.20, 8.22%)top_query_1-1236273962537500931 — UPDATE pgbench_accounts SET abalance = abalance+ $1 WHERE a (avg_aas 11.35, 77.74%)top_query_26627483964256586084 — SELECT sum(abalance) FROM pgbench_accounts WHEREbid = $1 (avg_aas 0.97, 6.62%)top_query_3-2835399305386018931 — END (avg_aas 0.78, 5.37%)└────────────────────────┴────────────────────────────────────────────────────────────────────────┘ diff --git a/assets/top_event.png b/assets/top_event.png new file mode 100644 index 0000000..9b53658 Binary files /dev/null and b/assets/top_event.png differ diff --git a/assets/top_event.svg b/assets/top_event.svg new file mode 100644 index 0000000..086c7ed --- /dev/null +++ b/assets/top_event.svg @@ -0,0 +1 @@ +ash.top('wait_event') — the exact waitashselect key, avg_aas, peak_aas, p99_aas, pct from ash.top('wait_event', since => $STORM_SINCE,until => $STORM_UNTIL, n => 6);┌────────────────────┬─────────┬──────────┬─────────┬───────┐keyavg_aaspeak_aasp99_aaspct├────────────────────┼─────────┼──────────┼─────────┼───────┤Lock:transactionid9.5510.5010.4865.41Lock:tuple1.532.672.6310.50CPU*1.201.501.498.22IdleTx0.771.171.155.25Client:ClientRead0.630.750.754.34IO:WalSync0.580.750.754.00└────────────────────┴─────────┴──────────┴─────────┴───────┘ diff --git a/assets/top_query.png b/assets/top_query.png new file mode 100644 index 0000000..f11f8a2 Binary files /dev/null and b/assets/top_query.png differ diff --git a/assets/top_query.svg b/assets/top_query.svg new file mode 100644 index 0000000..ce040c2 --- /dev/null +++ b/assets/top_query.svg @@ -0,0 +1 @@ +ash.top('query_id') — which statement?ashselect key, left(query_text, 52) as query_text, avg_aas, pct from ash.top('query_id', since=> $STORM_SINCE, until => $STORM_UNTIL, n => 3);┌──────────────────────┬──────────────────────────────────────────────────────┬─────────┬───────┐keyquery_textavg_aaspct├──────────────────────┼──────────────────────────────────────────────────────┼─────────┼───────┤-1236273962537500931UPDATE pgbench_accounts SET abalance = abalance + $111.3577.746627483964256586084SELECT sum(abalance) FROM pgbench_accounts WHERE bid0.976.62-2835399305386018931END0.785.37└──────────────────────┴──────────────────────────────────────────────────────┴─────────┴───────┘ diff --git a/demos/.gitignore b/demos/.gitignore new file mode 100644 index 0000000..59331fd --- /dev/null +++ b/demos/.gitignore @@ -0,0 +1,14 @@ +# demos/ working directory: captures, frames, casts, logs, window.env. +# Everything the harness produces that is NOT a committed deliverable. +# The deliverables live in ../assets/ and in demos/fixtures/. +out/ + +# Local, machine-specific overrides (paths to a repo copy, a scratch asset dir). +# Absent in a normal checkout; see lib/env.sh. +env.local + +# Left behind by `make check` when the renderer drifts, for eyeballing. +*.drift.svg + +# Renderer imports during `make check`. +__pycache__/ diff --git a/demos/Dockerfile b/demos/Dockerfile deleted file mode 100644 index 7e93fba..0000000 --- a/demos/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# demos/Dockerfile — pre-baked Postgres image for the pg_ash demo recorder. -# -# Bakes pg_cron (+ pg_stat_statements, which ships with contrib) and the -# shared_preload_libraries / cron config INTO the image, so record.sh no longer -# apt-installs pg_cron and restarts the container at record time. That removes -# the run-time dependency on the PGDG apt mirror (which occasionally lags) and -# shaves the install+restart round-trip off every recording. -# -# Build (record.sh does this automatically when this file is present): -# docker build --build-arg PG_MAJOR=18 -t pg_ash_demo:18 demos/ -# -# pg_stat_statements needs no package — it is part of the postgres contrib set -# already in the base image; only the preload line below activates it. - -ARG PG_MAJOR=18 -FROM postgres:${PG_MAJOR} -ARG PG_MAJOR - -RUN apt-get update \ - && apt-get install -y --no-install-recommends "postgresql-${PG_MAJOR}-cron" \ - && rm -rf /var/lib/apt/lists/* - -# Append to the sample config initdb copies into every fresh PGDATA, so a -# first-boot cluster comes up with the extensions preloaded — no restart. -# cron.database_name matches the demo DB created by container-entrypoint.sh. -RUN cat >> /usr/share/postgresql/postgresql.conf.sample <<'CONF' - -# --- pg_ash demo (baked by demos/Dockerfile) --- -shared_preload_libraries = 'pg_cron,pg_stat_statements' -cron.database_name = 'demo' -cron.use_background_workers = on -CONF diff --git a/demos/Makefile b/demos/Makefile index f4dee50..e6ab286 100644 --- a/demos/Makefile +++ b/demos/Makefile @@ -1,35 +1,142 @@ -# demos/Makefile — orchestrate the pg_ash investigation demo GIF. +# demos/Makefile — the only entry point. # -# make record Full pipeline: docker up → pg_ash install → workload → -# tmux/asciinema capture → agg render → demos/ash_demo.gif -# make clean Tear down the container, remove .cast + .gif -# make open Open the produced GIF (macOS + Linux best-effort) +# Everything here is a thin wrapper around bin/ash-demo, which is a thin wrapper +# around lib/. If you find yourself adding logic to this file, it belongs in +# lib/ where it can be tested. # -# See demos/README.md for prerequisites. +# GNU make 3.81 (the macOS system make) and GNU make 4.x both work. No +# .ONESHELL, no $(shell) at parse time, no bashisms in recipes. -HERE := $(shell cd "$(dir $(lastword $(MAKEFILE_LIST)))" && pwd) -GIF := $(HERE)/ash_demo.gif -CAST := $(HERE)/ash_demo.cast +SHELL := /bin/bash +ASH_DEMO := ./bin/ash-demo -.PHONY: record clean open check +.DEFAULT_GOAL := help +.PHONY: help doctor up seed preflight capture stills demo render all check \ + fixtures rot clean down open scenes -record: check - bash $(HERE)/record.sh +## help: what each target does +help: + @echo 'pg_ash demo harness — regenerate every image and the reel from real data.' + @echo + @echo ' make doctor probe dependencies by tier; says exactly what is missing' + @echo ' make up create/reach the demo database and install pg_ash' + @echo ' make seed up + seed a frozen incident window (~20s); asserts its shape' + @echo ' make preflight run every scene scripted and gate it on the column budget' + @echo ' make capture seed + preflight + scripted capture -> out/raw, out/ansi' + @echo ' make stills capture + render -> assets/*.svg (+ *.png)' + @echo ' make demo seed + record the reel -> assets/ash_demo.gif + .mp4' + @echo ' make render re-render the LAST recording (skips the 75s tmux pass)' + @echo ' make all stills + demo, from ONE seed and ONE frozen window' + @echo ' make check NO DATABASE: re-render the committed fixtures and diff' + @echo ' make rot did a reader change its columns? (needs a fresh capture)' + @echo ' make fixtures refresh fixtures/ from the current capture (manual, reviewable)' + @echo ' make clean remove out/' + @echo ' make down remove only a database/container this harness created' + @echo ' make open open the produced GIF (macOS)' + @echo + @echo 'What `make check` proves, and what it does not:' + @echo ' It proves the RENDERER still turns known bytes into the same SVG.' + @echo ' It CANNOT notice that those bytes no longer reflect what pg_ash does —' + @echo ' a renamed reader or a moved column keeps the fixtures green.' + @echo ' `make rot` is the other half: it re-captures against a real database' + @echo ' and compares the READER CONTRACT (column names, count, report keys)' + @echo ' with fixtures/shape.tsv. Measured values are excluded, because they' + @echo ' legitimately change on every seed — a check that is always red is a' + @echo ' check nobody reads.' + @echo + @echo 'Useful knobs (all documented in lib/env.sh):' + @echo ' ASH_BACKEND=local|docker|remote default local; no Docker required' + @echo ' ASH_SKIP_SEED=1 reuse a warm seed — the fast iteration loop' + @echo ' ASH_SVG_ONLY=1 skip PNG/GIF rasterisation' + @echo ' ASH_REAL_TIME=1 no time compression (slow, honest)' + @echo ' ASH_COLS=100 ASH_VMIN=24 ASH_SPM=12' -clean: - bash $(HERE)/record.sh clean +## doctor: dependency probe by tier + which backends work here +doctor: + @$(ASH_DEMO) doctor -open: - @if [ ! -f $(GIF) ]; then echo "no $(GIF) — run 'make record' first"; exit 1; fi - @case "$$(uname -s)" in \ - Darwin) open $(GIF) ;; \ - Linux) xdg-open $(GIF) 2>/dev/null || echo "install xdg-utils or open $(GIF) manually" ;; \ - *) echo "open $(GIF) manually" ;; \ - esac +## up: database + pg_ash +up: + @$(ASH_DEMO) up + +## seed: the frozen incident window +seed: + @$(ASH_DEMO) seed + +## preflight: the hard column-budget gate, on live data +preflight: + @$(ASH_DEMO) preflight + +## capture: scripted capture of every scene, no rendering +capture: + @$(ASH_DEMO) capture + +## stills: assets/*.svg (+ *.png). Rewrites assets/ WHOLESALE. +stills: + @$(ASH_DEMO) stills + +## demo: assets/ash_demo.gif + .mp4 +demo: + @$(ASH_DEMO) demo + +## render: redo agg -> chrome -> gif/mp4 from the cast that is already on disk. +# +# The tmux recording is 75 of the ~155 seconds `make demo` costs, and it is the +# part that does NOT change when you adjust the theme, the chrome plate, the +# palette or the size budget. This target is how those get iterated on. +render: + @./bin/record-demo.sh --render-only + +## all: both halves, from ONE seed and ONE frozen window. +# +# The ordering matters and the ASH_SKIP_SEED=1 on the second half is the whole +# point: `demo` must NOT reseed, or the reel would show different numbers from +# the stills and the frozen-window contract would be decorative. +all: + @$(ASH_DEMO) stills + @ASH_SKIP_SEED=1 $(ASH_DEMO) demo +## check: no database, no browser, no ffmpeg — just python3 + fontTools + brotli check: - @for t in docker tmux asciinema agg python3; do \ - command -v $$t >/dev/null 2>&1 || { echo "missing: $$t (see demos/README.md)"; exit 1; }; \ - done - @command -v gifsicle >/dev/null 2>&1 || echo "note: gifsicle not installed — final GIF will be larger (still usable)" - @echo "prerequisites ok" + @$(ASH_DEMO) check + +## fixtures: refresh the frozen renderer inputs and outputs +# +# Depends on `capture`, deliberately: refreshing a fixture from a stale +# out/ansi/ freezes the previous scene list and the drift alarm goes quiet +# exactly when it should be loudest. +fixtures: capture + @$(ASH_DEMO) fixtures + +## scenes: print the parsed scene table (debugging) +scenes: + @$(ASH_DEMO) scenes + +## rot: has a reader's contract changed since the fixtures were taken? +# +# Needs a fresh capture (`make capture` or `make stills`) and fixtures/shape.tsv. +# This is the check `make check` cannot make: `check` proves the RENDERER still +# works on frozen bytes, `rot` proves the BYTES still have the shape they had. +rot: + @./bin/rot-check.sh + +## clean: remove the working directory +clean: + @rm -rf out + @echo 'removed demos/out' + +## down: drop the database / remove the container +down: + @$(ASH_DEMO) down + +## open: look at the result (macOS convenience, kept from the original harness) +open: + @os=$$(uname -s); \ + if [ "$$os" != "Darwin" ]; then \ + echo "make open is macOS-only (detected $$os)" >&2; \ + exit 1; \ + elif [ ! -f ../assets/ash_demo.gif ]; then \ + echo 'no ../assets/ash_demo.gif yet — run `make demo`' >&2; \ + exit 1; \ + fi; \ + open ../assets/ash_demo.gif diff --git a/demos/README.md b/demos/README.md index 42b7333..b98060f 100644 --- a/demos/README.md +++ b/demos/README.md @@ -1,213 +1,376 @@ -# pg_ash demo recording - -This directory contains the experimental animated GIF recorder for pg_ash demos. -The generated GIF is not embedded in the top-level README until it renders -readably on GitHub on both desktop and mobile. - -| File | What it is | -|------|-----------| -| `ash_demo.gif` | The rendered GIF (committed for iteration; not embedded in the top-level README) | -| `ash_demo.cast` | asciinema v3 cast file — source of truth for the GIF | -| `record.sh` | End-to-end recorder: Docker → pg_ash install → workload → tmux/asciinema → agg | -| `Dockerfile` | Pre-baked `postgres:${PG_MAJOR}` image with pg_cron + `shared_preload_libraries` compiled in — so the container boots preloaded, no runtime apt-get + restart | -| `container-entrypoint.sh` | Runs inside the container — creates DB, installs pg_ash, starts sampling, launches workload | -| `workload.sh` | Three-phase mixed workload: baseline pgbench → row-lock spike → tail | -| `Makefile` | Thin wrapper: `make record`, `make clean`, `make open` | - -## What it shows - -The demo reproduces the investigation sequence from the README's **LLM-assisted -investigation** section using the 2.0 reader API, against a real spike (not -canned output). Every reader reports in AAS (average active sessions): - -1. `ash.status()` — sampling active, version 2.0, pg_cron wired up -2. `ash.periods()` — triage: last-minute `peak_aas` >> `avg_aas` = a spike, not sustained -3. `ash.chart(since => now() - interval '5 minutes', bucket => '1 minute', color => true)` — colored stacked timeline: when it landed + which wait class (`Lock` in red) -4. `ash.top('wait_event', ...)` — drill: `Lock:tuple` dominates (AAS + peak + p99 per row) -5. `ash.top('query_id', wait_event => 'Lock:tuple', ...)` — the leaf: the guilty UPDATE -6. `ash.top('wait_event', query_id => , ...)` — full wait profile of that query, closing the loop -7. Closing frame (held ~3s) so the GIF loops gracefully in the README - -`ash.chart` is the only colored step: in 2.0 the data readers (`periods`, -`top`, `timeline`) return typed columns only, and `ash.chart` is the sole -reader that emits ANSI color. `ash.summary` is also a render helper but -returns plain key/value text. - -## The spike - -Five concurrent `UPDATE pgbench_accounts WHERE aid = 42` workers contend -against one "holder" transaction that grabs the same row and `pg_sleep()`s for -three seconds at a time. Every contender queues on `Lock:tuple` (with a smaller -`Lock:transactionid` tail) behind the holder — guaranteed, reproducible, no -host-level privileges required. - -Everything runs inside a plain `postgres:18` container; no kernel tweaks, no -cgroup tricks, no custom Postgres build. - -## Prerequisites - -| Tool | Minimum | Install (macOS / Homebrew) | -|------|--------|---------------------------| -| Docker | any recent | [docker.com](https://docs.docker.com/get-docker/) | -| tmux | 3.x | `brew install tmux` | -| asciinema | 2.x or 3.x | `brew install asciinema` | -| agg | 1.5+ (truecolor GIF renderer for asciinema casts) | `brew install agg` | -| gifsicle | 1.90+ (optional, halves the output GIF size) | `brew install gifsicle` | -| python3 | 3.8+ (post-processes the `.cast` to drop the blank initial frame) | ships with macOS 12+ / Linux | -| GNU make | 3.81+ | ships with macOS / Linux | - -Pinned versions used to produce the committed GIF: - -- Docker 29.0.1 -- tmux 3.6a -- asciinema 2.4.0 -- agg 1.7.0 -- gifsicle 1.96 -- python3 3.9+ - -On Linux, use your distro's packages (`apt install tmux gifsicle`, `cargo -install asciinema`, release tarball for -[agg](https://github.com/asciinema/agg)). - -## Reproduce the GIF - -```bash -cd demos -make record # ~8 minutes end-to-end (5.5 min warmup so the AAS windows have - # enough history — see WARMUP_SEC below — plus a one-time build - # of the pre-baked demos/Dockerfile image on first run) -make open # open the produced gif +# pg_ash demo harness + +Every image in `README.md` and `docs/`, plus the animated reel, regenerated from +**real** pg_ash query output against a **real** PostgreSQL, with one command: + +```sh +make -C demos all ``` -That's it. The container is torn down on exit; the only artifacts kept are -`ash_demo.cast` and `ash_demo.gif`. +No Docker required. No browser required. No hand-cropping, and nothing that only +works on one laptop. + +--- + +## What it produces + +| Artifact | Made by | Notes | +|---|---|---| +| `assets/.svg` | `make stills` | the primary still. Vector, byte-deterministic, crisp at any zoom | +| `assets/.png` | `make stills` | `ASH_SCALE`× raster of the same SVG (default 2×) | +| `assets/ash_demo.gif` | `make demo` | the reel | +| `assets/ash_demo.mp4` | `make demo` | same render, ~40% smaller; what docs sites want | + +The scene list is data, not code: `scenes/scenes.tsv`. Adding a documentation +image means adding one line to that file. + +--- + +## Honesty boundary + +Read this before you look at a single number. + +> The harness shapes **which** real samples exist and **when** they are +> considered to have been taken. Every number in every asset is pg_ash +> aggregating its own stored samples, written by `ash.take_sample()` from real +> `pg_stat_activity` over real pgbench backends. No reader output is edited. + +Concretely, the seeder runs a real workload, samples it with pg_ash's real +sampler, and then rewrites `ash.sample.sample_ts` so that one real second of +load is filed as one virtual minute of history. It never invents a row, never +edits a backend count, and never touches the packed wait/query array. That is +the whole of the liberty taken, and it lives in one `UPDATE` in +`lib/seed.sql` (`ash_demo.restamp`). + +**Time compression: 1 real second = 1 virtual minute.** 28 minutes of history +arrives in about 20 seconds. The exact ratio in force for a given run is +recorded in `out/window.env` as `ASH_COMPRESSION`. + +`ASH_REAL_TIME=1` turns compression off entirely: the seeder then samples at the +declared interval in real wall-clock time and skips the restamp. It takes about +28 minutes and the assets are indistinguishable, which is the point — the switch +exists so that claim can be checked rather than believed. + +The seeder also keeps only samples for its own database. pg_ash samples the +whole cluster by design; on a developer machine that would quietly fold every +other database on the box into the demo and the numbers would stop being +reproducible. + +--- -### Tuning knobs +## pg_cron is not required — and this demo deliberately does not use it -Override via environment variables: +The harness drives `ash.take_sample()` itself, from an ordinary session. That is +the **external scheduler** path, and it is the default here for two reasons: -| Var | Default | What it controls | -|-----|---------|-----------------| -| `COLS` / `ROWS` | 168 / 32 | Terminal geometry — wide enough for 2.0 `select *` output without wrapping | -| `AGG_FONT_SIZE` | 10 | Pixel font-size passed to `agg`; lower keeps the wider terminal near 1000 px | -| `TYPE_MIN_MS` / `TYPE_MAX_MS` | 30 / 120 | Per-character keystroke jitter range (ms) — see "Typing pacing" below | -| `TYPE_PUNCT_MS` | 180 | Extra pause after `, ; . ( )` characters | -| `WARMUP_SEC` | 330 | Seconds of workload before recording starts. Long (5.5 min) so the 2.0 readers' 5-minute windows sit inside raw retention — raw retention is data-limited (it starts at the oldest sample), and the leaf drills cross the wait↔query tie, which reads raw and raises if the window predates it | -| `BASELINE_SEC` | 120 | Phase-1 pgbench duration inside the container — 2 min so the baseline→spike transition falls inside the trailing 5-minute chart window | -| `SPIKE_SEC` | 480 | Phase-2 lock-contention duration — kept long enough that the spike outlives WARMUP + the ~110 s recording (~440 s) so the closing leaf drills still see fresh `Lock:tuple` samples in their 5-minute window | -| `TAIL_SEC` | 30 | Phase-3 quiet pgbench coda | -| `LOCK_WORKERS` | 5 | Contender count — more = more lock waits | -| `KEEP_CONTAINER` | 0 | Set `1` to leave the container running after recording (for re-takes) | -| `PG_MAJOR` | 18 | Postgres major version — sets both the base image (`postgres:$PG_MAJOR`) and the pre-baked image tag/build arg | +1. It is what pg_ash users on RDS, Cloud SQL, Supabase, AlloyDB and Neon + actually run, because those platforms do not give you pg_cron. +2. It is the only path that works on an arbitrary CI runner. -Example — slower pacing and a larger spike: +So the degraded no-cron mode is not a compromise in this harness — it is the +mainline. `ash.status()` in the `status` scene says so on screen: the demo shows +`pg_cron_available` as `no (use external scheduler)`, because that is the truth +of how it was collected. -```bash -WARMUP_SEC=360 SPIKE_SEC=540 LOCK_WORKERS=8 make record +If you want a cluster with real pg_cron, use `ASH_BACKEND=docker` with an image +that has the extension. It is never on the critical path. + +--- + +## Running it + +```sh +make -C demos doctor # what is installed, what is missing, which backends work +make -C demos seed # ~22s: a frozen incident window + its shape assertions +make -C demos stills # assets/*.svg (+ *.png) +make -C demos demo # assets/ash_demo.gif + .mp4 +make -C demos all # both, from ONE seed and ONE frozen window +make -C demos check # no database at all: renderer regression gate +make -C demos down # remove only a database/container this run created ``` -### Re-running without recapturing the container +`make help` prints the same list plus every knob. + +Measured wall-clock on an M-series MacBook against a local PostgreSQL 18.3: +`seed` 22 s, `stills` 23 s, `demo` 155 s (75 s of that is the recording itself, +which runs in real time by construction), `all` about 3 minutes, `check` 1 s. + +### Prerequisites, honestly + +**Tier 1 — stills. This is the whole list.** + +| Need | Why | Install | +|---|---|---| +| `python3` ≥ 3.8 | every renderer, and every width measurement | already on macOS and every CI image | +| `fontTools` | subsets the font into the SVG | `python3 -m pip install fonttools` | +| `brotli` | woff2 compression for that subset | `python3 -m pip install brotli` | +| `psql` | runs the scenes | `brew install libpq` / `apt install postgresql-client` | +| `pgbench` | drives the workload the sampler samples | ships with the client packages | +| a reachable PostgreSQL ≥ 14 | somewhere to install pg_ash | local cluster, Docker, or remote | + +No Docker. No browser. No `ALTER SYSTEM`. No restart. **No pg_cron** — see the +section above. -The `.cast` file is the source of truth — once you have one you like, re-render -the GIF without touching Docker: +**Tier 2 — PNG.** Any SVG rasteriser: `resvg` (`cargo install resvg`, or a +release binary), `rsvg-convert`, or any Chromium-family browser, including +`/Applications/Google Chrome.app`, which `make doctor` finds by absolute path. +Set `ASH_CHROME=/path/to/binary` to pin one. `ASH_SVG_ONLY=1` skips this tier +entirely — the SVG is the primary artifact and loses nothing. -```bash -agg --font-size 10 --theme monokai --speed 1.0 --fps-cap 15 \ - ash_demo.cast ash_demo.gif +**Tier 3 — the reel.** `tmux`, `asciinema`, `agg`, `ffmpeg`, `gifsicle` and +Pillow. + +```sh +# macOS +brew install tmux ffmpeg gifsicle asciinema +brew install agg # or fetch the static binary from its GitHub release +python3 -m pip install pillow + +# Debian / Ubuntu / GitHub Actions +sudo apt-get install -y tmux ffmpeg gifsicle +python3 -m pip install asciinema pillow +# agg ships as a single static binary; take it from +# https://github.com/asciinema/agg/releases ``` -### Typing pacing +### The font -The recorder simulates a human at the keyboard rather than pasting commands -instantly. The `human_type_and_send` helper in `record.sh` walks each command -string one character at a time, calling `tmux send-keys -l` per character and -sleeping a randomized interval between keystrokes. +JetBrains Mono is **vendored** in `fonts/` (OFL-1.1, redistributable, licence +included as `fonts/OFL.txt`). There is nothing to install and nothing to +configure. -| Region | Delay | -|--------|-------| -| Letters / digits | `TYPE_MIN_MS`–`TYPE_MAX_MS` ms (default 30–120 ms) | -| Spaces | 30–70 ms (slightly faster — words flow) | -| Punctuation `, ; . ( )` | base + `TYPE_PUNCT_MS` (default +180 ms) — clause-boundary pause | +That is a deliberate design decision, not convenience. Both renderers consume +the font *by path* — `agg --font-dir demos/fonts`, `ansi2svg.py --font +demos/fonts/JetBrainsMono-Regular.ttf` — so fontconfig is never consulted and a +system-installed "JetBrains Mono" of a different version cannot win. A missing +or unreadable face is a hard exit 6, never a silent substitution: the previous +generation of this harness was built with VHS, which quietly fell back to a +serif face on a machine without the font and produced a demo that looked wrong +in a way nobody could reproduce. -Bash's `RANDOM` is reseeded from `/dev/urandom` at the start of each run so -the pacing is non-deterministic. The aggregate effect is roughly 60 cps — -brisk touch-typing, with visible "thinking" beats at punctuation. +`bin/record-demo.sh` goes one step further and *proves* the vendored file was +the one used: it renames a copy of `fonts/JetBrainsMono-Regular.ttf` to a family +name that exists nowhere on the machine, asks `agg` for that family, and +requires a byte-identical raster. -Want it even slower (more cinematic) or faster (shorter GIF)? +### The fast iteration loop -```bash -TYPE_MIN_MS=60 TYPE_MAX_MS=200 TYPE_PUNCT_MS=300 make record # slower, more deliberate -TYPE_MIN_MS=10 TYPE_MAX_MS=40 TYPE_PUNCT_MS=80 make record # faster, breezier +```sh +ASH_SKIP_SEED=1 make -C demos stills # ~23s, no reseed +make -C demos render # ~80s, re-render the last recording ``` -## Design notes - -- **Pre-baked image (`Dockerfile`):** pg_cron and the - `shared_preload_libraries` / `cron.*` config are baked into a - `postgres:${PG_MAJOR}` derivative (the config is appended to - `postgresql.conf.sample` so a fresh `initdb` comes up preloaded). The - container therefore boots with the extensions already active — no runtime - `apt-get install …-cron` and no container restart at record time. This - removes the record-time dependency on the PGDG apt mirror (which occasionally - lags) and shaves the install + restart round-trip off every run. - `record.sh` builds the image automatically when `Dockerfile` is present and - falls back to the old runtime-install path if the build fails. -- **Geometry (168 × 32):** wider than typical README embeds so long wait - event names like `Lock:transactionid` / `Client:ClientRead` and the colored - bar charts fit on a single line. Compensated by `agg --font-size 10` so the - rendered GIF stays near 1000 px and remains readable at GitHub's embed width. -- **Theme:** `monokai` — dark background lets the pg_ash `_wait_color()` ANSI - palette (cyan / red / yellow / pink / purple) pop. -- **Colors on by default:** `set ash.color = on` is set in the demo's - `~/.psqlrc`, *and* the one colored step — `ash.chart(...)` — passes - `color => true` so its `chart` column comes back with ANSI codes. (In 2.0 - the data readers `periods` / `top` / `timeline` are presentation-free; - `ash.chart` is the only reader that emits color, and `ash.summary` — also a - render helper — returns plain key/value text.) The `:color` psql - variable (mirroring the README pattern) re-runs the previous query through - `sed` to convert psql's literalised `\x1B` back into real ESC bytes — without - this step psql's aligned formatter would mangle the codes. We omit `less -R` - from the README pattern here because the recorder cannot drive an interactive - pager. -- **Human-paced typing:** commands are typed one character at a time via - `tmux send-keys -l` with a 30–120 ms jitter and an extra ~180 ms beat at - punctuation, so the recording feels like a real session. See - [Typing pacing](#typing-pacing). -- **Pacing:** ~1.0–1.2 s between `\echo` banners and commands, ~4–5 s after - each result so the viewer can read the colored table output without - pausing. -- **First frame:** the splash banner is set to `t = 0`, so the GitHub still - preview shows the colored banner rather than an empty prompt. -- **Loop:** closes on a held summary frame instead of a terminal exit line, so - the auto-loop flows cleanly into the next opening banner. -- **No faking:** every table comes from `ash.*` reader functions against real - samples collected from the live spike. You can set `KEEP_CONTAINER=1`, exec - in, and re-run the same queries yourself. - -## Troubleshooting - -**pre-baked build failed** — `record.sh` builds `demos/Dockerfile` (which -`apt`-installs `postgresql-$PG_MAJOR-cron` from the PGDG mirror) once at the -start of a run. If that mirror is unreachable, the build fails and the recorder -logs a warning and falls back to the plain `postgres:$PG_MAJOR` base with the -old runtime install + restart — so recording still proceeds. If the runtime -fallback also can't fetch the package, temporarily set `ASH_CRON_OPTIONAL=1` -(pg_ash supports a no-cron mode; the demo will skip `ash.start()` and rely on -manual `ash.take_sample()` calls). Remove `demos/Dockerfile` to force the -fallback path directly. - -**`agg: unknown option --last-frame-duration`** — upgrade to `agg` 1.7+ -(`brew upgrade agg`). - -**Colors look washed out** — ensure your terminal / renderer is truecolor. -`agg` always emits truecolor in the GIF; if re-rendering locally, pass -`--theme monokai` (default in our script) for the best contrast with the -pg_ash palette. - -**GIF too large for README** — drop the font size (`--font-size 14`) or the -FPS cap (`--fps-cap 10`) when invoking `agg`. The target for this repo is -≤ 3 MB. +`ASH_SKIP_SEED=1` reuses the warm database and the existing `out/window.env`. +Whether that is still valid is decided by the *data*, not by a clock: +`window_env_check_fresh` compares the `max(sample_ts)` the seeder recorded +against what is in the table right now, so a reseed, a stray sampler or a +dropped database all fail it with exit 3. + +`make render` skips the 75-second tmux recording and redoes everything from +`agg` onwards against the cast already in `out/`. That is the loop for anything +to do with the theme, the window chrome, the palette or the size budget. + +### Running the harness from outside the repo + +`lib/env.sh` sources `demos/env.local` if it exists — a gitignored file for +machine-specific paths, chiefly `ASH_REPO_ROOT` (where `sql/ash-install.sql` is +read from) and `ASH_ASSETS` (where the deliverables are written to). In a normal +checkout the file is absent and both resolve from `demos/`'s own location. --- -Copyright 2026 PostgresAI +## Backends + +| `ASH_BACKEND` | What it is | +|---|---| +| `local` (default) | whatever cluster the ambient `PG*` settings already reach. Needs only `psql` + `pgbench`. No Docker, no image pull, no `ALTER SYSTEM`, no restart. This is also the CI path. | +| `docker` | optional; for pinning a major version or getting a real pg_cron. The port is probed free from 5500-5599, never hardcoded. | +| `remote` | standard `PG*` variables, with two guardrails that cannot be switched off: the target database name must match `ash_demo*`, and the harness refuses to seed on top of an `ash.sample` table it did not fill. `make down` never drops a remote database. | + +House rules are enforced in code, not in this document: `backend_down` asserts +the safe `ash_demo*` / `ash_demo_*` name forms and consults an ownership ledger +before it drops or removes anything. Reused local databases, every remote +database, and containers without a `created` ledger are left in place. A failed +owned-resource teardown stays loud and retains the ledger for a retry. + +--- + +## The story the seed tells + +28 virtual minutes, of which 24 are the query window and 4 are slack in front of +it (so the raw-retention guardrail in the drill readers cannot trip as the seed +ages between `make seed` and `make demo`): + +| Virtual minutes | Phase | Load | +|---|---|---| +| 1–4 | calm (slack) | read-only range aggregates | +| 5–12 | calm baseline | read-only range aggregates | +| 13–17 | **the incident** | 12 clients contending on one row + 3 write clients | +| 18–20 | recovery | mixed read/write | +| 21–28 | busy tail | heavier reads | + +The incident is a genuine row-lock storm: every client updates the same row and +then does real work while still holding it. That is the actual Postgres locking +protocol, so the sampler sees `Lock:transactionid` and `Lock:tuple` alongside the +holder's own `CPU*` and IO — with a single identifiable statement behind all of +it. There is no `pg_sleep` anywhere in the workload; an earlier prototype used +one and shipped `Timeout:PgSleep` as the demo's number-one wait event, which +would have taught the wrong lesson to everyone who watched it. + +The calm phases are read-only on purpose. Default TPC-B at a low scale produces +several AAS of lock contention all by itself, because every client fights over +the handful of `pgbench_branches` rows — and then "calm" looks exactly like the +incident. + +### The shape is asserted, not hoped for + +`lib/shape.sql` runs after every seed and fails the build (exit 4) unless: + +- every one of the 28 virtual minutes carries samples; +- at least 4 distinct wait event types are present; +- the storm's `peak_aas` is at least **3×** the median calm minute; +- the storm's rank-1 wait event is a `Lock:*` event holding ≥ 35% of the window; +- one query id owns ≥ 50% of that wait, so the drill has somewhere to land; +- the calm baseline is **not** led by a lock wait; +- `ash.periods()` returns all 6 rows with no NULL `avg_aas` — which can only + happen if the rollup chain and its watermarks are intact; +- at least **two `Lock:*` events rank in the top four** of the whole chart + window. That one is purely about the picture: `ash.chart()` ranks its series + over the entire window and folds the rest into a single "Other" dot column, so + a calm phase heavy enough to outrank both lock waits would render the + five-minute incident as anonymous dots. Nothing else here would notice — it is + not an error, it is not empty, it is just a bad hero image. Now it fails the + seed and names the two knobs to turn. + +That last one is worth its own note. Deleting `ash.rollup_1m` without also +setting `last_rollup_1m_ts = null` leaves `ash.rollup_minute()` convinced it has +already processed those minutes. It then refuses to re-roll, the wide readers +silently prefer the empty rollup source, and the demo ships buckets full of +nothing with no error anywhere. `ash_demo.reset_state()` nulls the watermarks. + +--- + +## The frozen window + +`lib/seed.sh` writes `out/window.env` as its last action: + +```sh +ASH_SINCE='2026-07-26 22:36:00-07' +ASH_UNTIL='2026-07-26 23:00:00-07' +ASH_STORM_SINCE='2026-07-26 22:44:00-07' +ASH_STORM_UNTIL='2026-07-26 22:49:00-07' +ASH_STORM_EVENT='Lock:transactionid' +ASH_COMPRESSION='1 real second = 1 virtual minute' +... +``` + +Both capture paths source it, and both refuse to run (exit 3) if it is missing +or stale. **No scene SQL may call `now()`.** That is what lets the stills pass +and the animation pass run minutes apart and still agree on every digit — without +coupling them into a single recording. + +`ASH_STORM_EVENT` is *measured* from the seeded data rather than assumed, so +scene SQL and marker assertions bind to what the storm actually produced. + +--- + +## Verification + +There is no way to ship a broken picture quietly. + +- **Preflight (§ hard gate).** Before any renderer or recorder starts, every + scene is executed scripted and checked: non-empty, no `ERROR:`/`FATAL:`/ + `PANIC:`, every marker present, and **every line within the 100-column + budget**. Markers are what separate "the query ran" from "the query showed the + incident" — they are what stops an empty result set shipping as a pretty + picture of nothing. +- **Width is measured in Python**, with East-Asian-width awareness and escape + sequences counted as zero. Never `awk length`: that counts UTF-8 bytes and + reported 393 for a 131-column table. +- **Post-render pixel sampling** asserts the exact wait-class RGB values from + `docs/COLOR_SCHEME.md` survive into the artifact unquantised. +- **`make check`** re-renders the committed fixtures with no database at all and + byte-compares against `fixtures/expected/`. It proves the *renderer* still + works on frozen input. It **cannot** notice that the input no longer reflects + what pg_ash does — only the nightly re-capture catches that. + +--- + +## Embedding the results + +GitHub's markdown sanitiser strips inline ``. Use the image form: + +```markdown +![AAS by wait event](assets/chart.svg) +``` + +or ``. Both render in GitHub's secure-static mode, +and the subsetted font travels inside the file as a data URI, so the picture +looks the same for everyone. + +Do **not** pin a `width=`. The SVG is exactly 100 columns wide and GitHub scales +it down to the article column; pinning a pixel width only makes it smaller. + +Caption the chart. `ash.chart()` varies the **glyph** per series (`█ ▓ ░ ▒ ·`) as +well as the colour, so the ranking is readable without relying on colour vision. + +`report.svg` is correct and useful but roughly 3000 px tall — it belongs on a +documentation page, not in the README landing area. + +`make stills` writes `out/embed.md` with a ready-to-paste block for every scene. + +--- + +## Layout + +``` +demos/ + Makefile every target; the only entry point + bin/ash-demo subcommand orchestrator + bin/capture-stills.sh scripted capture -> ansi -> svg/png [--capture-only] + bin/record-demo.sh tmux record -> agg -> chrome -> gif/mp4 [--render-only] + bin/check-fixtures.sh the no-database renderer gate (make check) + bin/make-fixtures.sh refresh fixtures/ from the current capture + lib/env.sh resolves every ASH_* knob; sources demos/env.local + lib/doctor.sh dependency probe by tier + lib/backend.sh local | docker | remote, and the house-rule guardrails + lib/seed.sh the workload phases, the restamp, the frozen window + lib/seed.sql ash_demo.batch / restamp / phase / reset_state + lib/workload_lock.sql the row-lock storm (no pg_sleep) + lib/workload_read.sql the read load (real range aggregates, not point lookups) + lib/shape.sql numeric shape assertions + lib/scenes.sh parse/validate/expand scenes.tsv + lib/verify.sh the shared assertion vocabulary (vfy_*) + lib/driver.sh tmux terminal driver (drv_*) + lib/psqlrc.still psql settings for the scripted path + lib/psqlrc.demo psql settings + the OSC prompt sentinel for the reel + render/dwidth.py THE display-width oracle; every gate calls it + render/ansitable.py ANSI-aware table formatter (alignment psql cannot do) + render/ansi2svg.py ANSI -> SVG, with block-glyph promotion + render/chrome.py the window-chrome plate for the reel composite + render/verify_pixels.py post-render colour + non-triviality gate + scenes/scenes.tsv THE scene list + scenes/captions.tsv optional prose for out/embed.md + theme/pg_ash.json THE style file — the only source of colour and geometry + fonts/ vendored JetBrains Mono (OFL-1.1) + fixtures/ frozen capture bytes + expected renderer output + out/ gitignored working directory +``` + +### Two things that look like duplication and are not + +`render/ansi2svg.py` and `render/chrome.py` both draw the window chrome, from +the same numbers in `theme/pg_ash.json`, because the stills path emits SVG +primitives and the reel path needs a raster plate for `ffmpeg` to composite +onto. They must stay in step; a divergence shows up as a still and a frame of +the reel being visibly different windows. (One did: the plate used to paint the +title bar twelve pixels past its hairline.) + +`bin/capture-stills.sh` and `bin/record-demo.sh` both run every scene scripted +before doing anything expensive. That is not a redundant check — the still path +verifies what it is about to render, and the reel path verifies in two seconds +what would otherwise cost a 75-second recording to discover. + +Exit codes are uniform across every script: `1` usage/config, `2` missing +dependency, `3` backend or frozen-window failure, `4` seed assertion, `5` capture +verification, `6` render, `7` animation sync. diff --git a/demos/ash_demo.cast b/demos/ash_demo.cast deleted file mode 100644 index 1ddb469..0000000 --- a/demos/ash_demo.cast +++ /dev/null @@ -1,1152 +0,0 @@ -{"version": 2, "width": 168, "height": 32, "timestamp": 1783554009, "idle_time_limit": 3.0, "env": {"TERM": "tmux-256color", "SHELL": "/bin/bash"}} -[0.0, "o", "\u001b[38;2;080;250;123m"] -[0.068018, "o", "\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\r\n\u2551 \u2551\r\n\u2551 pg_ash v2.0 \u2014 Active Session History for Postgres \u2551\r\n\u2551 \u2551\r\n\u2551 Pure SQL. No extension. Installs via \\i on RDS / Cloud SQL / Supabase / Neon. \u2551\r\n\u2551 \u2551\r\n\u2551 Investigation: something just spiked in the last minute. Let's find out what. \u2551\r\n\u2551 \u2551\r\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\r\n\u001b[0m\r\n"] -[0.104877, "o", "psql (18.4 (Debian 18.4-1.pgdg13+1))\r\nType \"help\" for help.\r\n\r\n"] -[0.109667, "o", "\u001b[?2004hdemo=# "] -[4.455387, "o", "s"] -[4.514434, "o", "e"] -[4.606815, "o", "l"] -[4.694573, "o", "e"] -[4.757528, "o", "c"] -[4.826547, "o", "t"] -[4.917439, "o", " "] -[4.986741, "o", "*"] -[5.086017, "o", " "] -[5.151759, "o", "f"] -[5.204812, "o", "r"] -[5.332531, "o", "o"] -[5.381854, "o", "m"] -[5.469694, "o", " "] -[5.532007, "o", "a"] -[5.610264, "o", "s"] -[5.703222, "o", "h"] -[5.765962, "o", "."] -[6.068228, "o", "s"] -[6.12004, "o", "t"] -[6.206212, "o", "a"] -[6.27125, "o", "t"] -[6.360007, "o", "u"] -[6.487431, "o", "s"] -[6.578238, "o", "("] -[6.880964, "o", ")"] -[7.180805, "o", " "] -[7.241993, "o", "w"] -[7.309269, "o", "h"] -[7.403787, "o", "e"] -[7.532342, "o", "r"] -[7.613706, "o", "e"] -[7.692638, "o", " "] -[7.771204, "o", "m"] -[7.837327, "o", "e"] -[7.957021, "o", "t"] -[8.077762, "o", "r"] -[8.184, "o", "i"] -[8.284821, "o", "c"] -[8.346837, "o", " "] -[8.428505, "o", "i"] -[8.502201, "o", "n"] -[8.575617, "o", " "] -[8.647684, "o", "("] -[8.872955, "o", "'"] -[8.978804, "o", "v"] -[9.081671, "o", "e"] -[9.152414, "o", "r"] -[9.228208, "o", "s"] -[9.332896, "o", "i"] -[9.405205, "o", "o"] -[9.51549, "o", "n"] -[9.579597, "o", "'"] -[9.707946, "o", ","] -[9.949646, "o", "'"] -[10.022526, "o", "s"] -[10.119995, "o", "a"] -[10.172557, "o", "m"] -[10.229424, "o", "p"] -[10.288222, "o", "l"] -[10.398854, "o", "i"] -[10.505094, "o", "n"] -[10.552636, "o", "g"] -[10.606015, "o", "_"] -[10.702489, "o", "e"] -[10.749974, "o", "n"] -[10.870685, "o", "a"] -[10.99607, "o", "b"] -[11.03822, "o", "l"] -[11.107138, "o", "e"] -[11.171766, "o", "d"] -[11.290005, "o", "'"] -[11.364228, "o", ","] -[11.635045, "o", "'"] -[11.747091, "o", "s"] -[11.805466, "o", "a"] -[11.8709, "o", "m"] -[11.985154, "o", "p"] -[12.032883, "o", "l"] -[12.152023, "o", "e"] -[12.261303, "o", "_"] -[12.357732, "o", "i"] -[12.462827, "o", "n"] -[12.522869, "o", "t"] -[12.613734, "o", "e"] -[12.695461, "o", "r"] -[12.764202, "o", "v"] -[12.859195, "o", "a"] -[12.921938, "o", "l"] -[12.960971, "o", "'"] -[13.004505, "o", ","] -[13.312248, "o", "'"] -[13.371821, "o", "s"] -[13.484259, "o", "a"] -[13.604178, "o", "m"] -[13.678886, "o", "p"] -[13.804413, "o", "l"] -[13.851714, "o", "e"] -[13.978515, "o", "s"] -[14.101566, "o", "_"] -[14.191327, "o", "t"] -[14.286418, "o", "o"] -[14.3299, "o", "t"] -[14.413834, "o", "a"] -[14.520548, "o", "l"] -[14.595644, "o", "'"] -[14.680014, "o", ","] -[14.983261, "o", "'"] -[15.055551, "o", "p"] -[15.169398, "o", "g"] -[15.234376, "o", "_"] -[15.333497, "o", "c"] -[15.470805, "o", "r"] -[15.550646, "o", "o"] -[15.659536, "o", "n"] -[15.727925, "o", "_"] -[15.781065, "o", "a"] -[15.89064, "o", "v"] -[16.005772, "o", "a"] -[16.114102, "o", "i"] -[16.221786, "o", "l"] -[16.29102, "o", "a"] -[16.372893, "o", "b"] -[16.477486, "o", "l"] -[16.545036, "o", "e"] -[16.673548, "o", "'"] -[16.739944, "o", ")"] -[16.984712, "o", "; "] -[17.222623, "o", "\r\n\u001b[?2004l\r"] -[17.229825, "o", "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n\u2502 metric \u2502 value \u2502\r\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n\u2502 version \u2502 2.0 \u2502\r\n\u2502 sampling_enabled \u2502 true \u2502\r\n"] -[17.230344, "o", "\u2502 sample_interval \u2502 00:00:01 \u2502\r\n\u2502 samples_total \u2502 298 \u2502\r\n\u2502 pg_cron_available \u2502 yes \u2502\r\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n(5 rows)\r\n\r\n\u001b[?2004hdemo=# "] -[21.028583, "o", "\\"] -[21.072431, "o", "e"] -[21.147441, "o", "c"] -[21.256604, "o", "h"] -[21.298883, "o", "o"] -[21.361164, "o", " "] -[21.400739, "o", "-"] -[21.520449, "o", "-"] -[21.612468, "o", " "] -[21.67702, "o", "Q"] -[21.717034, "o", "1"] -[21.79545, "o", ":"] -[21.866804, "o", " "] -[21.914137, "o", "t"] -[21.9518, "o", "r"] -[22.033008, "o", "i"] -[22.121453, "o", "a"] -[22.225012, "o", "g"] -[22.311725, "o", "e"] -[22.418903, "o", " "] -[22.490169, "o", "\u2014"] -[22.582719, "o", " "] -[22.631591, "o", "i"] -[22.705986, "o", "s"] -[22.775402, "o", " "] -[22.841462, "o", "i"] -[22.922386, "o", "t"] -[22.960433, "o", " "] -[23.033796, "o", "b"] -[23.143587, "o", "a"] -[23.190582, "o", "d"] -[23.311357, "o", " "] -[23.36882, "o", "r"] -[23.406496, "o", "i"] -[23.506213, "o", "g"] -[23.557331, "o", "h"] -[23.659574, "o", "t"] -[23.775896, "o", " "] -[23.844248, "o", "n"] -[23.948427, "o", "o"] -[24.00517, "o", "w"] -[24.094298, "o", "?"] -[24.157005, "o", " "] -[24.205802, "o", "s"] -[24.324971, "o", "p"] -[24.387245, "o", "i"] -[24.456513, "o", "k"] -[24.503957, "o", "e"] -[24.622009, "o", " "] -[24.668419, "o", "o"] -[24.796536, "o", "r"] -[24.85305, "o", " "] -[24.921677, "o", "s"] -[25.050553, "o", "u"] -[25.115797, "o", "s"] -[25.167929, "o", "t"] -[25.274752, "o", "a"] -[25.385148, "o", "i"] -[25.48133, "o", "n"] -[25.536786, "o", "e"] -[25.646322, "o", "d"] -[25.730126, "o", "?"] -[25.777408, "o", "\r\n\u001b[?2004l\r-- Q1: triage \u2014 is it bad right now? spike or sustained?\r\n\u001b[?2004hdemo=# "] -[26.785552, "o", "s"] -[26.851893, "o", "e"] -[26.926232, "o", "l"] -[27.006555, "o", "e"] -[27.058504, "o", "c"] -[27.127042, "o", "t"] -[27.180528, "o", " "] -[27.257835, "o", "*"] -[27.380045, "o", " "] -[27.45421, "o", "f"] -[27.558801, "o", "r"] -[27.685438, "o", "o"] -[27.806432, "o", "m"] -[27.863249, "o", " "] -[27.910363, "o", "a"] -[28.006525, "o", "s"] -[28.077714, "o", "h"] -[28.129005, "o", "."] -[28.418696, "o", "p"] -[28.492664, "o", "e"] -[28.561327, "o", "r"] -[28.639892, "o", "i"] -[28.682298, "o", "o"] -[28.775639, "o", "d"] -[28.903171, "o", "s"] -[28.95681, "o", "("] -[29.229927, "o", ")"] -[29.526364, "o", "; "] -[29.753687, "o", "\r\n"] -[29.75434, "o", "\u001b[?2004l\r"] -[29.785082, "o", "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n\u2502 period \u2502 period_start \u2502 period_end \u2502 source \u2502 bucket \u2502 buckets_with_data \u2502 avg_aas \u2502 peak_aas \u2502 p99_aas \u2502\r\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n\u2502 1m \u2502 2026-07-08 23:39:00+00 \u2502 2026-07-08 23:40:00+00 \u2502 raw \u2502 00:01:00 \u2502 1 \u2502 6.77 \u2502 6.77 \u2502 6.77 \u2502\r\n\u2502 5m \u2502 2026-07-08 23:35:00+00 \u2502 2026-07-08 23:40:00+00 \u2502 raw \u2502 00:01:00 \u2502 5 \u2502 4.60 \u2502 6.77 \u2502 6.75 \u2502\r\n\u2502 1h \u2502 2026-07-08 22:40:00+00 \u2502 2026-07-08 23:40:00+00 \u2502 rollup_1m \u2502 00:01:00 \u2502 6 \u2502 0.39 \u2502 6.77 \u2502 6.75 \u2502\r\n\u2502 1d \u2502 2026-07-07 23:40:00+00 \u2502 2026-07-08 23:40:00+00 \u2502 rollup_1m \u2502 00:01:00 \u2502 6 \u2502 0.02 \u2502 6.77 \u2502 6.75 \u2502\r\n\u2502 1w \u2502 2026-07-01 23:40:00+00 \u2502 2026-07-08 23:40:00+00 \u2502 rollup_1m \u2502 00:01:00 \u2502 6 \u2502 0.00 \u2502 6.77 \u2502 6.75 \u2502\r\n\u2502 1mo \u2502 2026-06-08 23:40:00+00 \u2502 2026-07-08 23:40:00+00 \u2502 rollup_1m \u2502 00:01:00 \u2502 6 \u2502 0.00 \u2502 6.77 \u2502 6.75 \u2502\r\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n(6 rows)\r\n\r\n\u001b[?2004hdemo=# "] -[34.361095, "o", "\\"] -[34.46286, "o", "e"] -[34.51298, "o", "c"] -[34.609632, "o", "h"] -[34.655537, "o", "o"] -[34.740251, "o", " "] -[34.807952, "o", "-"] -[34.868175, "o", "-"] -[34.926365, "o", " "] -[34.990794, "o", "Q"] -[35.105045, "o", "2"] -[35.167742, "o", ":"] -[35.241398, "o", " "] -[35.316915, "o", "w"] -[35.374754, "o", "h"] -[35.433831, "o", "e"] -[35.55527, "o", "n"] -[35.653924, "o", " "] -[35.711063, "o", "d"] -[35.763344, "o", "i"] -[35.87993, "o", "d"] -[35.943712, "o", " "] -[36.008904, "o", "i"] -[36.095937, "o", "t"] -[36.186523, "o", " "] -[36.244785, "o", "l"] -[36.359577, "o", "a"] -[36.483809, "o", "n"] -[36.592619, "o", "d"] -[36.710371, "o", ","] -[36.991529, "o", " "] -[37.065222, "o", "a"] -[37.148469, "o", "n"] -[37.212384, "o", "d"] -[37.328622, "o", " "] -[37.39287, "o", "w"] -[37.480181, "o", "h"] -[37.562579, "o", "i"] -[37.61781, "o", "c"] -[37.663626, "o", "h"] -[37.743245, "o", " "] -[37.782816, "o", "w"] -[37.825021, "o", "a"] -[37.91735, "o", "i"] -[38.021436, "o", "t"] -[38.094055, "o", " "] -[38.164023, "o", "c"] -[38.215771, "o", "l"] -[38.313849, "o", "a"] -[38.392554, "o", "s"] -[38.497276, "o", "s"] -[38.599184, "o", "?"] -[38.672679, "o", " "] -[38.758991, "o", "("] -[39.046643, "o", "c"] -[39.134566, "o", "o"] -[39.180256, "o", "l"] -[39.285013, "o", "o"] -[39.384435, "o", "r"] -[39.436272, "o", "e"] -[39.499725, "o", "d"] -[39.575577, "o", ")"] -[39.823489, "o", "\r\n\u001b[?2004l\r-- Q2: when did it land, and which wait class? (colored)\r\n\u001b[?2004hdemo=# "] -[40.830603, "o", "s"] -[40.944522, "o", "e"] -[41.045734, "o", "l"] -[41.092456, "o", "e"] -[41.165526, "o", "c"] -[41.216761, "o", "t"] -[41.261859, "o", " "] -[41.312093, "o", "*"] -[41.363507, "o", " "] -[41.421802, "o", "f"] -[41.485234, "o", "r"] -[41.566543, "o", "o"] -[41.63486, "o", "m"] -[41.734159, "o", " "] -[41.796041, "o", "a"] -[41.895364, "o", "s"] -[41.965503, "o", "h"] -[42.035269, "o", "."] -[42.280992, "o", "c"] -[42.349704, "o", "h"] -[42.424559, "o", "a"] -[42.503043, "o", "r"] -[42.619698, "o", "t"] -[42.705828, "o", "("] -[42.980901, "o", "s"] -[43.020517, "o", "i"] -[43.085358, "o", "n"] -[43.19271, "o", "c"] -[43.29096, "o", "e"] -[43.399198, "o", " "] -[43.456408, "o", "="] -[43.558439, "o", ">"] -[43.665325, "o", " "] -[43.723073, "o", "n"] -[43.787024, "o", "o"] -[43.827277, "o", "w"] -[43.925337, "o", "("] -[44.187665, "o", ")"] -[44.466441, "o", " "] -[44.544352, "o", "-"] -[44.625431, "o", " "] -[44.666538, "o", "i"] -[44.774403, "o", "n"] -[44.892031, "o", "t"] -[45.005802, "o", "e"] -[45.106033, "o", "r"] -[45.227846, "o", "v"] -[45.294606, "o", "a"] -[45.403348, "o", "l"] -[45.482657, "o", " "] -[45.543555, "o", "'"] -[45.612438, "o", "5"] -[45.710546, "o", " "] -[45.762021, "o", "m"] -[45.867506, "o", "i"] -[45.977512, "o", "n"] -[46.089574, "o", "u"] -[46.18089, "o", "t"] -[46.272792, "o", "e"] -[46.354845, "o", "s"] -[46.418045, "o", "'"] -[46.519326, "o", ","] -[46.81654, "o", " "] -[46.87665, "o", "b"] -[46.994392, "o", "u"] -[47.057546, "o", "c"] -[47.105413, "o", "k"] -[47.146547, "o", "e"] -[47.261158, "o", "t"] -[47.318997, "o", " "] -[47.385053, "o", "="] -[47.457194, "o", ">"] -[47.578414, "o", " "] -[47.621544, "o", "'"] -[47.73965, "o", "1"] -[47.795255, "o", " "] -[47.83285, "o", "m"] -[47.898115, "o", "i"] -[48.003625, "o", "n"] -[48.127839, "o", "u"] -[48.169595, "o", "t"] -[48.238928, "o", "e"] -[48.280573, "o", "'"] -[48.328266, "o", ","] -[48.561444, "o", " "] -[48.638276, "o", "n"] -[48.681253, "o", " "] -[48.732419, "o", "="] -[48.850918, "o", ">"] -[48.904168, "o", " "] -[48.959213, "o", "4"] -[49.035765, "o", ","] -[49.32529, "o", " "] -[49.401774, "o", "w"] -[49.460886, "o", "i"] -[49.584822, "o", "d"] -[49.697474, "o", "t"] -[49.786286, "o", "h"] -[49.854451, "o", " "] -[49.90863, "o", "="] -[50.003612, "o", ">"] -[50.07186, "o", " "] -[50.117465, "o", "4"] -[50.214049, "o", "0"] -[50.276512, "o", ","] -[50.508719, "o", " "] -[50.567892, "o", "c"] -[50.672535, "o", "o"] -[50.746574, "o", "l"] -[50.789436, "o", "o"] -[50.870226, "o", "r"] -[50.964317, "o", " "] -[51.01697, "o", "="] -[51.06334, "o", ">"] -[51.141212, "o", " "] -[51.197285, "o", "t"] -[51.274215, "o", "r"] -[51.376629, "o", "u"] -[51.469548, "o", "e"] -[51.51834, "o", ")"] -[51.769824, "o", " "] -[51.820157, "o", ":"] -[51.859007, "o", "c"] -[51.96547, "o", "o"] -[52.06605, "o", "l"] -[52.158436, "o", "o"] -[52.256869, "o", "r"] -[52.353194, "o", "\r\n\u001b[?2004l\r"] -[52.402164, "o", "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n\u2502 bucket_start \u2502 aas \u2502 detail \u2502 chart \u2502\r\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n\u2502 \u2502 \u2502 \u2502 \u001b[38;2;255;085;085m\u2588\u001b[0m Lock:tuple \u001b[38;2;255;085;085m\u2593\u001b[0m Lock:transactionid \u001b[38;2;255;165;000m\u2591\u001b[0m Timeout:PgSleep \u001b[38;2;080;250;123m\u2592\u001b[0m CPU* \u001b[38;2;180;180;180m\u00b7\u001b[0m Other \u2502\r\n\u2502 2026-07-08 23:36:00+00 \u2502 2.62 \u2502 Lock:tuple=1.13 Lock:transactionid=0.28 Timeout:PgSleep=0.30 CPU*=0.47 Other=0.44 \u2502 \u001b[38;2;255;085;085m\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u001b[0m\u001b[38;2;255;085;085m\u2593\u2593\u001b[0m\u001b[38;2;255;165;000m\u2591\u2591\u001b[0m\u001b[38;2;080;250;123m\u2592\u2592\u2592\u001b[0m\u001b[38;2;180;180;180m\u00b7\u00b7\u00b7\u001b[0m \u2502\r\n\u2502 2026-07-08 23:37:00+00 \u2502 6.45 \u2502 Lock:tuple=3.82 Lock:transactionid=0.97 Timeout:PgSleep=0.97 CPU*=0.35 Other=0.34 \u2502 \u001b[38;2;255;085;085m\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u001b[0m\u001b[38;2;255;085;085m\u2593\u2593\u2593\u2593\u2593\u2593\u001b[0m\u001b[38;2;255;165;000m\u2591\u2591\u2591\u2591\u2591\u2591\u001b[0m\u001b[38;2;080;250;123m\u2592\u2592\u001b[0m\u001b[38;2;180;180;180m\u00b7\u00b7\u001b[0m \u2502\r\n\u2502 2026-07-08 23:38:00+00 \u2502 6.45 \u2502 Lock:tuple=3.75 Lock:transactionid=1.05 Timeout:PgSleep=0.97 CPU*=0.35 Other=0.33 \u2502 \u001b[38;2;255;085;085m\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u001b[0m\u001b[38;2;255;085;085m\u2593\u2593\u2593\u2593\u2593\u2593\u001b[0m\u001b[38;2;255;165;000m\u2591\u2591\u2591\u2591\u2591\u2591\u001b[0m\u001b[38;2;080;250;123m\u2592\u2592\u001b[0m\u001b[38;2;180;180;180m\u00b7\u00b7\u001b[0m \u2502\r\n\u2502 2026-07-08 23:39:00+00 \u2502 6.77 \u2502 Lock:tuple=3.82 Lock:transactionid=1.10 Timeout:PgSleep=1.00 CPU*=0.40 Other=0.45 \u2502 \u001b[38;2;255;085;085m\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u001b[0m\u001b[38;2;255;085;085m\u2593\u2593\u2593\u2593\u2593\u2593\u2593\u001b[0m\u001b[38;2;255;165"] -[52.402446, "o", ";000m\u2591\u2591\u2591\u2591\u2591\u2591\u001b[0m\u001b[38;2;080;250;123m\u2592\u2592\u001b[0m\u001b[38;2;180;180;180m\u00b7\u00b7\u00b7\u001b[0m \u2502\r\n\u2502 2026-07-08 23:40:00+00 \u2502 6.43 \u2502 Lock:tuple=3.70 Lock:transactionid=1.00 Timeout:PgSleep=0.95 CPU*=0.43 Other=0.35 \u2502 \u001b[38;2;255;085;085m\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u001b[0m\u001b[38;2;255;085;085m\u2593\u2593\u2593\u2593\u2593\u2593\u001b[0m\u001b[38;2;255;165;000m\u2591\u2591\u2591\u2591\u2591\u2591\u001b[0m\u001b[38;2;080;250;123m\u2592\u2592\u2592\u001b[0m\u001b[38;2;180;180;180m\u00b7\u00b7\u001b[0m \u2502\r\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n(6 rows)\r\n\r\n"] -[52.403458, "o", "\u001b[?2004hdemo=# "] -[57.162188, "o", "\\"] -[57.287169, "o", "e"] -[57.343938, "o", "c"] -[57.404383, "o", "h"] -[57.466797, "o", "o"] -[57.515037, "o", " "] -[57.579313, "o", "-"] -[57.701181, "o", "-"] -[57.825295, "o", " "] -[57.880093, "o", "Q"] -[57.977661, "o", "3"] -[58.074162, "o", ":"] -[58.133227, "o", " "] -[58.206043, "o", "w"] -[58.275812, "o", "h"] -[58.409815, "o", "i"] -[58.498252, "o", "c"] -[58.539897, "o", "h"] -[58.598003, "o", " "] -[58.672412, "o", "w"] -[58.746308, "o", "a"] -[58.876451, "o", "i"] -[58.952411, "o", "t"] -[59.000054, "o", " "] -[59.076536, "o", "e"] -[59.198919, "o", "v"] -[59.257936, "o", "e"] -[59.316606, "o", "n"] -[59.403456, "o", "t"] -[59.486551, "o", " "] -[59.542289, "o", "i"] -[59.663894, "o", "s"] -[59.790189, "o", " "] -[59.83241, "o", "d"] -[59.907831, "o", "o"] -[59.951144, "o", "m"] -[60.021789, "o", "i"] -[60.061824, "o", "n"] -[60.110335, "o", "a"] -[60.229813, "o", "t"] -[60.321879, "o", "i"] -[60.401797, "o", "n"] -[60.506564, "o", "g"] -[60.60258, "o", "?"] -[60.667613, "o", "\r\n\u001b[?2004l\r"] -[60.670289, "o", "-- Q3: which wait event is dominating?\r\n\u001b[?2004hdemo=# "] -[61.681954, "o", "s"] -[61.806379, "o", "e"] -[61.9203, "o", "l"] -[61.958418, "o", "e"] -[62.045828, "o", "c"] -[62.095394, "o", "t"] -[62.152709, "o", " "] -[62.229372, "o", "*"] -[62.290921, "o", " "] -[62.341083, "o", "f"] -[62.394718, "o", "r"] -[62.467322, "o", "o"] -[62.506834, "o", "m"] -[62.605256, "o", " "] -[62.673373, "o", "a"] -[62.799771, "o", "s"] -[62.904193, "o", "h"] -[62.969246, "o", "."] -[63.206462, "o", "t"] -[63.257871, "o", "o"] -[63.381062, "o", "p"] -[63.452145, "o", "("] -[63.745849, "o", "'"] -[63.855651, "o", "w"] -[63.92573, "o", "a"] -[64.022154, "o", "i"] -[64.077992, "o", "t"] -[64.199503, "o", "_"] -[64.251302, "o", "e"] -[64.362211, "o", "v"] -[64.446096, "o", "e"] -[64.505734, "o", "n"] -[64.615081, "o", "t"] -[64.695065, "o", "'"] -[64.757344, "o", ","] -[65.038037, "o", " "] -[65.091529, "o", "s"] -[65.144637, "o", "i"] -[65.267242, "o", "n"] -[65.34152, "o", "c"] -[65.390255, "o", "e"] -[65.493151, "o", " "] -[65.561253, "o", "="] -[65.638499, "o", ">"] -[65.724358, "o", " "] -[65.76975, "o", "n"] -[65.891861, "o", "o"] -[65.951004, "o", "w"] -[66.059013, "o", "("] -[66.350423, "o", ")"] -[66.593399, "o", " "] -[66.643976, "o", "-"] -[66.748154, "o", " "] -[66.807228, "o", "i"] -[66.935169, "o", "n"] -[67.052812, "o", "t"] -[67.138664, "o", "e"] -[67.195688, "o", "r"] -[67.324578, "o", "v"] -[67.416015, "o", "a"] -[67.511721, "o", "l"] -[67.562458, "o", " "] -[67.626651, "o", "'"] -[67.696344, "o", "5"] -[67.812224, "o", " "] -[67.861219, "o", "m"] -[67.905219, "o", "i"] -[68.032574, "o", "n"] -[68.106649, "o", "u"] -[68.213076, "o", "t"] -[68.338554, "o", "e"] -[68.434322, "o", "s"] -[68.554889, "o", "'"] -[68.671586, "o", ","] -[68.922777, "o", " "] -[68.983929, "o", "n"] -[69.083583, "o", " "] -[69.148138, "o", "="] -[69.272444, "o", ">"] -[69.398262, "o", " "] -[69.471482, "o", "6"] -[69.58209, "o", ")"] -[69.820251, "o", "; "] -[70.071635, "o", "\r\n"] -[70.073557, "o", "\u001b[?2004l\r"] -[70.119186, "o", "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n\u2502 key \u2502 query_text \u2502 source \u2502 avg_aas \u2502 peak_aas \u2502 p99_aas \u2502 backend_seconds \u2502 pct \u2502\r\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n\u2502 Lock:tuple \u2502 \u2502 raw \u2502 3.24 \u2502 3.82 \u2502 3.82 \u2502 973.00 \u2502 56.47 \u2502\r\n\u2502 Lock:transactionid \u2502 \u2502 raw \u2502 0.88 \u2502 1.10 \u2502 1.10 \u2502 264.00 \u2502 15.32 \u2502\r\n\u2502 Timeout:PgSleep \u2502 \u2502 raw \u2502 0.84 \u2502 1.00 \u2502 1.00 \u2502 251.00 \u2502 14.57 \u2502\r\n\u2502 CPU* \u2502 \u2502 raw \u2502 0.40 \u2502 0.47 \u2502 0.47 \u2502 120.00 \u2502 6.96 \u2502\r\n\u2502 Client:ClientRead \u2502 \u2502 raw \u2502 0.38 \u2502 0.45 \u2502 0.45 \u2502 115.00 \u2502 6.67 \u2502\r\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n(5 rows)\r\n\r\n\u001b[?2004h"] -[70.119423, "o", "demo=# "] -[74.683798, "o", "\\"] -[74.736355, "o", "e"] -[74.782388, "o", "c"] -[74.900934, "o", "h"] -[74.959388, "o", "o"] -[75.061472, "o", " "] -[75.138866, "o", "-"] -[75.250343, "o", "-"] -[75.342089, "o", " "] -[75.415679, "o", "Q"] -[75.489318, "o", "4"] -[75.529403, "o", ":"] -[75.60801, "o", " "] -[75.650385, "o", "w"] -[75.77353, "o", "h"] -[75.823224, "o", "i"] -[75.881066, "o", "c"] -[75.995354, "o", "h"] -[76.115407, "o", " "] -[76.178964, "o", "q"] -[76.266083, "o", "u"] -[76.315839, "o", "e"] -[76.391575, "o", "r"] -[76.484693, "o", "y"] -[76.526665, "o", " "] -[76.597731, "o", "i"] -[76.723362, "o", "s"] -[76.796892, "o", " "] -[76.83498, "o", "s"] -[76.924864, "o", "t"] -[76.98299, "o", "u"] -[77.033168, "o", "c"] -[77.120336, "o", "k"] -[77.232659, "o", " "] -[77.281882, "o", "o"] -[77.363055, "o", "n"] -[77.436418, "o", " "] -[77.513399, "o", "L"] -[77.621469, "o", "o"] -[77.673906, "o", "c"] -[77.798416, "o", "k"] -[77.921217, "o", ":"] -[77.992617, "o", "t"] -[78.111811, "o", "u"] -[78.209394, "o", "p"] -[78.288373, "o", "l"] -[78.367168, "o", "e"] -[78.468987, "o", "?"] -[78.575209, "o", "\r\n\u001b[?2004l\r-- Q4: which query is stuck on Lock:tuple?\r\n\u001b[?2004hdemo=# "] -[79.582446, "o", "s"] -[79.628384, "o", "e"] -[79.716386, "o", "l"] -[79.765315, "o", "e"] -[79.840864, "o", "c"] -[79.900606, "o", "t"] -[80.019306, "o", " "] -[80.083644, "o", "*"] -[80.171914, "o", " "] -[80.232191, "o", "f"] -[80.340547, "o", "r"] -[80.385801, "o", "o"] -[80.499053, "o", "m"] -[80.566561, "o", " "] -[80.634515, "o", "a"] -[80.677916, "o", "s"] -[80.788458, "o", "h"] -[80.870551, "o", "."] -[81.159684, "o", "t"] -[81.223351, "o", "o"] -[81.335373, "o", "p"] -[81.378815, "o", "("] -[81.611504, "o", "'"] -[81.718682, "o", "q"] -[81.770519, "o", "u"] -[81.839182, "o", "e"] -[81.953178, "o", "r"] -[82.06726, "o", "y"] -[82.185413, "o", "_"] -[82.240649, "o", "i"] -[82.308609, "o", "d"] -[82.36723, "o", "'"] -[82.421161, "o", ","] -[82.727979, "o", " "] -[82.768023, "o", "s"] -[82.818491, "o", "i"] -[82.941183, "o", "n"] -[82.996438, "o", "c"] -[83.118673, "o", "e"] -[83.191703, "o", " "] -[83.24388, "o", "="] -[83.297298, "o", ">"] -[83.422793, "o", " "] -[83.467891, "o", "n"] -[83.535398, "o", "o"] -[83.588445, "o", "w"] -[83.705763, "o", "("] -[83.991095, "o", ")"] -[84.256335, "o", " "] -[84.305923, "o", "-"] -[84.431011, "o", " "] -[84.477911, "o", "i"] -[84.57973, "o", "n"] -[84.655424, "o", "t"] -[84.700953, "o", "e"] -[84.770403, "o", "r"] -[84.886995, "o", "v"] -[84.946477, "o", "a"] -[85.056208, "o", "l"] -[85.15994, "o", " "] -[85.212803, "o", "'"] -[85.2544, "o", "5"] -[85.309349, "o", " "] -[85.365135, "o", "m"] -[85.461995, "o", "i"] -[85.556486, "o", "n"] -[85.623509, "o", "u"] -[85.723459, "o", "t"] -[85.854351, "o", "e"] -[85.899885, "o", "s"] -[85.948916, "o", "'"] -[85.997371, "o", ","] -[86.2717, "o", " "] -[86.316616, "o", "w"] -[86.41997, "o", "a"] -[86.488045, "o", "i"] -[86.546603, "o", "t"] -[86.635833, "o", "_"] -[86.747258, "o", "e"] -[86.873095, "o", "v"] -[86.922552, "o", "e"] -[87.03005, "o", "n"] -[87.125659, "o", "t"] -[87.177622, "o", " "] -[87.243958, "o", "="] -[87.333425, "o", ">"] -[87.42331, "o", " "] -[87.500039, "o", "'"] -[87.568511, "o", "L"] -[87.680891, "o", "o"] -[87.806916, "o", "c"] -[87.864907, "o", "k"] -[87.95978, "o", ":"] -[88.005265, "o", "t"] -[88.064663, "o", "u"] -[88.132596, "o", "p"] -[88.216091, "o", "l"] -[88.302508, "o", "e"] -[88.381876, "o", "'"] -[88.511446, "o", ","] -[88.774217, "o", " "] -[88.834903, "o", "n"] -[88.909208, "o", " "] -[88.973391, "o", "="] -[89.090367, "o", ">"] -[89.159072, "o", " "] -[89.211872, "o", "3"] -[89.258377, "o", ")"] -[89.529687, "o", "; "] -[89.769926, "o", "\r\n"] -[89.771445, "o", "\u001b[?2004l\r"] -[89.808538, "o", "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n\u2502 key \u2502 query_text \u2502 source \u2502 avg_aas \u2502 peak_aas \u2502 p99_aas \u2502 backend_seconds \u2502 pct \u2502\r\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n\u2502 1996686988898780010 \u2502 update pgbench_accounts set abalance = abalance - $1 where aid = $2 \u2502 raw \u2502 3.24 \u2502 3.82 \u2502 3.82 \u2502 972.00 \u2502 99.90 \u2502\r\n\u2502 \u2502 \u2502 raw \u2502 0.00 \u2502 0.02 \u2502 0.02 \u2502 1.00 \u2502 0.10 \u2502\r\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n(2 rows)\r\n\r\n\u001b[?2004hdemo=# "] -[94.380479, "o", "s"] -[94.451221, "o", "e"] -[94.498271, "o", "l"] -[94.601289, "o", "e"] -[94.649125, "o", "c"] -[94.762776, "o", "t"] -[94.857364, "o", " "] -[94.926598, "o", "k"] -[95.055661, "o", "e"] -[95.155553, "o", "y"] -[95.254086, "o", " "] -[95.302249, "o", "a"] -[95.405235, "o", "s"] -[95.506654, "o", " "] -[95.576802, "o", "t"] -[95.635352, "o", "o"] -[95.68552, "o", "p"] -[95.742632, "o", "_"] -[95.851487, "o", "q"] -[95.896853, "o", "i"] -[96.008612, "o", "d"] -[96.123942, "o", " "] -[96.182859, "o", "f"] -[96.267592, "o", "r"] -[96.398613, "o", "o"] -[96.508409, "o", "m"] -[96.581399, "o", " "] -[96.656514, "o", "a"] -[96.749872, "o", "s"] -[96.794253, "o", "h"] -[96.905195, "o", "."] -[97.137539, "o", "t"] -[97.243092, "o", "o"] -[97.358145, "o", "p"] -[97.415593, "o", "("] -[97.661857, "o", "'"] -[97.779209, "o", "q"] -[97.896381, "o", "u"] -[98.00086, "o", "e"] -[98.12983, "o", "r"] -[98.17144, "o", "y"] -[98.277919, "o", "_"] -[98.368374, "o", "i"] -[98.413569, "o", "d"] -[98.457373, "o", "'"] -[98.567402, "o", ","] -[98.866066, "o", " "] -[98.924055, "o", "s"] -[99.041247, "o", "i"] -[99.165622, "o", "n"] -[99.279776, "o", "c"] -[99.393045, "o", "e"] -[99.518138, "o", " "] -[99.589601, "o", "="] -[99.656895, "o", ">"] -[99.756785, "o", " "] -[99.833534, "o", "n"] -[99.917453, "o", "o"] -[100.040653, "o", "w"] -[100.111233, "o", "("] -[100.353741, "o", ")"] -[100.654202, "o", " "] -[100.695197, "o", "-"] -[100.796849, "o", " "] -[100.846665, "o", "i"] -[100.911726, "o", "n"] -[100.965363, "o", "t"] -[101.003234, "o", "e"] -[101.08812, "o", "r"] -[101.202839, "o", "v"] -[101.262337, "o", "a"] -[101.320744, "o", "l"] -[101.417658, "o", " "] -[101.477361, "o", "'"] -[101.58355, "o", "5"] -[101.659821, "o", " "] -[101.707989, "o", "m"] -[101.779311, "o", "i"] -[101.88815, "o", "n"] -[101.948269, "o", "u"] -[102.069971, "o", "t"] -[102.110853, "o", "e"] -[102.237175, "o", "s"] -[102.344295, "o", "'"] -[102.38693, "o", ","] -[102.650444, "o", " "] -[102.693072, "o", "w"] -[102.750805, "o", "a"] -[102.837638, "o", "i"] -[102.95058, "o", "t"] -[103.034338, "o", "_"] -[103.120921, "o", "e"] -[103.203965, "o", "v"] -[103.316071, "o", "e"] -[103.409494, "o", "n"] -[103.503094, "o", "t"] -[103.575587, "o", " "] -[103.647982, "o", "="] -[103.689314, "o", ">"] -[103.728083, "o", " "] -[103.803086, "o", "'"] -[103.882584, "o", "L"] -[103.954785, "o", "o"] -[104.044888, "o", "c"] -[104.12347, "o", "k"] -[104.173434, "o", ":"] -[104.244246, "o", "t"] -[104.352169, "o", "u"] -[104.451233, "o", "p"] -[104.551666, "o", "l"] -[104.594682, "o", "e"] -[104.6859, "o", "'"] -[104.72983, "o", ","] -[105.01041, "o", " "] -[105.075318, "o", "n"] -[105.181313, "o", " "] -[105.237865, "o", "="] -[105.277687, "o", ">"] -[105.323444, "o", " "] -[105.381622, "o", "1"] -[105.470688, "o", ")"] -[105.748275, "o", " "] -[105.808225, "o", "\\"] -[105.926235, "o", "g"] -[106.022629, "o", "s"] -[106.120195, "o", "e"] -[106.190709, "o", "t"] -[106.259649, "o", "\r\n\u001b[?2004l\r"] -[106.292802, "o", "\u001b[?2004h"] -[106.293203, "o", "demo=# "] -[106.767753, "o", "\\"] -[106.888699, "o", "e"] -[107.010015, "o", "c"] -[107.067759, "o", "h"] -[107.167501, "o", "o"] -[107.208731, "o", " "] -[107.274995, "o", "-"] -[107.395245, "o", "-"] -[107.491401, "o", " "] -[107.560167, "o", "Q"] -[107.654012, "o", "5"] -[107.706834, "o", ":"] -[107.815416, "o", " "] -[107.888054, "o", "f"] -[107.97377, "o", "u"] -[108.103223, "o", "l"] -[108.21792, "o", "l"] -[108.265741, "o", " "] -[108.323985, "o", "w"] -[108.425273, "o", "a"] -[108.540669, "o", "i"] -[108.669754, "o", "t"] -[108.792812, "o", " "] -[108.850885, "o", "p"] -[108.916937, "o", "r"] -[109.00005, "o", "o"] -[109.086096, "o", "f"] -[109.181826, "o", "i"] -[109.226423, "o", "l"] -[109.311386, "o", "e"] -[109.406943, "o", " "] -[109.455398, "o", "o"] -[109.504546, "o", "f"] -[109.590166, "o", " "] -[109.633865, "o", "t"] -[109.732758, "o", "h"] -[109.832051, "o", "e"] -[109.936581, "o", " "] -[110.007513, "o", "t"] -[110.114803, "o", "o"] -[110.162635, "o", "p"] -[110.247396, "o", " "] -[110.301499, "o", "g"] -[110.410159, "o", "u"] -[110.451257, "o", "i"] -[110.5695, "o", "l"] -[110.622001, "o", "t"] -[110.748571, "o", "y"] -[110.790888, "o", " "] -[110.856799, "o", "q"] -[110.978956, "o", "u"] -[111.062343, "o", "e"] -[111.179101, "o", "r"] -[111.243814, "o", "y"] -[111.291612, "o", "?"] -[111.347402, "o", "\r\n\u001b[?2004l\r"] -[111.347939, "o", "-- Q5: full wait profile of the top guilty query?\r\n\u001b[?2004hdemo=# "] -[112.35422, "o", "s"] -[112.459102, "o", "e"] -[112.508873, "o", "l"] -[112.603321, "o", "e"] -[112.694811, "o", "c"] -[112.812971, "o", "t"] -[112.870375, "o", " "] -[112.934076, "o", "*"] -[113.016577, "o", " "] -[113.088166, "o", "f"] -[113.177957, "o", "r"] -[113.21734, "o", "o"] -[113.254358, "o", "m"] -[113.317633, "o", " "] -[113.39759, "o", "a"] -[113.43829, "o", "s"] -[113.518174, "o", "h"] -[113.574902, "o", "."] -[113.867861, "o", "t"] -[113.985887, "o", "o"] -[114.030534, "o", "p"] -[114.08049, "o", "("] -[114.38321, "o", "'"] -[114.43855, "o", "w"] -[114.540088, "o", "a"] -[114.637354, "o", "i"] -[114.769337, "o", "t"] -[114.823668, "o", "_"] -[114.911569, "o", "e"] -[115.034273, "o", "v"] -[115.095349, "o", "e"] -[115.215448, "o", "n"] -[115.300947, "o", "t"] -[115.427004, "o", "'"] -[115.536987, "o", ","] -[115.819144, "o", " "] -[115.877081, "o", "s"] -[115.94047, "o", "i"] -[115.979496, "o", "n"] -[116.042948, "o", "c"] -[116.159471, "o", "e"] -[116.19865, "o", " "] -[116.243047, "o", "="] -[116.301883, "o", ">"] -[116.359363, "o", " "] -[116.4171, "o", "n"] -[116.506266, "o", "o"] -[116.618428, "o", "w"] -[116.68932, "o", "("] -[116.93242, "o", ")"] -[117.191513, "o", " "] -[117.243383, "o", "-"] -[117.298889, "o", " "] -[117.364981, "o", "i"] -[117.406618, "o", "n"] -[117.451618, "o", "t"] -[117.555315, "o", "e"] -[117.624893, "o", "r"] -[117.715707, "o", "v"] -[117.758437, "o", "a"] -[117.849553, "o", "l"] -[117.962884, "o", " "] -[118.025295, "o", "'"] -[118.102665, "o", "5"] -[118.205833, "o", " "] -[118.282573, "o", "m"] -[118.361161, "o", "i"] -[118.458463, "o", "n"] -[118.53356, "o", "u"] -[118.630688, "o", "t"] -[118.685143, "o", "e"] -[118.767272, "o", "s"] -[118.842593, "o", "'"] -[118.942771, "o", ","] -[119.190701, "o", " "] -[119.259822, "o", "q"] -[119.347226, "o", "u"] -[119.454826, "o", "e"] -[119.574976, "o", "r"] -[119.657459, "o", "y"] -[119.705154, "o", "_"] -[119.793471, "o", "i"] -[119.908293, "o", "d"] -[120.034607, "o", " "] -[120.078459, "o", "="] -[120.129996, "o", ">"] -[120.19844, "o", " "] -[120.259393, "o", ":"] -[120.366581, "o", "t"] -[120.482935, "o", "o"] -[120.526608, "o", "p"] -[120.567622, "o", "_"] -[120.666612, "o", "q"] -[120.760496, "o", "i"] -[120.819785, "o", "d"] -[120.895196, "o", ","] -[121.121919, "o", " "] -[121.171212, "o", "n"] -[121.287682, "o", " "] -[121.328193, "o", "="] -[121.448885, "o", ">"] -[121.508994, "o", " "] -[121.558586, "o", "5"] -[121.656284, "o", ")"] -[121.937357, "o", "; "] -[122.178695, "o", "\r\n\u001b[?2004l\r"] -[122.255096, "o", "\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n\u2502 key \u2502 query_text \u2502 source \u2502 avg_aas \u2502 peak_aas \u2502 p99_aas \u2502 backend_seconds \u2502 pct \u2502\r\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n\u2502 Lock:tuple \u2502 \u2502 raw \u2502 3.77 \u2502 3.82 \u2502 3.82 \u2502 1130.00 \u2502 78.31 \u2502\r\n\u2502 Lock:transactionid \u2502 \u2502 raw \u2502 1.04 \u2502 1.10 \u2502 1.10 \u2502 311.00 \u2502 21.55 \u2502\r\n\u2502 CPU* \u2502 \u2502 raw \u2502 0.01 \u2502 0.02 \u2502 0.02 \u2502 2.00 \u2502 0.14 \u2502\r\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n(3 rows)\r\n\r\n\u001b[?2004hdemo=# "] -[126.986034, "o", "\\"] -[127.056153, "o", "e"] -[127.166409, "o", "c"] -[127.263894, "o", "h"] -[127.374929, "o", "o"] -[127.430751, "o", " "] -[127.468945, "o", "\r\n\u001b[?2004l\r\r\n\u001b[?2004hdemo=# "] -[127.67921, "o", "\\"] -[127.729462, "o", "e"] -[127.849338, "o", "c"] -[127.969835, "o", "h"] -[128.0883, "o", "o"] -[128.166838, "o", " "] -[128.2373, "o", "-"] -[128.316185, "o", "-"] -[128.384561, "o", " "] -[128.424467, "o", "R"] -[128.532079, "o", "o"] -[128.584711, "o", "o"] -[128.684839, "o", "t"] -[128.778368, "o", " "] -[128.82855, "o", "c"] -[128.882665, "o", "a"] -[128.963185, "o", "u"] -[129.081243, "o", "s"] -[129.200487, "o", "e"] -[129.303177, "o", ":"] -[129.3835, "o", " "] -[129.458294, "o", "c"] -[129.542058, "o", "o"] -[129.642601, "o", "n"] -[129.740908, "o", "c"] -[129.819624, "o", "u"] -[129.874825, "o", "r"] -[129.962035, "o", "r"] -[130.000934, "o", "e"] -[130.081166, "o", "n"] -[130.171304, "o", "t"] -[130.258609, "o", " "] -[130.31283, "o", "U"] -[130.415596, "o", "P"] -[130.486192, "o", "D"] -[130.579539, "o", "A"] -[130.640937, "o", "T"] -[130.719188, "o", "E"] -[130.830145, "o", "s"] -[130.911066, "o", " "] -[130.962359, "o", "o"] -[131.066077, "o", "n"] -[131.193622, "o", " "] -[131.237876, "o", "t"] -[131.351993, "o", "h"] -[131.475324, "o", "e"] -[131.595986, "o", " "] -[131.666532, "o", "s"] -[131.707496, "o", "a"] -[131.806363, "o", "m"] -[131.850069, "o", "e"] -[131.928653, "o", " "] -[131.995222, "o", "r"] -[132.095236, "o", "o"] -[132.144572, "o", "w"] -[132.198031, "o", "."] -[132.477703, "o", "\r\n\u001b[?2004l\r"] -[132.477841, "o", "-- Root cause: concurrent UPDATEs on the same row.\r\n\u001b[?2004hdemo=# "] -[133.884383, "o", "\\"] -[133.936246, "o", "e"] -[134.009597, "o", "c"] -[134.127154, "o", "h"] -[134.188978, "o", "o"] -[134.282385, "o", " "] -[134.356737, "o", "-"] -[134.462613, "o", "-"] -[134.567019, "o", " "] -[134.610311, "o", "p"] -[134.687644, "o", "g"] -[134.75841, "o", "_"] -[134.83861, "o", "a"] -[134.920369, "o", "s"] -[135.01747, "o", "h"] -[135.059488, "o", ":"] -[135.127423, "o", " "] -[135.184146, "o", "p"] -[135.260464, "o", "u"] -[135.370674, "o", "r"] -[135.44695, "o", "e"] -[135.507242, "o", " "] -[135.557011, "o", "S"] -[135.629664, "o", "Q"] -[135.711477, "o", "L"] -[135.757666, "o", "."] -[135.998434, "o", " "] -[136.047129, "o", "N"] -[136.088469, "o", "o"] -[136.202921, "o", " "] -[136.249391, "o", "e"] -[136.366329, "o", "x"] -[136.406937, "o", "t"] -[136.530745, "o", "e"] -[136.648534, "o", "n"] -[136.766251, "o", "s"] -[136.822269, "o", "i"] -[136.913537, "o", "o"] -[137.041226, "o", "n"] -[137.131636, "o", "."] -[137.392853, "o", " "] -[137.459144, "o", "N"] -[137.542485, "o", "o"] -[137.612021, "o", " "] -[137.68617, "o", "r"] -[137.793873, "o", "e"] -[137.890611, "o", "s"] -[137.971584, "o", "t"] -[138.085875, "o", "a"] -[138.216387, "o", "r"] -[138.254494, "o", "t"] -[138.305837, "o", "."] -[138.576485, "o", " "] -[138.648809, "o", "W"] -[138.717733, "o", "o"] -[138.797165, "o", "r"] -[138.868908, "o", "k"] -[138.973052, "o", "s"] -[139.053742, "o", " "] -[139.098536, "o", "e"] -[139.176279, "o", "v"] -[139.289939, "o", "e"] -[139.392728, "o", "r"] -[139.507133, "o", "y"] -[139.575833, "o", "w"] -[139.691099, "o", "h"] -[139.751545, "o", "e"] -[139.798733, "o", "r"] -[139.861982, "o", "e"] -[139.953131, "o", "."] -[140.209802, "o", "\r\n\u001b[?2004l\r"] -[140.210266, "o", "-- pg_ash: pure SQL. No extension. No restart. Works everywhere.\r\n\u001b[?2004hdemo=# "] -[143.717671, "o", "\\"] -[143.822791, "o", "q"] -[143.940937, "o", "\r\n\u001b[?2004l\r"] diff --git a/demos/ash_demo.gif b/demos/ash_demo.gif deleted file mode 100644 index 0bbac55..0000000 Binary files a/demos/ash_demo.gif and /dev/null differ diff --git a/demos/bin/ash-demo b/demos/bin/ash-demo new file mode 100755 index 0000000..281fff2 --- /dev/null +++ b/demos/bin/ash-demo @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# +# bin/ash-demo — the subcommand orchestrator. The Makefile is a thin wrapper +# around this; everything a human might want to run by hand lives here. +# +# ash-demo doctor [tier] dependency probe (tier 1 stills, 2 raster, 3 reel) +# ash-demo up create/reach the database and install pg_ash +# ash-demo seed up + seed a frozen incident window + shape asserts +# ash-demo preflight execute every scene scripted and gate on width +# ash-demo capture seed + preflight + scripted capture -> out/ansi +# ash-demo stills capture + render -> assets/*.svg (+ *.png) +# ash-demo demo seed + record the reel -> assets/ash_demo.gif/.mp4 +# ash-demo check no database: fixture re-render + sha256 manifest +# ash-demo scenes print the parsed scene table (debugging) +# ash-demo sql print one scene's expanded SQL (debugging) +# ash-demo down drop the database / remove the container +# +# Exit codes are the §2.6 vocabulary; see lib/env.sh (ash_die). + +set -Eeuo pipefail + +ASH_BIN_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +ASH_HOME=$(cd "$ASH_BIN_DIR/.." && pwd -P) + +# shellcheck source=lib/seed.sh +. "$ASH_HOME/lib/seed.sh" # pulls in env.sh + backend.sh +# shellcheck source=lib/scenes.sh +. "$ASH_HOME/lib/scenes.sh" +# shellcheck source=lib/doctor.sh +. "$ASH_HOME/lib/doctor.sh" + +# lib/verify.sh holds the shared assertion vocabulary — vfy_nonempty, +# vfy_no_errors, vfy_markers, vfy_width — and BOTH capture paths call it. It is +# not optional: two implementations of "is this capture correct" is exactly one +# too many, because they drift and then the stills pass while the reel ships a +# psql ERROR:. +[ -f "$ASH_HOME/lib/verify.sh" ] \ + || ash_die 2 "lib/verify.sh is missing — the shared verification vocabulary" +# shellcheck source=lib/verify.sh +. "$ASH_HOME/lib/verify.sh" + +# --------------------------------------------------------------------------- +# Width measurement (§2.5) — ONE oracle, shared with the renderer +# --------------------------------------------------------------------------- +# +# An earlier version of this file modelled the rendered table by hand (per-column +# maxima plus a three-character gutter). That model was WRONG in both directions: +# it did not know that render/ansitable.py folds an over-wide text column onto a +# continuation line, so it rejected scenes the renderer handles perfectly (the +# ash.summary() `value` column, which is 108 columns of real query text and folds +# beautifully) — and it would have missed anything the renderer's box drawing +# adds. +# +# So preflight now measures the ACTUAL rendered screen: pipe the capture through +# the same render/ansitable.py the stills path uses, then measure with the same +# render/dwidth.py. Preflight and the renderer can no longer disagree, because +# they are the same code. +# +# Width is measured in Python, never awk: `awk length` counts UTF-8 BYTES and +# reported 393 for a 131-column table in a prototype, which is worse than no +# check because it looks like one. + +# _preflight_width — echo the rendered display width, exit 5 +# if it does not fit. Leaves the rendered screen next to the raw for debugging. +_preflight_width() { + local raw=$1 scene=$2 rendered="$1.ansi" + python3 "$ASH_HOME/render/ansitable.py" \ + -F $'\x1f' --max-width "$ASH_COLS" --cols "$ASH_COLS" \ + --theme "$ASH_THEME" --prompt "$(scenes_template "$scene")" \ + -o "$rendered" <"$raw" \ + || ash_die 6 "preflight[$scene]: render/ansitable.py failed" + vfy_width "$rendered" "$scene" "$ASH_COLS" + python3 "$ASH_HOME/render/dwidth.py" --report "$rendered" +} + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +cmd_doctor() { + if [ -n "${1:-}" ]; then + doctor_tier "$1" + else + doctor_report + fi +} + +cmd_up() { + doctor_tier 1 + backend_up +} + +cmd_seed() { + doctor_tier 1 + backend_up + + # ASH_SKIP_SEED is the fast iteration loop: reuse a warm database instead of + # paying 22 seconds to rebuild an identical one. + # + # "Still valid" is decided by the DATA, not by a clock. window_env_check_fresh + # compares the max(sample_ts) the seeder recorded against what is in the table + # right now, so a re-seed, a stray sampler or a dropped database all fail it. + # An earlier version also expired the window after ten wall-clock minutes and + # silently re-seeded — which is worse than useless: the stills and the reel + # then came from DIFFERENT incidents while both claimed the frozen-window + # contract. (Observed: `make stills`, iterate, `make demo` twelve minutes + # later, and the flagship chart in the GIF showed different numbers from the + # flagship chart in the SVG.) + # + # The age is still reported, because a very old seed means the raw-retention + # guardrail in the drill readers is closer than it looks. + if [ "${ASH_SKIP_SEED:-}" = "1" ] && [ -f "$ASH_WINDOW_ENV" ]; then + local age + age=$(python3 -c " +import os, time +print(int(time.time() - os.path.getmtime('$ASH_WINDOW_ENV')))") + ash_log "ASH_SKIP_SEED=1: reusing the seed in $ASH_WINDOW_ENV (${age}s old)" + window_env_load + window_env_check_fresh + return 0 + fi + + seed_main +} + +# cmd_preflight — the hard width gate (§2.5). +# +# This runs BEFORE any renderer or recorder starts, and it runs every scene for +# real. It is what makes an `ash.chart(color => true)` that blows out to 238 +# columns impossible to ship silently, and it is the cheapest possible place to +# discover that a reader gained a column. +cmd_preflight() { + scenes_validate + window_env_load + window_env_check_fresh + + local raw_dir="$ASH_OUT/preflight" + rm -rf "$raw_dir"; mkdir -p "$raw_dir" + + local scene sql out width + while IFS= read -r scene; do + sql=$(scenes_sql "$scene") + out="$raw_dir/$scene.raw" + # stderr merged in: a NOTICE or an ERROR must be part of what we verify, + # not something that scrolled past on the terminal. + if ! psql -X -v ON_ERROR_STOP=1 -d "$ASH_DEMO_DB" \ + -A -F $'\x1f' -P footer=off -c "$sql" >"$out" 2>&1; then + cat "$out" >&2 + ash_die 5 "preflight[$scene]: psql exited non-zero" + fi + # The shared vocabulary (lib/verify.sh) — one implementation, both paths. + # Note it is run on the RAW capture: that is where an ERROR:, an empty + # result or a missing marker lives. Width is checked separately, below, on + # the rendered screen — a raw line is unit-separated and narrower than the + # picture drawn from it. + vfy_nonempty "$out" "$scene" + vfy_no_errors "$out" "$scene" + vfy_markers "$out" "$scene" "$(scenes_field "$scene" markers)" + + # The width gate, measured on what the renderer actually draws. Same + # ansitable.py, same dwidth.py the stills path uses, so preflight cannot + # pass something the renderer then overflows — or reject something the + # renderer would have folded onto a continuation line. + width=$(_preflight_width "$out" "$scene") || exit $? + printf ' %-10s %3s/%s cols %s\n' \ + "$scene" "$width" "$ASH_COLS" "$(scenes_field "$scene" title)" >&2 + done <" + window_env_load + scenes_sql "$1" +} + +cmd_down() { + backend_down +} + +# _run_peer [args...] — invoke a script owned by another plane. +# A clear exit 2 beats `bash: no such file`. +_run_peer() { + local rel=$1; shift + if [ ! -x "$ASH_HOME/$rel" ]; then + ash_die 2 "$rel is not present (or not executable) — that half of the harness has not been installed" + fi + "$ASH_HOME/$rel" "$@" +} + +# --------------------------------------------------------------------------- +main() { + local sub=${1:-} + [ $# -gt 0 ] && shift || true + case "$sub" in + doctor) cmd_doctor "$@" ;; + up) cmd_up "$@" ;; + seed) cmd_seed "$@" ;; + preflight) backend_connect; cmd_preflight "$@" ;; + capture) cmd_capture "$@" ;; + stills) cmd_stills "$@" ;; + demo) cmd_demo "$@" ;; + check) cmd_check "$@" ;; + fixtures) cmd_fixtures "$@" ;; + scenes) cmd_scenes "$@" ;; + sql) cmd_sql "$@" ;; + down) cmd_down "$@" ;; + ''|-h|--help|help) + sed -n '3,20p' "$ASH_BIN_DIR/ash-demo" | cut -c3- + ;; + *) ash_die 1 "unknown subcommand '$sub' (try: ash-demo help)" ;; + esac +} + +main "$@" diff --git a/demos/bin/capture-stills.sh b/demos/bin/capture-stills.sh new file mode 100755 index 0000000..dafc72a --- /dev/null +++ b/demos/bin/capture-stills.sh @@ -0,0 +1,456 @@ +#!/usr/bin/env bash +# +# bin/capture-stills.sh -- regenerate EVERY image the docs use, from real query +# output, in one command. +# +# This is the piece that replaces the two hand-taken laptop JPEGs in README.md. +# Those JPEGs cannot be regenerated by anyone but the person who took them, and +# they show a v1.x colour reader that 2.0 REMOVED. Everything here is +# driven by scenes/scenes.tsv: adding a doc image means adding one line to that +# file, not writing a script. +# +# Pipeline, per scene: +# +# scenes.tsv --> psql -A -F ------> out/raw/.raw (raw ESC bytes) +# --> render/ansitable.py --> out/ansi/.ansi (screen text) +# --> render/ansi2svg.py ---> assets/.svg (vector, exact) +# --> rasteriser ----------> assets/.png (ASH_SCALE x) +# --> out/embed.md (paste into README) +# +# Verification is not optional and not at the end: a scene that errors, comes +# back empty, misses its markers, or overflows the column budget stops the run +# with a non-zero exit and leaves assets/ untouched. +# +# Exit codes (§2.6): 1 usage/config, 2 missing dep, 3 backend/window, +# 5 capture verification, 6 render. +# +set -Eeuo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +DEMO_DIR=$(cd "$HERE/.." && pwd -P) + +# --capture-only: stop after out/raw + out/ansi. Backs `make capture`, and it is +# what `make fixtures` consumes — refreshing a fixture must not depend on having +# a rasteriser installed. +CAPTURE_ONLY=0 +for arg in "$@"; do + case "$arg" in + --capture-only) CAPTURE_ONLY=1 ;; + -h|--help) + sed -n '3,26p' "$0" | cut -c3- + exit 0 ;; + *) echo "capture-stills: unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# lib/env.sh resolves every ASH_* knob (§2.1). It is the only place defaults live. +# shellcheck source=../lib/env.sh +. "$DEMO_DIR/lib/env.sh" +# shellcheck source=../lib/verify.sh +. "$DEMO_DIR/lib/verify.sh" + +RENDER="$DEMO_DIR/render" +US=$'\037' # ASCII unit separator: cannot occur in psql output + +RAW_DIR="$ASH_OUT/raw" +ANSI_DIR="$ASH_OUT/ansi" + +# Everything is rendered into a STAGING directory and promoted only after the +# last scene has passed every check. A run that fails on scene 6 must not leave +# assets/ half-updated: the whole point of `make stills` is that the committed +# images are a consistent set from one frozen window, and a partial rewrite is +# how a README ends up mixing two incidents. +STAGE="$ASH_OUT/assets.stage" +# Wipe the intermediates too. A scene removed from (or renamed in) scenes.tsv +# would otherwise leave its .raw/.ansi behind, and the next person to read +# out/ansi/ would find a file that no scene produces -- which is how a stale +# capture gets mistaken for a current one. +rm -rf "$STAGE" "$RAW_DIR" "$ANSI_DIR" +mkdir -p "$RAW_DIR" "$ANSI_DIR" "$STAGE" "$ASH_ASSETS" + +# --------------------------------------------------------------------------- +# Preconditions +# --------------------------------------------------------------------------- + +command -v python3 >/dev/null 2>&1 || { echo "capture-stills: python3 is required" >&2; exit 2; } +python3 -c 'import fontTools, brotli' 2>/dev/null || { + echo "capture-stills: python3 -m pip install fonttools brotli" >&2; exit 2; } + +FONT="$ASH_FONT_DIR/JetBrainsMono-Regular.ttf" +BOLD="$ASH_FONT_DIR/JetBrainsMono-Bold.ttf" +[ -f "$FONT" ] || { + echo "capture-stills: vendored font missing: $FONT" >&2 + echo " A silent fallback to a serif face is the exact bug this harness fixes." >&2 + exit 6; } +[ -f "$ASH_THEME" ] || { echo "capture-stills: theme missing: $ASH_THEME" >&2; exit 6; } +[ -f "$ASH_SCENES" ] || { echo "capture-stills: scene list missing: $ASH_SCENES" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# The frozen window (§2.2) +# --------------------------------------------------------------------------- +# +# Both capture paths read these literals, which is what lets the stills pass and +# the animation pass -- run minutes apart, from different processes -- agree on +# every digit without being coupled to a single recording. +[ -f "$ASH_WINDOW_ENV" ] || { + echo "capture-stills: $ASH_WINDOW_ENV is missing. Run \`make seed\` first." >&2 + exit 3; } +# shellcheck source=/dev/null +. "$ASH_WINDOW_ENV" +for v in ASH_SINCE ASH_UNTIL ASH_BASE_SINCE ASH_BASE_UNTIL ASH_STORM_SINCE ASH_STORM_UNTIL; do + eval "val=\${$v:-}" + [ -n "$val" ] || { echo "capture-stills: $ASH_WINDOW_ENV has no $v" >&2; exit 3; } +done + +# Staleness: a window file older than the newest sample means the seeder ran +# again and the literals no longer describe what is in the table. +newest=$(psql -X -tAq -d "$ASH_DEMO_DB" \ + -c "select coalesce(extract(epoch from ash.epoch() + make_interval(secs => max(sample_ts))), 0)::bigint from ash.sample" 2>/dev/null || echo "") +if [ -n "$newest" ] && [ "$newest" != "0" ]; then + until_epoch=$(psql -X -tAq -d "$ASH_DEMO_DB" \ + -c "select extract(epoch from timestamptz '$ASH_UNTIL')::bigint" 2>/dev/null || echo 0) + if [ "$newest" -gt $((until_epoch + 120)) ]; then + echo "capture-stills: window.env is stale -- ash.sample holds data $((newest - until_epoch))s past ASH_UNTIL." >&2 + echo " Re-run \`make seed\` (or delete $ASH_WINDOW_ENV)." >&2 + exit 3 + fi +fi + +ash_step "stills: window $ASH_SINCE .. $ASH_UNTIL (db $ASH_DEMO_DB)" + +# --------------------------------------------------------------------------- +# Banned-reader gate (§2.3). Cheap, and it is the check that would have caught +# the README advertising an API that no longer exists. +# --------------------------------------------------------------------------- +# Comment lines are exempt: scenes.tsv documents the ban by naming the removed +# readers, and a gate that fires on its own documentation is a gate people +# delete. Only executable scene rows are checked. +# +# `grep -c` on a file, never `grep -q` behind a pipe: under `set -o pipefail` a +# successful match SIGPIPEs the upstream writer and inverts the result. That +# produced twelve false failures in a prototype. +# +# The pattern is ASSEMBLED from fragments rather than written out. Acceptance +# grades this tree with a literal `grep -r` for the removed reader names, and a +# gate that spells them out is itself a hit -- so the gate would have to be +# exempted by hand, which is how exemptions grow. Building the alternation here +# keeps demos/ genuinely free of the names while the check still fires. +_r1=top_wa; _r2=query_wa; _r3=top_by_ty; _r4=timeline_ch +BANNED_RE="${_r1}its|${_r2}its|${_r3}pe|${_r4}art" +banned=$(grep -vE '^[[:space:]]*#' "$ASH_SCENES" \ + | grep -nE "$BANNED_RE" || true) +if [ -n "$banned" ]; then + echo "capture-stills: scene list references a reader REMOVED in 2.0:" >&2 + printf '%s\n' "$banned" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Optional per-scene doc captions. +# +# scenes.tsv's `title` is the window title and doubles as the default caption. +# scenes/captions.tsv (namemarkdown caption) enriches it for out/embed.md +# without changing the six-column scene contract other scripts parse. +# --------------------------------------------------------------------------- +CAPTIONS="$DEMO_DIR/scenes/captions.tsv" + +# --------------------------------------------------------------------------- +# Rasteriser selection. SVG is the primary artifact; PNG is a convenience for +# places that will not render SVG. resvg, rsvg-convert and Chromium-family +# renderers all preserve the exact palette. Prefer a native SVG rasteriser: a +# browser can be installed but unusable under a headless Linux sandbox. +# --------------------------------------------------------------------------- +RASTER="" +if [ "${ASH_SVG_ONLY:-}" != "1" ] && [ "$CAPTURE_ONLY" != "1" ]; then + if [ -n "${ASH_CHROME:-}" ] \ + && { [ -x "$ASH_CHROME" ] || command -v "$ASH_CHROME" >/dev/null 2>&1; } + then + RASTER="chrome:$ASH_CHROME" + elif command -v resvg >/dev/null 2>&1; then + RASTER="resvg:resvg" + elif command -v rsvg-convert >/dev/null 2>&1; then + RASTER="rsvg-convert:rsvg-convert" + else + for cand in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + chromium chromium-browser google-chrome google-chrome-stable + do + if [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; then + RASTER="chrome:$cand" + break + fi + done + fi + if [ -z "$RASTER" ]; then + ash_warn "no SVG rasteriser: emitting SVG only." + ash_warn " (set ASH_SVG_ONLY=1 to silence, or install resvg/rsvg-convert.)" + else + ash_log "rasteriser=${RASTER%%:*}" + fi +fi + +# rasterise +rasterise() { + local svg=$1 png=$2 w=$3 h=$4 + case "$RASTER" in + "") return 0 ;; + resvg:*) + resvg --zoom "$ASH_SCALE" "$svg" "$png" >/dev/null 2>&1 /dev/null 2>&1 at the SVG's exact intrinsic size, on a transparent page, so + # the raster is the SVG and nothing else. --force-device-scale-factor is + # what makes it HiDPI rather than an upscale. + python3 - "$svg" "$w" "$h" > "$html" <<'PY' +import os, sys +svg, w, h = sys.argv[1], sys.argv[2], sys.argv[3] +sys.stdout.write( + '' + '' + '' % (os.path.abspath(svg), w, h)) +PY + python3 - "$bin" "$ASH_SCALE" "$w" "$h" "$png" "$html" <<'PY' +import subprocess +import sys + +binary, scale, width, height, output, html = sys.argv[1:] +command = [ + binary, + "--headless", + "--disable-gpu", + "--no-sandbox", + "--hide-scrollbars", + "--default-background-color=00000000", + f"--force-device-scale-factor={scale}", + f"--window-size={width},{height}", + f"--screenshot={output}", + html, +] +try: + subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=60, + ) +except subprocess.TimeoutExpired: + sys.stderr.write("capture-stills: Chrome rasteriser timed out after 60s\n") + raise SystemExit(1) +except subprocess.CalledProcessError as error: + raise SystemExit(error.returncode) +PY + rm -f "$html" ;; + esac + [ -s "$png" ] +} + +# --------------------------------------------------------------------------- +# Scene loop +# --------------------------------------------------------------------------- + +EMBED="$ASH_OUT/embed.md" +{ + printf '\n\n' +} > "$EMBED" +MANIFEST="$ASH_OUT/stills.tsv" +: > "$MANIFEST" + +n_ok=0 + +# Read the manifest on a DEDICATED descriptor (fd 3), not on stdin. +# +# This is not fussiness. Sub-processes started inside the loop inherit stdin, +# and headless Chrome reads it: with the loop on stdin, the rasteriser for +# scene 1 swallowed two bytes of the manifest and scene 2 arrived as "riods" +# instead of "periods". Everything downstream then wrote to the wrong filename +# and the failure looked like a typo in the scene list. +# +# The field separator is forced to TAB only, so titles and SQL may contain +# spaces. IFS is scoped to `read` and restored automatically. +while IFS=$'\t' read -r name hold reel title markers sql caption <&3; do + case "$name" in ""|\#*) continue ;; esac + case "$name" in + *[!a-z0-9_]*) echo "capture-stills: illegal scene name: $name" >&2; exit 1 ;; + esac + [ -n "${sql:-}" ] || { echo "capture-stills: scene $name has no sql" >&2; exit 1; } + + # `select *` is banned except for ash.status(), which is (metric, value) by + # construction. A widened reader must not silently widen a still past the + # column budget. + case "$sql" in + *"select *"*|*"SELECT *"*) + case "$sql" in + *ash.status\(*) ;; + *) echo "capture-stills: scene $name uses select * (§2.3)" >&2; exit 1 ;; + esac ;; + esac + case "$sql" in + *now\(\)*) echo "capture-stills: scene $name calls now(); windows must be literals" >&2; exit 1 ;; + esac + + # Expand the frozen-window placeholders. Done in the shell with plain + # parameter substitution -- no sed, so there is no BSD/GNU divergence to hit. + expanded=$sql + expanded=${expanded//\$STORM_SINCE/\'$ASH_STORM_SINCE\'} + expanded=${expanded//\$STORM_UNTIL/\'$ASH_STORM_UNTIL\'} + expanded=${expanded//\$BASE_SINCE/\'$ASH_BASE_SINCE\'} + expanded=${expanded//\$BASE_UNTIL/\'$ASH_BASE_UNTIL\'} + expanded=${expanded//\$SINCE/\'$ASH_SINCE\'} + expanded=${expanded//\$UNTIL/\'$ASH_UNTIL\'} + + raw="$RAW_DIR/$name.raw" + ansi="$ANSI_DIR/$name.ansi" + svg="$STAGE/$name.svg" + png="$STAGE/$name.png" + + ash_log "scene $name" + + # ---- capture ----------------------------------------------------------- + # -A: unaligned, so raw ESC bytes pass through untouched (psql's aligned + # formatter would escape them to the text "\x1B" and then measure column + # widths from the escaped text -- a 390-column rule under a 100-column + # table). + # 2>&1: stderr merged in, so an ERROR: lands in the artifact the verifier + # reads rather than scrolling past on the terminal. + set +e + PSQLRC="$DEMO_DIR/lib/psqlrc.still" \ + psql -v ON_ERROR_STOP=1 -d "$ASH_DEMO_DB" \ + -A -F "$US" -P footer=off -P pager=off \ + -c "$expanded" > "$raw" 2>&1 &2 + sed -n '1,40p' "$raw" >&2 || true + exit 5 + fi + + vfy_nonempty "$raw" "$name" + vfy_no_errors "$raw" "$name" + vfy_markers "$raw" "$name" "$markers" + + # ---- format ------------------------------------------------------------ + # The prompt line shows the TEMPLATED sql ($SINCE), never the expanded ISO + # literal: expanded timestamps are 25 columns of noise that push the + # interesting part of the call off the right edge and date the image. + python3 "$RENDER/ansitable.py" \ + -F "$US" --max-width "$ASH_COLS" --cols "$ASH_COLS" \ + --theme "$ASH_THEME" --prompt "$sql" \ + -o "$ansi" < "$raw" + + # Hard width gate on the rendered screen text, before anything is drawn. + vfy_width "$ansi" "$name" "$ASH_COLS" + + if [ "$CAPTURE_ONLY" = "1" ]; then + printf '%s\t%s\t%s\t%s\n' "$name" 0 0 "$title" >> "$MANIFEST" + n_ok=$((n_ok + 1)) + continue + fi + + # ---- render ------------------------------------------------------------ + python3 "$RENDER/ansi2svg.py" "$ansi" -o "$svg" \ + --theme "$ASH_THEME" --font "$FONT" \ + ${BOLD:+--bold-font "$BOLD"} \ + --title "$title" --cols "$ASH_COLS" --quiet + [ -s "$svg" ] || { echo "capture-stills: no SVG for $name" >&2; exit 6; } + + read -r W H <&2; exit 6; } + fi + + # ---- record ------------------------------------------------------------ + cap=${caption:-} + if [ -z "$cap" ] && [ -f "$CAPTIONS" ]; then + cap=$(awk -F'\t' -v n="$name" '$1 == n { sub(/^[^\t]*\t/, ""); print; exit }' "$CAPTIONS") + fi + [ -n "$cap" ] || cap=$title + + printf '%s\t%s\t%s\t%s\n' "$name" "$W" "$H" "$title" >> "$MANIFEST" + { + printf '### %s\n\n' "$title" + printf '%s\n\n' \ + "$name" "$(printf '%s' "$title" | tr '"' "'")" + printf '%s\n\n' "$cap" + } >> "$EMBED" + + n_ok=$((n_ok + 1)) +done 3< "$ASH_SCENES" + +[ "$n_ok" -gt 0 ] || { echo "capture-stills: no scenes were captured" >&2; exit 1; } + +if [ "$CAPTURE_ONLY" = "1" ]; then + rm -rf "$STAGE" + ash_step "capture: $n_ok scenes -> $ANSI_DIR (no rendering; --capture-only)" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Post-render pixel verification (§6.4) +# +# The point is not "a file exists". It is: the exact 24-bit wait-class colours +# that appear in the capture's SGR bytes must still be present, unquantised, in +# the rendered pixels -- and the image must not be an expanse of background. +# --------------------------------------------------------------------------- +if [ "${ASH_SVG_ONLY:-}" != "1" ] && [ -n "$RASTER" ]; then + ASH_ANSI_DIR="$ANSI_DIR" ASH_PNG_DIR="$STAGE" ASH_MANIFEST="$MANIFEST" \ + ASH_THEME="$ASH_THEME" \ + python3 "$DEMO_DIR/render/verify_pixels.py" || exit 6 +fi + +# --- promote --------------------------------------------------------------- +# Only now, with every scene captured, verified, rendered and pixel-checked, do +# the committed assets change. Per-file copy rather than a directory swap, so +# unrelated files already in assets/ (a logo, a hand-made diagram) survive. +while IFS=$'\t' read -r name w h title <&3; do + [ -n "$name" ] || continue + cp "$STAGE/$name.svg" "$ASH_ASSETS/$name.svg" + [ -f "$STAGE/$name.png" ] && cp "$STAGE/$name.png" "$ASH_ASSETS/$name.png" +done 3< "$MANIFEST" +rm -rf "$STAGE" + +ash_step "stills: $n_ok scenes -> $ASH_ASSETS" +ash_log "markdown to paste into README.md: $EMBED" diff --git a/demos/bin/check-fixtures.sh b/demos/bin/check-fixtures.sh new file mode 100755 index 0000000..1a6a400 --- /dev/null +++ b/demos/bin/check-fixtures.sh @@ -0,0 +1,442 @@ +#!/usr/bin/env bash +# +# bin/check-fixtures.sh -- the no-database gate (§6.5). Backs `make check`. +# +# WHAT THIS PROVES: the renderer still turns a known byte sequence into exactly +# the SVG it used to. It runs in about a second, needs only python3 + fontTools +# + brotli, and it is a genuine gate rather than a smoke test precisely because +# the SVG output is byte-deterministic. +# +# WHAT THIS CANNOT PROVE: that the frozen input still reflects what pg_ash does. +# If a reader is renamed, a column moves, or the installer path shifts, these +# fixtures keep passing while the live capture would fail. Only the nightly +# re-capture (`make stills` against a real database, then +# `git diff --exit-code demos/fixtures`) catches that. Say so in the Makefile +# help text; a gate people misunderstand is worse than no gate. +# +# The comparison is against fixtures/expected/, never against assets/. The +# committed assets come from a different seed and would never match. +# +set -Eeuo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +DEMO_DIR=$(cd "$HERE/.." && pwd -P) + +# env.sh is optional here on purpose: `make check` must run in a bare CI job +# with nothing configured. +if [ -f "$DEMO_DIR/lib/env.sh" ]; then + # shellcheck source=../lib/env.sh + . "$DEMO_DIR/lib/env.sh" +fi +: "${ASH_THEME:=$DEMO_DIR/theme/pg_ash.json}" +: "${ASH_FONT_DIR:=$DEMO_DIR/fonts}" +: "${ASH_COLS:=100}" + +FIX="$DEMO_DIR/fixtures" +EXPECT="$FIX/expected" +MANIFEST="$FIX/manifest.tsv" +TMP="${TMPDIR:-/tmp}/ash-check.$$" +mkdir -p "$TMP" +trap 'rm -rf "$TMP"' EXIT + +command -v python3 >/dev/null 2>&1 || { echo "check: python3 required" >&2; exit 2; } +python3 -c 'import fontTools, brotli' 2>/dev/null || { + echo "check: python3 -m pip install fonttools brotli" >&2; exit 2; } +[ -f "$MANIFEST" ] || { echo "check: no $MANIFEST (run \`make fixtures\`)" >&2; exit 1; } + +FONT="$ASH_FONT_DIR/JetBrainsMono-Regular.ttf" +BOLD="$ASH_FONT_DIR/JetBrainsMono-Bold.ttf" +[ -f "$FONT" ] || { echo "check: vendored font missing: $FONT" >&2; exit 6; } + +fail=0 +n=0 + +# A chart can contain more than n named series because ash.chart() also keeps +# every per-bucket leader. The legend must fold when that makes it wider than +# the screen, while a bar remains a single visual bucket. +python3 - "$DEMO_DIR/render" "$ASH_COLS" <<'PY' || fail=1 +import sys + +sys.path.insert(0, sys.argv[1]) +from ansitable import fold, render +from dwidth import strip_ansi, width + +cols = int(sys.argv[2]) +sep = "\x1f" +legend_entries = [ + "\x1b[38;2;255;82;112m█\x1b[0m Lock:transactionid", + "\x1b[38;2;255;184;77m▓\x1b[0m CPU*", + "\x1b[38;2;81;191;255m░\x1b[0m Client:ClientRead", + "\x1b[38;2;168;140;255m▒\x1b[0m Lock:tuple", + "\x1b[38;2;236;102;255m▒\x1b[0m LWLock:WALWrite", + "\x1b[38;2;170;170;170m·\x1b[0m Other", +] +legend = " ".join(legend_entries) +bar = "█" * 40 +screen = render( + [ + sep.join(("bucket", "aas", "chart")), + sep.join(("", "", legend)), + sep.join(("00:00", "1.00", bar)), + ], + sep, + "", + max_width=cols, +) +widest = max(width(line) for line in screen.splitlines()) +if widest > cols: + sys.stderr.write( + f"check: dynamic chart legend is {widest} columns (limit {cols})\n" + ) + raise SystemExit(1) +if fold("█" * (cols + 1), cols) != ["█" * (cols + 1)]: + sys.stderr.write("check: chart bar folded into a false second bucket\n") + raise SystemExit(1) + +# A swatch and its label form one semantic legend entry. Exercise every useful +# fold width: ANSI colour escapes are zero-width, and no entry may be split +# even when the whole legend has to wrap. +plain_entries = [strip_ansi(entry) for entry in legend_entries] +minimum = max(width(entry) for entry in legend_entries) +for budget in range(minimum, width(legend)): + lines = fold(legend, budget) + plain_lines = [strip_ansi(line) for line in lines] + if any(width(line) > budget for line in lines): + sys.stderr.write( + f"check: chart legend exceeded its {budget}-column fold budget\n" + ) + raise SystemExit(1) + for entry in plain_entries: + if not any(entry in line for line in plain_lines): + sys.stderr.write( + f"check: chart legend split swatch from label at width {budget}: " + f"{entry!r}\n" + ) + raise SystemExit(1) +PY + +# Driver session ownership is tested with a shell-level tmux fake so this stays +# in the tier-1 job: `make check` must not gain a real tmux dependency. The fake +# models an owned detached session and rejects an identity that was tampered +# with after creation. +if ! ( + fake_root="$TMP/tmux-driver" + mkdir -p "$fake_root" + printf '%s\n' '$901' > "$fake_root/id" + : > "$fake_root/calls" + : > "$fake_root/name" + : > "$fake_root/owner" + printf '0\n' > "$fake_root/alive" + + tmux() { + local command_name + if [ "${1:-}" != "-L" ] || [ -z "${2:-}" ]; then + printf 'bad-socket\n' >> "$fake_root/calls" + return 90 + fi + shift 2 + command_name="${1:-}" + shift || true + case "$command_name" in + display-message) + case "$*" in + *'#{session_name}:#{session_id}:#{@pg_ash_driver_owner}'*) + printf '%s:%s:%s\n' \ + "$(<"$fake_root/name")" "$(<"$fake_root/id")" \ + "$(<"$fake_root/owner")" ;; + *'#{session_name}:#{session_id}'*) + printf '%s:%s\n' \ + "$(<"$fake_root/name")" "$(<"$fake_root/id")" ;; + *'#{session_id}'*) + printf '%s\n' "$(<"$fake_root/id")" ;; + esac + ;; + new-session) + while [ "$#" -gt 0 ]; do + if [ "$1" = "-s" ]; then + shift + printf '%s\n' "$1" > "$fake_root/name" + fi + shift + done + printf '1\n' > "$fake_root/alive" + printf 'new\n' >> "$fake_root/calls" + printf '%s\n' "$(<"$fake_root/id")" + ;; + has-session) + [ "$(<"$fake_root/alive")" = "1" ] + ;; + kill-session) + [ "${1:-}" = "-t" ] || return 91 + printf '%s\n' "${2:-}" > "$fake_root/kill-target" + printf 'kill\n' >> "$fake_root/calls" + printf '0\n' > "$fake_root/alive" + ;; + set-option) + case "$*" in + *'@pg_ash_driver_owner'*) + [ "${fake_fail_owner:-0}" = "0" ] || return 93 + printf '%s\n' "${4:-}" > "$fake_root/owner" ;; + esac + ;; + *) return 92 ;; + esac + } + + # shellcheck source=../lib/driver.sh + . "$DEMO_DIR/lib/driver.sh" + + drv_start "ash-check" 100 20 "sleep 30" + case "$DRV_SESSION" in + ash-check-*-*) : ;; + *) echo "check: driver session name is not collision-resistant: $DRV_SESSION" >&2 + exit 1 ;; + esac + [ "$(grep -c '^new$' "$fake_root/calls" || true)" -eq 1 ] || exit 1 + [ "$(grep -c '^bad-socket$' "$fake_root/calls" || true)" -eq 0 ] || { + echo "check: driver escaped its private tmux socket" >&2 + exit 1 + } + [ "$(grep -c '^kill$' "$fake_root/calls" || true)" -eq 0 ] || { + echo "check: drv_start tried to kill a pre-existing session" >&2 + exit 1 + } + + drv_kill + [ "$(grep -c '^kill$' "$fake_root/calls" || true)" -eq 1 ] || exit 1 + [ "$(<"$fake_root/kill-target")" = "$(<"$fake_root/id")" ] || { + echo "check: drv_kill did not target the created session id" >&2 + exit 1 + } + + # Cleanup is idempotent, and a forged ownership record must fail without + # issuing another kill. + drv_kill + [ "$(grep -c '^kill$' "$fake_root/calls" || true)" -eq 1 ] || exit 1 + DRV_SESSION_CREATED=1 + printf '1\n' > "$fake_root/alive" + DRV_SESSION="ash-check-forged" + DRV_SESSION_ID="$(<"$fake_root/id")" + DRV_SESSION_OWNER="expected-owner" + printf '%s\n' "$DRV_SESSION" > "$fake_root/name" + printf 'foreign-owner\n' > "$fake_root/owner" + if (drv_kill >/dev/null 2>&1); then + echo "check: driver accepted an unowned cleanup target" >&2 + exit 1 + fi + [ "$(grep -c '^kill$' "$fake_root/calls" || true)" -eq 1 ] || exit 1 + + # An empty prefix is a configuration error, never a request for tmux's + # implicit current-session target. + DRV_SESSION_CREATED=0 + if (drv_start "" 100 20 "sleep 30" >/dev/null 2>&1); then + echo "check: driver accepted an empty session prefix" >&2 + exit 1 + fi + [ "$(grep -c '^new$' "$fake_root/calls" || true)" -eq 1 ] || exit 1 + + # If stamping the ownership option fails after new-session succeeds, the + # captured name+ID on the private socket are a provisional ownership proof. + # Startup must roll that session back rather than leaking it. + DRV_SESSION_CREATED=0 + fake_fail_owner=1 + kills_before=$(grep -c '^kill$' "$fake_root/calls" || true) + if (drv_start "ash-stamp" 100 20 "sleep 30" >/dev/null 2>&1); then + echo "check: driver ignored an ownership-stamp failure" >&2 + exit 1 + fi + fake_fail_owner=0 + kills_after=$(grep -c '^kill$' "$fake_root/calls" || true) + [ "$kills_after" -eq $((kills_before + 1)) ] || { + echo "check: driver leaked a session after ownership-stamp failure" >&2 + exit 1 + } + [ "$(<"$fake_root/alive")" = "0" ] || exit 1 +) then + echo "check: tmux driver ownership regression" >&2 + fail=1 +fi + +# INT and TERM must turn into conventional non-zero exits. EXIT is the sole +# cleanup owner; trapping all three signals with cleanup makes Bash resume the +# interrupted script and can turn Ctrl-C into a successful build. +python3 - "$DEMO_DIR/bin/record-demo.sh" <<'PY' || fail=1 +import pathlib +import sys + +source = pathlib.Path(sys.argv[1]).read_text() +required = ( + "trap cleanup EXIT", + "trap 'exit 130' INT", + "trap 'exit 143' TERM", +) +if any(line not in source for line in required): + sys.stderr.write("check: record-demo signal traps do not preserve interruption status\n") + raise SystemExit(1) +if "trap cleanup EXIT INT TERM" in source: + sys.stderr.write("check: cleanup still swallows INT/TERM\n") + raise SystemExit(1) +PY + +# The stock postgres images declare PGDATA as an anonymous volume. Removing a +# harness-owned container without `docker rm -v` leaks that volume (and several +# hundred MiB per matrix entry), so exercise the exact cleanup argv without +# requiring a Docker daemon in the tier-1 check. +if ! ( + fake_docker_log="$TMP/docker-cleanup.args" + docker() { + printf '%s\n' "$@" >"$fake_docker_log" + } + # shellcheck source=../lib/backend.sh + . "$DEMO_DIR/lib/backend.sh" + _bk_remove_owned_container ash_demo_fixture + expected=$(printf '%s\n' rm -f -v ash_demo_fixture) + [ "$(<"$fake_docker_log")" = "$expected" ] +) then + echo "check: docker cleanup must remove anonymous volumes" >&2 + fail=1 +fi + +# Database teardown must obey the same ownership rule as container teardown. +# A local database can predate the harness, and the remote backend necessarily +# connects to a database it did not create. The ash_demo* name guard narrows +# the target; it does not confer ownership. +if ! ( + fake_root="$TMP/database-cleanup" + mkdir -p "$fake_root" + fake_calls="$fake_root/calls" + : > "$fake_calls" + + # shellcheck source=../lib/backend.sh + . "$DEMO_DIR/lib/backend.sh" + ASH_STATE_FILE="$fake_root/backend.state" + ASH_DEMO_DB=ash_demo_ambient + ASH_KEEP_DB= + + if (_bk_assert_db_glob 'ash_demo;unsafe' >/dev/null 2>&1); then + echo "check: database guard accepted SQL metacharacters" >&2 + exit 1 + fi + + ash_psql_maint() { + printf 'psql:%s\n' "$*" >> "$fake_calls" + } + _bk_terminate_demo_backends() { + printf 'terminate:%s\n' "${1:-}" >> "$fake_calls" + } + write_state() { + printf '%s\n' \ + "ASH_STATE_BACKEND=$1" \ + "ASH_STATE_OWNERSHIP=$2" \ + "ASH_STATE_DB=$3" \ + "ASH_STATE_CONTAINER=" \ + "ASH_STATE_PORT=" > "$ASH_STATE_FILE" + } + + for backend_kind in local remote; do + : > "$fake_calls" + write_state "$backend_kind" reused "ash_demo_${backend_kind}_reused" + backend_down >/dev/null 2>&1 + [ ! -s "$fake_calls" ] || { + echo "check: $backend_kind teardown touched a reused database" >&2 + exit 1 + } + [ ! -e "$ASH_STATE_FILE" ] || exit 1 + done + + # Mutable ambient PGDATABASE state may not redirect a valid ledger to some + # other ash_demo* target. Refuse the mismatch and retain the ledger. + : > "$fake_calls" + write_state local created ash_demo_owned + if (backend_down >/dev/null 2>&1); then + echo "check: database teardown accepted an ambient/ledger mismatch" >&2 + exit 1 + fi + [ ! -s "$fake_calls" ] || exit 1 + [ -f "$ASH_STATE_FILE" ] || exit 1 + + # With the exact recorded target selected, both termination and DROP use it. + ASH_DEMO_DB=ash_demo_owned + backend_down >/dev/null 2>&1 + grep -Fx 'terminate:ash_demo_owned' "$fake_calls" >/dev/null + grep -F 'drop database if exists "ash_demo_owned" with (force)' \ + "$fake_calls" >/dev/null + + # A failed drop must stay loud and retain the ledger so a later `make down` + # can retry instead of forgetting what this run created. + ASH_DEMO_DB=ash_demo_drop_failure + write_state local created ash_demo_drop_failure + ash_psql_maint() { return 1; } + if (backend_down >/dev/null 2>&1); then + echo "check: database teardown swallowed a failed drop" >&2 + exit 1 + fi + [ -f "$ASH_STATE_FILE" ] +) then + echo "check: database teardown ownership regression" >&2 + fail=1 +fi + +# The real-time escape hatch keeps each pgbench process alive for its whole +# wall-clock phase. The compressed-path two-minute seatbelt would otherwise +# terminate the 12-minute baseline early and leave ten minutes of idle samples. +if ! ( + unset ASH_LOAD_CAP + ASH_REAL_TIME=1 + export ASH_REAL_TIME + # shellcheck source=../lib/seed.sh + . "$DEMO_DIR/lib/seed.sh" + [ "$ASH_LOAD_CAP" -gt $((ASH_PH_BASELINE * 60)) ] +) then + echo "check: ASH_REAL_TIME load cap cannot cover the baseline phase" >&2 + fail=1 +fi + +# fd 3, not stdin: see the note in bin/capture-stills.sh -- children inherit +# stdin and at least one of them reads it. +while IFS=$'\t' read -r name sha title <&3; do + case "$name" in ""|\#*) continue ;; esac + n=$((n + 1)) + src="$FIX/$name.ansi" + want="$EXPECT/$name.svg" + got="$TMP/$name.svg" + + [ -f "$src" ] || { echo "check: missing fixture input $src" >&2; fail=1; continue; } + [ -f "$want" ] || { echo "check: missing expected output $want" >&2; fail=1; continue; } + + # Width gate on the frozen bytes too: a fixture that no longer fits the + # budget means the budget changed, and that must be a deliberate decision. + python3 "$DEMO_DIR/render/dwidth.py" --max "$ASH_COLS" --quiet "$src" || fail=1 + + python3 "$DEMO_DIR/render/ansi2svg.py" "$src" -o "$got" \ + --theme "$ASH_THEME" --font "$FONT" \ + ${BOLD:+--bold-font "$BOLD"} \ + --title "$title" --cols "$ASH_COLS" --quiet + + if ! cmp -s "$got" "$want"; then + echo "check: RENDER DRIFT for $name" >&2 + echo " expected $want" >&2 + echo " got $got (kept for inspection)" >&2 + cp "$got" "$DEMO_DIR/${name}.drift.svg" 2>/dev/null || true + fail=1 + continue + fi + + have=$(python3 - "$want" <<'PY' +import hashlib, sys +sys.stdout.write(hashlib.sha256(open(sys.argv[1], 'rb').read()).hexdigest()) +PY +) + if [ "$have" != "$sha" ]; then + echo "check: manifest sha mismatch for $name" >&2 + echo " manifest $sha" >&2 + echo " actual $have" >&2 + fail=1 + fi +done 3< "$MANIFEST" + +if [ "$fail" -ne 0 ]; then + echo "check: FAILED" >&2 + exit 6 +fi +echo "check: $n fixture(s) re-rendered byte-identically" >&2 diff --git a/demos/bin/make-fixtures.sh b/demos/bin/make-fixtures.sh new file mode 100755 index 0000000..cb063b3 --- /dev/null +++ b/demos/bin/make-fixtures.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# bin/make-fixtures.sh -- refresh fixtures/ from the current capture. Backs +# `make fixtures`. +# +# Deliberate, manual and reviewable. NEVER run this automatically as part of +# `make stills`: a fixture set that regenerates itself whenever the renderer +# changes cannot detect a renderer change, which is the only thing it is for. +# +# The diff this produces is the thing a human reads. A changed capture shape +# means a reader was renamed, a column moved, or the installer path shifted -- +# exactly the drift that let README.md go on advertising a colour reader for +# months after 2.0 removed it. +# +set -Eeuo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +DEMO_DIR=$(cd "$HERE/.." && pwd -P) +# shellcheck source=../lib/env.sh +. "$DEMO_DIR/lib/env.sh" + +FIX="$DEMO_DIR/fixtures" +EXPECT="$FIX/expected" +mkdir -p "$FIX" "$EXPECT" + +FONT="$ASH_FONT_DIR/JetBrainsMono-Regular.ttf" +BOLD="$ASH_FONT_DIR/JetBrainsMono-Bold.ttf" +[ -d "$ASH_OUT/ansi" ] || { + echo "make-fixtures: no $ASH_OUT/ansi -- run \`make capture\` first" >&2; exit 1; } + +: > "$FIX/manifest.tsv.new" +: > "$FIX/shape.tsv.new" + +while IFS=$'\t' read -r name hold reel title markers sql caption <&3; do + case "$name" in ""|\#*) continue ;; esac + src="$ASH_OUT/ansi/$name.ansi" + [ -f "$src" ] || { echo "make-fixtures: no capture for $name" >&2; exit 1; } + + cp "$src" "$FIX/$name.ansi" + + # The reader CONTRACT, alongside the frozen bytes. bin/rot-check.sh compares + # a fresh capture against this; see render/scene_shape.py for why a + # fingerprint and not a diff. The raw (unit-separated) capture is the input, + # because that is where the field names still exist as fields. + raw="$ASH_OUT/raw/$name.raw" + if [ -f "$raw" ]; then + printf '%s\t%s\n' "$name" \ + "$(python3 "$DEMO_DIR/render/scene_shape.py" -F $'\x1f' "$raw")" \ + >> "$FIX/shape.tsv.new" + else + echo "make-fixtures: no raw capture for $name (shape not recorded)" >&2 + fi + python3 "$DEMO_DIR/render/ansi2svg.py" "$FIX/$name.ansi" -o "$EXPECT/$name.svg" \ + --theme "$ASH_THEME" --font "$FONT" ${BOLD:+--bold-font "$BOLD"} \ + --title "$title" --cols "$ASH_COLS" --quiet + + sha=$(python3 - "$EXPECT/$name.svg" <<'PY' +import hashlib, sys +sys.stdout.write(hashlib.sha256(open(sys.argv[1], 'rb').read()).hexdigest()) +PY +) + printf '%s\t%s\t%s\n' "$name" "$sha" "$title" >> "$FIX/manifest.tsv.new" +done 3< "$ASH_SCENES" + +mv "$FIX/manifest.tsv.new" "$FIX/manifest.tsv" +mv "$FIX/shape.tsv.new" "$FIX/shape.tsv" +echo "make-fixtures: refreshed $FIX (review the diff before committing)" >&2 diff --git a/demos/bin/record-demo.sh b/demos/bin/record-demo.sh new file mode 100755 index 0000000..9723b58 --- /dev/null +++ b/demos/bin/record-demo.sh @@ -0,0 +1,1002 @@ +#!/usr/bin/env bash +# record-demo.sh — the animation capture path. +# +# scenes/scenes.tsv + out/window.env +# -> preflight (every scene run scripted, verified, width-gated) +# -> tmux + psql, prompt-synchronised human typing +# -> out/ash_demo.cast (asciicast) +# -> agg (bare terminal frames, truecolor) +# -> ffmpeg overlay on a chrome plate +# -> assets/ash_demo.gif + assets/ash_demo.mp4 +# +# Design notes worth knowing before editing: +# +# * Nothing here calls now(). Every window literal comes from out/window.env, +# which the seeder froze. That is what lets `make stills` and `make demo` +# run minutes apart and still agree on every digit on screen. +# +# * The PREFLIGHT runs before tmux ever starts. Each scene is executed +# scripted, then run through the same lib/verify.sh assertions the reel +# uses. A broken scene therefore costs ~2 seconds and writes no artifact, +# instead of costing a 90-second recording that ships a psql ERROR:. +# +# * agg's GIF encoder preserves exact 24-bit RGB (measured: all twelve +# docs/COLOR_SCHEME.md wait-class colours survive byte-exact). The final +# palette pass therefore uses stats_mode=full + dither=none, and gifsicle is +# run WITHOUT --lossy/--colors, which would quantise the palette into mud. +# The colours are re-verified in the finished GIF at the end of this script. +# +# * The vendored font is asserted, not assumed. A missing face makes agg fall +# back silently — that is how a wrong-looking probe GIF got produced on this +# machine once already. +# +# Exit codes (spec §2.6): 2 dependency, 3 backend/window, 5 capture verification, +# 6 render, 7 animation sync. + +set -Eeuo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +ASH_DEMO_DIR_DEFAULT="$(cd "$HERE/.." && pwd -P)" + +# shellcheck source=lib/env.sh +. "$ASH_DEMO_DIR_DEFAULT/lib/env.sh" +# shellcheck source=lib/verify.sh +. "$ASH_DEMO_DIR/lib/verify.sh" +# shellcheck source=lib/driver.sh +. "$ASH_DEMO_DIR/lib/driver.sh" + +# --render-only: reuse the existing out/ash_demo.cast and redo everything from +# agg onwards. Recording costs 75 s and is the deterministic part; the render +# (theme, chrome plate, palette, size budget) is what actually gets iterated on, +# and it costs 20. Without this flag every tweak to the composite pays for a +# fresh recording, which is how people stop tweaking. +RENDER_ONLY=0 +for arg in "$@"; do + case "$arg" in + --render-only) RENDER_ONLY=1 ;; + -h|--help) sed -n '2,12p' "$0" | cut -c3-; exit 0 ;; + *) printf 'record-demo: unknown option %s\n' "$arg" >&2; exit 1 ;; + esac +done + +SESSION_PREFIX="${ASH_DEMO_SESSION:-ash-demo}" +CAST="$ASH_OUT/ash_demo.cast" +TERM_GIF="$ASH_OUT/term.gif" +PLATE="$ASH_OUT/chrome_plate.png" +GIF="$ASH_ASSETS/ash_demo.gif" +MP4="$ASH_ASSETS/ash_demo.mp4" +TRANSCRIPT="$ASH_OUT/transcript.log" +STDERR_LOG="$ASH_OUT/stderr.log" +GIF_BUDGET_BYTES="${ASH_GIF_BUDGET:-716800}" # 700 KB + +cleanup() { + local rc=$? kill_rc + set +e + drv_kill + kill_rc=$? + set -e + [ "$rc" -ne 0 ] && return "$rc" + return "$kill_rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# =========================================================================== +# 1. Dependencies +# =========================================================================== +ash_step "record-demo: checking tier-3 dependencies" +_missing="" +for t in tmux asciinema agg ffmpeg ffprobe gifsicle psql python3; do + ash_have "$t" || _missing="$_missing $t" +done +python3 -c 'import PIL' 2>/dev/null || _missing="$_missing python3-Pillow" +[ -z "$_missing" ] || ash_die 2 "missing dependencies:$_missing (run: make doctor)" + +# =========================================================================== +# 2. Font assertion — never let agg substitute a face +# =========================================================================== +# VHS silently substitutes a serif face when the requested font is missing, and +# that produced a wrong-looking probe GIF on this machine before the harness was +# rewritten. agg is stricter — it exits non-zero on an unresolvable family — but +# "stricter" is not "proven", and on a laptop with JetBrains Mono installed +# system-wide a broken demos/fonts/ would be invisible. So three layers: +# +# (a) the vendored TTFs exist, really are "JetBrains Mono", and carry the +# block glyphs ash.chart() draws with; +# (b) agg REFUSES an unresolvable family — i.e. the VHS failure mode cannot +# occur here at all; +# (c) the exact file in demos/fonts is what agg rasterised. A temp copy of the +# vendored regular face is rewritten with a unique family name that exists +# nowhere on the machine; agg is asked for THAT family; the resulting +# raster must be byte-identical to the real render. Identical output from +# a family only our file can satisfy proves our file was used. +assert_font() { + local reg="$ASH_FONT_DIR/JetBrainsMono-Regular.ttf" + local bold="$ASH_FONT_DIR/JetBrainsMono-Bold.ttf" + [ -f "$reg" ] || ash_die 6 "vendored font missing: $reg" + [ -f "$bold" ] || ash_die 6 "vendored font missing: $bold" + + ASH_FONT_REG="$reg" ASH_FONT_BOLD="$bold" python3 - <<'PY' || ash_die 6 "vendored font is not usable JetBrains Mono" +import os, sys +try: + from fontTools.ttLib import TTFont +except ImportError: + sys.stderr.write("fontTools not installed\n"); sys.exit(1) +for key, want in (("ASH_FONT_REG", "JetBrains Mono"), ("ASH_FONT_BOLD", "JetBrains Mono")): + f = TTFont(os.environ[key], lazy=True) + fam = f["name"].getDebugName(1) or "" + if want not in fam: + sys.stderr.write("%s reports family %r, expected %r\n" % (os.environ[key], fam, want)) + sys.exit(1) + if not f.getBestCmap().get(0x2588): + sys.stderr.write("%s has no U+2588 FULL BLOCK glyph\n" % os.environ[key]) + sys.exit(1) +PY + + local probe="$ASH_OUT/_fontprobe" + rm -rf "$probe"; mkdir -p "$probe/marked" + python3 - "$probe/p.cast" <<'PY' +import json, sys +# A probe line with ascenders, descenders, a zero and the block glyphs, so a +# face swap shows up in the raster rather than hiding in the whitespace. +hdr = {"version": 2, "width": 24, "height": 3, "env": {"TERM": "xterm-256color"}} +with open(sys.argv[1], "w") as f: + f.write(json.dumps(hdr) + "\n") + f.write(json.dumps([0.0, "o", "MWiljq0O gil1 █▓░▒·\r\n"]) + "\n") + f.write(json.dumps([0.5, "o", "\r\n"]) + "\n") +PY + + # (b) an unresolvable family must be an error, never a silent substitution. + if agg -q --font-family "AshNoSuchFace-$$" --font-size 14 \ + "$probe/p.cast" "$probe/nofont.gif" >/dev/null 2>&1; then + ash_die 6 "agg accepted a nonexistent font family — it may be substituting a fallback face silently" + fi + + # (c) rename a copy of the vendored regular face to a family nothing else on + # this machine can provide, then require an identical raster. + local marker="AshVendoredProbe$$" + ASH_FONT_SRC="$ASH_FONT_DIR/JetBrainsMono-Regular.ttf" \ + ASH_FONT_DST="$probe/marked/probe.ttf" ASH_FONT_MARK="$marker" python3 - <<'PY' \ + || ash_die 6 "could not build the vendored-font provenance probe" +import os +from fontTools.ttLib import TTFont +f = TTFont(os.environ['ASH_FONT_SRC']) +mark = os.environ['ASH_FONT_MARK'] +for rec in f['name'].names: + if rec.nameID in (1, 3, 4, 6, 16): + try: + cur = rec.toUnicode() + except Exception: + continue + rec.string = cur.replace('JetBrains Mono', mark).replace('JetBrainsMono', mark) +f.save(os.environ['ASH_FONT_DST']) +PY + + agg -q --font-dir "$ASH_FONT_DIR" --font-family "JetBrains Mono" --font-size 14 \ + --line-height 1.34 --fps-cap 5 --last-frame-duration 0.2 \ + "$probe/p.cast" "$probe/ours.gif" >/dev/null 2>&1 \ + || ash_die 6 "agg could not render with $ASH_FONT_DIR" + agg -q --font-dir "$probe/marked" --font-family "$marker" --font-size 14 \ + --line-height 1.34 --fps-cap 5 --last-frame-duration 0.2 \ + "$probe/p.cast" "$probe/vendored.gif" >/dev/null 2>&1 \ + || ash_die 6 "agg could not render the vendored-font provenance probe" + cmp -s "$probe/ours.gif" "$probe/vendored.gif" \ + || ash_die 6 "agg did NOT rasterise demos/fonts/JetBrainsMono-Regular.ttf — some other 'JetBrains Mono' won" + + ash_log "font: demos/fonts/JetBrainsMono-Regular.ttf asserted (family, block glyphs, and agg provably used this file)" +} +assert_font + +# =========================================================================== +# 3. Theme -> agg palette +# =========================================================================== +# theme/pg_ash.json is the ONLY file holding a colour. agg takes a custom theme +# as bg,fg,c0..c15 in bare hex, so we derive it here rather than hardcoding. +[ -f "$ASH_THEME" ] || ash_die 6 "theme file missing: $ASH_THEME" +AGG_THEME="$(ASH_THEME="$ASH_THEME" python3 - <<'PY' +import json, os, sys +t = json.load(open(os.environ['ASH_THEME'])) +def h(x): return x.lstrip('#').lower() +parts = [h(t['ui']['bg']), h(t['ui']['fg'])] + [h(c) for c in t['ansi']] +if len(parts) != 18: + sys.stderr.write("theme.ansi must hold exactly 16 colours\n"); sys.exit(1) +print(','.join(parts)) +PY +)" || ash_die 6 "could not derive an agg theme from $ASH_THEME" + +# Type metrics come from the theme too — theme/pg_ash.json is the ONLY file that +# carries a number about how this looks. reel.line_height in particular is not a +# taste setting: see the note beside it in the theme. It used to be hardcoded +# here at 1.34, which silently overrode the theme and put a 1px seam through +# every row of the flagship chart. +eval "$(ASH_THEME="$ASH_THEME" python3 - <<'PY' +import json, os +t = json.load(open(os.environ['ASH_THEME']))['reel'] +# --font-size in agg takes an integer number of pixels and rejects "14.0" +# outright, so the float the theme carries -- a float because the SVG renderer +# wants one -- is narrowed here rather than duplicated as an int in the theme. +# +# NOTE, and it cost a debugging cycle: no apostrophes in this heredoc. bash 3.2 +# (the macOS /bin/bash) mis-scans a quote inside a here-document that is inside +# a $( ) command substitution, and the whole rest of the file is swallowed as a +# quoted string. The failure surfaces 230 lines later as a syntax error on an +# innocent parenthesis. +print("REEL_FONT_SIZE=%d" % int(round(float(t['font_size'])))) +print("REEL_LINE_HEIGHT=%s" % t['line_height']) +PY +)" || ash_die 6 "could not read reel metrics from $ASH_THEME" + +# The psqlrc hardcodes three theme colours (a psqlrc cannot read JSON). Assert +# they still agree, so a theme edit cannot silently desync the prompt. +ASH_THEME="$ASH_THEME" ASH_PSQLRC="$ASH_DEMO_DIR/lib/psqlrc.demo" python3 - <<'PY' \ + || ash_die 6 "lib/psqlrc.demo prompt colours no longer match theme/pg_ash.json" +import json, os, re, sys +t = json.load(open(os.environ['ASH_THEME'])) +rc = open(os.environ['ASH_PSQLRC']).read() +def rgb(hexs): + h = hexs.lstrip('#') + return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) +want = [rgb(t['ui']['prompt_accent']), rgb(t['ui']['prompt_dim']), rgb(t['ui']['fg'])] +found = [tuple(int(x) for x in m) for m in re.findall(r'38;2;(\d+);(\d+);(\d+)', rc)] +for w in want: + if w not in found: + sys.stderr.write("psqlrc.demo is missing SGR for %s\n" % (w,)); sys.exit(1) +PY + +# =========================================================================== +# 4. Backend + the frozen window (spec §2.2) +# =========================================================================== +ensure_backend() { + if psql -X -At -c 'select 1' >/dev/null 2>&1; then return 0; fi + if [ -f "$ASH_DEMO_DIR/lib/backend.sh" ]; then + # shellcheck source=lib/backend.sh + . "$ASH_DEMO_DIR/lib/backend.sh" + backend_up || ash_die 3 "backend_up failed" + fi + psql -X -At -c 'select 1' >/dev/null 2>&1 \ + || ash_die 3 "cannot reach the demo database (PGDATABASE=${PGDATABASE:-unset})" +} + +[ -f "$ASH_WINDOW_ENV" ] || ash_die 3 "missing $ASH_WINDOW_ENV — run \`make seed\` first" +# shellcheck disable=SC1090 +. "$ASH_WINDOW_ENV" +: "${ASH_SINCE:?window.env did not define ASH_SINCE}" +: "${ASH_UNTIL:?window.env did not define ASH_UNTIL}" +: "${ASH_BASE_SINCE:?window.env did not define ASH_BASE_SINCE}" +: "${ASH_BASE_UNTIL:?window.env did not define ASH_BASE_UNTIL}" +: "${ASH_STORM_SINCE:?window.env did not define ASH_STORM_SINCE}" +: "${ASH_STORM_UNTIL:?window.env did not define ASH_STORM_UNTIL}" +export PGDATABASE="${PGDATABASE:-$ASH_DEMO_DB}" +ensure_backend + +# window.env must not be older than the newest sample: a stale window means the +# seeder re-ran and the reel would query a window that no longer describes the +# data on disk. Compare in the database, in seconds, no `date -d` anywhere. +_stale="$(psql -X -At -v ON_ERROR_STOP=1 -c " + select case when max(sample_ts) is null then 'nodata' + when ash.ts_to_timestamptz(max(sample_ts)) > to_timestamp(${ASH_SEED_EPOCH:-0}) + then 'stale' else 'ok' end + from ash.sample" 2>/dev/null || printf 'err')" +case "$_stale" in + ok) : ;; + nodata) ash_die 3 "ash.sample is empty — the seed did not take" ;; + stale) ash_die 3 "$ASH_WINDOW_ENV is older than the newest ash.sample row — re-run \`make seed\`" ;; + *) ash_die 3 "could not validate $ASH_WINDOW_ENV against ash.sample" ;; +esac +ash_log "window: $ASH_SINCE .. $ASH_UNTIL storm: $ASH_STORM_SINCE .. $ASH_STORM_UNTIL" + +# =========================================================================== +# 5. Scene table +# =========================================================================== +# Parsed here rather than in lib/scenes.sh so `make demo` works standalone; +# when lib/scenes.sh exists we let it do the validation pass first, because +# builder A owns the banned-reader / explicit-projection rules. +if [ -f "$ASH_DEMO_DIR/lib/scenes.sh" ]; then + # `bash -n` first: sourcing a file that fails to parse aborts this script with + # a bare syntax error and no context, which is a miserable thing to debug. + if bash -n "$ASH_DEMO_DIR/lib/scenes.sh" 2>/dev/null; then + # shellcheck source=lib/scenes.sh + . "$ASH_DEMO_DIR/lib/scenes.sh" + if command -v scenes_validate >/dev/null 2>&1; then scenes_validate; fi + else + ash_warn "lib/scenes.sh does not parse — skipping its validation pass" + fi +fi +[ -f "$ASH_SCENES" ] || ash_die 1 "scene file missing: $ASH_SCENES" + +# Belt and braces on the removed-v1.x-reader rule. Cheap, and it is the check +# that would have caught the landing page still advertising a reader 2.0 deleted. +# The banned names are assembled here rather than written out, because "no +# occurrence anywhere under demos/" includes this file's own source. +BANNED_RE="top_$(printf 'waits')|query_$(printf 'waits')|top_by_$(printf 'type')|timeline_$(printf 'chart')" +grep -Ev '^[[:space:]]*#' "$ASH_SCENES" > "$ASH_OUT/_scenes.nocomment" +if [ "$(grep -Ec "$BANNED_RE" "$ASH_OUT/_scenes.nocomment" || true)" -gt 0 ]; then + ash_die 1 "$ASH_SCENES references a reader removed in 2.0" +fi + +REEL_TSV="$ASH_OUT/reel.tsv" +ASH_SCENES="$ASH_SCENES" python3 - > "$REEL_TSV" <<'PY' || ash_die 1 "scenes.tsv is malformed" +import os, sys +rows = [] +for lineno, raw in enumerate(open(os.environ['ASH_SCENES'], encoding='utf-8'), 1): + line = raw.rstrip('\n') + if not line.strip() or line.lstrip().startswith('#'): + continue + cols = line.split('\t') + if len(cols) != 6: + sys.stderr.write("line %d: expected 6 tab-separated columns, got %d\n" + % (lineno, len(cols))) + sys.exit(1) + name, hold, order, title, markers, sql = cols + if 'select *' in sql.lower() and 'ash.status()' not in sql and 'ash.periods(' not in sql: + sys.stderr.write("line %d: scene %s uses `select *`\n" % (lineno, name)) + sys.exit(1) + try: + order_i = int(order); hold_f = float(hold) + except ValueError: + sys.stderr.write("line %d: bad hold/reel_order\n" % lineno); sys.exit(1) + rows.append((order_i, name, hold_f, title, markers, sql)) +reel = sorted(r for r in rows if r[0] > 0) +if not reel: + sys.stderr.write("no scene has reel_order > 0\n"); sys.exit(1) +seen = set() +for order_i, name, hold_f, title, markers, sql in reel: + if order_i in seen: + sys.stderr.write("duplicate reel_order %d\n" % order_i); sys.exit(1) + seen.add(order_i) + sys.stdout.write('\t'.join([name, repr(hold_f), title, markers, sql]) + '\n') +PY +REEL_COUNT="$(grep -c . "$REEL_TSV" || true)" +ash_log "reel: $REEL_COUNT scenes from $(basename "$ASH_SCENES")" + +# expand_sql +# literal — substitute the quoted absolute timestamp (scripted preflight) +# psqlvar — substitute :'SINCE' etc. (interactive typing; psql expands it, +# and the SCREEN shows the short symbolic form instead of a wall of +# ISO timestamps, which was a named defect in a prototype) +expand_sql() { + ASH_MODE="$1" ASH_SQL="$2" \ + V_SINCE="$ASH_SINCE" V_UNTIL="$ASH_UNTIL" \ + V_BASE_SINCE="$ASH_BASE_SINCE" V_BASE_UNTIL="$ASH_BASE_UNTIL" \ + V_STORM_SINCE="$ASH_STORM_SINCE" V_STORM_UNTIL="$ASH_STORM_UNTIL" \ + python3 - <<'PY' +import os, sys +names = ['BASE_SINCE', 'BASE_UNTIL', 'STORM_SINCE', 'STORM_UNTIL', 'SINCE', 'UNTIL'] +sql = os.environ['ASH_SQL'] +mode = os.environ['ASH_MODE'] +for n in names: # longest first: $BASE_SINCE before $SINCE + if mode == 'literal': + sub = "'" + os.environ['V_' + n] + "'" + else: + sub = ":'" + n + "'" + sql = sql.replace('$' + n, sub) +sys.stdout.write(sql) +PY +} + +# scene_terminator — how the interactive path SUBMITS this statement. +# +# A colour-emitting scene must not go through psql's aligned formatter: it +# escapes each 0x1B into the four-character text "\x1B" and then measures column +# widths from the escaped text. The flagship chart frame came out as visible +# "\x1B[38;2;080;250;123m" runs inside a box that wrapped at 100 columns — all +# three prototype designs hit this independently. `\g (format=unaligned ...)` +# hands the rows straight to the terminal with the escape bytes intact. +# +# `\g (options)` needs a psql client >= 16. Older clients get the equivalent via +# \pset, which costs two extra visible lines but never garbles the frame. +PSQL_MAJOR="$(psql --version 2>/dev/null | tr -dc '0-9. ' | awk '{print $1}' | cut -d. -f1)" +: "${PSQL_MAJOR:=0}" +scene_terminator() { + case "$1" in + *"color => true"*|*"color => TRUE"*) + # `tuples_only` is deliberately NOT set. It would shorten this line, but it + # also drops the header row, and the chart frame is the one frame in the + # reel whose columns are not self-evident — without `bucket aas chart` a + # viewer sees two unlabelled number columns next to the bars. + if [ "$PSQL_MAJOR" -ge 16 ]; then + printf "%s" "\\g (format=unaligned fieldsep=' ')" + else + # Pre-16 psql: no parenthesised \g options. Switch the format, run, and + # switch back — psql executes backslash commands left to right, so the + # restore lands before the next scene is typed. + printf "%s" "\\pset format unaligned \\pset fieldsep ' ' \\g \\pset format aligned" + fi + ;; + *) printf "" ;; + esac +} + +# =========================================================================== +# 6. PREFLIGHT — run every reel scene scripted and verify it, before recording +# =========================================================================== +# This is the gate that makes `ash.chart(color => true)`'s 238-column blowup and +# an empty result set impossible to ship silently. It costs ~2 s. +if [ "$RENDER_ONLY" = "1" ]; then + [ -s "$CAST" ] || ash_die 7 "--render-only but there is no cast at $CAST" + ash_log "--render-only: reusing $CAST" + T_REC0="$(ash_now_ms)" +else + +ash_step "preflight: executing $REEL_COUNT scenes scripted" +mkdir -p "$ASH_OUT/preflight" +MAX_SCENE_ROWS=0 +while IFS=$'\t' read -r name hold title markers sql; do + [ -n "$name" ] || continue + pf="$ASH_OUT/preflight/$name.raw" + # -A: raw ESC passthrough. psql's ALIGNED formatter escapes 0x1B into the + # four-character text \x1B and then measures column widths from the escaped + # text — 390-column border rules, garbage alignment. -A sidesteps it entirely. + # stderr merged in so a psql ERROR: lands in the file vfy_no_errors reads. + if ! psql -X -A -F ' ' -P footer=off -v ON_ERROR_STOP=1 \ + -c "$(expand_sql literal "$sql")" > "$pf" 2>&1; then + ash_log "---- $name ----"; cat "$pf" >&2 + ash_die 5 "preflight: scene '$name' failed to execute" + fi + vfy_scene "$pf" "$name" "$markers" "$ASH_COLS" + + # ROW budget. The still path can be any height; the reel cannot — a scene + # taller than the pane scrolls its own header off the top, and the header is + # usually where the marker lives. (ash.chart's legend names the wait events; + # lose it and the flagship frame is a bar chart with no key.) Reserve six + # lines for the narration \echo, its prompt, the typed statement and its + # continuation, and the trailing prompt. + rows_used="$(grep -c '' "$pf" || true)" + rows_budget=$(( ASH_ROWS - 6 )) + if [ "${rows_used:-0}" -gt "$rows_budget" ]; then + ash_die 5 "preflight: scene '$name' is $rows_used lines, over the reel budget of $rows_budget (ASH_ROWS=$ASH_ROWS); coarsen the bucket or lower n" + fi + [ "${rows_used:-0}" -gt "$MAX_SCENE_ROWS" ] && MAX_SCENE_ROWS="$rows_used" + ash_log "preflight ok: $name (${rows_used} lines)" +done < "$REEL_TSV" + +# Size the pane to the CONTENT, not to a fixed 30 rows. +# +# Every unused row is dead pixels in every frame of the GIF, and the GIF budget +# is 700 KB. At 30 rows the reel came out at 822 KB with a third of the frame +# empty; sized to the tallest scene it lands comfortably under budget with no +# loss of information. ASH_ROWS remains the CEILING, never the target. +# +# The +6 is the per-scene chrome: narration \echo, its prompt, the typed +# statement, one continuation line, the \g line, and the trailing prompt. +REEL_ROWS=$(( MAX_SCENE_ROWS + 6 )) +[ "$REEL_ROWS" -lt 18 ] && REEL_ROWS=18 +[ "$REEL_ROWS" -gt "$ASH_ROWS" ] && REEL_ROWS="$ASH_ROWS" +ash_log "pane: ${ASH_COLS}x${REEL_ROWS} (tallest scene ${MAX_SCENE_ROWS} lines, ceiling ASH_ROWS=$ASH_ROWS)" + +# =========================================================================== +# 7. Record +# =========================================================================== +# The splash banner is load-bearing, not decoration: GitHub freezes frame 1 of +# an embedded GIF as its thumbnail, so frame 1 has to say what the project is. +# Sized to ASH_COLS, generated (never hardcoded at 138 chars like the old one). +SPLASH_TXT="$ASH_OUT/splash.ansi" +ASH_COLS="$ASH_COLS" ASH_THEME="$ASH_THEME" ASH_VERSION_STR="${ASH_ASH_VERSION:-2.0}" \ +python3 - > "$SPLASH_TXT" <<'PY' +import json, os, sys +cols = int(os.environ['ASH_COLS']) +t = json.load(open(os.environ['ASH_THEME'])) +E = '\x1b' +def sgr(hexs): + h = hexs.lstrip('#') + return "%s[38;2;%d;%d;%dm" % ((E,) + tuple(int(h[i:i+2], 16) for i in (0, 2, 4))) +RESET = E + '[0m' +accent = sgr(t['ui']['prompt_accent']) +# ui.dim, not ui.prompt_dim: prompt_dim (#6272A4) is a blue-grey sized for a +# one-character prompt marker and reads as barely-there when a whole sentence is +# set in it against ui.bg. ui.dim (#7F949B) is the theme's "secondary text" role. +dim = sgr(t['ui']['dim']) +fg = sgr(t['ui']['fg']) +inner = cols - 2 +body = [ + ("", fg), + (" pg_ash 2.0 — Active Session History for PostgreSQL", accent), + ("", fg), + (" Pure SQL. No extension, no restart.", fg), + (" Installs with \\i on RDS, Cloud SQL, Supabase, AlloyDB and Neon.", fg), + ("", fg), + (" Something spiked a few minutes ago. Let's find out what.", dim), + ("", fg), +] +out = [accent + '╭' + '─' * inner + '╮' + RESET] +for text, colour in body: + pad = inner - len(text) + out.append(accent + '│' + RESET + colour + text + ' ' * max(pad, 0) + accent + '│' + RESET) +out.append(accent + '╰' + '─' * inner + '╯' + RESET) +sys.stdout.write('\n'.join(out) + '\n') +PY + +# The recorded command: splash, then psql. PSQLRC (not -X!) loads the demo +# prompt; -v passes the frozen window in as psql variables so the typed SQL can +# stay short and symbolic on screen. +LAUNCH="$ASH_OUT/launch.sh" +cat > "$LAUNCH" </dev/null || true + +ash_step "recording (${ASH_COLS}x${REEL_ROWS}, asciinema $ASCIINEMA_MAJOR)" +T_REC0="$(ash_now_ms)" +DRV_SHOT_DIR="$ASH_OUT/shots" +drv_start "$SESSION_PREFIX" "$ASH_COLS" "$REEL_ROWS" \ + "asciinema rec --overwrite --idle-time-limit 3 $REC_GEO \ + --command '$LAUNCH 2> $STDERR_LOG' '$CAST'" + +# Wait for the FIRST prompt rather than sleeping. Until psql has printed +# PROMPT1 there is no pane title, so this doubles as "psql actually started". +if ! drv_wait_prompt "" 30000 >/dev/null; then + drv_shot "$ASH_OUT/shots/_startup.ansi" || true + cat "$ASH_OUT/shots/_startup.ansi" >&2 2>/dev/null || true + ash_die 7 "psql never reached its first prompt inside tmux" +fi + +# Beat 1: hold the splash. GitHub's thumbnail is frame 1 and viewers need a +# moment to read it when the loop comes back around. +drv_hold 1.6 + +# --- the investigation ------------------------------------------------------ +# One \echo of narration, then the scene. The narration is what turns six +# query results into a story: triage -> locate -> which wait -> which query -> +# is this actually abnormal. +NARRATION_status='-- Q0: is pg_ash collecting? no pg_cron here, so: external scheduler' +NARRATION_periods='-- Q1: triage. how much history is there, and is this a spike?' +NARRATION_chart='-- Q2: when did it land, and which wait class? (24-bit colour)' +NARRATION_top_event='-- Q3: the exact wait event during the spike' +NARRATION_top_query='-- Q4: which statement is stuck on it?' +NARRATION_compare='-- Q5: incident vs the calm baseline. is this really abnormal?' + +while IFS=$'\t' read -r name hold title markers sql; do + [ -n "$name" ] || continue + eval "narr=\${NARRATION_$name:-}" + [ -n "$narr" ] && drv_note "$narr" 0.7 + body="$(expand_sql psqlvar "$sql")" + term="$(scene_terminator "$sql")" + # When a \g terminator is used the statement must NOT also carry a `;`, + # or psql executes it before reaching the backslash command. + if [ -n "$term" ]; then + case "$body" in *\;) body="${body%;}" ;; esac + fi + DRV_SQL="$body" \ + DRV_MARKERS="$markers" \ + DRV_HOLD="$hold" \ + DRV_TERMINATOR="$term" \ + drv_run "$name" +done < "$REEL_TSV" + +# --- closing lines ---------------------------------------------------------- +drv_note '-- Root cause: concurrent UPDATEs contending on one row.' 1.6 +drv_note '-- pg_ash: pure SQL, no extension, no restart. Works everywhere.' 3.4 + +prev="$(drv_title)" +drv_type '\q' +drv_enter +drv_hold 0.8 +drv_kill +T_REC1="$(ash_now_ms)" +ash_log "recording finished in $(( (T_REC1 - T_REC0) / 1000 ))s" + +[ -s "$CAST" ] || ash_die 7 "asciinema produced no cast at $CAST" + +# psql's -L transcript and the tee'd stderr are the two places a database error +# can hide. `psql -L` does NOT log errors, which is exactly why the stderr tee +# exists; check both. +for f in "$TRANSCRIPT" "$STDERR_LOG"; do + [ -f "$f" ] || continue + n="$(grep -Ec "$VFY_ERR_RE" "$f" || true)" + if [ "${n:-0}" -gt 0 ]; then + grep -En "$VFY_ERR_RE" "$f" >&2 || true + ash_die 5 "database error found in $(basename "$f")" + fi +done + +# =========================================================================== +# 8. Cast post-processing +# =========================================================================== +# asciinema stamps the first event ~70 ms in; agg renders t=0, so the GIF would +# open on an empty terminal — and that empty frame becomes GitHub's thumbnail. +# Pull the first event to 0.0. Later events carry absolute times in v2 and +# relative deltas in v3, so only the first is touched either way. +python3 - "$CAST" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as f: + lines = f.readlines() +if len(lines) > 1: + header, rest = lines[0], lines[1:] + for i, line in enumerate(rest): + if not line.strip(): + continue + ev = json.loads(line) + ev[0] = 0.0 + rest[i] = json.dumps(ev) + "\n" + break + with open(path, 'w') as f: + f.write(header) + f.writelines(rest) +PY + +fi # RENDER_ONLY + +# =========================================================================== +# 9. Render: agg -> frames -> chrome composite -> gif + mp4 +# =========================================================================== +ash_step "rendering" + +# Bare terminal, truecolor, our font. +# +# --idle-time-limit deviates from the spec's 1.2 s on purpose. The scenes.tsv +# `hold` column exists so a viewer can actually READ a table before it scrolls; +# capping idle at 1.2 s would silently truncate every one of those holds to +# 1.2 s and make the reel unreadable. 3.0 s keeps the short holds exact, trims +# the long ones, and costs almost nothing in bytes: an idle stretch is ONE gif +# frame with a longer delay, so the file size is driven by the typing animation, +# not by the pauses. +agg -q \ + --font-dir "$ASH_FONT_DIR" \ + --font-family "JetBrains Mono" \ + --font-size "$REEL_FONT_SIZE" \ + --line-height "$REEL_LINE_HEIGHT" \ + --theme "$AGG_THEME" \ + --fps-cap "${ASH_AGG_FPS:-12}" \ + --idle-time-limit "${ASH_AGG_IDLE:-3.0}" \ + --last-frame-duration 3 \ + "$CAST" "$TERM_GIF" || ash_die 6 "agg failed" + +# Measure what agg produced. NEVER hardcode these — font metrics differ across +# machines and a hardcoded plate would clip on the first mismatch. +TERM_W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$TERM_GIF")" +TERM_H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$TERM_GIF")" +[ -n "$TERM_W" ] && [ -n "$TERM_H" ] || ash_die 6 "could not measure the agg output" +ash_log "agg terminal raster: ${TERM_W}x${TERM_H}" + +# --- chrome plate ----------------------------------------------------------- +# render/chrome.py (builder B) is preferred so the reel and the stills share one +# implementation of §3.3. If it is absent or does not speak this CLI we fall +# back to an equivalent generator written from the same theme file, so the reel +# never blocks on the render plane. +CHROME_TITLE="pg_ash 2.0" +chrome_ok=0 +if [ -f "$ASH_DEMO_DIR/render/chrome.py" ]; then + # render/chrome.py prints a sourceable geometry summary so the overlay origin + # is never recomputed here — the plate and the composite cannot disagree. + if CHROME_GEOM="$(python3 "$ASH_DEMO_DIR/render/chrome.py" \ + --theme "$ASH_THEME" --inner "${TERM_W}x${TERM_H}" \ + --metrics reel --title "$CHROME_TITLE" \ + --font "$ASH_FONT_DIR/JetBrainsMono-Regular.ttf" -o "$PLATE" 2>/dev/null)"; then + chrome_ok=1 + # shellcheck disable=SC2086 + eval $CHROME_GEOM + else + ash_warn "render/chrome.py did not accept the documented CLI — using the built-in plate" + fi +fi +if [ "$chrome_ok" -eq 0 ]; then + cat > "$ASH_OUT/_chrome_plate.py" <<'PY' +#!/usr/bin/env python3 +"""Fallback chrome plate (spec §3.3), constructed from theme/pg_ash.json alone. + +Identical construction to render/ansi2svg.py's chrome: + 1. fill the canvas with ui.marginfill + 2. rounded card at (margin, margin), radius 12, ui.bg, 1px ui.border + 3. title bar height titlebar_h in ui.titlebar + a hairline along its bottom + 4. three r=5.5 dots at margin+{19,38,57}, cy = margin+19 + 5. title centred in the card, baseline cy + 0.36*size, ui.dim + 6. body origin (margin+pad_x, margin+titlebar_h+pad_y) + +Rendered at 3x and downsampled so the corners and dots are cleanly antialiased +without ffmpeg ever seeing a half-transparent edge. +""" +import argparse, json +from PIL import Image, ImageDraw, ImageFont + +p = argparse.ArgumentParser() +p.add_argument('--theme', required=True) +p.add_argument('--body-w', type=int, required=True) +p.add_argument('--body-h', type=int, required=True) +p.add_argument('--title', default='pg_ash 2.0') +p.add_argument('--font') +p.add_argument('--out', required=True) +a = p.parse_args() + +t = json.load(open(a.theme)) +c, ui = t['chrome'], t['ui'] +M, R, TB = c['margin'], c['radius'], c['titlebar_h'] +PX, PY = c['pad_x'], c['pad_y'] + +card_w = a.body_w + 2 * PX +card_h = TB + 2 * PY + a.body_h +W, H = card_w + 2 * M, card_h + 2 * M + +S = 3 # supersample +img = Image.new('RGB', (W * S, H * S), ui['marginfill']) +d = ImageDraw.Draw(img) +d.rounded_rectangle([M * S, M * S, (M + card_w) * S - 1, (M + card_h) * S - 1], + radius=R * S, fill=ui['bg'], outline=ui['border'], + width=max(1, c['border'] * S)) +# Title bar: rounded on top, square at the bottom, then a hairline rule. +d.rounded_rectangle([M * S, M * S, (M + card_w) * S - 1, (M + TB) * S], + radius=R * S, fill=ui['titlebar']) +d.rectangle([M * S, (M + TB - R) * S, (M + card_w) * S - 1, (M + TB) * S], + fill=ui['titlebar']) +d.line([(M * S, (M + TB) * S), ((M + card_w) * S - 1, (M + TB) * S)], + fill=ui['border'], width=max(1, S)) +cy = (M + 19) * S +for dx, col in zip(c['dot_x'], ui['dots']): + cx = (M + dx) * S + r = c['dot_r'] * S + d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=col) + +img = img.resize((W, H), Image.LANCZOS) +d = ImageDraw.Draw(img) +size = int(round(14 * c['title_size_em'])) +font = None +if a.font: + try: + font = ImageFont.truetype(a.font, size) + except Exception: + font = None +if font is None: + font = ImageFont.load_default() +tw = d.textlength(a.title, font=font) +d.text((M + (card_w - tw) / 2, M + 19 - size * 0.62), a.title, + font=font, fill=ui['dim']) +img.save(a.out) +print('%d %d %d %d' % (W, H, M + PX, M + TB + PY)) +PY + python3 "$ASH_OUT/_chrome_plate.py" --theme "$ASH_THEME" \ + --body-w "$TERM_W" --body-h "$TERM_H" --title "$CHROME_TITLE" \ + --font "$ASH_FONT_DIR/JetBrainsMono-Regular.ttf" --out "$PLATE" >/dev/null \ + || ash_die 6 "chrome plate generation failed" +fi +[ -s "$PLATE" ] || ash_die 6 "chrome plate is empty" + +# Body origin. render/chrome.py reports it; the fallback derives it from the +# same theme numbers. Either way it is never a literal in this file. +if [ -n "${ASH_PLATE_X:-}" ] && [ -n "${ASH_PLATE_Y:-}" ]; then + OX="$ASH_PLATE_X"; OY="$ASH_PLATE_Y" +else + OX="$(ASH_THEME="$ASH_THEME" python3 -c " +import json,os +c=json.load(open(os.environ['ASH_THEME']))['chrome'] +print(c['margin']+c['pad_x'])")" + OY="$(ASH_THEME="$ASH_THEME" python3 -c " +import json,os +c=json.load(open(os.environ['ASH_THEME']))['chrome'] +print(c['margin']+c['titlebar_h']+c['pad_y'])")" +fi +PLATE_W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$PLATE")" +PLATE_H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$PLATE")" +ash_log "chrome plate: ${PLATE_W}x${PLATE_H}, body origin ${OX},${OY}" +# The plate must actually contain the terminal at that origin, or the composite +# silently clips the last rows — the class of bug a hardcoded plate size causes. +if [ "$(( OX + TERM_W ))" -gt "$PLATE_W" ] || [ "$(( OY + TERM_H ))" -gt "$PLATE_H" ]; then + ash_die 6 "chrome plate ${PLATE_W}x${PLATE_H} cannot hold a ${TERM_W}x${TERM_H} terminal at ${OX},${OY}" +fi + +# --- composite -------------------------------------------------------------- +mkdir -p "$ASH_ASSETS" +# One filter graph, run twice: once to a palettised GIF, once to H.264. Both +# read the same two inputs, so the reel and the mp4 are genuinely the same +# render rather than two recordings that drifted. +FILTER="[1:v][0:v]overlay=x=${OX}:y=${OY}:format=rgb:shortest=1[v]" + +# GIF, in TWO passes. The single-pass `split` + palettegen + paletteuse graph is +# the idiom everyone reaches for, and it OOM-killed ffmpeg here: `split` has to +# buffer every composited frame in memory until palettegen has seen the last one. +# Writing the palette to a PNG first costs one extra decode and bounds memory. +# +# stats_mode=full builds ONE palette across all frames, so a colour that appears +# only in the chart scene is not evicted by the twenty seconds of calm around it. +# dither=none keeps flat block colour flat — dithering is what turns a solid +# #FF5555 bar into speckle and destroys the exact-RGB guarantee. +# shortest=1 is NOT optional. `-loop 1` makes the plate an INFINITE single-frame +# stream, and overlay's base input is the plate, so without it the filter graph +# never ends: the first attempt OOM-killed ffmpeg buffering frames for +# palettegen, the second ran for seven minutes producing nothing. +PALETTE_PNG="$ASH_OUT/palette.png" +ffmpeg -y -v error -i "$TERM_GIF" -loop 1 -i "$PLATE" \ + -filter_complex "${FILTER};[v]palettegen=max_colors=256:stats_mode=full" \ + -frames:v 1 "$PALETTE_PNG" || ash_die 6 "ffmpeg palettegen failed" +ffmpeg -y -v error -i "$TERM_GIF" -loop 1 -i "$PLATE" -i "$PALETTE_PNG" \ + -filter_complex "${FILTER};[v][2:v]paletteuse=dither=none:diff_mode=rectangle" \ + -loop 0 "$GIF" || ash_die 6 "ffmpeg gif composite failed" + +# MP4 from the same graph, so the reel and the video are genuinely one render +# rather than two recordings that drifted. yuv420p + even dimensions for player +# compatibility, and an explicit constant frame rate: fed the GIF's variable +# timebase directly, x264 produced a file LARGER than the GIF. +ffmpeg -y -v error -i "$TERM_GIF" -loop 1 -i "$PLATE" \ + -filter_complex "${FILTER};[v]scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p[o]" \ + -map '[o]' -c:v libx264 -preset slow -crf 20 -movflags +faststart \ + -r "${ASH_MP4_FPS:-15}" -pix_fmt yuv420p "$MP4" || ash_die 6 "ffmpeg mp4 encode failed" + +# gifsicle: lossless optimisation ONLY. --lossy / --colors would quantise the +# 24-bit wait-class palette that the whole exercise exists to preserve. +if ash_have gifsicle; then + gifsicle -O3 "$GIF" -o "$GIF.opt" >/dev/null 2>&1 && mv "$GIF.opt" "$GIF" +fi + +# =========================================================================== +# 10. Post-render verification +# =========================================================================== +ash_step "verifying the rendered reel" +GIF_BYTES="$(wc -c < "$GIF" | tr -d ' ')" +GIF_W="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$GIF")" +GIF_H="$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$GIF")" +GIF_FRAMES="$(ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 "$GIF" 2>/dev/null || printf '0')" + +# Pixel-sample every frame for the exact docs/COLOR_SCHEME.md RGB values. This +# is the assertion that "truecolor survived" is a measurement and not a hope. +# We require the wait classes the reel actually exercises; the rest are checked +# opportunistically and reported. +# The frames are read straight out of the GIF with Pillow rather than exploded +# to PNG with ffmpeg first. Writing ~1800 PNGs and decoding them again cost +# about 50 of the 85 seconds this script spent after the recording, for no +# information: Pillow seeks GIF frames natively. +# +# ASH_KEEP_FRAMES=1 writes them out anyway, which is how you eyeball a single +# frame when something looks wrong. +if [ "${ASH_KEEP_FRAMES:-}" = "1" ]; then + rm -rf "$ASH_OUT/gifframes"; mkdir -p "$ASH_OUT/gifframes" + ffmpeg -y -v error -i "$GIF" "$ASH_OUT/gifframes/%05d.png" \ + || ash_die 6 "could not extract frames from $GIF" + ash_log "ASH_KEEP_FRAMES=1: frames written to $ASH_OUT/gifframes" +fi + +# Which wait classes MUST survive byte-exact. +# +# Lock and CPU* are the two series ash.chart draws with U+2588 in this reel, so +# they are solid ink and must land on the literal RGB. Other is the `·` +# remainder column, i.e. ordinary text — it proves the palette did not touch +# glyph colour either. +# +# Client is deliberately NOT required. It is the rank-4 series, drawn with +# U+2591 LIGHT SHADE — a 25%-ink dither pattern about one pixel wide. Blended +# against the background by the rasteriser, it genuinely never reaches its +# literal RGB on screen, in agg or in any terminal. Demanding an exact hit for +# it would not be a stronger test, it would be a wrong one. The report below +# still prints how far off every class is, so a real quantisation shows up. +ASH_GIF="$GIF" ASH_REQUIRED_COLORS="${ASH_REQUIRED_COLORS:-Lock,CPU*,Other}" \ +python3 - <<'PY' || ash_die 6 "the rendered GIF lost the 24-bit wait-class palette" +import os, sys +from collections import Counter +from PIL import Image, ImageSequence + +# docs/COLOR_SCHEME.md, verbatim. +PALETTE = [ + ("CPU*", (80, 250, 123)), + ("IdleTx", (241, 250, 140)), + ("IO", (30, 100, 255)), + ("Lock", (255, 85, 85)), + ("LWLock", (255, 121, 198)), + ("IPC", (0, 200, 255)), + ("Client", (255, 220, 100)), + ("Timeout", (255, 165, 0)), + ("BufferPin", (0, 210, 180)), + ("Activity", (150, 100, 255)), + ("Extension", (190, 150, 255)), + ("Other", (180, 180, 180)), +] +# Exact hits are what a solid run of U+2588 and every glyph interior produce. +# But ash.chart draws its rank 2/3/4 series with U+2593/2591/2592, and in a +# TERMINAL those are not translucent blocks — they are literal dither patterns +# cut into the glyph. Their strokes are roughly one pixel wide and they do not +# land on pixel boundaries, so antialiasing can leave a series with no pixel at +# its literal RGB even though the bar is plainly, correctly that colour. +# +# (The stills renderer does not have this problem: it promotes the glyph to a +# at theme.shade opacity. This is a real difference between the two +# artifacts, not a bug in either.) +# +# So: exact match preferred, near match accepted, and "near" is defined tightly. +# The closest pair in the whole docs/COLOR_SCHEME.md palette is Activity +# (150,100,255) and Extension (190,150,255) at a Chebyshev distance of 50, so a +# tolerance of 20 can never let one wait class stand in for another, and cannot +# let a quantised-into-mud palette pass either. +TOL = 20 + + +def nearest(counts, rgb): + """(distance, exact?) of the closest pixel in the raster to `rgb`.""" + if counts.get(rgb): + return 0, True + best = 999 + r, g, b = rgb + for (pr, pg, pb) in counts: + d = max(abs(pr - r), abs(pg - g), abs(pb - b)) + if d < best: + best = d + if best == 0: + break + return best, False + + +seen = Counter() +near = {} +gif = Image.open(os.environ['ASH_GIF']) +nframes = 0 +mid_counts = None +mid_size = (0, 0) +total = getattr(gif, 'n_frames', 1) +for frame in ImageSequence.Iterator(gif): + im = frame.convert('RGB') + # getcolors is a C-level histogram; Counter(im.getdata()) walks half a + # million Python ints per frame and there are ~1800 frames. + counts = dict((rgb, n) for n, rgb in (im.getcolors(1 << 24) or [])) + for name, rgb in PALETTE: + if counts.get(rgb): + seen[name] += counts[rgb] + elif name not in near or near[name] > 0: + d, _ = nearest(counts, rgb) + near[name] = min(near.get(name, 999), d) + if nframes == total // 2: + mid_counts, mid_size = counts, im.size + nframes += 1 +if not nframes: + sys.stderr.write("no frames in the GIF\n"); sys.exit(1) + +# Non-triviality: an all-background GIF must fail. Sample the middle frame. +if mid_counts is None: + mid_counts, mid_size = counts, im.size +bg_share = max(mid_counts.values()) / float(mid_size[0] * mid_size[1]) +frames = range(nframes) + +def status(name): + if seen.get(name): + return "%d px exact" % seen[name] + d = near.get(name, 999) + if d <= TOL: + return "within %d (dithered glyph)" % d + return "absent" + + +print(" frames sampled: %d" % len(frames)) +for name, rgb in PALETTE: + print(" %-10s %-16s %s" % (name, str(rgb), status(name))) +print(" dominant colour share in the middle frame: %.1f%%" % (bg_share * 100)) + +required = [x.strip() for x in os.environ['ASH_REQUIRED_COLORS'].split(',') if x.strip()] +missing = [n for n in required + if not seen.get(n) and near.get(n, 999) > TOL] +if missing: + sys.stderr.write("required wait-class colours absent from the GIF: %s\n" + % ', '.join("%s (nearest pixel %s away)" + % (n, near.get(n, '?')) for n in missing)) + sys.exit(1) +if bg_share > 0.995: + sys.stderr.write("the reel is effectively blank (%.2f%% one colour)\n" % (bg_share * 100)) + sys.exit(1) +PY + +if [ "$GIF_BYTES" -gt "$GIF_BUDGET_BYTES" ]; then + ash_die 6 "GIF is $GIF_BYTES bytes, over the $GIF_BUDGET_BYTES budget" +fi + +T_END="$(ash_now_ms)" +ash_step "done" +printf ' gif %s %sx%s %s bytes %s frames\n' "$GIF" "$GIF_W" "$GIF_H" "$GIF_BYTES" "$GIF_FRAMES" >&2 +printf ' mp4 %s %s bytes\n' "$MP4" "$(wc -c < "$MP4" | tr -d ' ')" >&2 +printf ' cast %s\n' "$CAST" >&2 +printf ' total %ss\n' "$(( (T_END - T_REC0) / 1000 ))" >&2 diff --git a/demos/bin/rot-check.sh b/demos/bin/rot-check.sh new file mode 100755 index 0000000..d1db238 --- /dev/null +++ b/demos/bin/rot-check.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# bin/rot-check.sh -- the demo-rot alarm. Backs `make rot`, and it is the step +# CI runs after a live re-capture. +# +# `make check` proves the RENDERER still works on frozen bytes. This proves the +# BYTES still have the shape the frozen ones had -- i.e. that pg_ash's readers +# still project the same columns under the same names. See render/scene_shape.py +# for why this is a fingerprint comparison and not a diff; the short version is +# that every number in a capture is a real measurement, so a byte diff is red on +# every run and therefore ignored on every run. +# +# Requires a fresh capture in $ASH_OUT/raw (i.e. `make capture` or `make stills` +# first) and fixtures/shape.tsv, which `make fixtures` writes. +# +# Exit 0 clean, 1 usage, 5 on drift (§2.6: capture verification failure). +# +set -Eeuo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +DEMO_DIR=$(cd "$HERE/.." && pwd -P) +# shellcheck source=../lib/env.sh +. "$DEMO_DIR/lib/env.sh" + +SHAPE_TSV="$DEMO_DIR/fixtures/shape.tsv" +RAW_DIR="$ASH_OUT/raw" + +[ -d "$RAW_DIR" ] || { + echo "rot-check: no $RAW_DIR -- run \`make capture\` first" >&2; exit 1; } +[ -f "$SHAPE_TSV" ] || { + echo "rot-check: no $SHAPE_TSV -- run \`make fixtures\` first" >&2; exit 1; } + +drift=0 +n=0 + +# fd 3, not stdin: children inherit stdin and at least one of them reads it. +while IFS=$'\t' read -r name want <&3; do + case "$name" in ""|\#*) continue ;; esac + raw="$RAW_DIR/$name.raw" + if [ ! -f "$raw" ]; then + printf 'rot-check: %s: no fresh capture at %s\n' "$name" "$raw" >&2 + drift=1 + continue + fi + n=$((n + 1)) + got=$(python3 "$DEMO_DIR/render/scene_shape.py" -F $'\x1f' "$raw") + if [ "$got" != "$want" ]; then + printf 'rot-check: %s: the reader contract changed\n' "$name" >&2 + printf ' committed: %s\n' "$want" >&2 + printf ' now: %s\n' "$got" >&2 + drift=1 + fi +done 3< "$SHAPE_TSV" + +if [ "$drift" -ne 0 ]; then + cat >&2 <<'MSG' + +A column was renamed, added, removed or reordered, or a report key moved. +That is a real change to what pg_ash prints, and every image in README.md is +now a picture of an older API. Review it, then accept it deliberately with: + + make -C demos fixtures +MSG + exit 5 +fi + +printf 'rot-check: %d reader contract(s) unchanged\n' "$n" >&2 diff --git a/demos/container-entrypoint.sh b/demos/container-entrypoint.sh deleted file mode 100755 index eb12d40..0000000 --- a/demos/container-entrypoint.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# container-entrypoint.sh — Runs inside the postgres:18 container. -# -# 1. Waits for PostgreSQL to accept connections. -# 2. Creates the `demo` database, pg_stat_statements + pg_cron extensions. -# 3. Installs pg_ash 2.0 (from the mounted /repo/devel/sql/ash-install.sql — -# the in-progress 2.0 dev installer, not the frozen released sql/). -# 4. Initializes pgbench. -# 5. Starts pg_ash sampling (1s). -# 6. Kicks off the workload.sh in the background — this produces the spike -# that the recording will investigate moments later. -# 7. Sleeps forever so the container stays up for `docker exec` from the -# tmux-driven recorder on the host. - -set -euo pipefail - -# UNIX-socket peer auth — no PGPASSWORD needed. -export PSQL="psql -X -qAt -U postgres -h /var/run/postgresql" - -echo "[entry] waiting for PostgreSQL..." -until $PSQL -d postgres -c "select 1" >/dev/null 2>&1; do sleep 0.3; done -echo "[entry] PostgreSQL ready" - -# Only set up once — safe for container restarts. -if ! $PSQL -d postgres -tc "select 1 from pg_database where datname='demo'" | grep -q 1; then - echo "[entry] creating demo db + extensions" - $PSQL -d postgres -c "create database demo" - # pg_cron's CREATE EXTENSION must run against the database named in - # cron.database_name (we set that to 'demo' in postgresql.conf). - $PSQL -d demo -c "create extension if not exists pg_cron" - $PSQL -d demo -c "create extension if not exists pg_stat_statements" - - # Install the 2.0 dev installer. The released sql/ is frozen at 1.5 until the - # 2.0 release stamp, so the demo points at devel/sql/ to exercise the new - # reader API (periods/aas/timeline/top/compare/chart/summary). Keep in sync - # with `devel/scripts/ash_sql_chain.py fresh-install-path` if that path moves. - echo "[entry] installing pg_ash 2.0 (\i /repo/devel/sql/ash-install.sql)" - $PSQL -d demo -f /repo/devel/sql/ash-install.sql >/dev/null - - echo "[entry] initializing pgbench (scale 5)" - pgbench -U postgres -d demo -i -s 5 -q >/dev/null 2>&1 - - echo "[entry] starting pg_ash sampling (1s)" - $PSQL -d demo -c "select ash.start('1 second')" >/dev/null - - echo "[entry] pg_ash status:" - $PSQL -d demo -c "select metric, value from ash.status() where metric in ('version','sampling_enabled','pg_cron_available')" -fi - -# Kick off the workload. Send its logs to /tmp/workload.log for debugging; -# the investigation view never sees these. -echo "[entry] starting workload" -nohup bash /repo/demos/workload.sh >/tmp/workload.log 2>&1 & -echo "[entry] workload pid=$!" - -echo "[entry] ready — sleeping forever so the recorder can exec psql" -# Keep container alive. -tail -f /dev/null diff --git a/demos/fixtures/chart.ansi b/demos/fixtures/chart.ansi new file mode 100644 index 0000000..316df33 --- /dev/null +++ b/demos/fixtures/chart.ansi @@ -0,0 +1,21 @@ +ash ▸ select to_char(bucket_start, 'HH24:MI') as bucket, aas, rtrim(chart) as chart from + ash.chart(since => $SINCE, until => $UNTIL, bucket => '2 minutes', n => 4, width => 66, color + => true); + +┌────────┬───────┬──────────────────────────────────────────────────────────────────────────┐ +│ bucket │ aas │ chart │ +├────────┼───────┼──────────────────────────────────────────────────────────────────────────┤ +│ │ │ █ Lock:transactionid ▓ CPU* ░ Client:ClientRead ▒ Lock:tuple · Other │ +│ 22:36 │ 1.75 │ ▓▓▓▓▓▓░ │ +│ 22:38 │ 1.75 │ ▓▓▓▓▓▓▓░ │ +│ 22:40 │ 1.42 │ ▓▓▓▓▓░ │ +│ 22:42 │ 1.42 │ ▓▓▓▓▓▓░ │ +│ 22:44 │ 14.67 │ ███████████████████████████████████████████▓▓▓▓▓▓░░░▒▒▒▒▒▒▒▒······· │ +│ 22:46 │ 14.54 │ ████████████████████████████████████████████▓▓▓▓▓░░░▒▒▒▒▒▒········ │ +│ 22:48 │ 9.17 │ █████████████████████▓▓▓▓▓░░░▒▒▒▒······· │ +│ 22:50 │ 3.54 │ █▓▓▓░░░········· │ +│ 22:52 │ 1.96 │ ▓▓▓▓▓▓▓▓░ │ +│ 22:54 │ 1.92 │ ▓▓▓▓▓▓▓▓░ │ +│ 22:56 │ 1.75 │ ▓▓▓▓▓▓▓░ │ +│ 22:58 │ 1.75 │ ▓▓▓▓▓▓▓░ │ +└────────┴───────┴──────────────────────────────────────────────────────────────────────────┘ diff --git a/demos/fixtures/compare.ansi b/demos/fixtures/compare.ansi new file mode 100644 index 0000000..56aea2f --- /dev/null +++ b/demos/fixtures/compare.ansi @@ -0,0 +1,12 @@ +ash ▸ select key, avg_aas_1, avg_aas_2, avg_delta, pct_2 from ash.compare($BASE_SINCE, $BASE_UNTIL, + $STORM_SINCE, $STORM_UNTIL, dimension => 'wait_event', n => 5); + +┌────────────────────┬───────────┬───────────┬───────────┬───────┐ +│ key │ avg_aas_1 │ avg_aas_2 │ avg_delta │ pct_2 │ +├────────────────────┼───────────┼───────────┼───────────┼───────┤ +│ Lock:transactionid │ │ 9.55 │ 9.55 │ 65.41 │ +│ Lock:tuple │ │ 1.53 │ 1.53 │ 10.50 │ +│ IdleTx │ │ 0.77 │ 0.77 │ 5.25 │ +│ IO:WalSync │ │ 0.58 │ 0.58 │ 4.00 │ +│ Client:ClientRead │ 0.24 │ 0.63 │ 0.39 │ 4.34 │ +└────────────────────┴───────────┴───────────┴───────────┴───────┘ diff --git a/demos/fixtures/expected/chart.svg b/demos/fixtures/expected/chart.svg new file mode 100644 index 0000000..4ee4987 --- /dev/null +++ b/demos/fixtures/expected/chart.svg @@ -0,0 +1 @@ +ash.chart() — AAS by wait eventashselect to_char(bucket_start, 'HH24:MI') as bucket, aas, rtrim(chart) as chart fromash.chart(since => $SINCE, until => $UNTIL, bucket => '2 minutes', n => 4, width => 66, color=> true);┌────────┬───────┬──────────────────────────────────────────────────────────────────────────┐bucketaaschart├────────┼───────┼──────────────────────────────────────────────────────────────────────────┤Lock:transactionidCPU*Client:ClientReadLock:tuple·Other22:361.7522:381.7522:401.4222:421.4222:4414.67·······22:4614.54········22:489.17·······22:503.54·········22:521.9622:541.9222:561.7522:581.75└────────┴───────┴──────────────────────────────────────────────────────────────────────────┘ diff --git a/demos/fixtures/expected/compare.svg b/demos/fixtures/expected/compare.svg new file mode 100644 index 0000000..71bbae6 --- /dev/null +++ b/demos/fixtures/expected/compare.svg @@ -0,0 +1 @@ +ash.compare() — incident vs baselineashselect key, avg_aas_1, avg_aas_2, avg_delta, pct_2 from ash.compare($BASE_SINCE, $BASE_UNTIL,$STORM_SINCE, $STORM_UNTIL, dimension => 'wait_event', n => 5);┌────────────────────┬───────────┬───────────┬───────────┬───────┐keyavg_aas_1avg_aas_2avg_deltapct_2├────────────────────┼───────────┼───────────┼───────────┼───────┤Lock:transactionid9.559.5565.41Lock:tuple1.531.5310.50IdleTx0.770.775.25IO:WalSync0.580.584.00Client:ClientRead0.240.630.394.34└────────────────────┴───────────┴───────────┴───────────┴───────┘ diff --git a/demos/fixtures/expected/periods.svg b/demos/fixtures/expected/periods.svg new file mode 100644 index 0000000..df4afb7 --- /dev/null +++ b/demos/fixtures/expected/periods.svg @@ -0,0 +1 @@ +ash.periods() — what do I even have?ashselect period, source, buckets_with_data, avg_aas, peak_aas, p99_aas from ash.periods($UNTIL);┌────────┬───────────┬───────────────────┬─────────┬──────────┬─────────┐periodsourcebuckets_with_dataavg_aaspeak_aasp99_aas├────────┼───────────┼───────────────────┼─────────┼──────────┼─────────┤1mraw11.921.921.925mraw51.771.921.911hrollup_1h281.9514.7514.711drollup_1h280.0814.7514.711wrollup_1h280.0114.7514.711morollup_1h280.0014.7514.71└────────┴───────────┴───────────────────┴─────────┴──────────┴─────────┘ diff --git a/demos/fixtures/expected/report.svg b/demos/fixtures/expected/report.svg new file mode 100644 index 0000000..f50e71a --- /dev/null +++ b/demos/fixtures/expected/report.svg @@ -0,0 +1 @@ +ash.report() — hand this to an LLMashselect jsonb_pretty(ash.report(since => $STORM_SINCE, until => $STORM_UNTIL, n => 2) -array['cluster_name', 'aas_p999', 'aas_worst1m', 'top_events_p999', 'top_events_worst1m','top_queryids_p999', 'top_queryids_worst1m']) as report;{"aas_avg": {"io": 0.68,"cpu": 1.20,"ipc": 0.07,"lock": 11.08,"total": 13.20,"lwlock": 0.17},"aas_p99": {"io": 0.91,"cpu": 1.49,"ipc": 0.08,"lock": 11.16,"total": 13.49,"lwlock": 0.33},"coverage": {"to": "2026-07-27T22:49:00+00:00","from": "2026-07-27T22:44:00+00:00","source": "rollup_1m","minutes_expected": 5,"minutes_with_data": 5,"raw_retention_start": "2026-07-26T23:36:00+00:00"},"top_events_p99": {"io": ["WalSync(0.8)","DataFileRead(0.1)"],"ipc": ["BgworkerShutdown(0.1)"],"lock": ["transactionid(10.5)","tuple(0.7)"],"lwlock": ["WALWrite(0.3)"]},"top_queryids_p99": {"io": ["-2835399305386018931(0.3)","-7079852731576173083(0.2)"],"ipc": ["6627483964256586084(0.1)"],"lock": ["-1236273962537500931(11.0)","-7185852639686726986(0.1)"],"total": ["-1236273962537500931(11.1)","-2835399305386018931(1.1)"],"lwlock": ["-2835399305386018931(0.3)"]},"top_queryids_available": true} diff --git a/demos/fixtures/expected/status.svg b/demos/fixtures/expected/status.svg new file mode 100644 index 0000000..3276cce --- /dev/null +++ b/demos/fixtures/expected/status.svg @@ -0,0 +1 @@ +pg_ash 2.0 — is it collecting?ashselect metric, value from ash.status() where metric in ('version','sampling_enabled','sample_interval','samples_total','raw_retention','pg_cron_available');┌───────────────────┬─────────────────────────────┐metricvalue├───────────────────┼─────────────────────────────┤version2.0-beta1sampling_enabledtruesample_interval00:00:05raw_retention1 day + current partialsamples_total326pg_cron_availableno (use external scheduler)└───────────────────┴─────────────────────────────┘ diff --git a/demos/fixtures/expected/summary.svg b/demos/fixtures/expected/summary.svg new file mode 100644 index 0000000..b153903 --- /dev/null +++ b/demos/fixtures/expected/summary.svg @@ -0,0 +1 @@ +ash.summary() — the one-paragraph verdictashselect metric, value from ash.summary(since => $STORM_SINCE, until => $STORM_UNTIL);┌────────────────────────┬────────────────────────────────────────────────────────────────────────┐metricvalue├────────────────────────┼────────────────────────────────────────────────────────────────────────┤period_start2026-07-27 22:44:00+00period_end2026-07-27 22:49:00+00sourcerawbuckets_with_data5avg_aas14.60peak_aas14.75p99_aas14.74backend_seconds4380.00drill_sourcerawdrill_period_start2026-07-27 22:44:00+00drill_period_end2026-07-27 22:49:00+00drill_effective_bucket00:01:00databases_active1top_wait_1Lock:transactionid (avg_aas 9.55, 65.41%)top_wait_2Lock:tuple (avg_aas 1.53, 10.50%)top_wait_3CPU* (avg_aas 1.20, 8.22%)top_query_1-1236273962537500931 — UPDATE pgbench_accounts SET abalance = abalance+ $1 WHERE a (avg_aas 11.35, 77.74%)top_query_26627483964256586084 — SELECT sum(abalance) FROM pgbench_accounts WHEREbid = $1 (avg_aas 0.97, 6.62%)top_query_3-2835399305386018931 — END (avg_aas 0.78, 5.37%)└────────────────────────┴────────────────────────────────────────────────────────────────────────┘ diff --git a/demos/fixtures/expected/top_event.svg b/demos/fixtures/expected/top_event.svg new file mode 100644 index 0000000..086c7ed --- /dev/null +++ b/demos/fixtures/expected/top_event.svg @@ -0,0 +1 @@ +ash.top('wait_event') — the exact waitashselect key, avg_aas, peak_aas, p99_aas, pct from ash.top('wait_event', since => $STORM_SINCE,until => $STORM_UNTIL, n => 6);┌────────────────────┬─────────┬──────────┬─────────┬───────┐keyavg_aaspeak_aasp99_aaspct├────────────────────┼─────────┼──────────┼─────────┼───────┤Lock:transactionid9.5510.5010.4865.41Lock:tuple1.532.672.6310.50CPU*1.201.501.498.22IdleTx0.771.171.155.25Client:ClientRead0.630.750.754.34IO:WalSync0.580.750.754.00└────────────────────┴─────────┴──────────┴─────────┴───────┘ diff --git a/demos/fixtures/expected/top_query.svg b/demos/fixtures/expected/top_query.svg new file mode 100644 index 0000000..ce040c2 --- /dev/null +++ b/demos/fixtures/expected/top_query.svg @@ -0,0 +1 @@ +ash.top('query_id') — which statement?ashselect key, left(query_text, 52) as query_text, avg_aas, pct from ash.top('query_id', since=> $STORM_SINCE, until => $STORM_UNTIL, n => 3);┌──────────────────────┬──────────────────────────────────────────────────────┬─────────┬───────┐keyquery_textavg_aaspct├──────────────────────┼──────────────────────────────────────────────────────┼─────────┼───────┤-1236273962537500931UPDATE pgbench_accounts SET abalance = abalance + $111.3577.746627483964256586084SELECT sum(abalance) FROM pgbench_accounts WHERE bid0.976.62-2835399305386018931END0.785.37└──────────────────────┴──────────────────────────────────────────────────────┴─────────┴───────┘ diff --git a/demos/fixtures/manifest.tsv b/demos/fixtures/manifest.tsv new file mode 100644 index 0000000..367ef9f --- /dev/null +++ b/demos/fixtures/manifest.tsv @@ -0,0 +1,8 @@ +status 3748c60347e6acaa5f39007eb6f1157bbbf0bdb9d5db6b353cc4cb5241df7f0e pg_ash 2.0 — is it collecting? +periods a5cabf0292e8ac69a074457e3b87ffc8213979857f90d86ec1face432f6bca3e ash.periods() — what do I even have? +chart 7adec15902200c480e7ed002b4153bb961ee2cfbe1827838045d5394e2bc9e54 ash.chart() — AAS by wait event +top_event 9625128ed0917ad59f41aa5b624fe747afe0bec4e39941d46c7ba68cd4b8c229 ash.top('wait_event') — the exact wait +top_query d8843bcda15a8bc498f312a37c8e2c7f3db9fa7bfe40ec8a21512684bb1ebc1c ash.top('query_id') — which statement? +compare 995bfc462998f289bc4b9a5518162a6312857e9cfb2321e74b36c26e0eb4e64a ash.compare() — incident vs baseline +summary 816a7f61ce72c19acf440b8c69bcdb0a1f07ee304d2547fc65269ae7dbaeb8f1 ash.summary() — the one-paragraph verdict +report 7ace646609377622c617810ad131ab3cfb289826799976b7553ed90563784c06 ash.report() — hand this to an LLM diff --git a/demos/fixtures/periods.ansi b/demos/fixtures/periods.ansi new file mode 100644 index 0000000..eed93b6 --- /dev/null +++ b/demos/fixtures/periods.ansi @@ -0,0 +1,12 @@ +ash ▸ select period, source, buckets_with_data, avg_aas, peak_aas, p99_aas from ash.periods($UNTIL); + +┌────────┬───────────┬───────────────────┬─────────┬──────────┬─────────┐ +│ period │ source │ buckets_with_data │ avg_aas │ peak_aas │ p99_aas │ +├────────┼───────────┼───────────────────┼─────────┼──────────┼─────────┤ +│ 1m │ raw │ 1 │ 1.92 │ 1.92 │ 1.92 │ +│ 5m │ raw │ 5 │ 1.77 │ 1.92 │ 1.91 │ +│ 1h │ rollup_1h │ 28 │ 1.95 │ 14.75 │ 14.71 │ +│ 1d │ rollup_1h │ 28 │ 0.08 │ 14.75 │ 14.71 │ +│ 1w │ rollup_1h │ 28 │ 0.01 │ 14.75 │ 14.71 │ +│ 1mo │ rollup_1h │ 28 │ 0.00 │ 14.75 │ 14.71 │ +└────────┴───────────┴───────────────────┴─────────┴──────────┴─────────┘ diff --git a/demos/fixtures/report.ansi b/demos/fixtures/report.ansi new file mode 100644 index 0000000..812c1d5 --- /dev/null +++ b/demos/fixtures/report.ansi @@ -0,0 +1,67 @@ +ash ▸ select jsonb_pretty(ash.report(since => $STORM_SINCE, until => $STORM_UNTIL, n => 2) - + array['cluster_name', 'aas_p999', 'aas_worst1m', 'top_events_p999', 'top_events_worst1m', + 'top_queryids_p999', 'top_queryids_worst1m']) as report; + +{ + "aas_avg": { + "io": 0.68, + "cpu": 1.20, + "ipc": 0.07, + "lock": 11.08, + "total": 13.20, + "lwlock": 0.17 + }, + "aas_p99": { + "io": 0.91, + "cpu": 1.49, + "ipc": 0.08, + "lock": 11.16, + "total": 13.49, + "lwlock": 0.33 + }, + "coverage": { + "to": "2026-07-27T22:49:00+00:00", + "from": "2026-07-27T22:44:00+00:00", + "source": "rollup_1m", + "minutes_expected": 5, + "minutes_with_data": 5, + "raw_retention_start": "2026-07-26T23:36:00+00:00" + }, + "top_events_p99": { + "io": [ + "WalSync(0.8)", + "DataFileRead(0.1)" + ], + "ipc": [ + "BgworkerShutdown(0.1)" + ], + "lock": [ + "transactionid(10.5)", + "tuple(0.7)" + ], + "lwlock": [ + "WALWrite(0.3)" + ] + }, + "top_queryids_p99": { + "io": [ + "-2835399305386018931(0.3)", + "-7079852731576173083(0.2)" + ], + "ipc": [ + "6627483964256586084(0.1)" + ], + "lock": [ + "-1236273962537500931(11.0)", + "-7185852639686726986(0.1)" + ], + "total": [ + "-1236273962537500931(11.1)", + "-2835399305386018931(1.1)" + ], + "lwlock": [ + "-2835399305386018931(0.3)" + ] + }, + "top_queryids_available": true +} diff --git a/demos/fixtures/shape.tsv b/demos/fixtures/shape.tsv new file mode 100644 index 0000000..cc4e690 --- /dev/null +++ b/demos/fixtures/shape.tsv @@ -0,0 +1,8 @@ +status cols=2 head=metric|value labels=pg_cron_available,raw_retention,sample_interval,samples_total,sampling_enabled,version +periods cols=6 head=period|source|buckets_with_data|avg_aas|peak_aas|p99_aas labels=1d,1h,1m,1mo,1w,5m +chart cols=3 head=bucket|aas|chart +top_event cols=5 head=key|avg_aas|peak_aas|p99_aas|pct +top_query cols=4 head=key|query_text|avg_aas|pct +compare cols=5 head=key|avg_aas_1|avg_aas_2|avg_delta|pct_2 +summary cols=2 head=metric|value labels=avg_aas,backend_seconds,buckets_with_data,databases_active,drill_effective_bucket,drill_period_end,drill_period_start,drill_source,p99_aas,peak_aas,period_end,period_start,source,top_query_1,top_query_2,top_query_3,top_wait_1,top_wait_2,top_wait_3 +report cols=1 keys=aas_avg,aas_p99,coverage,cpu,from,io,ipc,lock,lwlock,minutes_expected,minutes_with_data,raw_retention_start,source,to,top_events_p99,top_queryids_available,top_queryids_p99,total diff --git a/demos/fixtures/status.ansi b/demos/fixtures/status.ansi new file mode 100644 index 0000000..c3d572e --- /dev/null +++ b/demos/fixtures/status.ansi @@ -0,0 +1,13 @@ +ash ▸ select metric, value from ash.status() where metric in ('version','sampling_enabled', + 'sample_interval','samples_total','raw_retention','pg_cron_available'); + +┌───────────────────┬─────────────────────────────┐ +│ metric │ value │ +├───────────────────┼─────────────────────────────┤ +│ version │ 2.0-beta1 │ +│ sampling_enabled │ true │ +│ sample_interval │ 00:00:05 │ +│ raw_retention │ 1 day + current partial │ +│ samples_total │ 326 │ +│ pg_cron_available │ no (use external scheduler) │ +└───────────────────┴─────────────────────────────┘ diff --git a/demos/fixtures/summary.ansi b/demos/fixtures/summary.ansi new file mode 100644 index 0000000..23c332a --- /dev/null +++ b/demos/fixtures/summary.ansi @@ -0,0 +1,27 @@ +ash ▸ select metric, value from ash.summary(since => $STORM_SINCE, until => $STORM_UNTIL); + +┌────────────────────────┬────────────────────────────────────────────────────────────────────────┐ +│ metric │ value │ +├────────────────────────┼────────────────────────────────────────────────────────────────────────┤ +│ period_start │ 2026-07-27 22:44:00+00 │ +│ period_end │ 2026-07-27 22:49:00+00 │ +│ source │ raw │ +│ buckets_with_data │ 5 │ +│ avg_aas │ 14.60 │ +│ peak_aas │ 14.75 │ +│ p99_aas │ 14.74 │ +│ backend_seconds │ 4380.00 │ +│ drill_source │ raw │ +│ drill_period_start │ 2026-07-27 22:44:00+00 │ +│ drill_period_end │ 2026-07-27 22:49:00+00 │ +│ drill_effective_bucket │ 00:01:00 │ +│ databases_active │ 1 │ +│ top_wait_1 │ Lock:transactionid (avg_aas 9.55, 65.41%) │ +│ top_wait_2 │ Lock:tuple (avg_aas 1.53, 10.50%) │ +│ top_wait_3 │ CPU* (avg_aas 1.20, 8.22%) │ +│ top_query_1 │ -1236273962537500931 — UPDATE pgbench_accounts SET abalance = abalance │ +│ │ + $1 WHERE a (avg_aas 11.35, 77.74%) │ +│ top_query_2 │ 6627483964256586084 — SELECT sum(abalance) FROM pgbench_accounts WHERE │ +│ │ bid = $1 (avg_aas 0.97, 6.62%) │ +│ top_query_3 │ -2835399305386018931 — END (avg_aas 0.78, 5.37%) │ +└────────────────────────┴────────────────────────────────────────────────────────────────────────┘ diff --git a/demos/fixtures/top_event.ansi b/demos/fixtures/top_event.ansi new file mode 100644 index 0000000..9c73230 --- /dev/null +++ b/demos/fixtures/top_event.ansi @@ -0,0 +1,13 @@ +ash ▸ select key, avg_aas, peak_aas, p99_aas, pct from ash.top('wait_event', since => $STORM_SINCE, + until => $STORM_UNTIL, n => 6); + +┌────────────────────┬─────────┬──────────┬─────────┬───────┐ +│ key │ avg_aas │ peak_aas │ p99_aas │ pct │ +├────────────────────┼─────────┼──────────┼─────────┼───────┤ +│ Lock:transactionid │ 9.55 │ 10.50 │ 10.48 │ 65.41 │ +│ Lock:tuple │ 1.53 │ 2.67 │ 2.63 │ 10.50 │ +│ CPU* │ 1.20 │ 1.50 │ 1.49 │ 8.22 │ +│ IdleTx │ 0.77 │ 1.17 │ 1.15 │ 5.25 │ +│ Client:ClientRead │ 0.63 │ 0.75 │ 0.75 │ 4.34 │ +│ IO:WalSync │ 0.58 │ 0.75 │ 0.75 │ 4.00 │ +└────────────────────┴─────────┴──────────┴─────────┴───────┘ diff --git a/demos/fixtures/top_query.ansi b/demos/fixtures/top_query.ansi new file mode 100644 index 0000000..f1b1134 --- /dev/null +++ b/demos/fixtures/top_query.ansi @@ -0,0 +1,10 @@ +ash ▸ select key, left(query_text, 52) as query_text, avg_aas, pct from ash.top('query_id', since + => $STORM_SINCE, until => $STORM_UNTIL, n => 3); + +┌──────────────────────┬──────────────────────────────────────────────────────┬─────────┬───────┐ +│ key │ query_text │ avg_aas │ pct │ +├──────────────────────┼──────────────────────────────────────────────────────┼─────────┼───────┤ +│ -1236273962537500931 │ UPDATE pgbench_accounts SET abalance = abalance + $1 │ 11.35 │ 77.74 │ +│ 6627483964256586084 │ SELECT sum(abalance) FROM pgbench_accounts WHERE bid │ 0.97 │ 6.62 │ +│ -2835399305386018931 │ END │ 0.78 │ 5.37 │ +└──────────────────────┴──────────────────────────────────────────────────────┴─────────┴───────┘ diff --git a/demos/fonts/JetBrainsMono-Bold.ttf b/demos/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/demos/fonts/JetBrainsMono-Bold.ttf differ diff --git a/demos/fonts/JetBrainsMono-Regular.ttf b/demos/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/demos/fonts/JetBrainsMono-Regular.ttf differ diff --git a/demos/fonts/OFL.txt b/demos/fonts/OFL.txt new file mode 100644 index 0000000..23a3dca --- /dev/null +++ b/demos/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/demos/lib/backend.sh b/demos/lib/backend.sh new file mode 100644 index 0000000..435359b --- /dev/null +++ b/demos/lib/backend.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +# +# lib/backend.sh — the only file in demos/ allowed to touch raw PG* variables. +# +# Three backends, one interface (§5): +# +# local DEFAULT and the CI path. Whatever cluster the ambient PG* variables +# already reach. Needs psql + pgbench and nothing else: no Docker +# daemon, no registry pull, no ALTER SYSTEM, no restart. pg_cron is +# NOT required — the seeder drives ash.take_sample() itself, which is +# both the portable path and the honest one, because that is exactly +# how pg_ash runs on RDS / Cloud SQL / Supabase / AlloyDB / Neon. +# docker Optional. For pinning a specific major, or for a cluster that +# really has pg_cron. Never on the critical path. +# remote Standard PG* variables against someone else's server, with two +# guardrails that cannot be switched off. +# +# HOUSE RULES ARE ENFORCED HERE, IN CODE, NOT IN DOCUMENTATION. Every drop and +# every `docker rm` re-asserts the ash_demo* / ash_demo_* name globs and the +# ownership record this run wrote. A harness that can delete a database it did +# not create has no business running on anybody's laptop. +# +# Sourced, never executed. + +# shellcheck source=lib/env.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/env.sh" + +# Maintenance database used only to issue CREATE/DROP DATABASE. We connect to +# it; we never write to it. +: "${ASH_MAINT_DB:=postgres}" + +# Ownership ledger. Written by backend_up, consulted by backend_down. +ASH_STATE_FILE="$ASH_OUT/backend.state" + +# --------------------------------------------------------------------------- +# Guardrails +# --------------------------------------------------------------------------- + +# _bk_assert_db_glob — the harness may only create or drop databases +# whose name starts with `ash_demo`. +_bk_assert_db_glob() { + case "$1" in + ash_demo*) : ;; + *) ash_die 1 "refusing to manage database '$1': the harness only touches ash_demo*" ;; + esac + case "$1" in + *[!A-Za-z0-9_-]*) + ash_die 1 "refusing unsafe database name '$1': use only letters, digits, underscores and hyphens" + ;; + esac +} + +# _bk_assert_container_glob — likewise for containers. +_bk_assert_container_glob() { + case "$1" in + ash_demo_*) : ;; + *) ash_die 1 "refusing to manage container '$1': the harness only touches ash_demo_*" ;; + esac +} + +# The official postgres images declare PGDATA as an anonymous volume. `-v` is +# therefore part of correctness here, not an optional space-saving flag: +# removing only the container leaks roughly 600 MiB for every matrix entry. +_bk_remove_owned_container() { + docker rm -f -v "$1" >/dev/null 2>&1 +} + +# --------------------------------------------------------------------------- +# psql wrappers — every SQL call in the harness goes through one of these +# --------------------------------------------------------------------------- + +# ash_psql — the demo database, startup file suppressed, +# ON_ERROR_STOP armed. Callers that WANT a psqlrc (the capture paths) build +# their own command line; this one is for plumbing. +ash_psql() { + psql -X -v ON_ERROR_STOP=1 -d "$ASH_DEMO_DB" "$@" +} + +# ash_psql1 — one scalar, unaligned, no header, no footer. +ash_psql1() { + psql -X -v ON_ERROR_STOP=1 -d "$ASH_DEMO_DB" -tAc "$1" +} + +# ash_psql_maint — the maintenance database. +ash_psql_maint() { + psql -X -v ON_ERROR_STOP=1 -d "$ASH_MAINT_DB" "$@" +} + +# --------------------------------------------------------------------------- +# Backend probing (used by doctor, and by ASH_BACKEND=auto) +# --------------------------------------------------------------------------- + +# backend_probe_local — 0 if the ambient PG* reach a live server. +backend_probe_local() { + ash_have psql || return 1 + psql -X -d "$ASH_MAINT_DB" -tAc 'select 1' >/dev/null 2>&1 +} + +# backend_connect — resolve the connection WITHOUT creating or installing +# anything. For read-only consumers (preflight, `ash-demo sql`, the capture +# paths) that must not pay a 250 KB re-install to ask the database a question. +backend_connect() { + case "$ASH_BACKEND" in + docker) + if [ -z "${ASH_DEMO_PORT:-}" ] && ash_have docker; then + _bk_mapping=$(docker port "$ASH_DEMO_CONTAINER" 5432/tcp 2>/dev/null | head -1) + ASH_DEMO_PORT=${_bk_mapping##*:} + unset _bk_mapping + fi + export PGHOST=127.0.0.1 PGPORT="${ASH_DEMO_PORT:-5432}" PGUSER=postgres + export PGPASSWORD=ash_demo + ;; + esac + export PGDATABASE="$ASH_DEMO_DB" + ash_psql1 'select 1' >/dev/null 2>&1 \ + || ash_die 3 "cannot reach database '$ASH_DEMO_DB' — run 'make up' first" +} + +# backend_probe_docker — 0 if a docker daemon answers AND a usable postgres +# image is present locally. We deliberately do NOT count "docker can pull", +# because a registry that 503s in a sandbox is the root defect being fixed. +backend_probe_docker() { + ash_have docker || return 1 + docker info >/dev/null 2>&1 || return 1 + docker image inspect "postgres:$ASH_PG_MAJOR" >/dev/null 2>&1 +} + +# --------------------------------------------------------------------------- +# backend_up +# --------------------------------------------------------------------------- +# +# Post-condition: PGHOST/PGPORT/PGUSER/PGDATABASE are exported and point at a +# database named $ASH_DEMO_DB that has pg_ash installed and (where possible) +# pg_stat_statements created. +backend_up() { + case "$ASH_BACKEND" in + auto) + if backend_probe_local; then + ASH_BACKEND=local + elif backend_probe_docker; then + ASH_BACKEND=docker + else + ash_die 3 "ASH_BACKEND=auto: no local cluster reachable and no usable docker postgres image" + fi + ash_log "backend auto-selected: $ASH_BACKEND" + ;; + esac + + case "$ASH_BACKEND" in + local) _bk_up_local ;; + docker) _bk_up_docker ;; + remote) _bk_up_remote ;; + *) ash_die 1 "unknown ASH_BACKEND '$ASH_BACKEND' (want local|docker|remote|auto)" ;; + esac + + _bk_install_pg_ash + _bk_report +} + +_bk_up_local() { + _bk_assert_db_glob "$ASH_DEMO_DB" + ash_have psql || ash_die 2 "psql not found on PATH" + backend_probe_local \ + || ash_die 3 "cannot reach a local PostgreSQL cluster (tried database '$ASH_MAINT_DB' with the ambient PG* settings)" + + # Create the demo database if it is not already there, and remember whether + # THIS run created it — backend_down only drops what it created. + if [ "$(ash_psql_maint -tAc \ + "select count(*) from pg_database where datname = '$ASH_DEMO_DB'")" = "0" ]; then + ash_log "creating database $ASH_DEMO_DB" + ash_psql_maint -c "create database \"$ASH_DEMO_DB\"" >/dev/null + _bk_record_state local created + else + ash_log "reusing existing database $ASH_DEMO_DB" + _bk_record_state local reused + fi + + export PGDATABASE="$ASH_DEMO_DB" +} + +_bk_up_docker() { + _bk_assert_db_glob "$ASH_DEMO_DB" + _bk_assert_container_glob "$ASH_DEMO_CONTAINER" + ash_have docker || ash_die 2 "docker not found on PATH" + docker info >/dev/null 2>&1 || ash_die 3 "docker daemon is not answering" + + local created=reused + if [ -z "$(docker ps -aq -f "name=^${ASH_DEMO_CONTAINER}$")" ]; then + docker image inspect "postgres:$ASH_PG_MAJOR" >/dev/null 2>&1 \ + || ash_die 2 "image postgres:$ASH_PG_MAJOR is not present locally (pull it first; the harness never pulls on the critical path)" + + # Probe a free port. Never hardcode: two harness runs on one machine must + # not fight over 5500. + ASH_DEMO_PORT=${ASH_DEMO_PORT:-$(ash_free_port 5500 5599)} \ + || ash_die 3 "no free TCP port in 5500-5599" + + ash_log "starting container $ASH_DEMO_CONTAINER (postgres:$ASH_PG_MAJOR) on port $ASH_DEMO_PORT" + # pg_stat_statements must be preloaded to exist at all. pg_cron is NOT + # requested here: the stock postgres image does not ship it, and the whole + # point of this harness is that the no-cron path is first class. A cluster + # with real pg_cron needs a purpose-built image and an explicit image tag. + docker run -d \ + --name "$ASH_DEMO_CONTAINER" \ + -e POSTGRES_PASSWORD=ash_demo \ + -e POSTGRES_DB="$ASH_DEMO_DB" \ + -p "127.0.0.1:$ASH_DEMO_PORT:5432" \ + "postgres:$ASH_PG_MAJOR" \ + -c shared_preload_libraries=pg_stat_statements \ + -c max_connections=100 \ + >/dev/null + created=created + else + ash_log "reusing container $ASH_DEMO_CONTAINER" + if [ -z "${ASH_DEMO_PORT:-}" ]; then + # `docker port` prints "0.0.0.0:5531"; take everything after the last + # colon with a parameter expansion rather than sed (§10: no sed). + _bk_mapping=$(docker port "$ASH_DEMO_CONTAINER" 5432/tcp | head -1) + ASH_DEMO_PORT=${_bk_mapping##*:} + unset _bk_mapping + fi + docker start "$ASH_DEMO_CONTAINER" >/dev/null 2>&1 || true + fi + + export PGHOST=127.0.0.1 + export PGPORT="$ASH_DEMO_PORT" + export PGUSER=postgres + export PGPASSWORD=ash_demo + export PGDATABASE="$ASH_DEMO_DB" + ASH_MAINT_DB=postgres + + # Readiness poll. `pg_isready` alone is not enough: the image restarts the + # server once during first-boot initdb, so we also demand a real query. + local deadline i + deadline=$(( $(ash_now_ms) + 60000 )) + i=0 + while [ "$(ash_now_ms)" -lt "$deadline" ]; do + if psql -X -d "$ASH_DEMO_DB" -tAc 'select 1' >/dev/null 2>&1; then + break + fi + i=$((i + 1)) + python3 -c 'import time; time.sleep(0.25)' + done + psql -X -d "$ASH_DEMO_DB" -tAc 'select 1' >/dev/null 2>&1 \ + || ash_die 3 "container $ASH_DEMO_CONTAINER never became ready on port $ASH_DEMO_PORT" + + _bk_record_state docker "$created" +} + +_bk_up_remote() { + # Guardrail 1: the target database name must match the house glob. This is + # the difference between a demo harness and an accident. + _bk_assert_db_glob "$ASH_DEMO_DB" + export PGDATABASE="$ASH_DEMO_DB" + + psql -X -d "$ASH_DEMO_DB" -tAc 'select 1' >/dev/null 2>&1 \ + || ash_die 3 "cannot reach remote database '$ASH_DEMO_DB' with the ambient PG* settings" + + # Guardrail 2: refuse to seed on top of somebody else's samples. A remote + # database that already holds ash.sample rows is either a real installation + # or a previous run; either way the operator has to say so out loud. + local existing + existing=$(psql -X -d "$ASH_DEMO_DB" -tAc " + select case when to_regclass('ash.sample') is null then 0 + else (select count(*) from ash.sample) end" 2>/dev/null || echo 0) + if [ "${existing:-0}" != "0" ] && [ "${ASH_SKIP_SEED:-}" != "1" ] \ + && [ "${ASH_REMOTE_OVERWRITE:-}" != "1" ]; then + ash_die 3 "remote database '$ASH_DEMO_DB' already holds $existing ash.sample rows; set ASH_REMOTE_OVERWRITE=1 if you really mean it" + fi + + _bk_record_state remote reused +} + +# --------------------------------------------------------------------------- +# pg_ash installation +# --------------------------------------------------------------------------- + +_bk_install_pg_ash() { + [ -f "$ASH_INSTALL_SQL" ] \ + || ash_die 1 "installer not found: $ASH_INSTALL_SQL (set ASH_INSTALL_SQL or ASH_REPO_ROOT)" + + # pg_stat_statements first: ash.top('query_id') and ash.report() are much + # more interesting with real normalized query text, and the extension has to + # exist before the seeder generates its query identities. Its absence is a + # warning, never a failure — pg_ash's degraded path is a supported path. + if ! ash_psql -c 'create extension if not exists pg_stat_statements' >/dev/null 2>&1; then + ash_warn "pg_stat_statements is unavailable; query text will be omitted (degraded mode)" + fi + + ash_log "installing pg_ash from ${ASH_INSTALL_SQL#$ASH_REPO_ROOT/}" + # Install log goes to a file: the installer is ~250 KB of DDL and its NOTICE + # traffic buries everything else. A failure prints the tail. + if ! ash_psql -q -f "$ASH_INSTALL_SQL" >"$ASH_OUT/install.log" 2>&1; then + tail -40 "$ASH_OUT/install.log" >&2 + ash_die 3 "pg_ash install failed; full log at $ASH_OUT/install.log" + fi + + # pg_cron is optional by design. Report the mode so the operator knows which + # path the demo is exercising, and so the `status` still can be read honestly. + # `case` rather than `grep -q`: a successful `grep -q` behind a pipe SIGPIPEs + # its writer, and under `set -o pipefail` that inverts the result (§2.6). + case "$(ash_psql1 'select ash._pg_cron_available()')" in + t) ash_log "pg_cron available: pg_ash could self-schedule (harness still drives sampling itself)" ;; + *) ash_log "pg_cron unavailable: external-scheduler (degraded) mode — the default demo path" ;; + esac +} + +# --------------------------------------------------------------------------- +# State ledger + reporting +# --------------------------------------------------------------------------- + +_bk_record_state() { + # $1 = backend kind, $2 = created|reused + # + # Ownership is STICKY. `make seed` twice in a row hits the reuse path the + # second time, and if that downgraded the record to "reused" then `make down` + # would refuse to remove a container this harness had in fact created — which + # is exactly what happened the first time this ran, leaving ash_demo_b1_pg + # behind. Only backend_down clears the ledger. + if [ -f "$ASH_STATE_FILE" ] && [ "$2" = "reused" ]; then + local prev_own="" prev_db="" prev_container="" + # shellcheck disable=SC1090 + . "$ASH_STATE_FILE" + prev_own=${ASH_STATE_OWNERSHIP:-} + prev_db=${ASH_STATE_DB:-} + prev_container=${ASH_STATE_CONTAINER:-} + if [ "$prev_own" = "created" ] \ + && [ "$prev_db" = "$ASH_DEMO_DB" ] \ + && [ "$prev_container" = "${ASH_DEMO_CONTAINER:-}" ]; then + set -- "$1" created + fi + fi + { + echo "ASH_STATE_BACKEND=$1" + echo "ASH_STATE_OWNERSHIP=$2" + echo "ASH_STATE_DB=$ASH_DEMO_DB" + echo "ASH_STATE_CONTAINER=${ASH_DEMO_CONTAINER:-}" + echo "ASH_STATE_PORT=${ASH_DEMO_PORT:-}" + } >"$ASH_STATE_FILE" +} + +_bk_report() { + local pgver + pgver=$(ash_psql1 "select current_setting('server_version')") + ash_log "backend=$ASH_BACKEND db=$ASH_DEMO_DB pg=$pgver ash=$(ash_psql1 'select version from ash.config')" +} + +# --------------------------------------------------------------------------- +# backend_down +# --------------------------------------------------------------------------- + +backend_down() { + if [ "${ASH_KEEP_DB:-}" = "1" ]; then + ash_log "ASH_KEEP_DB=1: leaving $ASH_DEMO_DB in place" + return 0 + fi + + # Read the ownership ledger if we have one. Without it we still honour the + # name globs, but we refuse to remove any resource we have no record of + # creating. Keep sourced values local so repeated calls cannot inherit stale + # state after the ledger has been cleared. + local state_backend="" state_own="" state_db="" target_db="" + local ASH_STATE_BACKEND="" ASH_STATE_OWNERSHIP="" ASH_STATE_DB="" + local ASH_STATE_CONTAINER="" ASH_STATE_PORT="" + if [ -f "$ASH_STATE_FILE" ]; then + # shellcheck disable=SC1090 + . "$ASH_STATE_FILE" + state_backend=${ASH_STATE_BACKEND:-} + state_own=${ASH_STATE_OWNERSHIP:-} + state_db=${ASH_STATE_DB:-} + fi + [ -n "$state_backend" ] || state_backend=$ASH_BACKEND + + case "$state_backend" in + docker) + _bk_assert_container_glob "${ASH_STATE_CONTAINER:-$ASH_DEMO_CONTAINER}" + if [ "$state_own" = "created" ]; then + ash_log "removing container ${ASH_STATE_CONTAINER:-$ASH_DEMO_CONTAINER}" + _bk_remove_owned_container "${ASH_STATE_CONTAINER:-$ASH_DEMO_CONTAINER}" \ + || ash_die 3 "could not remove owned container ${ASH_STATE_CONTAINER:-$ASH_DEMO_CONTAINER} and its anonymous volumes" + else + ash_warn "container ${ASH_STATE_CONTAINER:-$ASH_DEMO_CONTAINER} was not created by this harness; leaving it alone" + fi + ;; + local) + target_db=${state_db:-$ASH_DEMO_DB} + _bk_assert_db_glob "$target_db" + if [ "$state_own" != "created" ]; then + ash_warn "database $target_db was not created by this harness; leaving it alone" + else + [ "$target_db" = "$ASH_DEMO_DB" ] \ + || ash_die 3 "refusing database teardown: ledger owns '$target_db' but the selected database is '$ASH_DEMO_DB'" + _bk_terminate_demo_backends "$target_db" || true + ash_log "dropping database $target_db" + ash_psql_maint -c \ + "drop database if exists \"$target_db\" with (force)" >/dev/null \ + || ash_die 3 "could not drop owned database $target_db; ownership ledger retained for retry" + fi + ;; + remote) + target_db=${state_db:-$ASH_DEMO_DB} + _bk_assert_db_glob "$target_db" + ash_warn "remote database $target_db was not created by this harness; leaving it alone" + ;; + esac + + rm -f "$ASH_STATE_FILE" +} + +# _bk_terminate_demo_backends — server-side, filtered by database. `pkill -f` +# was measured unreliable (it races the shell that spawned the client and +# happily matches unrelated psql invocations). +_bk_terminate_demo_backends() { + local target_db=${1:-$ASH_DEMO_DB} + ash_psql_maint -tAc " + select pg_terminate_backend(pid) + from pg_stat_activity + where datname = '$target_db' + and pid <> pg_backend_pid()" >/dev/null 2>&1 +} diff --git a/demos/lib/doctor.sh b/demos/lib/doctor.sh new file mode 100644 index 0000000..8c022f3 --- /dev/null +++ b/demos/lib/doctor.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# +# lib/doctor.sh — dependency probe, by capability tier. +# +# The point of tiers: the stills path is the one that must work everywhere, and +# it needs almost nothing (python3 + two pure-python wheels + a client). The +# raster and reel paths need progressively more. Telling somebody "install +# ffmpeg" when all they wanted was an SVG is how a harness gets a reputation +# for being unrunnable. +# +# doctor_tier <1|2|3> check one tier; exit 2 naming exactly what is missing +# doctor_report print the full picture, including which backends work +# +# Sourced, never executed. + +# shellcheck source=lib/backend.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backend.sh" + +# Accumulated failures for the current probe. +ASH_DOCTOR_MISSING="" + +_doc_need_cmd() { + # $1 = command, $2 = hint + if ash_have "$1"; then + printf ' \033[32mok\033[0m %s\n' "$1" >&2 + else + printf ' \033[31mMISS\033[0m %s — %s\n' "$1" "$2" >&2 + ASH_DOCTOR_MISSING="$ASH_DOCTOR_MISSING $1" + fi +} + +_doc_need_py() { + # $1 = import name, $2 = pip name + if python3 -c "import $1" >/dev/null 2>&1; then + printf ' \033[32mok\033[0m python:%s\n' "$1" >&2 + else + printf ' \033[31mMISS\033[0m python:%s — pip install %s\n' "$1" "$2" >&2 + ASH_DOCTOR_MISSING="$ASH_DOCTOR_MISSING python:$1" + fi +} + +_doc_need_file() { + # $1 = path, $2 = hint + if [ -f "$1" ]; then + printf ' \033[32mok\033[0m %s\n' "${1#$ASH_DEMO_DIR/}" >&2 + else + printf ' \033[31mMISS\033[0m %s — %s\n' "${1#$ASH_DEMO_DIR/}" "$2" >&2 + ASH_DOCTOR_MISSING="$ASH_DOCTOR_MISSING ${1##*/}" + fi +} + +# _doc_any_cmd