Make sync status non-blocking

This commit is contained in:
Mikhail Chusavitin
2026-03-17 18:34:28 +03:00
parent a054fc7564
commit b0a106415f
4 changed files with 78 additions and 78 deletions
+9
View File
@@ -62,6 +62,15 @@ Rules:
- BOM updates must use version-aware save flow, not a direct SQL field update; - 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. - 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 ## Naming collisions
UI-driven rename and copy flows use one suffix convention for conflicts. UI-driven rename and copy flows use one suffix convention for conflicts.
+16
View File
@@ -238,6 +238,22 @@ func (cm *ConnectionManager) Disconnect() {
cm.lastError = nil 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) // GetLastError returns the last connection error (thread-safe)
func (cm *ConnectionManager) GetLastError() error { func (cm *ConnectionManager) GetLastError() error {
cm.mu.RLock() cm.mu.RLock()
+29 -58
View File
@@ -78,41 +78,18 @@ type SyncReadinessResponse struct {
// GetStatus returns current sync status // GetStatus returns current sync status
// GET /api/sync/status // GET /api/sync/status
func (h *SyncHandler) GetStatus(c *gin.Context) { func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check online status by pinging MariaDB connStatus := h.connMgr.GetStatus()
isOnline := h.checkOnline() isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
// Get sync times
lastComponentSync := h.localDB.GetComponentSyncTime() lastComponentSync := h.localDB.GetComponentSyncTime()
lastPricelistSync := h.localDB.GetLastSyncTime() lastPricelistSync := h.localDB.GetLastSyncTime()
// Get counts
componentsCount := h.localDB.CountLocalComponents() componentsCount := h.localDB.CountLocalComponents()
pricelistsCount := h.localDB.CountLocalPricelists() pricelistsCount := h.localDB.CountLocalPricelists()
// Get server pricelist count if online
serverPricelists := 0
needPricelistSync := false
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt() lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
hasIncompleteServerSync := false hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
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)
needComponentSync := h.localDB.NeedComponentSync(24) needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessCached(10 * time.Second) readiness := h.getReadinessLocal()
c.JSON(http.StatusOK, SyncStatusResponse{ c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync, LastComponentSync: lastComponentSync,
@@ -120,14 +97,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
LastPricelistAttemptAt: lastPricelistAttemptAt, LastPricelistAttemptAt: lastPricelistAttemptAt,
LastPricelistSyncStatus: lastPricelistSyncStatus, LastPricelistSyncStatus: lastPricelistSyncStatus,
LastPricelistSyncError: lastPricelistSyncError, LastPricelistSyncError: lastPricelistSyncError,
HasIncompleteServerSync: hasIncompleteServerSync, HasIncompleteServerSync: hasFailedSync,
KnownServerChangesMiss: knownServerChangesMissing, KnownServerChangesMiss: hasFailedSync,
IsOnline: isOnline, IsOnline: isOnline,
ComponentsCount: componentsCount, ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount, PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists, ServerPricelists: 0,
NeedComponentSync: needComponentSync, NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync, NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
Readiness: readiness, Readiness: readiness,
}) })
} }
@@ -537,8 +514,8 @@ type SyncError struct {
// GetInfo returns sync information for modal // GetInfo returns sync information for modal
// GET /api/sync/info // GET /api/sync/info
func (h *SyncHandler) GetInfo(c *gin.Context) { func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB connStatus := h.connMgr.GetStatus()
isOnline := h.checkOnline() isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
// Get DB connection info // Get DB connection info
var dbHost, dbUser, dbName string var dbHost, dbUser, dbName string
@@ -553,8 +530,9 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt() lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
needPricelistSync := false hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
hasIncompleteServerSync := false needPricelistSync := lastPricelistSync == nil || hasFailedSync
hasIncompleteServerSync := hasFailedSync
// Get local counts // Get local counts
configCount := h.localDB.CountConfigurations() configCount := h.localDB.CountConfigurations()
@@ -587,16 +565,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
syncErrors = syncErrors[:10] syncErrors = syncErrors[:10]
} }
readiness := h.getReadinessCached(10 * time.Second) readiness := h.getReadinessLocal()
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
}
}
c.JSON(http.StatusOK, SyncInfoResponse{ c.JSON(http.StatusOK, SyncInfoResponse{
DBHost: dbHost, DBHost: dbHost,
@@ -671,19 +640,12 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count // Get pending count
pendingCount := h.localDB.GetPendingCount() pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessCached(10 * time.Second) readiness := h.getReadinessLocal()
isBlocked := readiness != nil && readiness.Blocked isBlocked := readiness != nil && readiness.Blocked
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() 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") hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
hasIncompleteServerSync := hasFailedSync
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked) 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() 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 cached := *h.readinessCached
h.readinessMu.Unlock() h.readinessMu.Unlock()
return &cached return &cached
} }
h.readinessMu.Unlock() h.readinessMu.Unlock()
readiness, err := h.syncService.GetReadiness() state, err := h.localDB.GetSyncGuardState()
if err != nil && readiness == nil { if err != nil || state == nil {
return 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.readinessMu.Lock()
h.readinessCached = readiness h.readinessCached = readiness
h.readinessCachedAt = time.Now() h.readinessCachedAt = time.Now()
+24 -20
View File
@@ -248,35 +248,20 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt() lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt()
lastSyncStatus := s.localDB.GetLastPricelistSyncStatus() lastSyncStatus := s.localDB.GetLastPricelistSyncStatus()
lastSyncError := s.localDB.GetLastPricelistSyncError() 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() localCount := s.localDB.CountLocalPricelists()
hasFailedSync := strings.EqualFold(lastSyncStatus, "failed")
needsSync, _ := s.NeedSync() needsSync := lastSync == nil || hasFailedSync
return &SyncStatus{ return &SyncStatus{
LastSyncAt: lastSync, LastSyncAt: lastSync,
LastAttemptAt: lastAttempt, LastAttemptAt: lastAttempt,
LastSyncStatus: lastSyncStatus, LastSyncStatus: lastSyncStatus,
LastSyncError: lastSyncError, LastSyncError: lastSyncError,
ServerPricelists: serverCount, ServerPricelists: 0,
LocalPricelists: int(localCount), LocalPricelists: int(localCount),
NeedsSync: needsSync, NeedsSync: needsSync,
IncompleteServerSync: needsSync && strings.EqualFold(lastSyncStatus, "failed"), IncompleteServerSync: hasFailedSync,
KnownServerChangesMiss: needsSync, KnownServerChangesMiss: hasFailedSync,
}, nil }, nil
} }
@@ -447,11 +432,29 @@ func (s *Service) recordPricelistSyncFailure(syncErr error) {
if s.localDB == nil || syncErr == nil { if s.localDB == nil || syncErr == nil {
return return
} }
s.markConnectionBroken(syncErr)
if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil { if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil {
slog.Warn("failed to persist pricelist sync failure state", "error", err) 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) { func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) {
if localPL == nil { if localPL == nil {
return 0, fmt.Errorf("local pricelist is nil") return 0, fmt.Errorf("local pricelist is nil")
@@ -990,6 +993,7 @@ func (s *Service) PushPendingChanges() (int, error) {
for _, change := range sortedChanges { for _, change := range sortedChanges {
err := s.pushSingleChange(&change) err := s.pushSingleChange(&change)
if err != nil { if err != nil {
s.markConnectionBroken(err)
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err) slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
// Increment attempts // Increment attempts
s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error()) s.localDB.IncrementPendingChangeAttempts(change.ID, err.Error())