Make discussion state mutations concurrency-safe

This commit is contained in:
Nilton Constantino 2026-07-03 09:54:22 +01:00
parent 0487a0b7ab
commit 4c89b400b4
No known key found for this signature in database
3 changed files with 302 additions and 223 deletions

View File

@ -6,7 +6,7 @@ import json
import re import re
import shutil import shutil
from .model import ALL_ARTIFACT_KINDS, DiscussionError, DiscussionIndex, WORKFLOW_KINDS, artifact_path from .model import ALL_ARTIFACT_KINDS, DiscussionError, DiscussionIndex, WORKFLOW_KINDS, artifact_path, index_lock
from .validation import validate, validate_or_raise from .validation import validate, validate_or_raise
@ -24,6 +24,7 @@ def init(root: Path) -> str:
): ):
(discussion_dir / relative).mkdir(parents=True, exist_ok=True) (discussion_dir / relative).mkdir(parents=True, exist_ok=True)
with index_lock(root):
index_path = discussion_dir / "index.ndjson" index_path = discussion_dir / "index.ndjson"
if index_path.exists(): if index_path.exists():
return f"already initialized: {index_path}" return f"already initialized: {index_path}"
@ -47,6 +48,7 @@ def start_agenda(
if not title.strip(): if not title.strip():
raise DiscussionError("title is required") raise DiscussionError("title is required")
with index_lock(root):
index = DiscussionIndex.load(root) index = DiscussionIndex.load(root)
normalized_ticket = _slugify(ticket) normalized_ticket = _slugify(ticket)
if not normalized_ticket: if not normalized_ticket:
@ -107,6 +109,7 @@ def start_agenda(
def accept_agenda(root: Path, discussion_id: str, agenda_id: str | None) -> dict: def accept_agenda(root: Path, discussion_id: str, agenda_id: str | None) -> dict:
with index_lock(root):
index = DiscussionIndex.load(root) index = DiscussionIndex.load(root)
discussion = index.find_discussion(discussion_id) discussion = index.find_discussion(discussion_id)
agenda = _select_artifact(discussion, "agendas", agenda_id) agenda = _select_artifact(discussion, "agendas", agenda_id)
@ -125,6 +128,7 @@ def accept_agenda(root: Path, discussion_id: str, agenda_id: str | None) -> dict
def add_decision(root: Path, discussion_id: str, agenda_id: str | None, title: str) -> dict: def add_decision(root: Path, discussion_id: str, agenda_id: str | None, title: str) -> dict:
if not title.strip(): if not title.strip():
raise DiscussionError("title is required") raise DiscussionError("title is required")
with index_lock(root):
index = DiscussionIndex.load(root) index = DiscussionIndex.load(root)
discussion = index.find_discussion(discussion_id) discussion = index.find_discussion(discussion_id)
agenda = _select_artifact(discussion, "agendas", agenda_id) agenda = _select_artifact(discussion, "agendas", agenda_id)
@ -165,6 +169,7 @@ def add_decision(root: Path, discussion_id: str, agenda_id: str | None, title: s
def accept_decision(root: Path, discussion_id: str, decision_id: str | None) -> dict: def accept_decision(root: Path, discussion_id: str, decision_id: str | None) -> dict:
with index_lock(root):
index = DiscussionIndex.load(root) index = DiscussionIndex.load(root)
discussion = index.find_discussion(discussion_id) discussion = index.find_discussion(discussion_id)
decision = _select_artifact(discussion, "decisions", decision_id) decision = _select_artifact(discussion, "decisions", decision_id)
@ -183,6 +188,7 @@ def accept_decision(root: Path, discussion_id: str, decision_id: str | None) ->
def add_plan(root: Path, discussion_id: str, decision_id: str | None, title: str) -> dict: def add_plan(root: Path, discussion_id: str, decision_id: str | None, title: str) -> dict:
if not title.strip(): if not title.strip():
raise DiscussionError("title is required") raise DiscussionError("title is required")
with index_lock(root):
index = DiscussionIndex.load(root) index = DiscussionIndex.load(root)
discussion = index.find_discussion(discussion_id) discussion = index.find_discussion(discussion_id)
decision = _select_artifact(discussion, "decisions", decision_id) decision = _select_artifact(discussion, "decisions", decision_id)
@ -225,6 +231,7 @@ def add_plan(root: Path, discussion_id: str, decision_id: str | None, title: str
def mark_plan(root: Path, plan_id: str, status: str) -> dict: def mark_plan(root: Path, plan_id: str, status: str) -> dict:
if status not in {"open", "review", "accepted", "in_progress", "done", "abandoned"}: if status not in {"open", "review", "accepted", "in_progress", "done", "abandoned"}:
raise DiscussionError(f"unsupported plan status: {status}") raise DiscussionError(f"unsupported plan status: {status}")
with index_lock(root):
index = DiscussionIndex.load(root) index = DiscussionIndex.load(root)
discussion, plan = _find_artifact(index, "plans", plan_id) discussion, plan = _find_artifact(index, "plans", plan_id)
current = plan.get("status") current = plan.get("status")
@ -424,6 +431,7 @@ def housekeep(root: Path, discussion_id: str, lesson: str | None, no_lesson: boo
if bool(lesson) == bool(no_lesson): if bool(lesson) == bool(no_lesson):
raise DiscussionError("choose exactly one of --lesson or --no-lesson") raise DiscussionError("choose exactly one of --lesson or --no-lesson")
with index_lock(root):
index = DiscussionIndex.load(root) index = DiscussionIndex.load(root)
discussion = index.find_discussion(discussion_id) discussion = index.find_discussion(discussion_id)

View File

@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import fcntl
import json import json
import os
COUNTERS = ("DSC", "AGD", "DEC", "PLN", "LSN", "CLSN") COUNTERS = ("DSC", "AGD", "DEC", "PLN", "LSN", "CLSN")
@ -14,6 +17,19 @@ class DiscussionError(RuntimeError):
pass pass
@contextmanager
def index_lock(root: Path):
discussion_dir = root / "discussion"
discussion_dir.mkdir(parents=True, exist_ok=True)
lock_path = discussion_dir / ".index.lock"
with lock_path.open("a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
@dataclass @dataclass
class DiscussionIndex: class DiscussionIndex:
root: Path root: Path
@ -55,7 +71,13 @@ class DiscussionIndex:
def save(self) -> None: def save(self) -> None:
records = [self.meta, *self.discussions] records = [self.meta, *self.discussions]
text = "\n".join(json.dumps(record, separators=(",", ":"), ensure_ascii=False) for record in records) text = "\n".join(json.dumps(record, separators=(",", ":"), ensure_ascii=False) for record in records)
self.path.write_text(text + "\n", encoding="utf-8") tmp_path = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp")
try:
tmp_path.write_text(text + "\n", encoding="utf-8")
os.replace(tmp_path, self.path)
finally:
if tmp_path.exists():
tmp_path.unlink()
def find_discussion(self, discussion_id: str) -> dict: def find_discussion(self, discussion_id: str) -> dict:
for discussion in self.discussions: for discussion in self.discussions:

View File

@ -3,7 +3,10 @@ from __future__ import annotations
import contextlib import contextlib
import io import io
import json import json
import os
from pathlib import Path from pathlib import Path
import subprocess
import sys
import unittest import unittest
from discussion_framework.cli import main from discussion_framework.cli import main
@ -145,6 +148,52 @@ class CliTest(unittest.TestCase):
self.assertIn("status: accepted", decision.read_text(encoding="utf-8")) self.assertIn("status: accepted", decision.read_text(encoding="utf-8"))
self.assertIn("status: done", plan.read_text(encoding="utf-8")) self.assertIn("status: done", plan.read_text(encoding="utf-8"))
def test_parallel_add_plan_allocates_unique_ids(self) -> None:
with TempDir() as root:
main(["--root", str(root), "init"])
self.assertEqual(main(["--root", str(root), "start", "parallel-demo", "Parallel Demo"]), 0)
self.assertEqual(main(["--root", str(root), "accept-agenda", "DSC-0001"]), 0)
self.assertEqual(main(["--root", str(root), "add-decision", "DSC-0001", "--title", "Parallel Decision"]), 0)
self.assertEqual(main(["--root", str(root), "accept-decision", "DSC-0001"]), 0)
script = "from discussion_framework.cli import main; import sys; raise SystemExit(main(sys.argv[1:]))"
env = os.environ.copy()
src = str(Path(__file__).resolve().parents[1] / "src")
env["PYTHONPATH"] = src + os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else src
processes = [
subprocess.Popen(
[
sys.executable,
"-c",
script,
"--root",
str(root),
"add-plan",
"DSC-0001",
"--title",
f"Parallel Plan {number}",
],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for number in range(1, 4)
]
results = [process.communicate(timeout=10) + (process.returncode,) for process in processes]
self.assertEqual([returncode for *_streams, returncode in results], [0, 0, 0], results)
discussion = root / "discussion"
records = [json.loads(line) for line in (discussion / "index.ndjson").read_text(encoding="utf-8").splitlines()]
meta = records[0]
plans = records[1]["plans"]
self.assertEqual(meta["next_id"]["PLN"], 4)
self.assertEqual(sorted(plan["id"] for plan in plans), ["PLN-0001", "PLN-0002", "PLN-0003"])
for plan in plans:
self.assertTrue((discussion / "workflow" / "plans" / plan["file"]).exists())
def test_housekeep_with_lesson_removes_workflow_artifacts(self) -> None: def test_housekeep_with_lesson_removes_workflow_artifacts(self) -> None:
with TempDir() as root: with TempDir() as root:
main(["--root", str(root), "init"]) main(["--root", str(root), "init"])