Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0f8f14d
Merge branch 'main' of github.com:entireio/cli into fix/checkpoints-v…
pfleidi May 7, 2026
b575cfc
Merge branch 'main' of github.com:entireio/cli into fix/checkpoints-v…
pfleidi May 7, 2026
a5aac69
Fix v2 full generation rotation push
pfleidi May 8, 2026
600cbd4
Detect stale v2 full generation archives
pfleidi May 8, 2026
a599cde
Handle multiple remote v2 rotations
pfleidi May 8, 2026
43fa9dc
Cover post-rotation v2 current pushes
pfleidi May 8, 2026
bc82aaf
Cover repeated local v2 rotations
pfleidi May 8, 2026
aa9ac82
Push all v2 archived generation refs
pfleidi May 8, 2026
65d9a46
Use SplitSeq for v2 remote ref scans
pfleidi May 8, 2026
d654296
Publish v2 archives from pending records
pfleidi May 8, 2026
090dc30
Rollback unqueued migrated v2 archives
pfleidi May 8, 2026
6c48c47
Push pending v2 archives without active refs
pfleidi May 8, 2026
ed4eddd
Guard empty v2 ref pushes
pfleidi May 8, 2026
3dccb8d
Lock pending v2 full generation markers
pfleidi May 8, 2026
3a95e50
Avoid redundant v2 generation lookups
pfleidi May 8, 2026
004ab54
Simplify v2 pending publication push path
pfleidi May 8, 2026
9c73ee3
Merge branch 'main' of github.com:entireio/cli into fix/checkpoints-v…
pfleidi May 8, 2026
aed8210
Harden v2 rotation recovery fetches
pfleidi May 8, 2026
cdcdec6
Merge remote-tracking branch 'origin/main' into fix/checkpoints-v2-ro…
pfleidi May 8, 2026
3ea8f1c
Refine v2 pending publication push handling
pfleidi May 9, 2026
25e04b8
Validate pending v2 archive publication refs
pfleidi May 9, 2026
d014d6d
Clean up fetched v2 archive temp refs
pfleidi May 9, 2026
48c2096
Reuse v2 push repository state
pfleidi May 9, 2026
c2446ed
Require pending v2 rotation marker before reset
pfleidi May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 56 additions & 24 deletions cmd/entire/cli/checkpoint/v2_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ func (s *V2GitStore) RotateCurrentGenerationIfNeeded(ctx context.Context, maxChe
return "", false, fmt.Errorf("rotation: failed to determine next generation number: %w", err)
}

// Phase 1: Archive — create ref pointing to the current commit.
// Phase 1: Prepare archive and reset commits without changing refs yet.
// If the archive ref already exists, another instance already rotated — skip.
archiveRefName := ArchivedGenerationRefName(archiveNumber)
if _, refErr := s.repo.Reference(archiveRefName, true); refErr == nil {
Expand All @@ -515,22 +515,6 @@ func (s *V2GitStore) RotateCurrentGenerationIfNeeded(ctx context.Context, maxChe
)
return archiveRefName, false, nil
}
archiveRef := plumbing.NewHashReference(archiveRefName, currentRef.Hash())
if err := s.repo.Storer.SetReference(archiveRef); err != nil {
return "", false, fmt.Errorf("rotation: failed to create archived ref %s: %w", archiveRefName, err)
}

// Verify /full/current hasn't been advanced by another writer since we read it.
// If it changed, abort — the archive ref is harmless (points to a valid commit)
// and the next writer will trigger rotation again.
postArchiveRef, err := s.repo.Reference(refName, true)
if err != nil {
return "", false, fmt.Errorf("rotation: failed to re-read /full/current: %w", err)
}
if postArchiveRef.Hash() != currentRef.Hash() {
logging.Info(ctx, "rotation: /full/current changed during rotation, aborting reset")
return archiveRefName, false, nil
}

// Write generation.json to the current tree before archiving.
gen := s.computeGenerationTimestamps(currentTreeHash)
Expand All @@ -545,13 +529,7 @@ func (s *V2GitStore) RotateCurrentGenerationIfNeeded(ctx context.Context, maxChe
return "", false, fmt.Errorf("rotation: failed to create archive commit: %w", err)
}

// Update the archive ref to point to the commit with generation.json
archiveRef = plumbing.NewHashReference(archiveRefName, archiveCommitHash)
if err := s.repo.Storer.SetReference(archiveRef); err != nil {
return "", false, fmt.Errorf("rotation: failed to update archived ref %s: %w", archiveRefName, err)
}

