Search code examples
springcachingredisspring-data-redisspring-cache

Unable to get collection from Redis cache


We are using Redis cache for storing data in cache in our application. We are directly using @Cacheable to allow caching and using redis underneath to cache. Below is the config

Redis Config -

@Configuration
@EnableCaching
@RequiredArgsConstructor
public class RedisConfig implements CachingConfigurer {

@Value("${spring.cache.redis.time-to-live}")
Long redisTTL;

@Bean
public RedisCacheConfiguration cacheConfiguration(ObjectMapper objectMapper) {
    objectMapper = objectMapper.copy();
    objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
    objectMapper.registerModules(new JavaTimeModule(), new Hibernate5Module())
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE)
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
            .enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)
            .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
    return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofDays(redisTTL))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)));
}

@Bean
public RedissonClient reddison(@Value("${spring.redis.host}") final String redisHost,
                               @Value("${spring.redis.port}") final int redisPort,
                               @Value("${spring.redis.cluster.nodes}") final String clusterAddress,
                               @Value("${spring.redis.use-cluster}") final boolean useCluster,
                               @Value("${spring.redis.timeout}") final int timeout) {
    Config config = new Config();
    if (useCluster) {
        config.useClusterServers().addNodeAddress(clusterAddress).setTimeout(timeout);
    } else {
        config.useSingleServer().setAddress(String.format("redis://%s:%d", redisHost, redisPort)).setTimeout(timeout);
    }
    return Redisson.create(config);
}

@Bean
public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redissonClient) {
    return new RedissonConnectionFactory(redissonClient);
}



@Bean
public RedisCacheManager cacheManager(RedissonClient redissonClient, ObjectMapper objectMapper) {
    this.redissonConnectionFactory(redissonClient).getConnection().flushDb();
    RedisCacheManager redisCacheManager= RedisCacheManager.builder(this.redissonConnectionFactory(redissonClient))
            .cacheDefaults(this.cacheConfiguration(objectMapper))
            .build();
    redisCacheManager.setTransactionAware(true);
    return redisCacheManager;
}

@Override
public CacheErrorHandler errorHandler() {
    return new RedisCacheErrorHandler();
}

@Slf4j
public static class RedisCacheErrorHandler implements CacheErrorHandler {

    @Override
    public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
        log.info("Unable to get from cache " + cache.getName() + " : " + exception.getMessage());
    }

    @Override
    public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
        log.info("Unable to put into cache " + cache.getName() + " : " + exception.getMessage());
    }

    @Override
    public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
        log.info("Unable to evict from cache " + cache.getName() + " : " + exception.getMessage());
    }

    @Override
    public void handleCacheClearError(RuntimeException exception, Cache cache) {
        log.info("Unable to clean cache " + cache.getName() + " : " + exception.getMessage());
    }
}
}

Service class -

@Service
@AllArgsConstructor
@Transactional
public class CompanyServiceImpl implements CompanyService {

private final CompanyRepository companyRepository;

@Cacheable(key = "#companyName", value = COMPANY_CACHE_NAME, cacheManager = "cacheManager")
public Optional<CompanyEntity> findByName(String companyName) {
    return companyRepository.findByName(companyName);
}

}

Company class -

@Entity    
@Jacksonized
@AllArgsConstructor
@NoArgsConstructor
public class CompanyEntity  {

@Id
private Long id;

@ToString.Exclude
@OneToMany(mappedBy = "comapnyENtity", cascade = CascadeType.ALL,fetch = FetchType.EAGER)
private List<EmployeeEntity> employeeEntities;

}

Once we run the service, caching gets done properly too. Once we fire the query, we get following record in cache -

> get Company::ABC

" {"@class":"com.abc.entity.CompanyEntity","createdTs":1693922698604,"id":100000000002,"name":"ABC","description":"ABC Operations","active":true,"EmployeeEntities":["org.hibernate.collection.internal.PersistentBag",[{"@class":"com.abc.entity.EmployeeEntity","createdTs":1693922698604,"Id":100000000002,"EmployeeEntity":{"@class":"com.abc.EmployeeLevel","levelId":100000000000,"name":"H1","active":true}}]]}"

But while we try to execute the query the second time, it still goes inside the cache method with below logs -

    Unable to get from cache Company : Could not read JSON: failed to lazily initialize a 
    collection, could not initialize proxy - no Session (through reference chain: 
    com.abc.entity.CompanyEntity$CompanyEntityBuilder["employeeEntities"]); nested exception 
    is com.fasterxml.jackson.databind.JsonMappingException: failed to lazily initialize a c 
    collection, could not initialize proxy - no Session (through reference chain: 
    com.abc.entity.CompanyEntity$CompanyEntityBuilder["employeeEntities"])

I understood from various SO answers that it is due unavailability of session for proxy child object. But we are caching using EAGER mode and whole collection is present in cache too. But still it goes inside the cached method and get values from db. How can we prevent it and use it directly from cache.

UPDATE If we use LAZY loading, the collection objects doesn't get cached and comes as null. But we require cached collection as methods don't get call on order and cached method will return null later.


Solution

  • Found the required answer here. My cache collection reference was not getting de-serialized properly. After applying the required changes, I was able to successfully de-serialize the cached collection object from Redis cache.

    Changes in the existing Redis config -

    @Bean
    public RedisCacheConfiguration cacheConfiguration(ObjectMapper objectMapper) {
        objectMapper = objectMapper.copy();
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        objectMapper.registerModules(new JavaTimeModule(), new Hibernate5Module(), new Jdk8Module())
                .setSerializationInclusion(JsonInclude.Include.NON_NULL)
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE)
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
                .enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)
                .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY).addMixIn(Collection.class, HibernateCollectionMixIn.class);
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofDays(redisTTL))
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)));
    }
    

    Two new classes were added as part of the fix -

    class HibernateCollectionIdResolver extends TypeIdResolverBase {
    
    public HibernateCollectionIdResolver() {
    }
    
    @Override
    public String idFromValue(Object value) {
        //translate from HibernanteCollection class to JDK collection class
        if (value instanceof PersistentArrayHolder) {
            return Array.class.getName();
        } else if (value instanceof PersistentBag || value instanceof PersistentIdentifierBag || value instanceof PersistentList) {
            return List.class.getName();
        } else if (value instanceof PersistentSortedMap) {
            return TreeMap.class.getName();
        } else if (value instanceof PersistentSortedSet) {
            return TreeSet.class.getName();
        } else if (value instanceof PersistentMap) {
            return HashMap.class.getName();
        } else if (value instanceof PersistentSet) {
            return HashSet.class.getName();
        } else {
            //default is JDK collection
            return value.getClass().getName();
        }
    }
    
    @Override
    public String idFromValueAndType(Object value, Class<?> suggestedType) {
        return idFromValue(value);
    }
    
    //deserialize the json annotated JDK collection class name to JavaType
    @Override
    public JavaType typeFromId(DatabindContext ctx, String id) throws IOException {
        try {
            return ctx.getConfig().constructType(Class.forName(id));
        } catch (ClassNotFoundException e) {
            throw new UnsupportedOperationException(e);
        }
    }
    
    @Override
    public JsonTypeInfo.Id getMechanism() {
        return JsonTypeInfo.Id.CLASS;
    }
    

    }

    And

    @JsonTypeInfo(
        use = JsonTypeInfo.Id.CLASS
    )
    @JsonTypeIdResolver(value = HibernateCollectionIdResolver.class)
    public class HibernateCollectionMixIn {
    }