Artifact 37d1544122334447c58a9ed85959e373bfa6579cfaa58ff0f8c0fd281d318a1a:
- File
cli/repo_snapshot.go
— part of check-in
[363ee3b9df]
at
2026-10-19 23:13:12
on branch feat/cli-snapshot
— fix(cli): hold the write lock from snapshot's read of the tip to its commit
Under -tags test_ncruces, TestSnapshotConcurrentProcesses failed 8 of 20 runs. Besides the open-time lock error fixed in db, a snapshot read the tip's manifest outside any transaction while another process's check-in re-deltaed it, and failed with a hash mismatch. A snapshot now runs in one bound write transaction from the read of the tip to its commit, so no other check-in can land in between; the optimistic retry loop it replaces is gone. (user: [email protected], size: 5220) [annotate] [blame] [check-ins using] [more...]
package cli import ( "bytes" "errors" "fmt" "io" "os" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" ) // RepoSnapshotCmd commits the bytes of one file onto trunk without a // checkout: the tip's other files carry forward, and bytes equal to the tip's // are a no-op that prints the existing tip. type RepoSnapshotCmd struct { Message string `short:"m" required:"" help:"Checkin comment"` Path string `required:"" help:"File name in the repository (relative, slash-separated); the bytes come from this path on disk unless --stdin"` Stdin bool `help:"Read the bytes from stdin instead of from --path on disk"` User string `help:"Checkin user (default: OS username)"` JSON bool `help:"Print {\"uuid\":...,\"changed\":...} instead of the bare uuid"` } // snapshotResult is the --json output of snapshot. type snapshotResult struct { UUID string `json:"uuid"` Changed bool `json:"changed"` } func (c *RepoSnapshotCmd) Run(g *Globals) (err error) { if err := validateRepoPath(c.Path); err != nil { return err } data, err := c.readContent() if err != nil { return err } user := c.User if user == "" { user = currentUser() } r, err := g.OpenRepo() if err != nil { return err } defer func() { if cerr := r.Close(); cerr != nil && err == nil { err = cerr } }() var res snapshotResult err = r.Inner().WithBoundTx(func(bound *repo.Repo) error { var err error res, err = snapshotLocked(r, bound, c.Path, data, c.Message, user) return err }) if err != nil { return err } if c.JSON { return writeJSON(res) } fmt.Println(res.UUID) return nil } func (c *RepoSnapshotCmd) readContent() ([]byte, error) { if c.Stdin { return io.ReadAll(os.Stdin) } return os.ReadFile(c.Path) } // snapshotLocked reads the trunk tip and commits name=data on it, inside the // write transaction bound holds. Holding the write lock from the read on // means no other process commits in between: the commit never builds on a // stale tip, and a reader never sees an artifact half rewritten, as a // concurrent check-in re-deltas the manifest before it. r reads through its // own connection, which sees everything committed and, as no one else can // commit, nothing newer. func snapshotLocked(r *libfossil.Repo, bound *repo.Repo, name string, data []byte, msg, user string) (snapshotResult, error) { var checkins int64 if err := bound.DB().QueryRow("SELECT count(*) FROM event WHERE type='ci'").Scan(&checkins); err != nil { return snapshotResult{}, err } parent, err := r.CurrentTrunk() if errors.Is(err, libfossil.ErrVersionNotFound) && checkins != 0 { // Check-ins exist but none on the main branch: a root here would // start a second, unrelated history. branch, berr := repo.MainBranch(bound.DB()) if berr != nil { return snapshotResult{}, berr } return snapshotResult{}, fmt.Errorf( "snapshot: the repository has check-ins but none on its main branch %q", branch) } if errors.Is(err, libfossil.ErrVersionNotFound) { parent = 0 // an empty repository: this is the root check-in } else if err != nil { return snapshotResult{}, err } perm := "" if parent > 0 { cur, found, err := manifest.LookupFile(bound, fsltype.FslID(parent), name) if err != nil { return snapshotResult{}, err } if found { perm = cur.Perm have, err := r.ReadFile(parent, name) if err != nil { return snapshotResult{}, err } if bytes.Equal(have, data) { uuid, err := checkinUUID(r, parent) return snapshotResult{UUID: uuid}, err } } } files, err := manifest.MergeParentFiles(bound, fsltype.FslID(parent), []manifest.File{{Name: name, Content: data, Perm: perm}}) if err != nil { return snapshotResult{}, err } _, uuid, err := manifest.Checkin(bound, manifest.CheckinOpts{ Files: files, Comment: msg, User: user, Parent: fsltype.FslID(parent), }) if err != nil { return snapshotResult{}, err } return snapshotResult{UUID: uuid, Changed: true}, nil } func checkinUUID(r *libfossil.Repo, rid int64) (string, error) { var uuid string err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid).Scan(&uuid) return uuid, err } // validateRepoPath rejects a name fossil would not accept as a new // repository file: one that is not a simple pathname in strict UTF-8 (file.c // file_is_simple_pathname: no leading, doubled or trailing slash, no "." or // ".." element, no backslash), one holding a control character, or one of the // names fossil reserves for its own files (file_is_reserved_name). func validateRepoPath(name string) error { if !deck.StrictPathname(name) { return fmt.Errorf("--path %q: want a clean relative path such as notes/x.md", name) } for i := 0; i < len(name); i++ { if name[i] < 0x20 { return fmt.Errorf("--path %q: a file name may not hold a control character (byte 0x%02x)", name, name[i]) } } if deck.IsReservedName(name) { return fmt.Errorf("--path %q: the name is reserved for fossil's own files", name) } return nil }