chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.cache.file import CacheEntry, FileCacheMixin
from mirage.cache.index import IndexCacheStore, RAMIndexCacheStore
from mirage.cache.lock import KeyLockMixin
__all__ = [
"CacheEntry",
"FileCacheMixin",
"IndexCacheStore",
"KeyLockMixin",
"RAMIndexCacheStore",
"RedisIndexCacheStore",
]
def __getattr__(name: str):
if name == "RedisIndexCacheStore":
from mirage.cache.index import RedisIndexCacheStore
return RedisIndexCacheStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+92
View File
@@ -0,0 +1,92 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from contextvars import ContextVar
from typing import Protocol
from mirage.types import PathSpec
class CacheInvalidator(Protocol):
"""What this module needs from a cache manager.
``mirage.cache.manager.CacheManager`` satisfies this structurally;
this module never imports it, keeping the dependency one-way:
core mutators -> cache.context <- mount (pushes a manager).
"""
async def invalidate_after_write(self, path: str | PathSpec) -> None:
...
async def invalidate_after_unlink(self, path: str | PathSpec) -> None:
...
async def cached_bytes(self, path: PathSpec) -> bytes | None:
...
_active: ContextVar[CacheInvalidator | None] = ContextVar(
"_active_cache_manager", default=None)
def push_cache_manager(
manager: CacheInvalidator | None) -> CacheInvalidator | None:
"""Set the active cache manager for the current async context.
Mirrors ``observe.context.push_mount_prefix``: the mount entry point
pushes its manager before dispatching a command, core backend
mutators report through :func:`invalidate_after_write` /
:func:`invalidate_after_unlink`, and the caller restores the
previous value afterwards.
Args:
manager (CacheInvalidator | None): Manager to activate, or None
to clear.
Returns:
CacheInvalidator | None: The previously active manager, so
callers can restore it.
"""
prev = _active.get()
_active.set(manager)
return prev
def active_cache_manager() -> CacheInvalidator | None:
"""Return the active cache manager for the current async context."""
return _active.get()
async def invalidate_after_write(path: str | PathSpec) -> None:
"""Report a backend write so caches are invalidated at the mutation
site. No-op if no cache manager is active.
Args:
path (str | PathSpec): Resource-relative path that was written.
"""
manager = _active.get()
if manager is not None:
await manager.invalidate_after_write(path)
async def invalidate_after_unlink(path: str | PathSpec) -> None:
"""Report a backend deletion so caches are invalidated at the
mutation site. No-op if no cache manager is active.
Args:
path (str | PathSpec): Resource-relative path that was removed.
"""
manager = _active.get()
if manager is not None:
await manager.invalidate_after_unlink(path)
+18
View File
@@ -0,0 +1,18 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.cache.file.entry import CacheEntry
from mirage.cache.file.mixin import FileCacheMixin
__all__ = ["CacheEntry", "FileCacheMixin"]
+29
View File
@@ -0,0 +1,29 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from pydantic import BaseModel
from mirage.types import CacheType
class CacheConfig(BaseModel):
type: CacheType = CacheType.RAM
limit: str | int = "512MB"
max_drain_bytes: int | None = None
class RedisCacheConfig(CacheConfig):
type: CacheType = CacheType.REDIS
url: str = "redis://localhost:6379/0"
key_prefix: str = "mirage:cache:"
+30
View File
@@ -0,0 +1,30 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import time
from pydantic import BaseModel
class CacheEntry(BaseModel):
size: int
cached_at: int
fingerprint: str | None = None
ttl: int | None = None
@property
def expired(self) -> bool:
if self.ttl is None:
return False
return (int(time.time()) - self.cached_at) >= self.ttl
+139
View File
@@ -0,0 +1,139 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
import logging
from typing import Callable
from mirage.cache.file.mixin import FileCacheMixin
from mirage.io import CachableAsyncIterator, IOResult
from mirage.observe.record import OpRecord
logger = logging.getLogger(__name__)
def read_fingerprint(records: list[OpRecord] | None, path: str) -> str | None:
"""Latest backend fingerprint recorded for a read of ``path``.
Backends stamp read records with the content identifier they
returned (S3 ETag, OneDrive cTag, Postgres sha256). Threading it
into the cache entry lets ALWAYS-mode ``is_fresh`` compare like
with like; the MD5-of-content default only matches simple-PUT S3
objects.
Args:
records (list[OpRecord] | None): Op records emitted by the
command that produced the IOResult being applied.
path (str): Virtual path used as the cache key.
"""
if records is None:
return None
for rec in reversed(records):
if rec.op == "read" and rec.path == path and rec.fingerprint:
return rec.fingerprint
return None
async def _set_cached(
cache: FileCacheMixin,
path: str,
data: bytes,
records: list[OpRecord] | None,
) -> None:
fingerprint = read_fingerprint(records, path)
if fingerprint is None and await cache.get(path) == data:
# Warm read: the bytes were served from this cache, so there is
# no backend read record. Re-setting would replace the backend
# fingerprint stamped on the cold read with the MD5 default and
# force ALWAYS mode to evict and refetch on every read.
return
await cache.set(path, data, fingerprint=fingerprint)
async def apply_io(
cache: FileCacheMixin,
io: IOResult,
is_cacheable: Callable[[str], bool] | None = None,
records: list[OpRecord] | None = None,
) -> None:
cache_set = set(io.cache)
max_bytes = getattr(cache, "max_drain_bytes", None)
for path in io.cache:
if is_cacheable is not None and not is_cacheable(path):
continue
data = io.reads.get(path)
if data is None:
data = io.writes.get(path)
if data is None:
continue
if isinstance(data, bytes):
await _set_cached(cache, path, data, records)
elif isinstance(data, CachableAsyncIterator):
if data.exhausted:
await _set_cached(cache, path, b"".join(data.buffered_chunks),
records)
else:
if (hasattr(cache, "_drain_tasks")
and path not in cache._drain_tasks
and not await cache.exists(path)):
task = asyncio.create_task(
_background_drain(cache, path, data, max_bytes,
records))
cache._drain_tasks[path] = task
task.add_done_callback(
lambda t, p=path: cache._drain_tasks.pop(p, None))
for path in io.writes:
if path in cache_set:
continue
if is_cacheable is not None and not is_cacheable(path):
continue
await cache.remove(path)
async def _background_drain(
cache: FileCacheMixin,
path: str,
it: CachableAsyncIterator,
max_bytes: int | None = None,
records: list[OpRecord] | None = None,
) -> None:
"""Drain an unconsumed stream and write to cache.
Cancelled by workspace.close() if the stream is still draining at
shutdown. If max_bytes is set and the drain exceeds it without
exhausting the source, the partial buffer is discarded and the path
is not cached (next read will fetch fresh from the resource).
The fingerprint is looked up after the drain: streaming backends
stamp their read record lazily, once the GET response arrives.
"""
try:
if max_bytes is None:
materialized = await it.drain()
await cache.add(path,
materialized,
fingerprint=read_fingerprint(records, path))
return
materialized, fully_drained = await it.drain_bounded(max_bytes)
if fully_drained:
await cache.add(path,
materialized,
fingerprint=read_fingerprint(records, path))
else:
logger.info(
"cache drain budget exceeded for %s "
"(>%d bytes), skipping cache fill", path, max_bytes)
except asyncio.CancelledError:
logger.warning("background drain cancelled for %s", path)
except Exception:
logger.warning("background drain failed for %s", path, exc_info=True)
+92
View File
@@ -0,0 +1,92 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from collections.abc import Iterable
class FileCacheMixin:
"""LRU file cache mixin for resources.
Adds cache tracking (sizes, fingerprints, TTL, LRU order)
on top of any resource. Data lives in the resource's storage —
subclass implements the cache methods using resource's store.
"""
async def get(self, key: str) -> bytes | None:
raise NotImplementedError
async def set(self,
key: str,
data: bytes,
fingerprint: str | None = None,
ttl: int | None = None) -> None:
raise NotImplementedError
async def add(self,
key: str,
data: bytes,
fingerprint: str | None = None,
ttl: int | None = None) -> bool:
raise NotImplementedError
async def remove(self, key: str) -> None:
raise NotImplementedError
async def exists(self, key: str) -> bool:
raise NotImplementedError
async def is_fresh(self, key: str, remote_fingerprint: str) -> bool:
raise NotImplementedError
async def clear(self) -> None:
raise NotImplementedError
def evict_paths(self, paths: Iterable[str]) -> None:
"""Synchronously evict the given keys from the cache.
Load-time helper invoked from ``Workspace.load`` (sync) to drop
snapshot-restored bytes for paths the caller wants to re-fetch
live. Backends whose state cannot be mutated synchronously
(e.g. Redis) override this as a no-op.
Args:
paths (Iterable[str]): Cache keys to drop.
"""
raise NotImplementedError
async def multi_get(self, keys: list[str]) -> list[bytes | None]:
return [await self.get(k) for k in keys]
async def multi_set(
self,
items: list[tuple[str, bytes]],
fingerprint: str | None = None,
ttl: int | None = None,
) -> None:
for key, data in items:
await self.set(key, data, fingerprint=fingerprint, ttl=ttl)
@property
def cache_size(self) -> int | None:
"""Cached bytes; None for stores that don't track them."""
raise NotImplementedError
@property
def cache_entries(self) -> int | None:
"""Number of cached entries; None for stores that don't track them."""
return None
@property
def cache_limit(self) -> int:
raise NotImplementedError
+168
View File
@@ -0,0 +1,168 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
import time
from collections import OrderedDict
from collections.abc import Iterable
from mirage.cache.file.entry import CacheEntry
from mirage.cache.file.mixin import FileCacheMixin
from mirage.cache.file.utils import default_fingerprint, parse_limit
from mirage.cache.lock import KeyLockMixin
from mirage.resource.ram import RAMResource
class RAMFileCacheStore(RAMResource, FileCacheMixin, KeyLockMixin):
"""RAMResource with LRU cache tracking.
Data lives in inherited _store.files (RAMStore).
_entries tracks LRU metadata only.
All RAM commands (cat, grep, head, ...) inherited.
"""
def __init__(
self,
cache_limit: str | int = "512MB",
max_drain_bytes: int | None = None,
) -> None:
super().__init__()
self._cache_limit: int = parse_limit(cache_limit)
self._cache_size: int = 0
self._entries: OrderedDict[str, CacheEntry] = OrderedDict()
self._drain_tasks: dict[str, asyncio.Task] = {}
self._clear_lock: asyncio.Lock = asyncio.Lock()
self.max_drain_bytes: int | None = max_drain_bytes
async def get(self, key: str) -> bytes | None:
async with self._lock_for(key):
entry = self._entries.get(key)
if entry is None:
return None
if entry.expired:
self._cache_size -= entry.size
del self._entries[key]
self._store.files.pop(key, None)
return None
self._entries.move_to_end(key)
return self._store.files.get(key)
async def set(self,
key: str,
data: bytes,
fingerprint: str | None = None,
ttl: int | None = None) -> None:
async with self._lock_for(key):
if key in self._entries:
self._cache_size -= self._entries[key].size
del self._entries[key]
if fingerprint is None:
fingerprint = default_fingerprint(data)
entry = CacheEntry(
size=len(data),
cached_at=int(time.time()),
fingerprint=fingerprint,
ttl=ttl,
)
self._entries[key] = entry
self._store.files[key] = data
self._cache_size += entry.size
await self._evict()
async def add(self,
key: str,
data: bytes,
fingerprint: str | None = None,
ttl: int | None = None) -> bool:
async with self._lock_for(key):
existing = self._entries.get(key)
if existing is not None and not existing.expired:
return False
if key in self._entries:
self._cache_size -= self._entries[key].size
del self._entries[key]
if fingerprint is None:
fingerprint = default_fingerprint(data)
entry = CacheEntry(
size=len(data),
cached_at=int(time.time()),
fingerprint=fingerprint,
ttl=ttl,
)
self._entries[key] = entry
self._store.files[key] = data
self._cache_size += entry.size
await self._evict()
return True
async def remove(self, key: str) -> None:
async with self._lock_for(key):
task = self._drain_tasks.pop(key, None)
if task:
task.cancel()
if key in self._entries:
self._cache_size -= self._entries[key].size
del self._entries[key]
self._store.files.pop(key, None)
self._discard_lock(key)
async def exists(self, key: str) -> bool:
entry = self._entries.get(key)
return entry is not None and not entry.expired
async def is_fresh(self, key: str, remote_fingerprint: str) -> bool:
entry = self._entries.get(key)
if entry is None:
return False
return entry.fingerprint == remote_fingerprint
async def clear(self) -> None:
async with self._clear_lock:
for task in self._drain_tasks.values():
task.cancel()
self._drain_tasks.clear()
self._entries.clear()
self._store.files.clear()
self._cache_size = 0
self._clear_locks()
def evict_paths(self, paths: Iterable[str]) -> None:
for key in paths:
entry = self._entries.pop(key, None)
if entry is not None:
self._cache_size -= entry.size
self._store.files.pop(key, None)
async def _evict(self) -> None:
while self._cache_size > self._cache_limit and self._entries:
evicted_key = next(iter(self._entries))
async with self._lock_for(evicted_key):
if evicted_key not in self._entries:
continue
evicted = self._entries.pop(evicted_key)
self._cache_size -= evicted.size
self._store.files.pop(evicted_key, None)
self._discard_lock(evicted_key)
@property
def cache_size(self) -> int:
return self._cache_size
@property
def cache_entries(self) -> int:
return len(self._entries)
@property
def cache_limit(self) -> int:
return self._cache_limit
+130
View File
@@ -0,0 +1,130 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
from collections.abc import Iterable
from mirage.cache.file.mixin import FileCacheMixin
from mirage.cache.file.utils import default_fingerprint, parse_limit
from mirage.resource.redis.redis import RedisResource
class RedisFileCacheStore(RedisResource, FileCacheMixin):
def __init__(
self,
cache_limit: str | int = "512MB",
url: str = "redis://localhost:6379/0",
key_prefix: str = "mirage:cache:",
max_drain_bytes: int | None = None,
) -> None:
super().__init__(url=url, key_prefix=key_prefix)
# Advisory only: unlike the RAM store there is no client-side
# LRU, so nothing evicts on overflow. Cap memory on the Redis
# server instead (maxmemory + maxmemory-policy allkeys-lru) to
# approximate the RAM store's eviction behavior.
self._cache_limit: int = parse_limit(cache_limit)
self._cache_client = self._store._client
self._data_prefix = f"{key_prefix}data:"
self._meta_prefix = f"{key_prefix}meta:"
self.max_drain_bytes: int | None = max_drain_bytes
self._drain_tasks: dict[str, asyncio.Task] = {}
async def get(self, key: str) -> bytes | None:
return await self._cache_client.get(f"{self._data_prefix}{key}")
async def set(
self,
key: str,
data: bytes,
fingerprint: str | None = None,
ttl: int | None = None,
) -> None:
if fingerprint is None:
fingerprint = default_fingerprint(data)
pipe = self._cache_client.pipeline()
dk = f"{self._data_prefix}{key}"
mk = f"{self._meta_prefix}{key}"
pipe.set(dk, data)
pipe.set(mk, fingerprint)
if ttl is not None:
pipe.expire(dk, ttl)
pipe.expire(mk, ttl)
await pipe.execute()
async def add(
self,
key: str,
data: bytes,
fingerprint: str | None = None,
ttl: int | None = None,
) -> bool:
dk = f"{self._data_prefix}{key}"
exists = await self._cache_client.exists(dk)
if exists:
return False
await self.set(key, data, fingerprint=fingerprint, ttl=ttl)
return True
async def remove(self, key: str) -> None:
task = self._drain_tasks.pop(key, None)
if task:
task.cancel()
pipe = self._cache_client.pipeline()
pipe.delete(f"{self._data_prefix}{key}")
pipe.delete(f"{self._meta_prefix}{key}")
await pipe.execute()
async def exists(self, key: str) -> bool:
return bool(await
self._cache_client.exists(f"{self._data_prefix}{key}"))
async def is_fresh(self, key: str, remote_fingerprint: str) -> bool:
fp = await self._cache_client.get(f"{self._meta_prefix}{key}")
if fp is None:
return False
if isinstance(fp, bytes):
fp = fp.decode()
return fp == remote_fingerprint
async def clear(self) -> None:
for task in self._drain_tasks.values():
task.cancel()
self._drain_tasks.clear()
for pattern in (
f"{self._data_prefix}*",
f"{self._meta_prefix}*",
):
keys: list = []
async for k in self._cache_client.scan_iter(pattern):
keys.append(k)
if keys:
await self._cache_client.delete(*keys)
def evict_paths(self, paths: Iterable[str]) -> None:
# No-op: Redis cache holds nothing restored from the snapshot
# (only RAM caches are repopulated by _restore_cache), and the
# snapshot load path is sync so we cannot await redis deletes
# here. If a caller needs to drop live Redis-cached entries, use
# await self.remove(key) per path from an async context.
pass
@property
def cache_size(self) -> int | None:
# Size lives in the redis server and is not tracked client-side.
return None
@property
def cache_limit(self) -> int:
return self._cache_limit
+29
View File
@@ -0,0 +1,29 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import hashlib
def parse_limit(limit: str | int) -> int:
if isinstance(limit, int):
return limit
s = limit.strip().upper()
for suffix, mult in [("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)]:
if s.endswith(suffix):
return int(s[:-len(suffix)]) * mult
return int(s)
def default_fingerprint(data: bytes) -> str:
return hashlib.md5(data).hexdigest()
+39
View File
@@ -0,0 +1,39 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.cache.index.config import (IndexConfig, IndexEntry, ListResult,
LookupResult, LookupStatus,
RedisIndexConfig, ResourceType)
from mirage.cache.index.ram import RAMIndexCacheStore
from mirage.cache.index.store import IndexCacheStore
__all__ = [
"IndexCacheStore",
"IndexEntry",
"ListResult",
"LookupResult",
"LookupStatus",
"IndexConfig",
"RAMIndexCacheStore",
"RedisIndexCacheStore",
"RedisIndexConfig",
"ResourceType",
]
def __getattr__(name: str):
if name == "RedisIndexCacheStore":
from mirage.cache.index.redis import RedisIndexCacheStore
return RedisIndexCacheStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+61
View File
@@ -0,0 +1,61 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from enum import Enum
from pydantic import BaseModel, Field
from mirage.types import IndexType
class ResourceType(str, Enum):
FILE = "file"
FOLDER = "folder"
class LookupStatus(str, Enum):
EXPIRED = "expired"
NOT_FOUND = "not_found"
class IndexEntry(BaseModel):
id: str
name: str
resource_type: str
remote_time: str = ""
index_time: str = ""
vfs_name: str = ""
size: int | None = None
extra: dict = Field(default_factory=dict)
class LookupResult(BaseModel):
entry: IndexEntry | None = None
status: LookupStatus | None = None
class ListResult(BaseModel):
entries: list[str] | None = None
status: LookupStatus | None = None
class IndexConfig(BaseModel):
type: IndexType = IndexType.RAM
ttl: float = 600
class RedisIndexConfig(IndexConfig):
type: IndexType = IndexType.REDIS
url: str = "redis://localhost:6379/0"
key_prefix: str = "mirage:index:"
+89
View File
@@ -0,0 +1,89 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from datetime import datetime, timedelta, timezone
from mirage.cache.index.config import (IndexEntry, ListResult, LookupResult,
LookupStatus)
from mirage.cache.index.store import IndexCacheStore
from mirage.cache.lock import KeyLockMixin
from mirage.core.timeutil import to_iso_z
class RAMIndexCacheStore(IndexCacheStore, KeyLockMixin):
"""In-memory index cache using plain dicts + asyncio locks."""
def __init__(self, ttl: float = 600) -> None:
super().__init__()
self._ttl = ttl
self._entries: dict[str, IndexEntry] = {}
self._children: dict[str, list[str]] = {}
self._expiry: dict[str, datetime] = {}
async def get(self, resource_path: str) -> LookupResult:
entry = self._entries.get(resource_path)
if entry is None:
return LookupResult(status=LookupStatus.NOT_FOUND)
return LookupResult(entry=entry)
async def put(self, resource_path: str, entry: IndexEntry) -> None:
async with self._lock_for(resource_path):
if not entry.index_time:
entry = entry.model_copy(
update={
"index_time": to_iso_z(datetime.now(timezone.utc))
})
self._entries[resource_path] = entry
async def list_dir(self, resource_path: str) -> ListResult:
exp = self._expiry.get(resource_path)
if exp is None:
return ListResult(status=LookupStatus.NOT_FOUND)
if datetime.now(timezone.utc) > exp:
return ListResult(status=LookupStatus.EXPIRED)
children = self._children.get(resource_path)
return ListResult(entries=children or [])
async def set_dir(
self,
resource_path: str,
entries: list[tuple[str, IndexEntry]],
expired_at: datetime | None = None,
) -> None:
async with self._lock_for(resource_path):
now = datetime.now(timezone.utc)
exp = expired_at or (now + timedelta(seconds=self._ttl))
now_iso = to_iso_z(now)
prefix = "/" if resource_path == "/" else resource_path + "/"
child_keys: list[str] = []
for name, entry in entries:
full_path = prefix + name
if not entry.index_time:
entry = entry.model_copy(update={"index_time": now_iso})
self._entries[full_path] = entry
child_keys.append(full_path)
self._children[resource_path] = child_keys
self._expiry[resource_path] = exp
async def invalidate_dir(self, resource_path: str) -> None:
for child in self._children.get(resource_path, []):
self._entries.pop(child, None)
self._expiry.pop(resource_path, None)
self._children.pop(resource_path, None)
async def clear(self) -> None:
self._entries.clear()
self._children.clear()
self._expiry.clear()
self._clear_locks()
+161
View File
@@ -0,0 +1,161 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from datetime import datetime, timezone
try:
from redis.asyncio import Redis
except ImportError as _err:
raise ImportError("RedisIndexCacheStore requires the 'redis' extra. "
"Install with: pip install mirage-ai[redis]") from _err
from mirage.cache.index.config import (IndexEntry, ListResult, LookupResult,
LookupStatus)
from mirage.cache.index.store import IndexCacheStore
from mirage.core.timeutil import to_iso_z
ENTRY_PREFIX = "mirage:idx:entry:"
CHILDREN_PREFIX = "mirage:idx:children:"
class RedisIndexCacheStore(IndexCacheStore):
"""Redis-backed index cache for remote resource metadata.
Stores IndexEntry objects as JSON strings and directory children as
Redis lists. Directory TTL is managed via native Redis key expiration.
All writes within set_dir are batched in a single pipeline for efficiency.
Multiple stores can share one Redis server by using distinct key_prefix
values (e.g. "gdrive:", "s3:"). The full key layout is::
{key_prefix}mirage:idx:entry:{resource_path} -> JSON string
{key_prefix}mirage:idx:children:{resource_path} -> Redis list
Args:
ttl (float): Default time-to-live in seconds for directory listings.
url (str): Redis connection URL, used when *client* is not provided.
client (Redis | None): Pre-existing async Redis client. When given,
the store will not close it on ``close()``.
key_prefix (str): Namespace prefix prepended to every Redis key,
allowing multiple stores to coexist on the same server.
"""
def __init__(
self,
ttl: float = 600,
url: str = "redis://localhost:6379/0",
client: Redis | None = None,
key_prefix: str = "",
) -> None:
super().__init__()
self._ttl = ttl
self._client = client or Redis.from_url(url, decode_responses=True)
self._owns_client = client is None
p = key_prefix or ""
self._entry_prefix = f"{p}{ENTRY_PREFIX}"
self._children_prefix = f"{p}{CHILDREN_PREFIX}"
def _entry_key(self, resource_path: str) -> str:
return f"{self._entry_prefix}{resource_path}"
def _children_key(self, resource_path: str) -> str:
return f"{self._children_prefix}{resource_path}"
async def get(self, resource_path: str) -> LookupResult:
raw = await self._client.get(self._entry_key(resource_path))
if raw is None:
return LookupResult(status=LookupStatus.NOT_FOUND)
entry = IndexEntry.model_validate_json(raw)
return LookupResult(entry=entry)
async def put(self, resource_path: str, entry: IndexEntry) -> None:
if not entry.index_time:
entry = entry.model_copy(
update={"index_time": to_iso_z(datetime.now(timezone.utc))})
await self._client.set(self._entry_key(resource_path),
entry.model_dump_json())
async def list_dir(self, resource_path: str) -> ListResult:
key = self._children_key(resource_path)
exists = await self._client.exists(key)
if not exists:
return ListResult(status=LookupStatus.NOT_FOUND)
ttl_remaining = await self._client.ttl(key)
if ttl_remaining == -2:
return ListResult(status=LookupStatus.EXPIRED)
raw = await self._client.lrange(key, 0, -1)
return ListResult(entries=raw)
async def set_dir(
self,
resource_path: str,
entries: list[tuple[str, IndexEntry]],
expired_at: datetime | None = None,
) -> None:
now = datetime.now(timezone.utc)
now_iso = to_iso_z(now)
prefix = "/" if resource_path == "/" else resource_path + "/"
pipe = self._client.pipeline()
child_keys: list[str] = []
for name, entry in entries:
full_path = prefix + name
if not entry.index_time:
entry = entry.model_copy(update={"index_time": now_iso})
pipe.set(self._entry_key(full_path), entry.model_dump_json())
child_keys.append(full_path)
children_key = self._children_key(resource_path)
pipe.delete(children_key)
if child_keys:
pipe.rpush(children_key, *child_keys)
if expired_at:
ttl_seconds = max(1, int((expired_at - now).total_seconds()))
else:
ttl_seconds = max(1, int(self._ttl))
pipe.expire(children_key, ttl_seconds)
await pipe.execute()
async def invalidate_dir(self, resource_path: str) -> None:
children_key = f"{self._children_prefix}{resource_path}"
child_paths = await self._client.lrange(children_key, 0, -1)
pipe = self._client.pipeline()
for child in child_paths:
pipe.delete(self._entry_key(child))
pipe.delete(children_key)
await pipe.execute()
async def clear(self) -> None:
cursor = 0
while True:
cursor, keys = await self._client.scan(
cursor, match=f"{self._entry_prefix}*", count=500)
if keys:
await self._client.delete(*keys)
if cursor == 0:
break
cursor = 0
while True:
cursor, keys = await self._client.scan(
cursor, match=f"{self._children_prefix}*", count=500)
if keys:
await self._client.delete(*keys)
if cursor == 0:
break
async def close(self) -> None:
if self._owns_client:
await self._client.aclose()
+48
View File
@@ -0,0 +1,48 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from datetime import datetime
from mirage.cache.index.config import IndexEntry, ListResult, LookupResult
class IndexCacheStore:
"""Per-resource metadata index for remote resources.
Abstract base. Maps resource paths to IndexEntry metadata.
Subclasses implement storage and concurrency.
"""
async def get(self, resource_path: str) -> LookupResult:
raise NotImplementedError
async def put(self, resource_path: str, entry: IndexEntry) -> None:
raise NotImplementedError
async def list_dir(self, resource_path: str) -> ListResult:
raise NotImplementedError
async def set_dir(
self,
resource_path: str,
entries: list[tuple[str, IndexEntry]],
expired_at: datetime | None = None,
) -> None:
raise NotImplementedError
async def invalidate_dir(self, resource_path: str) -> None:
raise NotImplementedError
async def clear(self) -> None:
raise NotImplementedError
+41
View File
@@ -0,0 +1,41 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
class KeyLockMixin:
"""Per-key async locking for RAM-backed cache stores.
Provides fine-grained locking so operations on different keys
run concurrently while same-key operations are serialized.
Only for in-process RAM stores. Redis/SQLite backends handle
concurrency natively and do not need this mixin.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._key_locks: dict[str, asyncio.Lock] = {}
def _lock_for(self, key: str) -> asyncio.Lock:
if key not in self._key_locks:
self._key_locks[key] = asyncio.Lock()
return self._key_locks[key]
def _discard_lock(self, key: str) -> None:
self._key_locks.pop(key, None)
def _clear_locks(self) -> None:
self._key_locks.clear()
+110
View File
@@ -0,0 +1,110 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.cache.file.mixin import FileCacheMixin
from mirage.cache.index.store import IndexCacheStore
from mirage.types import PathSpec
class CacheManager:
"""Post-mutation cache coherence for one mount.
A backend mutation has two cache consequences: the file-cache entry
for the path is stale, and the parent directory listing in the
index cache (including negative knowledge that the path does not
exist) is stale. This class discharges both, synchronously, at the
mutation site: core backend mutators report through
``mirage.cache.context`` right where they already emit observe
records, so invalidation happens before the next command in a
pipeline runs instead of after the whole command tree.
"""
def __init__(self, file_cache: FileCacheMixin | None,
index: IndexCacheStore | None, prefix: str,
caches_reads: bool) -> None:
"""Args:
file_cache (FileCacheMixin | None): Workspace file cache
store; entries are keyed by mount-absolute path.
index (IndexCacheStore | None): The mount resource's index
cache; listings are keyed by mount-absolute path.
prefix (str): Mount prefix (e.g. "/data/").
caches_reads (bool): Whether the resource caches reads; the
file cache only holds paths for read-caching backends.
"""
self._file_cache = file_cache
self._index = index
self._prefix = prefix.rstrip("/")
self._caches_reads = caches_reads
def _virtual(self, path: str | PathSpec) -> str:
if isinstance(path, PathSpec):
path = path.mount_path
if not path.startswith("/"):
path = "/" + path
if self._prefix and not path.startswith(self._prefix):
return self._prefix + path
return path
async def cached_bytes(self, path: PathSpec) -> bytes | None:
"""Return cached bytes for ``path`` if present, else None.
Lookup only: never fetches from the backend. The single
read-cache check, called by the shared read-through wrappers
(``mirage.cache.read_through``) that every read command reads
through, so warm reads are served from the file cache without the
command knowing about it.
Args:
path (PathSpec): the path to look up.
"""
if not self._caches_reads or self._file_cache is None:
return None
virtual = self._virtual(path)
if await self._file_cache.exists(virtual):
return await self._file_cache.get(virtual)
return None
async def invalidate_after_write(self, path: str | PathSpec) -> None:
"""Invalidate caches after a write to ``path``.
Args:
path (str | PathSpec): Resource-relative path that was
written.
"""
virtual = self._virtual(path)
if self._caches_reads and self._file_cache is not None:
await self._file_cache.remove(virtual)
await self._invalidate_parent(virtual)
async def invalidate_after_unlink(self, path: str | PathSpec) -> None:
"""Invalidate caches after a deletion of ``path``.
Args:
path (str | PathSpec): Resource-relative path that was
removed.
"""
virtual = self._virtual(path)
if self._caches_reads and self._file_cache is not None:
await self._file_cache.remove(virtual)
if self._index is not None:
await self._index.invalidate_dir(virtual)
await self._index.invalidate_dir(virtual + "/")
await self._invalidate_parent(virtual)
async def _invalidate_parent(self, virtual: str) -> None:
if self._index is None:
return
parent = virtual.rsplit("/", 1)[0] or "/"
await self._index.invalidate_dir(parent)
await self._index.invalidate_dir(parent + "/")
+136
View File
@@ -0,0 +1,136 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import inspect
from collections.abc import AsyncIterator, Callable
from mirage.cache.context import active_cache_manager
from mirage.types import PathSpec
async def _serve_stream(manager, raw: Callable, accessor, path: PathSpec,
*args, **kwargs) -> AsyncIterator[bytes]:
if manager is not None and isinstance(path, PathSpec):
cached = await manager.cached_bytes(path)
if cached is not None:
yield cached
return
async for chunk in raw(accessor, path, *args, **kwargs):
yield chunk
def cache_aware_read_stream(raw: Callable) -> Callable:
"""Wrap a backend ``read_stream`` so warm reads serve cached bytes.
The returned reader keeps the backend's ``(accessor, path, ...)``
signature, so it is a drop-in for the raw reader wherever a command
injects one (the factory, ``head_multi``, ``generic_grep``, ...). On a
warm hit it yields the whole cached blob as one chunk; otherwise it
streams from the backend. ``cached_bytes`` is a no-op (returns None)
for local or non-caching mounts, so this is safe to apply uniformly.
The wrapper is a ``def`` (not an ``async def``) that captures the
active cache manager eagerly and returns the async generator: the
manager must be read when the command calls the reader (inside the
mount's cache-manager scope), not lazily when the stream drains, by
which time that scope is gone.
Args:
raw (Callable): the backend ``read_stream`` op.
"""
def reader(accessor, path: PathSpec, *args, **kwargs):
manager = active_cache_manager()
return _serve_stream(manager, raw, accessor, path, *args, **kwargs)
return reader
def cache_aware_read_bytes(raw: Callable) -> Callable:
"""Wrap a backend ``read_bytes`` so warm reads serve cached bytes.
Drop-in for the raw reader, same signature. Returns the cached bytes
on a warm hit, else reads from the backend. No-op for local or
non-caching mounts.
Args:
raw (Callable): the backend ``read_bytes`` op.
"""
async def reader(accessor, path: PathSpec, *args, **kwargs) -> bytes:
manager = active_cache_manager()
if manager is not None and isinstance(path, PathSpec):
cached = await manager.cached_bytes(path)
if cached is not None:
return cached
return await raw(accessor, path, *args, **kwargs)
return reader
def cache_aware_read(raw: Callable) -> Callable:
"""Wrap a polymorphic reader so warm reads serve cached bytes.
For the ``read`` contract used by ``head_multi`` / ``tail_multi`` /
wc ``format_multi``: the reader is called as ``read(accessor, path,
...)`` and may return bytes, an awaitable of bytes, or an async byte
iterator. On a warm hit the wrapped reader returns the cached bytes;
otherwise it calls the raw reader and returns whatever it produced
unchanged, so the consumer's own ``isawaitable`` / ``ensure_stream``
normalization still applies. No-op for local or non-caching mounts.
The active cache manager is captured **eagerly**, when this wrapper
is applied, not when the wrapped reader is later called: a consumer
that yields lazily (``head_multi``) is drained after the mount's
cache-manager scope is gone, so reading the contextvar at drain time
would always miss. Apply this wrapper inside the command's scope
(which the consumers do) so the captured manager travels with the
stream, mirroring :func:`cache_aware_read_stream`.
Args:
raw (Callable): the backend reader (bytes / awaitable / stream).
"""
manager = active_cache_manager()
async def reader(accessor, path: PathSpec, *args, **kwargs):
if manager is not None and isinstance(path, PathSpec):
cached = await manager.cached_bytes(path)
if cached is not None:
return cached
result = raw(accessor, path, *args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
return reader
async def cached_prefix_bytes(path: PathSpec, n: int | None) -> bytes | None:
"""Return the first ``n`` cached bytes of ``path`` when warm, else None.
Lets a range-read fast path (e.g. ``head -c N``) serve from a fully
cached file without a partial backend fetch. ``n=None`` returns the
whole cached blob.
Args:
path (PathSpec): the path to look up.
n (int | None): byte count, or None for the whole file.
"""
manager = active_cache_manager()
if manager is None or not isinstance(path, PathSpec):
return None
cached = await manager.cached_bytes(path)
if cached is None:
return None
return cached if n is None else cached[:n]