diff --git a/graphify/ingest.py b/graphify/ingest.py index 86b9b7531..7dc92c341 100644 --- a/graphify/ingest.py +++ b/graphify/ingest.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import re +import uuid import urllib.error import urllib.parse from datetime import datetime, timezone @@ -299,7 +300,11 @@ def save_query_result( now = datetime.now(timezone.utc) slug = re.sub(r"[^\w]", "_", question.lower())[:50].strip("_") - filename = f"query_{now.strftime('%Y%m%d_%H%M%S')}_{slug}.md" + # A second-granularity stamp plus a 50-char slug is not unique: two saves in + # the same second whose questions share a prefix resolve to one path, and the + # later write_text silently replaces the earlier one (#3301). The short uuid + # makes every save its own file; the query_ prefix and .md suffix are kept. + filename = f"query_{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}_{slug}.md" frontmatter_lines = [ "---", diff --git a/tests/test_ingest.py b/tests/test_ingest.py index dd9e17ea8..6b7d1fb03 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -98,3 +98,15 @@ def test_no_outcome_means_no_outcome_section(tmp_path): def test_invalid_outcome_rejected(tmp_path): with pytest.raises(ValueError): save_query_result("q", "a", tmp_path / "memory", outcome="great") + + +def test_concurrent_saves_of_the_same_question_do_not_overwrite(tmp_path): + """Regression for #3301: a second-granularity stamp plus a 50-char slug is + not unique, so saves in the same second sharing a prefix collapsed into one + file and the earlier ones were silently lost.""" + from concurrent.futures import ThreadPoolExecutor + mem = tmp_path / "memory" + with ThreadPoolExecutor(max_workers=20) as ex: + paths = list(ex.map(lambda _: save_query_result("how does auth work", "a", mem), range(20))) + assert len({p.name for p in paths}) == 20 + assert len(list(mem.glob("*.md"))) == 20