// Phase 2: Create fresh orphan /full/current (empty tree, no generation.json)
// Create fresh orphan /full/current (empty tree, no generation.json).
emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry))
if err != nil {
return "", false, fmt.Errorf("rotation: failed to build empty tree: %w", err)
Expand All @@ -562,6 +540,59 @@ func (s *V2GitStore) RotateCurrentGenerationIfNeeded(ctx context.Context, maxChe
return "", false, fmt.Errorf("rotation: failed to create orphan commit: %w", err)
}

// Verify /full/current hasn't been advanced by another writer since we read it.
// If it changed, abort before recording a publication marker.
postArchiveRef, err := s.repo.Reference(refName, true)
if err != nil {
return "", false, fmt.Errorf("rotation: failed to re-read /full/current: %w", err)
}
if postArchiveRef.Hash() != currentRef.Hash() {
logging.Info(ctx, "rotation: /full/current changed during rotation, aborting reset")
return archiveRefName, false, nil
}

publication := PendingV2FullGenerationPublication{
ArchiveRefName: archiveRefName.String(),
ArchiveCommitHash: archiveCommitHash.String(),
PreviousFullCurrentHash: currentRef.Hash().String(),
ResetFullCurrentRootHash: orphanCommitHash.String(),
QueuedAt: time.Now().UTC(),
}

// The commit objects above are not reachable from the v2 refs yet. Record
// the pending publication before moving refs so pre-push can recover the
// intended rotation if the process stops after either ref update below.
if err := s.AppendPendingFullGenerationPublication(ctx, publication); err != nil {
return "", false, fmt.Errorf("rotation: failed to record pending full rotation: %w", err)
}
keepPendingPublication := false
defer func() {
if keepPendingPublication {
return
}
if removeErr := s.RemovePendingFullGenerationPublications(ctx, []PendingV2FullGenerationPublication{publication}); removeErr != nil {
logging.Warn(ctx, "rotation: failed to remove pending full rotation after failed rotation",
slog.String("error", removeErr.Error()),
slog.String("archive_ref", string(archiveRefName)),
slog.String("previous_full_current_hash", currentRef.Hash().String()),
slog.String("archive_commit_hash", archiveCommitHash.String()),
slog.String("reset_full_current_root_hash", orphanCommitHash.String()),
)
}
}()

// Phase 2: publish local refs after the pending publication marker exists.
if _, refErr := s.repo.Reference(archiveRefName, true); refErr == nil {
logging.Info(ctx, "rotation: archive ref already exists, skipping",
slog.String("archive_ref", string(archiveRefName)),
)
return archiveRefName, false, nil
}
archiveRef := plumbing.NewHashReference(archiveRefName, archiveCommitHash)
if err := s.repo.Storer.SetReference(archiveRef); err != nil {
return "", false, fmt.Errorf("rotation: failed to update archived ref %s: %w", archiveRefName, err)
}

reset, err := s.resetFullCurrentRefIfUnchanged(ctx, refName, postArchiveRef, orphanCommitHash)
if err != nil {
return "", false, err
Expand All @@ -570,6 +601,7 @@ func (s *V2GitStore) RotateCurrentGenerationIfNeeded(ctx context.Context, maxChe
return archiveRefName, false, nil
}

keepPendingPublication = true
logging.Info(ctx, "generation rotation complete",
slog.Int("archived_generation", archiveNumber),
slog.String("archive_ref", string(archiveRefName)),
Expand Down
61 changes: 61 additions & 0 deletions cmd/entire/cli/checkpoint/v2_generation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -447,6 +449,65 @@ func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) {
assert.Empty(t, freshTree.Entries, "fresh tree should be empty (no generation.json)")
}

func TestRotateGeneration_FailsBeforeResetWhenPendingMarkerCannotBeRecorded(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
store := NewV2GitStore(repo, "origin")
ctx := context.Background()

populateFullCurrent(t, store, 3, 0)

worktree, err := repo.Worktree()
require.NoError(t, err)
blockingPath := filepath.Join(worktree.Filesystem().Root(), ".git", pendingV2FullGenerationPublicationDirName)
require.NoError(t, os.WriteFile(blockingPath, []byte("not a directory"), 0o600))

refName, rotated, err := store.RotateCurrentGenerationIfNeeded(ctx, 3)
require.Error(t, err)
require.False(t, rotated)
require.Empty(t, refName)
assert.Contains(t, err.Error(), "failed to record pending full rotation")

_, currentTreeHash, err := store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName))
require.NoError(t, err)
currentCount, err := store.CountCheckpointsInTree(currentTreeHash)
require.NoError(t, err)
assert.Equal(t, 3, currentCount)

_, _, err = store.GetRefState(ArchivedGenerationRefName(1))
require.Error(t, err)
}

func TestRemovePendingFullGenerationPublications_PreservesLaterQueuedEntries(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
store := NewV2GitStore(repo, "origin")
ctx := context.Background()

first := PendingV2FullGenerationPublication{
ArchiveRefName: paths.V2FullRefPrefix + "0000000000001",
ArchiveCommitHash: "1111111111111111111111111111111111111111",
QueuedAt: time.Date(2026, 3, 19, 1, 2, 3, 0, time.UTC),
}
later := PendingV2FullGenerationPublication{
ArchiveRefName: paths.V2FullRefPrefix + "0000000000002",
ArchiveCommitHash: "2222222222222222222222222222222222222222",
QueuedAt: time.Date(2026, 3, 19, 4, 5, 6, 0, time.UTC),
}

require.NoError(t, store.AppendPendingFullGenerationPublication(ctx, first))
snapshot, err := store.ReadPendingFullGenerationPublications(ctx)
require.NoError(t, err)
require.Equal(t, []PendingV2FullGenerationPublication{first}, snapshot)

require.NoError(t, store.AppendPendingFullGenerationPublication(ctx, later))
require.NoError(t, store.RemovePendingFullGenerationPublications(ctx, snapshot))

remaining, err := store.ReadPendingFullGenerationPublications(ctx)
require.NoError(t, err)
assert.Equal(t, []PendingV2FullGenerationPublication{later}, remaining)
}

func TestResetFullCurrentRefIfUnchangedRejectsConcurrentChange(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
Expand Down
Loading
Loading