quadkit-cache 0.0.44__tar.gz → 0.0.46__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/PKG-INFO +6 -6
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/README.md +1 -1
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/pyproject.toml +5 -5
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memory/backend.py +16 -3
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memory/kv.py +1 -1
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memory_state.py +57 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/templates/cache_repository.py.jinja2 +5 -1
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/di/_registration.py +1 -1
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/core.py +50 -1
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/stores/redis_state.py +26 -0
- quadkit_cache-0.0.46/tests/unit/service/test_reporting_surface.py +136 -0
- quadkit_cache-0.0.46/tests/unit/test_state_store_protocol_conformance.py +196 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/.gitignore +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/LICENSE +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/NOTICE +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/example.yaml +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/contributor.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/handlers/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/handlers/backend_ping.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/handlers/eviction_rate.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/handlers/hit_miss_ratio.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/pages/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/pages/keys.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/pages/overview.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/admin/pages/stats.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/factory.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/hash.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memcached/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memcached/backend.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memcached/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memory/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memory/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memory_lock.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/memory_secrets.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/redis/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/redis/backend.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/redis/circuit.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/redis/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/redis/resilience.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/redis/resilient.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/backends/registry.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/checks.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/commands.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/contributor.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/doctor.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/generators/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/generators/cache_repository.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/cli/shell.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/config/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/config/backends.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/config/loaders.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/config/operation.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/config/service.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/config/top_level.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/constants.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/decorators.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/di/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/di/_services.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/di/provider.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/di/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/events.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/exceptions.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/hooks.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/locks/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/locks/auto_renewing.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/locks/context.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/locks/distributed.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/locks/manager.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/module.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/protocols.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/repository/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/repository/base.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/repository/collections.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/repository/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/semantic/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/semantic/cost_decision.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/semantic/protocols.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/semantic/store.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/semantic/vector_index.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/base.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/compression.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/factory.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/json.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/msgpack.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/serialization/type_registry.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/decorators.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/factory.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/health.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/invalidation.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/patterns.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/pipeline.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/protection.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/registry.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/request_cache.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/stampede.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/service/warmer.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/stores/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/stores/redis_lock.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/stores/redis_secrets.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/strategies/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/strategies/tiered.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/strategies/write_through.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/cache/types.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/src/quadkit/py.typed +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/conftest.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/integration/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/integration/conftest.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/integration/test_cache_lifecycle.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/integration/test_redis_cache_compliance.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/integration/test_redis_provider_lifecycle.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/abstractions/test_cache_backend.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/admin/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/admin/handlers/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/admin/handlers/test_backend_ping.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/admin/handlers/test_eviction_rate.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/admin/handlers/test_hit_miss_ratio.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/admin/test_cache_contributor.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_backend_error_logging.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_backend_registry_extra.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memcached.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memcached_client_precedence.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memory.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memory_eviction.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memory_kv.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memory_lock.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memory_secrets.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_memory_stats.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_redis.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/backends/test_resilient_redis.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/cli/test_generators_contract.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/conftest.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/integration/test_backends_create.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/integration/test_config.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/integration/test_integration_helpers.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/integration/test_provider_quadkit_cache_integration.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/semantic/__init__.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/semantic/test_semantic_cache_eviction.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/semantic/test_semantic_cache_lookup.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/semantic/test_semantic_cache_store.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/semantic/test_semantic_cache_vector_index.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/serialization/test_base.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/serialization/test_serialization.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/service/test_cache_service_get_typed.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/service/test_decorators.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/service/test_invalidation.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/service/test_service.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/service/test_stampede_protection.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/stores/test_redis_lock_extra.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/stores/test_redis_secrets_extra.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/stores/test_redis_state_extra.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_admin_stats_and_events.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_admin_protocol.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_cli.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_config.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_config_loaders.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_config_top_level.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_constants.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_di_registration.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_exceptions.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_module.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_protocols.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_provider.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_provider_named.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_providers.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_result_pattern.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_stores.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_strategies.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_types.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cache_warmer.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_cli_contributor.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_compressing_serializer.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_config_loading.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_di_boundary_contract.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_distributed_lock.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_exceptions.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_exceptions_quadkit_cache.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_lock_renewal.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_locks.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_package_hooks_p2.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_redis_fake.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_redis_resilience_backends.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_repository.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_request_cache.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_round28_hardening.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_stampede_coalescing.py +0 -0
- {quadkit_cache-0.0.44 → quadkit_cache-0.0.46}/tests/unit/test_type_registry.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quadkit-cache
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.46
|
|
4
4
|
Summary: Multi-backend caching system for Quadkit Framework - Redis, Memcached, and in-memory caching
|
|
5
|
-
Project-URL: Homepage, https://
|
|
5
|
+
Project-URL: Homepage, https://oridecon.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/dbtinoy-/quadkit
|
|
7
|
-
Project-URL: Documentation, https://
|
|
7
|
+
Project-URL: Documentation, https://oridecon.dev/quadkit/
|
|
8
8
|
Project-URL: Issues, https://github.com/dbtinoy-/quadkit/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/dbtinoy-/quadkit/blob/main/CHANGELOG.md
|
|
10
|
-
Author-email: Quadkit Framework Team <
|
|
11
|
-
Maintainer-email: Quadkit Framework Team <
|
|
10
|
+
Author-email: Quadkit Framework Team <hello@oridecon.dev>
|
|
11
|
+
Maintainer-email: Quadkit Framework Team <hello@oridecon.dev>
|
|
12
12
|
License: Apache-2.0
|
|
13
13
|
License-File: LICENSE
|
|
14
14
|
License-File: NOTICE
|
|
@@ -79,7 +79,7 @@ into any service. The `@cacheable` decorator provides cache-aside logic with
|
|
|
79
79
|
automatic key generation and Result support.
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
> Full documentation: [
|
|
82
|
+
> Full documentation: [oridecon.dev/quadkit](https://oridecon.dev/quadkit)
|
|
83
83
|
## Install
|
|
84
84
|
|
|
85
85
|
```bash
|
|
@@ -15,7 +15,7 @@ into any service. The `@cacheable` decorator provides cache-aside logic with
|
|
|
15
15
|
automatic key generation and Result support.
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
> Full documentation: [
|
|
18
|
+
> Full documentation: [oridecon.dev/quadkit](https://oridecon.dev/quadkit)
|
|
19
19
|
## Install
|
|
20
20
|
|
|
21
21
|
```bash
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "quadkit-cache"
|
|
9
|
-
version = "0.0.
|
|
9
|
+
version = "0.0.46"
|
|
10
10
|
description = "Multi-backend caching system for Quadkit Framework - Redis, Memcached, and in-memory caching"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.11"
|
|
@@ -43,10 +43,10 @@ dependencies = [
|
|
|
43
43
|
"jinja2>=3.1.0",
|
|
44
44
|
]
|
|
45
45
|
authors = [
|
|
46
|
-
{ name = "Quadkit Framework Team", email = "
|
|
46
|
+
{ name = "Quadkit Framework Team", email = "hello@oridecon.dev" },
|
|
47
47
|
]
|
|
48
48
|
maintainers = [
|
|
49
|
-
{ name = "Quadkit Framework Team", email = "
|
|
49
|
+
{ name = "Quadkit Framework Team", email = "hello@oridecon.dev" },
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
[project.license]
|
|
@@ -89,9 +89,9 @@ all = [
|
|
|
89
89
|
]
|
|
90
90
|
|
|
91
91
|
[project.urls]
|
|
92
|
-
Homepage = "https://
|
|
92
|
+
Homepage = "https://oridecon.dev"
|
|
93
93
|
Repository = "https://github.com/dbtinoy-/quadkit"
|
|
94
|
-
Documentation = "https://
|
|
94
|
+
Documentation = "https://oridecon.dev/quadkit/"
|
|
95
95
|
Issues = "https://github.com/dbtinoy-/quadkit/issues"
|
|
96
96
|
Changelog = "https://github.com/dbtinoy-/quadkit/blob/main/CHANGELOG.md"
|
|
97
97
|
|
|
@@ -54,7 +54,8 @@ class MemoryCacheBackend(CacheBackendProtocol):
|
|
|
54
54
|
hooks: Optional hook registry for lifecycle emission.
|
|
55
55
|
"""
|
|
56
56
|
self.config = config or default_cache_config()
|
|
57
|
-
self.
|
|
57
|
+
self.max_size = max_size
|
|
58
|
+
self._store = MemoryStateStore(max_size=max_size)
|
|
58
59
|
self._metrics = CacheMetrics()
|
|
59
60
|
self._evictions = 0
|
|
60
61
|
self._hooks = hooks
|
|
@@ -317,17 +318,29 @@ class MemoryCacheBackend(CacheBackendProtocol):
|
|
|
317
318
|
Dict with ``hits``, ``misses``, ``evictions``, and ``entries``
|
|
318
319
|
counts, or None when statistics are unavailable.
|
|
319
320
|
"""
|
|
321
|
+
store_stats = getattr(self._store, "get_stats", None)
|
|
322
|
+
store_evictions = 0
|
|
323
|
+
store_entries = len(self._store._data)
|
|
324
|
+
if callable(store_stats):
|
|
325
|
+
reported = store_stats() or {}
|
|
326
|
+
store_evictions = int(reported.get("evictions", 0) or 0)
|
|
327
|
+
store_entries = int(reported.get("entries", store_entries) or 0)
|
|
320
328
|
return {
|
|
321
329
|
"hits": self._metrics.hits,
|
|
322
330
|
"misses": self._metrics.misses,
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
# Both the backend's own eviction paths (tag/LRU maintenance) and
|
|
332
|
+
# the store's capacity evictions.
|
|
333
|
+
"evictions": self._evictions + store_evictions,
|
|
334
|
+
"entries": store_entries,
|
|
325
335
|
}
|
|
326
336
|
|
|
327
337
|
def reset_stats(self) -> None:
|
|
328
338
|
"""Reset counters without evicting cached entries."""
|
|
329
339
|
self._metrics.reset()
|
|
330
340
|
self._evictions = 0
|
|
341
|
+
store = self._store
|
|
342
|
+
if hasattr(store, "eviction_count"):
|
|
343
|
+
store.eviction_count = 0
|
|
331
344
|
|
|
332
345
|
async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
|
|
333
346
|
"""
|
|
@@ -43,7 +43,7 @@ class InMemoryStorage(StorageBackendProtocol):
|
|
|
43
43
|
def _get_store(self, namespace: str) -> MemoryStateStore:
|
|
44
44
|
"""Return (or create) the MemoryStateStore for *namespace*."""
|
|
45
45
|
if namespace not in self._stores:
|
|
46
|
-
self._stores[namespace] = MemoryStateStore(
|
|
46
|
+
self._stores[namespace] = MemoryStateStore(
|
|
47
47
|
max_size=self.max_size,
|
|
48
48
|
)
|
|
49
49
|
return self._stores[namespace]
|
|
@@ -31,6 +31,10 @@ class MemoryStateStore(StateStoreProtocol):
|
|
|
31
31
|
entry is evicted first. ``None`` (default) disables eviction.
|
|
32
32
|
"""
|
|
33
33
|
self.max_size = max_size
|
|
34
|
+
#: Entries dropped to make room for new ones. Expired entries that
|
|
35
|
+
#: happen to be reaped during a write are not evictions — they were
|
|
36
|
+
#: going to disappear anyway — so only the LRU drop is counted here.
|
|
37
|
+
self.eviction_count = 0
|
|
34
38
|
# OrderedDict preserves insertion/access order for LRU tracking.
|
|
35
39
|
self._data: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
|
36
40
|
|
|
@@ -98,6 +102,7 @@ class MemoryStateStore(StateStoreProtocol):
|
|
|
98
102
|
self._evict_expired()
|
|
99
103
|
if len(self._data) >= self.max_size:
|
|
100
104
|
self._data.popitem(last=False) # remove least-recently used
|
|
105
|
+
self.eviction_count += 1
|
|
101
106
|
|
|
102
107
|
self._data[key] = {"value": serialized_value, "expires_at": expires_at}
|
|
103
108
|
|
|
@@ -120,6 +125,44 @@ class MemoryStateStore(StateStoreProtocol):
|
|
|
120
125
|
return False
|
|
121
126
|
return True
|
|
122
127
|
|
|
128
|
+
async def expire(self, key: str, ttl: int) -> bool:
|
|
129
|
+
"""Set a new expiration on an existing, non-expired key."""
|
|
130
|
+
entry = self._data.get(key)
|
|
131
|
+
if entry is None:
|
|
132
|
+
return False
|
|
133
|
+
now = ambient_clock.timestamp()
|
|
134
|
+
expires_at = entry.get("expires_at")
|
|
135
|
+
if expires_at is not None and now > expires_at:
|
|
136
|
+
del self._data[key]
|
|
137
|
+
return False
|
|
138
|
+
entry["expires_at"] = now + ttl
|
|
139
|
+
if self.max_size is not None:
|
|
140
|
+
self._data.move_to_end(key)
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
async def ttl(self, key: str) -> int:
|
|
144
|
+
"""Remaining TTL in seconds; -1 with no expiry, -2 when absent."""
|
|
145
|
+
entry = self._data.get(key)
|
|
146
|
+
if entry is None:
|
|
147
|
+
return -2
|
|
148
|
+
now = ambient_clock.timestamp()
|
|
149
|
+
expires_at = entry.get("expires_at")
|
|
150
|
+
if expires_at is None:
|
|
151
|
+
return -1
|
|
152
|
+
if now > expires_at:
|
|
153
|
+
del self._data[key]
|
|
154
|
+
return -2
|
|
155
|
+
return max(0, int(expires_at - now))
|
|
156
|
+
|
|
157
|
+
async def get_many(self, keys: list[str]) -> dict[str, Any]:
|
|
158
|
+
"""Fetch several keys at once; absent keys are omitted."""
|
|
159
|
+
return await self.get_bulk(keys)
|
|
160
|
+
|
|
161
|
+
async def set_many(self, items: dict[str, Any], ttl: int | None = None) -> None:
|
|
162
|
+
"""Persist several key-value pairs in one call."""
|
|
163
|
+
for key, value in items.items():
|
|
164
|
+
await self.set(key, value, ttl)
|
|
165
|
+
|
|
123
166
|
async def clear(self) -> None:
|
|
124
167
|
"""Clear all values from the store."""
|
|
125
168
|
self._data.clear()
|
|
@@ -133,6 +176,19 @@ class MemoryStateStore(StateStoreProtocol):
|
|
|
133
176
|
result[key] = value
|
|
134
177
|
return result
|
|
135
178
|
|
|
179
|
+
def get_stats(self) -> dict[str, Any]:
|
|
180
|
+
"""Return occupancy and eviction counts for administrative reporting.
|
|
181
|
+
|
|
182
|
+
Reporting lives here rather than in each backend so a backend without
|
|
183
|
+
an eviction counter of its own (the memory cache) still reports the
|
|
184
|
+
evictions its store performed.
|
|
185
|
+
"""
|
|
186
|
+
return {
|
|
187
|
+
"entries": len(self._data),
|
|
188
|
+
"max_size": self.max_size,
|
|
189
|
+
"evictions": self.eviction_count,
|
|
190
|
+
}
|
|
191
|
+
|
|
136
192
|
async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
|
|
137
193
|
"""Check the health of the state store."""
|
|
138
194
|
return HealthCheckResult(
|
|
@@ -142,5 +198,6 @@ class MemoryStateStore(StateStoreProtocol):
|
|
|
142
198
|
"driver": "memory",
|
|
143
199
|
"keys_count": len(self._data),
|
|
144
200
|
"max_size": self.max_size,
|
|
201
|
+
"evictions": self.eviction_count,
|
|
145
202
|
},
|
|
146
203
|
)
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Convention followed: **Repository pattern** — a cache-aside
|
|
2
|
+
decorator: reads check the cache first, writes invalidate; the
|
|
3
|
+
wrapped repository stays the source of truth.
|
|
4
|
+
|
|
5
|
+
{{ entity_name }} repository with caching.
|
|
2
6
|
|
|
3
7
|
This module provides a cached repository that wraps an underlying data
|
|
4
8
|
repository and applies the cache-aside pattern:
|
|
@@ -136,7 +136,7 @@ class _CacheRegistrationMixin:
|
|
|
136
136
|
and resolve_backend_type(backend_config) == BackendType.REDIS
|
|
137
137
|
and backend_config.redis_url
|
|
138
138
|
):
|
|
139
|
-
store = RedisStateStore(
|
|
139
|
+
store = RedisStateStore(
|
|
140
140
|
url=backend_config.redis_url,
|
|
141
141
|
prefix=backend_config.key_prefix,
|
|
142
142
|
)
|
|
@@ -84,7 +84,14 @@ class CacheService(PipelineMixin, InvalidationMixin, PatternsMixin):
|
|
|
84
84
|
"""
|
|
85
85
|
self.provider = provider
|
|
86
86
|
self._backend = backend
|
|
87
|
-
|
|
87
|
+
# A directly-supplied backend carries the configuration that governs
|
|
88
|
+
# key prefixing and default TTL. Adopting it when the caller passed no
|
|
89
|
+
# service config keeps the two in step: the alternative is a service
|
|
90
|
+
# reporting no prefix while every key it writes is prefixed by the
|
|
91
|
+
# backend it delegates to.
|
|
92
|
+
self.config = (
|
|
93
|
+
config or getattr(backend, "config", None) or default_cache_config()
|
|
94
|
+
)
|
|
88
95
|
self._protection = protection
|
|
89
96
|
self._ctx = ctx
|
|
90
97
|
|
|
@@ -433,6 +440,48 @@ class CacheService(PipelineMixin, InvalidationMixin, PatternsMixin):
|
|
|
433
440
|
"""
|
|
434
441
|
return self._metrics.copy()
|
|
435
442
|
|
|
443
|
+
@property
|
|
444
|
+
def backend(self) -> CacheBackendProtocol | None:
|
|
445
|
+
"""Return the backing store, or ``None`` when only a provider is set.
|
|
446
|
+
|
|
447
|
+
Admin surfaces report per-cache occupancy and eviction from the
|
|
448
|
+
backend itself; exposing it keeps that reporting out of the private
|
|
449
|
+
attribute layout.
|
|
450
|
+
"""
|
|
451
|
+
return self._backend
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def key_prefix(self) -> str:
|
|
455
|
+
"""Return this service's key prefix (``""`` when it has none)."""
|
|
456
|
+
return str(getattr(self.config, "key_prefix", "") or "")
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def default_ttl(self) -> int | None:
|
|
460
|
+
"""Return the default TTL in seconds applied when ``set`` omits one."""
|
|
461
|
+
value = getattr(self.config, "default_ttl", None)
|
|
462
|
+
return int(value) if value is not None else None
|
|
463
|
+
|
|
464
|
+
def get_backend_stats(self) -> dict[str, int]:
|
|
465
|
+
"""Return the backend's own counters for administrative reporting.
|
|
466
|
+
|
|
467
|
+
``get_stats`` is optional on the backend protocol — a Redis backend
|
|
468
|
+
answers through ``INFO`` instead — so an unreporting backend yields
|
|
469
|
+
zeros rather than fabricated activity.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
Mapping with ``hits``, ``misses``, ``evictions`` and ``entries``.
|
|
473
|
+
"""
|
|
474
|
+
empty = {"hits": 0, "misses": 0, "evictions": 0, "entries": 0}
|
|
475
|
+
get_stats = getattr(self._backend, "get_stats", None)
|
|
476
|
+
if not callable(get_stats):
|
|
477
|
+
return empty
|
|
478
|
+
try:
|
|
479
|
+
raw = get_stats() or {}
|
|
480
|
+
except Exception: # noqa: BLE001 — reporting never raises
|
|
481
|
+
logger.warning("cache.backend_stats_unavailable", exc_info=True)
|
|
482
|
+
return empty
|
|
483
|
+
return {key: int(raw.get(key, 0) or 0) for key in empty}
|
|
484
|
+
|
|
436
485
|
def reset_metrics(self) -> None:
|
|
437
486
|
"""Reset performance metrics."""
|
|
438
487
|
self._metrics = {"hits": 0, "misses": 0, "errors": 0, "operations": 0}
|
|
@@ -151,6 +151,32 @@ class RedisStateStore(RedisDriver, StateStoreProtocol):
|
|
|
151
151
|
await cast("Awaitable[Any]", client.delete(self._key(key)))
|
|
152
152
|
return True
|
|
153
153
|
|
|
154
|
+
async def exists(self, key: str) -> bool:
|
|
155
|
+
"""Check whether a key exists."""
|
|
156
|
+
client = await self._get_client()
|
|
157
|
+
return bool(await client.exists(self._key(key)))
|
|
158
|
+
|
|
159
|
+
async def expire(self, key: str, ttl: int) -> bool:
|
|
160
|
+
"""Set expiration on a key; False when the key does not exist."""
|
|
161
|
+
client = await self._get_client()
|
|
162
|
+
return bool(await cast("Awaitable[Any]", client.expire(self._key(key), ttl)))
|
|
163
|
+
|
|
164
|
+
async def ttl(self, key: str) -> int:
|
|
165
|
+
"""Remaining TTL in seconds; -1 with no expiry, -2 when absent."""
|
|
166
|
+
client = await self._get_client()
|
|
167
|
+
# Redis already returns -1 for "no expiry" and -2 for "no such key",
|
|
168
|
+
# which is exactly the StateStoreProtocol contract.
|
|
169
|
+
return int(await client.ttl(self._key(key)))
|
|
170
|
+
|
|
171
|
+
async def get_many(self, keys: list[str]) -> dict[str, Any]:
|
|
172
|
+
"""Fetch several keys at once; absent keys are omitted."""
|
|
173
|
+
return await self.get_bulk(keys)
|
|
174
|
+
|
|
175
|
+
async def set_many(self, items: dict[str, Any], ttl: int | None = None) -> None:
|
|
176
|
+
"""Persist several key-value pairs in one call."""
|
|
177
|
+
for key, value in items.items():
|
|
178
|
+
await self.set(key, value, ttl)
|
|
179
|
+
|
|
154
180
|
async def get_bulk(self, keys: list[str]) -> dict[str, Any]:
|
|
155
181
|
"""Get multiple values by keys"""
|
|
156
182
|
client = await self._get_client()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The reporting surface admin dashboards read from a cache service.
|
|
2
|
+
|
|
3
|
+
Applications ship an admin cache dashboard; it needs the service's own
|
|
4
|
+
counters, the backend's counters (hits/misses/evictions/entries), the key
|
|
5
|
+
prefix and the default TTL. Asking for those through private attributes is
|
|
6
|
+
what this surface exists to avoid — and the eviction count in particular used
|
|
7
|
+
to be invisible: the store dropped the least-recently-used entry while the
|
|
8
|
+
backend reported ``evictions: 0``, so a full cache looked idle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from quadkit.cache.backends.memory.backend import MemoryCacheBackend
|
|
16
|
+
from quadkit.cache.config.operation import CacheOperationConfig
|
|
17
|
+
from quadkit.cache.service.core import CacheService
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _service(**kwargs: object) -> CacheService:
|
|
21
|
+
config = CacheOperationConfig(default_ttl=60, key_prefix="gw:test:")
|
|
22
|
+
return CacheService(
|
|
23
|
+
backend=MemoryCacheBackend(config=config, max_size=3), **kwargs
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_service_exposes_backend_prefix_and_ttl() -> None:
|
|
28
|
+
"""Reporting reads the configuration through public properties."""
|
|
29
|
+
service = _service()
|
|
30
|
+
assert isinstance(service.backend, MemoryCacheBackend)
|
|
31
|
+
assert service.key_prefix == "gw:test:"
|
|
32
|
+
assert service.default_ttl == 60
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_service_adopts_the_backend_config_when_none_is_given() -> None:
|
|
36
|
+
"""A backend-injected service reports the configuration it delegates to."""
|
|
37
|
+
config = CacheOperationConfig(default_ttl=15, key_prefix="gw:x:")
|
|
38
|
+
service = CacheService(backend=MemoryCacheBackend(config=config))
|
|
39
|
+
assert service.key_prefix == "gw:x:"
|
|
40
|
+
assert service.default_ttl == 15
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_explicit_service_config_wins() -> None:
|
|
44
|
+
"""A caller-supplied service config is not overwritten by the backend's."""
|
|
45
|
+
config = CacheOperationConfig(default_ttl=15, key_prefix="gw:x:")
|
|
46
|
+
service = CacheService(
|
|
47
|
+
config=CacheOperationConfig(default_ttl=5, key_prefix="gw:y:"),
|
|
48
|
+
backend=MemoryCacheBackend(config=config),
|
|
49
|
+
)
|
|
50
|
+
assert service.key_prefix == "gw:y:"
|
|
51
|
+
assert service.default_ttl == 5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_backend_stats_are_counted_from_real_operations() -> None:
|
|
55
|
+
"""Hits, misses and entries come from the operations that happened."""
|
|
56
|
+
service = _service()
|
|
57
|
+
|
|
58
|
+
async def _exercise() -> None:
|
|
59
|
+
await service.set("hit", "value")
|
|
60
|
+
assert await service.get("hit") == "value"
|
|
61
|
+
assert await service.get("absent") is None
|
|
62
|
+
|
|
63
|
+
asyncio_run(_exercise())
|
|
64
|
+
stats = service.get_backend_stats()
|
|
65
|
+
assert stats == {"hits": 1, "misses": 1, "evictions": 0, "entries": 1}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_backend_stats_reports_store_evictions() -> None:
|
|
69
|
+
"""Filling past capacity is visible to the dashboard as evictions."""
|
|
70
|
+
service = _service()
|
|
71
|
+
|
|
72
|
+
async def _fill() -> None:
|
|
73
|
+
for index in range(6):
|
|
74
|
+
await service.set(f"key-{index}", index)
|
|
75
|
+
|
|
76
|
+
asyncio_run(_fill())
|
|
77
|
+
stats = service.get_backend_stats()
|
|
78
|
+
assert stats["entries"] == 3
|
|
79
|
+
assert stats["evictions"] == 3
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_backend_stats_are_zeroed_when_the_backend_cannot_report() -> None:
|
|
83
|
+
"""A backend without counters yields zeros, never fabricated activity."""
|
|
84
|
+
|
|
85
|
+
class _Opaque:
|
|
86
|
+
async def get(self, key: str) -> None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
service = CacheService(backend=_Opaque())
|
|
90
|
+
assert service.get_backend_stats() == {
|
|
91
|
+
"hits": 0,
|
|
92
|
+
"misses": 0,
|
|
93
|
+
"evictions": 0,
|
|
94
|
+
"entries": 0,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_backend_stats_never_raise() -> None:
|
|
99
|
+
"""A backend whose stats blow up reports zeros instead of failing a page."""
|
|
100
|
+
|
|
101
|
+
class _Exploding(MemoryCacheBackend):
|
|
102
|
+
def get_stats(self):
|
|
103
|
+
raise RuntimeError("stats unavailable")
|
|
104
|
+
|
|
105
|
+
service = CacheService(backend=_Exploding(max_size=1))
|
|
106
|
+
assert service.get_backend_stats()["entries"] == 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_reset_stats_clears_evictions_too() -> None:
|
|
110
|
+
"""Resetting counters clears the store's eviction count as well."""
|
|
111
|
+
service = _service()
|
|
112
|
+
|
|
113
|
+
async def _fill() -> None:
|
|
114
|
+
for index in range(6):
|
|
115
|
+
await service.set(f"key-{index}", index)
|
|
116
|
+
|
|
117
|
+
asyncio_run(_fill())
|
|
118
|
+
assert service.get_backend_stats()["evictions"] == 3
|
|
119
|
+
|
|
120
|
+
service.backend.reset_stats() # type: ignore[union-attr]
|
|
121
|
+
assert service.get_backend_stats()["evictions"] == 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def asyncio_run(coro) -> None:
|
|
125
|
+
"""Run a coroutine to completion without pytest-asyncio ceremony."""
|
|
126
|
+
import asyncio
|
|
127
|
+
|
|
128
|
+
asyncio.run(coro)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.parametrize("key_prefix", ["", "gw:user:"])
|
|
132
|
+
def test_prefix_round_trips_through_the_service(key_prefix: str) -> None:
|
|
133
|
+
"""The reported prefix is the one keys are actually built with."""
|
|
134
|
+
config = CacheOperationConfig(key_prefix=key_prefix)
|
|
135
|
+
service = CacheService(backend=MemoryCacheBackend(config=config))
|
|
136
|
+
assert service.key_prefix == key_prefix
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Guards for the ``StateStoreProtocol`` implementations.
|
|
2
|
+
|
|
3
|
+
``StateStoreProtocol`` is a plain ``typing.Protocol`` whose members carry
|
|
4
|
+
docstring-only bodies. Subclassing it therefore does **not** make Python enforce
|
|
5
|
+
anything at runtime: an implementation that forgets a method silently inherits
|
|
6
|
+
the stub, and calling that method returns ``None`` instead of raising. Only
|
|
7
|
+
mypy notices.
|
|
8
|
+
|
|
9
|
+
That is exactly what happened — ``MemoryStateStore`` shipped without
|
|
10
|
+
``expire``/``ttl``/``get_many``/``set_many`` and ``RedisStateStore`` without
|
|
11
|
+
``exists`` on top of those four, so three call sites carried
|
|
12
|
+
``# type: ignore[abstract]`` to silence the resulting
|
|
13
|
+
"cannot instantiate abstract class" errors.
|
|
14
|
+
|
|
15
|
+
The first test is the regression guard: it fails if any implementation starts
|
|
16
|
+
inheriting a protocol stub again. The rest pin the behaviour the protocol's
|
|
17
|
+
docstrings promise.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
from quadkit.cache.backends.memory_state import MemoryStateStore
|
|
28
|
+
from quadkit.cache.stores.redis_state import RedisStateStore
|
|
29
|
+
from quadkit.contracts.infra.state import StateStoreProtocol
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _protocol_methods() -> list[str]:
|
|
33
|
+
"""Names declared directly on the protocol (not inherited dunders)."""
|
|
34
|
+
return sorted(
|
|
35
|
+
name
|
|
36
|
+
for name, member in vars(StateStoreProtocol).items()
|
|
37
|
+
if not name.startswith("_") and inspect.isfunction(member)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _inherited_stub(cls: type, method: str) -> bool:
|
|
42
|
+
"""True when *cls* resolves *method* to the protocol's own stub body."""
|
|
43
|
+
fn = getattr(cls, method, None)
|
|
44
|
+
if fn is None:
|
|
45
|
+
return True
|
|
46
|
+
return getattr(fn, "__qualname__", "").startswith("StateStoreProtocol")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _FakeClient:
|
|
50
|
+
"""Minimal async stand-in for ``redis.asyncio.Redis``."""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self.exists_result: Any = 1
|
|
54
|
+
self.expire_result: Any = True
|
|
55
|
+
self.ttl_result: Any = 42
|
|
56
|
+
self.mget_result: Any = []
|
|
57
|
+
self.set_calls: list[tuple] = []
|
|
58
|
+
self.expire_calls: list[tuple] = []
|
|
59
|
+
|
|
60
|
+
async def exists(self, key: str) -> Any:
|
|
61
|
+
return self.exists_result
|
|
62
|
+
|
|
63
|
+
async def expire(self, key: str, seconds: int) -> Any:
|
|
64
|
+
self.expire_calls.append((key, seconds))
|
|
65
|
+
return self.expire_result
|
|
66
|
+
|
|
67
|
+
async def ttl(self, key: str) -> Any:
|
|
68
|
+
return self.ttl_result
|
|
69
|
+
|
|
70
|
+
async def mget(self, keys: list[str]) -> Any:
|
|
71
|
+
return self.mget_result
|
|
72
|
+
|
|
73
|
+
async def set(self, key: str, value: str, ex: int | None = None) -> Any:
|
|
74
|
+
self.set_calls.append((key, value, ex))
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize("cls", [MemoryStateStore, RedisStateStore])
|
|
79
|
+
def test_every_protocol_method_is_really_implemented(cls: type) -> None:
|
|
80
|
+
"""No implementation may resolve a method to the protocol's empty stub."""
|
|
81
|
+
methods = _protocol_methods()
|
|
82
|
+
assert methods, "StateStoreProtocol declared nothing — the guard is vacuous"
|
|
83
|
+
inherited = [m for m in methods if _inherited_stub(cls, m)]
|
|
84
|
+
assert inherited == [], f"{cls.__name__} inherits protocol stubs for {inherited}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestMemoryStateStoreContract:
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_ttl_reports_no_expiry(self) -> None:
|
|
90
|
+
store = MemoryStateStore()
|
|
91
|
+
await store.set("k", "v")
|
|
92
|
+
assert await store.ttl("k") == -1
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_ttl_reports_absent_key(self) -> None:
|
|
96
|
+
store = MemoryStateStore()
|
|
97
|
+
assert await store.ttl("nope") == -2
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_ttl_reports_remaining_seconds(self) -> None:
|
|
101
|
+
store = MemoryStateStore()
|
|
102
|
+
await store.set("k", "v", ttl=60)
|
|
103
|
+
remaining = await store.ttl("k")
|
|
104
|
+
assert 0 < remaining <= 60
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_expire_sets_a_new_deadline(self) -> None:
|
|
108
|
+
store = MemoryStateStore()
|
|
109
|
+
await store.set("k", "v")
|
|
110
|
+
assert await store.expire("k", 30) is True
|
|
111
|
+
remaining = await store.ttl("k")
|
|
112
|
+
assert 0 < remaining <= 30
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_expire_on_missing_key_returns_false(self) -> None:
|
|
116
|
+
store = MemoryStateStore()
|
|
117
|
+
assert await store.expire("nope", 30) is False
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_expire_on_expired_key_returns_false_and_reaps(self) -> None:
|
|
121
|
+
store = MemoryStateStore()
|
|
122
|
+
await store.set("k", "v", ttl=-1) # already in the past
|
|
123
|
+
assert await store.expire("k", 30) is False
|
|
124
|
+
assert await store.get("k") is None
|
|
125
|
+
|
|
126
|
+
@pytest.mark.asyncio
|
|
127
|
+
async def test_get_many_omits_absent_keys(self) -> None:
|
|
128
|
+
store = MemoryStateStore()
|
|
129
|
+
await store.set("a", 1)
|
|
130
|
+
await store.set("b", {"nested": True})
|
|
131
|
+
assert await store.get_many(["a", "b", "missing"]) == {
|
|
132
|
+
"a": 1,
|
|
133
|
+
"b": {"nested": True},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@pytest.mark.asyncio
|
|
137
|
+
async def test_set_many_applies_ttl_to_every_entry(self) -> None:
|
|
138
|
+
store = MemoryStateStore()
|
|
139
|
+
await store.set_many({"a": 1, "b": 2}, ttl=120)
|
|
140
|
+
assert await store.get_many(["a", "b"]) == {"a": 1, "b": 2}
|
|
141
|
+
for key in ("a", "b"):
|
|
142
|
+
remaining = await store.ttl(key)
|
|
143
|
+
assert 0 < remaining <= 120
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestRedisStateStoreContract:
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_exists_maps_truthiness(self) -> None:
|
|
149
|
+
client = _FakeClient()
|
|
150
|
+
store = RedisStateStore(client=client)
|
|
151
|
+
assert await store.exists("k") is True
|
|
152
|
+
client.exists_result = 0
|
|
153
|
+
assert await store.exists("k") is False
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_expire_forwards_key_and_seconds(self) -> None:
|
|
157
|
+
client = _FakeClient()
|
|
158
|
+
store = RedisStateStore(prefix="p:", client=client)
|
|
159
|
+
assert await store.expire("k", 90) is True
|
|
160
|
+
assert client.expire_calls == [("p:k", 90)]
|
|
161
|
+
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_expire_returns_false_when_key_missing(self) -> None:
|
|
164
|
+
client = _FakeClient()
|
|
165
|
+
client.expire_result = False
|
|
166
|
+
store = RedisStateStore(client=client)
|
|
167
|
+
assert await store.expire("k", 90) is False
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_ttl_passes_through_redis_sentinels(self) -> None:
|
|
171
|
+
client = _FakeClient()
|
|
172
|
+
store = RedisStateStore(client=client)
|
|
173
|
+
assert await store.ttl("k") == 42
|
|
174
|
+
client.ttl_result = -1
|
|
175
|
+
assert await store.ttl("k") == -1
|
|
176
|
+
client.ttl_result = -2
|
|
177
|
+
assert await store.ttl("k") == -2
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_get_many_returns_found_keys_only(self) -> None:
|
|
181
|
+
client = _FakeClient()
|
|
182
|
+
client.mget_result = ['{"a":1}', None]
|
|
183
|
+
store = RedisStateStore(client=client)
|
|
184
|
+
assert await store.get_many(["a", "b"]) == {"a": {"a": 1}}
|
|
185
|
+
|
|
186
|
+
@pytest.mark.asyncio
|
|
187
|
+
async def test_set_many_writes_each_entry_with_ttl(self) -> None:
|
|
188
|
+
client = _FakeClient()
|
|
189
|
+
store = RedisStateStore(client=client)
|
|
190
|
+
await store.set_many({"a": 1, "b": {"n": 2}}, ttl=15)
|
|
191
|
+
# Each entry is serialised on its own: `set_many` forwards the value,
|
|
192
|
+
# not a wrapper dict.
|
|
193
|
+
assert list(client.set_calls) == [
|
|
194
|
+
("a", b"1", 15),
|
|
195
|
+
("b", b'{"n":2}', 15),
|
|
196
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|