From cdd17db27509908514c21517631306655a2b3bd7 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 4 Mar 2026 20:57:47 -0500 Subject: [PATCH] perf(07-01): add incremental persistence helpers for queue mutations - Add persistAddTrack/persistAddTracks for O(1) append operations - Add persistInsertTracks with variable-N position shift for insert-at operations - Add persistRemoveTrack with single DELETE + position shift - Wire AddTrack/AddTracks to use incremental INSERT (no full table rewrite) - Wire InsertNext/InsertNextTracks/InsertTracksAt to use position shift + INSERT - Wire RemoveTrack to use single DELETE + shift (no full table rewrite) - RemoveTracks keeps full persistTracks rewrite (bulk operation per design) - Preserve shuffle order regeneration in all mutation paths --- backend/queue/persistence.go | 181 +++++++++++++++++++++++++++++++++++ backend/queue/queue.go | 59 ++++++++++-- 2 files changed, 233 insertions(+), 7 deletions(-) diff --git a/backend/queue/persistence.go b/backend/queue/persistence.go index 4a49a2b..09abeb9 100644 --- a/backend/queue/persistence.go +++ b/backend/queue/persistence.go @@ -10,6 +10,187 @@ import ( "yellowjacket/backend/profiling" ) +// persistAddTrack inserts a single track at the end of the queue. +// No position shifting is needed because this is always an append. +// The caller must hold q.mu. +func (q *Queue) persistAddTrack(track Track) { + _, err := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ + AudioFileID: track.AudioFileID, + Position: track.Position, + }) + if err != nil { + q.logger.Error("Failed to persist added track", "err", err) + } +} + +// persistAddTracks inserts multiple tracks at the end of the queue +// atomically in a transaction. No position shifting is needed because +// these are always appends. +// The caller must hold q.mu. +func (q *Queue) persistAddTracks(tracks []Track) { + if len(tracks) == 0 { + return + } + + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + txQueries := q.db.Queries.WithTx(tx) + + for _, track := range tracks { + _, insertErr := txQueries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ + AudioFileID: track.AudioFileID, + Position: track.Position, + }) + if insertErr != nil { + q.logger.Error("Failed to insert track", "err", insertErr) + + return + } + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + +// persistInsertTracks inserts multiple tracks at a given position, +// shifting existing tracks to make room. Uses a transaction for atomicity. +// The caller must hold q.mu. +func (q *Queue) persistInsertTracks(tracks []Track, insertPos int) { + if len(tracks) == 0 { + return + } + + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + // SAFETY: Multi-row position shift by variable N unsupported by sqlc + // (ShiftQueuePositionsUp only shifts by 1). Bind variables match args; + // no string interpolation. + _, err = tx.ExecContext( + q.db.Ctx, + "UPDATE queue_tracks SET position = position + ? WHERE position >= ?", + len(tracks), insertPos, + ) + if err != nil { + q.logger.Error("Failed to shift positions up", "err", err) + + return + } + + txQueries := q.db.Queries.WithTx(tx) + + for i, track := range tracks { + _, insertErr := txQueries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{ + AudioFileID: track.AudioFileID, + Position: int64(insertPos + i), + }) + if insertErr != nil { + q.logger.Error("Failed to insert track", "err", insertErr) + + return + } + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + +// persistRemoveTrack deletes a single track at the given position and +// shifts subsequent positions down to close the gap. +// The caller must hold q.mu. +func (q *Queue) persistRemoveTrack(position int) { + tx, err := q.db.BeginTx() + if err != nil { + q.logger.Error("Failed to begin transaction", "err", err) + + return + } + + committed := false + + defer func() { + if !committed { + if rbErr := tx.Rollback(); rbErr != nil { + q.logger.Error( + "Failed to rollback transaction", + "err", rbErr, + ) + } + } + }() + + txQueries := q.db.Queries.WithTx(tx) + + if removeErr := txQueries.RemoveQueueTrackByPosition( + q.db.Ctx, int64(position), + ); removeErr != nil { + q.logger.Error("Failed to remove track by position", "err", removeErr) + + return + } + + if shiftErr := txQueries.ShiftQueuePositionsDown( + q.db.Ctx, int64(position), + ); shiftErr != nil { + q.logger.Error("Failed to shift positions down", "err", shiftErr) + + return + } + + if commitErr := tx.Commit(); commitErr != nil { + q.logger.Error("Failed to commit transaction", "err", commitErr) + + return + } + + committed = true +} + // lookupTrackMetaBatch fetches audio file IDs and metadata for a batch of // file paths using a single query per chunk (instead of 2 queries per track). // Returns a map keyed by file path. This is safe to call without holding q.mu. diff --git a/backend/queue/queue.go b/backend/queue/queue.go index 23b0a1a..5703e13 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -343,7 +343,12 @@ func (q *Queue) AddTrack(filePath string) { q.loadCurrentTrack() } - q.commitMutation(false) + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistAddTrack(track) + q.persistState() q.emitTracksModified( "add", []Track{track}, @@ -388,7 +393,12 @@ func (q *Queue) AddTracks(filePaths []string) { q.loadCurrentTrack() } - q.commitMutation(false) + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistAddTracks(newTracks) + q.persistState() q.emitTracksModified( "add", newTracks, @@ -439,7 +449,14 @@ func (q *Queue) InsertNextTracks(filePaths []string) { q.loadCurrentTrack() } - q.commitMutation(true) + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistInsertTracks(newTracks, insertPos) + q.persistState() q.emitTracksModified( "insert", newTracks, @@ -482,7 +499,14 @@ func (q *Queue) InsertNext(filePath string) { q.loadCurrentTrack() } - q.commitMutation(true) + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistInsertTracks([]Track{track}, insertPos) + q.persistState() q.emitTracksModified( "insert", []Track{track}, @@ -542,7 +566,14 @@ func (q *Queue) InsertTracksAt(filePaths []string, index int) { q.loadCurrentTrack() } - q.commitMutation(true) + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistInsertTracks(newTracks, index) + q.persistState() q.emitTracksModified( "insert", newTracks, @@ -722,7 +753,14 @@ func (q *Queue) RemoveTrack(position int) { q.currentIndex = len(q.tracks) - 1 } - q.commitMutation(true) + q.persistRemoveTrack(position) + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistState() q.emitTracksModified( "remove", nil, @@ -784,7 +822,14 @@ func (q *Queue) RemoveTracks(positions []int) { } } - q.commitMutation(true) + q.reindexPositions() + + if q.shuffleMode { + q.generateShuffleOrder() + } + + q.persistTracks() + q.persistState() q.emitTracksModified( "remove", nil,