fix: address code review findings

- Fix type mismatch in search_cache.go: SearchWithCache now returns *yandex.Response instead of *Itinerary
- Fix token bucket refill logic in yandex/client.go to properly accumulate tokens based on refillPerSec
- Fix hardcoded API key in main.go to load from YANDEX_API_KEY environment variable
- Fix missing error handling for JSON encoding in handlers.go RouteGeoJSON function
- Fix incorrect redis.Nil handling in cache/store.go CacheAside.Exists method
- Fix incorrect error return type in station_status.go updateStationStatus to return newStatus instead of empty string
This commit is contained in:
2026-08-17 22:42:08 +03:00
parent 78f662c985
commit 6dcec6a7a5
17 changed files with 331 additions and 349 deletions

View File

@@ -20,21 +20,21 @@ import (
// HandlerContext holds the dependencies for API handlers. // HandlerContext holds the dependencies for API handlers.
type HandlerContext struct { type HandlerContext struct {
Cache cache.Cache Cache cache.Cache
Redis *redis.Client Redis *redis.Client
Router *routing.Graph Router *routing.Graph
Yandex *yandex.Client Yandex *yandex.Client
SearchCache *routing.SearchCacheService SearchCache *routing.SearchCacheService
Preferences *cache.Preferences Preferences *cache.Preferences
Metrics *metrics.Metrics Metrics *metrics.Metrics
SearchStart time.Time SearchStart time.Time
} }
// stationStatusResponse represents the response for station status. // stationStatusResponse represents the response for station status.
type stationStatusResponse struct { type stationStatusResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` // "active" or "closed" Status string `json:"status"` // "active" or "closed"
Transport string `json:"transport"` // e.g., "train", "plane", "bus" Transport string `json:"transport"` // e.g., "train", "plane", "bus"
} }
@@ -44,22 +44,22 @@ type cityResponse []string
// routeSearchResponse represents the response for route search. // routeSearchResponse represents the response for route search.
type routeSearchResponse struct { type routeSearchResponse struct {
Routes []routeSearchRoute `json:"routes"` Routes []routeSearchRoute `json:"routes"`
Count int `json:"count"` Count int `json:"count"`
} }
type routeSearchRoute struct { type routeSearchRoute struct {
Duration int `json:"duration"` Duration int `json:"duration"`
Transfers int `json:"transfers"` Transfers int `json:"transfers"`
Cost int `json:"cost"` Cost int `json:"cost"`
ID string `json:"id"` ID string `json:"id"`
PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API
} }
// routeGeoJSONResponse represents the response for route GeoJSON. // routeGeoJSONResponse represents the response for route GeoJSON.
type routeGeoJSONResponse struct { type routeGeoJSONResponse struct {
Type string `json:"type"` Type string `json:"type"`
Features []map[string]interface{} `json:"features"` Features []map[string]interface{} `json:"features"`
SyntheticEdgeStyle map[string]string `json:"synthetic_edge_style,omitempty"` SyntheticEdgeStyle map[string]string `json:"synthetic_edge_style,omitempty"`
} }
// CityAutocomplete handles GET /v1/cities?query=. // CityAutocomplete handles GET /v1/cities?query=.
@@ -80,17 +80,17 @@ func CityAutocomplete(hc *HandlerContext, w http.ResponseWriter, r *http.Request
// main station is closed. The Source field indicates how the neighbor was discovered // main station is closed. The Source field indicates how the neighbor was discovered
// ("geo" for geographic proximity, "manual" for human-defined override). // ("geo" for geographic proximity, "manual" for human-defined override).
type CityNeighborResponse struct { type CityNeighborResponse struct {
StationID string `json:"station_id"` StationID string `json:"station_id"`
Name string `json:"name"` Name string `json:"name"`
CityCode string `json:"city_code"` CityCode string `json:"city_code"`
Source string `json:"source"` Source string `json:"source"`
IsExcluded bool `json:"is_excluded"` IsExcluded bool `json:"is_excluded"`
} }
// cityStationResponse is the response for the cities/{id}/stations endpoint. // cityStationResponse is the response for the cities/{id}/stations endpoint.
type cityStationResponse struct { type cityStationResponse struct {
// Stations are the regular stations for the city // Stations are the regular stations for the city
Stations []cityResponse `json:"stations"` Stations []cityResponse `json:"stations"`
// Neighbors are fallback stations included when the main station is closed // Neighbors are fallback stations included when the main station is closed
Neighbors []CityNeighborResponse `json:"neighbors,omitempty"` Neighbors []CityNeighborResponse `json:"neighbors,omitempty"`
} }
@@ -145,11 +145,11 @@ func CityStations(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
cityNeighbors := neighborTable.GetNonExcluded(cityID) cityNeighbors := neighborTable.GetNonExcluded(cityID)
for _, n := range cityNeighbors { for _, n := range cityNeighbors {
neighbors = append(neighbors, CityNeighborResponse{ neighbors = append(neighbors, CityNeighborResponse{
StationID: n.StationID, StationID: n.StationID,
Name: n.Name, Name: n.Name,
CityCode: n.CityCode, CityCode: n.CityCode,
Source: n.Source, Source: n.Source,
IsExcluded: n.IsExcluded, IsExcluded: n.IsExcluded,
}) })
} }
} }
@@ -239,11 +239,11 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
for _, route := range results { for _, route := range results {
priceNote := "цена не указана" priceNote := "цена не указана"
routeResponses = append(routeResponses, routeSearchRoute{ routeResponses = append(routeResponses, routeSearchRoute{
Duration: route.TotalDuration, Duration: route.TotalDuration,
Transfers: route.TotalTransfers, Transfers: route.TotalTransfers,
Cost: route.Cost, Cost: route.Cost,
ID: route.ID, ID: route.ID,
PriceNote: priceNote, PriceNote: priceNote,
}) })
} }
@@ -318,23 +318,23 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
fromCoord, toCoord, fromCoord, toCoord,
}, },
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"transport": edge.Transport, "transport": edge.Transport,
"transport_type": string(edge.TransportType), "transport_type": string(edge.TransportType),
"kind": fmt.Sprintf("%v", edge.Kind), "kind": fmt.Sprintf("%v", edge.Kind),
"synthetic": edge.Synthetic, "synthetic": edge.Synthetic,
"duration": edge.Duration, "duration": edge.Duration,
"cost": edge.Cost, "cost": edge.Cost,
"is_transfer": edge.IsTransfer, "is_transfer": edge.IsTransfer,
"stroke_color": strokeColor, "stroke_color": strokeColor,
"stroke_width": 2, "stroke_width": 2,
"stroke_dasharray": strokeDasharray, "stroke_dasharray": strokeDasharray,
}, },
} }
features = append(features, map[string]interface{}{ features = append(features, map[string]interface{}{
"type": "Feature", "type": "Feature",
"geometry": geoJsonLine, "geometry": geoJsonLine,
"properties": geoJsonLine["properties"], "properties": geoJsonLine["properties"],
}) })
// Add transfer point markers at nodes that are transfer destinations // Add transfer point markers at nodes that are transfer destinations
@@ -352,14 +352,14 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
}, },
}, },
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"marker_type": "transfer", "marker_type": "transfer",
"title": edge.To.Name, "title": edge.To.Name,
"connection_time": connectionTime, "connection_time": connectionTime,
"connection_time_formatted": fmt.Sprintf("%d min", connectionTime/60), "connection_time_formatted": fmt.Sprintf("%d min", connectionTime/60),
"transfer_type": edge.Transport, "transfer_type": edge.Transport,
"is_transfer": true, "is_transfer": true,
"stroke_color": strokeColor, "stroke_color": strokeColor,
"stroke_width": 2, "stroke_width": 2,
}, },
} }
features = append(features, transferFeature) features = append(features, transferFeature)
@@ -368,11 +368,14 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
resp := routeGeoJSONResponse{ resp := routeGeoJSONResponse{
Type: "FeatureCollection", Type: "FeatureCollection",
Features: features, Features: features,
SyntheticEdgeStyle: map[string]string{"stroke_dasharray": "5, 5", "stroke_color": "#ff9800"}, SyntheticEdgeStyle: map[string]string{"stroke_dasharray": "5, 5", "stroke_color": "#ff9800"},
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp) if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
return
}
} }
// StationStatus handles GET /v1/stations/{id}/status. // StationStatus handles GET /v1/stations/{id}/status.
@@ -460,8 +463,8 @@ func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Reque
// Decode request body to get status and source // Decode request body to get status and source
var req struct { var req struct {
Status string `json:"status"` Status string `json:"status"`
Source string `json:"source"` Source string `json:"source"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest) http.Error(w, "invalid request body", http.StatusBadRequest)
@@ -470,8 +473,8 @@ func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Reque
// Validate status value // Validate status value
validStatuses := map[string]bool{ validStatuses := map[string]bool{
"active": true, "active": true,
"closed": true, "closed": true,
} }
if !validStatuses[req.Status] { if !validStatuses[req.Status] {
http.Error(w, "invalid status value, must be 'active' or 'closed'", http.StatusBadRequest) http.Error(w, "invalid status value, must be 'active' or 'closed'", http.StatusBadRequest)
@@ -489,10 +492,10 @@ func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Reque
logStatusOverride(stationID, req.Status, req.Source) logStatusOverride(stationID, req.Status, req.Source)
resp := map[string]interface{}{ resp := map[string]interface{}{
"id": stationID, "id": stationID,
"status": req.Status, "status": req.Status,
"source": req.Source, "source": req.Source,
"message": "station status updated successfully", "message": "station status updated successfully",
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -512,9 +515,9 @@ func logStatusOverride(stationID, status, source string) {
// preferenceResponse represents the response for preference endpoints. // preferenceResponse represents the response for preference endpoints.
type preferenceResponse struct { type preferenceResponse struct {
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// getSavedCitiesHandler handles GET /v1/preferences/saved-cities. // getSavedCitiesHandler handles GET /v1/preferences/saved-cities.
@@ -646,13 +649,13 @@ func MetricsHandler(hc *HandlerContext, w http.ResponseWriter, r *http.Request)
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client, m *metrics.Metrics) *HandlerContext { func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client, m *metrics.Metrics) *HandlerContext {
cacheStore := cache.NewCacheStore(redisClient, m) cacheStore := cache.NewCacheStore(redisClient, m)
return &HandlerContext{ return &HandlerContext{
Cache: cacheStore, Cache: cacheStore,
Redis: redisClient, Redis: redisClient,
Router: router, Router: router,
Yandex: yandex, Yandex: yandex,
SearchCache: routing.NewSearchCacheService(cacheStore, yandex, m), SearchCache: routing.NewSearchCacheService(cacheStore, yandex, m),
Preferences: cache.NewPreferences(cacheStore), Preferences: cache.NewPreferences(cacheStore),
Metrics: m, Metrics: m,
SearchStart: time.Now(), SearchStart: time.Now(),
} }
} }

View File

@@ -10,8 +10,8 @@ import (
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
"trip-planner/internal/metrics"
"trip-planner/internal/airports" "trip-planner/internal/airports"
"trip-planner/internal/metrics"
"trip-planner/internal/routing" "trip-planner/internal/routing"
"trip-planner/internal/storage" "trip-planner/internal/storage"
"trip-planner/internal/yandex" "trip-planner/internal/yandex"
@@ -216,25 +216,25 @@ func TestRouteGeoJSON(t *testing.T) {
graph.AddNode(&routing.Node{ID: "s3", Type: routing.NodeTypeStation, Name: "Station 3", CityCode: "c1"}) graph.AddNode(&routing.Node{ID: "s3", Type: routing.NodeTypeStation, Name: "Station 3", CityCode: "c1"})
// Add a real edge s1 → s2 // Add a real edge s1 → s2
graph.AddEdge(&routing.Edge{ graph.AddEdge(&routing.Edge{
From: graph.Nodes()[0], From: graph.Nodes()[0],
To: graph.Nodes()[1], To: graph.Nodes()[1],
Kind: routing.EdgeKindReal, Kind: routing.EdgeKindReal,
Duration: 3600, Duration: 3600,
Transport: "train", Transport: "train",
TransportType: routing.TransportTypeTrain, TransportType: routing.TransportTypeTrain,
IsTransfer: false, IsTransfer: false,
Synthetic: false, Synthetic: false,
}) })
// Add a synthetic edge s2 → s3 (city↔airport transfer) // Add a synthetic edge s2 → s3 (city↔airport transfer)
graph.AddEdge(&routing.Edge{ graph.AddEdge(&routing.Edge{
From: graph.Nodes()[1], From: graph.Nodes()[1],
To: graph.Nodes()[2], To: graph.Nodes()[2],
Kind: routing.EdgeKindSynthetic, Kind: routing.EdgeKindSynthetic,
Duration: 300, Duration: 300,
Transport: "train", Transport: "train",
TransportType: routing.TransportTypeTrain, TransportType: routing.TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
Synthetic: true, Synthetic: true,
}) })
h.Router = graph h.Router = graph
@@ -305,25 +305,25 @@ func TestGeoJSONVisualization(t *testing.T) {
// Add real edge Moscow → Transfer // Add real edge Moscow → Transfer
graph.AddEdge(&routing.Edge{ graph.AddEdge(&routing.Edge{
From: graph.Nodes()[0], From: graph.Nodes()[0],
To: graph.Nodes()[1], To: graph.Nodes()[1],
Kind: routing.EdgeKindReal, Kind: routing.EdgeKindReal,
Duration: 1800, Duration: 1800,
Transport: "train", Transport: "train",
TransportType: routing.TransportTypeTrain, TransportType: routing.TransportTypeTrain,
IsTransfer: false, IsTransfer: false,
Synthetic: false, Synthetic: false,
}) })
// Add transfer edge Transfer → Destination // Add transfer edge Transfer → Destination
graph.AddEdge(&routing.Edge{ graph.AddEdge(&routing.Edge{
From: graph.Nodes()[1], From: graph.Nodes()[1],
To: graph.Nodes()[2], To: graph.Nodes()[2],
Kind: routing.EdgeKindReal, Kind: routing.EdgeKindReal,
Duration: 1800, Duration: 1800,
Transport: "train", Transport: "train",
TransportType: routing.TransportTypeTrain, TransportType: routing.TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
Synthetic: false, Synthetic: false,
}) })
h.Router = graph h.Router = graph
@@ -356,19 +356,19 @@ func TestGeoJSONVisualization(t *testing.T) {
if geomType == "Point" { if geomType == "Point" {
props, ok := feature["properties"].(map[string]interface{}) props, ok := feature["properties"].(map[string]interface{})
if ok { if ok {
markerType, ok := props["marker_type"].(string) markerType, ok := props["marker_type"].(string)
if ok && markerType == "transfer" { if ok && markerType == "transfer" {
hasTransferMarker = true hasTransferMarker = true
// Verify popup-related properties exist // Verify popup-related properties exist
_, hasConnTime := props["connection_time"] _, hasConnTime := props["connection_time"]
_, hasTransferType := props["transfer_type"] _, hasTransferType := props["transfer_type"]
if !hasConnTime { if !hasConnTime {
t.Error("expected connection_time property in transfer marker") t.Error("expected connection_time property in transfer marker")
} }
if !hasTransferType { if !hasTransferType {
t.Error("expected transfer_type property in transfer marker") t.Error("expected transfer_type property in transfer marker")
} }
} }
} }
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"os"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
@@ -17,7 +18,12 @@ func main() {
redisClient := initRedis() redisClient := initRedis()
router := routing.NewGraph() router := routing.NewGraph()
m := metrics.New() m := metrics.New()
yandexClient := yandex.NewClient("default-key", yandex.WithMetrics(m))
apiKey := os.Getenv("YANDEX_API_KEY")
if apiKey == "" {
apiKey = "default-key" // fallback for development
}
yandexClient := yandex.NewClient(apiKey, yandex.WithMetrics(m))
handlerCtx := NewHandlerContext(redisClient, router, yandexClient, m) handlerCtx := NewHandlerContext(redisClient, router, yandexClient, m)

View File

@@ -175,22 +175,22 @@ func (sm *StationMonitor) updateStationStatus(ctx context.Context, tripCount int
// Write updated status to cache with 24h TTL // Write updated status to cache with 24h TTL
if err := sm.Cache.Set(ctx, cacheKey, []byte(newStatus), 24*time.Hour); err != nil { if err := sm.Cache.Set(ctx, cacheKey, []byte(newStatus), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set status: %w", err) return newStatus, fmt.Errorf("cache set status: %w", err)
} }
// Write updated zero days count to cache with 24h TTL // Write updated zero days count to cache with 24h TTL
if err := sm.Cache.Set(ctx, zeroDaysKey, []byte(fmt.Sprintf("%d", zeroDays)), 24*time.Hour); err != nil { if err := sm.Cache.Set(ctx, zeroDaysKey, []byte(fmt.Sprintf("%d", zeroDays)), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set zero days: %w", err) return newStatus, fmt.Errorf("cache set zero days: %w", err)
} }
// Write updated zero-since timestamp to cache with 24h TTL // Write updated zero-since timestamp to cache with 24h TTL
if err := sm.Cache.Set(ctx, zeroSinceKey, []byte(fmt.Sprintf("%d", zeroSince.Unix())), 24*time.Hour); err != nil { if err := sm.Cache.Set(ctx, zeroSinceKey, []byte(fmt.Sprintf("%d", zeroSince.Unix())), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set zero since: %w", err) return newStatus, fmt.Errorf("cache set zero since: %w", err)
} }
// Write updated last-seen-flight timestamp to cache with 24h TTL // Write updated last-seen-flight timestamp to cache with 24h TTL
if err := sm.Cache.Set(ctx, lastSeenFlightKey, []byte(fmt.Sprintf("%d", lastSeenFlight.Unix())), 24*time.Hour); err != nil { if err := sm.Cache.Set(ctx, lastSeenFlightKey, []byte(fmt.Sprintf("%d", lastSeenFlight.Unix())), 24*time.Hour); err != nil {
return "", fmt.Errorf("cache set last seen flight: %w", err) return newStatus, fmt.Errorf("cache set last seen flight: %w", err)
} }
return newStatus, nil return newStatus, nil

View File

@@ -235,7 +235,6 @@ func TestProcessStation_Reactivation_AfterClosure(t *testing.T) {
} }
} }
// TestAutoClosureChronology verifies the chronology of auto-closure detection. // TestAutoClosureChronology verifies the chronology of auto-closure detection.
// It tests that a station closes after exactly N=3 consecutive zero-trip days, // It tests that a station closes after exactly N=3 consecutive zero-trip days,
// and that it reactivates when trips resume. // and that it reactivates when trips resume.
@@ -337,4 +336,3 @@ func TestAutoClosureChronology(t *testing.T) {
t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays4) t.Errorf("expected zero days 0 after reactivation, got %d", zeroDays4)
} }
} }

View File

@@ -35,9 +35,9 @@ type StationNeighbors struct {
// NewStationNeighbors creates a new StationNeighbors instance for the given city code. // NewStationNeighbors creates a new StationNeighbors instance for the given city code.
func NewStationNeighbors(cityCode string) *StationNeighbors { func NewStationNeighbors(cityCode string) *StationNeighbors {
return &StationNeighbors{ return &StationNeighbors{
CityCode: cityCode, CityCode: cityCode,
Neighbors: []StationNeighbor{}, Neighbors: []StationNeighbor{},
byID: make(map[string]int), byID: make(map[string]int),
} }
} }

View File

@@ -21,11 +21,11 @@ type PreferenceSavedCity struct {
// PreferenceSearchHistory represents a user's search history entry. // PreferenceSearchHistory represents a user's search history entry.
type PreferenceSearchHistory struct { type PreferenceSearchHistory struct {
Query string `json:"query"` Query string `json:"query"`
FromCity string `json:"from_city"` FromCity string `json:"from_city"`
ToCity string `json:"to_city"` ToCity string `json:"to_city"`
Date string `json:"date"` Date string `json:"date"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
// Preferences represents user preferences storage. // Preferences represents user preferences storage.

View File

@@ -40,7 +40,7 @@ type Cache interface {
// redisClient is a wrapper around go-redis client for dependency injection. // redisClient is a wrapper around go-redis client for dependency injection.
type redisClient struct { type redisClient struct {
client *redis.Client client *redis.Client
metrics *metrics.Metrics metrics *metrics.Metrics
} }
@@ -54,7 +54,7 @@ func (r *redisClient) Get(ctx context.Context, key *CacheKey) ([]byte, error) {
val, err := r.client.Get(ctx, keyString(key)).Bytes() val, err := r.client.Get(ctx, keyString(key)).Bytes()
if errors.Is(err, redis.Nil) { if errors.Is(err, redis.Nil) {
r.metrics.RecordCacheMiss("cache") // record cache miss at redis client level r.metrics.RecordCacheMiss("cache") // record cache miss at redis client level
return nil, nil // cache miss return nil, nil // cache miss
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("cache get: %w", err) return nil, fmt.Errorf("cache get: %w", err)
@@ -167,7 +167,7 @@ func GetSearchKey(from, to, date string) *CacheKey {
// CacheAside represents the cache-aside pattern implementation. // CacheAside represents the cache-aside pattern implementation.
// It follows the pattern: Redis → miss → Postgres/API → write-back to Redis. // It follows the pattern: Redis → miss → Postgres/API → write-back to Redis.
type CacheAside struct { type CacheAside struct {
store Cache store Cache
metrics *metrics.Metrics metrics *metrics.Metrics
} }
@@ -275,7 +275,7 @@ func (c *CacheAside) Get(ctx context.Context, key *CacheKey) ([]byte, error) {
} }
if val == nil { if val == nil {
c.metrics.RecordCacheMiss("cache_aside") // record cache aside miss c.metrics.RecordCacheMiss("cache_aside") // record cache aside miss
return nil, nil // cache miss return nil, nil // cache miss
} }
c.metrics.RecordCacheHit("cache_aside") // record cache aside hit c.metrics.RecordCacheHit("cache_aside") // record cache aside hit
return val, nil return val, nil
@@ -288,14 +288,11 @@ func (c *CacheAside) Set(ctx context.Context, key *CacheKey, value []byte, ttl t
// Exists checks if a key exists in cache. // Exists checks if a key exists in cache.
func (c *CacheAside) Exists(ctx context.Context, key *CacheKey) (bool, error) { func (c *CacheAside) Exists(ctx context.Context, key *CacheKey) (bool, error) {
_, err := c.store.Exists(ctx, key) exists, err := c.store.Exists(ctx, key)
if errors.Is(err, redis.Nil) {
return false, nil
}
if err != nil { if err != nil {
return false, fmt.Errorf("cache exists: %w", err) return false, fmt.Errorf("cache exists: %w", err)
} }
return true, nil return exists, nil
} }
// Increment increments a counter key. // Increment increments a counter key.

View File

@@ -8,8 +8,8 @@ import (
// Metrics holds all observability metrics for the trip planner service. // Metrics holds all observability metrics for the trip planner service.
type Metrics struct { type Metrics struct {
// Cache metrics per layer // Cache metrics per layer
CacheHits map[string]int64 // per-layer hit counts (city, station, search) CacheHits map[string]int64 // per-layer hit counts (city, station, search)
CacheMisses map[string]int64 // per-layer miss counts CacheMisses map[string]int64 // per-layer miss counts
// API quota remaining (per key or global) // API quota remaining (per key or global)
APIQuotaRemaining int64 APIQuotaRemaining int64
@@ -18,27 +18,27 @@ type Metrics struct {
CircuitBreakerTrips int64 // total circuit breaker trips (opened) CircuitBreakerTrips int64 // total circuit breaker trips (opened)
// Search metrics // Search metrics
SearchCount int64 // total number of searches SearchCount int64 // total number of searches
SearchDuration *histogram // distribution of search durations SearchDuration *histogram // distribution of search durations
// Internal counters // Internal counters
mu sync.Mutex mu sync.Mutex
layerTTLs map[string]time.Duration layerTTLs map[string]time.Duration
} }
// histogram tracks duration values and computes simple stats. // histogram tracks duration values and computes simple stats.
type histogram struct { type histogram struct {
mu sync.Mutex mu sync.Mutex
values []int64 // nanoseconds values []int64 // nanoseconds
maxValues int maxValues int
} }
// New creates a new Metrics instance with initialized maps. // New creates a new Metrics instance with initialized maps.
func New() *Metrics { func New() *Metrics {
return &Metrics{ return &Metrics{
CacheHits: make(map[string]int64), CacheHits: make(map[string]int64),
CacheMisses: make(map[string]int64), CacheMisses: make(map[string]int64),
layerTTLs: make(map[string]time.Duration), layerTTLs: make(map[string]time.Duration),
SearchDuration: &histogram{maxValues: 1000}, SearchDuration: &histogram{maxValues: 1000},
} }
} }
@@ -111,14 +111,14 @@ func (m *Metrics) GetMetricsJSON() map[string]interface{} {
} }
result := map[string]interface{}{ result := map[string]interface{}{
"cache_hits": m.CacheHits, "cache_hits": m.CacheHits,
"cache_misses": m.CacheMisses, "cache_misses": m.CacheMisses,
"cache_hit_rate": m.getOverallHitRate(), "cache_hit_rate": m.getOverallHitRate(),
"api_quota_remaining": m.APIQuotaRemaining, "api_quota_remaining": m.APIQuotaRemaining,
"circuit_breaker_trips": m.CircuitBreakerTrips, "circuit_breaker_trips": m.CircuitBreakerTrips,
"search_count": m.SearchCount, "search_count": m.SearchCount,
"avg_search_duration_ms": avgSearchDuration, "avg_search_duration_ms": avgSearchDuration,
"layer_ttls": m.layerTTLs, "layer_ttls": m.layerTTLs,
} }
return result return result
} }

View File

@@ -13,13 +13,13 @@ type Edge struct {
From *Node From *Node
To *Node To *Node
Kind EdgeKind Kind EdgeKind
Duration int // travel time in seconds Duration int // travel time in seconds
Transport string // transport type (train, plane, bus) Transport string // transport type (train, plane, bus)
TransportType TransportType // transport type enum TransportType TransportType // transport type enum
IsTransfer bool // whether this edge involves a transfer IsTransfer bool // whether this edge involves a transfer
Departure string // ISO 8601 departure time Departure string // ISO 8601 departure time
Arrival string // ISO 8601 arrival time Arrival string // ISO 8601 arrival time
Cost int // cost in minor currency units (e.g., rubles) Cost int // cost in minor currency units (e.g., rubles)
// Synthetic indicates whether this edge is a synthetic transfer edge // Synthetic indicates whether this edge is a synthetic transfer edge
// (e.g., city↔airport, station↔city hub) rather than a real scheduled trip. // (e.g., city↔airport, station↔city hub) rather than a real scheduled trip.
Synthetic bool Synthetic bool
@@ -99,8 +99,8 @@ type Graph struct {
// NewGraph creates a new empty routing graph. // NewGraph creates a new empty routing graph.
func NewGraph() *Graph { func NewGraph() *Graph {
return &Graph{ return &Graph{
nodes: []*Node{}, nodes: []*Node{},
edges: []*Edge{}, edges: []*Edge{},
StationNeighbors: make(map[string][]storage.StationNeighbor), StationNeighbors: make(map[string][]storage.StationNeighbor),
} }
} }
@@ -170,26 +170,26 @@ func BuildGraphFromStations(stations []StationInfo) *Graph {
tp = TransportTypeBus tp = TransportTypeBus
} }
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: station, From: station,
To: cityNode, To: cityNode,
Kind: EdgeKindSynthetic, Kind: EdgeKindSynthetic,
Duration: 300, // 5 min synthetic transfer Duration: 300, // 5 min synthetic transfer
Transport: string(tp), Transport: string(tp),
TransportType: tp, TransportType: tp,
IsTransfer: true, IsTransfer: true,
Synthetic: true, Synthetic: true,
}) })
// Add reverse synthetic edge: city hub -> station // Add reverse synthetic edge: city hub -> station
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: cityNode, From: cityNode,
To: station, To: station,
Kind: EdgeKindSynthetic, Kind: EdgeKindSynthetic,
Duration: 300, // 5 min synthetic transfer Duration: 300, // 5 min synthetic transfer
Transport: string(tp), Transport: string(tp),
TransportType: tp, TransportType: tp,
IsTransfer: true, IsTransfer: true,
Synthetic: true, Synthetic: true,
}) })
} }
@@ -225,26 +225,26 @@ func addSyntheticEdgesForNode(graph *Graph, node *Node) {
// Add synthetic edge from node to city hub // Add synthetic edge from node to city hub
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: node, From: node,
To: n, To: n,
Kind: EdgeKindSynthetic, Kind: EdgeKindSynthetic,
Duration: duration, Duration: duration,
Transport: string(tp), Transport: string(tp),
TransportType: tp, TransportType: tp,
IsTransfer: true, IsTransfer: true,
Synthetic: true, Synthetic: true,
}) })
// Add reverse synthetic edge from city hub to node // Add reverse synthetic edge from city hub to node
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: n, From: n,
To: node, To: node,
Kind: EdgeKindSynthetic, Kind: EdgeKindSynthetic,
Duration: duration, Duration: duration,
Transport: string(tp), Transport: string(tp),
TransportType: tp, TransportType: tp,
IsTransfer: true, IsTransfer: true,
Synthetic: true, Synthetic: true,
}) })
} }
} }
@@ -284,7 +284,6 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
// Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT // Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT
mct := getMCTForTransfer(opts.MCT, g) mct := getMCTForTransfer(opts.MCT, g)
// If origin or destination station is closed, add synthetic neighbor edges as fallback // If origin or destination station is closed, add synthetic neighbor edges as fallback
if closedStations[originID] || closedStations[destID] { if closedStations[originID] || closedStations[destID] {
// Get list of closed station IDs // Get list of closed station IDs
@@ -317,7 +316,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
for _, edge := range g.edges { for _, edge := range g.edges {
if (edge.From.ID == closedStationID && edge.To.ID == neighbor.StationID) || (edge.From.ID == neighbor.StationID && edge.To.ID == closedStationID) { if (edge.From.ID == closedStationID && edge.To.ID == neighbor.StationID) || (edge.From.ID == neighbor.StationID && edge.To.ID == closedStationID) {
alreadyExists = true alreadyExists = true
break break
} }
} }
if !alreadyExists { if !alreadyExists {
@@ -344,7 +343,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
} }
} }
} }
}// Build adjacency list from edges } // Build adjacency list from edges
adj := g.buildAdjacencyList() adj := g.buildAdjacencyList()
// BFS with transfer tracking // BFS with transfer tracking
@@ -583,12 +582,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
Legs: newLegs, Legs: newLegs,
TotalDuration: newDurationWithMCT, TotalDuration: newDurationWithMCT,
TotalTransfers: newTransfers, TotalTransfers: newTransfers,
Cost: current.itinerary.Cost + edge.Cost, Cost: current.itinerary.Cost + edge.Cost,
} }
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers { if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
continue continue
} }
queue2 = append(queue2, bfsState{ queue2 = append(queue2, bfsState{
nodeID: nextNode.ID, nodeID: nextNode.ID,
@@ -655,12 +654,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
} }
g.AddEdge(&Edge{ g.AddEdge(&Edge{
From: fromNode, From: fromNode,
To: toNode, To: toNode,
Duration: seg.Duration, Duration: seg.Duration,
Transport: string(TransportTypeTrain), Transport: string(TransportTypeTrain),
IsTransfer: seg.HasTransfers, IsTransfer: seg.HasTransfers,
Kind: EdgeKindReal, Kind: EdgeKindReal,
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
}) })
} }
@@ -754,12 +753,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
Legs: newLegs, Legs: newLegs,
TotalDuration: newDurationWithMCT, TotalDuration: newDurationWithMCT,
TotalTransfers: newTransfers, TotalTransfers: newTransfers,
Cost: current.itinerary.Cost + edge.Cost, Cost: current.itinerary.Cost + edge.Cost,
} }
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers { if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
continue continue
} }
queue3 = append(queue3, bfsState{ queue3 = append(queue3, bfsState{
nodeID: nextNode.ID, nodeID: nextNode.ID,
@@ -851,16 +850,16 @@ type SearchOptions struct {
// Itinerary represents a complete route with legs and summary metrics. // Itinerary represents a complete route with legs and summary metrics.
type Itinerary struct { type Itinerary struct {
Legs []RouteLeg Legs []RouteLeg
TotalDuration int // total travel time in seconds TotalDuration int // total travel time in seconds
TotalTransfers int // number of transfers TotalTransfers int // number of transfers
Cost int // cost in minor currency units (e.g., rubles) Cost int // cost in minor currency units (e.g., rubles)
// Identifier for the route (e.g., search_id + route_id) // Identifier for the route (e.g., search_id + route_id)
ID string ID string
// Route tracking for change detection // Route tracking for change detection
LastChecked int64 // Unix timestamp of last status check LastChecked int64 // Unix timestamp of last status check
NeedsReSearch bool // whether a re-search is recommended due to changes NeedsReSearch bool // whether a re-search is recommended due to changes
ReSearchReason string // reason for recommended re-search (e.g., "cancellation", "major_delay") ReSearchReason string // reason for recommended re-search (e.g., "cancellation", "major_delay")
} }
// RouteLeg represents a single leg of a route (one edge between two nodes). // RouteLeg represents a single leg of a route (one edge between two nodes).
@@ -872,7 +871,7 @@ type RouteLeg struct {
Duration int // travel time in seconds Duration int // travel time in seconds
Transport string // transport type (train, plane, bus) Transport string // transport type (train, plane, bus)
IsTransfer bool IsTransfer bool
Cost int // cost in minor currency units (e.g., rubles) Cost int // cost in minor currency units (e.g., rubles)
} }
// SearchResult represents the result of a route search. // SearchResult represents the result of a route search.
@@ -966,9 +965,9 @@ func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions, cl
// Hub stations are major transport nodes that serve as anchor points // Hub stations are major transport nodes that serve as anchor points
// for lazy graph expansion due to Yandex.Schedules API limitations. // for lazy graph expansion due to Yandex.Schedules API limitations.
type HubStation struct { type HubStation struct {
ID string ID string
Name string Name string
CityCode string CityCode string
MinOutgoingFlights int // minimum outgoing flights criterion for hub selection MinOutgoingFlights int // minimum outgoing flights criterion for hub selection
} }
@@ -1018,6 +1017,7 @@ func SelectHubStations(stations []StationInfo, minOutgoingFlights int) []*Node {
return hubs return hubs
} }
// getStationNeighbors returns neighboring stations for a given station ID in the same city. // getStationNeighbors returns neighboring stations for a given station ID in the same city.
// RouteStatus represents the current status of a route leg. // RouteStatus represents the current status of a route leg.
@@ -1036,7 +1036,7 @@ const (
type routeChangeReason string type routeChangeReason string
const ( const (
reasonNone routeChangeReason = "none" reasonNone routeChangeReason = "none"
reasonCancellation routeChangeReason = "cancellation" reasonCancellation routeChangeReason = "cancellation"
reasonMajorDelay routeChangeReason = "major_delay" reasonMajorDelay routeChangeReason = "major_delay"
) )
@@ -1123,10 +1123,10 @@ func getStationNeighbors(stationID string, g *Graph) []storage.StationNeighbor {
for _, n := range g.Nodes() { for _, n := range g.Nodes() {
if n.ID != stationID && n.CityCode == cityCode && n.Type == NodeTypeStation { if n.ID != stationID && n.CityCode == cityCode && n.Type == NodeTypeStation {
neighbors = append(neighbors, storage.StationNeighbor{ neighbors = append(neighbors, storage.StationNeighbor{
StationID: n.ID, StationID: n.ID,
Name: n.Name, Name: n.Name,
CityCode: n.CityCode, CityCode: n.CityCode,
Source: "geo", Source: "geo",
IsExcluded: false, IsExcluded: false,
}) })
} }

View File

@@ -18,25 +18,25 @@ func TestFindRouteMaxTransfers(t *testing.T) {
// Add direct edge s1 -> s6 (0 transfers) // Add direct edge s1 -> s6 (0 transfers)
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 From: graph.Nodes()[0], // s1
To: graph.Nodes()[5], // s6 To: graph.Nodes()[5], // s6
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 3600, Duration: 3600,
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: false, IsTransfer: false,
}) })
// Add chain edges s1->s2->s3->s4->s5->s6 (each is a transfer edge) // Add chain edges s1->s2->s3->s4->s5->s6 (each is a transfer edge)
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[i], From: graph.Nodes()[i],
To: graph.Nodes()[i+1], To: graph.Nodes()[i+1],
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 1000, Duration: 1000,
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
}) })
} }
@@ -105,68 +105,68 @@ func TestParetoFrontGeneration(t *testing.T) {
// Add direct edge s1 -> s8 (0 transfers, higher cost) // Add direct edge s1 -> s8 (0 transfers, higher cost)
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 From: graph.Nodes()[0], // s1
To: graph.Nodes()[7], // s8 To: graph.Nodes()[7], // s8
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 600, // 10 min Duration: 600, // 10 min
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: false, IsTransfer: false,
Cost: 500, // expensive direct Cost: 500, // expensive direct
}) })
// Add 1-transfer route s1->s3->s8 (lower cost, more time) // Add 1-transfer route s1->s3->s8 (lower cost, more time)
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 From: graph.Nodes()[0], // s1
To: graph.Nodes()[2], // s3 To: graph.Nodes()[2], // s3
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 200, // 3 min Duration: 200, // 3 min
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
Cost: 200, Cost: 200,
}) })
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[2], // s3 From: graph.Nodes()[2], // s3
To: graph.Nodes()[7], // s8 To: graph.Nodes()[7], // s8
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 300, // 5 min Duration: 300, // 5 min
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
Cost: 100, Cost: 100,
}) })
// Add 2-transfer route s1->s5->s6->s8 (even lower cost, more transfers) // Add 2-transfer route s1->s5->s6->s8 (even lower cost, more transfers)
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[0], // s1 From: graph.Nodes()[0], // s1
To: graph.Nodes()[4], // s5 To: graph.Nodes()[4], // s5
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 100, // 2 min Duration: 100, // 2 min
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
Cost: 100, Cost: 100,
}) })
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[4], // s5 From: graph.Nodes()[4], // s5
To: graph.Nodes()[5], // s6 To: graph.Nodes()[5], // s6
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 100, // 2 min Duration: 100, // 2 min
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
Cost: 50, Cost: 50,
}) })
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[5], // s6 From: graph.Nodes()[5], // s6
To: graph.Nodes()[7], // s8 To: graph.Nodes()[7], // s8
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 200, // 3 min Duration: 200, // 3 min
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
Cost: 50, Cost: 50,
}) })
t.Run("fastest mode (default) sorts by duration", func(t *testing.T) { t.Run("fastest mode (default) sorts by duration", func(t *testing.T) {
@@ -285,13 +285,13 @@ func TestLazyExpansionDepthLimit(t *testing.T) {
// Add chain of transfer edges s1->s2->s3->s4->s5->s6->s7 // Add chain of transfer edges s1->s2->s3->s4->s5->s6->s7
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
graph.AddEdge(&Edge{ graph.AddEdge(&Edge{
From: graph.Nodes()[i], From: graph.Nodes()[i],
To: graph.Nodes()[i+1], To: graph.Nodes()[i+1],
Kind: EdgeKindReal, Kind: EdgeKindReal,
Duration: 100, Duration: 100,
Transport: "train", Transport: "train",
TransportType: TransportTypeTrain, TransportType: TransportTypeTrain,
IsTransfer: true, IsTransfer: true,
}) })
} }
@@ -336,6 +336,7 @@ func TestLazyExpansionDepthLimit(t *testing.T) {
t.Log("MaxTransfers=0: no direct route s1->s7 found (only chain edges exist)") t.Log("MaxTransfers=0: no direct route s1->s7 found (only chain edges exist)")
} }
} }
// TestRouteReSearchOnChange tests that the route change detection logic correctly // TestRouteReSearchOnChange tests that the route change detection logic correctly
// identifies when a route leg has undergone significant changes (cancellation or major delay) // identifies when a route leg has undergone significant changes (cancellation or major delay)
// and triggers a re-search to find an updated route. // and triggers a re-search to find an updated route.
@@ -355,7 +356,7 @@ func TestRouteReSearchOnChange(t *testing.T) {
Duration: 3600, // 1 hour Duration: 3600, // 1 hour
Transport: "train", Transport: "train",
IsTransfer: false, IsTransfer: false,
Cost: 500, Cost: 500,
}) })
// Add real edge s2 -> s3 (direct route) // Add real edge s2 -> s3 (direct route)
@@ -366,7 +367,7 @@ func TestRouteReSearchOnChange(t *testing.T) {
Duration: 3600, // 1 hour Duration: 3600, // 1 hour
Transport: "train", Transport: "train",
IsTransfer: false, IsTransfer: false,
Cost: 500, Cost: 500,
}) })
// Create an itinerary simulating a found route from s1 to s3 // Create an itinerary simulating a found route from s1 to s3
@@ -375,13 +376,13 @@ func TestRouteReSearchOnChange(t *testing.T) {
{From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, {From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500},
{From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, {From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500},
}, },
TotalDuration: 7200, // 2 hours total TotalDuration: 7200, // 2 hours total
TotalTransfers: 0, TotalTransfers: 0,
ID: "test-route-123", ID: "test-route-123",
// Set LastChecked to 2 hours ago (7200 seconds) to force re-check // Set LastChecked to 2 hours ago (7200 seconds) to force re-check
// The check skips if checked within 3600 seconds (1 hour) // The check skips if checked within 3600 seconds (1 hour)
LastChecked: time.Now().Unix() - 7200, LastChecked: time.Now().Unix() - 7200,
NeedsReSearch: false, NeedsReSearch: false,
ReSearchReason: "", ReSearchReason: "",
} }
@@ -431,11 +432,11 @@ func TestRouteReSearchOnChange(t *testing.T) {
{From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, {From: graph.Nodes()[0], To: graph.Nodes()[1], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500},
{From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500}, {From: graph.Nodes()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500},
}, },
TotalDuration: 7200, TotalDuration: 7200,
TotalTransfers: 0, TotalTransfers: 0,
ID: "test-route-456", ID: "test-route-456",
LastChecked: time.Now().Unix() - 7200, LastChecked: time.Now().Unix() - 7200,
NeedsReSearch: false, NeedsReSearch: false,
ReSearchReason: "", ReSearchReason: "",
} }

View File

@@ -12,9 +12,9 @@ import (
// SearchCacheService handles caching and on-demand Yandex /search calls. // SearchCacheService handles caching and on-demand Yandex /search calls.
type SearchCacheService struct { type SearchCacheService struct {
cache *cache.CacheAside cache *cache.CacheAside
yclient *yandex.Client yclient *yandex.Client
metrics *metrics.Metrics metrics *metrics.Metrics
} }
// NewSearchCacheService creates a new search cache service. // NewSearchCacheService creates a new search cache service.
@@ -28,7 +28,7 @@ func NewSearchCacheService(cacheStore cache.Cache, yclient *yandex.Client, m *me
// SearchWithCache performs a route search with caching support. // SearchWithCache performs a route search with caching support.
// It uses the cache-aside pattern: try cache first, then Yandex API, then write back to cache. // It uses the cache-aside pattern: try cache first, then Yandex API, then write back to cache.
func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date string, opts SearchOptions) (*Itinerary, error) { func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date string, opts SearchOptions) (*yandex.Response, error) {
// Generate cache key // Generate cache key
searchKey := cache.GetSearchKey(from, to, date) searchKey := cache.GetSearchKey(from, to, date)
@@ -45,10 +45,10 @@ func (s *SearchCacheService) SearchWithCache(ctx context.Context, from, to, date
return nil, fmt.Errorf("search cache get/set: %w", err) return nil, fmt.Errorf("search cache get/set: %w", err)
} }
// Parse the itinerary from cached data (assuming JSON format) // Parse the yandex.Response from cached data
var result Itinerary var result yandex.Response
if err := parseItineraryFromBytes(data, &result); err != nil { if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse itinerary from cache: %w", err) return nil, fmt.Errorf("failed to parse yandex response from cache: %w", err)
} }
return &result, nil return &result, nil
@@ -73,17 +73,6 @@ func (s *SearchCacheService) performYandexSearch(ctx context.Context, from, to,
return convertResponseToBytes(resp) return convertResponseToBytes(resp)
} }
// parseItineraryFromBytes parses an itinerary from byte data.
func parseItineraryFromBytes(data []byte, result *Itinerary) error {
if len(data) == 0 {
return fmt.Errorf("empty data")
}
if err := json.Unmarshal(data, result); err != nil {
return fmt.Errorf("failed to parse itinerary from bytes: %w", err)
}
return nil
}
// convertResponseToBytes converts Yandex API response to bytes for caching. // convertResponseToBytes converts Yandex API response to bytes for caching.
func convertResponseToBytes(resp *yandex.Response) ([]byte, error) { func convertResponseToBytes(resp *yandex.Response) ([]byte, error) {
if resp == nil { if resp == nil {

View File

@@ -69,21 +69,6 @@ func TestNewSearchCacheService(t *testing.T) {
} }
} }
func TestParseItineraryFromBytes(t *testing.T) {
// Test with empty data
result := Itinerary{}
err := parseItineraryFromBytes([]byte{}, &result)
if err == nil {
t.Error("expected error for empty data")
}
// Test with invalid JSON data
err = parseItineraryFromBytes([]byte("invalid json data"), &result)
if err == nil {
t.Error("expected error for invalid JSON data")
}
}
func TestConvertResponseToBytes(t *testing.T) { func TestConvertResponseToBytes(t *testing.T) {
// Test with nil response // Test with nil response
_, err := convertResponseToBytes(nil) _, err := convertResponseToBytes(nil)

View File

@@ -65,7 +65,7 @@ func (snt *StationNeighborsTable) MarkExcluded(cityCode, stationID string) {
// GetNonExcluded returns non-excluded neighbors for a given city code. // GetNonExcluded returns non-excluded neighbors for a given city code.
func (snt *StationNeighborsTable) GetNonExcluded(cityCode string) []StationNeighbor { func (snt *StationNeighborsTable) GetNonExcluded(cityCode string) []StationNeighbor {
if neighbors, ok := snt.neighbors[cityCode]; ok { if neighbors, ok := snt.neighbors[cityCode]; ok {
var result []StationNeighbor var result []StationNeighbor
for _, n := range neighbors { for _, n := range neighbors {
if !n.IsExcluded { if !n.IsExcluded {
result = append(result, n) result = append(result, n)

View File

@@ -2,8 +2,8 @@ package storage
// TransferRule represents a minimum connection time rule. // TransferRule represents a minimum connection time rule.
type TransferRule struct { type TransferRule struct {
RuleKey string `json:"rule_key"` RuleKey string `json:"rule_key"`
MinTransferTimeMinutes int `json:"min_transfer_time_minutes"` MinTransferTimeMinutes int `json:"min_transfer_time_minutes"`
} }
// TransferRuleMap is a lookup map for MCT values. // TransferRuleMap is a lookup map for MCT values.

View File

@@ -325,11 +325,15 @@ func (tb *tokenBucket) acquire() error {
func (tb *tokenBucket) refill(now time.Time) { func (tb *tokenBucket) refill(now time.Time) {
elapsed := now.Sub(tb.lastRefill) elapsed := now.Sub(tb.lastRefill)
if elapsed >= time.Second { if elapsed >= time.Second {
// Refill tokens based on elapsed time and rate tokensToAdd := int(elapsed.Seconds()) * tb.refillPerSec
tb.tokens = tb.capacity if tb.tokens+tokensToAdd > tb.capacity {
tb.tokens = tb.capacity
} else {
tb.tokens += tokensToAdd
}
tb.lastRefill = now tb.lastRefill = now
} }
// else: keep current tokens, will fully refill on next second boundary // else: keep current tokens, will add on next refill
} }
// --- Circuit Breaker --- // --- Circuit Breaker ---
@@ -342,7 +346,6 @@ func newCircuitBreaker() *circuitBreaker {
} }
} }
func (cb *circuitBreaker) ResetCircuitBreaker() { func (cb *circuitBreaker) ResetCircuitBreaker() {
cb.mu.Lock() cb.mu.Lock()
defer cb.mu.Unlock() defer cb.mu.Unlock()