diff --git a/CHANGELOG.md b/CHANGELOG.md index 95bf52a..ad7d7d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-03-29 + +### Added + +- In-memory TTL cache for image metadata lookups (`get_by_id`), avoiding redundant + database hits on repeated reads. Implemented as a `CachedImageRepository` decorator + wrapping the existing `PostgresImageRepository`. +- New `IMG_CACHE_TTL_SECONDS` (default 60) and `IMG_CACHE_MAX_SIZE` (default 1024) + configuration settings for cache tuning. + +### Changed + +- Health endpoint (`/health`) now reads version from `importlib.metadata` instead of + hardcoding `"1.0.0"`, and verifies database connectivity (`SELECT 1`) and storage + directory existence. Response includes a `checks` map with per-component status; + overall status reports `"degraded"` if any check fails. +- `list_images()` and `get_expired()` now use server-side cursors + (`session.stream_scalars`) instead of buffered `execute` + `all()`, reducing peak + memory usage for large result sets. +- C++ `bilinear_resize` uses SSE2 SIMD intrinsics on x86-64 to interpolate all + channels per pixel in parallel, with a scalar fallback for other architectures. + Arithmetic switched from `double` to `float` for better vectorization throughput. +- C++ `bilinear_resize` now accepts and returns NumPy `uint8` arrays + (`py::array_t`) instead of `std::vector`, eliminating the + per-element copy between Python lists and C++ vectors. CMake builds with + `-march=native` to enable host-optimal SIMD. + +### Fixed + +- C++ `fast_resize.cpp` now passes `clang-tidy` with `bugprone-*`, `readability-*`, + `performance-*`, and `modernize-*` checks: renamed short identifiers, added explicit + `static_cast`, extracted magic numbers to constants, used uppercase float literal + suffixes, added parentheses for clarity, and passed NumPy array by `const&`. + ## [1.1.0] - 2026-03-28 ### Added diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index fd2d7f7..3908978 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -144,7 +144,7 @@ For particularly performance-critical image processing scenarios, the project in ## Testing Strategy -The project includes **29 tests** covering all architectural layers, all passing without requiring external services: +The project includes **tests** covering all architectural layers, all passing without requiring external services: | Layer | Tests | Strategy | |-------|-------|----------| diff --git a/README.md b/README.md index 72116ae..dc0338f 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ src/ cpp/ # Optional C++ resize module (pybind11) k8s/ # Kubernetes manifests (Deployment, HPA, PVC, …) minikube/ # Local K8s demo scripts -tests/ # 29 tests across all architecture layers +tests/ # tests across all architecture layers ``` ## Testing @@ -107,5 +107,5 @@ tests/ # 29 tests across all architecture layers pytest tests/ -v ``` -All 29 tests pass without external services — domain tests are pure unit tests, application tests use mocked ports, infrastructure tests use real Pillow/filesystem I/O, and API tests use FastAPI `TestClient` with dependency overrides. +All tests pass without external services — domain tests are pure unit tests, application tests use mocked ports, infrastructure tests use real Pillow/filesystem I/O, and API tests use FastAPI `TestClient` with dependency overrides. diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index a130e6a..6526e92 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -8,6 +8,6 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) find_package(pybind11 REQUIRED) pybind11_add_module(fast_resize fast_resize.cpp) -target_compile_options(fast_resize PRIVATE -O3 -Wall -Wextra) +target_compile_options(fast_resize PRIVATE -O3 -Wall -Wextra -march=native) install(TARGETS fast_resize DESTINATION .) diff --git a/cpp/fast_resize.cpp b/cpp/fast_resize.cpp index b311256..fe97a2a 100644 --- a/cpp/fast_resize.cpp +++ b/cpp/fast_resize.cpp @@ -5,38 +5,190 @@ * for bilinear interpolation resize, significantly faster than pure-Python * for large images in tight loops. * + * Features: + * - SSE2 SIMD for parallel per-pixel channel interpolation (x86-64) + * - Zero-copy I/O via NumPy arrays (no Python list conversion overhead) + * - Scalar fallback for non-x86 architectures + * * Build: - * pip install pybind11 - * c++ -O3 -Wall -shared -std=c++17 -fPIC \ + * pip install pybind11 numpy + * c++ -O3 -Wall -shared -std=c++17 -fPIC -march=native \ * $(python3 -m pybind11 --includes) \ * fast_resize.cpp -o fast_resize$(python3-config --extension-suffix) */ #include +#include #include #include #include +#include #include +#include #include -#include + +#ifdef __SSE2__ +#include +#endif namespace py = pybind11; namespace fast_resize { +constexpr float MAX_CHANNEL_VALUE = 255.0F; + +// ── SSE2 SIMD path ───────────────────────────────────────────────────────── + +#ifdef __SSE2__ + +/// Unpack the low 4 bytes of a 128-bit int register to 4 floats. +static inline __m128 unpack_u8x4_to_ps(__m128i packed) { + const __m128i zero = _mm_setzero_si128(); + const __m128i i16 = _mm_unpacklo_epi8(packed, zero); + const __m128i i32 = _mm_unpacklo_epi16(i16, zero); + return _mm_cvtepi32_ps(i32); +} + +/// Pack 4 floats (clamped to [0,255]) to the low 4 bytes of a 128-bit int register. +static inline __m128i pack_ps_to_u8x4(__m128 values) { + values = _mm_max_ps( + _mm_min_ps(values, _mm_set1_ps(MAX_CHANNEL_VALUE)), _mm_setzero_ps()); + const __m128i i32 = _mm_cvttps_epi32(values); + const __m128i i16 = _mm_packs_epi32(i32, i32); + return _mm_packus_epi16(i16, i16); +} + +/// Load channels bytes (3 or 4) from ptr into the low bytes of __m128i. +static inline __m128i load_pixel(const uint8_t* ptr, int num_channels) { + int32_t val = 0; + std::memcpy(&val, ptr, static_cast(num_channels)); + return _mm_cvtsi32_si128(val); +} + +static void bilinear_resize_sse2( + const uint8_t* src, int src_w, + int src_h, // NOLINT(bugprone-easily-swappable-parameters) + int num_channels, + uint8_t* dst, int dst_w, int dst_h) +{ + const auto col_stride = static_cast(num_channels); + const float x_ratio = static_cast(src_w) / static_cast(dst_w); + const float y_ratio = static_cast(src_h) / static_cast(dst_h); + + for (int dst_y = 0; dst_y < dst_h; ++dst_y) { + const float src_y = static_cast(dst_y) * y_ratio; + const int row_top = static_cast(src_y); + const int row_bot = std::min(row_top + 1, src_h - 1); + const float frac_y = src_y - static_cast(row_top); + const __m128 frac_y_v = _mm_set1_ps(frac_y); + const __m128 one_minus_fy = _mm_set1_ps(1.0F - frac_y); + + const auto top_row_off = static_cast(row_top) * src_w; + const auto bot_row_off = static_cast(row_bot) * src_w; + + for (int dst_x = 0; dst_x < dst_w; ++dst_x) { + const float src_x = static_cast(dst_x) * x_ratio; + const int col_left = static_cast(src_x); + const int col_right = std::min(col_left + 1, src_w - 1); + const float frac_x = src_x - static_cast(col_left); + const __m128 frac_x_v = _mm_set1_ps(frac_x); + const __m128 one_minus_fx = _mm_set1_ps(1.0F - frac_x); + + // Load four corner pixels and unpack to float + const __m128 top_left = unpack_u8x4_to_ps( + load_pixel(src + ((top_row_off + col_left) * col_stride), num_channels)); + const __m128 top_right = unpack_u8x4_to_ps( + load_pixel(src + ((top_row_off + col_right) * col_stride), num_channels)); + const __m128 bot_left = unpack_u8x4_to_ps( + load_pixel(src + ((bot_row_off + col_left) * col_stride), num_channels)); + const __m128 bot_right = unpack_u8x4_to_ps( + load_pixel(src + ((bot_row_off + col_right) * col_stride), num_channels)); + + // Bilinear interpolation on all channels simultaneously + const __m128 top = _mm_add_ps( + _mm_mul_ps(top_left, one_minus_fx), _mm_mul_ps(top_right, frac_x_v)); + const __m128 bottom = _mm_add_ps( + _mm_mul_ps(bot_left, one_minus_fx), _mm_mul_ps(bot_right, frac_x_v)); + const __m128 result = _mm_add_ps( + _mm_mul_ps(top, one_minus_fy), _mm_mul_ps(bottom, frac_y_v)); + + // Pack back to uint8 and store + const int32_t pixel_packed = _mm_cvtsi128_si32(pack_ps_to_u8x4(result)); + const auto dst_off = + (static_cast(dst_y) * dst_w + dst_x) * col_stride; + std::memcpy(dst + dst_off, &pixel_packed, static_cast(num_channels)); + } + } +} + +#endif // __SSE2__ + +// ── Scalar fallback ───────────────────────────────────────────────────────── + +#ifndef __SSE2__ + +static void bilinear_resize_scalar( + const uint8_t* src, int src_w, + int src_h, // NOLINT(bugprone-easily-swappable-parameters) + int num_channels, + uint8_t* dst, int dst_w, int dst_h) +{ + const float x_ratio = static_cast(src_w) / static_cast(dst_w); + const float y_ratio = static_cast(src_h) / static_cast(dst_h); + + for (int dst_y = 0; dst_y < dst_h; ++dst_y) { + const float src_y = static_cast(dst_y) * y_ratio; + const int row_top = static_cast(src_y); + const int row_bot = std::min(row_top + 1, src_h - 1); + const float frac_y = src_y - static_cast(row_top); + + for (int dst_x = 0; dst_x < dst_w; ++dst_x) { + const float src_x = static_cast(dst_x) * x_ratio; + const int col_left = static_cast(src_x); + const int col_right = std::min(col_left + 1, src_w - 1); + const float frac_x = src_x - static_cast(col_left); + + for (int chan = 0; chan < num_channels; ++chan) { + const float top_left = + src[(row_top * src_w + col_left) * num_channels + chan]; + const float top_right = + src[(row_top * src_w + col_right) * num_channels + chan]; + const float bot_left = + src[(row_bot * src_w + col_left) * num_channels + chan]; + const float bot_right = + src[(row_bot * src_w + col_right) * num_channels + chan]; + + const float top = top_left + frac_x * (top_right - top_left); + const float bottom = bot_left + frac_x * (bot_right - bot_left); + const float value = top + frac_y * (bottom - top); + + dst[(dst_y * dst_w + dst_x) * num_channels + chan] = + static_cast(std::clamp(value, 0.0F, MAX_CHANNEL_VALUE)); + } + } + } +} + +#endif // !__SSE2__ + +// ── Public API ────────────────────────────────────────────────────────────── + /** * Bilinear interpolation resize for 8-bit RGB/RGBA images. * + * Accepts and returns NumPy uint8 arrays (zero-copy, no Python list overhead). + * Uses SSE2 intrinsics on x86-64 to interpolate all channels in parallel. + * * @param src Flat pixel buffer (row-major, channels interleaved) * @param src_w Source width * @param src_h Source height * @param channels 3 (RGB) or 4 (RGBA) * @param dst_w Target width * @param dst_h Target height - * @return Resized flat pixel buffer + * @return Resized flat pixel buffer as NumPy uint8 array */ -std::vector bilinear_resize( - const std::vector& src, +py::array_t bilinear_resize( + const py::array_t& src, int src_w, int src_h, int channels, int dst_w, int dst_h) { @@ -46,42 +198,26 @@ std::vector bilinear_resize( if (src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) { throw std::invalid_argument("dimensions must be positive"); } - if (static_cast(src_w) * src_h * channels != src.size()) { + + const auto buf = src.request(); + const auto expected = static_cast(src_w) * src_h * channels; + if (buf.size != expected) { throw std::invalid_argument("src buffer size mismatch"); } - std::vector dst(static_cast(dst_w) * dst_h * channels); - - const double x_ratio = static_cast(src_w) / dst_w; - const double y_ratio = static_cast(src_h) / dst_h; - - for (int dy = 0; dy < dst_h; ++dy) { - const double src_y = dy * y_ratio; - const int y0 = static_cast(std::floor(src_y)); - const int y1 = std::min(y0 + 1, src_h - 1); - const double fy = src_y - y0; + const auto* src_data = static_cast(buf.ptr); - for (int dx = 0; dx < dst_w; ++dx) { - const double src_x = dx * x_ratio; - const int x0 = static_cast(std::floor(src_x)); - const int x1 = std::min(x0 + 1, src_w - 1); - const double fx = src_x - x0; + // Allocate output NumPy array — caller receives it with zero copy + const auto dst_size = static_cast(dst_w) * dst_h * channels; + py::array_t dst(dst_size); + auto* dst_data = static_cast(dst.request().ptr); - for (int c = 0; c < channels; ++c) { - const double top_left = src[(y0 * src_w + x0) * channels + c]; - const double top_right = src[(y0 * src_w + x1) * channels + c]; - const double bottom_left = src[(y1 * src_w + x0) * channels + c]; - const double bottom_right = src[(y1 * src_w + x1) * channels + c]; +#ifdef __SSE2__ + bilinear_resize_sse2(src_data, src_w, src_h, channels, dst_data, dst_w, dst_h); +#else + bilinear_resize_scalar(src_data, src_w, src_h, channels, dst_data, dst_w, dst_h); +#endif - const double top = top_left + fx * (top_right - top_left); - const double bottom = bottom_left + fx * (bottom_right - bottom_left); - const double value = top + fy * (bottom - top); - - dst[(dy * dst_w + dx) * channels + c] = - static_cast(std::clamp(value, 0.0, 255.0)); - } - } - } return dst; } @@ -106,14 +242,15 @@ std::pair fit_dimensions(int src_w, int src_h, int max_w, int max_h) } // namespace fast_resize - +// NOLINTNEXTLINE(readability-identifier-length) PYBIND11_MODULE(fast_resize, m) { m.doc() = "Performance-critical image resize operations in C++"; m.def("bilinear_resize", &fast_resize::bilinear_resize, py::arg("src"), py::arg("src_w"), py::arg("src_h"), py::arg("channels"), py::arg("dst_w"), py::arg("dst_h"), - "Bilinear interpolation resize for 8-bit RGB/RGBA pixel buffers."); + "Bilinear interpolation resize for 8-bit RGB/RGBA pixel buffers.\n\n" + "Accepts and returns NumPy uint8 arrays for zero-copy I/O."); m.def("fit_dimensions", &fast_resize::fit_dimensions, py::arg("src_w"), py::arg("src_h"), diff --git a/pyproject.toml b/pyproject.toml index befbc2e..bf7f463 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "image-processing-service" -version = "1.1.0" +version = "1.2.0" description = "High-performance image processing microservice with Clean Architecture" requires-python = ">=3.11" dependencies = [ diff --git a/src/config.py b/src/config.py index a540517..c5b68e4 100644 --- a/src/config.py +++ b/src/config.py @@ -22,6 +22,10 @@ class Settings(BaseSettings): processing_max_workers: int = 4 thumbnail_max_size: int = 256 + # ── Cache ───────────────────────────────────────────────────────── + cache_ttl_seconds: int = 60 + cache_max_size: int = 1024 + # ── Retention ──────────────────────────────────────────────────────── retention_batch_size: int = 100 diff --git a/src/infrastructure/cache/__init__.py b/src/infrastructure/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/cache/cached_image_repository.py b/src/infrastructure/cache/cached_image_repository.py new file mode 100644 index 0000000..2c9e4f2 --- /dev/null +++ b/src/infrastructure/cache/cached_image_repository.py @@ -0,0 +1,51 @@ +"""Caching decorator for ImageRepository. + +Wraps any ImageRepository implementation with an in-memory TTL cache for +single-entity lookups (get_by_id). Write operations (save, delete) +automatically invalidate the cache to maintain consistency. +""" + +from __future__ import annotations + +import uuid + +from src.domain.entities.image import Image +from src.domain.interfaces.image_repository import ImageRepository +from src.infrastructure.cache.in_memory_cache import InMemoryImageCache + + +class CachedImageRepository(ImageRepository): + """Repository decorator that caches get_by_id results.""" + + def __init__(self, inner: ImageRepository, cache: InMemoryImageCache) -> None: + self._inner = inner + self._cache = cache + + async def save(self, image: Image) -> Image: + result = await self._inner.save(image) + self._cache.invalidate(image.id) + return result + + async def get_by_id(self, image_id: uuid.UUID) -> Image | None: + cached = self._cache.get(image_id) + if cached is not None: + return cached + image = await self._inner.get_by_id(image_id) + if image is not None: + self._cache.set(image) + return image + + async def list_images( + self, *, offset: int = 0, limit: int = 50, status: str | None = None + ) -> list[Image]: + return await self._inner.list_images(offset=offset, limit=limit, status=status) + + async def delete(self, image_id: uuid.UUID) -> bool: + self._cache.invalidate(image_id) + return await self._inner.delete(image_id) + + async def get_expired(self, batch_size: int = 100) -> list[Image]: + return await self._inner.get_expired(batch_size=batch_size) + + async def count(self, *, status: str | None = None) -> int: + return await self._inner.count(status=status) diff --git a/src/infrastructure/cache/in_memory_cache.py b/src/infrastructure/cache/in_memory_cache.py new file mode 100644 index 0000000..43e74a4 --- /dev/null +++ b/src/infrastructure/cache/in_memory_cache.py @@ -0,0 +1,72 @@ +"""In-memory TTL cache for image metadata. + +Uses a simple dictionary with expiry timestamps. In production, this could be +swapped for a Redis-backed implementation without changing the repository +decorator interface. +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from threading import Lock + +from src.domain.entities.image import Image + + +@dataclass +class _CacheEntry: + image: Image + expires_at: float + + +@dataclass +class InMemoryImageCache: + """Thread-safe in-memory cache with TTL-based expiration.""" + + ttl_seconds: float = 60.0 + max_size: int = 1024 + _store: dict[uuid.UUID, _CacheEntry] = field(default_factory=dict, repr=False) + _lock: Lock = field(default_factory=Lock, repr=False) + + def get(self, image_id: uuid.UUID) -> Image | None: + with self._lock: + entry = self._store.get(image_id) + if entry is None: + return None + if time.monotonic() > entry.expires_at: + del self._store[image_id] + return None + return entry.image + + def set(self, image: Image) -> None: + with self._lock: + if len(self._store) >= self.max_size and image.id not in self._store: + self._evict_expired() + if len(self._store) >= self.max_size: + self._evict_oldest() + self._store[image.id] = _CacheEntry( + image=image, + expires_at=time.monotonic() + self.ttl_seconds, + ) + + def invalidate(self, image_id: uuid.UUID) -> None: + with self._lock: + self._store.pop(image_id, None) + + def clear(self) -> None: + with self._lock: + self._store.clear() + + def _evict_expired(self) -> None: + now = time.monotonic() + expired = [k for k, v in self._store.items() if now > v.expires_at] + for k in expired: + del self._store[k] + + def _evict_oldest(self) -> None: + if not self._store: + return + oldest_key = min(self._store, key=lambda k: self._store[k].expires_at) + del self._store[oldest_key] diff --git a/src/infrastructure/database/postgres_image_repository.py b/src/infrastructure/database/postgres_image_repository.py index 4334baa..bca65f1 100644 --- a/src/infrastructure/database/postgres_image_repository.py +++ b/src/infrastructure/database/postgres_image_repository.py @@ -40,8 +40,8 @@ async def list_images( if status: stmt = stmt.where(ImageModel.status == status) stmt = stmt.offset(offset).limit(limit) - result = await session.execute(stmt) - return [_model_to_entity(row) for row in result.scalars().all()] + result = await session.stream_scalars(stmt) + return [_model_to_entity(row) async for row in result] async def delete(self, image_id: uuid.UUID) -> bool: async with self._session_factory() as session, session.begin(): @@ -58,8 +58,8 @@ async def get_expired(self, batch_size: int = 100) -> list[Image]: .where(ImageModel.expires_at <= now) .limit(batch_size) ) - result = await session.execute(stmt) - return [_model_to_entity(row) for row in result.scalars().all()] + result = await session.stream_scalars(stmt) + return [_model_to_entity(row) async for row in result] async def count(self, *, status: str | None = None) -> int: async with self._session_factory() as session: diff --git a/src/presentation/api/dependencies.py b/src/presentation/api/dependencies.py index 5c271eb..7184809 100644 --- a/src/presentation/api/dependencies.py +++ b/src/presentation/api/dependencies.py @@ -14,6 +14,8 @@ from src.application.use_cases.process_image import ProcessImageUseCase from src.application.use_cases.upload_image import UploadImageUseCase from src.config import Settings +from src.infrastructure.cache.cached_image_repository import CachedImageRepository +from src.infrastructure.cache.in_memory_cache import InMemoryImageCache from src.infrastructure.database.postgres_image_repository import PostgresImageRepository from src.infrastructure.database.session import build_engine, build_session_factory from src.infrastructure.processing.pillow_processor import PillowImageProcessor @@ -33,8 +35,20 @@ def _session_factory(): @lru_cache -def _repository() -> PostgresImageRepository: - return PostgresImageRepository(_session_factory()) +def _cache() -> InMemoryImageCache: + settings = get_settings() + return InMemoryImageCache( + ttl_seconds=settings.cache_ttl_seconds, + max_size=settings.cache_max_size, + ) + + +@lru_cache +def _repository() -> CachedImageRepository: + return CachedImageRepository( + inner=PostgresImageRepository(_session_factory()), + cache=_cache(), + ) @lru_cache diff --git a/src/presentation/api/routes/health.py b/src/presentation/api/routes/health.py index 2a0ca34..c26750f 100644 --- a/src/presentation/api/routes/health.py +++ b/src/presentation/api/routes/health.py @@ -2,17 +2,56 @@ from __future__ import annotations -from fastapi import APIRouter +import importlib.metadata +import logging +from pathlib import Path +from typing import Annotated -from src.presentation.schemas.image_schemas import HealthResponse +from fastapi import APIRouter, Depends +from sqlalchemy import text + +from src.config import Settings +from src.presentation.api.dependencies import _session_factory, get_settings +from src.presentation.schemas.image_schemas import ComponentCheck, HealthResponse + +logger = logging.getLogger(__name__) router = APIRouter(tags=["health"]) +_VERSION = importlib.metadata.version("image-processing-service") + + +async def _check_database() -> ComponentCheck: + try: + factory = _session_factory() + async with factory() as session: + await session.execute(text("SELECT 1")) + return ComponentCheck(status="ok") + except Exception as exc: + logger.warning("Database health check failed: %s", exc) + return ComponentCheck(status="error", detail=str(exc)) + + +def _check_storage(settings: Settings) -> ComponentCheck: + storage_dir = Path(settings.storage_base_dir) + if not storage_dir.is_dir(): + return ComponentCheck(status="error", detail="storage directory not found") + return ComponentCheck(status="ok") + @router.get("/health", response_model=HealthResponse) -async def health_check(): +async def health_check( + settings: Annotated[Settings, Depends(get_settings)] = None, # type: ignore[assignment] +): + db = await _check_database() + storage = _check_storage(settings) + + checks = {"database": db, "storage": storage} + overall = "healthy" if all(c.status == "ok" for c in checks.values()) else "degraded" + return HealthResponse( - status="healthy", - service="image-processing-service", - version="1.0.0", + status=overall, + service=settings.app_name, + version=_VERSION, + checks=checks, ) diff --git a/src/presentation/schemas/image_schemas.py b/src/presentation/schemas/image_schemas.py index 61a8ef7..2ce3bf1 100644 --- a/src/presentation/schemas/image_schemas.py +++ b/src/presentation/schemas/image_schemas.py @@ -52,7 +52,13 @@ class RetentionResponse(BaseModel): errors: int +class ComponentCheck(BaseModel): + status: str + detail: str | None = None + + class HealthResponse(BaseModel): status: str service: str version: str + checks: dict[str, ComponentCheck] = Field(default_factory=dict) diff --git a/tests/infrastructure/test_cached_repository.py b/tests/infrastructure/test_cached_repository.py new file mode 100644 index 0000000..b1846cd --- /dev/null +++ b/tests/infrastructure/test_cached_repository.py @@ -0,0 +1,180 @@ +"""Tests for the in-memory cache and cached repository decorator.""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, patch + +import pytest + +from src.domain.entities.image import Image, ProcessingStatus +from src.domain.interfaces.image_repository import ImageRepository +from src.infrastructure.cache.cached_image_repository import CachedImageRepository +from src.infrastructure.cache.in_memory_cache import InMemoryImageCache + +# ── InMemoryImageCache tests ───────────────────────────────────────────────── + + +class TestInMemoryImageCache: + def _make_image(self, image_id: uuid.UUID | None = None) -> Image: + return Image( + id=image_id or uuid.uuid4(), + filename="test.png", + original_path="/data/test.png", + status=ProcessingStatus.PENDING, + ) + + def test_get_returns_none_for_missing(self): + cache = InMemoryImageCache(ttl_seconds=60) + assert cache.get(uuid.uuid4()) is None + + def test_set_and_get(self): + cache = InMemoryImageCache(ttl_seconds=60) + image = self._make_image() + cache.set(image) + assert cache.get(image.id) is image + + def test_invalidate(self): + cache = InMemoryImageCache(ttl_seconds=60) + image = self._make_image() + cache.set(image) + cache.invalidate(image.id) + assert cache.get(image.id) is None + + def test_clear(self): + cache = InMemoryImageCache(ttl_seconds=60) + for _ in range(5): + cache.set(self._make_image()) + cache.clear() + # All cleared — nothing should be retrievable + assert cache.get(uuid.uuid4()) is None + + @patch("src.infrastructure.cache.in_memory_cache.time.monotonic") + def test_expired_entry_returns_none(self, mock_monotonic): + mock_monotonic.return_value = 1000.0 + cache = InMemoryImageCache(ttl_seconds=10) + image = self._make_image() + cache.set(image) + + # Advance time past TTL + mock_monotonic.return_value = 1011.0 + assert cache.get(image.id) is None + + def test_max_size_eviction(self): + cache = InMemoryImageCache(ttl_seconds=60, max_size=3) + images = [self._make_image() for _ in range(4)] + for img in images: + cache.set(img) + + # Should have at most 3 entries; newest should be present + assert cache.get(images[3].id) is images[3] + + def test_update_existing_does_not_grow(self): + cache = InMemoryImageCache(ttl_seconds=60, max_size=2) + image = self._make_image() + cache.set(image) + cache.set(image) # re-set same ID + # Should still work, not evict + assert cache.get(image.id) is image + + +# ── CachedImageRepository tests ────────────────────────────────────────────── + + +class TestCachedImageRepository: + def _make_image(self, image_id: uuid.UUID | None = None) -> Image: + return Image( + id=image_id or uuid.uuid4(), + filename="test.png", + original_path="/data/test.png", + status=ProcessingStatus.PENDING, + ) + + @pytest.fixture + def inner(self) -> ImageRepository: + repo = AsyncMock(spec=ImageRepository) + repo.save = AsyncMock() + repo.get_by_id = AsyncMock(return_value=None) + repo.list_images = AsyncMock(return_value=[]) + repo.delete = AsyncMock(return_value=True) + repo.get_expired = AsyncMock(return_value=[]) + repo.count = AsyncMock(return_value=0) + return repo + + @pytest.fixture + def cache(self) -> InMemoryImageCache: + return InMemoryImageCache(ttl_seconds=60, max_size=100) + + @pytest.fixture + def cached_repo(self, inner, cache) -> CachedImageRepository: + return CachedImageRepository(inner=inner, cache=cache) + + async def test_get_by_id_cache_miss_delegates(self, cached_repo, inner): + image = self._make_image() + inner.get_by_id.return_value = image + + result = await cached_repo.get_by_id(image.id) + + assert result is image + inner.get_by_id.assert_awaited_once_with(image.id) + + async def test_get_by_id_cache_hit_skips_db(self, cached_repo, inner, cache): + image = self._make_image() + cache.set(image) + + result = await cached_repo.get_by_id(image.id) + + assert result is image + inner.get_by_id.assert_not_awaited() + + async def test_get_by_id_populates_cache(self, cached_repo, inner, cache): + image = self._make_image() + inner.get_by_id.return_value = image + + await cached_repo.get_by_id(image.id) + # Second call should hit cache + await cached_repo.get_by_id(image.id) + + assert inner.get_by_id.await_count == 1 + + async def test_save_invalidates_cache(self, cached_repo, inner, cache): + image = self._make_image() + cache.set(image) + inner.save.return_value = image + + await cached_repo.save(image) + + # Cache should be invalidated + assert cache.get(image.id) is None + inner.save.assert_awaited_once_with(image) + + async def test_delete_invalidates_cache(self, cached_repo, inner, cache): + image = self._make_image() + cache.set(image) + + await cached_repo.delete(image.id) + + assert cache.get(image.id) is None + inner.delete.assert_awaited_once_with(image.id) + + async def test_list_images_delegates(self, cached_repo, inner): + await cached_repo.list_images(offset=0, limit=10, status="pending") + inner.list_images.assert_awaited_once_with(offset=0, limit=10, status="pending") + + async def test_get_expired_delegates(self, cached_repo, inner): + await cached_repo.get_expired(batch_size=50) + inner.get_expired.assert_awaited_once_with(batch_size=50) + + async def test_count_delegates(self, cached_repo, inner): + await cached_repo.count(status="completed") + inner.count.assert_awaited_once_with(status="completed") + + async def test_get_by_id_none_not_cached(self, cached_repo, inner, cache): + inner.get_by_id.return_value = None + + result = await cached_repo.get_by_id(uuid.uuid4()) + + assert result is None + # Second call should still hit DB (None not cached) + await cached_repo.get_by_id(uuid.uuid4()) + assert inner.get_by_id.await_count == 2 diff --git a/tests/presentation/test_api.py b/tests/presentation/test_api.py index dd795a0..2ffa5ed 100644 --- a/tests/presentation/test_api.py +++ b/tests/presentation/test_api.py @@ -24,6 +24,7 @@ get_get_image_use_case, get_list_use_case, get_process_use_case, + get_settings, get_upload_use_case, ) @@ -60,8 +61,12 @@ def png_upload_bytes() -> bytes: @pytest.fixture -def client(image_response) -> TestClient: +def client(image_response, tmp_path) -> TestClient: from contextlib import asynccontextmanager + from unittest.mock import patch + + from src.config import Settings + from src.presentation.schemas.image_schemas import ComponentCheck # Override lifespan to skip DB connection in tests @asynccontextmanager @@ -90,7 +95,15 @@ async def _noop_lifespan(app): app.dependency_overrides[get_list_use_case] = lambda: mock_list app.dependency_overrides[get_process_use_case] = lambda: mock_process - yield TestClient(app) + # Health endpoint: provide real tmp_path for storage and mock the DB check + test_settings = Settings(storage_base_dir=str(tmp_path)) + app.dependency_overrides[get_settings] = lambda: test_settings + + async def _ok_db_check(): + return ComponentCheck(status="ok") + + with patch("src.presentation.api.routes.health._check_database", side_effect=_ok_db_check): + yield TestClient(app) app.dependency_overrides.clear() @@ -102,6 +115,9 @@ def test_health(self, client): data = resp.json() assert data["status"] == "healthy" assert data["service"] == "image-processing-service" + assert data["version"] # dynamic, just verify non-empty + assert data["checks"]["database"]["status"] == "ok" + assert data["checks"]["storage"]["status"] == "ok" class TestImageUpload: