Artifact 6f7bae920d4dd0994887f08ec3ed5b5f81d2544bb1a06413837664876a625ac3:
- File
internal/verify/rebuild.go
— part of check-in
[2832099f56]
at
2026-10-12 19:21:39
on branch main
— fix(verify): rebuild derived state in one transaction (#335) (#345)
fossil's rebuild_db empties and re-derives the repository's tables in a single transaction. Since #285 Rebuild committed the emptied derived tables before the crosslink sweep, because the sweep found candidates outside any transaction and opened its own; a sweep that failed partway left event, plink, mlink, tagxref and leaf empty.
manifest.CrosslinkRebuild now runs inside the caller's transaction: candidate discovery, linking and the leaf/tag repair gate all on tx. Rebuild empties, re-derives and refills the bookkeeping tables in one WithTx, so a failure rolls back to the state it found. The incremental sweep keeps its batched commits; only the repair gate is factored out so both paths share it. (user: [email protected], size: 5669) [annotate] [blame] [check-ins using]
package verify import ( "database/sql" "errors" "fmt" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" ) // crosslinkTables are the derived tables a crosslink sweep owns. Rebuild empties // them so every blob is a candidate again, then lets manifest.Crosslink refill // them exactly as a clone that received every artifact would. This is the same // set the repository-state corpus test empties before its sweep, which matches // `fossil rebuild` in event, plink, mlink, tagxref, leaf and phantom. var crosslinkTables = []string{ "event", "plink", "leaf", "mlink", "tagxref", "forumpost", "attachment", "backlink", "cherrypick", } // bookkeepingTables are the sync-bookkeeping tables the crosslink sweep does not // own. fossil's rebuild repopulates them; Rebuild clears and refills them after // the sweep so a rebuilt repository offers every artifact to its next push. var bookkeepingTables = []string{"unclustered", "unsent"} // rebuildTableList reports every table Rebuild reconstructs, for Report. var rebuildTableList = append(append([]string{}, crosslinkTables...), bookkeepingTables...) // Rebuild reconstructs all derived tables from raw blob content. It first // verifies blob integrity (read-only), then empties the crosslink-derived // tables and runs the same crosslink sweep a clone runs, so derived state has a // single owner -- manifest.Crosslink -- rather than a second, partial // derivation that diverged from `fossil rebuild`. Finally it refills the // sync-bookkeeping tables the sweep does not own. It holds one write // transaction for the whole sweep, so a concurrent writer waits up to // busy_timeout and then gets SQLITE_BUSY, as with fossil's rebuild. func Rebuild(r *repo.Repo) (*Report, error) { if r == nil { panic("verify.Rebuild: nil *repo.Repo") } start := time.Now() report := &Report{} // Phase 1: verify blobs (read-only, outside any transaction). This is the // responsibility the crosslink sweep does not carry: every blob is expanded // and its content hashed against its stored UUID, so a corrupt or // delta-dangling repository is reported before any derived row is rewritten. cache := content.NewCache(verifyCacheBytes) if err := checkBlobs(r, report, cache); err != nil { return nil, fmt.Errorf("verify.Rebuild: %w", err) } // Everything after the read-only check runs in one transaction, as // fossil's rebuild_db does, so a sweep that fails partway leaves the // derived state Rebuild found rather than emptied tables. if err := r.WithTx(rederive); err != nil { return nil, fmt.Errorf("verify.Rebuild: %w", err) } report.TablesRebuilt = rebuildTableList report.Duration = time.Since(start) // Postcondition: TablesRebuilt must be populated after successful rebuild. if len(report.TablesRebuilt) == 0 { panic("verify.Rebuild: postcondition: TablesRebuilt empty after successful rebuild") } return report, nil } // rederive empties the crosslink-derived tables so every blob owes the sweep an // examination again, crosslinks every artifact, and refills the sync-bookkeeping // tables the sweep does not own. The sweep writes event/plink/mlink/tagxref, // creates phantoms for absent targets, parents and file content, reparents, // then repairs leaf and tag-propagation state over the whole plink graph -- the // order `fossil rebuild` uses. CrosslinkRebuild, not Crosslink, because a // rebuild is terminal: a check-in whose content is absent is linked with // phantoms rather than deferred for a blob that will never arrive. func rederive(tx *db.Tx) error { if err := clearTables(tx, crosslinkTables); err != nil { return err } if _, err := manifest.CrosslinkRebuild(tx); err != nil { return err } return rebuildBookkeeping(tx) } // clearTables deletes all rows from the named tables that exist. A table may be // absent: a repository created by fossil(1) without the feature that owns it // (forumpost until the first forum post, say) has no such table until the // crosslink sweep's prepareCrosslinkTables creates it, and there is nothing to // clear in that case. func clearTables(tx *db.Tx, tables []string) error { if tx == nil { panic("verify.clearTables: nil *db.Tx") } for _, table := range tables { exists, err := tableExists(tx, table) if err != nil { return err } if !exists { continue } if _, err := tx.Exec("DELETE FROM " + table); err != nil { return fmt.Errorf("clear %s: %w", table, err) } } return nil } // tableExists reports whether the named table is present in the schema. func tableExists(tx *db.Tx, table string) (bool, error) { var name string err := tx.QueryRow( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, ).Scan(&name) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("table exists %s: %w", table, err) } return true, nil } // rebuildBookkeeping repopulates unclustered and unsent. Every non-phantom blob // is marked unclustered and unsent so sync re-evaluates what to push. It clears // first so a repeated Rebuild stays idempotent. func rebuildBookkeeping(tx *db.Tx) error { if tx == nil { panic("verify.rebuildBookkeeping: nil *db.Tx") } if err := clearTables(tx, bookkeepingTables); err != nil { return err } for _, table := range bookkeepingTables { _, err := tx.Exec("INSERT INTO " + table + "(rid) SELECT rid FROM blob WHERE size >= 0") if err != nil { return fmt.Errorf("rebuildBookkeeping %s: %w", table, err) } } return nil }