Skip to content

Build vehicles-for-agency tripStatus from the shared builder - #1332

Closed
dev-aditya-hub wants to merge 2 commits into
OneBusAway:mainfrom
dev-aditya-hub:fix/1195-vehicles-for-agency-trip-status
Closed

Build vehicles-for-agency tripStatus from the shared builder#1332
dev-aditya-hub wants to merge 2 commits into
OneBusAway:mainfrom
dev-aditya-hub:fix/1195-vehicles-for-agency-trip-status

Conversation

@dev-aditya-hub

Copy link
Copy Markdown

Fixes #1195.

Depends on #1331. This branch is stacked on that one, so its diff includes that commit as well. Please review/merge that first; GitHub will drop it from this diff once it lands.

Why

vehicles-for-agency builds its tripStatus inline, and only fills a subset of the fields the spec defines. Missing today: closestStop and closestStopTimeOffset, nextStop and nextStopTimeOffset, scheduleDeviation, scheduledDistanceAlongTrip, totalDistanceAlongTrip, distanceAlongTrip, predicted (always false) and situationIds (always empty).

All of it is already computed by api.BuildTripStatus, which every other real-time endpoint uses: trip-details, trip-for-vehicle, trips-for-route, and both arrivals endpoints. This handler was the odd one out, carrying a reduced copy of the same logic.

What this changes

The inline block is replaced by a call to the shared builder, wrapped in buildVehicleTripStatus. Roughly fifty lines of duplicated field assignment come out.

Three behaviours specific to this endpoint are re-applied on top, because changing them is outside the scope of this issue:

  1. activeTripId still comes from resolveActiveTripID, resolved against the request's time parameter, and blockTripSequence still reports -1 when it cannot be resolved rather than the builder's 0. Both are pinned by existing tests.
  2. status, phase and the two update timestamps are mirrored from the enclosing vehicleStatus, so the nested object cannot contradict the outer one within a single entry.
  3. Raw GPS position and orientation are restored when the builder leaves them unset. More on that below.

The stale-vehicle case

Worth calling out, because I nearly shipped it as a regression. BuildVehicleStatus returns early for a vehicle it considers stale, leaving position at (0, 0) and orientation at 0. The old inline code set both unconditionally. StaleDetector compares an absolute difference, so any time parameter more than fifteen minutes from the feed timestamp makes every vehicle stale, not just genuinely old ones.

The result was an entry carrying a real location at the top level next to a tripStatus.position of (0, 0), which is Null Island in the middle of the Atlantic. applyRawVehiclePosition restores the reported position and bearing when the builder has not set them. There is a regression test for it.

While in there I pulled the GTFS-bearing-to-OBA-orientation conversion out into OrientationFromGTFSBearing rather than have a second copy of it.

Discrepancies worth flagging

Two things I did not change but that a reviewer should probably rule on:

lastLocationUpdateTime semantics differ between handlers. BuildVehicleStatus only sets it when the vehicle has a position; this endpoint has always set it from the vehicle timestamp regardless. Adopting the shared behaviour breaks TestVehiclesForAgencyHandler_UpdateTimesPresentWhenSet. I preserved the existing behaviour rather than flip it inside this PR. If the shared semantics are the correct ones, I will file it separately.

activeTripId can now describe a different trip than the distance fields. The override in point 1 above picks the active trip by time window, while scheduledDistanceAlongTrip, totalDistanceAlongTrip and distanceAlongTrip were computed by the builder against its own distance-along-block choice. On an interlined block at a trip transition those two disagree, and the response reports one trip's ID with another trip's distances. This is a pre-existing tension between the endpoint's pinned activeTripId behaviour and the shared builder, not something this PR introduces, but it becomes visible here.

tripStatus.position is now shape-projected for fresh vehicles rather than raw GPS, since that is what the builder produces. Consistent with the other endpoints, but it is a change in the emitted number.

Cost

Reusing the builder is more expensive per vehicle: it loads that vehicle's stop times and shape and does the projection maths, which is what computing closestStop and distanceAlongTrip actually requires. The memoization in the prerequisite PR removes the part of that cost that was quadratic in vehicle count. What remains is linear and, as far as I can tell, irreducible without changing what the fields mean.

