Artifact 7780020eeaef0f05620fff2cc40925906ac490bc448112cf6c8992e18c811282:
- File
internal/content/available_test.go
— part of check-in
[4b14509374]
at
2026-10-03 17:27:28
on branch trunk
— fix(content): report database faults from the availability check (#239) (#247)
* fix(content): report database faults from the availability check (#239)
IsAvailable and AvailableByUUID return a bool, so a busy, closed or I/O-failed database read as "content missing". Where missing content is tolerated on purpose, that swallowed the fault: since #231, CreateCheckout leaves vfile empty for a partly synced tip, and a fault during resolveFiles' availability check took the same path, so Create succeeded with an empty checkout.
The chain walk moves to checkAvailable, which returns an error for a fault and a plain false for an unknown rid, a phantom, a cycle or an over-long chain. New CheckAvailableByUUID exposes that distinction, and resolveFiles uses it, so a fault fails LoadVFile with its own error instead of errMissingContent. IsAvailable and AvailableByUUID keep their signatures and their fail-closed reading of a fault; their ~30 callers treat "not available" as a failure already.
* docs(changelog): note the availability fault fix (#239)
* fix(content): NULL size is missing content; don't cache faults (#239)
checkAvailable scanned blob.size into an int64, so a NULL size (legal in the schema) became a hard error in resolveFiles where it used to read as missing content. Both walks now read a NULL size as a phantom.
AvailabilityCache memoized a walk's verdict for every node it visited, including a false that came from a database fault, so one transient fault stayed wrong for the rest of the crosslink sweep. A faulted walk now returns false without being remembered; an unknown rid is still a verdict and is cached as before. (user: [email protected], size: 11872) [annotate] [blame] [check-ins using]
package content import ( "database/sql" "strings" "testing" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) func TestIsAvailable_Phantom(t *testing.T) { d := setupTestDB(t) rid, err := blob.StorePhantom(d, "0000000000000000000000000000000000000001") if err != nil { t.Fatalf("StorePhantom: %v", err) } if IsAvailable(d, rid) { t.Fatalf("IsAvailable(phantom rid=%d) = true, want false", rid) } } // A delta whose base is a phantom is unavailable even though every blob row // in the chain exists and the delta's own size is >= 0. A size-only check // passes this case; the transitive walk must not. func TestIsAvailable_DeltaOnPhantom(t *testing.T) { d := setupTestDB(t) baseRid, err := blob.StorePhantom(d, "0000000000000000000000000000000000000002") if err != nil { t.Fatalf("StorePhantom: %v", err) } // Store a delta row directly: the blob exists with a real size, but its // delta source is the phantom above. This is the normal steady state // during clone when a delta arrives before its base. res, err := d.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, 42, x'00', 1)", "0000000000000000000000000000000000000003", ) if err != nil { t.Fatalf("insert delta blob: %v", err) } id, err := res.LastInsertId() if err != nil { t.Fatalf("LastInsertId: %v", err) } deltaRid := libfossil.FslID(id) if _, err := d.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", deltaRid, baseRid); err != nil { t.Fatalf("insert delta: %v", err) } // Sanity: a size-only check would report this as present. var size int64 if err := d.QueryRow("SELECT size FROM blob WHERE rid=?", deltaRid).Scan(&size); err != nil { t.Fatalf("scan size: %v", err) } if size < 0 { t.Fatalf("test setup wrong: delta blob size = %d, want >= 0", size) } if IsAvailable(d, deltaRid) { t.Fatalf("IsAvailable(delta-on-phantom rid=%d) = true, want false", deltaRid) } } func TestIsAvailable_UnknownRid(t *testing.T) { d := setupTestDB(t) if IsAvailable(d, libfossil.FslID(9999)) { t.Fatalf("IsAvailable(unknown rid) = true, want false") } } func TestIsAvailable_GroundedChain(t *testing.T) { d := setupTestDB(t) source := []byte("the original source content for delta testing purposes here") target := []byte("the original source content for MODIFIED testing purposes here") srcRid, _, err := blob.Store(d, source) if err != nil { t.Fatalf("Store: %v", err) } deltaRid, _, err := blob.StoreDelta(d, target, srcRid) if err != nil { t.Fatalf("StoreDelta: %v", err) } if !IsAvailable(d, srcRid) { t.Fatalf("IsAvailable(full-text rid=%d) = false, want true", srcRid) } if !IsAvailable(d, deltaRid) { t.Fatalf("IsAvailable(grounded delta rid=%d) = false, want true", deltaRid) } } // A cyclic delta table must terminate rather than hang the process. Cycles // are reachable from a corrupt repository or from hostile input over the // wire during clone, so the walk carries an explicit bound. func TestIsAvailable_CyclicChainTerminates(t *testing.T) { d := setupTestDB(t) var rids []libfossil.FslID for i := 0; i < 3; i++ { res, err := d.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, 42, x'00', 1)", []string{ "000000000000000000000000000000000000000a", "000000000000000000000000000000000000000b", "000000000000000000000000000000000000000c", }[i], ) if err != nil { t.Fatalf("insert blob %d: %v", i, err) } id, err := res.LastInsertId() if err != nil { t.Fatalf("LastInsertId: %v", err) } rids = append(rids, libfossil.FslID(id)) } // a -> b -> c -> a for i := range rids { src := rids[(i+1)%len(rids)] if _, err := d.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", rids[i], src); err != nil { t.Fatalf("insert delta: %v", err) } } done := make(chan bool, 1) go func() { done <- IsAvailable(d, rids[0]) }() select { case got := <-done: if got { t.Fatalf("IsAvailable(cyclic chain) = true, want false") } case <-time.After(30 * time.Second): t.Fatalf("IsAvailable(cyclic chain) did not terminate") } } func TestAvailableByUUID(t *testing.T) { d := setupTestDB(t) content := []byte("available by uuid content") rid, uuid, err := blob.Store(d, content) if err != nil { t.Fatalf("Store: %v", err) } gotRid, ok := AvailableByUUID(d, uuid) if !ok || gotRid != rid { t.Fatalf("AvailableByUUID(filled) = (%d, %v), want (%d, true)", gotRid, ok, rid) } phantomUUID := "000000000000000000000000000000000000000f" if _, err := blob.StorePhantom(d, phantomUUID); err != nil { t.Fatalf("StorePhantom: %v", err) } if _, ok := AvailableByUUID(d, phantomUUID); ok { t.Fatalf("AvailableByUUID(phantom) = true, want false") } if _, ok := AvailableByUUID(d, "00000000000000000000000000000000deadbeef"); ok { t.Fatalf("AvailableByUUID(unknown) = true, want false") } } // deltaQueryFailer delegates every query to the wrapped Querier except the // chain-step lookup that reads a blob's delta linkage, which it rewrites into // one that genuinely fails. That yields a real error on the exact path // IsAvailable uses to decide a chain is grounded. // // IsAvailable reads size and delta linkage in one statement now, so this can // no longer fail the delta half alone: what it proves is that a failing // chain-step query reports unavailable rather than grounded, which is the // property that matters. The match is on the join specifically, so an // unrelated statement that happens to mention delta is not caught by it. type deltaQueryFailer struct { db.Querier } func (f deltaQueryFailer) QueryRow(query string, args ...any) *sql.Row { if strings.Contains(query, "LEFT JOIN delta") || strings.Contains(query, "FROM delta") { return f.Querier.QueryRow("SELECT no_such_column FROM no_such_table") } return f.Querier.QueryRow(query, args...) } // A chain-step lookup that fails tells us nothing about whether the chain is // grounded. Reporting the content available on that basis would resurrect the // exact bug this predicate exists to prevent — claiming readable content we // never verified — under error conditions, on data arriving over the wire // during clone. func TestIsAvailable_DeltaLookupErrorFailsClosed(t *testing.T) { d := setupTestDB(t) // A perfectly ordinary full-text blob: no delta row, size >= 0. Against // the real DB this is available, which is what makes the injected failure // the only difference between the two assertions below. rid, _, err := blob.Store(d, []byte("grounded full text")) if err != nil { t.Fatalf("Store: %v", err) } if !IsAvailable(d, rid) { t.Fatalf("IsAvailable(full-text rid=%d) = false, want true (test premise broken)", rid) } if IsAvailable(deltaQueryFailer{d}, rid) { t.Fatalf("IsAvailable(rid=%d) = true when the delta lookup errored; "+ "must fail closed on anything but sql.ErrNoRows", rid) } } // A cycle must cost a number of queries proportional to the cycle, not to // maxDeltaChainDepth. IsAvailable is reachable from the wire -- sync's clone // and handler paths, and once per F-card of every check-in Crosslink // processes -- so a peer that plants a two-node delta cycle must not be able // to buy a bounded-but-huge query loop on every call. func TestIsAvailable_CycleCostsCycleLengthNotBound(t *testing.T) { d := setupTestDB(t) var rids []libfossil.FslID for i := 0; i < 2; i++ { res, err := d.Exec( "INSERT INTO blob(uuid, size, content, rcvid) VALUES(?, 42, x'00', 1)", []string{ "00000000000000000000000000000000000000d1", "00000000000000000000000000000000000000d2", }[i], ) if err != nil { t.Fatalf("insert blob %d: %v", i, err) } id, err := res.LastInsertId() if err != nil { t.Fatalf("LastInsertId: %v", err) } rids = append(rids, libfossil.FslID(id)) } for i := range rids { src := rids[(i+1)%len(rids)] if _, err := d.Exec("INSERT INTO delta(rid, srcid) VALUES(?, ?)", rids[i], src); err != nil { t.Fatalf("insert delta: %v", err) } } counter := &chainStepCounter{Querier: d} if IsAvailable(counter, rids[0]) { t.Fatal("IsAvailable(cyclic chain) = true, want false") } // Two nodes in the cycle, so the walk revisits on its third step. if counter.steps > 8 { t.Fatalf("cyclic chain cost %d chain-step queries for a 2-node cycle; "+ "a cycle must stop at the first repeat, not run to maxDeltaChainDepth (%d)", counter.steps, maxDeltaChainDepth) } } // chainStepCounter counts the per-node chain-step queries a walk issues. type chainStepCounter struct { db.Querier steps int } func (c *chainStepCounter) QueryRow(query string, args ...any) *sql.Row { if strings.Contains(query, "LEFT JOIN delta") || strings.Contains(query, "FROM delta") { c.steps++ } return c.Querier.QueryRow(query, args...) } // faultyQuerier fails every QueryRow whose SQL contains fail, the way a busy, // closed or I/O-failed database does, and passes everything else through. type faultyQuerier struct { db.Querier fail string } func (f faultyQuerier) QueryRow(query string, args ...any) *sql.Row { if strings.Contains(query, f.fail) { return f.Querier.QueryRow("SELECT * FROM no_such_table_simulating_a_fault") } return f.Querier.QueryRow(query, args...) } // TestCheckAvailableByUUIDReportsFaults pins that a database fault is an // error, not "unavailable", while missing content stays a plain false: a // caller that tolerates missing content must not swallow a fault with it // (#239). The bool wrappers keep reading a fault as unavailable. func TestCheckAvailableByUUIDReportsFaults(t *testing.T) { d := setupTestDB(t) _, uuid, err := blob.Store(d, []byte("fault injection content")) if err != nil { t.Fatalf("Store: %v", err) } phantomUUID := "0000000000000000000000000000000000000abc" if _, err := blob.StorePhantom(d, phantomUUID); err != nil { t.Fatalf("StorePhantom: %v", err) } for _, missing := range []string{phantomUUID, "00000000000000000000000000000000deadbeef"} { _, ok, err := CheckAvailableByUUID(d, missing) if err != nil || ok { t.Fatalf("CheckAvailableByUUID(%s) = (%v, %v), want (false, nil)", missing, ok, err) } } for _, fail := range []string{"SELECT rid FROM blob WHERE uuid", "LEFT JOIN delta"} { q := faultyQuerier{Querier: d, fail: fail} if _, _, err := CheckAvailableByUUID(q, uuid); err == nil { t.Fatalf("fault on %q: CheckAvailableByUUID returned no error", fail) } if _, ok := AvailableByUUID(q, uuid); ok { t.Fatalf("fault on %q: AvailableByUUID = true, want false", fail) } } } // A NULL blob.size is legal in the schema; it reads as a phantom (not there), // not as a fault (#239). func TestCheckAvailableByUUIDNullSizeIsMissing(t *testing.T) { d := setupTestDB(t) _, uuid, err := blob.Store(d, []byte("null size content")) if err != nil { t.Fatalf("Store: %v", err) } if _, err := d.Exec("UPDATE blob SET size = NULL WHERE uuid = ?", uuid); err != nil { t.Fatal(err) } if _, ok, err := CheckAvailableByUUID(d, uuid); err != nil || ok { t.Fatalf("CheckAvailableByUUID(NULL size) = (%v, %v), want (false, nil)", ok, err) } } // A fault during a cached walk is not remembered: once the database recovers, // the same cache answers correctly (#239). func TestAvailabilityCacheDoesNotMemoizeFaults(t *testing.T) { d := setupTestDB(t) _, uuid, err := blob.Store(d, []byte("cached fault content")) if err != nil { t.Fatalf("Store: %v", err) } cache := NewAvailabilityCache() if _, ok := cache.ByUUID(faultyQuerier{Querier: d, fail: "LEFT JOIN delta"}, uuid); ok { t.Fatal("ByUUID during a fault = true, want false") } if _, ok := cache.ByUUID(d, uuid); !ok { t.Fatal("ByUUID after the fault cleared = false, want true (fault was memoized)") } }