Artifact 115d7dfc85c654d74f3da20f3a44165511af077cdf7ddde7228220ea944a3deb:
- File
internal/manifest/dephantomize.go
— part of check-in
[0035af8ac5]
at
2026-10-13 12:27:17
on branch main
— fix(sync): repair leaves and tags for check-ins the cascade links (#414) (#416)
* fix(sync): repair leaves and tags for check-ins the cascade links (#414)
A pulled check-in arrives as an announced phantom filled the next round, so the phantom-fill cascade links it. The session's closing sweep then found nothing left to link and skipped the leaf and tag-propagation repair, which gated on the sweep's own count: the check-in had an event row and no leaf row, and its parent stayed a leaf. The server handler had the same gap for a pushed artifact that fills a phantom.
AfterDephantomize and AfterDephantomizeBatch now return how many artifacts they linked. The sync client and the handler add that up over the session and close it with the new CrosslinkAfterCascades, which runs the repair once when the sweep or the cascades linked anything (repo-state-01 Section 7.3, RS-7.3-4), not once per file. The receive linker's cancellation paths gate on the same flag.
The repo-state harness gains a pull load: the first artifacts of a script are held and crosslinked locally, the rest pulled in process from a go-libfossil server holding them all. New cases repostate.7.3.pulled-child and repostate.7.6.pulled-retag, checked against fossil per artifact, each run as a .pull case too.
Tracked in #414.
* fix(sync): repair leaves of cascade links when a session fails (#414) (user: [email protected], size: 32203) [annotate] [blame] [check-ins using]
package manifest import ( "context" "errors" "fmt" "log/slog" "sort" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" ) // AfterDephantomize crosslinks a formerly-phantom blob and any dependents. // Matches Fossil's after_dephantomize (content.c:389-456). // // ctx bounds the graph walk: this cascade runs synchronously mid-round as a // clone fills phantoms (see sync.storeReceivedFile), and on a large delta/orphan // graph can run for many multiples of a clone's deadline. A cancelled ctx aborts // the walk within crosslinkCancelCheckStride steps (see afterDephantomize), // mirroring the whole-repository sweep's CrosslinkContext contract. Callers with // no deadline pass context.Background(). // // It returns how many artifacts the cascade linked. The cascade writes each // artifact's own rows but not the leaf and tag-propagation state, which // depends on the whole plink graph; the caller owes that repair once per // session, by passing the sum to CrosslinkAfterCascades (issue #414). func AfterDephantomize(ctx context.Context, r *repo.Repo, rid libfossil.FslID) int { if r == nil { panic("manifest.AfterDephantomize: r must not be nil") } if rid <= 0 { return 0 } return afterDephantomize(ctx, r, rid, true).linked } // AfterDephantomizeBatch runs AfterDephantomize's cascade for every rid in // one linker, so a sync round that fills many phantoms commits its crosslinks // together rather than in one transaction per artifact. A cancelled ctx stops // it as it stops AfterDephantomize. It returns how many artifacts it linked, // which the caller owes a repair for, as for AfterDephantomize's count. func AfterDephantomizeBatch(ctx context.Context, r *repo.Repo, rids []libfossil.FslID) int { if r == nil { panic("manifest.AfterDephantomizeBatch: r must not be nil") } if len(rids) == 0 { return 0 } cl := newCascadeLinker(r) for _, rid := range rids { if rid > 0 && !cl.walk(ctx, rid, true) { return cl.stats.linked } } cl.flush(ctx) logDeferredCheckinSummary("AfterDephantomizeBatch", cl.guard, cl.stats.linked) return cl.stats.linked } // afterDephantomize walks the orphan/delta-child graph reachable from rid and // crosslinks every artifact it finds, returning how that run executed. // // The stats are the batching and cache-sharing guarantees made assertable: they // exist so a test can pin "one commit, not one per artifact" and "the shared // cache absorbed the repeated parent-manifest expansions" without a package // hook. AfterDephantomize discards them. func afterDephantomize(ctx context.Context, r *repo.Repo, rid libfossil.FslID, linkFlag bool) cascadeStats { cl := newCascadeLinker(r) if cl.walk(ctx, rid, linkFlag) { cl.flush(ctx) } cache := cl.cache.Stats() cl.stats.expansions = int(cache.Misses) cl.stats.cacheHits = int(cache.Hits) logDeferredCheckinSummary("AfterDephantomize", cl.guard, cl.stats.linked) return cl.stats } // ReceiveLinkStats reports the receive-scoped content work. ExpansionMisses // counts only durable-delta expansions; supplied full bytes are linked directly // and therefore do not take a cache miss. type ReceiveLinkStats struct { ExpansionMisses int ExpansionHits int Linked int } const ( receiveSavepoint = "receive_artifact" receiveWaiterLimit = 8192 receiveCascadeLimit = 2000 receiveCacheBytes = 64 << 20 receiveSettledLimit = 100_000 ) // ReceiveLinker incrementally derives manifest state while a clone receives // blobs. It owns one bounded expanded-content cache, one bounded reverse waiter // index, and one fixed-cap settled RID set through Finalize, when it runs the // residual candidate sweep and the single combined leaf/tag repair gate. type ReceiveLinker struct { r *repo.Repo state *linkState // waiters maps a newly available UUID to the deferred checkins that need it. // waitingOn is the reverse index needed to replace a checkin's wait set when // another retry discovers a different missing reference. waiters map[string]map[libfossil.FslID]struct{} waitingOn map[libfossil.FslID]map[string]struct{} waiterSize int waiterFull bool needsRepair bool inFlight map[libfossil.FslID]struct{} // settled keeps terminal receive-session verdicts without consulting // derived tables on every delta child. Its fixed cap is a performance // optimization, not a correctness boundary: once full, parsed artifacts // consult type-appropriate durable replay evidence before derivation. settled map[libfossil.FslID]struct{} } // NewReceiveLinker creates the receive-scoped linker and its on-demand // crosslink tables. It is not safe for concurrent use; one clone owns one. func NewReceiveLinker(r *repo.Repo) (*ReceiveLinker, error) { if r == nil { panic("manifest.NewReceiveLinker: r must not be nil") } return newReceiveLinker(r, receiveCacheBytes) } // newReceiveLinker creates a linker with an explicit expanded-content budget. // CrosslinkContext uses the rebuild budget while incremental receive sessions // use receiveCacheBytes. func newReceiveLinker(r *repo.Repo, cacheBytes int64) (*ReceiveLinker, error) { if r == nil { panic("manifest.newReceiveLinker: r must not be nil") } if cacheBytes <= 0 { panic("manifest.newReceiveLinker: cacheBytes must be > 0") } if err := prepareCrosslinkTables(r.DB()); err != nil { return nil, err } return &ReceiveLinker{ r: r, state: newLinkState(cacheBytes), waiters: make(map[string]map[libfossil.FslID]struct{}), waitingOn: make(map[libfossil.FslID]map[string]struct{}), inFlight: make(map[libfossil.FslID]struct{}), settled: make(map[libfossil.FslID]struct{}), }, nil } // LinkStored examines and links one blob inside the transaction that stored it. // verifiedFullBytes is supplied only for already hash-verified full content; // nil means the durable delta must be expanded through the receive cache. // // One accepted blob may make a large delta or deferred-checkin graph eligible, // so its synchronous receive-time work is bounded. Residual work is durable: // Finalize retries the waiters and sweeps every still-unlinked candidate. func (rl *ReceiveLinker) LinkStored(ctx context.Context, tx *db.Tx, rid libfossil.FslID, uuid string, verifiedFullBytes []byte) error { if rl == nil { panic("manifest.ReceiveLinker.LinkStored: linker must not be nil") } if tx == nil { panic("manifest.ReceiveLinker.LinkStored: tx must not be nil") } if rid <= 0 { panic("manifest.ReceiveLinker.LinkStored: rid must be positive") } if err := ctx.Err(); err != nil { return err } // A blob arrived since the previous checkin examination. Availability // negatives are deliberately scoped to one accepted blob so a formerly // missing UUID cannot keep a waiter deferred after this transaction fills it. rl.state.guard.avail = content.NewAvailabilityCache() remaining := receiveCascadeLimit if err := rl.linkStored(ctx, tx, rid, verifiedFullBytes, &remaining); err != nil { return err } if uuid == "" || remaining == 0 { return nil } return rl.retryWaitersFor(ctx, tx, uuid, &remaining) } // Finalize retries the bounded deferred set, sweeps only residual candidates // with the same content cache, and repairs leaf/tag state exactly once. Its // return count is only the final-sweep work, not receive-time links. func (rl *ReceiveLinker) Finalize(ctx context.Context) (int, error) { if rl == nil { panic("manifest.ReceiveLinker.Finalize: linker must not be nil") } if err := ctx.Err(); err != nil { _, repairErr := repairCrosslinkState(rl.r, rl.repairDue(), err) return 0, repairErr } if err := rl.retryAllWaiters(ctx); err != nil { // A cancellation or savepoint-control failure stops new derivation, but // receive-time batches may already have committed plink/tagxref rows. // They still get the same single repair gate before the cause returns. _, repairErr := repairCrosslinkState(rl.r, rl.repairDue(), err) return 0, repairErr } linked, sweepErr := crosslinkSweepWithState(ctx, rl.r, rl.state) if linked > 0 { rl.needsRepair = true } if !rl.needsRepair { return linked, sweepErr } _, repairErr := repairCrosslinkState(rl.r, 1, sweepErr) if repairErr == nil { rl.needsRepair = false } return linked, repairErr } // repairDue is the link count repairCrosslinkState gates on: nonzero when this // linker, or a cascade the caller reported (CrosslinkAfterCascades), linked // rows the leaf and tag state has not been repaired for. func (rl *ReceiveLinker) repairDue() int { if rl.needsRepair { return 1 } return 0 } // Stats returns a snapshot of receive-time linking and bounded-cache work. func (rl *ReceiveLinker) Stats() ReceiveLinkStats { if rl == nil { return ReceiveLinkStats{} } cache := rl.state.cache.Stats() return ReceiveLinkStats{ ExpansionMisses: int(cache.Misses), ExpansionHits: int(cache.Hits), Linked: rl.state.linked, } } // linkStored examines one durable blob, using supplied full bytes before it // touches the cache. Link failures roll back only this artifact and remain // candidates for Finalize; savepoint-control failures return to abort the // caller's outer storage transaction. // // remaining is shared by a top-level receive call and counts only candidates // that were neither already settled nor already on the active recursion path. func (rl *ReceiveLinker) linkStored(ctx context.Context, tx *db.Tx, rid libfossil.FslID, verifiedFullBytes []byte, remaining *int) (err error) { if err := ctx.Err(); err != nil { return err } if _, settled := rl.settled[rid]; settled { return nil } if _, busy := rl.inFlight[rid]; busy { return nil } if *remaining == 0 { return nil } (*remaining)-- rl.inFlight[rid] = struct{}{} defer delete(rl.inFlight, rid) data := verifiedFullBytes direct := data != nil available := false defer func() { if direct { // Parsing/linking consumes these exact verified bytes first. An // over-budget entry is only eligible for eviction afterwards. rl.state.cache.Remember(rid, data) } if err != nil || !available { return } if ctxErr := ctx.Err(); ctxErr != nil { err = ctxErr return } if *remaining != 0 { err = rl.retryDeltaChildren(ctx, tx, rid, remaining) } }() if !direct { data, err = rl.state.cache.Expand(tx, rid) if err != nil { return nil // not grounded yet; a later source arrival retries it } } available = true d, err := deck.Parse(data) if err != nil || !isArtifact(d) { err = recordNonArtifacts(tx, []libfossil.FslID{rid}) if err == nil { rl.settle(rid) } return err } if len(rl.settled) >= receiveSettledLimit { replayed, replayErr := parsedArtifactReplayed(tx, rid, d) if replayErr != nil { return replayErr } if replayed { return nil } } if d.Type == deck.Checkin { deferred, err := rl.deferCheckin(tx, rid, d) if err != nil || deferred { return err } } if _, err := tx.Exec("SAVEPOINT " + receiveSavepoint); err != nil { return fmt.Errorf("manifest.ReceiveLinker savepoint rid=%d: %w", rid, err) } handled, linkErr := linkArtifact(tx, rid, d, rl.state.cache) if linkErr != nil { if _, err := tx.Exec("ROLLBACK TO " + receiveSavepoint); err != nil { return errors.Join( fmt.Errorf("manifest.ReceiveLinker crosslink rid=%d: %w", rid, linkErr), fmt.Errorf("manifest.ReceiveLinker rollback rid=%d: %w", rid, err), ) } if _, err := tx.Exec("RELEASE " + receiveSavepoint); err != nil { return errors.Join( fmt.Errorf("manifest.ReceiveLinker crosslink rid=%d: %w", rid, linkErr), fmt.Errorf("manifest.ReceiveLinker release rid=%d: %w", rid, err), ) } slog.Warn("manifest.ReceiveLinker: crosslink failed, blob left un-crosslinked", "rid", rid, "type", d.Type, "error", linkErr) return nil } if _, err := tx.Exec("RELEASE " + receiveSavepoint); err != nil { return fmt.Errorf("manifest.ReceiveLinker release rid=%d: %w", rid, err) } if handled { rl.state.linked++ rl.needsRepair = true rl.settle(rid) } return nil } // deferCheckin reports whether checkin rid (deck d) must wait for a blob still // in flight -- its baseline or a file (missingCheckinRefs) -- recording its // baseline gap and queuing it as a waiter on each missing blob when so. func (rl *ReceiveLinker) deferCheckin(tx *db.Tx, rid libfossil.FslID, d *deck.Deck) (bool, error) { missing := missingCheckinRefs(tx, d, rl.state.guard) if len(missing) == 0 { return false, nil } if err := rl.state.guard.recordDeferred(tx, "ReceiveLinker", rid, d, missing); err != nil { return true, err } rl.enqueueWaiter(rid, missing) return true, nil } func (rl *ReceiveLinker) settle(rid libfossil.FslID) { if len(rl.settled) >= receiveSettledLimit { return } rl.settled[rid] = struct{}{} } // parsedArtifactReplayed reports whether a parsed artifact has the durable // output that proves a prior receive attempt committed. Event and Cluster // artifacts use their tagxref source rows as replay evidence; every other // handled artifact type uses its event row. func parsedArtifactReplayed(tx *db.Tx, rid libfossil.FslID, d *deck.Deck) (bool, error) { table, column := "event", "objid" if d.Type == deck.Event || d.Type == deck.Cluster { table, column = "tagxref", "srcid" } var replayed int if err := tx.QueryRow( "SELECT EXISTS(SELECT 1 FROM "+table+" WHERE "+column+"=?)", rid, ).Scan(&replayed); err != nil { return false, fmt.Errorf("manifest.ReceiveLinker replay rid=%d: %w", rid, err) } return replayed != 0, nil } // retryDeltaChildren follows the durable delta graph from an accepted source. // Its query is deliberately just the indexed delta source lookup joined to real // blobs: receive-session settled RIDs avoid repeated work without materializing // or scanning any derived table for each source. inFlight still breaks cycles, // and Finalize's candidate sweep remains the durable correctness backstop. func (rl *ReceiveLinker) retryDeltaChildren(ctx context.Context, tx *db.Tx, srcid libfossil.FslID, remaining *int) error { if err := ctx.Err(); err != nil { return err } if *remaining == 0 { return nil } rows, err := tx.Query(` SELECT d.rid FROM delta d JOIN blob b ON b.rid=d.rid WHERE d.srcid=? AND b.size>=0 ORDER BY d.rid LIMIT ?`, srcid, *remaining) if err != nil { return fmt.Errorf("manifest.ReceiveLinker delta children rid=%d: %w", srcid, err) } defer rows.Close() children := make([]libfossil.FslID, 0, *remaining) for i := 0; rows.Next(); i++ { if i%crosslinkCancelCheckStride == 0 { if err := ctx.Err(); err != nil { return err } } var rid int64 if err := rows.Scan(&rid); err != nil { return fmt.Errorf("manifest.ReceiveLinker delta child rid=%d: %w", srcid, err) } children = append(children, libfossil.FslID(rid)) } if err := rows.Err(); err != nil { return fmt.Errorf("manifest.ReceiveLinker delta children rid=%d: %w", srcid, err) } for i, rid := range children { if *remaining == 0 { return nil } if i%crosslinkCancelCheckStride == 0 { if err := ctx.Err(); err != nil { return err } } if err := rl.linkStored(ctx, tx, rid, nil, remaining); err != nil { return err } } return nil } func (rl *ReceiveLinker) enqueueWaiter(rid libfossil.FslID, missing []string) { rl.removeWaiter(rid) if rl.waiterFull { return } for _, uuid := range missing { if rl.waiterSize == receiveWaiterLimit { rl.waiterFull = true return } byRID := rl.waiters[uuid] if byRID == nil { byRID = make(map[libfossil.FslID]struct{}) rl.waiters[uuid] = byRID } if _, exists := byRID[rid]; exists { continue } byRID[rid] = struct{}{} byUUID := rl.waitingOn[rid] if byUUID == nil { byUUID = make(map[string]struct{}) rl.waitingOn[rid] = byUUID } byUUID[uuid] = struct{}{} rl.waiterSize++ } } func (rl *ReceiveLinker) removeWaiter(rid libfossil.FslID) { for uuid := range rl.waitingOn[rid] { byRID := rl.waiters[uuid] delete(byRID, rid) rl.waiterSize-- if len(byRID) == 0 { delete(rl.waiters, uuid) } } delete(rl.waitingOn, rid) } func (rl *ReceiveLinker) retryWaitersFor(ctx context.Context, tx *db.Tx, uuid string, remaining *int) error { if err := ctx.Err(); err != nil { return err } if *remaining == 0 { return nil } byRID := rl.waiters[uuid] if len(byRID) == 0 { return nil } rids := make([]libfossil.FslID, 0, len(byRID)) for rid := range byRID { rids = append(rids, rid) } sort.Slice(rids, func(i, j int) bool { return rids[i] < rids[j] }) for i, rid := range rids { if *remaining == 0 { return nil } if i%crosslinkCancelCheckStride == 0 { if err := ctx.Err(); err != nil { return err } } rl.removeWaiter(rid) if err := rl.linkStored(ctx, tx, rid, nil, remaining); err != nil { return err } } return nil } func (rl *ReceiveLinker) retryAllWaiters(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } if len(rl.waitingOn) == 0 { return nil } rids := make([]libfossil.FslID, 0, len(rl.waitingOn)) for rid := range rl.waitingOn { rids = append(rids, rid) } sort.Slice(rids, func(i, j int) bool { return rids[i] < rids[j] }) for i, rid := range rids { if i%crosslinkCancelCheckStride == 0 { if err := ctx.Err(); err != nil { return err } } rl.removeWaiter(rid) if err := rl.r.WithTx(func(tx *db.Tx) error { remaining := receiveCascadeLimit return rl.linkStored(ctx, tx, rid, nil, &remaining) }); err != nil { return err } } return nil } // cascadeStats reports how one cascade executed: how much work it did, how many // transactions that cost, and how much content expansion the shared cache // absorbed. type cascadeStats struct { // attempts counts artifacts handed to the linker, duplicates included: an // artifact reachable both as an orphan and as a delta child is crosslinked // once per visit, as it always was. attempts int // linked counts artifacts linkArtifact wrote rows for. linked int // commits counts write transactions this cascade committed. It is the // number the batching exists to hold down (issue #172). commits int // expansions counts content expansions actually materialized, and cacheHits // those the cascade's shared cache served instead. A delta base or parent // manifest several artifacts need shows up as one expansion and many hits. expansions int cacheHits int } // cascadeItem is one artifact the walk discovered. baseline is the orphan row's // baseline rid when the artifact was found as an orphan, and 0 otherwise; it is // carried only so a failure is logged against the same context it used to be. type cascadeItem struct { rid libfossil.FslID baseline libfossil.FslID } // cascadeSavepoint isolates one artifact's writes inside a batch transaction. // Never nested: the batch loop releases or rolls back before the next artifact. const cascadeSavepoint = "cascade_artifact" // cascadeLinker crosslinks the artifacts one phantom-fill cascade discovers, // with the two savings the whole-repository sweep already takes: a content cache // shared by every artifact in the cascade, and one commit per crosslinkBatchSize // artifacts rather than one per artifact. A single phantom fill can unblock // hundreds or thousands of related artifacts that share delta bases and parent // manifests, so the per-artifact shape this replaces paid a transaction commit // and a from-scratch delta-chain expansion for work a sibling had just done // (issue #172). // // It is the cascade's whole crosslink path; the per-artifact helper it replaces // had no callers outside this file, so nothing else has to keep working the old // way. Artifact linking itself routes through linkArtifact, the same type switch // the sweep uses, rather than a third copy of it. type cascadeLinker struct { r *repo.Repo cache *content.Cache // guard holds back a Checkin whose baseline or files are not yet locally // available (see checkinDeferralGuard and missingCheckinRefs in // crosslink.go). A deferred checkin gets at most its baseline gap record // (the baseline's phantom and an orphan row) and no // event/leaf/plink/mlink/tagxref rows from this cascade at all -- that is // what lets the sweep's own candidate query, which only reselects rids // with no event row, rediscover and complete it. The cascade has no // candidate query or round boundary of its own to retry from, so it // depends entirely on falling through to the sweep's recovery path; // writing so much as the event row here would strand the checkin // permanently (issue #180). guard *checkinDeferralGuard // pending holds discovered artifacts whose writes have not been committed. // The walk discovers work as it goes, so there is no candidate slice to cut // into batches the way linkCandidatesInOrder does: the buffer flushes when it // fills, and once more when the walk finishes. pending []cascadeItem stats cascadeStats } func newCascadeLinker(r *repo.Repo) *cascadeLinker { guard := newCheckinDeferralGuard() // The cascade runs mid-session, with more blobs on the way, so it waits // on files as well as the baseline; the session's closing sweep links // whatever is still waiting (see missingCheckinRefs). guard.awaitFiles = true return &cascadeLinker{ r: r, cache: content.NewCache(crosslinkCacheBytes), guard: guard, } } // walk is the work-stack traversal of the orphan/delta-child graph reachable // from rid. It reports whether it ran to completion; false means ctx was // cancelled, in which case the buffered batch is deliberately dropped rather // than flushed. Dropping costs nothing -- the buffer holds discovered rids, not // finished work -- and flushing it would spend up to a full batch of crosslinks // after the deadline already fired, which is the overshoot issue #166 closed. // Committed batches stand; the artifacts still buffered are simply not // crosslinked, and the next sync's Crosslink sweep re-selects them because they // have no event row. func (cl *cascadeLinker) walk(ctx context.Context, rid libfossil.FslID, linkFlag bool) bool { // Work stack replaces recursion. Bounded by total blob count in repo. type workItem struct { rid libfossil.FslID linkFlag bool } stack := []workItem{{rid: rid, linkFlag: linkFlag}} const maxIterations = 1_000_000 // Guard against pathological delta chains. iterations := 0 for len(stack) > 0 { // Observe the deadline every crosslinkCancelCheckStride pops, mirroring // linkBatch's stride-batched poll in the whole-repository sweep. This // cascade runs synchronously mid-round as a clone fills phantoms (see // sync.storeReceivedFile), so without this a cancelled clone context // could not interrupt it until the walk finished or hit maxIterations -- // the deadline-blind overshoot of issue #166. On cancellation we return // what has been crosslinked so far, exactly as the batch sweep abandons // its remaining candidates; nothing pretends the whole cascade completed. if iterations%crosslinkCancelCheckStride == 0 { select { case <-ctx.Done(): return false default: } } iterations++ if iterations > maxIterations { return true // Safety bound exceeded; commit what is buffered. } // Pop from stack. item := stack[len(stack)-1] stack = stack[:len(stack)-1] current := item.rid if current <= 0 { continue } if item.linkFlag { if !cl.add(ctx, cascadeItem{rid: current}) { return false } } // Process orphaned delta manifests whose baseline is this rid. orphanRows, err := cl.r.DB().Query("SELECT rid FROM orphan WHERE baseline=?", current) if err == nil { var orphans []libfossil.FslID for orphanRows.Next() { var orid int64 if orphanRows.Scan(&orid) == nil { orphans = append(orphans, libfossil.FslID(orid)) } } orphanRows.Close() for _, orid := range orphans { if !cl.add(ctx, cascadeItem{rid: orid, baseline: current}) { return false } } if len(orphans) > 0 { // Orphans are the one discovery the delta-child query below can // observe: an orphan is reached by its baseline, not by the delta // graph, so the same rid can also be a delta child of current, and // the query filters exactly on the mlink rows crosslinking it // writes. Every other buffered artifact was itself discovered by // that query -- delta.rid is a primary key, so an artifact has one // delta base and is queried for once -- and so cannot come back. // Committing here restores the read-after-write the per-artifact // transactions gave the query for free, at one commit per // orphan-bearing node rather than one per artifact. It happens // before the DELETE below, keeping the original order: orphans // crosslinked, orphan rows cleared, children queried. if !cl.flush(ctx) { return false } // Runs on r.DB() in autocommit, as it always did: no batch // transaction is open between flushes. A deferral does write // 'orphan', but only under a baseline that is still absent, so // never under current, which has just arrived: batching cannot // reorder this against the query above. if _, err := cl.r.DB().Exec("DELETE FROM orphan WHERE baseline=?", current); err != nil { continue } } } // Find delta children not yet crosslinked. The mlink filter reads // committed rows, so buffering defers when a just-linked artifact starts // filtering itself out. That only matters for a cycle in the delta base // relation, which fossil does not produce and content.Expand would not // survive: such a cycle now spins until the buffer flushes at // crosslinkBatchSize rather than dying after two pops. Bounded, // idempotent, and still capped by maxIterations. childRows, err := cl.r.DB().Query( `SELECT rid FROM delta WHERE srcid=? AND NOT EXISTS (SELECT 1 FROM mlink WHERE mid=delta.rid)`, current) if err != nil { continue } var children []libfossil.FslID for childRows.Next() { var crid int64 if childRows.Scan(&crid) == nil { children = append(children, libfossil.FslID(crid)) } } childRows.Close() // Push all children onto work stack (reverse order for LIFO processing). for i := len(children) - 1; i >= 0; i-- { stack = append(stack, workItem{rid: children[i], linkFlag: true}) } } return true } // add buffers one artifact, flushing once the buffer holds a full batch. It // reports whether the cascade may continue; false means ctx was cancelled // during the flush. func (cl *cascadeLinker) add(ctx context.Context, item cascadeItem) bool { cl.pending = append(cl.pending, item) if len(cl.pending) < crosslinkBatchSize { return true } return cl.flush(ctx) } // flush crosslinks the buffered artifacts in one transaction and commits it, // reporting whether the cascade may continue. // // Failure handling matches the per-artifact transactions this replaces. An // artifact that cannot be expanded, parsed, or linked is logged and skipped // while the rest of the batch still commits, so one bad blob no longer costs a // batch of unrelated successful work; and each artifact's writes are wrapped in // a savepoint, so a link that fails partway leaves no partial // event/plink/mlink/tagxref behind -- the atomicity its own transaction used to // give it. A commit that fails loses the batch instead of one artifact, which is // the one place batching widens the blast radius: crosslink output is derived, // the blobs stay durable, and the next sync's Crosslink sweep re-selects every // artifact that still has no event row, exactly as it did for an artifact whose // own transaction failed. func (cl *cascadeLinker) flush(ctx context.Context) bool { if len(cl.pending) == 0 { return true } batch := cl.pending cl.pending = nil err := cl.r.WithTx(func(tx *db.Tx) error { for i, item := range batch { // Poll on the same stride the sweep's linkBatch uses, so a deadline // interrupts a batch rather than only the walk between batches. The // batch rolls back on cancellation, as linkBatch's does. if i%crosslinkCancelCheckStride == 0 { select { case <-ctx.Done(): return ctx.Err() default: } } if err := cl.linkOne(tx, item); err != nil { return err } } return nil }) if err != nil { if ctx.Err() != nil { return false } slog.Warn("manifest.AfterDephantomize: crosslink batch failed, blobs left un-crosslinked", "artifacts", len(batch), "error", err) return true } cl.stats.commits++ return true } // linkOne crosslinks one artifact on the batch transaction, isolated by a // savepoint so a mid-artifact failure discards only its own writes. func (cl *cascadeLinker) linkOne(tx *db.Tx, item cascadeItem) error { cl.stats.attempts++ rid := item.rid data, err := cl.cache.Expand(tx, rid) if err != nil { cl.warn(item, fmt.Errorf("expand rid=%d: %w", rid, err)) return nil } d, err := deck.Parse(data) if err != nil { // Not a manifest, so there is nothing to crosslink -- the ordinary // outcome for a file blob, which is most of what a clone delivers. // The whole-repository sweep has always treated this as a skip (see // linkBatch); warning here instead made a clone log one line per file // blob it filled, ~39k of them on fossil's own repository, and buried // the failures that do matter (issue #186). A blob that parses but // cannot be linked is still a real fault and still warns, below. return nil } if !isArtifact(d) { // Satisfies the card grammar but not the artifact grammar -- a file // whose leading bytes read as cards, or an artifact missing a card its // type requires. Fossil refuses both, so linking either would write // rows `fossil rebuild` removes. Same skip the sweep applies in // linkBatch; see isArtifact. return nil } // Hold back a Checkin whose baseline or files have not arrived yet, as // the whole-repository sweep's linkBatch holds one for its baseline // before ever calling linkArtifact. Checked before the savepoint opens, // not inside it: a deferred checkin leaves only its gap record in the // batch transaction, matching the sweep's defer skipping every // derived-row write. A failed gap record is a database fault, not an // artifact's, so it fails the batch. See cascadeLinker.guard and // checkinDeferralGuard. if d.Type == deck.Checkin { deferred, err := cl.guard.shouldDefer(tx, "AfterDephantomize", rid, d) if err != nil || deferred { return err } } if _, err := tx.Exec("SAVEPOINT " + cascadeSavepoint); err != nil { return fmt.Errorf("manifest.AfterDephantomize savepoint rid=%d: %w", rid, err) } handled, linkErr := linkArtifact(tx, rid, d, cl.cache) if linkErr != nil { if _, err := tx.Exec("ROLLBACK TO " + cascadeSavepoint); err != nil { return fmt.Errorf("manifest.AfterDephantomize rollback rid=%d: %w", rid, err) } if _, err := tx.Exec("RELEASE " + cascadeSavepoint); err != nil { return fmt.Errorf("manifest.AfterDephantomize release rid=%d: %w", rid, err) } cl.warn(item, fmt.Errorf("link rid=%d type=%d: %w", rid, d.Type, linkErr)) return nil } if _, err := tx.Exec("RELEASE " + cascadeSavepoint); err != nil { return fmt.Errorf("manifest.AfterDephantomize release rid=%d: %w", rid, err) } if handled { cl.stats.linked++ } return nil } // warn logs an artifact-level failure, keeping the orphan and non-orphan // wordings the per-artifact call sites used so existing log consumers still see // which side of the walk lost the blob. func (cl *cascadeLinker) warn(item cascadeItem, err error) { if item.baseline != 0 { slog.Warn("manifest.AfterDephantomize: crosslink of orphan failed, blob left un-crosslinked", "rid", item.rid, "baseline", item.baseline, "error", err) return } slog.Warn("manifest.AfterDephantomize: crosslink failed, blob left un-crosslinked", "rid", item.rid, "error", err) }