geoip: verdict knows unknown/off/live pointer states; off refuses during a publish; bounded pointer reads; spec rev 10 (Astra round 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+234
−57
docs/superpowers/specs/2026-10-06-platform-geoip-design.md+44 −10docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R9.md+34 −0internal/console/apisystemgeoip.go+60 −24internal/console/apisystemgeoip_test.go+67 −8internal/console/geoippublish.go+6 −1internal/console/geoippublish_test.go+6 −8internal/edge/geoipmirror.go+17 −6
CI cancelled — superseded by a newer push — run details
diff --git a/docs/superpowers/specs/2026-10-06-platform-geoip-design.md b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md| index eb757b276..8a24c5d3e 100644 |
| --- a/docs/superpowers/specs/2026-10-06-platform-geoip-design.md |
| +++ b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md |
| @@ -1,7 +1,7 @@ |
| # Platform GeoIP for apps: headers, a mirrored database, a lookup endpoint |
| **Date:** 2026-10-06 |
| -**Revision:** 9 |
| +**Revision:** 10 |
| **Discussion:** amadan carlos/platform #241 (quoted in Appendix A) |
| **Branch:** `geoip-for-apps` |
| @@ -67,6 +67,13 @@ |
| clock runs from the start of each pass; |
| - §3 says digest, not size; |
| - only expected boxes can block the verdict. |
| +- Rev 10 answers Astra's round 9 (`CODEX-FINDINGS-GEOIP-R9.md`), marked |
| + "(codex R9-N)": |
| + - the verdict knows four pointer states; |
| + - `off` answers 409 during a publish instead of waiting; |
| + - pointer reads are bounded at 30 seconds, and the off bound includes |
| + them; |
| + - an unreadable heartbeat blocks only through the expected box it hides. |
| ## 0. Rulings and decisions |
| @@ -400,7 +407,9 @@ Under `carlos system`, through the console API: |
| - the feed's configuration and its last result; |
| - the per-box report from §5.4. |
| - `carlos system geoip off` deletes the pointer, then sweeps the blobs |
| - (§3). Every edge drops its reader within one poll (at most 11 minutes, |
| + (§3). While a publish holds the slot (an upload streaming in, or its |
| + job), `off` answers **409 straight away** rather than waiting behind it |
| + (codex R9-2). The operator runs it again once the publish finishes. Every edge drops its reader within one poll (at most 11 minutes, |
| with jitter). The console follows its own box's edge within a further |
| minute, so it takes at most 12 minutes (§11.3, codex R2-9). If |
| `CONSOLE_GEOIP_FEED` is set, `off` warns that the feed will publish |
| @@ -473,9 +482,14 @@ old generation, or the new `current` with the new one. |
| publish that landed mid-download takes effect at the end of that pass. |
| - If the re-read fails, nothing is installed until the next pass. |
| - Each pass is due 10 minutes plus jitter after the previous one |
| - **started**, and a pass is bounded at 10 minutes. A change to the |
| - pointer is therefore acted on within 11 minutes even with a long |
| - download in flight. That is the bound §4.2 quotes. |
| + **started**, and a pass is bounded at 10 minutes. |
| + - Every pointer read has its own 30-second timeout (codex R9 on R8-2). |
| + A change to the pointer is therefore acted on within 11 minutes plus |
| + one pointer read (at most 11.5 minutes), even with a long download in |
| + flight. That is the bound §4.2 quotes. |
| + - While pointer reads keep failing, the edge keeps what it has loaded, |
| + and its heartbeat reports the error, so the verdict shows the box as |
| + not caught up. |
| - **Every 10 minutes,** with up to 1 minute of jitter, read the pointer |
| (bounded read, at most 64 KiB). Each pass has a 10-minute deadline, |
| covering the pointer read and any blob download. A pass that misses it |
| @@ -594,6 +608,14 @@ blocking line uses the pair. Lines name a fleet box as `<fleet>/<label>`. |
| `systemBoxes`'s strict mode is used, because both skip unreadable |
| records silently. |
| +**What the pointer asks of every mirror** (codex R9-1): |
| + |
| +| Pointer | Each expected mirroring box must report | |
| +|---|---| |
| +| live | the pointer's sha | |
| +| absent (off), or expired | no generation loaded | |
| +| unreadable | nothing satisfies this: the verdict is never ready while the pointer can't be read | |
| + |
| **Which boxes are listed, and which can block:** |
| - Every expected box is listed whatever its age, and **only expected |
| boxes can block** the verdict (codex R8 on R1-16). |
| @@ -607,8 +629,10 @@ It reports: |
| - **stale,** by the shared rule `status.BoxStale` (3 heartbeat periods, |
| 15 minutes), not a threshold of its own (codex R3-4). The verdict can't |
| vouch for a box it can't currently see. |
| -- **record unreadable**, or an incomplete read of any prefix: |
| - "unreadable". An incomplete read is never "ready". |
| +- **record unreadable:** the box is missing from what was read, so if it |
| + is expected it blocks as "no heartbeat". An unreadable record for a box |
| + nobody expects blocks nothing (codex R9 on R1-16). If the list of |
| + expected fleet boxes itself can't be read, that always blocks. |
| - **expected box with no heartbeat at all:** "no heartbeat". |
| - **no block:** "old binary". The headers can't be trusted while any such |
| box is serving. |
| @@ -630,9 +654,10 @@ retirement. |
| GeoIP. Plan 1 files it as a discussion rather than working around its |
| absence. |
| -It ends with a one-line verdict. **"Ready"** means every listed box is |
| -fresh, readable and GeoIP-aware, and every mirroring box has loaded the |
| -current sha. Anything else names the boxes in the way (codex R2 on R1-16). |
| +It ends with a one-line verdict. **"Ready"** means the pointer was read, |
| +the expected-box list was complete, and every **expected** box is fresh, |
| +readable and GeoIP-aware, with every expected mirroring box holding what |
| +the pointer asks for (table above). Anything else names the boxes in the way (codex R2 on R1-16). |
| A test sets a box's heartbeat just inside the next jittered tick and |
| checks it still reads as fresh. |
| @@ -1340,6 +1365,15 @@ Round 8 (`CODEX-FINDINGS-GEOIP-R8.md`): every finding accepted. |
| | 2 | P2 | Accepted. A re-read of the pointer before install, one immediate re-run, and pass-start timing, so the 11-minute bound holds (§5.3) | |
| | 3 | P3 | Accepted. §3 step 2 now requires digest and size | |
| +Round 9 (`CODEX-FINDINGS-GEOIP-R9.md`): every finding accepted. |
| + |
| +| # | Sev | Disposition | |
| +|---|---|---| |
| +| R1-16 (partly) | | Unreadable records block only through expected identities; the final requirement says "every expected box" (§5.5) | |
| +| R8-2 (partly) | | 30-second bound on each pointer read; the off bound states it; read failures keep the loaded database and surface in the heartbeat (§5.3) | |
| +| 1 | P2 | Accepted. Four pointer states: unknown never ready, off/expired requires empty mirrors, live requires the sha (§5.5) | |
| +| 2 | P2 | Accepted. `off` answers 409 while a publish holds the slot (§4.2) | |
| + |
| ## Appendix A. The request (#241, verbatim) |
| > **Expose the platform's GeoIP lookup to apps (headers and/or lookup endpoint)** |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R9.md b/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R9.md| new file mode 100644 |
| index 000000000..6395e0774 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R9.md |
| @@ -0,0 +1,34 @@ |
| +| Previous finding | Disposition | Evidence from revision 9 at `ca3f3536` | |
| +|---|---|---| |
| +| R3-1. Expiry/install races | Resolved | §5.3 retains expiry checks before download and installation, with `genMu` serializing selection, expiry and cleanup. | |
| +| R3-2. Unprotected console `Locate` | Resolved | §5.4 retains locked `Swappable.Locate` and assigns the wrapper to `Config.GeoIP`. | |
| +| R3-3. Coordinate-only results | Resolved | §8.1a accepts coordinate-only hits; §9 counts their country as `_unknown`. | |
| +| R3-4. Heartbeat freshness | Resolved | §5.5 retains `status.BoxStale` and the jittered-heartbeat test. | |
| +| R1-4, carried forward: orphan destruction | Resolved | §3 retains startup and daily orphan sweeps, followed by §15’s noncurrent-version expiration. | |
| +| R1-16, carried forward: rollout verification | Partly resolved | §5.5 now excludes retired rows from blocking, but its blanket incomplete-read rule still lets an unreadable retired heartbeat block readiness. Scope record-level blockers to expected identities; also change the final “every listed box” requirement to “every expected box.” | |
| +| R4-1. Duplicate tunnel analytics | Resolved | §§8.4 and 9 retain zero remote observations and exactly one at the hub. | |
| +| R4-2. Publication deadline across backhaul | Resolved | §4.2 retains staging followed by a 202 response and a polled publication job. | |
| +| R5-1. Concurrent upload staging | Resolved | §4.2 retains mutex reservation before reading the body and ownership through job cleanup. | |
| +| R6-1. Plaintext tunnel cache mismatch | Resolved | D2 and §§8–9, 16 consistently require the transport-independent tunnel mark. | |
| +| R6-2. Abandoned upload staging | Resolved | §4.2 retains the dedicated staging directory, startup cleanup and restart tests. | |
| +| R7-1. Publication of expired candidates | Resolved | §4.1 rejects expired candidates, rechecks before pointer replacement and forbids a force override. | |
| +| R7-2. Empty sanitized source | Resolved | §6 retains the valid fallback identifier `mmdb`. | |
| +| R7-3. Attribution lost through overflow | Resolved | §9 exempts attributed sources from folding into `_other`. | |
| +| R7-4. Interface discrepancies | Resolved | §§4.1 and 6 retain the corrected signatures and Unix-seconds conversion. | |
| +| R8-1. Box identity namespaces | Resolved | §5.5 requires `(namespace, label)` throughout inventory, heartbeat matching and blockers. | |
| +| R8-2. Shutdown propagation bound | Partly resolved | §5.3 adds pointer revalidation and pass-start scheduling, but its eleven-minute guarantee still excludes pointer-read time. An eleven-minute polling wait followed by a successful nine-minute read takes twenty minutes. Include read duration and qualify behavior during read failures. | |
| +| R8-3. Existing-blob verification | Resolved | §3 now requires both SHA-256 and size to match before replacing the pointer. | |
| + |
| +1. **P2 — §§4.2, 5.5: status can report `ready` without establishing database convergence.** The readiness contract does not distinguish an unreadable pointer from confirmed absence. The implementation records `pointer_error` but passes a nil pointer to the verdict, disabling the hash comparison. Healthy heartbeats reporting different generations can therefore produce `ready: true`. Confirmed absence or expiry also disables that comparison, allowing readiness while mirrors still report a loaded database. |
| + |
| + **Evidence:** [apisystemgeoip.go:137](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemgeoip.go:137) leaves `ptr` nil after a read failure; [apisystemgeoip.go:157](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemgeoip.go:157) still computes readiness. At [apisystemgeoip.go:299](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemgeoip.go:299), mismatches block only when the desired SHA is nonempty. |
| + |
| + **Fix:** specify distinct unknown, off, live and expired pointer states. Unknown must block readiness. Off and expired must require expected mirrors to report no loaded generation. Test pointer-read failure with otherwise healthy heartbeats, and deletion while a mirror still reports the previous SHA. |
| + |
| +2. **P2 — §§3, 4.2: `off` can time out waiting for the publication mutex.** An upload reserves the mutex before reading its body and retains it through the publication job. A concurrent synchronous `off` therefore waits behind an operation allowed to take minutes. The caller or backhaul times out before the pointer is deleted, leaving the operator without a completed shutdown. The spec defines serialization but omits admission and response behavior for this case. |
| + |
| + **Evidence:** [geoippublish.go:290](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoippublish.go:290) uses blocking `Lock`; [apisystemgeoip.go:106](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemgeoip.go:106) waits synchronously for `Off`. The ordinary CLI client has a thirty-second timeout at [auth.go:404](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/auth.go:404), and backhaul has the same response-header limit at [edge.go:2710](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/edge/edge.go:2710). |
| + |
| + **Fix:** require immediate 409 when the publication slot is occupied, or make `off` a polled job with an independent lifetime. Preserve serialization through cleanup. Test `off` during both a stalled upload and an active publication job, including through backhaul. |
| + |
| +Verdict: revise. |
| \ No newline at end of file |
diff --git a/internal/console/apisystemgeoip.go b/internal/console/apisystemgeoip.go| index 22dc83d31..7c5598546 100644 |
| --- a/internal/console/apisystemgeoip.go |
| +++ b/internal/console/apisystemgeoip.go |
| @@ -103,7 +103,10 @@ func (s *Server) handleAPISystemGeoipDelete(w http.ResponseWriter, r *http.Reque |
| if !s.systemBucket(w) { |
| return |
| } |
| - if err := s.geoip.Off(r.Context()); err != nil { |
| + if err := s.geoip.Off(r.Context()); errors.Is(err, errGeoipBusy) { |
| + jsonError(w, http.StatusConflict, "a geoip publish is in progress. run off again when it finishes") |
| + return |
| + } else if err != nil { |
| log.Printf("console: geoip off: %v", err) |
| jsonError(w, http.StatusBadGateway, "could not turn geoip off") |
| return |
| @@ -133,11 +136,17 @@ func (s *Server) handleAPISystemGeoipGet(w http.ResponseWriter, r *http.Request) |
| ctx := r.Context() |
| cur, _, present, perr := s.geoip.Current(ctx) |
| out := map[string]any{"pointer": nil} |
| - var ptr *geoip.Pointer |
| - if perr != nil { |
| + want := geoipWant{state: geoipOff} |
| + switch { |
| + case perr != nil: |
| out["pointer_error"] = "the published pointer is unreadable" |
| - } else if present { |
| - ptr = &cur |
| + want.state = geoipUnknown |
| + case present && cur.Expired: |
| + want.state = geoipOff // expired: every edge must have dropped it |
| + case present: |
| + want = geoipWant{state: geoipLive, sha: cur.SHA256} |
| + } |
| + if perr == nil && present { |
| p := map[string]any{"sha256": cur.SHA256, "database_type": cur.DatabaseType, "build": cur.Build(), |
| "published": cur.Published, "origin": cur.Origin, "edition": cur.Edition, "size": cur.Size, "expired": cur.Expired} |
| if at, ok := geoip.ExpiresAt(cur.DatabaseType, cur.Build()); ok { |
| @@ -154,7 +163,7 @@ func (s *Server) handleAPISystemGeoipGet(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadGateway, "box heartbeats unreadable") |
| return |
| } |
| - ready, rows, blocking := geoipVerdict(ptr, inv, time.Now()) |
| + ready, rows, blocking := geoipVerdict(want, inv, time.Now()) |
| out["boxes"], out["ready"], out["blocking"] = rows, ready, blocking |
| out["feed_set"] = os.Getenv("CONSOLE_GEOIP_FEED") != "" |
| writeJSON(w, http.StatusOK, out) |
| @@ -173,17 +182,38 @@ func (k geoipBoxKey) String() string { |
| } |
| // geoipInventoryRead is everything the verdict reconciles (spec §5.5). |
| +// expectedComplete is false when the list of expected boxes itself could |
| +// not be read (a fleet registry or fleet listing failed): that always |
| +// blocks. heartbeatsComplete is false when some heartbeat record could not |
| +// be read: informational only, because an expected box whose record was |
| +// unreadable is missing from boxes and blocks as "no heartbeat" on its own |
| +// (codex R9 on R1-16). |
| type geoipInventoryRead struct { |
| - expected map[geoipBoxKey]bool |
| - boxes map[geoipBoxKey]status.Box |
| - complete bool |
| + expected map[geoipBoxKey]bool |
| + boxes map[geoipBoxKey]status.Box |
| + expectedComplete bool |
| + heartbeatsComplete bool |
| +} |
| + |
| +// geoipWantState is what the published pointer asks of every mirror. |
| +type geoipWantState int |
| + |
| +const ( |
| + geoipUnknown geoipWantState = iota // the pointer could not be read: never ready |
| + geoipOff // no pointer, or an expired one: mirrors must hold nothing |
| + geoipLive // mirrors must hold sha |
| +) |
| + |
| +type geoipWant struct { |
| + state geoipWantState |
| + sha string |
| } |
| // geoipInventory reads the expected boxes (platform edges records, active |
| // fleet registrations) and every heartbeat prefix with LoadBoxesComplete, |
| // never LoadBoxes, which skips unreadable records silently. |
| func (s *Server) geoipInventory(ctx context.Context) (geoipInventoryRead, error) { |
| - inv := geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{}, complete: true} |
| + inv := geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{}, expectedComplete: true, heartbeatsComplete: true} |
| keys, err := s.cfg.Releases.List(ctx, "control/edges/") |
| if err != nil { |
| return inv, fmt.Errorf("edges records: %w", err) |
| @@ -200,7 +230,7 @@ func (s *Server) geoipInventory(ctx context.Context) (geoipInventoryRead, error) |
| return err |
| } |
| if !complete { |
| - inv.complete = false |
| + inv.heartbeatsComplete = false |
| } |
| for _, b := range boxes { |
| inv.boxes[geoipBoxKey{fleet, b.Label}] = b |
| @@ -218,18 +248,18 @@ func (s *Server) geoipInventory(ctx context.Context) (geoipInventoryRead, error) |
| case errors.Is(err, os.ErrNotExist): |
| return inv, nil |
| case err != nil: |
| - inv.complete = false |
| + inv.expectedComplete = false |
| return inv, nil |
| } |
| fleets, err := reg.ListFleets() |
| if err != nil { |
| - inv.complete = false |
| + inv.expectedComplete = false |
| return inv, nil |
| } |
| for _, f := range fleets { |
| fbs, err := reg.ListFleetBoxes(f.Name) |
| if err != nil { |
| - inv.complete = false |
| + inv.expectedComplete = false |
| continue |
| } |
| for _, fb := range fbs { |
| @@ -238,7 +268,9 @@ func (s *Server) geoipInventory(ctx context.Context) (geoipInventoryRead, error) |
| } |
| } |
| if err := add(release.NewPrefixStore(s.cfg.Releases, "fleets/"+f.Name+"/"), f.Name); err != nil { |
| - inv.complete = false |
| + // That fleet's heartbeats are unknown: its expected boxes are |
| + // missing from boxes and block as "no heartbeat". |
| + inv.heartbeatsComplete = false |
| } |
| } |
| return inv, nil |
| @@ -247,7 +279,7 @@ func (s *Server) geoipInventory(ctx context.Context) (geoipInventoryRead, error) |
| // geoipVerdict is spec §5.5's reconciliation. ready only when every listed |
| // box is fresh, readable and GeoIP-aware, every mirroring box holds the |
| // live pointer's sha, and every read was complete. |
| -func geoipVerdict(ptr *geoip.Pointer, inv geoipInventoryRead, now time.Time) (bool, []geoipBoxRow, []string) { |
| +func geoipVerdict(want geoipWant, inv geoipInventoryRead, now time.Time) (bool, []geoipBoxRow, []string) { |
| var rows []geoipBoxRow |
| var blocking []string |
| keys := map[geoipBoxKey]bool{} |
| @@ -264,10 +296,6 @@ func geoipVerdict(ptr *geoip.Pointer, inv geoipInventoryRead, now time.Time) (bo |
| sorted = append(sorted, k) |
| } |
| sort.Slice(sorted, func(i, j int) bool { return sorted[i].String() < sorted[j].String() }) |
| - liveSHA := "" |
| - if ptr != nil && !ptr.Expired { |
| - liveSHA = ptr.SHA256 |
| - } |
| for _, k := range sorted { |
| name, expected := k.String(), inv.expected[k] |
| row := geoipBoxRow{Label: k.Label, Fleet: k.Fleet, Expected: expected} |
| @@ -296,9 +324,14 @@ func geoipVerdict(ptr *geoip.Pointer, inv geoipInventoryRead, now time.Time) (bo |
| switch g := b.Metrics.GeoIP; { |
| case g.Mode == "tunnel", g.Mode == "none": |
| row.State = g.Mode |
| - case liveSHA != "" && g.SHA256 != liveSHA: |
| + case want.state == geoipLive && g.SHA256 != want.sha: |
| row.State = "other sha" |
| - row.Blocking = name + ": has " + short8(g.SHA256) + ", not the published " + short8(liveSHA) |
| + row.Blocking = name + ": has " + short8(g.SHA256) + ", not the published " + short8(want.sha) |
| + case want.state == geoipOff && g.SHA256 != "": |
| + // Off or expired: a mirror still serving a generation has |
| + // not caught up (codex R9-1). |
| + row.State = "still loaded" |
| + row.Blocking = name + ": still has " + short8(g.SHA256) + " loaded" |
| default: |
| row.State = "ok" |
| } |
| @@ -315,8 +348,11 @@ func geoipVerdict(ptr *geoip.Pointer, inv geoipInventoryRead, now time.Time) (bo |
| } |
| rows = append(rows, row) |
| } |
| - if !inv.complete { |
| - blocking = append(blocking, "heartbeats: some records could not be read") |
| + if want.state == geoipUnknown { |
| + blocking = append(blocking, "database: the published pointer could not be read") |
| + } |
| + if !inv.expectedComplete { |
| + blocking = append(blocking, "boxes: the list of fleet boxes could not be read") |
| } |
| if rows == nil { |
| rows = []geoipBoxRow{} |
diff --git a/internal/console/apisystemgeoip_test.go b/internal/console/apisystemgeoip_test.go| index 73b8ea0a0..62a9789b3 100644 |
| --- a/internal/console/apisystemgeoip_test.go |
| +++ b/internal/console/apisystemgeoip_test.go |
| @@ -227,7 +227,7 @@ func TestGeoipStatusVerdict(t *testing.T) { |
| } |
| func TestGeoipVerdictIncompleteReadIsNeverReady(t *testing.T) { |
| - ready, _, blocking := geoipVerdict(nil, geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{}, complete: false}, time.Now()) |
| + ready, _, blocking := geoipVerdict(geoipWant{state: geoipOff}, geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{}, expectedComplete: false, heartbeatsComplete: true}, time.Now()) |
| if ready || len(blocking) == 0 { |
| t.Fatalf("incomplete read = ready %v, blocking %v", ready, blocking) |
| } |
| @@ -267,16 +267,16 @@ func TestGeoipVerdictKeysBoxesByNamespace(t *testing.T) { |
| old := status.CollectBox(nil, status.Probes{Release: "v0"}, now) |
| old.Label = "node-1" |
| inv := geoipInventoryRead{ |
| - expected: map[geoipBoxKey]bool{{"", "node-1"}: true, {"tito", "node-1"}: true, {"bae", "node-1"}: true}, |
| - boxes: map[geoipBoxKey]status.Box{{"", "node-1"}: fresh("node-1"), {"tito", "node-1"}: fresh("node-1"), {"bae", "node-1"}: old}, |
| - complete: true, |
| + expected: map[geoipBoxKey]bool{{"", "node-1"}: true, {"tito", "node-1"}: true, {"bae", "node-1"}: true}, |
| + boxes: map[geoipBoxKey]status.Box{{"", "node-1"}: fresh("node-1"), {"tito", "node-1"}: fresh("node-1"), {"bae", "node-1"}: old}, |
| + expectedComplete: true, heartbeatsComplete: true, |
| } |
| - ready, _, blocking := geoipVerdict(&geoip.Pointer{SHA256: "s"}, inv, now) |
| + ready, _, blocking := geoipVerdict(geoipWant{state: geoipLive, sha: "s"}, inv, now) |
| if ready || len(blocking) != 1 || !strings.HasPrefix(blocking[0], "bae/node-1: runs a binary") { |
| t.Fatalf("ready=%v blocking=%v, want only bae/node-1 blocking", ready, blocking) |
| } |
| delete(inv.boxes, geoipBoxKey{"tito", "node-1"}) |
| - _, _, blocking = geoipVerdict(&geoip.Pointer{SHA256: "s"}, inv, now) |
| + _, _, blocking = geoipVerdict(geoipWant{state: geoipLive, sha: "s"}, inv, now) |
| found := false |
| for _, b := range blocking { |
| found = found || strings.HasPrefix(b, "tito/node-1: no heartbeat") |
| @@ -292,9 +292,68 @@ func TestGeoipVerdictRetiredBoxDoesNotBlock(t *testing.T) { |
| now := time.Now() |
| b := status.CollectBox(nil, status.Probes{Release: "v0"}, now.Add(-time.Hour)) |
| b.Label = "gone-1" |
| - inv := geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{{"", "gone-1"}: b}, complete: true} |
| - ready, rows, _ := geoipVerdict(nil, inv, now) |
| + inv := geoipInventoryRead{expected: map[geoipBoxKey]bool{}, boxes: map[geoipBoxKey]status.Box{{"", "gone-1"}: b}, expectedComplete: true, heartbeatsComplete: false} |
| + ready, rows, _ := geoipVerdict(geoipWant{state: geoipOff}, inv, now) |
| if !ready || len(rows) != 1 { |
| t.Fatalf("ready=%v rows=%v, want ready with the retired box listed", ready, rows) |
| } |
| } |
| + |
| +// Codex R9-1: an unreadable pointer is never ready, and off requires every |
| +// mirror to have dropped its generation. |
| +func TestGeoipVerdictPointerStates(t *testing.T) { |
| + now := time.Now() |
| + mirror := func(sha string) status.Box { |
| + b := status.CollectBox(nil, status.Probes{Release: "v1", GeoIP: &status.GeoIPState{Mode: "mirror", SHA256: sha}}, now) |
| + b.Label = "ie-1" |
| + return b |
| + } |
| + inv := func(sha string) geoipInventoryRead { |
| + return geoipInventoryRead{expected: map[geoipBoxKey]bool{{"", "ie-1"}: true}, |
| + boxes: map[geoipBoxKey]status.Box{{"", "ie-1"}: mirror(sha)}, expectedComplete: true, heartbeatsComplete: true} |
| + } |
| + if ready, _, _ := geoipVerdict(geoipWant{state: geoipUnknown}, inv("a"), now); ready { |
| + t.Fatal("an unreadable pointer reported ready") |
| + } |
| + if ready, _, b := geoipVerdict(geoipWant{state: geoipOff}, inv("a"), now); ready || len(b) != 1 || !strings.Contains(b[0], "still has") { |
| + t.Fatalf("off with a mirror still loaded = %v %v", ready, b) |
| + } |
| + if ready, _, _ := geoipVerdict(geoipWant{state: geoipOff}, inv(""), now); !ready { |
| + t.Fatal("off with every mirror empty must be ready") |
| + } |
| + if ready, _, _ := geoipVerdict(geoipWant{state: geoipLive, sha: "b"}, inv("a"), now); ready { |
| + t.Fatal("a mirror on another sha reported ready") |
| + } |
| +} |
| + |
| +// Codex R9-2: off never waits behind a publish. |
| +func TestGeoipOffDuringAPublishIs409(t *testing.T) { |
| + withFixtureProbes(t) |
| + fx := newConsoleFixture(t) |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-offbusy") |
| + st, err := fx.srv.geoip.Stage(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb"))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer fx.srv.geoip.Abandon(st) |
| + req, _ := http.NewRequest(http.MethodDelete, fx.ts.URL+"/api/cli/system/geoip", nil) |
| + req.Header.Set("Authorization", "Bearer "+owner) |
| + done := make(chan int, 1) |
| + go func() { |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + done <- 0 |
| + return |
| + } |
| + res.Body.Close() |
| + done <- res.StatusCode |
| + }() |
| + select { |
| + case code := <-done: |
| + if code != http.StatusConflict { |
| + t.Fatalf("off during a publish = %d, want 409", code) |
| + } |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("off waited behind the publish") |
| + } |
| +} |
diff --git a/internal/console/geoippublish.go b/internal/console/geoippublish.go| index ab564df6e..e56c83e5c 100644 |
| --- a/internal/console/geoippublish.go |
| +++ b/internal/console/geoippublish.go |
| @@ -287,7 +287,12 @@ func hasCountryData(db *geo.MMDB) bool { |
| // Off deletes the pointer, then every blob (spec §3). Unconditional delete: |
| // safe under the single-writer mutex (codex R2-6). |
| func (p *geoipPublisher) Off(ctx context.Context) error { |
| - p.mu.Lock() |
| + // Never waits behind a publish (codex R9-2): a slow upload or job can |
| + // hold the slot for minutes, longer than any caller's timeout. The |
| + // operator gets a 409 and runs off again when the publish finishes. |
| + if !p.mu.TryLock() { |
| + return errGeoipBusy |
| + } |
| defer p.mu.Unlock() |
| if err := p.store.Delete(ctx, geoip.PointerKey); err != nil && !errors.Is(err, os.ErrNotExist) { |
| return err |
diff --git a/internal/console/geoippublish_test.go b/internal/console/geoippublish_test.go| index 428f84bbc..6d93cf327 100644 |
| --- a/internal/console/geoippublish_test.go |
| +++ b/internal/console/geoippublish_test.go |
| @@ -324,7 +324,7 @@ func TestCommitRefusesAForeignPointerWrite(t *testing.T) { |
| p.mu.Unlock() |
| } |
| -func TestOffWaitsForAnInFlightPublish(t *testing.T) { |
| +func TestOffRefusesDuringAPublishThenSucceeds(t *testing.T) { |
| withFixtureProbes(t) |
| ctx := context.Background() |
| p, s := newTestPublisher(t) |
| @@ -332,17 +332,15 @@ func TestOffWaitsForAnInFlightPublish(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - offDone := make(chan error, 1) |
| - go func() { offDone <- p.Off(ctx) }() |
| - select { |
| - case <-offDone: |
| - t.Fatal("off ran while a publish held the slot") |
| - case <-time.After(50 * time.Millisecond): |
| + // Off refuses rather than waits while the slot is held (codex R9-2)... |
| + if err := p.Off(ctx); !errors.Is(err, errGeoipBusy) { |
| + t.Fatalf("off during a publish = %v, want errGeoipBusy", err) |
| } |
| if _, err := p.Commit(ctx, st, geoipPublishOpts{Origin: "manual"}); err != nil { |
| t.Fatal(err) |
| } |
| - if err := <-offDone; err != nil { |
| + // ...and succeeds once the publish has finished. |
| + if err := p.Off(ctx); err != nil { |
| t.Fatal(err) |
| } |
| if _, _, err := s.Get(ctx, geoip.PointerKey); !errors.Is(err, os.ErrNotExist) { |
diff --git a/internal/edge/geoipmirror.go b/internal/edge/geoipmirror.go| index 1735af904..419645b3e 100644 |
| --- a/internal/edge/geoipmirror.go |
| +++ b/internal/edge/geoipmirror.go |
| @@ -27,10 +27,14 @@ import ( |
| // expires or deletes a generation (codex R3-1); network I/O stays outside it. |
| var ( |
| - geoMirrorInterval = 10 * time.Minute |
| - geoMirrorJitter = time.Minute |
| - geoMirrorDeadline = 10 * time.Minute |
| - geoExpiryInterval = time.Minute |
| + // geoPointerReadTimeout bounds every pointer read on its own, so a |
| + // slow store cannot stretch the off bound past interval+jitter+this |
| + // (codex R9 on R8-2). |
| + geoPointerReadTimeout = 30 * time.Second |
| + geoMirrorInterval = 10 * time.Minute |
| + geoMirrorJitter = time.Minute |
| + geoMirrorDeadline = 10 * time.Minute |
| + geoExpiryInterval = time.Minute |
| ) |
| type geoCurrent struct { |
| @@ -220,7 +224,7 @@ func (m *geoMirror) poll(ctx context.Context, recheck bool) error { |
| if err := os.MkdirAll(m.dir, 0o755); err != nil { |
| return m.fail("local directory unusable", err) |
| } |
| - raw, err := readCapped(ctx, m.store, geoip.PointerKey, geoip.MaxPointerBytes) |
| + raw, err := m.readPointer(ctx) |
| var p geoip.Pointer |
| if err == nil { |
| p, err = geoip.ParsePointer(raw) |
| @@ -304,7 +308,7 @@ var downloadHook func() |
| // failure answers false, which defers the install to the next pass rather |
| // than installing something nobody has confirmed is still wanted. |
| func (m *geoMirror) pointerStillNames(ctx context.Context, sha string) bool { |
| - raw, err := readCapped(ctx, m.store, geoip.PointerKey, geoip.MaxPointerBytes) |
| + raw, err := m.readPointer(ctx) |
| if err != nil { |
| return false |
| } |
| @@ -434,6 +438,13 @@ func (m *geoMirror) failLocked(category string, err error) error { |
| return fmt.Errorf("%s: %w", category, err) |
| } |
| +// readPointer is one bounded pointer read. |
| +func (m *geoMirror) readPointer(ctx context.Context) ([]byte, error) { |
| + ctx, cancel := context.WithTimeout(ctx, geoPointerReadTimeout) |
| + defer cancel() |
| + return readCapped(ctx, m.store, geoip.PointerKey, geoip.MaxPointerBytes) |
| +} |
| + |
| // readCapped reads key through OpenObject, refusing more than max bytes. |
| func readCapped(ctx context.Context, s release.Store, key string, max int) ([]byte, error) { |
| rc, _, err := release.OpenObject(ctx, s, key) |