Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
146 lines
4.4 KiB
Go
146 lines
4.4 KiB
Go
package checksum
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
|
)
|
|
|
|
// Options configures a backfill run.
|
|
type Options struct {
|
|
// GalleryID limits the run to one gallery; empty means the whole library.
|
|
GalleryID string
|
|
// Concurrency is how many originals are hashed at a time.
|
|
Concurrency int
|
|
// DryRun reports what would be hashed without writing to the database.
|
|
DryRun bool
|
|
}
|
|
|
|
// Result counts what a run did. Duplicates are rows whose bytes match an
|
|
// earlier photo in the same gallery; they keep a NULL checksum and are listed
|
|
// so an admin can decide what to do with them.
|
|
type Result struct {
|
|
Total int // rows without a checksum
|
|
Hashed int // hashed and recorded (or, with DryRun, would be)
|
|
Duplicates int
|
|
Missing int // original object absent from storage
|
|
Failed int
|
|
}
|
|
|
|
// Backfill hashes the stored original of every photo that has no checksum yet,
|
|
// so duplicate detection also catches re-uploads of photos from before the
|
|
// checksum column existed. It only ever writes the checksum column; no photo,
|
|
// row or object is deleted.
|
|
//
|
|
// It is idempotent: rerunning it finds only what the previous run left behind.
|
|
func Backfill(ctx context.Context, db *store.DB, st storage.Storage, opts Options) (Result, error) {
|
|
if opts.Concurrency < 1 {
|
|
opts.Concurrency = 4
|
|
}
|
|
photos, err := db.PhotosMissingChecksum(ctx, opts.GalleryID)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("list photos without a checksum: %w", err)
|
|
}
|
|
|
|
prefix := ""
|
|
if opts.DryRun {
|
|
prefix = "[dry-run] "
|
|
}
|
|
log.Printf("backfill-checksums: %s%d photos to hash, concurrency %d", prefix, len(photos), opts.Concurrency)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
res = Result{Total: len(photos)}
|
|
done int64
|
|
jobs = make(chan store.Photo)
|
|
wg sync.WaitGroup
|
|
)
|
|
// Sequential hashing per worker, but the database update is what can
|
|
// collide: two photos of the same gallery with identical bytes race, and
|
|
// the unique index decides which one keeps the checksum.
|
|
for i := 0; i < opts.Concurrency; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for p := range jobs {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
sum, err := hashOriginal(ctx, st, p)
|
|
if err == nil && !opts.DryRun {
|
|
err = db.SetPhotoChecksum(ctx, p.ID, sum)
|
|
}
|
|
n := atomic.AddInt64(&done, 1)
|
|
|
|
mu.Lock()
|
|
switch {
|
|
case errors.Is(err, storage.ErrNotExist):
|
|
res.Missing++
|
|
log.Printf("backfill-checksums: [%d/%d] MISSING object, skipped: %s (photo %s)",
|
|
n, len(photos), p.OriginalKey, p.ID)
|
|
case store.IsUniqueViolation(err):
|
|
res.Duplicates++
|
|
other := "an earlier photo"
|
|
if existing, findErr := db.FindPhotoByChecksum(ctx, p.GalleryID, sum); findErr == nil {
|
|
other = "photo " + existing.ID
|
|
}
|
|
log.Printf("backfill-checksums: [%d/%d] DUPLICATE: photo %s (%s) has the same content as %s "+
|
|
"in gallery %s — left without a checksum, delete it by hand if unwanted",
|
|
n, len(photos), p.ID, p.OriginalFilename, other, p.GalleryID)
|
|
case err != nil:
|
|
res.Failed++
|
|
log.Printf("backfill-checksums: [%d/%d] FAILED %s (photo %s): %v",
|
|
n, len(photos), p.OriginalKey, p.ID, err)
|
|
default:
|
|
res.Hashed++
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
for _, p := range photos {
|
|
select {
|
|
case jobs <- p:
|
|
case <-ctx.Done():
|
|
}
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
|
|
log.Printf("backfill-checksums: %sdone — %d hashed, %d duplicates left unhashed, %d objects missing, %d failed",
|
|
prefix, res.Hashed, res.Duplicates, res.Missing, res.Failed)
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return res, fmt.Errorf("interrupted after %d/%d photos: %w", res.Hashed, res.Total, err)
|
|
}
|
|
if res.Failed > 0 {
|
|
return res, fmt.Errorf("%d of %d photos failed to hash (rerun to retry; already-hashed photos are skipped)",
|
|
res.Failed, res.Total)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// hashOriginal reads a photo's stored original. Stat runs first because it is
|
|
// the only call that reports a missing object as storage.ErrNotExist on both
|
|
// backends — Open surfaces the driver's own error.
|
|
func hashOriginal(ctx context.Context, st storage.Storage, p store.Photo) (string, error) {
|
|
if _, err := st.Stat(ctx, p.OriginalKey); err != nil {
|
|
return "", err
|
|
}
|
|
r, _, err := st.Open(ctx, p.OriginalKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer r.Close()
|
|
return Sum(r)
|
|
}
|