Artifact 92da28a0c5d1b016a03b308422dc1d24fa37171b65ba209e797d126ae04162b3:
- File
internal/sync/session.go
— part of check-in
[b2928e9629]
at
2026-07-27 20:03:00
on branch trunk
— fix(sync): retry a dropped exchange round instead of aborting the clone (#185) (#196)
A clone of a large repository is a conversation of dozens of rounds spanning minutes. A single transient disconnect anywhere in it aborted the whole operation with "http transport: Post ...: EOF" and produced no store at all: there was no retry, no reconnect, and minutes of transfer were discarded for one dropped packet. Reported failures landed at rounds 6, 11 and 18 across otherwise identical runs, which is the signature of independent random drops rather than a deterministic defect.
Both round loops now send a failed round again after a bounded exponential backoff — four attempts, 250 ms doubling to a 2 s cap — but only when the failure was the network dropping the exchange. The classification is an allow-list, not a deny-list: a reply that arrived whole and would not decode, a card the peer rejected, a 4xx, or an unrecognised error from a Transport this package does not know all still fail on their first occurrence. Retrying those would turn a reproducible defect into an intermittent one, which is worse than the failure retrying exists to prevent.
Telling the two apart cannot be done reliably after the fact — a truncated zlib body and a truncated wire read both surface as io.ErrUnexpectedEOF — so the transports tag their own wire stages via the optional TransportFault interface, in the same shape as FramedTransport. Non-2xx replies are now reported as such instead of being handed to the decoder, which also stops a peer's outage from being misreported as a framing defect.
The caller's context governs throughout: a cancellation or deadline aborts immediately, including during a backoff wait, so retrying cannot extend an operation past the bound its caller set (#166, #167, #183).
Resuming a partially-completed clone across process exit is deliberately not included: it needs the clone cursor persisted, a staleness policy for the server's snapshot bound, and a separate entry point, and within-session retry already preserves the expensive part. (user: [email protected], size: 10890) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/uv" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" ) const ( // DefaultMaxSend is the default byte budget per round for file payloads. DefaultMaxSend = 250000 // MaxRounds caps the number of sync rounds before giving up. MaxRounds = 100 // MaxGimmeBase is the minimum gimme cap per round. MaxGimmeBase = 200 ) // BuggifyChecker is an optional fault injection interface. // Pass nil in production — implementations should be nil-safe. type BuggifyChecker interface { Check(site string, probability float64) bool } // SyncOpts configures a sync session. type SyncOpts struct { Push, Pull bool // enable push/pull directions (at least one must be true) ProjectCode string // repo's project-code — sent to identify the repository ServerCode string // server-code from a previous session (cookie-like, speeds up sync) User string // login user — empty means unauthenticated "nobody" sync Password string // login password PeerID string // identifies this leaf agent instance (for observability) MaxSend int // byte budget per round for file payloads (0 defaults to DefaultMaxSend) UV bool // enable unversioned file sync (wiki, forum, attachments) XTableSync bool // enable extension table sync (peer_registry, etc.) — only between EdgeSync peers Private bool // enable private artifact sync Env *simio.Env // nil defaults to RealEnv Buggify BuggifyChecker // nil in production — used by DST for fault injection Observer Observer // nil defaults to no-op CkinLock *CkinLockReq // nil = no lock requested ContentCache *content.Cache // nil = no caching (every Expand walks the full delta chain) } // CkinLockReq requests a server-side check-in lock. type CkinLockReq struct { ParentUUID string ClientID string } // SyncResult reports what happened during a sync. type SyncResult struct { Rounds, FilesSent, FilesRecvd int UVFilesSent, UVFilesRecvd int UVGimmesSent int BytesSent, BytesRecvd int64 ArtifactsLinked int Errors []string CkinLockFail *CkinLockFail // nil = no conflict } // session holds the mutable state of a running sync. type session struct { repo *repo.Repo env *simio.Env opts SyncOpts result SyncResult cookie string remoteHas map[string]bool phantoms map[string]bool pendingSend map[string]bool filesRecvdLastRound int igotSentThisRound int maxSend int phantomAge map[string]int // UUID -> consecutive rounds gimme'd without delivery uvHashSent bool uvPushOK bool uvPullOnly bool uvToSend map[string]bool // name -> true=full content, false=mtime-only uvGimmes map[string]bool nUvGimmeSent int nUvFileRcvd int nGimmeRcvd int // cumulative gimmes received across all rounds nextIsPrivate bool // true when a PrivateCard precedes the next file/cfile roundStats RoundStats xTableHashSent map[string]bool // table -> true if xtable-hash pragma sent xTableGimmes map[string]map[string]bool // table -> pkHash -> true xTableToSend map[string]map[string]bool // table -> pkHash -> true xTableCache map[string]*repo.TableDef // cached table defs, lazily loaded dephantomizeHook func(libfossil.FslID) // called after phantom→real transition cache *content.Cache // nil = passthrough to content.Expand } func newSession(r *repo.Repo, opts SyncOpts) *session { if r == nil { panic("sync.newSession: r must not be nil") } ms := opts.MaxSend if ms <= 0 { ms = DefaultMaxSend } env := opts.Env if env == nil { env = simio.RealEnv() } s := &session{ repo: r, env: env, opts: opts, maxSend: ms, remoteHas: make(map[string]bool), phantoms: make(map[string]bool), pendingSend: make(map[string]bool), phantomAge: make(map[string]int), xTableHashSent: make(map[string]bool), xTableGimmes: make(map[string]map[string]bool), xTableToSend: make(map[string]map[string]bool), cache: opts.ContentCache, } // Pre-populate uvToSend with all local non-tombstone UV files. if opts.UV { if err := uv.EnsureSchema(r.DB()); err != nil { panic(fmt.Sprintf("sync.newSession: uv.EnsureSchema: %v", err)) } entries, err := uv.List(r.DB()) if err != nil { panic(fmt.Sprintf("sync.newSession: uv.List: %v", err)) } s.uvToSend = make(map[string]bool) s.uvGimmes = make(map[string]bool) for _, e := range entries { // Include both live files and tombstones so deletions propagate. s.uvToSend[e.Name] = true } } return s } // getXTableDef returns a cached table definition, loading all tables on first miss. func (s *session) getXTableDef(tableName string) (*repo.TableDef, error) { if s.xTableCache != nil { if def, ok := s.xTableCache[tableName]; ok { return def, nil } } // Cache miss — load all tables. tables, err := repo.ListSyncedTables(s.repo.DB()) if err != nil { return nil, err } s.xTableCache = make(map[string]*repo.TableDef) for _, info := range tables { d := info.Def s.xTableCache[info.Name] = &d } if def, ok := s.xTableCache[tableName]; ok { return def, nil } return nil, nil } // Sync runs the client sync loop against the given transport. // It returns once the protocol has converged or a fatal error occurs. func Sync(ctx context.Context, r *repo.Repo, t Transport, opts SyncOpts) (result *SyncResult, err error) { if r == nil { panic("sync.Sync: r must not be nil") } if t == nil { panic("sync.Sync: t must not be nil") } defer func() { if err == nil && result == nil { panic("sync.Sync: result must not be nil on success") } }() obs := resolveObserver(opts.Observer) s := newSession(r, opts) ctx = obs.Started(ctx, SessionStart{ Operation: "sync", Push: opts.Push, Pull: opts.Pull, UV: opts.UV, ProjectCode: opts.ProjectCode, PeerID: opts.PeerID, }) for cycle := 0; ; cycle++ { select { case <-ctx.Done(): obs.Completed(ctx, sessionEndFromSync(&s.result, opts.ProjectCode), ctx.Err()) return &s.result, ctx.Err() default: } if cycle >= MaxRounds { err := fmt.Errorf("sync: exceeded %d rounds", MaxRounds) obs.Completed(ctx, sessionEndFromSync(&s.result, opts.ProjectCode), err) return &s.result, err } roundCtx := obs.RoundStarted(ctx, cycle) s.roundStats = RoundStats{} req, err := s.buildRequest(cycle) if err != nil { obs.Error(roundCtx, err) obs.RoundCompleted(roundCtx, cycle, s.roundStats) obs.Completed(ctx, sessionEndFromSync(&s.result, opts.ProjectCode), err) return &s.result, fmt.Errorf("sync: buildRequest round %d: %w", cycle, err) } resp, err := exchangeWithRetry(ctx, t, req, s.env.Clock, obs) if err != nil { obs.Error(roundCtx, err) obs.RoundCompleted(roundCtx, cycle, s.roundStats) obs.Completed(ctx, sessionEndFromSync(&s.result, opts.ProjectCode), err) return &s.result, fmt.Errorf("sync: exchange round %d: %w", cycle, err) } sentBefore := s.result.FilesSent recvdBefore := s.result.FilesRecvd done, err := s.processResponse(ctx, resp) s.roundStats.FilesSent = s.result.FilesSent - sentBefore s.roundStats.FilesReceived = s.result.FilesRecvd - recvdBefore s.result.BytesSent += s.roundStats.BytesSent s.result.BytesRecvd += s.roundStats.BytesReceived if err != nil { obs.Error(roundCtx, err) obs.RoundCompleted(roundCtx, cycle, s.roundStats) obs.Completed(ctx, sessionEndFromSync(&s.result, opts.ProjectCode), err) return &s.result, fmt.Errorf("sync: processResponse round %d: %w", cycle, err) } s.result.Rounds = cycle + 1 obs.RoundCompleted(roundCtx, cycle, s.roundStats) if done { break } } // Auto-crosslink after convergence. Context-aware for the same reason the // clone's sweep is (#120): this runs after the round loop, so it has no // round boundary of its own at which a cancelled context could be noticed. linked, xlinkErr := manifest.CrosslinkContext(ctx, s.repo) if xlinkErr != nil { obs.Completed(ctx, sessionEndFromSync(&s.result, opts.ProjectCode), xlinkErr) return &s.result, fmt.Errorf("sync: crosslink: %w", xlinkErr) } s.result.ArtifactsLinked = linked obs.Completed(ctx, sessionEndFromSync(&s.result, opts.ProjectCode), nil) return &s.result, nil } // sessionEndFromSync builds a SessionEnd from a SyncResult. func sessionEndFromSync(r *SyncResult, projectCode string) SessionEnd { return SessionEnd{ Operation: "sync", Rounds: r.Rounds, FilesSent: r.FilesSent, FilesRecvd: r.FilesRecvd, UVFilesSent: r.UVFilesSent, UVFilesRecvd: r.UVFilesRecvd, UVGimmesSent: r.UVGimmesSent, BytesSent: r.BytesSent, BytesRecvd: r.BytesRecvd, ProjectCode: projectCode, Errors: r.Errors, } } // cardSummary returns a short string describing a card (for trace logging). // codePrefix returns the first 8 characters of a code for trace logging, // or the full string when shorter. Avoids slice-out-of-range panics when // ServerCode/ProjectCode are empty (the no-codes-negotiated form). func codePrefix(s string) string { if len(s) >= 8 { return s[:8] } return s } func cardSummary(c xfer.Card) string { switch v := c.(type) { case *xfer.PullCard: return fmt.Sprintf("srv=%s proj=%s", codePrefix(v.ServerCode), codePrefix(v.ProjectCode)) case *xfer.PushCard: return fmt.Sprintf("srv=%s proj=%s", codePrefix(v.ServerCode), codePrefix(v.ProjectCode)) case *xfer.IGotCard: return v.UUID[:16] + "..." case *xfer.GimmeCard: return v.UUID[:16] + "..." case *xfer.FileCard: return fmt.Sprintf("uuid=%s len=%d delta=%s", v.UUID[:16], len(v.Content), v.DeltaSrc) case *xfer.CFileCard: return fmt.Sprintf("uuid=%s len=%d delta=%s", v.UUID[:16], len(v.Content), v.DeltaSrc) case *xfer.LoginCard: return fmt.Sprintf("user=%s", v.User) case *xfer.CookieCard: return v.Value case *xfer.ErrorCard: return v.Message case *xfer.MessageCard: return v.Message case *xfer.PragmaCard: return v.Name default: return "" } } // cardsByType is a helper that filters cards by type for testing/debugging. func cardsByType(msg *xfer.Message, ct xfer.CardType) []xfer.Card { var out []xfer.Card for _, c := range msg.Cards { if c.Type() == ct { out = append(out, c) } } return out }