fix: while connected to MySQL if internet was lost no qso would be logged anymore
This commit is contained in:
+191
@@ -0,0 +1,191 @@
|
||||
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) }
|
||||
Reference in New Issue
Block a user