Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ability to lock detections to protect them from cleanup #424

Merged
merged 9 commits into from
Feb 1, 2025
175 changes: 168 additions & 7 deletions internal/datastore/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ type Interface interface {
CountSpeciesDetections(species, date, hour string, duration int) (int64, error)
CountSearchResults(query string) (int64, error)
Transaction(fc func(tx *gorm.DB) error) error
// Lock management methods
LockNote(noteID string) error
UnlockNote(noteID string) error
GetNoteLock(noteID string) (*NoteLock, error)
IsNoteLocked(noteID string) (bool, error)
}

// DataStore implements StoreInterface using a GORM database.
Expand Down Expand Up @@ -161,8 +166,8 @@ func (ds *DataStore) Get(id string) (Note, error) {
}

var note Note
// Retrieve the note by its ID with Review and Comments preloaded
if err := ds.DB.Preload("Review").Preload("Comments", func(db *gorm.DB) *gorm.DB {
// Retrieve the note by its ID with Review, Lock, and Comments preloaded
if err := ds.DB.Preload("Review").Preload("Lock").Preload("Comments", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC") // Order comments by creation time, newest first
}).First(&note, noteID).Error; err != nil {
return Note{}, fmt.Errorf("getting note with ID %d: %w", noteID, err)
Expand All @@ -173,6 +178,9 @@ func (ds *DataStore) Get(id string) (Note, error) {
note.Verified = note.Review.Verified
}

// Populate virtual Locked field
note.Locked = note.Lock != nil

return note, nil
}

Expand All @@ -184,6 +192,15 @@ func (ds *DataStore) Delete(id string) error {
return fmt.Errorf("converting ID to integer: %w", err)
}

// Check if the note is locked
isLocked, err := ds.IsNoteLocked(id)
if err != nil {
return fmt.Errorf("checking note lock status: %w", err)
}
if isLocked {
return fmt.Errorf("cannot delete note: note is locked")
}

// Perform the deletion within a transaction
return ds.DB.Transaction(func(tx *gorm.DB) error {
// Delete the full results entry associated with the note
Expand Down Expand Up @@ -281,7 +298,11 @@ func (ds *DataStore) GetClipsQualifyingForRemoval(minHours, minClips int) ([]Cli

// Define a subquery to count the number of recordings per scientific name
subquery := ds.DB.Model(&Note{}).Select("ID, scientific_name, ROW_NUMBER() OVER (PARTITION BY scientific_name) as num_recordings").
Where("clip_name != ''")
Where("clip_name != ''").
// Exclude notes that have a lock
Joins("LEFT JOIN note_locks ON notes.id = note_locks.note_id").
Where("note_locks.id IS NULL")

if err := subquery.Error; err != nil {
return nil, fmt.Errorf("error creating subquery: %w", err)
}
Expand All @@ -290,6 +311,9 @@ func (ds *DataStore) GetClipsQualifyingForRemoval(minHours, minClips int) ([]Cli
err := ds.DB.Table("(?) AS n", ds.DB.Model(&Note{})).
Select("n.ID, n.scientific_name, n.clip_name, sub.num_recordings").
Joins("INNER JOIN (?) AS sub ON n.ID = sub.ID", subquery).
// Exclude notes that have a lock
Joins("LEFT JOIN note_locks ON n.id = note_locks.note_id").
Where("note_locks.id IS NULL").
Where("strftime('%s', 'now') - strftime('%s', begin_time) > ?", minHours*3600). // Convert hours to seconds for comparison
Where("sub.num_recordings > ?", minClips).
Scan(&results).Error
Expand Down Expand Up @@ -347,7 +371,7 @@ func (ds *DataStore) GetHourlyOccurrences(date, commonName string, minConfidence
func (ds *DataStore) SpeciesDetections(species, date, hour string, duration int, sortAscending bool, limit, offset int) ([]Note, error) {
sortOrder := sortAscendingString(sortAscending)

query := ds.DB.Preload("Review").Preload("Comments", func(db *gorm.DB) *gorm.DB {
query := ds.DB.Preload("Review").Preload("Lock").Preload("Comments", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC") // Order comments by creation time, newest first
}).Where("common_name = ? AND date = ?", species, date)
if hour != "" {
Expand All @@ -362,11 +386,12 @@ func (ds *DataStore) SpeciesDetections(species, date, hour string, duration int,
var detections []Note
err := query.Find(&detections).Error

// Populate virtual Verified field
// Populate virtual fields
for i := range detections {
if detections[i].Review != nil {
detections[i].Verified = detections[i].Review.Verified
}
detections[i].Locked = detections[i].Lock != nil
}

return detections, err
Expand All @@ -378,17 +403,18 @@ func (ds *DataStore) GetLastDetections(numDetections int) ([]Note, error) {
now := time.Now()

// Retrieve the most recent detections based on the ID in descending order
if result := ds.DB.Preload("Review").Preload("Comments", func(db *gorm.DB) *gorm.DB {
if result := ds.DB.Preload("Review").Preload("Lock").Preload("Comments", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC") // Order comments by creation time, newest first
}).Order("id DESC").Limit(numDetections).Find(&notes); result.Error != nil {
return nil, fmt.Errorf("error getting last detections: %w", result.Error)
}

// Populate virtual Verified field
// Populate virtual fields
for i := range notes {
if notes[i].Review != nil {
notes[i].Verified = notes[i].Review.Verified
}
notes[i].Locked = notes[i].Lock != nil
}

elapsed := time.Since(now)
Expand Down Expand Up @@ -705,3 +731,138 @@ func (ds *DataStore) Transaction(fc func(tx *gorm.DB) error) error {
}
return ds.DB.Transaction(fc)
}

// GetNoteLock retrieves the lock status for a note
func (ds *DataStore) GetNoteLock(noteID string) (*NoteLock, error) {
id, err := strconv.ParseUint(noteID, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid note ID: %w", err)
}

var lock NoteLock
// Check if the lock exists and get its details in one query
err = ds.DB.Where("note_id = ?", id).First(&lock).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // Return nil if no lock exists
}
return nil, fmt.Errorf("error getting lock details: %w", err)
}

return &lock, nil
}

// IsNoteLocked checks if a note is locked
func (ds *DataStore) IsNoteLocked(noteID string) (bool, error) {
id, err := strconv.ParseUint(noteID, 10, 32)
if err != nil {
return false, fmt.Errorf("invalid note ID: %w", err)
}

var count int64
err = ds.DB.Model(&NoteLock{}).
Where("note_id = ?", id).
Count(&count).
Error

if err != nil {
return false, fmt.Errorf("error checking lock status: %w", err)
}

return count > 0, nil
}

// LockNote creates or updates a lock for a note
func (ds *DataStore) LockNote(noteID string) error {
id, err := strconv.ParseUint(noteID, 10, 32)
if err != nil {
return fmt.Errorf("invalid note ID: %w", err)
}

// Generate a unique transaction ID (first 8 chars of UUID)
txID := fmt.Sprintf("tx-%s", uuid.New().String()[:8])

// Retry configuration
maxRetries := 5
baseDelay := 500 * time.Millisecond

var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
// Use upsert operation to either create or update the lock
lock := &NoteLock{
NoteID: uint(id),
LockedAt: time.Now(),
}

result := ds.DB.Where("note_id = ?", id).
Assign(*lock).
FirstOrCreate(lock)

if result.Error != nil {
if strings.Contains(strings.ToLower(result.Error.Error()), "database is locked") {
delay := baseDelay * time.Duration(attempt+1)
log.Printf("[%s] Database locked, retrying in %v (attempt %d/%d)", txID, delay, attempt+1, maxRetries)
time.Sleep(delay)
lastErr = result.Error
continue
}
return fmt.Errorf("failed to lock note: %w", result.Error)
}

// If we get here, the transaction was successful
if attempt > 0 {
log.Printf("[%s] Database transaction successful after %d attempts", txID, attempt+1)
}
return nil
}

return fmt.Errorf("[%s] failed after %d attempts: %w", txID, maxRetries, lastErr)
}

// UnlockNote removes a lock from a note
func (ds *DataStore) UnlockNote(noteID string) error {
id, err := strconv.ParseUint(noteID, 10, 32)
if err != nil {
return fmt.Errorf("invalid note ID: %w", err)
}

// Generate a unique transaction ID (first 8 chars of UUID)
txID := fmt.Sprintf("tx-%s", uuid.New().String()[:8])

// Retry configuration
maxRetries := 5
baseDelay := 500 * time.Millisecond

var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
// First check if the lock exists
exists, err := ds.IsNoteLocked(noteID)
if err != nil {
return fmt.Errorf("failed to check lock existence: %w", err)
}
if !exists {
// Lock doesn't exist, nothing to unlock
return nil
}

result := ds.DB.Where("note_id = ?", id).Delete(&NoteLock{})
if result.Error != nil {
if strings.Contains(strings.ToLower(result.Error.Error()), "database is locked") {
delay := baseDelay * time.Duration(attempt+1)
log.Printf("[%s] Database locked, retrying in %v (attempt %d/%d)", txID, delay, attempt+1, maxRetries)
time.Sleep(delay)
lastErr = result.Error
continue
}
return fmt.Errorf("failed to unlock note: %w", result.Error)
}

// If we get here, the transaction was successful
if attempt > 0 {
log.Printf("[%s] Database transaction successful after %d attempts", txID, attempt+1)
}
return nil
}

return fmt.Errorf("[%s] failed after %d attempts: %w", txID, maxRetries, lastErr)
}
2 changes: 1 addition & 1 deletion internal/datastore/manage.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func createGormLogger() logger.Interface {

// performAutoMigration automates database migrations with error handling.
func performAutoMigration(db *gorm.DB, debug bool, dbType, connectionInfo string) error {
if err := db.AutoMigrate(&Note{}, &Results{}, &NoteReview{}, &NoteComment{}, &DailyEvents{}, &HourlyWeather{}); err != nil {
if err := db.AutoMigrate(&Note{}, &Results{}, &NoteReview{}, &NoteComment{}, &DailyEvents{}, &HourlyWeather{}, &NoteLock{}); err != nil {
return fmt.Errorf("failed to auto-migrate %s database: %w", dbType, err)
}

Expand Down
12 changes: 11 additions & 1 deletion internal/datastore/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ type Note struct {
Results []Results `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"`
Review *NoteReview `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` // One-to-one relationship with cascade delete
Comments []NoteComment `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` // One-to-many relationship with cascade delete
Lock *NoteLock `gorm:"foreignKey:NoteID;constraint:OnDelete:CASCADE"` // One-to-one relationship with cascade delete

// Virtual field to maintain compatibility with templates
// Virtual fields to maintain compatibility with templates
Verified string `gorm:"-"` // This will be populated from Review.Verified
Locked bool `gorm:"-"` // This will be populated from Lock presence
}

// Result represents the identification result with a species name and its confidence level, linked to a Note.
Expand Down Expand Up @@ -69,6 +71,14 @@ type NoteComment struct {
UpdatedAt time.Time // When the comment was last updated
}

// NoteLock represents the lock status of a Note
// GORM will automatically create table name as 'note_locks'
type NoteLock struct {
ID uint `gorm:"primaryKey"`
NoteID uint `gorm:"uniqueIndex;not null;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:NoteID;references:ID"` // Foreign key to associate with Note, with unique constraint
LockedAt time.Time `gorm:"index;not null"` // When the note was locked
}

// DailyEvents represents the daily weather data that doesn't change throughout the day
type DailyEvents struct {
ID uint `gorm:"primaryKey"`
Expand Down
Loading