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. // lagging telnet). The read loop now just enqueues here; one worker does the work.
clusterEventCh chan clusterEvent clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic) 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 pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo awardRefs *awardref.Repo
@@ -802,6 +807,7 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook backend = %s", backend) applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn a.logDb = logbookConn
a.qso = qso.NewRepo(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 // cty.dat for offline DXCC / country resolution. Cached on disk; first
// run downloads it from country-files.com in the background so startup // 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 { if err == nil {
q.ID = id 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). // Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
wruntime.EventsEmit(a.ctx, "qso:logged", id) wruntime.EventsEmit(a.ctx, "qso:logged", id)
a.saveQSORecording(&q) a.saveQSORecording(&q)
@@ -3377,6 +3384,10 @@ func (a *App) invalidateAwardStats() {
a.awardSnap = nil a.awardSnap = nil
a.awardSnapRev = "" a.awardSnapRev = ""
a.awardSnapMu.Unlock() 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 // 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 // isWorkedBandMode reports whether the exact callsign is already logged on the
// given band+mode (used by rules with "skip worked before"). // 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 { func (a *App) isWorkedBandMode(call, band, mode string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" { if a.qso == nil || strings.TrimSpace(call) == "" {
return false return false
} }
rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5}) a.wcbmMu.RLock()
if err != nil { ready := a.wcbm != nil
return false a.wcbmMu.RUnlock()
if !ready {
a.rebuildWorkedIndex()
} }
call = strings.ToUpper(strings.TrimSpace(call)) a.wcbmMu.RLock()
for _, q := range rows { _, ok := a.wcbm[wcbmKey(call, band, mode)]
if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call { a.wcbmMu.RUnlock()
return true return ok
}
}
return false
} }
// sendAlertEmail notifies the operator by e-mail that a wanted station was // 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 ---------- // ---------- parsing ----------
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]". // 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( 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. // 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() 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 // WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks // (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately. // (an n-fer); each is added separately.