Files
2026-08-15 07:13:00 +00:00

118 lines
3.5 KiB
Go

package store
import (
"context"
"database/sql"
"github.com/iceBear67/simplepages/internal/cas"
)
// maxReportedDrift bounds what a report carries back. A repair fixes every row
// it finds; the list is for a human reading the output, and a human does not
// read ten thousand digests.
const maxReportedDrift = 100
// Drift is one blob whose recorded refcount disagrees with the manifests that
// actually name it.
type Drift struct {
Digest cas.Digest
// Stored is what the blobs row claims, Actual what counting the manifest
// rows gives. Stored above Actual wastes disk: the collector will never
// reclaim the blob. Stored below Actual is the dangerous direction — the
// collector may delete content a deployment still needs.
Stored int64
Actual int64
}
// FsckReport is what a consistency check found.
type FsckReport struct {
// Blobs is how many rows were examined.
Blobs int64
// DriftCount is how many disagreed; Drift lists the first few of them.
DriftCount int
Drift []Drift
// Repaired is how many rows were corrected, and is zero unless the check
// was asked to repair.
Repaired int
}
// Fsck recomputes every blob's refcount from the manifests and reports the rows
// that disagree, optionally correcting them.
//
// Refcounts are maintained by triggers on deployment_files, so under normal
// operation they cannot drift. This exists for the cases outside normal
// operation: a database restored from a backup taken mid-transaction, a schema
// touched by hand, or a bug in this program. Drift matters because the blob
// collector trusts the counter — a count that reads low is content that will be
// deleted while a deployment still references it, which is the one way this
// design can lose data.
//
// The recount is a single grouped join rather than a query per blob, so a store
// with a million blobs is one table scan and not a million index seeks.
func (d *DB) Fsck(ctx context.Context, repair bool) (FsckReport, error) {
var rep FsckReport
if err := d.r.QueryRowContext(ctx, `SELECT count(*) FROM blobs`).Scan(&rep.Blobs); err != nil {
return rep, err
}
rows, err := d.r.QueryContext(ctx, `
SELECT b.digest, b.refcount, coalesce(c.n, 0)
FROM blobs b
LEFT JOIN (SELECT digest, count(*) AS n FROM deployment_files GROUP BY digest) c
ON c.digest = b.digest
WHERE b.refcount <> coalesce(c.n, 0)
ORDER BY b.digest`)
if err != nil {
return rep, err
}
defer rows.Close()
var drift []Drift
for rows.Next() {
var dr Drift
var raw []byte
if err := rows.Scan(&raw, &dr.Stored, &dr.Actual); err != nil {
return rep, err
}
if dr.Digest, err = cas.FromBytes(raw); err != nil {
return rep, err
}
drift = append(drift, dr)
}
if err := rows.Err(); err != nil {
return rep, err
}
rep.DriftCount = len(drift)
rep.Drift = drift
if len(rep.Drift) > maxReportedDrift {
rep.Drift = rep.Drift[:maxReportedDrift]
}
if !repair || len(drift) == 0 {
return rep, nil
}
// Repair writes the recounted value rather than adjusting by the difference:
// the manifests are the definition of the refcount, so the correct value is
// the one just counted, whatever the column happened to say.
err = d.Tx(ctx, func(tx *sql.Tx) error {
rep.Repaired = 0
stmt, err := tx.PrepareContext(ctx, `UPDATE blobs SET refcount = ? WHERE digest = ?`)
if err != nil {
return err
}
defer stmt.Close()
for _, dr := range drift {
if _, err := stmt.ExecContext(ctx, dr.Actual, dr.Digest.Bytes()); err != nil {
return err
}
rep.Repaired++
}
return nil
})
if err != nil {
return rep, err
}
return rep, nil
}