diff --git a/app.go b/app.go index c560a2c..74a7684 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 3846ccc..26748e6 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -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. diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 5505902..6283119 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -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.