feat(cluster): feed the locator store from PSK Reporter, filtered at the broker

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.
This commit is contained in:
2026-08-12 17:16:47 +02:00
parent 82a49150d2
commit 7d33379fe1
15 changed files with 352 additions and 81 deletions
+8 -8
View File
@@ -21,8 +21,8 @@ func open(t *testing.T) (*Store, string) {
// of after an hour of listening.
func TestSurvivesRestart(t *testing.T) {
s, path := open(t)
s.Put("F4BPO", "JN36")
s.Put("OH5CX", "KP30")
s.Put("F4BPO", "JN36", SourceDecode)
s.Put("OH5CX", "KP30", SourceDecode)
if err := s.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
@@ -51,11 +51,11 @@ func TestNewestReportWins(t *testing.T) {
s, _ := open(t)
defer s.Close()
s.Put("F4BPO", "JN36")
s.Put("F4BPO", "JN36", SourceDecode)
if err := s.Flush(); err != nil {
t.Fatal(err)
}
s.Put("F4BPO", "KP30") // moved
s.Put("F4BPO", "KP30", SourceDecode) // moved
if err := s.Flush(); err != nil {
t.Fatal(err)
}
@@ -79,7 +79,7 @@ func TestBatching(t *testing.T) {
defer s.Close()
for _, c := range []string{"A1AA", "B2BB", "C3CC"} {
s.Put(c, "JN36")
s.Put(c, "JN36", SourceDecode)
}
if n := s.Pending(); n != 3 {
t.Errorf("pending = %d, want 3 queued and unwritten", n)
@@ -106,8 +106,8 @@ func TestBatching(t *testing.T) {
// 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")
s.Put("FRESH", "JN36", SourceDecode)
s.Put("STALE", "IO91", SourceDecode)
if err := s.Flush(); err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestPruneOnOpen(t *testing.T) {
// trade.
func TestCloseFlushes(t *testing.T) {
s, path := open(t)
s.Put("LATE", "JN36")
s.Put("LATE", "JN36", SourceDecode)
if err := s.Close(); err != nil {
t.Fatalf("close: %v", err)
}