use a fresh spooldb session per image instead of a shared one

This commit is contained in:
Ian Gulliver
2026-05-31 19:33:48 -07:00
parent e70dce275c
commit f0809220e8
2 changed files with 54 additions and 61 deletions
+38 -46
View File
@@ -71,11 +71,8 @@ func main() {
} }
slog.Debug("claude auth", "mechanism", auth.Name()) slog.Debug("claude auth", "mechanism", auth.Name())
sp := &spoolSync{}
defer sp.close()
if *serveAddr != "" { if *serveAddr != "" {
if err := serve(*serveAddr, auth, sp, *dryRun); err != nil { if err := serve(*serveAddr, auth, *dryRun); err != nil {
fail("serve: %v", err) fail("serve: %v", err)
} }
return return
@@ -87,7 +84,7 @@ func main() {
} }
results := make([]result, 0, flag.NArg()) results := make([]result, 0, flag.NArg())
for _, path := range flag.Args() { for _, path := range flag.Args() {
results = append(results, processImage(path, auth, sp, *dryRun)) results = append(results, processImage(path, auth, *dryRun))
} }
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
@@ -95,50 +92,43 @@ func main() {
enc.Encode(results) enc.Encode(results)
} }
// spoolSync lazily logs into spooldb on first use, so its (slow) browser login // spoolSession is a fresh, logged-in spooldb session used for a single image:
// overlaps with the first image's weight read rather than blocking it. Every // it owns one headless browser, does the read and the maybe-write, then closes.
// site interaction is best-effort: failures are logged and the corresponding // Every site interaction is best-effort failures are logged and the
// output fields are simply omitted. // corresponding output fields are simply omitted.
type spoolSync struct { type spoolSession struct {
once sync.Once
client *spooldb.Client client *spooldb.Client
err error
} }
func (s *spoolSync) login() (*spooldb.Client, error) { // openSpoolSession launches a browser and logs into spooldb. It returns nil (and
s.once.Do(func() { // logs) if credentials are missing or login fails, so the caller can carry on
// with just the weight read.
func openSpoolSession() *spoolSession {
user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS") user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS")
if user == "" || pass == "" { if user == "" || pass == "" {
s.err = fmt.Errorf("SPOOLDB_USER/SPOOLDB_PASS not set") slog.Warn("spooldb: skipping (SPOOLDB_USER/SPOOLDB_PASS not set)")
return return nil
} }
c, err := spooldb.New() c, err := spooldb.New()
if err != nil { if err != nil {
s.err = err slog.Warn("spooldb: skipping (browser launch failed)", "err", err)
return return nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel() defer cancel()
if err := c.Login(ctx, user, pass); err != nil { if err := c.Login(ctx, user, pass); err != nil {
c.Close() c.Close()
s.err = fmt.Errorf("login: %w", err) slog.Warn("spooldb: skipping (login failed)", "err", err)
return return nil
} }
s.client = c return &spoolSession{client: c}
})
return s.client, s.err
} }
// info reads the spool's current location and weights, or nil if unavailable. // info reads the spool's current location and weights, or nil if unavailable.
func (s *spoolSync) info(spoolID string) *spooldb.SpoolInfo { func (s *spoolSession) info(spoolID string) *spooldb.SpoolInfo {
client, err := s.login() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
if err != nil {
slog.Warn("spooldb: skipping spool lookup", "err", err)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
info, err := client.SpoolInfo(ctx, spoolID) info, err := s.client.SpoolInfo(ctx, spoolID)
if err != nil { if err != nil {
slog.Warn("spooldb: lookup failed", "spool", spoolID, "err", err) slog.Warn("spooldb: lookup failed", "spool", spoolID, "err", err)
return nil return nil
@@ -147,22 +137,17 @@ func (s *spoolSync) info(spoolID string) *spooldb.SpoolInfo {
} }
// setTotal writes a new total (with-spool) weight back to the site. // setTotal writes a new total (with-spool) weight back to the site.
func (s *spoolSync) setTotal(spoolID string, grams float64) bool { func (s *spoolSession) setTotal(spoolID string, grams float64) bool {
client, err := s.login()
if err != nil {
slog.Warn("spooldb: skipping weight update", "err", err)
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel() defer cancel()
if err := client.SetTotalWeight(ctx, spoolID, grams); err != nil { if err := s.client.SetTotalWeight(ctx, spoolID, grams); err != nil {
slog.Warn("spooldb: weight update failed", "spool", spoolID, "err", err) slog.Warn("spooldb: weight update failed", "spool", spoolID, "err", err)
return false return false
} }
return true return true
} }
func (s *spoolSync) close() { func (s *spoolSession) close() {
if s.client != nil { if s.client != nil {
s.client.Close() s.client.Close()
} }
@@ -170,17 +155,17 @@ func (s *spoolSync) close() {
// processImage reads one photo, capturing any failure in the result's Error // processImage reads one photo, capturing any failure in the result's Error
// field so a single bad image doesn't abort the whole batch. // field so a single bad image doesn't abort the whole batch.
func processImage(path string, auth claude.Auth, sp *spoolSync, dryRun bool) result { func processImage(path string, auth claude.Auth, dryRun bool) result {
img, err := loadImage(path) img, err := loadImage(path)
if err != nil { if err != nil {
return result{Image: path, Error: fmt.Sprintf("load image: %v", err)} return result{Image: path, Error: fmt.Sprintf("load image: %v", err)}
} }
return processImg(img, path, auth, sp, dryRun) return processImg(img, path, auth, dryRun)
} }
// processImg runs the QR + weight + spooldb logic on a decoded image. name is // processImg runs the QR + weight + spooldb logic on a decoded image. name is
// just a label for the output (a file path or upload filename). // just a label for the output (a file path or upload filename).
func processImg(img image.Image, name string, auth claude.Auth, sp *spoolSync, dryRun bool) result { func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) result {
defer trace("image " + name)() defer trace("image " + name)()
r := result{Image: name} r := result{Image: name}
@@ -196,18 +181,25 @@ func processImg(img image.Image, name string, auth claude.Auth, sp *spoolSync, d
r.SpoolID = spoolID(url) r.SpoolID = spoolID(url)
r.URL = url r.URL = url
// Read the spool's current location/weight from the site concurrently with // Open a fresh spooldb session and read the spool's current location/weight,
// the (slower) weight read off the photo. // concurrently with the (slower) weight read off the photo. The same session
// is reused for the maybe-write below, then closed.
var sess *spoolSession
var info *spooldb.SpoolInfo var info *spooldb.SpoolInfo
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
info = sp.info(r.SpoolID) if sess = openSpoolSession(); sess != nil {
info = sess.info(r.SpoolID)
}
}() }()
reading, err := claude.ReadWeight(img, auth) reading, err := claude.ReadWeight(img, auth)
wg.Wait() wg.Wait()
if sess != nil {
defer sess.close()
}
if info != nil { if info != nil {
r.Location = info.Location r.Location = info.Location
r.PreviousWeight = &weightBreakdown{ r.PreviousWeight = &weightBreakdown{
@@ -246,7 +238,7 @@ func processImg(img image.Image, name string, auth claude.Auth, sp *spoolSync, d
if dryRun { if dryRun {
slog.Info("spooldb: would update", "spool", r.SpoolID, slog.Info("spooldb: would update", "spool", r.SpoolID,
"remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight) "remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight)
} else if sp.setTotal(r.SpoolID, reading.Weight) { } else if sess.setTotal(r.SpoolID, reading.Weight) {
updated = true updated = true
slog.Info("spooldb: updated", "spool", r.SpoolID, slog.Info("spooldb: updated", "spool", r.SpoolID,
"remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight) "remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight)
+5 -4
View File
@@ -15,9 +15,10 @@ import (
//go:embed index.html //go:embed index.html
var indexHTML []byte var indexHTML []byte
// serve runs the mobile putaway web server. Requests are serialized because the // serve runs the mobile putaway web server. Each request gets its own fresh
// shared spooldb browser session is single-threaded. // spooldb session; requests are serialized so we never run several headless
func serve(addr string, auth claude.Auth, sp *spoolSync, dryRun bool) error { // browsers at once.
func serve(addr string, auth claude.Auth, dryRun bool) error {
var mu sync.Mutex var mu sync.Mutex
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -53,7 +54,7 @@ func serve(addr string, auth claude.Auth, sp *spoolSync, dryRun bool) error {
start := time.Now() start := time.Now()
mu.Lock() mu.Lock()
res := processImg(img, hdr.Filename, auth, sp, dryRun) res := processImg(img, hdr.Filename, auth, dryRun)
mu.Unlock() mu.Unlock()
slog.Info("processed upload", "file", hdr.Filename, "spool", res.SpoolID, "dur", time.Since(start)) slog.Info("processed upload", "file", hdr.Filename, "spool", res.SpoolID, "dur", time.Since(start))