feat(cluster): persist learnt locators behind a "Chase new grids" option
The callsign->grid map died with the process. Every restart began with an empty Locator column that took an hour of listening to refill, and everything learnt the day before was thrown away. internal/gridcache is its own SQLite file in the data directory, not a table in the settings database: that one sits wherever the operator chose to put it, often a synchronised folder, and a store that rewrites itself every minute has no business there. Deleting the file costs a few days of listening and nothing else. Callsign is the primary key and the newest report wins — operators move, go portable, go on expedition, and a stale locator is worse than none for grid chasing because it reads as a square already worked. Entries not seen in two years are pruned at open: that is the one way this cache can be actively wrong rather than merely empty, and it is what bounds a store that would otherwise only grow. Only CHANGES are queued. The feeds repeat themselves, so writing every report would put the whole stream in the batch instead of the news in it. Batches flush on a timer and on shutdown — a restart is exactly when the cache is worth the most. Rotation is switched off while the store is attached: the cap would discard callsigns the database still holds and the lookup would then miss what we know. Age in the store is the bound instead.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package gridcache
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func open(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "grids.db")
|
||||
s, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
// The whole reason the store exists: what was learnt is still there after a
|
||||
// restart, so the cluster's locator column is full in the first second instead
|
||||
// of after an hour of listening.
|
||||
func TestSurvivesRestart(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("F4BPO", "JN36")
|
||||
s.Put("OH5CX", "KP30")
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, err := again.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got["F4BPO"] != "JN36" || got["OH5CX"] != "KP30" {
|
||||
t.Errorf("locators lost across a restart: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign has ONE grid and the newest report wins. Operators move, go
|
||||
// portable, go on expedition — and a stale locator is worse than none for grid
|
||||
// chasing, because it reads as a square already worked.
|
||||
func TestNewestReportWins(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
defer s.Close()
|
||||
|
||||
s.Put("F4BPO", "JN36")
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Put("F4BPO", "KP30") // moved
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["F4BPO"] != "KP30" {
|
||||
t.Errorf("grid = %q, want the newer KP30", got["F4BPO"])
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("a callsign must hold one row, got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing reaches the disk until a flush, and a flush with nothing pending is
|
||||
// not an error — that is most minutes on a quiet band.
|
||||
func TestBatching(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
defer s.Close()
|
||||
|
||||
for _, c := range []string{"A1AA", "B2BB", "C3CC"} {
|
||||
s.Put(c, "JN36")
|
||||
}
|
||||
if n := s.Pending(); n != 3 {
|
||||
t.Errorf("pending = %d, want 3 queued and unwritten", n)
|
||||
}
|
||||
if got, _ := s.LoadAll(); len(got) != 0 {
|
||||
t.Errorf("wrote before the flush: %v", got)
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := s.Pending(); n != 0 {
|
||||
t.Errorf("pending = %d after a flush, want 0", n)
|
||||
}
|
||||
if got, _ := s.LoadAll(); len(got) != 3 {
|
||||
t.Errorf("flush wrote %d rows, want 3", len(got))
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Errorf("empty flush must be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Age is what bounds a store that would otherwise only grow. A callsign not
|
||||
// heard in two years is likely reassigned, and carrying its previous holder's
|
||||
// square is the one way this cache can be actively wrong rather than empty.
|
||||
func TestPruneOnOpen(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("FRESH", "JN36")
|
||||
s.Put("STALE", "IO91")
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate one row past the retention window.
|
||||
old := time.Now().Add(-Retention - 24*time.Hour).Unix()
|
||||
if _, err := s.db.Exec(`UPDATE grids SET updated_at = ? WHERE call = 'STALE'`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, _ := again.LoadAll()
|
||||
if _, ok := got["STALE"]; ok {
|
||||
t.Error("an entry past the retention window survived — the store is unbounded")
|
||||
}
|
||||
if got["FRESH"] != "JN36" {
|
||||
t.Error("pruning took a live entry with it")
|
||||
}
|
||||
}
|
||||
|
||||
// Close has to write what the last minute learnt: a restart is exactly when the
|
||||
// cache is worth the most, so losing it to a clean shutdown would be a poor
|
||||
// trade.
|
||||
func TestCloseFlushes(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("LATE", "JN36")
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, _ := again.LoadAll()
|
||||
if got["LATE"] != "JN36" {
|
||||
t.Error("what was pending at shutdown was dropped")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user