lazy-graph-expansion #2
@@ -53,8 +53,8 @@ const (
|
|||||||
|
|
||||||
// hubCriteria defines the criteria for selecting hub stations.
|
// hubCriteria defines the criteria for selecting hub stations.
|
||||||
type hubCriteria struct {
|
type hubCriteria struct {
|
||||||
minPopulation int // minimum city population in millions to be considered a hub
|
|
||||||
minOutgoingFlights int // minimum number of outgoing Yandex flights to be considered a hub
|
minOutgoingFlights int // minimum number of outgoing Yandex flights to be considered a hub
|
||||||
|
minPopulation int // minimum city population (millions) to be considered a hub
|
||||||
defaultOutgoingFlights int // default outgoing flights count when data is unavailable
|
defaultOutgoingFlights int // default outgoing flights count when data is unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +66,6 @@ type HubStation struct {
|
|||||||
CityCode string
|
CityCode string
|
||||||
// OutgoingFlights is the estimated number of outgoing Yandex flights from this station.
|
// OutgoingFlights is the estimated number of outgoing Yandex flights from this station.
|
||||||
OutgoingFlights int
|
OutgoingFlights int
|
||||||
// Population is the city population in millions used for hub selection.
|
|
||||||
Population int
|
|
||||||
// IsHub indicates whether this station meets the hub criteria.
|
// IsHub indicates whether this station meets the hub criteria.
|
||||||
IsHub bool
|
IsHub bool
|
||||||
}
|
}
|
||||||
@@ -93,13 +91,12 @@ func SelectHubStations(stations []StationInfo, criteria hubCriteria) HubStationS
|
|||||||
Station: &Node{ID: si.ID, Type: NodeTypeStation, Name: si.Name, CityCode: si.CityCode},
|
Station: &Node{ID: si.ID, Type: NodeTypeStation, Name: si.Name, CityCode: si.CityCode},
|
||||||
CityCode: si.CityCode,
|
CityCode: si.CityCode,
|
||||||
OutgoingFlights: criteria.defaultOutgoingFlights,
|
OutgoingFlights: criteria.defaultOutgoingFlights,
|
||||||
Population: 0, // will be inferred from city code later
|
|
||||||
IsHub: false,
|
IsHub: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A station is considered a hub if:
|
// A station is considered a hub if it has >= minOutgoingFlights outgoing Yandex flights.
|
||||||
// 1. It has >= minOutgoingFlights (outgoing Yandex flight data available) - primary criterion
|
// Population-based selection (minPopulation) is tracked for future implementation;
|
||||||
// For MVP, outgoing flights is the primary criterion.
|
// currently only the outgoing flights criterion is enforced.
|
||||||
hasOutgoingFlights := hub.OutgoingFlights >= criteria.minOutgoingFlights
|
hasOutgoingFlights := hub.OutgoingFlights >= criteria.minOutgoingFlights
|
||||||
|
|
||||||
if hasOutgoingFlights {
|
if hasOutgoingFlights {
|
||||||
@@ -573,8 +570,8 @@ func expandFromCityHub(g *Graph, from *Node, destCityCode string, date string, o
|
|||||||
|
|
||||||
if foundEdges {
|
if foundEdges {
|
||||||
// Edges already added to graph during cache miss fetch
|
// Edges already added to graph during cache miss fetch
|
||||||
// Return cached data indicating success
|
// Return a marker indicating success; GetSearch caller only checks err != nil
|
||||||
return []byte("found_edges"), nil
|
return []byte("1"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return error to trigger synthetic fallback
|
// Return error to trigger synthetic fallback
|
||||||
@@ -716,7 +713,10 @@ func (g *Graph) FindRoute(originID, destID string, opts SearchOptions) *Itinerar
|
|||||||
// Expand from this node using lazy expansion
|
// Expand from this node using lazy expansion
|
||||||
// Use the destination city code and date from search options for /search calls
|
// Use the destination city code and date from search options for /search calls
|
||||||
if opts.DestCityCode != "" && opts.Date != "" {
|
if opts.DestCityCode != "" && opts.Date != "" {
|
||||||
g.ExpandGraphLazy(g.currentNodeByID(current.nodeID), opts.DestCityCode, opts.Date, &opts)
|
if err := g.ExpandGraphLazy(g.currentNodeByID(current.nodeID), opts.DestCityCode, opts.Date, &opts); err != nil {
|
||||||
|
// Expansion failed (e.g., API error, node not found) — fall back to synthetic edges
|
||||||
|
addSyntheticEdgesForNode(g, current.nodeID)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no dest city/code available, add synthetic edges as fallback
|
// If no dest city/code available, add synthetic edges as fallback
|
||||||
addSyntheticEdgesForNode(g, current.nodeID)
|
addSyntheticEdgesForNode(g, current.nodeID)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package routing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"trip-planner/internal/cache"
|
"trip-planner/internal/cache"
|
||||||
"trip-planner/internal/yandex"
|
"trip-planner/internal/yandex"
|
||||||
@@ -571,7 +571,7 @@ func TestSelectHubStations(t *testing.T) {
|
|||||||
|
|
||||||
// With minOutgoingFlights=1, stations with default outgoing flights are hubs
|
// With minOutgoingFlights=1, stations with default outgoing flights are hubs
|
||||||
// defaultOutgoingFlights is set to 1 so stations get selected
|
// defaultOutgoingFlights is set to 1 so stations get selected
|
||||||
criteria := hubCriteria{minPopulation: 1, minOutgoingFlights: 1, defaultOutgoingFlights: 1}
|
criteria := hubCriteria{minOutgoingFlights: 1, defaultOutgoingFlights: 1}
|
||||||
result := SelectHubStations(stations, criteria)
|
result := SelectHubStations(stations, criteria)
|
||||||
|
|
||||||
// Moscow has default outgoing flights and should be a hub
|
// Moscow has default outgoing flights and should be a hub
|
||||||
@@ -590,7 +590,7 @@ func TestSelectHubStations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// With high minOutgoingFlights, all stations should be rejected
|
// With high minOutgoingFlights, all stations should be rejected
|
||||||
highCriteria := hubCriteria{minPopulation: 1, minOutgoingFlights: 100}
|
highCriteria := hubCriteria{minOutgoingFlights: 100}
|
||||||
highResult := SelectHubStations(stations, highCriteria)
|
highResult := SelectHubStations(stations, highCriteria)
|
||||||
|
|
||||||
// All stations should be rejected when threshold is too high
|
// All stations should be rejected when threshold is too high
|
||||||
@@ -622,7 +622,7 @@ func TestBuildGraphFromHubs(t *testing.T) {
|
|||||||
{ID: "s4", Name: "SmallCity", CityCode: "s1", CityName: "Smallville"},
|
{ID: "s4", Name: "SmallCity", CityCode: "s1", CityName: "Smallville"},
|
||||||
}
|
}
|
||||||
|
|
||||||
criteria := hubCriteria{minPopulation: 1, minOutgoingFlights: 10, defaultOutgoingFlights: 10}
|
criteria := hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10}
|
||||||
graph := BuildGraphFromHubs(stations, criteria)
|
graph := BuildGraphFromHubs(stations, criteria)
|
||||||
|
|
||||||
// Should have station nodes + city nodes
|
// Should have station nodes + city nodes
|
||||||
@@ -891,7 +891,7 @@ func TestFindRouteWithLazyExpansion_MCTCalculation(t *testing.T) {
|
|||||||
// TestBuildGraphFromHubs_EdgeCases tests edge cases for hub graph building.
|
// TestBuildGraphFromHubs_EdgeCases tests edge cases for hub graph building.
|
||||||
func TestBuildGraphFromHubs_EdgeCases(t *testing.T) {
|
func TestBuildGraphFromHubs_EdgeCases(t *testing.T) {
|
||||||
// Empty stations list
|
// Empty stations list
|
||||||
graph := BuildGraphFromHubs(nil, hubCriteria{minPopulation: 1, minOutgoingFlights: 10, defaultOutgoingFlights: 10})
|
graph := BuildGraphFromHubs(nil, hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10})
|
||||||
if len(graph.Nodes()) != 0 {
|
if len(graph.Nodes()) != 0 {
|
||||||
t.Errorf("expected 0 nodes for empty stations list, got %d", len(graph.Nodes()))
|
t.Errorf("expected 0 nodes for empty stations list, got %d", len(graph.Nodes()))
|
||||||
}
|
}
|
||||||
@@ -901,7 +901,7 @@ func TestBuildGraphFromHubs_EdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
// Single station
|
// Single station
|
||||||
graph = BuildGraphFromHubs([]StationInfo{{ID: "s1", Name: "Only", CityCode: "c1", CityName: "City1"}},
|
graph = BuildGraphFromHubs([]StationInfo{{ID: "s1", Name: "Only", CityCode: "c1", CityName: "City1"}},
|
||||||
hubCriteria{minPopulation: 1, minOutgoingFlights: 10, defaultOutgoingFlights: 10})
|
hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10})
|
||||||
if len(graph.Nodes()) != 2 { // 1 station + 1 city
|
if len(graph.Nodes()) != 2 { // 1 station + 1 city
|
||||||
t.Errorf("expected 2 nodes (1 station + 1 city) for single station, got %d", len(graph.Nodes()))
|
t.Errorf("expected 2 nodes (1 station + 1 city) for single station, got %d", len(graph.Nodes()))
|
||||||
}
|
}
|
||||||
@@ -913,7 +913,7 @@ func TestBuildGraphFromHubs_EdgeCases(t *testing.T) {
|
|||||||
graph = BuildGraphFromHubs([]StationInfo{
|
graph = BuildGraphFromHubs([]StationInfo{
|
||||||
{ID: "s1", Name: "Station 1", CityCode: "c1", CityName: "City1"},
|
{ID: "s1", Name: "Station 1", CityCode: "c1", CityName: "City1"},
|
||||||
{ID: "s2", Name: "Station 2", CityCode: "c1", CityName: "City1"},
|
{ID: "s2", Name: "Station 2", CityCode: "c1", CityName: "City1"},
|
||||||
}, hubCriteria{minPopulation: 1, minOutgoingFlights: 10, defaultOutgoingFlights: 10})
|
}, hubCriteria{minOutgoingFlights: 10, defaultOutgoingFlights: 10})
|
||||||
nodes := graph.Nodes()
|
nodes := graph.Nodes()
|
||||||
cityCount := 0
|
cityCount := 0
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
@@ -930,11 +930,11 @@ func TestBuildGraphFromHubs_EdgeCases(t *testing.T) {
|
|||||||
// for on-demand route searching between station pairs.
|
// for on-demand route searching between station pairs.
|
||||||
func TestSearchRoutes_onDemand(t *testing.T) {
|
func TestSearchRoutes_onDemand(t *testing.T) {
|
||||||
c := yandex.NewClient("test-key")
|
c := yandex.NewClient("test-key")
|
||||||
|
yandex.ResetCircuitBreaker(c)
|
||||||
|
|
||||||
resp, err := c.SearchRoutes(context.Background(), "s9600213", "s9600396", "2026-08-15")
|
resp, err := c.SearchRoutes(context.Background(), "s9600213", "s9600396", "2026-08-15")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Circuit breaker may be open from prior test sequence; skip if so
|
t.Skipf("skipping SearchRoutes test: %v (circuit breaker may be open)", err)
|
||||||
t.Skipf("skipping SearchRoutes test: %v (circuit breaker may be open from prior tests)", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify response structure
|
// Verify response structure
|
||||||
@@ -945,9 +945,6 @@ func TestSearchRoutes_onDemand(t *testing.T) {
|
|||||||
t.Error("expected valid pagination total from SearchRoutes")
|
t.Error("expected valid pagination total from SearchRoutes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLazySearchCacheIntegration tests the cache hit/miss behavior
|
|
||||||
// when used with lazy graph expansion and on-demand /search calls.
|
|
||||||
func TestLazySearchCacheIntegration(t *testing.T) {
|
func TestLazySearchCacheIntegration(t *testing.T) {
|
||||||
// This test verifies the cache key generation and TTL policies
|
// This test verifies the cache key generation and TTL policies
|
||||||
// work correctly with the lazy expansion strategy
|
// work correctly with the lazy expansion strategy
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ func WithRetryConfig(maxRetries int, baseBackoff, maxBackoff time.Duration, jitt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetCircuitBreaker resets the circuit breaker to closed state.
|
||||||
|
// Useful for tests to ensure a fresh start.
|
||||||
|
func ResetCircuitBreaker(c *Client) {
|
||||||
|
c.circuitBreaker = newCircuitBreaker()
|
||||||
|
}
|
||||||
|
|
||||||
// Do executes a Yandex API request with rate limiting, circuit breaking, and retry.
|
// Do executes a Yandex API request with rate limiting, circuit breaking, and retry.
|
||||||
func (c *Client) Do(ctx context.Context, method, path string, query map[string]string) (*Response, error) {
|
func (c *Client) Do(ctx context.Context, method, path string, query map[string]string) (*Response, error) {
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
|
|||||||
Reference in New Issue
Block a user