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.
161 lines
5.1 KiB
Go
161 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"hamlog/internal/gridcache"
|
|
)
|
|
|
|
// The grid cache used to drop EVERYTHING at its ceiling. That was survivable
|
|
// while the only feed was this station's own decodes, which never reached it.
|
|
// Fed by anything larger it is a cliff: every locator in the cluster list
|
|
// disappears at once, periodically, and the operator sees the column empty
|
|
// itself for no reason.
|
|
//
|
|
// Rotation keeps the previous generation, so a full cache costs the older half
|
|
// and nothing more.
|
|
func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) {
|
|
a := &App{}
|
|
|
|
// Fill exactly one generation.
|
|
for i := 0; i < decodeGridsCap; i++ {
|
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36")
|
|
}
|
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
|
t.Fatalf("first entry lost before any rotation: %q", got)
|
|
}
|
|
if a.decodeGridsOld != nil {
|
|
t.Fatal("rotated early — the cap is the ceiling of ONE generation")
|
|
}
|
|
|
|
// One more entry rotates.
|
|
a.rememberDecodeGrid("NEWCALL", "IO91")
|
|
if a.decodeGridsOld == nil {
|
|
t.Fatal("did not rotate at the cap")
|
|
}
|
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
|
t.Errorf("the entry that caused the rotation was lost: %q", got)
|
|
}
|
|
// The whole previous generation is still readable — this is the point.
|
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
|
t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got)
|
|
}
|
|
if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" {
|
|
t.Errorf("previous generation incomplete: %q", got)
|
|
}
|
|
|
|
// A second rotation is what finally retires the oldest half.
|
|
for i := 0; i < decodeGridsCap; i++ {
|
|
a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20")
|
|
}
|
|
if got := a.lookupDecodeGrid("CALL000000"); got != "" {
|
|
t.Errorf("the cache is unbounded: %q survived two rotations", got)
|
|
}
|
|
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
|
t.Errorf("an entry one generation old was retired too early: %q", got)
|
|
}
|
|
}
|
|
|
|
// Callsigns are normalised on the way in AND on the way out, or a spot for
|
|
// "f4bpo" would miss a grid learnt as "F4BPO".
|
|
func TestDecodeGridCaseAndBlanks(t *testing.T) {
|
|
a := &App{}
|
|
a.rememberDecodeGrid(" f4bpo ", "JN36")
|
|
if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
|
|
t.Errorf("lookup of the upper-case form failed: %q", got)
|
|
}
|
|
a.rememberDecodeGrid("", "JN36")
|
|
a.rememberDecodeGrid("K1ABC", "")
|
|
if got := a.lookupDecodeGrid("K1ABC"); got != "" {
|
|
t.Errorf("stored an empty grid: %q", got)
|
|
}
|
|
}
|
|
|
|
// The write path runs on the decode goroutine while the cluster status builder
|
|
// reads. Rotation swaps the map headers, so an unguarded read is a data race
|
|
// rather than a stale value.
|
|
//
|
|
// This build is CGO-free, so -race is not available here; the test exercises the
|
|
// interleaving and would fault on a concurrent map access even without it.
|
|
func TestDecodeGridConcurrentAccess(t *testing.T) {
|
|
a := &App{}
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 20000; i++ {
|
|
a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31")
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 20000; i++ {
|
|
_ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i))
|
|
}
|
|
}()
|
|
wg.Wait()
|
|
}
|
|
|
|
// Only a CHANGE may reach the write batch.
|
|
//
|
|
// The feeds repeat themselves — the same station is reported by dozens of
|
|
// receivers a minute — so queueing every report would put the whole stream in
|
|
// the batch instead of the news in it, and turn a cache into a write amplifier.
|
|
func TestOnlyChangesAreQueued(t *testing.T) {
|
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
a := &App{gridStore: st}
|
|
|
|
a.rememberDecodeGrid("F4BPO", "JN36")
|
|
if n := st.Pending(); n != 1 {
|
|
t.Fatalf("a new locator queued %d writes, want 1", n)
|
|
}
|
|
if err := st.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The same report, a hundred times over, is not news.
|
|
for i := 0; i < 100; i++ {
|
|
a.rememberDecodeGrid("F4BPO", "JN36")
|
|
}
|
|
if n := st.Pending(); n != 0 {
|
|
t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n)
|
|
}
|
|
|
|
// A station that moved is.
|
|
a.rememberDecodeGrid("F4BPO", "KP30")
|
|
if n := st.Pending(); n != 1 {
|
|
t.Errorf("a changed locator queued %d writes, want 1", n)
|
|
}
|
|
if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" {
|
|
t.Errorf("the map kept the old locator: %q", got)
|
|
}
|
|
}
|
|
|
|
// Rotation must be OFF while a store is attached: it would discard callsigns the
|
|
// database still holds, and the lookup would then miss something we know.
|
|
func TestNoRotationWhilePersisting(t *testing.T) {
|
|
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
a := &App{gridStore: st}
|
|
|
|
for i := 0; i < decodeGridsCap+10; i++ {
|
|
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36")
|
|
}
|
|
if a.decodeGridsOld != nil {
|
|
t.Error("rotated while a store was attached — locators the database holds would go missing")
|
|
}
|
|
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
|
t.Errorf("the first entry was dropped: %q", got)
|
|
}
|
|
}
|