From 7d295e197124098b85658d1c44edcf9fffa09e41 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 6 Sep 2026 06:31:09 -0400 Subject: [PATCH 1/2] fix: make resource import failures atomic --- docs/REWORK_EXECUTION.md | 31 +++ engraphis/core/engine.py | 5 + engraphis/core/store.py | 18 +- engraphis/service.py | 99 ++++--- tests/test_resource_import_atomicity.py | 351 ++++++++++++++++++++++++ 5 files changed, 457 insertions(+), 47 deletions(-) create mode 100644 tests/test_resource_import_atomicity.py diff --git a/docs/REWORK_EXECUTION.md b/docs/REWORK_EXECUTION.md index f705baa4..c6fc3fca 100644 --- a/docs/REWORK_EXECUTION.md +++ b/docs/REWORK_EXECUTION.md @@ -81,6 +81,37 @@ changes have not been performed by these local changes. Ordinary local engineeri and verification are already authorized; missing hardware and independent evidence are execution constraints, not reasons to claim completion or invent results. +## Resource-import transaction follow-up + +Legacy folder/upload imports now isolate each file, including its chunks, FTS, +canonical vectors, transactional index rows and receipts, before returning a +recoverable per-file error. An optional fact-derivation failure rolls back its +complete derived prefix while retaining the successful source import. Unexpected +failures and final commit failures still abort the service-owned batch; a caller's +preceding transaction remains caller-owned. + +The additional counterexamples at `dc4382d1` included an FTS failure leaving three +canonical records despite a two-import/one-error report, and a second-chunk +embedding failure leaving an unreported first chunk. Fourteen new regression +cases reproduced these integrity failures against that unchanged dependency +checkout. Review also reproduced failed savepoint rollback/release being treated +as a recoverable file error. Savepoint settlement now raises `SavepointError` and +aborts the enclosing operation, including optional conflict repair. + +This follow-up changes no schema, public signature, response field, ranking or +approval rule. There is no data migration. Reverting the patch restores the prior +partial-write risk; it does not reconcile fragments left by earlier imports. +Do not delete suspected fragments automatically: retain provenance and inspect +the applicable import report before governed correction or erasure. + +Preparation remains the next dependency. Folder enumeration, resource parsing, +chunking, embedding and explicitly enabled derivation still occur inside the +legacy batch writer. Move them through immutable prepared commands, with current +workspace/embedding validation and explicit post-commit index publication, in a +separate change. Preserve the existing caller-owned separate-index rejection until +that publication contract exists. These integrity tests establish no throughput, +100k capacity or production recovery claim. + ## Schema 17 to 18 and recovery Schema 18 adds memory-command receipts/source claims, portable browsing revisions diff --git a/engraphis/core/engine.py b/engraphis/core/engine.py index 866efb25..f6742cd4 100644 --- a/engraphis/core/engine.py +++ b/engraphis/core/engine.py @@ -74,6 +74,7 @@ ) from engraphis.core.secrets import redact_secrets as _redact_secrets, reject_secrets from engraphis.core.store import ( + SavepointError, Store, _is_memory_database_path, memory_matches_filter, @@ -1758,6 +1759,10 @@ def _resolve_and_store(self, content: str, *, text: str, vec: Optional[np.ndarra "UPDATE memories SET confidence=MIN(confidence, ?) WHERE id=?", (round(CONFLICT_CONFIDENCE_FACTOR, 4), conflicted_with), ) + except SavepointError: + # A failed rollback/release cannot be treated as an optional repair + # failure: it may leave partial writes in the enclosing transaction. + raise except Exception as exc: # noqa: BLE001 - derived repair must not discard the memory self._warn_redacted_failure("conflict repair", exc) out: dict[str, object] diff --git a/engraphis/core/store.py b/engraphis/core/store.py index 221339e3..c953852e 100644 --- a/engraphis/core/store.py +++ b/engraphis/core/store.py @@ -79,6 +79,10 @@ ) +class SavepointError(RuntimeError): + """A sub-operation could not settle; its enclosing transaction must abort.""" + + # Rows materialized per locked batch when streaming the vector table (see iter_vectors). VECTOR_SCAN_BATCH = 2000 _STARTUP_GRAPH_TRANSFORMS = {"edge_supports": 1, "live_edge_deduplication": 1} @@ -3791,17 +3795,23 @@ def opener(*, timeout): @contextmanager def write_savepoint(self): - """Isolate a best-effort sub-operation inside an authoritative transaction.""" + """Isolate a sub-operation; settlement failures must abort its outer owner.""" name = f"engraphis_optional_{threading.get_ident()}_{time.monotonic_ns()}" self.conn.execute(f"SAVEPOINT {name}") try: yield except BaseException: - self.conn.execute(f"ROLLBACK TO SAVEPOINT {name}") - self.conn.execute(f"RELEASE SAVEPOINT {name}") + try: + self.conn.execute(f"ROLLBACK TO SAVEPOINT {name}") + self.conn.execute(f"RELEASE SAVEPOINT {name}") + except Exception as exc: + raise SavepointError("could not roll back the write savepoint") from exc raise else: - self.conn.execute(f"RELEASE SAVEPOINT {name}") + try: + self.conn.execute(f"RELEASE SAVEPOINT {name}") + except Exception as exc: + raise SavepointError("could not release the write savepoint") from exc # ── local source-import manifest ───────────────────────────────────────── def _authorize_source_workspace_id(self, workspace_id: str) -> str: diff --git a/engraphis/service.py b/engraphis/service.py index 1ce3cd7e..2260a83e 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -2300,43 +2300,53 @@ def _import_one(self, name: str, content: str, *, ws: str, mt: MemoryType, ) chunks = chunker.extract(content) if chunker is not None else None try: - if chunks: - total = len(chunks) - first: Optional[dict] = None - for i, fact in enumerate(chunks): - title = ( - fact.title or resource_title - or _title_from_content(fact.content, fallback) - ) - r = self.remember( - fact.content, workspace=ws, - mtype=(fact.mtype.value if fact.mtype else mt.value), - scope="workspace", title=title[:MAX_TITLE_CHARS], - source="import", trusted=False, kind=kind, - keywords=fact.keywords, - metadata={**(extra_provenance or {}), "import_file": name, - "chunk": {"index": i, "of": total, - "heading": (fact.title or "")[:200]}}, - resolve_conflicts=False, - ) - first = first or r - return {"file": name, "id": first["id"], "op": first["op"], "chunks": total} - title = resource_title or _title_from_content(content, fallback=fallback) - r = self.remember( - content, workspace=ws, mtype=mt.value, scope="workspace", - title=title[:MAX_TITLE_CHARS], source="import", trusted=False, kind=kind, - metadata={**(extra_provenance or {}), "import_file": name}, - ) - return {"file": name, "id": r["id"], "op": r["op"]} + # Expected per-file errors are caught below the batch boundary. Give the + # complete file (including all chunks and receipts) its own rollback scope + # before converting a write failure into a successful batch response. + with self.store.write_savepoint(): + return self._store_import_chunks( + name, content, ws=ws, mt=mt, kind=kind, chunks=chunks, + fallback=fallback, extra_provenance=extra_provenance, + resource_title=resource_title, + ) except (ValidationError, ValueError, sqlite3.Error, RecursionError, MemoryError) as exc: - # One bad file must degrade to a per-file error, not void the whole batch - # (e.g. sqlite3.OperationalError "database is locked" from a concurrent - # CLI/MCP writer, embedder ValueError, or a crafted deep-nested JSON upload - # blowing json.loads recursion). logger.info("uploaded resource import rejected (%s)", type(exc).__name__) return {"file": name, "error": "resource could not be imported"} + def _store_import_chunks(self, name: str, content: str, *, ws: str, mt: MemoryType, + kind: str, chunks, fallback: str, + extra_provenance: Optional[dict], resource_title: str) -> dict: + """Apply a resource inside its caller's per-file savepoint.""" + if chunks: + total = len(chunks) + first: Optional[dict] = None + for i, fact in enumerate(chunks): + title = ( + fact.title or resource_title + or _title_from_content(fact.content, fallback) + ) + r = self.remember( + fact.content, workspace=ws, + mtype=(fact.mtype.value if fact.mtype else mt.value), + scope="workspace", title=title[:MAX_TITLE_CHARS], + source="import", trusted=False, kind=kind, + keywords=fact.keywords, + metadata={**(extra_provenance or {}), "import_file": name, + "chunk": {"index": i, "of": total, + "heading": (fact.title or "")[:200]}}, + resolve_conflicts=False, + ) + first = first or r + return {"file": name, "id": first["id"], "op": first["op"], "chunks": total} + title = resource_title or _title_from_content(content, fallback=fallback) + r = self.remember( + content, workspace=ws, mtype=mt.value, scope="workspace", + title=title[:MAX_TITLE_CHARS], source="import", trusted=False, kind=kind, + metadata={**(extra_provenance or {}), "import_file": name}, + ) + return {"file": name, "id": r["id"], "op": r["op"]} + def _derive_import_facts(self, content: str, *, ws: str, mt: MemoryType, resource_name: str, resource_kind: str, resource_meta: dict) -> tuple[int, str]: @@ -2357,17 +2367,20 @@ def _derive_import_facts(self, content: str, *, ws: str, mt: MemoryType, created = 0 extracted = False - for chunk in inputs: - derived = self.ingest( - chunk, workspace=ws, mtype=mt.value, scope="workspace", - metadata={"derived_from_resource": resource_name, **resource_meta}, - source="resource_extractor", trusted=False, - kind=f"{resource_kind}_facts", - ) - extracted = extracted or bool(derived["extracted"]) - created += sum( - 1 for fact in derived["facts"] if fact.get("op") != "noop" - ) + # This optional pass may fail without failing the imported source. Its count + # must describe committed facts, so discard the entire derived prefix first. + with self.store.write_savepoint(): + for chunk in inputs: + derived = self.ingest( + chunk, workspace=ws, mtype=mt.value, scope="workspace", + metadata={"derived_from_resource": resource_name, **resource_meta}, + source="resource_extractor", trusted=False, + kind=f"{resource_kind}_facts", + ) + extracted = extracted or bool(derived["extracted"]) + created += sum( + 1 for fact in derived["facts"] if fact.get("op") != "noop" + ) if not extracted or created == 0: return created, "configured extractor produced no new discrete facts" return created, "" diff --git a/tests/test_resource_import_atomicity.py b/tests/test_resource_import_atomicity.py new file mode 100644 index 00000000..6da2cf88 --- /dev/null +++ b/tests/test_resource_import_atomicity.py @@ -0,0 +1,351 @@ +"""A failed resource must not leave unreported fragments in canonical storage.""" +import json +import sqlite3 +from contextlib import closing +from types import SimpleNamespace + +import pytest + +from engraphis.core.interfaces import ExtractedFact +from engraphis.core.store import SavepointError +from engraphis.service import MemoryService + + +@pytest.fixture(params=["files", "folder"]) +def import_resources(request, tmp_path): + def run(service, files, **kwargs): + if request.param == "files": + return service.import_files(workspace="atomic", files=files, **kwargs) + folder = tmp_path / "resources" + folder.mkdir(exist_ok=True) + for item in files: + (folder / item["name"]).write_text(item["content"], encoding="utf-8") + return service.import_folder(workspace="atomic", path=str(folder), **kwargs) + + return run + + +FILES = [ + {"name": "1-good.md", "content": "Herons gather beside the river."}, + {"name": "2-bad.md", "content": "Egrets nest in the southern marsh."}, + {"name": "3-good.md", "content": "Cranes migrate across the northern plain."}, +] + + +def assert_consistent(service, expected_files): + conn = service.store.conn + rows = conn.execute("SELECT id, metadata FROM memories").fetchall() + assert {json.loads(row["metadata"]).get("import_file") for row in rows} == set(expected_files) + ids = {row["id"] for row in rows} + assert {row[0] for row in conn.execute("SELECT id FROM mem_vectors")} == ids + assert {row[0] for row in conn.execute("SELECT id FROM mem_fts")} == ids + assert conn.execute("SELECT COUNT(*) FROM operation_receipts").fetchone()[0] == len(ids) + assert not conn.execute( + "SELECT r.memory_id FROM vector_index_repairs r " + "LEFT JOIN memories m ON m.id=r.memory_id WHERE m.id IS NULL" + ).fetchall() + + +@pytest.mark.parametrize("stage", ["fts", "vector", "receipt"]) +def test_expected_write_failure_rolls_back_only_failed_resource( + import_resources, tmp_path, monkeypatch, stage, +): + with closing(MemoryService.create(str(tmp_path / "import.db"), extractor="none")) as service: + method_name = {"fts": "_fts_upsert", "vector": "put_vector", "receipt": "record_receipt"}[stage] + original = getattr(service.store, method_name) + calls = 0 + + def fail_second(*args, **kwargs): + nonlocal calls + calls += 1 + result = original(*args, **kwargs) + if calls == 2: + raise sqlite3.OperationalError("injected failure after partial write") + return result + + monkeypatch.setattr(service.store, method_name, fail_second) + report = import_resources(service, FILES) + assert (report["imported"], report["errors"]) == (2, 1) + assert report["details"] == [ + {"file": "2-bad.md", "error": "resource could not be imported"}, + ] + assert_consistent(service, ["1-good.md", "3-good.md"]) + assert not service.store.conn.in_transaction + + +def test_second_chunk_embedding_failure_discards_whole_file( + import_resources, tmp_path, monkeypatch, +): + with closing(MemoryService.create(str(tmp_path / "chunks.db"), extractor="chunk")) as service: + embed = service.engine.embedder.embed + calls = 0 + + def fail_second(texts): + nonlocal calls + calls += 1 + if calls == 2: + raise ValueError("injected second chunk failure") + return embed(texts) + + monkeypatch.setattr(service.engine.embedder, "embed", fail_second) + report = import_resources(service, [{"name": "sections.md", "content": ( + "# Herons\nHerons gather beside the river.\n\n" + "# Egrets\nEgrets nest in the southern marsh.\n\n" + "# Cranes\nCranes migrate across the northern plain.\n" + )}]) + assert calls == 2 + assert (report["imported"], report["errors"]) == (0, 1) + assert_consistent(service, []) + + +@pytest.mark.parametrize("failure", ["late_write", "audit"]) +@pytest.mark.parametrize("caller_owned", [False, True]) +def test_fatal_batch_failure_preserves_transaction_owner( + import_resources, tmp_path, monkeypatch, failure, caller_owned, +): + with closing(MemoryService.create(str(tmp_path / "fatal.db"), extractor="none")) as service: + conn = service.store.conn + if caller_owned: + wid = service.create_workspace("atomic")["id"] + conn.execute("BEGIN IMMEDIATE") + conn.execute("UPDATE workspaces SET settings=? WHERE id=?", + ('{"caller":"preserved"}', wid)) + original = getattr(service.store, "_fts_upsert" if failure == "late_write" else "audit") + calls = 0 + + def fail_late(*args, **kwargs): + nonlocal calls + calls += 1 + result = original(*args, **kwargs) + if (failure == "late_write" and calls == 2) or ( + failure == "audit" and args[1] in {"import_files", "import_folder"} + ): + raise RuntimeError("injected fatal batch failure") + return result + + monkeypatch.setattr(service.store, "_fts_upsert" if failure == "late_write" else "audit", fail_late) + with pytest.raises(RuntimeError, match="injected fatal batch failure"): + import_resources(service, FILES) + assert_consistent(service, []) + assert conn.transaction_owned_by_current_thread() is caller_owned + if caller_owned: + assert conn.execute("SELECT settings FROM workspaces WHERE id=?", (wid,)).fetchone()[0] == ( + '{"caller":"preserved"}' + ) + conn.rollback() + else: + assert conn.execute("SELECT id FROM workspaces WHERE name='atomic'").fetchone() is None + # A failed batch must release every owned reservation/savepoint. + service.create_workspace("after-failure") + + +def test_optional_derivation_failure_keeps_base_without_partial_facts( + import_resources, tmp_path, monkeypatch, +): + with closing(MemoryService.create(str(tmp_path / "derive.db"), extractor="none")) as service: + service.engine.extractor = SimpleNamespace(extract=lambda text: [ + ExtractedFact(content="Derived first fact about herons."), + ExtractedFact(content="Derived second fact about egrets."), + ]) + original = service.store._fts_upsert + calls = 0 + + def fail_second_fact(*args, **kwargs): + nonlocal calls + calls += 1 + result = original(*args, **kwargs) + if calls == 3: + raise ValueError("injected derived fact failure") + return result + + monkeypatch.setattr(service.store, "_fts_upsert", fail_second_fact) + report = import_resources(service, FILES[:1], derive_facts=True) + assert (report["imported"], report["errors"], report["derived_facts"]) == (1, 0, 0) + assert report["warnings"] == [{"file": "1-good.md", "warnings": ["fact derivation failed"]}] + assert_consistent(service, ["1-good.md"]) + + +@pytest.mark.parametrize("backend", ["sqlite-adapter", "sqlite-vec"]) +def test_index_failure_rolls_back_resource_and_native_rows( + import_resources, tmp_path, monkeypatch, backend, +): + if backend == "sqlite-vec": + pytest.importorskip("sqlite_vec") + with closing(MemoryService.create( + str(tmp_path / "native.db"), extractor="none", + vector_backend="sqlite-vec" if backend == "sqlite-vec" else "numpy", + )) as service: + if backend == "sqlite-adapter": + # A real SQLite participant exercises rollback without the optional + # extension; it is not native-backend performance evidence. + conn = service.store.conn + conn.execute("CREATE TABLE import_test_index (id TEXT PRIMARY KEY)") + conn.commit() + + def upsert(ids, vectors, meta=None, *, commit=True): + conn.executemany("INSERT INTO import_test_index VALUES (?)", [(mid,) for mid in ids]) + if commit: + conn.commit() + + service.engine.index = SimpleNamespace( + store=service.store, shares_store_transaction=True, + upsert=upsert, + ) + original = service.engine.index.upsert + calls = 0 + + def fail_second(*args, **kwargs): + nonlocal calls + calls += 1 + original(*args, **kwargs) + if calls == 2: + raise sqlite3.OperationalError("injected native index failure") + + monkeypatch.setattr(service.engine.index, "upsert", fail_second) + report = import_resources(service, FILES) + assert (report["imported"], report["errors"]) == (2, 1) + assert_consistent(service, ["1-good.md", "3-good.md"]) + table = "mem_vec_ann" if backend == "sqlite-vec" else "import_test_index" + indexed = {row[0] for row in service.store.conn.execute(f"SELECT id FROM {table}")} + assert indexed == {row[0] for row in service.store.conn.execute("SELECT id FROM memories")} + + +def test_recoverable_file_error_does_not_commit_callers_batch( + import_resources, tmp_path, monkeypatch, +): + path = str(tmp_path / "caller.db") + with closing(MemoryService.create(path, extractor="none")) as service: + wid = service.create_workspace("atomic")["id"] + conn = service.store.conn + conn.execute("BEGIN IMMEDIATE") + conn.execute("UPDATE workspaces SET settings=? WHERE id=?", ('{"caller":true}', wid)) + original = service.store._fts_upsert + calls = 0 + + def fail_second(*args, **kwargs): + nonlocal calls + calls += 1 + result = original(*args, **kwargs) + if calls == 2: + raise sqlite3.OperationalError("injected recoverable failure") + return result + + monkeypatch.setattr(service.store, "_fts_upsert", fail_second) + report = import_resources(service, FILES) + assert (report["imported"], report["errors"]) == (2, 1) + assert_consistent(service, ["1-good.md", "3-good.md"]) + assert conn.transaction_owned_by_current_thread() + with closing(sqlite3.connect(path)) as observer: + assert observer.execute("SELECT COUNT(*) FROM memories").fetchone()[0] == 0 + assert observer.execute("SELECT settings FROM workspaces WHERE id=?", (wid,)).fetchone()[0] != ( + '{"caller":true}' + ) + conn.rollback() + assert_consistent(service, []) + + +def test_final_commit_failure_rolls_back_owned_batch(import_resources, tmp_path, monkeypatch): + with closing(MemoryService.create(str(tmp_path / "commit.db"), extractor="none")) as service: + conn = service.store.conn + commit = type(conn).commit + + def fail_commit(current): + if current is conn and not getattr(conn._pin, "defer_commits", 0): + raise sqlite3.OperationalError("injected commit failure") + return commit(current) + + monkeypatch.setattr(type(conn), "commit", fail_commit) + with pytest.raises(sqlite3.OperationalError, match="injected commit failure"): + import_resources(service, FILES) + assert_consistent(service, []) + assert not conn.in_transaction + assert conn.execute("SELECT id FROM workspaces WHERE name='atomic'").fetchone() is None + + +def fail_settlement(monkeypatch, conn, action, *, occurrence): + execute = type(conn).execute + seen = 0 + selected = "" + failed = False + + def run(current, statement, *args, **kwargs): + nonlocal seen, selected, failed + if current is conn: + if statement.startswith("SAVEPOINT engraphis_optional_"): + seen += 1 + if seen == occurrence: + selected = statement.split()[-1] + if selected and not failed and statement == f"{action} SAVEPOINT {selected}": + failed = True + raise sqlite3.OperationalError("injected savepoint settlement failure") + return execute(current, statement, *args, **kwargs) + + monkeypatch.setattr(type(conn), "execute", run) + + +@pytest.mark.parametrize("action", ["RELEASE", "ROLLBACK TO"]) +@pytest.mark.parametrize("derive", [False, True]) +@pytest.mark.parametrize("caller_owned", [False, True]) +def test_savepoint_settlement_failure_aborts_batch( + import_resources, tmp_path, monkeypatch, action, derive, caller_owned, +): + with closing(MemoryService.create(str(tmp_path / "settlement.db"), extractor="none")) as service: + conn = service.store.conn + if caller_owned: + wid = service.create_workspace("atomic")["id"] + conn.execute("BEGIN IMMEDIATE") + conn.execute("UPDATE workspaces SET settings=? WHERE id=?", ('{"caller":true}', wid)) + if derive: + service.engine.extractor = SimpleNamespace(extract=lambda text: [ + ExtractedFact(content="Derived first fact about herons."), + ExtractedFact(content="Derived second fact about egrets."), + ]) + if action == "ROLLBACK TO": + original = service.store._fts_upsert + calls = 0 + + def fail_write(*args, **kwargs): + nonlocal calls + calls += 1 + result = original(*args, **kwargs) + if calls == (3 if derive else 2): + raise ValueError("injected operation failure") + return result + + monkeypatch.setattr(service.store, "_fts_upsert", fail_write) + fail_settlement(monkeypatch, conn, action, occurrence=2) + with pytest.raises(SavepointError, match="write savepoint"): + import_resources(service, FILES[:1] if derive else FILES, derive_facts=derive) + assert_consistent(service, []) + assert conn.transaction_owned_by_current_thread() is caller_owned + if caller_owned: + assert conn.execute("SELECT settings FROM workspaces WHERE id=?", (wid,)).fetchone()[0] == ( + '{"caller":true}' + ) + conn.rollback() + + +@pytest.mark.parametrize("action", ["RELEASE", "ROLLBACK TO"]) +def test_conflict_repair_cannot_swallow_settlement_failure(tmp_path, monkeypatch, action): + with closing(MemoryService.create(str(tmp_path / "conflict.db"), extractor="none")) as service: + engine = service.engine + wid = service.create_workspace("atomic")["id"] + original_id = engine.remember( + "The API uses JWT tokens for authentication.", workspace_id=wid, + ) + before = service.store.get_memory(original_id) + if action == "ROLLBACK TO": + advance = service.store.advance_memory_modified_hlc + + def fail_advance(*args, **kwargs): + advance(*args, **kwargs) + raise RuntimeError("injected conflict repair failure") + + monkeypatch.setattr(service.store, "advance_memory_modified_hlc", fail_advance) + fail_settlement(monkeypatch, service.store.conn, action, occurrence=1) + with pytest.raises(SavepointError, match="write savepoint"): + engine.remember("The API does not use JWT tokens for authentication.", workspace_id=wid) + assert service.store.get_memory(original_id) == before + assert {row[0] for row in service.store.conn.execute("SELECT id FROM memories")} == {original_id} + assert not service.store.conn.execute("SELECT * FROM mem_links").fetchall() + assert not service.store.conn.in_transaction From 6638196f5c70191523f1a8680e9c549ac8afc0ca Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 6 Sep 2026 06:50:46 -0400 Subject: [PATCH 2/2] test: explicitly allow temporary resource import roots --- tests/test_resource_import_atomicity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_resource_import_atomicity.py b/tests/test_resource_import_atomicity.py index 6da2cf88..e54ba781 100644 --- a/tests/test_resource_import_atomicity.py +++ b/tests/test_resource_import_atomicity.py @@ -12,7 +12,9 @@ @pytest.fixture(params=["files", "folder"]) -def import_resources(request, tmp_path): +def import_resources(request, tmp_path, monkeypatch): + monkeypatch.setenv("ENGRAPHIS_IMPORT_ROOTS", str(tmp_path)) + def run(service, files, **kwargs): if request.param == "files": return service.import_files(workspace="atomic", files=files, **kwargs)