From 7ae804d2d3d43140255f0c27d23a8bd0b75ce434 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Mon, 30 Mar 2026 12:34:57 +0300 Subject: [PATCH] fix: prevent config creation hang on pricelist sync SyncPricelistsIfNeeded was called synchronously in Create(), blocking the HTTP response for several seconds while pricelist data was fetched. Users clicking multiple times caused 6+ duplicate configurations. - Run SyncPricelistsIfNeeded in a goroutine so Create() returns immediately - Add TryLock mutex to SyncPricelistsIfNeeded to skip concurrent calls Co-Authored-By: Claude Sonnet 4.6 --- internal/services/local_configuration.go | 10 ++++++---- internal/services/sync/service.go | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index e1bf6b3..5ea9b7d 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -49,11 +49,13 @@ func NewLocalConfigurationService( // Create creates a new configuration in local SQLite and queues it for sync func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { - // If online, check for new pricelists first + // If online, trigger pricelist sync in the background — do not block config creation if s.isOnline() { - if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { - // Log but don't fail - we can still use local pricelists - } + go func() { + if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { + // Log but don't fail - we can still use local pricelists + } + }() } projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID) diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index a4139b1..96d7a1a 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -7,6 +7,7 @@ import ( "log/slog" "sort" "strings" + "sync" "time" "git.mchus.pro/mchus/quoteforge/internal/appmeta" @@ -22,9 +23,10 @@ var ErrOffline = errors.New("database is offline") // Service handles synchronization between MariaDB and local SQLite type Service struct { - connMgr *db.ConnectionManager - localDB *localdb.LocalDB - directDB *gorm.DB + connMgr *db.ConnectionManager + localDB *localdb.LocalDB + directDB *gorm.DB + pricelistMu sync.Mutex // prevents concurrent pricelist syncs } // NewService creates a new sync service @@ -939,9 +941,15 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local return localPL, nil } -// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed -// This should be called before creating a new configuration when online +// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed. +// If a sync is already in progress, returns immediately without blocking. func (s *Service) SyncPricelistsIfNeeded() error { + if !s.pricelistMu.TryLock() { + slog.Debug("pricelist sync already in progress, skipping") + return nil + } + defer s.pricelistMu.Unlock() + needSync, err := s.NeedSync() if err != nil { slog.Warn("failed to check if sync needed", "error", err)