perf(awards): release the logbook snapshot once it goes cold
Opening the Awards panel pulls every QSO into a cached slice and kept it for the rest of the session: the cache was only ever invalidated by a logbook change, never by disuse. Each cached QSO is a 1896-byte struct plus its strings AND a decoded map of its ADIF extras - one map allocation per QSO. Harmless at 30k rows, several hundred megabytes at 132k, which is where it was reported. A janitor drops it after 15 minutes without a reader and calls FreeOSMemory, because Go hands pages back lazily and the whole point is that the operator sees the memory return. 15 minutes is deliberately generous: an awards session recomputes every few seconds and re-pulling a large remote logbook costs seconds. The heap size is now logged when the snapshot is built and when it is released - a memory report was unanswerable without a number.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -688,6 +689,7 @@ type App struct {
|
||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
|
||||
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
|
||||
|
||||
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||
@@ -1235,6 +1237,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// behind telnet).
|
||||
a.clusterEvents = newClusterQueue()
|
||||
go a.clusterEventWorker()
|
||||
go a.awardSnapshotJanitor() // give the award snapshot's memory back once it goes cold
|
||||
|
||||
a.cluster = cluster.NewManager(
|
||||
// onSpot / onLine run on the session's socket-read goroutine, so they must
|
||||
@@ -4172,6 +4175,7 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
|
||||
a.awardSnapMu.Lock()
|
||||
if a.awardSnap != nil && a.awardSnapRev == rev {
|
||||
qs := a.awardSnap
|
||||
a.awardSnapUsed = time.Now()
|
||||
a.awardSnapMu.Unlock()
|
||||
return qs, nil
|
||||
}
|
||||
@@ -4187,18 +4191,63 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s)",
|
||||
len(all), time.Since(t0).Round(time.Millisecond), rev)
|
||||
// Heap alongside the row count: this snapshot is the single largest thing
|
||||
// OpsLog holds, and "OpsLog is eating memory" reports are unanswerable
|
||||
// without a number. A 132 000-QSO logbook was the case that prompted it.
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s) — go heap now %d MB",
|
||||
len(all), time.Since(t0).Round(time.Millisecond), rev, ms.HeapAlloc/(1024*1024))
|
||||
|
||||
if revErr == nil {
|
||||
a.awardSnapMu.Lock()
|
||||
a.awardSnap = all
|
||||
a.awardSnapRev = rev
|
||||
a.awardSnapUsed = time.Now()
|
||||
a.awardSnapMu.Unlock()
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// awardSnapIdleTTL is how long the snapshot survives without a reader.
|
||||
//
|
||||
// Generous on purpose: an operator working through the Awards panel triggers a
|
||||
// computation every few seconds, and re-pulling costs seconds on a big remote
|
||||
// logbook. This is only meant to catch the far commoner case — awards looked at
|
||||
// once, then hours of logging with several hundred megabytes still held.
|
||||
const awardSnapIdleTTL = 15 * time.Minute
|
||||
|
||||
// awardSnapshotJanitor drops the award snapshot once nothing has read it for a
|
||||
// while, and returns the memory to the OS.
|
||||
//
|
||||
// The snapshot is a whole logbook of QSO structs, each carrying a decoded map of
|
||||
// its ADIF extras: ~1.9 KB of struct plus strings and one map allocation per
|
||||
// QSO. At 30 000 QSOs that is tens of megabytes and nobody notices; at 132 000
|
||||
// it is several hundred, held for the rest of the session because the cache had
|
||||
// no expiry — only invalidation when the logbook changed.
|
||||
func (a *App) awardSnapshotJanitor() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
a.awardSnapMu.Lock()
|
||||
n := len(a.awardSnap)
|
||||
idle := !a.awardSnapUsed.IsZero() && time.Since(a.awardSnapUsed) > awardSnapIdleTTL
|
||||
if a.awardSnap != nil && idle {
|
||||
a.awardSnap = nil
|
||||
a.awardSnapRev = ""
|
||||
}
|
||||
a.awardSnapMu.Unlock()
|
||||
if n > 0 && idle {
|
||||
// FreeOSMemory, not just GC: Go hands pages back lazily, and the whole
|
||||
// point here is that the operator sees the memory come back.
|
||||
debug.FreeOSMemory()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
applog.Printf("awardSnapshot: released %d cached qsos after %v idle — go heap now %d MB",
|
||||
n, awardSnapIdleTTL, ms.HeapAlloc/(1024*1024))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAwardStats computes the worked/confirmed/validated reference counts of one
|
||||
// award, broken down by band and by mode category (All/CW/Digital/Phone).
|
||||
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
|
||||
|
||||
Reference in New Issue
Block a user