|
| 1 | +# lib-cache-tiered-redis |
| 2 | + |
| 3 | +Two-tier caching implementation with Caffeine (L1) and Redis (L2) for distributed caching. |
| 4 | + |
| 5 | +## Installation |
| 6 | + |
| 7 | +Add this dependency to your `build.gradle`: |
| 8 | + |
| 9 | +```gradle |
| 10 | +dependencies { |
| 11 | + implementation 'io.seqera:lib-cache-tiered-redis:1.0.0' |
| 12 | +} |
| 13 | +``` |
| 14 | + |
| 15 | +## Overview |
| 16 | + |
| 17 | +`lib-cache-tiered-redis` provides a two-level caching strategy that combines the speed of local in-memory caching with the consistency of distributed caching: |
| 18 | + |
| 19 | +- **L1 Cache (Caffeine)**: Fast, in-memory local cache for single-instance performance |
| 20 | +- **L2 Cache (Redis)**: Distributed cache shared across multiple instances for consistency |
| 21 | + |
| 22 | +## Features |
| 23 | + |
| 24 | +- 🚀 **Fast Local Access**: L1 cache provides microsecond-level response times |
| 25 | +- 🌐 **Distributed Consistency**: L2 cache enables cache sharing across instances |
| 26 | +- ⏱️ **TTL Support**: Automatic expiration at both cache levels |
| 27 | +- 🔒 **Thread-Safe**: Per-key locking ensures safe concurrent access |
| 28 | +- 📦 **MoshiSerializable**: Seamless JSON serialization for cache entries |
| 29 | +- 🔧 **Configurable**: Customize cache sizes and prefixes per implementation |
| 30 | + |
| 31 | +## Usage |
| 32 | + |
| 33 | +### Basic Implementation |
| 34 | + |
| 35 | +Extend `AbstractTieredCache` to create your own cache: |
| 36 | + |
| 37 | +```java |
| 38 | +@Singleton |
| 39 | +public class UserCache extends AbstractTieredCache<String, User> { |
| 40 | + |
| 41 | + public UserCache(L2TieredCache<String, String> l2Cache, MoshiEncodeStrategy<Entry> encoder) { |
| 42 | + super(l2Cache, encoder); |
| 43 | + } |
| 44 | + |
| 45 | + @Override |
| 46 | + protected String getPrefix() { |
| 47 | + return "users:v1"; |
| 48 | + } |
| 49 | + |
| 50 | + @Override |
| 51 | + protected int getMaxSize() { |
| 52 | + return 10_000; |
| 53 | + } |
| 54 | + |
| 55 | + @Override |
| 56 | + protected String getName() { |
| 57 | + return "user-cache"; |
| 58 | + } |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +### Using Tiered Keys |
| 63 | + |
| 64 | +For complex keys, implement the `TieredKey` interface: |
| 65 | + |
| 66 | +```java |
| 67 | +public class UserCacheKey implements TieredKey { |
| 68 | + private final String tenantId; |
| 69 | + private final String userId; |
| 70 | + |
| 71 | + public UserCacheKey(String tenantId, String userId) { |
| 72 | + this.tenantId = tenantId; |
| 73 | + this.userId = userId; |
| 74 | + } |
| 75 | + |
| 76 | + @Override |
| 77 | + public String stableHash() { |
| 78 | + return tenantId + ":" + userId; |
| 79 | + } |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +### Cache Operations |
| 84 | + |
| 85 | +```java |
| 86 | +// Simple get/put |
| 87 | +cache.put("user123", user, Duration.ofHours(1)); |
| 88 | +User user = cache.get("user123"); |
| 89 | + |
| 90 | +// Get with loader function |
| 91 | +User user = cache.getOrCompute("user123", |
| 92 | + key -> userService.loadUser(key), |
| 93 | + Duration.ofHours(1) |
| 94 | +); |
| 95 | + |
| 96 | +// Get with loader returning value and TTL |
| 97 | +User user = cache.getOrCompute("user123", |
| 98 | + key -> { |
| 99 | + User loaded = userService.loadUser(key); |
| 100 | + Duration ttl = loaded.isPremium() |
| 101 | + ? Duration.ofHours(24) |
| 102 | + : Duration.ofHours(1); |
| 103 | + return new Pair<>(loaded, ttl); |
| 104 | + } |
| 105 | +); |
| 106 | + |
| 107 | +// Invalidate L1 cache |
| 108 | +cache.invalidateAll(); |
| 109 | +``` |
| 110 | + |
| 111 | +### Encoder Configuration |
| 112 | + |
| 113 | +Create a Moshi encoder for your cache entries: |
| 114 | + |
| 115 | +```java |
| 116 | +@Singleton |
| 117 | +public class UserCacheEncoder extends MoshiEncodeStrategy<AbstractTieredCache.Entry> { |
| 118 | + |
| 119 | + public UserCacheEncoder() { |
| 120 | + super(createFactory()); |
| 121 | + } |
| 122 | + |
| 123 | + private static JsonAdapter.Factory createFactory() { |
| 124 | + return PolymorphicJsonAdapterFactory |
| 125 | + .of(MoshiSerializable.class, "@type") |
| 126 | + .withSubtype(AbstractTieredCache.Entry.class, "Entry") |
| 127 | + .withSubtype(User.class, "User"); |
| 128 | + } |
| 129 | +} |
| 130 | +``` |
| 131 | + |
| 132 | +## Configuration |
| 133 | + |
| 134 | +### Enable Redis |
| 135 | + |
| 136 | +The Redis L2 cache is automatically enabled when the `redis.uri` property is configured: |
| 137 | + |
| 138 | +```yaml |
| 139 | +redis: |
| 140 | + uri: redis://localhost:6379 |
| 141 | +``` |
| 142 | +
|
| 143 | +### Disable L2 Cache (Development) |
| 144 | +
|
| 145 | +For local development or testing without Redis, simply omit the `redis.uri` property. The cache will continue to work with only the L1 tier: |
| 146 | + |
| 147 | +```java |
| 148 | +// Works without Redis - L1 only |
| 149 | +UserCache cache = new UserCache(null, encoder); |
| 150 | +``` |
| 151 | + |
| 152 | +## How It Works |
| 153 | + |
| 154 | +1. **Cache Hit Path**: |
| 155 | + - Check L1 (Caffeine) → if found, return immediately |
| 156 | + - On L1 miss, check L2 (Redis) → if found, hydrate L1 and return |
| 157 | + - On L2 miss, invoke loader function (if provided) |
| 158 | + |
| 159 | +2. **Cache Write Path**: |
| 160 | + - Store in both L1 and L2 with TTL |
| 161 | + - L1 expires based on local timestamp |
| 162 | + - L2 expires based on Redis TTL |
| 163 | + |
| 164 | +3. **Thread Safety**: |
| 165 | + - Per-key locks prevent race conditions |
| 166 | + - Multiple keys can be accessed concurrently |
| 167 | + - Same key access is serialized |
| 168 | + |
| 169 | +## Best Practices |
| 170 | + |
| 171 | +- ✅ Use appropriate TTLs based on data volatility |
| 172 | +- ✅ Set L1 cache size based on available heap memory |
| 173 | +- ✅ Use meaningful cache prefixes to avoid key conflicts |
| 174 | +- ✅ Implement `TieredKey` for complex key types |
| 175 | +- ✅ Handle null values appropriately in loader functions |
| 176 | +- ⚠️ Remember that strong consistency is not guaranteed across instances |
| 177 | +- ⚠️ L1 invalidation only affects the local instance |
| 178 | + |
| 179 | +## Configuration |
| 180 | + |
| 181 | +### Enabling Redis Caching |
| 182 | + |
| 183 | +The `RedisL2TieredCache` bean is conditionally loaded based on the presence of a `RedisActivator` bean. To enable Redis caching: |
| 184 | + |
| 185 | +1. **For Micronaut applications**, provide a `RedisActivator` bean: |
| 186 | + |
| 187 | +```java |
| 188 | +@Singleton |
| 189 | +@Requires(property = "redis.uri") |
| 190 | +public class AppRedisActivator implements RedisActivator { |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +2. **For testing**, use the `@Requires(env=['redis'])` pattern: |
| 195 | + |
| 196 | +```groovy |
| 197 | +@Singleton |
| 198 | +@Requires(env=['redis']) |
| 199 | +class TestRedisActivation implements RedisActivator { |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +The `RedisActivator` marker interface provides a clean way to conditionally enable Redis-based components only when Redis infrastructure is available. |
| 204 | + |
| 205 | +## Dependencies |
| 206 | + |
| 207 | +- Caffeine 3.x (L1 cache) |
| 208 | +- Jedis 5.x (Redis client) |
| 209 | +- Micronaut Context (dependency injection) |
| 210 | +- lib-serde-moshi (JSON serialization) |
| 211 | +- lib-activator (conditional bean activation) |
| 212 | + |
| 213 | +## License |
| 214 | + |
| 215 | +Apache License 2.0 |
0 commit comments