From d9b7e48e833a8ec36efa3bf4780773e976e0ee13 Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 21 Jul 2026 11:18:36 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20UDP-logged=20QSOs=20fall=20back=20to=20t?= =?UTF-8?q?he=20offline=20outbox=20on=20DB=20failure=20(were=20silently=20?= =?UTF-8?q?lost);=20speed=20up=20MySQL=20connect=20(batch=20migration=20ch?= =?UTF-8?q?ecks=20into=201=20query,=20connect=20direct-to-DB=20first)=20?= =?UTF-8?q?=E2=80=94=20was=20~40s=20on=20a=20high-latency=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.go | 12 ++++++++++++ internal/db/db.go | 26 +++++++++++++++++++------- internal/db/mysql.go | 25 ++++++++++++++++++++----- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/app.go b/app.go index 2c5947f..d3e0c93 100644 --- a/app.go +++ b/app.go @@ -9623,6 +9623,14 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { id, err := a.qso.Add(a.ctx, q) if err != nil { + // DB UNREACHABLE (drop / timeout on a slow-or-broken MySQL) — park the QSO + // in the offline outbox rather than lose it, exactly like the manual log + // path. Without this, a laggy shared MySQL silently dropped UDP-logged QSOs + // from a multi-stream MSHV. Returns -1 so the caller treats it as "saved, + // waiting to sync", not a failure. + if db.IsConnLost(err) && a.queueOffline(q, err) { + return -1, nil + } return 0, fmt.Errorf("insert qso: %w", err) } q.ID = id @@ -9633,6 +9641,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { a.extsvc.OnQSOLogged(id) } a.maybeAutoSendEQSL(q) + // A successful write means the link is healthy again — flush anything parked. + if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 { + go func() { _, _ = a.replayOfflineQueue() }() + } return id, nil } diff --git a/internal/db/db.go b/internal/db/db.go index 0951805..bfaa8c6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error { } sort.Strings(names) - for _, name := range names { - var dummy string - err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy) - if err == nil { - continue // already applied + // Fetch every applied migration name in ONE query rather than one round-trip + // per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the + // connect time (each RTT × migration count) — the whole logbook connect could + // take tens of seconds. One SELECT + an in-memory set is effectively instant. + applied := map[string]bool{} + if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil { + for rows.Next() { + var n string + if rows.Scan(&n) == nil { + applied[n] = true + } } - if err != sql.ErrNoRows { - return fmt.Errorf("check migration %s: %w", name, err) + rows.Close() + } else { + return fmt.Errorf("read applied migrations: %w", err) + } + + for _, name := range names { + if applied[name] { + continue // already applied } content, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { diff --git a/internal/db/mysql.go b/internal/db/mysql.go index 7134e7b..3469c80 100644 --- a/internal/db/mysql.go +++ b/internal/db/mysql.go @@ -137,10 +137,6 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) { if !validDBIdent(name) { return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name) } - // Ensure the database exists (connect server-level first). - if err := PingMySQL(c); err != nil { - return nil, err - } conn, err := sql.Open("mysql", c.dsn()) if err != nil { return nil, fmt.Errorf("open mysql: %w", err) @@ -157,9 +153,28 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) { // (surfacing as "Unknown database"). Idle connections are still recycled // after 90s, and the driver retries stale pooled connections. conn.SetConnMaxLifetime(0) + // Connect DIRECTLY to the database first. Only if that fails — the DB doesn't + // exist yet (first-time setup) or the server is unreachable — do we pay for the + // server-level connect + CREATE DATABASE (two more handshakes). On a normal + // startup against an existing DB this halves the connection round-trips, which + // matters a lot on a high-latency remote MySQL (each handshake is several RTTs). if err := conn.Ping(); err != nil { _ = conn.Close() - return nil, fmt.Errorf("connect to %s: %w", name, err) + if perr := PingMySQL(c); perr != nil { + return nil, perr // creates the DB (or returns a clear "cannot create" error) + } + conn, err = sql.Open("mysql", c.dsn()) + if err != nil { + return nil, fmt.Errorf("open mysql: %w", err) + } + conn.SetMaxOpenConns(50) + conn.SetMaxIdleConns(10) + conn.SetConnMaxIdleTime(90 * time.Second) + conn.SetConnMaxLifetime(0) + if err := conn.Ping(); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("connect to %s: %w", name, err) + } } // Set the dialect before migrating so the runner takes the MySQL path // (per-statement, idempotent) rather than the SQLite transaction path.