edge: geoip alert reason codes; expiry keeps the alert until off or a fresh publish (geoip spec §10.4, codex R11-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+181
−6
internal/edge/geoipmirror.go+82 −4internal/edge/geoipmirror_test.go+99 −2
diff --git a/internal/edge/geoipmirror.go b/internal/edge/geoipmirror.go| index 535684185..7cee66a56 100644 |
| --- a/internal/edge/geoipmirror.go |
| +++ b/internal/edge/geoipmirror.go |
| @@ -57,9 +57,20 @@ type geoMirror struct { |
| skipSHA map[string]string // sha -> why (rejected | expired); cleared whenever the observed pointer changes |
| pointerSHA string // the sha the last readable pointer named; "" for none |
| syncPending bool // the last `current` rename is not known durable: keep the previous generation |
| - lastErr string // last poll's error category, "" when healthy |
| - failingAt time.Time |
| - lastFailLog time.Time |
| + |
| + // The alert's inputs (geoip spec §10.4). The pointer's metadata is |
| + // kept after the reader is dropped, so an expired pointer still reads |
| + // as expired by the clock before the console marks it (codex R11-1). |
| + pointerPresent bool |
| + pointerType string |
| + pointerBuild time.Time |
| + pointerMarked bool |
| + wantSince time.Time // a live pointer has named a sha not loaded here since then |
| + feedFailing bool |
| + feedBox string |
| + lastErr string // last poll's error category, "" when healthy |
| + failingAt time.Time |
| + lastFailLog time.Time |
| } |
| func newGeoMirror(store release.Store, dir string, sw *geo.Swappable) *geoMirror { |
| @@ -242,6 +253,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) |
| } |
| + m.readFeedStatus(ctx) |
| raw, err := m.readPointer(ctx) |
| var p geoip.Pointer |
| if err == nil { |
| @@ -259,6 +271,7 @@ func (m *geoMirror) poll(ctx context.Context, recheck bool) error { |
| } |
| } |
| m.pointerSHA, m.skipSHA = "", map[string]string{} |
| + m.pointerPresent, m.pointerType, m.pointerBuild, m.pointerMarked, m.wantSince = false, "", time.Time{}, false, time.Time{} |
| m.clearLocked() |
| m.lastErr = "" |
| m.genMu.Unlock() |
| @@ -271,6 +284,15 @@ func (m *geoMirror) poll(ctx context.Context, recheck bool) error { |
| return m.fail("pointer invalid", err) |
| } |
| m.genMu.Lock() |
| + m.pointerPresent, m.pointerType, m.pointerBuild, m.pointerMarked = true, p.DatabaseType, p.Build(), p.Expired |
| + switch { |
| + case p.Expired || geoip.Expired(p.DatabaseType, p.Build(), m.now()): |
| + m.wantSince = time.Time{} |
| + case p.SHA256 == m.loadedSHA: |
| + m.wantSince = time.Time{} |
| + case m.wantSince.IsZero(): |
| + m.wantSince = m.now() |
| + } |
| if p.SHA256 != m.pointerSHA { |
| // The pointer moved (to anything, including back to an sha seen |
| // before): every earlier rejection is void (plan review R1-8). |
| @@ -421,7 +443,7 @@ func (m *geoMirror) install(tmp string, p geoip.Pointer) error { |
| } else { |
| m.cleanLocked(p.SHA256, false) |
| } |
| - m.lastErr, m.failingAt = "", time.Time{} |
| + m.lastErr, m.failingAt, m.wantSince = "", time.Time{}, time.Time{} |
| log.Printf("edge: geoip database %s (%s, built %s) loaded", short(p.SHA256), db.Source(), db.BuildEpoch().Format("2006-01-02")) |
| return nil |
| } |
| @@ -461,6 +483,53 @@ func (m *geoMirror) failLocked(category string, err error) error { |
| return fmt.Errorf("%s: %w", category, err) |
| } |
| +// readFeedStatus refreshes the feed reason's inputs, best effort: a |
| +// missing or unreadable feed.json means no feed reason. |
| +func (m *geoMirror) readFeedStatus(ctx context.Context) { |
| + ctx, cancel := context.WithTimeout(ctx, geoPointerReadTimeout) |
| + defer cancel() |
| + failing, box := false, "" |
| + if raw, err := readCapped(ctx, m.store, geoip.FeedKey, geoip.MaxPointerBytes); err == nil { |
| + if st, err := geoip.ParseFeedStatus(raw); err == nil { |
| + failing, box = st.Failing(), st.Box |
| + } |
| + } |
| + m.genMu.Lock() |
| + m.feedFailing, m.feedBox = failing, box |
| + m.genMu.Unlock() |
| +} |
| + |
| +// Alert reason codes (geoip spec §10.4), the geoip rule's reading. |
| +const ( |
| + GeoAlertNone = 0 |
| + GeoAlertStale = 1 // the loaded database is over 45 days old |
| + GeoAlertExpiring = 2 // a GeoLite2 database within 9 days of D1 |
| + GeoAlertExpired = 3 // the pointer is expired, marked or by the clock |
| + GeoAlertNotLoaded = 4 // a live pointer not loaded here for over an hour |
| + GeoAlertFeed = 5 // the feed has failed 7 times in a row (the console's box only) |
| +) |
| + |
| +// AlertCode is the geoip rule's reading for this box: the most severe |
| +// reason that holds (3, 4, 2, 1, then 5), or 0. |
| +func (m *geoMirror) AlertCode(now time.Time, box string) float64 { |
| + m.genMu.Lock() |
| + defer m.genMu.Unlock() |
| + expired := m.pointerPresent && (m.pointerMarked || geoip.Expired(m.pointerType, m.pointerBuild, now)) |
| + switch { |
| + case expired: |
| + return GeoAlertExpired |
| + case m.pointerPresent && !m.wantSince.IsZero() && now.Sub(m.wantSince) > time.Hour: |
| + return GeoAlertNotLoaded |
| + case m.loadedSHA != "" && strings.HasPrefix(m.loadedType, "GeoLite2-") && now.Sub(m.loadedBuild) >= geoip.GeoLiteWarn: |
| + return GeoAlertExpiring |
| + case m.loadedSHA != "" && now.Sub(m.loadedBuild) > geoip.StaleAfter: |
| + return GeoAlertStale |
| + case m.feedFailing && box != "" && m.feedBox == box: |
| + return GeoAlertFeed |
| + } |
| + return GeoAlertNone |
| +} |
| + |
| // readPointer is one bounded pointer read. |
| func (m *geoMirror) readPointer(ctx context.Context) ([]byte, error) { |
| ctx, cancel := context.WithTimeout(ctx, geoPointerReadTimeout) |
| @@ -532,3 +601,12 @@ func (e *Edge) GeoIPState() GeoIPState { |
| } |
| return GeoIPState{Mode: e.geoMode} |
| } |
| + |
| +// GeoIPAlertCode is the geoip rule's reading for this box (spec §10.4); |
| +// 0 on an edge with no mirror. |
| +func (e *Edge) GeoIPAlertCode(now time.Time) float64 { |
| + if e.geoMirror == nil { |
| + return GeoAlertNone |
| + } |
| + return e.geoMirror.AlertCode(now, e.box) |
| +} |
diff --git a/internal/edge/geoipmirror_test.go b/internal/edge/geoipmirror_test.go| index c8fed921d..4e09507d3 100644 |
| --- a/internal/edge/geoipmirror_test.go |
| +++ b/internal/edge/geoipmirror_test.go |
| @@ -32,8 +32,8 @@ func publishFixture(t *testing.T, s release.Store, body []byte, dbType string, b |
| sum := sha256.Sum256(body) |
| sha := hex.EncodeToString(sum[:]) |
| ctx := context.Background() |
| - if _, err := s.Put(ctx, geoip.BlobKey(sha), body, ""); err != nil { |
| - t.Fatal(err) |
| + if _, err := s.Put(ctx, geoip.BlobKey(sha), body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatal(err) // an existing blob is the same bytes: content-addressed |
| } |
| p := geoip.Pointer{V: 1, SHA256: sha, Key: geoip.BlobKey(sha), Size: int64(len(body)), |
| DatabaseType: dbType, BuildEpoch: build.Unix(), Origin: "manual"} |
| @@ -338,3 +338,100 @@ func TestMirrorNewerPublishDuringADownloadInstallsTheNewer(t *testing.T) { |
| t.Fatalf("loaded %s, want the newer %s", st.SHA256, newer.SHA256) |
| } |
| } |
| + |
| +// Plan 2 Task 4 / spec §10.4: the geoip rule's reason codes. |
| +func TestMirrorAlertCodes(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| + m, s, _ := newTestMirror(t) |
| + m.now = func() time.Time { return now } |
| + ctx := context.Background() |
| + |
| + if c := m.AlertCode(now, "box"); c != GeoAlertNone { |
| + t.Fatalf("nothing published = %v", c) |
| + } |
| + publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| + m.Poll(ctx) |
| + // The fixture was built 2026-02-04: more than 45 days before now. |
| + if c := m.AlertCode(now, "box"); c != GeoAlertStale { |
| + t.Fatalf("an old database = %v, want stale", c) |
| + } |
| + if c := m.AlertCode(time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC), "box"); c != GeoAlertNone { |
| + t.Fatalf("a fresh database = %v", c) |
| + } |
| + // GeoLite2 at 22 days: expiring wins over stale's absence. |
| + m.genMu.Lock() |
| + m.loadedType, m.loadedBuild = "GeoLite2-City", now.Add(-22*24*time.Hour) |
| + m.genMu.Unlock() |
| + if c := m.AlertCode(now, "box"); c != GeoAlertExpiring { |
| + t.Fatalf("GeoLite2 at 22 days = %v, want expiring", c) |
| + } |
| +} |
| + |
| +// Codex R11-1: an expired pointer keeps the alert firing after the reader |
| +// is dropped and before the console marks the bucket; off clears it. |
| +func TestMirrorAlertSurvivesExpiryUntilOff(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) |
| + m, s, _ := newTestMirror(t) |
| + m.now = func() time.Time { return now } |
| + ctx := context.Background() |
| + publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoLite2-City", now.Add(-31*24*time.Hour)) // unmarked, past D1 |
| + m.Poll(ctx) |
| + if c := m.AlertCode(now, "box"); c != GeoAlertExpired { |
| + t.Fatalf("expired, unmarked = %v, want expired", c) |
| + } |
| + // A restart with the same unmarked pointer: still expired. |
| + m2 := newGeoMirror(s, m.dir, geo.NewSwappable()) |
| + m2.now = m.now |
| + m2.Boot() |
| + m2.Poll(ctx) |
| + if c := m2.AlertCode(now, "box"); c != GeoAlertExpired { |
| + t.Fatalf("after restart = %v, want expired", c) |
| + } |
| + // A fresh publish clears it. |
| + publishFixture(t, s, geoFixture(t, "GeoIP2-Country-Test.mmdb"), "GeoIP2-Country", now) |
| + m.Poll(ctx) |
| + if c := m.AlertCode(time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC), "box"); c == GeoAlertExpired { |
| + t.Fatal("a fresh publish left the expired reason") |
| + } |
| + // Expired again, then off: off clears every pointer reason. |
| + publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoLite2-City", now.Add(-31*24*time.Hour)) |
| + m.Poll(ctx) |
| + s.Delete(ctx, geoip.PointerKey) |
| + m.Poll(ctx) |
| + if c := m.AlertCode(now, "box"); c != GeoAlertNone { |
| + t.Fatalf("after off = %v, want none", c) |
| + } |
| +} |
| + |
| +func TestMirrorAlertNotLoadedAfterAnHour(t *testing.T) { |
| + now := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC) |
| + m, s, _ := newTestMirror(t) |
| + clock := now |
| + m.now = func() time.Time { return clock } |
| + p := publishFixture(t, s, geoFixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", now) |
| + os.WriteFile(filepath.Join(testMirrorStoreDir[s], p.Key), []byte("corrupt"), 0o644) |
| + m.Poll(context.Background()) |
| + if c := m.AlertCode(clock.Add(59*time.Minute), "box"); c != GeoAlertNone { |
| + t.Fatalf("not loaded for 59 minutes = %v", c) |
| + } |
| + if c := m.AlertCode(clock.Add(61*time.Minute), "box"); c != GeoAlertNotLoaded { |
| + t.Fatalf("not loaded for 61 minutes = %v, want not loaded", c) |
| + } |
| +} |
| + |
| +func TestMirrorAlertFeedReasonOnlyOnTheConsoleBox(t *testing.T) { |
| + now := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC) |
| + m, s, _ := newTestMirror(t) |
| + m.now = func() time.Time { return now } |
| + raw, _ := json.Marshal(geoip.FeedStatus{V: 1, Feed: "dbip-city-lite", Box: "console-box", ConsecutiveNotFound: 7}) |
| + if _, err := s.Put(context.Background(), geoip.FeedKey, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.Poll(context.Background()) // no pointer at all: a fresh deployment |
| + if c := m.AlertCode(now, "console-box"); c != GeoAlertFeed { |
| + t.Fatalf("console box = %v, want feed", c) |
| + } |
| + if c := m.AlertCode(now, "other-box"); c != GeoAlertNone { |
| + t.Fatalf("another box = %v, want none", c) |
| + } |
| +} |