feat(engine): OSRM travel provider with cache and fallback #90

Merged
patrick merged 1 commit from feature/engine-osrm into main 2026-09-11 14:07:58 +00:00
Owner

Description

Adds real OSRM-based travel-time routing to the planning engine, alongside the straight-line
(haversine) estimate it's had until now. When the OSRM router is reachable, the engine gets
actual road-network travel times instead of a straight-line guess; when it isn't — down, still
starting up, or simply not deployed in an environment — the engine quietly falls back to the
straight-line estimate instead of failing to produce a plan, and that fallback is recorded so a
planner reviewing the plan can see it was built on an estimate rather than real routing. Repeated
requests for the same set of locations are served from an in-memory cache instead of asking the
router again every time.

A standalone OSRM reachability check is also added, ready for the /healthz//metrics endpoints
to report on — see Additional Context for why that wiring isn't in this PR.

Closes #42. Row PH6.1 in docs/12-implementation-plan.md. Files a follow-up: #89 (seam
request, blocks #42).

Type of Change

  • New feature
  • Bug fix
  • Refactor / cleanup (no behaviour change)
  • Documentation
  • Chore / build / CI
  • Breaking change

Breaking Changes

None.

Test Plan

Tests green (mypy --strict, ruff, full unit suite). Beyond the mocked-transport unit tests,
this was also verified against a real, running OSRM process (osrm/osrm-backend, the same image
the compose stack uses) on a small road network built locally with osrm-extract/partition/
customize: real /table response parsing and unit conversion, a cache hit skipping a second
HTTP call, and the fallback-then-recovery path by stopping and restarting that container mid-run.
The full Netherlands extract (infra/osrm/prepare.sh) is a multi-GB download and a multi-minute
build, impractical to run in this session — a compose-stack smoke test
(backend/tests/integration/engine/osrm_smoke_test.py) is included for that, written to run
unmodified once the real extract is up (pytest -m smoke); it auto-skips otherwise, same as the
existing Keycloak smoke test.

Checklist

  • Conventional commit(s), one logical change per commit
  • Tests added/updated and passing
  • docs/ updated for any behavioural change (ADR added if a prior decision was reversed)
  • No hard deletes; no direct status updates outside polaris.domain
  • No business logic in routers/components
  • Migration included if the schema changed (one per PR max, reversible downgrade)
  • No new dependency without a reason in the commit body

Additional Context

This ticket's third acceptance criterion is only partially closed, and honestly so — see the
comment thread on #42 for the full explanation. Short version: making OSRM's reachability actually
show up as "degraded" on GET /healthz requires editing api/routers/platform/health.py and
api/metrics.py, both outside this row's owned paths (only engine/travel/osrm.py and
core/health.py are), and the router's current health status has no "degraded" state to report
into yet. The probe itself (check_osrm()) is complete, tested, and verified against a real OSRM
process both up and down — I opened #89 (seam request, set to block #42) for the remaining wiring
rather than widening this ticket's scope.

