Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8bff587
perf(api): add optional Redis read caching
phernandez Jul 29, 2026
1f14649
fix(api): close Redis cache freshness gaps
phernandez Jul 29, 2026
621f642
fix(sync): invalidate cache after recovery and moves
phernandez Jul 29, 2026
87ee016
fix(sync): close cache invalidation failure paths
phernandez Jul 29, 2026
c80d631
fix(sync): invalidate partial single-file indexes
phernandez Jul 29, 2026
78e109d
fix(api): invalidate cache after imports
phernandez Jul 29, 2026
5994aca
fix(api): close remaining cache freshness gaps
phernandez Jul 29, 2026
2b7eea3
fix(api): preserve cache freshness on failure paths
phernandez Jul 29, 2026
9f02ff3
fix(api): invalidate cache after project root changes
phernandez Jul 29, 2026
b9e736a
fix(api): expire Redis cache generations
phernandez Jul 30, 2026
0f18c37
fix(ci): skip Redis tests without Windows Docker
phernandez Jul 30, 2026
10511e6
fix(ci): skip Redis testcontainers on Windows
phernandez Jul 30, 2026
d4121ac
refactor(api): simplify read-cache control flow
phernandez Jul 30, 2026
007b54c
fix(api): finish cache invalidation after cancellation
phernandez Jul 30, 2026
b8c5f72
fix(index): invalidate each committed project batch
phernandez Jul 30, 2026
bf006be
fix(api): finish cache invalidation after directory commits
phernandez Jul 30, 2026
1badd6f
refactor(api): simplify optional read caching
phernandez Jul 30, 2026
53076d7
test(index): cover optional project cache wiring
phernandez Jul 30, 2026
930b61f
fix(api): make move invalidation cancellation-safe
phernandez Jul 30, 2026
d46d420
fix(api): close read cache invalidation gaps
phernandez Jul 30, 2026
5963560
fix(api): close cancellation invalidation windows
phernandez Jul 30, 2026
305c858
fix(api): invalidate rebuild and watcher batches
phernandez Jul 30, 2026
b35ba08
fix(api): shield startup recovery invalidation
phernandez Jul 30, 2026
a562574
fix(api): skip disabled cache identity
phernandez Jul 30, 2026
efed2c8
fix(api): protect watcher delete invalidation
phernandez Jul 30, 2026
7dd8175
fix(sync): invalidate watcher index commits
phernandez Jul 30, 2026
7428125
perf(api): cache directory read responses
phernandez Jul 30, 2026
310c743
fix(sync): invalidate terminal materialization state
phernandez Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
591 changes: 591 additions & 0 deletions docs/REDIS_READ_CACHE_PLAN.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ milvus = [
"pymilvus>=3.0.0,<4; sys_platform == 'win32'",
"pymilvus[milvus-lite]>=3.0.0,<4; sys_platform != 'win32'",
]
redis = [
"redis>=8.0.0,<9",
]

