Make discussion state mutations concurrency-safe
This commit is contained in:
parent
0487a0b7ab
commit
4c89b400b4
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user