Skip to content

Commit 261927a

Browse files
committed
fix: preserve cancellation evidence across Python 3.10 task boundaries
Read attached diagnostics through CancelledError context wrappers in iteration handling, checkpoints and partial reports. Retain cancelled task semantics and test real Task/wait_for boundaries on supported Python versions.
1 parent 114c776 commit 261927a

5 files changed

Lines changed: 137 additions & 17 deletions

File tree

‎src/pg_perf_bench/benchmark.py‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
CommandExecutionError,
4747
CommandFailure,
4848
ConfigurationError,
49+
exception_evidence,
4950
)
5051
from pg_perf_bench.executors.process import ProcessResult
5152
from pg_perf_bench.init_policy import (
@@ -547,8 +548,9 @@ async def finish_sampling():
547548
except BaseException as exc:
548549
result['status'] = 'cancelled' if isinstance(exc, asyncio.CancelledError) else 'failed'
549550
result['error'] = str(exc) or type(exc).__name__
550-
if hasattr(exc, 'failure') and 'workload' not in result:
551-
result['workload'] = exc.failure.as_dict()
551+
failure = exception_evidence(exc, 'failure')
552+
if failure is not None and 'workload' not in result:
553+
result['workload'] = failure.as_dict()
552554
# Failed stdout can contain apparently valid TPS. Keep it only as raw
553555
# evidence, never as a point in the successful benchmark series.
554556
exc.benchmark_run = redact_mapping(result, secrets=secrets)
@@ -704,10 +706,11 @@ def write_checkpoint(workload_conf, db_conf, results, status, error=None):
704706
}
705707
if error is not None:
706708
payload['error'] = str(error) or type(error).__name__
707-
payload['failed_iteration'] = getattr(error, 'benchmark_run', None)
708-
if hasattr(error, 'failure'):
709-
payload['failed_command'] = error.failure.as_dict()
710-
payload['statement_timeout_messages'] = error.failure.stderr.count(
709+
payload['failed_iteration'] = exception_evidence(error, 'benchmark_run')
710+
failure = exception_evidence(error, 'failure')
711+
if failure is not None:
712+
payload['failed_command'] = failure.as_dict()
713+
payload['statement_timeout_messages'] = failure.stderr.count(
711714
'canceling statement due to statement timeout'
712715
)
713716
payload = redact_mapping(payload, secrets=(db_conf.get('password'),))
@@ -901,9 +904,10 @@ async def _run_benchmark_iterations(
901904
result['existing_dataset_validation'] = validation
902905
except BaseException as exc:
903906
primary_error = exc
904-
evidence = getattr(exc, 'benchmark_run', {})
905-
if hasattr(exc, 'failure') and 'workload' not in evidence:
906-
evidence['failed_command'] = exc.failure.as_dict()
907+
evidence = exception_evidence(exc, 'benchmark_run', {})
908+
failure = exception_evidence(exc, 'failure')
909+
if failure is not None and 'workload' not in evidence:
910+
evidence['failed_command'] = failure.as_dict()
907911
evidence.update(
908912
iteration=iteration,
909913
init_policy=init_policy,
@@ -1023,8 +1027,8 @@ def partial_report(report, report_data, error, *, started_at, started_clock, sec
10231027
"""Render existing evidence without reconnecting to a failed database."""
10241028
safe = redact_mapping(
10251029
{
1026-
'runs': getattr(error, 'completed_runs', []),
1027-
'failed': getattr(error, 'benchmark_run', None),
1030+
'runs': exception_evidence(error, 'completed_runs', []),
1031+
'failed': exception_evidence(error, 'benchmark_run'),
10281032
'message': str(error) or type(error).__name__,
10291033
},
10301034
secrets=secrets,

‎src/pg_perf_bench/errors.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,34 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
from dataclasses import dataclass
67
from typing import Any
78

89

10+
def exception_evidence(error: BaseException, name: str, default: Any = None) -> Any:
11+
"""Read attached evidence through Task cancellation wrappers on Python 3.10.
12+
13+
Older asyncio Tasks create a new CancelledError and keep the original in
14+
__context__. Do not follow unrelated exception contexts: they may describe
15+
a different operation. The closest explicit value, including None, wins.
16+
"""
17+
pending = [error]
18+
seen = set()
19+
while pending:
20+
current = pending.pop()
21+
if id(current) in seen:
22+
continue
23+
seen.add(id(current))
24+
if hasattr(current, name):
25+
return getattr(current, name)
26+
if isinstance(current, asyncio.CancelledError):
27+
for nested in (current.__context__, current.__cause__):
28+
if isinstance(nested, asyncio.CancelledError):
29+
pending.append(nested)
30+
return default
31+
32+
933
class PgPerfBenchError(Exception):
1034
"""Base class for user-facing failures."""
1135

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Cancelled task diagnostics must survive the Python 3.10 Task boundary."""
2+
3+
import asyncio
4+
import json
5+
import time
6+
from copy import deepcopy
7+
8+
import pytest
9+
10+
from pg_perf_bench.benchmark import BenchmarkRunner
11+
from pg_perf_bench.errors import CommandFailure, exception_evidence
12+
from tests.test_interrupted_benchmark import partial_report
13+
14+
15+
@pytest.mark.parametrize('wait_for', [False, True])
16+
def test_cancelled_task_retains_checkpoint_and_partial_report_evidence(tmp_path, wait_for):
17+
async def scenario():
18+
reference = partial_report(cancelled=True)
19+
started = asyncio.Event()
20+
command = CommandFailure('pgbench', -15, 'partial secret-value', 'cancelled', 1)
21+
22+
async def child():
23+
try:
24+
started.set()
25+
await asyncio.Event().wait()
26+
except asyncio.CancelledError as exc:
27+
exc.failure = command
28+
exc.benchmark_run = reference['failed_iteration']
29+
exc.completed_runs = reference['benchmark_runs']
30+
raise
31+
32+
task = asyncio.create_task(child())
33+
await started.wait()
34+
task.cancel()
35+
with pytest.raises(asyncio.CancelledError) as caught:
36+
if wait_for:
37+
await asyncio.wait_for(task, timeout=2)
38+
else:
39+
await task
40+
error = caught.value
41+
assert task.cancelled() # Evidence handling must not turn cancellation into success.
42+
assert exception_evidence(error, 'failure') is command
43+
results = exception_evidence(error, 'completed_runs')
44+
assert results == reference['benchmark_runs']
45+
path = tmp_path / 'checkpoint.json'
46+
BenchmarkRunner.write_checkpoint(
47+
{'checkpoint_path': str(path)},
48+
{'password': 'secret-value'},
49+
results,
50+
'cancelled',
51+
error,
52+
)
53+
checkpoint = json.loads(path.read_text())
54+
assert checkpoint['completed_iterations'] == 1
55+
assert checkpoint['failed_iteration'] == reference['failed_iteration']
56+
assert checkpoint['failed_command']['returncode'] == -15
57+
assert 'partial' in checkpoint['failed_command']['stdout']
58+
assert 'secret-value' not in path.read_text()
59+
report = BenchmarkRunner.partial_report(
60+
deepcopy(reference),
61+
{
62+
'workload_conf': {
63+
'pgbench_iter_name': 'pgbench_clients',
64+
'pgbench_iter_list': [8, 16, 64],
65+
},
66+
'report_conf': {'report_name': 'cancellation'},
67+
},
68+
error,
69+
started_at='start',
70+
started_clock=time.monotonic(),
71+
)
72+
assert report['benchmark_status'] == 'cancelled'
73+
assert report['benchmark_runs'] == reference['benchmark_runs']
74+
assert report['failed_iteration'] == reference['failed_iteration']
75+
assert report['maximum_tps']['tps'] == 10
76+
77+
asyncio.run(scenario())
78+
79+
80+
def test_cancellation_lookup_respects_current_value_and_ignores_unrelated_errors():
81+
inner = asyncio.CancelledError()
82+
inner.benchmark_run = {'workload': 'original'}
83+
outer = asyncio.CancelledError()
84+
outer.__context__ = inner
85+
assert exception_evidence(outer, 'benchmark_run') == inner.benchmark_run
86+
outer.benchmark_run = None
87+
assert exception_evidence(outer, 'benchmark_run', {}) is None
88+
unrelated = RuntimeError('new operation')
89+
unrelated.__context__ = inner
90+
assert exception_evidence(unrelated, 'benchmark_run') is None
91+
inner.__context__ = outer # A malformed context cycle must not hang reporting.
92+
assert exception_evidence(outer, 'missing', 'fallback') == 'fallback'

‎tests/test_core_review_regressions.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pg_diag.sampler_runtime import SamplerCollection
88

99
from pg_perf_bench.benchmark import BenchmarkRunner
10-
from pg_perf_bench.errors import CommandExecutionError, CommandFailure
10+
from pg_perf_bench.errors import CommandExecutionError, CommandFailure, exception_evidence
1111
from pg_perf_bench.executors import ProcessResult
1212
from pg_perf_bench.report.benchmark_status import build_execution_section
1313
from pg_perf_bench.system_metrics import collect_system_metrics
@@ -113,7 +113,7 @@ async def command(logger, cmd, **kwargs):
113113
release_window.set()
114114
with pytest.raises(asyncio.CancelledError) as caught:
115115
await asyncio.wait_for(task, timeout=2)
116-
evidence = caught.value.benchmark_run
116+
evidence = exception_evidence(caught.value, 'benchmark_run')
117117
assert evidence['status'] == 'cancelled'
118118
assert evidence['system_metrics']['samples']['os.memory'] == [sample(1), sample(2)]
119119
assert evidence['system_metrics']['samples']['os.disk'] == [sample(1)]

‎tests/test_interrupted_benchmark.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pg_perf_bench.benchmark import BenchmarkRunner
1212
from pg_perf_bench.cli import main
1313
from pg_perf_bench.const import BENCHMARK_TEMPLATE_JSON_PATH
14-
from pg_perf_bench.errors import CommandExecutionError, CommandTimeoutError
14+
from pg_perf_bench.errors import CommandExecutionError, CommandTimeoutError, exception_evidence
1515
from pg_perf_bench.executors import ProcessResult, run_local_process
1616
from pg_perf_bench.report.processing import get_report_structure
1717
from pg_perf_bench.system_metrics import collect_system_metrics
@@ -344,8 +344,8 @@ async def scenario():
344344
command_timeout=1,
345345
initialization_plan=MagicMock(),
346346
)
347-
assert caught.value.benchmark_run['initialization'] == init
348-
assert caught.value.benchmark_run['init']['returncode'] == 0
347+
assert exception_evidence(caught.value, 'benchmark_run')['initialization'] == init
348+
assert exception_evidence(caught.value, 'benchmark_run')['init']['returncode'] == 0
349349

350350
asyncio.run(scenario())
351351

@@ -411,7 +411,7 @@ async def command(logger, cmd, **kwargs):
411411
task.cancel()
412412
with pytest.raises(asyncio.CancelledError) as caught:
413413
await task
414-
result = caught.value.benchmark_run
414+
result = exception_evidence(caught.value, 'benchmark_run')
415415
assert 'tps = 1.0' in result['workload']['stdout']
416416
assert 'metrics' not in result
417417
else:

0 commit comments

Comments
 (0)