Skip to content

Commit 9df49ec

Browse files
authored
fix(mount): converge first empty-revision bootstrap (#362)
* fix(mount): converge first empty revision bootstrap * fix(mount): localize empty bootstrap exception * fix(mount): keep empty bootstrap non-destructive
1 parent ee15b2c commit 9df49ec

3 files changed

Lines changed: 122 additions & 8 deletions

File tree

internal/mountsync/bootstrap_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,45 @@ func TestBootstrapResumesFromPersistedCursor(t *testing.T) {
366366
}
367367
}
368368

369+
// TestBootstrapConvergesOnFirstUnversionedObservation reproduces #360's
370+
// empty-revision bootstrap shape. A complete traversal with no remote
371+
// revision is still a legitimate first observation: it must not be counted as
372+
// a revision-gate refusal, and the persisted bootstrap must converge.
373+
func TestBootstrapConvergesOnFirstUnversionedObservation(t *testing.T) {
374+
client := newBootstrapClient(12, 12)
375+
for path, file := range client.files {
376+
file.Revision = ""
377+
client.files[path] = file
378+
}
379+
localDir := t.TempDir()
380+
stateFile := filepath.Join(localDir, ".relayfile-mount-state.json")
381+
if err := writeMountState(stateFile, mountState{
382+
Files: map[string]trackedFile{},
383+
BootstrapComplete: false,
384+
}); err != nil {
385+
t.Fatalf("seed state: %v", err)
386+
}
387+
388+
s := newBootstrapSyncer(t, client, localDir, SyncerOptions{
389+
RootCtx: context.Background(),
390+
StateFile: stateFile,
391+
})
392+
if err := s.Reconcile(context.Background()); err != nil {
393+
t.Fatalf("unversioned bootstrap reconcile: %v", err)
394+
}
395+
396+
st := loadPersistedState(t, localDir)
397+
if !st.BootstrapComplete {
398+
t.Fatalf("empty-revision full traversal must persist BootstrapComplete")
399+
}
400+
if st.Counters.SnapshotDeleteBlocked != 0 {
401+
t.Fatalf("first empty-revision observation was blocked %d time(s), want 0", st.Counters.SnapshotDeleteBlocked)
402+
}
403+
if got := countLocalFiles(t, localDir); got != 12 {
404+
t.Fatalf("mirrored files = %d, want 12", got)
405+
}
406+
}
407+
369408
// TestBootstrapStallCycleGuardPersistsAndFailsHard reproduces the
370409
// non-converging unversioned partial-bootstrap shape: the first page is
371410
// persisted, then every resumed cycle fails at the same next-page cursor.

