Skip to content

Commit ebbbdcd

Browse files
committed
Release 0.6.2: harden component integration and lifecycle checks
1 parent e1884b1 commit ebbbdcd

8 files changed

Lines changed: 158 additions & 8 deletions

File tree

‎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-workload"
7-
version = "0.6.1"
7+
version = "0.6.2"
88
description = "PostgreSQL workload emulator for diagnostic and observability testing"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎src/pg_workload/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""PostgreSQL workload generation and scheduling tools."""
22

3-
__version__ = "0.6.1"
3+
__version__ = "0.6.2"
44

55
from pg_workload.assets import bundled_profiles_root, initialize_project
66
from pg_workload.cli import build_parser

‎src/pg_workload/assets.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ def _copy_tree(source_root: Path, destination_root: Path, *, force: bool) -> dic
5050
destination_root.mkdir(parents=True, exist_ok=True)
5151
for source in sorted(source_root.rglob("*")):
5252
relative = source.relative_to(source_root)
53+
# Bytecode caches are interpreter by-products of the installation, not assets.
54+
if "__pycache__" in relative.parts:
55+
continue
5356
destination = destination_root / relative
5457
if source.is_symlink():
5558
raise WorkloadError(f"Bundled asset must not be a symlink: {source}")

‎src/pg_workload/cli.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,11 @@ def build_parser() -> argparse.ArgumentParser:
374374
stop_cmd.add_argument("--root", default=DEFAULT_ROOT, help="Initialized project directory")
375375
stop_cmd.add_argument("--daemon-lock-file", default=DEFAULT_DAEMON_LOCK_FILE)
376376
stop_cmd.add_argument("--scheduler-log-file", default=DEFAULT_SCHEDULER_LOG_FILE)
377+
stop_cmd.add_argument(
378+
"--expected-identity",
379+
type=_scheduler_identity_arg,
380+
help="Require the recorded scheduler identity before signalling",
381+
)
377382
stop_cmd.add_argument("--timeout", type=positive_float_arg, default=10.0)
378383

379384
plan_cmd = subparsers.add_parser("plan", help="Build a deterministic execution plan without connecting")
@@ -460,8 +465,18 @@ def _verify_plan_hash(args: argparse.Namespace, operation: str) -> dict[str, obj
460465
return plan
461466

462467

468+
def _scheduler_identity_arg(value: str) -> dict[str, object]:
469+
identity = json.loads(value)
470+
if not isinstance(identity, dict):
471+
raise argparse.ArgumentTypeError("scheduler identity must be a JSON object")
472+
return identity
473+
474+
463475
def _scheduler_paths(args: argparse.Namespace) -> tuple[Path, Path, Path]:
464476
root = Path(args.root).resolve()
477+
# Without this a mistyped --root reads as "stopped" while the real scheduler keeps running.
478+
if not root.is_dir():
479+
raise WorkloadError(f"Workload project directory does not exist: {root}")
465480
lock_path = resolve_relative_path(root, args.daemon_lock_file, "scheduler lock file")
466481
log_path = resolve_relative_path(root, args.scheduler_log_file, "scheduler log file")
467482
return root, lock_path, log_path
@@ -593,7 +608,7 @@ def _run(args: argparse.Namespace) -> int:
593608
return 0
594609
if args.command == "stop":
595610
root, lock_path, log_path = _scheduler_paths(args)
596-
result = stop_scheduler(root, lock_path, log_path, args.timeout)
611+
result = stop_scheduler(root, lock_path, log_path, args.timeout, expected_identity=args.expected_identity)
597612
if args.machine:
598613
_emit_machine(args, "succeeded", result=result)
599614
else:

‎src/pg_workload/control.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ def _owned(pid: int, root: Path) -> bool:
4646
)
4747

4848

49+
def scheduler_identity(pid: int) -> dict[str, Any] | None:
50+
try:
51+
stat = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
52+
return {
53+
"pid": pid,
54+
"start_ticks": int(stat[19]),
55+
"boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip(),
56+
}
57+
except (OSError, ValueError, IndexError):
58+
return None
59+
60+
4961
def scheduler_status(root: Path, lock_path: Path, log_path: Path) -> dict[str, Any]:
5062
pid = _pid(lock_path)
5163
running = pid is not None and _owned(pid, root)
@@ -55,6 +67,7 @@ def scheduler_status(root: Path, lock_path: Path, log_path: Path) -> dict[str, A
5567
"pid": pid if running else None,
5668
"stale_pid": pid if pid is not None and not running else None,
5769
"owned": running,
70+
"identity": scheduler_identity(pid) if running else None,
5871
"root": str(root.resolve()),
5972
"lock_file": str(lock_path),
6073
"log_file": str(log_path),
@@ -139,6 +152,7 @@ def stop_scheduler(
139152
lock_path: Path,
140153
log_path: Path,
141154
timeout: float,
155+
expected_identity: dict[str, Any] | None = None,
142156
) -> dict[str, Any]:
143157
pid = _pid(lock_path)
144158
if pid is None:
@@ -148,7 +162,20 @@ def stop_scheduler(
148162
raise WorkloadOwnershipError(f"Refusing to signal pid {pid}: it is not a pg-workload scheduler for {root}")
149163
_clear_stale_pid(lock_path, pid)
150164
return scheduler_status(root, lock_path, log_path)
151-
os.kill(pid, signal.SIGTERM)
165+
if expected_identity is not None:
166+
# Pin the process before checking its identity: PID reuse must never redirect the signal.
167+
try:
168+
fd = os.pidfd_open(pid)
169+
except ProcessLookupError:
170+
return scheduler_status(root, lock_path, log_path)
171+
try:
172+
if scheduler_identity(pid) != expected_identity:
173+
raise WorkloadOwnershipError("Scheduler identity differs from the recorded run; refusing to stop it")
174+
signal.pidfd_send_signal(fd, signal.SIGTERM)
175+
finally:
176+
os.close(fd)
177+
else:
178+
os.kill(pid, signal.SIGTERM)
152179
deadline = time.monotonic() + timeout
153180
while time.monotonic() < deadline:
154181
if not _cmdline(pid):

‎src/pg_workload/orchestration.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ def _file_hash(path: Path) -> str:
5454
def profile_descriptor(profile: Profile) -> dict[str, Any]:
5555
files = []
5656
for path in sorted(profile.path.rglob("*")):
57-
if not path.is_file() or "log" in path.relative_to(profile.path).parts:
57+
parts = path.relative_to(profile.path).parts
58+
# Job logs and interpreter bytecode caches are run by-products, not profile content.
59+
if not path.is_file() or "log" in parts or "__pycache__" in parts:
5860
continue
5961
files.append(
6062
{

‎tests/test_scheduler_identity.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import os
2+
import subprocess
3+
import sys
4+
import time
5+
6+
import pytest
7+
8+
from pg_workload.control import WorkloadOwnershipError, scheduler_status, stop_scheduler
9+
10+
11+
def test_guarded_stop_leaves_replacement_scheduler_alive(tmp_path):
12+
(tmp_path / "data").mkdir()
13+
lock = tmp_path / "state/scheduler.lock"
14+
log = tmp_path / "scheduler.log"
15+
with log.open("w") as output:
16+
process = subprocess.Popen(
17+
[
18+
sys.executable,
19+
"-m",
20+
"pg_workload",
21+
"scheduler",
22+
"--root",
23+
str(tmp_path),
24+
"--target=external",
25+
"--host=127.0.0.1",
26+
"--dry-run",
27+
],
28+
stdout=output,
29+
stderr=output,
30+
)
31+
try:
32+
deadline = time.monotonic() + 10
33+
while time.monotonic() < deadline:
34+
status = scheduler_status(tmp_path, lock, log)
35+
if status["running"]:
36+
break
37+
assert process.poll() is None, log.read_text()
38+
time.sleep(0.05)
39+
assert status["running"], log.read_text()
40+
identity = status["identity"]
41+
assert identity["pid"] == process.pid
42+
for old in [
43+
{},
44+
dict(identity, start_ticks=identity["start_ticks"] - 1),
45+
dict(identity, boot_id="old-boot"),
46+
dict(identity, pid=os.getpid()),
47+
]:
48+
with pytest.raises(WorkloadOwnershipError, match="identity differs"):
49+
stop_scheduler(tmp_path, lock, log, 3, expected_identity=old)
50+
assert process.poll() is None
51+
stopped = stop_scheduler(tmp_path, lock, log, 5, expected_identity=identity)
52+
assert not stopped["running"]
53+
process.wait(timeout=5)
54+
finally:
55+
if process.poll() is None:
56+
process.terminate()
57+
process.wait(timeout=5)

‎tests/test_workload.py‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import io
55
import json
66
import os
7+
import queue
78
import re
89
import subprocess
910
import sys
1011
import tempfile
12+
import threading
1113
import time
1214
import types
1315
import unittest
@@ -157,13 +159,30 @@ def plan_hash(*, disk_max_used_pct: int = 90) -> str:
157159
first = plan_hash()
158160
self.assertEqual(first, plan_hash())
159161
self.assertNotEqual(first, plan_hash(disk_max_used_pct=95))
162+
cache = root / "data" / "pagila" / "__pycache__"
163+
cache.mkdir(exist_ok=True)
164+
(cache / "generator.cpython-399.pyc").write_bytes(b"interpreter by-product")
165+
self.assertEqual(first, plan_hash())
160166
profile = root / "data" / "pagila" / "profile.yml"
161167
profile.write_text(
162168
profile.read_text(encoding="utf-8") + "\n# local experiment\n",
163169
encoding="utf-8",
164170
)
165171
self.assertNotEqual(first, plan_hash())
166172

173+
def test_scheduler_commands_reject_a_missing_project_root(self):
174+
with tempfile.TemporaryDirectory() as tmpdir:
175+
missing = Path(tmpdir) / "no-such-project"
176+
for command in ("status", "stop"):
177+
stdout = io.StringIO()
178+
with contextlib.redirect_stdout(stdout):
179+
result = main(["--machine", command, "--root", str(missing)])
180+
envelope = json.loads(stdout.getvalue())
181+
self.assertNotEqual(0, result)
182+
self.assertEqual("failed", envelope["status"])
183+
self.assertIn("does not exist", envelope["error"]["message"])
184+
self.assertFalse(missing.exists())
185+
167186
def test_detached_scheduler_has_owned_start_status_stop_lifecycle(self):
168187
with tempfile.TemporaryDirectory() as tmpdir:
169188
root = Path(tmpdir)
@@ -764,6 +783,7 @@ def test_init_is_idempotent_and_protects_modified_assets(self):
764783
second = self.workload.initialize_project(root)
765784
self.assertGreater(first["assets"]["created"], 0)
766785
self.assertGreater(second["assets"]["unchanged"], 0)
786+
self.assertEqual([], sorted(root.rglob("__pycache__")))
767787
self.assertTrue((root / "schema" / "pg_workload-v1.schema.json").is_file())
768788
manifest = root / "data" / "simple_stock" / "profile.yml"
769789
manifest.write_text(manifest.read_text(encoding="utf-8") + "# local edit\n", encoding="utf-8")
@@ -1379,8 +1399,30 @@ def test_running_scheduler_picks_up_new_profile_from_state_without_restart(self)
13791399
stderr=subprocess.PIPE,
13801400
text=True,
13811401
)
1402+
lines = []
1403+
events = queue.Queue()
1404+
1405+
def read_stdout():
1406+
for line in proc.stdout:
1407+
lines.append(line)
1408+
events.put(line)
1409+
1410+
reader = threading.Thread(target=read_stdout, daemon=True)
1411+
reader.start()
1412+
1413+
def wait_for(text):
1414+
deadline = time.monotonic() + 15
1415+
while time.monotonic() < deadline:
1416+
try:
1417+
line = events.get(timeout=max(0.01, deadline - time.monotonic()))
1418+
except queue.Empty:
1419+
break
1420+
if text in line:
1421+
return
1422+
self.fail(f"Timed out waiting for {text!r}: {''.join(lines)}")
1423+
13821424
try:
1383-
time.sleep(0.2)
1425+
wait_for("Scheduler started.")
13841426
self.create_minimal_profile(root, "dynamic_profile")
13851427
self.workload.update_job_state(
13861428
root / "state" / "workloads.yml",
@@ -1389,10 +1431,14 @@ def test_running_scheduler_picks_up_new_profile_from_state_without_restart(self)
13891431
enabled=True,
13901432
interval=60,
13911433
)
1392-
time.sleep(0.5)
1434+
wait_for("Running dynamic_profile:main")
1435+
wait_for("pgbench")
13931436
finally:
13941437
proc.terminate()
1395-
stdout, stderr = proc.communicate(timeout=10)
1438+
proc.wait(timeout=10)
1439+
reader.join(timeout=5)
1440+
stdout = "".join(lines)
1441+
stderr = proc.stderr.read()
13961442

13971443
self.assertIn("Scheduler started.", stdout)
13981444
self.assertIn("Running dynamic_profile:main", stdout)

0 commit comments

Comments
 (0)