Skip to content

Commit 7529736

Browse files
committed
feat: report storage snapshots and total benchmark timing
Capture all database sizes and top 100 tables and indexes before and after each workload. Wait for OS sampling to finish before the after snapshot so size queries do not contaminate the final samples. Preserve storage evidence in JOIN reports, show start and finish timestamps and total duration, expand key report items, and document the collection sequence. Keep version 0.4.0.
1 parent 7c59f8c commit 7529736

21 files changed

Lines changed: 942 additions & 52 deletions

‎README.md‎

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ PGPASSWORD=secret pg-perf-bench benchmark \
240240
--report-name local-pg18
241241
```
242242

243-
The command timeout applies independently to initialization and workload
243+
The command timeout applies independently to initialization, `VACUUM ANALYZE`, and workload
244244
commands. It must be longer than the expected command duration.
245245

246246
### Workload placeholders
@@ -288,7 +288,7 @@ several SQL statements or transactions. The default HTAP reporting share is
288288
| Data volume | `--workload-scale SCALE`, default `1`; positive fractional values such as `0.25` are accepted. Generators retain minimum table sizes at small scales. |
289289
| Concurrent clients | `--pgbench-clients 1,2,4,8,16`; each value gets a freshly recreated database. Bundled commands also use one pgbench job per client. |
290290
| Measured window per point | `--workload-duration-seconds 120`; overrides the profile default for every client count. |
291-
| Command time limit | `--command-timeout 300`; allow enough time for initialization and for the workload window plus completion of in-flight queries. |
291+
| Command time limit | `--command-timeout 300`; allow enough time for initialization, pre-workload `VACUUM ANALYZE`, and the workload window plus completion of in-flight queries. |
292292

293293
Bundled profiles require `--pgbench-clients`; `--pgbench-time` is rejected.
294294
They select benchmark type `custom` automatically. `--workload-path` cannot be
@@ -496,9 +496,11 @@ For each axis value in the regular mode without Patroni, the backend:
496496
4. flushes filesystems and optionally drops host OS caches;
497497
5. starts PostgreSQL and recreates the database;
498498
6. runs the initialization command;
499-
7. runs the workload command while the `pg_diag` Linux sampler records CPU,
499+
7. runs `VACUUM ANALYZE` and captures the Before workload size snapshot;
500+
8. runs the workload command while the `pg_diag` Linux sampler records CPU,
500501
RAM, disk and network metrics on the database host;
501-
8. stores raw stdout, stderr, return code, UTC start time, elapsed time, parsed
502+
9. waits for any remaining OS sampling to finish, then captures the After workload size snapshot;
503+
10. stores both snapshots, raw stdout, stderr, return code, UTC start time, elapsed time, parsed
502504
pgbench metrics, and iteration metadata.
503505

504506
After the final iteration it collects the configured host and PostgreSQL facts
@@ -645,6 +647,21 @@ required on the SSH server.
645647

646648
## Report contents
647649

650+
Below `parameters`, the report header shows `Started:`, `Finished:`, and
651+
`Common duration:` on separate lines. New reports record UTC timestamps and a
652+
total elapsed duration in `timing`. For benchmarks, this covers preparation,
653+
every workload iteration, and final data/log collection, up to report
654+
serialization. Collection and JOIN reports measure their own operations.
655+
Duration is measured with a monotonic clock and displayed as `HH:MM:SS.mmm`.
656+
Older reports retain their recorded start time; missing finish times and total
657+
durations display `Not recorded`.
658+
659+
The main measurements, TPS, average latency and failed-transaction charts,
660+
database version/settings, replication policy/settings/slots/senders, storage
661+
snapshots, pgbench options, CPU/RAM capacity, disk space, and key CPU/memory/disk
662+
timelines are expanded by default. Detailed source code and secondary items
663+
remain collapsible; `Expand all` and `Collapse all` still control the full report.
664+
648665
A benchmark report contains:
649666

650667
- artifact schema and generator versions;
@@ -658,6 +675,8 @@ A benchmark report contains:
658675
- a compatibility preflight containing the PostgreSQL server major, the newest
659676
local pgbench/psql versions and the supported server range 10–18;
660677
- raw initialization and workload evidence for every completed iteration;
678+
- all database sizes and the workload database's top 100 tables/indexes, captured
679+
before and after every measured workload;
661680
- parsed clients, duration, transaction count, average latency, latency standard
662681
deviation, failed/retried transaction percentages, initial connection time, and TPS;
663682
- an explicit `maximum_tps` point with its axis value and complete metrics;
@@ -695,6 +714,49 @@ pg-perf-bench render \
695714
--out report/local-pg18.html
696715
```
697716

