RedisShipmentPositionCache.java

package com.ecommerce.fulfillment.infrastructure.geo;

import com.ecommerce.fulfillment.application.port.ShipmentPositionCache;
import com.ecommerce.fulfillment.application.port.ShipmentPositionRepository.Position;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

@Component
@ConditionalOnProperty(
        prefix = "ecommerce.fulfillment.geo",
        name = "cache-enabled",
        havingValue = "true",
        matchIfMissing = true)
public class RedisShipmentPositionCache implements ShipmentPositionCache {

    private static final BigDecimal COORDINATE_TOLERANCE = new BigDecimal("0.000001");

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    private final ShipmentGeoProperties properties;

    public RedisShipmentPositionCache(
            StringRedisTemplate redisTemplate,
            ObjectMapper objectMapper,
            ShipmentGeoProperties properties) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
        this.properties = properties;
    }

    @Override
    public Optional<Position> get(String fulfillmentNo) {
        String metadata = redisTemplate.opsForValue().get(
                properties.redisMetadataKey(fulfillmentNo));
        if (metadata == null) {
            return Optional.empty();
        }
        List<Point> points = redisTemplate.opsForGeo().position(
                properties.redisGeoKey(), fulfillmentNo);
        if (points == null || points.isEmpty() || points.get(0) == null) {
            return Optional.empty();
        }
        Position position = read(metadata);
        Point point = points.get(0);
        if (!fulfillmentNo.equals(position.fulfillmentNo())
                || !sameCoordinate(position.longitude(), BigDecimal.valueOf(point.getX()))
                || !sameCoordinate(position.latitude(), BigDecimal.valueOf(point.getY()))) {
            return Optional.empty();
        }
        return Optional.of(position);
    }

    @Override
    public void put(Position position) {
        redisTemplate.opsForValue().set(
                properties.redisMetadataKey(position.fulfillmentNo()),
                write(position));
        Long updated = redisTemplate.opsForGeo().add(
                properties.redisGeoKey(),
                new Point(position.longitude().doubleValue(), position.latitude().doubleValue()),
                position.fulfillmentNo());
        if (updated == null) {
            throw new IllegalStateException("Redis GEO update returned no result");
        }
    }

    @Override
    public int rebuild(List<Position> positions) {
        for (Position position : positions) {
            put(position);
        }
        return positions.size();
    }

    private Position read(String value) {
        try {
            return objectMapper.readValue(value, Position.class);
        } catch (JsonProcessingException exception) {
            throw new IllegalStateException("Redis GEO metadata is invalid", exception);
        }
    }

    private String write(Position position) {
        try {
            return objectMapper.writeValueAsString(position);
        } catch (JsonProcessingException exception) {
            throw new IllegalStateException("Unable to serialize Redis GEO metadata", exception);
        }
    }

    private boolean sameCoordinate(BigDecimal expected, BigDecimal actual) {
        return expected.subtract(actual).abs().compareTo(COORDINATE_TOLERANCE) <= 0;
    }
}