Skip to content

Commit e0ca7d5

Browse files
author
Gurov Oleg
committed
fix: release v0.7.1 with clearer initialization reports
1 parent f80e496 commit e0ca7d5

5 files changed

Lines changed: 241 additions & 22 deletions

File tree

‎INITIALIZATION.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ flight, regardless of dataset size. A batch commits independently; the loader
2222
does not create one transaction for the entire dataset. Progress logs report
2323
committed rows and batches per task.
2424

25+
The report shows a compact stage summary, with table conversions and vacuum
26+
operations grouped together. Expand **data loading by task** for PostgreSQL
27+
INSERT/COPY row counts, completed batches and cumulative worker time. Inserted
28+
rows are measured before post-load updates or deduplication; they are not final
29+
table cardinalities. Worker times overlap during parallel loading and must not
30+
be added to elapsed stage times. DDL and maintenance details are collapsed and
31+
do not display artificial zero row counters. A task targeting a partitioned
32+
parent can populate several partitions without separate partition-level jobs.
33+
2534
Before each iteration, `--reset-mode database` (default) recreates the disposable
2635
database. `--reset-mode schema` resets the schemas in `LoadPlan.schemas` inside
2736
a pre-created dedicated database; it requires fast initialization and `--init-fsync keep`.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pg-perf-bench"
7-
version = "0.7.0"
7+
version = "0.7.1"
88
description = "Reproducible PostgreSQL TPS benchmarks with environment evidence and report comparison"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎src/pg_perf_bench/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""PostgreSQL performance benchmark and evidence collector."""
22

3-
__version__ = '0.7.0'
3+
__version__ = '0.7.1'

‎src/pg_perf_bench/initialization.py‎

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -418,37 +418,155 @@ async def statement(phase, sql):
418418

419419

420420
def build_initialization_section(benchmark_runs, options):
421+
"""Separate measured loading counters from DDL and maintenance timings."""
422+
labels = {
423+
'schema': 'Create schema',
424+
'unlogged': 'Set tables UNLOGGED',
425+
'prepare': 'Prepare generator',
426+
'data': 'Load data',
427+
'after_data': 'Post-load processing',
428+
'logged': 'Set tables LOGGED',
429+
'indexes': 'Build indexes',
430+
'constraints': 'Apply constraints',
431+
'finalize': 'Finalize dataset',
432+
'vacuum': 'Vacuum and freeze',
433+
'analyze': 'Analyze relations',
434+
'restore_settings_and_sync': 'Restore durability settings',
435+
}
421436
reports = {}
422437
for run in benchmark_runs:
423438
evidence = run.get('initialization')
424439
if evidence is None:
425440
continue
426441
index = run['iteration']['index']
427-
rows = []
442+
groups = {}
443+
loads = []
444+
operations = []
428445
for phase in evidence['phases']:
429-
tasks = phase.get('tasks', [])
430-
rows.append(
431-
[
432-
phase['name'],
433-
round(phase['elapsed_seconds'], 3),
434-
sum(task['rows'] for task in tasks),
435-
sum(task['batches'] for task in tasks),
436-
]
437-
)
446+
kind = phase['name'].split(':', 1)[0]
447+
group = groups.setdefault(kind, {'seconds': 0.0, 'phases': [], 'tasks': []})
448+
group['seconds'] += phase['elapsed_seconds']
449+
group['phases'].append(phase)
450+
group['tasks'].extend(phase.get('tasks', []))
451+
if kind == 'data':
452+
for task in phase.get('tasks', []):
453+
loads.append(
454+
[
455+
task['name'],
456+
round(task['worker_seconds'], 3),
457+
f'Rows inserted: {task["rows"]:,}; batches: {task["batches"]:,}',
458+
]
459+
)
460+
elif kind in ('indexes', 'constraints'):
461+
# Aggregate timings already appear in the summary. Listing them
462+
# again beside individual tasks invites double-counting.
463+
action = 'Build index' if kind == 'indexes' else 'Apply constraint'
464+
for task in phase.get('tasks', []):
465+
operations.append(
466+
[
467+
action,
468+
round(task['worker_seconds'], 3),
469+
f'{task["name"]}; completed executions: {task["batches"]:,}. '
470+
'Time includes execution and commit.',
471+
]
472+
)
473+
else:
474+
relation = phase['name'].partition(':')[2]
475+
description = {
476+
'schema': 'Create profile schemas and supporting objects: '
477+
+ ', '.join(evidence.get('schemas', [])),
478+
'unlogged': f'Set {relation} to UNLOGGED before bulk loading.',
479+
'logged': (
480+
f'Set {relation} to LOGGED before bulk loading.'
481+
if options['table_mode'] == 'logged'
482+
else f'Restore WAL logging for {relation} after loading.'
483+
),
484+
'vacuum': f'VACUUM (FREEZE, ANALYZE) {relation}: freeze rows and '
485+
'refresh visibility and planner statistics.',
486+
'prepare': 'Execute profile setup SQL for data generation.',
487+
'after_data': 'Execute profile post-load SQL, including declared '
488+
'data corrections; affected rows are not recorded.',
489+
'finalize': 'Execute profile finalization SQL, such as materialized '
490+
'view refresh and workload bounds setup.',
491+
'analyze': 'ANALYZE profile relations, including partitioned parents.',
492+
'restore_settings_and_sync': 'Restore pre-load durability settings; '
493+
'checkpoint and sync only if fsync changed.',
494+
}.get(kind, f'Execute custom initialization step {phase["name"]}.')
495+
operations.append(
496+
[
497+
labels.get(kind, kind),
498+
round(phase['elapsed_seconds'], 3),
499+
description,
500+
]
501+
)
502+
503+
summary = []
504+
for kind, group in groups.items():
505+
tasks = group['tasks']
506+
if kind == 'data':
507+
inserted = sum(task['rows'] for task in tasks)
508+
batches = sum(task['batches'] for task in tasks)
509+
work = f'{inserted:,} rows inserted; {len(tasks):,} load tasks; {batches:,} batches'
510+
elif kind in ('indexes', 'constraints'):
511+
work = f'Tasks: {len(tasks):,}; executions: {sum(t["batches"] for t in tasks):,}'
512+
elif kind in ('unlogged', 'logged', 'vacuum'):
513+
work = f'{len(group["phases"]):,} relations'
514+
else:
515+
work = {
516+
'schema': 'Create tables and supporting objects',
517+
'prepare': 'Install generator helpers',
518+
'after_data': 'Apply profile post-processing; row counts not collected',
519+
'finalize': 'Run profile finalization SQL',
520+
'analyze': 'Refresh planner statistics',
521+
'restore_settings_and_sync': 'Restore settings; sync storage if fsync changed',
522+
}.get(kind, 'Execute profile SQL; row counts not collected')
523+
summary.append([labels.get(kind, kind), round(group['seconds'], 3), work])
438524
barrier = evidence['replication_barrier']
439-
rows.append(['replica_replay_barrier', round(barrier['elapsed_seconds'], 3), None, None])
525+
summary.append(
526+
[
527+
'Wait for replica replay',
528+
round(barrier['elapsed_seconds'], 3),
529+
f'{barrier["replicas"]} replicas caught up to {barrier["target_lsn"]}',
530+
]
531+
)
440532
reports[f'iteration_{index}'] = {
441-
'header': f'Iteration {index}: initialization phases',
442-
'description': f'Workers: {options["workers"]}; batch rows: {options["batch_rows"]}; '
443-
f'tables during load: {options["table_mode"]}; fsync during load: {options["fsync"]}; '
444-
f'loader synchronous_commit: {options["synchronous_commit"]}. '
445-
f'Final fsync: {evidence["fsync_after"]}; replicas caught up: {barrier["replicas"]}; '
446-
f'replay target: {barrier["target_lsn"]}. '
447-
'Rows and batches describe data jobs; elapsed times are wall-clock seconds.',
533+
'header': f'Iteration {index}: initialization summary',
534+
'description': f'Up to {options["workers"]} load workers; '
535+
f'batch limit: {options["batch_rows"]:,} rows. '
536+
f'Tables during load: {options["table_mode"]}; fsync policy: {options["fsync"]}; '
537+
f'synchronous_commit policy: {options["synchronous_commit"]}; '
538+
f'fsync after initialization: {evidence["fsync_after"]}. '
539+
'Times are measured elapsed seconds per stage; per-relation operations are grouped. '
540+
'Parallel worker times are not added to stage elapsed time.',
448541
'item_type': 'table',
449542
'state': 'expanded',
450543
'collection_status': 'ok',
451-
'theader': ['phase', 'elapsed_seconds', 'rows', 'jobs'],
452-
'data': rows,
544+
'theader': ['Stage', 'Elapsed (s)', 'Work performed'],
545+
'data': summary,
546+
}
547+
reports[f'iteration_{index}_loading'] = {
548+
'header': f'Iteration {index}: data loading by task',
549+
'description': 'Inserted rows come from PostgreSQL INSERT/COPY completion counts. '
550+
'They are not final table sizes: post-load processing may update or delete rows. '
551+
'Batches count successful loader executions, including single-statement tasks. '
552+
'Worker time is cumulative execution time including commit, not wall-clock time. '
553+
'Task names identify loader jobs, not necessarily physical tables; inserting into '
554+
'a partitioned parent routes rows to its partitions without separate loader jobs.',
555+
'item_type': 'table',
556+
'state': 'collapsed',
557+
'collection_status': 'ok',
558+
'theader': ['Load task', 'Worker time (s)', 'Details'],
559+
'data': loads,
560+
}
561+
reports[f'iteration_{index}_operations'] = {
562+
'header': f'Iteration {index}: SQL and maintenance details',
563+
'description': 'Individual operations, without the stage totals already shown above. '
564+
'Each row describes the work actually timed. Index tasks can run in parallel, '
565+
'so adding these times does not give total initialization elapsed time.',
566+
'item_type': 'table',
567+
'state': 'collapsed',
568+
'collection_status': 'ok',
569+
'theader': ['Operation', 'Time (s)', 'Description'],
570+
'data': operations,
453571
}
454572
return {'header': 'Database initialization', 'state': 'expanded', 'reports': reports}

‎tests/test_initialization.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,95 @@ def test_recovery_rejects_sql_failover_when_host_transport_still_targets_old_pri
301301
)
302302
with pytest.raises(ConfigurationError, match='target different servers'):
303303
asyncio.run(guard._identity())
304+
305+
306+
def test_initialization_report_separates_row_counts_from_maintenance_times():
307+
from pg_perf_bench.initialization import build_initialization_section
308+
309+
phases = [
310+
{'name': 'schema', 'elapsed_seconds': 0.1},
311+
{'name': 'unlogged:first', 'elapsed_seconds': 1},
312+
{'name': 'unlogged:second', 'elapsed_seconds': 2},
313+
{
314+
'name': 'data',
315+
'elapsed_seconds': 5,
316+
'tasks': [
317+
{'name': 'first', 'rows': 7, 'batches': 2, 'worker_seconds': 4},
318+
{'name': 'payment', 'rows': 3, 'batches': 1, 'worker_seconds': 9},
319+
],
320+
},
321+
{'name': 'after_data', 'elapsed_seconds': 0.5},
322+
{
323+
'name': 'indexes',
324+
'elapsed_seconds': 2,
325+
'tasks': [{'name': 'first_pk', 'rows': 0, 'batches': 1, 'worker_seconds': 2}],
326+
},
327+
]
328+
runs = [
329+
{
330+
'iteration': {'index': 1},
331+
'initialization': {
332+
'phases': phases,
333+
'fsync_after': 'on',
334+
'replication_barrier': {'elapsed_seconds': 0.2, 'replicas': 2, 'target_lsn': '0/A'},
335+
},
336+
}
337+
]
338+
options = dict(
339+
workers=4, batch_rows=100000, table_mode='unlogged', fsync='keep', synchronous_commit='keep'
340+
)
341+
reports = build_initialization_section(runs, options)['reports']
342+
summary = reports['iteration_1']
343+
assert summary['theader'] == ['Stage', 'Elapsed (s)', 'Work performed']
344+
assert ['Set tables UNLOGGED', 3, '2 relations'] in summary['data']
345+
assert ['Load data', 5, '10 rows inserted; 2 load tasks; 3 batches'] in summary['data']
346+
assert ['Build indexes', 2, 'Tasks: 1; executions: 1'] in summary['data']
347+
loading = reports['iteration_1_loading']
348+
assert loading['data'] == [
349+
['first', 4, 'Rows inserted: 7; batches: 2'],
350+
['payment', 9, 'Rows inserted: 3; batches: 1'],
351+
]
352+
assert loading['theader'] == ['Load task', 'Worker time (s)', 'Details']
353+
assert 'not final table sizes' in loading['description']
354+
assert 'partitioned parent' in loading['description']
355+
operations = reports['iteration_1_operations']
356+
assert operations['theader'] == ['Operation', 'Time (s)', 'Description']
357+
assert len(operations['data']) == 5 # No duplicate aggregate index timing.
358+
assert any(row[0] == 'Build index' and 'first_pk' in row[2] for row in operations['data'])
359+
assert not any(row[0] == 'Build indexes' for row in operations['data'])
360+
assert any('first to UNLOGGED' in row[2] for row in operations['data'])
361+
assert any('second to UNLOGGED' in row[2] for row in operations['data'])
362+
assert not any('Timed operation;' in row[2] for row in operations['data'])
363+
assert reports['iteration_1_operations']['state'] == 'collapsed'
364+
assert all(len(row) == 3 for row in reports['iteration_1_operations']['data'])
365+
assert build_initialization_section([{'iteration': {'index': 2}}], options)['reports'] == {}
366+
367+
368+
@pytest.mark.parametrize(
369+
('table_mode', 'description'),
370+
[
371+
('logged', 'Set payment to LOGGED before bulk loading.'),
372+
('unlogged', 'Restore WAL logging for payment after loading.'),
373+
],
374+
)
375+
def test_initialization_report_logged_description_matches_load_mode(table_mode, description):
376+
from pg_perf_bench.initialization import build_initialization_section
377+
378+
conversion = {'name': 'logged:payment', 'elapsed_seconds': 0.5}
379+
data = {'name': 'data', 'elapsed_seconds': 1, 'tasks': []}
380+
phases = [conversion, data] if table_mode == 'logged' else [data, conversion]
381+
run = {
382+
'iteration': {'index': 1},
383+
'initialization': {
384+
'phases': phases,
385+
'fsync_after': 'on',
386+
'replication_barrier': {'elapsed_seconds': 0, 'replicas': 0, 'target_lsn': '0/A'},
387+
},
388+
}
389+
options = dict(
390+
workers=4, batch_rows=100000, table_mode=table_mode, fsync='keep', synchronous_commit='keep'
391+
)
392+
393+
reports = build_initialization_section([run], options)['reports']
394+
395+
assert reports['iteration_1_operations']['data'] == [['Set tables LOGGED', 0.5, description]]

0 commit comments

Comments
 (0)