Against main at 50 vehicles it is still roughly 15x the allocations, all of it per-vehicle work that main was simply not doing. If that trade is not acceptable for large agencies I would rather hear it now than after more of the response is built on it.

Testing

TestVehiclesForAgencyHandler_TripStatusSpecFields parks a vehicle at a real stop on an active trip and asserts the previously-missing fields. I confirmed it fails on main on exactly those assertions and passes with the change, so it is a real regression test rather than one written to fit.

TestVehiclesForAgencyHandler_StaleVehicleKeepsPosition covers the Null Island case above.

Also adds BenchmarkVehiclesForAgency_1Vehicle and BenchmarkVehiclesForAgency_50Vehicles. The existing BenchmarkVehiclesForAgency runs against the single-vehicle RABA fixture whose position goes stale during a long run, so it ends up measuring the empty path; these hold the clock still and take a vehicle count.

go fmt clean and the full suite passes on the pure-Go path (CGO_ENABLED=0 go test -tags purego ./...).

Aditya Kuchekar added 2 commits August 12, 2026 11:23
BuildTripStatus resolves a trip's block to compute position, distance and
schedule deviation. A handler that calls it once per response row repeats
that resolution for every row, and each one reloads the stop times and
shape points of every trip in the block. The work scales with
(rows x block size) rather than with the number of distinct blocks.

GetActiveServiceIDsForDate is a smaller repeat in the same path: it is
called once per row for a date that cannot change within a request.

Add a request-scoped memo, carried on the context, covering
loadBlockTripData (keyed on the trip-ID set), the active service calendar
(keyed on the date), and stop times seeded by prefetchStopTimes. This
mirrors WithSnapshotCache, which already solves the same problem one level
up for whole block snapshots; these entries are still reached on the
schedule-deviation path, which resolves a block without a snapshot.

Handlers that do not opt in are unaffected, since every accessor falls
through to the original query when the context carries no memo.

Shape points are deliberately not prefetched. GetShapePointsByTripIDs
joins shapes to trips, so a shape shared by many trips is returned once
per trip with no deduplication, which for a large agency would materialize
hundreds of megabytes of duplicated geometry for the life of a request.

loadBlockTripData hands out a clone because loadShiftTrips sorts in place.
A nil result there means the query failed rather than that the block is
empty, so it is not cached; doing so would turn one transient error into a
block-wide degradation for the rest of the request.
vehicles-for-agency built its tripStatus inline and filled only a subset
of the spec'd fields. closestStop, nextStop and their offsets,
scheduleDeviation, the three distance-along-trip fields, predicted and
situationIds were all left at their zero values.

api.BuildTripStatus already computes every one of them, and every other
real-time endpoint uses it. This handler carried a reduced copy of the
same logic instead.

Replace the inline block with a call to the shared builder, and re-apply
the three behaviours specific to this endpoint: activeTripId resolved
against the request's time parameter with blockTripSequence reporting -1
when unresolvable, status/phase and the update timestamps mirrored from
the enclosing vehicleStatus so the nested object cannot contradict it,
and the raw GPS position restored when the builder leaves it unset.

That last one is not cosmetic. BuildVehicleStatus returns early for a
vehicle it considers stale, and StaleDetector compares an absolute
difference, so any time parameter more than fifteen minutes from the feed
timestamp makes every vehicle stale. Without the fallback the entry pairs
a real top-level location with a tripStatus position of (0, 0).

Extract OrientationFromGTFSBearing rather than keep a second copy of the
bearing conversion.

Fixes OneBusAway#1195
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


Aditya Kuchekar seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@dev-aditya-hub, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 68a20f0e-af0c-40e6-a029-d3f32e382bc5

📥 Commits

Reviewing files that changed from the base of the PR and between 91226a9 and db72902.

