Skip to content

Commit 261fab4

Browse files
feat: add KB mutation locks and atomic state writes (#86)
* feat: add KB lock primitives * chore: make KB state writes atomic * feat: apply KB locks to CLI commands --------- Co-authored-by: mountain <kose2livs@gmail.com>
1 parent f201aca commit 261fab4

6 files changed

Lines changed: 413 additions & 60 deletions

File tree

openkb/agent/chat.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -733,18 +733,24 @@ async def _handle_slash(
733733
if not session.user_turns:
734734
_fmt(style, ("class:error", "Nothing to save yet.\n"))
735735
return None
736-
path = _save_transcript(kb_dir, session, arg or None)
736+
from openkb.locks import kb_ingest_lock
737+
with kb_ingest_lock(kb_dir / ".openkb"):
738+
path = _save_transcript(kb_dir, session, arg or None)
737739
_fmt(style, ("class:slash.ok", f"Saved to {path}\n"))
738740
return None
739741

740742
if head == "/status":
743+
from openkb.locks import kb_read_lock
741744
from openkb.cli import print_status
742-
print_status(kb_dir)
745+
with kb_read_lock(kb_dir / ".openkb"):
746+
print_status(kb_dir)
743747
return None
744748

745749
if head == "/list":
750+
from openkb.locks import kb_read_lock
746751
from openkb.cli import print_list
747-
print_list(kb_dir)
752+
with kb_read_lock(kb_dir / ".openkb"):
753+
print_list(kb_dir)
748754
return None
749755

750756
if head == "/lint":
@@ -899,7 +905,9 @@ async def run_chat(
899905
prompt_session = _make_prompt_session(session, style, use_color, kb_dir)
900906
continue
901907

902-
append_log(kb_dir / "wiki", "query", user_input)
908+
from openkb.locks import kb_ingest_lock
909+
with kb_ingest_lock(kb_dir / ".openkb"):
910+
append_log(kb_dir / "wiki", "query", user_input)
903911
try:
904912
await _run_turn(agent, session, user_input, style, use_color=use_color, raw=raw)
905913
except KeyboardInterrupt:

openkb/cli.py

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import shutil
1414
import sys
1515
import time
16+
from functools import wraps
1617
from pathlib import Path
1718
from typing import Literal
1819

@@ -42,6 +43,7 @@ def filter(self, record: logging.LogRecord) -> bool:
4243

4344
from openkb.config import DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb
4445
from openkb.converter import _registry_path, convert_document
46+
from openkb.locks import atomic_write_json, atomic_write_text, kb_ingest_lock, kb_read_lock
4547
from openkb.log import append_log
4648
from openkb.schema import AGENTS_MD, INDEX_SEED, PAGE_CONTENT_DIRS
4749

@@ -260,6 +262,12 @@ def _clear_existing_skill_dir(kb_dir: Path, name: str) -> None:
260262

261263

262264
def add_single_file(file_path: Path, kb_dir: Path) -> Literal["added", "skipped", "failed"]:
265+
"""Convert, index, and compile a single document under the KB mutation lock."""
266+
with kb_ingest_lock(kb_dir / ".openkb"):
267+
return _add_single_file_locked(file_path, kb_dir)
268+
269+
270+
def _add_single_file_locked(file_path: Path, kb_dir: Path) -> Literal["added", "skipped", "failed"]:
263271
"""Convert, index, and compile a single document into the knowledge base.
264272
265273
Steps:
@@ -405,6 +413,23 @@ def cli(ctx, verbose, kb_dir_override):
405413
ctx.obj["kb_dir_override"] = None
406414

407415

416+
def _with_kb_lock(*, exclusive: bool):
417+
"""Wrap a Click command in the appropriate KB lock when a KB exists."""
418+
def decorator(fn):
419+
@wraps(fn)
420+
def wrapper(ctx, *args, **kwargs):
421+
kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override"))
422+
if kb_dir is None:
423+
return fn(ctx, *args, **kwargs)
424+
if exclusive:
425+
with kb_ingest_lock(kb_dir / ".openkb"):
426+
return fn(ctx, *args, **kwargs)
427+
with kb_read_lock(kb_dir / ".openkb"):
428+
return fn(ctx, *args, **kwargs)
429+
return wrapper
430+
return decorator
431+
432+
408433
@cli.command()
409434
@click.argument("path", default=".")
410435
def use(path):
@@ -554,9 +579,9 @@ def init(model, language):
554579
Path("wiki/entities").mkdir(parents=True, exist_ok=True)
555580

556581
# Write wiki files
557-
Path("wiki/AGENTS.md").write_text(AGENTS_MD, encoding="utf-8")
558-
Path("wiki/index.md").write_text(INDEX_SEED, encoding="utf-8")
559-
Path("wiki/log.md").write_text("# Operations Log\n\n", encoding="utf-8")
582+
atomic_write_text(Path("wiki/AGENTS.md"), AGENTS_MD)
583+
atomic_write_text(Path("wiki/index.md"), INDEX_SEED)
584+
atomic_write_text(Path("wiki/log.md"), "# Operations Log\n\n")
560585

561586
# Create .openkb/ state directory
562587
openkb_dir.mkdir()
@@ -566,7 +591,7 @@ def init(model, language):
566591
"pageindex_threshold": DEFAULT_CONFIG["pageindex_threshold"],
567592
}
568593
save_config(openkb_dir / "config.yaml", config)
569-
(openkb_dir / "hashes.json").write_text(json.dumps({}), encoding="utf-8")
594+
atomic_write_json(openkb_dir / "hashes.json", {})
570595

571596
# Write API key to KB-local .env (0600) if the user provided one
572597
if api_key:
@@ -587,6 +612,7 @@ def init(model, language):
587612
@cli.command()
588613
@click.argument("path")
589614
@click.pass_context
615+
@_with_kb_lock(exclusive=True)
590616
def add(ctx, path):
591617
"""Add a document or directory of documents at PATH to the knowledge base.
592618
@@ -797,6 +823,7 @@ def _resolve_doc_identifier(registry, identifier: str) -> list[tuple[str, dict]]
797823
@click.option("--yes", "-y", is_flag=True, default=False,
798824
help="Skip the confirmation prompt.")
799825
@click.pass_context
826+
@_with_kb_lock(exclusive=True)
800827
def remove(ctx, identifier, keep_raw, keep_empty, dry_run, yes):
801828
"""Remove a document from the knowledge base.
802829
@@ -1086,6 +1113,7 @@ def _refresh_schema(wiki_dir: Path) -> bool:
10861113
help="Overwrite wiki/AGENTS.md with the bundled schema (backs up "
10871114
"the old one to AGENTS.md.bak) if it differs.")
10881115
@click.pass_context
1116+
@_with_kb_lock(exclusive=True)
10891117
def recompile(ctx, doc_name, all_docs, dry_run, yes, refresh_schema):
10901118
"""Re-run the current compile pipeline on already-indexed documents.
10911119
@@ -1389,40 +1417,42 @@ async def run_lint(kb_dir: Path) -> Path | None:
13891417

13901418
openkb_dir = kb_dir / ".openkb"
13911419

1392-
# Skip lint entirely when the KB has no indexed documents
1393-
hashes_file = openkb_dir / "hashes.json"
1394-
if hashes_file.exists():
1395-
hashes = json.loads(hashes_file.read_text(encoding="utf-8"))
1396-
else:
1397-
hashes = {}
1398-
if not hashes:
1399-
click.echo("Nothing to lint — no documents indexed yet. Run `openkb add` first.")
1400-
return
1420+
with kb_read_lock(openkb_dir):
1421+
# Skip lint entirely when the KB has no indexed documents
1422+
hashes_file = openkb_dir / "hashes.json"
1423+
if hashes_file.exists():
1424+
hashes = json.loads(hashes_file.read_text(encoding="utf-8"))
1425+
else:
1426+
hashes = {}
1427+
if not hashes:
1428+
click.echo("Nothing to lint — no documents indexed yet. Run `openkb add` first.")
1429+
return
14011430

1402-
config = load_config(openkb_dir / "config.yaml")
1403-
_setup_llm_key(kb_dir)
1404-
model: str = config.get("model", DEFAULT_CONFIG["model"])
1431+
config = load_config(openkb_dir / "config.yaml")
1432+
_setup_llm_key(kb_dir)
1433+
model: str = config.get("model", DEFAULT_CONFIG["model"])
14051434

1406-
click.echo("Running structural lint...")
1407-
structural_report = run_structural_lint(kb_dir)
1408-
click.echo(structural_report)
1435+
click.echo("Running structural lint...")
1436+
structural_report = run_structural_lint(kb_dir)
1437+
click.echo(structural_report)
14091438

1410-
click.echo("Running knowledge lint...")
1411-
try:
1412-
knowledge_report = await run_knowledge_lint(kb_dir, model)
1413-
except Exception as exc:
1414-
knowledge_report = f"Knowledge lint failed: {exc}"
1415-
click.echo(knowledge_report)
1439+
click.echo("Running knowledge lint...")
1440+
try:
1441+
knowledge_report = await run_knowledge_lint(kb_dir, model)
1442+
except Exception as exc:
1443+
knowledge_report = f"Knowledge lint failed: {exc}"
1444+
click.echo(knowledge_report)
14161445

14171446
# Write combined report
1418-
reports_dir = kb_dir / "wiki" / "reports"
1419-
reports_dir.mkdir(parents=True, exist_ok=True)
1420-
import datetime
1421-
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
1422-
report_path = reports_dir / f"lint_{timestamp}.md"
1423-
report_content = f"# Lint Report — {timestamp}\n\n## Structural\n\n{structural_report}\n\n## Semantic\n\n{knowledge_report}\n"
1424-
report_path.write_text(report_content, encoding="utf-8")
1425-
append_log(kb_dir / "wiki", "lint", f"report → {report_path.name}")
1447+
with kb_ingest_lock(openkb_dir):
1448+
reports_dir = kb_dir / "wiki" / "reports"
1449+
reports_dir.mkdir(parents=True, exist_ok=True)
1450+
import datetime
1451+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
1452+
report_path = reports_dir / f"lint_{timestamp}.md"
1453+
report_content = f"# Lint Report — {timestamp}\n\n## Structural\n\n{structural_report}\n\n## Semantic\n\n{knowledge_report}\n"
1454+
report_path.write_text(report_content, encoding="utf-8")
1455+
append_log(kb_dir / "wiki", "lint", f"report → {report_path.name}")
14261456
click.echo(f"\nReport written to {report_path}")
14271457
return report_path
14281458

@@ -1440,7 +1470,8 @@ def lint(ctx, fix):
14401470
return
14411471
if fix:
14421472
from openkb.lint import fix_broken_links
1443-
files_changed, ghosts = fix_broken_links(kb_dir / "wiki")
1473+
with kb_ingest_lock(kb_dir / ".openkb"):
1474+
files_changed, ghosts = fix_broken_links(kb_dir / "wiki")
14441475
if files_changed:
14451476
click.echo(
14461477
f"Fixed {ghosts} wikilink(s) across {files_changed} file(s)."
@@ -1515,6 +1546,7 @@ def print_list(kb_dir: Path) -> None:
15151546

15161547
@cli.command(name="list")
15171548
@click.pass_context
1549+
@_with_kb_lock(exclusive=False)
15181550
def list_cmd(ctx):
15191551
"""List all documents in the knowledge base."""
15201552
kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override"))
@@ -1585,6 +1617,7 @@ def print_status(kb_dir: Path) -> None:
15851617

15861618
@cli.command()
15871619
@click.pass_context
1620+
@_with_kb_lock(exclusive=False)
15881621
def status(ctx):
15891622
"""Show the current status of the knowledge base.
15901623

openkb/config.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from __future__ import annotations
22

3+
import contextlib
4+
import fcntl
35
import logging
46
import re
57
from pathlib import Path
6-
from typing import Any
8+
from typing import Any, Iterator
79

810
import yaml
911

12+
from openkb.locks import atomic_write_text
13+
1014
logger = logging.getLogger(__name__)
1115

1216
DEFAULT_CONFIG: dict[str, Any] = {
@@ -23,6 +27,32 @@
2327

2428
GLOBAL_CONFIG_DIR = Path.home() / ".config" / "openkb"
2529
GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_DIR / "global.yaml"
30+
GLOBAL_CONFIG_LOCK_PATH = GLOBAL_CONFIG_DIR / "global.lock"
31+
32+
33+
@contextlib.contextmanager
34+
def _with_global_config_lock() -> Iterator[None]:
35+
GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
36+
with GLOBAL_CONFIG_LOCK_PATH.open("a+", encoding="utf-8") as fh:
37+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
38+
try:
39+
yield
40+
finally:
41+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
42+
43+
44+
def _atomic_yaml_dump(path: Path, config: dict[str, Any]) -> None:
45+
atomic_write_text(
46+
path,
47+
yaml.safe_dump(config, allow_unicode=True, sort_keys=True),
48+
)
49+
50+
51+
def _load_global_config_unlocked() -> dict[str, Any]:
52+
if GLOBAL_CONFIG_PATH.exists():
53+
with GLOBAL_CONFIG_PATH.open("r", encoding="utf-8") as fh:
54+
return yaml.safe_load(fh) or {}
55+
return {}
2656

2757

2858
def resolve_entity_types(config: dict) -> list[str]:
@@ -81,33 +111,28 @@ def load_config(config_path: Path) -> dict[str, Any]:
81111

82112
def save_config(config_path: Path, config: dict) -> None:
83113
"""Persist config dict to YAML, creating parent directories as needed."""
84-
config_path.parent.mkdir(parents=True, exist_ok=True)
85-
with config_path.open("w", encoding="utf-8") as fh:
86-
yaml.safe_dump(config, fh, allow_unicode=True, sort_keys=True)
114+
_atomic_yaml_dump(config_path, config)
87115

88116

89117
def load_global_config() -> dict[str, Any]:
90118
"""Load the global config from ~/.config/openkb/global.yaml."""
91-
if GLOBAL_CONFIG_PATH.exists():
92-
with GLOBAL_CONFIG_PATH.open("r", encoding="utf-8") as fh:
93-
return yaml.safe_load(fh) or {}
94-
return {}
119+
return _load_global_config_unlocked()
95120

96121

97122
def save_global_config(config: dict[str, Any]) -> None:
98123
"""Save the global config to ~/.config/openkb/global.yaml."""
99-
GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
100-
with GLOBAL_CONFIG_PATH.open("w", encoding="utf-8") as fh:
101-
yaml.safe_dump(config, fh, allow_unicode=True, sort_keys=True)
124+
with _with_global_config_lock():
125+
_atomic_yaml_dump(GLOBAL_CONFIG_PATH, config)
102126

103127

104128
def register_kb(kb_path: Path) -> None:
105129
"""Register a KB path in the global config's known_kbs list."""
106-
gc = load_global_config()
107-
known = gc.get("known_kbs", [])
108-
resolved = str(kb_path.resolve())
109-
if resolved not in known:
110-
known.append(resolved)
111-
gc["known_kbs"] = known
112-
gc["default_kb"] = resolved
113-
save_global_config(gc)
130+
with _with_global_config_lock():
131+
gc = _load_global_config_unlocked()
132+
known = gc.get("known_kbs", [])
133+
resolved = str(kb_path.resolve())
134+
if resolved not in known:
135+
known.append(resolved)
136+
gc["known_kbs"] = known
137+
gc["default_kb"] = resolved
138+
_atomic_yaml_dump(GLOBAL_CONFIG_PATH, gc)

0 commit comments

Comments
 (0)