The cache dropped EVERY entry once it passed 20 000. That was survivable while the only feed was this station's own WSJT-X decodes, which never reached the ceiling — it is a cliff for anything larger, and every locator in the cluster list would disappear at once, periodically, for no reason the operator could see. Two generations: when the current map fills it becomes the previous one and a fresh map takes over; lookups consult both. A rotation therefore costs the older half and nothing more. It needs no insertion order, no per-entry timestamp and no bookkeeping on the write path — all of which "evict the oldest thousand" would require, on a path that runs once per decode. The cap is 100 000, measured at 82 bytes an entry: 8 MB a generation, 16 MB for both. 20 000 was chosen when the ceiling was unreachable anyway. Both maps now go through rememberDecodeGrid / lookupDecodeGrid. Rotation swaps the map headers, so the read had to be guarded; routing every access through one accessor is what makes that checkable rather than remembered.
98 lines
3.2 KiB
Go
98 lines
3.2 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// 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()
|
|
}
|