Skip to content

Commit 450f625

Browse files
authored
Merge pull request #1811 from entireio/fix/checkpoint-backfill-kind-routing
fix(checkpoint): kind-route backfill writes to the store holding the checkpoint
2 parents ca1e2bf + a57a493 commit 450f625

7 files changed

Lines changed: 458 additions & 24 deletions

File tree

cmd/entire/cli/checkpoint/open.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,11 @@ func Open(ctx context.Context, repo *git.Repository, opts OpenOptions) (*Stores,
8787
}
8888
writer := newFanoutStore(primary, mirrors)
8989

90-
// Read routing: resolve id-keyed reads by the checkpoint's format across both
91-
// git backends (a ULID lives in refs, a hex ID on the branch or a migrated
92-
// ref), so a coexisting / mid-migration repo reads either format without
93-
// reconfiguring. Writes still go through writer (configured primary + mirrors).
90+
// Kind routing: resolve id-keyed reads and backfill writes by the
91+
// checkpoint's format across both git backends (a ULID lives in refs, a hex
92+
// ID on the branch or a migrated ref), so a coexisting / mid-migration repo
93+
// handles either format without reconfiguring. Creates still go through
94+
// writer (configured primary + mirrors).
9495
branchStore, refsStore, err := buildKindReadStores(ctx, env, primaryType, primary)
9596
if err != nil {
9697
return nil, err

cmd/entire/cli/checkpoint/persistent.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -866,8 +866,9 @@ func (s *GitStore) backfillAttribution(ctx context.Context, checkpointID id.Chec
866866
return err //nolint:wrapcheck // Propagating context cancellation
867867
}
868868

869-
if err := s.ensureSessionsBranch(ctx); err != nil {
870-
return fmt.Errorf("failed to ensure sessions branch: %w", err)
869+
// Backfills require the branch to exist; a miss must not create it.
870+
if err := s.requireSessionsBranch(); err != nil {
871+
return err
871872
}
872873

873874
parentHash, rootTreeHash, err := s.getSessionsBranchRef()
@@ -1701,9 +1702,9 @@ func (s *GitStore) backfillSummary(ctx context.Context, checkpointID id.Checkpoi
17011702
return err //nolint:wrapcheck // Propagating context cancellation
17021703
}
17031704

1704-
// Ensure sessions branch exists
1705-
if err := s.ensureSessionsBranch(ctx); err != nil {
1706-
return fmt.Errorf("failed to ensure sessions branch: %w", err)
1705+
// Backfills require the branch to exist; a miss must not create it.
1706+
if err := s.requireSessionsBranch(); err != nil {
1707+
return err
17071708
}
17081709

17091710
// Get branch ref and root tree hash (O(1), no flatten)
@@ -1752,9 +1753,9 @@ func (s *GitStore) backfillTranscript(ctx context.Context, opts UpdateOptions) e
17521753
return errors.New("invalid update options: checkpoint ID is required")
17531754
}
17541755

1755-
// Ensure sessions branch exists
1756-
if err := s.ensureSessionsBranch(ctx); err != nil {
1757-
return fmt.Errorf("failed to ensure sessions branch: %w", err)
1756+
// Backfills require the branch to exist; a miss must not create it.
1757+
if err := s.requireSessionsBranch(); err != nil {
1758+
return err
17581759
}
17591760

17601761
// Get branch ref and root tree hash (O(1), no flatten)
@@ -2117,6 +2118,23 @@ func PrecomputeTranscriptBlobs(ctx context.Context, repo *git.Repository, transc
21172118
}, nil
21182119
}
21192120

2121+
// requireSessionsBranch reports ErrCheckpointNotFound when the primary
2122+
// metadata ref does not exist. Backfills use this instead of
2123+
// ensureSessionsBranch: they target an existing checkpoint, and a missing
2124+
// branch trivially implies the checkpoint is absent — creating an orphan
2125+
// branch as a side effect of that probe would leave a live v1 branch (List
2126+
// union, pre-push) in a repo that never used the git-branch backend.
2127+
func (s *GitStore) requireSessionsBranch() error {
2128+
_, err := s.repo.Reference(s.refs.Primary, true)
2129+
if err == nil {
2130+
return nil
2131+
}
2132+
if errors.Is(err, plumbing.ErrReferenceNotFound) {
2133+
return ErrCheckpointNotFound
2134+
}
2135+
return fmt.Errorf("failed to check sessions branch: %w", err)
2136+
}
2137+
21202138
// ensureSessionsBranch ensures the primary metadata ref exists.
21212139
func (s *GitStore) ensureSessionsBranch(ctx context.Context) error {
21222140
_, err := s.repo.Reference(s.refs.Primary, true)

cmd/entire/cli/checkpoint/routing_store.go

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package checkpoint
33
import (
44
"context"
55
"errors"
6+
"fmt"
7+
"log/slog"
68
"sort"
79

810
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
11+
"github.com/entireio/cli/cmd/entire/cli/logging"
912
)
1013

1114
// kindRoutingStore resolves id-keyed reads across the two git backends so a repo
@@ -20,10 +23,12 @@ import (
2023
// a git-branch primary the branch is authoritative for hex, so refs is not
2124
// consulted.
2225
//
23-
// List unions both backends (disjoint ID spaces). Writes are NOT kind-routed:
24-
// they go to the configured primary (+ mirrors) via writer, since a new
25-
// checkpoint's ID is already minted to match the primary's format
26-
// (see checkpoint.GenerateCheckpointID).
26+
// List unions both backends (disjoint ID spaces). Creates (Session) are NOT
27+
// kind-routed: they go to the configured primary (+ mirrors) via writer, since
28+
// a new checkpoint's ID is already minted to match the primary's format (see
29+
// checkpoint.GenerateCheckpointID). Backfills update an existing checkpoint,
30+
// so they follow the same store order as reads, though only
31+
// ErrCheckpointNotFound falls through (stricter than reads) — see Write.
2732
type kindRoutingStore struct {
2833
writer PersistentStore // configured primary + mirrors (fanout); handles Write
2934
branch PersistentStore // git-branch store; serves hex reads
@@ -183,9 +188,103 @@ func (s *kindRoutingStore) ReadSessionMetadataAndPrompts(ctx context.Context, ch
183188
return mp.meta, mp.prompts, err
184189
}
185190

186-
// Write is not kind-routed: it targets the configured primary (+ mirrors).
191+
// Write routes a create (Session) to the configured primary (+ mirrors): a new
192+
// checkpoint's ID is already minted to match the primary's format (see
193+
// checkpoint.GenerateCheckpointID). Backfills target an EXISTING checkpoint,
194+
// which — like reads — may live in either git backend (e.g. a pre-migration hex
195+
// checkpoint still on the v1 branch under a git-refs primary), so they follow
196+
// the read order, falling through to the next store on ErrCheckpointNotFound.
197+
//
198+
// The fallthrough is deliberately stricter than read routing's firstResolved
199+
// (which falls through on absent OR any error): only the not-found sentinel
200+
// falls through here. Redirecting a write to another backend after a transient
201+
// primary failure could fork the data, so a hard error aborts and surfaces.
202+
// Note the stores' backfill absence probes are local-only (the refs store's
203+
// refBase does not on-demand fetch like its read path does); a checkpoint
204+
// whose ref exists only remotely backfills to the fallback store.
187205
func (s *kindRoutingStore) Write(ctx context.Context, req WriteRequest) error {
188-
return s.writer.Write(ctx, req) //nolint:wrapcheck // primary error is the operation's error, surfaced verbatim
206+
checkpointID, isBackfill := backfillTarget(req)
207+
if !isBackfill {
208+
return s.writer.Write(ctx, req) //nolint:wrapcheck // primary error is the operation's error, surfaced verbatim
209+
}
210+
stores := s.backfillOrder(checkpointID)
211+
var err error
212+
for i, st := range stores {
213+
err = st.Write(ctx, req)
214+
if !errors.Is(err, ErrCheckpointNotFound) {
215+
if err == nil && i > 0 {
216+
// The most consequential routing decision here: the data landed
217+
// somewhere other than the configured primary, and mirrors
218+
// (which follow the primary) were skipped. Record it so "why is
219+
// this backfill on the v1 branch and not in refs / the mirror"
220+
// stays diagnosable.
221+
logging.Info(ctx, "checkpoint: backfill served by fallback store; absent from primary, mirrors skipped",
222+
slog.String("checkpoint_id", checkpointID.String()),
223+
slog.String("request_type", fmt.Sprintf("%T", req)))
224+
}
225+
return err //nolint:wrapcheck // in-package store error surfaced verbatim
226+
}
227+
if i < len(stores)-1 {
228+
logging.Debug(ctx, "checkpoint: backfill target absent in store, trying next",
229+
slog.String("checkpoint_id", checkpointID.String()),
230+
slog.String("request_type", fmt.Sprintf("%T", req)),
231+
slog.Int("store_index", i))
232+
}
233+
}
234+
return err //nolint:wrapcheck // ErrCheckpointNotFound from the final store, surfaced verbatim
235+
}
236+
237+
// backfillTarget returns the checkpoint ID a backfill request updates.
238+
// ok is false for Session (a create) and unknown request types, which are not
239+
// kind-routed.
240+
//
241+
// WriteRequest is a closed union: any new backfill-shaped request type MUST be
242+
// added to this switch, or it silently gets create routing — primary-only, no
243+
// fallback — which for a pre-migration checkpoint reintroduces the discarded-
244+
// write bug this routing exists to prevent.
245+
func backfillTarget(req WriteRequest) (id.CheckpointID, bool) {
246+
switch r := req.(type) {
247+
case SessionTranscript:
248+
return r.CheckpointID, true
249+
case SessionSummary:
250+
return r.CheckpointID, true
251+
case CheckpointAttribution:
252+
return r.CheckpointID, true
253+
default:
254+
return id.EmptyCheckpointID, false
255+
}
256+
}
257+
258+
// backfillOrder returns the write targets for a backfill of checkpointID, in
259+
// the same priority order reads use. The store that is the configured primary
260+
// is replaced by writer, so a backfill landing on the primary still fans out to
261+
// mirrors; a backfill landing on a fallback store deliberately skips mirrors
262+
// (mirrors follow the primary).
263+
func (s *kindRoutingStore) backfillOrder(checkpointID id.CheckpointID) []PersistentStore {
264+
order := s.readOrder(checkpointID)
265+
targets := make([]PersistentStore, len(order))
266+
for i, st := range order {
267+
if s.isPrimary(st) {
268+
targets[i] = s.writer
269+
} else {
270+
targets[i] = st
271+
}
272+
}
273+
return targets
274+
}
275+
276+
// isPrimary reports whether st is the configured primary's read store.
277+
func (s *kindRoutingStore) isPrimary(st PersistentStore) bool {
278+
switch s.primaryType {
279+
case BackendTypeGitBranch:
280+
return st == s.branch
281+
case BackendTypeGitRefs:
282+
return st == s.refs
283+
default:
284+
// Not a real configuration today (buildPrimary only accepts the git
285+
// backends); backfills would bypass writer and therefore mirrors.
286+
return false
287+
}
189288
}
190289

191290
// kindRoutingStoreWithAuthor adds the optional AuthorReader capability, routing

0 commit comments

Comments
 (0)