717+
### Storage sizes before and after workload
718+
719+
Benchmark reports include **Storage sizes before and after workload**, with six
720+
items for **every iteration**:
721+
722+
| Snapshot | Items |
723+
| --- | --- |
724+
| Before workload | All database sizes; top 100 tables by total size; top 100 indexes by size |
725+
| After workload | All database sizes; top 100 tables by total size; top 100 indexes by size |
726+
727+
The order is: recreate the workload database → run `init_command` → run
728+
`VACUUM ANALYZE` → collect **Before workload** → run `pgbench` → finish any
729+
remaining OS sampling → collect **After workload**. Preparation and size collection
730+
are outside the measured pgbench command. Waiting for the OS sampler prevents
731+
size-query CPU and I/O from entering its final samples. An explicit
732+
`--system-metrics-duration` longer than the workload delays the after snapshot
733+
until sampling finishes. No additional vacuum is run before the after snapshot.
734+
`VACUUM ANALYZE` uses `--command-timeout`; a failure stops the benchmark before
735+
the workload starts.
736+
737+
The database item lists **every database on the instance**, including templates,
738+
with `is_workload_database` marking the target. Sizes cover database files across
739+
tablespaces; WAL and shared cluster files are excluded. An inaccessible database
740+
remains listed with a null size and its collection error. Other databases' sizes
741+
are retained.
742+
743+
Table and index items cover the **workload database**. They use measured sizes,
744+
not catalog page estimates, and sort before applying the 100-row limit. Tables
745+
are ranked by total size including indexes and TOAST; the table, index and TOAST
746+
sizes are also shown separately. TOAST is already included in the table size.
747+
Stored leaf partitions and materialized views are included; partitioned parents
748+
without storage and system objects are omitted. Index sizes include all forks;
749+
TOAST indexes are accounted for in the table item. Sizes are integer bytes with
750+
human-readable totals.
751+
752+
Every snapshot records its collection interval in JSON and HTML. Measurements
753+
are sequential, so concurrent activity can change sizes during collection.
754+
SQL statements have a 10-second timeout; errors remain explicit in the report.
755+
Snapshots are retained in `benchmark_runs[].storage` before the next iteration
756+
recreates the database. JOIN preserves all source snapshots and identifies older
757+
reports that did not collect them. PostgreSQL 10–18 and managed PostgreSQL use
758+
the same SQL collection path.
759+
698760
### Replication evidence
699761

700762
Benchmark, `collect-db-info`, and `collect-all-info` reports include a

‎doc/logic_building_and_comparing_reports.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ Join requires at least two source reports with:
103103
- complete chart and pgbench-result structures;
104104
- every required join-task path present and equal.
105105

106+
The Storage section preserves each iteration's Before workload and After workload
107+
items from every source, including database sizes and top tables/indexes. Sources
108+
without storage snapshots remain joinable and receive an explicit missing-evidence
109+
item. Required storage paths in a JOIN task must still exist and match.
110+
106111
The Replication section is optional for older `report-v1` sources. A mixed join
107112
preserves each source's replication snapshot separately and labels sources that
108113
did not collect it. Missing replication evidence is not an empty slot list or a

‎doc/workload_description.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ axis value is used.
1818
Every axis value is a separate destructive iteration with a freshly recreated
1919
database.
2020

