diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e387413..cfc5e1e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -68,7 +68,7 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} @@ -92,9 +92,11 @@ jobs: run: chmod +x gradlew - name: Build project - run: ./gradlew build + run: | + export GRADLE_OPTS="-Xmx3g" + ./gradlew build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3354d70..f5cf1cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,24 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Planned for Beta - -- Redis integration -- Edge cache providers (Cloudflare, AWS CloudFront, Fastly) -- Advanced metrics and monitoring -- Circuit breaker pattern -- Rate limiting -- Batch operations -- Cost tracking -- Web UI for cache management - -### Planned for 1.0 - -- Performance optimizations -- Enterprise features -- Advanced configuration options -- Comprehensive documentation -- Migration tools +## [0.2.0-beta] - 2026-01-12 + +### Added +- **Redis Integration**: Distributed caching support via `CacheFlowRedisConfiguration`. +- **Edge Cache Orchestration**: Automatic purging of Cloudflare, AWS CloudFront, and Fastly caches. +- **Russian Doll Pattern**: Local → Redis → Edge multi-level cache flow. +- **Advanced Metrics**: Micrometer integration for tracking hits, misses, and evictions per layer. +- **Async Operations**: Non-blocking Edge Cache purges using Kotlin Coroutines. + +### Changed +- Refactored `CacheFlowServiceImpl` to support tiered storage. +- Updated `CacheFlowCoreConfiguration` to inject optional Redis and Edge dependencies. + +### Fixed +- Improved test stability and added mock-based verification for distributed paths. ## [0.1.0-alpha] - 2024-12-19 diff --git a/README.md b/README.md index 5a7f913..bc5b693 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Kotlin](https://img.shields.io/badge/Kotlin-1.9.20-blue.svg)](https://kotlinlang.org) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.2.0-brightgreen.svg)](https://spring.io/projects/spring-boot) -[![Alpha](https://img.shields.io/badge/Status-Alpha-orange.svg)](https://github.com/mmorrison/cacheflow) +[![Beta](https://img.shields.io/badge/Status-Beta-blue.svg)](https://github.com/mmorrison/cacheflow) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com) -> ⚠️ **Alpha Release** - This is an early alpha version. Features and APIs may change before the stable release. +> ⚠️ **Beta Release** - This project is now in Beta. Core features are implemented and stable, but we are looking for community feedback. **CacheFlow** makes multi-level caching effortless. Data flows seamlessly through Local → Redis → Edge layers with automatic invalidation and monitoring. @@ -20,7 +20,7 @@ - ⚡ **Blazing Fast** - 10x faster than traditional caching - 🔄 **Auto-Invalidation** - Smart cache invalidation across all layers - 📊 **Rich Metrics** - Built-in monitoring and observability -- 🌐 **Edge Ready** - Cloudflare, AWS CloudFront, Fastly support (coming soon) +- 🌐 **Edge Ready** - Cloudflare, AWS CloudFront, Fastly support - 🛡️ **Production Ready** - Rate limiting, circuit breakers, batching ## 🚀 Quick Start @@ -150,28 +150,21 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## 🗺️ Roadmap -### Alpha (Current) +### Beta (Current) -- [x] Basic in-memory caching -- [x] AOP annotations (@CacheFlow, @CacheFlowEvict) -- [x] SpEL support -- [x] Management endpoints -- [x] Spring Boot auto-configuration - -### Beta (Planned) - -- [ ] Redis integration -- [ ] Advanced metrics and monitoring -- [ ] Circuit breaker pattern -- [ ] Rate limiting +- [x] Redis integration +- [x] Advanced metrics and monitoring +- [x] Circuit breaker pattern (Edge) +- [x] Rate limiting (Edge) +- [x] Russian Doll Caching logic ### 1.0 (Future) -- [ ] Edge cache providers (Cloudflare, AWS CloudFront, Fastly) -- [ ] Batch operations -- [ ] Cost tracking +- [ ] Batch operations (Core) +- [ ] Cost tracking (Extended) - [ ] Web UI for cache management - [ ] Performance optimizations +- [ ] Comprehensive documentation --- diff --git a/RUSSIAN_DOLL_CACHING_IMPLEMENTATION_PLAN.md b/RUSSIAN_DOLL_CACHING_IMPLEMENTATION_PLAN.md index 221beba..35859df 100644 --- a/RUSSIAN_DOLL_CACHING_IMPLEMENTATION_PLAN.md +++ b/RUSSIAN_DOLL_CACHING_IMPLEMENTATION_PLAN.md @@ -1,542 +1,66 @@ -# Russian Doll Caching Implementation Plan for CacheFlow +# Russian Doll Caching Implementation Plan (Level 3 Upgrade) -## Overview +## 📋 Strategy: "Distributed & Reactive" +We will focus on making the Russian Doll pattern robust in a distributed environment by moving state from local memory to Redis and implementing active communication between instances. -This document outlines a comprehensive plan to implement true Russian Doll Caching functionality in the CacheFlow Spring Boot Starter, inspired by Rails' fragment caching pattern. The implementation will add nested fragment caching, dependency-based invalidation, and granular cache regeneration capabilities. +--- -## Current State Analysis +### Phase 1: Robust Distributed State (Level 2 Completion) +**Goal:** Ensure dependencies and state persist across restarts and are shared between instances. -### ✅ Existing Strengths -- Multi-level caching architecture (Local → Redis → Edge) -- Annotation-based approach with `@CacheFlow` -- SpEL support for dynamic cache keys -- Tag-based eviction system -- AOP integration +#### 1. Redis-Backed Dependency Graph (⚠️ -> ✅) +* **Problem:** `CacheDependencyTracker` currently uses in-memory `ConcurrentHashMap`. Dependencies are lost on restart and isolated per instance. +* **Solution:** Refactor `CacheDependencyTracker` to use Redis Sets. + * **Data Structure:** + * `rd:deps:{cacheKey}` -> Set of `dependencyKeys` + * `rd:rev-deps:{dependencyKey}` -> Set of `cacheKeys` + * **Implementation:** Inject `StringRedisTemplate` into `CacheDependencyTracker`. Replace `dependencyGraph` and `reverseDependencyGraph` operations with `redisTemplate.opsForSet().add/remove/members`. + * **Optimization:** Use `pipelined` execution for batch operations to reduce network latency. + * **Maintenance:** Set default expiration (e.g., 24h) on dependency keys to prevent garbage accumulation. -### ❌ Missing Russian Doll Features -- Nested fragment caching -- Dependency resolution and automatic invalidation -- Cache key versioning with timestamps -- Fragment composition -- Granular regeneration +#### 2. Touch Propagation Mechanism (⚠️ -> ✅) +* **Problem:** `HasUpdatedAt` exists but isn't automatically updated. +* **Solution:** Implement an Aspect-based approach for flexibility. + * **Action:** Create `TouchPropagationAspect` targeting methods annotated with `@CacheFlowUpdate`. + * **Logic:** When a child is updated, identify the parent via configuration and update its `updatedAt` field. + * **Annotation:** Introduce `@CacheFlowUpdate(parent = "userId")` or similar to link actions to parent entities. -## Implementation Phases +--- -## Phase 1: Core Dependency Management (Weeks 1-2) +### Phase 2: Active Distributed Coordination (Level 3 - Pub/Sub) +**Goal:** Real-time synchronization of Layer 1 (Local) caches across the cluster. -### 1.1 Implement Dependency Resolution Engine +#### 3. Pub/Sub for Invalidation (❌ -> ✅) +* **Problem:** When Instance A updates Redis, Instance B's local in-memory cache remains stale until TTL expires. +* **Solution:** Implement Redis Pub/Sub. + * **Channel:** `cacheflow:invalidation` + * **Message:** JSON payload `{ "type": "EVICT", "keys": ["key1", "key2"], "origin": "instance-id" }`. + * **Publisher:** `CacheFlowServiceImpl` publishes a message after any `put` or `evict` operation. + * **Subscriber:** A `RedisMessageListenerContainer` bean that listens to the channel. Upon receipt (if `origin != self`), it evicts the keys from the *local* in-memory cache (L1) only. -**Files to Create/Modify:** -- `src/main/kotlin/io/cacheflow/spring/dependency/DependencyResolver.kt` -- `src/main/kotlin/io/cacheflow/spring/dependency/CacheDependencyTracker.kt` -- `src/main/kotlin/io/cacheflow/spring/aspect/CacheFlowAspect.kt` (modify) +--- -**Key Components:** +### Phase 3: Operational Excellence (Level 3 - Advanced) +**Goal:** Enhance usability and performance for production readiness. -```kotlin -// DependencyResolver.kt -interface DependencyResolver { - fun trackDependency(cacheKey: String, dependencyKey: String) - fun invalidateDependentCaches(dependencyKey: String): Set - fun getDependencies(cacheKey: String): Set -} +#### 4. Cache Warming & Preloading (❌ -> ✅) +* **Problem:** Cold caches lead to latency spikes on startup or after deployments. +* **Solution:** Add a "Warmer" interface and runner. + * **Interface:** `interface CacheWarmer { fun warm(cache: CacheFlowService) }`. + * **Runner:** A `CommandLineRunner` that auto-detects all `CacheWarmer` beans and executes them on startup. + * **Config:** Add properties `cacheflow.warming.enabled` (default `true`) and `cacheflow.warming.parallelism`. -// CacheDependencyTracker.kt -@Component -class CacheDependencyTracker : DependencyResolver { - private val dependencyGraph = ConcurrentHashMap>() - private val reverseDependencyGraph = ConcurrentHashMap>() - - override fun trackDependency(cacheKey: String, dependencyKey: String) { - // Implementation for tracking cache dependencies - } - - override fun invalidateDependentCaches(dependencyKey: String): Set { - // Implementation for cascading invalidation - } -} -``` +--- -**Tasks:** -- [ ] Create dependency tracking data structures -- [ ] Implement dependency resolution logic -- [ ] Add dependency tracking to CacheFlowAspect -- [ ] Create unit tests for dependency management -- [ ] Add integration tests for cascading invalidation +### 📅 Execution Roadmap -### 1.2 Enhance CacheFlowAspect for Dependencies +#### Week 1: Distributed Core +1. **Refactor `CacheDependencyTracker`:** Migrate from `ConcurrentHashMap` to `RedisTemplate` sets. (High Priority) +2. **Add `TouchPropagation`:** Implement `@CacheFlowUpdate` aspect for parent touching. -**Modifications to `CacheFlowAspect.kt`:** +#### Week 2: Real-time Sync +3. **Implement Pub/Sub:** Set up Redis Topic, Publisher, and Subscriber to clear L1 caches globally. (High Priority for consistency) -```kotlin -private fun processCacheFlow(joinPoint: ProceedingJoinPoint, cached: CacheFlow): Any? { - val key = generateCacheKeyFromExpression(cached.key, joinPoint) - if (key.isBlank()) return joinPoint.proceed() - - // Track dependencies - trackDependencies(key, cached.dependsOn, joinPoint) - - val cachedValue = cacheService.get(key) - return cachedValue ?: executeAndCache(joinPoint, key, cached) -} - -private fun trackDependencies(cacheKey: String, dependsOn: Array, joinPoint: ProceedingJoinPoint) { - dependsOn.forEach { paramName -> - val dependencyKey = generateDependencyKey(paramName, joinPoint) - dependencyResolver.trackDependency(cacheKey, dependencyKey) - } -} -``` - -## Phase 2: Fragment Caching System (Weeks 3-4) - -### 2.1 Create Fragment Caching Annotations - -**Files to Create:** -- `src/main/kotlin/io/cacheflow/spring/annotation/CacheFlowFragment.kt` -- `src/main/kotlin/io/cacheflow/spring/annotation/CacheFlowComposition.kt` - -```kotlin -// CacheFlowFragment.kt -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class CacheFlowFragment( - val key: String = "", - val template: String = "", - val versioned: Boolean = false, - val dependsOn: Array = [], - val tags: Array = [], - val ttl: Long = -1 -) - -// CacheFlowComposition.kt -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class CacheFlowComposition( - val fragments: Array = [], - val key: String = "", - val ttl: Long = -1 -) -``` - -### 2.2 Implement Fragment Cache Service - -**Files to Create:** -- `src/main/kotlin/io/cacheflow/spring/fragment/FragmentCacheService.kt` -- `src/main/kotlin/io/cacheflow/spring/fragment/impl/FragmentCacheServiceImpl.kt` - -```kotlin -// FragmentCacheService.kt -interface FragmentCacheService { - fun cacheFragment(key: String, fragment: String, ttl: Long) - fun getFragment(key: String): String? - fun composeFragments(fragmentKeys: List): String - fun invalidateFragment(key: String) - fun invalidateFragmentsByTag(tag: String) -} -``` - -### 2.3 Create Fragment Aspect - -**Files to Create:** -- `src/main/kotlin/io/cacheflow/spring/aspect/FragmentCacheAspect.kt` - -**Tasks:** -- [ ] Implement fragment caching annotations -- [ ] Create fragment cache service -- [ ] Add fragment composition logic -- [ ] Implement fragment aspect -- [ ] Add comprehensive tests - -## Phase 3: Cache Key Versioning (Weeks 5-6) - -### 3.1 Implement Versioned Cache Keys - -**Files to Create/Modify:** -- `src/main/kotlin/io/cacheflow/spring/versioning/CacheKeyVersioner.kt` -- `src/main/kotlin/io/cacheflow/spring/versioning/TimestampExtractor.kt` -- `src/main/kotlin/io/cacheflow/spring/aspect/CacheFlowAspect.kt` (modify) - -```kotlin -// CacheKeyVersioner.kt -@Component -class CacheKeyVersioner { - fun generateVersionedKey(baseKey: String, obj: Any?): String { - val timestamp = extractTimestamp(obj) - return if (timestamp != null) { - "$baseKey-v$timestamp" - } else { - baseKey - } - } - - private fun extractTimestamp(obj: Any?): Long? { - // Extract updatedAt timestamp from objects - return when (obj) { - is TemporalAccessor -> obj.toEpochMilli() - is HasUpdatedAt -> obj.updatedAt?.toEpochMilli() - else -> null - } - } -} - -// TimestampExtractor.kt -interface TimestampExtractor { - fun extractTimestamp(obj: Any?): Long? -} - -@Component -class DefaultTimestampExtractor : TimestampExtractor { - override fun extractTimestamp(obj: Any?): Long? { - // Implementation for extracting timestamps from various object types - } -} -``` - -### 3.2 Add Versioning Support to Annotations - -**Modifications to existing annotations:** - -```kotlin -// Enhanced CacheFlow annotation -annotation class CacheFlow( - val key: String = "", - val versioned: Boolean = false, // New parameter - val timestampField: String = "updatedAt", // New parameter - // ... existing parameters -) -``` - -**Tasks:** -- [ ] Implement timestamp extraction logic -- [ ] Add versioning to cache key generation -- [ ] Create timestamp extractor interface -- [ ] Add versioning support to annotations -- [ ] Update aspect to use versioned keys - -## Phase 4: Granular Invalidation System (Weeks 7-8) - -### 4.1 Implement Granular Invalidation - -**Files to Create:** -- `src/main/kotlin/io/cacheflow/spring/invalidation/GranularInvalidator.kt` -- `src/main/kotlin/io/cacheflow/spring/invalidation/InvalidationStrategy.kt` - -```kotlin -// GranularInvalidator.kt -@Component -class GranularInvalidator( - private val cacheService: CacheFlowService, - private val dependencyResolver: DependencyResolver -) { - fun invalidateGranularly( - rootKey: String, - strategy: InvalidationStrategy = InvalidationStrategy.CASCADE - ) { - when (strategy) { - InvalidationStrategy.CASCADE -> invalidateCascade(rootKey) - InvalidationStrategy.SELECTIVE -> invalidateSelective(rootKey) - InvalidationStrategy.FRAGMENT_ONLY -> invalidateFragmentOnly(rootKey) - } - } - - private fun invalidateCascade(rootKey: String) { - val dependentKeys = dependencyResolver.invalidateDependentCaches(rootKey) - dependentKeys.forEach { cacheService.evict(it) } - } -} - -// InvalidationStrategy.kt -enum class InvalidationStrategy { - CASCADE, // Invalidate all dependent caches - SELECTIVE, // Invalidate only directly dependent caches - FRAGMENT_ONLY // Invalidate only fragment caches -} -``` - -### 4.2 Enhanced CacheFlowEvict Annotation - -**Modifications to `CacheFlowEvict.kt`:** - -```kotlin -annotation class CacheFlowEvict( - val key: String = "", - val tags: Array = [], - val allEntries: Boolean = false, - val beforeInvocation: Boolean = false, - val condition: String = "", - val strategy: InvalidationStrategy = InvalidationStrategy.CASCADE, // New parameter - val cascade: Array = [] // New parameter for specific cascading -) -``` - -**Tasks:** -- [ ] Implement granular invalidation logic -- [ ] Create invalidation strategies -- [ ] Enhance CacheFlowEvict annotation -- [ ] Add cascade invalidation support -- [ ] Create invalidation tests - -## Phase 5: Fragment Composition Engine (Weeks 9-10) - -### 5.1 Implement Fragment Composition - -**Files to Create:** -- `src/main/kotlin/io/cacheflow/spring/composition/FragmentComposer.kt` -- `src/main/kotlin/io/cacheflow/spring/composition/CompositionTemplate.kt` - -```kotlin -// FragmentComposer.kt -@Component -class FragmentComposer( - private val fragmentCacheService: FragmentCacheService -) { - fun composeFragments( - template: String, - fragments: Map - ): String { - var result = template - fragments.forEach { (placeholder, fragment) -> - result = result.replace("{{$placeholder}}", fragment) - } - return result - } - - fun composeWithCaching( - compositionKey: String, - template: String, - fragmentKeys: List - ): String { - val fragments = fragmentKeys.mapNotNull { key -> - fragmentCacheService.getFragment(key) - } - return composeFragments(template, fragments.associateByIndexed { i, _ -> "fragment$i" to it }) - } -} - -// CompositionTemplate.kt -data class CompositionTemplate( - val name: String, - val template: String, - val fragmentPlaceholders: List -) -``` - -### 5.2 Add Composition Support to Aspect - -**Modifications to `CacheFlowAspect.kt`:** - -```kotlin -@Around("@annotation(io.cacheflow.spring.annotation.CacheFlowComposition)") -fun aroundComposition(joinPoint: ProceedingJoinPoint): Any? { - val method = (joinPoint.signature as MethodSignature).method - val composition = method.getAnnotation(CacheFlowComposition::class.java) ?: return joinPoint.proceed() - - return processComposition(joinPoint, composition) -} - -private fun processComposition(joinPoint: ProceedingJoinPoint, composition: CacheFlowComposition): Any? { - val key = generateCacheKeyFromExpression(composition.key, joinPoint) - if (key.isBlank()) return joinPoint.proceed() - - val cachedValue = cacheService.get(key) - return cachedValue ?: executeAndCompose(joinPoint, key, composition) -} -``` - -**Tasks:** -- [ ] Implement fragment composition logic -- [ ] Create composition templates -- [ ] Add composition aspect support -- [ ] Create composition caching -- [ ] Add composition tests - -## Phase 6: Integration and Testing (Weeks 11-12) - -### 6.1 Integration Testing - -**Files to Create:** -- `src/test/kotlin/io/cacheflow/spring/integration/RussianDollCachingIntegrationTest.kt` -- `src/test/kotlin/io/cacheflow/spring/integration/FragmentCachingIntegrationTest.kt` - -```kotlin -// RussianDollCachingIntegrationTest.kt -@SpringBootTest -class RussianDollCachingIntegrationTest { - - @Test - fun `should implement russian doll caching pattern`() { - // Test nested fragment caching - // Test dependency invalidation - // Test granular regeneration - // Test fragment composition - } - - @Test - fun `should handle cascading invalidation correctly`() { - // Test that changing a user invalidates user fragments - // but not unrelated fragments - } -} -``` - -### 6.2 Performance Testing - -**Files to Create:** -- `src/test/kotlin/io/cacheflow/spring/performance/RussianDollPerformanceTest.kt` - -```kotlin -@SpringBootTest -class RussianDollPerformanceTest { - - @Test - fun `should demonstrate performance benefits of russian doll caching`() { - // Benchmark traditional caching vs Russian Doll caching - // Measure cache hit rates - // Measure invalidation performance - } -} -``` - -### 6.3 Documentation and Examples - -**Files to Create:** -- `docs/RUSSIAN_DOLL_CACHING_GUIDE.md` -- `docs/examples/RussianDollCachingExamples.kt` -- `docs/examples/application-russian-doll-example.yml` - -**Tasks:** -- [ ] Create comprehensive integration tests -- [ ] Add performance benchmarks -- [ ] Write detailed documentation -- [ ] Create practical examples -- [ ] Update README with Russian Doll features - -## Phase 7: Advanced Features (Weeks 13-14) - -### 7.1 Smart Invalidation - -**Files to Create:** -- `src/main/kotlin/io/cacheflow/spring/smart/SmartInvalidator.kt` -- `src/main/kotlin/io/cacheflow/spring/smart/InvalidationRule.kt` - -```kotlin -// SmartInvalidator.kt -@Component -class SmartInvalidator { - fun shouldInvalidate( - changedObject: Any, - cacheKey: String, - rules: List - ): Boolean { - return rules.any { rule -> rule.matches(changedObject, cacheKey) } - } -} - -// InvalidationRule.kt -data class InvalidationRule( - val condition: String, // SpEL expression - val action: InvalidationAction -) - -enum class InvalidationAction { - INVALIDATE_IMMEDIATELY, - INVALIDATE_ON_NEXT_ACCESS, - SKIP_INVALIDATION -} -``` - -### 7.2 Cache Warming - -**Files to Create:** -- `src/main/kotlin/io/cacheflow/spring/warming/CacheWarmer.kt` -- `src/main/kotlin/io/cacheflow/spring/warming/WarmingStrategy.kt` - -```kotlin -// CacheWarmer.kt -@Component -class CacheWarmer( - private val cacheService: CacheFlowService, - private val fragmentCacheService: FragmentCacheService -) { - fun warmCache(warmingStrategy: WarmingStrategy) { - when (warmingStrategy) { - is WarmingStrategy.Preload -> preloadCache(warmingStrategy.keys) - is WarmingStrategy.OnDemand -> setupOnDemandWarming(warmingStrategy.triggers) - } - } -} -``` - -**Tasks:** -- [ ] Implement smart invalidation rules -- [ ] Add cache warming capabilities -- [ ] Create advanced invalidation strategies -- [ ] Add cache warming tests - -## Implementation Timeline - -| Phase | Duration | Key Deliverables | -|-------|----------|------------------| -| Phase 1 | 2 weeks | Dependency resolution engine | -| Phase 2 | 2 weeks | Fragment caching system | -| Phase 3 | 2 weeks | Cache key versioning | -| Phase 4 | 2 weeks | Granular invalidation | -| Phase 5 | 2 weeks | Fragment composition | -| Phase 6 | 2 weeks | Integration & testing | -| Phase 7 | 2 weeks | Advanced features | - -**Total Duration: 14 weeks (3.5 months)** - -## Success Metrics - -### Functional Requirements -- [ ] Support for nested fragment caching -- [ ] Automatic dependency-based invalidation -- [ ] Cache key versioning with timestamps -- [ ] Granular cache regeneration -- [ ] Fragment composition capabilities - -### Performance Requirements -- [ ] 95%+ cache hit rate for nested fragments -- [ ] <10ms invalidation time for dependent caches -- [ ] 50% reduction in cache misses compared to traditional caching -- [ ] Support for 10,000+ concurrent fragment operations - -### Quality Requirements -- [ ] 90%+ test coverage for new features -- [ ] Comprehensive documentation -- [ ] Backward compatibility with existing CacheFlow features -- [ ] Performance benchmarks and monitoring - -## Risk Mitigation - -### Technical Risks -1. **Complexity**: Russian Doll caching is inherently complex - - *Mitigation*: Implement in phases, extensive testing -2. **Performance**: Dependency tracking overhead - - *Mitigation*: Optimize data structures, lazy evaluation -3. **Memory Usage**: Fragment storage requirements - - *Mitigation*: Implement TTL, compression, cleanup strategies - -### Implementation Risks -1. **Breaking Changes**: Modifying existing APIs - - *Mitigation*: Maintain backward compatibility, deprecation strategy -2. **Testing Complexity**: Complex dependency scenarios - - *Mitigation*: Comprehensive test suite, integration tests -3. **Documentation**: Complex feature documentation - - *Mitigation*: Examples, tutorials, step-by-step guides - -## Next Steps - -1. **Review and Approve Plan**: Get stakeholder approval for the implementation plan -2. **Set Up Development Environment**: Prepare development and testing infrastructure -3. **Begin Phase 1**: Start with dependency resolution engine implementation -4. **Regular Reviews**: Weekly progress reviews and adjustments -5. **Community Feedback**: Early feedback from users and contributors - -## Conclusion - -This implementation plan provides a comprehensive roadmap for adding true Russian Doll Caching functionality to CacheFlow. The phased approach ensures manageable development cycles while building toward a robust, production-ready feature set that matches the spirit and functionality of Rails' fragment caching. - -The plan balances ambitious feature goals with practical implementation considerations, ensuring that CacheFlow becomes a leading caching solution for Spring Boot applications with advanced fragment caching capabilities. +#### Week 3: Polish +4. **Implement Cache Warming:** Create the warmer interface and runner infrastructure. +5. **Documentation:** Update docs to explain the distributed architecture and new configurations. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index d4d0afe..00732e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ plugins { group = "io.cacheflow" -version = "0.1.0-alpha" +version = "0.2.0-beta" tasks.bootJar { enabled = false diff --git a/docs/DISTRIBUTED_AND_REACTIVE_STRATEGY.md b/docs/DISTRIBUTED_AND_REACTIVE_STRATEGY.md new file mode 100644 index 0000000..b01fe04 --- /dev/null +++ b/docs/DISTRIBUTED_AND_REACTIVE_STRATEGY.md @@ -0,0 +1,78 @@ +# Distributed & Reactive CacheFlow Strategy + +> **Goal:** Elevate CacheFlow to Level 3 maturity by implementing robust distributed state management, real-time coordination, and operational excellence features. + +## 📋 Strategy: "Distributed & Reactive" + +We will focus on making the Russian Doll pattern robust in a distributed environment by moving state from local memory to Redis and implementing active communication between instances. + +--- + +### Phase 1: Robust Distributed State (Level 2 Completion) +**Goal:** Ensure dependencies and state persist across restarts and are shared between instances. + +#### 1. Redis-Backed Dependency Graph (⚠️ -> ✅) +* **Problem:** `CacheDependencyTracker` currently uses in-memory `ConcurrentHashMap`. Dependencies are lost on restart and isolated per instance. +* **Solution:** Refactor `CacheDependencyTracker` to use Redis Sets. + * **Data Structure:** + * `rd:deps:{cacheKey}` -> Set of `dependencyKeys` + * `rd:rev-deps:{dependencyKey}` -> Set of `cacheKeys` + * **Implementation:** Inject `StringRedisTemplate` into `CacheDependencyTracker`. Replace `dependencyGraph` and `reverseDependencyGraph` operations with `redisTemplate.opsForSet().add/remove/members`. + * **Optimization:** Use `pipelined` execution for batch operations to reduce network latency. + * **Maintenance:** Set default expiration (e.g., 24h) on dependency keys to prevent garbage accumulation. + +#### 2. Touch Propagation Mechanism (⚠️ -> ✅) +* **Problem:** `HasUpdatedAt` exists but isn't automatically updated. +* **Solution:** Implement an Aspect-based approach for flexibility. + * **Action:** Create `TouchPropagationAspect` targeting methods annotated with `@CacheFlowUpdate`. + * **Logic:** When a child is updated, identify the parent via configuration and update its `updatedAt` field. + * **Annotation:** Introduce `@CacheFlowUpdate(parent = "userId")` or similar to link actions to parent entities. + +--- + +### Phase 2: Active Distributed Coordination (Level 3 - Pub/Sub) +**Goal:** Real-time synchronization of Layer 1 (Local) caches across the cluster. + +#### 3. Pub/Sub for Invalidation (❌ -> ✅) +* **Problem:** When Instance A updates Redis, Instance B's local in-memory cache remains stale until TTL expires. +* **Solution:** Implement Redis Pub/Sub. + * **Channel:** `cacheflow:invalidation` + * **Message:** JSON payload `{ "type": "EVICT", "keys": ["key1", "key2"], "origin": "instance-id" }`. + * **Publisher:** `CacheFlowServiceImpl` publishes a message after any `put` or `evict` operation. + * **Subscriber:** A `RedisMessageListenerContainer` bean that listens to the channel. Upon receipt (if `origin != self`), it evicts the keys from the *local* in-memory cache (L1) only. + +--- + +### Phase 3: Operational Excellence (Level 3 - Advanced) +**Goal:** Enhance usability and performance for production readiness. + +#### 4. Cache Warming & Preloading (❌ -> ✅) +* **Problem:** Cold caches lead to latency spikes on startup or after deployments. +* **Solution:** Add a "Warmer" interface and runner. + * **Interface:** `interface CacheWarmer { fun warm(cache: CacheFlowService) }`. + * **Runner:** A `CommandLineRunner` that auto-detects all `CacheWarmer` beans and executes them on startup. + * **Config:** Add properties `cacheflow.warming.enabled` (default `true`) and `cacheflow.warming.parallelism`. + +#### 5. Tag-Based Cache Eviction (❌ -> ✅) +* **Problem:** `evictByTags()` currently clears the entire local cache (aggressive) and doesn't support tag eviction for Redis. Only Edge cache properly supports tag-based eviction. +* **Solution:** Implement proper tag tracking for Local and Redis caches. + * **Options:** + * Add tag metadata to `CacheEntry` and maintain a tag→keys index in both local and Redis storage. + * Alternatively, document current behavior as a known limitation and make it configurable. + * **Current Workaround:** Local cache calls `cache.clear()` on tag eviction to ensure consistency (safe but aggressive). + * **Location:** `CacheFlowServiceImpl.evictByTags()` (line 190) + +--- + +### 📅 Execution Roadmap + +#### Week 1: Distributed Core +1. **Refactor `CacheDependencyTracker`:** Migrate from `ConcurrentHashMap` to `RedisTemplate` sets. (High Priority) +2. **Add `TouchPropagation`:** Implement `@CacheFlowUpdate` aspect for parent touching. + +#### Week 2: Real-time Sync +3. **Implement Pub/Sub:** Set up Redis Topic, Publisher, and Subscriber to clear L1 caches globally. (High Priority for consistency) + +#### Week 3: Polish +4. **Implement Cache Warming:** Create the warmer interface and runner infrastructure. +5. **Documentation:** Update docs to explain the distributed architecture and new configurations. diff --git a/docs/TAG_BASED_EVICTION_TECHNICAL_DESIGN.md b/docs/TAG_BASED_EVICTION_TECHNICAL_DESIGN.md new file mode 100644 index 0000000..86eaf56 --- /dev/null +++ b/docs/TAG_BASED_EVICTION_TECHNICAL_DESIGN.md @@ -0,0 +1,45 @@ +# Tag-Based Eviction Technical Design + +## 📋 Overview +Currently, CacheFlow's tag-based eviction is only fully supported at the Edge layer. The Local (L1) and Redis (L2) layers lack the necessary metadata and indexing to perform efficient tag-based purges, currently resorting to aggressive cache clearing. + +## 🛠️ Required Changes + +### 1. Metadata Enhancement +The `CacheEntry` needs to store the tags associated with the value at the time of insertion. + +```kotlin +data class CacheEntry( + val value: Any, + val expiresAt: Long, + val tags: Set = emptySet() // Added metadata +) +``` + +### 2. Local Indexing (L1) +To avoid scanning the entire `ConcurrentHashMap` during eviction, we need a reverse index: `Map>`. + +- **Implementation:** Use `ConcurrentHashMap>` for the tag index. +- **Maintenance:** + - `put`: Add key to index for each tag. + - `evict`: Remove key from index. + - `get`: Clean up index if entry is found to be expired. + +### 3. Redis Indexing (L2) +Use Redis Sets to store the relationship between tags and keys. + +- **Key Pattern:** `rd:tag:{tagName}` -> Set of cache keys. +- **Operations:** + - `SADD` on `put`. + - `SREM` on `evict`. + - `SMEMBERS` + `DEL` on `evictByTags`. + +### 4. Consistency Considerations +- **Orchestration:** When `evictByTags` is called, it must propagate through all three layers (Local Index -> Redis Index -> Edge API). +- **Race Conditions:** Use atomic Redis operations (or Lua scripts) to ensure the tag index stays in sync with the actual data keys. + +## 📅 Implementation Steps +1. **Update `CacheFlowServiceImpl`**: Store tags in `CacheEntry` and maintain a local `tagIndex`. +2. **Update Redis Logic**: Implement `SADD` and `SMEMBERS` logic in the service. +3. **Refactor `CacheFlowAspect`**: Extract tags from the `@CacheFlow` annotation and pass them to the `put` method. +4. **Testing**: Add specific tests for partial eviction (e.g., evicting "users" tag should not affect "products" entries). diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5c3f450 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +# Gradle properties +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.daemon=true +org.gradle.configuration-cache=false + +# SonarQube configuration +sonar.gradle.skipCompile=true + +# Kotlin configuration +kotlin.code.style=official +kotlin.incremental=true diff --git a/src/main/kotlin/io/cacheflow/spring/annotation/CacheFlowComposition.kt b/src/main/kotlin/io/cacheflow/spring/annotation/CacheFlowComposition.kt index 13196d7..5290e32 100644 --- a/src/main/kotlin/io/cacheflow/spring/annotation/CacheFlowComposition.kt +++ b/src/main/kotlin/io/cacheflow/spring/annotation/CacheFlowComposition.kt @@ -26,4 +26,6 @@ annotation class CacheFlowComposition( val template: String = "", /** Time to live for the composed result in seconds. */ val ttl: Long = -1, + /** Array of tags for group-based eviction. */ + val tags: Array = [], ) diff --git a/src/main/kotlin/io/cacheflow/spring/aspect/CacheFlowAspect.kt b/src/main/kotlin/io/cacheflow/spring/aspect/CacheFlowAspect.kt index 93fd37b..25516ac 100644 --- a/src/main/kotlin/io/cacheflow/spring/aspect/CacheFlowAspect.kt +++ b/src/main/kotlin/io/cacheflow/spring/aspect/CacheFlowAspect.kt @@ -91,7 +91,7 @@ class CacheFlowAspect( val result = joinPoint.proceed() if (result != null) { val ttl = if (config.ttl > 0) config.ttl else defaultTtlSeconds - cacheService.put(key, result, ttl) + cacheService.put(key, result, ttl, config.tags.toSet()) } return result } diff --git a/src/main/kotlin/io/cacheflow/spring/aspect/FragmentCacheAspect.kt b/src/main/kotlin/io/cacheflow/spring/aspect/FragmentCacheAspect.kt index 913172f..f5ad957 100644 --- a/src/main/kotlin/io/cacheflow/spring/aspect/FragmentCacheAspect.kt +++ b/src/main/kotlin/io/cacheflow/spring/aspect/FragmentCacheAspect.kt @@ -82,12 +82,17 @@ class FragmentCacheAspect( val result = joinPoint.proceed() if (result is String) { val ttl = if (fragment.ttl > 0) fragment.ttl else defaultTtlSeconds - fragmentCacheService.cacheFragment(key, result, ttl) - - // Add tags if specified - fragment.tags.forEach { tag -> - val evaluatedTag = evaluateFragmentKeyExpression(tag, joinPoint) - tagManager.addFragmentTag(key, evaluatedTag) + + // Evaluate tags + val evaluatedTags = fragment.tags.map { tag -> + evaluateFragmentKeyExpression(tag, joinPoint) + }.filter { it.isNotBlank() }.toSet() + + fragmentCacheService.cacheFragment(key, result, ttl, evaluatedTags) + + // Add tags to local tag manager for local tracking + evaluatedTags.forEach { tag -> + tagManager.addFragmentTag(key, tag) } } return result @@ -132,7 +137,13 @@ class FragmentCacheAspect( return if (composedResult.isNotBlank()) { val ttl = if (composition.ttl > 0) composition.ttl else defaultTtlSeconds - fragmentCacheService.cacheFragment(key, composedResult, ttl) + + // Evaluate tags for composition + val evaluatedTags = composition.tags.map { tag -> + evaluateFragmentKeyExpression(tag, joinPoint) + }.filter { it.isNotBlank() }.toSet() + + fragmentCacheService.cacheFragment(key, composedResult, ttl, evaluatedTags) composedResult } else { null diff --git a/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfiguration.kt b/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfiguration.kt index eac0155..b1eab89 100644 --- a/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfiguration.kt +++ b/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfiguration.kt @@ -26,5 +26,6 @@ import org.springframework.context.annotation.Import CacheFlowFragmentConfiguration::class, CacheFlowAspectConfiguration::class, CacheFlowManagementConfiguration::class, + CacheFlowRedisConfiguration::class, ) class CacheFlowAutoConfiguration diff --git a/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowCoreConfiguration.kt b/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowCoreConfiguration.kt index 14bf2f2..1743cd1 100644 --- a/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowCoreConfiguration.kt +++ b/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowCoreConfiguration.kt @@ -1,16 +1,22 @@ package io.cacheflow.spring.autoconfigure import io.cacheflow.spring.annotation.CacheFlowConfigRegistry +import io.cacheflow.spring.config.CacheFlowProperties import io.cacheflow.spring.dependency.CacheDependencyTracker import io.cacheflow.spring.dependency.DependencyResolver +import io.cacheflow.spring.edge.service.EdgeCacheIntegrationService import io.cacheflow.spring.service.CacheFlowService import io.cacheflow.spring.service.impl.CacheFlowServiceImpl import io.cacheflow.spring.versioning.CacheKeyVersioner import io.cacheflow.spring.versioning.TimestampExtractor import io.cacheflow.spring.versioning.impl.DefaultTimestampExtractor +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.core.RedisTemplate /** * Core configuration for CacheFlow services. @@ -23,11 +29,20 @@ class CacheFlowCoreConfiguration { /** * Creates the CacheFlow service bean. * + * @param properties Cache configuration properties + * @param redisTemplate Optional Redis template for distributed caching + * @param edgeCacheService Optional Edge cache service for edge integration + * @param meterRegistry Optional MeterRegistry for metrics * @return The CacheFlow service implementation */ @Bean @ConditionalOnMissingBean - fun cacheFlowService(): CacheFlowService = CacheFlowServiceImpl() + fun cacheFlowService( + properties: CacheFlowProperties, + @Autowired(required = false) @Qualifier("cacheFlowRedisTemplate") redisTemplate: RedisTemplate?, + @Autowired(required = false) edgeCacheService: EdgeCacheIntegrationService?, + @Autowired(required = false) meterRegistry: MeterRegistry?, + ): CacheFlowService = CacheFlowServiceImpl(properties, redisTemplate, edgeCacheService, meterRegistry) /** * Creates the dependency resolver bean. diff --git a/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowRedisConfiguration.kt b/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowRedisConfiguration.kt new file mode 100644 index 0000000..8fde22d --- /dev/null +++ b/src/main/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowRedisConfiguration.kt @@ -0,0 +1,31 @@ +package io.cacheflow.spring.autoconfigure + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer +import com.fasterxml.jackson.databind.ObjectMapper + +@Configuration +@ConditionalOnClass(RedisTemplate::class, ObjectMapper::class) +@ConditionalOnProperty(prefix = "cacheflow", name = ["storage"], havingValue = "REDIS") +class CacheFlowRedisConfiguration { + + @Bean + @ConditionalOnMissingBean(name = ["cacheFlowRedisTemplate"]) + fun cacheFlowRedisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate { + val template = RedisTemplate() + template.connectionFactory = connectionFactory + template.keySerializer = StringRedisSerializer() + template.valueSerializer = GenericJackson2JsonRedisSerializer() + template.hashKeySerializer = StringRedisSerializer() + template.hashValueSerializer = GenericJackson2JsonRedisSerializer() + template.afterPropertiesSet() + return template + } +} diff --git a/src/main/kotlin/io/cacheflow/spring/fragment/FragmentStorageService.kt b/src/main/kotlin/io/cacheflow/spring/fragment/FragmentStorageService.kt index 13b271e..e48fc98 100644 --- a/src/main/kotlin/io/cacheflow/spring/fragment/FragmentStorageService.kt +++ b/src/main/kotlin/io/cacheflow/spring/fragment/FragmentStorageService.kt @@ -13,11 +13,13 @@ interface FragmentStorageService { * @param key The fragment cache key * @param fragment The fragment content to cache * @param ttl Time to live in seconds + * @param tags Tags associated with this fragment */ fun cacheFragment( key: String, fragment: String, ttl: Long, + tags: Set = emptySet(), ) /** diff --git a/src/main/kotlin/io/cacheflow/spring/fragment/impl/FragmentCacheServiceImpl.kt b/src/main/kotlin/io/cacheflow/spring/fragment/impl/FragmentCacheServiceImpl.kt index b2ebe4a..1a3f095 100644 --- a/src/main/kotlin/io/cacheflow/spring/fragment/impl/FragmentCacheServiceImpl.kt +++ b/src/main/kotlin/io/cacheflow/spring/fragment/impl/FragmentCacheServiceImpl.kt @@ -24,9 +24,10 @@ class FragmentCacheServiceImpl( key: String, fragment: String, ttl: Long, + tags: Set, ) { val fragmentKey = buildFragmentKey(key) - cacheService.put(fragmentKey, fragment, ttl) + cacheService.put(fragmentKey, fragment, ttl, tags) } override fun getFragment(key: String): String? { @@ -51,8 +52,9 @@ class FragmentCacheServiceImpl( } override fun invalidateFragmentsByTag(tag: String) { + cacheService.evictByTags(tag) val fragmentKeys = tagManager.getFragmentsByTag(tag).toList() - fragmentKeys.forEach { key -> invalidateFragment(key) } + fragmentKeys.forEach { key -> tagManager.removeFragmentFromAllTags(key) } } override fun invalidateAllFragments() { @@ -76,4 +78,4 @@ class FragmentCacheServiceImpl( } private fun buildFragmentKey(key: String): String = "$fragmentPrefix$key" -} +} \ No newline at end of file diff --git a/src/main/kotlin/io/cacheflow/spring/service/CacheFlowService.kt b/src/main/kotlin/io/cacheflow/spring/service/CacheFlowService.kt index db2a289..f14cb7b 100644 --- a/src/main/kotlin/io/cacheflow/spring/service/CacheFlowService.kt +++ b/src/main/kotlin/io/cacheflow/spring/service/CacheFlowService.kt @@ -16,11 +16,13 @@ interface CacheFlowService { * @param key The cache key * @param value The value to cache * @param ttl Time to live in seconds + * @param tags Tags associated with this cache entry */ fun put( key: String, value: Any, ttl: Long = 3_600, + tags: Set = emptySet(), ) /** diff --git a/src/main/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImpl.kt b/src/main/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImpl.kt index e2985e7..07cc7b4 100644 --- a/src/main/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImpl.kt +++ b/src/main/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImpl.kt @@ -1,24 +1,82 @@ package io.cacheflow.spring.service.impl +import io.cacheflow.spring.config.CacheFlowProperties +import io.cacheflow.spring.edge.service.EdgeCacheIntegrationService import io.cacheflow.spring.service.CacheFlowService +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Service import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit -/** Simple in-memory implementation of CacheFlowService. */ +/** Implementation of CacheFlowService supporting Local -> Redis -> Edge layering. */ @Service -class CacheFlowServiceImpl : CacheFlowService { +class CacheFlowServiceImpl( + private val properties: CacheFlowProperties, + private val redisTemplate: RedisTemplate? = null, + private val edgeCacheService: EdgeCacheIntegrationService? = null, + private val meterRegistry: MeterRegistry? = null, +) : CacheFlowService { + private val logger = LoggerFactory.getLogger(CacheFlowServiceImpl::class.java) private val cache = ConcurrentHashMap() + private val localTagIndex = ConcurrentHashMap>() private val millisecondsPerSecond = 1_000L + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val localHits: Counter? = meterRegistry?.counter("cacheflow.local.hits") + private val localMisses: Counter? = meterRegistry?.counter("cacheflow.local.misses") + private val redisHits: Counter? = meterRegistry?.counter("cacheflow.redis.hits") + private val redisMisses: Counter? = meterRegistry?.counter("cacheflow.redis.misses") + private val puts: Counter? = meterRegistry?.counter("cacheflow.puts") + private val evictions: Counter? = meterRegistry?.counter("cacheflow.evictions") + + private val isRedisEnabled: Boolean + get() = properties.storage == CacheFlowProperties.StorageType.REDIS && redisTemplate != null override fun get(key: String): Any? { - val entry = cache[key] ?: return null + // 1. Check Local Cache + val localEntry = cache[key] + if (localEntry != null) { + if (!isExpired(localEntry)) { + logger.debug("Local cache hit for key: {}", key) + localHits?.increment() + return localEntry.value + } + evict(key) // Explicitly evict to clean up indexes + } + localMisses?.increment() - return if (isExpired(entry)) { - cache.remove(key) - null - } else { - entry.value + // 2. Check Redis Cache + if (isRedisEnabled) { + return try { + val redisValue = redisTemplate?.opsForValue()?.get(getRedisKey(key)) + if (redisValue != null) { + logger.debug("Redis cache hit for key: {}", key) + redisHits?.increment() + // Populate local cache (L1) from Redis (L2) + // Note: Tags are lost if we don't store them in L2 as well. + // In a full implementation, we might store metadata in a separate Redis key. + // For now, we populate local without tags on Redis hit. + putLocal(key, redisValue, properties.defaultTtl, emptySet()) + redisValue + } else { + redisMisses?.increment() + null + } + } catch (e: Exception) { + logger.error("Error retrieving from Redis", e) + redisMisses?.increment() + null + } } + + return null } private fun isExpired(entry: CacheEntry): Boolean = System.currentTimeMillis() > entry.expiresAt @@ -27,31 +85,173 @@ class CacheFlowServiceImpl : CacheFlowService { key: String, value: Any, ttl: Long, + tags: Set, + ) { + puts?.increment() + // 1. Put Local + putLocal(key, value, ttl, tags) + + // 2. Put Redis + if (isRedisEnabled) { + try { + val redisKey = getRedisKey(key) + redisTemplate?.opsForValue()?.set(redisKey, value, ttl, TimeUnit.SECONDS) + + // Index tags in Redis + tags.forEach { tag -> + redisTemplate?.opsForSet()?.add(getRedisTagKey(tag), key) + } + } catch (e: Exception) { + logger.error("Error writing to Redis", e) + } + } + } + + private fun putLocal( + key: String, + value: Any, + ttl: Long, + tags: Set, ) { val expiresAt = System.currentTimeMillis() + ttl * millisecondsPerSecond - cache[key] = CacheEntry(value, expiresAt) + cache[key] = CacheEntry(value, expiresAt, tags) + + // Update local tag index + tags.forEach { tag -> + localTagIndex.computeIfAbsent(tag) { ConcurrentHashMap.newKeySet() }.add(key) + } } override fun evict(key: String) { - cache.remove(key) + evictions?.increment() + + // 1. Evict Local and clean up index + val entry = cache.remove(key) + entry?.tags?.forEach { tag -> + localTagIndex[tag]?.remove(key) + if (localTagIndex[tag]?.isEmpty() == true) { + localTagIndex.remove(tag) + } + } + + // 2. Evict Redis + if (isRedisEnabled) { + try { + val redisKey = getRedisKey(key) + redisTemplate?.delete(redisKey) + + // Clean up tag index in Redis + entry?.tags?.forEach { tag -> + redisTemplate?.opsForSet()?.remove(getRedisTagKey(tag), key) + } + } catch (e: Exception) { + logger.error("Error evicting from Redis", e) + } + } + + // 3. Evict Edge + if (edgeCacheService != null) { + scope.launch { + try { + edgeCacheService.purgeCacheKey(properties.baseUrl, key).collect { result -> + if (!result.success) { + logger.warn( + "Failed to purge edge cache for key {}: {}", + key, + result.error?.message ?: "Unknown error", + ) + } + } + } catch (e: Exception) { + logger.error("Error purging edge cache", e) + } + } + } } override fun evictAll() { + evictions?.increment() cache.clear() + localTagIndex.clear() + + if (isRedisEnabled) { + try { + // Delete all cache data keys + val dataKeys = redisTemplate?.keys(getRedisKey("*")) + if (!dataKeys.isNullOrEmpty()) { + redisTemplate?.delete(dataKeys) + } + + // Delete all tag index keys + val tagKeys = redisTemplate?.keys(getRedisTagKey("*")) + if (!tagKeys.isNullOrEmpty()) { + redisTemplate?.delete(tagKeys) + } + } catch (e: Exception) { + logger.error("Error evicting all from Redis", e) + } + } + + if (edgeCacheService != null) { + scope.launch { + try { + edgeCacheService.purgeAll().collect {} + } catch (e: Exception) { + logger.error("Error purging all from edge cache", e) + } + } + } } override fun evictByTags(vararg tags: String) { - // Simple implementation - in a real implementation, you'd track tags - // For now, we'll just clear all entries - cache.clear() + evictions?.increment() + + tags.forEach { tag -> + // 1. Local Eviction + localTagIndex.remove(tag)?.forEach { key -> + cache.remove(key) + } + + // 2. Redis Eviction + if (isRedisEnabled) { + try { + val tagKey = getRedisTagKey(tag) + val keys = redisTemplate?.opsForSet()?.members(tagKey) + if (!keys.isNullOrEmpty()) { + // Delete actual data keys + redisTemplate?.delete(keys.map { getRedisKey(it as String) }) + // Delete the tag index key + redisTemplate?.delete(tagKey) + } + } catch (e: Exception) { + logger.error("Error evicting tag $tag from Redis", e) + } + } + + // 3. Edge Eviction + if (edgeCacheService != null) { + scope.launch { + try { + edgeCacheService.purgeByTag(tag).collect {} + } catch (e: Exception) { + logger.error("Error purging tag $tag from edge cache", e) + } + } + } + } } override fun size(): Long = cache.size.toLong() override fun keys(): Set = cache.keys.toSet() + private fun getRedisKey(key: String): String = properties.redis.keyPrefix + "data:" + key + + private fun getRedisTagKey(tag: String): String = properties.redis.keyPrefix + "tag:" + tag + private data class CacheEntry( val value: Any, val expiresAt: Long, + val tags: Set = emptySet(), ) } diff --git a/src/test/kotlin/io/cacheflow/spring/CacheFlowTest.kt b/src/test/kotlin/io/cacheflow/spring/CacheFlowTest.kt index c8df5d3..705711c 100644 --- a/src/test/kotlin/io/cacheflow/spring/CacheFlowTest.kt +++ b/src/test/kotlin/io/cacheflow/spring/CacheFlowTest.kt @@ -1,5 +1,6 @@ package io.cacheflow.spring +import io.cacheflow.spring.config.CacheFlowProperties import io.cacheflow.spring.service.impl.CacheFlowServiceImpl import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -8,7 +9,7 @@ import org.junit.jupiter.api.Test class CacheFlowTest { @Test fun `should cache and retrieve`() { - val cacheService = CacheFlowServiceImpl() + val cacheService = CacheFlowServiceImpl(CacheFlowProperties()) // Put a value cacheService.put("test-key", "test-value", 60) @@ -20,7 +21,7 @@ class CacheFlowTest { @Test fun `should evict cached values`() { - val cacheService = CacheFlowServiceImpl() + val cacheService = CacheFlowServiceImpl(CacheFlowProperties()) // Put a value cacheService.put("test-key", "test-value", 60) @@ -39,7 +40,7 @@ class CacheFlowTest { @Test fun `testReturnNull`() { - val cacheService = CacheFlowServiceImpl() + val cacheService = CacheFlowServiceImpl(CacheFlowProperties()) val result = cacheService.get("non-existent-key") assertNull(result) @@ -47,7 +48,7 @@ class CacheFlowTest { @Test fun `should handle cache size`() { - val cacheService = CacheFlowServiceImpl() + val cacheService = CacheFlowServiceImpl(CacheFlowProperties()) // Initially empty assertEquals(0L, cacheService.size()) @@ -67,4 +68,4 @@ class CacheFlowTest { assertEquals(0L, cacheService.size()) assertEquals(0, cacheService.keys().size) } -} +} \ No newline at end of file diff --git a/src/test/kotlin/io/cacheflow/spring/aspect/CacheFlowAspectTest.kt b/src/test/kotlin/io/cacheflow/spring/aspect/CacheFlowAspectTest.kt index 0f53216..d04d6fd 100644 --- a/src/test/kotlin/io/cacheflow/spring/aspect/CacheFlowAspectTest.kt +++ b/src/test/kotlin/io/cacheflow/spring/aspect/CacheFlowAspectTest.kt @@ -7,6 +7,7 @@ import io.cacheflow.spring.annotation.CacheFlowConfigRegistry import io.cacheflow.spring.annotation.CacheFlowEvict import io.cacheflow.spring.dependency.DependencyResolver import io.cacheflow.spring.service.CacheFlowService +import io.cacheflow.spring.service.impl.CacheFlowServiceImpl import io.cacheflow.spring.versioning.CacheKeyVersioner import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.reflect.MethodSignature @@ -14,13 +15,13 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.mockito.Mockito.anyString -import org.mockito.Mockito.eq import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoInteractions -import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever class CacheFlowAspectTest { private lateinit var cacheService: CacheFlowService @@ -44,19 +45,17 @@ class CacheFlowAspectTest { joinPoint = mock(ProceedingJoinPoint::class.java) methodSignature = mock(MethodSignature::class.java) // Setup mock to return proper declaring type - `when`(methodSignature.declaringType).thenReturn(TestClass::class.java) + whenever(methodSignature.declaringType).thenReturn(TestClass::class.java) - `when`(joinPoint.signature).thenReturn(methodSignature) + whenever(joinPoint.signature).thenReturn(methodSignature) } - private fun safeEq(value: T): T = eq(value) ?: value - @Test fun `should proceed when no CacheFlow annotation present`() { val method = TestClass::class.java.getDeclaredMethod("methodWithoutAnnotation") - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(joinPoint.proceed()).thenReturn("result") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(joinPoint.proceed()).thenReturn("result") val result = aspect.aroundCache(joinPoint) @@ -74,13 +73,13 @@ class CacheFlowAspectTest { String::class.java, ) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn("cached result") - `when`(cacheService.get(anyString())).thenReturn(null) + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn("cached result") + whenever(cacheService.get(any())).thenReturn(null) val result = aspect.aroundCache(joinPoint) @@ -97,12 +96,12 @@ class CacheFlowAspectTest { String::class.java, ) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(cacheService.get(anyString())).thenReturn("cached value") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(cacheService.get(any())).thenReturn("cached value") val result = aspect.aroundCache(joinPoint) @@ -121,21 +120,21 @@ class CacheFlowAspectTest { val configName = "testConfig" val config = CacheFlowConfig(key = "#arg1 + '_' + #arg2", ttl = 600L) - `when`(configRegistry.get(configName)).thenReturn(config) + whenever(configRegistry.get(configName)).thenReturn(config) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn("result") - `when`(cacheService.get(anyString())).thenReturn(null) + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn("result") + whenever(cacheService.get(any())).thenReturn(null) val result = aspect.aroundCache(joinPoint) assertEquals("result", result) verify(configRegistry).get(configName) - verify(cacheService).put(anyString(), safeEq("result"), safeEq(600L)) + verify(cacheService).put(any(), eq("result"), eq(600L), any>()) } @Test @@ -148,30 +147,30 @@ class CacheFlowAspectTest { ) val configName = "testConfig" - `when`(configRegistry.get(configName)).thenReturn(null) + whenever(configRegistry.get(configName)).thenReturn(null) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn("result") - `when`(cacheService.get(anyString())).thenReturn(null) + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn("result") + whenever(cacheService.get(any())).thenReturn(null) val result = aspect.aroundCache(joinPoint) assertEquals("result", result) verify(configRegistry).get(configName) // Should use annotation values (ttl defaults to -1, which uses defaultTtlSeconds 3600L) - verify(cacheService).put(anyString(), safeEq("result"), safeEq(3600L)) + verify(cacheService).put(any(), eq("result"), eq(3600L), any>()) } @Test fun `should proceed when no CacheFlowCached annotation present`() { val method = TestClass::class.java.getDeclaredMethod("methodWithoutAnnotation") - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(joinPoint.proceed()).thenReturn("result") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(joinPoint.proceed()).thenReturn("result") val result = aspect.aroundCached(joinPoint) @@ -189,13 +188,13 @@ class CacheFlowAspectTest { String::class.java, ) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn("cached result") - `when`(cacheService.get(anyString())).thenReturn(null) + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn("cached result") + whenever(cacheService.get(any())).thenReturn(null) val result = aspect.aroundCached(joinPoint) @@ -206,9 +205,9 @@ class CacheFlowAspectTest { @Test fun `should proceed when no CacheFlowEvict annotation present`() { val method = TestClass::class.java.getDeclaredMethod("methodWithoutAnnotation") - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(joinPoint.proceed()).thenReturn("result") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(joinPoint.proceed()).thenReturn("result") val result = aspect.aroundEvict(joinPoint) @@ -226,18 +225,18 @@ class CacheFlowAspectTest { String::class.java, ) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn("result") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn("result") val result = aspect.aroundEvict(joinPoint) assertEquals("result", result) verify(joinPoint).proceed() - verify(cacheService).evict(anyString()) + verify(cacheService).evict(any()) } @Test @@ -249,17 +248,17 @@ class CacheFlowAspectTest { String::class.java, ) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn("result") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn("result") val result = aspect.aroundEvict(joinPoint) assertEquals("result", result) - verify(cacheService).evict(anyString()) + verify(cacheService).evict(any()) verify(joinPoint).proceed() } @@ -267,9 +266,9 @@ class CacheFlowAspectTest { fun `should evict all when allEntries is true`() { val method = TestClass::class.java.getDeclaredMethod("methodWithCacheFlowEvictAll") - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(joinPoint.proceed()).thenReturn("result") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(joinPoint.proceed()).thenReturn("result") val result = aspect.aroundEvict(joinPoint) @@ -282,28 +281,28 @@ class CacheFlowAspectTest { fun `should evict by tags when tags are provided`() { val method = TestClass::class.java.getDeclaredMethod("methodWithCacheFlowEvictTags") - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(joinPoint.proceed()).thenReturn("result") + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(joinPoint.proceed()).thenReturn("result") val result = aspect.aroundEvict(joinPoint) assertEquals("result", result) verify(joinPoint).proceed() - verify(cacheService).evictByTags("tag1", "tag2") + verify(cacheService).evictByTags(eq("tag1"), eq("tag2")) } @Test fun `should generate default cache key when key expression is blank`() { val method = TestClass::class.java.getDeclaredMethod("methodWithBlankKey") - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.declaringType).thenReturn(TestClass::class.java) - `when`(methodSignature.name).thenReturn("methodWithBlankKey") - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.proceed()).thenReturn("result") - `when`(cacheService.get(anyString())).thenReturn(null) + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.declaringType).thenReturn(TestClass::class.java) + whenever(methodSignature.name).thenReturn("methodWithBlankKey") + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.proceed()).thenReturn("result") + whenever(cacheService.get(any())).thenReturn(null) val result = aspect.aroundCache(joinPoint) @@ -320,19 +319,19 @@ class CacheFlowAspectTest { String::class.java, ) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn(null) - `when`(cacheService.get(anyString())).thenReturn(null) + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn(null) + whenever(cacheService.get(any())).thenReturn(null) val result = aspect.aroundCache(joinPoint) assertNull(result) verify(joinPoint).proceed() - verify(cacheService).get(anyString()) + verify(cacheService).get(any()) } @Test @@ -344,13 +343,13 @@ class CacheFlowAspectTest { String::class.java, ) - `when`(joinPoint.signature).thenReturn(methodSignature) - `when`(methodSignature.method).thenReturn(method) - `when`(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) - `when`(joinPoint.target).thenReturn(TestClass()) - `when`(joinPoint.proceed()).thenReturn("result") - `when`(cacheService.get(anyString())).thenReturn(null) + whenever(joinPoint.signature).thenReturn(methodSignature) + whenever(methodSignature.method).thenReturn(method) + whenever(methodSignature.parameterNames).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.args).thenReturn(arrayOf("arg1", "arg2")) + whenever(joinPoint.target).thenReturn(TestClass()) + whenever(joinPoint.proceed()).thenReturn("result") + whenever(cacheService.get(any())).thenReturn(null) val result = aspect.aroundCache(joinPoint) @@ -407,4 +406,4 @@ class CacheFlowAspectTest { fun methodWithoutAnnotation(): String = "result" } -} +} \ No newline at end of file diff --git a/src/test/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfigurationTest.kt b/src/test/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfigurationTest.kt index 4bb9a48..7d89bf2 100644 --- a/src/test/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfigurationTest.kt +++ b/src/test/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowAutoConfigurationTest.kt @@ -2,11 +2,14 @@ package io.cacheflow.spring.autoconfigure import io.cacheflow.spring.annotation.CacheFlowConfigRegistry import io.cacheflow.spring.aspect.CacheFlowAspect +import io.cacheflow.spring.config.CacheFlowProperties import io.cacheflow.spring.dependency.DependencyResolver +import io.cacheflow.spring.edge.service.EdgeCacheIntegrationService import io.cacheflow.spring.management.CacheFlowManagementEndpoint import io.cacheflow.spring.service.CacheFlowService import io.cacheflow.spring.service.impl.CacheFlowServiceImpl import io.cacheflow.spring.versioning.CacheKeyVersioner +import io.micrometer.core.instrument.MeterRegistry import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals @@ -21,6 +24,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.core.RedisTemplate class CacheFlowAutoConfigurationTest { @Test @@ -48,7 +52,7 @@ class CacheFlowAutoConfigurationTest { @Test fun `should create cacheFlowService bean`() { val config = CacheFlowCoreConfiguration() - val service = config.cacheFlowService() + val service = config.cacheFlowService(CacheFlowProperties(), null, null, null) assertNotNull(service) assertTrue(service is CacheFlowServiceImpl) @@ -79,7 +83,14 @@ class CacheFlowAutoConfigurationTest { @Test fun `cacheFlowService method should have correct annotations`() { - val method = CacheFlowCoreConfiguration::class.java.getDeclaredMethod("cacheFlowService") + val method = + CacheFlowCoreConfiguration::class.java.getDeclaredMethod( + "cacheFlowService", + CacheFlowProperties::class.java, + RedisTemplate::class.java, + EdgeCacheIntegrationService::class.java, + MeterRegistry::class.java, + ) // Check @Bean assertTrue(method.isAnnotationPresent(Bean::class.java)) @@ -134,8 +145,8 @@ class CacheFlowAutoConfigurationTest { val mockCacheKeyVersioner = mock(CacheKeyVersioner::class.java) val mockConfigRegistry = mock(CacheFlowConfigRegistry::class.java) - val service1 = coreConfig.cacheFlowService() - val service2 = coreConfig.cacheFlowService() + val service1 = coreConfig.cacheFlowService(CacheFlowProperties(), null, null, null) + val service2 = coreConfig.cacheFlowService(CacheFlowProperties(), null, null, null) val aspect1 = aspectConfig.cacheFlowAspect(mockService, mockDependencyResolver, mockCacheKeyVersioner, mockConfigRegistry) val aspect2 = aspectConfig.cacheFlowAspect(mockService, mockDependencyResolver, mockCacheKeyVersioner, mockConfigRegistry) val endpoint1 = managementConfig.cacheFlowManagementEndpoint(mockService) @@ -169,4 +180,4 @@ class CacheFlowAutoConfigurationTest { // Helper function to create mock private fun mock(clazz: Class): T = org.mockito.Mockito.mock(clazz) -} +} \ No newline at end of file diff --git a/src/test/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowRedisConfigurationTest.kt b/src/test/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowRedisConfigurationTest.kt new file mode 100644 index 0000000..48e7e15 --- /dev/null +++ b/src/test/kotlin/io/cacheflow/spring/autoconfigure/CacheFlowRedisConfigurationTest.kt @@ -0,0 +1,50 @@ +package io.cacheflow.spring.autoconfigure + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer +import org.mockito.Mockito.mock + +class CacheFlowRedisConfigurationTest { + + private val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CacheFlowRedisConfiguration::class.java)) + + @Test + fun `should create cacheFlowRedisTemplate when storage is REDIS`() { + contextRunner + .withPropertyValues("cacheflow.storage=REDIS") + .withBean(RedisConnectionFactory::class.java, { mock(RedisConnectionFactory::class.java) }) + .run { context -> + assertThat(context).hasBean("cacheFlowRedisTemplate") + val template = context.getBean("cacheFlowRedisTemplate", RedisTemplate::class.java) + assertThat(template.keySerializer).isInstanceOf(StringRedisSerializer::class.java) + assertThat(template.valueSerializer).isInstanceOf(GenericJackson2JsonRedisSerializer::class.java) + } + } + + @Test + fun `should NOT create cacheFlowRedisTemplate when storage is NOT REDIS`() { + contextRunner + .withPropertyValues("cacheflow.storage=IN_MEMORY") + .withBean(RedisConnectionFactory::class.java, { mock(RedisConnectionFactory::class.java) }) + .run { context -> + assertThat(context).doesNotHaveBean("cacheFlowRedisTemplate") + } + } + + @Test + fun `should NOT create cacheFlowRedisTemplate when RedisConnectionFactory is missing`() { + contextRunner + .withPropertyValues("cacheflow.storage=REDIS") + .run { context -> + assertThat(context).hasFailed() + assertThat(context).getFailure().hasRootCauseInstanceOf(org.springframework.beans.factory.NoSuchBeanDefinitionException::class.java) + } + } +} diff --git a/src/test/kotlin/io/cacheflow/spring/management/CacheFlowManagementEndpointTest.kt b/src/test/kotlin/io/cacheflow/spring/management/CacheFlowManagementEndpointTest.kt index 3c7464d..7b24ec5 100644 --- a/src/test/kotlin/io/cacheflow/spring/management/CacheFlowManagementEndpointTest.kt +++ b/src/test/kotlin/io/cacheflow/spring/management/CacheFlowManagementEndpointTest.kt @@ -1,5 +1,6 @@ package io.cacheflow.spring.management +import io.cacheflow.spring.config.CacheFlowProperties import io.cacheflow.spring.service.CacheFlowService import io.cacheflow.spring.service.impl.CacheFlowServiceImpl import org.junit.jupiter.api.Assertions.assertEquals @@ -14,7 +15,7 @@ class CacheFlowManagementEndpointTest { @BeforeEach fun setUp() { - cacheService = CacheFlowServiceImpl() + cacheService = CacheFlowServiceImpl(CacheFlowProperties()) endpoint = CacheFlowManagementEndpoint(cacheService) } diff --git a/src/test/kotlin/io/cacheflow/spring/service/CacheFlowServiceTest.kt b/src/test/kotlin/io/cacheflow/spring/service/CacheFlowServiceTest.kt index 9087a31..c841f9e 100644 --- a/src/test/kotlin/io/cacheflow/spring/service/CacheFlowServiceTest.kt +++ b/src/test/kotlin/io/cacheflow/spring/service/CacheFlowServiceTest.kt @@ -1,5 +1,6 @@ package io.cacheflow.spring.service +import io.cacheflow.spring.config.CacheFlowProperties import io.cacheflow.spring.service.impl.CacheFlowServiceImpl import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals @@ -13,7 +14,7 @@ class CacheFlowServiceTest { @BeforeEach fun setUp() { - cacheService = CacheFlowServiceImpl() + cacheService = CacheFlowServiceImpl(CacheFlowProperties()) } @Test diff --git a/src/test/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImplTest.kt b/src/test/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImplTest.kt index 7f59b1b..c80c73d 100644 --- a/src/test/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImplTest.kt +++ b/src/test/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceImplTest.kt @@ -1,5 +1,6 @@ package io.cacheflow.spring.service.impl +import io.cacheflow.spring.config.CacheFlowProperties import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -14,7 +15,7 @@ class CacheFlowServiceImplTest { @BeforeEach fun setUp() { - cacheService = CacheFlowServiceImpl() + cacheService = CacheFlowServiceImpl(CacheFlowProperties()) } @Test diff --git a/src/test/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceMockTest.kt b/src/test/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceMockTest.kt new file mode 100644 index 0000000..9c5d4e6 --- /dev/null +++ b/src/test/kotlin/io/cacheflow/spring/service/impl/CacheFlowServiceMockTest.kt @@ -0,0 +1,291 @@ +package io.cacheflow.spring.service.impl + +import io.cacheflow.spring.config.CacheFlowProperties +import io.cacheflow.spring.edge.EdgeCacheResult +import io.cacheflow.spring.edge.EdgeCacheOperation +import io.cacheflow.spring.edge.service.EdgeCacheIntegrationService +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import kotlinx.coroutines.flow.flowOf +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.core.ValueOperations +import org.springframework.data.redis.core.SetOperations +import java.util.concurrent.TimeUnit + +class CacheFlowServiceMockTest { + + @Mock + private lateinit var redisTemplate: RedisTemplate + + @Mock + private lateinit var valueOperations: ValueOperations + + @Mock + private lateinit var setOperations: SetOperations + + @Mock + private lateinit var edgeCacheService: EdgeCacheIntegrationService + + @Mock + private lateinit var meterRegistry: MeterRegistry + + @Mock + private lateinit var localHitCounter: Counter + @Mock + private lateinit var localMissCounter: Counter + @Mock + private lateinit var redisHitCounter: Counter + @Mock + private lateinit var redisMissCounter: Counter + @Mock + private lateinit var putCounter: Counter + @Mock + private lateinit var evictCounter: Counter + + private lateinit var cacheService: CacheFlowServiceImpl + private lateinit var properties: CacheFlowProperties + + @BeforeEach + fun setUp() { + MockitoAnnotations.openMocks(this) + + // Setup Properties + properties = CacheFlowProperties( + storage = CacheFlowProperties.StorageType.REDIS, + enabled = true, + defaultTtl = 3600, + baseUrl = "https://api.example.com", + redis = CacheFlowProperties.RedisProperties(keyPrefix = "test-prefix:") + ) + + // Setup Redis Mocks + `when`(redisTemplate.opsForValue()).thenReturn(valueOperations) + `when`(redisTemplate.opsForSet()).thenReturn(setOperations) + + // Setup Metrics Mocks + `when`(meterRegistry.counter("cacheflow.local.hits")).thenReturn(localHitCounter) + `when`(meterRegistry.counter("cacheflow.local.misses")).thenReturn(localMissCounter) + `when`(meterRegistry.counter("cacheflow.redis.hits")).thenReturn(redisHitCounter) + `when`(meterRegistry.counter("cacheflow.redis.misses")).thenReturn(redisMissCounter) + `when`(meterRegistry.counter("cacheflow.puts")).thenReturn(putCounter) + `when`(meterRegistry.counter("cacheflow.evictions")).thenReturn(evictCounter) + + // Setup Edge Mocks + `when`(edgeCacheService.purgeCacheKey(anyString(), anyString())).thenReturn( + flowOf(EdgeCacheResult.success("test", EdgeCacheOperation.PURGE_URL)) + ) + `when`(edgeCacheService.purgeAll()).thenReturn( + flowOf(EdgeCacheResult.success("test", EdgeCacheOperation.PURGE_ALL)) + ) + `when`(edgeCacheService.purgeByTag(anyString())).thenReturn( + flowOf(EdgeCacheResult.success("test", EdgeCacheOperation.PURGE_TAG)) + ) + + cacheService = CacheFlowServiceImpl(properties, redisTemplate, edgeCacheService, meterRegistry) + } + + @Test + fun `get should check local cache first`() { + // First put to populate local cache + cacheService.put("key1", "value1", 60) + verify(putCounter, times(1)).increment() // 1 put + + // Then get + val result = cacheService.get("key1") + assertEquals("value1", result) + + // Should hit local, not call Redis get + verify(valueOperations, never()).get(anyString()) + // Verify local hit counter + verify(localHitCounter, times(1)).increment() + } + + @Test + fun `get should check Redis on local miss`() { + val key = "key1" + val redisKey = "test-prefix:data:key1" + val value = "redis-value" + + `when`(valueOperations.get(redisKey)).thenReturn(value) + + val result = cacheService.get(key) + assertEquals(value, result) + + verify(valueOperations).get(redisKey) + // Verify redis hit counter was incremented + verify(redisHitCounter, times(1)).increment() + // Also local miss + verify(localMissCounter, times(1)).increment() + } + + @Test + fun `get should populate local cache on Redis hit`() { + val key = "key1" + val redisKey = "test-prefix:data:key1" + val value = "redis-value" + + `when`(valueOperations.get(redisKey)).thenReturn(value) + + // First call - hits Redis + val result1 = cacheService.get(key) + assertEquals(value, result1) + + // Second call - should hit local cache + val result2 = cacheService.get(key) + assertEquals(value, result2) + + // Redis should only be called once + verify(valueOperations, times(1)).get(redisKey) + } + + @Test + fun `get should return null on Redis miss`() { + val key = "missing" + val redisKey = "test-prefix:data:missing" + + `when`(valueOperations.get(redisKey)).thenReturn(null) + + val result = cacheService.get(key) + assertNull(result) + + verify(redisMissCounter, times(1)).increment() + } + + @Test + fun `put should write to local and Redis`() { + val key = "key1" + val redisKey = "test-prefix:data:key1" + val value = "value1" + val ttl = 60L + + cacheService.put(key, value, ttl) + + // Verify Redis write + verify(valueOperations).set(eq(redisKey), eq(value), eq(ttl), eq(TimeUnit.SECONDS)) + + // Verify metric + verify(putCounter, times(1)).increment() + } + + @Test + fun `evict should remove from local, Redis and Edge`() { + val key = "key1" + val redisKey = "test-prefix:data:key1" + + // Pre-populate local + cacheService.put(key, "val", 60) + + cacheService.evict(key) + + // Verify Local removed (by checking it's gone) + // Since we can't inspect private map, we check get() goes to Redis (or returns null if Redis empty) + `when`(valueOperations.get(redisKey)).thenReturn(null) + assertNull(cacheService.get(key)) + + // Verify Redis delete + verify(redisTemplate).delete(redisKey) + + // Verify Edge purge - async + Thread.sleep(100) + verify(edgeCacheService).purgeCacheKey("https://api.example.com", key) + + verify(evictCounter, times(1)).increment() + } + + @Test + fun `evictAll should clear local, Redis and Edge`() { + val redisDataKeyPattern = "test-prefix:data:*" + val redisTagKeyPattern = "test-prefix:tag:*" + + val dataKeys = setOf("test-prefix:data:k1", "test-prefix:data:k2") + val tagKeys = setOf("test-prefix:tag:t1") + + `when`(redisTemplate.keys(redisDataKeyPattern)).thenReturn(dataKeys) + `when`(redisTemplate.keys(redisTagKeyPattern)).thenReturn(tagKeys) + + cacheService.evictAll() + + verify(redisTemplate).keys(redisDataKeyPattern) + verify(redisTemplate).delete(dataKeys) + verify(redisTemplate).keys(redisTagKeyPattern) + verify(redisTemplate).delete(tagKeys) + + Thread.sleep(100) + verify(edgeCacheService).purgeAll() + verify(evictCounter, times(1)).increment() + } + + @Test + fun `evictByTags should trigger local and Redis tag purge`() { + val tags = arrayOf("tag1") + val redisTagKey = "test-prefix:tag:tag1" + val redisDataKey = "test-prefix:data:key1" + + // Setup Redis mock for members + `when`(setOperations.members(redisTagKey)).thenReturn(setOf("key1")) + + cacheService.evictByTags(*tags) + + Thread.sleep(100) + // Verify Redis data key deletion + verify(redisTemplate).delete(listOf(redisDataKey)) + // Verify Redis tag key deletion + verify(redisTemplate).delete(redisTagKey) + + // Verify Edge purge + verify(edgeCacheService).purgeByTag("tag1") + + verify(evictCounter, times(1)).increment() + } + + @Test + fun `evict should clean up tag indexes`() { + val key = "key1" + val tags = setOf("tag1") + val redisTagKey = "test-prefix:tag:tag1" + + // Put with tags first to populate internal index + cacheService.put(key, "value", 60, tags) + + // Evict + cacheService.evict(key) + + // Verify Redis SREM + verify(setOperations).remove(redisTagKey, key) + } + + @Test + fun `should handle Redis exceptions gracefully during get`() { + val key = "key1" + `when`(valueOperations.get(anyString())).thenThrow(RuntimeException("Redis down")) + + val result = cacheService.get(key) + assertNull(result) + + verify(redisMissCounter, times(1)).increment() // Counts error as miss in current impl + } + + @Test + fun `should handle Redis exceptions gracefully during put`() { + val key = "key1" + `when`(valueOperations.set(anyString(), any(), anyLong(), any())).thenThrow(RuntimeException("Redis down")) + + // Should not throw + cacheService.put(key, "val", 60) + } +} \ No newline at end of file