bcbd1bdb22
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
140 lines
5.3 KiB
Python
140 lines
5.3 KiB
Python
# ========= 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)
|