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:
@@ -41,6 +41,7 @@ import (
|
||||
"hamlog/internal/email"
|
||||
"hamlog/internal/extsvc"
|
||||
"hamlog/internal/geo"
|
||||
"hamlog/internal/gridcache"
|
||||
"hamlog/internal/integrations/udp"
|
||||
"hamlog/internal/lookup"
|
||||
"hamlog/internal/lotwusers"
|
||||
@@ -235,6 +236,7 @@ const (
|
||||
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
|
||||
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
|
||||
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
||||
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
|
||||
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
|
||||
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||
@@ -622,6 +624,12 @@ type App struct {
|
||||
decodeGrids map[string]string // current generation, written to
|
||||
decodeGridsOld map[string]string // previous generation, still readable
|
||||
decodeGridsMu sync.RWMutex
|
||||
// gridStore persists the map across restarts when grid chasing is on. With
|
||||
// it, rotation is switched OFF: rotating would drop callsigns the database
|
||||
// still holds, and a lookup would then miss something we know. The store
|
||||
// bounds itself by age instead, so memory follows how many distinct stations
|
||||
// have actually been heard in two years rather than a made-up ceiling.
|
||||
gridStore *gridcache.Store
|
||||
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
||||
// It is the source that makes VHF detection work at all: the cluster and RBN
|
||||
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
||||
@@ -1387,6 +1395,10 @@ func (a *App) startup(ctx context.Context) {
|
||||
// PSK Reporter, when the opening watch is on. After the operator's grid is
|
||||
// known: without it there is no distance to measure and the feed stays down.
|
||||
a.startBandOpenFeed()
|
||||
// Locator store. After settings are scoped, so the option is read from the
|
||||
// right profile, and before the cluster starts serving statuses so the first
|
||||
// spots already carry their locators.
|
||||
a.startGridCache()
|
||||
// One-time tidy-up of a field nothing used to record. Background, once.
|
||||
a.backfillDistancesOnce()
|
||||
|
||||
@@ -1634,6 +1646,14 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.Stop()
|
||||
}
|
||||
// Before the databases: Close flushes what the last minute learnt, and a
|
||||
// restart is exactly when the grid cache is worth the most.
|
||||
if a.gridStore != nil {
|
||||
if err := a.gridStore.Close(); err != nil {
|
||||
applog.Printf("gridcache: close: %v", err)
|
||||
}
|
||||
a.gridStore = nil
|
||||
}
|
||||
if a.logDb != nil && a.logDb != a.db {
|
||||
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
|
||||
}
|
||||
@@ -17023,6 +17043,65 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
|
||||
// decodes and the ceiling was never reached anyway.
|
||||
const decodeGridsCap = 100000
|
||||
|
||||
// GetChaseNewGrids reports whether learnt locators are kept across restarts.
|
||||
func (a *App) GetChaseNewGrids() bool { return a.settingOr(keyChaseNewGrids, "") == "1" }
|
||||
|
||||
// SetChaseNewGrids turns grid chasing on or off and applies it immediately.
|
||||
func (a *App) SetChaseNewGrids(on bool) error {
|
||||
a.setSetting(keyChaseNewGrids, boolStr(on))
|
||||
a.startGridCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// startGridCache brings the locator store up or down to match the setting.
|
||||
//
|
||||
// Switching it ON seeds the in-memory map from the database, which is the whole
|
||||
// point: the cluster's locator column is populated in the first second instead
|
||||
// of after an hour of listening. Switching it OFF closes the file and leaves the
|
||||
// map alone — what has already been learnt this session stays usable, it simply
|
||||
// stops being remembered. Nothing is deleted: turning the option back on picks
|
||||
// up where it left off.
|
||||
func (a *App) startGridCache() {
|
||||
if a.gridStore != nil {
|
||||
if err := a.gridStore.Close(); err != nil {
|
||||
applog.Printf("gridcache: close: %v", err)
|
||||
}
|
||||
a.gridStore = nil
|
||||
}
|
||||
if !a.GetChaseNewGrids() {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(a.dataDir, "grids.db")
|
||||
st, err := gridcache.Open(path, applog.Printf)
|
||||
if err != nil {
|
||||
applog.Printf("gridcache: disabled — %v", err)
|
||||
return
|
||||
}
|
||||
seed, err := st.LoadAll()
|
||||
if err != nil {
|
||||
applog.Printf("gridcache: cannot read %s (%v) — starting empty", path, err)
|
||||
seed = map[string]string{}
|
||||
}
|
||||
a.decodeGridsMu.Lock()
|
||||
if a.decodeGrids == nil {
|
||||
a.decodeGrids = make(map[string]string, len(seed)+512)
|
||||
}
|
||||
// Seed UNDER what this session already heard: a locator decoded a minute ago
|
||||
// is newer than one stored days back, and the newest report is the one that
|
||||
// counts when a station has moved.
|
||||
for call, grid := range seed {
|
||||
if _, live := a.decodeGrids[call]; !live {
|
||||
a.decodeGrids[call] = grid
|
||||
}
|
||||
}
|
||||
n := len(a.decodeGrids)
|
||||
a.decodeGridsMu.Unlock()
|
||||
|
||||
a.gridStore = st
|
||||
st.Start(a.ctx)
|
||||
applog.Printf("gridcache: grid chasing on — %d locators loaded from %s (%d known in total)", len(seed), path, n)
|
||||
}
|
||||
|
||||
// rememberDecodeGrid records the grid a station announced.
|
||||
//
|
||||
// Rotation, not eviction: when the current generation fills it becomes the
|
||||
@@ -17031,19 +17110,38 @@ const decodeGridsCap = 100000
|
||||
// because this runs once per decode.
|
||||
func (a *App) rememberDecodeGrid(call, grid string) {
|
||||
call = strings.ToUpper(strings.TrimSpace(call))
|
||||
grid = strings.TrimSpace(grid)
|
||||
if call == "" || grid == "" {
|
||||
return
|
||||
}
|
||||
store := a.gridStore
|
||||
|
||||
a.decodeGridsMu.Lock()
|
||||
if a.decodeGrids == nil {
|
||||
a.decodeGrids = make(map[string]string, 512)
|
||||
}
|
||||
if len(a.decodeGrids) >= decodeGridsCap {
|
||||
// Rotate only while nothing is persisting the map. With a store the cap would
|
||||
// discard callsigns the database still holds, so the lookup would miss what
|
||||
// we know; age in the store is the bound instead.
|
||||
if store == nil && len(a.decodeGrids) >= decodeGridsCap {
|
||||
a.decodeGridsOld = a.decodeGrids
|
||||
a.decodeGrids = make(map[string]string, 512)
|
||||
}
|
||||
// Only a CHANGE is worth writing. 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.
|
||||
changed := a.decodeGrids[call] != grid
|
||||
if changed && a.decodeGridsOld != nil && a.decodeGridsOld[call] == grid {
|
||||
// Known already, just in the older generation: promote it without calling
|
||||
// it news.
|
||||
changed = false
|
||||
}
|
||||
a.decodeGrids[call] = grid
|
||||
a.decodeGridsMu.Unlock()
|
||||
|
||||
if changed && store != nil {
|
||||
store.Put(call, grid)
|
||||
}
|
||||
}
|
||||
|
||||
// lookupDecodeGrid returns the last grid heard for a callsign, "" if unknown.
|
||||
|
||||
Reference in New Issue
Block a user