Also out of scope for the same reason: polaris.engine.solve and
polaris.worker.handlers.solve still default to the haversine provider unconditionally — using
OSRM in a real solve needs one of them to construct OsrmTravelMatrixProvider and read
last_call_source back into travel_source. Both are sealed to other, already-merged rows
(#18, #82); noted as a seam in osrm.py's own module docstring for whoever next touches those
files, rather than filed as a separate issue since it's a smaller, lower-priority follow-up than
#89.

engine/travel.py became a package (engine/travel/__init__.py, content unchanged) so osrm.py
has the module path this row specifies in docs/12-implementation-plan.md; every existing
from polaris.engine.travel import ... call site is unaffected.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX

## Description Adds real OSRM-based travel-time routing to the planning engine, alongside the straight-line (haversine) estimate it's had until now. When the OSRM router is reachable, the engine gets actual road-network travel times instead of a straight-line guess; when it isn't — down, still starting up, or simply not deployed in an environment — the engine quietly falls back to the straight-line estimate instead of failing to produce a plan, and that fallback is recorded so a planner reviewing the plan can see it was built on an estimate rather than real routing. Repeated requests for the same set of locations are served from an in-memory cache instead of asking the router again every time. A standalone OSRM reachability check is also added, ready for the `/healthz`/`/metrics` endpoints to report on — see Additional Context for why that wiring isn't in this PR. ## Related Issues Closes #42. Row **PH6.1** in `docs/12-implementation-plan.md`. Files a follow-up: #89 (seam request, blocks #42). ## Type of Change - [x] New feature - [ ] Bug fix - [ ] Refactor / cleanup (no behaviour change) - [ ] Documentation - [ ] Chore / build / CI - [ ] Breaking change ## Breaking Changes None. ## Test Plan Tests green (`mypy --strict`, `ruff`, full unit suite). Beyond the mocked-transport unit tests, this was also verified against a real, running OSRM process (`osrm/osrm-backend`, the same image the compose stack uses) on a small road network built locally with `osrm-extract`/`partition`/ `customize`: real `/table` response parsing and unit conversion, a cache hit skipping a second HTTP call, and the fallback-then-recovery path by stopping and restarting that container mid-run. The full Netherlands extract (`infra/osrm/prepare.sh`) is a multi-GB download and a multi-minute build, impractical to run in this session — a compose-stack smoke test (`backend/tests/integration/engine/osrm_smoke_test.py`) is included for that, written to run unmodified once the real extract is up (`pytest -m smoke`); it auto-skips otherwise, same as the existing Keycloak smoke test. ## Checklist - [x] Conventional commit(s), one logical change per commit - [x] Tests added/updated and passing - [x] `docs/` updated for any behavioural change (ADR added if a prior decision was reversed) - [x] No hard deletes; no direct status updates outside `polaris.domain` - [x] No business logic in routers/components - [x] Migration included if the schema changed (one per PR max, reversible downgrade) - [x] No new dependency without a reason in the commit body ## Additional Context **This ticket's third acceptance criterion is only partially closed**, and honestly so — see the comment thread on #42 for the full explanation. Short version: making OSRM's reachability actually show up as "degraded" on `GET /healthz` requires editing `api/routers/platform/health.py` and `api/metrics.py`, both outside this row's owned paths (only `engine/travel/osrm.py` and `core/health.py` are), and the router's current health status has no "degraded" state to report into yet. The probe itself (`check_osrm()`) is complete, tested, and verified against a real OSRM process both up and down — I opened #89 (seam request, set to block #42) for the remaining wiring rather than widening this ticket's scope. Also out of scope for the same reason: `polaris.engine.solve` and `polaris.worker.handlers.solve` still default to the haversine provider unconditionally — using OSRM in a real solve needs one of them to construct `OsrmTravelMatrixProvider` and read `last_call_source` back into `travel_source`. Both are sealed to other, already-merged rows (#18, #82); noted as a seam in `osrm.py`'s own module docstring for whoever next touches those files, rather than filed as a separate issue since it's a smaller, lower-priority follow-up than #89. `engine/travel.py` became a package (`engine/travel/__init__.py`, content unchanged) so `osrm.py` has the module path this row specifies in `docs/12-implementation-plan.md`; every existing `from polaris.engine.travel import ...` call site is unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
Add OsrmTravelMatrixProvider (backend/src/polaris/engine/travel/osrm.py),
an OSRM `/table`-backed TravelMatrixProvider satisfying the same Protocol
as the existing haversine fallback (#18). It calls OSRM's table service
for the full N x N duration matrix, caches responses in-process keyed on
the ordered coordinate set (LRU-evicted), and degrades gracefully to the
haversine provider on any connection error, timeout, non-2xx, OSRM-level
error code, or unroutable (null) cell -- never raising out of matrix().
last_call_source/last_call_cache_hit expose how the most recent call was
actually served so a caller can report PlanResult.travel_source
accurately; the existing W_TRAVEL_FALLBACK validator rule already fires
whenever that ends up "fallback", so no validator change was needed.

engine/travel.py becomes a package (engine/travel/__init__.py unchanged
in content) so osrm.py has the module path this ticket's row in
docs/12-implementation-plan.md specifies; all existing
`from polaris.engine.travel import ...` call sites are unaffected.

Add core/health.py: check_osrm(), a standalone OSRM reachability probe
(GET /nearest) for the /healthz and /metrics endpoints. Wiring it into
those routers is left to a seam request -- see PR description -- since
api/routers/platform/health.py and api/metrics.py are outside this row's
owned paths and the router's current two-state CheckStatus has no
"degraded" state yet.

Both modules read POLARIS_OSRM_URL as a plain env var (already set by
infra/compose/docker-compose.yml for api/worker) rather than through
core/settings.py, which is sealed for this row -- same pattern
api/metrics.py's _otel_enabled() already uses for the same reason.

Verified against a real OSRM instance (osrm/osrm-backend, MLD, a small
synthetic road network built with osrm-extract/partition/customize) in
addition to the mocked-transport unit tests: real /table response
parsing and unit conversion, cache hit avoiding a second HTTP call, and
degraded-to-haversine + recovery when the container is stopped/restarted.

Closes #42

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoNrSy7Reyp7evkfcdHeLX
patrick deleted branch feature/engine-osrm 2026-09-11 14:07:58 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
patrick/Polaris!90
No description provided.