Skip to content

Commit 7630640

Browse files
committed
fix(api): cross-platform delete_kb + endpoint OSError handling (xhigh review #1,#2)
- delete_kb no longer holds the ingest lock DURING rmtree (the lock file lives inside the KB dir; Windows cannot delete an open file — the prior review-fix regressed this). It now takes the lock as a BARRIER (drain + wait out any in-flight mutation), releases it, re-checks existence, then rmtrees. [#1] - delete-KB endpoint maps FileNotFoundError to an idempotent success (a concurrent delete already removed the tree) and other OSError to a clean 500 with a message, instead of an uncaught 500 stack trace. [#2] Tests: endpoint OSError -> clean 500, FileNotFoundError -> 200 deleted. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG
1 parent a672916 commit 7630640

3 files changed

Lines changed: 56 additions & 3 deletions

File tree

openkb/api_kbs_router.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,10 @@ async def delete_kb_endpoint(
4242
await run_in_threadpool(delete_kb, kb_dir)
4343
except ValueError as exc: # resolved to an existing path that is not a KB
4444
raise HTTPException(status_code=400, detail=str(exc)) from exc
45+
except FileNotFoundError:
46+
pass # a concurrent delete already removed it — idempotent success
47+
except OSError as exc: # rmtree failed (permission/disk; a still-open file on Windows)
48+
raise HTTPException(
49+
status_code=500, detail=f"Failed to delete the knowledge base: {exc}"
50+
) from exc
4551
return KbDeleteResponse(deleted=True, kb=request.kb, path=str(kb_dir))

openkb/kb_admin.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,14 @@ def delete_kb(kb_dir: Path) -> None:
5656
if kb_dir.exists():
5757
if not config._is_kb_dir(kb_dir):
5858
raise ValueError(f"Refusing to delete: not a knowledge base directory: {kb_dir}")
59-
# Serialize against in-flight ingest/recompile on this KB (like `openkb
60-
# remove`). POSIX: rmtree unlinks the held lock file, but the open fd
61-
# stays valid, so the funlock on context-exit is safe.
59+
# Serialize against in-flight ingest/recompile by acquiring the ingest
60+
# lock as a BARRIER (it drains pending journals and waits out any active
61+
# mutation), then RELEASING it before rmtree. The lock file lives INSIDE
62+
# kb_dir and Windows cannot delete a still-open file, so it must not be
63+
# held during rmtree. Re-check existence in case a concurrent delete won
64+
# the race while we waited on the barrier.
6265
with kb_ingest_lock(kb_dir / ".openkb"):
66+
pass
67+
if kb_dir.exists():
6368
shutil.rmtree(kb_dir)
6469
unregister_kb(kb_dir)

tests/test_api.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3316,3 +3316,45 @@ def test_summary_page_is_editable_but_not_deletable(monkeypatch, kb_dir):
33163316
"/api/v1/page/delete", json={"kb": kb, "path": "summaries/doc"}, headers=_auth()
33173317
)
33183318
assert r.status_code == 400
3319+
3320+
3321+
def test_delete_kb_maps_oserror_to_clean_500(monkeypatch, tmp_path):
3322+
# An rmtree failure (permission/disk, or a still-open lock file on Windows)
3323+
# surfaces as a clean 500 with a message, not an uncaught stack trace.
3324+
from openkb.config import register_kb_alias
3325+
3326+
_isolate_global(monkeypatch, tmp_path)
3327+
kb = _make_kb(tmp_path / "mykb")
3328+
register_kb_alias("boom-kb", kb)
3329+
3330+
def boom(_):
3331+
raise OSError("disk on fire")
3332+
3333+
monkeypatch.setattr("openkb.api_kbs_router.delete_kb", boom)
3334+
client = _client(monkeypatch)
3335+
r = client.post(
3336+
"/api/v1/kb/delete", json={"kb": "boom-kb", "confirm_name": "boom-kb"}, headers=_auth()
3337+
)
3338+
assert r.status_code == 500
3339+
assert "Failed to delete" in r.json()["detail"]
3340+
3341+
3342+
def test_delete_kb_filenotfound_is_idempotent(monkeypatch, tmp_path):
3343+
# A concurrent delete that already removed the tree (FileNotFoundError) is a
3344+
# success, not a 500 — deleting an already-gone KB is idempotent.
3345+
from openkb.config import register_kb_alias
3346+
3347+
_isolate_global(monkeypatch, tmp_path)
3348+
kb = _make_kb(tmp_path / "mykb")
3349+
register_kb_alias("gone-kb", kb)
3350+
3351+
def already_gone(_):
3352+
raise FileNotFoundError()
3353+
3354+
monkeypatch.setattr("openkb.api_kbs_router.delete_kb", already_gone)
3355+
client = _client(monkeypatch)
3356+
r = client.post(
3357+
"/api/v1/kb/delete", json={"kb": "gone-kb", "confirm_name": "gone-kb"}, headers=_auth()
3358+
)
3359+
assert r.status_code == 200
3360+
assert r.json()["deleted"] is True

0 commit comments

Comments
 (0)