Make sync status non-blocking
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user