[project.urls]
Homepage = "https://github.com/basicmachines-co/basic-memory"
Expand Down Expand Up @@ -103,6 +106,7 @@ markers = [
"benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
"slow: Slow-running tests (deselect with '-m \"not slow\"')",
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
"redis: Tests that run against a real Redis server",
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
"smoke: Fast end-to-end smoke tests for MCP flows",
"semantic: Tests requiring semantic dependencies (fastembed, sqlite-vec, openai)",
Expand Down Expand Up @@ -131,6 +135,7 @@ dev = [
"ruff>=0.16.0",
"freezegun>=1.5.5",
"testcontainers[postgres]>=4.0.0",
"redis>=8.0.0,<9",
"psycopg>=3.2.0",
"pyright>=1.1.408",
"pytest-testmon>=2.2.0",
Expand Down
7 changes: 7 additions & 0 deletions src/basic_memory/api/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from basic_memory import db
from basic_memory.config import BasicMemoryConfig, ConfigManager
from basic_memory.read_cache import ReadCache
from basic_memory.runtime.mode import RuntimeMode, resolve_runtime_mode

if TYPE_CHECKING: # pragma: no cover
Expand All @@ -39,6 +40,11 @@ class ApiContainer:
engine: AsyncEngine | None = None
session_maker: async_sessionmaker[AsyncSession] | None = None

# --- Optional semantic read cache ---
# Hosts inject a namespace-bound implementation; local and off-lifespan
# ASGI requests deliberately stay dependency-free by default.
read_cache: ReadCache | None = None

@classmethod
def create(cls) -> "ApiContainer": # pragma: no cover
"""Create container by reading ConfigManager.
Expand Down Expand Up @@ -91,6 +97,7 @@ def create_watch_coordinator(self) -> "WatchCoordinator": # pragma: no cover
config=self.config,
should_watch=self.should_watch_files,
skip_reason=self.watch_skip_reason,
read_cache=self.read_cache,
)

# --- Database Factory ---
Expand Down
136 changes: 114 additions & 22 deletions src/basic_memory/api/v2/routers/directory_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@
- Better performance through indexed queries
"""

from typing import Optional
from contextlib import nullcontext
from typing import Annotated

from fastapi import APIRouter, Query, Path
from fastapi import APIRouter, Depends, Path, Query

from basic_memory.deps import DirectoryServiceV2ExternalDep
from basic_memory.deps import (
create_model_read_cache,
DirectoryServiceV2ExternalDep,
ReadCacheDep,
)
from basic_memory.read_cache import (
ModelReadCache,
ReadCacheKey,
ReadCacheOperation,
ReadCacheScope,
read_cache_request_digest,
)
from basic_memory.read_cache.policy import DIRECTORY_READ_CACHE_MAX_PAYLOAD_BYTES
from basic_memory.schemas.directory import (
DEFAULT_DIRECTORY_PAGE_SIZE,
MAX_DIRECTORY_PAGE_SIZE,
Expand All @@ -24,11 +37,42 @@
router = APIRouter(prefix="/directory", tags=["directory-v2"])


def get_directory_node_read_cache(
read_cache: ReadCacheDep,
) -> ModelReadCache[DirectoryNode] | None:
"""Bind directory trees and structures to the optional cache backend."""
return create_model_read_cache(
read_cache,
DirectoryNode,
max_payload_bytes=DIRECTORY_READ_CACHE_MAX_PAYLOAD_BYTES,
)


DirectoryNodeReadCacheDep = Annotated[
ModelReadCache[DirectoryNode] | None,
Depends(get_directory_node_read_cache),
]


def get_directory_list_read_cache(
read_cache: ReadCacheDep,
) -> ModelReadCache[DirectoryListResponse] | None:
"""Bind paginated directory listings to the optional cache backend."""
return create_model_read_cache(read_cache, DirectoryListResponse)


DirectoryListReadCacheDep = Annotated[
ModelReadCache[DirectoryListResponse] | None,
Depends(get_directory_list_read_cache),
]


@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
async def get_directory_tree(
directory_service: DirectoryServiceV2ExternalDep,
read_cache: DirectoryNodeReadCacheDep,
project_id: str = Path(..., description="Project external UUID"),
):
) -> DirectoryNode:
"""Get hierarchical directory structure from the knowledge base.

Args:
Expand All @@ -38,18 +82,31 @@ async def get_directory_tree(
Returns:
DirectoryNode representing the root of the hierarchical tree structure
"""
# Get a hierarchical directory tree for the specific project
tree = await directory_service.get_directory_tree()
cache_key = ReadCacheKey(
project_id=project_id,
operation=ReadCacheOperation.directory_tree,
request_digest=read_cache_request_digest("tree"),
)
cache_scope = (
read_cache.read(key=cache_key)
if read_cache is not None
else nullcontext(ReadCacheScope[DirectoryNode]())
)
async with cache_scope as cached:
if cached.value is not None:
return cached.value

# Return the hierarchical tree
return tree
result = await directory_service.get_directory_tree()
cached.value = result
return result


@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
async def get_directory_structure(
directory_service: DirectoryServiceV2ExternalDep,
read_cache: DirectoryNodeReadCacheDep,
project_id: str = Path(..., description="Project external UUID"),
):
) -> DirectoryNode:
"""Get folder structure for navigation (no files).

Optimized endpoint for folder tree navigation. Returns only directory nodes
Expand All @@ -62,8 +119,23 @@ async def get_directory_structure(
Returns:
DirectoryNode tree containing only folders (type="directory")
"""
structure = await directory_service.get_directory_structure()
return structure
cache_key = ReadCacheKey(
project_id=project_id,
operation=ReadCacheOperation.directory_structure,
request_digest=read_cache_request_digest("structure"),
)
cache_scope = (
read_cache.read(key=cache_key)
if read_cache is not None
else nullcontext(ReadCacheScope[DirectoryNode]())
)
async with cache_scope as cached:
if cached.value is not None:
return cached.value

result = await directory_service.get_directory_structure()
cached.value = result
return result


@router.get(
Expand All @@ -73,20 +145,19 @@ async def get_directory_structure(
)
async def list_directory(
directory_service: DirectoryServiceV2ExternalDep,
read_cache: DirectoryListReadCacheDep,
project_id: str = Path(..., description="Project external UUID"),
dir_name: str = Query("/", description="Directory path to list"),
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
file_name_glob: Optional[str] = Query(
None, description="Glob pattern for filtering file names"
),
file_name_glob: str | None = Query(None, description="Glob pattern for filtering file names"),
page: int = Query(1, ge=1, description="One-indexed result page"),
page_size: int = Query(
DEFAULT_DIRECTORY_PAGE_SIZE,
ge=1,
le=MAX_DIRECTORY_PAGE_SIZE,
description="Number of nodes per page",
),
):
) -> DirectoryListResponse:
"""List directory contents with filtering and depth control.

Args:
Expand All @@ -101,11 +172,32 @@ async def list_directory(
Returns:
Bounded page of DirectoryNode objects matching the criteria
"""
# Get directory listing with filtering
return await directory_service.list_directory(
dir_name=dir_name,
depth=depth,
file_name_glob=file_name_glob,
page=page,
page_size=page_size,
cache_key = ReadCacheKey(
project_id=project_id,
operation=ReadCacheOperation.directory_list,
request_digest=read_cache_request_digest(
dir_name,
str(depth),
file_name_glob or "",
str(page),
str(page_size),
),
)
cache_scope = (
read_cache.read(key=cache_key)
if read_cache is not None
else nullcontext(ReadCacheScope[DirectoryListResponse]())
)
async with cache_scope as cached:
if cached.value is not None:
return cached.value

result = await directory_service.list_directory(
dir_name=dir_name,
depth=depth,
file_name_glob=file_name_glob,
page=page,
page_size=page_size,
)
cached.value = result
return result
Loading
Loading