Skip to content

Add slow-query log analysis tools (psycodict.slowlog) - #102

Merged
roed314 merged 4 commits into
roed314:masterfrom
roed-math:slowlog-analysis
Jul 22, 2026
Merged

Add slow-query log analysis tools (psycodict.slowlog)#102
roed314 merged 4 commits into
roed314:masterfrom
roed-math:slowlog-analysis

Conversation

@roed-math

Copy link
Copy Markdown

Fixes LMFDB/lmfdb#6418.

Stacked on #89 (which stacks on #88); once those merge this reduces to a single commit. Part of an eight-PR wave (with #90#94 also open); the full thirteen-branch combination was merged and tested locally: 633 passed / 26 skipped, ruff clean, all 23 devmirror tests passing.

New module psycodict/slowlog.py for the 11.9GB-and-growing slow-query logs, addressing both goals in the issue:

  • parse_slow_log: a streaming parser built against what _execute actually emits — the colored <SQL> ran in <t>s lines, the pre-2019 uncolored era, the Replicate with db.<t>.analyze(...) hint lines (attached to their record interleaving-safely, since several gunicorn workers append to one file), Search iterator lines, and multi-line SQL; unparseable lines are counted, not fatal.
  • normalize_query groups queries by shape: strings/numbers/booleans/ARRAY[...]/IN-and-ANY lists become placeholders, an OR/AND-repeated clause collapses (the jsonb $in expansion), while identifiers and jsonb path keys survive — so the same search with different constants aggregates under one shape, in both the psycopg2 and psycopg3 renderings.
  • slow_query_report / show_slow_report / db.show_slow_report: per-shape count, total/mean/max durations, tables, the slowest original example and its analyze() replication call; overall p50/p90/p99/max; and a threshold table — "a cutoff of X seconds would have kept N records / Y% of volume" — directly answering whether production's slowcutoff should rise (goal 1). Bounded memory via a 3-significant-digit duration histogram; measured ~8 MB/s for a full report, so the real file takes ~25 minutes.
  • Honest heuristic suggestions (goal 2): when a db is passed, WHERE equality/range/ANY columns and the leading ORDER BY column are checked against meta_indexes, suggesting db.<t>.create_index([...]) only when no index leads with that column (GIN suggested for @>-family operators); db-independent notes for leading-wildcard LIKE (pg_trgm) and jsonb-path equality. Every suggestion states the observation ("appears N times totalling Ys") and the action — no overclaiming.
  • 7 tests in tests/test_slowlog.py that generate a REAL log (slowcutoff = 0) and verify parsing, grouping, threshold consistency against a re-parse, and the suggestion lifecycle (named index suggestion appears, disappears once the index is created). Branch suite: 550 passed / 26 skipped.

Merge-order note: this and the #5900 PR both append methods at the end of PostgresDatabase; whichever merges second has a trivial append-append conflict — keep both blocks.

🤖 Generated with Claude Code

roed314 and others added 3 commits July 21, 2026 23:07
Tools for the 11.9GB of slow-query logs discussed in LMFDB/lmfdb#6418:
parse_slow_log streams the records out of a log written via the
slowlogfile option (the colored "ran in" lines, their "Replicate with
db.<table>.analyze(...)" hints, the pre-2019 uncolored era, "Search
iterator" lines, and multi-line SQL, with unrecognized lines counted and
skipped); slow_query_report groups the queries by shape -- normalize_query
strips numbers, quoted strings, ARRAY contents and IN/ANY lists, keeping
jsonb paths -- and reports per-shape counts and durations, overall
percentiles, and a threshold table showing how many lines each candidate
slowcutoff would have kept, addressing the issue's first goal directly.

Each top shape also carries heuristic suggestions for the issue's second
goal: given a db, the columns in WHERE equality/range constraints and the
leading ORDER BY column are checked against meta_indexes (via
list_indexes) and a create_index call is suggested when no index leads
with them; containment operators (@>, <@, &&) suggest a GIN index;
leading-wildcard LIKEs and equality on jsonb paths get db-independent
notes.  db.show_slow_report(logfile) prints the report.

The tests generate a real log by pointing a Configuration with
slowcutoff = 0 at a temporary file and running searches against a small
table, then check parsing, grouping, threshold consistency, and that an
index suggestion for an unindexed column disappears once the index is
created; a synthetic file covers the older formats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mple retention, atomic example/replicate

- Iterator-line query dicts are normalized by a new normalize_dict_query,
  which keeps dictionary keys and $-operators (the structure) while still
  collapsing values, so {'n': {'$gte': 5}} and {'label': {'$lte': 'z'}} no
  longer share a shape; numeric jsonb path components ("data"->0) are now
  kept by normalize_query like the string ones already were.
- Index suggestions resolve qualified references ("tbl"."col") against
  exactly that table; unqualified columns present on several referenced
  tables produce a suggestion naming every candidate instead of silently
  using the first match.
- slow_query_report keeps exact numeric aggregates for every shape but
  retains the large example/replicate strings only for a candidate set of
  ~4*top current leaders (lazy min-heap with periodic compaction); the
  docstring now states precisely what is bounded and what grows.
- The example and its replicate hint are updated atomically from the same
  record, so a slower hintless record clears a stale hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The report ranked the top query shapes by total time (the biggest cumulative
cost).  Add a second ranking by mean time -- the slowest per call, regardless
of how often the shape ran -- which surfaces the occasionally-catastrophic
queries that a total-time ranking buries under high-frequency ones.

slow_query_report now returns shapes_by_mean alongside shapes (the two lists
reorder the same per-shape dicts, each finalized once), and show_slow_report
prints both sections.  Example/replicate strings still follow the total-time
retention, so a shape ranked high on mean only by a rare slow call may show
its aggregates without an example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The by-mean ranking is dominated by rare, very slow shapes (often a single
call), but example/replicate strings were retained only for the by-total
leaders, so those by-mean entries showed their aggregates without a
reproducible example.

Add a second retention pool keyed on max duration alongside the total-time
one (both keys are monotonic per shape, so the existing lazy-heap logic
applies to each): a shape keeps its example while it leads by total OR by
max, and the max-pool covers exactly the high-per-call shapes the by-mean
ranking surfaces.  Factored the pool into a _LeaderPool class and use two
instances; the candidate set is now up to ~8*top shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roed314
roed314 merged commit 62aefc6 into roed314:master Jul 22, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Analyze slow query logs

2 participants