fix(redis,scanner): atomic IncrWindow Lua, lock rand/ctx fixes, ScanLock renewal, single-flight, error logging (B1,B2,B3,B9,B10,B11)
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"booklib/internal/bookfile"
|
||||
@@ -22,6 +23,9 @@ type Scanner struct {
|
||||
st *store.Store
|
||||
cfg *config.Config
|
||||
rdb *redispkg.R
|
||||
// B9-②: per-library single-flight — concurrent scan triggers for the same
|
||||
// library are merged into one execution, even without redis.
|
||||
flights sync.Map // map[int64]*sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(st *store.Store, cfg *config.Config, rdb *redispkg.R) *Scanner {
|
||||
@@ -42,7 +46,7 @@ func (s *Scanner) Run(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
for _, l := range libs {
|
||||
s.ScanLibrary(ctx, l)
|
||||
s.scanOnce(ctx, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,12 +58,28 @@ func (s *Scanner) ScanLibraryByID(ctx context.Context, id int64) {
|
||||
log.Printf("scan: library %d: %v", id, err)
|
||||
return
|
||||
}
|
||||
s.scanOnce(ctx, lib)
|
||||
}
|
||||
|
||||
// scanOnce ensures only one scan per library runs concurrently in this process.
|
||||
// Concurrent callers block until the in-flight scan completes (B9-②).
|
||||
func (s *Scanner) scanOnce(ctx context.Context, lib store.Library) {
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
if existing, loaded := s.flights.LoadOrStore(lib.ID, wg); loaded {
|
||||
existing.(*sync.WaitGroup).Wait()
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
s.flights.Delete(lib.ID)
|
||||
wg.Done()
|
||||
}()
|
||||
s.ScanLibrary(ctx, lib)
|
||||
}
|
||||
|
||||
func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
|
||||
// ponytail: 5min lock TTL; a scan longer than this lets another replica join — refresh mid-walk if libs ever outgrow it
|
||||
unlock, ok := s.rdb.Lock(ctx, fmt.Sprintf("scan:%d", lib.ID), 5*time.Minute)
|
||||
// B9-①: ScanLock auto-renews every TTL/2 during long scans.
|
||||
unlock, ok := s.rdb.ScanLock(ctx, fmt.Sprintf("scan:%d", lib.ID), 5*time.Minute)
|
||||
if !ok {
|
||||
return // 别的副本在扫
|
||||
}
|
||||
@@ -137,8 +157,6 @@ func titleOf(rel string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSuffix(base, filepath.Ext(base)), "_", " "))
|
||||
}
|
||||
|
||||
// cbz 完整性判定集中在 add/update:PageIndex 失败 → state=error。
|
||||
// InsertBook/UpdateBookFile 的 SQL 已把 state 重置为 ready(Task 2),无需显式清 error。
|
||||
func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds diskStat) {
|
||||
format := bookfile.FormatFromExt(filepath.Base(rel))
|
||||
pageCount := 0
|
||||
@@ -147,7 +165,7 @@ func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds dis
|
||||
idx, err := s.zipIndex(root, rel)
|
||||
pageCount = len(idx)
|
||||
idxErr = err
|
||||
if idxErr == nil && pageCount == 0 { // 视频/文档 zip 不是漫画,空白 reader 没有意义
|
||||
if idxErr == nil && pageCount == 0 {
|
||||
idxErr = errors.New("no images in archive")
|
||||
}
|
||||
}
|
||||
@@ -157,7 +175,10 @@ func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds dis
|
||||
return
|
||||
}
|
||||
if idxErr != nil {
|
||||
s.st.SetBookState(ctx, id, "error", idxErr.Error())
|
||||
// B10: log SetBookState errors instead of discarding.
|
||||
if e := s.st.SetBookState(ctx, id, "error", idxErr.Error()); e != nil {
|
||||
log.Printf("scan: SetBookState error for %s: %v", rel, e)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.cover(ctx, id, root, rel, format, ds)
|
||||
@@ -171,7 +192,7 @@ func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel str
|
||||
idx, err := s.zipIndex(root, rel)
|
||||
pageCount = len(idx)
|
||||
idxErr = err
|
||||
if idxErr == nil && pageCount == 0 { // 视频/文档 zip 不是漫画,空白 reader 没有意义
|
||||
if idxErr == nil && pageCount == 0 {
|
||||
idxErr = errors.New("no images in archive")
|
||||
}
|
||||
}
|
||||
@@ -180,7 +201,9 @@ func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel str
|
||||
return
|
||||
}
|
||||
if idxErr != nil {
|
||||
s.st.SetBookState(ctx, bookID, "error", idxErr.Error())
|
||||
if e := s.st.SetBookState(ctx, bookID, "error", idxErr.Error()); e != nil {
|
||||
log.Printf("scan: SetBookState error for %s: %v", rel, e)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.cover(ctx, bookID, root, rel, format, ds)
|
||||
@@ -199,7 +222,8 @@ func (s *Scanner) zipIndex(root, rel string) ([]string, error) {
|
||||
return bookfile.PageIndex(f, st.Size())
|
||||
}
|
||||
|
||||
// cover 失败(坏 epub、无图等)只 log — 书的 state 由 PageIndex 判定,封面缺了有占位 SVG 兜底
|
||||
// cover writes the cover image to the cache dir. B11: all write errors are
|
||||
// checked and logged; orphan .tmp files are cleaned only on failure path.
|
||||
func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) {
|
||||
var img []byte
|
||||
var ext string
|
||||
@@ -223,8 +247,15 @@ func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string,
|
||||
}
|
||||
tmp := filepath.Join(dir, "cover"+ext+".tmp")
|
||||
dst := filepath.Join(dir, "cover"+ext)
|
||||
if e := os.WriteFile(tmp, img, 0o644); e == nil {
|
||||
os.Rename(tmp, dst)
|
||||
if e := os.WriteFile(tmp, img, 0o644); e != nil {
|
||||
log.Printf("scan: write cover tmp %s: %v", rel, e)
|
||||
os.Remove(tmp)
|
||||
return
|
||||
}
|
||||
if e := os.Rename(tmp, dst); e != nil {
|
||||
log.Printf("scan: rename cover %s: %v", rel, e)
|
||||
os.Remove(tmp)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user