dolthub--dolt
1079 行
37 KiB
Go
1079 行
37 KiB
Go
// Copyright 2024 Dolthub, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package commands
|
|
|
|
import (
|
|
"bytes"
|
|
"container/list"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
|
|
"github.com/dolthub/dolt/go/cmd/dolt/cli"
|
|
"github.com/dolthub/dolt/go/gen/fb/serial"
|
|
"github.com/dolthub/dolt/go/libraries/doltcore/dbfactory"
|
|
"github.com/dolthub/dolt/go/libraries/doltcore/env"
|
|
"github.com/dolthub/dolt/go/libraries/doltcore/ref"
|
|
"github.com/dolthub/dolt/go/libraries/utils/argparser"
|
|
"github.com/dolthub/dolt/go/libraries/utils/earl"
|
|
"github.com/dolthub/dolt/go/store/chunks"
|
|
"github.com/dolthub/dolt/go/store/datas"
|
|
"github.com/dolthub/dolt/go/store/hash"
|
|
"github.com/dolthub/dolt/go/store/nbs"
|
|
"github.com/dolthub/dolt/go/store/prolly"
|
|
"github.com/dolthub/dolt/go/store/prolly/tree"
|
|
"github.com/dolthub/dolt/go/store/types"
|
|
)
|
|
|
|
type FsckCmd struct{}
|
|
|
|
var _ cli.Command = FsckCmd{}
|
|
|
|
func (cmd FsckCmd) Description() string {
|
|
return "Verifies the contents of the database are not corrupted. Provides repair when possible."
|
|
}
|
|
|
|
var fsckDocs = cli.CommandDocumentationContent{
|
|
ShortDesc: "Verifies the contents of the database are not corrupted.",
|
|
LongDesc: "Verifies the contents of the database are not corrupted.",
|
|
Synopsis: []string{
|
|
"[--quiet]",
|
|
"--revive-journal-with-data-loss",
|
|
},
|
|
}
|
|
|
|
const (
|
|
journalReviveFlag = "revive-journal-with-data-loss"
|
|
)
|
|
|
|
func (cmd FsckCmd) Docs() *cli.CommandDocumentation {
|
|
return cli.NewCommandDocumentation(fsckDocs, cmd.ArgParser())
|
|
}
|
|
|
|
func (cmd FsckCmd) ArgParser() *argparser.ArgParser {
|
|
ap := argparser.NewArgParserWithMaxArgs(cmd.Name(), 0)
|
|
ap.SupportsFlag(cli.QuietFlag, "", "Don't show progress. Just print final report.")
|
|
ap.SupportsFlag(journalReviveFlag, "", `Revives a corrupted chunk journal by discarding unparsable data.
|
|
WARNING: This may result in data loss. Your original data will be preserved in a backup file. Use this option to restore
|
|
the ability to use your Dolt database. Please contact Dolt (https://github.com/dolthub/dolt/issues) for assistance.
|
|
`)
|
|
|
|
return ap
|
|
}
|
|
|
|
func (cmd FsckCmd) Name() string {
|
|
return "fsck"
|
|
}
|
|
|
|
// Exec re-loads the database, and verifies the integrity of all chunks (referenced or not), walks the commit DAG, then
|
|
// validates all reachable trees and their referenced objects.
|
|
//
|
|
// We go to extra effort to load a new database because the default behavior of dolt is to self-heal for some types
|
|
// of corruption. For this reason we bypass any cached database and load a fresh one from disk.
|
|
func (cmd FsckCmd) Exec(ctx context.Context, commandStr string, args []string, dEnv *env.DoltEnv, _ cli.CliContext) int {
|
|
ap := cmd.ArgParser()
|
|
apr, _, terminate, status := ParseArgsOrPrintHelp(ap, commandStr, args, fsckDocs)
|
|
if terminate {
|
|
return status
|
|
}
|
|
|
|
if apr.Contains(journalReviveFlag) {
|
|
return reviveJournalWithDataLoss(dEnv)
|
|
}
|
|
|
|
quiet := apr.Contains(cli.QuietFlag)
|
|
|
|
// We expect these to work because the database has already been initialized in higher layers. We'll check anyway
|
|
// since it's possible something went sideways or this isn't a local database.
|
|
exists, isDir := dEnv.FS.Exists(dbfactory.DoltDataDir)
|
|
if !exists || !isDir {
|
|
cli.PrintErrln(fmt.Sprintf("Dolt data directory not found at %s", dbfactory.DoltDataDir))
|
|
return 1
|
|
}
|
|
|
|
absPath, err := dEnv.FS.Abs(dbfactory.DoltDataDir)
|
|
if err != nil {
|
|
// This should never happen
|
|
cli.PrintErrln("Could not get absolute path for dolt data directory:", err.Error())
|
|
return 1
|
|
}
|
|
|
|
urlStr := earl.FileUrlFromPath(filepath.ToSlash(absPath), os.PathSeparator)
|
|
u, err := earl.Parse(urlStr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
// Information is presented to users in two forms: progress messages, and an error report. The progress messages are sent
|
|
// over a channel to a separate goroutine that handles printing them (so that progress can be reported while fsck is running).
|
|
// The error report is built up in a slice of errors that is passed around and appended to as issues are found.
|
|
progress := make(chan FsckProgressMessage, 32)
|
|
var report FsckReport
|
|
|
|
params := make(map[string]interface{})
|
|
params[dbfactory.ChunkJournalParam] = struct{}{}
|
|
dbFact := dbfactory.FileFactory{}
|
|
ddb, _, _, err := dbFact.CreateDbNoCache(ctx, types.Format_DOLT, u, params, func(vErr error) {
|
|
report.ScanErrs.AppendE(vErr)
|
|
})
|
|
if err == nil {
|
|
// Creating NomsBlockStore can lazily load the actual database resources. We force them here by performing
|
|
// an actual read against the database.
|
|
_, err = datas.ChunkStoreFromDatabase(ddb).Root(ctx)
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, nbs.ErrJournalDataLoss) {
|
|
cli.PrintErrln("WARNING: Chunk journal is corrupted and some data may be lost.")
|
|
cli.PrintErrln("Run `dolt fsck --revive-journal-with-data-loss` to attempt to recover the journal by")
|
|
cli.PrintErrln("discarding invalid data blocks. Your original data will be preserved in a backup file.")
|
|
} else {
|
|
cli.PrintErrln(fmt.Sprintf("Could not open dolt database: %s", err.Error()))
|
|
}
|
|
return 1
|
|
}
|
|
gs, ok := datas.ChunkStoreFromDatabase(ddb).(*nbs.GenerationalNBS)
|
|
if !ok {
|
|
// This should never happen. Mainly protection against future changes.
|
|
cli.PrintErrln(fmt.Sprintf("runtime error: FSCK requires *nbs.GenerationalNBS chunk store. Got: %T", datas.ChunkStoreFromDatabase(ddb)))
|
|
return 1
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
fsckHandleProgress(ctx, progress, quiet)
|
|
close(done)
|
|
}()
|
|
|
|
terminate = func() bool {
|
|
defer close(progress)
|
|
err := fsckOnChunkStore(ctx, gs, &report, progress)
|
|
if err != nil {
|
|
// When FSCK errors, it's unexpected. As in corruption can be found and we shouldn't get an error here.
|
|
// So we print the error and not the report.
|
|
cli.PrintErrln(err.Error())
|
|
return true
|
|
}
|
|
// skip printing the report if we were cancelled.
|
|
select {
|
|
case <-ctx.Done():
|
|
cli.PrintErrln(ctx.Err().Error())
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}()
|
|
// Wait for fsckHandleProgress to finish processing all messages
|
|
<-done
|
|
|
|
if terminate {
|
|
return 1
|
|
}
|
|
|
|
return report.Print()
|
|
}
|
|
|
|
func reviveJournalWithDataLoss(dEnv *env.DoltEnv) int {
|
|
root, err := dEnv.FS.Abs("")
|
|
if err != nil {
|
|
cli.PrintErrln("Could not get absolute path for dolt data directory:", err.Error())
|
|
return 1
|
|
}
|
|
noms := filepath.Join(root, ".dolt", "noms")
|
|
|
|
path, err := nbs.ReviveJournalWithDataLoss(noms)
|
|
if err != nil {
|
|
cli.PrintErrln("Could not revive chunk journal:", err.Error())
|
|
return 1
|
|
}
|
|
|
|
cli.Printf("Revived chunk journal at:\n%s\n", path)
|
|
cli.Printf("For assistance recovering data, please file a ticket: https://github.com/dolthub/dolt/issues\n")
|
|
return 0
|
|
}
|
|
|
|
// Errs is a slice of errors encountered during fsck processing. It has helper for adding to it and printing it.
|
|
type Errs []error
|
|
|
|
func (e *Errs) AppendF(msg string, args ...any) {
|
|
*e = append(*e, fmt.Errorf(msg, args...))
|
|
}
|
|
|
|
// CmtAppendF appends an error message prefixed with a specific commit hash. The error returned is the error created
|
|
// and appended. Not an indication of success or failure, so can be ignored.
|
|
func (e *Errs) CmtAppendF(commitHash hash.Hash, msg string, args ...any) error {
|
|
msg = fmt.Sprintf("::commit:%s: %s", commitHash.String(), msg)
|
|
newErr := fmt.Errorf(msg, args...)
|
|
*e = append(*e, newErr)
|
|
return newErr
|
|
}
|
|
func (e *Errs) AppendE(err error) {
|
|
*e = append(*e, err)
|
|
}
|
|
|
|
// FsckReport collects errors from each phase of the fsck scan and prints them in a structured order:
|
|
// first individual chunk errors, then a per-file summary, then commit-level errors, then the scan summary.
|
|
type FsckReport struct {
|
|
ScanErrs Errs
|
|
FileErrCounts map[string]int // physical file name -> number of corrupt chunks
|
|
CommitErrs Errs
|
|
Summary []string // informational summary lines printed last
|
|
}
|
|
|
|
func (r *FsckReport) AppendSummary(format string, args ...any) {
|
|
r.Summary = append(r.Summary, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func (r *FsckReport) Print() int {
|
|
hasErrors := len(r.ScanErrs) > 0 || len(r.CommitErrs) > 0
|
|
|
|
if len(r.ScanErrs) > 0 {
|
|
cli.Println(color.RedString("============ Corrupt Chunks ============"))
|
|
for _, err := range r.ScanErrs {
|
|
cli.Println(err.Error())
|
|
}
|
|
}
|
|
|
|
if len(r.CommitErrs) > 0 {
|
|
cli.Println(color.RedString("============ Commit Errors ============"))
|
|
for _, err := range r.CommitErrs {
|
|
cli.Println(err.Error())
|
|
}
|
|
}
|
|
|
|
if len(r.Summary) > 0 {
|
|
cli.Println("============ Summary ============")
|
|
for _, line := range r.Summary {
|
|
cli.Println(line)
|
|
}
|
|
}
|
|
|
|
if !hasErrors {
|
|
cli.Println("No problems found.")
|
|
}
|
|
|
|
if hasErrors {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ProgressReporter is a channel for reporting progress messages during fsck. There is a dedicated goroutine that
|
|
// pulls off messages and displays them to the user. FSCK can be a long process, so progress reporting is important.
|
|
type ProgressReporter chan FsckProgressMessage
|
|
|
|
func (pr ProgressReporter) Milestonef(ctx context.Context, msg string, args ...any) {
|
|
pr.Insert(ctx, FsckProgressMessage{Type: FsckProgressMilestone, Message: fmt.Sprintf(msg, args...)})
|
|
}
|
|
|
|
func (pr ProgressReporter) Milestone(ctx context.Context, msg string) {
|
|
pr.Insert(ctx, FsckProgressMessage{Type: FsckProgressMilestone, Message: msg})
|
|
}
|
|
|
|
func (pr ProgressReporter) Insert(ctx context.Context, msg FsckProgressMessage) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case pr <- msg:
|
|
}
|
|
}
|
|
|
|
// FsckProgressMessageType indicates the type of progress message
|
|
type FsckProgressMessageType int
|
|
|
|
const (
|
|
// Milestone messages that should always be displayed
|
|
FsckProgressMilestone FsckProgressMessageType = iota
|
|
// Ephemeral chunk scanning progress with percentage
|
|
FsckProgressChunkScan
|
|
// Ephemeral tree validation progress
|
|
FsckProgressTreeValidation
|
|
)
|
|
|
|
// FsckProgressMessage represents a structured progress update during fsck
|
|
type FsckProgressMessage struct {
|
|
Type FsckProgressMessageType
|
|
Message string
|
|
Percentage float64 // Optional percentage for progress tracking
|
|
Current int // Optional current item count
|
|
Total int // Optional total item count
|
|
}
|
|
|
|
// fsckHandleProgress processes progress messages from the fsck operation and displays them to the user.
|
|
func fsckHandleProgress(ctx context.Context, progress ProgressReporter, quiet bool) {
|
|
if quiet {
|
|
// Just drain the progress channel without displaying anything
|
|
for range progress {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
var spinney Spinner
|
|
p := cli.NewEphemeralPrinter()
|
|
lastUpdateTime := time.Now()
|
|
var currentEphemeralMsg *FsckProgressMessage
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case msg, ok := <-progress:
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch msg.Type {
|
|
case FsckProgressMilestone:
|
|
// Clear any existing ephemeral message and print the persistent message
|
|
if currentEphemeralMsg != nil {
|
|
p.Printf("\n")
|
|
p.Display()
|
|
currentEphemeralMsg = nil
|
|
}
|
|
cli.Println(msg.Message)
|
|
|
|
case FsckProgressChunkScan, FsckProgressTreeValidation:
|
|
// Store ephemeral message for in-line updates
|
|
currentEphemeralMsg = &msg
|
|
}
|
|
|
|
// Update ephemeral display every second
|
|
case <-time.After(1 * time.Second):
|
|
}
|
|
|
|
// Update spinner and ephemeral message
|
|
if currentEphemeralMsg != nil && time.Since(lastUpdateTime) > 1*time.Second {
|
|
spinney.Tick()
|
|
spinChr := spinney.Text()
|
|
|
|
// Display with percentage and/or count information if available
|
|
var operation string
|
|
switch currentEphemeralMsg.Type {
|
|
case FsckProgressChunkScan:
|
|
operation = "Scanning chunks"
|
|
case FsckProgressTreeValidation:
|
|
operation = "Validating trees"
|
|
default:
|
|
operation = ""
|
|
}
|
|
|
|
if operation != "" && currentEphemeralMsg.Percentage > 0.0 && currentEphemeralMsg.Total > 0 {
|
|
p.Printf("%s %s: %d/%d (%.1f%% complete)", spinChr, operation, currentEphemeralMsg.Current, currentEphemeralMsg.Total, currentEphemeralMsg.Percentage)
|
|
} else if operation != "" && currentEphemeralMsg.Percentage > 0.0 {
|
|
p.Printf("%s %s: %.1f%% complete", spinChr, operation, currentEphemeralMsg.Percentage)
|
|
} else {
|
|
p.Printf("%s %s", spinChr, currentEphemeralMsg.Message)
|
|
}
|
|
p.Display()
|
|
lastUpdateTime = time.Now()
|
|
}
|
|
}
|
|
}
|
|
|
|
// FSCK performs a full file system check on the database. This is currently exposed with the CLI as `dolt fsck`
|
|
// The success or failure of the scan are returned in the report as a list of errors. The error returned by this function
|
|
// indicates a deeper issue such as an inability to read from the underlying storage at all.
|
|
//
|
|
// The FSCK process runs in multiple phases:
|
|
// 1. Full chunk scan: Every chunk in the store is read, its hash is verified, and a round-trip load is performed.
|
|
// During this phase, we also build a set of all chunks found, and categorize them by their message type. In particular,
|
|
// we identify commit objects for later processing.
|
|
// 2. Commit DAG walking: Starting from all branch HEADs and tags, we walk the commit DAG to identify reachable commits.
|
|
// This phase is lightweight and only validates commit objects and their parent relationships.
|
|
// 3. Commit tree validation: For each commit found in phase 2, we validate its tree structure and all referenced objects.
|
|
//
|
|
// As with the other code in this file, we try and continue processing as much as possible even in the presence of corruption,
|
|
// so that a full report can be generated. Errors encountered during processing are appended to the |errs| object. Only
|
|
// when there is an unexpected failure (such as inability to open a storage file) is an error returned. In that situation,
|
|
// we halt processing.
|
|
func fsckOnChunkStore(ctx context.Context, gs *nbs.GenerationalNBS, report *FsckReport, progress ProgressReporter) error {
|
|
report.FileErrCounts = make(map[string]int)
|
|
rt, err := newRoundTripper(ctx, gs, progress, &report.ScanErrs, report.FileErrCounts)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize FSCK round tripper: %w", err)
|
|
}
|
|
err = rt.scanAll(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed during full chunk scan: %w", err)
|
|
}
|
|
|
|
chunkCount := rt.chunkCount
|
|
chunksByType := rt.chunksByType
|
|
|
|
// Find all commit objects from our scanned chunks
|
|
allCommitsSet := make(hash.HashSet)
|
|
if commitChunks, hasCommits := chunksByType[serial.CommitFileID]; hasCommits {
|
|
for _, commitHash := range commitChunks {
|
|
allCommitsSet.Insert(commitHash)
|
|
}
|
|
}
|
|
|
|
reachableCommits, err := walkCommitDAGFromRefs(ctx, gs, &allCommitsSet, progress, &report.CommitErrs, report)
|
|
if err != nil {
|
|
return fmt.Errorf("commit DAG walking failed: %w", err)
|
|
}
|
|
|
|
// Phase 3: Tree validation for commits (performance heavy)
|
|
if len(reachableCommits) > 0 {
|
|
progress.Milestonef(ctx, "Starting tree validation for %d commit objects...", len(reachableCommits))
|
|
|
|
vs := types.NewValueStore(gs)
|
|
|
|
commitReachableChunks, err := validateCommitTrees(ctx, vs, gs, &reachableCommits, progress, &report.CommitErrs)
|
|
if err != nil {
|
|
return fmt.Errorf("commit tree validation failed: %w", err)
|
|
}
|
|
|
|
// Collect summary stats.
|
|
unreachableCommits := 0
|
|
for commitHash := range allCommitsSet {
|
|
if !reachableCommits.Has(commitHash) {
|
|
unreachableCommits++
|
|
}
|
|
}
|
|
unreachableChunks := chunkCount - uint32(commitReachableChunks.Size())
|
|
|
|
report.AppendSummary("Commit objects found: %d (%d reachable from branches/tags, %d unreachable)", len(allCommitsSet), len(reachableCommits), unreachableCommits)
|
|
report.AppendSummary("Chunks reachable from branches/tags: %d (unreachable: %d)", commitReachableChunks.Size(), unreachableChunks)
|
|
if len(report.FileErrCounts) > 0 {
|
|
report.AppendSummary("Files with corruption:")
|
|
for fileName, count := range report.FileErrCounts {
|
|
report.AppendSummary(" %s: %d chunk(s) with checksum errors", fileName, count)
|
|
}
|
|
}
|
|
report.AppendSummary("Chunk type breakdown:")
|
|
for chunkType, hashes := range chunksByType {
|
|
report.AppendSummary(" %s: %d", chunkType, len(hashes))
|
|
}
|
|
} else {
|
|
report.AppendSummary("No branches or tags found.")
|
|
if len(report.FileErrCounts) > 0 {
|
|
report.AppendSummary("Files with corruption:")
|
|
for fileName, count := range report.FileErrCounts {
|
|
report.AppendSummary(" %s: %d chunk(s) with checksum errors", fileName, count)
|
|
}
|
|
}
|
|
for chunkType, hashes := range chunksByType {
|
|
report.AppendSummary(" %s: %d", chunkType, len(hashes))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// roundTripper performs a full scan of all chunks, verifying that their hashes match their content.
|
|
type roundTripper struct {
|
|
ctx context.Context
|
|
vs *types.ValueStore
|
|
gs *nbs.GenerationalNBS
|
|
chunkCount uint32
|
|
progress ProgressReporter
|
|
errs *Errs
|
|
fileErrCounts map[string]int
|
|
allChunks hash.HashSet
|
|
chunksByType map[string][]hash.Hash
|
|
proccessedCnt uint32
|
|
}
|
|
|
|
func newRoundTripper(ctx context.Context, gs *nbs.GenerationalNBS, progress chan FsckProgressMessage, errs *Errs, fileErrCounts map[string]int) (*roundTripper, error) {
|
|
chunkCount, err := gs.OldGen().Count(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chunkCount2, err := gs.NewGen().Count(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chunkCount += chunkCount2
|
|
|
|
vs := types.NewValueStore(gs)
|
|
|
|
return &roundTripper{
|
|
ctx: ctx,
|
|
vs: vs,
|
|
gs: gs,
|
|
chunkCount: chunkCount,
|
|
progress: progress,
|
|
errs: errs,
|
|
fileErrCounts: fileErrCounts,
|
|
allChunks: make(hash.HashSet),
|
|
chunksByType: make(map[string][]hash.Hash),
|
|
}, nil
|
|
}
|
|
|
|
func (rt *roundTripper) scanAll(ctx context.Context) error {
|
|
err := rt.gs.TolerantIterateAllChunks(ctx, rt.roundTripAndCategorizeChunk, func(sourceFile string, err error) {
|
|
rt.errs.AppendE(err)
|
|
rt.fileErrCounts[sourceFile]++
|
|
})
|
|
return errors.Join(err, ctx.Err())
|
|
}
|
|
|
|
// roundTripAndCategorizeChunk verifies the chunk's hash matches its content, categorizes it by type. This method is
|
|
// passed as an argument to the chunk store's IterateAllChunks method, so interface mustn't change.
|
|
func (rt *roundTripper) roundTripAndCategorizeChunk(chunk chunks.Chunk) {
|
|
chunkOk := true
|
|
rt.proccessedCnt++
|
|
h := chunk.Hash()
|
|
raw := chunk.Data()
|
|
calcChkSum := hash.Of(raw)
|
|
|
|
if h != calcChkSum {
|
|
fuzzyMatch := false
|
|
// Special case for the journal chunk source. We may have an address which has 4 null bytes at the end.
|
|
if h[hash.ByteLen-1] == 0 && h[hash.ByteLen-2] == 0 && h[hash.ByteLen-3] == 0 && h[hash.ByteLen-4] == 0 {
|
|
// Now we'll just verify that the first 16 bytes match.
|
|
ln := hash.ByteLen - 4
|
|
fuzzyMatch = bytes.Compare(h[:ln], calcChkSum[:ln]) == 0
|
|
}
|
|
if !fuzzyMatch {
|
|
hrs := rt.decodeMsg(chunk)
|
|
rt.errs.AppendF("Chunk: %s content hash mismatch: %s\n%s", h.String(), calcChkSum.String(), hrs)
|
|
chunkOk = false
|
|
}
|
|
|
|
// Use the calculated checksum going forward. This ensures that the categorizations and round trip loads use the correct hash.
|
|
h = calcChkSum
|
|
}
|
|
|
|
var chunkType string
|
|
if len(raw) >= serial.MessagePrefixSz+4 { // Check if we have enough bytes for a serial message
|
|
fileID := serial.GetFileID(raw)
|
|
if fileID != "" {
|
|
chunkType = fileID
|
|
} else {
|
|
chunkType = "UNKNOWN"
|
|
}
|
|
} else {
|
|
chunkType = "TOO_SHORT"
|
|
}
|
|
|
|
rt.chunksByType[chunkType] = append(rt.chunksByType[chunkType], h)
|
|
|
|
// Add chunk to our set of all found chunks
|
|
rt.allChunks.Insert(h)
|
|
|
|
if chunkOk {
|
|
// Round trip validation. Ensure that the top level store returns the same data.
|
|
c, err := rt.gs.Get(rt.ctx, h)
|
|
if err != nil {
|
|
rt.errs.AppendF("Chunk: %s load failed with error: %w", h.String(), err)
|
|
chunkOk = false
|
|
} else if bytes.Compare(raw, c.Data()) != 0 {
|
|
hrs := rt.decodeMsg(chunk)
|
|
rt.errs.AppendF("Chunk: %s read with incorrect ID: %s\n%s", h.String(), c.Hash().String(), hrs)
|
|
chunkOk = false
|
|
}
|
|
}
|
|
|
|
percentage := (float64(rt.proccessedCnt) * 100) / float64(rt.chunkCount)
|
|
|
|
status := "OK"
|
|
if !chunkOk {
|
|
status = "FAIL"
|
|
}
|
|
|
|
rt.progress.Insert(rt.ctx, FsckProgressMessage{
|
|
Type: FsckProgressChunkScan,
|
|
Message: fmt.Sprintf("%s: %s", status, h.String()),
|
|
Percentage: percentage,
|
|
Current: int(rt.proccessedCnt),
|
|
Total: int(rt.chunkCount),
|
|
})
|
|
}
|
|
|
|
// decodeMsg attempts to decode the chunk into a human-readable string for error reporting.
|
|
func (rt *roundTripper) decodeMsg(chk chunks.Chunk) string {
|
|
hrs := ""
|
|
val, err := types.DecodeValue(chk, rt.vs)
|
|
if err == nil {
|
|
hrs = val.HumanReadableString()
|
|
} else {
|
|
hrs = fmt.Sprintf("Unable to decode value: %s", err.Error())
|
|
}
|
|
return hrs
|
|
}
|
|
|
|
// validateCommitTrees validates each commit's content and structure (trees, referenced objects)
|
|
// but does not follow parent hashes (no DAG traversal). Parent hashes are validated but not followed.
|
|
func validateCommitTrees(
|
|
ctx context.Context,
|
|
vs *types.ValueStore,
|
|
cs chunks.ChunkStore,
|
|
reachableCommits *hash.HashSet,
|
|
progress ProgressReporter,
|
|
errs *Errs,
|
|
) (*hash.HashSet, error) {
|
|
|
|
reachableChunks := &hash.HashSet{}
|
|
ns := tree.NewNodeStore(cs)
|
|
treeScnr := newTreeScanner(vs, ns, reachableChunks, errs, progress)
|
|
|
|
totalCommits := len(*reachableCommits)
|
|
processedCommits := 0
|
|
|
|
for commitHash := range *reachableCommits {
|
|
processedCommits++
|
|
percentage := (float64(processedCommits) * 100) / float64(totalCommits)
|
|
|
|
// Send progress update for tree validation
|
|
progress.Insert(ctx, FsckProgressMessage{
|
|
Type: FsckProgressTreeValidation,
|
|
Message: fmt.Sprintf("Validating commit %s", commitHash.String()),
|
|
Percentage: percentage,
|
|
Current: processedCommits,
|
|
Total: totalCommits,
|
|
})
|
|
// Load and validate the commit
|
|
commitValue, err := vs.MustReadValue(ctx, commitHash)
|
|
if err != nil {
|
|
_ = errs.CmtAppendF(commitHash, "read failure: %w", err)
|
|
continue
|
|
}
|
|
|
|
if serialMsg, ok := commitValue.(types.SerialMessage); ok {
|
|
err = treeScnr.processCommitContent(ctx, commitHash, serialMsg, reachableCommits)
|
|
if err != nil {
|
|
// Any Errors here are unexpected. appendErr should be used for things we expect to possibly fail.
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// Spit on the old format.
|
|
panic(fmt.Sprintf("Commit %s is not a SerialMessage commit, got type %T", commitHash.String(), commitValue))
|
|
}
|
|
}
|
|
|
|
for closureCommit := range treeScnr.unreachableClosureCommits {
|
|
errs.AppendF("commit %s: unreachable but referenced in parent closure of reachable commits", closureCommit.String())
|
|
}
|
|
|
|
return reachableChunks, nil
|
|
}
|
|
|
|
// treeScanner walks and validates prolly tree structures.
|
|
type treeScanner struct {
|
|
vs *types.ValueStore
|
|
ns tree.NodeStore
|
|
// visited tracks every chunk hash that has been enqueued for processing (or already processed).
|
|
// A hash present here means we've already visited it; skip it to avoid redundant validation and duplicate error messages.
|
|
visited hash.HashSet
|
|
errs *Errs
|
|
reachableChunks *hash.HashSet
|
|
progress chan FsckProgressMessage
|
|
// unreachableClosureCommits collects commits that appear in a reachable commit's parent closure but are not
|
|
// themselves reachable. Each such commit is gathered here rather than reported once per referencing ancestor,
|
|
// to avoid a flood of duplicate messages.
|
|
unreachableClosureCommits hash.HashSet
|
|
}
|
|
|
|
func newTreeScanner(vs *types.ValueStore, ns tree.NodeStore, reachableChunks *hash.HashSet, errs *Errs, progress chan FsckProgressMessage) *treeScanner {
|
|
return &treeScanner{
|
|
vs: vs,
|
|
ns: ns,
|
|
visited: make(hash.HashSet),
|
|
errs: errs,
|
|
reachableChunks: reachableChunks,
|
|
progress: progress,
|
|
unreachableClosureCommits: make(hash.HashSet),
|
|
}
|
|
}
|
|
|
|
// processCommitContent validates a commit's structure and referenced objects
|
|
// but does NOT follow parent hashes (no DAG traversal)
|
|
func (ts *treeScanner) processCommitContent(
|
|
ctx context.Context,
|
|
commitHash hash.Hash, // Use just for error messages.
|
|
serialMsg types.SerialMessage,
|
|
reachableCommits *hash.HashSet, // Set of all commits found in the second phase. Used to validate parent hashes exist.
|
|
) error {
|
|
// Validate the file ID is correct for a commit
|
|
fileID := serial.GetFileID(serialMsg)
|
|
if fileID != serial.CommitFileID {
|
|
// This shouldn't happen since we got all commits from the chunk type categorization phase.
|
|
return fmt.Errorf("runtime error: commit %s has incorrect file ID: expected %s, got %s", commitHash.String(), serial.CommitFileID, fileID)
|
|
}
|
|
|
|
// Parse the SerialMessage as a commit
|
|
var commit serial.Commit
|
|
err := serial.InitCommitRoot(&commit, serialMsg, serial.MessagePrefixSz)
|
|
if err != nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "failed to deserialize commit: %w", err)
|
|
return nil // Continue processing other commits
|
|
}
|
|
|
|
// Get the root tree hash and validate the tree structure
|
|
rootBytes := commit.RootBytes()
|
|
if len(rootBytes) == hash.ByteLen {
|
|
rootHash := hash.New(rootBytes)
|
|
err = ts.validateTreeRoot(ctx, commitHash, rootHash)
|
|
if err != nil {
|
|
// Any Errors here are unexpected. appendErr should be used for things we expect to possibly fail.
|
|
return fmt.Errorf("error validating commit %s: root tree %s: %w", commitHash.String(), rootHash.String(), err)
|
|
}
|
|
} else {
|
|
_ = ts.errs.CmtAppendF(commitHash, "invalid root tree length: %d", len(rootBytes))
|
|
return nil
|
|
}
|
|
|
|
// Validate parent hashes exist in our commit set
|
|
parentAddrs, err := types.SerialCommitParentAddrs(ts.vs.Format(), serialMsg)
|
|
if err != nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "failed to get parent addresses: %w", err)
|
|
return nil
|
|
}
|
|
for _, parentHash := range parentAddrs {
|
|
if !reachableCommits.Has(parentHash) {
|
|
_ = ts.errs.CmtAppendF(commitHash, "references missing parent %s", parentHash.String())
|
|
}
|
|
}
|
|
|
|
// Get and validate the parent closure (commit closure) reference
|
|
parentClosureBytes := commit.ParentClosureBytes()
|
|
if len(parentClosureBytes) == hash.ByteLen {
|
|
parentClosureHash := hash.New(parentClosureBytes)
|
|
if !parentClosureHash.IsEmpty() {
|
|
ts.reachableChunks.Insert(parentClosureHash) // Mark parent closure as reachable
|
|
|
|
// Closure will be a single object
|
|
value, err := ts.vs.ReadValue(ctx, parentClosureHash)
|
|
if err != nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "missing data. failed to read commit closure %s: %w", parentClosureHash.String(), err)
|
|
} else if value == nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "missing data. failed to read commit closure %s", parentClosureHash.String())
|
|
} else {
|
|
// All hashes in the closure should be reachable commits.
|
|
// Use the proper commit closure approach instead of WalkAddrs
|
|
closure, err := datas.NewParentsClosure(ctx, nil, serialMsg, ts.vs, ts.ns)
|
|
if err != nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "failed to load parent closure %s: %w", parentClosureHash.String(), err)
|
|
} else if !closure.IsEmpty() {
|
|
// Verify all commits in the closure are things we expect to see.
|
|
iter, err := closure.IterAllReverse(ctx)
|
|
if err != nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "failed to get iterator for parent closure %s: %w", parentClosureHash.String(), err)
|
|
} else {
|
|
for {
|
|
key, _, err := iter.Next(ctx)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "failure iterating parent closure %s: %w", parentClosureHash.String(), err)
|
|
break
|
|
}
|
|
|
|
closureCommitAddr := key.Addr()
|
|
if !reachableCommits.Has(closureCommitAddr) {
|
|
ts.unreachableClosureCommits.Insert(closureCommitAddr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if len(parentAddrs) != 0 {
|
|
// Empty closure should happen only for root commits. Make sure there are no parents.
|
|
_ = ts.errs.CmtAppendF(commitHash, "has empty parent closure but has %d parents", len(parentAddrs))
|
|
}
|
|
|
|
} else {
|
|
panic(fmt.Sprintf("invalid parent closure length: %d", len(parentClosureBytes)))
|
|
}
|
|
|
|
// TODO: Validate commit signatures.
|
|
// commit.Signature()
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateTreeRoot performs breadth-first validation of a tree structure and tracks all reachable chunks in |ts.reachableChunks|
|
|
func (ts *treeScanner) validateTreeRoot(
|
|
ctx context.Context,
|
|
commitHash, // Use just for error messages.
|
|
treeHash hash.Hash,
|
|
) error {
|
|
// Skip if already processed. Root hashes rarely repeat, but possible if you revert to a previous state.
|
|
if ts.visited.Has(treeHash) {
|
|
return nil
|
|
}
|
|
|
|
treeValue, err := ts.vs.ReadValue(ctx, treeHash)
|
|
if err != nil || treeValue == nil {
|
|
// Mark visited on error so a second commit sharing this root doesn't re-report the same error.
|
|
ts.visited.Insert(treeHash)
|
|
_ = ts.errs.CmtAppendF(commitHash, "failed to read tree %s: %w", treeHash.String(), err)
|
|
return nil
|
|
}
|
|
|
|
if _, ok := treeValue.(types.SerialMessage); ok {
|
|
err = ts.validateTree(ctx, commitHash, treeHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to validate tree object %s: %w", treeHash.String(), err)
|
|
}
|
|
} else {
|
|
// Spit on the old format.
|
|
panic(fmt.Sprintf("hash %s is not a SerialMessage tree, got type %T", treeHash.String(), treeValue))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateTree validates SerialMessages which are trees of objects. We track all reachable chunks, and any
|
|
// errors encountered during traversal are appended via appendErr. If this function returns an error, it indicates
|
|
// an unexpected failure, and further processing should halt.
|
|
func (ts *treeScanner) validateTree(
|
|
ctx context.Context,
|
|
commitHash hash.Hash, // Uses for error messages only.
|
|
treeHash hash.Hash,
|
|
) error {
|
|
workQueue := list.New()
|
|
workQueue.PushBack(treeHash)
|
|
|
|
for workQueue.Len() > 0 {
|
|
elem := workQueue.Front()
|
|
currentChunkHash := elem.Value.(hash.Hash)
|
|
workQueue.Remove(elem)
|
|
|
|
// Mark visited immediately so re-queued references from sibling nodes are skipped.
|
|
if ts.visited.Has(currentChunkHash) {
|
|
continue
|
|
}
|
|
ts.visited.Insert(currentChunkHash)
|
|
|
|
ts.reachableChunks.Insert(currentChunkHash)
|
|
|
|
value, err := ts.vs.MustReadValue(ctx, currentChunkHash)
|
|
if err != nil {
|
|
_ = ts.errs.CmtAppendF(commitHash, "read failure of %s: %w", currentChunkHash.String(), err)
|
|
continue
|
|
}
|
|
|
|
if serialMsg, ok := value.(types.SerialMessage); ok {
|
|
err := serialMsg.WalkAddrs(ts.vs.Format(), func(addr hash.Hash) error {
|
|
if ts.visited.Has(addr) {
|
|
// Already visited; skip.
|
|
return nil
|
|
}
|
|
workQueue.PushBack(addr)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
// We intentionally never return errors from WalkAddrs, so any error here is unexpected. Halt.
|
|
return fmt.Errorf("failed to walk references in tree %s: %w", treeHash.String(), err)
|
|
}
|
|
} else {
|
|
panic(fmt.Sprintf("commit::%s: referenced chunk %s from tree %s is not a SerialMessage, got type %T", commitHash.String(), currentChunkHash.String(), treeHash.String(), value))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// walkCommitDAGFromRefs loads all branches/tags and walks the commit DAG to find reachable commits
|
|
// This is lightweight - only validates commit objects, parent closures, and parent hashes (no trees)
|
|
func walkCommitDAGFromRefs(ctx context.Context, gs *nbs.GenerationalNBS, allCommits *hash.HashSet, progress ProgressReporter, errs *Errs, report *FsckReport) (hash.HashSet, error) {
|
|
startingCommits, err := getRawReferencesFromStoreRoot(ctx, gs, errs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get references from store root: %w", err)
|
|
}
|
|
|
|
refCount := 0
|
|
for _, refs := range startingCommits {
|
|
refCount += len(refs)
|
|
}
|
|
report.AppendSummary("Refs found: %d pointing to %d unique starting commits", refCount, len(startingCommits))
|
|
|
|
if len(startingCommits) == 0 {
|
|
return hash.HashSet{}, nil
|
|
}
|
|
|
|
// commitQueue is used as the work queue, and reachableCommits tracks all commits we've put in the queue (to avoid double enqueueing)
|
|
commitQueue := list.New()
|
|
reachableCommits := hash.HashSet{}
|
|
for commitHash := range startingCommits {
|
|
commitQueue.PushBack(commitHash)
|
|
reachableCommits.Insert(commitHash)
|
|
}
|
|
|
|
vs := types.NewValueStore(gs)
|
|
|
|
for commitQueue.Len() > 0 {
|
|
elem := commitQueue.Front()
|
|
commitHash := elem.Value.(hash.Hash)
|
|
commitQueue.Remove(elem)
|
|
|
|
// Skip if this commit doesn't exist in our found commits
|
|
if !allCommits.Has(commitHash) {
|
|
_ = errs.CmtAppendF(commitHash, "missing commit object")
|
|
continue
|
|
}
|
|
|
|
commitValue, err := vs.ReadValue(ctx, commitHash)
|
|
if err != nil {
|
|
_ = errs.CmtAppendF(commitHash, "read error: %w", err)
|
|
continue
|
|
}
|
|
|
|
if serialMsg, ok := commitValue.(types.SerialMessage); ok {
|
|
parentAddrs, err := types.SerialCommitParentAddrs(vs.Format(), serialMsg)
|
|
if err != nil {
|
|
_ = errs.CmtAppendF(commitHash, "corrupted parent data: %w", err)
|
|
continue
|
|
}
|
|
|
|
for _, parentHash := range parentAddrs {
|
|
if !reachableCommits.Has(parentHash) {
|
|
commitQueue.PushBack(parentHash)
|
|
reachableCommits.Insert(parentHash)
|
|
}
|
|
}
|
|
} else {
|
|
panic(fmt.Sprintf("::commit:%s: is not a SerialMessage, got type %T", commitHash.String(), commitValue))
|
|
}
|
|
}
|
|
return reachableCommits, nil
|
|
}
|
|
|
|
// getRawReferencesFromStoreRoot accesses raw references from the chunk store root.
|
|
// Returns a map from commit hash to list of reference names that point to it.
|
|
func getRawReferencesFromStoreRoot(ctx context.Context, cs chunks.ChunkStore, errs *Errs) (map[hash.Hash][]string, error) {
|
|
// Get the root hash from the chunk store
|
|
rootHash, err := cs.Root(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get store root hash: %w", err)
|
|
}
|
|
if rootHash.IsEmpty() {
|
|
// Empty root? There should always be something.
|
|
return nil, fmt.Errorf("store root hash is empty")
|
|
}
|
|
|
|
// Get the store root chunk
|
|
rootChunk, err := cs.Get(ctx, rootHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get store root chunk: %w", err)
|
|
}
|
|
if rootChunk.IsEmpty() {
|
|
return nil, fmt.Errorf("store root chunk is empty")
|
|
}
|
|
|
|
rootData := rootChunk.Data()
|
|
if serial.GetFileID(rootData) != serial.StoreRootFileID {
|
|
return nil, fmt.Errorf("invalid root chunk: %s. expected store root file id, got: %s", rootHash.String(), serial.GetFileID(rootData))
|
|
}
|
|
|
|
sr, err := serial.TryGetRootAsStoreRoot(rootData, serial.MessagePrefixSz)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mapBytes := sr.AddressMapBytes()
|
|
node, fileId, err := tree.NodeFromBytes(mapBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if fileId != serial.AddressMapFileID {
|
|
return nil, fmt.Errorf("unexpected file ID for address map, expected %s, found %s", serial.AddressMapFileID, fileId)
|
|
}
|
|
|
|
ns := tree.NewNodeStore(cs)
|
|
addressMap, err := prolly.NewAddressMap(node, ns)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract references into a map[hash.Hash][]string, filtering for commit-pointing refs only
|
|
refs := make(map[hash.Hash][]string)
|
|
err = addressMap.IterAll(ctx, func(name string, addr hash.Hash) error {
|
|
// Parse the reference using the ref package to determine its type
|
|
if ref.IsRef(name) {
|
|
doltRef, err := ref.Parse(name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse ref name %s: %w", name, err)
|
|
}
|
|
|
|
refType := doltRef.GetType()
|
|
switch refType {
|
|
case ref.BranchRefType, ref.RemoteRefType, ref.InternalRefType, ref.WorkspaceRefType, ref.StashRefType:
|
|
// Address is the commit id.
|
|
refs[addr] = append(refs[addr], name)
|
|
case ref.TagRefType:
|
|
if commitHash, ok := resolveTagToCommit(ctx, cs, name, addr, errs); ok {
|
|
refs[commitHash] = append(refs[commitHash], name)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unexpected ref type (%s) from ref: %s", refType, name)
|
|
}
|
|
} else if ref.IsWorkingSet(name) {
|
|
// skip.
|
|
} else {
|
|
return fmt.Errorf("invalid ref name (%s)", name)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
// Failure to iterate address map is unexpected, but possibly recoverable. We'll return the error and give up now,
|
|
// but there may be a future need to continue processing other refs.
|
|
return nil, fmt.Errorf("failed to iterate root address map %s: %w", rootHash.String(), err)
|
|
}
|
|
|
|
return refs, nil
|
|
}
|
|
|
|
// resolveTagToCommit reads a tag object and extracts the commit hash it points to
|
|
// Returns the commit hash and true if successful, or zero hash and false if there was an error
|
|
func resolveTagToCommit(ctx context.Context, cs chunks.ChunkStore, tagName string, tagAddr hash.Hash, errs *Errs) (hash.Hash, bool) {
|
|
// Get the tag object from the chunk store
|
|
tagChunk, err := cs.Get(ctx, tagAddr)
|
|
if err != nil {
|
|
errs.AppendF("failed to read tag object %s: %w", tagAddr.String(), err)
|
|
return hash.Hash{}, false
|
|
}
|
|
if tagChunk.IsEmpty() {
|
|
errs.AppendF("tag object %s is empty", tagAddr.String())
|
|
return hash.Hash{}, false
|
|
}
|
|
|
|
// Parse the tag object to get the commit hash it points to
|
|
tagData := tagChunk.Data()
|
|
if serial.GetFileID(tagData) != serial.TagFileID {
|
|
errs.AppendF("tag object %s has incorrect file ID: expected %s, got %s", tagAddr.String(), serial.TagFileID, serial.GetFileID(tagData))
|
|
return hash.Hash{}, false
|
|
}
|
|
|
|
var tag serial.Tag
|
|
err = serial.InitTagRoot(&tag, tagData, serial.MessagePrefixSz)
|
|
if err != nil {
|
|
errs.AppendF("failed to parse tag object %s: %w", tagAddr.String(), err)
|
|
return hash.Hash{}, false
|
|
}
|
|
|
|
// Extract the commit hash from the tag
|
|
commitBytes := tag.CommitAddrBytes()
|
|
if len(commitBytes) != hash.ByteLen {
|
|
errs.AppendF("tag %s has invalid commit address length: %d", tagName, len(commitBytes))
|
|
return hash.Hash{}, false
|
|
}
|
|
|
|
commitHash := hash.New(commitBytes)
|
|
return commitHash, true
|
|
}
|