// Package gridcache is the long-term callsign→grid store behind grid chasing. // // Its own SQLite file, not a table in the settings database: that one sits // wherever the operator put it, often a synchronised folder, and this rewrites // itself every minute. Deleting the file costs a few days of listening. // // A callsign has one grid and the newest report wins — a stale locator reads as // a square already worked, which is worse than none. package gridcache import ( "context" "database/sql" "fmt" "strings" "sync" "time" _ "modernc.org/sqlite" ) // Retention bounds a store that would otherwise only ever grow. Two years is // chosen to be far longer than any propagation interest and short enough that a // reassigned callsign eventually stops carrying its previous holder's square — // the one way this cache can be actively wrong rather than merely empty. const Retention = 2 * 365 * 24 * time.Hour // FlushEvery is the batch interval. Long enough that a burst of reports costs // one transaction, short enough that a crash loses a minute of learning. const FlushEvery = 60 * time.Second // Sources a locator can come from. const ( SourceDecode = "decode" // a CQ this station's own receiver decoded SourceMQTT = "mqtt" // a PSK Reporter report ) // Entry is one locator and where it came from. type Entry struct { Grid string Source string } type Store struct { db *sql.DB mu sync.Mutex dirty map[string]Entry // call → what to write stop chan struct{} stopOnce sync.Once wg sync.WaitGroup logf func(string, ...any) } // Open creates or opens the store at path and prunes what has aged out. func Open(path string, logf func(string, ...any)) (*Store, error) { if logf == nil { logf = func(string, ...any) {} } // WAL so a flush never blocks a read, and a busy timeout because the flush // goroutine and the startup load can overlap on a slow disk. db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") if err != nil { return nil, fmt.Errorf("gridcache: open %s: %w", path, err) } if _, err := db.Exec("CREATE TABLE IF NOT EXISTS grids (" + "call TEXT PRIMARY KEY, grid TEXT NOT NULL, updated_at INTEGER NOT NULL, " + "source TEXT NOT NULL DEFAULT '')"); err != nil { db.Close() return nil, fmt.Errorf("gridcache: schema: %w", err) } // A file written before the column existed keeps its rows; this fails // harmlessly when the column is already there. db.Exec("ALTER TABLE grids ADD COLUMN source TEXT NOT NULL DEFAULT ''") s := &Store{db: db, dirty: map[string]Entry{}, stop: make(chan struct{}), logf: logf} if n, err := s.prune(); err != nil { s.logf("gridcache: prune failed: %v", err) } else if n > 0 { s.logf("gridcache: pruned %d locators not heard in %d days", n, int(Retention.Hours()/24)) } return s, nil } func (s *Store) prune() (int64, error) { cut := time.Now().Add(-Retention).Unix() res, err := s.db.Exec(`DELETE FROM grids WHERE updated_at < ?`, cut) if err != nil { return 0, err } return res.RowsAffected() } // LoadAll returns every stored locator, for seeding the in-memory map at // startup. One query and one map build — the point of the whole package is that // nothing afterwards has to ask the database anything. func (s *Store) LoadAll() (map[string]string, error) { rows, err := s.db.Query(`SELECT call, grid FROM grids`) if err != nil { return nil, fmt.Errorf("gridcache: load: %w", err) } defer rows.Close() out := make(map[string]string, 4096) for rows.Next() { var call, grid string if err := rows.Scan(&call, &grid); err != nil { return nil, err } out[call] = grid } return out, rows.Err() } // Put queues a locator for writing. Callers pass only what CHANGED — an // unchanged report is the common case by a wide margin and must not reach here, // or the batch would carry the whole feed instead of the news in it. func (s *Store) Put(call, grid, source string) { call = strings.ToUpper(strings.TrimSpace(call)) grid = strings.TrimSpace(grid) if call == "" || grid == "" { return } s.mu.Lock() s.dirty[call] = Entry{Grid: grid, Source: source} s.mu.Unlock() } // Start runs the flush loop until Close. func (s *Store) Start(ctx context.Context) { s.wg.Add(1) go func() { defer s.wg.Done() t := time.NewTicker(FlushEvery) defer t.Stop() for { select { case <-s.stop: return case <-ctx.Done(): return case <-t.C: if err := s.Flush(); err != nil { s.logf("gridcache: flush failed: %v", err) } } } }() } // Flush writes the pending batch in one transaction. Safe to call with nothing // pending, which is most of the time on a quiet band. func (s *Store) Flush() error { s.mu.Lock() if len(s.dirty) == 0 { s.mu.Unlock() return nil } batch := s.dirty s.dirty = map[string]Entry{} s.mu.Unlock() tx, err := s.db.Begin() if err != nil { s.requeue(batch) return err } st, err := tx.Prepare(`INSERT INTO grids (call, grid, updated_at, source) VALUES (?, ?, ?, ?) ON CONFLICT(call) DO UPDATE SET grid = excluded.grid, updated_at = excluded.updated_at, source = excluded.source`) if err != nil { tx.Rollback() s.requeue(batch) return err } defer st.Close() now := time.Now().Unix() for call, e := range batch { if _, err := st.Exec(call, e.Grid, now, e.Source); err != nil { tx.Rollback() s.requeue(batch) return err } } if err := tx.Commit(); err != nil { s.requeue(batch) return err } // Logged because a batch that never reaches the disk looks exactly like one // that does: the locators are in memory either way until the next restart, // which is the one moment the difference shows. s.logf("gridcache: wrote %d locators", len(batch)) return nil } // requeue puts a failed batch back, without overwriting anything learnt while it // was in flight — the newer value is the right one. func (s *Store) requeue(batch map[string]Entry) { s.mu.Lock() defer s.mu.Unlock() for call, e := range batch { if _, newer := s.dirty[call]; !newer { s.dirty[call] = e } } } // Pending reports how many locators are waiting to be written. func (s *Store) Pending() int { s.mu.Lock() defer s.mu.Unlock() return len(s.dirty) } // Close stops the loop and writes whatever is pending. A restart is the moment // the cache is most valuable, so losing the last minute of learning to a clean // shutdown would be a poor trade. func (s *Store) Close() error { s.stopOnce.Do(func() { close(s.stop) }) s.wg.Wait() err := s.Flush() if cerr := s.db.Close(); err == nil { err = cerr } return err }