- 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.
94 lines
3.2 KiB
Go
94 lines
3.2 KiB
Go
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 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
|
|
}
|
|
|
|
// IsNotificationSent checks whether a notification has already been sent for the given RGID.
|
|
func IsNotificationSent(db *DB, rgid string) (bool, error) {
|
|
var count int
|
|
err := db.Conn().QueryRow(
|
|
"SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", rgid,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
return false, fmt.Errorf("check notification sent: %w", err)
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// GetUnnotifiedReleases returns all external_release rows for monitored artists
|
|
// that have no entry in notifications_sent. Releases belonging to unmonitored
|
|
// 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, 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
|
|
WHERE s.monitored = 1 AND n.rgid IS NULL AND e.is_ignored = 0
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query unnotified releases: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []ExternalRelease
|
|
for rows.Next() {
|
|
var r ExternalRelease
|
|
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil {
|
|
return nil, fmt.Errorf("scan unnotified release: %w", err)
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate unnotified releases: %w", err)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// GetNotificationSentAt returns the sent_at time for a given RGID.
|
|
// Returns sql.ErrNoRows if no notification has been sent.
|
|
func GetNotificationSentAt(db *DB, rgid string) (string, error) {
|
|
var sentAt string
|
|
err := db.Conn().QueryRow(
|
|
"SELECT sent_at FROM notifications_sent WHERE rgid = ? ORDER BY sent_at DESC LIMIT 1", rgid,
|
|
).Scan(&sentAt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return sentAt, nil
|
|
}
|