diff --git a/app.go b/app.go index bf1f7ee..803dab1 100644 --- a/app.go +++ b/app.go @@ -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 // /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) { diff --git a/changelog.json b/changelog.json index d70f146..d8a797b 100644 --- a/changelog.json +++ b/changelog.json @@ -3,11 +3,13 @@ "version": "0.24.1", "date": "", "en": [ + "Memory: the awards cache is given back once you stop using it. Opening the Awards panel loads the whole logbook into memory and kept it there for the rest of the session — a few tens of megabytes on a small log, but several hundred on a large one, and it was never released because the cache only expired when the logbook changed. It is now dropped after fifteen minutes without use, and the memory returned to Windows. Fifteen minutes on purpose: working through your awards keeps it warm, since reloading a large log takes seconds. The log also records the heap size each time the cache is built or released, so a memory report can be answered with a figure instead of a guess.", "Performance: the DX-cluster console no longer drags the whole interface down. Every line of traffic — spots, MOTD, everything — was applied to the screen one at a time, and an RBN feed alone sends hundreds a second: that meant two copies of a 2000-line buffer and a redraw for each one, whether the console was open or not. Lines are now grouped and applied five times a second. The difference is most visible on an older PC, where this alone could make the app crawl.", "Update: \"stage current exe: … Accès refusé\" is fixed. Two causes, both handled. The previous build was always staged under the same name, so one leftover that could not be deleted — an antivirus holding it open is the usual reason — blocked every later update, permanently, with no way out but deleting the file by hand; the staging name is now unique. And when the running program cannot be renamed at all, which some endpoint protection deliberately prevents, OpsLog no longer gives up: it leaves the new build beside the old one and swaps them after closing, when its own file is an ordinary file again. If even that fails, OpsLog restarts on the current version rather than leaving you with nothing, and the download is kept for the next attempt.", "Band map: the width can be dragged. Both the map docked beside the tables and the per-band cards in the Band map tab were locked at a fixed width, so an operator watching a busy band could not give the map more room — nor take it back for the log. Grab the edge to resize, double-click it to go back to the default. The width is remembered and travels with your data folder, like the other layout settings. In the tab, one width applies to every card: they sit side by side, and columns of different widths read as a mistake." ], "fr": [ + "Mémoire : le cache des diplômes est rendu quand tu ne t'en sers plus. Ouvrir le panneau Diplômes charge tout le journal en mémoire et l'y gardait jusqu'à la fermeture — quelques dizaines de Mo sur un petit journal, plusieurs centaines sur un gros, et jamais libérés puisque le cache n'expirait qu'au changement du journal. Il est désormais abandonné après quinze minutes sans usage, et la mémoire rendue à Windows. Quinze minutes volontairement : parcourir tes diplômes le garde chaud, recharger un gros journal coûtant plusieurs secondes. Le journal technique note aussi la taille du tas à chaque construction ou libération, pour qu'un signalement de mémoire se réponde avec un chiffre plutôt qu'une supposition.", "Performance : la console du cluster DX ne plombe plus toute l'interface. Chaque ligne de trafic — spots, MOTD, tout — était appliquée à l'écran une par une, et un flux RBN en envoie à lui seul des centaines par seconde : cela faisait deux copies d'un tampon de 2000 lignes et un redessin pour chacune, que la console soit ouverte ou non. Les lignes sont désormais groupées et appliquées cinq fois par seconde. La différence se voit surtout sur un PC ancien, où cela suffisait à faire ramer l'application.", "Mise à jour : le « stage current exe : … Accès refusé » est corrigé. Deux causes, traitées toutes les deux. L'ancienne version était toujours mise de côté sous le même nom : un seul reliquat impossible à supprimer — un antivirus qui le garde ouvert, le plus souvent — bloquait définitivement toutes les mises à jour suivantes, sans autre issue que d'effacer le fichier à la main ; ce nom est désormais unique. Et quand le programme en cours d'exécution ne peut pas être renommé du tout, ce que certaines protections empêchent volontairement, OpsLog n'abandonne plus : il laisse la nouvelle version à côté de l'ancienne et les échange après sa fermeture, quand son propre fichier redevient un fichier ordinaire. Si même cela échoue, OpsLog redémarre sur la version actuelle plutôt que de te laisser sans rien, et le téléchargement est conservé pour la prochaine tentative.", "Band map : la largeur se règle à la souris. La carte ancrée à côté des tableaux et les cartes par bande de l'onglet Band map étaient figées à une largeur fixe : impossible de donner plus de place à la carte sur une bande chargée, ni de la reprendre pour le journal. Attrape le bord pour redimensionner, double-clic pour revenir au défaut. La largeur est mémorisée et voyage avec ton dossier de données, comme les autres réglages de disposition. Dans l'onglet, une seule largeur vaut pour toutes les cartes : elles sont côte à côte, et des colonnes de largeurs différentes se lisent comme une erreur."