Files

192 lines
6.3 KiB
Go

package main
import (
"context"
"fmt"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/db"
"hamlog/internal/offlineq"
"hamlog/internal/qso"
)
// ── Offline safety net ────────────────────────────────────────────────
//
// With the shared MySQL logbook, the network sits on the critical path of every
// write: lose it and you can't log at all. Rather than a fragile "fall back to
// SQLite" (which would leave you with a partial log and needs a restart), we
// keep the architecture exactly as it is and add a NET:
//
// DB unreachable → the QSO is parked in a local ADIF outbox (never lost)
// DB back → replayed into the logbook, idempotently, file archived
//
// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what
// keeps it small. Accepted trade-off: while offline you don't see other ops'
// QSOs, and worked-before doesn't know about your own pending ones (they're
// listed separately in the UI instead).
// offlineReplayEvery is how often we re-probe the database while QSOs are
// waiting. Short enough to feel automatic, long enough not to hammer a dead host.
const offlineReplayEvery = 15 * time.Second
// OfflineStatus is what the UI shows: are we parked, and how much is waiting.
type OfflineStatus struct {
Offline bool `json:"offline"` // last write failed because the DB was unreachable
Pending int `json:"pending"` // QSOs sitting in the outbox
Path string `json:"path"` // where the outbox physically is
}
// queueOffline parks a QSO that couldn't reach the database. Returns false when
// queueing isn't applicable (local SQLite can't "go offline") or the write to the
// outbox itself failed — in which case the caller MUST surface the original error
// rather than pretend the QSO was saved.
func (a *App) queueOffline(q qso.QSO, cause error) bool {
if a.offlineQ == nil || !db.IsMySQL() {
return false
}
qid, err := a.offlineQ.Append(q)
if err != nil {
// The net itself tore: do NOT claim success.
applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause)
return false
}
applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid)
a.offlineMode = true
a.emitOfflineStatus()
return true
}
// emitOfflineStatus pushes the banner state to the UI.
func (a *App) emitOfflineStatus() {
if a.ctx == nil || a.offlineQ == nil {
return
}
wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus())
}
// GetOfflineStatus reports whether we're parked and how many QSOs are waiting.
func (a *App) GetOfflineStatus() OfflineStatus {
if a.offlineQ == nil {
return OfflineStatus{}
}
n := a.offlineQ.Count()
return OfflineStatus{
Offline: a.offlineMode && n > 0,
Pending: n,
Path: a.offlineQ.Path(),
}
}
// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE
// what they logged while the database was down instead of flying blind.
func (a *App) GetPendingQSOs() ([]qso.QSO, error) {
if a.offlineQ == nil {
return nil, nil
}
return a.offlineQ.Pending()
}
// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of
// waiting for the next tick.
func (a *App) RetryOfflineSync() (int, error) {
return a.replayOfflineQueue()
}
// offlineReplayLoop re-probes the database while QSOs are waiting and replays
// them the moment it answers.
func (a *App) offlineReplayLoop() {
t := time.NewTicker(offlineReplayEvery)
defer t.Stop()
for range t.C {
if a.offlineQ == nil || a.offlineQ.Count() == 0 {
continue
}
if a.logDb == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := a.logDb.PingContext(ctx)
cancel()
if err != nil {
continue // still down — keep waiting, the QSOs are safe on disk
}
if n, rerr := a.replayOfflineQueue(); rerr != nil {
applog.Printf("offline: replay failed: %v", rerr)
} else if n > 0 {
applog.Printf("offline: replayed %d QSO(s) into the logbook", n)
}
}
}
// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each
// parked QSO carries a queue id, and one already present in the log is skipped —
// so a crash between "inserted" and "removed from the file" can never duplicate a
// contact. The file is archived BEFORE being cleared.
func (a *App) replayOfflineQueue() (int, error) {
if a.offlineQ == nil || a.qso == nil {
return 0, nil
}
pending, err := a.offlineQ.Pending()
if err != nil {
return 0, err
}
if len(pending) == 0 {
a.offlineMode = false
a.emitOfflineStatus()
return 0, nil
}
imported := 0
var failed []qso.QSO
for _, q := range pending {
qid := ""
if q.Extras != nil {
qid = q.Extras[qso.OfflineQueueKey]
}
// Idempotency guard: already replayed on an earlier (interrupted) pass?
if qid != "" {
if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists {
imported++ // it IS in the log — treat as done, drop from the outbox
continue
} else if e != nil && db.IsConnLost(e) {
failed = append(failed, q) // DB went away again mid-replay
continue
}
}
if _, e := a.qso.Add(a.ctx, q); e != nil {
applog.Printf("offline: replay of %s failed: %v", q.Callsign, e)
failed = append(failed, q)
continue
}
imported++
}
// Archive the outbox as it was, then keep only what still failed.
if imported > 0 {
if dst, e := a.offlineQ.Archive(); e != nil {
applog.Printf("offline: archive failed: %v", e)
} else if dst != "" {
applog.Printf("offline: outbox archived to %s", dst)
}
}
if e := a.offlineQ.Rewrite(failed); e != nil {
return imported, fmt.Errorf("offline: rewrite outbox: %w", e)
}
a.offlineMode = len(failed) > 0
a.emitOfflineStatus()
if imported > 0 && a.ctx != nil {
wruntime.EventsEmit(a.ctx, "logbook:changed")
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported))
}
return imported, nil
}
// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT
// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the
// very thing this design avoids.
func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }