Artifact 6f2974235e476edd5cad908ae4ca5bb2f1d734543534ae54f57eb06feec95f60:
- File
internal/manifest/crosslink.go
— part of check-in
[9864120a32]
at
2026-07-20 02:36:37
on branch trunk
— perf: memoize delta-chain interiors during crosslink (#107)
* perf: memoize delta-chain interiors during crosslink (#89)
manifest.Crosslink ran at roughly 1.4 events/sec on the Fossil SCM repository, which puts a 27,000-artifact clone at two and a half hours of CPU after the transfer has already finished. A CPU profile of a sweep over that corpus attributes 152.77s of Crosslink's 167.20s -- 91% -- to content.Expand, and 126.35s of that to the single blob.Load inside its delta-replay loop.
The cause is chain depth, not per-event overhead: not a transaction per event, not a missing index, not a full-table scan. content_deltify stores a file's older versions as deltas against its newer ones, so one file's history is a single chain -- 469 nodes deep on average across that repository, 1,032 for the blobs carrying artifacts, 2,546 at worst. Expand walked each chain from its root on every call and Crosslink calls it once per blob, so the sweep performed 31.7 million delta applications to expand 67,451 blobs.
Cache.Expand now stops the chain walk at the deepest ancestor it already holds and keeps every node it materializes on the way forward, and Crosslink runs one such cache for the sweep. Blob content is immutable once written and the candidate rids are snapshotted before the loop, so nothing a sweep does can invalidate an entry.
Every node handed to the cache is verified against the UUID its blob row is stored under before it is stored, because a cached interior is served later as if it had been expanded in its own right. The number of hash checks per sweep does not change: each blob is materialized once and verified once, where before each was expanded once and verified once. A plain Expand, with no cache, walks and verifies exactly as it did.
IsAvailable folds its two-statements-per-chain-node walk into one. It runs once per F-card of every check-in being crosslinked, over the same deep chains, and every SQL round trip there pays a WAL read-lock acquisition -- the round trips, not the work they do, are the cost.
No transaction boundary moves and no batching is introduced. A manifest that fails to crosslink still fails on its own, exactly where it did.
Measured on the Fossil SCM repository, 67,451 content blobs and 27,060 artifacts. Time for Crosslink to write its first 300 event rows: 221s before, 131s after. Over the expansion path in isolation, sweeping the same corpus in rid order: 22.9 blobs/sec with no cache, 50.1 at the 256 MiB budget set here, 414 at 1 GiB -- where the process needs 2.9 GB resident, which is why 1 GiB is not the number.
A bounded cache cannot close the gap on its own. Chains run from a high-rid root down to low-rid ancestors while the sweep visits rids ascending, so the first candidate of a chain materializes all of it and the remaining nodes are consumed arbitrarily far in the future; the working set of a whole-repository sweep in rid order is the whole repository expanded, 8 GiB for this corpus. Closing it means visiting candidates in delta-chain order, which is how Fossil's own rebuild avoids the cost, and which changes the order-sensitive state Crosslink writes -- leaf, tag propagation, tech-note event replacement, forum thread titles. That is a separate change.
TestFossilBinaryReadsCrosslinkedRepo covers the acceptance criterion this cannot self-certify: a repository whose relational tables were written through the accelerated path passes fossil 2.28's test-integrity, shows its tip in fossil timeline, survives fossil rebuild, and its event, plink, leaf and mlink rows match, column for column, what fossil derives from the same blobs.
* fix: bound and cycle-check the delta-chain walks (#89)
IsAvailable walked the delta chain with no cycle detection, to a bound of 100,000 against real chains whose deepest observed node count is 2,546. It is wire-reachable -- sync/clone.go, sync/handler.go, and once per F-card of every check-in Crosslink processes -- so a peer that plants a two-node delta cycle bought a 100,000-iteration query loop on every call, inside whatever process embeds this library.
Both halves are fixed. Each walk now carries a visited set and stops at the first repeat, which is the actual defence: a cycle costs its own length, not the bound. maxDeltaChainDepth replaces the old constant at 16,384, roughly six times the deepest chain measured on the Fossil SCM repository, and it is now a backstop for an acyclic-but-absurd chain rather than a number an attacker spends. walkDeltaChain, which the memoizing expansion path made hotter, previously had no bound at all and now shares the same one.
This is what made TestIsAvailable_CyclicChainTerminates sit at ~99% of its 30s budget on main and fail outright under -race here: the cyclic walk was running the full bound. It now finishes in about a second under -race, six runs out of six.
walkDeltaChain returns the content it stopped on instead of leaving expandChain to ask the store a second time. The truncation decision and the use of it were separate steps, so a concurrent eviction between them would send a delta row to blob.Load and hand raw delta bytes to the replay as full text. bindToName caught that and failed closed, but the caller saw a hash mismatch on an artifact that was not corrupt, and Crosslink's error path drops those silently. Unreachable while the cache is a single-goroutine local, but Cache is documented concurrency-safe and takes its mutex on every method, so the type promised something the expansion path did not deliver.
Cache entries are clipped to their length before being stored, so curSize is the bytes actually retained rather than an allocator-dependent approximation of them.
Test corrections: the corpus harness no longer closes the repository handle out from under the sweep goroutine when it returns at the event target; the sweep owns the handle and closes it. snapshotDerived now fails on an empty digest rather than comparing "" to "" and passing without examining a row. The fail-closed injector matches the merged chain-step statement specifically rather than any query mentioning delta, and says what it can and cannot prove now that the two lookups are one statement. The fossilbin comparison documents that it is not full-table equivalence, and which tables it leaves uncompared.
MEASUREMENTS, SUPERSEDING THOSE IN 2426591
The figures in the parent commit were taken before clipping, and its "221s -> 131s" pair predates the double-hash and IsAvailable fixes that landed in that same commit. Everything below was re-measured on the code as it ships, one run at a time on an idle machine, against the Fossil SCM repository (67,451 content blobs, 27,060 artifacts).
Crosslink to its first 300 event rows, cpuprofile on throughout:
original code 221.4s as shipped 103.9s 2.1x
Expanding the first 4,000 blobs, which isolates the expansion path:
budget throughput peak RSS entries held none 22.1 blobs/s 169 MB 0 0 256 MiB 52.0 blobs/s 1036 MB 543 255 MiB 1 GiB 468.3 blobs/s 2278 MB 15,842 1021 MiB
Clipping changed neither throughput nor memory. Crosslink to 300 events runs 103.9s clipped against 105.9s unclipped, peak RSS 1067 MB against 1050 MB, and the cache holds the identical 543 entries and 255 MiB at a 256 MiB budget either way -- at these sizes the buffers already arrive tightly sized. It is worth keeping as an accounting guarantee, not as a saving, and the comment on clip says so.
The earlier claim that capacity slack was why a 256 MiB cache reached nearly a gigabyte resident was wrong. Peak RSS tracks the no-cache baseline plus roughly twice the budget, because the cache is live data and the collector sizes the heap against it: 169 + 2x1021 = 2211 against 2278 observed at 1 GiB. That is why the budget stays at 256 MiB -- a gigabyte of cache costs over two gigabytes resident to clone a 72 MB repository. The shape of the curve did not move.
* docs: correct the claims around the chain bound and the RSS model (#89)
Three claims did not survive checking, and one bound was a node out.
maxDeltaChainDepth said it bounds "every delta-chain walk in this package". It does not: deltifyBreaksLoop is a third walk, under deltifyMaxChainWalk, which is 64 times larger. The rationale on the new constant applies to that walk just as well, but changing it would change deltify's shipped behaviour, so the sentence is narrowed to the two read-path walks it actually governs and points at the third rather than quietly covering it.
Two comments still named maxAvailabilityChainDepth, which no longer exists, and argued from IsAvailable having a bound "of its own" -- the framing of a world where the two walks had separate bounds. Against a shared constant that argument reads backwards. Both now name the shared bound, and the reason deltifyBreaksLoop keeps its own visited set and its own step cap is stated as the general rule it is: every walk over the peer-shaped delta graph carries both, and none of them relies on another walk's guard.
The retraction in the previous commit replaced one unsupported mechanism with another. "Peak RSS runs at roughly the baseline plus twice the budget" is a two-point fit whose worse point is the shipped one: it is 3% off at 1 GiB and 53% under at 256 MiB, where resident memory is four times the budget rather than twice. The measured table stays; the model is demoted to the direction the data supports, that peak RSS grows faster than the budget, with the fit's own weakness stated.
walkDeltaChain counted loop iterations where IsAvailable counts nodes visited, so the shared bound bit a node apart: a chain of exactly maxDeltaChainDepth+1 was unavailable to one walk and complete to the other. It now counts nodes, and TestChainBoundBitesAtSameLength pins the agreement at both boundaries -- nothing else ties the two loops together. (user: [email protected], size: 32283) [annotate] [blame] [check-ins using]
package manifest import ( "fmt" "log/slog" "sort" libfossil "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/blob" "github.com/danmestas/libfossil/internal/content" "github.com/danmestas/libfossil/db" "github.com/danmestas/libfossil/internal/deck" "github.com/danmestas/libfossil/internal/repo" "github.com/danmestas/libfossil/internal/tag" ) // attachTargetTypeName maps attachment target type codes to human-readable names. // Used by crosslinkAttachment and updateAttachmentComments. var attachTargetTypeName = map[byte]string{ 'w': "wiki page", 't': "ticket", 'e': "tech note", } // crosslinkCacheBytes bounds the expanded content one Crosslink sweep keeps // live. A miss costs throughput, not correctness: the walk simply continues // further back toward the chain root. // // It cannot be sized to eliminate misses. A chain runs from a high-rid root // down to low-rid ancestors, and the sweep visits rids ascending, so the // first candidate of a chain materializes all of it and the rest are consumed // arbitrarily far in the future — the working set of a whole-repository sweep // in rid order is the whole repository expanded, 8 GiB for the Fossil SCM // repository. This budget buys the locally-coherent runs, which is most of // the win available without changing the order the sweep visits candidates in. // // Measured against that repository, expanding its first 4,000 blobs: // // budget throughput peak RSS // none 22.1 blobs/s 169 MB // 256 MiB 52.0 blobs/s 1036 MB // 1 GiB 468.3 blobs/s 2278 MB // // Raising the budget is the one lever that moves this a lot, and it is not // cheap: peak RSS grows faster than the budget does, because the cache is live // data and the collector sizes the heap against it. The measurements above // bound that badly -- three points do not fit a curve, and resident memory at // 256 MiB is four times the budget where at 1 GiB it is only twice -- but the // direction is not in doubt. A gigabyte of cache costs over two gigabytes // resident to clone a 72 MB repository, which is why 1 GiB is not the number // here. const crosslinkCacheBytes = 256 << 20 type pendingItem struct { Type byte // 'w' = wiki backlink, 't' = ticket rebuild ID string } // Crosslink scans all blobs not yet crosslinked in event/tagxref/forumpost/attachment tables, // parses them as manifests, and populates cross-reference tables (event/plink/leaf/mlink/tagxref). // This is the Go equivalent of Fossil's manifest_crosslink. func Crosslink(r *repo.Repo) (int, error) { if r == nil { panic("manifest.Crosslink: r must not be nil") } // Pass 1: Discover and crosslink all uncrosslinked artifacts. // ORDER BY b.rid: deferred manifests re-discovered across sweeps must // be processed in stable order. Without it, two syncs delivering the // same blobs in different arrival orders could produce divergent // per-defer slog streams and pending-item processing orders, masking // determinism bugs in downstream code. rows, err := r.DB().Query(` SELECT b.rid, b.uuid FROM blob b WHERE b.size >= 0 AND NOT EXISTS (SELECT 1 FROM event e WHERE e.objid = b.rid) AND NOT EXISTS (SELECT 1 FROM tagxref tx WHERE tx.srcid = b.rid) AND NOT EXISTS (SELECT 1 FROM forumpost fp WHERE fp.fpid = b.rid) AND NOT EXISTS (SELECT 1 FROM attachment a WHERE a.attachid = b.rid) ORDER BY b.rid `) if err != nil { return 0, fmt.Errorf("manifest.Crosslink query: %w", err) } defer rows.Close() type candidate struct { rid libfossil.FslID uuid string } var candidates []candidate for rows.Next() { var c candidate if err := rows.Scan(&c.rid, &c.uuid); err != nil { return 0, fmt.Errorf("manifest.Crosslink scan: %w", err) } candidates = append(candidates, c) } if err := rows.Err(); err != nil { return 0, fmt.Errorf("manifest.Crosslink rows: %w", err) } // One cache for the sweep. Candidate rids were snapshotted above and // blob content is immutable once written, so nothing this loop does can // invalidate an entry; the cache is dropped when the sweep returns. // // It exists for the delta chains, not for repeated lookups of the same // rid — each candidate is expanded exactly once. A repository that has // been through content_deltify stores a file's older versions as deltas // against its newer ones, so one file's history is a single chain // thousands of nodes deep, and expanding every node of it from the chain // root is quadratic in the length of the chain. Keeping what the walk // materialized turns the repeats into hits wherever the budget reaches. // See internal/content.Cache.Expand and crosslinkCacheBytes. cache := content.NewCache(crosslinkCacheBytes) linked := 0 var deferredRids []libfossil.FslID missingBlobs := make(map[string]struct{}) var pending []pendingItem for _, c := range candidates { data, err := cache.Expand(r.DB(), c.rid) if err != nil { continue // not expandable, skip } d, err := deck.Parse(data) if err != nil { continue // not a valid manifest, skip } // Defer Checkin manifests whose referenced file blobs (F-cards) or // delta baseline (B-card) haven't all arrived locally yet. The // manifest blob remains durable in 'blob'; we just skip writing // event/leaf/plink/mlink in this sweep so a downstream // Checkout.Update walking the manifest's F-cards via // manifest.ListFiles doesn't hit // `expandUUID: blob not found for uuid <hex>` mid-traversal. // // Surfaced by agent-infra trial #10 under 16-way concurrent // fork+merge: a leaf Pulled a multi-blob session in which the // merge manifest landed before its file blobs, the original // crosslink ran with insertCheckinMlinks silently skipping // missing-blob F-cards, and the next Update on that leaf failed. // The next sync round that delivers the missing blob also // triggers another Crosslink sweep (HandleSync runs Crosslink // whenever filesRecvd > 0); the candidate query selects this // rid again because no event row was written, and the Checkin // crosslinks completely. if d.Type == deck.Checkin { if missing := missingCheckinRefs(r, d); len(missing) > 0 { deferredRids = append(deferredRids, c.rid) for _, u := range missing { missingBlobs[u] = struct{}{} } slog.Debug("manifest.Crosslink: deferring checkin", "rid", c.rid, "uuid", c.uuid, "missing_count", len(missing), "first_missing", missing[0]) continue } } var linkErr error var p []pendingItem switch d.Type { case deck.Checkin: linkErr = crosslinkCheckin(r, c.rid, d) case deck.Wiki: p, linkErr = crosslinkWiki(r, c.rid, d) case deck.Ticket: p, linkErr = crosslinkTicket(r, c.rid, d) case deck.Event: p, linkErr = crosslinkEvent(r, c.rid, d) case deck.Attachment: linkErr = crosslinkAttachment(r, c.rid, d) case deck.Cluster: linkErr = CrosslinkCluster(r.DB(), c.rid, d) case deck.ForumPost: linkErr = crosslinkForum(r, c.rid, d) case deck.Control: linkErr = crosslinkControl(r, c.rid, d) default: continue } if linkErr != nil { return linked, fmt.Errorf("manifest.Crosslink rid=%d type=%d: %w", c.rid, d.Type, linkErr) } linked++ pending = append(pending, p...) } if len(deferredRids) > 0 { // Sort missing-blob UUIDs so the rollup is byte-identical across // runs that defer the same set, regardless of map iteration order. distinctMissing := make([]string, 0, len(missingBlobs)) for u := range missingBlobs { distinctMissing = append(distinctMissing, u) } sort.Strings(distinctMissing) slog.Info("manifest.Crosslink: deferred checkins awaiting missing blobs", "deferred", len(deferredRids), "linked", linked, "deferred_rids", deferredRids, "missing_blob_count", len(distinctMissing), "missing_blobs", distinctMissing) } // Pass 2: Process pending items (wiki backlinks, ticket rebuilds). for _, item := range pending { _ = item // Stubs return nil, nothing to process yet. } return linked, nil } // missingCheckinRefs returns the list of UUIDs referenced by a Checkin // manifest whose blobs are not yet present locally. References checked: // - B-card: the baseline manifest UUID for delta manifests. Without // the baseline, ListFiles cannot resolve the effective F-card set. // - F-cards: every (non-deleted) file UUID. These are the targets // Checkout.Update.expandUUID will need. // // Empty result means crosslink is safe to run; non-empty means defer // to a later sweep that will discover the manifest again (no event row // was written, so the candidate query re-selects this rid). // // Divergence from fossil-scm/c: fossil's reference uses an `rcvfrom` // table + deferred-flush at content arrival; the Go port reuses the // existing whole-repo sweep semantics by checking presence at sweep // time. The candidate query naturally re-discovers deferred manifests // because we do not write any event/leaf/plink/mlink/tagxref rows for // them. func missingCheckinRefs(r *repo.Repo, d *deck.Deck) []string { if r == nil { panic("manifest.missingCheckinRefs: r must not be nil") } if d == nil { panic("manifest.missingCheckinRefs: d must not be nil") } var missing []string seen := make(map[string]struct{}) check := func(uuid string) { if uuid == "" { return } if _, dup := seen[uuid]; dup { return } seen[uuid] = struct{}{} if _, ok := content.AvailableByUUID(r.DB(), uuid); !ok { missing = append(missing, uuid) } } check(d.B) for _, f := range d.F { check(f.UUID) // skipped if "" (deleted file in delta manifest) } return missing } func crosslinkCheckin(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) error { if r == nil { panic("crosslinkCheckin: r must not be nil") } if rid <= 0 { panic("crosslinkCheckin: rid must be positive") } if err := crosslinkCheckinTables(r, rid, d); err != nil { return err } return applyInlineTags(r, rid, d) } // crosslinkCheckinTables populates event/plink/leaf/mlink/cherrypick in a single transaction. func crosslinkCheckinTables(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) error { return r.WithTx(func(tx *db.Tx) error { // event if _, err := tx.Exec( "INSERT OR IGNORE INTO event(type, mtime, objid, user, comment) VALUES('ci', ?, ?, ?, ?)", libfossil.TimeToJulian(d.D), rid, d.U, d.C, ); err != nil { return fmt.Errorf("event: %w", err) } // Resolve baseid for plink if B-card present var baseid any = nil if d.B != "" { var baseRid int64 if err := tx.QueryRow("SELECT rid FROM blob WHERE uuid=?", d.B).Scan(&baseRid); err == nil { baseid = baseRid } } if err := insertCheckinPlinks(tx, rid, d, baseid); err != nil { return err } if err := updateLeafTable(tx, rid, d); err != nil { return err } if err := insertCheckinMlinks(tx, rid, d); err != nil { return err } return insertCherrypicks(tx, rid, d) }) } // insertCheckinPlinks inserts plink rows for each parent (P-card). func insertCheckinPlinks(tx *db.Tx, rid libfossil.FslID, d *deck.Deck, baseid any) error { for i, parentUUID := range d.P { var parentRid int64 if err := tx.QueryRow("SELECT rid FROM blob WHERE uuid=?", parentUUID).Scan(&parentRid); err != nil { continue // parent blob missing, skip } isPrim := 0 if i == 0 { isPrim = 1 } if _, err := tx.Exec( "INSERT OR IGNORE INTO plink(pid, cid, isprim, mtime, baseid) VALUES(?, ?, ?, ?, ?)", parentRid, rid, isPrim, libfossil.TimeToJulian(d.D), baseid, ); err != nil { return fmt.Errorf("plink: %w", err) } } return nil } // updateLeafTable marks this checkin as a leaf and removes parents from the leaf table. func updateLeafTable(tx *db.Tx, rid libfossil.FslID, d *deck.Deck) error { if _, err := tx.Exec("INSERT OR IGNORE INTO leaf(rid) VALUES(?)", rid); err != nil { return fmt.Errorf("leaf insert: %w", err) } // Remove parent from leaf table (it now has a child) for _, parentUUID := range d.P { var parentRid int64 if err := tx.QueryRow("SELECT rid FROM blob WHERE uuid=?", parentUUID).Scan(&parentRid); err != nil { continue } if _, err := tx.Exec("DELETE FROM leaf WHERE rid=?", parentRid); err != nil { return fmt.Errorf("leaf delete parent %d: %w", parentRid, err) } } return nil } // insertCheckinMlinks inserts mlink rows for each file mapping (F-card). // An F-card with an empty UUID is an explicit deletion (delta-manifest // convention); it still gets a row, with fid=0, so `fossil ls -r` and // compute_fileage's ancestry walk see it — see insertMlinkRow for the // parent-column convention shared with the direct check-in path. func insertCheckinMlinks(tx *db.Tx, rid libfossil.FslID, d *deck.Deck) error { primaryParentMid, mergeParentMids := resolveParentMids(tx, d) for _, f := range d.F { fnid, err := ensureFilename(tx, f.Name) if err != nil { return fmt.Errorf("filename %q: %w", f.Name, err) } var fileRid int64 if f.UUID != "" { if err := tx.QueryRow("SELECT rid FROM blob WHERE uuid=?", f.UUID).Scan(&fileRid); err != nil { continue // file blob missing } } if err := insertMlinkRow(tx, rid, fileRid, fnid, f.OldName, f.Perm, primaryParentMid, mergeParentMids); err != nil { return err } } return nil } // resolveParentMids resolves a checkin manifest's P-card UUIDs to blob // rids: the first is the primary parent, any remaining are merge parents. // A parent UUID whose blob has not arrived locally is skipped, mirroring // insertCheckinPlinks' existing tolerance for missing parent blobs. func resolveParentMids(tx *db.Tx, d *deck.Deck) (primaryParentMid libfossil.FslID, mergeParentMids []libfossil.FslID) { if tx == nil { panic("manifest.resolveParentMids: tx must not be nil") } if d == nil { panic("manifest.resolveParentMids: d must not be nil") } if len(d.P) > maxMlinkMergeParents { panic("manifest.resolveParentMids: d.P exceeds bound") } for i, parentUUID := range d.P { var parentRid int64 if err := tx.QueryRow("SELECT rid FROM blob WHERE uuid=?", parentUUID).Scan(&parentRid); err != nil { continue // parent blob missing, skip } if i == 0 { primaryParentMid = libfossil.FslID(parentRid) } else { mergeParentMids = append(mergeParentMids, libfossil.FslID(parentRid)) } } return primaryParentMid, mergeParentMids } // insertCherrypicks inserts cherrypick rows for Q-cards (cherrypick/backout). func insertCherrypicks(tx *db.Tx, rid libfossil.FslID, d *deck.Deck) error { for _, cp := range d.Q { target := cp.Target isExclude := 0 if cp.IsBackout { isExclude = 1 } var parentRid int64 if err := tx.QueryRow("SELECT rid FROM blob WHERE uuid=?", target).Scan(&parentRid); err != nil { continue // target blob missing, skip } if _, err := tx.Exec( "REPLACE INTO cherrypick(parentid, childid, isExclude) VALUES(?, ?, ?)", parentRid, rid, isExclude, ); err != nil { return fmt.Errorf("cherrypick: %w", err) } } return nil } // applyInlineTags processes T-cards with UUID="*" (self-referencing tags) and propagates from parent. func applyInlineTags(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) error { mtime := libfossil.TimeToJulian(d.D) for _, tc := range d.T { if tc.UUID != "*" { continue } var tagType int switch tc.Type { case deck.TagPropagating: tagType = tag.TagPropagating case deck.TagSingleton: tagType = tag.TagSingleton case deck.TagCancel: tagType = tag.TagCancel default: continue } if err := tag.ApplyTag(r, tag.ApplyOpts{ TargetRID: rid, SrcRID: rid, // inline: checkin is its own source TagName: tc.Name, TagType: tagType, Value: tc.Value, MTime: mtime, }); err != nil { return fmt.Errorf("inline tag %q: %w", tc.Name, err) } } // PropagateAll from primary parent (if checkin has parents) if len(d.P) > 0 { var primaryParentRid int64 if err := r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", d.P[0]).Scan(&primaryParentRid); err == nil { if err := tag.PropagateAll(r.DB(), libfossil.FslID(primaryParentRid)); err != nil { return fmt.Errorf("propagate from parent: %w", err) } } } return nil } func crosslinkControl(r *repo.Repo, srcRID libfossil.FslID, d *deck.Deck) error { if r == nil { panic("crosslinkControl: r must not be nil") } if srcRID <= 0 { panic("crosslinkControl: rid must be positive") } mtime := libfossil.TimeToJulian(d.D) for _, tc := range d.T { if tc.UUID == "*" { continue // self-referencing — handled in crosslinkCheckin } var targetRID int64 if err := r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", tc.UUID).Scan(&targetRID); err != nil { continue // target not found } var tagType int switch tc.Type { case deck.TagPropagating: tagType = tag.TagPropagating case deck.TagSingleton: tagType = tag.TagSingleton case deck.TagCancel: tagType = tag.TagCancel default: continue } if err := tag.ApplyTag(r, tag.ApplyOpts{ TargetRID: libfossil.FslID(targetRID), SrcRID: srcRID, TagName: tc.Name, TagType: tagType, Value: tc.Value, MTime: mtime, }); err != nil { return fmt.Errorf("apply tag %q to rid=%d: %w", tc.Name, targetRID, err) } } // Generate event row with type='g' and descriptive comment. comment := buildControlComment(d) if _, err := r.DB().Exec( "REPLACE INTO event(type, mtime, objid, user, comment) VALUES('g', ?, ?, ?, ?)", mtime, srcRID, d.U, comment, ); err != nil { return fmt.Errorf("control event: %w", err) } return nil } // buildControlComment generates a human-readable comment from a control artifact's T-cards. func buildControlComment(d *deck.Deck) string { var comment string for _, tc := range d.T { if tc.UUID == "*" { continue } prefix := string(tc.Type) name := tc.Name val := tc.Value switch { case prefix == "*" && name == "branch": comment += fmt.Sprintf(" Move to branch %s.", val) case prefix == "*" && name == "bgcolor": comment += fmt.Sprintf(" Change branch background color to %q.", val) case prefix == "+" && name == "bgcolor": comment += fmt.Sprintf(" Change background color to %q.", val) case prefix == "-" && name == "bgcolor": comment += " Cancel background color." case prefix == "+" && name == "comment": comment += " Edit check-in comment." case prefix == "+" && name == "user": comment += fmt.Sprintf(" Change user to %q.", val) default: switch prefix { case "-": comment += fmt.Sprintf(" Cancel %q.", name) case "+": comment += fmt.Sprintf(" Add %q.", name) case "*": comment += fmt.Sprintf(" Add propagating %q.", name) } } } if comment == "" { comment = " " } return comment } // addFWTPlink handles plink insertion and tag propagation for wiki/forum/technote/ticket. // Shared helper for artifact types that use P-cards (parents) but not the full checkin flow. func addFWTPlink(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) error { if r == nil { panic("manifest.addFWTPlink: r must not be nil") } if rid <= 0 { panic("manifest.addFWTPlink: rid must be positive") } mtime := libfossil.TimeToJulian(d.D) var primaryParentRid libfossil.FslID for i, parentUUID := range d.P { var parentRid int64 if err := r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", parentUUID).Scan(&parentRid); err != nil { continue // parent blob missing, skip } isPrim := 0 if i == 0 { isPrim = 1 primaryParentRid = libfossil.FslID(parentRid) } if _, err := r.DB().Exec( "INSERT OR IGNORE INTO plink(pid, cid, isprim, mtime) VALUES(?, ?, ?, ?)", parentRid, rid, isPrim, mtime, ); err != nil { return fmt.Errorf("addFWTPlink: %w", err) } } // Propagate tags from primary parent if primaryParentRid > 0 { if err := tag.PropagateAll(r.DB(), primaryParentRid); err != nil { return fmt.Errorf("addFWTPlink propagate: %w", err) } } return nil } func crosslinkWiki(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) ([]pendingItem, error) { if r == nil { panic("crosslinkWiki: r must not be nil") } if rid <= 0 { panic("crosslinkWiki: rid must be positive") } if err := addFWTPlink(r, rid, d); err != nil { return nil, fmt.Errorf("wiki plink: %w", err) } title := d.L if title == "" { return nil, fmt.Errorf("wiki manifest missing title (L-card)") } // Apply wiki-<title> tag with value = content length wikiLen := fmt.Sprintf("%d", len(d.W)) if err := tag.ApplyTag(r, tag.ApplyOpts{ TargetRID: rid, SrcRID: rid, TagName: fmt.Sprintf("wiki-%s", title), TagType: tag.TagSingleton, Value: wikiLen, MTime: libfossil.TimeToJulian(d.D), }); err != nil { return nil, fmt.Errorf("wiki tag: %w", err) } // Insert event row with prefix: '+' = new, ':' = edit, '-' = delete var prefix byte if len(d.W) == 0 { prefix = '-' // deletion } else if len(d.P) == 0 { prefix = '+' // new page } else { prefix = ':' // edit } comment := fmt.Sprintf("%c%s", prefix, title) if _, err := r.DB().Exec( "REPLACE INTO event(type, mtime, objid, user, comment) VALUES('w', ?, ?, ?, ?)", libfossil.TimeToJulian(d.D), rid, d.U, comment, ); err != nil { return nil, fmt.Errorf("wiki event: %w", err) } return []pendingItem{{Type: 'w', ID: title}}, nil } func crosslinkTicket(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) ([]pendingItem, error) { if r == nil { panic("crosslinkTicket: r must not be nil") } if rid <= 0 { panic("crosslinkTicket: rid must be positive") } ticketUUID := d.K if ticketUUID == "" { return nil, fmt.Errorf("ticket manifest missing UUID (K-card)") } if err := tag.ApplyTag(r, tag.ApplyOpts{ TargetRID: rid, SrcRID: rid, TagName: fmt.Sprintf("tkt-%s", ticketUUID), TagType: tag.TagSingleton, MTime: libfossil.TimeToJulian(d.D), }); err != nil { return nil, fmt.Errorf("ticket tag: %w", err) } if err := updateAttachmentComments(r, ticketUUID, 't'); err != nil { return nil, fmt.Errorf("ticket attachment comments: %w", err) } return []pendingItem{{Type: 't', ID: ticketUUID}}, nil } func crosslinkEvent(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) ([]pendingItem, error) { if r == nil { panic("crosslinkEvent: r must not be nil") } if rid <= 0 { panic("crosslinkEvent: rid must be positive") } if d.E == nil { return nil, fmt.Errorf("event manifest missing E-card") } if err := addFWTPlink(r, rid, d); err != nil { return nil, fmt.Errorf("event plink: %w", err) } eventID := d.E.UUID tagName := fmt.Sprintf("event-%s", eventID) mtime := libfossil.TimeToJulian(d.D) if err := tag.ApplyTag(r, tag.ApplyOpts{ TargetRID: rid, SrcRID: rid, TagName: tagName, TagType: tag.TagSingleton, Value: fmt.Sprintf("%d", len(d.W)), MTime: mtime, }); err != nil { return nil, fmt.Errorf("event tag: %w", err) } var tagid int64 if err := r.DB().QueryRow("SELECT tagid FROM tag WHERE tagname=?", tagName).Scan(&tagid); err != nil { return nil, fmt.Errorf("event tagid: %w", err) } var subsequent int64 r.DB().QueryRow("SELECT rid FROM tagxref WHERE tagid=? AND mtime>=? AND rid!=? ORDER BY mtime LIMIT 1", tagid, mtime, rid).Scan(&subsequent) // Fossil deletes stale event rows when a newer version of this tech note exists // but no subsequent version has been crosslinked yet. This ensures only the latest // version's event row survives, preventing duplicate timeline entries. if len(d.P) > 0 && subsequent == 0 { r.DB().Exec("DELETE FROM event WHERE type='e' AND tagid=? AND objid IN (SELECT rid FROM tagxref WHERE tagid=?)", tagid, tagid) } if subsequent == 0 { var bgcolor any var bgStr string if r.DB().QueryRow("SELECT value FROM tagxref JOIN tag USING(tagid) WHERE tagname='bgcolor' AND rid=?", rid).Scan(&bgStr) == nil { bgcolor = bgStr } if _, err := r.DB().Exec( "REPLACE INTO event(type, mtime, objid, tagid, user, comment, bgcolor) VALUES('e', ?, ?, ?, ?, ?, ?)", libfossil.TimeToJulian(d.E.Date), rid, tagid, d.U, d.C, bgcolor, ); err != nil { return nil, fmt.Errorf("event insert: %w", err) } } if err := updateAttachmentComments(r, eventID, 'e'); err != nil { return nil, fmt.Errorf("event attachment comments: %w", err) } return nil, nil } func crosslinkAttachment(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) error { if r == nil { panic("crosslinkAttachment: r must not be nil") } if rid <= 0 { panic("crosslinkAttachment: rid must be positive") } if d.A == nil { return fmt.Errorf("attachment manifest missing A-card") } mtime := libfossil.TimeToJulian(d.D) src, target, filename := d.A.Source, d.A.Target, d.A.Filename if _, err := r.DB().Exec( "INSERT INTO attachment(attachid, mtime, src, target, filename, comment, user) VALUES(?, ?, ?, ?, ?, ?, ?)", rid, mtime, src, target, filename, d.C, d.U, ); err != nil { return fmt.Errorf("attachment insert: %w", err) } if _, err := r.DB().Exec( `UPDATE attachment SET isLatest = (mtime = (SELECT max(mtime) FROM attachment WHERE target=? AND filename=?)) WHERE target=? AND filename=?`, target, filename, target, filename, ); err != nil { return fmt.Errorf("attachment isLatest: %w", err) } // Fossil defaults to wiki when target is not a hash (page name = wiki target). // Only hash-shaped targets can refer to tickets or tech notes. attachToType := byte('w') if isHash(target) { var dummy int if r.DB().QueryRow("SELECT 1 FROM tag WHERE tagname=?", "tkt-"+target).Scan(&dummy) == nil { attachToType = 't' } else if r.DB().QueryRow("SELECT 1 FROM tag WHERE tagname=?", "event-"+target).Scan(&dummy) == nil { attachToType = 'e' } } typeName := attachTargetTypeName[attachToType] var evComment string if src != "" { evComment = fmt.Sprintf("Add attachment %s to %s %s", filename, typeName, target) } else { evComment = fmt.Sprintf("Delete attachment %q from %s %s", filename, typeName, target) } if _, err := r.DB().Exec("REPLACE INTO event(type, mtime, objid, user, comment) VALUES(?, ?, ?, ?, ?)", string(attachToType), mtime, rid, d.U, evComment); err != nil { return fmt.Errorf("attachment event: %w", err) } return nil } func isHash(s string) bool { if len(s) != 40 && len(s) != 64 { return false } for _, c := range s { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { return false } } return true } func updateAttachmentComments(r *repo.Repo, targetID string, targetType byte) error { if r == nil { panic("updateAttachmentComments: r must not be nil") } if targetID == "" { panic("updateAttachmentComments: targetID must not be empty") } rows, err := r.DB().Query("SELECT attachid, src, target, filename FROM attachment WHERE target=?", targetID) if err != nil { return fmt.Errorf("updateAttachmentComments query: %w", err) } defer rows.Close() for rows.Next() { var attachid int64 var src, target, filename string if rows.Scan(&attachid, &src, &target, &filename) != nil { continue } typeName := attachTargetTypeName[targetType] var comment string if src != "" { comment = fmt.Sprintf("Add attachment %s to %s %s", filename, typeName, target) } else { comment = fmt.Sprintf("Delete attachment %q from %s %s", filename, typeName, target) } if _, err := r.DB().Exec("UPDATE event SET comment=?, type=? WHERE objid=?", comment, string(targetType), attachid); err != nil { return fmt.Errorf("updateAttachmentComments event update: %w", err) } } return rows.Err() } func crosslinkForum(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) error { if r == nil { panic("crosslinkForum: r must not be nil") } if rid <= 0 { panic("crosslinkForum: rid must be positive") } if err := addFWTPlink(r, rid, d); err != nil { return fmt.Errorf("forum plink: %w", err) } // Resolve thread references froot, fprev, firt := resolveForumRefs(r, rid, d) // Insert forumpost if _, err := r.DB().Exec( "REPLACE INTO forumpost(fpid, froot, fprev, firt, fmtime) VALUES(?, ?, nullif(?, 0), nullif(?, 0), ?)", rid, froot, fprev, firt, libfossil.TimeToJulian(d.D), ); err != nil { return fmt.Errorf("forumpost insert: %w", err) } mtime := libfossil.TimeToJulian(d.D) if firt == 0 { return crosslinkForumStarter(r, rid, d, froot, fprev, mtime) } return crosslinkForumReply(r, rid, d, froot, fprev, mtime) } // resolveForumRefs resolves the thread root, previous, and in-reply-to rids from deck cards. func resolveForumRefs(r *repo.Repo, rid libfossil.FslID, d *deck.Deck) (froot, fprev, firt libfossil.FslID) { if d.G != "" { var rootRid int64 if r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", d.G).Scan(&rootRid) == nil { froot = libfossil.FslID(rootRid) } } if froot == 0 { froot = rid // self is thread root } if len(d.P) > 0 { var prevRid int64 if r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", d.P[0]).Scan(&prevRid) == nil { fprev = libfossil.FslID(prevRid) } } if d.I != "" { var irtRid int64 if r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", d.I).Scan(&irtRid) == nil { firt = libfossil.FslID(irtRid) } } return froot, fprev, firt } // crosslinkForumStarter inserts the event row for a thread-starting forum post. func crosslinkForumStarter(r *repo.Repo, rid libfossil.FslID, d *deck.Deck, froot, fprev libfossil.FslID, mtime float64) error { title := d.H if title == "" { title = "(Deleted)" } fType := "Post" if fprev != 0 { fType = "Edit" } if _, err := r.DB().Exec( "REPLACE INTO event(type, mtime, objid, user, comment) VALUES('f', ?, ?, ?, ?)", mtime, rid, d.U, fmt.Sprintf("%s: %s", fType, title), ); err != nil { return fmt.Errorf("forum event: %w", err) } // Update thread title if most recent var hasNewer int r.DB().QueryRow("SELECT count(*) FROM forumpost WHERE froot=? AND firt=0 AND fpid!=? AND fmtime>?", froot, rid, mtime).Scan(&hasNewer) if hasNewer == 0 { r.DB().Exec( "UPDATE event SET comment=substr(comment,1,instr(comment,':')) || ' ' || ? WHERE objid IN (SELECT fpid FROM forumpost WHERE froot=?)", title, froot) } return nil } // crosslinkForumReply inserts the event row for a forum reply. func crosslinkForumReply(r *repo.Repo, rid libfossil.FslID, d *deck.Deck, froot, fprev libfossil.FslID, mtime float64) error { var rootTitle string if r.DB().QueryRow("SELECT substr(comment, instr(comment,':')+2) FROM event WHERE objid=?", froot).Scan(&rootTitle) != nil { rootTitle = "Unknown" } fType := "Reply" if len(d.W) == 0 { fType = "Delete reply" } else if fprev != 0 { fType = "Edit reply" } if _, err := r.DB().Exec( "REPLACE INTO event(type, mtime, objid, user, comment) VALUES('f', ?, ?, ?, ?)", mtime, rid, d.U, fmt.Sprintf("%s: %s", fType, rootTitle), ); err != nil { return fmt.Errorf("forum reply event: %w", err) } return nil } // CrosslinkCluster processes a cluster artifact: applies the cluster singleton // tag (tagid=7), removes clustered blobs from unclustered, and creates phantoms // for any referenced UUIDs not yet in the blob table. func CrosslinkCluster(q db.Querier, rid libfossil.FslID, d *deck.Deck) error { if q == nil { panic("manifest.CrosslinkCluster: q must not be nil") } if rid <= 0 { panic("manifest.CrosslinkCluster: rid must be > 0") } if d == nil { panic("manifest.CrosslinkCluster: d must not be nil") } // Apply cluster singleton tag (tagid=7, tagtype=1). if _, err := q.Exec( "INSERT OR REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) VALUES(7, 1, ?, ?, NULL, 0, ?)", rid, rid, rid, ); err != nil { return fmt.Errorf("manifest.CrosslinkCluster tag: %w", err) } // Process each M-card UUID. for _, uuid := range d.M { memberRID, exists := blob.Exists(q, uuid) if exists { // Remove from unclustered — this blob is now accounted for. if _, err := q.Exec("DELETE FROM unclustered WHERE rid=?", memberRID); err != nil { return fmt.Errorf("manifest.CrosslinkCluster unclustered delete rid=%d: %w", memberRID, err) } } else { // Create phantom for unknown UUID. if _, err := blob.StorePhantom(q, uuid); err != nil { return fmt.Errorf("manifest.CrosslinkCluster phantom %s: %w", uuid, err) } } } return nil }