corpus

Log | Files | Refs | README | LICENSE

commit e1cefbaee28bae5d3a22745dd10606686269c84b
parent 48121008f711ae381c19a665422f7dcd37fe092c
Author: mtmn <miro@haravara.org>
Date:   Fri, 17 Apr 2026 22:41:24 +0200

feat: add write locks to db

- Replace `isSyncing :: Ref Boolean` with `writeLock :: AVar Unit`.
- Sync transactions now acquire the lock, serializing concurrent writes on the same connection.

Diffstat:
Msrc/Db.purs | 20++++++++++++++++++++
Msrc/Main.purs | 107++++++++++++++++++++++++++++++-------------------------------------------------
2 files changed, 61 insertions(+), 66 deletions(-)

diff --git a/src/Db.purs b/src/Db.purs @@ -8,8 +8,11 @@ import Data.Maybe (Maybe(..), fromMaybe) import Data.Time.Duration (Milliseconds(..)) import Effect (Effect) import Effect.Aff (Aff, delay, makeAff, nonCanceler, try) +import Effect.Aff.AVar (AVar) +import Effect.Aff.AVar as Avar import Effect.Class (liftEffect) import Effect.Exception (Error, error, message) +import Control.Monad.Error.Class (throwError) import Effect.Now (nowDateTime) import Data.Formatter.DateTime (formatDateTime) import Foreign (Foreign) @@ -89,6 +92,23 @@ backupDb conn dbFile s3cfg intervalMs = forever do Left err -> Log.error $ "Backup failed: " <> message err Right _ -> pure unit +-- Acquires the write lock, runs the action inside a transaction, then releases. +-- If the action throws, the transaction is rolled back before the lock is released. +withTransaction :: forall a. Connection -> AVar Unit -> Aff a -> Aff a +withTransaction conn lock action = do + Avar.take lock + run conn "BEGIN TRANSACTION" [] + result <- try action + case result of + Left err -> do + void $ try $ run conn "ROLLBACK" [] + Avar.put unit lock + throwError err + Right r -> do + run conn "COMMIT" [] + Avar.put unit lock + pure r + queryAll :: Connection -> String -> Array Foreign -> Aff (Array Json) queryAll conn sql params = makeAff \cb -> do allImpl conn sql params \err rows -> diff --git a/src/Main.purs b/src/Main.purs @@ -24,8 +24,8 @@ import Node.Buffer (fromArrayBuffer) import Data.Either (Either(..)) import Effect.Exception as Exception import Effect.Aff (Aff, launchAff_, makeAff, nonCanceler, try, delay, forkAff, joinFiber) -import Effect.Ref as Ref -import Effect.Ref (Ref) +import Effect.Aff.AVar (AVar) +import Effect.Aff.AVar as Avar import Unsafe.Coerce (unsafeCoerce) import Node.FS.Aff as FSA import Fetch (fetch, Method(GET), lookup) @@ -39,7 +39,7 @@ import Data.Array ((!!), length, uncons, mapMaybe, find) import Data.Tuple (Tuple(..)) import Data.Foldable (for_) import Data.Traversable (traverse) -import Db (Connection, connect, initDb, upsertScrobble, getScrobbles, checkExists, getOldestTs, run, initReleaseMetadata, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleaseByMbid, upsertReleaseMetadata, touchGenreCheckedAt, getStats, ping, backupDb) +import Db (Connection, connect, initDb, upsertScrobble, getScrobbles, checkExists, getOldestTs, initReleaseMetadata, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleaseByMbid, upsertReleaseMetadata, touchGenreCheckedAt, getStats, ping, backupDb, withTransaction) import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..)) import Control.Monad.Rec.Class (forever) import Data.Time.Duration (Milliseconds(..)) @@ -61,7 +61,7 @@ type Response = ServerResponse type UserContext = { conn :: Connection - , isSyncing :: Ref Boolean + , writeLock :: AVar Unit , config :: UserConfig , slug :: String } @@ -188,11 +188,8 @@ lastfmTrackToListen json = do } -- One complete paginated pass; used for both initial and recurring syncs. -lbSyncOnce :: Connection -> String -> Ref Boolean -> Boolean -> Aff Unit -lbSyncOnce conn username isSyncing initialSyncEnabled = do - liftEffect $ Ref.write true isSyncing - void $ performFullSync - liftEffect $ Ref.write false isSyncing +lbSyncOnce :: Connection -> String -> AVar Unit -> Boolean -> Aff Unit +lbSyncOnce conn username writeLock initialSyncEnabled = void performFullSync where performFullSync = do when initialSyncEnabled $ Log.info $ "Starting ListenBrainz sync for " <> username @@ -202,9 +199,7 @@ lbSyncOnce conn username isSyncing initialSyncEnabled = do case parseJson body >>= decodeJson of Left err -> Log.error $ "Sync parse error: " <> show err Right (ListenBrainzResponse { payload: Payload { listens } }) -> do - run conn "BEGIN TRANSACTION" [] - Tuple added (Tuple minTs hitCount) <- processListens listens - run conn "COMMIT" [] + Tuple added (Tuple minTs hitCount) <- withTransaction conn writeLock (processListens listens) when (added > 0 || initialSyncEnabled) $ Log.info $ "ListenBrainz batch: added " <> show added <> ", " <> show hitCount <> " already present." @@ -228,9 +223,7 @@ lbSyncOnce conn username isSyncing initialSyncEnabled = do Log.error $ "Sync parse error: " <> show err pure acc Right (ListenBrainzResponse { payload: Payload { listens } }) -> do - run conn "BEGIN TRANSACTION" [] - Tuple added (Tuple newMinTs hitCount) <- processListens listens - run conn "COMMIT" [] + Tuple added (Tuple newMinTs hitCount) <- withTransaction conn writeLock (processListens listens) Log.info $ "ListenBrainz batch " <> show batchNum <> ": added " <> show added <> ", " <> show hitCount <> " already present." let allExist = hitCount == length listens && length listens > 0 if allExist || length listens == 0 then do @@ -257,24 +250,20 @@ lbSyncOnce conn username isSyncing initialSyncEnabled = do Log.warn "Skipping scrobble without timestamp" syncRecursive acc minTs hitCount tail -lbSyncLoop :: Connection -> String -> Ref Boolean -> Boolean -> Aff Unit -lbSyncLoop conn username isSyncing initialSyncEnabled = forever do +lbSyncLoop :: Connection -> String -> AVar Unit -> Boolean -> Aff Unit +lbSyncLoop conn username writeLock initialSyncEnabled = forever do delay (Milliseconds 60000.0) - lbSyncOnce conn username isSyncing initialSyncEnabled + lbSyncOnce conn username writeLock initialSyncEnabled -lfSyncOnce :: Connection -> String -> String -> Ref Boolean -> Boolean -> Aff Unit -lfSyncOnce conn apiKey lfmUser isSyncing initialSyncEnabled = do - liftEffect $ Ref.write true isSyncing +lfSyncOnce :: Connection -> String -> String -> AVar Unit -> Boolean -> Aff Unit +lfSyncOnce conn apiKey lfmUser writeLock initialSyncEnabled = do void $ performLastfmSync when initialSyncEnabled $ void $ performLastfmBackfill - liftEffect $ Ref.write false isSyncing where performLastfmSync = do when initialSyncEnabled $ Log.info $ "Starting Last.fm sync for " <> lfmUser { tracks, totalPages } <- fetchLastfmPage apiKey lfmUser 1 Nothing - run conn "BEGIN TRANSACTION" [] - Tuple added hitCount <- processLastfmTracks tracks - run conn "COMMIT" [] + Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) when (added > 0 || initialSyncEnabled) $ Log.info $ "Last.fm page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." @@ -292,9 +281,7 @@ lfSyncOnce conn apiKey lfmUser isSyncing initialSyncEnabled = do | otherwise = do when initialSyncEnabled $ Log.info $ "Fetching Last.fm page " <> show page <> "/" <> show totalPages <> "..." { tracks } <- fetchLastfmPage apiKey lfmUser page mTo - run conn "BEGIN TRANSACTION" [] - Tuple added hitCount <- processLastfmTracks tracks - run conn "COMMIT" [] + Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) Log.info $ "Last.fm page " <> show page <> "/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." let validTracks = mapMaybe lastfmTrackToListen tracks @@ -314,9 +301,7 @@ lfSyncOnce conn apiKey lfmUser isSyncing initialSyncEnabled = do pure unit else do Log.info $ "Backfilling " <> show totalPages <> " pages of Last.fm history before " <> show oldestTs - run conn "BEGIN TRANSACTION" [] - Tuple added hitCount <- processLastfmTracks tracks - run conn "COMMIT" [] + Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) Log.info $ "Last.fm backfill page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." total <- paginateLastfmUntilDone 2 totalPages (Just (oldestTs - 1)) added Log.info $ "Last.fm backfill complete. Added " <> show total <> " older scrobbles." @@ -333,10 +318,10 @@ lfSyncOnce conn apiKey lfmUser isSyncing initialSyncEnabled = do syncLastfmRecursive (acc + 1) hitCount tail Just { head: _, tail } -> syncLastfmRecursive acc hitCount tail -lfSyncLoop :: Connection -> String -> String -> Ref Boolean -> Boolean -> Aff Unit -lfSyncLoop conn apiKey lfmUser isSyncing initialSyncEnabled = forever do +lfSyncLoop :: Connection -> String -> String -> AVar Unit -> Boolean -> Aff Unit +lfSyncLoop conn apiKey lfmUser writeLock initialSyncEnabled = forever do delay (Milliseconds 60000.0) - lfSyncOnce conn apiKey lfmUser isSyncing initialSyncEnabled + lfSyncOnce conn apiKey lfmUser writeLock initialSyncEnabled -- Build the per-user index HTML, inlining the slug for the Elm app. indexHtml :: String -> String @@ -801,10 +786,10 @@ handleRequest contexts req res = do "/client.js" -> serveClientJs res "/favicon.png" -> serveAsset "image/png" "assets/favicon.png" res "/" -> serveIndex "" res - "/proxy" -> withUser url \ctx -> serveProxy ctx.conn ctx.isSyncing url res - "/stats" -> withUser url \ctx -> serveStats ctx.conn ctx.isSyncing url res - "/cover" -> withUser url \ctx -> serveCover ctx.config ctx.isSyncing url res - "/healthz" -> withUser url \ctx -> serveHealthz ctx.conn ctx.isSyncing res + "/proxy" -> withUser url \ctx -> serveProxy ctx.conn url res + "/stats" -> withUser url \ctx -> serveStats ctx.conn url res + "/cover" -> withUser url \ctx -> serveCover ctx.config url res + "/healthz" -> withUser url \ctx -> serveHealthz ctx.conn res _ -> case stripPrefix (Pattern "/~") path of Just slug -> serveIndex slug res Nothing -> do @@ -857,10 +842,9 @@ sanitizeKey str = in replace re2 "_" (replace re1 "_" str) -serveCover :: UserConfig -> Ref Boolean -> URL -> Response -> Effect Unit -serveCover cfg isSyncing url res = do +serveCover :: UserConfig -> URL -> Response -> Effect Unit +serveCover cfg url res = do launchAff_ do - yieldToSync isSyncing let mbid = fromMaybe "" (getQueryParam "mbid" url) let artistStr = fromMaybe "" (getQueryParam "artist" url) let releaseStr = fromMaybe "" (getQueryParam "release" url) @@ -1007,15 +991,14 @@ serveCover cfg isSyncing url res = do Log.info "Discogs API request failed" liftEffect $ serveNotFound response -serveProxy :: Connection -> Ref Boolean -> URL -> Response -> Effect Unit -serveProxy db isSyncing url res = do +serveProxy :: Connection -> URL -> Response -> Effect Unit +serveProxy db url res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) setHeader "Access-Control-Allow-Methods" "GET, POST, OPTIONS" (toOutgoingMessage res) setHeader "Access-Control-Allow-Headers" "*" (toOutgoingMessage res) launchAff_ do - yieldToSync isSyncing let limit = fromMaybe 25 (getQueryParam "limit" url >>= fromString) let offset = fromMaybe 0 (getQueryParam "offset" url >>= fromString) let @@ -1048,12 +1031,11 @@ serveAsset contentType path res = do end w Left _ -> serveNotFound res -serveHealthz :: Connection -> Ref Boolean -> Response -> Effect Unit -serveHealthz db isSyncing res = do +serveHealthz :: Connection -> Response -> Effect Unit +serveHealthz db res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) launchAff_ do - yieldToSync isSyncing result <- try $ ping db liftEffect $ do let w = toWriteable (toOutgoingMessage res) @@ -1075,12 +1057,11 @@ serveNotFound res = do void $ writeString w UTF8 "Not Found" end w -serveStats :: Connection -> Ref Boolean -> URL -> Response -> Effect Unit -serveStats db isSyncing url res = do +serveStats :: Connection -> URL -> Response -> Effect Unit +serveStats db url res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) setHeader "Access-Control-Allow-Origin" "*" (toOutgoingMessage res) launchAff_ do - yieldToSync isSyncing let period = getQueryParam "period" url let section = getQueryParam "section" url let safeDate = map (replace (unsafeRegex "[^0-9\\-]" (parseFlags "g")) "") @@ -1204,14 +1185,8 @@ fetchDiscogsGenre (Just t) artist release = do Log.warn "Discogs genre API request failed" pure Nothing -yieldToSync :: Ref Boolean -> Aff Unit -yieldToSync isSyncing = do - syncing <- liftEffect $ Ref.read isSyncing - when syncing $ delay (Milliseconds 200.0) *> yieldToSync isSyncing - -enrichMetadata :: Connection -> Ref Boolean -> UserConfig -> Aff Unit -enrichMetadata conn isSyncing cfg = forever do - yieldToSync isSyncing +enrichMetadata :: Connection -> UserConfig -> Aff Unit +enrichMetadata conn cfg = forever do unenrichedMbids <- getUnenrichedMbids conn 10 emptyGenreMbids <- getEmptyGenreMbids conn 10 let allMbids = unenrichedMbids <> emptyGenreMbids @@ -1221,7 +1196,6 @@ enrichMetadata conn isSyncing cfg = forever do else do Log.info $ "Processing " <> show (length unenrichedMbids) <> " unenriched + " <> show (length emptyGenreMbids) <> " empty genre releases" for_ allMbids \mbid -> do - yieldToSync isSyncing delay (Milliseconds 1100.0) result <- try $ fetchMusicBrainzRelease mbid case result of @@ -1259,34 +1233,35 @@ startUser { slug, config } = do conn <- connect config.databaseFile initDb conn initReleaseMetadata conn - isSyncing <- liftEffect $ Ref.new false + writeLock <- Avar.new unit case config.lastfmUser, config.lastfmApiKey of Just _, Nothing -> Log.warn $ "lastfmUser set for user '" <> slug <> "' but lastfmApiKey is missing — Last.fm sync disabled" _, _ -> pure unit -- Run initial syncs in parallel, then join before starting loops. + -- Both syncs share the write lock, so their transactions are serialized. lbFiber <- case config.listenbrainzUser of - Just username -> Just <$> forkAff (lbSyncOnce conn username isSyncing config.initialSync) + Just username -> Just <$> forkAff (lbSyncOnce conn username writeLock config.initialSync) Nothing -> pure Nothing lfFiber <- case config.lastfmUser, config.lastfmApiKey of - Just lfmUser, Just apiKey -> Just <$> forkAff (lfSyncOnce conn apiKey lfmUser isSyncing config.initialSync) + Just lfmUser, Just apiKey -> Just <$> forkAff (lfSyncOnce conn apiKey lfmUser writeLock config.initialSync) _, _ -> pure Nothing for_ lbFiber joinFiber for_ lfFiber joinFiber -- Background loops - void $ forkAff $ enrichMetadata conn isSyncing config + void $ forkAff $ enrichMetadata conn config for_ config.listenbrainzUser \username -> - void $ forkAff $ lbSyncLoop conn username isSyncing config.initialSync + void $ forkAff $ lbSyncLoop conn username writeLock config.initialSync case config.lastfmUser, config.lastfmApiKey of - Just lfmUser, Just apiKey -> void $ forkAff $ lfSyncLoop conn apiKey lfmUser isSyncing config.initialSync + Just lfmUser, Just apiKey -> void $ forkAff $ lfSyncLoop conn apiKey lfmUser writeLock config.initialSync _, _ -> pure unit when config.backupEnabled $ void $ forkAff $ backupDb conn config.databaseFile (s3ConfigFromUser config) (toNumber config.backupIntervalHours * 3600000.0) - pure { conn, isSyncing, config, slug } + pure { conn, writeLock, config, slug } foreign import dotenvConfig :: Effect Unit