fix: address code review findings

- Start Web UI before the blocking initial sync so the dashboard is
  reachable during the (rate-limited, potentially multi-minute) first
  sync; fold the immediate sync into startPeriodicSync's overlap guard
  so it can never race a concurrent tick over the shared DB / MB client.
- Make MarkNotificationSent idempotent: INSERT OR IGNORE for same-second
  PK collisions, and explicitly swallow FK violations when a release was
  pruned by a concurrent re-sync. Prevents a single vanished/duplicate
  release from aborting the digest mark-sent loop and re-sending.
- Do not abort NotifyOnce's mark-sent loop on a single failure; log and
  continue so every release in the batch is marked.
- NULL-safe reads: COALESCE(type,''), COALESCE(release_date,'') in the
  external_releases and unnotified readers to match the cache reader.
- Update/extend tests for the new idempotency and startup contracts.
This commit is contained in:
2026-07-20 06:27:42 +03:00
parent a8aa445d94
commit aee0241bb7
6 changed files with 110 additions and 42 deletions

View File

@@ -131,17 +131,14 @@ func (a *App) Close() {
}
func (a *App) run(ctx context.Context) error {
// Run an immediate sync+scan so the service produces results without
// waiting a full interval.
if err := a.doSync(ctx); err != nil {
if ctx.Err() != nil {
return nil
}
log.Printf("Initial sync+scan failed: %v", err)
}
// Start the Web UI dashboard in its own goroutine; it serves until ctx is
// cancelled, then shuts down gracefully.
// Start the Web UI dashboard FIRST, in its own goroutine, so the dashboard
// accepts connections immediately. The initial sync below is throttled by
// the MusicBrainz 1 req/s limit and can take many minutes on a large
// library (worst case: a fresh DB where every artist needs MBID
// resolution) — exactly when an operator is most likely watching. Starting
// the server first means the dashboard is reachable (serving cached data)
// during that window instead of refusing connections. It serves until ctx
// is cancelled, then shuts down gracefully.
if a.web != nil {
go func() {
if err := a.web.Start(ctx); err != nil {
@@ -157,7 +154,10 @@ func (a *App) run(ctx context.Context) error {
// disabled (sender nil / enabled false), so always calling it is safe.
a.startNotifier(ctx)
// Kick off the periodic sync+scan loop goroutine.
// Kick off the periodic sync+scan loop. It runs an immediate first sync
// (governed by the same overlap guard as periodic ticks) so the service
// produces results without waiting a full interval, without racing a
// concurrent tick over the shared DB and rate-limited MusicBrainz client.
a.startPeriodicSync(ctx)
<-ctx.Done()
@@ -237,25 +237,38 @@ func (a *App) startPeriodicSync(ctx context.Context) {
var free = make(chan struct{}, 1)
free <- struct{}{}
// launch starts a guarded sync if the slot is free, returning true when a
// sync was started and false when one is already in progress. The in-flight
// goroutine returns the token when done.
launch := func(label string) bool {
select {
case <-free:
go func() {
defer func() { free <- struct{}{} }()
if err := a.doSync(ctx); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("%s sync+scan failed: %v", label, err)
}
}()
return true
default:
return false
}
}
// Immediate first sync (guarded), so the service produces results without
// waiting a full interval and without racing the first ticker fire.
launch("Initial")
for {
select {
case <-ctx.Done():
log.Println("Periodic sync stopped.")
return
case <-ticker.C:
select {
case <-free:
// Slot was free; start a sync and release the slot when done.
go func() {
defer func() { free <- struct{}{} }()
if err := a.doSync(ctx); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Periodic sync+scan failed: %v", err)
}
}()
default:
if !launch("Periodic") {
// Previous sync still running; skip this tick.
log.Println("Skipping periodic sync: previous sync still in progress.")
}

View File

@@ -143,8 +143,10 @@ func TestStartPeriodicSync_CancelsCleanly(t *testing.T) {
close(done)
}()
// With a 1h interval the ticker would never fire on its own; cancel should
// return promptly.
// With a 1h interval the ticker never fires on its own; cancel should
// return promptly. The loop does run one immediate (guarded) sync at
// startup, so depending on scheduling calls may be 0 (cancel won the race)
// or 1 (immediate sync ran) — but never more, since no tick can fire in 1h.
cancel()
select {
@@ -154,8 +156,8 @@ func TestStartPeriodicSync_CancelsCleanly(t *testing.T) {
t.Fatal("startPeriodicSync did not exit after ctx cancellation")
}
if got := atomic.LoadInt64(&calls); got != 0 {
t.Errorf("expected no sync calls with 1h interval, got %d", got)
if got := atomic.LoadInt64(&calls); got > 1 {
t.Errorf("expected at most 1 (immediate) sync call with 1h interval, got %d", got)
}
}