internal/mountsync/syncer.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4784,15 +4784,18 @@ func (s *Syncer) applyRemoteSnapshotDeletes(remotePaths map[string]struct{}, con
47844784
// 1. Refuse the destructive pass entirely when the cloud-error circuit
47854785
// is open (read-only mirror remains; the next healthy cycle catches
47864786
// up). Layout materialization is still safe to run.
4787-
// 2. Refuse when the observedRevision does not strictly advance past
4787+
// 2. Treat the first complete bootstrap observation with no revision as a
4788+
// non-destructive no-op. This lets the caller persist bootstrap completion
4789+
// without creating or confirming tombstones.
4790+
// 3. Refuse when the observedRevision does not strictly advance past
47884791
// state.LastAppliedRevision — an older or equal listing must not
47894792
// authorize deletes.
4790-
// 3. For every tracked path missing from the fresh listing, write or
4793+
// 4. For every tracked path missing from the fresh listing, write or
47914794
// confirm a tombstone under .relay/pending-deletes. Only the second
47924795
// consecutive confirmation actually deletes; the first observation
47934796
// is recorded and skipped.
4794-
// 4. After the pass, prune tombstones for paths that have reappeared.
4795-
// 5. On a clean pass, advance state.LastAppliedRevision.
4797+
// 5. After the pass, prune tombstones for paths that have reappeared.
4798+
// 6. On a clean pass, advance state.LastAppliedRevision.
47964799
func (s *Syncer) applyRemoteSnapshotDeletesRev(remotePaths map[string]struct{}, conflicted map[string]struct{}, observedRevision string) error {
47974800
if err := s.materializeProviderLayoutsFromPaths(remotePaths); err != nil {
47984801
return err
@@ -4805,10 +4808,21 @@ func (s *Syncer) applyRemoteSnapshotDeletesRev(remotePaths map[string]struct{},
48054808
return nil
48064809
}
48074810

4811+
// The first completed bootstrap traversal may legitimately have no
4812+
// revision. Let the caller record that completion, but do not allow this
4813+
// unversioned observation to enter tombstone creation or confirmation.
4814+
// BootstrapComplete is persisted immediately after the successful
4815+
// traversal, so later unversioned observations fall through to the normal
4816+
// revision gate and are counted as blocked.
4817+
if strings.TrimSpace(observedRevision) == "" &&
4818+
strings.TrimSpace(s.state.LastAppliedRevision) == "" &&
4819+
!s.state.BootstrapComplete {
4820+
s.logf("snapshot delete pass skipped: first bootstrap observation has no revision; destructive reconciliation deferred")
4821+
return nil
4822+
}
4823+
48084824
// Revision gate: refuse to act on a listing that does not strictly
4809-
// advance the highest-applied revision. revisionAdvances treats an
4810-
// empty observedRevision as "unknown" — which is also refused. An
4811-
// empty stored LastAppliedRevision allows the first advancement.
4825+
// advance the highest-applied revision.
48124826
if !revisionAdvances(s.state.LastAppliedRevision, observedRevision) {
48134827
s.state.Counters.SnapshotDeleteBlocked++
48144828
s.logf("snapshot delete pass refused: observed revision %q does not advance past last applied %q",
@@ -4870,7 +4884,10 @@ func (s *Syncer) applyRemoteSnapshotDeletesRev(remotePaths map[string]struct{},
48704884
// Revisions in this codebase look like "rev_<int>" (see fakeClient) but
48714885
// real cloud revisions may be opaque; we compare numerically when both
48724886
// match the rev_<int> shape, and lexicographically otherwise. An empty
4873-
// observed is never an advancement.
4887+
// observed is never an advancement. Bootstrap callers that need to accept a
4888+
// first unversioned observation must keep that exception local to their
4889+
// persisted bootstrap-completion gate; this generic predicate is also used by
4890+
// strict candidate ordering.
48744891
func revisionAdvances(last, observed string) bool {
48754892
observed = strings.TrimSpace(observed)
48764893
last = strings.TrimSpace(last)

internal/mountsync/tombstones_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ func TestRevisionGateRefusesUnversionedListingAcrossRepeatedPulls(t *testing.T)
156156
"/keep.md": {ContentType: "text/markdown", Hash: hashString("# keep")},
157157
"/other.md": {ContentType: "text/markdown", Hash: hashString("# other")},
158158
},
159+
BootstrapComplete: true,
159160
}); err != nil {
160161
t.Fatalf("seed state: %v", err)
161162
}
@@ -195,6 +196,9 @@ func TestRevisionGateRefusesUnversionedListingAcrossRepeatedPulls(t *testing.T)
195196

196197
// TestRevisionAdvances exercises the numeric and lexicographic paths.
197198
func TestRevisionAdvances(t *testing.T) {
199+
if revisionAdvances("", "") {
200+
t.Fatalf("generic revision ordering must not treat empty + empty as strictly newer")
201+
}
198202
if revisionAdvances("rev_5", "") {
199203
t.Fatalf("empty observed must never advance")
200204
}
@@ -277,3 +281,57 @@ func TestObservePendingDeleteAgedOutReobservesCurrentPass(t *testing.T) {
277281
t.Fatalf("unexpected counters after reset: %#v", s.state.Counters)
278282
}
279283
}
284+
285+
func TestFirstUnversionedBootstrapDoesNotConfirmLegacyTombstone(t *testing.T) {
286+
const remotePath = "/gone.md"
287+
const contents = "# preserve me"
288+
289+
localDir := t.TempDir()
290+
localPath := filepath.Join(localDir, "gone.md")
291+
if err := os.WriteFile(localPath, []byte(contents), 0o644); err != nil {
292+
t.Fatalf("seed tracked file: %v", err)
293+
}
294+
s, err := NewSyncer(&fakeClient{}, SyncerOptions{
295+
WorkspaceID: "ws_first_unversioned_bootstrap_tombstone",
296+
RemoteRoot: "/",
297+
LocalRoot: localDir,
298+
})
299+
if err != nil {
300+
t.Fatalf("new syncer: %v", err)
301+
}
302+
s.state.Files[remotePath] = trackedFile{
303+
Revision: "rev_legacy",
304+
ContentType: "text/markdown",
305+
Hash: hashString(contents),
306+
}
307+
s.state.BootstrapComplete = false
308+
s.state.LastAppliedRevision = ""
309+
seeded := pendingDeleteTombstone{
310+
Path: remotePath,
311+
FirstObservedAt: time.Now().UTC(),
312+
LastObservedAt: time.Now().UTC(),
313+
Attempts: 1,
314+
ObservedRevision: "rev_legacy",
315+
}
316+
if err := s.writeTombstone(&seeded); err != nil {
317+
t.Fatalf("seed legacy tombstone: %v", err)
318+
}
319+
320+
if err := s.applyRemoteSnapshotDeletesRev(map[string]struct{}{}, nil, ""); err != nil {
321+
t.Fatalf("first unversioned bootstrap delete pass: %v", err)
322+
}
323+
324+
if got, err := os.ReadFile(localPath); err != nil || string(got) != contents {
325+
t.Fatalf("first unversioned bootstrap changed tracked file: contents=%q err=%v", got, err)
326+
}
327+
if _, ok := s.state.Files[remotePath]; !ok {
328+
t.Fatalf("first unversioned bootstrap removed tracked state")
329+
}
330+
got, err := s.loadTombstone(remotePath)
331+
if err != nil {
332+
t.Fatalf("load legacy tombstone: %v", err)
333+
}
334+
if got == nil || got.Attempts != seeded.Attempts || got.ObservedRevision != seeded.ObservedRevision {
335+
t.Fatalf("first unversioned bootstrap mutated legacy tombstone: got=%#v want=%#v", got, seeded)
336+
}
337+
}

0 commit comments

Comments
 (0)