21+
After `init_command`, the runner executes `VACUUM ANALYZE` and collects the
22+
**Before workload** size snapshot. It then runs the measured command, waits for
23+
any remaining OS sampling to finish, and collects **After workload**, without
24+
another vacuum. This keeps size-query CPU and I/O out of the OS samples. Each snapshot
25+
lists all databases on the instance and the workload database's top 100 tables
26+
and indexes by measured size. Snapshots are retained separately for every axis
27+
value. See [storage report contents](../README.md#storage-sizes-before-and-after-workload).
28+
2129
Bundled profiles intentionally use `--pgbench-clients`: they sweep concurrency
2230
to find maximum TPS for one fixed dataset, query mix and environment.
2331

@@ -96,6 +104,7 @@ recreates the database for each concurrency point and measures maximum TPS.
96104
`--command-timeout` is a positive number of seconds applied independently to:
97105

98106
- every initialization command;
107+
- the pre-workload `VACUUM ANALYZE`;
99108
- every measured workload command;
100109
- transport host commands unless an item defines a smaller timeout.
101110

‎src/pg_perf_bench/benchmark.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import platform
44
import re
55
import sys
6+
import time
67
from contextlib import nullcontext
78
from copy import deepcopy
9+
from datetime import datetime, timezone
810
from pathlib import Path
911
from typing import Any
1012

@@ -43,6 +45,11 @@
4345
from pg_perf_bench.pgbench_metrics import LEGACY_METRIC_KEYS, parse_pgbench_metrics
4446
from pg_perf_bench.report.commands import fill_info_report
4547
from pg_perf_bench.report.processing import get_report_structure
48+
from pg_perf_bench.storage import (
49+
build_storage_section,
50+
collect_storage_snapshot,
51+
vacuum_analyze,
52+
)
4653
from pg_perf_bench.system_metrics import (
4754
build_system_metrics_section,
4855
collect_system_metrics,
@@ -328,6 +335,9 @@ async def run_benchmark_with_evidence(
328335
env=environment,
329336
secrets=secrets,
330337
)
338+
await vacuum_analyze(logger, db_conf, command_timeout)
339+
logger.info('Collecting storage sizes before workload.')
340+
storage_before = await collect_storage_snapshot(logger, db_conf)
331341
logger.info('Executing pgbench workload command.')
332342
sampler_task = None
333343
if connection is not None and connection_type != ConnectionType.MANAGED:
@@ -353,7 +363,11 @@ async def run_benchmark_with_evidence(
353363
env=environment,
354364
secrets=secrets,
355365
)
366+
# Providers can finish after pgbench. Keep size-query CPU and I/O
367+
# outside their final sampling interval.
356368
system_metrics = await sampler_task if sampler_task is not None else None
369+
logger.info('Collecting storage sizes after workload.')
370+
storage_after = await collect_storage_snapshot(logger, db_conf)
357371
except BaseException:
358372
if sampler_task is not None and not sampler_task.done():
359373
sampler_task.cancel()
@@ -370,6 +384,7 @@ async def run_benchmark_with_evidence(
370384
'workload': workload_result.as_dict(secrets=secrets),
371385
'metrics': metrics,
372386
'legacy_metrics': [metrics[key] for key in LEGACY_METRIC_KEYS],
387+
'storage': {'before_workload': storage_before, 'after_workload': storage_after},
373388
}
374389
if system_metrics is not None:
375390
result['system_metrics'] = system_metrics
@@ -589,6 +604,8 @@ async def run_benchmark_and_collect_metrics(
589604
"""
590605
Main entry point to execute the full benchmarking workflow.
591606
"""
607+
started_at = datetime.now(timezone.utc).isoformat()
608+
started_clock = time.monotonic()
592609
display_user_configuration(args, logger)
593610

594611
try:
@@ -632,6 +649,8 @@ async def run_benchmark_and_collect_metrics(
632649
),
633650
'workload_definition_hash': workload_evidence['definition_hash'],
634651
'workload_execution_hash': workload_evidence['execution_hash'],
652+
'vacuum_analyze_before_each_workload': True,
653+
'storage_snapshots': ['before_workload', 'after_workload'],
635654
'system_metrics_engine': None if managed_path else 'pg_diag',
636655
'system_metrics_collected_during_workload': not bool(managed_path),
637656
'system_metrics_interval_seconds': (
@@ -672,6 +691,7 @@ async def run_benchmark_and_collect_metrics(
672691
report_data['pgbench_outputs'] = [run['legacy_metrics'] for run in benchmark_runs]
673692
report['benchmark_runs'] = benchmark_runs
674693
report['maximum_tps'] = BenchmarkRunner.maximum_tps(benchmark_runs)
694+
report['sections']['storage'] = build_storage_section(benchmark_runs)
675695
report['sections']['os_metrics'] = build_system_metrics_section(benchmark_runs)
676696
if managed_path:
677697
section = report['sections']['os_metrics']
@@ -702,6 +722,11 @@ async def run_benchmark_and_collect_metrics(
702722
report['database_configuration_evidence'] = database_evidence
703723

704724
logger.info('Benchmarking process completed successfully.')
725+
report['timing'] = {
726+
'started_at': started_at,
727+
'finished_at': datetime.now(timezone.utc).isoformat(),
728+
'elapsed_seconds': time.monotonic() - started_clock,
729+
}
705730
return report
706731

707732
except Exception as e:

‎src/pg_perf_bench/collect_info.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import platform
22
import sys
3+
import time
4+
from datetime import datetime, timezone
35

46
import asyncpg
57

@@ -113,6 +115,8 @@ async def collect_info(
113115
Orchestrates full process of collecting system and DB info,
114116
generating and returning a report dictionary.
115117
"""
118+
started_at = datetime.now(timezone.utc).isoformat()
119+
started_clock = time.monotonic()
116120
if 'report_template' not in report_conf:
117121
logger.error('Missing "report_template" in report_conf.')
118122
return None
@@ -165,6 +169,11 @@ async def collect_info(
165169
logger.info('Database connection closed.')
166170

167171
logger.info('Collect info process completed successfully.')
172+
report['timing'] = {
173+
'started_at': started_at,
174+
'finished_at': datetime.now(timezone.utc).isoformat(),
175+
'elapsed_seconds': time.monotonic() - started_clock,
176+
}
168177
return report
169178

170179
except FileNotFoundError as fe:
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Include newly grown indexes even if their catalog statistics have not been refreshed.
2+
WITH sizes AS (
3+
SELECT idx.oid::bigint AS index_oid,
4+
n.nspname::text AS schema_name,
5+
tbl.relname::text AS table_name,
6+
idx.relname::text AS index_name,
7+
am.amname::text AS access_method,
8+
i.indisprimary AS is_primary,
9+
i.indisvalid AS is_valid,
10+
pg_catalog.pg_table_size(idx.oid) AS index_size_bytes
11+
FROM pg_catalog.pg_index i
12+
JOIN pg_catalog.pg_class idx ON idx.oid = i.indexrelid
13+
JOIN pg_catalog.pg_class tbl ON tbl.oid = i.indrelid
14+
JOIN pg_catalog.pg_namespace n ON n.oid = idx.relnamespace
15+
JOIN pg_catalog.pg_am am ON am.oid = idx.relam
16+
WHERE idx.relkind = 'i' AND tbl.relkind IN ('r', 'm')
17+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
18+
AND n.nspname !~ '^pg_(toast|temp)_'
19+
AND n.nspname <> 'pg_toast'
20+
)
21+
SELECT *, pg_catalog.pg_size_pretty(index_size_bytes) AS index_size
22+
FROM sizes
23+
ORDER BY index_size_bytes DESC NULLS LAST, schema_name, table_name, index_name, index_oid
24+
LIMIT 100;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Measure every stored user table before limiting: relpages can be stale after the workload.
2+
WITH sizes AS (
3+
SELECT c.oid::bigint AS table_oid,
4+
n.nspname::text AS schema_name,
5+
c.relname::text AS table_name,
6+
c.relkind::text AS relation_kind,
7+
c.relispartition AS is_partition,
8+
pg_catalog.pg_table_size(c.oid) AS table_size_bytes,
9+
pg_catalog.pg_indexes_size(c.oid) AS indexes_size_bytes,
10+
CASE WHEN c.reltoastrelid <> 0
11+
THEN pg_catalog.pg_total_relation_size(c.reltoastrelid)
12+
ELSE 0::bigint END AS toast_size_bytes,
13+
pg_catalog.pg_total_relation_size(c.oid) AS total_size_bytes
14+
FROM pg_catalog.pg_class c
15+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
16+
WHERE c.relkind IN ('r', 'm')
17+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
18+
AND n.nspname !~ '^pg_(toast|temp)_'
19+
AND n.nspname <> 'pg_toast'
20+
)
21+
SELECT *, pg_catalog.pg_size_pretty(total_size_bytes) AS total_size
22+
FROM sizes
23+
ORDER BY total_size_bytes DESC NULLS LAST, schema_name, table_name, table_oid
24+
LIMIT 100;

0 commit comments

Comments
 (0)