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