gap: a cell's ground waits on api.pdok.nl before it is tessellated #267
Labels
No labels
agent
agent:ci
agent:done
agent:failed
agent:needs-input
agent:refined
agent:refining
agent:running
agent:shipped
agent:skip
autonomous
autopilot
driven
local
plan
proposal
qa
qa-gap
research
retro
ship
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jeroen/cartopolis#267
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
spawn_geometry_taskawaits twoapi.pdok.nlfetchers between the cell's MVT body and the tessellation worker, so a cell's ground, roads, water, bridges, trees, labels and footprints are gated on a third party that owns none of them:crates/cartopolis/src/systems/map/map_geometry.rs:447— the cell's MVT bodymap_geometry.rs:454,:457,:463— surveyed BGT, paving, crops; all our own hostmap_geometry.rs:474-478—nav_marks::load_marks_cell, two sequential requests toapi.pdok.nl(nav_marks.rs:65,:418-425)map_geometry.rs:483-490—crossings::load_crossings_cell, one more to the same host (crossings.rs:72,:296-298)map_geometry.rs:491—workers::submit("surfaces", …), i.e. the cell is only built hereFour properties make the stall worse than "the cell waits":
Request::get, which takesDEFAULT_TIMEOUT= 20 s (platform/http.rs:26,:89-97). The agent'sCONNECT_TIMEOUTof 10 s (http.rs:242) only caps the connect phase; a host that accepts and then goes quiet spends the full 20 s.load_marks_cellloops the two collections one after the other (nav_marks.rs:418-433), andmap_geometryawaits marks fully before starting crossings. Worst case is three timeouts in series per cell, not one.http::fetchis a blockingureqcall with no await point (http.rs:275-281), so a stalled fetch holds anIoTaskPoolthread. That pool ismin_threads: 4, max_threads: 8(lib.rs:1458-1476) and is shared with every other streamer; the surface stream alone allowsSURFACE_MAX_IN_FLIGHT: usize = 8(map_geometry.rs:99). Eight stalled surface tasks can occupy the entire pool, so the basemap textures and building streamers stop too — not just the geometry cell.nav_marks.rs:421-424;crossings.rs:296-298). The surface stream ismax_attempts: 1(map_geometry.rs:335), but a retear re-runs the whole task from scratch, and there are four triggers: re-anchor (map_geometry.rs:942), terrain-field or BGT/crop coverage revision (:967), and any detail-layer toggle (:994). Each one re-pays the full wait for every cell in the ring.systems::tall_structuresis the in-tree counter-example — its ownCellStreamwithRETRY_AFTER_SECS = 60.0(tall_structures.rs:42), arriving late instead of blocking.Approach
The issue lists three directions. Take direction two — bound the await — and leave the other two open. Reasons, stated so they can be overruled cheaply:
streetslayer (map_geometry.rs:723-737, andshot_harness.rs:646records the same asymmetry), so a late-arriving crossing cell needs the parse back. Marks do not. That is a restructure, not a bound.Three parts.
1. A named short timeout for an optional third-party layer —
crates/cartopolis/src/platform/http.rs, besideDEFAULT_TIMEOUT(:26). Both fetchers' requests carry it. The issue's own timing over Groningen — median 82 ms, max 186 ms across 18 requests — is the basis: 3 s is ~16× the observed worst case, so a slow-but-working service still delivers, while a dead one costs three seconds rather than twenty. Prior art for a per-request override:transit_vehicles.rs:658,weather.rs:156,globe_weather.rs:217.2. Concurrency, in two places.
nav_marks.rs:418-433— the two collections in flight together instead of aforloop, viafutures_lite::future::zip(already used attile_source.rs:392-393). Keep the existing all-or-nothing rule: if either half fails, returnMarkCell::default()and cache nothing.map_geometry.rs:471-490— zip the marks future with the crossings future.has_rail(:483-485) has to move above the marks await so both futures can be built first; it reads onlyfetched, so nothing else reorders.3. A per-service outage gate, so a retear does not re-pay. Not negative caching under the cell's own key — the existing comment at
nav_marks.rs:421-424is right that a poisoned key never re-fetches. Instead a small process-wide breaker, one instance per module (RWS's marks service and ProRail's crossings service can fail independently even though both sit behindapi.pdok.nl): any fetch or parse failure trips it, and while it is openshould_fetch(nav_marks.rs:291-293,crossings.rs:199-201) answersfalseimmediately. Cooldown 60 s, matchingtall_structures::RETRY_AFTER_SECS(tall_structures.rs:42). Place it besidehttp::statsinplatform/http.rs, which already owns process-wide fetch state — two layers do this now, so it is a shared helper rather than a copy in each module.Two implementation constraints:
web_time, notstd::time—Instant/SystemTimepanic on wasm (user_store.rs:531-533, andhttp.rs:353-354says the same about the wasm arm).should_fetchwas split out in the first place (nav_marks.rs:286-293).Accepted consequence, not a regression: with the breaker open, cells built during an outage carry no marks or crossings and nothing revisits them until the ring re-enters or a retear fires. That is exactly what happens today on a failed fetch (
map_geometry.rs:335).Acceptance criteria
platform/http.rsdocuments the optional-layer timeout, its value, and the measurement it comes from; it is strictly less thanDEFAULT_TIMEOUT.http::Requestbuilt innav_marks.rsandcrossings.rscarries that timeout.nav_marks::load_marks_cellissues its two collection requests concurrently, not in aforloop.spawn_geometry_taskawaits the marks and crossings futures concurrently;has_waterandhas_railare both derived before either is polled.should_fetchreturnsfalseand no request is issued — asserted as a pure function with an injected instant, no runtime, no network.nav_marks.rs:421-424and the equivalent incrossings.rsstill describe the code.web_time, so the wasm lane compiles and does not panic.Verification
Run by the implementer; this pass built nothing.
Wasm lane (the breaker's clock is the only new platform-sensitive code):
Healthy-path regression, workstation only — this container cannot render, so
--shotis not available to this pass:nav_marksandlevel_crossingsare already--dump-statefields (shot_harness.rs:640,:651), so "the layer still arrives" is assertable rather than a judgement about a picture.Fault injection needs no root and so is reachable here or anywhere, at the cost of one build: point
MARKS_ORIGIN(nav_marks.rs:65) at a non-routable address such ashttps://10.255.255.1in a scratch build and time the first cell'ssurfacessubmit. Before the change that is ~40 s of held io threads for a two-collection cell; after it, one 3 s wait and then 60 s of instant returns. Nobody has run this — it is the check the change exists to make possible, and the issue records that the 20 s figure is the inherited constant, not a measured stall.Out of scope
ehttpignoresRequest::timeouton wasm (http.rs:334-341), so the bound is native-only. That gap is pre-existing and already affectstransit_vehicles,weatherandglobe_weather; fixing it means racing a timer future inside the wasm arm, which is aplatform::httpchange of its own. The breaker still works on wasm — it is what limits the damage there.CellStream(direction one). Not foreclosed by anything here.surveyed,paving,crops,map_geometry.rs:454-463): they reach our own host and carry data the cell cannot be drawn without, which is exactly the distinction this change turns on.nav_marks.rs:405-407,crossings.rs:284-286).Open questions
None.
Branch:
fix/267-bound-optional-layer-fetchesOriginal request
Found by the QA re-verification pass on #266, walking what #206/#207 shipped.
What I did
Read
map_geometry::spawn_geometry_taskend to end, then cold-started the clientwith an empty cache (
CARTO_CACHE_DIR) over the Waal at Nijmegen and over theapp's own default location, and timed the register by hand.
What happened
The per-cell geometry task awaits the navigation-marks fetch before it submits
anything to the tessellation worker:
So a cell's ground fills, roads, water, bridges, trees, labels and building
footprints are all held behind a third party that owns none of them.
load_marks_cellmakes its two collection requests sequentially in aforloop, each on
platform::http's default 20 s timeout, and writes nothing tothe cache when one fails — so a failing cell pays the full wait again on every
retear (re-anchor, terrain revision, layer toggle).
Healthy, this is cheap. I timed the 18 requests a cold start over Groningen makes
(9 cells × 2 collections, sequential): 1.70 s total, median 82 ms, max 186 ms
— about 0.17 s added to each cell. That is not the problem.
The problem is what happens when
api.pdok.nlis slow rather than fast. Nothingin this path degrades: there is no shorter timeout for an optional layer, no
"draw the cell now and add the marks later", and no negative caching. A PDOK
incident does not read as "no buoys today" — it reads as no map, for up to
20 s per request, twice per cell, everywhere inside the register's envelope,
which is the whole Netherlands.
What a user would expect instead
An optional decorative layer that cannot be reached should cost the layer, not the
basemap. Every other await in that function reaches our own host and carries data
the cell genuinely cannot be drawn without; these two do neither.
Where the seam is
crates/cartopolis/src/systems/map/map_geometry.rs—spawn_geometry_task, theload_marks_cell/load_crossings_cellawaits sitting between the tile fetchand
workers::submitcrates/cartopolis/src/systems/map/nav_marks.rs—load_marks_cell: thesequential
foroverCOLLECTIONS, and the earlyreturn MarkCell::default()that caches nothing on failure
crates/cartopolis/src/platform/http.rs—DEFAULT_TIMEOUT(20 s), which iswhat this path inherits
crates/cartopolis/src/systems/map/crossings.rs— the same shape, copied by#216. Two layers now, so this is a pattern rather than one module's choice.
systems::tall_structuresis the in-tree counter-example: a per-cell third-partystreamer that runs on a stream of its own and arrives late instead of blocking.
What a person might decide
Three directions, none of them decided here:
tall_structuresdoes —build the cell, add the marks when they land. Costs a second mesh pass per cell.
or two), the two collections in flight together rather than one after the other,
and an empty cell cached on failure so a retear does not re-pay it.
register at roughly 2.1 MB in the client's own compacted form, which would
delete this await along with both gates.
What I could not check
I could not fault-inject a slow or unreachable
api.pdok.nlin this container —there is no root, so no hosts entry and no netns. The ordering above is read from
the source and is unambiguous; the 20 s figure is the constant this path inherits,
not a measured stall.
Filed by the QA pass on #266. Not
autonomous— a person decides whether this becomes work.🤖 Refined by the viberfox issue agent. Reply with @agent refine and what is wrong to have this rewritten.
🤖 Promoted into the build lane by the 7-day retrospective (#269) —
autonomous+ship.Why this one, of the six gaps filed against the navigation-marks layer:
api.pdok.nl, on a 20 s timeout each, for an optional decorative layer that owns none of them. A PDOK incident does not read as "no buoys today"; it reads as no map, everywhere inside the register's envelope, which is the whole country.systems::tall_structuresis a per-cell third-party streamer that runs on a stream of its own and arrives late instead of blocking.The fix direction is decided here, so this cannot stall at
agent:needs-input. Of the three the ticket offers, take the bounded await (its option 2) and not the other two. It is the smallest thing a machine can judge, which is value 6, and it neither costs a second mesh pass per cell nor rewrites the fetch into a national download:load_marks_cellgo in flight together, not one after the other in afor.platform::http::DEFAULT_TIMEOUT, which every other fetch in the client inherits.crossings::load_crossings_cell, which is the same code.Judgeable without fault injection: the concurrency and the negative cache are both testable against a loopback host that stalls or 500s — a second request must not be made after a failure, and two collections must overlap. The container cannot fault-inject
api.pdok.nlitself (#267 says so), so say plainly in the report which parts were exercised and which are read from the source.Nothing here is fenced:
map_geometry.rs,nav_marks.rs,crossings.rsand a call-site timeout. No wire protocol, no schema, no workflow.One thing to know going in: this ticket does not carry the
autopilotlabel, so — until #270 is fixed — the daily digest on #257 will not see it in any of its buckets.Stuck, waiting for you: nonewhile three tickets wait for a person #270🤖 Refining this before anything gets built — reading the code, no branch, no commits. If it turns out I'd have to guess at the requirements I'll stop and ask instead.
🤖 Refined. The description above is now the spec; I'll pick it up for implementation on the next poll.
If the direction is wrong, say so now — @agent refine with a correction rewrites it, and
agent:skipstops me.🤖 viberfox issue agent on the VPS — 25 turns · $2.36 · session
941d8955-c37a-4995-931c-bba35df2efab. Reply with @agent to continue this session.🤖 Picked this up — working on branch
fix/267-bound-optional-layer-fetchesin a fresh worktree on the VPS. I'll report back here when I'm done.Label an issue
agent:skipto keep me off it, ordrivento keep it in a session you are driving.A cell's map no longer waits twenty seconds on a service that owns none of it.
What changed
api.pdok.nlfetches on the geometry path — the navigation marks (nav_marks.rs) and the level crossings (crossings.rs) — now carry a new 3-second timeout,http::OPTIONAL_LAYER_TIMEOUT, instead of inheriting the 20-second default. 3 s is about sixteen times the worst request the issue measured (186 ms across 18 requests over Groningen).http::Breaker, besidehttp::stats. Any failed fetch or unreadable answer trips that service's breaker; while it is open,should_fetchreturns false and no request goes out. Cooldown 60 seconds, matchingtall_structures::RETRY_AFTER_SECS. One breaker per service, so RWS failing does not gate ProRail. Its clock isweb_time, so the browser build compiles and does not panic.spawn_geometry_task, are now awaited withfutures_lite::future::zip;has_waterandhas_railare both derived before either future is polled.docs/notes/optional-layers-on-the-critical-path.md, with pointers from the two existing register notes.One thing to know, because it is not what the issue assumed
zipdoes not actually overlap the requests on desktop or Android.http::fetchthere is a blockingureqcall with no await point in it, so the two still run one after the other; the overlap is real only in the browser. Natively, what shortens the wait is the 3-second bound and the breaker — worst case for a first cell during an outage is three requests end to end, about 9 seconds, then 60 seconds of instant empty answers instead of 20 seconds per request per cell for ever. Making it genuinely concurrent would mean giving each fetch its own pool task, which doubles the peak thread occupancy in the same eight-thread pool this issue is about. I did not do that; it is written up in the note as an open question for you.How I verified it
cargo test -p cartopolis— 826 passed, 0 failed. The existing fixture tests in both modules pass unchanged.cargo test -p cartopolis_geo— 236 passed, 0 failed.cargo check -p cartopolis --target wasm32-unknown-unknown --no-default-features --features audioon nightly with the deploy's flags — clean.cargo fmt --checkover the five crates — clean.Not verified
--shotover the Waal with--expect 'nav_marks>0') still needs running on a workstation. Nothing about the parse or the mesh changed, but that is the check that would prove the layer still arrives.MARKS_ORIGINat a dead address and time a real stall, so the "3 seconds then 60 seconds of nothing" behaviour is asserted by tests, not observed against a hung host.Branch
fix/267-bound-optional-layer-fetchesis pushed.Branch
fix/267-bound-optional-layer-fetches(pushed)🤖 viberfox issue agent on the VPS — 416 turns · $47.48 · session
6347ca4d-f4cf-4c00-8555-e2ac87c8dc8f. Reply with @agent to continue this session.🤖 Marked
agent:donewithout a CI verdict on16091087— no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.🤖 Merged into
mainas pull request #275 (16091087).🤖 Merged, but not deployed. The deploy step exited 1:
mainhas the change; the public services are still on the previous build.mainwas already red, and every merge commit was red #282