ADR-080: Garage Service Architecture
Status: Proposed Date: 2025-12-14 Deciders: @aaronsb, @claude Related ADRs: ADR-057 (Multimodal Image Ingestion), ADR-079 (Projection Artifact Storage), ADR-081 (Source Document Lifecycle) Closes: #175
Context
The GarageClient class has grown to 732 lines handling multiple distinct responsibilities:
- Image storage (ADR-057)
- Projection storage (ADR-079)
- Credential management
- S3 client initialization
This violates the Single Responsibility Principle (#175) and makes the code harder to maintain and test.
This ADR focuses on clean code refactoring - establishing a modular service architecture that future features (like source document storage in ADR-081) can build upon.
Scope clarification: - ADR-080 (this): Service architecture, SRP refactor, retention policies - ADR-081: Source document lifecycle, deduplication, versioning, regeneration
Decision
1. Modular Service Architecture
Refactor garage_client.py (732 lines) into focused service modules:
api/api/lib/garage/
├── __init__.py # Re-exports for backward compatibility
├── base.py # GarageBaseClient (S3 ops, credentials)
├── image_storage.py # ImageStorageService (ADR-057)
├── projection_storage.py # ProjectionStorageService (ADR-079)
├── source_storage.py # SourceDocumentService (NEW)
└── retention.py # RetentionPolicyManager (NEW)
Each service follows the Facade pattern established by GraphQueryFacade (ADR-048).
2. GarageBaseClient
Core S3 operations shared by all services:
class GarageBaseClient:
"""
Base Garage client providing core S3 operations.
All storage services depend on this client for:
- Credential management (encrypted store or env fallback)
- S3 client initialization with retry config
- Bucket management
- Health checks
"""
def __init__(self, endpoint=None, bucket_name=None):
# Load credentials (not stored as instance attrs)
# Initialize boto3 with retry config
pass
@property
def client(self) -> boto3.client:
"""S3 client instance."""
pass
def ensure_bucket_exists(self) -> None:
"""Create bucket if it doesn't exist."""
pass
def health_check(self) -> bool:
"""Verify Garage connectivity."""
pass
def put_object(self, key: str, data: bytes, content_type: str) -> None:
"""Store object with standard error handling."""
pass
def get_object(self, key: str) -> Optional[bytes]:
"""Retrieve object, returns None if not found."""
pass
def delete_object(self, key: str) -> bool:
"""Delete object, returns success status."""
pass
def list_objects(self, prefix: str) -> List[Dict]:
"""List objects with prefix."""
pass
3. Storage Services
Each service encapsulates domain-specific logic:
class ImageStorageService:
"""Image storage operations (ADR-057)."""
def __init__(self, base: GarageBaseClient):
self.base = base
def upload_image(self, ontology, source_id, image_bytes, filename) -> str
def download_image(self, object_key) -> bytes
def delete_image(self, object_key) -> None
def list_images(self, ontology=None) -> List[Dict]
def get_image_metadata(self, object_key) -> Dict
class ProjectionStorageService:
"""Projection artifact storage (ADR-079)."""
def __init__(self, base: GarageBaseClient):
self.base = base
def store(self, ontology, embedding_source, data, keep_history=True) -> str
def get(self, ontology, embedding_source="concepts") -> Optional[Dict]
def get_history(self, ontology, embedding_source="concepts", limit=10) -> List
def delete(self, ontology, embedding_source="concepts") -> bool
def delete_all(self, ontology) -> int
class SourceDocumentService:
"""Source document storage (NEW - #172)."""
def __init__(self, base: GarageBaseClient):
self.base = base
def store(self, ontology, source_id, content, filename=None) -> str
def get(self, ontology, source_id) -> Optional[str]
def delete(self, ontology, source_id) -> bool
def list(self, ontology) -> List[Dict]
4. Source Document Storage
Store original text documents in Garage alongside images:
Key format:
sources/{ontology}/{source_id}.txt
sources/{ontology}/{source_id}.md
sources/{ontology}/{source_id}.json # For structured content
Use cases: - Re-process documents with improved extraction - Audit trail for ingested content - Export/backup original materials - Enable document search and retrieval
Integration with Source nodes:
// Source node gains optional garage_key property
(:Source {
source_id: "src-123",
document: "Philosophy",
full_text: "...", // Still stored in graph for queries
garage_key: "sources/Philosophy/src-123.txt" // Optional link to original
})
5. Retention Policies
Configure retention for different artifact types:
class RetentionPolicyManager:
"""Manage artifact retention and cleanup."""
DEFAULT_POLICIES = {
"projections": {
"keep_latest": True, # Always keep latest.json
"history_count": 10, # Keep last N snapshots
"history_max_age_days": 30, # Delete older than N days
},
"sources": {
"keep_always": True, # Never auto-delete sources
},
"images": {
"keep_always": True, # Never auto-delete images
}
}
def __init__(self, base: GarageBaseClient, policies=None):
self.base = base
self.policies = policies or self.DEFAULT_POLICIES
def cleanup_projections(self, ontology: str) -> CleanupResult:
"""Apply retention policy to projection history."""
pass
def cleanup_all(self) -> Dict[str, CleanupResult]:
"""Run cleanup across all ontologies."""
pass
def get_storage_stats(self) -> StorageStats:
"""Get storage usage by category."""
pass
Cleanup can be triggered: - Manually via API endpoint - Scheduled via job launcher (like ProjectionLauncher) - After ingestion jobs complete
6. Backward Compatibility
Maintain get_garage_client() function for existing code:
# api/api/lib/garage/__init__.py
from .base import GarageBaseClient
from .image_storage import ImageStorageService
from .projection_storage import ProjectionStorageService
from .source_storage import SourceDocumentService
from .retention import RetentionPolicyManager
# Singleton instances
_base_client: Optional[GarageBaseClient] = None
_image_service: Optional[ImageStorageService] = None
_projection_service: Optional[ProjectionStorageService] = None
_source_service: Optional[SourceDocumentService] = None
def get_garage_client() -> GarageBaseClient:
"""Get base Garage client (backward compatible)."""
global _base_client
if _base_client is None:
_base_client = GarageBaseClient()
return _base_client
def get_image_storage() -> ImageStorageService:
"""Get image storage service."""
global _image_service
if _image_service is None:
_image_service = ImageStorageService(get_garage_client())
return _image_service
def get_projection_storage() -> ProjectionStorageService:
"""Get projection storage service."""
global _projection_service
if _projection_service is None:
_projection_service = ProjectionStorageService(get_garage_client())
return _projection_service
def get_source_storage() -> SourceDocumentService:
"""Get source document storage service."""
global _source_service
if _source_service is None:
_source_service = SourceDocumentService(get_garage_client())
return _source_service
7. API Endpoints
New endpoints for source document and retention management:
# Source documents
GET /sources/{ontology}/{source_id}/document # Retrieve original document
POST /sources/{ontology}/{source_id}/document # Store document (during ingestion)
# Retention/cleanup
GET /admin/storage/stats # Storage usage by category
POST /admin/storage/cleanup # Run retention cleanup
GET /admin/storage/retention # Get retention policies
PUT /admin/storage/retention # Update retention policies
Implementation Plan
Phase 1: Refactor (SRP)
- Create
api/api/lib/garage/module structure - Extract
GarageBaseClientfrom current code - Extract
ImageStorageService - Extract
ProjectionStorageService - Add backward-compatible
get_garage_client() - Update all import sites
- Verify existing tests pass
Phase 2: Source Documents
- Implement
SourceDocumentService - Add
garage_keyproperty to Source node schema - Update ingestion worker to optionally store source docs
- Add API endpoints for document retrieval
- Add tests
Phase 3: Retention
- Implement
RetentionPolicyManager - Add retention configuration to system settings
- Create cleanup job launcher
- Add admin API endpoints
- Add tests
Consequences
Positive
- Single Responsibility: Each service has one reason to change
- Testability: Services can be unit tested in isolation
- Extensibility: Easy to add new storage services
- Source Preservation: Original documents available for re-processing
- Storage Management: Retention policies prevent unbounded growth
Negative
- Migration Effort: Need to update all import sites
- More Files: 6 files instead of 1 (but each is focused)
- Indirection: Slightly more complex initialization
Neutral
- Same External API:
get_garage_client()still works - Same Storage Format: No changes to S3 key structure
- Additive: Source storage is optional, not required
Migration
No database migration needed. Changes are code-only:
- Create new module structure
- Move code to appropriate services
- Update imports (can be done incrementally)
- Old
garage_client.pybecomes thin wrapper during transition
References
- Issue #172: Expand Garage storage for projections and source documents
- Issue #175: Refactor GarageClient to follow Single Responsibility Principle
- ADR-048: GraphQueryFacade (similar pattern)
- ADR-057: Multimodal Image Ingestion
- ADR-079: Projection Artifact Storage