pg-perf-bench 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. pg_perf_bench/__init__.py +3 -0
  2. pg_perf_bench/__main__.py +4 -0
  3. pg_perf_bench/benchmark.py +646 -0
  4. pg_perf_bench/cli.py +820 -0
  5. pg_perf_bench/client_tools.py +135 -0
  6. pg_perf_bench/collect_info.py +180 -0
  7. pg_perf_bench/commands/bash_commands/cpu_info.sh +2 -0
  8. pg_perf_bench/commands/bash_commands/df_h.sh +3 -0
  9. pg_perf_bench/commands/bash_commands/etc_fstab.sh +3 -0
  10. pg_perf_bench/commands/bash_commands/etc_os_release.sh +3 -0
  11. pg_perf_bench/commands/bash_commands/ip_br_addr.sh +4 -0
  12. pg_perf_bench/commands/bash_commands/lshw_bridge.sh +1 -0
  13. pg_perf_bench/commands/bash_commands/lshw_bus.sh +1 -0
  14. pg_perf_bench/commands/bash_commands/lshw_communication.sh +1 -0
  15. pg_perf_bench/commands/bash_commands/lshw_disk.sh +1 -0
  16. pg_perf_bench/commands/bash_commands/lshw_display.sh +1 -0
  17. pg_perf_bench/commands/bash_commands/lshw_generic.sh +1 -0
  18. pg_perf_bench/commands/bash_commands/lshw_input.sh +1 -0
  19. pg_perf_bench/commands/bash_commands/lshw_memory.sh +1 -0
  20. pg_perf_bench/commands/bash_commands/lshw_multimedia.sh +1 -0
  21. pg_perf_bench/commands/bash_commands/lshw_network.sh +1 -0
  22. pg_perf_bench/commands/bash_commands/lshw_power.sh +1 -0
  23. pg_perf_bench/commands/bash_commands/lshw_processor.sh +1 -0
  24. pg_perf_bench/commands/bash_commands/lshw_storage.sh +1 -0
  25. pg_perf_bench/commands/bash_commands/lshw_system.sh +1 -0
  26. pg_perf_bench/commands/bash_commands/lshw_volume.sh +1 -0
  27. pg_perf_bench/commands/bash_commands/mount.sh +3 -0
  28. pg_perf_bench/commands/bash_commands/pg_config.sh +2 -0
  29. pg_perf_bench/commands/bash_commands/sys_memory_total.sh +6 -0
  30. pg_perf_bench/commands/bash_commands/sysctl_net_ipv4_tcp.sh +1 -0
  31. pg_perf_bench/commands/bash_commands/sysctl_net_ipv4_udp.sh +1 -0
  32. pg_perf_bench/commands/bash_commands/sysctl_vm.sh +2 -0
  33. pg_perf_bench/commands/bash_commands/total_ram.sh +2 -0
  34. pg_perf_bench/commands/bash_commands/uname_a.sh +3 -0
  35. pg_perf_bench/commands/sql_commands/available_server_extensions.sql +1 -0
  36. pg_perf_bench/commands/sql_commands/full_version.sql +1 -0
  37. pg_perf_bench/commands/sql_commands/pg_settings.sql +1 -0
  38. pg_perf_bench/commands/sql_commands/server_version.sql +1 -0
  39. pg_perf_bench/commands/sql_commands/server_version_major.sql +1 -0
  40. pg_perf_bench/config.py +418 -0
  41. pg_perf_bench/connections/__init__.py +11 -0
  42. pg_perf_bench/connections/common.py +13 -0
  43. pg_perf_bench/connections/docker.py +271 -0
  44. pg_perf_bench/connections/local.py +115 -0
  45. pg_perf_bench/connections/ssh.py +179 -0
  46. pg_perf_bench/const.py +102 -0
  47. pg_perf_bench/context/__init__.py +5 -0
  48. pg_perf_bench/context/base_context.py +79 -0
  49. pg_perf_bench/context/benchmark.py +157 -0
  50. pg_perf_bench/context/collect_info.py +125 -0
  51. pg_perf_bench/context/join.py +14 -0
  52. pg_perf_bench/contracts.py +121 -0
  53. pg_perf_bench/db_operations/__init__.py +81 -0
  54. pg_perf_bench/db_operations/conn_tasks/__init__.py +12 -0
  55. pg_perf_bench/db_operations/conn_tasks/common.py +48 -0
  56. pg_perf_bench/db_operations/conn_tasks/docker.py +29 -0
  57. pg_perf_bench/db_operations/conn_tasks/local.py +35 -0
  58. pg_perf_bench/db_operations/conn_tasks/ssh.py +32 -0
  59. pg_perf_bench/db_operations/db.py +124 -0
  60. pg_perf_bench/errors.py +61 -0
  61. pg_perf_bench/executors/__init__.py +5 -0
  62. pg_perf_bench/executors/process.py +156 -0
  63. pg_perf_bench/join.py +581 -0
  64. pg_perf_bench/join_catalog.py +134 -0
  65. pg_perf_bench/join_tasks/README.md +29 -0
  66. pg_perf_bench/join_tasks/compare-postgresql-major/README.md +19 -0
  67. pg_perf_bench/join_tasks/compare-postgresql-major/task.json +18 -0
  68. pg_perf_bench/join_tasks/compare-storage/README.md +19 -0
  69. pg_perf_bench/join_tasks/compare-storage/task.json +20 -0
  70. pg_perf_bench/join_tasks/optimize-db-config/README.md +22 -0
  71. pg_perf_bench/join_tasks/optimize-db-config/task.json +14 -0
  72. pg_perf_bench/join_tasks/repeatability/README.md +17 -0
  73. pg_perf_bench/join_tasks/repeatability/task.json +15 -0
  74. pg_perf_bench/join_tasks/scale-cpu/README.md +19 -0
  75. pg_perf_bench/join_tasks/scale-cpu/task.json +20 -0
  76. pg_perf_bench/join_tasks/scale-memory/README.md +18 -0
  77. pg_perf_bench/join_tasks/scale-memory/task.json +20 -0
  78. pg_perf_bench/join_tasks/tune-os-kernel/README.md +17 -0
  79. pg_perf_bench/join_tasks/tune-os-kernel/task.json +20 -0
  80. pg_perf_bench/log.py +74 -0
  81. pg_perf_bench/orchestration.py +135 -0
  82. pg_perf_bench/report/__init__.py +11 -0
  83. pg_perf_bench/report/commands.py +546 -0
  84. pg_perf_bench/report/html.py +112 -0
  85. pg_perf_bench/report/processing.py +146 -0
  86. pg_perf_bench/run.py +36 -0
  87. pg_perf_bench/system_metrics.py +218 -0
  88. pg_perf_bench/templates/all_info_report_struct.json +274 -0
  89. pg_perf_bench/templates/benchmark_report_struct.json +377 -0
  90. pg_perf_bench/templates/db_info_report_struct.json +61 -0
  91. pg_perf_bench/templates/report.html +806 -0
  92. pg_perf_bench/templates/sys_info_report_struct.json +220 -0
  93. pg_perf_bench/templates/vendor/THIRD_PARTY_LICENSES.txt +15 -0
  94. pg_perf_bench/templates/vendor/echarts-6.1.0.LICENSE-d3.txt +27 -0
  95. pg_perf_bench/templates/vendor/echarts-6.1.0.LICENSE.txt +222 -0
  96. pg_perf_bench/templates/vendor/echarts-6.1.0.NOTICE.txt +5 -0
  97. pg_perf_bench/templates/vendor/echarts-6.1.0.min.js +45 -0
  98. pg_perf_bench/templates/vendor/highlight-11.11.1.LICENSE.txt +29 -0
  99. pg_perf_bench/templates/vendor/highlight-11.11.1.min.js +1244 -0
  100. pg_perf_bench/templates/vendor/highlight-github-dark-11.11.1.min.css +10 -0
  101. pg_perf_bench/validator.py +55 -0
  102. pg_perf_bench/workload_profiles/README.md +16 -0
  103. pg_perf_bench/workload_profiles/imdb/README.md +18 -0
  104. pg_perf_bench/workload_profiles/imdb/generator.py +127 -0
  105. pg_perf_bench/workload_profiles/imdb/profile.json +25 -0
  106. pg_perf_bench/workload_profiles/imdb/sql/01_company_catalog.sql +16 -0
  107. pg_perf_bench/workload_profiles/imdb/sql/02_people_by_keyword.sql +18 -0
  108. pg_perf_bench/workload_profiles/imdb/sql/03_keyword_trends.sql +14 -0
  109. pg_perf_bench/workload_profiles/imdb/sql/04_genre_cast.sql +14 -0
  110. pg_perf_bench/workload_profiles/imdb/sql/05_join_stress.sql +22 -0
  111. pg_perf_bench/workload_profiles/imdb/sql/indexes.sql +20 -0
  112. pg_perf_bench/workload_profiles/imdb/sql/schema.sql +69 -0
  113. pg_perf_bench/workload_profiles/pagila/README.md +17 -0
  114. pg_perf_bench/workload_profiles/pagila/generator.py +203 -0
  115. pg_perf_bench/workload_profiles/pagila/profile.json +23 -0
  116. pg_perf_bench/workload_profiles/pagila/sql/01_select.sql +264 -0
  117. pg_perf_bench/workload_profiles/pagila/sql/02_insert.sql +273 -0
  118. pg_perf_bench/workload_profiles/pagila/sql/03_update.sql +149 -0
  119. pg_perf_bench/workload_profiles/pagila/sql/04_delete.sql +27 -0
  120. pg_perf_bench/workload_profiles/pagila/sql/pagila-schema.sql +1688 -0
  121. pg_perf_bench/workloads.py +297 -0
  122. pg_perf_bench-0.2.0.dist-info/METADATA +530 -0
  123. pg_perf_bench-0.2.0.dist-info/RECORD +133 -0
  124. pg_perf_bench-0.2.0.dist-info/WHEEL +5 -0
  125. pg_perf_bench-0.2.0.dist-info/entry_points.txt +2 -0
  126. pg_perf_bench-0.2.0.dist-info/licenses/LICENSE +21 -0
  127. pg_perf_bench-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +37 -0
  128. pg_perf_bench-0.2.0.dist-info/licenses/src/pg_perf_bench/templates/vendor/THIRD_PARTY_LICENSES.txt +15 -0
  129. pg_perf_bench-0.2.0.dist-info/licenses/src/pg_perf_bench/templates/vendor/echarts-6.1.0.LICENSE-d3.txt +27 -0
  130. pg_perf_bench-0.2.0.dist-info/licenses/src/pg_perf_bench/templates/vendor/echarts-6.1.0.LICENSE.txt +222 -0
  131. pg_perf_bench-0.2.0.dist-info/licenses/src/pg_perf_bench/templates/vendor/echarts-6.1.0.NOTICE.txt +5 -0
  132. pg_perf_bench-0.2.0.dist-info/licenses/src/pg_perf_bench/templates/vendor/highlight-11.11.1.LICENSE.txt +29 -0
  133. pg_perf_bench-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """PostgreSQL performance benchmark and evidence collector."""
