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,11 +24,12 @@ def init(root: Path) -> str:
): ):
(discussion_dir / relative).mkdir(parents=True, exist_ok=True) (discussion_dir / relative).mkdir(parents=True, exist_ok=True)
index_path = discussion_dir / "index.ndjson" with index_lock(root):
if index_path.exists(): index_path = discussion_dir / "index.ndjson"
return f"already initialized: {index_path}" if index_path.exists():
index_path.write_text(INITIAL_INDEX, encoding="utf-8") return f"already initialized: {index_path}"
return f"initialized: {discussion_dir}" index_path.write_text(INITIAL_INDEX, encoding="utf-8")
return f"initialized: {discussion_dir}"
def start_agenda( def start_agenda(
@ -47,199 +48,205 @@ def start_agenda(
if not title.strip(): if not title.strip():
raise DiscussionError("title is required") raise DiscussionError("title is required")
index = DiscussionIndex.load(root) with index_lock(root):
normalized_ticket = _slugify(ticket) index = DiscussionIndex.load(root)
if not normalized_ticket: normalized_ticket = _slugify(ticket)
raise DiscussionError("ticket must contain at least one alphanumeric character") if not normalized_ticket:
for discussion in index.discussions: raise DiscussionError("ticket must contain at least one alphanumeric character")
if discussion.get("ticket") == normalized_ticket: for discussion in index.discussions:
raise DiscussionError(f"discussion already exists for ticket: {normalized_ticket}") if discussion.get("ticket") == normalized_ticket:
raise DiscussionError(f"discussion already exists for ticket: {normalized_ticket}")
discussion_id = index.next_id("DSC") discussion_id = index.next_id("DSC")
agenda_id = index.next_id("AGD") agenda_id = index.next_id("AGD")
slug = _slugify(title) or normalized_ticket slug = _slugify(title) or normalized_ticket
agenda_file = f"{agenda_id}-{slug}.md" agenda_file = f"{agenda_id}-{slug}.md"
today = date.today().isoformat() today = date.today().isoformat()
normalized_tags = _normalize_tags(tags) normalized_tags = _normalize_tags(tags)
agenda_path = artifact_path(root, "agendas", agenda_file) agenda_path = artifact_path(root, "agendas", agenda_file)
agenda_path.parent.mkdir(parents=True, exist_ok=True) agenda_path.parent.mkdir(parents=True, exist_ok=True)
agenda_path.write_text( agenda_path.write_text(
_render_agenda( _render_agenda(
agenda_id=agenda_id, agenda_id=agenda_id,
ticket=normalized_ticket, ticket=normalized_ticket,
title=title.strip(), title=title.strip(),
created=today, created=today,
tags=normalized_tags, tags=normalized_tags,
domain_owner=domain_owner.strip(), domain_owner=domain_owner.strip(),
pain=pain.strip(), pain=pain.strip(),
context=context.strip(), context=context.strip(),
questions=questions, questions=questions,
), ),
encoding="utf-8", encoding="utf-8",
) )
discussion = { discussion = {
"type": "discussion", "type": "discussion",
"id": discussion_id, "id": discussion_id,
"status": "open", "status": "open",
"ticket": normalized_ticket, "ticket": normalized_ticket,
"title": title.strip(), "title": title.strip(),
"created_at": today, "created_at": today,
"updated_at": today, "updated_at": today,
"tags": normalized_tags, "tags": normalized_tags,
"agendas": [{"id": agenda_id, "file": agenda_file, "status": "open", "created_at": today, "updated_at": today}], "agendas": [{"id": agenda_id, "file": agenda_file, "status": "open", "created_at": today, "updated_at": today}],
"decisions": [], "decisions": [],
"plans": [], "plans": [],
"lessons": [], "lessons": [],
} }
index.discussions.insert(0, discussion) index.discussions.insert(0, discussion)
index.ensure_next_id_above("DSC", discussion_id) index.ensure_next_id_above("DSC", discussion_id)
index.ensure_next_id_above("AGD", agenda_id) index.ensure_next_id_above("AGD", agenda_id)
index.save() index.save()
validate_or_raise(root) validate_or_raise(root)
return { return {
"discussion_id": discussion_id, "discussion_id": discussion_id,
"agenda_id": agenda_id, "agenda_id": agenda_id,
"ticket": normalized_ticket, "ticket": normalized_ticket,
"title": title.strip(), "title": title.strip(),
"agenda_file": str(agenda_path), "agenda_file": str(agenda_path),
} }
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:
index = DiscussionIndex.load(root) with index_lock(root):
discussion = index.find_discussion(discussion_id) index = DiscussionIndex.load(root)
agenda = _select_artifact(discussion, "agendas", agenda_id) discussion = index.find_discussion(discussion_id)
if agenda.get("status") not in {"open", "review"}: agenda = _select_artifact(discussion, "agendas", agenda_id)
raise DiscussionError(f"agenda cannot be accepted from status: {agenda.get('status')}") if agenda.get("status") not in {"open", "review"}:
today = date.today().isoformat() raise DiscussionError(f"agenda cannot be accepted from status: {agenda.get('status')}")
_set_artifact_status(root, "agendas", agenda, "accepted") today = date.today().isoformat()
agenda["status"] = "accepted" _set_artifact_status(root, "agendas", agenda, "accepted")
agenda["updated_at"] = today agenda["status"] = "accepted"
discussion["updated_at"] = today agenda["updated_at"] = today
index.save() discussion["updated_at"] = today
validate_or_raise(root) index.save()
return {"discussion_id": discussion_id, "agenda_id": agenda["id"], "status": "accepted"} validate_or_raise(root)
return {"discussion_id": discussion_id, "agenda_id": agenda["id"], "status": "accepted"}
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")
index = DiscussionIndex.load(root) with index_lock(root):
discussion = index.find_discussion(discussion_id) index = DiscussionIndex.load(root)
agenda = _select_artifact(discussion, "agendas", agenda_id) discussion = index.find_discussion(discussion_id)
if agenda.get("status") != "accepted": agenda = _select_artifact(discussion, "agendas", agenda_id)
raise DiscussionError(f"agenda must be accepted before adding a decision: {agenda.get('id')}") if agenda.get("status") != "accepted":
decision_id = index.next_id("DEC") raise DiscussionError(f"agenda must be accepted before adding a decision: {agenda.get('id')}")
slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or decision_id.lower() decision_id = index.next_id("DEC")
decision_file = f"{decision_id}-{slug}.md" slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or decision_id.lower()
today = date.today().isoformat() decision_file = f"{decision_id}-{slug}.md"
decision_path = artifact_path(root, "decisions", decision_file) today = date.today().isoformat()
decision_path.parent.mkdir(parents=True, exist_ok=True) decision_path = artifact_path(root, "decisions", decision_file)
decision_path.write_text( decision_path.parent.mkdir(parents=True, exist_ok=True)
_render_decision( decision_path.write_text(
decision_id=decision_id, _render_decision(
ticket=discussion.get("ticket", ""), decision_id=decision_id,
title=title.strip(), ticket=discussion.get("ticket", ""),
created=today, title=title.strip(),
tags=discussion.get("tags", []), created=today,
agenda_id=agenda["id"], tags=discussion.get("tags", []),
), agenda_id=agenda["id"],
encoding="utf-8", ),
) encoding="utf-8",
entry = { )
"id": decision_id, entry = {
"file": decision_file, "id": decision_id,
"status": "open", "file": decision_file,
"created_at": today, "status": "open",
"updated_at": today, "created_at": today,
"ref_agenda": agenda["id"], "updated_at": today,
} "ref_agenda": agenda["id"],
discussion.setdefault("decisions", []).append(entry) }
discussion["status"] = "in_progress" discussion.setdefault("decisions", []).append(entry)
discussion["updated_at"] = today discussion["status"] = "in_progress"
index.ensure_next_id_above("DEC", decision_id) discussion["updated_at"] = today
index.save() index.ensure_next_id_above("DEC", decision_id)
validate_or_raise(root) index.save()
return {"discussion_id": discussion_id, "decision_id": decision_id, "decision_file": str(decision_path)} validate_or_raise(root)
return {"discussion_id": discussion_id, "decision_id": decision_id, "decision_file": str(decision_path)}
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:
index = DiscussionIndex.load(root) with index_lock(root):
discussion = index.find_discussion(discussion_id) index = DiscussionIndex.load(root)
decision = _select_artifact(discussion, "decisions", decision_id) discussion = index.find_discussion(discussion_id)
if decision.get("status") not in {"open", "review"}: decision = _select_artifact(discussion, "decisions", decision_id)
raise DiscussionError(f"decision cannot be accepted from status: {decision.get('status')}") if decision.get("status") not in {"open", "review"}:
today = date.today().isoformat() raise DiscussionError(f"decision cannot be accepted from status: {decision.get('status')}")
_set_artifact_status(root, "decisions", decision, "accepted") today = date.today().isoformat()
decision["status"] = "accepted" _set_artifact_status(root, "decisions", decision, "accepted")
decision["updated_at"] = today decision["status"] = "accepted"
discussion["updated_at"] = today decision["updated_at"] = today
index.save() discussion["updated_at"] = today
validate_or_raise(root) index.save()
return {"discussion_id": discussion_id, "decision_id": decision["id"], "status": "accepted"} validate_or_raise(root)
return {"discussion_id": discussion_id, "decision_id": decision["id"], "status": "accepted"}
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")
index = DiscussionIndex.load(root) with index_lock(root):
discussion = index.find_discussion(discussion_id) index = DiscussionIndex.load(root)
decision = _select_artifact(discussion, "decisions", decision_id) discussion = index.find_discussion(discussion_id)
if decision.get("status") != "accepted": decision = _select_artifact(discussion, "decisions", decision_id)
raise DiscussionError(f"decision must be accepted before adding a plan: {decision.get('id')}") if decision.get("status") != "accepted":
plan_id = index.next_id("PLN") raise DiscussionError(f"decision must be accepted before adding a plan: {decision.get('id')}")
slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or plan_id.lower() plan_id = index.next_id("PLN")
plan_file = f"{plan_id}-{slug}.md" slug = _slugify(title) or _slugify(discussion.get("ticket", "")) or plan_id.lower()
today = date.today().isoformat() plan_file = f"{plan_id}-{slug}.md"
plan_path = artifact_path(root, "plans", plan_file) today = date.today().isoformat()
plan_path.parent.mkdir(parents=True, exist_ok=True) plan_path = artifact_path(root, "plans", plan_file)
plan_path.write_text( plan_path.parent.mkdir(parents=True, exist_ok=True)
_render_plan( plan_path.write_text(
plan_id=plan_id, _render_plan(
ticket=discussion.get("ticket", ""), plan_id=plan_id,
title=title.strip(), ticket=discussion.get("ticket", ""),
created=today, title=title.strip(),
tags=discussion.get("tags", []), created=today,
decision_id=decision["id"], tags=discussion.get("tags", []),
), decision_id=decision["id"],
encoding="utf-8", ),
) encoding="utf-8",
entry = { )
"id": plan_id, entry = {
"file": plan_file, "id": plan_id,
"status": "open", "file": plan_file,
"created_at": today, "status": "open",
"updated_at": today, "created_at": today,
"ref_decisions": [decision["id"]], "updated_at": today,
} "ref_decisions": [decision["id"]],
discussion.setdefault("plans", []).append(entry) }
discussion["status"] = "in_progress" discussion.setdefault("plans", []).append(entry)
discussion["updated_at"] = today discussion["status"] = "in_progress"
index.ensure_next_id_above("PLN", plan_id) discussion["updated_at"] = today
index.save() index.ensure_next_id_above("PLN", plan_id)
validate_or_raise(root) index.save()
return {"discussion_id": discussion_id, "plan_id": plan_id, "plan_file": str(plan_path)} validate_or_raise(root)
return {"discussion_id": discussion_id, "plan_id": plan_id, "plan_file": str(plan_path)}
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}")
index = DiscussionIndex.load(root) with index_lock(root):
discussion, plan = _find_artifact(index, "plans", plan_id) index = DiscussionIndex.load(root)
current = plan.get("status") discussion, plan = _find_artifact(index, "plans", plan_id)
if not _can_transition(current, status): current = plan.get("status")
raise DiscussionError(f"invalid plan transition: {current} -> {status}") if not _can_transition(current, status):
today = date.today().isoformat() raise DiscussionError(f"invalid plan transition: {current} -> {status}")
_set_artifact_status(root, "plans", plan, status) today = date.today().isoformat()
plan["status"] = status _set_artifact_status(root, "plans", plan, status)
plan["updated_at"] = today plan["status"] = status
discussion["updated_at"] = today plan["updated_at"] = today
if status == "in_progress": discussion["updated_at"] = today
discussion["status"] = "in_progress" if status == "in_progress":
index.save() discussion["status"] = "in_progress"
validate_or_raise(root) index.save()
return {"discussion_id": discussion["id"], "plan_id": plan_id, "status": status} validate_or_raise(root)
return {"discussion_id": discussion["id"], "plan_id": plan_id, "status": status}
def status(root: Path) -> str: def status(root: Path) -> str:
@ -424,57 +431,58 @@ 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")
index = DiscussionIndex.load(root) with index_lock(root):
discussion = index.find_discussion(discussion_id) index = DiscussionIndex.load(root)
discussion = index.find_discussion(discussion_id)
lesson_entry = None lesson_entry = None
if lesson: if lesson:
lesson_path = Path(lesson) lesson_path = Path(lesson)
if not lesson_path.is_absolute(): if not lesson_path.is_absolute():
lesson_path = root / lesson_path lesson_path = root / lesson_path
lesson_path = lesson_path.resolve() lesson_path = lesson_path.resolve()
if not lesson_path.exists(): if not lesson_path.exists():
raise DiscussionError(f"lesson file not found: {lesson_path}") raise DiscussionError(f"lesson file not found: {lesson_path}")
lesson_id = _read_frontmatter_field(lesson_path, "id") lesson_id = _read_frontmatter_field(lesson_path, "id")
if not lesson_id: if not lesson_id:
lesson_id = lesson_path.name.split("-", 2)[0] + "-" + lesson_path.name.split("-", 2)[1] lesson_id = lesson_path.name.split("-", 2)[0] + "-" + lesson_path.name.split("-", 2)[1]
relative = lesson_path.relative_to(root.resolve()).as_posix() relative = lesson_path.relative_to(root.resolve()).as_posix()
today = date.today().isoformat() today = date.today().isoformat()
lesson_entry = { lesson_entry = {
"id": lesson_id, "id": lesson_id,
"file": relative, "file": relative,
"status": "done", "status": "done",
"created_at": today, "created_at": today,
"updated_at": today, "updated_at": today,
} }
index.ensure_next_id_above("LSN", lesson_id) index.ensure_next_id_above("LSN", lesson_id)
removed: list[Path] = [] removed: list[Path] = []
for kind in WORKFLOW_KINDS: for kind in WORKFLOW_KINDS:
for artifact in discussion.get(kind, []): for artifact in discussion.get(kind, []):
file_value = artifact.get("file") file_value = artifact.get("file")
if not file_value: if not file_value:
continue continue
path = artifact_path(root, kind, file_value) path = artifact_path(root, kind, file_value)
if path.exists(): if path.exists():
path.unlink() path.unlink()
removed.append(path) removed.append(path)
discussion[kind] = [] discussion[kind] = []
if lesson_entry: if lesson_entry:
lessons = discussion.setdefault("lessons", []) lessons = discussion.setdefault("lessons", [])
lessons[:] = [existing for existing in lessons if existing.get("id") != lesson_entry["id"]] lessons[:] = [existing for existing in lessons if existing.get("id") != lesson_entry["id"]]
lessons.append(lesson_entry) lessons.append(lesson_entry)
discussion["status"] = "done" discussion["status"] = "done"
discussion["updated_at"] = date.today().isoformat() discussion["updated_at"] = date.today().isoformat()
index.save() index.save()
validate_or_raise(root) validate_or_raise(root)
lines = [f"housekept {discussion_id}", f"removed workflow artifacts: {len(removed)}"] lines = [f"housekept {discussion_id}", f"removed workflow artifacts: {len(removed)}"]
if lesson_entry: if lesson_entry:
lines.append(f"registered lesson: {lesson_entry['id']}") lines.append(f"registered lesson: {lesson_entry['id']}")
return "\n".join(lines) return "\n".join(lines)
def backup_index(root: Path) -> str: def backup_index(root: Path) -> str:

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"])