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
+13
@@ -0,0 +1,13 @@
|
||||
# ========= 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. =========
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# ========= 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. =========
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# ========= 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 mirage.cache.file.entry import CacheEntry
|
||||
|
||||
|
||||
def test_cache_entry_creation():
|
||||
entry = CacheEntry(cached_at=int(time.time()), size=5)
|
||||
assert entry.fingerprint is None
|
||||
assert entry.size == 5
|
||||
|
||||
|
||||
def test_cache_entry_mutable():
|
||||
entry = CacheEntry(cached_at=100, size=1)
|
||||
entry.cached_at -= 20
|
||||
assert entry.cached_at == 80
|
||||
|
||||
|
||||
def test_expired_with_ttl():
|
||||
entry = CacheEntry(cached_at=1000, size=1, ttl=10)
|
||||
assert entry.expired is True
|
||||
|
||||
|
||||
def test_not_expired_without_ttl():
|
||||
entry = CacheEntry(cached_at=int(time.time()), size=1)
|
||||
assert entry.ttl is None
|
||||
assert entry.expired is False
|
||||
|
||||
|
||||
def test_not_expired_within_ttl():
|
||||
entry = CacheEntry(cached_at=int(time.time()), size=1, ttl=3600)
|
||||
assert entry.expired is False
|
||||
Vendored
+300
@@ -0,0 +1,300 @@
|
||||
# ========= 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 pytest
|
||||
|
||||
from mirage.cache.file import io as cache_io
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
from mirage.io import CachableAsyncIterator, IOResult
|
||||
from mirage.observe.record import OpRecord
|
||||
|
||||
|
||||
def _read_record(path: str, fingerprint: str | None) -> OpRecord:
|
||||
return OpRecord(op="read",
|
||||
path=path,
|
||||
source="s3",
|
||||
bytes=0,
|
||||
timestamp=0,
|
||||
duration_ms=0,
|
||||
fingerprint=fingerprint)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
return RAMFileCacheStore()
|
||||
|
||||
|
||||
# ── cache population via apply_io ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_caches_reads(cache):
|
||||
"""Command reads a file → apply_io stores it in cache."""
|
||||
io = IOResult(
|
||||
reads={"/data/file.txt": b"hello"},
|
||||
cache=["/data/file.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/data/file.txt") == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_caches_writes(cache):
|
||||
"""Command writes a file and marks it cacheable → stored in cache."""
|
||||
io = IOResult(
|
||||
writes={"/data/out.txt": b"output"},
|
||||
cache=["/data/out.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/data/out.txt") == b"output"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_reads_preferred_over_writes(cache):
|
||||
"""When both reads and writes exist for same path, read data wins."""
|
||||
io = IOResult(
|
||||
reads={"/f.txt": b"read-data"},
|
||||
writes={"/f.txt": b"write-data"},
|
||||
cache=["/f.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/f.txt") == b"read-data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_multiple_paths(cache):
|
||||
"""Multiple paths in cache list are all stored."""
|
||||
io = IOResult(
|
||||
reads={
|
||||
"/a.txt": b"aaa",
|
||||
"/b.txt": b"bbb"
|
||||
},
|
||||
cache=["/a.txt", "/b.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/a.txt") == b"aaa"
|
||||
assert await cache.get("/b.txt") == b"bbb"
|
||||
|
||||
|
||||
# ── backend fingerprint threading ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_sets_backend_fingerprint_from_records(cache):
|
||||
"""A read record with a backend fingerprint (ETag, cTag, sha256)
|
||||
stamps the cache entry, so ALWAYS-mode is_fresh can match it."""
|
||||
io = IOResult(reads={"/s3/f.txt": b"hello"}, cache=["/s3/f.txt"])
|
||||
records = [_read_record("/s3/f.txt", "etag-multipart-2")]
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
assert await cache.get("/s3/f.txt") == b"hello"
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-multipart-2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_exhausted_stream_uses_record_fingerprint(cache):
|
||||
"""An exhausted stream read carries its record fingerprint too."""
|
||||
stream = _make_stream(b"hello")
|
||||
assert await stream.drain() == b"hello"
|
||||
io = IOResult(reads={"/s3/f.txt": stream}, cache=["/s3/f.txt"])
|
||||
records = [_read_record("/s3/f.txt", "etag-multipart-2")]
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-multipart-2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_warm_reapply_preserves_fingerprint(cache):
|
||||
"""A warm read re-applies cache-served bytes with no backend read
|
||||
record; the entry's backend fingerprint must survive, not be
|
||||
replaced by the MD5-of-content default."""
|
||||
cold = IOResult(reads={"/s3/f.txt": b"hello"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache,
|
||||
cold,
|
||||
records=[_read_record("/s3/f.txt", "etag-3")])
|
||||
warm = IOResult(reads={"/s3/f.txt": b"hello"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache, warm, records=[])
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_changed_data_without_record_resets_entry(cache):
|
||||
"""New bytes with no backend fingerprint still replace the entry."""
|
||||
cold = IOResult(reads={"/s3/f.txt": b"old"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache,
|
||||
cold,
|
||||
records=[_read_record("/s3/f.txt", "etag-3")])
|
||||
fresh = IOResult(writes={"/s3/f.txt": b"new"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache, fresh, records=[])
|
||||
assert await cache.get("/s3/f.txt") == b"new"
|
||||
assert not await cache.is_fresh("/s3/f.txt", "etag-3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_drain_uses_fingerprint_recorded_during_drain():
|
||||
"""Streaming backends set the record fingerprint lazily when the GET
|
||||
response arrives, i.e. during the background drain. The drained
|
||||
entry must pick it up."""
|
||||
cache = RAMFileCacheStore()
|
||||
records: list[OpRecord] = []
|
||||
|
||||
async def _gen():
|
||||
records.append(_read_record("/s3/f.txt", "etag-multipart-2"))
|
||||
yield b"hello"
|
||||
|
||||
stream = CachableAsyncIterator(_gen())
|
||||
io = IOResult(reads={"/s3/f.txt": stream}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/s3/f.txt") == b"hello"
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-multipart-2")
|
||||
|
||||
|
||||
# ── cache invalidation ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_write_without_cache_invalidates(cache):
|
||||
"""Write to a path NOT in cache list → invalidate (remove from cache)."""
|
||||
await cache.set("/f.txt", b"old")
|
||||
io = IOResult(writes={"/f.txt": b"new"})
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/f.txt") is None
|
||||
|
||||
|
||||
# ── edge cases ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_no_cache_no_data_skips(cache):
|
||||
"""Path in cache list but no data in reads/writes → skip, don't cache."""
|
||||
io = IOResult(cache=["/missing.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/missing.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_empty_io(cache):
|
||||
"""Empty IOResult → no-op."""
|
||||
io = IOResult()
|
||||
await cache_io.apply_io(cache, io)
|
||||
|
||||
|
||||
# ── background drain ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_stream(data: bytes) -> CachableAsyncIterator:
|
||||
|
||||
async def _gen():
|
||||
yield data
|
||||
|
||||
return CachableAsyncIterator(_gen())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_duplicate_drain():
|
||||
"""If a drain is already running for a path, a second apply_io
|
||||
should not start another drain. The first drain finishes and
|
||||
caches the data; the second stream is ignored."""
|
||||
cache = RAMFileCacheStore()
|
||||
stream1 = _make_stream(b"first")
|
||||
stream2 = _make_stream(b"second")
|
||||
io1 = IOResult(reads={"/f.txt": stream1}, cache=["/f.txt"])
|
||||
await cache_io.apply_io(cache, io1)
|
||||
assert "/f.txt" in cache._drain_tasks
|
||||
io2 = IOResult(reads={"/f.txt": stream2}, cache=["/f.txt"])
|
||||
await cache_io.apply_io(cache, io2)
|
||||
assert len([k for k in cache._drain_tasks if k == "/f.txt"]) == 1
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/f.txt") == b"first"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_drain_if_already_cached():
|
||||
"""If the path is already in cache, don't start a drain even if
|
||||
apply_io receives an unconsumed stream for it. Existing cached
|
||||
data is preserved."""
|
||||
cache = RAMFileCacheStore()
|
||||
await cache.set("/f.txt", b"cached")
|
||||
stream = _make_stream(b"new")
|
||||
io = IOResult(reads={"/f.txt": stream}, cache=["/f.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert "/f.txt" not in cache._drain_tasks
|
||||
assert await cache.get("/f.txt") == b"cached"
|
||||
|
||||
|
||||
# ── max_drain_bytes (cancellable cache drain) ───────────────────────────
|
||||
|
||||
|
||||
def _make_chunked_stream(chunks: list[bytes]) -> CachableAsyncIterator:
|
||||
|
||||
async def _gen():
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
return CachableAsyncIterator(_gen())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_unbounded_when_threshold_none():
|
||||
"""Default behavior: full drain into cache regardless of size."""
|
||||
cache = RAMFileCacheStore() # max_drain_bytes=None
|
||||
chunks = [b"a" * 100 for _ in range(10)] # 1000 bytes total
|
||||
stream = _make_chunked_stream(chunks)
|
||||
io = IOResult(reads={"/big.txt": stream}, cache=["/big.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
await asyncio.sleep(0.05)
|
||||
cached = await cache.get("/big.txt")
|
||||
assert cached is not None and len(cached) == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_completes_below_threshold():
|
||||
"""Source is smaller than threshold → full drain, cache populated."""
|
||||
cache = RAMFileCacheStore(max_drain_bytes=10000)
|
||||
chunks = [b"x" * 100 for _ in range(5)] # 500 bytes total
|
||||
stream = _make_chunked_stream(chunks)
|
||||
io = IOResult(reads={"/small.txt": stream}, cache=["/small.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
await asyncio.sleep(0.05)
|
||||
cached = await cache.get("/small.txt")
|
||||
assert cached is not None and len(cached) == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_cancelled_above_threshold():
|
||||
"""Source exceeds threshold → drain stops, partial buffer NOT cached."""
|
||||
cache = RAMFileCacheStore(max_drain_bytes=300)
|
||||
chunks = [b"z" * 100 for _ in range(20)] # 2000 bytes total
|
||||
stream = _make_chunked_stream(chunks)
|
||||
io = IOResult(reads={"/huge.txt": stream}, cache=["/huge.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/huge.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_threshold_per_task_not_shared():
|
||||
"""Each drain task has its own counter, not a shared workspace pool."""
|
||||
cache = RAMFileCacheStore(max_drain_bytes=300)
|
||||
s1 = _make_chunked_stream([b"a" * 100, b"a" * 100]) # 200 < 300
|
||||
s2 = _make_chunked_stream([b"b" * 100, b"b" * 100]) # 200 < 300
|
||||
io1 = IOResult(reads={"/a.txt": s1}, cache=["/a.txt"])
|
||||
io2 = IOResult(reads={"/b.txt": s2}, cache=["/b.txt"])
|
||||
await cache_io.apply_io(cache, io1)
|
||||
await cache_io.apply_io(cache, io2)
|
||||
await asyncio.sleep(0.05)
|
||||
# Both fit individually under the per-task budget → both cached.
|
||||
assert await cache.get("/a.txt") is not None
|
||||
assert await cache.get("/b.txt") is not None
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
# ========= 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 unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
|
||||
|
||||
class TestGetSet:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"hello")
|
||||
result = await cache.get("/a")
|
||||
assert result == b"hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_miss(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
assert await cache.get("/missing") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_overwrites(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"first")
|
||||
await cache.set("/a", b"second")
|
||||
assert await cache.get("/a") == b"second"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_fingerprint(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
entry = cache._entries.get("/a")
|
||||
assert entry is not None
|
||||
assert entry.fingerprint is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_fingerprint(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", fingerprint="etag-123")
|
||||
entry = cache._entries["/a"]
|
||||
assert entry.fingerprint == "etag-123"
|
||||
|
||||
|
||||
class TestAdd:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_new_key(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
result = await cache.add("/a", b"data")
|
||||
assert result is True
|
||||
assert await cache.get("/a") == b"data"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_existing_key(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"first")
|
||||
result = await cache.add("/a", b"second")
|
||||
assert result is False
|
||||
assert await cache.get("/a") == b"first"
|
||||
|
||||
|
||||
class TestMulti:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_get(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
results = await cache.multi_get(["/a", "/missing", "/b"])
|
||||
assert results[0] == b"aaa"
|
||||
assert results[1] is None
|
||||
assert results[2] == b"bbb"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_set(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.multi_set([("/a", b"aaa"), ("/b", b"bbb")])
|
||||
assert await cache.get("/a") == b"aaa"
|
||||
assert await cache.get("/b") == b"bbb"
|
||||
|
||||
|
||||
class TestExistsRemoveClear:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
assert await cache.exists("/a") is False
|
||||
await cache.set("/a", b"data")
|
||||
assert await cache.exists("/a") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
await cache.remove("/a")
|
||||
assert await cache.get("/a") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_missing(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.remove("/missing")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_cancels_drain_task(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
|
||||
cancelled = False
|
||||
|
||||
async def slow_drain():
|
||||
nonlocal cancelled
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
|
||||
task = asyncio.create_task(slow_drain())
|
||||
cache._drain_tasks["/a"] = task
|
||||
await cache.set("/a", b"data")
|
||||
await asyncio.sleep(0)
|
||||
await cache.remove("/a")
|
||||
await asyncio.sleep(0)
|
||||
assert cancelled
|
||||
assert "/a" not in cache._drain_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
await cache.clear()
|
||||
assert await cache.get("/a") is None
|
||||
assert await cache.get("/b") is None
|
||||
assert cache.cache_size == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_concurrent_with_set(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
|
||||
async def do_clear():
|
||||
await cache.clear()
|
||||
|
||||
async def do_set():
|
||||
await cache.set("/b", b"bbb")
|
||||
|
||||
await asyncio.gather(do_clear(), do_set())
|
||||
actual_size = sum(e.size for e in cache._entries.values())
|
||||
assert cache._cache_size == actual_size
|
||||
|
||||
|
||||
class TestTTL:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_not_expired(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", ttl=60)
|
||||
assert await cache.get("/a") == b"data"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_expired(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
with patch("mirage.cache.file.entry.time.time", return_value=1000.0):
|
||||
await cache.set("/a", b"data", ttl=10)
|
||||
with patch("mirage.cache.file.entry.time.time", return_value=1011.0):
|
||||
result = await cache.get("/a")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_ttl_never_expires(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
entry = cache._entries["/a"]
|
||||
assert entry.ttl is None
|
||||
assert entry.expired is False
|
||||
|
||||
|
||||
class TestFingerprint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_match(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", fingerprint="etag-1")
|
||||
assert await cache.is_fresh("/a", "etag-1") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_mismatch(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", fingerprint="etag-1")
|
||||
assert await cache.is_fresh("/a", "etag-2") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_missing(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
assert await cache.is_fresh("/missing", "etag-1") is False
|
||||
|
||||
|
||||
class TestEviction:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lru_evicts_oldest(self):
|
||||
cache = RAMFileCacheStore(cache_limit=100)
|
||||
await cache.set("/a", b"x" * 60)
|
||||
await cache.set("/b", b"y" * 60)
|
||||
assert await cache.get("/a") is None
|
||||
assert await cache.get("/b") == b"y" * 60
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lru_access_refreshes(self):
|
||||
cache = RAMFileCacheStore(cache_limit=150)
|
||||
await cache.set("/a", b"x" * 50)
|
||||
await cache.set("/b", b"y" * 50)
|
||||
await cache.get("/a")
|
||||
await cache.set("/c", b"z" * 60)
|
||||
assert await cache.get("/a") is not None
|
||||
assert await cache.get("/b") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_size_tracking(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"x" * 100)
|
||||
assert cache.cache_size == 100
|
||||
await cache.set("/b", b"y" * 200)
|
||||
assert cache.cache_size == 300
|
||||
await cache.remove("/a")
|
||||
assert cache.cache_size == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_limit(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1KB")
|
||||
assert cache.cache_limit == 1024
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eviction_does_not_corrupt_size(self):
|
||||
cache = RAMFileCacheStore(cache_limit=100)
|
||||
await cache.set("/a", b"x" * 60)
|
||||
|
||||
async def concurrent_set():
|
||||
await cache.set("/a", b"z" * 40)
|
||||
|
||||
async def trigger_eviction():
|
||||
await cache.set("/b", b"y" * 60)
|
||||
|
||||
await asyncio.gather(concurrent_set(), trigger_eviction())
|
||||
actual_size = sum(e.size for e in cache._entries.values())
|
||||
assert cache._cache_size == actual_size
|
||||
Vendored
+101
@@ -0,0 +1,101 @@
|
||||
# ========= 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 pytest
|
||||
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_stored_in_store_files():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/f.txt", b"hello")
|
||||
assert cache._store.files["/f.txt"] == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_stored_in_entries():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/f.txt", b"hello")
|
||||
entry = cache._entries["/f.txt"]
|
||||
assert entry.size == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_cleans_store():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/f.txt", b"data")
|
||||
assert "/f.txt" in cache._store.files
|
||||
await cache.remove("/f.txt")
|
||||
assert "/f.txt" not in cache._store.files
|
||||
assert "/f.txt" not in cache._entries
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_empties_store():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
await cache.clear()
|
||||
assert len(cache._store.files) == 0
|
||||
assert len(cache._entries) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_remove():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
await cache.remove("/a")
|
||||
assert "/a" not in cache._key_locks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_clear():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
await cache.clear()
|
||||
assert len(cache._key_locks) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_eviction():
|
||||
cache = RAMFileCacheStore(cache_limit=100)
|
||||
await cache.set("/a", b"x" * 60)
|
||||
await cache.set("/b", b"y" * 60)
|
||||
assert "/a" not in cache._key_locks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_task_cancelled_on_remove():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
|
||||
cancelled = False
|
||||
|
||||
async def slow():
|
||||
nonlocal cancelled
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
|
||||
task = asyncio.create_task(slow())
|
||||
cache._drain_tasks["/a"] = task
|
||||
await cache.set("/a", b"data")
|
||||
await asyncio.sleep(0)
|
||||
await cache.remove("/a")
|
||||
await asyncio.sleep(0)
|
||||
assert cancelled
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# ========= 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 os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from mirage.cache.file import io as cache_io
|
||||
from mirage.cache.file.redis import RedisFileCacheStore
|
||||
from mirage.io import CachableAsyncIterator, IOResult
|
||||
from mirage.observe.record import OpRecord
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
pytestmark = pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def cache():
|
||||
c = RedisFileCacheStore(
|
||||
cache_limit="1MB",
|
||||
url=REDIS_URL,
|
||||
key_prefix="test:cache:",
|
||||
)
|
||||
await c.clear()
|
||||
yield c
|
||||
await c.clear()
|
||||
await c._store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get(cache):
|
||||
await cache.set("/file.txt", b"hello")
|
||||
result = await cache.get("/file.txt")
|
||||
assert result == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing(cache):
|
||||
result = await cache.get("/nope")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove(cache):
|
||||
await cache.set("/file.txt", b"data")
|
||||
await cache.remove("/file.txt")
|
||||
assert await cache.get("/file.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists(cache):
|
||||
assert await cache.exists("/file.txt") is False
|
||||
await cache.set("/file.txt", b"data")
|
||||
assert await cache.exists("/file.txt") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh(cache):
|
||||
await cache.set("/file.txt", b"data", fingerprint="abc123")
|
||||
assert await cache.is_fresh("/file.txt", "abc123") is True
|
||||
assert await cache.is_fresh("/file.txt", "different") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_missing(cache):
|
||||
assert await cache.is_fresh("/nope", "abc") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(cache):
|
||||
await cache.set("/a.txt", b"a")
|
||||
await cache.set("/b.txt", b"b")
|
||||
await cache.clear()
|
||||
assert await cache.get("/a.txt") is None
|
||||
assert await cache.get("/b.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_new(cache):
|
||||
result = await cache.add("/file.txt", b"data")
|
||||
assert result is True
|
||||
assert await cache.get("/file.txt") == b"data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_existing(cache):
|
||||
await cache.set("/file.txt", b"first")
|
||||
result = await cache.add("/file.txt", b"second")
|
||||
assert result is False
|
||||
assert await cache.get("/file.txt") == b"first"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_with_fingerprint(cache):
|
||||
await cache.set("/file.txt", b"data", fingerprint="fp1")
|
||||
assert await cache.is_fresh("/file.txt", "fp1") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_limit(cache):
|
||||
assert cache.cache_limit == 1 * 1024 * 1024
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_prefix_isolation():
|
||||
c1 = RedisFileCacheStore(url=REDIS_URL, key_prefix="test:cache:ns1:")
|
||||
c2 = RedisFileCacheStore(url=REDIS_URL, key_prefix="test:cache:ns2:")
|
||||
await c1.clear()
|
||||
await c2.clear()
|
||||
await c1.set("/shared", b"from-c1")
|
||||
assert await c2.get("/shared") is None
|
||||
assert await c1.get("/shared") == b"from-c1"
|
||||
await c1.clear()
|
||||
await c2.clear()
|
||||
await c1._store.close()
|
||||
await c2._store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_drains_stream_into_cache(cache):
|
||||
"""An unexhausted stream must background-drain into the Redis cache
|
||||
like the RAM store does, carrying the record fingerprint."""
|
||||
|
||||
async def _gen():
|
||||
yield b"drained"
|
||||
|
||||
stream = CachableAsyncIterator(_gen())
|
||||
io = IOResult(reads={"/file.txt": stream}, cache=["/file.txt"])
|
||||
records = [
|
||||
OpRecord(op="read",
|
||||
path="/file.txt",
|
||||
source="s3",
|
||||
bytes=0,
|
||||
timestamp=0,
|
||||
duration_ms=0,
|
||||
fingerprint="etag-9")
|
||||
]
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
tasks = list(cache._drain_tasks.values())
|
||||
assert tasks, "drain task must be registered"
|
||||
await asyncio.gather(*tasks)
|
||||
assert await cache.get("/file.txt") == b"drained"
|
||||
assert await cache.is_fresh("/file.txt", "etag-9") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_cancels_pending_drain(cache):
|
||||
started = asyncio.Event()
|
||||
|
||||
async def _gen():
|
||||
started.set()
|
||||
await asyncio.sleep(1)
|
||||
yield b"slow"
|
||||
|
||||
stream = CachableAsyncIterator(_gen())
|
||||
io = IOResult(reads={"/slow.txt": stream}, cache=["/slow.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert "/slow.txt" in cache._drain_tasks
|
||||
await started.wait()
|
||||
await cache.remove("/slow.txt")
|
||||
assert "/slow.txt" not in cache._drain_tasks
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/slow.txt") is None
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# ========= 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.utils import default_fingerprint, parse_limit
|
||||
|
||||
|
||||
def test_parse_limit_bytes():
|
||||
assert parse_limit(1024) == 1024
|
||||
|
||||
|
||||
def test_parse_limit_kb():
|
||||
assert parse_limit("1KB") == 1024
|
||||
|
||||
|
||||
def test_parse_limit_mb():
|
||||
assert parse_limit("512MB") == 536870912
|
||||
|
||||
|
||||
def test_parse_limit_gb():
|
||||
assert parse_limit("1GB") == 1073741824
|
||||
|
||||
|
||||
def test_parse_limit_lowercase():
|
||||
assert parse_limit("10mb") == 10485760
|
||||
|
||||
|
||||
def test_parse_limit_plain_string():
|
||||
assert parse_limit("1024") == 1024
|
||||
|
||||
|
||||
def test_default_fingerprint():
|
||||
fp = default_fingerprint(b"hello")
|
||||
assert isinstance(fp, str)
|
||||
assert len(fp) == 32
|
||||
|
||||
|
||||
def test_default_fingerprint_deterministic():
|
||||
assert default_fingerprint(b"same") == default_fingerprint(b"same")
|
||||
|
||||
|
||||
def test_default_fingerprint_different_data():
|
||||
assert default_fingerprint(b"a") != default_fingerprint(b"b")
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# ========= 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. =========
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# ========= 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 import (IndexConfig, IndexEntry, ListResult,
|
||||
LookupResult, LookupStatus, RedisIndexConfig,
|
||||
ResourceType)
|
||||
from mirage.types import IndexType
|
||||
|
||||
|
||||
def test_index_entry_defaults():
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
assert entry.remote_time == ""
|
||||
assert entry.index_time == ""
|
||||
assert entry.vfs_name == ""
|
||||
assert entry.size is None
|
||||
|
||||
|
||||
def test_index_entry_with_size():
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file", size=1024)
|
||||
assert entry.size == 1024
|
||||
|
||||
|
||||
def test_lookup_result_not_found():
|
||||
result = LookupResult(status=LookupStatus.NOT_FOUND)
|
||||
assert result.entry is None
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
def test_lookup_result_with_entry():
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
result = LookupResult(entry=entry)
|
||||
assert result.entry is not None
|
||||
assert result.status is None
|
||||
|
||||
|
||||
def test_list_result_with_entries():
|
||||
result = ListResult(entries=["/a", "/b"])
|
||||
assert result.entries == ["/a", "/b"]
|
||||
assert result.status is None
|
||||
|
||||
|
||||
def test_list_result_expired():
|
||||
result = ListResult(status=LookupStatus.EXPIRED)
|
||||
assert result.entries is None
|
||||
|
||||
|
||||
def test_resource_type_enum():
|
||||
assert ResourceType.FILE == "file"
|
||||
assert ResourceType.FOLDER == "folder"
|
||||
|
||||
|
||||
def test_index_config_defaults():
|
||||
config = IndexConfig()
|
||||
assert config.ttl == 600
|
||||
assert config.type == IndexType.RAM
|
||||
|
||||
|
||||
def test_redis_index_config():
|
||||
config = RedisIndexConfig(ttl=300, key_prefix="s3:")
|
||||
assert config.type == IndexType.REDIS
|
||||
assert config.ttl == 300
|
||||
assert config.key_prefix == "s3:"
|
||||
assert config.url == "redis://localhost:6379/0"
|
||||
|
||||
|
||||
def test_redis_index_config_is_index_config():
|
||||
config = RedisIndexConfig()
|
||||
assert isinstance(config, IndexConfig)
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
# ========= 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
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return RAMIndexCacheStore(ttl=60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entries_stored_in_dict(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.put("/f.txt", entry)
|
||||
assert "/f.txt" in store._entries
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_children_stored_in_dict(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
assert "/dir" in store._children
|
||||
assert store._children["/dir"] == ["/dir/f.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expiry_stored_in_dict(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
assert "/dir" in store._expiry
|
||||
assert isinstance(store._expiry["/dir"], datetime)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_dir_evicts_child_entries(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
assert (await store.get("/dir/f.txt")).entry is not None
|
||||
await store.invalidate_dir("/dir")
|
||||
assert (await store.get("/dir/f.txt")).entry is None
|
||||
assert "/dir" not in store._children
|
||||
assert "/dir" not in store._expiry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_empties_all_dicts(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.put("/f.txt", entry)
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
await store.clear()
|
||||
assert len(store._entries) == 0
|
||||
assert len(store._children) == 0
|
||||
assert len(store._expiry) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_clear(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.put("/a", entry)
|
||||
await store.put("/b", entry)
|
||||
await store.clear()
|
||||
assert len(store._key_locks) == 0
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# ========= 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 pytest
|
||||
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_preserves_insertion_order():
|
||||
store = RAMIndexCacheStore(ttl=60)
|
||||
entries = [
|
||||
("2026-05-03_zebra__id3.gdoc.json",
|
||||
IndexEntry(id="3", name="zebra", resource_type="gdocs/file")),
|
||||
("2026-05-02_apple__id2.gdoc.json",
|
||||
IndexEntry(id="2", name="apple", resource_type="gdocs/file")),
|
||||
("2026-05-01_mango__id1.gdoc.json",
|
||||
IndexEntry(id="1", name="mango", resource_type="gdocs/file")),
|
||||
]
|
||||
await store.set_dir("/gdocs/owned", entries)
|
||||
result = await store.list_dir("/gdocs/owned")
|
||||
assert result.entries == [
|
||||
"/gdocs/owned/2026-05-03_zebra__id3.gdoc.json",
|
||||
"/gdocs/owned/2026-05-02_apple__id2.gdoc.json",
|
||||
"/gdocs/owned/2026-05-01_mango__id1.gdoc.json",
|
||||
]
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
# ========= 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 os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from mirage.cache.index import IndexEntry, LookupStatus
|
||||
from mirage.cache.index.redis import RedisIndexCacheStore
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
pytestmark = pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def store():
|
||||
s = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="test:")
|
||||
await s.clear()
|
||||
yield s
|
||||
await s.clear()
|
||||
await s.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
def entry():
|
||||
return IndexEntry(
|
||||
id="file1",
|
||||
name="Test File",
|
||||
resource_type="text/plain",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="test_file.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_and_get(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.id == "file1"
|
||||
assert result.entry.name == "Test File"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_sets_index_time(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.index_time != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_preserves_existing_index_time(store):
|
||||
entry = IndexEntry(id="b",
|
||||
name="b.txt",
|
||||
resource_type="file",
|
||||
index_time="2026-01-01T00:00:00")
|
||||
await store.put("/data/b.txt", entry)
|
||||
result = await store.get("/data/b.txt")
|
||||
assert result.entry.index_time == "2026-01-01T00:00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_not_found(store):
|
||||
result = await store.get("/nonexistent/file.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
assert result.entry is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_not_found(store):
|
||||
result = await store.list_dir("/some_dir")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_and_list(store):
|
||||
entries = [
|
||||
("a.txt",
|
||||
IndexEntry(id="a",
|
||||
name="A",
|
||||
resource_type="text/plain",
|
||||
vfs_name="a.txt")),
|
||||
("b.txt",
|
||||
IndexEntry(id="b",
|
||||
name="B",
|
||||
resource_type="text/plain",
|
||||
vfs_name="b.txt")),
|
||||
]
|
||||
await store.set_dir("/mydir", entries)
|
||||
result = await store.list_dir("/mydir")
|
||||
assert result.status is None
|
||||
assert sorted(result.entries) == ["/mydir/a.txt", "/mydir/b.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_root(store):
|
||||
entries = [
|
||||
("top.txt",
|
||||
IndexEntry(id="t",
|
||||
name="T",
|
||||
resource_type="text/plain",
|
||||
vfs_name="top.txt")),
|
||||
]
|
||||
await store.set_dir("/", entries)
|
||||
result = await store.list_dir("/")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/top.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_entries_are_retrievable(store):
|
||||
entries = [("x.csv", IndexEntry(id="x1",
|
||||
name="x.csv",
|
||||
resource_type="file"))]
|
||||
await store.set_dir("/root", entries)
|
||||
result = await store.get("/root/x.csv")
|
||||
assert result.entry is not None
|
||||
assert result.entry.id == "x1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_expired(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt")),
|
||||
]
|
||||
past = datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
await store.set_dir("/dir", entries, expired_at=past)
|
||||
time.sleep(1.5)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status in (LookupStatus.NOT_FOUND, LookupStatus.EXPIRED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_fresh(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt")),
|
||||
]
|
||||
future = datetime.now(timezone.utc) + timedelta(seconds=3600)
|
||||
await store.set_dir("/dir", entries, expired_at=future)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/dir/file.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_overwrites_previous(store):
|
||||
await store.set_dir("/d", [
|
||||
("old.txt", IndexEntry(id="o", name="old.txt", resource_type="file"))
|
||||
])
|
||||
await store.set_dir("/d", [
|
||||
("new.txt", IndexEntry(id="n", name="new.txt", resource_type="file"))
|
||||
])
|
||||
result = await store.list_dir("/d")
|
||||
assert result.entries is not None
|
||||
assert len(result.entries) == 1
|
||||
assert "new.txt" in result.entries[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_dir_evicts_child_entries(store, entry):
|
||||
await store.set_dir("/folder", [("test.txt", entry)])
|
||||
assert (await store.get("/folder/test.txt")).entry is not None
|
||||
await store.invalidate_dir("/folder")
|
||||
assert (await
|
||||
store.get("/folder/test.txt")).status == LookupStatus.NOT_FOUND
|
||||
assert (await store.list_dir("/folder")).status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(store, entry):
|
||||
await store.put("/folder/test.txt", entry)
|
||||
await store.set_dir("/folder", [("test.txt", entry)])
|
||||
await store.clear()
|
||||
result = await store.get("/folder/test.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
result = await store.list_dir("/folder")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_prefix_isolation():
|
||||
s1 = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="ns1:")
|
||||
s2 = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="ns2:")
|
||||
await s1.clear()
|
||||
await s2.clear()
|
||||
await s1.put("/shared", IndexEntry(id="a", name="a", resource_type="file"))
|
||||
assert (await s2.get("/shared")).status == LookupStatus.NOT_FOUND
|
||||
assert (await s1.get("/shared")).entry is not None
|
||||
await s1.clear()
|
||||
await s2.clear()
|
||||
await s1.close()
|
||||
await s2.close()
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# ========= 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 os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.redis import RedisIndexCacheStore
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
pytestmark = pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def store():
|
||||
s = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="test:order:")
|
||||
await s.clear()
|
||||
yield s
|
||||
await s.clear()
|
||||
await s.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_preserves_insertion_order(store):
|
||||
entries = [
|
||||
("2026-05-03_zebra__id3.gdoc.json",
|
||||
IndexEntry(id="3", name="zebra", resource_type="gdocs/file")),
|
||||
("2026-05-02_apple__id2.gdoc.json",
|
||||
IndexEntry(id="2", name="apple", resource_type="gdocs/file")),
|
||||
("2026-05-01_mango__id1.gdoc.json",
|
||||
IndexEntry(id="1", name="mango", resource_type="gdocs/file")),
|
||||
]
|
||||
await store.set_dir("/gdocs/owned", entries)
|
||||
result = await store.list_dir("/gdocs/owned")
|
||||
assert result.entries == [
|
||||
"/gdocs/owned/2026-05-03_zebra__id3.gdoc.json",
|
||||
"/gdocs/owned/2026-05-02_apple__id2.gdoc.json",
|
||||
"/gdocs/owned/2026-05-01_mango__id1.gdoc.json",
|
||||
]
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# ========= 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
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry, LookupStatus, RAMIndexCacheStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return RAMIndexCacheStore(ttl=60)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entry():
|
||||
return IndexEntry(
|
||||
id="file1",
|
||||
name="Test File",
|
||||
resource_type="text/plain",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="test_file.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_and_get(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.id == "file1"
|
||||
assert result.entry.name == "Test File"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_sets_index_time(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.index_time != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_not_found(store):
|
||||
result = await store.get("/nonexistent/file.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
assert result.entry is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_not_found(store):
|
||||
result = await store.list_dir("/some_dir")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_and_list(store):
|
||||
entries = [
|
||||
("a.txt",
|
||||
IndexEntry(
|
||||
id="a",
|
||||
name="A",
|
||||
resource_type="text/plain",
|
||||
vfs_name="a.txt",
|
||||
)),
|
||||
("b.txt",
|
||||
IndexEntry(
|
||||
id="b",
|
||||
name="B",
|
||||
resource_type="text/plain",
|
||||
vfs_name="b.txt",
|
||||
)),
|
||||
]
|
||||
await store.set_dir("/mydir", entries)
|
||||
result = await store.list_dir("/mydir")
|
||||
assert result.status is None
|
||||
assert sorted(result.entries) == ["/mydir/a.txt", "/mydir/b.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_root(store):
|
||||
entries = [
|
||||
("top.txt",
|
||||
IndexEntry(
|
||||
id="t",
|
||||
name="T",
|
||||
resource_type="text/plain",
|
||||
vfs_name="top.txt",
|
||||
)),
|
||||
]
|
||||
await store.set_dir("/", entries)
|
||||
result = await store.list_dir("/")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/top.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_expired(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(
|
||||
id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt",
|
||||
)),
|
||||
]
|
||||
past = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
await store.set_dir("/dir", entries, expired_at=past)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status == LookupStatus.EXPIRED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_fresh(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(
|
||||
id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt",
|
||||
)),
|
||||
]
|
||||
future = datetime.now(timezone.utc) + timedelta(seconds=3600)
|
||||
await store.set_dir("/dir", entries, expired_at=future)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/dir/file.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(store, entry):
|
||||
await store.put("/folder/test.txt", entry)
|
||||
await store.set_dir("/folder", [("test.txt", entry)])
|
||||
|
||||
await store.clear()
|
||||
result = await store.get("/folder/test.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
result = await store.list_dir("/folder")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
# ========= 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 mirage.cache.context import (active_cache_manager,
|
||||
invalidate_after_unlink,
|
||||
invalidate_after_write, push_cache_manager)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class FakeManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.writes: list[str] = []
|
||||
self.unlinks: list[str] = []
|
||||
|
||||
async def invalidate_after_write(self, path: str) -> None:
|
||||
self.writes.append(path)
|
||||
|
||||
async def invalidate_after_unlink(self, path: str) -> None:
|
||||
self.unlinks.append(path)
|
||||
|
||||
|
||||
async def _delegates() -> FakeManager:
|
||||
manager = FakeManager()
|
||||
prev = push_cache_manager(manager)
|
||||
await invalidate_after_write("/a.txt")
|
||||
await invalidate_after_unlink("/b.txt")
|
||||
push_cache_manager(prev)
|
||||
return manager
|
||||
|
||||
|
||||
def test_delegates_to_active_manager():
|
||||
manager = _run(_delegates())
|
||||
assert manager.writes == ["/a.txt"]
|
||||
assert manager.unlinks == ["/b.txt"]
|
||||
|
||||
|
||||
async def _noop_without_manager() -> None:
|
||||
push_cache_manager(None)
|
||||
await invalidate_after_write("/a.txt")
|
||||
await invalidate_after_unlink("/b.txt")
|
||||
|
||||
|
||||
def test_noop_without_active_manager():
|
||||
_run(_noop_without_manager())
|
||||
|
||||
|
||||
async def _push_restores() -> tuple[object, object, object]:
|
||||
first = FakeManager()
|
||||
second = FakeManager()
|
||||
prev0 = push_cache_manager(first)
|
||||
prev1 = push_cache_manager(second)
|
||||
active = active_cache_manager()
|
||||
push_cache_manager(prev1)
|
||||
restored = active_cache_manager()
|
||||
push_cache_manager(prev0)
|
||||
return prev1, active, restored
|
||||
|
||||
|
||||
def test_push_returns_previous_manager():
|
||||
prev1, active, restored = _run(_push_restores())
|
||||
assert prev1 is not None
|
||||
assert active is not restored
|
||||
assert restored is prev1
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# ========= 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 IndexEntry
|
||||
|
||||
|
||||
def test_index_entry_extra_defaults_to_empty_dict():
|
||||
entry = IndexEntry(id="x", name="x", resource_type="t")
|
||||
assert entry.extra == {}
|
||||
|
||||
|
||||
def test_index_entry_extra_round_trip():
|
||||
entry = IndexEntry(
|
||||
id="F1",
|
||||
name="report",
|
||||
resource_type="slack/file",
|
||||
extra={
|
||||
"url": "https://files.slack.com/x",
|
||||
"mimetype": "application/pdf"
|
||||
},
|
||||
)
|
||||
assert entry.extra == {
|
||||
"url": "https://files.slack.com/x",
|
||||
"mimetype": "application/pdf",
|
||||
}
|
||||
|
||||
|
||||
def test_index_entry_extra_survives_json_round_trip():
|
||||
entry = IndexEntry(
|
||||
id="F1",
|
||||
name="report",
|
||||
resource_type="slack/file",
|
||||
extra={"url": "u"},
|
||||
)
|
||||
restored = IndexEntry.model_validate_json(entry.model_dump_json())
|
||||
assert restored.extra == {"url": "u"}
|
||||
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. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.lock import KeyLockMixin
|
||||
|
||||
|
||||
class _Store(KeyLockMixin):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_keys_return_different_locks():
|
||||
store = _Store()
|
||||
assert store._lock_for("/a") is not store._lock_for("/b")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_key_returns_same_lock():
|
||||
store = _Store()
|
||||
assert store._lock_for("/a") is store._lock_for("/a")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_lock_removes_key():
|
||||
store = _Store()
|
||||
store._lock_for("/a")
|
||||
store._discard_lock("/a")
|
||||
assert "/a" not in store._key_locks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_lock_missing_key_no_error():
|
||||
store = _Store()
|
||||
store._discard_lock("/nope")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_locks_removes_all():
|
||||
store = _Store()
|
||||
store._lock_for("/a")
|
||||
store._lock_for("/b")
|
||||
store._clear_locks()
|
||||
assert len(store._key_locks) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_different_keys_no_block():
|
||||
store = _Store()
|
||||
order = []
|
||||
|
||||
async def acquire(key, label):
|
||||
async with store._lock_for(key):
|
||||
order.append(f"{label}_start")
|
||||
await asyncio.sleep(0)
|
||||
order.append(f"{label}_end")
|
||||
|
||||
await asyncio.gather(acquire("/a", "a"), acquire("/b", "b"))
|
||||
assert "a_start" in order
|
||||
assert "b_start" in order
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_key_serialized():
|
||||
store = _Store()
|
||||
order = []
|
||||
|
||||
async def acquire(label):
|
||||
async with store._lock_for("/same"):
|
||||
order.append(f"{label}_start")
|
||||
await asyncio.sleep(0.01)
|
||||
order.append(f"{label}_end")
|
||||
|
||||
await asyncio.gather(acquire("first"), acquire("second"))
|
||||
assert order[0] == "first_start"
|
||||
assert order[1] == "first_end"
|
||||
assert order[2] == "second_start"
|
||||
assert order[3] == "second_end"
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
# ========= 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 mirage.cache.file.ram import RAMFileCacheStore
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.cache.manager import CacheManager
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _stores() -> tuple[RAMFileCacheStore, RAMIndexCacheStore]:
|
||||
return RAMFileCacheStore(), RAMIndexCacheStore(ttl=600)
|
||||
|
||||
|
||||
async def _seed(cache: RAMFileCacheStore, index: RAMIndexCacheStore) -> None:
|
||||
await cache.set("/data/arch/h.txt", b"two\n")
|
||||
await index.set_dir("/data/arch", [
|
||||
("h.txt", IndexEntry(id="h", name="h.txt", resource_type="file")),
|
||||
])
|
||||
|
||||
|
||||
async def _write_case() -> tuple[bool, bool]:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
await manager.invalidate_after_write("/arch/h.txt")
|
||||
cached = await cache.exists("/data/arch/h.txt")
|
||||
listing = await index.list_dir("/data/arch")
|
||||
return cached, listing.entries is not None
|
||||
|
||||
|
||||
def test_write_evicts_file_and_parent_listing():
|
||||
cached, listed = _run(_write_case())
|
||||
assert cached is False
|
||||
assert listed is False
|
||||
|
||||
|
||||
async def _unlink_case() -> tuple[bool, bool, object]:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
await manager.invalidate_after_unlink("/arch/h.txt")
|
||||
cached = await cache.exists("/data/arch/h.txt")
|
||||
listing = await index.list_dir("/data/arch")
|
||||
entry = await index.get("/data/arch/h.txt")
|
||||
return cached, listing.entries is not None, entry.entry
|
||||
|
||||
|
||||
def test_unlink_evicts_file_listing_and_entry():
|
||||
cached, listed, entry = _run(_unlink_case())
|
||||
assert cached is False
|
||||
assert listed is False
|
||||
assert entry is None
|
||||
|
||||
|
||||
async def _local_case() -> tuple[bool, bool]:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", False)
|
||||
await manager.invalidate_after_write("/arch/h.txt")
|
||||
cached = await cache.exists("/data/arch/h.txt")
|
||||
listing = await index.list_dir("/data/arch")
|
||||
return cached, listing.entries is not None
|
||||
|
||||
|
||||
def test_local_mount_keeps_file_cache_but_invalidates_index():
|
||||
cached, listed = _run(_local_case())
|
||||
assert cached is True
|
||||
assert listed is False
|
||||
|
||||
|
||||
async def _pathspec_case() -> bool:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
spec = PathSpec(resource_path=mount_key("/data/arch/h.txt", "/data/"),
|
||||
virtual="/data/arch/h.txt",
|
||||
directory="/data/arch")
|
||||
await manager.invalidate_after_write(spec)
|
||||
return await cache.exists("/data/arch/h.txt")
|
||||
|
||||
|
||||
def test_pathspec_input_maps_to_virtual_key():
|
||||
assert _run(_pathspec_case()) is False
|
||||
|
||||
|
||||
async def _cached_hit_case() -> bytes | None:
|
||||
cache, index = _stores()
|
||||
await cache.set("/data/x.txt", b"cached")
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
spec = PathSpec(resource_path=mount_key("/data/x.txt", "/data/"),
|
||||
virtual="/data/x.txt",
|
||||
directory="/data/")
|
||||
return await manager.cached_bytes(spec)
|
||||
|
||||
|
||||
def test_cached_bytes_returns_cached_value():
|
||||
assert _run(_cached_hit_case()) == b"cached"
|
||||
|
||||
|
||||
async def _cached_miss_case() -> bytes | None:
|
||||
cache, index = _stores()
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
spec = PathSpec(resource_path=mount_key("/data/x.txt", "/data/"),
|
||||
virtual="/data/x.txt",
|
||||
directory="/data/")
|
||||
return await manager.cached_bytes(spec)
|
||||
|
||||
|
||||
def test_cached_bytes_miss_returns_none():
|
||||
assert _run(_cached_miss_case()) is None
|
||||
|
||||
|
||||
async def _cached_local_case() -> bytes | None:
|
||||
cache, index = _stores()
|
||||
await cache.set("/data/x.txt", b"cached")
|
||||
manager = CacheManager(cache, index, "/data/", False)
|
||||
spec = PathSpec(resource_path=mount_key("/data/x.txt", "/data/"),
|
||||
virtual="/data/x.txt",
|
||||
directory="/data/")
|
||||
return await manager.cached_bytes(spec)
|
||||
|
||||
|
||||
def test_cached_bytes_local_mount_returns_none():
|
||||
assert _run(_cached_local_case()) is None
|
||||
|
||||
|
||||
async def _no_index_case() -> bool:
|
||||
cache, _ = _stores()
|
||||
await cache.set("/data/a.txt", b"x")
|
||||
manager = CacheManager(cache, None, "/data/", True)
|
||||
await manager.invalidate_after_write("/a.txt")
|
||||
return await cache.exists("/data/a.txt")
|
||||
|
||||
|
||||
def test_missing_index_is_tolerated():
|
||||
assert _run(_no_index_case()) is False
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# ========= 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 pytest
|
||||
|
||||
from mirage.cache.context import push_cache_manager
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
from mirage.cache.manager import CacheManager
|
||||
from mirage.cache.read_through import (cache_aware_read_bytes,
|
||||
cache_aware_read_stream,
|
||||
cached_prefix_bytes)
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
class _CountingBackend:
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self.data = data
|
||||
self.stream_calls = 0
|
||||
self.bytes_calls = 0
|
||||
|
||||
async def read_stream(self, accessor, path, *args, **kwargs):
|
||||
self.stream_calls += 1
|
||||
yield self.data
|
||||
|
||||
async def read_bytes(self, accessor, path, *args, **kwargs) -> bytes:
|
||||
self.bytes_calls += 1
|
||||
return self.data
|
||||
|
||||
|
||||
def _spec() -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key("/s3/a.txt", "/s3/"),
|
||||
virtual="/s3/a.txt",
|
||||
directory="/s3/")
|
||||
|
||||
|
||||
async def _warm_manager(data: bytes) -> CacheManager:
|
||||
cache = RAMFileCacheStore()
|
||||
await cache.set("/s3/a.txt", data)
|
||||
return CacheManager(cache, None, "/s3/", True)
|
||||
|
||||
|
||||
async def _drain(source) -> bytes:
|
||||
return b"".join([c async for c in source])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_warm_serves_cache_without_backend():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = await _warm_manager(b"payload")
|
||||
reader = cache_aware_read_bytes(backend.read_bytes)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await reader(None, _spec())
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.bytes_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_cold_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = CacheManager(RAMFileCacheStore(), None, "/s3/", True)
|
||||
reader = cache_aware_read_bytes(backend.read_bytes)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await reader(None, _spec())
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.bytes_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_no_manager_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
reader = cache_aware_read_bytes(backend.read_bytes)
|
||||
out = await reader(None, _spec())
|
||||
assert out == b"payload"
|
||||
assert backend.bytes_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_warm_serves_cache_without_backend():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = await _warm_manager(b"payload")
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await _drain(reader(None, _spec()))
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_cold_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = CacheManager(RAMFileCacheStore(), None, "/s3/", True)
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await _drain(reader(None, _spec()))
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_no_manager_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
out = await _drain(reader(None, _spec()))
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_captures_manager_before_drain():
|
||||
# The manager must be captured when the reader is called (inside the
|
||||
# mount's scope), not when the stream drains (after the scope is gone).
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = await _warm_manager(b"payload")
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
prev = push_cache_manager(manager)
|
||||
source = reader(None, _spec())
|
||||
push_cache_manager(prev)
|
||||
out = await _drain(source)
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_prefix_bytes_slices_when_warm():
|
||||
manager = await _warm_manager(b"payload")
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await cached_prefix_bytes(_spec(), 4)
|
||||
whole = await cached_prefix_bytes(_spec(), None)
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payl"
|
||||
assert whole == b"payload"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_prefix_bytes_miss_and_no_manager_return_none():
|
||||
miss_manager = CacheManager(RAMFileCacheStore(), None, "/s3/", True)
|
||||
prev = push_cache_manager(miss_manager)
|
||||
try:
|
||||
assert await cached_prefix_bytes(_spec(), 4) is None
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert await cached_prefix_bytes(_spec(), 4) is None
|
||||
Reference in New Issue
Block a user