Cover byte reclamation: one guarded helper, file first, covers row last (#154)

This commit is contained in:
2026-08-22 09:34:02 +07:00
parent e1d61534bb
commit a4ea80dcc2
4 changed files with 345 additions and 7 deletions
+40 -3
View File
@@ -746,6 +746,42 @@ func (s *Store) putCover(sourceURL string, body []byte, contentType string) (str
return address, nil
}
// ReclaimCover permanently removes a Cover nothing references: the sharded
// file first, the covers row last. A blank address is a no-op, and so is any
// address a Series row still points at — byte-identical artwork is one row by
// construction (ADR-0014), so reclaiming one Series' stranded bytes must not
// blank another's. The file goes first because the covers row is the handle:
// an interrupted run stays findable in SQL — covers rows unreferenced by any
// series cover_address — and re-running finishes the job, whereas deleting
// the row first would leave a file nothing names. A concurrent Forced Poll
// repointing a live Series at this address between the guard and the unlink
// is the repairable case: the missing file reads as ok=false and the next
// pass re-installs it. Failures are returned, never logged here — the caller
// logs and carries on — and a failed unlink leaves the row in place for a
// retry. A whole-table sweep, if ever wanted, is one SQL query over covers,
// not a tree walk and not this function.
func (s *Store) ReclaimCover(address string) error {
if address == "" {
return nil
}
var referenced int
err := s.db.QueryRow(`SELECT 1 FROM series WHERE cover_address = $1 LIMIT 1`, address).Scan(&referenced)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("guard reclaim of cover %q: %w", address, err)
}
coverPath := filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address)))
if err := os.Remove(coverPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove cover file %q: %w", address, err)
}
if _, err := s.db.Exec(`DELETE FROM covers WHERE address = $1`, address); err != nil {
return fmt.Errorf("delete cover row %q: %w", address, err)
}
return nil
}
// GetCover returns the immutable object a source URL's own hash names. Rows
// written before byte addressing (ADR-0014) are the only ones that ever reach
// it; it hashes the URL, so a byte-addressed Cover is invisible to it. Missing
@@ -825,9 +861,10 @@ func (s *Store) SetSeriesCover(site, seriesID, sourceURL string, body []byte, co
// ("" if it had none) and current the address of the bytes just stored; both
// are read and written in one transaction, so a concurrent replacement reports
// the exact displacement. previous == current means the Site served identical
// artwork, an honest no-op; otherwise previous is stranded — its bytes stay
// served under the covers table (ADR-0014), the row just no longer points at
// them.
// artwork, an honest no-op; otherwise previous is stranded — the row no
// longer points at it, and reclaiming its bytes is the caller's separate act
// (the poller's replace path calls ReclaimCover on it). This write itself
// removes nothing.
func (s *Store) ReplaceSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) (previous, current string, err error) {
current, err = s.putCover(sourceURL, body, contentType)
if err != nil {