corpus

Log | Files | Refs | README | LICENSE

commit 0e6c1544957a348a76cb2e81533d7d7c33e98b18
parent 5b39e9c30e9cbc3618aed559857190b3531c3ef7
Author: mtmn <miro@haravara.org>
Date:   Sun, 19 Apr 2026 20:30:30 +0200

feat: shutdown handling, fix race conditions

Diffstat:
Msrc/Client.elm | 2+-
Msrc/Db.purs | 14+++++++++-----
Msrc/Main.purs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/Templates.purs | 13++++++++-----
4 files changed, 104 insertions(+), 51 deletions(-)

diff --git a/src/Client.elm b/src/Client.elm @@ -797,7 +797,7 @@ view model = [ input [ type_ "text" , class "search-input" - , placeholder "track, artist, album or label" + , placeholder "search by track, artist, album or label" , value model.searchInput , onInput UpdateSearchInput , onEnter SubmitSearch diff --git a/src/Db.purs b/src/Db.purs @@ -125,7 +125,7 @@ withTransaction conn lock action = do void $ try $ run conn "ROLLBACK" [] throwError err Right v -> do - run conn "COMMIT" [] + void $ try $ run conn "COMMIT" [] pure v Avar.put unit lock case result of @@ -211,16 +211,20 @@ getScrobbles conn limit offset (Just { field, value }) Nothing = do filterQuery :: FilterField -> String filterQuery FilterArtist = scrobbleCols <> scrobbleFromLeft - <> " WHERE s.artist_name = ?" <> scrobbleOrderPage + <> " WHERE s.artist_name = ?" + <> scrobbleOrderPage filterQuery FilterLabel = scrobbleCols <> scrobbleFromInner - <> " WHERE rm.label = ?" <> scrobbleOrderPage + <> " WHERE rm.label = ?" + <> scrobbleOrderPage filterQuery FilterYear = scrobbleCols <> scrobbleFromInner - <> " WHERE rm.release_year::VARCHAR = ?" <> scrobbleOrderPage + <> " WHERE rm.release_year::VARCHAR = ?" + <> scrobbleOrderPage filterQuery FilterGenre = scrobbleCols <> scrobbleFromInner - <> " WHERE rm.genre = ?" <> scrobbleOrderPage + <> " WHERE rm.genre = ?" + <> scrobbleOrderPage initReleaseMetadata :: Connection -> Aff Unit initReleaseMetadata conn = do diff --git a/src/Main.purs b/src/Main.purs @@ -4,13 +4,13 @@ import Prelude import Config (AppConfig, UserConfig, UserEntry, loadConfig, s3ConfigFromUser) import Effect (Effect) +import Node.EventEmitter (on_, EventHandle(..)) import Effect.Class (liftEffect) import Log as Log import Metrics as Metrics import Node.HTTP (createServer) import Node.HTTPS as HTTPS import Node.HTTP.Server as Server -import Node.EventEmitter (on_, EventHandle(..)) import Effect.Uncurried (mkEffectFn1) import Node.HTTP.ClientRequest as Client import Node.HTTP.IncomingMessage as IM @@ -25,7 +25,7 @@ import Node.Buffer (fromArrayBuffer) import Data.Either (Either(..), hush) import Control.Monad.Error.Class (throwError) import Effect.Exception as Exception -import Effect.Aff (Aff, launchAff_, makeAff, nonCanceler, try, delay, forkAff, joinFiber) +import Effect.Aff (Aff, launchAff_, makeAff, nonCanceler, try, delay, forkAff, joinFiber, killFiber, Fiber) import Effect.Aff.AVar (AVar) import Effect.Aff.AVar as Avar import Unsafe.Coerce (unsafeCoerce) @@ -39,7 +39,7 @@ import Data.Argonaut (decodeJson, encodeJson, parseJson) import Data.Argonaut.Core (Json, toObject, toArray, toString, stringify) import Data.Array ((!!), length, null, mapMaybe, find) import Data.Tuple (Tuple(..)) -import Data.Foldable (for_, foldM) +import Data.Foldable (for_, foldM, traverse_) import Data.Traversable (traverse) import Db (Connection, FilterField(..), connect, initDb, upsertScrobble, getScrobbles, checkExists, getOldestTs, initReleaseMetadata, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleasesByMbids, upsertReleaseMetadata, touchGenreCheckedAt, getStats, ping, backupDb, withTransaction) import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..)) @@ -60,6 +60,7 @@ import Templates (indexHtml) -- Types type Request = IncomingMessage IMServer + type Response = ServerResponse type UserContext = @@ -68,6 +69,8 @@ type UserContext = , config :: UserConfig , slug :: String , displayName :: String + , enrichMetadataFiber :: Maybe (Fiber Unit) + , backupFiber :: Maybe (Fiber Unit) } listenBrainzUrl :: String -> String @@ -341,39 +344,49 @@ handleRequest metricsEnabled contexts req res = do Just url -> do let path = URL.pathname url Metrics.wrapRequest method (normalizePath path) Log.info req res do - case path of - "/client.js" -> - serveClientJs res - "/favicon.png" -> - serveAsset "image/png" "assets/favicon.png" res - "/" -> - serveIndex allUsers "" res - "/metrics" -> - if metricsEnabled then serveMetrics res - else do - Log.warn "Path not found: /metrics" - serveNotFound 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 ctx.slug url res - "/similar" -> - withUser url \ctx -> serveSimilar ctx.slug ctx.config url res - "/healthz" -> - withUser url \ctx -> serveHealthz ctx.conn res - _ -> - case stripPrefix (Pattern "/u/") path of - Just slug -> - serveIndex allUsers slug res - Nothing -> do - Log.warn $ "Path not found: " <> path - serveNotFound res + launchAff_ $ do + result <- try $ routeRequest metricsEnabled contexts url path allUsers res + case result of + Left err -> do + Log.error $ "Internal server error: " <> Exception.message err + liftEffect $ serveInternalError res + Right _ -> + pure unit + +routeRequest :: Boolean -> Array UserContext -> URL -> String -> Array { slug :: String, name :: String } -> Response -> Aff Unit +routeRequest metricsEnabled contexts url path allUsers res = liftEffect $ case path of + "/client.js" -> + serveClientJs res + "/favicon.png" -> + serveAsset "image/png" "assets/favicon.png" res + "/" -> + serveIndex allUsers "" res + "/metrics" -> + if metricsEnabled then serveMetrics res + else do + Log.warn "Path not found: /metrics" + serveNotFound 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 ctx.slug url res + "/similar" -> + withUser url \ctx -> serveSimilar ctx.slug ctx.config url res + "/healthz" -> + withUser url \ctx -> serveHealthz ctx.conn res + _ -> + case stripPrefix (Pattern "/u/") path of + Just slug -> + serveIndex allUsers slug res + Nothing -> do + Log.warn $ "Path not found: " <> path + serveNotFound res where - withUser url f = + withUser urlParam f = let - slug = fromMaybe "" (getQueryParam "user" url) + slug = fromMaybe "" (getQueryParam "user" urlParam) in case find (\c -> c.slug == slug) contexts of Nothing -> do @@ -648,6 +661,14 @@ serveNotFound res = do void $ writeString w UTF8 "Not Found" end w +serveInternalError :: Response -> Effect Unit +serveInternalError res = do + setHeader "Content-Type" "text/plain" (toOutgoingMessage res) + setStatusCode 500 res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 "Internal Server Error" + end w + serveBadRequest :: Response -> String -> Effect Unit serveBadRequest res message = do setHeader "Content-Type" "text/plain" (toOutgoingMessage res) @@ -980,14 +1001,33 @@ startUser { slug, name, config } = do _, _ -> pure unit -- Background tasks that don't depend on initial sync completion. - void $ forkAff $ enrichMetadata conn config slug - when config.backupEnabled $ void $ forkAff $ - backupDb conn config.databaseFile (s3ConfigFromUser config) - (toNumber config.backupIntervalHours * 3600000.0) - slug + enrichMetadataFiber <- forkAff $ enrichMetadata conn config slug + backupFiber <- + if config.backupEnabled then Just <$> forkAff (backupDb conn config.databaseFile (s3ConfigFromUser config) (toNumber config.backupIntervalHours * 3600000.0) slug) + else pure Nothing let displayName = fromMaybe (if slug == "" then "root" else slug) name - pure { conn, writeLock, config, slug, displayName } + pure { conn, writeLock, config, slug, displayName, enrichMetadataFiber: Just enrichMetadataFiber, backupFiber } + +cleanupUser :: UserContext -> Aff Unit +cleanupUser ctx = do + Log.info $ "Cleaning up user: " <> if ctx.slug == "" then "(root)" else ctx.slug + -- Kill background fibers + case ctx.enrichMetadataFiber of + Just fiber -> do + Log.info "Killing enrich metadata fiber" + void $ try $ killFiber (Exception.error "Server shutting down") fiber + Nothing -> do + pure unit + case ctx.backupFiber of + Just fiber -> do + Log.info "Killing backup fiber" + void $ try $ killFiber (Exception.error "Server shutting down") fiber + Nothing -> do + pure unit + -- Close database connection + Log.info "Closing database connection" + void $ try $ ping ctx.conn foreign import dotenvConfig :: Effect Unit @@ -1013,3 +1053,9 @@ main = do Log.info $ "Server is running on port " <> show appConfig.port listenTcp netServer { host: "127.0.0.1", port: appConfig.port, backlog: 128 } + +cleanupAll :: Array UserContext -> Aff Unit +cleanupAll contexts = do + Log.info "Starting graceful shutdown of all users" + traverse_ cleanupUser contexts + Log.info "Graceful shutdown complete" diff --git a/src/Templates.purs b/src/Templates.purs @@ -9,7 +9,7 @@ indexHtml userSlug allUsers = encodeUser { slug, name } = "{\"slug\":\"" <> slug <> "\",\"name\":\"" <> name <> "\"}" usersJson = "[" <> joinWith "," (map encodeUser allUsers) <> "]" in - """<!DOCTYPE html> + """<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> @@ -704,10 +704,13 @@ indexHtml userSlug allUsers = <div id="app"></div> <script src="/client.js"></script> <script> - var userSlug = '""" <> userSlug <> - """'; - var allUsers = """ <> usersJson <> - """; + var userSlug = '""" <> userSlug + <> + """'; + var allUsers = """ + <> usersJson + <> + """; var app = Elm.Client.init({ node: document.getElementById('app'), flags: { search: window.location.search, userSlug: userSlug, allUsers: allUsers }