Artifact ec626c2f7fc13b311bc92e553abf599a6bcdce56cc8ace55406dcb7eb6e5e0f6:
- File
internal/tag/tag.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: 15103) [annotate] [blame] [check-ins using]
package tag import ( "database/sql" "errors" "fmt" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/leaf" "github.com/danmestas/go-libfossil/internal/repo" ) const ( TagCancel = 0 TagSingleton = 1 TagPropagating = 2 ) // TagOpts describes a tag operation on a target artifact. type TagOpts struct { TargetRID libfossil.FslID TagName string TagType int // TagCancel, TagSingleton, or TagPropagating Value string User string Time time.Time } // ApplyOpts describes a tag application from an existing control artifact. type ApplyOpts struct { TargetRID libfossil.FslID // Artifact to tag. // SrcRID is the artifact whose T card introduced this tag: the control // artifact, or the check-in carrying an inline card. tagxref keeps 0 for // inherited rows only, so a date tag needs it positive to set a time. SrcRID libfossil.FslID TagName string TagType int // TagCancel, TagSingleton, or TagPropagating. Value string MTime float64 // Julian day. // Leaves is the batch's leaf-recheck schedule. A branch tag, and the // propagation walk it starts, schedule the check-ins whose branch row // they write (draft-fossil-repo-state-01 Section 7.3); the caller applies // the schedule before its batch ends. ApplyTagWithTx requires it for a // branch tag; ApplyTag supplies its own when it is nil. Leaves *leaf.Pending } // AddTag creates a control artifact that adds or cancels a tag on a target checkin. // It stores the artifact as a blob, ensures the tag name exists in the tag table, // and inserts/replaces a row in the tagxref table. func AddTag(r *repo.Repo, opts TagOpts) (libfossil.FslID, error) { if r == nil { panic("tag.AddTag: r must not be nil") } if opts.TagName == "" { panic("tag.AddTag: opts.TagName must not be empty") } if opts.TargetRID <= 0 { panic("tag.AddTag: opts.TargetRID must be positive") } if opts.User == "" { panic("tag.AddTag: opts.User must not be empty") } if opts.Time.IsZero() { opts.Time = time.Now().UTC() } var controlRid libfossil.FslID err := r.WithTx(func(tx *db.Tx) error { d, err := controlDeck(tx, opts) if err != nil { return err } // Marshal and store as blob manifestBytes, err := d.Marshal() if err != nil { return fmt.Errorf("marshal control artifact: %w", err) } rid, _, err := blob.Store(tx, manifestBytes) if err != nil { return fmt.Errorf("store control artifact: %w", err) } controlRid = rid // Derive the tag the way crosslinking the control artifact would. A // superseded tag leaves tagxref alone, but the control artifact is // still stored and queued for sync. The leaf rechecks a branch tag // schedules are applied before this transaction commits, as fossil // applies them at the end of its own (src/db.c, leaf_do_pending_checks). leaves := leaf.NewPending() if err := ApplyTagWithTx(tx, ApplyOpts{ TargetRID: opts.TargetRID, SrcRID: controlRid, TagName: opts.TagName, TagType: opts.TagType, Value: opts.Value, MTime: libfossil.TimeToJulian(opts.Time), Leaves: leaves, }); err != nil { return err } if err := leaves.Apply(tx); err != nil { return err } // Mark control artifact as unsent so sync pushes it (unclustered is handled by blob.Store). if _, err := tx.Exec("INSERT OR IGNORE INTO unsent(rid) VALUES(?)", controlRid); err != nil { return fmt.Errorf("tag.AddTag: unsent: %w", err) } return nil }) if err != nil { return 0, fmt.Errorf("tag.AddTag: %w", err) } return controlRid, nil } // controlDeck builds the control artifact AddTag stores: one T card naming // the target by its hash. func controlDeck(q db.Querier, opts TagOpts) (*deck.Deck, error) { var targetUUID string const lookup = "SELECT uuid FROM blob WHERE rid=?" if err := q.QueryRow(lookup, opts.TargetRID).Scan(&targetUUID); err != nil { return nil, fmt.Errorf("target uuid lookup: %w", err) } // Map our integer tag type to deck.TagType byte var deckTagType deck.TagType switch opts.TagType { case TagCancel: deckTagType = deck.TagCancel case TagSingleton: deckTagType = deck.TagSingleton case TagPropagating: deckTagType = deck.TagPropagating default: return nil, fmt.Errorf("invalid tag type: %d", opts.TagType) } return &deck.Deck{ Type: deck.Control, D: opts.Time, T: []deck.TagCard{{ Type: deckTagType, Name: opts.TagName, UUID: targetUUID, Value: opts.Value, }}, U: deck.User(opts.User), }, nil } // ApplyTag inserts a tagxref row and propagates without creating a control artifact. // Used by Crosslink to process existing control artifacts. func ApplyTag(r *repo.Repo, opts ApplyOpts) error { if r == nil { panic("tag.ApplyTag: r must not be nil") } if opts.TagName == "" { panic("tag.ApplyTag: opts.TagName must not be empty") } if opts.TargetRID <= 0 { panic("tag.ApplyTag: opts.TargetRID must be positive") } return r.WithTx(func(tx *db.Tx) error { if opts.Leaves != nil { return ApplyTagWithTx(tx, opts) } // The transaction is the batch: its rechecks are applied before it // commits. opts.Leaves = leaf.NewPending() if err := ApplyTagWithTx(tx, opts); err != nil { return err } return opts.Leaves.Apply(tx) }) } // ApplyTagWithTx inserts a tagxref row and propagates using an existing transaction. // This avoids the nested-transaction problem when called from within Rebuild's // single wrapping transaction. Identical logic to ApplyTag but accepts a db.Querier. func ApplyTagWithTx(q db.Querier, opts ApplyOpts) error { if q == nil { panic("tag.ApplyTagWithTx: q must not be nil") } if opts.TagName == "" { panic("tag.ApplyTagWithTx: opts.TagName must not be empty") } if opts.TargetRID <= 0 { panic("tag.ApplyTagWithTx: opts.TargetRID must be positive") } tagid, err := ensureTag(q, opts.TagName) if err != nil { return fmt.Errorf("ensure tag %q: %w", opts.TagName, err) } if tagid == leaf.BranchTagID && opts.Leaves == nil { panic("tag.ApplyTagWithTx: a branch tag needs opts.Leaves") } skip, err := superseded(q, tagid, opts.TargetRID, opts.MTime) if err != nil { return err } if skip { return nil } reach, err := replacesPropagatingBranch(q, tagid, opts) if err != nil { return err } if _, err := q.Exec( `INSERT OR REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) VALUES(?, ?, ?, ?, ?, ?, ?)`, tagid, opts.TagType, opts.SrcRID, opts.TargetRID, nullableValue(opts.Value), opts.MTime, opts.TargetRID, ); err != nil { return fmt.Errorf("tagxref insert: %w", err) } // tag_insert schedules the check-in and its parents once its branch row // is written (src/tag.c:195), whatever the tag type. if tagid == leaf.BranchTagID { if err := opts.Leaves.Schedule(q, opts.TargetRID); err != nil { return err } } if err := applyEventOverride(q, opts); err != nil { return err } if err := applyDateOverride(q, opts); err != nil { return err } if err := propagateAfterInsert(q, tagid, opts, reach); err != nil { return fmt.Errorf("propagate: %w", err) } return nil } // eventTagColumns maps the tags that override a check-in's derived event row // to the column each one sets, as tag_insert's switch does (src/tag.c). var eventTagColumns = map[string]string{ "bgcolor": "bgcolor", "user": "euser", "comment": "ecomment", } // applyEventOverride sets event.bgcolor, euser or ecomment from a bgcolor, // user or comment tag that has just won its tagxref row. Like tag_insert it // does this for every tag type, writing NULL for a cancel, so a singleton or // cancelled bgcolor takes effect too. A check-in crosslinked later reads the // same rows back; see crosslinkCheckinTables. func applyEventOverride(q db.Querier, opts ApplyOpts) error { col, ok := eventTagColumns[opts.TagName] if !ok { return nil } var value any if opts.TagType != TagCancel { // Empty maps to NULL so this agrees with the tagxref read-back // crosslink does later. value = nullableValue(opts.Value) } // col comes from the fixed map above, never from input. stmt := "UPDATE event SET " + col + "=? WHERE objid=?" if _, err := q.Exec(stmt, value, opts.TargetRID); err != nil { return fmt.Errorf("%s update: %w", opts.TagName, err) } return nil } // applyDateOverride re-derives a check-in's event time from its date tag // (`fossil amend --date`) once that tag has won its tagxref row. func applyDateOverride(q db.Querier, opts ApplyOpts) error { if opts.TagName != "date" { return nil } if err := RefreshEventTime(q, opts.TargetRID); err != nil { return fmt.Errorf("date update: %w", err) } return nil } // RefreshEventTime sets rid's event.mtime from its date tag, following // draft-fossil-repo-state-01 Section 4.2, step 6: the value of a date tag // applied directly to rid (srcid > 0), when it parses (by SQLite // julianday, which already bounds the upper end) to a positive time; // otherwise the D-card time kept in event.omtime. An inherited row // (srcid 0, from a propagating tag on an ancestor) and a cancel set nothing. // // Both arrival orders call this, tag application after it wins its row and // crosslink after it inserts the event row, so the result depends only on // tagxref, never on which came first. That is where -01 departs from fossil // 2.28: its tag_insert writes julianday(value) as is, so a cancel or an // unparsable value leaves no time, 0 stores a time of 0, and its // manifest_crosslink reads an inherited row too. omtime keeps the D-card // time (RS-4.2-15); coalesce fills it for an event row that predates it. // A missing event row (a phantom target) is a no-op. func RefreshEventTime(q db.Querier, rid libfossil.FslID) error { if q == nil { panic("tag.RefreshEventTime: q must not be nil") } if rid <= 0 { panic("tag.RefreshEventTime: rid must be positive") } // In an UPDATE every right-hand side reads the row as it was, so the // fallback is the stored omtime, or, for a row without one, the old mtime. _, err := q.Exec( `UPDATE event SET mtime=coalesce((SELECT jd FROM (SELECT julianday(value) AS jd FROM tagxref WHERE rid=?1 AND srcid>0 AND tagtype>0 AND tagid=(SELECT tagid FROM tag WHERE tagname='date')) WHERE jd>0 AND jd<5373484.5), omtime, mtime), omtime=coalesce(omtime, mtime) WHERE objid=?1`, rid, ) return err } // superseded reports whether tagxref already holds a row for (tagid, rid) // whose mtime is at least as new as the application being attempted. // // Canonical fossil opens tag_insert with exactly this query and returns // without touching tagxref -- and, just as importantly, without calling // tag_propagate -- when it matches (src/tag.c:173-186): // // SELECT 1 FROM tagxref WHERE tagid=%d AND rid=%d AND mtime>=:mtime // ... // if( rc==SQLITE_ROW ){ // /* Another entry that is more recent already exists. Do nothing */ // return tagid; // } // // This is what makes the resulting tagxref state independent of the order // artifacts are applied in. A check-in's inline T-cards and a control artifact // that later retagged the same check-in reach us in whichever order the sweep // visits them; the newer one has to win either way. Dropping the propagation // matters as much as dropping the write: replaying a stale branch value from // a check-in that has since been retagged floods every descendant with it // (issue #198). // // The comparison is >=, not >, so equal mtimes leave the standing row alone, // matching fossil. func superseded(q db.Querier, tagid int64, rid libfossil.FslID, mtime float64) (bool, error) { var one int err := q.QueryRow( "SELECT 1 FROM tagxref WHERE tagid=? AND rid=? AND mtime>=?", tagid, rid, mtime, ).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("tagxref mtime check: %w", err) } return true, nil } // propagateAfterInsert runs the propagation half of canonical's tag_insert, // which downgrades a singleton to a cancel before propagating and then // propagates unconditionally (src/tag.c:239): // // if( tagtype==1 ) tagtype = 0; // tag_propagate(rid, tagid, tagtype, rid, zValue, mtime); // // A singleton (+) tag therefore does not merely fail to propagate its own // value -- it actively blocks a same-named propagating tag at this artifact, // deleting the copies descendants had inherited. Confirmed against fossil // 2.28: applying `tag add --raw zz A v2` over an inherited `zz` removes the // child's row. func propagateAfterInsert( q db.Querier, tagid int64, opts ApplyOpts, reachAbsent bool, ) error { tagType := opts.TagType if tagType == TagSingleton { tagType = TagCancel } pr := propagation{ tagid: tagid, tagType: tagType, origID: opts.TargetRID, mtime: opts.MTime, value: opts.Value, tagName: opts.TagName, leaves: opts.Leaves, reachAbsent: reachAbsent && tagType == TagCancel, } return pr.propagate(q, opts.TargetRID) } // replacesPropagatingBranch is whether this write replaces a propagating // branch row with a cancel or a singleton. The row it replaces was handed down // the primary children when it landed, in fossil, and the walk deletes those // copies and rechecks each (src/tag.c:100-102). A crosslink batch defers // propagation to its repair gate, so the copies may not exist yet; the walk // then reaches the children that hold none (propagation.reachAbsent), which are // the check-ins fossil would have rechecked (Section 7.3, RS-7.3-3). Only a // branch tag matters: no other tag schedules a recheck. func replacesPropagatingBranch(q db.Querier, tagid int64, opts ApplyOpts) (bool, error) { if tagid != leaf.BranchTagID || opts.TagType == TagPropagating { return false, nil } var old int err := q.QueryRow("SELECT tagtype FROM tagxref WHERE tagid=? AND rid=?", tagid, opts.TargetRID).Scan(&old) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("tagxref branch row: %w", err) } return old == TagPropagating, nil } // ensureTag returns the tagid for the given tag name, creating it if it doesn't exist. func ensureTag(q db.Querier, name string) (int64, error) { if q == nil { panic("tag.ensureTag: q must not be nil") } if name == "" { panic("tag.ensureTag: name must not be empty") } var tagid int64 err := q.QueryRow("SELECT tagid FROM tag WHERE tagname=?", name).Scan(&tagid) if err == nil { return tagid, nil } result, err := q.Exec("INSERT INTO tag(tagname) VALUES(?)", name) if err != nil { return 0, err } return result.LastInsertId() } // nullableValue is the tagxref.value for a tag value: NULL for a tag that has // none, as fossil stores it (#258). A T card cannot carry an empty value, so // "" always means absent; storing an empty string would read as a tag with // an empty value. func nullableValue(v string) any { if v == "" { return nil } return v }