feat: Optimization of spots not to miss any
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user