From b0a106415fc215d68596b1b555f3089f1e48d0f4 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 17 Mar 2026 18:34:28 +0300 Subject: [PATCH] Make sync status non-blocking --- bible-local/02-architecture.md | 9 ++++ internal/db/connection.go | 16 ++++++ internal/handlers/sync.go | 87 +++++++++++-------------------- internal/services/sync/service.go | 44 +++++++++------- 4 files changed, 78 insertions(+), 78 deletions(-) diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index f6d79d5..5af8428 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -62,6 +62,15 @@ Rules: - BOM updates must use version-aware save flow, not a direct SQL field update; - current revision pointer must be recoverable if legacy or damaged rows are found locally. +## Sync UX + +UI-facing sync status must never block on live MariaDB calls. + +Rules: +- navbar sync indicator and sync info modal read only local cached state from SQLite/app settings; +- background/manual sync may talk to MariaDB, but polling endpoints must stay fast even on slow or broken connections; +- any MariaDB timeout/invalid-connection during sync must invalidate the cached remote handle immediately so UI stops treating the connection as healthy. + ## Naming collisions UI-driven rename and copy flows use one suffix convention for conflicts. diff --git a/internal/db/connection.go b/internal/db/connection.go index 9da37b3..a3cc2c1 100644 --- a/internal/db/connection.go +++ b/internal/db/connection.go @@ -238,6 +238,22 @@ func (cm *ConnectionManager) Disconnect() { cm.lastError = nil } +// MarkOffline closes the current connection and preserves the last observed error. +func (cm *ConnectionManager) MarkOffline(err error) { + cm.mu.Lock() + defer cm.mu.Unlock() + + if cm.db != nil { + sqlDB, dbErr := cm.db.DB() + if dbErr == nil { + sqlDB.Close() + } + } + cm.db = nil + cm.lastError = err + cm.lastCheck = time.Now() +} + // GetLastError returns the last connection error (thread-safe) func (cm *ConnectionManager) GetLastError() error { cm.mu.RLock() diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index b0e5eed..ee68dcf 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -78,41 +78,18 @@ type SyncReadinessResponse struct { // GetStatus returns current sync status // GET /api/sync/status func (h *SyncHandler) GetStatus(c *gin.Context) { - // Check online status by pinging MariaDB - isOnline := h.checkOnline() - - // Get sync times + connStatus := h.connMgr.GetStatus() + isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == "" lastComponentSync := h.localDB.GetComponentSyncTime() lastPricelistSync := h.localDB.GetLastSyncTime() - - // Get counts componentsCount := h.localDB.CountLocalComponents() pricelistsCount := h.localDB.CountLocalPricelists() - - // Get server pricelist count if online - serverPricelists := 0 - needPricelistSync := false lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt() lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() - hasIncompleteServerSync := false - knownServerChangesMissing := false - if isOnline { - status, err := h.syncService.GetStatus() - if err == nil { - serverPricelists = status.ServerPricelists - needPricelistSync = status.NeedsSync - lastPricelistAttemptAt = status.LastAttemptAt - lastPricelistSyncStatus = status.LastSyncStatus - lastPricelistSyncError = status.LastSyncError - hasIncompleteServerSync = status.IncompleteServerSync - knownServerChangesMissing = status.KnownServerChangesMiss - } - } - - // Check if component sync is needed (older than 24 hours) + hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed") needComponentSync := h.localDB.NeedComponentSync(24) - readiness := h.getReadinessCached(10 * time.Second) + readiness := h.getReadinessLocal() c.JSON(http.StatusOK, SyncStatusResponse{ LastComponentSync: lastComponentSync, @@ -120,14 +97,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) { LastPricelistAttemptAt: lastPricelistAttemptAt, LastPricelistSyncStatus: lastPricelistSyncStatus, LastPricelistSyncError: lastPricelistSyncError, - HasIncompleteServerSync: hasIncompleteServerSync, - KnownServerChangesMiss: knownServerChangesMissing, + HasIncompleteServerSync: hasFailedSync, + KnownServerChangesMiss: hasFailedSync, IsOnline: isOnline, ComponentsCount: componentsCount, PricelistsCount: pricelistsCount, - ServerPricelists: serverPricelists, + ServerPricelists: 0, NeedComponentSync: needComponentSync, - NeedPricelistSync: needPricelistSync, + NeedPricelistSync: lastPricelistSync == nil || hasFailedSync, Readiness: readiness, }) } @@ -537,8 +514,8 @@ type SyncError struct { // GetInfo returns sync information for modal // GET /api/sync/info func (h *SyncHandler) GetInfo(c *gin.Context) { - // Check online status by pinging MariaDB - isOnline := h.checkOnline() + connStatus := h.connMgr.GetStatus() + isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == "" // Get DB connection info var dbHost, dbUser, dbName string @@ -553,8 +530,9 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt() lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() - needPricelistSync := false - hasIncompleteServerSync := false + hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed") + needPricelistSync := lastPricelistSync == nil || hasFailedSync + hasIncompleteServerSync := hasFailedSync // Get local counts configCount := h.localDB.CountConfigurations() @@ -587,16 +565,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { syncErrors = syncErrors[:10] } - readiness := h.getReadinessCached(10 * time.Second) - if isOnline { - if status, err := h.syncService.GetStatus(); err == nil { - lastPricelistAttemptAt = status.LastAttemptAt - lastPricelistSyncStatus = status.LastSyncStatus - lastPricelistSyncError = status.LastSyncError - needPricelistSync = status.NeedsSync - hasIncompleteServerSync = status.IncompleteServerSync - } - } + readiness := h.getReadinessLocal() c.JSON(http.StatusOK, SyncInfoResponse{ DBHost: dbHost, @@ -671,19 +640,12 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { // Get pending count pendingCount := h.localDB.GetPendingCount() - readiness := h.getReadinessCached(10 * time.Second) + readiness := h.getReadinessLocal() isBlocked := readiness != nil && readiness.Blocked lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() - hasIncompleteServerSync := false - if !isOffline { - if status, err := h.syncService.GetStatus(); err == nil { - lastPricelistSyncStatus = status.LastSyncStatus - lastPricelistSyncError = status.LastSyncError - hasIncompleteServerSync = status.IncompleteServerSync - } - } hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed") + hasIncompleteServerSync := hasFailedSync slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked) @@ -721,20 +683,29 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { } } -func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness { +func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness { h.readinessMu.Lock() - if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge { + if h.readinessCached != nil && time.Since(h.readinessCachedAt) < 10*time.Second { cached := *h.readinessCached h.readinessMu.Unlock() return &cached } h.readinessMu.Unlock() - readiness, err := h.syncService.GetReadiness() - if err != nil && readiness == nil { + state, err := h.localDB.GetSyncGuardState() + if err != nil || state == nil { return nil } + readiness := &sync.SyncReadiness{ + Status: state.Status, + Blocked: state.Status == sync.ReadinessBlocked, + ReasonCode: state.ReasonCode, + ReasonText: state.ReasonText, + RequiredMinAppVersion: state.RequiredMinAppVersion, + LastCheckedAt: state.LastCheckedAt, + } + h.readinessMu.Lock() h.readinessCached = readiness h.readinessCachedAt = time.Now() diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 82ae094..a4139b1 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -248,35 +248,20 @@ func (s *Service) GetStatus() (*SyncStatus, error) { lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt() lastSyncStatus := s.localDB.GetLastPricelistSyncStatus() lastSyncError := s.localDB.GetLastPricelistSyncError() - - // Count server pricelists (only if already connected, don't reconnect) - serverCount := 0 - connStatus := s.getConnectionStatus() - if connStatus.IsConnected { - if mariaDB, err := s.getDB(); err == nil && mariaDB != nil { - pricelistRepo := repository.NewPricelistRepository(mariaDB) - activeCount, err := pricelistRepo.CountActive() - if err == nil { - serverCount = int(activeCount) - } - } - } - - // Count local pricelists localCount := s.localDB.CountLocalPricelists() - - needsSync, _ := s.NeedSync() + hasFailedSync := strings.EqualFold(lastSyncStatus, "failed") + needsSync := lastSync == nil || hasFailedSync return &SyncStatus{ LastSyncAt: lastSync, LastAttemptAt: lastAttempt, LastSyncStatus: lastSyncStatus, LastSyncError: lastSyncError, - ServerPricelists: serverCount, + ServerPricelists: 0, LocalPricelists: int(localCount), NeedsSync: needsSync, - IncompleteServerSync: needsSync && strings.EqualFold(lastSyncStatus, "failed"), - KnownServerChangesMiss: needsSync, + IncompleteServerSync: hasFailedSync, + KnownServerChangesMiss: hasFailedSync, }, nil } @@ -447,11 +432,29 @@ func (s *Service) recordPricelistSyncFailure(syncErr error) { if s.localDB == nil || syncErr == nil { return } + s.markConnectionBroken(syncErr) if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil { slog.Warn("failed to persist pricelist sync failure state", "error", err) } } +func (s *Service) markConnectionBroken(err error) { + if err == nil || s.connMgr == nil { + return + } + + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "i/o timeout"), + strings.Contains(msg, "invalid connection"), + strings.Contains(msg, "bad connection"), + strings.Contains(msg, "connection reset"), + strings.Contains(msg, "broken pipe"), + strings.Contains(msg, "unexpected eof"): + s.connMgr.MarkOffline(err) + } +} + func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) { if localPL == nil { return 0, fmt.Errorf("local pricelist is nil") @@ -990,6 +993,7 @@ func (s *Service) PushPendingChanges() (int, error) { for _, change := range sortedChanges { err := s.pushSingleChange(&change) if err != nil { + s.markConnectionBroken(err) slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err) // Increment attempts s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())