release: v2.0.1 (#209)

- fix(meshmapper): refresh the longest-waiting scope catalogue first
- feat(config): default scope catalogue refresh to 24h
- chore: bump version
This commit is contained in:
MrAlders0n
2026-10-03 16:51:11 -04:00
committed by GitHub
parent 001543032f
commit 0dca03cd4d
9 changed files with 75 additions and 8 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ import (
var version = "dev"
// @title MeshCore Beacon API
// @version 2.0.0
// @version 2.0.1
// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.
// @description REST requests share a configurable per-client rate limit (default 300/minute and a 300-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget.
// @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open.
+1 -1
View File
@@ -66,7 +66,7 @@ ratelimit:
#meshmapper:
# scopes:
# enabled: false
# refresh_interval: 1h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick
# refresh_interval: 24h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick
#
# Region boundaries (Zones API). Every known IATA, including ones auto-created
# from traffic, is looked up in its country's zone list, then its get_geojson.php
+1 -1
View File
@@ -5811,7 +5811,7 @@ const docTemplate = `{
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "2.0.0",
Version: "2.0.1",
Host: "localhost:8080",
BasePath: "/api/v1",
Schemes: []string{"http", "https"},
+1 -1
View File
@@ -15,7 +15,7 @@
"license": {
"name": "AGPL-3-or-later"
},
"version": "2.0.0"
"version": "2.0.1"
},
"host": "localhost:8080",
"basePath": "/api/v1",
+1 -1
View File
@@ -1635,7 +1635,7 @@ info:
name: AGPL-3-or-later
termsOfService: https://github.com/MeshCore-Beacon/beacon-server
title: MeshCore Beacon API
version: 2.0.0
version: 2.0.1
paths:
/admin/accounts:
get:
+1 -1
View File
@@ -60,7 +60,7 @@ type MeshMapperScopesConfig struct {
func (c MeshMapperScopesConfig) Interval() time.Duration {
if c.RefreshInterval.Duration == 0 {
return time.Hour
return 24 * time.Hour
}
return c.RefreshInterval.Duration
}
+1 -1
View File
@@ -38,7 +38,7 @@ func TestMeshMapperConfig(t *testing.T) {
}
})
}
if (MeshMapperScopesConfig{}).Interval() != time.Hour {
if (MeshMapperScopesConfig{}).Interval() != 24*time.Hour {
t.Fatal("wrong default")
}
}
+9 -1
View File
@@ -150,7 +150,15 @@ func (i *Importer) Refresh(ctx context.Context) (err error) {
return fmt.Errorf("list IATAs for MeshMapper scopes: %w", err)
}
i.track(iatas, nil)
for n := range i.sources {
// Longest-waiting first, so a pass longer than the interval can't starve the tail.
order := make([]int, len(i.sources))
for n := range order {
order[n] = n
}
slices.SortStableFunc(order, func(a, b int) int {
return i.sources[a].cache.NextAttempt.Compare(i.sources[b].cache.NextAttempt)
})
for _, n := range order {
s := &i.sources[n]
if now.Before(s.cache.NextAttempt) {
continue
+59
View File
@@ -425,3 +425,62 @@ func TestScopesRequestCutShortStillCounts(t *testing.T) {
t.Fatal("abandoned request not persisted", row)
}
}
// newSweep serves a CA zone list whose IATAs each have a catalogue; fail lists IATAs answering 500.
func newSweep(t *testing.T, store *memoryStore, fail ...string) (*Importer, *[]string) {
t.Helper()
var hits []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/get_zones.php" {
var zones []string
for _, iata := range store.iatas {
zones = append(zones, fmt.Sprintf(`{"code":%q,"url":"%s/%s/","has_boundary":true,"group":null}`, iata, "http://"+r.Host, iata))
}
_, _ = fmt.Fprintf(w, `{"country":"CA","zones":[%s]}`, strings.Join(zones, ","))
return
}
iata := strings.Split(r.URL.Path, "/")[1]
hits = append(hits, iata)
if slices.Contains(fail, iata) {
w.WriteHeader(500)
return
}
_, _ = w.Write([]byte(strings.ReplaceAll(string(catalogue("x")), "YOW", iata)))
}))
t.Cleanup(server.Close)
dir := NewDirectory(newZoneListMemory())
dir.listURL = server.URL + "/get_zones.php"
imp, err := New(context.Background(), config.MeshMapperScopesConfig{Enabled: true}, store, dir, scopestore.New(), nil)
if err != nil {
t.Fatal(err)
}
imp.scopesURL = func(site string) (string, bool) { return site + "get_scopes.php", true }
refreshN(t, imp, 1) // zone list fetch
return imp, &hits
}
func TestScopesLongestWaitingGoesFirst(t *testing.T) {
store := newMemoryStore("YOW", "YUL", "YVR", "YYZ")
now := time.Now()
// Restored mid-pass: YOW/YUL done, YVR never reached; YOW is already due again.
store.rows[sourceKey{"YOW", "old"}] = Cache{AttemptedAt: now.Add(-time.Hour), NextAttempt: now.Add(-time.Minute)}
store.rows[sourceKey{"YUL", "old"}] = Cache{AttemptedAt: now, NextAttempt: now.Add(time.Hour)}
store.rows[sourceKey{"YVR", "old"}] = Cache{AttemptedAt: now.Add(-48 * time.Hour), NextAttempt: now.Add(-47 * time.Hour)}
imp, hits := newSweep(t, store)
refreshN(t, imp, 3)
if want := []string{"YYZ", "YVR", "YOW"}; !reflect.DeepEqual(*hits, want) {
t.Fatalf("refresh order %v, want %v", *hits, want)
}
}
func TestScopesFailureDoesNotStallThePass(t *testing.T) {
store := newMemoryStore("YOW", "YUL", "YVR")
imp, hits := newSweep(t, store, "YOW")
refreshN(t, imp, 4)
if want := []string{"YOW", "YUL", "YVR"}; !reflect.DeepEqual(*hits, want) {
t.Fatalf("refresh order %v, want %v", *hits, want)
}
if s := imp.sources[0]; s.iata != "YOW" || time.Until(s.cache.NextAttempt) > failureRetry {
t.Fatalf("failed source not retried early: %+v", s.cache)
}
}