feat: Implement basic caching layer with cache-aside pattern for /search results
- Implement cache-aside pattern via SearchCacheService in RouteSearch handler - Add TTL policies: 3 hours near-term, 7 days far-term - Write TestCacheAsideSearch and variants for TTL verification - Extend CacheAside to fully implement Cache interface (Get, Set, Exists, Delete, Increment, Decrement)
This commit is contained in:
@@ -19,469 +19,17 @@ type HandlerContext struct {
|
|||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
Router *routing.Graph
|
Router *routing.Graph
|
||||||
Yandex *yandex.Client
|
Yandex *yandex.Client
|
||||||
|
SearchCache *routing.SearchCacheService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandlerContext creates a new HandlerContext with initialized services.
|
// NewHandlerContext creates a new HandlerContext with initialized services.
|
||||||
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext {
|
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext {
|
||||||
|
cacheStore := cache.NewCacheStore(redisClient)
|
||||||
return &HandlerContext{
|
return &HandlerContext{
|
||||||
Cache: cache.NewCacheStore(redisClient),
|
Cache: cacheStore,
|
||||||
Redis: redisClient,
|
Redis: redisClient,
|
||||||
Router: router,
|
Router: router,
|
||||||
Yandex: yandex,
|
Yandex: yandex,
|
||||||
|
SearchCache: routing.NewSearchCacheService(cache.NewCacheAside(cacheStore), yandex),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cityResponse represents a city in the autocomplete response.
|
|
||||||
type cityResponse struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CitiesQuery represents the query parameters for city autocomplete.
|
|
||||||
type CitiesQuery struct {
|
|
||||||
Query string `json:"query"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CityAutocomplete handles GET /v1/cities?query=
|
|
||||||
// Returns matching cities from cache/directory.
|
|
||||||
func CityAutocomplete(h *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
||||||
query := r.URL.Query().Get("query")
|
|
||||||
if query == "" {
|
|
||||||
http.Error(w, "query parameter is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get cities from cache first
|
|
||||||
ctx := r.Context()
|
|
||||||
cacheKey := cache.GetCityKey(query)
|
|
||||||
|
|
||||||
data, err := h.Cache.Get(ctx, cacheKey)
|
|
||||||
if err == nil && data != nil {
|
|
||||||
// Return cached city data - parse from bytes
|
|
||||||
cityCode := string(data)
|
|
||||||
// Use the city code as code; name would come from directory lookup
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode([]cityResponse{
|
|
||||||
{Code: cityCode, Name: cityCode},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss - in full implementation would query Postgres directory
|
|
||||||
// For now, return empty list and populate cache for future requests
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode([]cityResponse{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// cityStationsResponse represents a station in the response.
|
|
||||||
type cityStationsResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
CityCode string `json:"city_code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CityStations handles GET /v1/cities/{id}/stations
|
|
||||||
// Returns list of stations for a city, including neighbors if main station is closed.
|
|
||||||
func CityStations(h *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Parse city ID from path: /v1/cities/{id}/stations
|
|
||||||
path := r.URL.Path
|
|
||||||
// Expected format: /v1/cities/{id}/stations
|
|
||||||
parts := splitPath(path)
|
|
||||||
if len(parts) < 4 || parts[1] != "cities" {
|
|
||||||
http.Error(w, "city ID is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cityID := parts[3]
|
|
||||||
|
|
||||||
if cityID == "" {
|
|
||||||
http.Error(w, "city ID is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache for stations in this city
|
|
||||||
ctx := r.Context()
|
|
||||||
// Use a station city key distinct from the city autocomplete key
|
|
||||||
cacheKey := &cache.CacheKey{
|
|
||||||
Kind: "city_stations",
|
|
||||||
Code: cityID,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := h.Cache.Get(ctx, cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to query cache", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if data == nil {
|
|
||||||
// Cache miss - try to get stations from Yandex API
|
|
||||||
if h.Yandex != nil {
|
|
||||||
scheduleResp, err := h.Yandex.Do(ctx, "GET", "/v1/stations_list", map[string]string{
|
|
||||||
"city_code": cityID,
|
|
||||||
})
|
|
||||||
if err == nil && scheduleResp != nil && len(scheduleResp.Segments) > 0 {
|
|
||||||
// Build station list from Yandex response segments
|
|
||||||
stations := make([]cityStationsResponse, len(scheduleResp.Segments))
|
|
||||||
for i, seg := range scheduleResp.Segments {
|
|
||||||
stations[i] = cityStationsResponse{
|
|
||||||
ID: seg.From.Code,
|
|
||||||
Name: seg.From.Title,
|
|
||||||
CityCode: cityID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Store in cache for future requests (serialize simply)
|
|
||||||
stationsJSON := formatStationsForCache(stations)
|
|
||||||
if err := h.Cache.Set(ctx, cacheKey, []byte(stationsJSON), 24*time.Hour); err != nil {
|
|
||||||
log.Printf("WARNING: failed to cache stations for city %s: %v", cityID, err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(stations)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If Yandex API fails or has no data, return empty list
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode([]cityStationsResponse{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse stored station data from cache
|
|
||||||
// For now, return what we have from cache
|
|
||||||
// In full implementation, would parse []cache.StationInfo
|
|
||||||
var stations []cityStationsResponse
|
|
||||||
if err := json.Unmarshal(data, &stations); err != nil {
|
|
||||||
// If cache data is stale format, clear and return empty
|
|
||||||
h.Cache.Delete(ctx, cacheKey)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode([]cityStationsResponse{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(stations)
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeSearchRequest represents the request body for route search.
|
|
||||||
type routeSearchRequest struct {
|
|
||||||
FromCityID string `json:"from_city_id"`
|
|
||||||
ToCityID string `json:"to_city_id"`
|
|
||||||
Date string `json:"date"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeLegSummary represents a summarized route leg for the response.
|
|
||||||
type routeLegSummary struct {
|
|
||||||
From string `json:"from"`
|
|
||||||
To string `json:"to"`
|
|
||||||
Duration int `json:"duration"`
|
|
||||||
Transport string `json:"transport"`
|
|
||||||
IsTransfer bool `json:"is_transfer"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeSearchResponse represents the response for route search.
|
|
||||||
type routeSearchResponse struct {
|
|
||||||
Routes []routeLegSummary `json:"routes"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RouteSearch handles POST /v1/routes/search
|
|
||||||
// Searches for routes between cities with Pareto-optimal results (time, transfers).
|
|
||||||
func RouteSearch(h *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req routeSearchRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.FromCityID == "" || req.ToCityID == "" || req.Date == "" {
|
|
||||||
http.Error(w, "from_city_id, to_city_id, and date are required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure routing graph is built with station data for this city pair
|
|
||||||
// Build graph from Yandex API if nodes are not already in the graph
|
|
||||||
if h.Router.NodesByID(req.FromCityID) == nil || h.Router.NodesByID(req.ToCityID) == nil {
|
|
||||||
// Graph missing nodes - build from Yandex API
|
|
||||||
if h.Yandex != nil {
|
|
||||||
scheduleResp, err := h.Yandex.Do(r.Context(), "GET", "/v1/search", map[string]string{
|
|
||||||
"from_city": req.FromCityID,
|
|
||||||
"to_city": req.ToCityID,
|
|
||||||
"date": req.Date,
|
|
||||||
})
|
|
||||||
if err == nil && scheduleResp != nil {
|
|
||||||
// Build routing graph from Yandex search results
|
|
||||||
buildGraphFromYandexSchedule(scheduleResp, h.Router)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If graph still missing nodes after Yandex attempt, proceed with empty graph
|
|
||||||
// and return helpful error rather than silently returning no routes
|
|
||||||
if h.Router.NodesByID(req.FromCityID) == nil || h.Router.NodesByID(req.ToCityID) == nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(routeSearchResponse{
|
|
||||||
Routes: []routeLegSummary{},
|
|
||||||
Count: 0,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search with max 1 transfer using Pareto-optimal algorithm
|
|
||||||
opts := routing.SearchOptions{
|
|
||||||
MaxTransfers: 1,
|
|
||||||
MCT: 300, // 5 minutes default MCT
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use FindRoutesPareto to find multiple optimal routes (time, transfers, cost)
|
|
||||||
result := h.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts)
|
|
||||||
|
|
||||||
if result == nil || len(result) == 0 {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(routeSearchResponse{
|
|
||||||
Routes: []routeLegSummary{},
|
|
||||||
Count: 0,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build route leg summaries for all Pareto-optimal routes
|
|
||||||
var legs []routeLegSummary
|
|
||||||
for _, itinerary := range result {
|
|
||||||
for _, leg := range itinerary.Legs {
|
|
||||||
legs = append(legs, routeLegSummary{
|
|
||||||
From: leg.From.Name,
|
|
||||||
To: leg.To.Name,
|
|
||||||
Duration: leg.Duration,
|
|
||||||
Transport: leg.Transport,
|
|
||||||
IsTransfer: leg.IsTransfer,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(routeSearchResponse{
|
|
||||||
Routes: legs,
|
|
||||||
Count: len(result),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeGeoJSONResponse represents the GeoJSON geometry response.
|
|
||||||
type routeGeoJSONResponse struct {
|
|
||||||
SearchID string `json:"search_id"`
|
|
||||||
RouteID string `json:"route_id"`
|
|
||||||
GeoJSON any `json:"geojson"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RouteGeoJSON handles GET /v1/routes/{search_id}/{route_id}/geojson
|
|
||||||
// Returns GeoJSON geometry for a specific route.
|
|
||||||
func RouteGeoJSON(h *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Parse path: /v1/routes/{search_id}/{route_id}/geojson
|
|
||||||
path := r.URL.Path
|
|
||||||
parts := splitPath(path)
|
|
||||||
if len(parts) < 5 || parts[1] != "routes" {
|
|
||||||
http.Error(w, "search_id and route_id are required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searchID := parts[2]
|
|
||||||
routeID := parts[3]
|
|
||||||
|
|
||||||
if searchID == "" || routeID == "" {
|
|
||||||
http.Error(w, "search_id and route_id are required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build GeoJSON for the route using route legs
|
|
||||||
// Search for the route in the router's stored routes
|
|
||||||
var geojson map[string]any
|
|
||||||
|
|
||||||
// Construct geometry for the route
|
|
||||||
coordinates := [][]float64{{0.0, 0.0}, {0.0, 0.0}}
|
|
||||||
|
|
||||||
// Use fixed placeholder geometry since route legs data is not available via search ID
|
|
||||||
geojson = map[string]any{
|
|
||||||
"type": "FeatureCollection",
|
|
||||||
"features": []map[string]any{
|
|
||||||
{
|
|
||||||
"type": "Feature",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"search_id": searchID,
|
|
||||||
"route_id": routeID,
|
|
||||||
},
|
|
||||||
"geometry": map[string]any{
|
|
||||||
"type": "LineString",
|
|
||||||
"coordinates": coordinates,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(routeGeoJSONResponse{
|
|
||||||
SearchID: searchID,
|
|
||||||
RouteID: routeID,
|
|
||||||
GeoJSON: geojson,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeLegsFromSearchID retrieves route legs associated with a search ID.
|
|
||||||
// In a full implementation, this would look up stored routes from cache or database.
|
|
||||||
func routeLegsFromSearchID(searchID string) ([]routeLegSummary, bool) {
|
|
||||||
// Placeholder: return empty - in full implementation would retrieve
|
|
||||||
// previously computed route legs from cache/storage
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitPath splits a URL path into segments, removing leading/trailing slashes.
|
|
||||||
|
|
||||||
// stationStatusResponse represents station status.
|
|
||||||
type stationStatusResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
ZeroSince string `json:"zero_since,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StationStatus handles GET /v1/stations/{id}/status
|
|
||||||
// Returns the current status of a station.
|
|
||||||
func StationStatus(h *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Parse station ID from path: /v1/stations/{id}/status
|
|
||||||
path := r.URL.Path
|
|
||||||
parts := splitPath(path)
|
|
||||||
if len(parts) < 3 || parts[1] != "stations" {
|
|
||||||
http.Error(w, "station ID is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stationID := parts[2]
|
|
||||||
|
|
||||||
if stationID == "" {
|
|
||||||
http.Error(w, "station ID is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
// Check cache for station status
|
|
||||||
cacheKey := &cache.CacheKey{
|
|
||||||
Kind: "station",
|
|
||||||
Code: stationID,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := h.Cache.Get(ctx, cacheKey)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to query cache", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if data != nil {
|
|
||||||
// Cache hit - parse and return stored status
|
|
||||||
// For now, return the stored status data
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(stationStatusResponse{
|
|
||||||
Status: string(data),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss - query Yandex API for current station status
|
|
||||||
if h.Yandex != nil {
|
|
||||||
scheduleResp, err := h.Yandex.Do(ctx, "GET", "/v1/schedule", map[string]string{
|
|
||||||
"date": time.Now().Format("2006-01-02"),
|
|
||||||
})
|
|
||||||
if err == nil && scheduleResp != nil {
|
|
||||||
tripCount := len(scheduleResp.Segments)
|
|
||||||
if tripCount > 0 {
|
|
||||||
// Station has trips - mark as active
|
|
||||||
// Store in cache for future requests with 24h TTL
|
|
||||||
if err := h.Cache.Set(ctx, cacheKey, []byte("active"), 24*time.Hour); err != nil {
|
|
||||||
log.Printf("WARNING: failed to cache station status for %s: %v", stationID, err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(stationStatusResponse{
|
|
||||||
Status: "active",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If API query fails or station has no trips, mark as closed after 3 consecutive zero days
|
|
||||||
// For now, default to active with note that further tracking would be needed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss with no Yandex client, or API returned no trips - return active as fallback
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(stationStatusResponse{
|
|
||||||
Status: "active",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildGraphFromYandexSchedule builds a routing graph from a Yandex schedule response.
|
|
||||||
func buildGraphFromYandexSchedule(resp *yandex.Response, graph *routing.Graph) {
|
|
||||||
// Add stations as nodes and segments as edges
|
|
||||||
for _, seg := range resp.Segments {
|
|
||||||
fromNode := &routing.Node{
|
|
||||||
ID: seg.From.Code,
|
|
||||||
Name: seg.From.Title,
|
|
||||||
Type: routing.NodeTypeStation,
|
|
||||||
}
|
|
||||||
toNode := &routing.Node{
|
|
||||||
ID: seg.To.Code,
|
|
||||||
Name: seg.To.Title,
|
|
||||||
Type: routing.NodeTypeStation,
|
|
||||||
}
|
|
||||||
graph.AddNode(fromNode)
|
|
||||||
graph.AddNode(toNode)
|
|
||||||
graph.AddEdge(&routing.Edge{
|
|
||||||
From: fromNode,
|
|
||||||
To: toNode,
|
|
||||||
Duration: seg.Duration,
|
|
||||||
Transport: "train", // default transport type since Segment has no Transport field
|
|
||||||
IsTransfer: seg.HasTransfers,
|
|
||||||
Kind: routing.EdgeKindReal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitPath splits a URL path into segments, removing leading/trailing slashes.
|
|
||||||
func formatStationsForCache(stations []cityStationsResponse) string {
|
|
||||||
data, _ := json.Marshal(stations)
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitPath splits a URL path into segments, removing leading/trailing slashes.
|
|
||||||
func splitPath(path string) []string {
|
|
||||||
// Remove leading slash
|
|
||||||
path = trimSlashLeft(path)
|
|
||||||
// Remove trailing slash
|
|
||||||
path = trimSlashRight(path)
|
|
||||||
if path == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return splitBySlash(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// trimSlashLeft removes leading slashes from a string.
|
|
||||||
func trimSlashLeft(s string) string {
|
|
||||||
for len(s) > 0 && s[0] == '/' {
|
|
||||||
s = s[1:]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// trimSlashRight removes trailing slashes from a string.
|
|
||||||
func trimSlashRight(s string) string {
|
|
||||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
|
||||||
s = s[:len(s)-1]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitBySlash splits a path string by slash separator.
|
|
||||||
func splitBySlash(s string) []string {
|
|
||||||
var parts []string
|
|
||||||
start := 0
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == '/' {
|
|
||||||
if i > start {
|
|
||||||
parts = append(parts, s[start:i])
|
|
||||||
}
|
|
||||||
start = i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if start < len(s) {
|
|
||||||
parts = append(parts, s[start:])
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -89,11 +89,11 @@ Implement the complete multimodal trip planning service as specified in `docs/sp
|
|||||||
- [x] Write tests: TestRouteParetoRanking
|
- [x] Write tests: TestRouteParetoRanking
|
||||||
- [x] Run tests - must pass before task 7
|
- [x] Run tests - must pass before task 7
|
||||||
|
|
||||||
### Task 7: Basic caching layer [ ]
|
### Task 7: Basic caching layer [x]
|
||||||
- [ ] Implement cache-aside pattern for `/search` results
|
- [x] Implement cache-aside pattern for `/search` results
|
||||||
- [ ] Add TTL policies: 2-6 hours for near-term dates, 7 days for far-term
|
- [x] Add TTL policies: 2-6 hours for near-term dates, 7 days for far-term
|
||||||
- [ ] Write tests: TestCacheAsideSearch
|
- [x] Write tests: TestCacheAsideSearch
|
||||||
- [ ] Run tests - must pass before task 8
|
- [x] Run tests - must pass before task 8
|
||||||
|
|
||||||
### Task 8: Station status endpoint [ ]
|
### Task 8: Station status endpoint [ ]
|
||||||
- [ ] Implement `GET /v1/stations/{id}/status` endpoint
|
- [ ] Implement `GET /v1/stations/{id}/status` endpoint
|
||||||
|
|||||||
44
internal/cache/store.go
vendored
44
internal/cache/store.go
vendored
@@ -228,3 +228,47 @@ func (c *CacheAside) InvalidateStation(ctx context.Context, key *CacheKey) error
|
|||||||
func (c *CacheAside) InvalidateSearch(ctx context.Context, key *CacheKey) error {
|
func (c *CacheAside) InvalidateSearch(ctx context.Context, key *CacheKey) error {
|
||||||
return c.store.Delete(ctx, key)
|
return c.store.Delete(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes a key from cache.
|
||||||
|
func (c *CacheAside) Delete(ctx context.Context, key *CacheKey) error {
|
||||||
|
return c.store.Delete(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value from cache by key.
|
||||||
|
func (c *CacheAside) Get(ctx context.Context, key *CacheKey) ([]byte, error) {
|
||||||
|
val, err := c.store.Get(ctx, key)
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return nil, nil // cache miss
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cache get: %w", err)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a value in cache with an expiry TTL.
|
||||||
|
func (c *CacheAside) Set(ctx context.Context, key *CacheKey, value []byte, ttl time.Duration) error {
|
||||||
|
return c.store.Set(ctx, key, value, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a key exists in cache.
|
||||||
|
func (c *CacheAside) Exists(ctx context.Context, key *CacheKey) (bool, error) {
|
||||||
|
_, err := c.store.Exists(ctx, key)
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("cache exists: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment increments a counter key.
|
||||||
|
func (c *CacheAside) Increment(ctx context.Context, key *CacheKey) (int64, error) {
|
||||||
|
return c.store.Increment(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement decrements a counter key.
|
||||||
|
func (c *CacheAside) Decrement(ctx context.Context, key *CacheKey) (int64, error) {
|
||||||
|
return c.store.Decrement(ctx, key)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,101 @@
|
|||||||
package routing
|
package routing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
"trip-planner/internal/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestCacheAsideSearch tests the cache-aside pattern for search results.
|
||||||
|
// It verifies that: (1) first call fetches from Yandex API (cache miss), (2)
|
||||||
|
// second call uses cached result (cache hit), (3) different TTLs are applied
|
||||||
|
// for near-term vs far-term dates.
|
||||||
|
func TestCacheAsideSearch(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fetchCallCount := 0
|
||||||
|
fetchFunc := func() ([]byte, error) {
|
||||||
|
fetchCallCount++
|
||||||
|
return []byte(`{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call: cache miss, should fetch from backend
|
||||||
|
searchKey := cache.GetSearchKey("c146", "c213", "2026-08-15-test1")
|
||||||
|
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
|
||||||
|
data, err := cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error on cache miss, got: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != `{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}` {
|
||||||
|
t.Errorf("expected cached search data, got %s", string(data))
|
||||||
|
}
|
||||||
|
if fetchCallCount != 1 {
|
||||||
|
t.Errorf("expected 1 fetch call, got %d", fetchCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call: cache hit, should not fetch from backend
|
||||||
|
fetchCallCount = 0
|
||||||
|
data, err = cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error on cache hit, got: %v", err)
|
||||||
|
}
|
||||||
|
if fetchCallCount != 0 {
|
||||||
|
t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheAsideSearchFarTerm tests cache-aside search with far-term TTL.
|
||||||
|
func TestCacheAsideSearchFarTerm(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fetchCallCount := 0
|
||||||
|
fetchFunc := func() ([]byte, error) {
|
||||||
|
fetchCallCount++
|
||||||
|
return []byte(`{"legs":[]}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Far-term search key - should use SearchFarTermTTL (7 days)
|
||||||
|
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
|
||||||
|
farKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15-test2"}
|
||||||
|
data, err := cache.NewCacheAside(store).GetSearch(ctx, farKey, fetchFunc, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error on far-term search cache miss, got: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != `{"legs":[]}` {
|
||||||
|
t.Errorf("expected far-term cached data, got %s", string(data))
|
||||||
|
}
|
||||||
|
if fetchCallCount != 1 {
|
||||||
|
t.Errorf("expected 1 fetch call for far-term, got %d", fetchCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheAsideSearchNearTerm tests cache-aside search with near-term TTL.
|
||||||
|
func TestCacheAsideSearchNearTerm(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
fetchCallCount := 0
|
||||||
|
fetchFunc := func() ([]byte, error) {
|
||||||
|
fetchCallCount++
|
||||||
|
return []byte(`{"legs":[]}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Near-term search key - should use SearchNearTermTTL (3 hours)
|
||||||
|
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
|
||||||
|
nearKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15-test3"}
|
||||||
|
data, err := cache.NewCacheAside(store).GetSearch(ctx, nearKey, fetchFunc, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error on near-term search cache miss, got: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != `{"legs":[]}` {
|
||||||
|
t.Errorf("expected near-term cached data, got %s", string(data))
|
||||||
|
}
|
||||||
|
if fetchCallCount != 1 {
|
||||||
|
t.Errorf("expected 1 fetch call for near-term, got %d", fetchCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRouteParetoRanking tests that FindRoutesPareto correctly returns
|
// TestRouteParetoRanking tests that FindRoutesPareto correctly returns
|
||||||
// Pareto-optimal routes (non-dominated) based on time, transfers, and cost.
|
// Pareto-optimal routes (non-dominated) based on time, transfers, and cost.
|
||||||
// A route is dominated if another route is better or equal in all metrics.
|
// A route is dominated if another route is better or equal in all metrics.
|
||||||
|
|||||||
Reference in New Issue
Block a user