Skip to main content

Service Architecture and Caching Strategy

The BookmarkService in etchblok-test-api serves as the central orchestration layer, implementing the Facade pattern to provide a unified interface for complex operations. It coordinates between three distinct subsystems: the BookmarkRepository for persistence, the SearchIndex for full-text retrieval, and an LRUCache for performance optimization.

The Singleton Facade

To ensure consistent state across different Flask blueprints (such as bookmarks_bp and tags_bp), BookmarkService is implemented as a Singleton. This ensures that the in-memory repository, search index, and cache are shared throughout the application process.

class BookmarkService:
_instance: Optional["BookmarkService"] = None

def __new__(cls) -> "BookmarkService":
"""Singleton — share state across blueprint modules."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init_services()
return cls._instance

When initialized via _init_services(), the service bootstraps its dependencies, including an LRUCache with a hardcoded max_size of 256 entries.

Read Path and Caching Strategy

The BookmarkService implements a cache-aside strategy for individual bookmark lookups. When get_bookmark is called, the service first checks the internal LRUCache. If the entry is missing (a cache miss), it retrieves the bookmark from the BookmarkRepository and populates the cache before returning the result.

def get_bookmark(self, bookmark_id: str) -> Optional[Bookmark]:
"""Retrieve a bookmark by ID, using cache when available."""
cached = self._cache.get(bookmark_id)
if cached is not None:
return cached
bookmark = self._repo.get_bookmark(bookmark_id)
if bookmark:
self._cache.put(bookmark.id, bookmark)
return bookmark

The LRUCache (found in app/services/_cache.py) uses an OrderedDict to track access order. Accessing an item via get() moves it to the most-recently-used position, while put() operations evict the oldest entries once the max_size is exceeded.

Write Path and Coordination

Write operations in etchblok-test-api require careful coordination to maintain consistency across the repository, search index, and cache. The BookmarkService handles this by sequencing operations:

  1. Validation: Data is validated using internal helpers like _validate_url.
  2. Persistence: The entity is saved to the BookmarkRepository.
  3. Indexing: The SearchIndex is updated incrementally.
  4. Invalidation: The specific cache entry is explicitly invalidated to ensure subsequent reads fetch the fresh data.

For example, the update_bookmark method ensures that any modification to a bookmark's title or URL is reflected in the search index and that stale data is removed from the cache:

def update_bookmark(self, bookmark_id: str, data: Dict[str, Any]) -> Tuple[Optional[Bookmark], Optional[str]]:
# ... validation and updates ...
bookmark._touch()
self._repo.save_bookmark(bookmark)
self._search.index_bookmark(bookmark)
self._cache.invalidate(bookmark.id)
return bookmark, None

Cross-Entity Operations

The service architecture is particularly important for operations that span multiple entity types. A primary example is delete_tag, which must maintain referential integrity across all bookmarks.

When a tag is deleted, the BookmarkService iterates through every bookmark associated with that tag. It removes the tag reference from the bookmark model, persists the change in the repository, and invalidates the cache for every affected bookmark.

def delete_tag(self, tag_id: str) -> bool:
tag = self._repo.get_tag(tag_id)
if not tag:
return False
for bookmark in self._repo.get_bookmarks_with_tag(tag_id):
bookmark.remove_tag(tag_id)
self._repo.save_bookmark(bookmark)
self._cache.invalidate(bookmark.id)
self._repo.delete_tag(tag_id)
return True

Search Index Integration

The SearchIndex (implemented in app/services/search_service.py) provides an in-memory inverted index. It tokenizes the title and description of bookmarks, filtering out common stop words.

The BookmarkService triggers incremental updates to this index during create_bookmark and update_bookmark calls. While the SearchIndex handles the logic of mapping tokens to IDs and ranking results by frequency, the BookmarkService acts as the bridge that provides the repository context needed to turn those IDs back into full Bookmark objects for the API response.

def search(self, query: str, limit: int = 20) -> List[Bookmark]:
"""Full-text search across bookmarks."""
return self._search.search(query, limit=limit)

Design Tradeoffs

The architecture of etchblok-test-api prioritizes simplicity and speed for small datasets through its in-memory design:

  • Manual Invalidation: Caching is not transparent; the BookmarkService must explicitly manage self._cache.invalidate(id) for every write operation. This places the burden of consistency on the service layer.
  • In-Memory Limitations: Because the SearchIndex and BookmarkRepository are in-memory, they are rebuilt on every application start. The SearchIndex specifically rebuilds itself by scanning the entire repository during initialization in SearchIndex._rebuild().
  • Singleton State: While the Singleton pattern simplifies access across Flask blueprints, it means the application state is tied to a single process and cannot be easily scaled horizontally without moving to an external persistence and caching layer (e.g., Redis or PostgreSQL).