fix: self-heal застрявших pending changes при broken project reference

- ensureConfigurationProject: если project не найден ни на сервере, ни локально
  (stale UUID после удаления), падаем в fallback «Без проекта» вместо вечной ошибки
- PushPendingChanges: автоматически вызывает RepairPendingChanges() перед циклом,
  чтобы локально-исправимые проблемы чинились до попытки отправки
- maxPendingChangeAttempts=20: после 20 неудачных попыток change считается
  unrecoverable и удаляется из очереди (логируется ERROR)
- pushSingleChange/pushConfigurationChange: unknown entity type / operation
  теперь дропается с warn вместо вечного error в цикле
- latestSyncErrorState: last_sync_error_text в qt_client_schema_state теперь
  содержит JSON-массив с type/uuid/op/attempts/error по всем застрявшим changes
  (до 20 штук) вместо текста только последней ошибки

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-06-16 17:28:07 +03:00
parent 0fc0366bb1
commit 6df262b8ee
2 changed files with 80 additions and 24 deletions

View File

@@ -1,6 +1,7 @@
package sync
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -320,14 +321,37 @@ func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
}
var pending localdb.PendingChange
var errored []localdb.PendingChange
if err := local.DB().
Where("TRIM(COALESCE(last_error, '')) <> ''").
Order("id DESC").
First(&pending).Error; err == nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
Limit(20).
Find(&errored).Error; err != nil || len(errored) == 0 {
return nil, nil
}
return nil, nil
type errorEntry struct {
Type string `json:"type"`
UUID string `json:"uuid"`
Op string `json:"op"`
Attempts int `json:"attempts"`
Error string `json:"error"`
}
entries := make([]errorEntry, 0, len(errored))
for _, ch := range errored {
entries = append(entries, errorEntry{
Type: ch.EntityType,
UUID: ch.EntityUUID,
Op: ch.Operation,
Attempts: ch.Attempts,
Error: strings.TrimSpace(ch.LastError),
})
}
detail, jsonErr := json.Marshal(entries)
if jsonErr != nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(errored[0].LastError))
}
return optionalString("PENDING_CHANGE_ERROR"), optionalString(string(detail))
}
func optionalString(value string) *string {