The store shipped without its main source. Locators came only from this station's own WSJT-X decodes, which is what the whole MQTT discussion was about. PSK Reporter now feeds it through a new OnGrid callback, fired before any geographic filtering: what the store wants is "which square is this callsign in", and that is true whoever happened to hear the report. The subscription filters on the RECEIVER's square, a level the v2 topic exposes. Measured on the live feed: the four opening bands unfiltered are 83 messages a second, of which roughly one in a hundred survived the NearKm test that already existed here — the rest was received, TLS-decrypted, JSON-parsed and discarded. One ring of squares is 0.2 to 1.2 a second. By square rather than by DXCC, which was the obvious alternative: one country measured 1.2 messages a second (OH) against 72.5 (K) on a single band, because a DXCC can be a continent. By square the same measurement is 0.2 to 1.2, so the load follows distance — what the feed is actually about — and is the same for every operator. Grid chasing subscribes with the "+" band wildcard, so one subscription per square covers every band instead of one per band per square. The store gains a source column (decode | mqtt), migrated in place on an existing file.
161 lines
5.3 KiB
Go
161 lines
5.3 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", gridcache.SourceDecode)
|
|
}
|
|
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", gridcache.SourceDecode)
|
|
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", gridcache.SourceDecode)
|
|
}
|
|
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", gridcache.SourceDecode)
|
|
if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
|
|
t.Errorf("lookup of the upper-case form failed: %q", got)
|
|
}
|
|
a.rememberDecodeGrid("", "JN36", gridcache.SourceDecode)
|
|
a.rememberDecodeGrid("K1ABC", "", gridcache.SourceDecode)
|
|
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", gridcache.SourceDecode)
|
|
}
|
|
}()
|
|
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", gridcache.SourceDecode)
|
|
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", gridcache.SourceDecode)
|
|
}
|
|
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", gridcache.SourceDecode)
|
|
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", gridcache.SourceDecode)
|
|
}
|
|
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)
|
|
}
|
|
}
|