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
This commit is contained in:
2026-03-04 20:57:47 -05:00
parent cd98ad6dc8
commit cdd17db275
2 changed files with 233 additions and 7 deletions
+181
View File
@@ -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.
+52 -7
View File
@@ -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,