📒 Files selected for processing (7)
  • internal/restapi/request_cache.go
  • internal/restapi/request_cache_test.go
  • internal/restapi/scheduled_block_helper.go
  • internal/restapi/trips_helper.go
  • internal/restapi/vehicles_for_agency_handler.go
  • internal/restapi/vehicles_for_agency_handler_test.go
  • internal/restapi/vehicles_helper.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@aaronbrethorst

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. tripStatus.closestStop / nextStop are now populated, but references.stops is still never filled, so those IDs dangle. The handler only sets references.Agencies, Routes, Trips and Situations; NewEmptyReferences() leaves Stops as []. This was not a problem on main because both fields were always "". Issue vehicles-for-agency: tripStatus fields left unpopulated #1195 (which this PR says it fixes) calls it out explicitly: "Stop-relative fields (closestStop, nextStop, and their offsets) are resolved from schedule/real-time data, with the referenced stops appearing in references.stops." The three sibling handlers that emit a tripStatus all populate it (trip_for_vehicle_handler.go:159, trip_details_handler.go:360, trips_for_route_handler.go:914).

// Omit references entirely when includeReferences=false.
references := models.NewEmptyReferences()
if ShouldIncludeReferences(r) {
references.Agencies = []models.AgencyReference{models.AgencyReferenceFromDatabase(agency)}
references.Routes = routeRefList
references.Trips = tripRefList
alerts := deduplicateAlerts(
api.collectAlertsForRoutes(routeIDs),
api.GtfsManager.GetAlertsByIDs("", "", id),
)
references.Situations = append(references.Situations, api.BuildSituationReferences(alerts)...)
}

Two smaller things, not blocking, for the maintainer to rule on:

  • situationIds has the same shape of gap. GetSituationIDsForTrip matches alerts by trip, route and agency, but the reference block only collects route-scoped and agency-scoped alerts (collectAlertsForRoutes(routeIDs) + GetAlertsByIDs("", "", id)). An alert whose informed entity names only a trip_id will appear in situationIds with no matching entry in references.situations.
  • The doc comment on buildVehicleTripStatus says it "re-applies the two behaviours that are specific to this endpoint" and then lists activeTripId/blockTripSequence, but the function also mirrors status/phase/update times and restores the raw GPS position. The PR description says three; the comment says two.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@aaronbrethorst aaronbrethorst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need some changes before merging — please see the notes below.

I did the field-by-field comparison this kind of refactor demands, and it came out clean: serviceDate still normalizes to service-day midnight, occupancyStatus is still set unconditionally from the vehicle, occupancyCapacity/occupancyCount still default to -1 through NewTripStatus(), and the status/phase mapping is unchanged. The raw-GPS fallback for stale vehicles correctly preserves the endpoint's long-standing behavior rather than quietly regressing it. Replacing fifty lines of inline construction with the shared builder while re-applying only the genuinely endpoint-specific bits is exactly the right shape for this change, and the description is unusually candid about its own trade-offs — that made reviewing it much faster.

One thing to fix:

references.stops is never populated. The handler sets Agencies, Routes, Trips, and Situations, and NewEmptyReferences() leaves Stops as []. On main that was fine because closestStop and nextStop were always empty strings — but the shared builder emits them now, so those IDs come back as dangling references. #1195, which this PR says it fixes, names that requirement directly, and all three sibling tripStatus handlers populate it.

Related and worth handling at the same time: situationIds can carry trip-scoped alert IDs that aren't in references.situations either. Rarer, since it needs an alert whose informed entity names only a trip, but it's the same class of gap.

Two notes, not blocking:

  • For a stale vehicle, tripStatus.position is the old GPS fix while distanceAlongTrip and scheduledDistanceAlongTrip come from the schedule snapshot at referenceTime — so the two describe different places. That's inherent to the fallback you chose and it beats the previous behavior of reporting 0, but it's worth a comment so the next reader doesn't think it's a bug.
  • The buildVehicleTripStatus doc comment says "the two behaviours specific to this endpoint," but four are re-applied.

The allocation increase you flagged — per-vehicle shape query, per-vehicle GetTrip+GetRoute inside GetSituationIDsForTrip, per-vehicle block snapshot — is real and I'd like to see numbers on a large agency before this lands, since vehicles-for-agency is on the hot path.

Finally, sequencing: this is stacked on #1331, which I've asked for changes on (its cache has no production caller yet), so this can't land before that resolves. The CLA check is also still pending on both.

@burma-shave

Copy link
Copy Markdown
Collaborator

Thanks for your work on this. The PR for the caching mechanism that this PR depends on has been closed. The linked issue is currently blocked until the underlying scalability issue has been addressed in a different way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

vehicles-for-agency: tripStatus fields left unpopulated

4 participants