Skip to content

Commit 52ed783

Browse files
authored
feat(storage): move target_data source of truth from SQLite to filesystem (#677)
* feat(storage): move target_data source of truth from SQLite to filesystem The target_data table stored complete output JSON arrays as TEXT blobs, causing 1GB+ databases for content-heavy workflows. The same data was already written to agent_io/target/ on every call — the DB copy was redundant. This change makes the filesystem the source of truth: - write_target writes data to filesystem, stores only metadata in DB - _read_target_raw reads from filesystem instead of DB - preview_target reads from filesystem - --fresh now cleans up target JSON files alongside DB rows - data_scanners and smoke tests read from filesystem The DB data column now contains "[]" (satisfies NOT NULL, eliminates blob storage). No schema migration needed. * clean: remove TOCTOU is_file checks, unused import - Remove is_file() pre-checks before read_text() in _read_target_raw, preview_target, and data_scanners — handle errors directly - Remove unused ensure_directory_exists import from writer.py * fix: address review — path containment, fail-loud on missing target_dir, dead code - Add assert_path_contained in write_target and _read_target_raw to prevent path traversal via absolute relative_path - Fail loudly (ValueError) in write_target when target_dir is None instead of silently skipping the filesystem write - Change preview_target target_dir=None from continue to break - Remove 18 dead Path(tmpdir) expressions in integration tests - Add comment explaining intentional double-write in FileWriter - Add target_dir to test_delete_target.py fixtures * clean: fail-fast on missing target_dir in preview_target Replace silent break with early ValueError, matching write_target and _read_target_raw. Remove now-unreachable None check in loop. * fix: eliminate double filesystem write, fix --fresh nested path cleanup - Remove redundant atomic_json_write from FileWriter — backend now owns the filesystem write exclusively. Byte count computed from serialized data instead of stat. - Change --fresh cleanup from glob("*.json") to rglob("*.json") with batch/ exclusion, so nested target files are properly cleaned. - Align test output_directory with backend's target_dir/action so file_path and backend write path are always consistent.
1 parent 567c522 commit 52ed783

13 files changed

Lines changed: 309 additions & 244 deletions

File tree

agent_actions/output/writer.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import csv
6+
import json
67
from collections.abc import Callable
78
from pathlib import Path
89
from typing import TYPE_CHECKING, Any
@@ -16,7 +17,6 @@
1617
from agent_actions.processing.error_handling import ProcessorErrorHandlerMixin
1718
from agent_actions.utils.atomic_write import atomic_json_write
1819
from agent_actions.utils.path_safety import assert_path_contained
19-
from agent_actions.utils.path_utils import ensure_directory_exists
2020

2121
if TYPE_CHECKING:
2222
from agent_actions.storage.backend import StorageBackend
@@ -132,10 +132,7 @@ def do_write() -> int:
132132

133133
self.storage_backend.write_target(self.action_name, relative_path, data)
134134

135-
ensure_directory_exists(file_path, is_file=True)
136-
atomic_json_write(file_path, data)
137-
138-
return file_path.stat().st_size
135+
return len(json.dumps(data, ensure_ascii=False).encode("utf-8"))
139136

140137
self._execute_write("Write target file", do_write)
141138

agent_actions/storage/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ def get_storage_backend(
3030

3131
workflow_dir = Path(workflow_path)
3232
db_path = workflow_dir / "agent_io" / "store" / f"{workflow_name}.db"
33-
backend = backend_class.create(db_path=str(db_path), workflow_name=workflow_name)
33+
target_dir = workflow_dir / "agent_io" / "target"
34+
backend = backend_class.create(
35+
db_path=str(db_path), workflow_name=workflow_name, target_dir=str(target_dir)
36+
)
3437

3538
return backend
3639

agent_actions/storage/backends/sqlite_backend.py

Lines changed: 90 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
DispositionRow,
2121
StorageBackend,
2222
)
23+
from agent_actions.utils.atomic_write import atomic_json_write
24+
from agent_actions.utils.path_safety import assert_path_contained
2325

2426
logger = logging.getLogger(__name__)
2527

@@ -168,10 +170,11 @@ class SQLiteBackend(StorageBackend):
168170
# Restrictive as defense-in-depth; all SQL is parameterized.
169171
_VALID_IDENTIFIER_CHARS = set(string.ascii_letters + string.digits + "_-./ ")
170172

171-
def __init__(self, db_path: str, workflow_name: str):
173+
def __init__(self, db_path: str, workflow_name: str, target_dir: str | None = None):
172174
"""Initialize SQLite backend."""
173175
self.db_path = Path(db_path)
174176
self.workflow_name = workflow_name
177+
self.target_dir: Path | None = Path(target_dir) if target_dir else None
175178
self._connection: sqlite3.Connection | None = None
176179
self._lock = (
177180
threading.RLock()
@@ -184,15 +187,17 @@ def create(cls, **kwargs) -> "SQLiteBackend":
184187
Required kwargs:
185188
db_path: Path to the SQLite database file.
186189
workflow_name: Name of the workflow.
190+
target_dir: Path to agent_io/target directory.
187191
"""
188192
db_path = kwargs.pop("db_path")
189193
workflow_name = kwargs.pop("workflow_name")
194+
target_dir = kwargs.pop("target_dir", None)
190195
if kwargs:
191196
raise ConfigValidationError(
192197
f"Unknown kwargs for SQLiteBackend: {list(kwargs)}",
193198
context={"unknown_kwargs": list(kwargs)},
194199
)
195-
return cls(str(db_path), workflow_name)
200+
return cls(str(db_path), workflow_name, target_dir=str(target_dir) if target_dir else None)
196201

197202
def _validate_identifier(self, name: str, field: str) -> str:
198203
"""Validate and POSIX-normalize an identifier to prevent injection.
@@ -313,23 +318,33 @@ def _enforce_schema(self, cursor: sqlite3.Cursor) -> None:
313318
)
314319

315320
def write_target(self, action_name: str, relative_path: str, data: list[dict[str, Any]]) -> str:
316-
"""Write target data for a specific node."""
321+
"""Write target data to filesystem and metadata to DB."""
317322
action_name = self._validate_identifier(action_name, "action_name")
318323
relative_path = self._validate_identifier(relative_path, "relative_path")
319324

320-
data_json = json.dumps(data, ensure_ascii=False)
321325
record_count = len(data)
322326

327+
if self.target_dir is None:
328+
raise ValueError(
329+
f"Cannot write target data without target_dir configured "
330+
f"({action_name}/{relative_path})"
331+
)
332+
333+
file_path = self.target_dir / action_name / relative_path
334+
assert_path_contained(file_path, self.target_dir)
335+
file_path.parent.mkdir(parents=True, exist_ok=True)
336+
atomic_json_write(file_path, data)
337+
323338
with self._lock:
324339
cursor = self.connection.cursor()
325340
try:
326341
cursor.execute(
327342
"""
328343
INSERT OR REPLACE INTO target_data
329344
(action_name, relative_path, data, record_count, created_at)
330-
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
345+
VALUES (?, ?, '[]', ?, CURRENT_TIMESTAMP)
331346
""",
332-
(action_name, relative_path, data_json, record_count),
347+
(action_name, relative_path, record_count),
333348
)
334349
self.connection.commit()
335350
logger.debug(
@@ -354,25 +369,33 @@ def write_target(self, action_name: str, relative_path: str, data: list[dict[str
354369
raise
355370

356371
def _read_target_raw(self, action_name: str, relative_path: str) -> list[dict[str, Any]]:
357-
"""Read raw target data from SQLite.
372+
"""Read raw target data from the filesystem.
358373
359374
Raises:
360375
FileNotFoundError: If no data exists for the given path.
361376
"""
362377
action_name = self._validate_identifier(action_name, "action_name")
363378
relative_path = self._validate_identifier(relative_path, "relative_path")
364-
with self._lock:
365-
cursor = self.connection.cursor()
366-
cursor.execute(
367-
"SELECT data FROM target_data WHERE action_name = ? AND relative_path = ?",
368-
(action_name, relative_path),
369-
)
370-
row = cursor.fetchone()
371379

372-
if row is None:
373-
raise FileNotFoundError(f"No target data found for {action_name}/{relative_path}")
380+
if self.target_dir is None:
381+
raise FileNotFoundError(
382+
f"No target_dir configured — cannot read {action_name}/{relative_path}"
383+
)
374384

375-
result: list[dict[str, Any]] = json.loads(row["data"])
385+
file_path = self.target_dir / action_name / relative_path
386+
assert_path_contained(file_path, self.target_dir)
387+
try:
388+
result: list[dict[str, Any]] = json.loads(file_path.read_text(encoding="utf-8"))
389+
except FileNotFoundError:
390+
raise FileNotFoundError(
391+
f"No target data found for {action_name}/{relative_path}"
392+
) from None
393+
394+
if not isinstance(result, list):
395+
raise FileNotFoundError(
396+
f"Target data at {action_name}/{relative_path} is not a list "
397+
f"(got {type(result).__name__})"
398+
)
376399
return result
377400

378401
def write_source(
@@ -485,11 +508,17 @@ def preview_target(
485508
offset: int = 0,
486509
relative_path: str | None = None,
487510
) -> dict[str, Any]:
488-
"""Preview target data for a node with pagination."""
511+
"""Preview target data for a node with pagination.
512+
513+
Reads file metadata from DB, actual data from filesystem.
514+
"""
489515
action_name = self._validate_identifier(action_name, "action_name")
490516
if relative_path is not None:
491517
relative_path = self._validate_identifier(relative_path, "relative_path")
492518

519+
if self.target_dir is None:
520+
raise ValueError("Cannot preview target data without target_dir configured")
521+
493522
limit = min(max(1, limit), 1000)
494523
offset = max(0, offset)
495524

@@ -499,7 +528,7 @@ def preview_target(
499528
cursor.execute(
500529
"""
501530
SELECT relative_path,
502-
COALESCE(record_count, json_array_length(data)) as record_count
531+
COALESCE(record_count, 0) as record_count
503532
FROM target_data
504533
WHERE action_name = ?
505534
ORDER BY relative_path
@@ -508,60 +537,58 @@ def preview_target(
508537
)
509538
file_metadata = cursor.fetchall()
510539

511-
files = [row["relative_path"] for row in file_metadata]
540+
files = [row["relative_path"] for row in file_metadata]
512541

513-
if relative_path:
514-
if relative_path not in files:
515-
return {
516-
"records": [],
517-
"total_count": 0,
518-
"action_name": action_name,
519-
"files": files,
520-
"error": f"File '{relative_path}' not found for node '{action_name}'",
521-
}
522-
file_metadata = [
523-
row for row in file_metadata if row["relative_path"] == relative_path
524-
]
542+
if relative_path:
543+
if relative_path not in files:
544+
return {
545+
"records": [],
546+
"total_count": 0,
547+
"action_name": action_name,
548+
"files": files,
549+
"error": f"File '{relative_path}' not found for node '{action_name}'",
550+
}
551+
file_metadata = [row for row in file_metadata if row["relative_path"] == relative_path]
525552

526-
total_count = sum(row["record_count"] for row in file_metadata)
553+
total_count = sum(row["record_count"] for row in file_metadata)
527554

528-
paginated_records: list[dict[str, Any]] = []
529-
skipped = 0
530-
collected = 0
555+
paginated_records: list[dict[str, Any]] = []
556+
skipped = 0
557+
collected = 0
531558

532-
for row in file_metadata:
533-
if collected >= limit:
534-
break
559+
for row in file_metadata:
560+
if collected >= limit:
561+
break
535562

536-
file_path = row["relative_path"]
537-
file_record_count = row["record_count"]
563+
file_path = row["relative_path"]
564+
file_record_count = row["record_count"]
538565

539-
if skipped + file_record_count <= offset:
540-
skipped += file_record_count
541-
continue
566+
if skipped + file_record_count <= offset:
567+
skipped += file_record_count
568+
continue
542569

543-
cursor.execute(
544-
"SELECT data FROM target_data WHERE action_name = ? AND relative_path = ?",
545-
(action_name, file_path),
546-
)
547-
data_row = cursor.fetchone()
548-
if not data_row:
570+
fs_path = self.target_dir / action_name / file_path
571+
try:
572+
records = json.loads(fs_path.read_text(encoding="utf-8"))
573+
except (json.JSONDecodeError, OSError):
574+
continue
575+
576+
if not isinstance(records, list):
577+
continue
578+
579+
for record in records:
580+
if skipped < offset:
581+
skipped += 1
549582
continue
550583

551-
records = json.loads(data_row["data"])
552-
for record in records:
553-
if skipped < offset:
554-
skipped += 1
555-
continue
556-
557-
if collected < limit:
558-
if isinstance(record, dict):
559-
paginated_records.append({**record, "_file": file_path})
560-
else:
561-
paginated_records.append({"_file": file_path, "_value": record})
562-
collected += 1
584+
if collected < limit:
585+
if isinstance(record, dict):
586+
paginated_records.append({**record, "_file": file_path})
563587
else:
564-
break
588+
paginated_records.append({"_file": file_path, "_value": record})
589+
collected += 1
590+
else:
591+
break
565592

566593
return {
567594
"records": paginated_records,

agent_actions/tooling/docs/scanner/data_scanners.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ def scan_workflow_data(project_root: Path) -> dict[str, Any]:
105105
workflow_name = db_file.stem
106106

107107
try:
108-
data = scan_sqlite_readonly(db_file, workflow_name)
108+
data = scan_sqlite_readonly(
109+
db_file, workflow_name, target_dir=agent_io_dir / "target"
110+
)
109111
if data is not None:
110112
workflow_data[workflow_name] = data
111113
except (OSError, sqlite3.Error) as e:
@@ -142,7 +144,9 @@ def _unwrap_record_content(record: dict, action_name: str) -> dict:
142144
return record
143145

144146

145-
def scan_sqlite_readonly(db_file: Path, workflow_name: str) -> dict[str, Any] | None:
147+
def scan_sqlite_readonly(
148+
db_file: Path, workflow_name: str, target_dir: Path | None = None
149+
) -> dict[str, Any] | None:
146150
"""Open a workflow SQLite DB read-only and extract stats + preview data.
147151
148152
Uses a direct sqlite3 connection in read-only mode so that scanning
@@ -213,27 +217,25 @@ def scan_sqlite_readonly(db_file: Path, workflow_name: str) -> dict[str, Any] |
213217
)
214218
files = [row["relative_path"] for row in cursor.fetchall()]
215219

216-
# Preview: iterate the cursor lazily so we never load every
217-
# data blob into memory. Cap at 20 flattened records.
218-
cursor.execute(
219-
"SELECT relative_path, data FROM target_data WHERE action_name = ?",
220-
(action_name,),
221-
)
220+
# Preview: read from filesystem (source of truth).
221+
# Cap at 20 flattened records.
222222
records: list[dict] = []
223-
for target_row in cursor:
223+
for file_path in files:
224224
if len(records) >= 20:
225225
break
226+
if target_dir is None:
227+
break
228+
fs_path = target_dir / action_name / file_path
226229
try:
227-
row_data = _json.loads(target_row["data"])
228-
except (ValueError, _json.JSONDecodeError):
230+
row_data = _json.loads(fs_path.read_text(encoding="utf-8"))
231+
except (ValueError, _json.JSONDecodeError, OSError):
229232
logger.debug(
230-
"Skipping malformed JSON in %s node %s, file %s",
233+
"Skipping unreadable target file %s/%s/%s",
231234
workflow_name,
232235
action_name,
233-
target_row["relative_path"],
236+
file_path,
234237
)
235238
continue
236-
file_path = target_row["relative_path"]
237239
if isinstance(row_data, list):
238240
for item in row_data:
239241
if len(records) >= 20:

agent_actions/workflow/coordinator.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,21 @@ def _clear_for_fresh_run(self) -> None:
286286
except Exception as e:
287287
logger.warning("Failed to clear stored data for %s: %s", action_name, e)
288288

289+
# Clear target output files from filesystem (rglob for nested paths,
290+
# but skip batch/ — batch artifacts have their own cleanup below)
291+
action_target_dir = target_dir / action_name
292+
batch_dir = action_target_dir / "batch"
293+
if action_target_dir.is_dir():
294+
for json_file in action_target_dir.rglob("*.json"):
295+
if batch_dir in json_file.parents or json_file.parent == batch_dir:
296+
continue
297+
try:
298+
json_file.unlink()
299+
logger.debug("Removed target file: %s", json_file)
300+
except OSError as e:
301+
logger.warning("Failed to remove %s: %s", json_file, e)
302+
289303
# Batch artifacts live on disk, not in the DB
290-
batch_dir = target_dir / action_name / "batch"
291304
if batch_dir.is_dir():
292305
for pattern in [
293306
".recovery_state_*.json",

0 commit comments

Comments
 (0)