Repository navigation
Bound the direction precompute shape cache #1668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,11 @@ type stopDirectionResult struct { | |
| direction string | ||
| } | ||
|
|
||
| // precomputeShapeCachePoints is how many shape points the precomputer keeps | ||
| // in memory, about 20 MB of rows. It holds roughly 500 shapes of average size | ||
| // in a large bus feed such as NJ Transit's. | ||
| const precomputeShapeCachePoints = 500_000 | ||
|
|
||
| // DirectionPrecomputer handles batch computation of stop directions | ||
| type DirectionPrecomputer struct { | ||
| calculator *AdvancedDirectionCalculator | ||
|
|
@@ -29,8 +34,10 @@ type DirectionPrecomputer struct { | |
|
|
||
| // NewDirectionPrecomputer creates a new direction precomputer | ||
| func NewDirectionPrecomputer(queries *gtfsdb.Queries, db *sql.DB) *DirectionPrecomputer { | ||
| calculator := NewAdvancedDirectionCalculator(queries) | ||
| calculator.recentShapes = newRecentShapes(precomputeShapeCachePoints) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whether the LRU hits depends on ListStops order (stop_id), not shape order, as the PR description notes. A feed whose stop IDs do not follow routes can fall back to about the no-cache time (31 s vs 21 s for NJT). A deeper fix is to give workers stops in shape order (for example, sort stops by the first shape that serves them), or to compute per shape. Then a small cache, or none, gets close to one read per shape whatever the ID scheme. Generated by Claude Code
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed that's the deeper fix, but handing workers stops in shape order is a redesign of the precompute loop. The PR description already names this limit, so I'd like to take it as a follow-up.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filed as #1970. |
||
| return &DirectionPrecomputer{ | ||
| calculator: NewAdvancedDirectionCalculator(queries), | ||
| calculator: calculator, | ||
| queries: queries, | ||
| db: db, | ||
| logger: slog.Default().With(slog.String("component", "direction_precomputer")), | ||
|
|
@@ -54,35 +61,6 @@ func (dp *DirectionPrecomputer) PrecomputeAllDirections(ctx context.Context) err | |
| return nil | ||
| } | ||
|
|
||
| // ===== PHASE 0: PRE-LOAD SHAPE DATA INTO CACHE ===== | ||
| logging.LogOperation(dp.logger, "loading_shape_cache_started") | ||
| shapeCache, err := dp.loadShapeCache(ctx) | ||
| if err != nil { | ||
| logging.LogError(dp.logger, "Failed to load shape cache", err) | ||
| // Don't fail precomputation if cache loading fails - will fall back to DB queries | ||
| } else { | ||
| // Calculate total points across all shapes | ||
| totalPoints := 0 | ||
| for _, points := range shapeCache { | ||
| totalPoints += len(points) | ||
| } | ||
|
|
||
| // Estimate memory usage (approximate): | ||
| // Each GetShapePointsWithDistanceRow: 2 float64 (lat/lon) + 1 int64 (sequence) + 1 sql.NullFloat64 (dist) | ||
| // = 16 + 16 + 8 + 24 = 64 bytes per point (conservative estimate) | ||
| // Plus map overhead and slice headers | ||
| estimatedMemoryMB := float64(totalPoints*64) / (1024 * 1024) | ||
|
|
||
| logging.LogOperation(dp.logger, "shape_cache_loaded", | ||
| slog.Int("shape_count", len(shapeCache)), | ||
| slog.Int("total_points", totalPoints), | ||
| slog.Float64("estimated_memory_mb", estimatedMemoryMB)) | ||
| // Set the cache on the calculator for use during precomputation | ||
| if err := dp.calculator.SetShapeCache(shapeCache); err != nil { | ||
| return fmt.Errorf("failed to set shape cache: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // ===== PHASE 1: PARALLEL DIRECTION CALCULATION (read-only) ===== | ||
| numWorkers := runtime.NumCPU() | ||
| stopsChan := make(chan gtfsdb.Stop, numWorkers) | ||
|
|
@@ -236,39 +214,16 @@ func (dp *DirectionPrecomputer) PrecomputeAllDirections(ctx context.Context) err | |
| } | ||
|
|
||
| duration := time.Since(startTime) | ||
| shapeStats := dp.calculator.recentShapes.lookupStats() | ||
| logging.LogOperation(dp.logger, "precomputing_stop_directions_completed", | ||
| slog.Duration("duration_ms", duration), | ||
| slog.Int("total_stops", len(stops)), | ||
| slog.Int("successful", successCount), | ||
| slog.Int("skipped", skippedCount), | ||
| slog.Int("errors", errorCount)) | ||
| slog.Int("errors", errorCount), | ||
| slog.Int("shape_lookups", shapeStats.lookups), | ||
| slog.Int("shape_cache_hits", shapeStats.hits), | ||
| slog.Int("shape_reads", shapeStats.reads)) | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // loadShapeCache loads all shape data from the database and organizes it by shape_id | ||
| // for efficient lookup during direction precomputation | ||
| func (dp *DirectionPrecomputer) loadShapeCache(ctx context.Context) (map[string][]gtfsdb.GetShapePointsWithDistanceRow, error) { | ||
| // Get all shape points from the database | ||
| allShapes, err := dp.queries.GetAllShapes(ctx) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to fetch all shapes: %w", err) | ||
| } | ||
|
|
||
| // Organize shapes by shape_id for O(1) lookup | ||
| shapeCache := make(map[string][]gtfsdb.GetShapePointsWithDistanceRow) | ||
|
|
||
| for _, shape := range allShapes { | ||
| // Convert Shape to GetShapePointsWithDistanceRow (excludes ShapeID as it's the map key) | ||
| shapePoint := gtfsdb.GetShapePointsWithDistanceRow{ | ||
| Lat: shape.Lat, | ||
| Lon: shape.Lon, | ||
| ShapePtSequence: shape.ShapePtSequence, | ||
| ShapeDistTraveled: shape.ShapeDistTraveled, | ||
| } | ||
|
|
||
| shapeCache[shape.ShapeID] = append(shapeCache[shape.ShapeID], shapePoint) | ||
| } | ||
|
|
||
| return shapeCache, nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
recentShapes lives outside the directionCache generation, so ClearCache never resets it. The only thing preventing stale shapes after a reload is this comment. Moving recentShapes and shapeLoads into directionCache, the way requestGroup and tripGroup already are, would drop the "one import only" restriction. It would also stop a reload-time read from filling the new generation, which the directionCache comment says must not happen.
Generated by Claude Code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leaving this for now. Only the calculator
NewDirectionPrecomputercreates has arecentShapes, it's created fresh for each import, and nothing callsClearCacheon it. The long-livedmanager.DirectionCalculatorhas none. So the stale-shape case can't happen today. Moving it intodirectionCachewould mean threading the cache generation throughcalculateOrientationAtStopand about 15 test call sites, which is fine hardening for later but out of scope here.