fix(ingest): make memory-doc filenames unique so concurrent saves do not overwrite

save_query_result named files query_<second>_<slug50>.md and wrote them with
write_text, so two saves in the same second whose questions share the first
50 characters resolved to one path and the later one silently replaced the
earlier. Both calls returned normally. This is the common case when several
agents sweep one subsystem in parallel.

Add a short uuid to the name. The query_ prefix and .md suffix are unchanged
so reflect.load_memory_docs and existing tests keep working.

Fixes #3301
This commit is contained in:
daichiyasunami-vottia committed 2026-09-03 20:03:02 +09:00
1 parent 33362d9692
commit 0bdf89f64b
2 files changed
+18 -1

No files matched your search

+6 -1
View File
@@ -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 = [
"---",
+12
View File
@@ -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