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
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:
Vendored
+33
@@ -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}")
|
||||
Vendored
+92
@@ -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)
|
||||
Vendored
+18
@@ -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"]
|
||||
Vendored
+29
@@ -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:"
|
||||
Vendored
+30
@@ -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
|
||||
Vendored
+139
@@ -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)
|
||||
Vendored
+92
@@ -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
|
||||
Vendored
+168
@@ -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
|
||||
Vendored
+130
@@ -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
|
||||
Vendored
+29
@@ -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
@@ -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}")
|
||||
Vendored
+61
@@ -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:"
|
||||
Vendored
+89
@@ -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()
|
||||
Vendored
+161
@@ -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()
|
||||
Vendored
+48
@@ -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
|
||||
Vendored
+41
@@ -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()
|
||||
Vendored
+110
@@ -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 + "/")
|
||||
Vendored
+136
@@ -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]
|
||||
Reference in New Issue
Block a user