Artifact aaecdb03d9bcde6f014091aca487db009e51bd27dbae573db98f14bb301a21af:
- File
internal/manifest/crosslink_bounded_test.go
— part of check-in
[3748b95de7]
at
2026-07-28 01:50:16
on branch trunk
— perf: stop the post-sync crosslink sweep re-examining the whole repository (#202) (#210)
A pull into an already-up-to-date clone of the Fossil SCM repository took 2m44s and moved nothing. A CPU profile of that no-op pull puts 134.4s of 149.1s of samples (90%) inside manifest.CrosslinkContext, the whole-repository sweep Sync runs after the round loop. Nothing in the protocol path registers.
Two independent costs, both O(repository size) and neither bounded by what arrived:
collectCrosslinkCandidates 99.5s (67%) linkBatch -> Cache.Expand 34.9s (23%) of which hash.SHA3 26.0s (17%)
The query was quadratic. Its four exclusions were correlated NOT EXISTS subqueries, and tagxref is indexed on (rid, tagid) and (tagid, mtime) -- neither covers srcid -- so SQLite rescanned all 51,134 tagxref rows once per blob, 67,615 times. EXPLAIN QUERY PLAN showed the CORRELATED SCALAR SUBQUERY -> SCAN tx. Rewritten as NOT IN, each set materializes once and is probed: identical 39,727 rows, 47.7s -> 0.08s measured under canonical fossil's SQLite.
The candidate set never shrank. A blob qualifies when it has none of the derived rows a crosslink writes, and ordinary file content never gains those rows however often it is examined -- so 39,727 of the repository's 67,615 blobs were re-expanded (delta chain walked, SHA3 re-verified) and re-parsed on every sync. A probe run confirms the sweep linked exactly 0 artifacts on each of two consecutive runs over the converged repository, at 3m20s and 3m15s.
Canonical fossil has no equivalent cost because it never sweeps: xfer.c attempts one crosslink per blob as it arrives, artifact or not. This gives the sweep the same once-per-blob property via crosslink_nonartifact, an on-demand table recording blobs that expanded but hold no parseable artifact. Both facts are properties of immutable blob content, so the verdict cannot go stale; a blob that failed to expand, or that parsed but was deferred, is deliberately not recorded and is reconsidered next sweep.
Measured on a clone of the Fossil SCM repository (67,615 blobs), no-op pull:
before 2m43.9s after 0.40s
Clone is unchanged at 5m30s (it pays the one-time examination either way), and the two clones' derived tables are identical -- blob, event, mlink, plink, tagxref, filename and leaf totals all match row for row.
Fixes #202 (user: [email protected], size: 3707) [annotate] [blame] [check-ins using]
package manifest import ( "fmt" "strings" "testing" "github.com/danmestas/go-libfossil/internal/blob" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // TestCrosslinkSweepBoundedByOutstandingWork pins the property issue #202 was // the absence of: a sweep costs what is left to link, not what the repository // holds. Ordinary file content carries none of the derived rows a crosslink // writes, so before the fix every file blob was re-expanded and re-parsed on // every sweep -- 39,727 of the Fossil SCM repository's 67,615 blobs, linking // zero of them, on every sync. // // The assertion is on the candidate set rather than on wall clock: a timing // bound would be flaky on shared CI, while "a converged repository offers the // sweep nothing to do" is the invariant that actually broke. func TestCrosslinkSweepBoundedByOutstandingWork(t *testing.T) { r := setupTestRepo(t) // The body of any repository: file content, none of it an artifact. const fileBlobs = 200 for i := 0; i < fileBlobs; i++ { if _, _, err := blob.Store(r.DB(), []byte(fmt.Sprintf("file content %d\n", i))); err != nil { t.Fatalf("blob.Store: %v", err) } } if err := ensureNonArtifactTable(r.DB()); err != nil { t.Fatalf("ensureNonArtifactTable: %v", err) } before, err := collectCrosslinkCandidates(r.DB()) if err != nil { t.Fatalf("collectCrosslinkCandidates: %v", err) } if len(before) < fileBlobs { t.Fatalf("candidates before first sweep = %d, want >= %d", len(before), fileBlobs) } if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink: %v", err) } after, err := collectCrosslinkCandidates(r.DB()) if err != nil { t.Fatalf("collectCrosslinkCandidates: %v", err) } if len(after) != 0 { t.Fatalf("candidates after sweep = %d, want 0: the sweep re-examines blobs it has already ruled out", len(after)) } // One new blob costs one candidate -- not one per blob already stored. if _, _, err := blob.Store(r.DB(), []byte("newly arrived content\n")); err != nil { t.Fatalf("blob.Store: %v", err) } next, err := collectCrosslinkCandidates(r.DB()) if err != nil { t.Fatalf("collectCrosslinkCandidates: %v", err) } if len(next) != 1 { t.Fatalf("candidates after one new blob = %d, want 1", len(next)) } } // TestCrosslinkCandidateQueryHasNoCorrelatedSubquery pins the other half of // #202: the candidate query's exclusions must be answerable from an index or // from a set materialized once, never by rescanning a derived table per blob. // As correlated NOT EXISTS subqueries, tagxref -- whose indexes are on // (rid, tagid) and (tagid, mtime), neither covering srcid -- was scanned in // full for every blob, which was 99 s of the 164 s a no-op sync took. func TestCrosslinkCandidateQueryHasNoCorrelatedSubquery(t *testing.T) { r := setupTestRepo(t) if err := ensureForumPostTable(r.DB()); err != nil { t.Fatalf("ensureForumPostTable: %v", err) } if err := ensureNonArtifactTable(r.DB()); err != nil { t.Fatalf("ensureNonArtifactTable: %v", err) } rows, err := r.DB().Query("EXPLAIN QUERY PLAN " + candidateQuerySQL) if err != nil { t.Fatalf("EXPLAIN QUERY PLAN: %v", err) } defer rows.Close() var plan []string for rows.Next() { var id, parent, notUsed int var detail string if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { t.Fatalf("scan: %v", err) } plan = append(plan, detail) } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } if len(plan) == 0 { t.Fatal("EXPLAIN QUERY PLAN returned no rows") } for _, step := range plan { if strings.Contains(step, "CORRELATED") { t.Fatalf("candidate query rescans a derived table per blob: %q\nplan: %v", step, plan) } } }