2
+
3
+ __version__ = '0.2.0'
@@ -0,0 +1,4 @@
1
+ from pg_perf_bench.cli import main
2
+
3
+ if __name__ == '__main__':
4
+ raise SystemExit(main())
@@ -0,0 +1,646 @@
1
+ import asyncio
2
+ import os
3
+ import platform
4
+ import re
5
+ import sys
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import asyncpg
11
+
12
+ from pg_perf_bench import __version__
13
+ from pg_perf_bench.client_tools import (
14
+ SUPPORTED_SERVER_MAJORS,
15
+ select_local_clients,
16
+ server_major_from_version_num,
17
+ )
18
+ from pg_perf_bench.connections import get_connection
19
+ from pg_perf_bench.const import (
20
+ BENCHMARK_TEMPLATE_JSON_PATH,
21
+ WorkMode,
22
+ get_datetime_report,
23
+ get_default_report_name,
24
+ )
25
+ from pg_perf_bench.contracts import ARTIFACT_SCHEMA_VERSION, canonical_hash, file_hash
26
+ from pg_perf_bench.db_operations import (
27
+ DBTasks,
28
+ collect_db_logs,
29
+ get_conn_type_tasks,
30
+ run_command_result,
31
+ )
32
+ from pg_perf_bench.errors import CollectionError
33
+ from pg_perf_bench.log import display_user_configuration
34
+ from pg_perf_bench.report.commands import fill_info_report
35
+ from pg_perf_bench.report.processing import get_report_structure
36
+ from pg_perf_bench.system_metrics import (
37
+ build_system_metrics_section,
38
+ collect_system_metrics,
39
+ infer_pgbench_duration,
40
+ )
41
+ from pg_perf_bench.workloads import build_workload_evidence
42
+
43
+
44
+ class BenchmarkRunner:
45
+ """
46
+ A stateless utility class that encapsulates all steps for running
47
+ PostgreSQL performance benchmarks and collecting metrics.
48
+ All methods are static since they share no internal state.
49
+ """
50
+
51
+ @staticmethod
52
+ def maximum_tps(benchmark_runs: list[dict[str, Any]]) -> dict[str, Any] | None:
53
+ """Return the complete winning point from a client/load sweep."""
54
+ candidates = [
55
+ run
56
+ for run in benchmark_runs
57
+ if isinstance(run.get('metrics'), dict)
58
+ and isinstance(run['metrics'].get('tps'), (int, float))
59
+ and not isinstance(run['metrics'].get('tps'), bool)
60
+ ]
61
+ if not candidates:
62
+ return None
63
+ best = max(candidates, key=lambda run: float(run['metrics']['tps']))
64
+ return {
65
+ 'tps': best['metrics']['tps'],
66
+ 'iteration': deepcopy(best.get('iteration')),
67
+ 'metrics': deepcopy(best['metrics']),
68
+ }
69
+
70
+ @staticmethod
71
+ def environment_evidence(report: dict[str, Any]) -> dict[str, Any]:
72
+ """Build stable dimension hashes without volatile usage counters."""
73
+ system_reports = report['sections']['system']['reports']
74
+
75
+ def stable_value(item_name: str) -> Any:
76
+ value = deepcopy(system_reports[item_name].get('data'))
77
+ if item_name == 'ip_br_addr' and isinstance(value, str):
78
+ return re.sub(r'@if\d+', '@if*', value)
79
+ return value
80
+
81
+ dimension_items = {
82
+ 'kernel_os': (
83
+ 'uname_a',
84
+ 'etc_os_release',
85
+ 'sysctl_vm',
86
+ 'sysctl_net_ipv4_tcp',
87
+ 'sysctl_net_ipv4_udp',
88
+ ),
89
+ # ``lshw_processor`` includes the instantaneous CPU clock. It is
90
+ # useful raw evidence, but it is not hardware identity: frequency
91
+ # changes naturally with load and power management between runs.
92
+ 'cpu': ('cpu_info',),
93
+ 'memory_capacity': ('total_ram', 'lshw_memory'),
94
+ 'storage_hardware': ('lshw_storage', 'lshw_disk', 'lshw_volume'),
95
+ # Interface addresses and Docker bridge/veth names are runtime
96
+ # topology, not hardware identity. The raw ``ip -br addr`` output
97
+ # remains in the report for inspection.
98
+ 'network_hardware': ('lshw_network',),
99
+ }
100
+ dimensions = {
101
+ name: {
102
+ 'hash': canonical_hash(
103
+ {item_name: stable_value(item_name) for item_name in item_names}
104
+ ),
105
+ 'items': list(item_names),
106
+ }
107
+ for name, item_names in dimension_items.items()
108
+ }
109
+ compatibility = report.get('postgresql_compatibility') or {}
110
+ load_generator = compatibility.get('load_generator') or {}
111
+ first_run = next(iter(report.get('benchmark_runs') or []), {})
112
+ collection_scope = (first_run.get('system_metrics') or {}).get('collection_scope')
113
+ identity = {
114
+ 'dimensions': {name: item['hash'] for name, item in dimensions.items()},
115
+ 'load_generator': load_generator,
116
+ 'system_metrics_collection_scope': collection_scope,
117
+ }
118
+ return {
119
+ 'schema_version': 'pg_perf_bench/environment-evidence-v1',
120
+ 'identity_hash': canonical_hash(identity),
121
+ 'dimensions': dimensions,
122
+ 'load_generator_hash': canonical_hash(load_generator),
123
+ 'system_metrics_collection_scope': collection_scope,
124
+ }
125
+
126
+ @staticmethod
127
+ def get_pgbench_results(pgbench_output: str) -> list[int | float]:
128
+ """
129
+ Extracts key performance metrics from the pgbench output string.
130
+ Returns a list of metrics in the following order:
131
+ [clients, duration, transactions, latency_avg, init_conn_time, tps].
132
+ """
133
+
134
+ def get_val(iter_matches, val_type: str) -> int | float | None:
135
+ for match_obj in iter_matches:
136
+ sub_str = pgbench_output[match_obj.span()[0] : match_obj.span()[1]]
137
+ val_iter = re.finditer(r'\d+([.,]\d+)?', sub_str)
138
+ for vv in val_iter:
139
+ numeric_str = sub_str[vv.span()[0] : vv.span()[1]]
140
+ numeric_str = numeric_str.replace(',', '.')
141
+ if val_type == 'float':
142
+ return float(numeric_str)
143
+ elif val_type == 'int':
144
+ return int(numeric_str)
145
+ return None
146
+
147
+ clients = get_val(re.finditer(r'number\sof\sclients\:\s(\d+)', pgbench_output), 'int')
148
+ duration = get_val(re.finditer(r'duration\:\s(\d+)', pgbench_output), 'int')
149
+ transactions = get_val(
150
+ re.finditer(
151
+ r'number\sof\stransactions\sactually\sprocessed\:\s((\d+)/\d+|\d+)',
152
+ pgbench_output,
153
+ ),
154
+ 'int',
155
+ )
156
+ latency_avg = get_val(
157
+ re.finditer(r'latency\saverage\s=\s\d+(?:[.,]\d+)?\sms', pgbench_output),
158
+ 'float',
159
+ )
160
+ init_conn_time = get_val(
161
+ re.finditer(
162
+ r'initial\sconnection\stime\s=\s\d+(?:[.,]\d+)?\sms',
163
+ pgbench_output,
164
+ ),
165
+ 'float',
166
+ )
167
+ tps = get_val(re.finditer(r'tps\s=\s\d+(?:[.,]\d+)?', pgbench_output), 'float')
168
+
169
+ return [
170
+ clients,
171
+ duration,
172
+ transactions,
173
+ latency_avg,
174
+ init_conn_time,
175
+ tps,
176
+ ]
177
+
178
+ @staticmethod
179
+ def get_filled_load_commands(
180
+ db_conf: dict,
181
+ workload_conf: dict,
182
+ pgbench_param: str,
183
+ iter_amount: Any,
184
+ ) -> list[str]:
185
+ """
186
+ Replaces placeholders (ARG_*) in the init_command and workload_command
187
+ with actual config values and iteration-specific parameter.
188
+ """
189
+ arg_values = {**db_conf, **workload_conf, pgbench_param: iter_amount}
190
+ init_command = workload_conf['init_command']
191
+ workload_command = workload_conf['workload_command']
192
+
193
+ for key, value in arg_values.items():
194
+ if isinstance(key, str):
195
+ placeholder = f'ARG_{key.upper()}'
196
+ init_command = init_command.replace(placeholder, str(value))
197
+ workload_command = workload_command.replace(placeholder, str(value))
198
+
199
+ unresolved = sorted(
200
+ set(re.findall(r'ARG_[A-Z][A-Z0-9_]*', init_command + '\n' + workload_command))
201
+ )
202
+ if unresolved:
203
+ raise ValueError('Unresolved workload placeholders: ' + ', '.join(unresolved))
204
+
205
+ return [init_command, workload_command]
206
+
207
+ @staticmethod
208
+ def load_iterations_config(db_conf: dict, workload_conf: dict) -> list[list[str]]:
209
+ """
210
+ Builds a list of [init_command, workload_command] pairs for each iteration.
211
+ """
212
+ db_conf_pg = {f'pg_{k}': v for k, v in db_conf.items()}
213
+
214
+ pgbench_param_name = workload_conf.get('pgbench_iter_name')
215
+ iter_list = workload_conf.get('pgbench_iter_list')
216
+ if (
217
+ not workload_conf
218
+ or not isinstance(workload_conf, dict)
219
+ or not pgbench_param_name
220
+ or not iter_list
221
+ or not isinstance(iter_list, list)
222
+ or 'init_command' not in workload_conf
223
+ or 'workload_command' not in workload_conf
224
+ ):
225
+ return []
226
+
227
+ return [
228
+ BenchmarkRunner.get_filled_load_commands(
229
+ db_conf_pg, workload_conf, pgbench_param_name, iteration
230
+ )
231
+ for iteration in iter_list
232
+ ]
233
+
234
+ @staticmethod
235
+ async def reset_db_environment(
236
+ logger, conn_type: str, conn, db_conf: dict, workload_conf: dict
237
+ ) -> None:
238
+ """
239
+ Fully resets the database environment before a test iteration.
240
+ """
241
+ if not workload_conf.get('allow_database_reset'):
242
+ raise CollectionError(
243
+ 'Database reset was not explicitly confirmed for this benchmark run'
244
+ )
245
+ try:
246
+ db_tasks = DBTasks(db_conf, logger)
247
+ conn_tasks = get_conn_type_tasks(conn_type)(
248
+ db_conf=workload_conf, conn=conn, logger=logger
249
+ )
250
+
251
+ try:
252
+ await conn_tasks.start_db()
253
+ except Exception as e:
254
+ logger.warning(str(e))
255
+
256
+ await db_tasks.check_db_access()
257
+ await db_tasks.drop_db()
258
+ await conn_tasks.stop_db()
259
+ await conn_tasks.sync()
260
+ if workload_conf.get('drop_os_caches'):
261
+ await conn_tasks.drop_caches()
262
+ await conn_tasks.start_db()
263
+ await db_tasks.check_db_access()
264
+ await db_tasks.init_db()
265
+ await db_tasks.check_user_db_access()
266
+
267
+ except Exception as e:
268
+ raise RuntimeError(f'Failed to reset DB environment:\n{str(e)}') from e
269
+
270
+ @staticmethod
271
+ async def run_benchmark(logger, load_iteration: list[str]) -> list[Any]:
272
+ """
273
+ Runs a benchmark iteration and returns the parsed pgbench metrics.
274
+ """
275
+ init_cmd, workload_cmd = load_iteration
276
+
277
+ logger.info(f'Executing init_command:\n {init_cmd}')
278
+ await run_command_result(logger, init_cmd, check=True)
279
+
280
+ logger.info(f'Executing workload_command:\n {workload_cmd}')
281
+ workload_result = await run_command_result(
282
+ logger,
283
+ workload_cmd,
284
+ check=True,
285
+ )
286
+ perf_result = workload_result.stdout
287
+
288
+ if not perf_result.strip():
289
+ logger.warning('Workload command returned an empty or whitespace-only result.')
290
+ else:
291
+ logger.debug(f'Result of pgbench iteration:\n{perf_result}')
292
+ metrics = BenchmarkRunner.get_pgbench_results(perf_result)
293
+ if metrics[5] is None:
294
+ raise CollectionError(
295
+ 'pgbench completed but TPS could not be parsed; raw output is preserved'
296
+ )
297
+ return metrics
298
+
299
+ @staticmethod
300
+ async def run_benchmark_with_evidence(
301
+ logger,
302
+ load_iteration: list[str],
303
+ *,
304
+ db_conf: dict[str, Any],
305
+ command_timeout: float,
306
+ connection_type: str = 'local',
307
+ connection: Any = None,
308
+ system_metrics_interval: float = 1.0,
309
+ system_metrics_duration: float | None = None,
310
+ ) -> dict[str, Any]:
311
+ init_cmd, workload_cmd = load_iteration
312
+ environment = os.environ.copy()
313
+ environment.update(
314
+ {
315
+ 'PGHOST': str(db_conf.get('host', '')),
316
+ 'PGPORT': str(db_conf.get('port', '')),
317
+ 'PGUSER': str(db_conf.get('user', '')),
318
+ 'PGDATABASE': str(db_conf.get('database', '')),
319
+ }
320
+ )
321
+ password = db_conf.get('password')
322
+ if password:
323
+ environment['PGPASSWORD'] = str(password)
324
+ secrets = (str(password) if password else None,)
325
+ logger.info('Executing benchmark initialization command.')
326
+ init_result = await run_command_result(
327
+ logger,
328
+ init_cmd,
329
+ check=True,
330
+ timeout=command_timeout,
331
+ env=environment,
332
+ secrets=secrets,
333
+ )
334
+ logger.info('Executing pgbench workload command.')
335
+ sampler_task = None
336
+ if connection is not None:
337
+ sampling_duration = infer_pgbench_duration(
338
+ workload_cmd,
339
+ system_metrics_duration,
340
+ )
341
+ sampler_task = asyncio.create_task(
342
+ collect_system_metrics(
343
+ connection_type=connection_type,
344
+ connection=connection,
345
+ duration_seconds=sampling_duration,
346
+ interval_seconds=system_metrics_interval,
347
+ ),
348
+ name='pg-perf-bench:system-metrics',
349
+ )
350
+ try:
351
+ workload_result = await run_command_result(
352
+ logger,
353
+ workload_cmd,
354
+ check=True,
355
+ timeout=command_timeout,
356
+ env=environment,
357
+ secrets=secrets,
358
+ )
359
+ system_metrics = await sampler_task if sampler_task is not None else None
360
+ except BaseException:
361
+ if sampler_task is not None and not sampler_task.done():
362
+ sampler_task.cancel()
363
+ if sampler_task is not None:
364
+ await asyncio.gather(sampler_task, return_exceptions=True)
365
+ raise
366
+ metrics = BenchmarkRunner.get_pgbench_results(workload_result.stdout)
367
+ if metrics[5] is None:
368
+ raise CollectionError(
369
+ 'pgbench completed but TPS could not be parsed; raw output is preserved'
370
+ )
371
+ result = {
372
+ 'init': init_result.as_dict(secrets=secrets),
373
+ 'workload': workload_result.as_dict(secrets=secrets),
374
+ 'metrics': {
375
+ 'clients': metrics[0],
376
+ 'duration_seconds': metrics[1],
377
+ 'transactions': metrics[2],
378
+ 'latency_average_ms': metrics[3],
379
+ 'initial_connection_time_ms': metrics[4],
380
+ 'tps': metrics[5],
381
+ },
382
+ 'legacy_metrics': metrics,
383
+ }
384
+ if system_metrics is not None:
385
+ result['system_metrics'] = system_metrics
386
+ return result
387
+
388
+ @staticmethod
389
+ async def collect_compatibility_evidence(
390
+ db_conf: dict[str, Any], workload_conf: dict[str, Any]
391
+ ) -> dict[str, Any]:
392
+ """Validate server 10-18 and prove that newest clients run locally."""
393
+ pgbench, psql = select_local_clients(
394
+ str(workload_conf.get('pgbench_path') or '') or None,
395
+ str(workload_conf.get('psql_path') or '') or None,
396
+ )
397
+ connection_kwargs = {
398
+ key: value
399
+ for key, value in db_conf.items()
400
+ if key not in {'database', 'connect_timeout'}
401
+ }
402
+ connection_kwargs['database'] = 'postgres'
403
+ connection_kwargs['timeout'] = float(db_conf.get('connect_timeout', 5.0))
404
+ connection = await asyncpg.connect(**connection_kwargs)
405
+ try:
406
+ version_num = int(await connection.fetchval('SHOW server_version_num'))
407
+ version_text = str(await connection.fetchval('SHOW server_version'))
408
+ finally:
409
+ await connection.close()
410
+ server_major = server_major_from_version_num(version_num)
411
+ return {
412
+ 'schema_version': 'pg_perf_bench/postgresql-compatibility-v1',
413
+ 'supported_server_majors': list(SUPPORTED_SERVER_MAJORS),
414
+ 'server': {
415
+ 'major': server_major,
416
+ 'version_num': version_num,
417
+ 'version': version_text,
418
+ },
419
+ 'load_generator': {
420
+ 'execution_host': 'pg_perf_bench_local_host',
421
+ 'pgbench': pgbench.as_dict(),
422
+ 'psql': psql.as_dict(),
423
+ 'newest_installed_client_required': True,
424
+ },
425
+ }
426
+
427
+ @staticmethod
428
+ def setup_report_structure(report_conf: dict, logger) -> dict:
429
+ """
430
+ Prepares and returns the base report structure.
431
+ """
432
+ report = get_report_structure(BENCHMARK_TEMPLATE_JSON_PATH)
433
+ report['artifact_schema_version'] = ARTIFACT_SCHEMA_VERSION
434
+ report['generator'] = {'name': 'pg_perf_bench', 'version': __version__}
435
+ report['runtime'] = {
436
+ 'python': sys.version.split()[0],
437
+ 'platform': platform.platform(),
438
+ }
439
+ report['description'] = get_datetime_report('%d/%m/%Y %H:%M:%S')
440
+ if report_conf.get('report_name') is None:
441
+ report['report_name'] = f'{WorkMode.BENCHMARK}-{get_default_report_name()}'
442
+ report_conf['report_name'] = report['report_name']
443
+ else:
444
+ report['report_name'] = report_conf.get('report_name')
445
+
446
+ logger.info('Report structure initialized.')
447
+ return report
448
+
449
+ @staticmethod
450
+ def setup_connection(conn_type: str, conn_conf: dict, logger):
451
+ """
452
+ Initializes a database connection object based on the selected type.
453
+ """
454
+ connection_class = get_connection(conn_type)
455
+ if not connection_class:
456
+ logger.error(f'No valid connection factory for type: {conn_type}')
457
+ return None
458
+ logger.info(f'Connection type selected: {conn_type}')
459
+
460
+ connection = connection_class(**conn_conf)
461
+ connection.logger = logger
462
+ return connection
463
+
464
+ @staticmethod
465
+ async def run_benchmark_iterations(
466
+ logger,
467
+ load_iterations: list[list[str]],
468
+ conn_type: str,
469
+ client,
470
+ db_conf: dict,
471
+ workload_conf: dict,
472
+ ) -> list[dict[str, Any]]:
473
+ """
474
+ Executes all load test iterations sequentially and gathers results.
475
+ """
476
+ perf_results = []
477
+ logger.info('Starting load iterations...')
478
+ for idx, load_iteration in enumerate(load_iterations, start=1):
479
+ logger.info(f'Preparing for iteration {idx}...')
480
+ await BenchmarkRunner.reset_db_environment(
481
+ logger, conn_type, client, db_conf, workload_conf
482
+ )
483
+ result = deepcopy(
484
+ await BenchmarkRunner.run_benchmark_with_evidence(
485
+ logger,
486
+ load_iteration,
487
+ db_conf=db_conf,
488
+ command_timeout=float(workload_conf.get('command_timeout', 300.0)),
489
+ connection_type=conn_type,
490
+ connection=client,
491
+ system_metrics_interval=float(
492
+ workload_conf.get('system_metrics_interval', 1.0)
493
+ ),
494
+ system_metrics_duration=workload_conf.get('system_metrics_duration'),
495
+ )
496
+ )
497
+ iteration_values = workload_conf.get('pgbench_iter_list', [])
498
+ result['iteration'] = {
499
+ 'index': idx,
500
+ 'parameter': workload_conf.get('pgbench_iter_name'),
501
+ 'value': (iteration_values[idx - 1] if idx - 1 < len(iteration_values) else None),
502
+ }
503
+ perf_results.append(result)
504
+ logger.info(f'Iteration {idx} completed.')
505
+ return perf_results
506
+
507
+ @staticmethod
508
+ async def collect_monitoring_metrics(
509
+ logger,
510
+ db_conf: dict,
511
+ report_data: dict,
512
+ report: dict,
513
+ log_conf: dict,
514
+ client,
515
+ ) -> None:
516
+ """
517
+ Collects system and database metrics after the benchmark is complete.
518
+ """
519
+ logger.info('Connecting to DB for monitoring metrics...')
520
+ connection_kwargs = {
521
+ key: value for key, value in db_conf.items() if key != 'connect_timeout'
522
+ }
523
+ connection_kwargs['timeout'] = float(db_conf.get('connect_timeout', 5.0))
524
+ connection_kwargs['server_settings'] = {
525
+ 'default_transaction_read_only': 'on',
526
+ 'statement_timeout': '10000',
527
+ }
528
+ db_conn = await asyncpg.connect(**connection_kwargs)
529
+ try:
530
+ await fill_info_report(logger, client, db_conn, report_data, report)
531
+ logger.info('Monitoring data collected.')
532
+ if log_conf.get('collect_pg_logs'):
533
+ await collect_db_logs(
534
+ logger,
535
+ client,
536
+ db_conn,
537
+ report,
538
+ log_conf.get('db_logs_dir'),
539
+ )
540
+ finally:
541
+ await db_conn.close()
542
+ logger.info('Monitoring DB connection closed.')
543
+
544
+ @staticmethod
545
+ async def run_benchmark_and_collect_metrics(
546
+ args: dict,
547
+ conn_type: str,
548
+ conn_conf: dict,
549
+ db_conf: dict,
550
+ workload_conf: dict,
551
+ report_conf: dict,
552
+ log_conf: dict,
553
+ logger,
554
+ ) -> dict[str, Any] | None:
555
+ """
556
+ Main entry point to execute the full benchmarking workflow.
557
+ """
558
+ display_user_configuration(args, logger)
559
+
560
+ try:
561
+ report = BenchmarkRunner.setup_report_structure(report_conf, logger)
562
+ load_iterations = BenchmarkRunner.load_iterations_config(db_conf, workload_conf)
563
+ if not load_iterations:
564
+ logger.error('No valid load iterations configured.')
565
+ return None
566
+
567
+ connection = BenchmarkRunner.setup_connection(conn_type, conn_conf, logger)
568
+ if not connection:
569
+ return None
570
+
571
+ report_data = {
572
+ 'args': args,
573
+ 'workload_conf': workload_conf,
574
+ 'report_conf': report_conf,
575
+ }
576
+ workload_evidence = build_workload_evidence(workload_conf, load_iterations)
577
+ report_data['workload_evidence'] = workload_evidence
578
+ report['workload_evidence'] = workload_evidence
579
+ report['benchmark_methodology'] = {
580
+ 'database_recreated_before_each_iteration': True,
581
+ 'os_caches_dropped_before_each_iteration': bool(
582
+ workload_conf.get('drop_os_caches')
583
+ ),
584
+ 'workload_definition_hash': workload_evidence['definition_hash'],
585
+ 'workload_execution_hash': workload_evidence['execution_hash'],
586
+ 'system_metrics_engine': 'pg_diag',
587
+ 'system_metrics_collected_during_workload': True,
588
+ 'system_metrics_interval_seconds': float(
589
+ workload_conf.get('system_metrics_interval', 1.0)
590
+ ),
591
+ 'system_metrics_duration_override': workload_conf.get('system_metrics_duration'),
592
+ }
593
+
594
+ async with connection as client:
595
+ compatibility = await BenchmarkRunner.collect_compatibility_evidence(
596
+ db_conf,
597
+ workload_conf,
598
+ )
599
+ report['postgresql_compatibility'] = compatibility
600
+ if workload_conf.get('pg_custom_config'):
601
+ custom_path = workload_conf['pg_custom_config']
602
+ db_path = workload_conf.get('pg_data_path', '')
603
+ logger.info(f'Sending custom PostgreSQL config: {custom_path}')
604
+ remote_config = await client.send_pg_config_file(custom_path, db_path)
605
+ logger.info(f'Config applied: {custom_path} -> {remote_config}')
606
+
607
+ benchmark_runs = await BenchmarkRunner.run_benchmark_iterations(
608
+ logger,
609
+ load_iterations,
610
+ conn_type,
611
+ client,
612
+ db_conf,
613
+ workload_conf,
614
+ )
615
+ report_data['benchmark_runs'] = benchmark_runs
616
+ report_data['pgbench_outputs'] = [run['legacy_metrics'] for run in benchmark_runs]
617
+ report['benchmark_runs'] = benchmark_runs
618
+ report['maximum_tps'] = BenchmarkRunner.maximum_tps(benchmark_runs)
619
+ report['sections']['os_metrics'] = build_system_metrics_section(benchmark_runs)
620
+
621
+ await BenchmarkRunner.collect_monitoring_metrics(
622
+ logger, db_conf, report_data, report, log_conf, client
623
+ )
624
+ report['environment_evidence'] = BenchmarkRunner.environment_evidence(report)
625
+ effective_settings = report['sections']['db']['reports']['pg_settings'].get('data')
626
+ database_evidence = {
627
+ 'schema_version': 'pg_perf_bench/database-configuration-evidence-v1',
628
+ 'effective_settings_hash': canonical_hash(effective_settings),
629
+ }
630
+ custom_config = workload_conf.get('pg_custom_config')
631
+ if custom_config:
632
+ custom_path = Path(str(custom_config)).expanduser()
633
+ database_evidence['supplied_config'] = {
634
+ 'name': custom_path.name,
635
+ 'hash': file_hash(custom_path),
636
+ }
637
+ report['database_configuration_evidence'] = database_evidence
638
+
639
+ logger.info('Benchmarking process completed successfully.')
640
+ return report
641
+
642
+ except Exception as e:
643
+ logger.error(f'Benchmark failed: {e}')
644
+ if isinstance(e, CollectionError):
645
+ raise
646
+ raise CollectionError(f'Benchmark failed: {e}') from e