Artifact 3cdb59fcf0b0323fc3e5ba460d2308195a785d7f30e3ee8d554046d152fd353f:
- File
internal/tag/propagate.go
— part of check-in
[628f932301]
at
2026-10-13 16:51:51
on branch main
— fix(manifest): maintain the leaf table incrementally (RS-7.3-3) (#434)
* fix(manifest): maintain the leaf table incrementally (RS-7.3-3)
The leaf table was recomputed from scratch at the end of every crosslink batch, so a check-in lost its leaf verdict whenever a later batch linked an untagged child to it. repo-state-01 Section 7.3, as corrected, and fossil keep that verdict: a check-in and its plink parents are rechecked only when a branch tag is written to it or propagation updates or deletes its branch row (src/tag.c:195, :100-102), and the checks run when the batch ends (src/leaf.c leaf_do_pending_checks).
The rule now lives in a new internal/leaf package: Check (leaf_check), Rebuild (the explicit full rebuild) and Pending (the deferred schedule). internal/tag schedules a recheck at exactly the branch-row writes; the crosslink sweep, receive linker and repair gate apply a batch's schedule at the gate, each phantom-fill cascade transaction and each commit or tag transaction apply their own before commit, and verify.Rebuild keeps the whole-table recompute. Reparenting schedules nothing, as in fossil, so the reparent case is split into a one-batch case and a per-artifact case.
Tracked in #426.
* fix(manifest): recheck leaves a cancel reaches; drop rechecks of a failed receive tx (user: [email protected], size: 9027) [annotate] [blame] [check-ins using]
package tag import ( "container/heap" "database/sql" "fmt" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/leaf" ) // propagation is one tag_propagate walk: the tag being pushed down the // primary-parent DAG and the schedule its branch-row writes feed. type propagation struct { tagid int64 tagType int // TagPropagating or TagCancel; a singleton arrives as a cancel. origID libfossil.FslID mtime float64 value string tagName string // leaves receives a recheck for every child whose branch row the walk // writes or deletes (src/tag.c:100-102). Nil only for a tag that is not a // branch tag, which schedules nothing. leaves *leaf.Pending // reachAbsent makes a cancel walk act on a child that holds no row of its // own, as it does on one holding an older inherited row. See // propagateAfterInsert: a crosslink batch propagates only at its repair // gate, so a descendant can lack the row fossil would already have handed // it, and the cancel that deletes that row must still recheck it. reachAbsent bool } // walkChild is one primary child of a check-in the walk is visiting. type walkChild struct { cid libfossil.FslID childMtime float64 doit bool } // propagate walks the plink DAG from a target artifact to all descendants, // inserting/deleting tagxref rows to cascade propagating tags. // This matches Fossil's tag_propagate in tag.c:34-113. // // For propagating tags (type 2), it inserts tagxref rows with srcid=0. // For cancel tags (type 0), it deletes all tagxref entries for that tag. // Special case: if tagName is "bgcolor", also updates event.bgcolor. func (pr propagation) propagate(q db.Querier, pid libfossil.FslID) error { if q == nil { panic("tag.propagate: q must not be nil") } // Priority queue seeded with the target artifact (mtime=0.0 for seed) pq := &mtimeQueue{} heap.Init(pq) heap.Push(pq, &queueItem{rid: pid, mtime: 0.0}) // Guard against DAG cycles or pathologically deep chains. // Fossil repos typically have <100K checkins; 1M is a safe upper bound. const maxIterations = 1_000_000 // Visited set prevents re-processing nodes reachable via multiple paths. visited := make(map[libfossil.FslID]bool) iterations := 0 for pq.Len() > 0 { iterations++ if iterations > maxIterations { return fmt.Errorf("tag.propagate: exceeded %d iterations (possible cycle)", maxIterations) } item := heap.Pop(pq).(*queueItem) if visited[item.rid] { continue } visited[item.rid] = true children, err := pr.children(q, item.rid) if err != nil { return err } for _, child := range children { if !child.doit { continue } if err := pr.apply(q, child.cid); err != nil { return err } heap.Push(pq, &queueItem{rid: child.cid, mtime: child.childMtime}) } } return nil } // children lists rid's primary children and, for each, whether the walk acts // on it. doit is whether to propagate the tag to (type 2) or block it on // (type 0) the child. Both cases carry the same guard as fossil's // tag_propagate: act on an inherited row (srcid=0) only when it is OLDER than // this tag (tagxref.mtime < ?); the default when the child has no row for the // tag is 1 for a propagating tag (give it the row) and 0 for a cancel (nothing // to remove), bound as a parameter. A newer inherited row is left in place and // the walk does not descend past it (#288). func (pr propagation) children(q db.Querier, rid libfossil.FslID) ([]walkChild, error) { defaultDoit := 0 if pr.tagType == TagPropagating || pr.reachAbsent { defaultDoit = 1 } const query = ` SELECT cid, plink.mtime, COALESCE(srcid=0 AND tagxref.mtime < ?, ?) AS doit FROM plink LEFT JOIN tagxref ON cid=tagxref.rid AND tagxref.tagid=? WHERE pid=? AND isprim=1 ` rows, err := q.Query(query, pr.mtime, defaultDoit, pr.tagid, rid) if err != nil { return nil, fmt.Errorf("query children of %d: %w", rid, err) } defer rows.Close() var children []walkChild for rows.Next() { var cid int64 var childMtimeRaw any var doitInt int if err := rows.Scan(&cid, &childMtimeRaw, &doitInt); err != nil { return nil, fmt.Errorf("scan child: %w", err) } childMtime, ok := db.ScanTextJulianDay(childMtimeRaw) // plink.mtime if !ok { return nil, fmt.Errorf("child %d: unreadable plink.mtime %T", cid, childMtimeRaw) } children = append(children, walkChild{libfossil.FslID(cid), childMtime, doitInt != 0}) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows error: %w", err) } return children, nil } // apply gives cid the inherited row (a propagating tag) or removes its row (a // cancel), and schedules cid's leaf recheck when the row is its branch row, // as tag_propagate does for TAG_BRANCH. func (pr propagation) apply(q db.Querier, cid libfossil.FslID) error { if pr.tagType == TagPropagating { if _, err := q.Exec( `REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) VALUES(?, 2, 0, ?, ?, ?, ?)`, pr.tagid, pr.origID, nullableValue(pr.value), pr.mtime, cid, ); err != nil { return fmt.Errorf("propagate to %d: %w", cid, err) } if pr.tagName == "bgcolor" { const stmt = "UPDATE event SET bgcolor=? WHERE objid=?" if _, err := q.Exec(stmt, pr.value, cid); err != nil { return fmt.Errorf("update event bgcolor for %d: %w", cid, err) } } } else { const stmt = "DELETE FROM tagxref WHERE tagid=? AND rid=?" if _, err := q.Exec(stmt, pr.tagid, cid); err != nil { return fmt.Errorf("cancel at %d: %w", cid, err) } if pr.tagName == "bgcolor" { if _, err := q.Exec("UPDATE event SET bgcolor=NULL WHERE objid=?", cid); err != nil { return fmt.Errorf("clear event bgcolor for %d: %w", cid, err) } } } if pr.tagid != leaf.BranchTagID || pr.leaves == nil { return nil } if err := pr.leaves.Schedule(q, cid); err != nil { return fmt.Errorf("schedule leaf recheck of %d: %w", cid, err) } return nil } // queueItem represents a node in the priority queue. type queueItem struct { rid libfossil.FslID mtime float64 index int // heap index } // mtimeQueue implements heap.Interface for mtime-ordered priority queue. type mtimeQueue []*queueItem func (pq mtimeQueue) Len() int { return len(pq) } func (pq mtimeQueue) Less(i, j int) bool { return pq[i].mtime < pq[j].mtime } func (pq mtimeQueue) Swap(i, j int) { pq[i], pq[j] = pq[j], pq[i] pq[i].index = i pq[j].index = j } func (pq *mtimeQueue) Push(x any) { n := len(*pq) item := x.(*queueItem) item.index = n *pq = append(*pq, item) } func (pq *mtimeQueue) Pop() any { old := *pq n := len(old) item := old[n-1] old[n-1] = nil item.index = -1 *pq = old[0 : n-1] return item } // PropagateAllLeaves re-propagates all tags from rid to its descendants, as // fossil's tag_propagate_all does (tag.c:118-135); singleton tags walk as // cancels. It schedules on leaves a recheck of every check-in whose branch row // the walk writes or deletes, as fossil does through tag_propagate // (draft-fossil-repo-state-01 Section 7.3). func PropagateAllLeaves(q db.Querier, rid libfossil.FslID, leaves *leaf.Pending) error { if q == nil { panic("tag.PropagateAllLeaves: q must not be nil") } if leaves == nil { panic("tag.PropagateAllLeaves: leaves must not be nil") } return propagateAll(q, rid, leaves) } // propagateAll is tag_propagate_all. func propagateAll(q db.Querier, rid libfossil.FslID, leaves *leaf.Pending) error { if rid <= 0 { return nil } rows, err := q.Query( "SELECT tagid, tagtype, mtime, value, origid FROM tagxref WHERE rid=?", rid, ) if err != nil { return fmt.Errorf("tag.PropagateAll query: %w", err) } defer rows.Close() type entry struct { tagid int64 tagtype int mtime float64 value string origid libfossil.FslID } var entries []entry for rows.Next() { var e entry var mtimeRaw any var value sql.NullString // tagxref.value is nullable (e.g. CrosslinkCluster's cluster tag) if err := rows.Scan(&e.tagid, &e.tagtype, &mtimeRaw, &value, &e.origid); err != nil { return fmt.Errorf("tag.PropagateAll scan: %w", err) } e.value = value.String e.mtime, _ = db.ScanJulianDay(mtimeRaw) entries = append(entries, e) } if err := rows.Err(); err != nil { return fmt.Errorf("tag.PropagateAll rows: %w", err) } for _, e := range entries { tagtype := e.tagtype if tagtype == TagSingleton { tagtype = TagCancel // Matches Fossil: if(tagtype==1) tagtype=0; } var tagname string if err := q.QueryRow("SELECT tagname FROM tag WHERE tagid=?", e.tagid).Scan(&tagname); err != nil { return fmt.Errorf("tag.PropagateAll tagname: %w", err) } pr := propagation{ tagid: e.tagid, tagType: tagtype, origID: e.origid, mtime: e.mtime, value: e.value, tagName: tagname, leaves: leaves, } if err := pr.propagate(q, rid); err != nil { return fmt.Errorf("tag.PropagateAll propagate tagid=%d: %w", e.tagid, err) } } return nil }