gap: a cell's ground waits on api.pdok.nl before it is tessellated #267

Closed
opened 2026-09-02 02:33:37 +00:00 by viberfox-agent · 8 comments
Collaborator

Problem

spawn_geometry_task awaits two api.pdok.nl fetchers 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 body
  • map_geometry.rs:454, :457, :463 — surveyed BGT, paving, crops; all our own host
  • map_geometry.rs:474-478 — nav_marks::load_marks_cell, two sequential requests to api.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 here

Four properties make the stall worse than "the cell waits":

  1. No bound but the default. Both build plain Request::get, which takes DEFAULT_TIMEOUT = 20 s (platform/http.rs:26, :89-97). The agent's CONNECT_TIMEOUT of 10 s (http.rs:242) only caps the connect phase; a host that accepts and then goes quiet spends the full 20 s.
  2. Serialised. load_marks_cell loops the two collections one after the other (nav_marks.rs:418-433), and map_geometry awaits marks fully before starting crossings. Worst case is three timeouts in series per cell, not one.
  3. It parks the shared io pool. Native http::fetch is a blocking ureq call with no await point (http.rs:275-281), so a stalled fetch holds an IoTaskPool thread. That pool is min_threads: 4, max_threads: 8 (lib.rs:1458-1476) and is shared with every other streamer; the surface stream alone allows SURFACE_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.
  4. Nothing remembers the failure. Both fetchers return an empty cell and write nothing to the cache, deliberately (nav_marks.rs:421-424; crossings.rs:296-298). The surface stream is max_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_structures is the in-tree counter-example — its own CellStream with RETRY_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:

  • Direction one (a stream of its own) is not symmetric between the two layers: crossings are placed against the parsed tile's streets layer (map_geometry.rs:723-737, and shot_harness.rs:646 records the same asymmetry), so a late-arriving crossing cell needs the parse back. Marks do not. That is a restructure, not a bound.
  • Direction three is #212's scope by the issue's own text.
  • The bound is local, removes the described failure entirely, and does not foreclose either of the others.

Three parts.

1. A named short timeout for an optional third-party layer — crates/cartopolis/src/platform/http.rs, beside DEFAULT_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 a for loop, via futures_lite::future::zip (already used at tile_source.rs:392-393). Keep the existing all-or-nothing rule: if either half fails, return MarkCell::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 only fetched, 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-424 is 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 behind api.pdok.nl): any fetch or parse failure trips it, and while it is open should_fetch (nav_marks.rs:291-293, crossings.rs:199-201) answers false immediately. Cooldown 60 s, matching tall_structures::RETRY_AFTER_SECS (tall_structures.rs:42). Place it beside http::stats in platform/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:

  • The clock must be web_time, not std::time — Instant/SystemTime panic on wasm (user_store.rs:531-533, and http.rs:353-354 says the same about the wasm arm).
  • The decision must stay a pure predicate over an injected instant, so it is testable with no runtime and no network. That is the stated reason should_fetch was 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

  • A named constant in platform/http.rs documents the optional-layer timeout, its value, and the measurement it comes from; it is strictly less than DEFAULT_TIMEOUT.
  • Every http::Request built in nav_marks.rs and crossings.rs carries that timeout.
  • nav_marks::load_marks_cell issues its two collection requests concurrently, not in a for loop.
  • spawn_geometry_task awaits the marks and crossings futures concurrently; has_water and has_rail are both derived before either is polled.
  • A failed fetch or an unreadable response trips that module's breaker.
  • While a breaker is open, should_fetch returns false and no request is issued — asserted as a pure function with an injected instant, no runtime, no network.
  • The breaker reopens after the cooldown, asserted the same way.
  • Tripping one module's breaker leaves the other module's closed.
  • Nothing is written to the storage cache on a failed or partial answer; the existing comments at nav_marks.rs:421-424 and the equivalent in crossings.rs still describe the code.
  • The existing fixture tests in both modules still pass unchanged — the parse, the URL and the cache key are not touched.
  • The breaker's clock is web_time, so the wasm lane compiles and does not panic.

