feat: Optimization of spots not to miss any

This commit is contained in:
2026-07-18 12:17:22 +02:00
parent a1be0dfe68
commit 80c5fdc095
3 changed files with 89 additions and 11 deletions
+60 -10
View File
@@ -437,6 +437,11 @@ type App struct {
// lagging telnet). The read loop now just enqueues here; one worker does the work.
clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
// Loaded once, appended to on each log, rebuilt after bulk changes.
wcbm map[string]struct{}
wcbmMu sync.RWMutex
pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo
@@ -802,6 +807,7 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
// cty.dat for offline DXCC / country resolution. Cached on disk; first
// run downloads it from country-files.com in the background so startup
@@ -1851,6 +1857,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
if err == nil {
q.ID = id
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
wruntime.EventsEmit(a.ctx, "qso:logged", id)
a.saveQSORecording(&q)
@@ -3377,6 +3384,10 @@ func (a *App) invalidateAwardStats() {
a.awardSnap = nil
a.awardSnapRev = ""
a.awardSnapMu.Unlock()
// Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
// worked-index so alert "needed" checks stay accurate. Async: never block the
// mutation, and it's a single lightweight query.
go a.rebuildWorkedIndex()
}
// RescanAwards forces the next award computation to re-pull the logbook from the
@@ -5999,21 +6010,60 @@ func (a *App) evaluateAlerts(s cluster.Spot) {
// isWorkedBandMode reports whether the exact callsign is already logged on the
// given band+mode (used by rules with "skip worked before").
// wcbmKey builds the worked-index key: "CALL|BAND|MODE", all upper-cased.
func wcbmKey(call, band, mode string) string {
return strings.ToUpper(strings.TrimSpace(call)) + "|" +
strings.ToUpper(strings.TrimSpace(band)) + "|" +
strings.ToUpper(strings.TrimSpace(mode))
}
// rebuildWorkedIndex loads the whole "CALL|BAND|MODE" worked-index into memory in
// one pass. Called at startup and after bulk changes (import, delete, bulk edit).
func (a *App) rebuildWorkedIndex() {
if a.qso == nil {
return
}
m, err := a.qso.WorkedCallBandModeKeys(a.ctx)
if err != nil {
applog.Printf("worked-index: rebuild failed: %v", err)
return
}
a.wcbmMu.Lock()
a.wcbm = m
a.wcbmMu.Unlock()
}
// noteWorked adds a just-logged QSO to the in-memory worked-index, so an alert
// rule sees it as worked immediately without a full rebuild.
func (a *App) noteWorked(call, band, mode string) {
if strings.TrimSpace(call) == "" {
return
}
a.wcbmMu.Lock()
if a.wcbm == nil {
a.wcbm = map[string]struct{}{}
}
a.wcbm[wcbmKey(call, band, mode)] = struct{}{}
a.wcbmMu.Unlock()
}
// isWorkedBandMode reports whether this exact call+band+mode is in the log,
// answered from the in-memory index (no DB round-trip per spot). If the index
// hasn't loaded yet it lazily builds it once.
func (a *App) isWorkedBandMode(call, band, mode string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" {
return false
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5})
if err != nil {
return false
a.wcbmMu.RLock()
ready := a.wcbm != nil
a.wcbmMu.RUnlock()
if !ready {
a.rebuildWorkedIndex()
}
call = strings.ToUpper(strings.TrimSpace(call))
for _, q := range rows {
if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call {
return true
}
}
return false
a.wcbmMu.RLock()
_, ok := a.wcbm[wcbmKey(call, band, mode)]
a.wcbmMu.RUnlock()
return ok
}
// sendAlertEmail notifies the operator by e-mail that a wanted station was
+7 -1
View File
@@ -534,8 +534,14 @@ func (s *session) emitLine(text string, sent bool) {
// ---------- parsing ----------
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
//
// The spotter→freq separator is (?::\s*|\s+): a colon followed by ANY number of
// spaces (including ZERO), or one-or-more spaces with no colon. Some RBN skimmer
// nodes glue the frequency straight onto the colon — "DX de DL1HWS-3-#:14024.0 …"
// — which the old ":?\s+" (colon then a REQUIRED space) rejected, dropping every
// spot from those nodes.
var spotRE = regexp.MustCompile(
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
`^\s*DX\s+de\s+([A-Z0-9/#\-]+)(?::\s*|\s+)(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
)
// Pacing for the per-server init commands.
+22
View File
@@ -1809,6 +1809,28 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err()
}
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
// an FT8 skimmer firehose (and hammers a remote MySQL).
func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT upper(callsign), upper(band), upper(mode) FROM qso WHERE callsign != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 4096)
for rows.Next() {
var c, b, m string
if err := rows.Scan(&c, &b, &m); err != nil {
return nil, err
}
out[c+"|"+b+"|"+m] = struct{}{}
}
return out, rows.Err()
}
// WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately.