Skip to content

Commit ca1e2bf

Browse files
authored
Merge pull request #1824 from entireio/fix/refs-backfill-fetch-probe
fix(checkpoint): fetch a locally-missing ref before declaring a backfill target absent
2 parents 942a7fe + f5364e3 commit ca1e2bf

11 files changed

Lines changed: 642 additions & 50 deletions

File tree

cmd/entire/cli/checkpoint/open.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,11 @@ func buildMirrors(ctx context.Context, env OpenEnv, cfg *settings.CheckpointsCon
174174
return nil, fmt.Errorf("checkpoints.mirrors[%d]: backend type %q is already used by the primary or another mirror; each backend type may appear at most once", i, m.Type)
175175
}
176176
seen[m.Type] = true
177-
store, err := build(ctx, env, m.Type, m.Config)
177+
// Mirrors are best-effort write-only copies whose failures are logged
178+
// and dropped; never pay on-demand ref-fetch network probes for them.
179+
mirrorEnv := env
180+
mirrorEnv.RefFetcher = nil
181+
store, err := build(ctx, mirrorEnv, m.Type, m.Config)
178182
if err != nil {
179183
return nil, fmt.Errorf("checkpoints.mirrors[%d]: %w", i, err)
180184
}

cmd/entire/cli/checkpoint/persistent.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,6 +1745,9 @@ func (s *GitStore) backfillSummary(ctx context.Context, checkpointID id.Checkpoi
17451745
//
17461746
// Returns ErrCheckpointNotFound if the checkpoint doesn't exist.
17471747
func (s *GitStore) backfillTranscript(ctx context.Context, opts UpdateOptions) error {
1748+
if err := ctx.Err(); err != nil {
1749+
return err //nolint:wrapcheck // Propagating context cancellation
1750+
}
17481751
if opts.CheckpointID.IsEmpty() {
17491752
return errors.New("invalid update options: checkpoint ID is required")
17501753
}

cmd/entire/cli/checkpoint/refs_store.go

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"log/slog"
88
"os"
99
"strconv"
10+
"sync"
1011
"time"
1112

1213
"github.com/go-git/go-git/v6"
@@ -48,6 +49,17 @@ type gitRefsStore struct {
4849
blobFetcher BlobFetchFunc
4950
refFetcher RefFetchFunc
5051
remoteRefLister RemoteRefListFunc
52+
53+
// fetchFailureMu guards fetchFailure: the first transport-level ref-fetch
54+
// failure, memoized for the store's lifetime so a loop over N missing refs
55+
// (e.g. a stop hook finalizing every checkpoint of a turn) pays a dead —
56+
// or too-slow-for-the-budget — network once instead of N times. Genuine
57+
// remote absence is per-ref and
58+
// is never memoized. The memo never clears — safe because every
59+
// fetcher-wired store today is opened per command/hook invocation; a
60+
// long-lived fetcher-wired store would need an expiry before reusing this.
61+
fetchFailureMu sync.Mutex
62+
fetchFailure error
5163
}
5264

5365
// newGitRefsStore constructs the per-checkpoint-ref store for a repository.
@@ -116,8 +128,18 @@ func (s *gitRefsStore) Write(ctx context.Context, req WriteRequest) error {
116128
}
117129

118130
// refBase resolves a checkpoint ref's current tip commit (the parent for the
119-
// next write) and subtree object (the checkpoint's current contents). A missing
120-
// ref yields (ZeroHash, nil) so the next write becomes an orphan commit.
131+
// next write) and subtree object (the checkpoint's current contents) with a
132+
// LOCAL-ONLY lookup. A missing ref yields (ZeroHash, nil) so the next write
133+
// becomes an orphan commit — correct for creates, whose ref never exists yet
134+
// (locally or remotely); probing the remote would add a doomed round-trip to
135+
// every condensation and, with a fetcher configured, fail offline writes.
136+
// Backfills, which target an existing checkpoint, use refBaseForBackfill
137+
// instead. Migration (migrate.go) also uses refBase deliberately: it imports
138+
// from the LOCAL v1 branch and must never probe the remote, even though its
139+
// target ref may already exist. One writeSession caller does target an
140+
// existing checkpoint — attach, which adds a session to it — but attach
141+
// pre-fetches and verifies the ref's presence itself (refreshCheckpoint)
142+
// before writing, so the local-only probe is safe there too.
121143
func (s *gitRefsStore) refBase(cid id.CheckpointID) (plumbing.Hash, *object.Tree, error) {
122144
refName, err := RefName(cid)
123145
if err != nil {
@@ -132,6 +154,35 @@ func (s *gitRefsStore) refBase(cid id.CheckpointID) (plumbing.Hash, *object.Tree
132154
// rather than silently starting a fresh orphan history over the ref.
133155
return plumbing.ZeroHash, nil, fmt.Errorf("resolve checkpoint ref %s: %w", refName, err)
134156
}
157+
return s.refTip(cid, ref)
158+
}
159+
160+
// refBaseForBackfill resolves like refBase, but a ref missing locally is
161+
// first fetched once from the remote (resolveRefMaybeFetch) when a fetcher is
162+
// configured: a backfill targets an EXISTING checkpoint that may have been
163+
// written or migrated on another machine, and declaring it absent without
164+
// looking remotely diverges from the read path — the backfill would be
165+
// handled as targeting a nonexistent checkpoint while reads, which DO fetch,
166+
// serve the refs copy, leaving the backfilled data permanently invisible.
167+
// A ref absent even after the fetch yields (ZeroHash, nil), which the
168+
// backfill helpers report as ErrCheckpointNotFound — the signal that the
169+
// checkpoint does not exist in this backend. A fetch FAILURE is returned
170+
// as-is: transient unavailability must never masquerade as absence, because
171+
// a caller or routing layer acting on a false "absent" would misdirect the
172+
// backfill (e.g. onto a stale copy in another backend) instead of retrying.
173+
func (s *gitRefsStore) refBaseForBackfill(ctx context.Context, cid id.CheckpointID) (plumbing.Hash, *object.Tree, error) {
174+
ref, err := s.resolveRefMaybeFetch(ctx, cid)
175+
if errors.Is(err, plumbing.ErrReferenceNotFound) {
176+
return plumbing.ZeroHash, nil, nil // genuinely absent → backfill reports not-found
177+
}
178+
if err != nil {
179+
return plumbing.ZeroHash, nil, err
180+
}
181+
return s.refTip(cid, ref)
182+
}
183+
184+
// refTip reads the commit and tree at a resolved checkpoint ref.
185+
func (s *gitRefsStore) refTip(cid id.CheckpointID, ref *plumbing.Reference) (plumbing.Hash, *object.Tree, error) {
135186
commit, err := s.repo.CommitObject(ref.Hash())
136187
if err != nil {
137188
return plumbing.ZeroHash, nil, fmt.Errorf("read checkpoint commit %s: %w", ref.Hash(), err)
@@ -207,11 +258,14 @@ func (s *gitRefsStore) writeSession(ctx context.Context, opts WriteOptions) erro
207258
}
208259

209260
func (s *gitRefsStore) backfillTranscript(ctx context.Context, opts UpdateOptions) error {
261+
if err := ctx.Err(); err != nil {
262+
return err //nolint:wrapcheck // Propagating context cancellation
263+
}
210264
if opts.CheckpointID.IsEmpty() {
211265
return errors.New("invalid update options: checkpoint ID is required")
212266
}
213267

214-
parentHash, existing, err := s.refBase(opts.CheckpointID)
268+
parentHash, existing, err := s.refBaseForBackfill(ctx, opts.CheckpointID)
215269
if err != nil {
216270
return err
217271
}
@@ -238,7 +292,7 @@ func (s *gitRefsStore) backfillSummary(ctx context.Context, checkpointID id.Chec
238292
return err //nolint:wrapcheck // Propagating context cancellation
239293
}
240294

241-
parentHash, existing, err := s.refBase(checkpointID)
295+
parentHash, existing, err := s.refBaseForBackfill(ctx, checkpointID)
242296
if err != nil {
243297
return err
244298
}
@@ -262,7 +316,7 @@ func (s *gitRefsStore) backfillAttribution(ctx context.Context, checkpointID id.
262316
return err //nolint:wrapcheck // Propagating context cancellation
263317
}
264318

265-
parentHash, existing, err := s.refBase(checkpointID)
319+
parentHash, existing, err := s.refBaseForBackfill(ctx, checkpointID)
266320
if err != nil {
267321
return err
268322
}
@@ -330,7 +384,33 @@ func (s *gitRefsStore) resolveRefMaybeFetch(ctx context.Context, cid id.Checkpoi
330384
if s.refFetcher == nil {
331385
return nil, err //nolint:wrapcheck // genuinely absent; caller maps ErrReferenceNotFound to ErrCheckpointNotFound
332386
}
387+
s.fetchFailureMu.Lock()
388+
priorFailure := s.fetchFailure
389+
s.fetchFailureMu.Unlock()
390+
if priorFailure != nil {
391+
// Note the cause may name a DIFFERENT ref — it is the first failure
392+
// of this operation, remembered so the outage is paid once.
393+
return nil, fmt.Errorf("fetch checkpoint ref %s: skipped, an earlier checkpoint-ref fetch already failed in this operation: %w", refName, priorFailure)
394+
}
333395
if fetchErr := s.refFetcher(ctx, refName); fetchErr != nil {
396+
if errors.Is(fetchErr, plumbing.ErrReferenceNotFound) {
397+
// The fetcher probed the remote and it genuinely lacks this ref
398+
// (remote.FetchCheckpointRef's absence signal) — absence, not a
399+
// failure, and per-ref, so it is not memoized.
400+
logging.Debug(ctx, "git-refs: remote has no such checkpoint ref",
401+
slog.String("ref", refName.String()))
402+
return nil, plumbing.ErrReferenceNotFound
403+
}
404+
// Memoize only network verdicts: a cancellation originating from the
405+
// CALLER's context says nothing about the remote and must not poison
406+
// later fetches on this store.
407+
if ctx.Err() == nil {
408+
s.fetchFailureMu.Lock()
409+
if s.fetchFailure == nil {
410+
s.fetchFailure = fetchErr
411+
}
412+
s.fetchFailureMu.Unlock()
413+
}
334414
logging.Debug(ctx, "git-refs: on-demand checkpoint ref fetch failed",
335415
slog.String("ref", refName.String()), slog.String("error", fetchErr.Error()))
336416
return nil, fmt.Errorf("fetch checkpoint ref %s: %w", refName, fetchErr)

0 commit comments

Comments
 (0)