Verification

Run by the implementer; this pass built nothing.

cargo test -p cartopolis marks
cargo test -p cartopolis crossing
cargo test -p cartopolis                     # the suite, not just a check
cargo fmt --check -p cartopolis -p cartopolis_core -p cartopolis_geo \
                  -p cartopolis_simulator -p cartopolis_android

Wasm lane (the breaker's clock is the only new platform-sensitive code):

cargo check -p cartopolis --target wasm32-unknown-unknown --no-default-features --features audio

Healthy-path regression, workstation only — this container cannot render, so --shot is not available to this pass:

# a Waal cell, so the marks layer has something in it
cargo run -p cartopolis -- --shot /tmp/waal.png --at 51.8570,5.8680,900 --look=-35,0 \
    --dump-state /tmp/waal.json --expect 'nav_marks>0'

nav_marks and level_crossings are already --dump-state fields (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 as https://10.255.255.1 in a scratch build and time the first cell's surfaces submit. 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

  • ehttp ignores Request::timeout on wasm (http.rs:334-341), so the bound is native-only. That gap is pre-existing and already affects transit_vehicles, weather and globe_weather; fixing it means racing a timer future inside the wasm arm, which is a platform::http change of its own. The breaker still works on wasm — it is what limits the damage there.
  • Moving either layer onto its own CellStream (direction one). Not foreclosed by anything here.
  • Fetching the national register once (direction three) — #212.
  • The other three per-cell awaits (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.
  • Any change to what the two layers parse, how they are cached, or how they are meshed.
  • Surfacing the outage in the UI. A layer that quietly has nothing in it is the behaviour both modules already document (nav_marks.rs:405-407, crossings.rs:284-286).

Open questions

None.


Branch: fix/267-bound-optional-layer-fetches

Original request

Found by the QA re-verification pass on #266, walking what #206/#207 shipped.

What I did

Read map_geometry::spawn_geometry_task end to end, then cold-started the client
with an empty cache (CARTO_CACHE_DIR) over the Waal at Nijmegen and over the
app'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
:

let fetched = load_tile_image(&key, &template).await;   // the cell's MVT
let surveyed = ...await;                                 // our host
let paving   = ...await;                                 // our host
let crop_cell = ...await;                                // our host
let mark_cell = nav_marks::load_marks_cell(...).await;   // api.pdok.nl  ← two requests
let crossing_cell = crossings::load_crossings_cell(...).await;  // api.pdok.nl ← one more
workers::submit("surfaces", ...)                         // ← the cell is built only here

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_cell makes its two collection requests sequentially in a for
loop, each on platform::http's default 20 s timeout, and writes nothing to
the 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.nl is slow rather than fast. Nothing
in 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, the
    load_marks_cell / load_crossings_cell awaits sitting between the tile fetch
    and workers::submit
  • crates/cartopolis/src/systems/map/nav_marks.rs — load_marks_cell: the
    sequential for over COLLECTIONS, and the early return MarkCell::default()
    that caches nothing on failure
  • crates/cartopolis/src/platform/http.rs — DEFAULT_TIMEOUT (20 s), which is
    what 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_structures is the in-tree counter-example: a per-cell third-party
streamer 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:

  • Take the marks off the critical path, the way tall_structures does —
    build the cell, add the marks when they land. Costs a second mesh pass per cell.
  • Keep the await but bound it: a short timeout for an optional layer (a second
    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.
  • Fetch the register once, not per cell — #212 measures the whole national
    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.nl in 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.

## Problem `spawn_geometry_task` awaits two `api.pdok.nl` fetchers 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 body - `map_geometry.rs:454`, `:457`, `:463` — surveyed BGT, paving, crops; all our own host - `map_geometry.rs:474-478` — `nav_marks::load_marks_cell`, two sequential requests to `api.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 here Four properties make the stall worse than "the cell waits": 1. **No bound but the default.** Both build plain `Request::get`, which takes `DEFAULT_TIMEOUT` = 20 s (`platform/http.rs:26`, `:89-97`). The agent's `CONNECT_TIMEOUT` of 10 s (`http.rs:242`) only caps the connect phase; a host that accepts and then goes quiet spends the full 20 s. 2. **Serialised.** `load_marks_cell` loops the two collections one after the other (`nav_marks.rs:418-433`), and `map_geometry` awaits marks fully before starting crossings. Worst case is three timeouts in series per cell, not one. 3. **It parks the shared io pool.** Native `http::fetch` is a blocking `ureq` call with no await point (`http.rs:275-281`), so a stalled fetch holds an `IoTaskPool` thread. That pool is `min_threads: 4, max_threads: 8` (`lib.rs:1458-1476`) and is shared with every other streamer; the surface stream alone allows `SURFACE_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. 4. **Nothing remembers the failure.** Both fetchers return an empty cell and write nothing to the cache, deliberately (`nav_marks.rs:421-424`; `crossings.rs:296-298`). The surface stream is `max_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_structures` is the in-tree counter-example — its own `CellStream` with `RETRY_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: - Direction one (a stream of its own) is not symmetric between the two layers: crossings are *placed* against the parsed tile's `streets` layer (`map_geometry.rs:723-737`, and `shot_harness.rs:646` records the same asymmetry), so a late-arriving crossing cell needs the parse back. Marks do not. That is a restructure, not a bound. - Direction three is #212's scope by the issue's own text. - The bound is local, removes the described failure entirely, and does not foreclose either of the others. Three parts. **1. A named short timeout for an optional third-party layer** — `crates/cartopolis/src/platform/http.rs`, beside `DEFAULT_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 a `for` loop, via `futures_lite::future::zip` (already used at `tile_source.rs:392-393`). Keep the existing all-or-nothing rule: if either half fails, return `MarkCell::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 only `fetched`, 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-424` is 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 behind `api.pdok.nl`): any fetch or parse failure trips it, and while it is open `should_fetch` (`nav_marks.rs:291-293`, `crossings.rs:199-201`) answers `false` immediately. Cooldown **60 s**, matching `tall_structures::RETRY_AFTER_SECS` (`tall_structures.rs:42`). Place it beside `http::stats` in `platform/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: - The clock must be `web_time`, not `std::time` — `Instant`/`SystemTime` panic on wasm (`user_store.rs:531-533`, and `http.rs:353-354` says the same about the wasm arm). - The decision must stay a **pure predicate over an injected instant**, so it is testable with no runtime and no network. That is the stated reason `should_fetch` was 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 - [ ] A named constant in `platform/http.rs` documents the optional-layer timeout, its value, and the measurement it comes from; it is strictly less than `DEFAULT_TIMEOUT`. - [ ] Every `http::Request` built in `nav_marks.rs` and `crossings.rs` carries that timeout. - [ ] `nav_marks::load_marks_cell` issues its two collection requests concurrently, not in a `for` loop. - [ ] `spawn_geometry_task` awaits the marks and crossings futures concurrently; `has_water` and `has_rail` are both derived before either is polled. - [ ] A failed fetch or an unreadable response trips that module's breaker. - [ ] While a breaker is open, `should_fetch` returns `false` and no request is issued — asserted as a pure function with an injected instant, no runtime, no network. - [ ] The breaker reopens after the cooldown, asserted the same way. - [ ] Tripping one module's breaker leaves the other module's closed. - [ ] Nothing is written to the storage cache on a failed or partial answer; the existing comments at `nav_marks.rs:421-424` and the equivalent in `crossings.rs` still describe the code. - [ ] The existing fixture tests in both modules still pass unchanged — the parse, the URL and the cache key are not touched. - [ ] The breaker's clock is `web_time`, so the wasm lane compiles and does not panic. ## Verification Run by the implementer; this pass built nothing. ```bash cargo test -p cartopolis marks cargo test -p cartopolis crossing cargo test -p cartopolis # the suite, not just a check cargo fmt --check -p cartopolis -p cartopolis_core -p cartopolis_geo \ -p cartopolis_simulator -p cartopolis_android ``` Wasm lane (the breaker's clock is the only new platform-sensitive code): ```bash cargo check -p cartopolis --target wasm32-unknown-unknown --no-default-features --features audio ``` Healthy-path regression, **workstation only** — this container cannot render, so `--shot` is not available to this pass: ```bash # a Waal cell, so the marks layer has something in it cargo run -p cartopolis -- --shot /tmp/waal.png --at 51.8570,5.8680,900 --look=-35,0 \ --dump-state /tmp/waal.json --expect 'nav_marks>0' ``` `nav_marks` and `level_crossings` are already `--dump-state` fields (`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 as `https://10.255.255.1` in a scratch build and time the first cell's `surfaces` submit. 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 - **`ehttp` ignores `Request::timeout` on wasm** (`http.rs:334-341`), so the bound is native-only. That gap is pre-existing and already affects `transit_vehicles`, `weather` and `globe_weather`; fixing it means racing a timer future inside the wasm arm, which is a `platform::http` change of its own. The breaker still works on wasm — it is what limits the damage there. - Moving either layer onto its own `CellStream` (direction one). Not foreclosed by anything here. - Fetching the national register once (direction three) — #212. - The other three per-cell awaits (`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. - Any change to what the two layers parse, how they are cached, or how they are meshed. - Surfacing the outage in the UI. A layer that quietly has nothing in it is the behaviour both modules already document (`nav_marks.rs:405-407`, `crossings.rs:284-286`). ## Open questions None. --- Branch: `fix/267-bound-optional-layer-fetches` <details><summary>Original request</summary> Found by the QA re-verification pass on #266, walking what #206/#207 shipped. ## What I did Read `map_geometry::spawn_geometry_task` end to end, then cold-started the client with an empty cache (`CARTO_CACHE_DIR`) over the Waal at Nijmegen and over the app'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**: ```rust let fetched = load_tile_image(&key, &template).await; // the cell's MVT let surveyed = ...await; // our host let paving = ...await; // our host let crop_cell = ...await; // our host let mark_cell = nav_marks::load_marks_cell(...).await; // api.pdok.nl ← two requests let crossing_cell = crossings::load_crossings_cell(...).await; // api.pdok.nl ← one more workers::submit("surfaces", ...) // ← the cell is built only here ``` 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_cell` makes its two collection requests **sequentially** in a `for` loop, each on `platform::http`'s default **20 s** timeout, and writes nothing to the 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.nl` is slow rather than fast. Nothing in 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`, the `load_marks_cell` / `load_crossings_cell` awaits sitting between the tile fetch and `workers::submit` - `crates/cartopolis/src/systems/map/nav_marks.rs` — `load_marks_cell`: the sequential `for` over `COLLECTIONS`, and the early `return MarkCell::default()` that caches nothing on failure - `crates/cartopolis/src/platform/http.rs` — `DEFAULT_TIMEOUT` (20 s), which is what 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_structures` is the in-tree counter-example: a per-cell third-party streamer 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: - **Take the marks off the critical path**, the way `tall_structures` does — build the cell, add the marks when they land. Costs a second mesh pass per cell. - **Keep the await but bound it**: a short timeout for an optional layer (a second 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. - **Fetch the register once, not per cell** — #212 measures the whole national 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.nl` in 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. <sub>Filed by the QA pass on #266. Not `autonomous` — a person decides whether this becomes work.</sub> </details> <sub>🤖 Refined by the viberfox issue agent. Reply with **@agent refine** and what is wrong to have this rewritten.</sub>
Author
Collaborator

🤖 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:

  • It is value 3 broken outright — degrade, never break. A cell's ground, roads, water, bridges, trees, labels and building footprints are all held behind two sequential requests to 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.
  • It is a pattern, not one module's slip. #216 copied the same shape for level crossings. Two layers now, and the third will copy it too unless the shape changes.
  • The in-tree counter-example already exists. systems::tall_structures is 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:

  1. The two collections in load_marks_cell go in flight together, not one after the other in a for.
  2. Both requests take a short per-request timeout appropriate to an optional layer (a second or two). Pass it at the call site — do not change platform::http::DEFAULT_TIMEOUT, which every other fetch in the client inherits.
  3. A failed or timed-out cell caches an empty result, so a retear (re-anchor, terrain revision, layer toggle) does not re-pay the wait.
  4. Apply all three to 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.nl itself (#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.rs and a call-site timeout. No wire protocol, no schema, no workflow.

One thing to know going in: this ticket does not carry the autopilot label, so — until #270 is fixed — the daily digest on #257 will not see it in any of its buckets.

🤖 **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: - **It is value 3 broken outright — degrade, never break.** A cell's ground, roads, water, bridges, trees, labels and building footprints are all held behind two sequential requests to `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. - **It is a pattern, not one module's slip.** #216 copied the same shape for level crossings. Two layers now, and the third will copy it too unless the shape changes. - **The in-tree counter-example already exists.** `systems::tall_structures` is 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: 1. The two collections in `load_marks_cell` go in flight **together**, not one after the other in a `for`. 2. Both requests take a **short per-request timeout** appropriate to an optional layer (a second or two). Pass it at the call site — **do not change `platform::http::DEFAULT_TIMEOUT`**, which every other fetch in the client inherits. 3. A failed or timed-out cell **caches an empty result**, so a retear (re-anchor, terrain revision, layer toggle) does not re-pay the wait. 4. Apply all three to `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.nl` itself (#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.rs` and a call-site timeout. No wire protocol, no schema, no workflow. One thing to know going in: this ticket does not carry the `autopilot` label, so — until #270 is fixed — the daily digest on #257 will not see it in any of its buckets.
Author
Collaborator

🤖 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.

🤖 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.
Author
Collaborator

🤖 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:skip stops me.


🤖 viberfox issue agent on the VPS — 25 turns · $2.36 · session 941d8955-c37a-4995-931c-bba35df2efab. Reply with @agent to continue this session.

🤖 **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:skip` stops me. --- <sub>🤖 viberfox issue agent on the VPS — 25 turns · $2.36 · session `941d8955-c37a-4995-931c-bba35df2efab`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Picked this up — working on branch fix/267-bound-optional-layer-fetches in a fresh worktree on the VPS. I'll report back here when I'm done.

Label an issue agent:skip to keep me off it, or driven to keep it in a session you are driving.

🤖 Picked this up — working on branch `fix/267-bound-optional-layer-fetches` in a fresh worktree on the VPS. I'll report back here when I'm done. <sub>Label an issue `agent:skip` to keep me off it, or `driven` to keep it in a session you are driving.</sub>
Author
Collaborator

A cell's map no longer waits twenty seconds on a service that owns none of it.

What changed

  • Both api.pdok.nl fetches 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).
  • I added a small per-service outage gate, http::Breaker, beside http::stats. Any failed fetch or unreadable answer trips that service's breaker; while it is open, should_fetch returns false and no request goes out. Cooldown 60 seconds, matching tall_structures::RETRY_AFTER_SECS. One breaker per service, so RWS failing does not gate ProRail. Its clock is web_time, so the browser build compiles and does not panic.
  • I split each layer's gate in two rather than widening it: the cell's own tests (coverage, water/rail) run before the cache lookup, the breaker after it. A cell whose marks are already on disk is still drawn with them during an outage, and a foreign or dry cell still costs neither a request nor a storage lookup.
  • The two collections, and the two layers in spawn_geometry_task, are now awaited with futures_lite::future::zip; has_water and has_rail are both derived before either future is polled.
  • Nothing is cached on a failed or partial answer — that was already true and still is. Parsing, URLs and cache keys are untouched.
  • New note 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

zip does not actually overlap the requests on desktop or Android. http::fetch there is a blocking ureq call 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.
  • Six new tests, all passing: the timeout is shorter than the default; a tripped breaker holds for the cooldown and reopens after it; a second failure restarts the window; two breakers are independent; and each layer refuses to ask while its own breaker is open. All pure functions with an injected instant — no network, no runtime, no sleeping.
  • Browser target: cargo check -p cartopolis --target wasm32-unknown-unknown --no-default-features --features audio on nightly with the deploy's flags — clean.
  • cargo fmt --check over the five crates — clean.

Not verified

  • No rendered check. This container cannot render, so the healthy-path capture in the issue (--shot over 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.
  • No fault injection. I did not point MARKS_ORIGIN at 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-fetches is pushed.

Branch fix/267-bound-optional-layer-fetches (pushed)

1609108 fix(map): bound the two optional PDOK fetches a cell's ground waits on

🤖 viberfox issue agent on the VPS — 416 turns · $47.48 · session 6347ca4d-f4cf-4c00-8555-e2ac87c8dc8f. Reply with @agent to continue this session.

A cell's map no longer waits twenty seconds on a service that owns none of it. **What changed** - Both `api.pdok.nl` fetches 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). - I added a small per-service outage gate, `http::Breaker`, beside `http::stats`. Any failed fetch or unreadable answer trips that service's breaker; while it is open, `should_fetch` returns false and no request goes out. Cooldown 60 seconds, matching `tall_structures::RETRY_AFTER_SECS`. One breaker per service, so RWS failing does not gate ProRail. Its clock is `web_time`, so the browser build compiles and does not panic. - I split each layer's gate in two rather than widening it: the cell's own tests (coverage, water/rail) run before the cache lookup, the breaker after it. A cell whose marks are already on disk is still drawn with them during an outage, and a foreign or dry cell still costs neither a request nor a storage lookup. - The two collections, and the two layers in `spawn_geometry_task`, are now awaited with `futures_lite::future::zip`; `has_water` and `has_rail` are both derived before either future is polled. - Nothing is cached on a failed or partial answer — that was already true and still is. Parsing, URLs and cache keys are untouched. - New note `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** `zip` does not actually overlap the requests on desktop or Android. `http::fetch` there is a blocking `ureq` call 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. - Six new tests, all passing: the timeout is shorter than the default; a tripped breaker holds for the cooldown and reopens after it; a second failure restarts the window; two breakers are independent; and each layer refuses to ask while its own breaker is open. All pure functions with an injected instant — no network, no runtime, no sleeping. - Browser target: `cargo check -p cartopolis --target wasm32-unknown-unknown --no-default-features --features audio` on nightly with the deploy's flags — clean. - `cargo fmt --check` over the five crates — clean. **Not verified** - No rendered check. This container cannot render, so the healthy-path capture in the issue (`--shot` over 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. - No fault injection. I did not point `MARKS_ORIGIN` at 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-fetches` is pushed. **Branch** `fix/267-bound-optional-layer-fetches` (pushed) ``` 1609108 fix(map): bound the two optional PDOK fetches a cell's ground waits on ``` --- <sub>🤖 viberfox issue agent on the VPS — 416 turns · $47.48 · session `6347ca4d-f4cf-4c00-8555-e2ac87c8dc8f`. Reply with **@agent** to continue this session.</sub>
Author
Collaborator

🤖 Marked agent:done without a CI verdict on 16091087 — no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.

🤖 Marked `agent:done` **without a CI verdict** on `16091087` — no workflow reported on this commit. The branch is waiting for review; check the run yourself before landing it.
viberfox-agent 2026-09-02 08:28:44 +00:00
Author
Collaborator

🤖 Merged into main as pull request #275 (16091087).

🤖 **Merged into `main`** as pull request #275 (`16091087`).
Author
Collaborator

🤖 Merged, but not deployed. The deploy step exited 1:

main is at 9e5ed895
  waiting for CI on 9e5ed895
  success    wasm & android targets
  failure    test cartopolis
refusing: CI is not green on 9e5ed895

main has the change; the public services are still on the previous build.

🤖 **Merged, but not deployed.** The deploy step exited 1: ``` main is at 9e5ed895 waiting for CI on 9e5ed895 success wasm & android targets failure test cartopolis refusing: CI is not green on 9e5ed895 ``` `main` has the change; the public services are still on the previous build.
Sign in to join this conversation.
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
jeroen/cartopolis#267
No description provided.