Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/providers/aws.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,10 +668,10 @@ database:
capture_log_seconds: 600
```

Each `collect` then watches the log for `capture_log_seconds` and reads that
window over its own connection. Keep the schedule interval longer than
`capture_log_seconds`. The same connection reads a pgAudit csv log when
`capture_log_type` is `pgaudit` instead of `statement`.
Each `collect` then watches the log for `capture_log_seconds`, takes its
snapshot, and reads that window over its own connection. Keep the schedule
interval longer than `capture_log_seconds`. The same connection reads a pgAudit
csv log when `capture_log_type` is `pgaudit` instead of `statement`.

**Turn it off afterwards:** return `log_min_duration_statement` to the value it
had. RDS re-adds `stderr` alongside `csvlog`, so every event is written twice
Expand Down
31 changes: 30 additions & 1 deletion docs/workload-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ Two more files appear only when the query log was captured:
| `burst.csv` | the query log: every statement the server logged, **with literal values in the `parameters` column**, which is real data, by design. `session_id` and `command_tag` carry the transaction framing: which statements shared a session, and which rows opened or closed a transaction |
| `coverage.json` | how much of the aggregate workload the log windows observed, and whether they landed on ordinary traffic |

Both checks in `coverage.json` compare the log with the calls that a client
sent. A call that a function, procedure or trigger runs never reaches the log,
so the checks leave out the `nested_calls` of each statement.
`nested_calls_left_out` counts those calls, and `statements.only_nested` counts
the statements that ran only inside a function, procedure or trigger. When the
server cannot separate the two (PostgreSQL 13 and earlier), `nesting_unknown` is
`true` and the checks count every call.

Every capture declares the columns your migration engineer names, and the
bundle then holds one more file:

Expand Down Expand Up @@ -106,12 +114,23 @@ Each record in `workload.sql` carries its counters as comments, so the file
holds the traffic weight as well as the statement:

-- neki:stmt id=<statement id>
-- neki:metrics calls=12904 total_exec_time_ms=88213.4 rows=1032320
-- neki:metrics calls=12904 total_exec_time_ms=88213.4 rows=1032320 nested_calls=0
SELECT ... ;

A query log alone weights every pattern equally, so the weight travels with the
statement.

`nested_calls` counts how many of the `calls` ran inside a function, procedure
or trigger. It comes from the `toplevel` column of `pg_stat_statements`. A
statement whose `nested_calls` equals `calls` ran only inside one, so it can
name `NEW`, `OLD` or a function parameter, which exist only there. PostgreSQL
records a nested statement only when `pg_stat_statements.track` is `all`.

The header leaves `nested_calls` out when the server cannot say.
`pg_stat_statements` has no `toplevel` column before PostgreSQL 14, and there
one entry holds both kinds of call. The README's caveats name this case, and
count the statements that ran nested.

The planning tools do not read these headers yet. Their SQL loader supplies no
count, so every pattern arrives weighted 1 and a rare statement carries the
weight of a hot one. This is being addressed on the planning side. Until it is,
Expand Down Expand Up @@ -191,6 +210,8 @@ when the server cannot say: `pg_stat_statements_info` arrived in PostgreSQL 14,
so 12 and 13 report unknown rather than a `false` that would claim nothing was
lost. Treat `null` as unknown, not as falsy.

When `pgss.evicted` is `true`, the README's caveats say so too.

## Column statistics

Row counts say how big a table is. They cannot say whether a column can spread
Expand Down Expand Up @@ -224,6 +245,14 @@ you declare are in [`distributions.json`](#value-distributions).
The figures come from the last `ANALYZE`, so they are a sample and an estimate,
and they describe one column at a time rather than a composite key.

PostgreSQL shows a column in `pg_stats` only to a role that may `SELECT` the
column. A role that holds only `pg_monitor`, as the capture guide sets up, sees
only the columns you grant it. When no table in the capture has a row in
`pg_stats`, the README's caveats say so and name the cause: no `ANALYZE` has
run, the analyzed tables hold no rows, the role may not `SELECT` the columns,
or no snapshot could read `pg_stats`. A snapshot that cannot read `pg_stats`
does not replace an earlier reading, and the caveats count those snapshots.

## Value distributions

`column_stats.json` says how skewed a column is. It cannot say which values
Expand Down
12 changes: 8 additions & 4 deletions docs/workload_capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,9 +699,10 @@ the file's encoding itself: plain text, csvlog, or a Cloud SQL or AlloyDB
pgAudit JSON export.

With `capture_log_source: log_fdw`, the tool reads the server's log over its own
connection instead, and each `collect` watches the log for
`capture_log_seconds` after its snapshot, so the log window always falls inside
the snapshot window around it. **Keep the schedule interval longer than
connection instead. Each `collect` watches the log for `capture_log_seconds`,
takes its snapshot, and then reads the log. The log window therefore falls
between the previous snapshot and this one, so the counters in `workload.sql`
cover the same traffic as `burst.csv`. **Keep the schedule interval longer than
`capture_log_seconds`**, or the next `collect` starts before this one returns.
With the hourly crontab line `init` prints, anything up to about 45 minutes is
safe.
Expand Down Expand Up @@ -1156,7 +1157,10 @@ function or procedure is attributed to the call rather than recorded on its own.
If your application's work happens inside stored procedures, the query log will
describe the calls rather than the queries. Set `pg_stat_statements.track = all`
to record both. It costs more shared memory and more entries, so raise
`pg_stat_statements.max` with it.
`pg_stat_statements.max` with it. With `all`, `workload.sql` gives each
statement a `nested_calls` count: the calls that ran inside a function,
procedure or trigger. `coverage.json` leaves those calls out, because the query
log never holds them.

### W202 statement_eviction_possible

Expand Down
104 changes: 104 additions & 0 deletions planetscale_discovery/workload/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def write_bundle(
outside_window,
unreadable_bursts,
distributions_summary,
plannable,
),
}
_write_text(out_dir / "README.md", _render_readme(summary, out_dir))
Expand Down Expand Up @@ -542,12 +543,15 @@ def render_workload_sql(

for statement in sorted(statements, key=lambda s: s["id"]):
counters = statement.get("counters") or {}
# Absent, not 0, when the server cannot say which calls were nested.
nested = statement.get("nested_calls")
lines.append(f"-- neki:stmt id={statement['id']}")
lines.append(
"-- neki:metrics"
f" calls={_num(counters.get('calls'))}"
f" total_exec_time_ms={_ms(counters.get('total_exec_time'))}"
f" rows={_num(counters.get('rows'))}"
+ ("" if nested is None else f" nested_calls={_num(nested)}")
)
text = (statement.get("query") or "").rstrip().rstrip(";")
lines.append(f"{text};")
Expand Down Expand Up @@ -697,6 +701,7 @@ def _caveats(
outside_window=None,
unreadable_bursts=None,
distributions_summary=None,
plannable=None,
) -> List[str]:
"""Notes that change how the counts in this bundle should be read."""
caveats: List[str] = []
Expand Down Expand Up @@ -738,6 +743,31 @@ def _caveats(
"A growth rate needs a size reading at each end of the window."
)

empty_stats = _empty_column_stats(merged)
if empty_stats:
caveats.append(empty_stats)
failed_reads = merged.get("pg_stats_failed_reads") or 0
if failed_reads and merged.get("pg_stats_read"):
# Only the collect console showed these, and the bundle outlives it.
caveats.append(
f"{failed_reads} snapshot(s) could not read pg_stats "
f"({label('column_stats_read_failed')}). column_stats.json holds "
"the last reading that succeeded, taken at "
f"{merged.get('pg_stats_read_at') or 'an unknown time'}."
)

pgss = merged.get("pgss") or {}
if pgss.get("evicted"):
# The window's own evictions. W202 at init only says they are possible.
limit = f" (now {pgss['max']})" if pgss.get("max") else ""
caveats.append(
"pg_stat_statements was full during the window, so it discarded "
"entries. It discards the least-executed statements first. A "
"statement that ran rarely can be missing from workload.sql, or "
"its counts can be low. Raise pg_stat_statements.max"
f"{limit} before the next capture."
)

skipped = (merged.get("coverage") or {}).get("intervals_skipped") or []
if skipped:
caveats.append(
Expand Down Expand Up @@ -801,6 +831,29 @@ def _caveats(
"the system catalogs rather than your application's traffic."
)

nested = [s for s in plannable or [] if s.get("nested_calls")]
if nested:
only = sum(
1
for s in nested
if s["nested_calls"] >= ((s.get("counters") or {}).get("calls") or 0)
)
caveats.append(
f"{len(nested)} statement(s) in workload.sql ran inside a function, "
f"procedure or trigger: {only} only there, and {len(nested) - only} "
"also as statements of their own. The nested_calls value on each "
"neki:metrics line counts those calls."
)
if merged.get("toplevel_known") is False:
caveats.append(
"This server's pg_stat_statements has no toplevel column, which "
"arrived in PostgreSQL 14. So workload.sql cannot show which "
"statements ran inside a function, procedure or trigger, and its "
"neki:metrics lines carry no nested_calls. With "
"pg_stat_statements.track set to 'all', those statements look like "
"statements your application sent."
)

if parse.get("available") is False:
caveats.append(
"The schema file was not re-parsed before being written, because the "
Expand Down Expand Up @@ -866,6 +919,57 @@ def _caveats(
return caveats


def _empty_column_stats(merged: Dict[str, Any]) -> Optional[str]:
"""A caveat when no table in the capture has column statistics, or None.

An empty column_stats.json looks the same whatever the cause, so the caveat
names the cause that the capture can see. The analyze timestamps come from
pg_stat_user_tables, which pg_monitor can read. pg_stats shows a column only
to a role that may SELECT it, so the two sources can disagree.
"""
tables = list((merged.get("tables") or {}).values())
if not tables or merged.get("columns"):
return None
effect = (
"column_stats.json is empty, and plantest_counts.json holds no NDV or "
"null fraction for any column."
)
if merged.get("pg_stats_read") is False:
return (
f"{effect} No snapshot could read pg_stats "
f"({label('column_stats_read_failed')})."
)
analyzed = [t for t in tables if t.get("last_analyze") or t.get("last_autoanalyze")]
# ANALYZE records no statistics for an empty table.
with_rows = sum(1 for t in analyzed if (t.get("estimated_rows") or 0) > 0)
seen = f"pg_stats showed no row for any of the {len(tables)} table(s)."
if with_rows:
return (
f"{effect} {seen} {with_rows} of those tables hold rows and record "
"an ANALYZE, so PostgreSQL holds statistics for them. pg_stats "
"hides a column from a role that may not SELECT the column, and "
"from a role that row-level security applies to. See 'Grant the "
"capture role each column' in the workload capture guide."
)
# No table both holds rows and records an ANALYZE. Name each group, so a
# capture with some of each gets advice for both.
causes = []
never = len(tables) - len(analyzed)
if never:
causes.append(
f"{never} table(s) record no ANALYZE, and PostgreSQL fills pg_stats "
"only when ANALYZE runs. Run ANALYZE, then run 'collect' again "
"before 'finalize'. pg_stats also hides a column from a role that "
"may not SELECT the column."
)
if analyzed:
causes.append(
f"{len(analyzed)} table(s) record an ANALYZE but hold no rows, and "
"ANALYZE records no statistics for an empty table."
)
return f"{effect} {seen} {' '.join(causes)}"


def _render_readme(summary: Dict[str, Any], out_dir: Path) -> str:
covered = summary.get("covered_seconds")
window = (
Expand Down
13 changes: 1 addition & 12 deletions planetscale_discovery/workload/bundle_burst.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,24 +86,13 @@ def _bursts_in_window(bursts, merged):
if start is None or end is None:
undated.append(burst)
included.append(burst)
elif end < window_start:
excluded.append(burst)
elif start > window_end and not _read_during_the_capture(burst):
elif end < window_start or start > window_end:
excluded.append(burst)
else:
included.append(burst)
return included, excluded, unreadable, undated


def _read_during_the_capture(burst: Dict[str, Any]) -> bool:
"""A live read watches the log after its own snapshot, so the last window
of a capture always starts after the last snapshot it is measured against.
"""
from planetscale_discovery.workload.burst.collector import SOURCE_LIVE

return burst.get("source") == SOURCE_LIVE


def _scope_burst(burst: Dict[str, Any], known: Set[str]) -> Dict[str, Any]:
if not known:
return burst
Expand Down
Loading
Loading