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:
@@ -35,9 +35,9 @@ type StationNeighbors struct {
|
||||
// NewStationNeighbors creates a new StationNeighbors instance for the given city code.
|
||||
func NewStationNeighbors(cityCode string) *StationNeighbors {
|
||||
return &StationNeighbors{
|
||||
CityCode: cityCode,
|
||||
CityCode: cityCode,
|
||||
Neighbors: []StationNeighbor{},
|
||||
byID: make(map[string]int),
|
||||
byID: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,4 @@ func (sn *StationNeighbors) GetNonExcluded() []StationNeighbor {
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
12
internal/cache/preferences.go
vendored
12
internal/cache/preferences.go
vendored
@@ -21,11 +21,11 @@ type PreferenceSavedCity struct {
|
||||
|
||||
// PreferenceSearchHistory represents a user's search history entry.
|
||||
type PreferenceSearchHistory struct {
|
||||
Query string `json:"query"`
|
||||
FromCity string `json:"from_city"`
|
||||
ToCity string `json:"to_city"`
|
||||
Date string `json:"date"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Query string `json:"query"`
|
||||
FromCity string `json:"from_city"`
|
||||
ToCity string `json:"to_city"`
|
||||
Date string `json:"date"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// Preferences represents user preferences storage.
|
||||
@@ -265,4 +265,4 @@ func (p *Preferences) RemoveOldSearchHistory(ctx context.Context, userID string,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
17
internal/cache/store.go
vendored
17
internal/cache/store.go
vendored
@@ -40,7 +40,7 @@ type Cache interface {
|
||||
|
||||
// redisClient is a wrapper around go-redis client for dependency injection.
|
||||
type redisClient struct {
|
||||
client *redis.Client
|
||||
client *redis.Client
|
||||
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()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
r.metrics.RecordCacheMiss("cache") // record cache miss at redis client level
|
||||
return nil, nil // cache miss
|
||||
return nil, nil // cache miss
|
||||
}
|
||||
if err != nil {
|
||||
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.
|
||||
// It follows the pattern: Redis → miss → Postgres/API → write-back to Redis.
|
||||
type CacheAside struct {
|
||||
store Cache
|
||||
store Cache
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ func (c *CacheAside) Get(ctx context.Context, key *CacheKey) ([]byte, error) {
|
||||
}
|
||||
if val == nil {
|
||||
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
|
||||
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.
|
||||
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
|
||||
}
|
||||
exists, err := c.store.Exists(ctx, key)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cache exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// Increment increments a counter key.
|
||||
@@ -306,4 +303,4 @@ func (c *CacheAside) Increment(ctx context.Context, key *CacheKey) (int64, error
|
||||
// Decrement decrements a counter key.
|
||||
func (c *CacheAside) Decrement(ctx context.Context, key *CacheKey) (int64, error) {
|
||||
return c.store.Decrement(ctx, key)
|
||||
}
|
||||
}
|
||||
|
||||
2
internal/cache/store_test.go
vendored
2
internal/cache/store_test.go
vendored
@@ -229,4 +229,4 @@ func TestCacheInvalidate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error invalidating search, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
// Metrics holds all observability metrics for the trip planner service.
|
||||
type Metrics struct {
|
||||
// Cache metrics per layer
|
||||
CacheHits map[string]int64 // per-layer hit counts (city, station, search)
|
||||
CacheMisses map[string]int64 // per-layer miss counts
|
||||
CacheHits map[string]int64 // per-layer hit counts (city, station, search)
|
||||
CacheMisses map[string]int64 // per-layer miss counts
|
||||
|
||||
// API quota remaining (per key or global)
|
||||
APIQuotaRemaining int64
|
||||
@@ -18,27 +18,27 @@ type Metrics struct {
|
||||
CircuitBreakerTrips int64 // total circuit breaker trips (opened)
|
||||
|
||||
// Search metrics
|
||||
SearchCount int64 // total number of searches
|
||||
SearchDuration *histogram // distribution of search durations
|
||||
SearchCount int64 // total number of searches
|
||||
SearchDuration *histogram // distribution of search durations
|
||||
|
||||
// Internal counters
|
||||
mu sync.Mutex
|
||||
layerTTLs map[string]time.Duration
|
||||
mu sync.Mutex
|
||||
layerTTLs map[string]time.Duration
|
||||
}
|
||||
|
||||
// histogram tracks duration values and computes simple stats.
|
||||
type histogram struct {
|
||||
mu sync.Mutex
|
||||
values []int64 // nanoseconds
|
||||
maxValues int
|
||||
mu sync.Mutex
|
||||
values []int64 // nanoseconds
|
||||
maxValues int
|
||||
}
|
||||
|
||||
// New creates a new Metrics instance with initialized maps.
|
||||
func New() *Metrics {
|
||||
return &Metrics{
|
||||
CacheHits: make(map[string]int64),
|
||||
CacheMisses: make(map[string]int64),
|
||||
layerTTLs: make(map[string]time.Duration),
|
||||
CacheHits: make(map[string]int64),
|
||||
CacheMisses: make(map[string]int64),
|
||||
layerTTLs: make(map[string]time.Duration),
|
||||
SearchDuration: &histogram{maxValues: 1000},
|
||||
}
|
||||
}
|
||||
@@ -111,14 +111,14 @@ func (m *Metrics) GetMetricsJSON() map[string]interface{} {
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cache_hits": m.CacheHits,
|
||||
"cache_misses": m.CacheMisses,
|
||||
"cache_hit_rate": m.getOverallHitRate(),
|
||||
"api_quota_remaining": m.APIQuotaRemaining,
|
||||
"circuit_breaker_trips": m.CircuitBreakerTrips,
|
||||
"search_count": m.SearchCount,
|
||||
"cache_hits": m.CacheHits,
|
||||
"cache_misses": m.CacheMisses,
|
||||
"cache_hit_rate": m.getOverallHitRate(),
|
||||
"api_quota_remaining": m.APIQuotaRemaining,
|
||||
"circuit_breaker_trips": m.CircuitBreakerTrips,
|
||||
"search_count": m.SearchCount,
|
||||
"avg_search_duration_ms": avgSearchDuration,
|
||||
"layer_ttls": m.layerTTLs,
|
||||
"layer_ttls": m.layerTTLs,
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -152,4 +152,4 @@ func (m *Metrics) GetLayerTTL(layer string) (time.Duration, bool) {
|
||||
defer m.mu.Unlock()
|
||||
ttl, ok := m.layerTTLs[layer]
|
||||
return ttl, ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ type Edge struct {
|
||||
From *Node
|
||||
To *Node
|
||||
Kind EdgeKind
|
||||
Duration int // travel time in seconds
|
||||
Transport string // transport type (train, plane, bus)
|
||||
Duration int // travel time in seconds
|
||||
Transport string // transport type (train, plane, bus)
|
||||
TransportType TransportType // transport type enum
|
||||
IsTransfer bool // whether this edge involves a transfer
|
||||
Departure string // ISO 8601 departure time
|
||||
Arrival string // ISO 8601 arrival time
|
||||
Cost int // cost in minor currency units (e.g., rubles)
|
||||
IsTransfer bool // whether this edge involves a transfer
|
||||
Departure string // ISO 8601 departure time
|
||||
Arrival string // ISO 8601 arrival time
|
||||
Cost int // cost in minor currency units (e.g., rubles)
|
||||
// Synthetic indicates whether this edge is a synthetic transfer edge
|
||||
// (e.g., city↔airport, station↔city hub) rather than a real scheduled trip.
|
||||
Synthetic bool
|
||||
@@ -99,8 +99,8 @@ type Graph struct {
|
||||
// NewGraph creates a new empty routing graph.
|
||||
func NewGraph() *Graph {
|
||||
return &Graph{
|
||||
nodes: []*Node{},
|
||||
edges: []*Edge{},
|
||||
nodes: []*Node{},
|
||||
edges: []*Edge{},
|
||||
StationNeighbors: make(map[string][]storage.StationNeighbor),
|
||||
}
|
||||
}
|
||||
@@ -170,26 +170,26 @@ func BuildGraphFromStations(stations []StationInfo) *Graph {
|
||||
tp = TransportTypeBus
|
||||
}
|
||||
graph.AddEdge(&Edge{
|
||||
From: station,
|
||||
To: cityNode,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: 300, // 5 min synthetic transfer
|
||||
Transport: string(tp),
|
||||
From: station,
|
||||
To: cityNode,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: 300, // 5 min synthetic transfer
|
||||
Transport: string(tp),
|
||||
TransportType: tp,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
})
|
||||
|
||||
// Add reverse synthetic edge: city hub -> station
|
||||
graph.AddEdge(&Edge{
|
||||
From: cityNode,
|
||||
To: station,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: 300, // 5 min synthetic transfer
|
||||
Transport: string(tp),
|
||||
From: cityNode,
|
||||
To: station,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: 300, // 5 min synthetic transfer
|
||||
Transport: string(tp),
|
||||
TransportType: tp,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,26 +225,26 @@ func addSyntheticEdgesForNode(graph *Graph, node *Node) {
|
||||
|
||||
// Add synthetic edge from node to city hub
|
||||
graph.AddEdge(&Edge{
|
||||
From: node,
|
||||
To: n,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: duration,
|
||||
Transport: string(tp),
|
||||
From: node,
|
||||
To: n,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: duration,
|
||||
Transport: string(tp),
|
||||
TransportType: tp,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
})
|
||||
|
||||
// Add reverse synthetic edge from city hub to node
|
||||
graph.AddEdge(&Edge{
|
||||
From: n,
|
||||
To: node,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: duration,
|
||||
Transport: string(tp),
|
||||
From: n,
|
||||
To: node,
|
||||
Kind: EdgeKindSynthetic,
|
||||
Duration: duration,
|
||||
Transport: string(tp),
|
||||
TransportType: tp,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
IsTransfer: true,
|
||||
Synthetic: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,6 @@ func (g *Graph) NodesByID(id string) *Node {
|
||||
func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedStations map[string]bool, neighbors map[string][]storage.StationNeighbor, yclient ...*yandex.Client) *Itinerary {
|
||||
// Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT
|
||||
mct := getMCTForTransfer(opts.MCT, g)
|
||||
|
||||
|
||||
// If origin or destination station is closed, add synthetic neighbor edges as fallback
|
||||
if closedStations[originID] || closedStations[destID] {
|
||||
@@ -317,7 +316,7 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
|
||||
for _, edge := range g.edges {
|
||||
if (edge.From.ID == closedStationID && edge.To.ID == neighbor.StationID) || (edge.From.ID == neighbor.StationID && edge.To.ID == closedStationID) {
|
||||
alreadyExists = true
|
||||
break
|
||||
break
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
// BFS with transfer tracking
|
||||
@@ -583,12 +582,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
|
||||
Legs: newLegs,
|
||||
TotalDuration: newDurationWithMCT,
|
||||
TotalTransfers: newTransfers,
|
||||
Cost: current.itinerary.Cost + edge.Cost,
|
||||
}
|
||||
Cost: current.itinerary.Cost + edge.Cost,
|
||||
}
|
||||
|
||||
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
|
||||
continue
|
||||
}
|
||||
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
|
||||
continue
|
||||
}
|
||||
|
||||
queue2 = append(queue2, bfsState{
|
||||
nodeID: nextNode.ID,
|
||||
@@ -655,12 +654,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
|
||||
}
|
||||
|
||||
g.AddEdge(&Edge{
|
||||
From: fromNode,
|
||||
To: toNode,
|
||||
Duration: seg.Duration,
|
||||
Transport: string(TransportTypeTrain),
|
||||
IsTransfer: seg.HasTransfers,
|
||||
Kind: EdgeKindReal,
|
||||
From: fromNode,
|
||||
To: toNode,
|
||||
Duration: seg.Duration,
|
||||
Transport: string(TransportTypeTrain),
|
||||
IsTransfer: seg.HasTransfers,
|
||||
Kind: EdgeKindReal,
|
||||
TransportType: TransportTypeTrain,
|
||||
})
|
||||
}
|
||||
@@ -754,12 +753,12 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedSta
|
||||
Legs: newLegs,
|
||||
TotalDuration: newDurationWithMCT,
|
||||
TotalTransfers: newTransfers,
|
||||
Cost: current.itinerary.Cost + edge.Cost,
|
||||
}
|
||||
Cost: current.itinerary.Cost + edge.Cost,
|
||||
}
|
||||
|
||||
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
|
||||
continue
|
||||
}
|
||||
if opts.MaxTransfers >= 0 && newTransfers > opts.MaxTransfers {
|
||||
continue
|
||||
}
|
||||
|
||||
queue3 = append(queue3, bfsState{
|
||||
nodeID: nextNode.ID,
|
||||
@@ -851,16 +850,16 @@ type SearchOptions struct {
|
||||
|
||||
// Itinerary represents a complete route with legs and summary metrics.
|
||||
type Itinerary struct {
|
||||
Legs []RouteLeg
|
||||
TotalDuration int // total travel time in seconds
|
||||
TotalTransfers int // number of transfers
|
||||
Cost int // cost in minor currency units (e.g., rubles)
|
||||
Legs []RouteLeg
|
||||
TotalDuration int // total travel time in seconds
|
||||
TotalTransfers int // number of transfers
|
||||
Cost int // cost in minor currency units (e.g., rubles)
|
||||
// Identifier for the route (e.g., search_id + route_id)
|
||||
ID string
|
||||
ID string
|
||||
// Route tracking for change detection
|
||||
LastChecked int64 // Unix timestamp of last status check
|
||||
NeedsReSearch bool // whether a re-search is recommended due to changes
|
||||
ReSearchReason string // reason for recommended re-search (e.g., "cancellation", "major_delay")
|
||||
LastChecked int64 // Unix timestamp of last status check
|
||||
NeedsReSearch bool // whether a re-search is recommended due to changes
|
||||
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).
|
||||
@@ -872,7 +871,7 @@ type RouteLeg struct {
|
||||
Duration int // travel time in seconds
|
||||
Transport string // transport type (train, plane, bus)
|
||||
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.
|
||||
@@ -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
|
||||
// for lazy graph expansion due to Yandex.Schedules API limitations.
|
||||
type HubStation struct {
|
||||
ID string
|
||||
Name string
|
||||
CityCode string
|
||||
ID string
|
||||
Name string
|
||||
CityCode string
|
||||
MinOutgoingFlights int // minimum outgoing flights criterion for hub selection
|
||||
}
|
||||
|
||||
@@ -1018,6 +1017,7 @@ func SelectHubStations(stations []StationInfo, minOutgoingFlights int) []*Node {
|
||||
|
||||
return hubs
|
||||
}
|
||||
|
||||
// getStationNeighbors returns neighboring stations for a given station ID in the same city.
|
||||
|
||||
// RouteStatus represents the current status of a route leg.
|
||||
@@ -1036,7 +1036,7 @@ const (
|
||||
type routeChangeReason string
|
||||
|
||||
const (
|
||||
reasonNone routeChangeReason = "none"
|
||||
reasonNone routeChangeReason = "none"
|
||||
reasonCancellation routeChangeReason = "cancellation"
|
||||
reasonMajorDelay routeChangeReason = "major_delay"
|
||||
)
|
||||
@@ -1123,10 +1123,10 @@ func getStationNeighbors(stationID string, g *Graph) []storage.StationNeighbor {
|
||||
for _, n := range g.Nodes() {
|
||||
if n.ID != stationID && n.CityCode == cityCode && n.Type == NodeTypeStation {
|
||||
neighbors = append(neighbors, storage.StationNeighbor{
|
||||
StationID: n.ID,
|
||||
Name: n.Name,
|
||||
CityCode: n.CityCode,
|
||||
Source: "geo",
|
||||
StationID: n.ID,
|
||||
Name: n.Name,
|
||||
CityCode: n.CityCode,
|
||||
Source: "geo",
|
||||
IsExcluded: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,25 +18,25 @@ func TestFindRouteMaxTransfers(t *testing.T) {
|
||||
|
||||
// Add direct edge s1 -> s6 (0 transfers)
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[5], // s6
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 3600,
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[5], // s6
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 3600,
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: false,
|
||||
IsTransfer: false,
|
||||
})
|
||||
|
||||
// Add chain edges s1->s2->s3->s4->s5->s6 (each is a transfer edge)
|
||||
for i := 0; i < 5; i++ {
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[i],
|
||||
To: graph.Nodes()[i+1],
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 1000,
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[i],
|
||||
To: graph.Nodes()[i+1],
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 1000,
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: true,
|
||||
IsTransfer: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,68 +105,68 @@ func TestParetoFrontGeneration(t *testing.T) {
|
||||
|
||||
// Add direct edge s1 -> s8 (0 transfers, higher cost)
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[7], // s8
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 600, // 10 min
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[7], // s8
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 600, // 10 min
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: false,
|
||||
Cost: 500, // expensive direct
|
||||
IsTransfer: false,
|
||||
Cost: 500, // expensive direct
|
||||
})
|
||||
|
||||
// Add 1-transfer route s1->s3->s8 (lower cost, more time)
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[2], // s3
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 200, // 3 min
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[2], // s3
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 200, // 3 min
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: true,
|
||||
Cost: 200,
|
||||
IsTransfer: true,
|
||||
Cost: 200,
|
||||
})
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[2], // s3
|
||||
To: graph.Nodes()[7], // s8
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 300, // 5 min
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[2], // s3
|
||||
To: graph.Nodes()[7], // s8
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 300, // 5 min
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: true,
|
||||
Cost: 100,
|
||||
IsTransfer: true,
|
||||
Cost: 100,
|
||||
})
|
||||
|
||||
// Add 2-transfer route s1->s5->s6->s8 (even lower cost, more transfers)
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[4], // s5
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 100, // 2 min
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[0], // s1
|
||||
To: graph.Nodes()[4], // s5
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 100, // 2 min
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: true,
|
||||
Cost: 100,
|
||||
IsTransfer: true,
|
||||
Cost: 100,
|
||||
})
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[4], // s5
|
||||
To: graph.Nodes()[5], // s6
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 100, // 2 min
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[4], // s5
|
||||
To: graph.Nodes()[5], // s6
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 100, // 2 min
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: true,
|
||||
Cost: 50,
|
||||
IsTransfer: true,
|
||||
Cost: 50,
|
||||
})
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[5], // s6
|
||||
To: graph.Nodes()[7], // s8
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 200, // 3 min
|
||||
Transport: "train",
|
||||
From: graph.Nodes()[5], // s6
|
||||
To: graph.Nodes()[7], // s8
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 200, // 3 min
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: true,
|
||||
Cost: 50,
|
||||
IsTransfer: true,
|
||||
Cost: 50,
|
||||
})
|
||||
|
||||
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
|
||||
for i := 0; i < 6; i++ {
|
||||
graph.AddEdge(&Edge{
|
||||
From: graph.Nodes()[i],
|
||||
To: graph.Nodes()[i+1],
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 100,
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
IsTransfer: true,
|
||||
From: graph.Nodes()[i],
|
||||
To: graph.Nodes()[i+1],
|
||||
Kind: EdgeKindReal,
|
||||
Duration: 100,
|
||||
Transport: "train",
|
||||
TransportType: TransportTypeTrain,
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteReSearchOnChange tests that the route change detection logic correctly
|
||||
// identifies when a route leg has undergone significant changes (cancellation or major delay)
|
||||
// and triggers a re-search to find an updated route.
|
||||
@@ -355,7 +356,7 @@ func TestRouteReSearchOnChange(t *testing.T) {
|
||||
Duration: 3600, // 1 hour
|
||||
Transport: "train",
|
||||
IsTransfer: false,
|
||||
Cost: 500,
|
||||
Cost: 500,
|
||||
})
|
||||
|
||||
// Add real edge s2 -> s3 (direct route)
|
||||
@@ -366,7 +367,7 @@ func TestRouteReSearchOnChange(t *testing.T) {
|
||||
Duration: 3600, // 1 hour
|
||||
Transport: "train",
|
||||
IsTransfer: false,
|
||||
Cost: 500,
|
||||
Cost: 500,
|
||||
})
|
||||
|
||||
// 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()[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,
|
||||
ID: "test-route-123",
|
||||
ID: "test-route-123",
|
||||
// Set LastChecked to 2 hours ago (7200 seconds) to force re-check
|
||||
// The check skips if checked within 3600 seconds (1 hour)
|
||||
LastChecked: time.Now().Unix() - 7200,
|
||||
NeedsReSearch: false,
|
||||
LastChecked: time.Now().Unix() - 7200,
|
||||
NeedsReSearch: false,
|
||||
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()[1], To: graph.Nodes()[2], Duration: 3600, Transport: "train", IsTransfer: false, Cost: 500},
|
||||
},
|
||||
TotalDuration: 7200,
|
||||
TotalDuration: 7200,
|
||||
TotalTransfers: 0,
|
||||
ID: "test-route-456",
|
||||
LastChecked: time.Now().Unix() - 7200,
|
||||
NeedsReSearch: false,
|
||||
ID: "test-route-456",
|
||||
LastChecked: time.Now().Unix() - 7200,
|
||||
NeedsReSearch: false,
|
||||
ReSearchReason: "",
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
|
||||
// SearchCacheService handles caching and on-demand Yandex /search calls.
|
||||
type SearchCacheService struct {
|
||||
cache *cache.CacheAside
|
||||
yclient *yandex.Client
|
||||
metrics *metrics.Metrics
|
||||
cache *cache.CacheAside
|
||||
yclient *yandex.Client
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
|
||||
// Parse the itinerary from cached data (assuming JSON format)
|
||||
var result Itinerary
|
||||
if err := parseItineraryFromBytes(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse itinerary from cache: %w", err)
|
||||
// Parse the yandex.Response from cached data
|
||||
var result yandex.Response
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse yandex response from cache: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -73,17 +73,6 @@ func (s *SearchCacheService) performYandexSearch(ctx context.Context, from, to,
|
||||
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.
|
||||
func convertResponseToBytes(resp *yandex.Response) ([]byte, error) {
|
||||
if resp == nil {
|
||||
|
||||
@@ -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) {
|
||||
// Test with nil response
|
||||
_, err := convertResponseToBytes(nil)
|
||||
|
||||
@@ -65,7 +65,7 @@ func (snt *StationNeighborsTable) MarkExcluded(cityCode, stationID string) {
|
||||
// GetNonExcluded returns non-excluded neighbors for a given city code.
|
||||
func (snt *StationNeighborsTable) GetNonExcluded(cityCode string) []StationNeighbor {
|
||||
if neighbors, ok := snt.neighbors[cityCode]; ok {
|
||||
var result []StationNeighbor
|
||||
var result []StationNeighbor
|
||||
for _, n := range neighbors {
|
||||
if !n.IsExcluded {
|
||||
result = append(result, n)
|
||||
@@ -74,4 +74,4 @@ func (snt *StationNeighborsTable) GetNonExcluded(cityCode string) []StationNeigh
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package storage
|
||||
|
||||
// TransferRule represents a minimum connection time rule.
|
||||
type TransferRule struct {
|
||||
RuleKey string `json:"rule_key"`
|
||||
MinTransferTimeMinutes int `json:"min_transfer_time_minutes"`
|
||||
RuleKey string `json:"rule_key"`
|
||||
MinTransferTimeMinutes int `json:"min_transfer_time_minutes"`
|
||||
}
|
||||
|
||||
// TransferRuleMap is a lookup map for MCT values.
|
||||
@@ -42,4 +42,4 @@ func ExtractBaseKey(ruleKey string) string {
|
||||
}
|
||||
|
||||
// DefaultMCT is the default minimum connection time in seconds (30 minutes).
|
||||
const DefaultMCT = 1800 // 30 minutes
|
||||
const DefaultMCT = 1800 // 30 minutes
|
||||
|
||||
@@ -325,11 +325,15 @@ func (tb *tokenBucket) acquire() error {
|
||||
func (tb *tokenBucket) refill(now time.Time) {
|
||||
elapsed := now.Sub(tb.lastRefill)
|
||||
if elapsed >= time.Second {
|
||||
// Refill tokens based on elapsed time and rate
|
||||
tb.tokens = tb.capacity
|
||||
tokensToAdd := int(elapsed.Seconds()) * tb.refillPerSec
|
||||
if tb.tokens+tokensToAdd > tb.capacity {
|
||||
tb.tokens = tb.capacity
|
||||
} else {
|
||||
tb.tokens += tokensToAdd
|
||||
}
|
||||
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 ---
|
||||
@@ -342,7 +346,6 @@ func newCircuitBreaker() *circuitBreaker {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (cb *circuitBreaker) ResetCircuitBreaker() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
@@ -420,4 +423,4 @@ func applyJitter(backoff time.Duration) time.Duration {
|
||||
func randFloat64() float64 {
|
||||
// Use math/rand with a seed based on function call index for variability
|
||||
return rand.Float64()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user