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

@@ -1,16 +1,37 @@
package database
import (
"errors"
"fmt"
sqlite3 "github.com/mattn/go-sqlite3"
)
// MarkNotificationSent records that a notification has been sent for the given RGID.
//
// Uses INSERT OR IGNORE so a pre-existing marker for the same RGID (a
// same-second re-notify colliding on the (rgid, sent_at) primary key) is a
// no-op rather than an error: the marker's presence, not its exact timestamp,
// is what matters for idempotency.
//
// A concurrent re-sync that prunes the external_releases row before this insert
// would violate the FK constraint. OR IGNORE does NOT downgrade FK violations
// in this SQLite build, so the FK error is caught explicitly and treated as a
// benign no-op ("the release is already gone"). This ensures a single vanished
// release cannot abort a whole digest's mark-sent loop and trigger duplicate
// notifications on the next run.
func MarkNotificationSent(db *DB, rgid string) error {
_, err := db.Conn().Exec(
"INSERT INTO notifications_sent (rgid) VALUES (?)",
"INSERT OR IGNORE INTO notifications_sent (rgid) VALUES (?)",
rgid,
)
if err != nil {
var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) && sqliteErr.Code == sqlite3.ErrConstraint &&
sqliteErr.ExtendedCode == sqlite3.ErrConstraintForeignKey {
// Release row was pruned concurrently; nothing to mark.
return nil
}
return fmt.Errorf("mark notification sent: %w", err)
}
return nil
@@ -33,7 +54,7 @@ func IsNotificationSent(db *DB, rgid string) (bool, error) {
// artists are excluded so the digest honors the monitoring contract.
func GetUnnotifiedReleases(db *DB) ([]ExternalRelease, error) {
rows, err := db.Conn().Query(`
SELECT e.rgid, e.artist_id, e.title, e.type, e.release_date, e.is_ignored
SELECT e.rgid, e.artist_id, e.title, COALESCE(e.type,''), COALESCE(e.release_date,''), e.is_ignored
FROM external_releases e
JOIN artist_settings s ON e.artist_id = s.id
LEFT JOIN notifications_sent n ON e.rgid = n.rgid