feat(clients): LRU mesh cache for Open3DModel recipes (etag-validated)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
16ffe6ac20
commit
684bd2de5e
2 files changed
+146
No files matched your search
@@ -0,0 +1,45 @@
|
||||
"""In-memory LRU cache of parsed STL meshes, keyed by recipe_id + etag."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..perception.stl_sampler import parse_stl_bytes
|
||||
from .open3dmodel import Open3DModelClient
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecipeMesh:
|
||||
vertices: np.ndarray # (M, 3) float64, mm in model frame
|
||||
triangles: np.ndarray # (T, 3) int32
|
||||
etag: str
|
||||
|
||||
|
||||
class RecipeMeshCache:
|
||||
"""LRU keyed by recipe_id. On hit, validates the etag against the client
|
||||
and re-fetches if it changed. Singleton-friendly: thread-safe with a
|
||||
simple lock is NOT implemented (presa-mano is single-process; if
|
||||
concurrency becomes a need, wrap the OrderedDict ops in a lock)."""
|
||||
|
||||
def __init__(self, client: Open3DModelClient, capacity: int = 8):
|
||||
self._client = client
|
||||
self._capacity = max(1, int(capacity))
|
||||
self._store: OrderedDict[str, RecipeMesh] = OrderedDict()
|
||||
|
||||
def get(self, recipe_id: str, client: Open3DModelClient | None = None
|
||||
) -> RecipeMesh:
|
||||
c = client or self._client
|
||||
body, etag = c.get_stl_bytes(recipe_id)
|
||||
cached = self._store.get(recipe_id)
|
||||
if cached is not None and cached.etag == etag:
|
||||
self._store.move_to_end(recipe_id)
|
||||
return cached
|
||||
verts, tris = parse_stl_bytes(body)
|
||||
mesh = RecipeMesh(vertices=verts, triangles=tris, etag=etag)
|
||||
self._store[recipe_id] = mesh
|
||||
self._store.move_to_end(recipe_id)
|
||||
while len(self._store) > self._capacity:
|
||||
self._store.popitem(last=False)
|
||||
return mesh
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Unit tests for RecipeMeshCache."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from presa_mano.clients.open3dmodel_cache import RecipeMesh, RecipeMeshCache
|
||||
|
||||
|
||||
def _verts_tris():
|
||||
verts = np.array([[0.,0.,0.],[1.,0.,0.],[0.,1.,0.]])
|
||||
tris = np.array([[0,1,2]], dtype=np.int32)
|
||||
return verts, tris
|
||||
|
||||
|
||||
def _mock_client(stl_bytes_by_id: dict[str, tuple[bytes, str]]):
|
||||
"""Mock client whose get_stl_bytes returns the registered (bytes, etag)
|
||||
for each recipe_id, and counts calls."""
|
||||
m = MagicMock()
|
||||
m._call_count = {}
|
||||
def _get(recipe_id: str):
|
||||
m._call_count[recipe_id] = m._call_count.get(recipe_id, 0) + 1
|
||||
return stl_bytes_by_id[recipe_id]
|
||||
m.get_stl_bytes.side_effect = _get
|
||||
return m
|
||||
|
||||
|
||||
def test_cache_miss_then_hit(monkeypatch):
|
||||
verts, tris = _verts_tris()
|
||||
monkeypatch.setattr(
|
||||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||||
lambda b: (verts, tris),
|
||||
)
|
||||
client = _mock_client({"r1": (b"STL1", "etag-A")})
|
||||
cache = RecipeMeshCache(client=client, capacity=4)
|
||||
|
||||
m1 = cache.get("r1")
|
||||
assert isinstance(m1, RecipeMesh)
|
||||
np.testing.assert_array_equal(m1.vertices, verts)
|
||||
np.testing.assert_array_equal(m1.triangles, tris)
|
||||
assert m1.etag == "etag-A"
|
||||
assert client._call_count["r1"] == 1
|
||||
|
||||
# secondo accesso: la cache valida l'etag (refetch dei bytes, sempre)
|
||||
# ma evita il parse -> identity preserved sull'oggetto RecipeMesh.
|
||||
m2 = cache.get("r1")
|
||||
assert m2 is m1
|
||||
assert client._call_count["r1"] == 2
|
||||
|
||||
|
||||
def test_cache_etag_change_refetches(monkeypatch):
|
||||
verts, tris = _verts_tris()
|
||||
monkeypatch.setattr(
|
||||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||||
lambda b: (verts, tris),
|
||||
)
|
||||
client = MagicMock()
|
||||
seq = iter([(b"STL_OLD", "etag-A"), (b"STL_NEW", "etag-B")])
|
||||
client.get_stl_bytes.side_effect = lambda rid: next(seq)
|
||||
cache = RecipeMeshCache(client=client, capacity=4)
|
||||
cache.get("r1")
|
||||
m2 = cache.get("r1")
|
||||
assert m2.etag == "etag-B"
|
||||
assert client.get_stl_bytes.call_count == 2
|
||||
|
||||
|
||||
def test_cache_lru_evicts_oldest(monkeypatch):
|
||||
verts, tris = _verts_tris()
|
||||
monkeypatch.setattr(
|
||||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||||
lambda b: (verts, tris),
|
||||
)
|
||||
client = _mock_client({
|
||||
f"r{i}": (f"S{i}".encode(), f"e{i}") for i in range(10)
|
||||
})
|
||||
cache = RecipeMeshCache(client=client, capacity=3)
|
||||
cache.get("r1"); cache.get("r2"); cache.get("r3")
|
||||
# r1 e' il piu' vecchio
|
||||
cache.get("r4") # evict r1
|
||||
cache.get("r1") # miss -> re-fetch (call_count va a 2)
|
||||
assert client._call_count["r1"] == 2
|
||||
|
||||
|
||||
def test_cache_get_uses_overrideable_client(monkeypatch):
|
||||
"""`get(recipe_id, client=other)` permette di passare un client fresco
|
||||
(utile quando PUT /api/config cambia base_url)."""
|
||||
verts, tris = _verts_tris()
|
||||
monkeypatch.setattr(
|
||||
"presa_mano.clients.open3dmodel_cache.parse_stl_bytes",
|
||||
lambda b: (verts, tris),
|
||||
)
|
||||
default_client = _mock_client({"r1": (b"S1", "e1")})
|
||||
override_client = _mock_client({"r1": (b"S2", "e2")})
|
||||
cache = RecipeMeshCache(client=default_client, capacity=2)
|
||||
|
||||
m = cache.get("r1", client=override_client)
|
||||
assert m.etag == "e2"
|
||||
assert default_client._call_count.get("r1", 0) == 0
|
||||
assert override_client._call_count["r1"] == 1
|
||||
Reference in new issue
Block a user