Artifact 2afd2aa9f9def684c947ec652816d119176ffa23f5009c9cfbcde8f460ccf7c4:
- File
internal/annotate/annotate.go
— part of check-in
[d367012009]
at
2026-10-25 23:10:47
on branch feat/read-blob-content-cache
— feat: read an artifact by hash and cache content on Repo
Repo.ReadBlob expands one artifact by its full hash after the availability check ReadFile applies, and names an unknown hash with ErrArtifactNotFound.
Repo.SetContentCache attaches a byte-bounded content.Cache that ReadFile, ReadFileAt, ReadBlob, ListFiles, Diff and Annotate share; check-in parses are kept in its derived memo, so reads at one check-in parse the manifest once. Default off. A read that finds the DB's finished-write count moved swaps in an empty cache, so a purged, compacted or elided artifact, and a rid SQLite reuses after a purge, is never served from it. Writes by another process are not seen; the method's doc says so.
Tracked in #492. (user: [email protected], size: 10663) [annotate] [blame] [check-ins using] [more...]
package annotate import ( "bytes" "context" "fmt" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" ) // Line represents an annotated line of a file, with the text and the version // that last changed it. // // BelowHorizon marks a line whose origin lies at or below the structural // horizon of the store (draft-fossil-horizon-00 §5.5): the walk reached a // parent the store does not hold because it lies below the horizon, so the // line is reported as originating at the horizon, and Version is the // horizon's check-in rather than the line's origin. type Line struct { Text string Version VersionInfo BelowHorizon bool } // VersionInfo identifies the commit that last changed a line. type VersionInfo struct { UUID string User string Date time.Time } // Options controls the annotate operation. type Options struct { FilePath string // pathname of the file to annotate StartRID libfossil.FslID // checkin to start from (tip) Limit int // max ancestors to walk (0 = unlimited) OriginRID libfossil.FslID // stop at this checkin (0 = none) // Cache, when set, serves the walk in place of the one Annotate makes // for itself, so a caller's longer-lived cache carries over between // calls. The caller keeps its entries current. Cache *content.Cache } // Annotate attributes each line of a file to the commit that last changed it. // It walks the primary parent chain from StartRID, pushing line attributions // back to the earliest ancestor that contains the same line. func Annotate(r *repo.Repo, opts Options) ([]Line, error) { if r == nil { panic("annotate.Annotate: r must not be nil") } if opts.StartRID <= 0 { return nil, fmt.Errorf("annotate: invalid StartRID %d", opts.StartRID) } if opts.FilePath == "" { return nil, fmt.Errorf("annotate: FilePath is required") } // One cache for the whole walk. Annotate expands the file blob -- and the // checkin manifest -- at every revision back along the parent chain, and // content_deltify stores each older revision as a delta against a newer // one, so those per-revision expansions are all walks of the same few // delta chains (one for the file, one for the manifest). Without the // cache each revision replays its chain from the root: O(revisions^2) blob // reads. With it, LRU keeps the just-visited newer revision hot, so the // next older revision truncates its walk one link back -- O(revisions). // Blob content is immutable and the repo is only read here, so no entry // this loop caches can go stale; the cache is dropped when Annotate returns. cache := opts.Cache if cache == nil { cache = content.NewCache(annotateCacheBytes) } fileContent, err := loadFileAt(r, opts.StartRID, opts.FilePath, cache) if err != nil { return nil, fmt.Errorf("annotate: load file at start: %w", err) } startInfo, err := versionInfoFor(r, opts.StartRID) if err != nil { return nil, fmt.Errorf("annotate: version info for start: %w", err) } start, ok := breakIntoLines(fileContent) if !ok { return nil, fmt.Errorf( "annotate: %s is binary or has a line over %d bytes", opts.FilePath, lengthMask) } credit, versions, horizon, err := walkAncestors(r, opts, fileContent, start, startInfo, cache) if err != nil { return nil, fmt.Errorf("annotate: %w", err) } var horizonInfo VersionInfo if horizon != 0 { if horizonInfo, err = versionInfoFor(r, horizon); err != nil { return nil, fmt.Errorf("annotate: version info for the horizon: %w", err) } } result := make([]Line, len(start)) for i, d := range start { switch v := credit[i]; { case v >= 0: result[i] = Line{Text: d.text, Version: versions[v]} case horizon != 0: result[i] = Line{Text: d.text, Version: horizonInfo, BelowHorizon: true} default: // In the oldest version the walk reached. result[i] = Line{Text: d.text, Version: versions[len(versions)-1]} } } return result, nil } // annotateCacheBytes bounds the expanded content one Annotate walk keeps live. // The working set is the handful of delta-chain nodes in flight for the single // file and its manifests, not the whole repository, so this only has to be // large enough that the immediately-preceding revision is never evicted before // the next older one truncates its walk at it. A miss costs throughput, not // correctness. // // It is deliberately left at the figure manifest.crosslinkCacheBytes has since // been measured down from: this walk follows one file, so the budget is a // ceiling it never approaches rather than a working-set bound that decides // throughput, and cutting it would buy nothing. const annotateCacheBytes = 256 << 20 // walkAncestors walks the primary parent chain from opts.StartRID the way // fossil's annotate does. Each ancestor version of the file is diffed // against the starting version, not against the next newer one, and a start // line the ancestor lacks is credited to the version just newer than it, the // first time that happens. versions lists the versions walked, newest first; // credit[i] indexes it, or is -1 for a line every version walked contains. // // A run of check-ins that leave the file unchanged counts as its oldest // check-in, which is where fossil's annotate, reading mlink, sees that // version of the file appear. // // horizon is the store's structural horizon C when the walk stopped at a // parent below it (draft-fossil-horizon-00 §5.5), 0 otherwise: a line // without credit then originates at or below the horizon, which the store // cannot tell apart. func walkAncestors( r *repo.Repo, opts Options, startContent []byte, start []dline, startInfo VersionInfo, cache *content.Cache, ) (credit []int, versions []VersionInfo, horizon libfossil.FslID, err error) { credit = make([]int, len(start)) for i := range credit { credit[i] = -1 } versions = []VersionInfo{startInfo} untagged := len(start) currentRID, currentContent := opts.StartRID, startContent for steps := 0; untagged > 0; steps++ { if opts.Limit > 0 && steps >= opts.Limit { break } if opts.OriginRID > 0 && currentRID == opts.OriginRID { break } parentRID, err := primaryParent(r, currentRID) if err != nil || parentRID <= 0 { break } parentContent, err := loadFileAt(r, parentRID, opts.FilePath, cache) if err != nil { // The file is new here, and what is left belongs to this // version; or the parent lies below the horizon, and it may not. horizon, err = crossedHorizon(r, parentRID) return credit, versions, horizon, err } parentInfo, err := versionInfoFor(r, parentRID) if err != nil { break } currentRID = parentRID if bytes.Equal(parentContent, currentContent) { versions[len(versions)-1] = parentInfo continue } currentContent = parentContent if parent, ok := breakIntoLines(parentContent); ok { untagged -= creditInsertions(credit, diffAll(parent, start), len(versions)-1) } versions = append(versions, parentInfo) } return credit, versions, 0, nil } // crossedHorizon is the rid of the store's structural horizon C when parent // is a check-in below it, 0 otherwise. func crossedHorizon(r *repo.Repo, parent libfossil.FslID) (libfossil.FslID, error) { h, below, err := content.BelowHorizon(context.Background(), r.DB(), parent) if err != nil || !below { return 0, err } var rid libfossil.FslID if err := r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=? AND size>=0", h.Hash()).Scan(&rid); err != nil { return 0, fmt.Errorf("the horizon %s: %w", h.Hash(), err) } return rid, nil } // creditInsertions credits to version v every start line the edit script // inserts that has no credit yet, and returns how many it credited: fossil's // annotation_step. func creditInsertions(credit []int, edit []int, v int) int { if v < 0 { panic("annotate.creditInsertions: negative version") } if len(edit)%3 != 0 { panic("annotate.creditInsertions: edit script is not triples") } credited, line := 0, 0 for i := 0; i < len(edit); i += 3 { line += edit[i] for j := 0; j < edit[i+2]; j, line = j+1, line+1 { if credit[line] < 0 { credit[line] = v credited++ } } } if line > len(credit) { panic("annotate.creditInsertions: edit script runs past the file") } return credited } // loadFileAt loads the content of a file at a given checkin RID, serving both // the checkin manifest and the file blob through cache so a full-history walk // amortizes their overlapping delta chains. A nil cache reads uncached. func loadFileAt( r *repo.Repo, rid libfossil.FslID, filePath string, cache *content.Cache, ) ([]byte, error) { if r == nil { panic("annotate.loadFileAt: r must not be nil") } if rid <= 0 { panic("annotate.loadFileAt: rid must be positive") } if filePath == "" { panic("annotate.loadFileAt: filePath must not be empty") } // One name, so ask for one name: LookupFileCached searches the delta's // own F cards first (artifact-format-00 8.4). f, found, err := manifest.LookupFileCached(r, rid, filePath, cache) if err != nil { return nil, err } if found { fileRID, err := content.RequireAvailableByUUID(r.DB(), f.UUID) if err != nil { return nil, err } return cache.Expand(r.DB(), fileRID) } return nil, fmt.Errorf("file %q not found in checkin %d", filePath, rid) } // versionInfoFor retrieves commit metadata for a checkin RID. func versionInfoFor(r *repo.Repo, rid libfossil.FslID) (VersionInfo, error) { if r == nil { panic("annotate.versionInfoFor: r must not be nil") } if rid <= 0 { panic("annotate.versionInfoFor: rid must be positive") } var uuid, user string var mtimeRaw any err := r.DB().QueryRow( "SELECT b.uuid, e.user, e.mtime FROM blob b JOIN event e ON e.objid=b.rid WHERE b.rid=?", rid, ).Scan(&uuid, &user, &mtimeRaw) if err != nil { return VersionInfo{}, fmt.Errorf("version info for rid %d: %w", rid, err) } t, _ := db.ScanTime(mtimeRaw) return VersionInfo{UUID: uuid, User: user, Date: t}, nil } // primaryParent returns the primary parent RID of a checkin, or 0 if none. func primaryParent(r *repo.Repo, rid libfossil.FslID) (libfossil.FslID, error) { if r == nil { panic("annotate.primaryParent: r must not be nil") } if rid <= 0 { panic("annotate.primaryParent: rid must be positive") } var pid int64 err := r.DB().QueryRow("SELECT pid FROM plink WHERE cid=? AND isprim=1", rid).Scan(&pid) if err != nil { return 0, err } return libfossil.FslID(pid), nil }