From 3bee7ec6b98740c9cb5da43c1331c7f83166b109 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 23 Jun 2026 11:36:00 +0000 Subject: [PATCH 01/65] bootstrap arbiter into wire-server --- cabal.project | 12 ++++ flake.lock | 18 ++++++ flake.nix | 5 ++ nix/haskell-pins.nix | 12 ++++ nix/manual-overrides.nix | 6 ++ .../background-worker/background-worker.cabal | 5 ++ services/background-worker/default.nix | 8 +++ .../src/Wire/BackgroundWorker.hs | 8 ++- .../Wire/BackgroundWorker/ScheduledJobs.hs | 62 +++++++++++++++++++ 9 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs diff --git a/cabal.project b/cabal.project index 1cf1aa8ecbc..36993cc3f2f 100644 --- a/cabal.project +++ b/cabal.project @@ -1,6 +1,18 @@ repository hackage.haskell.org url: https://hackage.haskell.org/ index-state: 2023-10-03T15:17:00Z + +source-repository-package + type: git + location: https://github.com/velveteer/arbiter.git + tag: 96097411a0480e182d0cbce819c45023fe8aeec7 + subdir: + arbiter-core + arbiter-hasql + arbiter-migrations + arbiter-worker + arbiter-test-common + packages: integration , libs/bilge/ diff --git a/flake.lock b/flake.lock index f4c3b235f89..49d3242d64c 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,23 @@ "type": "github" } }, + "arbiter": { + "flake": false, + "locked": { + "lastModified": 1782164937, + "narHash": "sha256-6TOIjB+p0+yZo58hNOWshsa7JWTtMM5wYpkdEEcH8Pk=", + "owner": "velveteer", + "repo": "arbiter", + "rev": "96097411a0480e182d0cbce819c45023fe8aeec7", + "type": "github" + }, + "original": { + "owner": "velveteer", + "repo": "arbiter", + "rev": "96097411a0480e182d0cbce819c45023fe8aeec7", + "type": "github" + } + }, "bloodhound": { "flake": false, "locked": { @@ -367,6 +384,7 @@ "root": { "inputs": { "amazonka": "amazonka", + "arbiter": "arbiter", "bloodhound": "bloodhound", "cql": "cql", "cql-io": "cql-io", diff --git a/flake.nix b/flake.nix index 5206ca874e8..94ae0cf0e0f 100644 --- a/flake.nix +++ b/flake.nix @@ -108,6 +108,11 @@ url = "github:wireapp/hasql-resource-pool?rev=5b5d3df0fff81801986a0110acae5420215f01c5"; flake = false; }; + + arbiter = { + url = "github:velveteer/arbiter?rev=96097411a0480e182d0cbce819c45023fe8aeec7"; + flake = false; + }; }; outputs = inputs@{ nixpkgs, nixpkgs_24_11, flake-utils, tom-bombadil, sbomnix, ... }: diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 17849cbc878..07b0f2c3fa3 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -58,6 +58,18 @@ let # END maintained by us # -------------------- + arbiter = { + src = inputs.arbiter; + packages = { + arbiter-core = "arbiter-core"; + arbiter-hasql = "arbiter-hasql"; + arbiter-migrations = "arbiter-migrations"; + arbiter-simple = "arbiter-simple"; + arbiter-test-common = "arbiter-test-common"; + arbiter-worker = "arbiter-worker"; + }; + }; + bloodhound = { src = inputs.bloodhound; }; diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index 59373e631c3..e1096aec75b 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -32,6 +32,12 @@ hself: hsuper: { hasql-migration = hlib.markUnbroken (hlib.doJailbreak (hlib.dontCheck hsuper.hasql-migration)); hasql-transaction = hlib.dontCheck hsuper.hasql-transaction_1_2_2; postgresql-binary = hlib.dontCheck (hsuper.postgresql-binary_0_15_0_1); + monad-logger-aeson = hlib.markUnbroken (hlib.dontCheck hsuper.monad-logger-aeson); + arbiter-core = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-core); + arbiter-hasql = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-hasql); + arbiter-migrations = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-migrations); + arbiter-simple = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-simple); + arbiter-worker = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-worker); # Test fixtures don't seem to be bundled for Hackage hsaml2 = hlib.dontCheck (hsuper.hsaml2); diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index af6b66a8ffe..a5cb3cc10a2 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -19,6 +19,7 @@ library Wire.BackgroundWorker.Jobs.Consumer Wire.BackgroundWorker.Jobs.Registry Wire.BackgroundWorker.Options + Wire.BackgroundWorker.ScheduledJobs Wire.BackgroundWorker.Util Wire.DeadUserNotificationWatcher Wire.Effects @@ -35,6 +36,10 @@ library build-depends: aeson , amqp + , arbiter-core + , arbiter-hasql + , arbiter-migrations + , arbiter-worker , base , bilge , bytestring diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 28faab430e9..69f847a28ef 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -5,6 +5,10 @@ { mkDerivation , aeson , amqp +, arbiter-core +, arbiter-hasql +, arbiter-migrations +, arbiter-worker , base , bilge , bytestring @@ -66,6 +70,10 @@ mkDerivation { libraryHaskellDepends = [ aeson amqp + arbiter-core + arbiter-hasql + arbiter-migrations + arbiter-worker base bilge bytestring diff --git a/services/background-worker/src/Wire/BackgroundWorker.hs b/services/background-worker/src/Wire/BackgroundWorker.hs index fd145cf4668..1dc08e6b1fa 100644 --- a/services/background-worker/src/Wire/BackgroundWorker.hs +++ b/services/background-worker/src/Wire/BackgroundWorker.hs @@ -33,6 +33,7 @@ import Wire.BackgroundWorker.Env import Wire.BackgroundWorker.Health qualified as Health import Wire.BackgroundWorker.Jobs.Consumer qualified as Jobs import Wire.BackgroundWorker.Options +import Wire.BackgroundWorker.ScheduledJobs qualified as ScheduledJobs import Wire.DeadUserNotificationWatcher qualified as DeadUserNotificationWatcher import Wire.MeetingsCleanupWorker qualified as MeetingsCleanupWorker import Wire.Options.Galley qualified as Galley @@ -82,6 +83,10 @@ run opts galleyOpts = do runAppT env $ withNamedLogger "background-job-consumer" $ Jobs.startWorker amqpEP + cleanupScheduledJobs <- + runAppT env $ + withNamedLogger "scheduled-jobs" $ + ScheduledJobs.startWorker cleanupMeetings <- runAppT env $ withNamedLogger "meetings-cleanup" $ @@ -89,7 +94,7 @@ run opts galleyOpts = do let cleanup = void $ runConcurrently $ - (,,,,,,,) + (,,,,,,,,) <$> Concurrently cleanupDeadUserNotifWatcher <*> Concurrently cleanupBackendNotifPusher <*> Concurrently cleanupConvMigration @@ -97,6 +102,7 @@ run opts galleyOpts = do <*> Concurrently cleanupTeamFeaturesMigration <*> Concurrently cleanupDomainRegistrationMigration <*> Concurrently cleanupJobs + <*> Concurrently cleanupScheduledJobs <*> Concurrently cleanupMeetings let server = defaultServer (T.unpack opts.backgroundWorker.host) opts.backgroundWorker.port env.logger diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs new file mode 100644 index 00000000000..2f3e045fb09 --- /dev/null +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE BlockArguments #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.BackgroundWorker.ScheduledJobs (startWorker) where + +import Data.Proxy (Proxy) +import Imports +import qualified Arbiter.Core as ArbiterCore +import qualified Arbiter.Hasql as ArbiterHasql +import qualified Arbiter.Migrations as ArbiterMigrations +import qualified Arbiter.Worker as ArbiterWorker +import Wire.BackgroundWorker.Env (AppT) +import Wire.BackgroundWorker.Util (CleanupAction) + +startWorker :: AppT IO CleanupAction +startWorker = do + -- Temporary anchors to keep the Arbiter packages exercised in the build + -- until the real scheduled-job flow is wired in. + let _coreInsertJob + :: forall m registry payload. + ArbiterCore.QueueOperation m registry payload => + ArbiterCore.JobWrite payload -> m (Maybe (ArbiterCore.JobRead payload)) + _coreInsertJob = ArbiterCore.insertJob + + _createHasqlEnv + :: forall registry m. + MonadIO m => + Proxy registry -> ByteString -> ArbiterCore.SchemaName -> m (ArbiterHasql.HasqlEnv registry) + _createHasqlEnv = ArbiterHasql.createHasqlEnv + + _runMigrationsForRegistry + :: forall registry. + ArbiterCore.RegistryTables registry => + Proxy registry -> ByteString -> ArbiterCore.SchemaName -> ArbiterMigrations.MigrationConfig -> IO (ArbiterMigrations.MigrationResult String) + _runMigrationsForRegistry = ArbiterMigrations.runMigrationsForRegistry + + _runWorkerPool + :: forall m registry payload result. + ( ArbiterWorker.JobResult result + , MonadUnliftIO m + , ArbiterCore.QueueOperation m registry payload + , ArbiterCore.RegistryTables registry + ) => + ArbiterWorker.WorkerConfig m payload result -> m () + _runWorkerPool = ArbiterWorker.runWorkerPool + pure $ pure () From f78d55091fa2e7eb2a00421e785a772229796187 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 23 Jun 2026 13:32:48 +0000 Subject: [PATCH 02/65] migrate the meetings clean up job to arbiter --- .../src/Wire/BackgroundWorker.hs | 10 +- .../src/Wire/BackgroundWorker/Env.hs | 4 + .../Wire/BackgroundWorker/ScheduledJobs.hs | 165 +++++++++++++----- .../src/Wire/MeetingsCleanupWorker.hs | 3 +- 4 files changed, 131 insertions(+), 51 deletions(-) diff --git a/services/background-worker/src/Wire/BackgroundWorker.hs b/services/background-worker/src/Wire/BackgroundWorker.hs index 1dc08e6b1fa..1ffc8eb85f0 100644 --- a/services/background-worker/src/Wire/BackgroundWorker.hs +++ b/services/background-worker/src/Wire/BackgroundWorker.hs @@ -35,7 +35,6 @@ import Wire.BackgroundWorker.Jobs.Consumer qualified as Jobs import Wire.BackgroundWorker.Options import Wire.BackgroundWorker.ScheduledJobs qualified as ScheduledJobs import Wire.DeadUserNotificationWatcher qualified as DeadUserNotificationWatcher -import Wire.MeetingsCleanupWorker qualified as MeetingsCleanupWorker import Wire.Options.Galley qualified as Galley import Wire.PostgresMigrations qualified as Migrations @@ -86,15 +85,11 @@ run opts galleyOpts = do cleanupScheduledJobs <- runAppT env $ withNamedLogger "scheduled-jobs" $ - ScheduledJobs.startWorker - cleanupMeetings <- - runAppT env $ - withNamedLogger "meetings-cleanup" $ - MeetingsCleanupWorker.startWorker opts.meetingsCleanup + ScheduledJobs.startWorker opts.meetingsCleanup let cleanup = void $ runConcurrently $ - (,,,,,,,,) + (,,,,,,,) <$> Concurrently cleanupDeadUserNotifWatcher <*> Concurrently cleanupBackendNotifPusher <*> Concurrently cleanupConvMigration @@ -103,7 +98,6 @@ run opts galleyOpts = do <*> Concurrently cleanupDomainRegistrationMigration <*> Concurrently cleanupJobs <*> Concurrently cleanupScheduledJobs - <*> Concurrently cleanupMeetings let server = defaultServer (T.unpack opts.backgroundWorker.host) opts.backgroundWorker.port env.logger let settings = newSettings server diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 2bccadee873..3f1917272d4 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -25,10 +25,12 @@ import Cassandra.Util (defInitCassandra) import Control.Monad.Base import Control.Monad.Catch import Control.Monad.Trans.Control +import Data.ByteString qualified as ByteString import Data.Domain (Domain) import Data.Id (TeamId) import Data.Map.Strict qualified as Map import Data.Misc (HttpsUrl) +import Data.Text.Encoding qualified as Text import HTTP2.Client.Manager import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as Hasql @@ -89,6 +91,7 @@ data Env = Env cassandraGalley :: ClientState, cassandraBrig :: ClientState, hasqlPool :: Hasql.Pool, + arbiterConnStr :: ByteString.ByteString, -- Dedicated AMQP channels per concern amqpJobsPublisherChannel :: MVar Q.Channel, amqpBackendNotificationsChannel :: MVar Q.Channel, @@ -190,6 +193,7 @@ mkEnv opts galleyOpts = do checkGroupInfo = galleyOpts._settings._checkGroupInfo workerRunningGauge <- mkWorkerRunningGauge hasqlPool <- initPostgresPool opts.postgresqlPool galleyOpts._postgresql galleyOpts._postgresqlPassword + arbiterConnStr <- Text.encodeUtf8 <$> postgresqlConnectionString galleyOpts._postgresql galleyOpts._postgresqlPassword Log.info logger $ Log.msg @Text "Opening RabbitMQ channel: background-worker-jobs-publisher..." amqpJobsPublisherChannel <- mkRabbitMqChannelMVar logger (Just "background-worker-jobs-publisher") $ diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 2f3e045fb09..cf756e5f4fb 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -1,13 +1,17 @@ -{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} -- This file is part of the Wire Server implementation. -- --- Copyright (C) 2025 Wire Swiss GmbH +-- Copyright (C) 2026 Wire Swiss GmbH -- -- This program is free software: you can redistribute it and/or modify it under -- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. -- -- This program is distributed in the hope that it will be useful, but WITHOUT -- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS @@ -19,44 +23,121 @@ module Wire.BackgroundWorker.ScheduledJobs (startWorker) where -import Data.Proxy (Proxy) +import Arbiter.Core qualified as ArbiterCore +import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql +import Arbiter.Migrations qualified as ArbiterMigrations +import Arbiter.Worker qualified as ArbiterWorker +import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) +import Data.Proxy (Proxy (..)) import Imports -import qualified Arbiter.Core as ArbiterCore -import qualified Arbiter.Hasql as ArbiterHasql -import qualified Arbiter.Migrations as ArbiterMigrations -import qualified Arbiter.Worker as ArbiterWorker -import Wire.BackgroundWorker.Env (AppT) +import System.Cron (Job (..), forkJob) +import System.Logger qualified as Log +import UnliftIO.Async qualified as Async +import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) +import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) import Wire.BackgroundWorker.Util (CleanupAction) +import Wire.MeetingsCleanupWorker + ( CleanupConfig (..), + runCleanupOldMeetings, + ) -startWorker :: AppT IO CleanupAction -startWorker = do - -- Temporary anchors to keep the Arbiter packages exercised in the build - -- until the real scheduled-job flow is wired in. - let _coreInsertJob - :: forall m registry payload. - ArbiterCore.QueueOperation m registry payload => - ArbiterCore.JobWrite payload -> m (Maybe (ArbiterCore.JobRead payload)) - _coreInsertJob = ArbiterCore.insertJob - - _createHasqlEnv - :: forall registry m. - MonadIO m => - Proxy registry -> ByteString -> ArbiterCore.SchemaName -> m (ArbiterHasql.HasqlEnv registry) - _createHasqlEnv = ArbiterHasql.createHasqlEnv - - _runMigrationsForRegistry - :: forall registry. - ArbiterCore.RegistryTables registry => - Proxy registry -> ByteString -> ArbiterCore.SchemaName -> ArbiterMigrations.MigrationConfig -> IO (ArbiterMigrations.MigrationResult String) - _runMigrationsForRegistry = ArbiterMigrations.runMigrationsForRegistry - - _runWorkerPool - :: forall m registry payload result. - ( ArbiterWorker.JobResult result - , MonadUnliftIO m - , ArbiterCore.QueueOperation m registry payload - , ArbiterCore.RegistryTables registry - ) => - ArbiterWorker.WorkerConfig m payload result -> m () - _runWorkerPool = ArbiterWorker.runWorkerPool - pure $ pure () +type ScheduledJobsRegistry = + '[ '("meetings_cleanup_jobs", MeetingsCleanupJob) + ] + +-- | Empty payload because the schedule itself carries all execution context. +data MeetingsCleanupJob = MeetingsCleanupJob + deriving stock (Eq, Generic, Show) + +instance ToJSON MeetingsCleanupJob where + toJSON MeetingsCleanupJob = Null + +instance FromJSON MeetingsCleanupJob where + parseJSON Null = pure MeetingsCleanupJob + parseJSON _ = fail "MeetingsCleanupJob expects null" + +startWorker :: MeetingsCleanupConfig -> AppT IO CleanupAction +startWorker config = do + env <- ask + Log.info env.logger $ + Log.msg (Log.val "Starting scheduled meetings cleanup jobs") + . Log.field "schedule" (show config.schedule) + . Log.field "clean_older_than_hours" config.cleanOlderThanHours + + let cleanupConfig = + CleanupConfig + { retentionHours = config.cleanOlderThanHours, + batchSize = config.batchSize + } + schemaName = ArbiterCore.defaultSchemaName + -- Arbiter keeps its own schema and migrations separate from the existing + -- meetings tables, so the worker can claim jobs independently. + arbiterEnv <- + liftIO $ + ArbiterHasql.createHasqlEnv (Proxy @ScheduledJobsRegistry) env.arbiterConnStr schemaName + + -- Insert a single queued job for each tick. The dedup key prevents one + -- scheduler from enqueuing duplicate runs for the same logical job. + let jobWrite :: ArbiterCore.JobWrite MeetingsCleanupJob + jobWrite = + (ArbiterCore.defaultGroupedJob "meetings-cleanup" MeetingsCleanupJob) + { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate "meetings-cleanup") + } + + enqueueCleanupJob :: ArbiterHasql.HasqlDb ScheduledJobsRegistry IO () + enqueueCleanupJob = void $ ArbiterCore.insertJob jobWrite + + workerHandler _ _ = do + -- Arbiter claims the job; the handler just runs the existing cleanup + -- logic inside the background-worker application environment. + liftIO $ + runAppT env $ do + Log.info env.logger $ Log.msg (Log.val "Running scheduled meetings cleanup job") + runCleanupOldMeetings cleanupConfig + + -- Create the Arbiter queue tables for this registry on startup. + void $ + liftIO $ + ArbiterMigrations.runMigrationsForRegistry + (Proxy @ScheduledJobsRegistry) + env.arbiterConnStr + schemaName + ArbiterMigrations.defaultMigrationConfig + + workerConfig <- + liftIO $ + -- One worker thread is enough for now because each background-worker + -- instance enqueues the same logical cleanup job, and Arbiter's dedup + -- key keeps duplicate runs from stacking up. + -- One worker thread is enough for this proof of concept. + ( ArbiterWorker.defaultWorkerConfig + env.arbiterConnStr + 1 + workerHandler :: + IO + ( ArbiterWorker.WorkerConfig + (ArbiterHasql.HasqlDb ScheduledJobsRegistry IO) + MeetingsCleanupJob + () + ) + ) + + schedulerThread <- liftIO . forkJob . Job config.schedule $ do + -- The scheduler only inserts the next job row; Arbiter does the claiming + -- and execution. + Log.info env.logger $ + Log.msg (Log.val "Enqueuing scheduled meetings cleanup job") + . Log.field "queue_name" ("meetings_cleanup_jobs" :: String) + void $ ArbiterHasql.runHasqlDb arbiterEnv enqueueCleanupJob + + workerAsync <- + liftIO . Async.async $ + -- Run the Arbiter worker loop in the same process. + ArbiterHasql.runHasqlDb arbiterEnv $ + ArbiterWorker.runWorkerPool workerConfig + + pure $ do + liftIO $ do + killThread schedulerThread + ArbiterWorker.shutdownWorker workerConfig + Async.cancel workerAsync diff --git a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs index 1fd4db93ff9..ef670dca3f0 100644 --- a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs +++ b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs @@ -18,6 +18,7 @@ module Wire.MeetingsCleanupWorker ( startWorker, CleanupConfig (..), + runCleanupOldMeetings, ) where @@ -64,7 +65,6 @@ startWorker config = do runAppT env $ do Log.info env.logger $ Log.msg (Log.val "Starting scheduled meetings cleanup") runCleanupOldMeetings (configFromOptions config) - liftIO $ incCounter env.meetingsCleanupMetrics.runsCounter pure $ pure () @@ -95,6 +95,7 @@ runCleanupOldMeetings config = do Log.info env.logger $ Log.msg (Log.val "Completed cleanup of old meetings") . Log.field "total_deleted" totalDeleted + liftIO $ incCounter env.meetingsCleanupMetrics.runsCounter cleanupLoop :: Env -> UTCTime -> NominalDiffTime -> Int -> Int64 -> AppT IO Int64 cleanupLoop env cutoffTime validityPeriod batchSize totalSoFar = do From c7a7d41475bf8ca0433010d08a02b890880bc696 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 23 Jun 2026 13:58:34 +0000 Subject: [PATCH 03/65] brig internal: route plumbing the arbiter UI --- libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs | 7 +++++++ services/brig/src/Brig/API/Internal.hs | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 4a0e907c808..8c397f58221 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -48,6 +48,7 @@ module Wire.API.Routes.Internal.Brig SAMLIdPAPI, DeleteApp, IdpChangedNotification (..), + JobsUIAPI, ) where @@ -725,8 +726,14 @@ type API = :<|> SAMLIdPAPI :<|> DeleteApp :<|> GetAppIds + :<|> JobsUIAPI ) +type JobsUIAPI = + "jobs" + :> "ui" + :> Raw + type SAMLIdPAPI = "idp" :> ( Named diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 6a6f17dbb80..580be3fe8b5 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -61,6 +61,7 @@ import Data.Time.Clock.System import Data.ZAuth.CryptoSign (CryptoSign) import Imports hiding (head) import Network.Wai.Utilities as Utilities +import Network.Wai.Utilities.Response qualified as WaiResponse import Polysemy import Polysemy.Error qualified as Polysemy import Polysemy.Input (Input, input) @@ -207,6 +208,7 @@ servantSitemap = :<|> samlIdPApi :<|> Named @"i-delete-app" deleteAppH :<|> Named @"i-get-app-ids" getAppIdsH + :<|> jobsUiApp istatusAPI :: forall r. ServerT BrigIRoutes.IStatusAPI (Handler r) istatusAPI = Named @"get-status" (pure NoContent) @@ -1053,3 +1055,9 @@ deleteAppH tid uid = lift . liftSem $ AppSubsystem.deleteApp tid uid >> pure NoC getAppIdsH :: (Member AppStore r) => TeamId -> Handler r [UserId] getAppIdsH tid = lift . liftSem $ map (.id) <$> AppStore.getApps tid + +jobsUiApp :: ServerT BrigIRoutes.JobsUIAPI (Handler r) +jobsUiApp = Tagged $ \_req respond -> + respond $ + WaiResponse.html + "Jobs UI

Jobs UI

This route is reserved for the Arbiter admin UI.

" From 089dab54fa06c932a5aaa13cde0a4ae5fca4cc9f Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 23 Jun 2026 16:06:27 +0000 Subject: [PATCH 04/65] refactoring and implementing admin ui --- cabal.project | 3 ++ libs/wire-api/src/Wire/API/Jobs.hs | 50 +++++++++++++++++++ .../src/Wire/API/Routes/Internal/Brig.hs | 9 +--- .../src/Wire/API/Routes/Internal/Jobs.hs | 34 +++++++++++++ libs/wire-api/wire-api.cabal | 2 + libs/wire-subsystems/default.nix | 15 ++++++ libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs | 30 +++++++++++ .../src/Wire/Jobs/ArbiterAPI.hs | 39 +++++++++++++++ libs/wire-subsystems/src/Wire/Options/Keys.hs | 8 +-- libs/wire-subsystems/wire-subsystems.cabal | 7 +++ nix/haskell-pins.nix | 2 + nix/manual-overrides.nix | 2 + .../Wire/BackgroundWorker/ScheduledJobs.hs | 25 ++-------- .../Wire/BackendNotificationPusherSpec.hs | 3 ++ .../background-worker/test/Test/Wire/Util.hs | 2 + services/brig/src/Brig/API/Internal.hs | 14 +++--- services/brig/src/Brig/App.hs | 16 +++++- services/brig/src/Brig/Run.hs | 2 +- 18 files changed, 221 insertions(+), 42 deletions(-) create mode 100644 libs/wire-api/src/Wire/API/Jobs.hs create mode 100644 libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs create mode 100644 libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs create mode 100644 libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs diff --git a/cabal.project b/cabal.project index 36993cc3f2f..16b8dd89809 100644 --- a/cabal.project +++ b/cabal.project @@ -10,6 +10,9 @@ source-repository-package arbiter-core arbiter-hasql arbiter-migrations + arbiter-servant + arbiter-servant-ui + arbiter-simple arbiter-worker arbiter-test-common diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs new file mode 100644 index 00000000000..6dc421e0aaf --- /dev/null +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -0,0 +1,50 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TypeFamilies #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Jobs + ( ScheduledJobsRegistry, + MeetingsCleanupJob (..), + meetingsCleanupQueueName, + ) +where + +import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) +import Imports + +-- | Shared queue name for the scheduled meetings cleanup job. +meetingsCleanupQueueName :: Text +meetingsCleanupQueueName = "meetings_cleanup_jobs" + +-- | Empty payload because the schedule itself carries all execution context. +data MeetingsCleanupJob = MeetingsCleanupJob + deriving stock (Eq, Generic, Show) + +instance ToJSON MeetingsCleanupJob where + toJSON MeetingsCleanupJob = Null + +instance FromJSON MeetingsCleanupJob where + parseJSON Null = pure MeetingsCleanupJob + parseJSON _ = fail "MeetingsCleanupJob expects null" + +-- | Registry for the scheduled jobs we expose via Arbiter. +type ScheduledJobsRegistry = + '[ '("meetings_cleanup_jobs", MeetingsCleanupJob) + ] diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 8c397f58221..67992643c7f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -48,7 +48,6 @@ module Wire.API.Routes.Internal.Brig SAMLIdPAPI, DeleteApp, IdpChangedNotification (..), - JobsUIAPI, ) where @@ -89,6 +88,7 @@ import Wire.API.Routes.Internal.Brig.EnterpriseLogin (EnterpriseLoginApi) import Wire.API.Routes.Internal.Brig.OAuth (OAuthAPI) import Wire.API.Routes.Internal.Brig.SearchIndex (ISearchIndexAPI) import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti qualified as Multi +import Wire.API.Routes.Internal.Jobs (JobsAppAPI) import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public (ZUser) @@ -726,14 +726,9 @@ type API = :<|> SAMLIdPAPI :<|> DeleteApp :<|> GetAppIds - :<|> JobsUIAPI + :<|> JobsAppAPI ) -type JobsUIAPI = - "jobs" - :> "ui" - :> Raw - type SAMLIdPAPI = "idp" :> ( Named diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs new file mode 100644 index 00000000000..e383e37590a --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeOperators #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.Internal.Jobs + ( JobsAppAPI, + ) +where + +import Servant + +-- | Internal mount point for the embedded Arbiter admin UI and REST API. +-- +-- The embedded Arbiter app serves both the UI and its API under one prefix, +-- with the API living at @/api/v1@ relative to the app root. +type JobsAppAPI = + "jobs" + :> Raw diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 1ef9703bef6..a48e7b70c29 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -112,6 +112,7 @@ library Wire.API.History Wire.API.Internal.BulkPush Wire.API.Internal.Notification + Wire.API.Jobs Wire.API.Locale Wire.API.Meeting Wire.API.Message @@ -186,6 +187,7 @@ library Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti Wire.API.Routes.Internal.Galley.TeamsIntra Wire.API.Routes.Internal.Gundeck + Wire.API.Routes.Internal.Jobs Wire.API.Routes.Internal.Spar Wire.API.Routes.LowLevelStream Wire.API.Routes.MultiTablePaging diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index cbda44f339a..d0564085562 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -11,6 +11,11 @@ , amazonka-ses , amazonka-sqs , amqp +, arbiter-core +, arbiter-servant +, arbiter-servant-ui +, asn1-encoding +, asn1-types , async , attoparsec , base @@ -152,6 +157,11 @@ mkDerivation { amazonka-ses amazonka-sqs amqp + arbiter-core + arbiter-servant + arbiter-servant-ui + asn1-encoding + asn1-types async attoparsec base @@ -281,6 +291,11 @@ mkDerivation { amazonka-ses amazonka-sqs amqp + arbiter-core + arbiter-servant + arbiter-servant-ui + asn1-encoding + asn1-types async attoparsec base diff --git a/libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs b/libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs new file mode 100644 index 00000000000..58374e7d1ca --- /dev/null +++ b/libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE NoImplicitPrelude #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.Jobs.AdminUI + ( adminApplication, + ) +where + +import Arbiter.Servant.UI qualified as ArbUI +import Network.Wai (Application) + +-- | Embedded Arbiter dashboard used by Brig's internal jobs route. +adminApplication :: Application +adminApplication = ArbUI.adminApplication diff --git a/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs b/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs new file mode 100644 index 00000000000..380915b599c --- /dev/null +++ b/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeApplications #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.Jobs.ArbiterAPI + ( adminApplication, + ) +where + +import Arbiter.Core qualified as ArbiterCore +import Arbiter.Servant.Server qualified as ArbServer +import Arbiter.Servant.UI qualified as ArbUI +import Data.ByteString (ByteString) +import Data.Proxy (Proxy (..)) +import Network.Wai (Application) +import Prelude (IO, ($), pure) +import Wire.API.Jobs (ScheduledJobsRegistry) + +-- | Build the Arbiter admin API application for the shared scheduled jobs registry. +adminApplication :: ByteString -> IO Application +adminApplication connStr = do + config <- ArbServer.initArbiterServer (Proxy @ScheduledJobsRegistry) connStr ArbiterCore.defaultSchemaName + pure $ ArbUI.arbiterAppWithAdmin config diff --git a/libs/wire-subsystems/src/Wire/Options/Keys.hs b/libs/wire-subsystems/src/Wire/Options/Keys.hs index 53d3057ef78..b44c51b44c6 100644 --- a/libs/wire-subsystems/src/Wire/Options/Keys.hs +++ b/libs/wire-subsystems/src/Wire/Options/Keys.hs @@ -28,10 +28,10 @@ import Crypto.ECC hiding (KeyPair) import Crypto.Error import Crypto.PubKey.ECDSA qualified as ECDSA import Crypto.PubKey.Ed25519 qualified as Ed25519 -import Data.ASN1.BinaryEncoding -import Data.ASN1.BitArray -import Data.ASN1.Encoding -import Data.ASN1.Types +import "crypton-asn1-encoding" Data.ASN1.BinaryEncoding +import "crypton-asn1-types" Data.ASN1.BitArray +import "crypton-asn1-encoding" Data.ASN1.Encoding +import "crypton-asn1-types" Data.ASN1.Types import Data.Bifunctor import Data.ByteString.Lazy qualified as LBS import Data.PEM diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 9ee955bcaf3..5dd1656a904 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -91,6 +91,11 @@ common common-all , amazonka-ses , amazonka-sqs , amqp + , arbiter-core + , arbiter-servant + , arbiter-servant-ui + , asn1-encoding + , asn1-types , async , attoparsec , base @@ -363,6 +368,8 @@ library Wire.InternalEvent Wire.InvitationStore Wire.InvitationStore.Cassandra + Wire.Jobs.AdminUI + Wire.Jobs.ArbiterAPI Wire.LegalHold Wire.LegalHoldStore Wire.LegalHoldStore.Cassandra diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 07b0f2c3fa3..40f17549dfc 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -64,6 +64,8 @@ let arbiter-core = "arbiter-core"; arbiter-hasql = "arbiter-hasql"; arbiter-migrations = "arbiter-migrations"; + arbiter-servant = "arbiter-servant"; + arbiter-servant-ui = "arbiter-servant-ui"; arbiter-simple = "arbiter-simple"; arbiter-test-common = "arbiter-test-common"; arbiter-worker = "arbiter-worker"; diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index e1096aec75b..df47c94eda2 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -36,6 +36,8 @@ hself: hsuper: { arbiter-core = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-core); arbiter-hasql = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-hasql); arbiter-migrations = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-migrations); + arbiter-servant = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-servant); + arbiter-servant-ui = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-servant-ui); arbiter-simple = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-simple); arbiter-worker = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-worker); diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index cf756e5f4fb..698501f8086 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -1,6 +1,4 @@ {-# LANGUAGE DataKinds #-} -{-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} @@ -27,12 +25,12 @@ import Arbiter.Core qualified as ArbiterCore import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Arbiter.Migrations qualified as ArbiterMigrations import Arbiter.Worker qualified as ArbiterWorker -import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) import Data.Proxy (Proxy (..)) import Imports import System.Cron (Job (..), forkJob) import System.Logger qualified as Log import UnliftIO.Async qualified as Async +import Wire.API.Jobs (MeetingsCleanupJob (..), ScheduledJobsRegistry, meetingsCleanupQueueName) import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) import Wire.BackgroundWorker.Util (CleanupAction) @@ -41,21 +39,6 @@ import Wire.MeetingsCleanupWorker runCleanupOldMeetings, ) -type ScheduledJobsRegistry = - '[ '("meetings_cleanup_jobs", MeetingsCleanupJob) - ] - --- | Empty payload because the schedule itself carries all execution context. -data MeetingsCleanupJob = MeetingsCleanupJob - deriving stock (Eq, Generic, Show) - -instance ToJSON MeetingsCleanupJob where - toJSON MeetingsCleanupJob = Null - -instance FromJSON MeetingsCleanupJob where - parseJSON Null = pure MeetingsCleanupJob - parseJSON _ = fail "MeetingsCleanupJob expects null" - startWorker :: MeetingsCleanupConfig -> AppT IO CleanupAction startWorker config = do env <- ask @@ -80,8 +63,8 @@ startWorker config = do -- scheduler from enqueuing duplicate runs for the same logical job. let jobWrite :: ArbiterCore.JobWrite MeetingsCleanupJob jobWrite = - (ArbiterCore.defaultGroupedJob "meetings-cleanup" MeetingsCleanupJob) - { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate "meetings-cleanup") + (ArbiterCore.defaultGroupedJob meetingsCleanupQueueName MeetingsCleanupJob) + { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate meetingsCleanupQueueName) } enqueueCleanupJob :: ArbiterHasql.HasqlDb ScheduledJobsRegistry IO () @@ -127,7 +110,7 @@ startWorker config = do -- and execution. Log.info env.logger $ Log.msg (Log.val "Enqueuing scheduled meetings cleanup job") - . Log.field "queue_name" ("meetings_cleanup_jobs" :: String) + . Log.field "queue_name" meetingsCleanupQueueName void $ ArbiterHasql.runHasqlDb arbiterEnv enqueueCleanupJob workerAsync <- diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index 4b32861ba3e..ff580236ae7 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -25,6 +25,7 @@ import Control.Exception import Control.Monad.Trans.Except import Data.Aeson ((.=)) import Data.Aeson qualified as Aeson +import Data.ByteString qualified as BS import Data.ByteString.Builder qualified as Builder import Data.ByteString.Lazy qualified as LBS import Data.Default @@ -384,6 +385,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = BS.empty convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = @@ -447,6 +449,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = BS.empty convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index ddecd07b9e9..4b149736d47 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -19,6 +19,7 @@ module Test.Wire.Util where +import Data.ByteString qualified as BS import Data.Default import Data.Domain (Domain (Domain)) import Data.Misc @@ -83,6 +84,7 @@ testEnv = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = BS.empty convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 580be3fe8b5..d0e848509f2 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -61,7 +61,6 @@ import Data.Time.Clock.System import Data.ZAuth.CryptoSign (CryptoSign) import Imports hiding (head) import Network.Wai.Utilities as Utilities -import Network.Wai.Utilities.Response qualified as WaiResponse import Polysemy import Polysemy.Error qualified as Polysemy import Polysemy.Input (Input, input) @@ -78,6 +77,7 @@ import Wire.API.MLS.CipherSuite import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Brig as BrigIRoutes import Wire.API.Routes.Internal.Brig.Connection +import Wire.API.Routes.Internal.Jobs qualified as JobsIRoutes import Wire.API.Routes.Named import Wire.API.Team.Export import Wire.API.Team.Feature @@ -144,6 +144,7 @@ import Wire.VerificationCodeGen import Wire.VerificationCodeSubsystem servantSitemap :: + App.Env -> forall r p. ( Member BlockListStore r, Member (Concurrency 'Unsafe) r, @@ -190,7 +191,7 @@ servantSitemap :: Member ClientSubsystem r ) => ServerT BrigIRoutes.API (Handler r) -servantSitemap = +servantSitemap env = istatusAPI :<|> ejpdAPI :<|> accountAPI @@ -208,7 +209,7 @@ servantSitemap = :<|> samlIdPApi :<|> Named @"i-delete-app" deleteAppH :<|> Named @"i-get-app-ids" getAppIdsH - :<|> jobsUiApp + :<|> jobsApp env istatusAPI :: forall r. ServerT BrigIRoutes.IStatusAPI (Handler r) istatusAPI = Named @"get-status" (pure NoContent) @@ -1056,8 +1057,5 @@ deleteAppH tid uid = lift . liftSem $ AppSubsystem.deleteApp tid uid >> pure NoC getAppIdsH :: (Member AppStore r) => TeamId -> Handler r [UserId] getAppIdsH tid = lift . liftSem $ map (.id) <$> AppStore.getApps tid -jobsUiApp :: ServerT BrigIRoutes.JobsUIAPI (Handler r) -jobsUiApp = Tagged $ \_req respond -> - respond $ - WaiResponse.html - "Jobs UI

Jobs UI

This route is reserved for the Arbiter admin UI.

" +jobsApp :: App.Env -> ServerT JobsIRoutes.JobsAppAPI (Handler r) +jobsApp env = Tagged env.jobsApiApp diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 9a4a59dda72..673053f8564 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -140,10 +140,12 @@ import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as HasqlPool import Imports +import Language.Haskell.TH (nameBase) import Network.AMQP qualified as Q import Network.AMQP.Extended qualified as Q import Network.HTTP.Client (responseTimeoutMicro) import Network.HTTP.Client.OpenSSL +import Network.Wai (Application) import OpenSSL.EVP.Digest (Digest, getDigestByName) import OpenSSL.Session (SSLOption (..)) import OpenSSL.Session qualified as SSL @@ -168,6 +170,7 @@ import Wire.EmailSending.SMTP qualified as SMTP import Wire.EmailSubsystem.Template (Localised, TemplateBranding, forLocale) import Wire.EmailSubsystem.Templates.User import Wire.ExternalAccess.External +import Wire.Jobs.ArbiterAPI qualified as JobsArbiterAPI import Wire.PostgresMigrationOpts import Wire.RateLimit.Interpreter import Wire.SessionStore @@ -220,10 +223,16 @@ data Env = Env enableSFTFederation :: Maybe Bool, rateLimitEnv :: RateLimitEnv, amqpJobsPublisherChannel :: MVar Q.Channel, + jobsApiApp :: Application, postgresMigration :: PostgresMigrationOpts } -makeLensesWith (lensRules & lensField .~ suffixNamer) ''Env +makeLensesWith (lensRules & lensField .~ brigFieldNamer) ''Env + +brigFieldNamer :: FieldNamer +brigFieldNamer s t n + | nameBase n == "jobsApiApp" = [] + | otherwise = suffixNamer s t n newEnv :: Opts -> IO Env newEnv opts = do @@ -275,6 +284,10 @@ newEnv opts = do idxEnv <- mkIndexEnv opts.elasticsearch lgr (Opt.galley opts) mgr rateLimitEnv <- newRateLimitEnv opts.settings.passwordHashingRateLimit hasqlPool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword + Log.info lgr $ Log.msg (Log.val "Initializing internal jobs API") + jobsApiConnStr <- postgresqlConnectionString opts.postgresql opts.postgresqlPassword + jobsApiApp <- JobsArbiterAPI.adminApplication (encodeUtf8 jobsApiConnStr) + Log.info lgr $ Log.msg (Log.val "Internal jobs API initialized") amqpJobsPublisherChannel <- Q.mkRabbitMqChannelMVar lgr (Just "brig") opts.rabbitmq pure $! Env @@ -318,6 +331,7 @@ newEnv opts = do enableSFTFederation = opts.multiSFT, rateLimitEnv, amqpJobsPublisherChannel, + jobsApiApp, postgresMigration = opts.postgresMigration } where diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 6ef4967bcde..0964de7cbcd 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -147,7 +147,7 @@ mkApp opts = do (customFormatters :. localDomain :. Servant.EmptyContext) ( docsAPI :<|> hoistServerWithDomain @BrigAPI (toServantHandler env) servantSitemap - :<|> hoistServerWithDomain @IAPI.API (toServantHandler env) IAPI.servantSitemap + :<|> hoistServerWithDomain @IAPI.API (toServantHandler env) (IAPI.servantSitemap env) :<|> hoistServerWithDomain @FederationAPI (toServantHandler env) federationSitemap :<|> hoistServerWithDomain @VersionAPI (toServantHandler env) versionAPI ) From 92f8ad712efc42ae1ad52ace028461edc530cea6 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 24 Jun 2026 08:45:35 +0000 Subject: [PATCH 05/65] resolve warning, formatting --- libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs | 2 +- services/brig/src/Brig/App.hs | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs b/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs index 380915b599c..97972c48bf0 100644 --- a/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs +++ b/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs @@ -29,8 +29,8 @@ import Arbiter.Servant.UI qualified as ArbUI import Data.ByteString (ByteString) import Data.Proxy (Proxy (..)) import Network.Wai (Application) -import Prelude (IO, ($), pure) import Wire.API.Jobs (ScheduledJobsRegistry) +import Prelude (IO, pure, ($)) -- | Build the Arbiter admin API application for the shared scheduled jobs registry. adminApplication :: ByteString -> IO Application diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 673053f8564..66686cf1ec2 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -74,6 +74,7 @@ module Brig.App rateLimitEnvLens, amqpJobsPublisherChannelLens, postgresMigrationLens, + jobsApiAppLens, initZAuth, initLogger, initPostgresPool, @@ -140,7 +141,6 @@ import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as HasqlPool import Imports -import Language.Haskell.TH (nameBase) import Network.AMQP qualified as Q import Network.AMQP.Extended qualified as Q import Network.HTTP.Client (responseTimeoutMicro) @@ -227,12 +227,7 @@ data Env = Env postgresMigration :: PostgresMigrationOpts } -makeLensesWith (lensRules & lensField .~ brigFieldNamer) ''Env - -brigFieldNamer :: FieldNamer -brigFieldNamer s t n - | nameBase n == "jobsApiApp" = [] - | otherwise = suffixNamer s t n +makeLensesWith (lensRules & lensField .~ suffixNamer) ''Env newEnv :: Opts -> IO Env newEnv opts = do From 594ab9f974f23e333abc405cb56225e1df75f446 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 24 Jun 2026 09:18:44 +0000 Subject: [PATCH 06/65] inline arbiter ui/api --- libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs | 30 -------------- .../src/Wire/Jobs/ArbiterAPI.hs | 39 ------------------- libs/wire-subsystems/wire-subsystems.cabal | 2 - services/brig/brig.cabal | 3 ++ services/brig/default.nix | 3 ++ services/brig/src/Brig/App.hs | 10 ++++- 6 files changed, 14 insertions(+), 73 deletions(-) delete mode 100644 libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs delete mode 100644 libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs diff --git a/libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs b/libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs deleted file mode 100644 index 58374e7d1ca..00000000000 --- a/libs/wire-subsystems/src/Wire/Jobs/AdminUI.hs +++ /dev/null @@ -1,30 +0,0 @@ -{-# LANGUAGE NoImplicitPrelude #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2026 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) --- any later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or --- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License --- for more details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Wire.Jobs.AdminUI - ( adminApplication, - ) -where - -import Arbiter.Servant.UI qualified as ArbUI -import Network.Wai (Application) - --- | Embedded Arbiter dashboard used by Brig's internal jobs route. -adminApplication :: Application -adminApplication = ArbUI.adminApplication diff --git a/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs b/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs deleted file mode 100644 index 97972c48bf0..00000000000 --- a/libs/wire-subsystems/src/Wire/Jobs/ArbiterAPI.hs +++ /dev/null @@ -1,39 +0,0 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE TypeApplications #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2026 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) --- any later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or --- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License --- for more details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Wire.Jobs.ArbiterAPI - ( adminApplication, - ) -where - -import Arbiter.Core qualified as ArbiterCore -import Arbiter.Servant.Server qualified as ArbServer -import Arbiter.Servant.UI qualified as ArbUI -import Data.ByteString (ByteString) -import Data.Proxy (Proxy (..)) -import Network.Wai (Application) -import Wire.API.Jobs (ScheduledJobsRegistry) -import Prelude (IO, pure, ($)) - --- | Build the Arbiter admin API application for the shared scheduled jobs registry. -adminApplication :: ByteString -> IO Application -adminApplication connStr = do - config <- ArbServer.initArbiterServer (Proxy @ScheduledJobsRegistry) connStr ArbiterCore.defaultSchemaName - pure $ ArbUI.arbiterAppWithAdmin config diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 5dd1656a904..9e8616d3596 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -368,8 +368,6 @@ library Wire.InternalEvent Wire.InvitationStore Wire.InvitationStore.Cassandra - Wire.Jobs.AdminUI - Wire.Jobs.ArbiterAPI Wire.LegalHold Wire.LegalHoldStore Wire.LegalHoldStore.Cassandra diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 7868d06447c..065c824278f 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -214,6 +214,9 @@ library , amazonka-ses >=2 , amazonka-sqs >=2 , amqp + , arbiter-core + , arbiter-servant + , arbiter-servant-ui , async >=2.1 , auto-update >=0.1 , base >=4 && <5 diff --git a/services/brig/default.nix b/services/brig/default.nix index e431f9c93db..7c05bac4ac1 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -9,6 +9,9 @@ , amazonka-ses , amazonka-sqs , amqp +, arbiter-core +, arbiter-servant +, arbiter-servant-ui , async , attoparsec , auto-update diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 66686cf1ec2..5a9c05fc1c2 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -102,6 +102,9 @@ module Brig.App ) where +import Arbiter.Core qualified as ArbiterCore +import Arbiter.Servant.Server qualified as ArbServer +import Arbiter.Servant.UI qualified as ArbUI import Bilge qualified as RPC import Bilge.IO import Bilge.RPC (HasRequestId (..)) @@ -130,6 +133,7 @@ import Data.Credentials (Credentials (..)) import Data.Domain import Data.Id import Data.Misc +import Data.Proxy (Proxy (..)) import Data.Qualified import Data.Text qualified as Text import Data.Text.Encoding (encodeUtf8) @@ -161,6 +165,7 @@ import System.Logger.Extended qualified as Log import Util.Options import Util.SuffixNamer import Wire.API.Federation.Error (federationNotImplemented) +import Wire.API.Jobs (ScheduledJobsRegistry) import Wire.API.Locale (Locale) import Wire.API.Routes.Version import Wire.API.User.Identity @@ -170,7 +175,6 @@ import Wire.EmailSending.SMTP qualified as SMTP import Wire.EmailSubsystem.Template (Localised, TemplateBranding, forLocale) import Wire.EmailSubsystem.Templates.User import Wire.ExternalAccess.External -import Wire.Jobs.ArbiterAPI qualified as JobsArbiterAPI import Wire.PostgresMigrationOpts import Wire.RateLimit.Interpreter import Wire.SessionStore @@ -281,7 +285,9 @@ newEnv opts = do hasqlPool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword Log.info lgr $ Log.msg (Log.val "Initializing internal jobs API") jobsApiConnStr <- postgresqlConnectionString opts.postgresql opts.postgresqlPassword - jobsApiApp <- JobsArbiterAPI.adminApplication (encodeUtf8 jobsApiConnStr) + jobsApiApp <- do + config <- ArbServer.initArbiterServer (Proxy @ScheduledJobsRegistry) (encodeUtf8 jobsApiConnStr) ArbiterCore.defaultSchemaName + pure $ ArbUI.arbiterAppWithAdmin config Log.info lgr $ Log.msg (Log.val "Internal jobs API initialized") amqpJobsPublisherChannel <- Q.mkRabbitMqChannelMVar lgr (Just "brig") opts.rabbitmq pure $! From c0ae7eb19d193fbd7c42218fc056db8d17c06bf9 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 24 Jun 2026 09:23:36 +0000 Subject: [PATCH 07/65] removed unused code --- .../src/Wire/MeetingsCleanupWorker.hs | 38 +------------------ 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs index ef670dca3f0..5b00955373f 100644 --- a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs +++ b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs @@ -16,8 +16,7 @@ -- with this program. If not, see . module Wire.MeetingsCleanupWorker - ( startWorker, - CleanupConfig (..), + ( CleanupConfig (..), runCleanupOldMeetings, ) where @@ -29,11 +28,8 @@ import Data.Time.Clock import Imports import Polysemy.Error (runError) import Prometheus (incCounter) -import System.Cron (Job (..), forkJob) import System.Logger qualified as Log -import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..), runAppT) -import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) -import Wire.BackgroundWorker.Util (CleanupAction) +import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..)) import Wire.Effects import Wire.ExternalAccess.External import Wire.MeetingsStore.Postgres (interpretMeetingsStoreToPostgres) @@ -46,36 +42,6 @@ data CleanupConfig = CleanupConfig } deriving (Show, Eq) --- | Start the meetings cleanup worker thread --- --- This worker runs periodically to clean up old meetings based on the configuration. -startWorker :: - MeetingsCleanupConfig -> - AppT IO CleanupAction -startWorker config = do - env <- ask - Log.info env.logger $ - Log.msg (Log.val "Starting meetings cleanup worker") - . Log.field "schedule" (show config.schedule) - . Log.field "clean_older_than_hours" config.cleanOlderThanHours - - void . liftIO $ do - forkJob $ - Job config.schedule $ - runAppT env $ do - Log.info env.logger $ Log.msg (Log.val "Starting scheduled meetings cleanup") - runCleanupOldMeetings (configFromOptions config) - - pure $ pure () - --- | Convert MeetingsCleanupConfig to CleanupConfig -configFromOptions :: MeetingsCleanupConfig -> CleanupConfig -configFromOptions cfg = - CleanupConfig - { retentionHours = cfg.cleanOlderThanHours, - batchSize = cfg.batchSize - } - -- | Main cleanup function that orchestrates the cleanup process runCleanupOldMeetings :: CleanupConfig -> AppT IO () runCleanupOldMeetings config = do From 6733db4bebc10c10f83f297df03003c720ea2b75 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 24 Jun 2026 10:43:12 +0000 Subject: [PATCH 08/65] job store --- libs/wire-api/src/Wire/API/Jobs.hs | 62 ++++++++ .../20260624095944-extend-job-model.sql | 28 ++++ libs/wire-subsystems/src/Wire/JobStore.hs | 44 +++++ .../src/Wire/JobStore/Postgres.hs | 150 ++++++++++++++++++ libs/wire-subsystems/wire-subsystems.cabal | 2 + 5 files changed, 286 insertions(+) create mode 100644 libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql create mode 100644 libs/wire-subsystems/src/Wire/JobStore.hs create mode 100644 libs/wire-subsystems/src/Wire/JobStore/Postgres.hs diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index 6dc421e0aaf..f06ed23cab9 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeFamilies #-} -- This file is part of the Wire Server implementation. @@ -21,13 +22,21 @@ module Wire.API.Jobs ( ScheduledJobsRegistry, + ScheduledJob (..), + ScheduledJobKind (..), MeetingsCleanupJob (..), meetingsCleanupQueueName, + scheduledJobKindFromInt, + scheduledJobKindToInt, ) where import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) +import Data.Int qualified as Int +import Data.Time.Clock (UTCTime) +import Data.UUID (UUID) import Imports +import Wire.API.PostgresMarshall -- | Shared queue name for the scheduled meetings cleanup job. meetingsCleanupQueueName :: Text @@ -44,6 +53,59 @@ instance FromJSON MeetingsCleanupJob where parseJSON Null = pure MeetingsCleanupJob parseJSON _ = fail "MeetingsCleanupJob expects null" +-- | The generic scheduled-job families we currently need to persist. +data ScheduledJobKind + = AdminlessReminder + | AdminlessDeletion + | AdminlessSetup + | AdminlessTeardown + deriving stock (Bounded, Enum, Eq, Generic, Ord, Show) + +scheduledJobKindToInt :: ScheduledJobKind -> Int +scheduledJobKindToInt = fromEnum + +scheduledJobKindFromInt :: Int -> Maybe ScheduledJobKind +scheduledJobKindFromInt n + | n < fromEnum (minBound :: ScheduledJobKind) = Nothing + | n > fromEnum (maxBound :: ScheduledJobKind) = Nothing + | otherwise = Just (toEnum n) + +-- | App-level metadata stored alongside Arbiter's runtime state. +data ScheduledJob = ScheduledJob + { scheduledJobId :: UUID, + scheduledJobKind :: ScheduledJobKind, + scheduledJobTeamId :: UUID, + scheduledJobConversationId :: Maybe UUID, + scheduledJobScheduledFor :: UTCTime + } + deriving stock (Eq, Generic, Show) + +instance PostgresMarshall Int.Int32 ScheduledJobKind where + postgresMarshall = fromIntegral . scheduledJobKindToInt + +instance PostgresUnmarshall Int.Int32 ScheduledJobKind where + postgresUnmarshall n = + maybe (Left "invalid scheduled job kind") Right $ + scheduledJobKindFromInt (fromIntegral n) + +instance PostgresMarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where + postgresMarshall ScheduledJob{..} = + ( scheduledJobId, + postgresMarshall scheduledJobKind, + scheduledJobTeamId, + scheduledJobConversationId, + scheduledJobScheduledFor + ) + +instance PostgresUnmarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where + postgresUnmarshall (jobId, jobKind, teamId, conversationId, scheduledFor) = + ScheduledJob + <$> postgresUnmarshall jobId + <*> postgresUnmarshall jobKind + <*> postgresUnmarshall teamId + <*> postgresUnmarshall conversationId + <*> postgresUnmarshall scheduledFor + -- | Registry for the scheduled jobs we expose via Arbiter. type ScheduledJobsRegistry = '[ '("meetings_cleanup_jobs", MeetingsCleanupJob) diff --git a/libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql b/libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql new file mode 100644 index 00000000000..9e8a3ee5b5b --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql @@ -0,0 +1,28 @@ +-- Migration: Add a minimal scheduled-jobs catalog for app-level lookup +-- Description: Stores the metadata we need to find and manage scheduled jobs +-- while Arbiter owns runtime execution state. + +CREATE TABLE IF NOT EXISTS scheduled_jobs ( + id uuid NOT NULL, -- app-level job id + kind int NOT NULL, -- maps to a Haskell sum type + team_id uuid NOT NULL, -- team scope for teardown and lookup + conversation_id uuid, -- optional conversation scope for later lookups + scheduled_for timestamptz NOT NULL, -- when the job should run + PRIMARY KEY (id) +); + +-- Find the next due jobs quickly. +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_scheduled_for + ON scheduled_jobs (scheduled_for); + +-- Find jobs by family. +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_kind + ON scheduled_jobs (kind); + +-- Find jobs for a team, optionally narrowed by family. +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_team_kind + ON scheduled_jobs (team_id, kind); + +-- Find jobs scoped to a conversation. +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_conversation_id + ON scheduled_jobs (conversation_id); diff --git a/libs/wire-subsystems/src/Wire/JobStore.hs b/libs/wire-subsystems/src/Wire/JobStore.hs new file mode 100644 index 00000000000..0e234a3c70b --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobStore.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobStore + ( JobStore (..), + createJob, + deleteJob, + deleteJobsByTeamAndKind, + findJobById, + findJobsByConversationId, + findJobsByTeamAndKind, + ) +where + +import Data.UUID (UUID) +import Imports +import Polysemy +import Wire.API.Jobs + +data JobStore m a where + CreateJob :: ScheduledJob -> JobStore m () + FindJobById :: UUID -> JobStore m (Maybe ScheduledJob) + FindJobsByTeamAndKind :: UUID -> ScheduledJobKind -> JobStore m [ScheduledJob] + FindJobsByConversationId :: UUID -> JobStore m [ScheduledJob] + DeleteJob :: UUID -> JobStore m () + DeleteJobsByTeamAndKind :: UUID -> ScheduledJobKind -> JobStore m () + +makeSem ''JobStore diff --git a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs new file mode 100644 index 00000000000..16a5a77dbcc --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs @@ -0,0 +1,150 @@ +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TypeApplications #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobStore.Postgres + ( interpretJobStoreToPostgres, + ) +where + +import Hasql.Pool +import Hasql.TH +import Imports +import Data.UUID (UUID) +import Polysemy +import Polysemy.Error (Error) +import Polysemy.Input +import Wire.API.Jobs +import Wire.API.PostgresMarshall +import Wire.JobStore +import Wire.Postgres + +interpretJobStoreToPostgres :: + ( Member (Embed IO) r, + Member (Input Pool) r, + Member (Error UsageError) r + ) => + InterpreterFor JobStore r +interpretJobStoreToPostgres = + interpret $ \case + CreateJob job -> createJobImpl job + FindJobById jobId -> findJobByIdImpl jobId + FindJobsByTeamAndKind teamId kind -> findJobsByTeamAndKindImpl teamId kind + FindJobsByConversationId conversationId -> findJobsByConversationIdImpl conversationId + DeleteJob jobId -> deleteJobImpl jobId + DeleteJobsByTeamAndKind teamId kind -> deleteJobsByTeamAndKindImpl teamId kind + +createJobImpl :: + ( Member (Embed IO) r, + Member (Input Pool) r, + Member (Error UsageError) r + ) => + ScheduledJob -> + Sem r () +createJobImpl job = + runStatement job $ + lmapPG + [resultlessStatement| + INSERT INTO scheduled_jobs + (id, kind, team_id, conversation_id, scheduled_for) + VALUES + ($1 :: uuid, $2 :: int4, $3 :: uuid, $4 :: uuid?, $5 :: timestamptz) |] + +findJobByIdImpl :: + ( Member (Embed IO) r, + Member (Input Pool) r, + Member (Error UsageError) r + ) => + UUID -> + Sem r (Maybe ScheduledJob) +findJobByIdImpl jobId = + runStatement jobId $ + dimapPG + [maybeStatement| + SELECT + (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), + (scheduled_for :: timestamptz) + FROM scheduled_jobs + WHERE id = ($1 :: uuid) |] + +findJobsByTeamAndKindImpl :: + ( Member (Embed IO) r, + Member (Input Pool) r, + Member (Error UsageError) r + ) => + UUID -> + ScheduledJobKind -> + Sem r [ScheduledJob] +findJobsByTeamAndKindImpl teamId kind = + runStatement (teamId, kind) $ + dimapPG + [vectorStatement| + SELECT + (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), + (scheduled_for :: timestamptz) + FROM scheduled_jobs + WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) + ORDER BY scheduled_for ASC, id ASC |] + +findJobsByConversationIdImpl :: + ( Member (Embed IO) r, + Member (Input Pool) r, + Member (Error UsageError) r + ) => + UUID -> + Sem r [ScheduledJob] +findJobsByConversationIdImpl conversationId = + runStatement conversationId $ + dimapPG + [vectorStatement| + SELECT + (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), + (scheduled_for :: timestamptz) + FROM scheduled_jobs + WHERE conversation_id = ($1 :: uuid) + ORDER BY scheduled_for ASC, id ASC |] + +deleteJobImpl :: + ( Member (Embed IO) r, + Member (Input Pool) r, + Member (Error UsageError) r + ) => + UUID -> + Sem r () +deleteJobImpl jobId = + runStatement jobId $ + lmapPG + [resultlessStatement| + DELETE FROM scheduled_jobs + WHERE id = ($1 :: uuid) |] + +deleteJobsByTeamAndKindImpl :: + ( Member (Embed IO) r, + Member (Input Pool) r, + Member (Error UsageError) r + ) => + UUID -> + ScheduledJobKind -> + Sem r () +deleteJobsByTeamAndKindImpl teamId kind = + runStatement (teamId, kind) $ + lmapPG + [resultlessStatement| + DELETE FROM scheduled_jobs + WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) |] diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 9e8616d3596..63c6f1f5faf 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -368,6 +368,8 @@ library Wire.InternalEvent Wire.InvitationStore Wire.InvitationStore.Cassandra + Wire.JobStore + Wire.JobStore.Postgres Wire.LegalHold Wire.LegalHoldStore Wire.LegalHoldStore.Cassandra From f8dba718b26cf4ecaf66c6d84e457fe8ed66e79d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 24 Jun 2026 10:48:49 +0000 Subject: [PATCH 09/65] schema --- postgres-schema.sql | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/postgres-schema.sql b/postgres-schema.sql index c60938bceb0..fceb33a5c84 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -360,6 +360,21 @@ CREATE TABLE public.remote_conversation_local_member ( ALTER TABLE public.remote_conversation_local_member OWNER TO "wire-server"; +-- +-- Name: scheduled_jobs; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.scheduled_jobs ( + id uuid NOT NULL, + kind integer NOT NULL, + team_id uuid NOT NULL, + conversation_id uuid, + scheduled_for timestamp with time zone NOT NULL +); + + +ALTER TABLE public.scheduled_jobs OWNER TO "wire-server"; + -- -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: wire-server -- @@ -608,6 +623,14 @@ ALTER TABLE ONLY public.remote_conversation_local_member ADD CONSTRAINT remote_conversation_local_member_pkey PRIMARY KEY ("user", conv_remote_domain, conv_remote_id); +-- +-- Name: scheduled_jobs scheduled_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.scheduled_jobs + ADD CONSTRAINT scheduled_jobs_pkey PRIMARY KEY (id); + + -- -- Name: subconversation subconversation_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -791,6 +814,34 @@ CREATE INDEX idx_meetings_end_time ON public.meetings USING btree (end_time); CREATE INDEX idx_meetings_start_time ON public.meetings USING btree (start_time); +-- +-- Name: idx_scheduled_jobs_conversation_id; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_scheduled_jobs_conversation_id ON public.scheduled_jobs USING btree (conversation_id); + + +-- +-- Name: idx_scheduled_jobs_kind; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_scheduled_jobs_kind ON public.scheduled_jobs USING btree (kind); + + +-- +-- Name: idx_scheduled_jobs_scheduled_for; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_scheduled_jobs_scheduled_for ON public.scheduled_jobs USING btree (scheduled_for); + + +-- +-- Name: idx_scheduled_jobs_team_kind; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_scheduled_jobs_team_kind ON public.scheduled_jobs USING btree (team_id, kind); + + -- -- Name: user_group_member_user_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- From 96c1fcd37630fb449361708d1f7e4b08c8826dbc Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 24 Jun 2026 11:08:59 +0000 Subject: [PATCH 10/65] job subsystem --- libs/types-common/src/Data/Id.hs | 7 + libs/wire-api/src/Wire/API/Jobs.hs | 60 +++-- libs/wire-subsystems/default.nix | 12 + .../Wire/ConversationSubsystem/Interpreter.hs | 2 + .../src/Wire/ConversationSubsystem/Update.hs | 26 ++- libs/wire-subsystems/src/Wire/JobStore.hs | 12 +- .../src/Wire/JobStore/Postgres.hs | 131 ++++++----- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 72 ++++++ .../src/Wire/JobSubsystem/Interpreter.hs | 89 ++++++++ .../src/Wire/JobSubsystem/Recurring.hs | 213 ++++++++++++++++++ .../ConversationSubsystem/InterpreterSpec.hs | 25 ++ libs/wire-subsystems/wire-subsystems.cabal | 7 + .../background-worker/background-worker.cabal | 3 - services/background-worker/default.nix | 6 - .../Wire/BackgroundWorker/ScheduledJobs.hs | 126 ++++------- .../background-worker/src/Wire/Effects.hs | 13 ++ services/brig/default.nix | 3 + services/brig/src/Brig/App.hs | 2 + services/galley/default.nix | 3 + services/galley/galley.cabal | 1 + services/galley/src/Galley/App.hs | 16 ++ services/galley/src/Galley/Env.hs | 4 +- 22 files changed, 659 insertions(+), 174 deletions(-) create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem.hs create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs index a1c0aedf911..09db37239d7 100644 --- a/libs/types-common/src/Data/Id.hs +++ b/libs/types-common/src/Data/Id.hs @@ -59,6 +59,7 @@ module Data.Id ChallengeId, MeetingId, HistoryClientId, + ScheduledJobId, -- * Utils uuidSchema, @@ -118,6 +119,7 @@ data IdTag | Job | Meeting | HistoryClient + | ScheduledJob idTagName :: IdTag -> Text idTagName Asset = "Asset" @@ -135,6 +137,7 @@ idTagName Challenge = "Challenge" idTagName Job = "Job" idTagName Meeting = "Meeting" idTagName HistoryClient = "HistoryClient" +idTagName ScheduledJob = "ScheduledJob " class KnownIdTag (t :: IdTag) where idTagValue :: IdTag @@ -167,6 +170,8 @@ instance KnownIdTag 'Meeting where idTagValue = Meeting instance KnownIdTag 'HistoryClient where idTagValue = HistoryClient +instance KnownIdTag 'ScheduledJob where idTagValue = ScheduledJob + type AssetId = Id 'Asset type InvitationId = Id 'Invitation @@ -199,6 +204,8 @@ type MeetingId = Id 'Meeting type HistoryClientId = Id 'HistoryClient +type ScheduledJobId = Id 'ScheduledJob + -- Id ------------------------------------------------------------------------- data NoId = NoId deriving (Eq, Show, Generic) diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index f06ed23cab9..cc0292310cf 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -20,18 +20,10 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.Jobs - ( ScheduledJobsRegistry, - ScheduledJob (..), - ScheduledJobKind (..), - MeetingsCleanupJob (..), - meetingsCleanupQueueName, - scheduledJobKindFromInt, - scheduledJobKindToInt, - ) -where +module Wire.API.Jobs where import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) +import Data.Id import Data.Int qualified as Int import Data.Time.Clock (UTCTime) import Data.UUID (UUID) @@ -42,6 +34,10 @@ import Wire.API.PostgresMarshall meetingsCleanupQueueName :: Text meetingsCleanupQueueName = "meetings_cleanup_jobs" +-- | Shared queue name for the adminless deletion job. +adminlessDeletionQueueName :: Text +adminlessDeletionQueueName = "adminless_deletion_jobs" + -- | Empty payload because the schedule itself carries all execution context. data MeetingsCleanupJob = MeetingsCleanupJob deriving stock (Eq, Generic, Show) @@ -53,6 +49,17 @@ instance FromJSON MeetingsCleanupJob where parseJSON Null = pure MeetingsCleanupJob parseJSON _ = fail "MeetingsCleanupJob expects null" +-- | Empty payload for adminless deletions. +data AdminlessDeletionJob = AdminlessDeletionJob + deriving stock (Eq, Generic, Show) + +instance ToJSON AdminlessDeletionJob where + toJSON AdminlessDeletionJob = Null + +instance FromJSON AdminlessDeletionJob where + parseJSON Null = pure AdminlessDeletionJob + parseJSON _ = fail "AdminlessDeletionJob expects null" + -- | The generic scheduled-job families we currently need to persist. data ScheduledJobKind = AdminlessReminder @@ -72,10 +79,10 @@ scheduledJobKindFromInt n -- | App-level metadata stored alongside Arbiter's runtime state. data ScheduledJob = ScheduledJob - { scheduledJobId :: UUID, + { scheduledJobId :: ScheduledJobId, scheduledJobKind :: ScheduledJobKind, - scheduledJobTeamId :: UUID, - scheduledJobConversationId :: Maybe UUID, + scheduledJobTeamId :: TeamId, + scheduledJobConversationId :: Maybe ConvId, scheduledJobScheduledFor :: UTCTime } deriving stock (Eq, Generic, Show) @@ -88,8 +95,8 @@ instance PostgresUnmarshall Int.Int32 ScheduledJobKind where maybe (Left "invalid scheduled job kind") Right $ scheduledJobKindFromInt (fromIntegral n) -instance PostgresMarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where - postgresMarshall ScheduledJob{..} = +instance PostgresMarshall (ScheduledJobId, Int.Int32, TeamId, Maybe ConvId, UTCTime) ScheduledJob where + postgresMarshall ScheduledJob {..} = ( scheduledJobId, postgresMarshall scheduledJobKind, scheduledJobTeamId, @@ -97,7 +104,7 @@ instance PostgresMarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) Scheduled scheduledJobScheduledFor ) -instance PostgresUnmarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where +instance PostgresUnmarshall (ScheduledJobId, Int.Int32, TeamId, Maybe ConvId, UTCTime) ScheduledJob where postgresUnmarshall (jobId, jobKind, teamId, conversationId, scheduledFor) = ScheduledJob <$> postgresUnmarshall jobId @@ -106,7 +113,26 @@ instance PostgresUnmarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) Schedul <*> postgresUnmarshall conversationId <*> postgresUnmarshall scheduledFor +instance PostgresMarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where + postgresMarshall ScheduledJob {..} = + ( toUUID scheduledJobId, + postgresMarshall scheduledJobKind, + toUUID scheduledJobTeamId, + toUUID <$> scheduledJobConversationId, + scheduledJobScheduledFor + ) + +instance PostgresUnmarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where + postgresUnmarshall (jobId, jobKind, teamId, conversationId, scheduledFor) = + ScheduledJob + <$> postgresUnmarshall jobId + <*> postgresUnmarshall jobKind + <*> postgresUnmarshall teamId + <*> postgresUnmarshall conversationId + <*> pure scheduledFor + -- | Registry for the scheduled jobs we expose via Arbiter. type ScheduledJobsRegistry = - '[ '("meetings_cleanup_jobs", MeetingsCleanupJob) + '[ '("meetings_cleanup_jobs", MeetingsCleanupJob), + '("adminless_deletion_jobs", AdminlessDeletionJob) ] diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index d0564085562..00ac3e0a708 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -12,8 +12,11 @@ , amazonka-sqs , amqp , arbiter-core +, arbiter-hasql +, arbiter-migrations , arbiter-servant , arbiter-servant-ui +, arbiter-worker , asn1-encoding , asn1-types , async @@ -35,6 +38,7 @@ , contravariant , cookie , cql +, cron , crypton , crypton-asn1-encoding , crypton-asn1-types @@ -158,8 +162,11 @@ mkDerivation { amazonka-sqs amqp arbiter-core + arbiter-hasql + arbiter-migrations arbiter-servant arbiter-servant-ui + arbiter-worker asn1-encoding asn1-types async @@ -181,6 +188,7 @@ mkDerivation { contravariant cookie cql + cron crypton crypton-asn1-encoding crypton-asn1-types @@ -292,8 +300,11 @@ mkDerivation { amazonka-sqs amqp arbiter-core + arbiter-hasql + arbiter-migrations arbiter-servant arbiter-servant-ui + arbiter-worker asn1-encoding asn1-types async @@ -314,6 +325,7 @@ mkDerivation { contravariant cookie cql + cron crypton crypton-asn1-encoding crypton-asn1-types diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 972ec7573b8..a1fe3d982bb 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -76,6 +76,7 @@ import Wire.NotificationSubsystem as NS import Wire.Options.Galley (GuestLinkTTLSeconds) import Wire.ProposalStore (ProposalStore) import Wire.RateLimit (RateLimit) +import Wire.JobSubsystem (JobSubsystem) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.TeamCollaboratorsSubsystem @@ -115,6 +116,7 @@ interpretConversationSubsystem :: Member TeamStore r, Member ConvStore.MLSCommitLockStore r, Member FederationSubsystem r, + Member JobSubsystem r, Member Resource r, Member (Input (Maybe (MLSKeysByPurpose MLSPrivateKeys))) r, Member UserClientIndexStore r, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index a96558746bd..ede92114a2e 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -85,6 +85,7 @@ import Data.Qualified import Data.Set qualified as Set import Data.Singletons import Data.Vector qualified as V +import Data.Time.Clock (addUTCTime, nominalDay) import Galley.Types.Error import Imports hiding (forkIO) import Polysemy @@ -145,6 +146,7 @@ import Wire.NotificationSubsystem import Wire.Options.Galley import Wire.ProposalStore (ProposalStore) import Wire.RateLimit +import Wire.JobSubsystem (JobSubsystem, scheduleAdminlessDeletionJob) import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) @@ -972,6 +974,7 @@ replaceMembers :: Member FederationSubsystem r, Member FeaturesConfigSubsystem r, Member TeamSubsystem r, + Member JobSubsystem r, Member (Input ConversationSubsystemConfig) r ) => Local UserId -> @@ -1153,6 +1156,7 @@ removeMemberQualified :: Member TinyLog r, Member FeaturesConfigSubsystem r, Member TeamSubsystem r, + Member JobSubsystem r, Member (Input ConversationSubsystemConfig) r ) => RemoveMemberResponseMode -> @@ -1185,7 +1189,8 @@ guardPreventAdminlessGroups :: Member E.ExternalAccess r, Member BackendNotificationQueueAccess r, Member TeamSubsystem r, - Member FeaturesConfigSubsystem r + Member FeaturesConfigSubsystem r, + Member JobSubsystem r ) => RemoveMemberResponseMode -> Local ConvId -> @@ -1196,6 +1201,11 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do conv <- getConversationWithError lcnv for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid + let scheduleDeletion = do + now <- Now.get + let scheduledFor = addUTCTime (fromIntegral feature.config.deletionTimeout * nominalDay) now + void $ + scheduleAdminlessDeletionJob tid (Just (qUnqualified (tUntagged lcnv))) scheduledFor when (feature.status == FeatureStatusEnabled && isLeavingLastConversationAdmin (qUnqualified victim) conv) $ do eligibleMembers <- eligibleAdminFallbackMembers lcnv (qUnqualified victim) conv case (responseMode, eligibleMembers) of @@ -1208,11 +1218,9 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do (RemoveMemberEligibleMembersResponse, _ : _) -> throw $ AdminlessConversation (fmap fst eligibleMembers) (RemoveMemberLegacyResponse, []) -> - -- FUTUREWORK: mark for deletion - pure () - (RemoveMemberEligibleMembersResponse, []) -> do - -- FUTUREWORK: mark for deletion - pure () + scheduleDeletion + (RemoveMemberEligibleMembersResponse, []) -> + scheduleDeletion where -- Use eight random bytes and fold them into a big-endian Word64. This keeps -- the helper small, deterministic under tests, and free of extra Random API. @@ -1268,7 +1276,8 @@ deleteUserFromTeamConversationsImpl :: Member E.ExternalAccess r, Member Now r, Member Random r, - Member TeamSubsystem r + Member TeamSubsystem r, + Member JobSubsystem r ) => Local UserId -> Maybe ConnId -> @@ -1379,7 +1388,8 @@ removeMemberFromLocalConv :: Member (ErrorS ConvMemberNotFound) r, Member (Error AdminlessConversation) r, Member FeaturesConfigSubsystem r, - Member BrigAPIAccess r + Member BrigAPIAccess r, + Member JobSubsystem r ) => RemoveMemberResponseMode -> Local ConvId -> diff --git a/libs/wire-subsystems/src/Wire/JobStore.hs b/libs/wire-subsystems/src/Wire/JobStore.hs index 0e234a3c70b..13072bad8b4 100644 --- a/libs/wire-subsystems/src/Wire/JobStore.hs +++ b/libs/wire-subsystems/src/Wire/JobStore.hs @@ -28,17 +28,17 @@ module Wire.JobStore ) where -import Data.UUID (UUID) import Imports import Polysemy +import Data.Id (ConvId, ScheduledJobId, TeamId) import Wire.API.Jobs data JobStore m a where CreateJob :: ScheduledJob -> JobStore m () - FindJobById :: UUID -> JobStore m (Maybe ScheduledJob) - FindJobsByTeamAndKind :: UUID -> ScheduledJobKind -> JobStore m [ScheduledJob] - FindJobsByConversationId :: UUID -> JobStore m [ScheduledJob] - DeleteJob :: UUID -> JobStore m () - DeleteJobsByTeamAndKind :: UUID -> ScheduledJobKind -> JobStore m () + FindJobById :: ScheduledJobId -> JobStore m (Maybe ScheduledJob) + FindJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobStore m [ScheduledJob] + FindJobsByConversationId :: ConvId -> JobStore m [ScheduledJob] + DeleteJob :: ScheduledJobId -> JobStore m () + DeleteJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobStore m () makeSem ''JobStore diff --git a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs index 16a5a77dbcc..3cfc47612be 100644 --- a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs @@ -11,22 +11,27 @@ -- any later version. -- -- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. -- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . module Wire.JobStore.Postgres ( interpretJobStoreToPostgres, ) where +import Data.Int qualified as Int +import Data.Time.Clock (UTCTime) +import Data.UUID (UUID) +import Data.Vector (Vector) +import Data.Id (ConvId, ScheduledJobId, TeamId) import Hasql.Pool +import Hasql.Statement qualified as Hasql import Hasql.TH import Imports -import Data.UUID (UUID) import Polysemy import Polysemy.Error (Error) import Polysemy.Input @@ -58,93 +63,111 @@ createJobImpl :: ScheduledJob -> Sem r () createJobImpl job = - runStatement job $ - lmapPG - [resultlessStatement| - INSERT INTO scheduled_jobs - (id, kind, team_id, conversation_id, scheduled_for) - VALUES - ($1 :: uuid, $2 :: int4, $3 :: uuid, $4 :: uuid?, $5 :: timestamptz) |] + runStatement job insertJob + where + insertJob :: Hasql.Statement ScheduledJob () + insertJob = + lmapPG + [resultlessStatement| + INSERT INTO scheduled_jobs + (id, kind, team_id, conversation_id, scheduled_for) + VALUES + ($1 :: uuid, $2 :: int4, $3 :: uuid, $4 :: uuid?, $5 :: timestamptz) |] findJobByIdImpl :: ( Member (Embed IO) r, Member (Input Pool) r, Member (Error UsageError) r ) => - UUID -> + ScheduledJobId -> Sem r (Maybe ScheduledJob) findJobByIdImpl jobId = - runStatement jobId $ - dimapPG - [maybeStatement| - SELECT - (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), - (scheduled_for :: timestamptz) - FROM scheduled_jobs - WHERE id = ($1 :: uuid) |] + runStatement jobId selectJob + where + selectJob :: Hasql.Statement ScheduledJobId (Maybe ScheduledJob) + selectJob = + dimapPG + [maybeStatement| + SELECT + (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), + (scheduled_for :: timestamptz) + FROM scheduled_jobs + WHERE id = ($1 :: uuid) |] findJobsByTeamAndKindImpl :: ( Member (Embed IO) r, Member (Input Pool) r, Member (Error UsageError) r ) => - UUID -> + TeamId -> ScheduledJobKind -> Sem r [ScheduledJob] findJobsByTeamAndKindImpl teamId kind = - runStatement (teamId, kind) $ - dimapPG - [vectorStatement| - SELECT - (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), - (scheduled_for :: timestamptz) - FROM scheduled_jobs - WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) - ORDER BY scheduled_for ASC, id ASC |] + runStatement (teamId, kind) selectJobs + where + selectJobs :: Hasql.Statement (TeamId, ScheduledJobKind) [ScheduledJob] + selectJobs = + dimapPG + [vectorStatement| + SELECT + (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), + (scheduled_for :: timestamptz) + FROM scheduled_jobs + WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) + ORDER BY scheduled_for ASC, id ASC |] findJobsByConversationIdImpl :: ( Member (Embed IO) r, Member (Input Pool) r, Member (Error UsageError) r ) => - UUID -> + ConvId -> Sem r [ScheduledJob] findJobsByConversationIdImpl conversationId = - runStatement conversationId $ - dimapPG - [vectorStatement| - SELECT - (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), - (scheduled_for :: timestamptz) - FROM scheduled_jobs - WHERE conversation_id = ($1 :: uuid) - ORDER BY scheduled_for ASC, id ASC |] + runStatement conversationId selectJobs + where + selectJobs :: Hasql.Statement ConvId [ScheduledJob] + selectJobs = + dimapPG + [vectorStatement| + SELECT + (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), + (scheduled_for :: timestamptz) + FROM scheduled_jobs + WHERE conversation_id = ($1 :: uuid) + ORDER BY scheduled_for ASC, id ASC |] deleteJobImpl :: ( Member (Embed IO) r, Member (Input Pool) r, Member (Error UsageError) r ) => - UUID -> + ScheduledJobId -> Sem r () deleteJobImpl jobId = - runStatement jobId $ - lmapPG - [resultlessStatement| - DELETE FROM scheduled_jobs - WHERE id = ($1 :: uuid) |] + runStatement jobId deleteJob + where + deleteJob :: Hasql.Statement ScheduledJobId () + deleteJob = + lmapPG + [resultlessStatement| + DELETE FROM scheduled_jobs + WHERE id = ($1 :: uuid) |] deleteJobsByTeamAndKindImpl :: ( Member (Embed IO) r, Member (Input Pool) r, Member (Error UsageError) r ) => - UUID -> + TeamId -> ScheduledJobKind -> Sem r () deleteJobsByTeamAndKindImpl teamId kind = - runStatement (teamId, kind) $ - lmapPG - [resultlessStatement| - DELETE FROM scheduled_jobs - WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) |] + runStatement (teamId, kind) deleteJobs + where + deleteJobs :: Hasql.Statement (TeamId, ScheduledJobKind) () + deleteJobs = + lmapPG + [resultlessStatement| + DELETE FROM scheduled_jobs + WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) |] diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs new file mode 100644 index 00000000000..d6a825ed0e1 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -0,0 +1,72 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem + ( CleanupAction, + JobSubsystemConfig (..), + JobSubsystem (..), + JobRunnerConfig, + registerJob, + scheduleAdminlessDeletionJob, + registerAdminlessDeletionJob, + cancelJob, + cancelJobsByTeamAndKind, + findJobById, + findJobsByConversationId, + findJobsByTeamAndKind, + startJobRunner, + ) +where + +import Data.ByteString qualified as ByteString +import Data.Time.Clock (UTCTime) +import Imports +import Polysemy +import Data.Id (ConvId, ScheduledJobId, TeamId) +import Wire.API.Jobs +import Wire.JobSubsystem.Recurring (RecurringJobRunnerConfig) + +type CleanupAction = IO () + +data JobSubsystemConfig = JobSubsystemConfig + { jobSubsystemArbiterConnStr :: ByteString.ByteString, + jobSubsystemSchemaName :: Text + } + +type JobRunnerConfig = RecurringJobRunnerConfig ScheduledJobsRegistry MeetingsCleanupJob + +data JobSubsystem m a where + RegisterJob :: ScheduledJob -> JobSubsystem m () + CancelJob :: ScheduledJobId -> JobSubsystem m () + CancelJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobSubsystem m () + FindJobById :: ScheduledJobId -> JobSubsystem m (Maybe ScheduledJob) + FindJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobSubsystem m [ScheduledJob] + FindJobsByConversationId :: ConvId -> JobSubsystem m [ScheduledJob] + ScheduleAdminlessDeletionJob :: TeamId -> Maybe ConvId -> UTCTime -> JobSubsystem m ScheduledJob + StartJobRunner :: JobRunnerConfig -> JobSubsystem m CleanupAction + +makeSem ''JobSubsystem + +registerAdminlessDeletionJob :: + (Member JobSubsystem r) => + TeamId -> + Maybe ConvId -> + UTCTime -> + Sem r ScheduledJob +registerAdminlessDeletionJob = scheduleAdminlessDeletionJob diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs new file mode 100644 index 00000000000..210a30c1ae4 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -0,0 +1,89 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem.Interpreter + ( interpretJobSubsystem, + runJobRunner, + ) +where + +import Arbiter.Core qualified as ArbiterCore +import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql +import Data.Id +import Data.Proxy (Proxy (..)) +import Data.UUID.V4 qualified as UUID +import Imports +import Polysemy +import Wire.API.Jobs +import Wire.JobStore qualified as JobStore +import Wire.JobSubsystem +import Wire.JobSubsystem.Recurring (runRecurringJobRunner) + +runJobRunner :: JobRunnerConfig -> IO CleanupAction +runJobRunner = runRecurringJobRunner (Proxy @ScheduledJobsRegistry) + +interpretJobSubsystem :: + ( Member JobStore.JobStore r, + Member (Embed IO) r + ) => + JobSubsystemConfig -> + Sem (JobSubsystem : r) a -> + Sem r a +interpretJobSubsystem JobSubsystemConfig {..} sem = do + arbiterEnv <- + embed $ + ArbiterHasql.createHasqlEnv + (Proxy @ScheduledJobsRegistry) + jobSubsystemArbiterConnStr + jobSubsystemSchemaName + interpret + ( \case + RegisterJob job -> JobStore.createJob job + CancelJob jobId -> JobStore.deleteJob jobId + CancelJobsByTeamAndKind teamId kind -> JobStore.deleteJobsByTeamAndKind teamId kind + FindJobById jobId -> JobStore.findJobById jobId + FindJobsByTeamAndKind teamId kind -> JobStore.findJobsByTeamAndKind teamId kind + FindJobsByConversationId conversationId -> JobStore.findJobsByConversationId conversationId + ScheduleAdminlessDeletionJob teamId conversationId scheduledFor -> do + jobId <- embed $ Id <$> UUID.nextRandom + let job = + ScheduledJob + { scheduledJobId = jobId, + scheduledJobKind = AdminlessDeletion, + scheduledJobTeamId = teamId, + scheduledJobConversationId = conversationId, + scheduledJobScheduledFor = scheduledFor + } + arbiterJob = + (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName AdminlessDeletionJob) + { ArbiterCore.notVisibleUntil = Just scheduledFor + } + JobStore.createJob job + embed $ + void $ + ArbiterHasql.runHasqlDb arbiterEnv $ + void $ + ArbiterCore.insertJob arbiterJob + pure job + StartJobRunner cfg -> embed $ runRecurringJobRunner (Proxy @ScheduledJobsRegistry) cfg + ) + sem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs new file mode 100644 index 00000000000..58a779a99d9 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs @@ -0,0 +1,213 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE RecordWildCards #-} + +{-# LANGUAGE TypeFamilies #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem.Recurring + ( RecurringJobRunnerConfig (..), + OneOffJobRunnerConfig (..), + runRecurringJobRunner, + runOneOffJobRunner, + ) +where + +import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql +import Arbiter.Migrations qualified as ArbiterMigrations +import Arbiter.Worker qualified as ArbiterWorker +import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) +import Data.Kind (Type) +import Data.ByteString qualified as ByteString +import Data.Aeson (FromJSON, ToJSON) +import Data.Proxy (Proxy (..)) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Imports +import System.Cron (CronSchedule, nextMatch) +import System.Logger qualified as Log +import UnliftIO.Async qualified as Async +import GHC.TypeLits (KnownSymbol) + +data RecurringJobRunnerConfig registry (payload :: Type) = RecurringJobRunnerConfig + { recurringJobRunnerLogger :: Log.Logger, + recurringJobRunnerSchedule :: CronSchedule, + recurringJobRunnerArbiterConnStr :: ByteString.ByteString, + recurringJobRunnerSchemaName :: Text, + recurringJobRunnerWorkerThreads :: Int, + recurringJobRunnerEnqueueAt :: UTCTime -> ArbiterHasql.HasqlDb registry IO (), + recurringJobRunnerRunJob :: IO (), + recurringJobRunnerJobName :: Text, + recurringJobRunnerQueueName :: Text + } + +data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig + { oneOffJobRunnerLogger :: Log.Logger, + oneOffJobRunnerArbiterConnStr :: ByteString.ByteString, + oneOffJobRunnerSchemaName :: Text, + oneOffJobRunnerWorkerThreads :: Int, + oneOffJobRunnerRunJob :: IO (), + oneOffJobRunnerJobName :: Text, + oneOffJobRunnerQueueName :: Text + } + +runRecurringJobRunner :: + forall registry (payload :: Type). + ( RegistryTables registry, + KnownSymbol (TableForPayload payload registry), + FromJSON payload, + ToJSON payload + ) => + Proxy registry -> + RecurringJobRunnerConfig registry payload -> + IO (IO ()) +runRecurringJobRunner registry RecurringJobRunnerConfig {..} = do + Log.info recurringJobRunnerLogger $ + Log.msg (Log.val "Starting scheduled jobs worker") + . Log.field "job_name" recurringJobRunnerJobName + . Log.field "queue_name" recurringJobRunnerQueueName + . Log.field "schedule" (show recurringJobRunnerSchedule) + + arbiterEnv <- + ArbiterHasql.createHasqlEnv + registry + recurringJobRunnerArbiterConnStr + recurringJobRunnerSchemaName + + let enqueueNextRun :: UTCTime -> IO () + enqueueNextRun scheduledFor = + void $ + ArbiterHasql.runHasqlDb arbiterEnv $ + recurringJobRunnerEnqueueAt scheduledFor + + enqueueIfScheduled :: UTCTime -> IO Bool + enqueueIfScheduled now = case nextMatch recurringJobRunnerSchedule now of + Nothing -> do + Log.warn recurringJobRunnerLogger $ + Log.msg (Log.val "Scheduled job will not be enqueued") + . Log.field "job_name" recurringJobRunnerJobName + . Log.field "queue_name" recurringJobRunnerQueueName + pure False + Just nextRun -> do + Log.info recurringJobRunnerLogger $ + Log.msg (Log.val "Enqueuing scheduled job") + . Log.field "job_name" recurringJobRunnerJobName + . Log.field "queue_name" recurringJobRunnerQueueName + . Log.field "scheduled_for" (show nextRun) + enqueueNextRun nextRun + pure True + + workerHandler _ _ = + liftIO $ do + Log.info recurringJobRunnerLogger $ + Log.msg (Log.val "Running scheduled job") + . Log.field "job_name" recurringJobRunnerJobName + . Log.field "queue_name" recurringJobRunnerQueueName + recurringJobRunnerRunJob + now <- getCurrentTime + void $ enqueueIfScheduled now + + void $ + ArbiterMigrations.runMigrationsForRegistry + registry + recurringJobRunnerArbiterConnStr + recurringJobRunnerSchemaName + ArbiterMigrations.defaultMigrationConfig + + workerConfig <- + ( ArbiterWorker.defaultWorkerConfig + recurringJobRunnerArbiterConnStr + recurringJobRunnerWorkerThreads + workerHandler :: + IO + ( ArbiterWorker.WorkerConfig + (ArbiterHasql.HasqlDb registry IO) + payload + () + ) + ) + + now <- getCurrentTime + void $ enqueueIfScheduled now + + workerAsync <- + Async.async $ + ArbiterHasql.runHasqlDb arbiterEnv $ + ArbiterWorker.runWorkerPool workerConfig + + pure $ do + ArbiterWorker.shutdownWorker workerConfig + Async.cancel workerAsync + +runOneOffJobRunner :: + forall registry (payload :: Type). + ( RegistryTables registry, + KnownSymbol (TableForPayload payload registry), + FromJSON payload, + ToJSON payload + ) => + Proxy registry -> + OneOffJobRunnerConfig registry payload -> + IO (IO ()) +runOneOffJobRunner registry OneOffJobRunnerConfig {..} = do + Log.info oneOffJobRunnerLogger $ + Log.msg (Log.val "Starting one-off jobs worker") + . Log.field "job_name" oneOffJobRunnerJobName + . Log.field "queue_name" oneOffJobRunnerQueueName + + arbiterEnv <- + ArbiterHasql.createHasqlEnv + registry + oneOffJobRunnerArbiterConnStr + oneOffJobRunnerSchemaName + + let workerHandler _ _ = + liftIO $ do + Log.info oneOffJobRunnerLogger $ + Log.msg (Log.val "Running one-off job") + . Log.field "job_name" oneOffJobRunnerJobName + . Log.field "queue_name" oneOffJobRunnerQueueName + oneOffJobRunnerRunJob + + void $ + ArbiterMigrations.runMigrationsForRegistry + registry + oneOffJobRunnerArbiterConnStr + oneOffJobRunnerSchemaName + ArbiterMigrations.defaultMigrationConfig + + workerConfig <- + ( ArbiterWorker.defaultWorkerConfig + oneOffJobRunnerArbiterConnStr + oneOffJobRunnerWorkerThreads + workerHandler :: + IO + ( ArbiterWorker.WorkerConfig + (ArbiterHasql.HasqlDb registry IO) + payload + () + ) + ) + + workerAsync <- + Async.async $ + ArbiterHasql.runHasqlDb arbiterEnv $ + ArbiterWorker.runWorkerPool workerConfig + + pure $ do + ArbiterWorker.shutdownWorker workerConfig + Async.cancel workerAsync diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 34133bf4475..6fcf58750fb 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -53,6 +53,8 @@ import Wire.MockInterpreters.TinyLog (noopLogger) import Wire.NotificationSubsystem (NotificationSubsystem (..)) import Wire.ProposalStore (ProposalStore (..)) import Wire.Sem.Random (Random (..)) +import Wire.JobSubsystem (JobSubsystem (..)) +import Wire.API.Jobs (ScheduledJob (..), ScheduledJobKind (AdminlessDeletion)) import Wire.StoredConversation import Wire.TeamSubsystem (TeamSubsystem (..)) @@ -121,6 +123,7 @@ spec = describe "ConversationSubsystem.Interpreter" do . interpretNotificationSubsystem . interpretProposalStore . interpretTeamSubsystem + . interpretJobSubsystem . interpretNowConst defaultTime . interpretRandom . noopLogger @@ -260,6 +263,28 @@ interpretTeamSubsystem = interpret $ \case _ -> error "unexpected TeamSubsystem call in test" +interpretJobSubsystem :: + Sem (JobSubsystem ': r) a -> + Sem r a +interpretJobSubsystem = + interpret $ \case + RegisterJob _ -> pure () + CancelJob _ -> pure () + CancelJobsByTeamAndKind _ _ -> pure () + FindJobById _ -> pure Nothing + FindJobsByTeamAndKind _ _ -> pure [] + FindJobsByConversationId _ -> pure [] + ScheduleAdminlessDeletionJob _ _ _ -> + pure + ScheduledJob + { scheduledJobId = Id UUID.nil, + scheduledJobKind = AdminlessDeletion, + scheduledJobTeamId = Id UUID.nil, + scheduledJobConversationId = Just (Id UUID.nil), + scheduledJobScheduledFor = defaultTime + } + StartJobRunner _ -> pure (pure ()) + interpretRandom :: Sem (Random ': r) a -> Sem r a diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 63c6f1f5faf..7cab4a0f896 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -92,8 +92,11 @@ common common-all , amazonka-sqs , amqp , arbiter-core + , arbiter-hasql + , arbiter-migrations , arbiter-servant , arbiter-servant-ui + , arbiter-worker , asn1-encoding , asn1-types , async @@ -114,6 +117,7 @@ common common-all , contravariant , cookie , cql + , cron , crypton , crypton-asn1-encoding , crypton-asn1-types @@ -370,6 +374,9 @@ library Wire.InvitationStore.Cassandra Wire.JobStore Wire.JobStore.Postgres + Wire.JobSubsystem + Wire.JobSubsystem.Interpreter + Wire.JobSubsystem.Recurring Wire.LegalHold Wire.LegalHoldStore Wire.LegalHoldStore.Cassandra diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index a5cb3cc10a2..bfa55214611 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -37,9 +37,6 @@ library aeson , amqp , arbiter-core - , arbiter-hasql - , arbiter-migrations - , arbiter-worker , base , bilge , bytestring diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 69f847a28ef..7a07604b93f 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -6,9 +6,6 @@ , aeson , amqp , arbiter-core -, arbiter-hasql -, arbiter-migrations -, arbiter-worker , base , bilge , bytestring @@ -71,9 +68,6 @@ mkDerivation { aeson amqp arbiter-core - arbiter-hasql - arbiter-migrations - arbiter-worker base bilge bytestring diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 698501f8086..3e34edbb0eb 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -22,18 +22,24 @@ module Wire.BackgroundWorker.ScheduledJobs (startWorker) where import Arbiter.Core qualified as ArbiterCore -import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql -import Arbiter.Migrations qualified as ArbiterMigrations -import Arbiter.Worker qualified as ArbiterWorker import Data.Proxy (Proxy (..)) import Imports -import System.Cron (Job (..), forkJob) -import System.Logger qualified as Log -import UnliftIO.Async qualified as Async -import Wire.API.Jobs (MeetingsCleanupJob (..), ScheduledJobsRegistry, meetingsCleanupQueueName) +import Wire.API.Jobs + ( AdminlessDeletionJob (..), + MeetingsCleanupJob (..), + ScheduledJobsRegistry, + adminlessDeletionQueueName, + meetingsCleanupQueueName, + ) import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) import Wire.BackgroundWorker.Util (CleanupAction) +import Wire.JobSubsystem.Recurring + ( OneOffJobRunnerConfig (..), + RecurringJobRunnerConfig (..), + runOneOffJobRunner, + runRecurringJobRunner, + ) import Wire.MeetingsCleanupWorker ( CleanupConfig (..), runCleanupOldMeetings, @@ -42,85 +48,47 @@ import Wire.MeetingsCleanupWorker startWorker :: MeetingsCleanupConfig -> AppT IO CleanupAction startWorker config = do env <- ask - Log.info env.logger $ - Log.msg (Log.val "Starting scheduled meetings cleanup jobs") - . Log.field "schedule" (show config.schedule) - . Log.field "clean_older_than_hours" config.cleanOlderThanHours - let cleanupConfig = CleanupConfig { retentionHours = config.cleanOlderThanHours, batchSize = config.batchSize } - schemaName = ArbiterCore.defaultSchemaName - -- Arbiter keeps its own schema and migrations separate from the existing - -- meetings tables, so the worker can claim jobs independently. - arbiterEnv <- - liftIO $ - ArbiterHasql.createHasqlEnv (Proxy @ScheduledJobsRegistry) env.arbiterConnStr schemaName - - -- Insert a single queued job for each tick. The dedup key prevents one - -- scheduler from enqueuing duplicate runs for the same logical job. - let jobWrite :: ArbiterCore.JobWrite MeetingsCleanupJob - jobWrite = + jobWrite scheduledFor = (ArbiterCore.defaultGroupedJob meetingsCleanupQueueName MeetingsCleanupJob) - { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate meetingsCleanupQueueName) + { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate meetingsCleanupQueueName), + ArbiterCore.notVisibleUntil = Just scheduledFor } - - enqueueCleanupJob :: ArbiterHasql.HasqlDb ScheduledJobsRegistry IO () - enqueueCleanupJob = void $ ArbiterCore.insertJob jobWrite - - workerHandler _ _ = do - -- Arbiter claims the job; the handler just runs the existing cleanup - -- logic inside the background-worker application environment. - liftIO $ - runAppT env $ do - Log.info env.logger $ Log.msg (Log.val "Running scheduled meetings cleanup job") - runCleanupOldMeetings cleanupConfig - - -- Create the Arbiter queue tables for this registry on startup. - void $ + cleanupRecurring <- liftIO $ - ArbiterMigrations.runMigrationsForRegistry + runRecurringJobRunner @ScheduledJobsRegistry @MeetingsCleanupJob (Proxy @ScheduledJobsRegistry) - env.arbiterConnStr - schemaName - ArbiterMigrations.defaultMigrationConfig - - workerConfig <- + RecurringJobRunnerConfig + { recurringJobRunnerLogger = env.logger, + recurringJobRunnerSchedule = config.schedule, + recurringJobRunnerArbiterConnStr = env.arbiterConnStr, + recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + recurringJobRunnerWorkerThreads = 1, + recurringJobRunnerEnqueueAt = \scheduledFor -> + void $ ArbiterCore.insertJob (jobWrite scheduledFor), + recurringJobRunnerRunJob = + runAppT env $ + runCleanupOldMeetings cleanupConfig, + recurringJobRunnerJobName = "meetings-cleanup", + recurringJobRunnerQueueName = meetingsCleanupQueueName + } + cleanupDeletion <- liftIO $ - -- One worker thread is enough for now because each background-worker - -- instance enqueues the same logical cleanup job, and Arbiter's dedup - -- key keeps duplicate runs from stacking up. - -- One worker thread is enough for this proof of concept. - ( ArbiterWorker.defaultWorkerConfig - env.arbiterConnStr - 1 - workerHandler :: - IO - ( ArbiterWorker.WorkerConfig - (ArbiterHasql.HasqlDb ScheduledJobsRegistry IO) - MeetingsCleanupJob - () - ) - ) - - schedulerThread <- liftIO . forkJob . Job config.schedule $ do - -- The scheduler only inserts the next job row; Arbiter does the claiming - -- and execution. - Log.info env.logger $ - Log.msg (Log.val "Enqueuing scheduled meetings cleanup job") - . Log.field "queue_name" meetingsCleanupQueueName - void $ ArbiterHasql.runHasqlDb arbiterEnv enqueueCleanupJob - - workerAsync <- - liftIO . Async.async $ - -- Run the Arbiter worker loop in the same process. - ArbiterHasql.runHasqlDb arbiterEnv $ - ArbiterWorker.runWorkerPool workerConfig - - pure $ do - liftIO $ do - killThread schedulerThread - ArbiterWorker.shutdownWorker workerConfig - Async.cancel workerAsync + runOneOffJobRunner @ScheduledJobsRegistry @AdminlessDeletionJob + (Proxy @ScheduledJobsRegistry) + OneOffJobRunnerConfig + { oneOffJobRunnerLogger = env.logger, + oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, + oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + oneOffJobRunnerWorkerThreads = 1, + oneOffJobRunnerRunJob = + runAppT env $ + pure (), + oneOffJobRunnerJobName = "adminless-deletion", + oneOffJobRunnerQueueName = adminlessDeletionQueueName + } + pure $ cleanupRecurring >> cleanupDeletion diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 57330f9f1e3..ca554addd0b 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -34,6 +34,7 @@ import Data.Qualified import Data.Tagged (Tagged) import Data.Text qualified as T import Data.Text.Lazy qualified as TL +import Arbiter.Core qualified as ArbiterCore import Galley.Types.Error (InternalError, internalErrorDescription, legalHoldServiceUnavailable) import Hasql.Pool (UsageError) import Hasql.Pool.Extended qualified as HasqlPoolExt @@ -93,6 +94,10 @@ import Wire.HashPassword.Interpreter (runHashPassword) import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) +import Wire.JobStore (JobStore) +import Wire.JobStore.Postgres (interpretJobStoreToPostgres) +import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) +import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.NotificationSubsystem (NotificationSubsystem) import Wire.NotificationSubsystem.Interpreter import Wire.Options.Galley (GuestLinkTTLSeconds) @@ -210,6 +215,8 @@ type BackgroundWorkerEffects = Now, TeamJournal, LegalHoldStore, + JobSubsystem, + JobStore, TeamCollaboratorsStore, TeamStore, ConversationStore, @@ -322,6 +329,12 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretConversationStoreByMigration env.postgresMigration.conversation env.cassandraGalley . interpretTeamStoreToCassandra . interpretTeamCollaboratorsStoreToPostgres + . interpretJobStoreToPostgres + . interpretJobSubsystem + JobSubsystemConfig + { jobSubsystemArbiterConnStr = env.arbiterConnStr, + jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + } . interpretLegalHoldStoreToCassandra (env.conversationSubsystemConfig.legalholdDefaults) . interpretTeamJournal Nothing . nowToIO diff --git a/services/brig/default.nix b/services/brig/default.nix index 7c05bac4ac1..a79b7a5802a 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -164,6 +164,9 @@ mkDerivation { amazonka-ses amazonka-sqs amqp + arbiter-core + arbiter-servant + arbiter-servant-ui async auto-update base diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 5a9c05fc1c2..df002b1355a 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -227,6 +227,7 @@ data Env = Env enableSFTFederation :: Maybe Bool, rateLimitEnv :: RateLimitEnv, amqpJobsPublisherChannel :: MVar Q.Channel, + jobsApiConnStr :: Text, jobsApiApp :: Application, postgresMigration :: PostgresMigrationOpts } @@ -332,6 +333,7 @@ newEnv opts = do enableSFTFederation = opts.multiSFT, rateLimitEnv, amqpJobsPublisherChannel, + jobsApiConnStr, jobsApiApp, postgresMigration = opts.postgresMigration } diff --git a/services/galley/default.nix b/services/galley/default.nix index 3f9eb5cb466..532c7591ad0 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -28,6 +28,7 @@ , errors , exceptions , extended +, arbiter-core , extra , federator , filepath @@ -133,6 +134,7 @@ mkDerivation { errors exceptions extended + arbiter-core galley-types hasql-resource-pool hs-opentelemetry-instrumentation-wai @@ -205,6 +207,7 @@ mkDerivation { errors exceptions extended + arbiter-core extra federator filepath diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 5ca4fdc34c8..f7c48e3bc00 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -197,6 +197,7 @@ library , aeson >=2.0.1.0 , amazonka >=1.4.5 , amqp + , arbiter-core , async >=2.0 , base >=4.6 && <5 , bilge >=0.21.1 diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 78183816268..ffa31a35bce 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -43,6 +43,7 @@ module Galley.App where import Bilge hiding (Request, header, host, options, port, statusCode, statusMessage) +import Arbiter.Core qualified as ArbiterCore import Cassandra hiding (Set) import Cassandra.Util (initCassandraForService) import Control.Error hiding (err) @@ -53,6 +54,7 @@ import Data.Misc import Data.Qualified import Data.Range import Data.Text qualified as Text +import Data.Text.Encoding qualified as TextEncoding import Galley.Effects.Queue qualified as GE import Galley.Env import Galley.External.LegalHoldService.Internal qualified as LHInternal @@ -127,6 +129,10 @@ import Wire.HashPassword.Interpreter import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) +import Wire.JobStore (JobStore) +import Wire.JobStore.Postgres (interpretJobStoreToPostgres) +import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) +import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.ListItems (ListItems) import Wire.ListItems.Team.Cassandra ( interpretInternalTeamListToCassandra, @@ -191,6 +197,8 @@ import Wire.UserGroupStore.Postgres (interpretUserGroupStoreToPostgres) type GalleyEffects = '[ MeetingsSubsystem, ConversationSubsystem, + JobSubsystem, + JobStore, FederationSubsystem, TeamCollaboratorsSubsystem, Input AllTeamFeatures, @@ -324,6 +332,7 @@ createEnv o l = do h2mgr <- initHttp2Manager codeURIcfg <- validateOptions o postgres <- initPostgresPool o._postgresqlPool o._postgresql o._postgresqlPassword + galleyJobsApiConnStr <- postgresqlConnectionString o._postgresql o._postgresqlPassword let disableTlsV1 = True Env (RequestId defRequestId) o l mgr h2mgr (o ^. O.federator) (o ^. O.brig) cass postgres <$> Q.new 16000 @@ -333,6 +342,7 @@ createEnv o l = do <*> traverse (mkRabbitMqChannelMVar l (Just "galley")) (o ^. rabbitmq) <*> pure codeURIcfg <*> newRateLimitEnv (o ^. settings . passwordHashingRateLimit) + <*> pure galleyJobsApiConnStr initCassandra :: Opts -> Logger -> IO ClientState initCassandra o l = @@ -549,6 +559,12 @@ evalGalley e = . runInputSem getAllTeamFeaturesForServer . interpretTeamCollaboratorsSubsystem . runFederationSubsystem conversationSubsystemConfig.federationProtocols + . interpretJobStoreToPostgres + . interpretJobSubsystem + JobSubsystemConfig + { jobSubsystemArbiterConnStr = TextEncoding.encodeUtf8 (e ^. jobsApiConnStr), + jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + } . interpretConversationSubsystem . Meeting.interpretMeetingsSubsystem meetingValidityPeriod where diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index 24da89559cd..cc7871ea567 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -37,6 +37,7 @@ module Galley.Env rabbitmqChannel, convCodeURI, passwordHashingRateLimitEnv, + jobsApiConnStr, reqIdMsg, notificationSubsystemConfig, currentFanoutLimitOpts, @@ -87,7 +88,8 @@ data Env = Env _mlsKeys :: Maybe (MLSKeysByPurpose MLSPrivateKeys), _rabbitmqChannel :: Maybe (MVar Q.Channel), _convCodeURI :: Either HttpsUrl (Map Domain HttpsUrl), - _passwordHashingRateLimitEnv :: RateLimitEnv + _passwordHashingRateLimitEnv :: RateLimitEnv, + _jobsApiConnStr :: Text } makeLenses ''Env From 761acd73684617d0a4e3c96a9c3be5e119e26bbd Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 25 Jun 2026 08:04:24 +0000 Subject: [PATCH 11/65] resolve warning --- services/brig/src/Brig/App.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index df002b1355a..e5468672597 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -75,6 +75,7 @@ module Brig.App amqpJobsPublisherChannelLens, postgresMigrationLens, jobsApiAppLens, + jobsApiConnStrLens, initZAuth, initLogger, initPostgresPool, From f6be6313fe0d643ee470f516ec9eb79401bde6c9 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 25 Jun 2026 08:04:44 +0000 Subject: [PATCH 12/65] revomve obsolete job subsystem operations --- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 23 +------------------ .../src/Wire/JobSubsystem/Interpreter.hs | 6 ----- .../ConversationSubsystem/InterpreterSpec.hs | 6 ----- 3 files changed, 1 insertion(+), 34 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index d6a825ed0e1..9abb314a4b5 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -22,23 +22,16 @@ module Wire.JobSubsystem JobSubsystemConfig (..), JobSubsystem (..), JobRunnerConfig, - registerJob, scheduleAdminlessDeletionJob, - registerAdminlessDeletionJob, - cancelJob, - cancelJobsByTeamAndKind, - findJobById, - findJobsByConversationId, - findJobsByTeamAndKind, startJobRunner, ) where import Data.ByteString qualified as ByteString +import Data.Id (ConvId, TeamId) import Data.Time.Clock (UTCTime) import Imports import Polysemy -import Data.Id (ConvId, ScheduledJobId, TeamId) import Wire.API.Jobs import Wire.JobSubsystem.Recurring (RecurringJobRunnerConfig) @@ -52,21 +45,7 @@ data JobSubsystemConfig = JobSubsystemConfig type JobRunnerConfig = RecurringJobRunnerConfig ScheduledJobsRegistry MeetingsCleanupJob data JobSubsystem m a where - RegisterJob :: ScheduledJob -> JobSubsystem m () - CancelJob :: ScheduledJobId -> JobSubsystem m () - CancelJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobSubsystem m () - FindJobById :: ScheduledJobId -> JobSubsystem m (Maybe ScheduledJob) - FindJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobSubsystem m [ScheduledJob] - FindJobsByConversationId :: ConvId -> JobSubsystem m [ScheduledJob] ScheduleAdminlessDeletionJob :: TeamId -> Maybe ConvId -> UTCTime -> JobSubsystem m ScheduledJob StartJobRunner :: JobRunnerConfig -> JobSubsystem m CleanupAction makeSem ''JobSubsystem - -registerAdminlessDeletionJob :: - (Member JobSubsystem r) => - TeamId -> - Maybe ConvId -> - UTCTime -> - Sem r ScheduledJob -registerAdminlessDeletionJob = scheduleAdminlessDeletionJob diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 210a30c1ae4..ab211f45b3c 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -57,12 +57,6 @@ interpretJobSubsystem JobSubsystemConfig {..} sem = do jobSubsystemSchemaName interpret ( \case - RegisterJob job -> JobStore.createJob job - CancelJob jobId -> JobStore.deleteJob jobId - CancelJobsByTeamAndKind teamId kind -> JobStore.deleteJobsByTeamAndKind teamId kind - FindJobById jobId -> JobStore.findJobById jobId - FindJobsByTeamAndKind teamId kind -> JobStore.findJobsByTeamAndKind teamId kind - FindJobsByConversationId conversationId -> JobStore.findJobsByConversationId conversationId ScheduleAdminlessDeletionJob teamId conversationId scheduledFor -> do jobId <- embed $ Id <$> UUID.nextRandom let job = diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 6fcf58750fb..a9800646291 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -268,12 +268,6 @@ interpretJobSubsystem :: Sem r a interpretJobSubsystem = interpret $ \case - RegisterJob _ -> pure () - CancelJob _ -> pure () - CancelJobsByTeamAndKind _ _ -> pure () - FindJobById _ -> pure Nothing - FindJobsByTeamAndKind _ _ -> pure [] - FindJobsByConversationId _ -> pure [] ScheduleAdminlessDeletionJob _ _ _ -> pure ScheduledJob From 42fb6d603a7bbd1a6cbc30c067a6968ba1783f2c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 25 Jun 2026 08:06:20 +0000 Subject: [PATCH 13/65] formatting --- .../src/Wire/ConversationSubsystem/Interpreter.hs | 2 +- .../src/Wire/ConversationSubsystem/Update.hs | 4 ++-- libs/wire-subsystems/src/Wire/JobStore.hs | 2 +- libs/wire-subsystems/src/Wire/JobStore/Postgres.hs | 2 +- libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs | 9 ++++----- .../unit/Wire/ConversationSubsystem/InterpreterSpec.hs | 4 ++-- services/background-worker/src/Wire/Effects.hs | 8 ++++---- services/galley/src/Galley/App.hs | 8 ++++---- 8 files changed, 19 insertions(+), 20 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index a1fe3d982bb..c2f5b8e2337 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -71,12 +71,12 @@ import Wire.FederationAPIAccess (FederationAPIAccess) import Wire.FederationSubsystem (FederationSubsystem) import Wire.FireAndForget (FireAndForget) import Wire.HashPassword (HashPassword) +import Wire.JobSubsystem (JobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem as NS import Wire.Options.Galley (GuestLinkTTLSeconds) import Wire.ProposalStore (ProposalStore) import Wire.RateLimit (RateLimit) -import Wire.JobSubsystem (JobSubsystem) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.TeamCollaboratorsSubsystem diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index ede92114a2e..2e0d5a73123 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -84,8 +84,8 @@ import Data.Misc import Data.Qualified import Data.Set qualified as Set import Data.Singletons -import Data.Vector qualified as V import Data.Time.Clock (addUTCTime, nominalDay) +import Data.Vector qualified as V import Galley.Types.Error import Imports hiding (forkIO) import Polysemy @@ -141,12 +141,12 @@ import Wire.FederationAPIAccess qualified as E import Wire.FederationSubsystem import Wire.FireAndForget import Wire.HashPassword as HashPassword +import Wire.JobSubsystem (JobSubsystem, scheduleAdminlessDeletionJob) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem import Wire.Options.Galley import Wire.ProposalStore (ProposalStore) import Wire.RateLimit -import Wire.JobSubsystem (JobSubsystem, scheduleAdminlessDeletionJob) import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) diff --git a/libs/wire-subsystems/src/Wire/JobStore.hs b/libs/wire-subsystems/src/Wire/JobStore.hs index 13072bad8b4..c3e1b85fadb 100644 --- a/libs/wire-subsystems/src/Wire/JobStore.hs +++ b/libs/wire-subsystems/src/Wire/JobStore.hs @@ -28,9 +28,9 @@ module Wire.JobStore ) where +import Data.Id (ConvId, ScheduledJobId, TeamId) import Imports import Polysemy -import Data.Id (ConvId, ScheduledJobId, TeamId) import Wire.API.Jobs data JobStore m a where diff --git a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs index 3cfc47612be..bdb2b946756 100644 --- a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs @@ -23,11 +23,11 @@ module Wire.JobStore.Postgres ) where +import Data.Id (ConvId, ScheduledJobId, TeamId) import Data.Int qualified as Int import Data.Time.Clock (UTCTime) import Data.UUID (UUID) import Data.Vector (Vector) -import Data.Id (ConvId, ScheduledJobId, TeamId) import Hasql.Pool import Hasql.Statement qualified as Hasql import Hasql.TH diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs index 58a779a99d9..e7228a2427c 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE RecordWildCards #-} - {-# LANGUAGE TypeFamilies #-} -- This file is part of the Wire Server implementation. @@ -28,20 +27,20 @@ module Wire.JobSubsystem.Recurring ) where +import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Arbiter.Migrations qualified as ArbiterMigrations import Arbiter.Worker qualified as ArbiterWorker -import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) -import Data.Kind (Type) -import Data.ByteString qualified as ByteString import Data.Aeson (FromJSON, ToJSON) +import Data.ByteString qualified as ByteString +import Data.Kind (Type) import Data.Proxy (Proxy (..)) import Data.Time.Clock (UTCTime, getCurrentTime) +import GHC.TypeLits (KnownSymbol) import Imports import System.Cron (CronSchedule, nextMatch) import System.Logger qualified as Log import UnliftIO.Async qualified as Async -import GHC.TypeLits (KnownSymbol) data RecurringJobRunnerConfig registry (payload :: Type) = RecurringJobRunnerConfig { recurringJobRunnerLogger :: Log.Logger, diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index a9800646291..0cc19d0f9a8 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -38,6 +38,7 @@ import Wire.API.Conversation.Role hiding (DeleteConversation) import Wire.API.Error.Galley (AdminlessConversation (..), GalleyError (..)) import Wire.API.Federation.Client (FederatorClient) import Wire.API.Federation.Error (FederationError) +import Wire.API.Jobs (ScheduledJob (..), ScheduledJobKind (AdminlessDeletion)) import Wire.API.Team.Feature (AllTeamFeatures, FeatureStatus (..), LockStatus (..), LockableFeature (..), PreventAdminlessGroupsConfig, npProject, npUpdate) import Wire.API.User (AccountStatus (..), User (..), UserType (..), userId) import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess (..)) @@ -48,13 +49,12 @@ import Wire.ConversationSubsystem.Update (removeMemberQualified) import Wire.ExternalAccess (ExternalAccess (..)) import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem (..)) import Wire.FederationAPIAccess (FederationAPIAccess (..)) +import Wire.JobSubsystem (JobSubsystem (..)) import Wire.MockInterpreters.Now (defaultTime, interpretNowConst) import Wire.MockInterpreters.TinyLog (noopLogger) import Wire.NotificationSubsystem (NotificationSubsystem (..)) import Wire.ProposalStore (ProposalStore (..)) import Wire.Sem.Random (Random (..)) -import Wire.JobSubsystem (JobSubsystem (..)) -import Wire.API.Jobs (ScheduledJob (..), ScheduledJobKind (AdminlessDeletion)) import Wire.StoredConversation import Wire.TeamSubsystem (TeamSubsystem (..)) diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index ca554addd0b..9f2324cf499 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -20,6 +20,7 @@ module Wire.Effects ) where +import Arbiter.Core qualified as ArbiterCore import Bilge qualified import Bilge.Retry import Cassandra (ClientState) @@ -34,7 +35,6 @@ import Data.Qualified import Data.Tagged (Tagged) import Data.Text qualified as T import Data.Text.Lazy qualified as TL -import Arbiter.Core qualified as ArbiterCore import Galley.Types.Error (InternalError, internalErrorDescription, legalHoldServiceUnavailable) import Hasql.Pool (UsageError) import Hasql.Pool.Extended qualified as HasqlPoolExt @@ -91,13 +91,13 @@ import Wire.GalleyAPIAccess.Rpc (interpretGalleyAPIAccessToRpc) import Wire.GundeckAPIAccess import Wire.HashPassword (HashPassword) import Wire.HashPassword.Interpreter (runHashPassword) -import Wire.LegalHoldStore (LegalHoldStore) -import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) -import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) import Wire.JobStore (JobStore) import Wire.JobStore.Postgres (interpretJobStoreToPostgres) import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) +import Wire.LegalHoldStore (LegalHoldStore) +import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) +import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) import Wire.NotificationSubsystem (NotificationSubsystem) import Wire.NotificationSubsystem.Interpreter import Wire.Options.Galley (GuestLinkTTLSeconds) diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index ffa31a35bce..930c39ecfc1 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -42,8 +42,8 @@ module Galley.App ) where -import Bilge hiding (Request, header, host, options, port, statusCode, statusMessage) import Arbiter.Core qualified as ArbiterCore +import Bilge hiding (Request, header, host, options, port, statusCode, statusMessage) import Cassandra hiding (Set) import Cassandra.Util (initCassandraForService) import Control.Error hiding (err) @@ -126,13 +126,13 @@ import Wire.FireAndForget import Wire.GundeckAPIAccess (GundeckAPIAccess, runGundeckAPIAccess) import Wire.HashPassword import Wire.HashPassword.Interpreter -import Wire.LegalHoldStore (LegalHoldStore) -import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) -import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) import Wire.JobStore (JobStore) import Wire.JobStore.Postgres (interpretJobStoreToPostgres) import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) +import Wire.LegalHoldStore (LegalHoldStore) +import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) +import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) import Wire.ListItems (ListItems) import Wire.ListItems.Team.Cassandra ( interpretInternalTeamListToCassandra, From 14ba21c1eeb508ba162527583b4a7f28890e98b3 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 25 Jun 2026 08:07:20 +0000 Subject: [PATCH 14/65] linting --- .../test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 0cc19d0f9a8..2b063624c7b 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -268,7 +268,7 @@ interpretJobSubsystem :: Sem r a interpretJobSubsystem = interpret $ \case - ScheduleAdminlessDeletionJob _ _ _ -> + ScheduleAdminlessDeletionJob {} -> pure ScheduledJob { scheduledJobId = Id UUID.nil, From 2292bcfaf0ca5118504361022f54bcf07173e44e Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 25 Jun 2026 08:30:17 +0000 Subject: [PATCH 15/65] single worker start effect, renaming --- .../src/Wire/JobStore/Postgres.hs | 16 ++-- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 16 ++-- .../src/Wire/JobSubsystem/Interpreter.hs | 13 +-- .../JobSubsystem/{Recurring.hs => Workers.hs} | 2 +- .../ConversationSubsystem/InterpreterSpec.hs | 2 +- libs/wire-subsystems/wire-subsystems.cabal | 2 +- .../Wire/BackgroundWorker/ScheduledJobs.hs | 82 ++++++++++--------- 7 files changed, 71 insertions(+), 62 deletions(-) rename libs/wire-subsystems/src/Wire/JobSubsystem/{Recurring.hs => Workers.hs} (99%) diff --git a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs index bdb2b946756..cf1eb1219a7 100644 --- a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs @@ -24,10 +24,6 @@ module Wire.JobStore.Postgres where import Data.Id (ConvId, ScheduledJobId, TeamId) -import Data.Int qualified as Int -import Data.Time.Clock (UTCTime) -import Data.UUID (UUID) -import Data.Vector (Vector) import Hasql.Pool import Hasql.Statement qualified as Hasql import Hasql.TH @@ -145,10 +141,10 @@ deleteJobImpl :: ScheduledJobId -> Sem r () deleteJobImpl jobId = - runStatement jobId deleteJob + runStatement jobId deleteJobStmt where - deleteJob :: Hasql.Statement ScheduledJobId () - deleteJob = + deleteJobStmt :: Hasql.Statement ScheduledJobId () + deleteJobStmt = lmapPG [resultlessStatement| DELETE FROM scheduled_jobs @@ -163,10 +159,10 @@ deleteJobsByTeamAndKindImpl :: ScheduledJobKind -> Sem r () deleteJobsByTeamAndKindImpl teamId kind = - runStatement (teamId, kind) deleteJobs + runStatement (teamId, kind) deleteJobsStmt where - deleteJobs :: Hasql.Statement (TeamId, ScheduledJobKind) () - deleteJobs = + deleteJobsStmt :: Hasql.Statement (TeamId, ScheduledJobKind) () + deleteJobsStmt = lmapPG [resultlessStatement| DELETE FROM scheduled_jobs diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index 9abb314a4b5..a06a4568c64 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -21,9 +21,9 @@ module Wire.JobSubsystem ( CleanupAction, JobSubsystemConfig (..), JobSubsystem (..), - JobRunnerConfig, + JobWorkersConfig (..), scheduleAdminlessDeletionJob, - startJobRunner, + startJobWorkers, ) where @@ -33,7 +33,10 @@ import Data.Time.Clock (UTCTime) import Imports import Polysemy import Wire.API.Jobs -import Wire.JobSubsystem.Recurring (RecurringJobRunnerConfig) +import Wire.JobSubsystem.Workers + ( OneOffJobRunnerConfig, + RecurringJobRunnerConfig, + ) type CleanupAction = IO () @@ -42,10 +45,13 @@ data JobSubsystemConfig = JobSubsystemConfig jobSubsystemSchemaName :: Text } -type JobRunnerConfig = RecurringJobRunnerConfig ScheduledJobsRegistry MeetingsCleanupJob +data JobWorkersConfig = JobWorkersConfig + { recurringJobRunnerConfig :: RecurringJobRunnerConfig ScheduledJobsRegistry MeetingsCleanupJob, + oneOffJobRunnerConfig :: OneOffJobRunnerConfig ScheduledJobsRegistry AdminlessDeletionJob + } data JobSubsystem m a where ScheduleAdminlessDeletionJob :: TeamId -> Maybe ConvId -> UTCTime -> JobSubsystem m ScheduledJob - StartJobRunner :: JobRunnerConfig -> JobSubsystem m CleanupAction + StartJobWorkers :: JobWorkersConfig -> JobSubsystem m CleanupAction makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index ab211f45b3c..6850385bccd 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -22,7 +22,7 @@ module Wire.JobSubsystem.Interpreter ( interpretJobSubsystem, - runJobRunner, + runJobWorkers, ) where @@ -36,10 +36,13 @@ import Polysemy import Wire.API.Jobs import Wire.JobStore qualified as JobStore import Wire.JobSubsystem -import Wire.JobSubsystem.Recurring (runRecurringJobRunner) +import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) -runJobRunner :: JobRunnerConfig -> IO CleanupAction -runJobRunner = runRecurringJobRunner (Proxy @ScheduledJobsRegistry) +runJobWorkers :: JobWorkersConfig -> IO CleanupAction +runJobWorkers JobWorkersConfig {..} = do + cleanupRecurring <- runRecurringJobRunner (Proxy @ScheduledJobsRegistry) recurringJobRunnerConfig + cleanupOneOff <- runOneOffJobRunner (Proxy @ScheduledJobsRegistry) oneOffJobRunnerConfig + pure $ cleanupRecurring >> cleanupOneOff interpretJobSubsystem :: ( Member JobStore.JobStore r, @@ -78,6 +81,6 @@ interpretJobSubsystem JobSubsystemConfig {..} sem = do void $ ArbiterCore.insertJob arbiterJob pure job - StartJobRunner cfg -> embed $ runRecurringJobRunner (Proxy @ScheduledJobsRegistry) cfg + StartJobWorkers cfg -> embed $ runJobWorkers cfg ) sem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs similarity index 99% rename from libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs rename to libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index e7228a2427c..10a7bf1bb95 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Recurring.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -19,7 +19,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.JobSubsystem.Recurring +module Wire.JobSubsystem.Workers ( RecurringJobRunnerConfig (..), OneOffJobRunnerConfig (..), runRecurringJobRunner, diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 2b063624c7b..0fe8e7149c9 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -277,7 +277,7 @@ interpretJobSubsystem = scheduledJobConversationId = Just (Id UUID.nil), scheduledJobScheduledFor = defaultTime } - StartJobRunner _ -> pure (pure ()) + StartJobWorkers _ -> pure (pure ()) interpretRandom :: Sem (Random ': r) a -> diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 7cab4a0f896..74f0e292019 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -376,7 +376,7 @@ library Wire.JobStore.Postgres Wire.JobSubsystem Wire.JobSubsystem.Interpreter - Wire.JobSubsystem.Recurring + Wire.JobSubsystem.Workers Wire.LegalHold Wire.LegalHoldStore Wire.LegalHoldStore.Cassandra diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 3e34edbb0eb..ad063ca5706 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -22,23 +22,25 @@ module Wire.BackgroundWorker.ScheduledJobs (startWorker) where import Arbiter.Core qualified as ArbiterCore -import Data.Proxy (Proxy (..)) +import Data.Id (RequestId (..)) import Imports import Wire.API.Jobs - ( AdminlessDeletionJob (..), - MeetingsCleanupJob (..), - ScheduledJobsRegistry, + ( MeetingsCleanupJob (..), adminlessDeletionQueueName, meetingsCleanupQueueName, ) import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) import Wire.BackgroundWorker.Util (CleanupAction) -import Wire.JobSubsystem.Recurring +import Wire.Effects (runBackgroundWorkerEffects) +import Wire.ExternalAccess.External (initExtEnv) +import Wire.JobSubsystem + ( JobWorkersConfig (..), + startJobWorkers, + ) +import Wire.JobSubsystem.Workers ( OneOffJobRunnerConfig (..), RecurringJobRunnerConfig (..), - runOneOffJobRunner, - runRecurringJobRunner, ) import Wire.MeetingsCleanupWorker ( CleanupConfig (..), @@ -48,6 +50,7 @@ import Wire.MeetingsCleanupWorker startWorker :: MeetingsCleanupConfig -> AppT IO CleanupAction startWorker config = do env <- ask + extEnv <- liftIO $ initExtEnv True let cleanupConfig = CleanupConfig { retentionHours = config.cleanOlderThanHours, @@ -58,37 +61,38 @@ startWorker config = do { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate meetingsCleanupQueueName), ArbiterCore.notVisibleUntil = Just scheduledFor } - cleanupRecurring <- - liftIO $ - runRecurringJobRunner @ScheduledJobsRegistry @MeetingsCleanupJob - (Proxy @ScheduledJobsRegistry) - RecurringJobRunnerConfig - { recurringJobRunnerLogger = env.logger, - recurringJobRunnerSchedule = config.schedule, - recurringJobRunnerArbiterConnStr = env.arbiterConnStr, - recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, - recurringJobRunnerWorkerThreads = 1, - recurringJobRunnerEnqueueAt = \scheduledFor -> - void $ ArbiterCore.insertJob (jobWrite scheduledFor), - recurringJobRunnerRunJob = - runAppT env $ - runCleanupOldMeetings cleanupConfig, - recurringJobRunnerJobName = "meetings-cleanup", - recurringJobRunnerQueueName = meetingsCleanupQueueName + workersConfig = + JobWorkersConfig + { recurringJobRunnerConfig = + RecurringJobRunnerConfig + { recurringJobRunnerLogger = env.logger, + recurringJobRunnerSchedule = config.schedule, + recurringJobRunnerArbiterConnStr = env.arbiterConnStr, + recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + recurringJobRunnerWorkerThreads = 1, + recurringJobRunnerEnqueueAt = \scheduledFor -> + void $ ArbiterCore.insertJob (jobWrite scheduledFor), + recurringJobRunnerRunJob = + runAppT env $ + runCleanupOldMeetings cleanupConfig, + recurringJobRunnerJobName = "meetings-cleanup", + recurringJobRunnerQueueName = meetingsCleanupQueueName + }, + oneOffJobRunnerConfig = + OneOffJobRunnerConfig + { oneOffJobRunnerLogger = env.logger, + oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, + oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + oneOffJobRunnerWorkerThreads = 1, + oneOffJobRunnerRunJob = + runAppT env $ + pure (), + oneOffJobRunnerJobName = "adminless-deletion", + oneOffJobRunnerQueueName = adminlessDeletionQueueName + } } - cleanupDeletion <- + result <- liftIO $ - runOneOffJobRunner @ScheduledJobsRegistry @AdminlessDeletionJob - (Proxy @ScheduledJobsRegistry) - OneOffJobRunnerConfig - { oneOffJobRunnerLogger = env.logger, - oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, - oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, - oneOffJobRunnerWorkerThreads = 1, - oneOffJobRunnerRunJob = - runAppT env $ - pure (), - oneOffJobRunnerJobName = "adminless-deletion", - oneOffJobRunnerQueueName = adminlessDeletionQueueName - } - pure $ cleanupRecurring >> cleanupDeletion + runBackgroundWorkerEffects env extEnv (RequestId "scheduled-jobs") Nothing $ + startJobWorkers workersConfig + either (liftIO . fail . show) pure result From 60952118b1d4da18e9c15e632fe2ab2e4eb3b081 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 25 Jun 2026 09:37:01 +0000 Subject: [PATCH 16/65] clean up --- .../src/Wire/ConversationSubsystem/Update.hs | 14 ++-- .../src/Wire/JobSubsystem/Interpreter.hs | 68 +++++++++++-------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index 2e0d5a73123..fb95126c1ee 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -1201,11 +1201,6 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do conv <- getConversationWithError lcnv for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid - let scheduleDeletion = do - now <- Now.get - let scheduledFor = addUTCTime (fromIntegral feature.config.deletionTimeout * nominalDay) now - void $ - scheduleAdminlessDeletionJob tid (Just (qUnqualified (tUntagged lcnv))) scheduledFor when (feature.status == FeatureStatusEnabled && isLeavingLastConversationAdmin (qUnqualified victim) conv) $ do eligibleMembers <- eligibleAdminFallbackMembers lcnv (qUnqualified victim) conv case (responseMode, eligibleMembers) of @@ -1218,9 +1213,9 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do (RemoveMemberEligibleMembersResponse, _ : _) -> throw $ AdminlessConversation (fmap fst eligibleMembers) (RemoveMemberLegacyResponse, []) -> - scheduleDeletion + scheduleDeletion tid feature (RemoveMemberEligibleMembersResponse, []) -> - scheduleDeletion + scheduleDeletion tid feature where -- Use eight random bytes and fold them into a big-endian Word64. This keeps -- the helper small, deterministic under tests, and free of extra Random API. @@ -1228,6 +1223,11 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do randomWord64 = BS.foldl' step 0 <$> Random.bytes 8 where step acc byte = shiftL acc 8 .|. fromIntegral byte + scheduleDeletion tid feature = do + now <- Now.get + let scheduledFor = addUTCTime (fromIntegral feature.config.deletionTimeout * nominalDay) now + void $ + scheduleAdminlessDeletionJob tid (Just (qUnqualified (tUntagged lcnv))) scheduledFor isLeavingLastConversationAdmin :: UserId -> StoredConversation -> Bool isLeavingLastConversationAdmin leavingUser conv = diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 6850385bccd..97bc9194d91 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -30,12 +30,13 @@ import Arbiter.Core qualified as ArbiterCore import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Data.Id import Data.Proxy (Proxy (..)) +import Data.Time import Data.UUID.V4 qualified as UUID import Imports import Polysemy import Wire.API.Jobs import Wire.JobStore qualified as JobStore -import Wire.JobSubsystem +import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkersConfig (..)) import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) runJobWorkers :: JobWorkersConfig -> IO CleanupAction @@ -49,38 +50,45 @@ interpretJobSubsystem :: Member (Embed IO) r ) => JobSubsystemConfig -> - Sem (JobSubsystem : r) a -> - Sem r a -interpretJobSubsystem JobSubsystemConfig {..} sem = do + InterpreterFor JobSubsystem r +interpretJobSubsystem conf = + interpret + \case + ScheduleAdminlessDeletionJob tid cid scheduledFor -> scheduleAdminlessDeletionJob conf tid cid scheduledFor + StartJobWorkers cfg -> embed $ runJobWorkers cfg + +scheduleAdminlessDeletionJob :: + forall r. + (Member (Embed IO) r, Member JobStore.JobStore r) => + JobSubsystemConfig -> + TeamId -> + Maybe ConvId -> + UTCTime -> + Sem r ScheduledJob +scheduleAdminlessDeletionJob JobSubsystemConfig {..} teamId convId scheduledFor = do arbiterEnv <- embed $ ArbiterHasql.createHasqlEnv (Proxy @ScheduledJobsRegistry) jobSubsystemArbiterConnStr jobSubsystemSchemaName - interpret - ( \case - ScheduleAdminlessDeletionJob teamId conversationId scheduledFor -> do - jobId <- embed $ Id <$> UUID.nextRandom - let job = - ScheduledJob - { scheduledJobId = jobId, - scheduledJobKind = AdminlessDeletion, - scheduledJobTeamId = teamId, - scheduledJobConversationId = conversationId, - scheduledJobScheduledFor = scheduledFor - } - arbiterJob = - (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName AdminlessDeletionJob) - { ArbiterCore.notVisibleUntil = Just scheduledFor - } - JobStore.createJob job - embed $ - void $ - ArbiterHasql.runHasqlDb arbiterEnv $ - void $ - ArbiterCore.insertJob arbiterJob - pure job - StartJobWorkers cfg -> embed $ runJobWorkers cfg - ) - sem + jobId <- embed $ Id <$> UUID.nextRandom + let job = + ScheduledJob + { scheduledJobId = jobId, + scheduledJobKind = AdminlessDeletion, + scheduledJobTeamId = teamId, + scheduledJobConversationId = convId, + scheduledJobScheduledFor = scheduledFor + } + arbiterJob = + (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName AdminlessDeletionJob) + { ArbiterCore.notVisibleUntil = Just scheduledFor + } + JobStore.createJob job + embed $ + void $ + ArbiterHasql.runHasqlDb arbiterEnv $ + void $ + ArbiterCore.insertJob arbiterJob + pure job From 07f2d6138b68cc664fcf620b063f8a557b09ba5a Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 25 Jun 2026 16:01:24 +0000 Subject: [PATCH 17/65] reminder and deletion jobs --- libs/wire-api/src/Wire/API/Jobs.hs | 45 +++++++-- .../src/Wire/ConversationSubsystem.hs | 4 + .../src/Wire/ConversationSubsystem/Action.hs | 54 ++++++----- .../Wire/ConversationSubsystem/Interpreter.hs | 2 + .../src/Wire/ConversationSubsystem/Update.hs | 94 ++++++++++++++++--- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 22 +++-- .../src/Wire/JobSubsystem/Interpreter.hs | 65 ++++++++++--- .../src/Wire/JobSubsystem/Workers.hs | 17 ++-- .../ConversationSubsystem/InterpreterSpec.hs | 13 ++- .../background-worker/background-worker.cabal | 1 + .../src/Wire/AdminlessJobsWorker.hs | 55 +++++++++++ .../Wire/BackgroundWorker/ScheduledJobs.hs | 46 ++++----- 12 files changed, 322 insertions(+), 96 deletions(-) create mode 100644 services/background-worker/src/Wire/AdminlessJobsWorker.hs diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index cc0292310cf..afcb3ad5ce9 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -25,6 +25,7 @@ module Wire.API.Jobs where import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) import Data.Id import Data.Int qualified as Int +import Data.Schema import Data.Time.Clock (UTCTime) import Data.UUID (UUID) import Imports @@ -38,6 +39,10 @@ meetingsCleanupQueueName = "meetings_cleanup_jobs" adminlessDeletionQueueName :: Text adminlessDeletionQueueName = "adminless_deletion_jobs" +-- | Shared queue name for the adminless reminder job. +adminlessReminderQueueName :: Text +adminlessReminderQueueName = "adminless_reminder_jobs" + -- | Empty payload because the schedule itself carries all execution context. data MeetingsCleanupJob = MeetingsCleanupJob deriving stock (Eq, Generic, Show) @@ -49,16 +54,39 @@ instance FromJSON MeetingsCleanupJob where parseJSON Null = pure MeetingsCleanupJob parseJSON _ = fail "MeetingsCleanupJob expects null" --- | Empty payload for adminless deletions. +-- | Payload for adminless deletions. data AdminlessDeletionJob = AdminlessDeletionJob + { adminlessDeletionJobTeamId :: TeamId, + adminlessDeletionJobConversationId :: ConvId, + adminlessDeletionJobOrigUserId :: UserId + } deriving stock (Eq, Generic, Show) + deriving (ToJSON, FromJSON) via (Schema AdminlessDeletionJob) + +instance ToSchema AdminlessDeletionJob where + schema = + object $ + AdminlessDeletionJob + <$> (.adminlessDeletionJobTeamId) .= field "team_id" schema + <*> (.adminlessDeletionJobConversationId) .= field "conversation_id" schema + <*> (.adminlessDeletionJobOrigUserId) .= field "orig_user_id" schema + +-- | Payload for adminless reminders. +data AdminlessReminderJob = AdminlessReminderJob + { adminlessReminderJobTeamId :: TeamId, + adminlessReminderJobConversationId :: ConvId, + adminlessReminderJobOrigUserId :: UserId + } + deriving stock (Eq, Generic, Show) + deriving (ToJSON, FromJSON) via (Schema AdminlessReminderJob) -instance ToJSON AdminlessDeletionJob where - toJSON AdminlessDeletionJob = Null - -instance FromJSON AdminlessDeletionJob where - parseJSON Null = pure AdminlessDeletionJob - parseJSON _ = fail "AdminlessDeletionJob expects null" +instance ToSchema AdminlessReminderJob where + schema = + object $ + AdminlessReminderJob + <$> (.adminlessReminderJobTeamId) .= field "team_id" schema + <*> (.adminlessReminderJobConversationId) .= field "conversation_id" schema + <*> (.adminlessReminderJobOrigUserId) .= field "orig_user_id" schema -- | The generic scheduled-job families we currently need to persist. data ScheduledJobKind @@ -134,5 +162,6 @@ instance PostgresUnmarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) Schedul -- | Registry for the scheduled jobs we expose via Arbiter. type ScheduledJobsRegistry = '[ '("meetings_cleanup_jobs", MeetingsCleanupJob), - '("adminless_deletion_jobs", AdminlessDeletionJob) + '("adminless_deletion_jobs", AdminlessDeletionJob), + '("adminless_reminder_jobs", AdminlessReminderJob) ] diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index d1991691258..61fe088f0e9 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -341,6 +341,10 @@ data ConversationSubsystem m a where InternalDeleteLocalConversation :: Local ConvId -> ConversationSubsystem m () + InternalDeleteLocalAdminlessGroup :: + Local UserId -> + Local ConvId -> + ConversationSubsystem m () GetMLSPublicKeys :: Maybe MLSPublicKeyFormat -> ConversationSubsystem m (MLSKeysByPurpose (MLSKeys SomeKey)) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs index a6e6921db12..fe70e496b35 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs @@ -51,6 +51,7 @@ module Wire.ConversationSubsystem.Action addLocalUsersToRemoteConv, ConversationUpdate, ensureAllowed, + removeConversation ) where @@ -121,6 +122,7 @@ import Wire.ConversationSubsystem.Action.Leave import Wire.ConversationSubsystem.Action.Notify import Wire.ConversationSubsystem.Action.Reset import Wire.ConversationSubsystem.MLS.Conversation +import Wire.ConversationStore (ConversationStore) import Wire.ConversationSubsystem.MLS.Migration import Wire.ConversationSubsystem.MLS.Removal import Wire.ConversationSubsystem.Util @@ -375,28 +377,7 @@ instance IsConversationAction 'ConversationDeleteTag where ] performAction lconv _qusr _conId () = do - let lcnv = fmap (.id_) lconv - storedConv = tUnqualified lconv - let deleteGroup groupId = do - E.removeAllMLSClients groupId - E.removeAllHistoryClients groupId - E.deleteAllProposals groupId - - let cid = storedConv.id_ - for_ (storedConv & mlsMetadata <&> cnvmlsGroupId . fst) $ \gidParent -> do - sconvs <- E.listSubConversations cid - for_ (Map.assocs sconvs) $ \(subid, mlsData) -> do - let gidSub = cnvmlsGroupId mlsData - E.deleteSubConversation cid subid - deleteGroup gidSub - deleteGroup gidParent - - key <- E.makeKey (CodeReferentConv (tUnqualified lcnv)) - E.deleteCode key - case convTeam storedConv of - Nothing -> E.deleteConversation (tUnqualified lcnv) - Just tid -> E.deleteTeamConversation tid (tUnqualified lcnv) - + removeConversation lconv pure $ mkPerformActionResult () ensureAllowed _ _action _conv (ActorContext Nothing (Just _tm)) = @@ -409,6 +390,35 @@ instance IsConversationAction 'ConversationDeleteTag where allowChannelManagePermission = True +removeConversation :: + ( Member ConversationStore r, + Member ProposalStore r, + Member CodeStore r + ) => + Local StoredConversation -> Sem r () +removeConversation lconv = do + let lcnv = fmap (.id_) lconv + storedConv = tUnqualified lconv + let deleteGroup groupId = do + E.removeAllMLSClients groupId + E.removeAllHistoryClients groupId + E.deleteAllProposals groupId + + let cid = storedConv.id_ + for_ (storedConv & mlsMetadata <&> cnvmlsGroupId . fst) $ \gidParent -> do + sconvs <- E.listSubConversations cid + for_ (Map.assocs sconvs) $ \(subid, mlsData) -> do + let gidSub = cnvmlsGroupId mlsData + E.deleteSubConversation cid subid + deleteGroup gidSub + deleteGroup gidParent + + key <- E.makeKey (tUnqualified lcnv) + E.deleteCode key + case convTeam storedConv of + Nothing -> E.deleteConversation (tUnqualified lcnv) + Just tid -> E.deleteTeamConversation tid (tUnqualified lcnv) + instance IsConversationAction 'ConversationRenameTag where type HasConversationActionEffects 'ConversationRenameTag r = diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index c2f5b8e2337..1c04ce2101d 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -213,6 +213,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Update.deleteLocalConversation lusr con lcnv InternalDeleteLocalConversation lcnv -> mapErrors $ Action.updateLocalConversationDeleteUnchecked lcnv + InternalDeleteLocalAdminlessGroup lusr lcnv -> + mapErrors $ Update.adminlessAutopromoteOrDelete lusr lcnv GetMLSPublicKeys fmt -> mapErrors $ MLS.getMLSPublicKeys fmt ResetMLSConversation lusr reset -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index fb95126c1ee..19c8a9c7282 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -42,6 +42,7 @@ module Wire.ConversationSubsystem.Update updateConversationProtocolWithLocalUser, updateLocalStateOfRemoteConv, updateCellsState, + adminlessAutopromoteOrDelete, -- * Managing Members addQualifiedMembersUnqualified, @@ -141,7 +142,7 @@ import Wire.FederationAPIAccess qualified as E import Wire.FederationSubsystem import Wire.FireAndForget import Wire.HashPassword as HashPassword -import Wire.JobSubsystem (JobSubsystem, scheduleAdminlessDeletionJob) +import Wire.JobSubsystem (JobSubsystem, scheduleAdminlessDeletionJob, scheduleAdminlessReminderJob) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem import Wire.Options.Galley @@ -1202,7 +1203,7 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid when (feature.status == FeatureStatusEnabled && isLeavingLastConversationAdmin (qUnqualified victim) conv) $ do - eligibleMembers <- eligibleAdminFallbackMembers lcnv (qUnqualified victim) conv + eligibleMembers <- eligibleAdminFallbackMembers lcnv (Just (qUnqualified victim)) conv case (responseMode, eligibleMembers) of (RemoveMemberLegacyResponse, x : xs) -> do seed <- randomWord64 @@ -1217,17 +1218,80 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do (RemoveMemberEligibleMembersResponse, []) -> scheduleDeletion tid feature where - -- Use eight random bytes and fold them into a big-endian Word64. This keeps - -- the helper small, deterministic under tests, and free of extra Random API. - randomWord64 :: (Member Random r) => Sem r Word64 - randomWord64 = BS.foldl' step 0 <$> Random.bytes 8 - where - step acc byte = shiftL acc 8 .|. fromIntegral byte scheduleDeletion tid feature = do - now <- Now.get - let scheduledFor = addUTCTime (fromIntegral feature.config.deletionTimeout * nominalDay) now + now <- Now.get + let scheduledFor = addUTCTime (fromIntegral feature.config.deletionTimeout * nominalDay) now + void $ scheduleAdminlessDeletionJob lusr tid (qUnqualified (tUntagged lcnv)) scheduledFor + for_ feature.config.reminderTimeouts $ + scheduleReminder now tid feature.config.deletionTimeout + + scheduleReminder now tid deletionTimeout reminderTimeout = do + when (reminderTimeout < deletionTimeout) do + let reminderAt = addUTCTime (fromIntegral (deletionTimeout - reminderTimeout) * nominalDay) now + void $ scheduleAdminlessReminderJob lusr tid (qUnqualified (tUntagged lcnv)) reminderAt + +adminlessAutopromoteOrDelete :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member ProposalStore r, + Member CodeStore r + ) => + Local UserId -> + Local ConvId -> + Sem r () +adminlessAutopromoteOrDelete lusr lcnv = do + -- the local user is probably not a conversation member, it could be e.g. the user that had previously left + conv <- getConversationWithError lcnv + for_ conv.metadata.cnvmTeam $ \tid -> do + (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid + let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers + when (feature.status == FeatureStatusEnabled && not adminExists) $ do + eligibleMembers <- eligibleAdminFallbackMembers lcnv Nothing conv + case eligibleMembers of + x : xs -> do + seed <- randomWord64 + let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) + update = (OtherMemberUpdate (Just roleNameWireAdmin)) + for_ autopromotionCandidates $ \candidate -> do + E.setOtherMember lcnv candidate update + void $ + sendConversationActionNotifications + (sing @'ConversationMemberUpdateTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + (ConversationMemberUpdate (tUntagged lusr) update) + def + [] -> do + removeConversation (qualifyAs lcnv conv) void $ - scheduleAdminlessDeletionJob tid (Just (qUnqualified (tUntagged lcnv))) scheduledFor + sendConversationActionNotifications + (sing @'ConversationDeleteTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + () + def + + +-- Use eight random bytes and fold them into a big-endian Word64. This keeps +-- the helper small, deterministic under tests, and free of extra Random API. +randomWord64 :: (Member Random r) => Sem r Word64 +randomWord64 = BS.foldl' step 0 <$> Random.bytes 8 + where + step acc byte = shiftL acc 8 .|. fromIntegral byte isLeavingLastConversationAdmin :: UserId -> StoredConversation -> Bool isLeavingLastConversationAdmin leavingUser conv = @@ -1242,16 +1306,16 @@ isLeavingLastConversationAdmin leavingUser conv = eligibleAdminFallbackMembers :: (Member BrigAPIAccess r) => Local ConvId -> - UserId -> + Maybe UserId -> StoredConversation -> Sem r [(Qualified UserId, User.Name)] -eligibleAdminFallbackMembers lcnv leavingUser conv = do - users <- Brig.getUsers (map (.id_) (filter ((/= leavingUser) . (.id_)) conv.localMembers)) +eligibleAdminFallbackMembers lcnv mLeavingUser conv = do + users <- Brig.getUsers (map (.id_) (filter ((/= mLeavingUser) . Just . (.id_)) conv.localMembers)) let usersById = Map.fromList [(User.userId u, u) | u <- users] pure [ (tUntagged (qualifyAs lcnv member.id_), u.userDisplayName) | member <- conv.localMembers, - member.id_ /= leavingUser, + Just member.id_ /= mLeavingUser, Just u <- [Map.lookup member.id_ usersById], isEligibleUser u ] diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index a06a4568c64..8996a7f70ea 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -21,22 +21,22 @@ module Wire.JobSubsystem ( CleanupAction, JobSubsystemConfig (..), JobSubsystem (..), + JobWorkerHandlers (..), JobWorkersConfig (..), scheduleAdminlessDeletionJob, + scheduleAdminlessReminderJob, startJobWorkers, ) where import Data.ByteString qualified as ByteString -import Data.Id (ConvId, TeamId) +import Data.Id +import Data.Qualified import Data.Time.Clock (UTCTime) import Imports import Polysemy import Wire.API.Jobs import Wire.JobSubsystem.Workers - ( OneOffJobRunnerConfig, - RecurringJobRunnerConfig, - ) type CleanupAction = IO () @@ -47,11 +47,19 @@ data JobSubsystemConfig = JobSubsystemConfig data JobWorkersConfig = JobWorkersConfig { recurringJobRunnerConfig :: RecurringJobRunnerConfig ScheduledJobsRegistry MeetingsCleanupJob, - oneOffJobRunnerConfig :: OneOffJobRunnerConfig ScheduledJobsRegistry AdminlessDeletionJob + adminlessDeletionJobRunnerConfig :: OneOffJobRunnerConfig ScheduledJobsRegistry AdminlessDeletionJob, + adminlessReminderJobRunnerConfig :: OneOffJobRunnerConfig ScheduledJobsRegistry AdminlessReminderJob + } + +data JobWorkerHandlers = JobWorkerHandlers + { recurringJobRunnerRunJob :: MeetingsCleanupJob -> IO (), + adminlessDeletionJobRunnerRunJob :: AdminlessDeletionJob -> IO (), + adminlessReminderJobRunnerRunJob :: AdminlessReminderJob -> IO () } data JobSubsystem m a where - ScheduleAdminlessDeletionJob :: TeamId -> Maybe ConvId -> UTCTime -> JobSubsystem m ScheduledJob - StartJobWorkers :: JobWorkersConfig -> JobSubsystem m CleanupAction + ScheduleAdminlessDeletionJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m ScheduledJob + ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m ScheduledJob + StartJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> JobSubsystem m CleanupAction makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 97bc9194d91..12a2de8cb86 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -30,20 +30,22 @@ import Arbiter.Core qualified as ArbiterCore import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Data.Id import Data.Proxy (Proxy (..)) +import Data.Qualified import Data.Time import Data.UUID.V4 qualified as UUID import Imports import Polysemy import Wire.API.Jobs import Wire.JobStore qualified as JobStore -import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkersConfig (..)) +import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkerHandlers (..), JobWorkersConfig (..)) import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) -runJobWorkers :: JobWorkersConfig -> IO CleanupAction -runJobWorkers JobWorkersConfig {..} = do - cleanupRecurring <- runRecurringJobRunner (Proxy @ScheduledJobsRegistry) recurringJobRunnerConfig - cleanupOneOff <- runOneOffJobRunner (Proxy @ScheduledJobsRegistry) oneOffJobRunnerConfig - pure $ cleanupRecurring >> cleanupOneOff +runJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> IO CleanupAction +runJobWorkers JobWorkersConfig {..} JobWorkerHandlers {..} = do + cleanupRecurring <- runRecurringJobRunner (Proxy @ScheduledJobsRegistry) recurringJobRunnerConfig recurringJobRunnerRunJob + cleanupDeletion <- runOneOffJobRunner (Proxy @ScheduledJobsRegistry) adminlessDeletionJobRunnerConfig adminlessDeletionJobRunnerRunJob + cleanupReminder <- runOneOffJobRunner (Proxy @ScheduledJobsRegistry) adminlessReminderJobRunnerConfig adminlessReminderJobRunnerRunJob + pure $ cleanupRecurring >> cleanupDeletion >> cleanupReminder interpretJobSubsystem :: ( Member JobStore.JobStore r, @@ -54,18 +56,20 @@ interpretJobSubsystem :: interpretJobSubsystem conf = interpret \case - ScheduleAdminlessDeletionJob tid cid scheduledFor -> scheduleAdminlessDeletionJob conf tid cid scheduledFor - StartJobWorkers cfg -> embed $ runJobWorkers cfg + ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor + ScheduleAdminlessReminderJob lusr tid cid scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid scheduledFor + StartJobWorkers cfg handlers -> embed $ runJobWorkers cfg handlers scheduleAdminlessDeletionJob :: forall r. (Member (Embed IO) r, Member JobStore.JobStore r) => JobSubsystemConfig -> + Local UserId -> TeamId -> - Maybe ConvId -> + ConvId -> UTCTime -> Sem r ScheduledJob -scheduleAdminlessDeletionJob JobSubsystemConfig {..} teamId convId scheduledFor = do +scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do arbiterEnv <- embed $ ArbiterHasql.createHasqlEnv @@ -78,11 +82,48 @@ scheduleAdminlessDeletionJob JobSubsystemConfig {..} teamId convId scheduledFor { scheduledJobId = jobId, scheduledJobKind = AdminlessDeletion, scheduledJobTeamId = teamId, - scheduledJobConversationId = convId, + scheduledJobConversationId = Just convId, scheduledJobScheduledFor = scheduledFor } arbiterJob = - (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName AdminlessDeletionJob) + (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName (AdminlessDeletionJob teamId convId (tUnqualified lusr))) + { ArbiterCore.notVisibleUntil = Just scheduledFor + } + JobStore.createJob job + embed $ + void $ + ArbiterHasql.runHasqlDb arbiterEnv $ + void $ + ArbiterCore.insertJob arbiterJob + pure job + +scheduleAdminlessReminderJob :: + forall r. + (Member (Embed IO) r, Member JobStore.JobStore r) => + JobSubsystemConfig -> + Local UserId -> + TeamId -> + ConvId -> + UTCTime -> + Sem r ScheduledJob +scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do + arbiterEnv <- + embed $ + ArbiterHasql.createHasqlEnv + (Proxy @ScheduledJobsRegistry) + jobSubsystemArbiterConnStr + jobSubsystemSchemaName + jobId <- embed $ Id <$> UUID.nextRandom + let job = + ScheduledJob + { scheduledJobId = jobId, + scheduledJobKind = AdminlessReminder, + scheduledJobTeamId = teamId, + scheduledJobConversationId = Just convId, + scheduledJobScheduledFor = scheduledFor + } + arbiterJob = + (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr))) { ArbiterCore.notVisibleUntil = Just scheduledFor } JobStore.createJob job diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 10a7bf1bb95..1b0caf42b6a 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -27,6 +27,7 @@ module Wire.JobSubsystem.Workers ) where +import Arbiter.Core qualified as ArbiterCore import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Arbiter.Migrations qualified as ArbiterMigrations @@ -49,7 +50,6 @@ data RecurringJobRunnerConfig registry (payload :: Type) = RecurringJobRunnerCon recurringJobRunnerSchemaName :: Text, recurringJobRunnerWorkerThreads :: Int, recurringJobRunnerEnqueueAt :: UTCTime -> ArbiterHasql.HasqlDb registry IO (), - recurringJobRunnerRunJob :: IO (), recurringJobRunnerJobName :: Text, recurringJobRunnerQueueName :: Text } @@ -59,7 +59,6 @@ data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig oneOffJobRunnerArbiterConnStr :: ByteString.ByteString, oneOffJobRunnerSchemaName :: Text, oneOffJobRunnerWorkerThreads :: Int, - oneOffJobRunnerRunJob :: IO (), oneOffJobRunnerJobName :: Text, oneOffJobRunnerQueueName :: Text } @@ -73,8 +72,9 @@ runRecurringJobRunner :: ) => Proxy registry -> RecurringJobRunnerConfig registry payload -> + (payload -> IO ()) -> IO (IO ()) -runRecurringJobRunner registry RecurringJobRunnerConfig {..} = do +runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do Log.info recurringJobRunnerLogger $ Log.msg (Log.val "Starting scheduled jobs worker") . Log.field "job_name" recurringJobRunnerJobName @@ -110,13 +110,13 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} = do enqueueNextRun nextRun pure True - workerHandler _ _ = + workerHandler _conn job = liftIO $ do Log.info recurringJobRunnerLogger $ Log.msg (Log.val "Running scheduled job") . Log.field "job_name" recurringJobRunnerJobName . Log.field "queue_name" recurringJobRunnerQueueName - recurringJobRunnerRunJob + runJob (ArbiterCore.payload job) now <- getCurrentTime void $ enqueueIfScheduled now @@ -161,8 +161,9 @@ runOneOffJobRunner :: ) => Proxy registry -> OneOffJobRunnerConfig registry payload -> + (payload -> IO ()) -> IO (IO ()) -runOneOffJobRunner registry OneOffJobRunnerConfig {..} = do +runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do Log.info oneOffJobRunnerLogger $ Log.msg (Log.val "Starting one-off jobs worker") . Log.field "job_name" oneOffJobRunnerJobName @@ -174,13 +175,13 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} = do oneOffJobRunnerArbiterConnStr oneOffJobRunnerSchemaName - let workerHandler _ _ = + let workerHandler _conn job = liftIO $ do Log.info oneOffJobRunnerLogger $ Log.msg (Log.val "Running one-off job") . Log.field "job_name" oneOffJobRunnerJobName . Log.field "queue_name" oneOffJobRunnerQueueName - oneOffJobRunnerRunJob + runJob (ArbiterCore.payload job) void $ ArbiterMigrations.runMigrationsForRegistry diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 0fe8e7149c9..886d7e8e788 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -38,7 +38,7 @@ import Wire.API.Conversation.Role hiding (DeleteConversation) import Wire.API.Error.Galley (AdminlessConversation (..), GalleyError (..)) import Wire.API.Federation.Client (FederatorClient) import Wire.API.Federation.Error (FederationError) -import Wire.API.Jobs (ScheduledJob (..), ScheduledJobKind (AdminlessDeletion)) +import Wire.API.Jobs (ScheduledJob (..), ScheduledJobKind (AdminlessDeletion, AdminlessReminder)) import Wire.API.Team.Feature (AllTeamFeatures, FeatureStatus (..), LockStatus (..), LockableFeature (..), PreventAdminlessGroupsConfig, npProject, npUpdate) import Wire.API.User (AccountStatus (..), User (..), UserType (..), userId) import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess (..)) @@ -277,7 +277,16 @@ interpretJobSubsystem = scheduledJobConversationId = Just (Id UUID.nil), scheduledJobScheduledFor = defaultTime } - StartJobWorkers _ -> pure (pure ()) + ScheduleAdminlessReminderJob {} -> + pure + ScheduledJob + { scheduledJobId = Id UUID.nil, + scheduledJobKind = AdminlessReminder, + scheduledJobTeamId = Id UUID.nil, + scheduledJobConversationId = Just (Id UUID.nil), + scheduledJobScheduledFor = defaultTime + } + StartJobWorkers _ _ -> pure (pure ()) interpretRandom :: Sem (Random ': r) a -> diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index bfa55214611..ffaadd8cfbd 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -12,6 +12,7 @@ build-type: Simple library -- cabal-fmt: expand src exposed-modules: + Wire.AdminlessJobsWorker Wire.BackendNotificationPusher Wire.BackgroundWorker Wire.BackgroundWorker.Env diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs new file mode 100644 index 00000000000..4fca5b8686e --- /dev/null +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -0,0 +1,55 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.AdminlessJobsWorker + ( runAdminlessDeletionJob, + runAdminlessReminderJob, + ) +where + +import Data.Id (RequestId (..)) +import Data.Qualified +import Imports +import System.Logger qualified as Log +import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..)) +import Wire.BackgroundWorker.Env (AppT, Env (..)) +import Wire.ConversationSubsystem +import Wire.Effects (runBackgroundWorkerEffects) +import Wire.ExternalAccess.External (ExtEnv) + +runAdminlessDeletionJob :: ExtEnv -> AdminlessDeletionJob -> AppT IO () +runAdminlessDeletionJob extEnv job = do + env <- ask + let loc :: a -> Local a + loc a = toLocalUnsafe env.federationDomain a + Log.info env.logger $ + Log.msg (Log.val "Running adminless deletion job") + . Log.field "team_id" (show (adminlessDeletionJobTeamId job)) + . Log.field "conversation_id" (show (adminlessDeletionJobConversationId job)) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv (RequestId "adminless-deletion") Nothing $ + internalDeleteLocalAdminlessGroup (loc job.adminlessDeletionJobOrigUserId) (loc job.adminlessDeletionJobConversationId) + either (liftIO . fail . show) pure result + +runAdminlessReminderJob :: AdminlessReminderJob -> AppT IO () +runAdminlessReminderJob job = do + env <- ask + Log.info env.logger $ + Log.msg (Log.val "Running adminless reminder job") + . Log.field "team_id" (show (adminlessReminderJobTeamId job)) + . Log.field "conversation_id" (show (adminlessReminderJobConversationId job)) diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index ad063ca5706..8e0094ed5ca 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -25,27 +25,14 @@ import Arbiter.Core qualified as ArbiterCore import Data.Id (RequestId (..)) import Imports import Wire.API.Jobs - ( MeetingsCleanupJob (..), - adminlessDeletionQueueName, - meetingsCleanupQueueName, - ) +import Wire.AdminlessJobsWorker (runAdminlessDeletionJob, runAdminlessReminderJob) import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) -import Wire.BackgroundWorker.Util (CleanupAction) import Wire.Effects (runBackgroundWorkerEffects) import Wire.ExternalAccess.External (initExtEnv) import Wire.JobSubsystem - ( JobWorkersConfig (..), - startJobWorkers, - ) import Wire.JobSubsystem.Workers - ( OneOffJobRunnerConfig (..), - RecurringJobRunnerConfig (..), - ) import Wire.MeetingsCleanupWorker - ( CleanupConfig (..), - runCleanupOldMeetings, - ) startWorker :: MeetingsCleanupConfig -> AppT IO CleanupAction startWorker config = do @@ -61,6 +48,18 @@ startWorker config = do { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate meetingsCleanupQueueName), ArbiterCore.notVisibleUntil = Just scheduledFor } + jobHandlers = + JobWorkerHandlers + { recurringJobRunnerRunJob = \_ -> + runAppT env $ + runCleanupOldMeetings cleanupConfig, + adminlessDeletionJobRunnerRunJob = \job -> + runAppT env $ + runAdminlessDeletionJob extEnv job, + adminlessReminderJobRunnerRunJob = \job -> + runAppT env $ + runAdminlessReminderJob job + } workersConfig = JobWorkersConfig { recurringJobRunnerConfig = @@ -72,27 +71,30 @@ startWorker config = do recurringJobRunnerWorkerThreads = 1, recurringJobRunnerEnqueueAt = \scheduledFor -> void $ ArbiterCore.insertJob (jobWrite scheduledFor), - recurringJobRunnerRunJob = - runAppT env $ - runCleanupOldMeetings cleanupConfig, recurringJobRunnerJobName = "meetings-cleanup", recurringJobRunnerQueueName = meetingsCleanupQueueName }, - oneOffJobRunnerConfig = + adminlessDeletionJobRunnerConfig = OneOffJobRunnerConfig { oneOffJobRunnerLogger = env.logger, oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, oneOffJobRunnerWorkerThreads = 1, - oneOffJobRunnerRunJob = - runAppT env $ - pure (), oneOffJobRunnerJobName = "adminless-deletion", oneOffJobRunnerQueueName = adminlessDeletionQueueName + }, + adminlessReminderJobRunnerConfig = + OneOffJobRunnerConfig + { oneOffJobRunnerLogger = env.logger, + oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, + oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + oneOffJobRunnerWorkerThreads = 1, + oneOffJobRunnerJobName = "adminless-reminder", + oneOffJobRunnerQueueName = adminlessReminderQueueName } } result <- liftIO $ runBackgroundWorkerEffects env extEnv (RequestId "scheduled-jobs") Nothing $ - startJobWorkers workersConfig + startJobWorkers workersConfig jobHandlers either (liftIO . fail . show) pure result From b24539018e1d2154d52c8605376b84c7187bc42f Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 26 Jun 2026 08:32:32 +0000 Subject: [PATCH 18/65] migrate the meetings clean up to a real arbiter cron --- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 2 +- .../src/Wire/JobSubsystem/Workers.hs | 81 +++++++++---------- .../Wire/BackgroundWorker/ScheduledJobs.hs | 9 +-- services/galley/default.nix | 5 +- 4 files changed, 42 insertions(+), 55 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index 8996a7f70ea..53ec7afdb58 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -46,7 +46,7 @@ data JobSubsystemConfig = JobSubsystemConfig } data JobWorkersConfig = JobWorkersConfig - { recurringJobRunnerConfig :: RecurringJobRunnerConfig ScheduledJobsRegistry MeetingsCleanupJob, + { recurringJobRunnerConfig :: RecurringJobRunnerConfig ScheduledJobsRegistry, adminlessDeletionJobRunnerConfig :: OneOffJobRunnerConfig ScheduledJobsRegistry AdminlessDeletionJob, adminlessReminderJobRunnerConfig :: OneOffJobRunnerConfig ScheduledJobsRegistry AdminlessReminderJob } diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 1b0caf42b6a..7e9abea8988 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE RecordWildCards #-} -{-# LANGUAGE TypeFamilies #-} -- This file is part of the Wire Server implementation. -- @@ -32,24 +31,26 @@ import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Arbiter.Migrations qualified as ArbiterMigrations import Arbiter.Worker qualified as ArbiterWorker +import Arbiter.Worker.Config qualified as ArbiterWorkerConfig +import Arbiter.Worker.Cron qualified as ArbiterWorkerCron import Data.Aeson (FromJSON, ToJSON) import Data.ByteString qualified as ByteString import Data.Kind (Type) import Data.Proxy (Proxy (..)) -import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Text qualified as T import GHC.TypeLits (KnownSymbol) import Imports -import System.Cron (CronSchedule, nextMatch) +import System.Cron (CronSchedule, serializeCronSchedule) import System.Logger qualified as Log import UnliftIO.Async qualified as Async +import Wire.API.Jobs (MeetingsCleanupJob (..)) -data RecurringJobRunnerConfig registry (payload :: Type) = RecurringJobRunnerConfig +data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig { recurringJobRunnerLogger :: Log.Logger, recurringJobRunnerSchedule :: CronSchedule, recurringJobRunnerArbiterConnStr :: ByteString.ByteString, recurringJobRunnerSchemaName :: Text, recurringJobRunnerWorkerThreads :: Int, - recurringJobRunnerEnqueueAt :: UTCTime -> ArbiterHasql.HasqlDb registry IO (), recurringJobRunnerJobName :: Text, recurringJobRunnerQueueName :: Text } @@ -63,16 +64,19 @@ data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig oneOffJobRunnerQueueName :: Text } +-- This runner is specialized to the meetings cleanup payload for now. +-- If we add another cron job later, we can either reuse this helper with a +-- shared payload type or factor out the common Arbiter setup first. runRecurringJobRunner :: - forall registry (payload :: Type). + forall registry. ( RegistryTables registry, - KnownSymbol (TableForPayload payload registry), - FromJSON payload, - ToJSON payload + KnownSymbol (TableForPayload MeetingsCleanupJob registry), + FromJSON MeetingsCleanupJob, + ToJSON MeetingsCleanupJob ) => Proxy registry -> - RecurringJobRunnerConfig registry payload -> - (payload -> IO ()) -> + RecurringJobRunnerConfig registry -> + (MeetingsCleanupJob -> IO ()) -> IO (IO ()) runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do Log.info recurringJobRunnerLogger $ @@ -87,38 +91,28 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do recurringJobRunnerArbiterConnStr recurringJobRunnerSchemaName - let enqueueNextRun :: UTCTime -> IO () - enqueueNextRun scheduledFor = - void $ - ArbiterHasql.runHasqlDb arbiterEnv $ - recurringJobRunnerEnqueueAt scheduledFor - - enqueueIfScheduled :: UTCTime -> IO Bool - enqueueIfScheduled now = case nextMatch recurringJobRunnerSchedule now of - Nothing -> do - Log.warn recurringJobRunnerLogger $ - Log.msg (Log.val "Scheduled job will not be enqueued") - . Log.field "job_name" recurringJobRunnerJobName - . Log.field "queue_name" recurringJobRunnerQueueName - pure False - Just nextRun -> do - Log.info recurringJobRunnerLogger $ - Log.msg (Log.val "Enqueuing scheduled job") - . Log.field "job_name" recurringJobRunnerJobName - . Log.field "queue_name" recurringJobRunnerQueueName - . Log.field "scheduled_for" (show nextRun) - enqueueNextRun nextRun - pure True - - workerHandler _conn job = + let workerHandler _conn job = liftIO $ do Log.info recurringJobRunnerLogger $ Log.msg (Log.val "Running scheduled job") . Log.field "job_name" recurringJobRunnerJobName . Log.field "queue_name" recurringJobRunnerQueueName runJob (ArbiterCore.payload job) - now <- getCurrentTime - void $ enqueueIfScheduled now + + cronJob = + case + ArbiterWorkerCron.cronJob + recurringJobRunnerJobName + (serializeCronSchedule recurringJobRunnerSchedule) + ArbiterWorkerCron.SkipOverlap + (\_ scheduledFor -> + (ArbiterCore.defaultGroupedJob recurringJobRunnerQueueName MeetingsCleanupJob) + { ArbiterCore.notVisibleUntil = Just scheduledFor + } + ) + of + Left err -> error $ "Invalid cron schedule for " <> T.unpack recurringJobRunnerJobName <> ": " <> err + Right job -> job void $ ArbiterMigrations.runMigrationsForRegistry @@ -135,21 +129,22 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do IO ( ArbiterWorker.WorkerConfig (ArbiterHasql.HasqlDb registry IO) - payload + MeetingsCleanupJob () ) ) - - now <- getCurrentTime - void $ enqueueIfScheduled now + let workerConfig' = + workerConfig + { ArbiterWorkerConfig.cronJobs = [cronJob] + } workerAsync <- Async.async $ ArbiterHasql.runHasqlDb arbiterEnv $ - ArbiterWorker.runWorkerPool workerConfig + ArbiterWorker.runWorkerPool workerConfig' pure $ do - ArbiterWorker.shutdownWorker workerConfig + ArbiterWorker.shutdownWorker workerConfig' Async.cancel workerAsync runOneOffJobRunner :: diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 8e0094ed5ca..9cf1b6fb804 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -1,5 +1,5 @@ {-# LANGUAGE DataKinds #-} -{-# LANGUAGE TypeApplications #-} + {-# LANGUAGE TypeFamilies #-} -- This file is part of the Wire Server implementation. @@ -43,11 +43,6 @@ startWorker config = do { retentionHours = config.cleanOlderThanHours, batchSize = config.batchSize } - jobWrite scheduledFor = - (ArbiterCore.defaultGroupedJob meetingsCleanupQueueName MeetingsCleanupJob) - { ArbiterCore.dedupKey = Just (ArbiterCore.IgnoreDuplicate meetingsCleanupQueueName), - ArbiterCore.notVisibleUntil = Just scheduledFor - } jobHandlers = JobWorkerHandlers { recurringJobRunnerRunJob = \_ -> @@ -69,8 +64,6 @@ startWorker config = do recurringJobRunnerArbiterConnStr = env.arbiterConnStr, recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, recurringJobRunnerWorkerThreads = 1, - recurringJobRunnerEnqueueAt = \scheduledFor -> - void $ ArbiterCore.insertJob (jobWrite scheduledFor), recurringJobRunnerJobName = "meetings-cleanup", recurringJobRunnerQueueName = meetingsCleanupQueueName }, diff --git a/services/galley/default.nix b/services/galley/default.nix index 532c7591ad0..949e941b3a9 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -7,6 +7,7 @@ , aeson-qq , amazonka , amqp +, arbiter-core , async , base , base64-bytestring @@ -28,7 +29,6 @@ , errors , exceptions , extended -, arbiter-core , extra , federator , filepath @@ -122,6 +122,7 @@ mkDerivation { aeson amazonka amqp + arbiter-core async base bilge @@ -134,7 +135,6 @@ mkDerivation { errors exceptions extended - arbiter-core galley-types hasql-resource-pool hs-opentelemetry-instrumentation-wai @@ -207,7 +207,6 @@ mkDerivation { errors exceptions extended - arbiter-core extra federator filepath From 51d11a9b0e09d7d0bc6687fb2b8b5283daae54de Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 26 Jun 2026 09:52:45 +0000 Subject: [PATCH 19/65] send reminder events --- .../src/Wire/API/Event/Conversation.hs | 23 ++- libs/wire-api/src/Wire/API/Jobs.hs | 10 +- .../test/unit/Test/Wire/API/Conversation.hs | 1 + .../src/Wire/ConversationSubsystem.hs | 5 + .../src/Wire/ConversationSubsystem/Action.hs | 4 +- .../Wire/ConversationSubsystem/Interpreter.hs | 2 + .../src/Wire/ConversationSubsystem/Update.hs | 141 +++++++++++++----- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 2 +- .../src/Wire/JobSubsystem/Interpreter.hs | 7 +- .../src/Wire/JobSubsystem/Workers.hs | 20 ++- .../src/Wire/AdminlessJobsWorker.hs | 13 +- .../Wire/BackgroundWorker/ScheduledJobs.hs | 3 +- 12 files changed, 169 insertions(+), 62 deletions(-) diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index a75477d2ade..3746df1f8f8 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -68,6 +68,7 @@ module Wire.API.Event.Conversation MemberUpdateData (..), OtrMessage (..), ConversationReset (..), + AdminlessReminder (..), -- * re-exports ConversationReceiptModeUpdate (..), @@ -194,6 +195,7 @@ data EventType | ProtocolUpdate | AddPermissionUpdate | ConvHistoryUpdate + | ConvAdminlessReminder deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) deriving (Arbitrary) via (GenericUniform EventType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType @@ -222,7 +224,8 @@ instance ToSchema EventType where element "conversation.mls-welcome" MLSWelcome, element "conversation.protocol-update" ProtocolUpdate, element "conversation.add-permission-update" AddPermissionUpdate, - element "conversation.history-update" ConvHistoryUpdate + element "conversation.history-update" ConvHistoryUpdate, + element "conversation.adminless-reminder" ConvAdminlessReminder ] data EventData @@ -247,6 +250,7 @@ data EventData | EdProtocolUpdate P.ProtocolTag | EdAddPermissionUpdate Conv.AddPermissionUpdate | EdConvHistoryUpdate History + | EdAdminlessReminder AdminlessReminder deriving stock (Eq, Show, Generic) genEventData :: EventType -> QC.Gen EventData @@ -272,6 +276,7 @@ genEventData = \case ProtocolUpdate -> EdProtocolUpdate <$> arbitrary AddPermissionUpdate -> EdAddPermissionUpdate <$> arbitrary ConvHistoryUpdate -> EdConvHistoryUpdate <$> arbitrary + ConvAdminlessReminder -> EdAdminlessReminder <$> arbitrary eventDataType :: EventData -> EventType eventDataType (EdMembersJoin _) = MemberJoin @@ -295,6 +300,7 @@ eventDataType (EdConvReset _) = ConvReset eventDataType (EdProtocolUpdate _) = ProtocolUpdate eventDataType (EdAddPermissionUpdate _) = AddPermissionUpdate eventDataType (EdConvHistoryUpdate _) = ConvHistoryUpdate +eventDataType (EdAdminlessReminder _) = ConvAdminlessReminder createConversationEventData :: OwnConversation GroupConvType -> EventData @@ -326,6 +332,7 @@ isCellsConversationEvent eventType = ProtocolUpdate -> False AddPermissionUpdate -> False ConvHistoryUpdate -> False + ConvAdminlessReminder -> False -------------------------------------------------------------------------------- -- Event data helpers @@ -493,6 +500,19 @@ instance ToSchema ConversationReset where <$> (.groupId) .= field "group_id" schema <*> (.newGroupId) .= maybe_ (optField "new_group_id" schema) +data AdminlessReminder = AdminlessReminder + { daysUntilDeletion :: Int + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform AdminlessReminder) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema AdminlessReminder + +instance ToSchema AdminlessReminder where + schema = + object $ + AdminlessReminder + <$> (.daysUntilDeletion) .= field "days_until_deletion" schema + makePrisms ''EventData taggedEventDataSchema :: ObjectSchema SwaggerDoc (EventType, EventData) @@ -529,6 +549,7 @@ taggedEventDataSchema = ProtocolUpdate -> tag _EdProtocolUpdate (unnamed (unProtocolUpdate <$> P.ProtocolUpdate .= schema)) AddPermissionUpdate -> tag _EdAddPermissionUpdate (unnamed schema) ConvHistoryUpdate -> tag _EdConvHistoryUpdate (unnamed schema) + ConvAdminlessReminder -> tag _EdAdminlessReminder (unnamed schema) memberLeaveSchema :: ValueSchema NamedSwaggerDoc (EdMemberLeftReason, QualifiedUserIdList) memberLeaveSchema = diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index afcb3ad5ce9..9be9d035505 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -55,6 +55,9 @@ instance FromJSON MeetingsCleanupJob where parseJSON _ = fail "MeetingsCleanupJob expects null" -- | Payload for adminless deletions. +-- Keep the JSON encoding backwards compatible. The jobs API reads these payloads +-- back from Arbiter, so changing field names or shapes without a migration will +-- break job listing and management. data AdminlessDeletionJob = AdminlessDeletionJob { adminlessDeletionJobTeamId :: TeamId, adminlessDeletionJobConversationId :: ConvId, @@ -72,10 +75,14 @@ instance ToSchema AdminlessDeletionJob where <*> (.adminlessDeletionJobOrigUserId) .= field "orig_user_id" schema -- | Payload for adminless reminders. +-- Keep the JSON encoding backwards compatible. The jobs API reads these payloads +-- back from Arbiter, so changing field names or shapes without a migration will +-- break job listing and management. data AdminlessReminderJob = AdminlessReminderJob { adminlessReminderJobTeamId :: TeamId, adminlessReminderJobConversationId :: ConvId, - adminlessReminderJobOrigUserId :: UserId + adminlessReminderJobOrigUserId :: UserId, + adminlessReminderJobDaysUntilDeletion :: Int } deriving stock (Eq, Generic, Show) deriving (ToJSON, FromJSON) via (Schema AdminlessReminderJob) @@ -87,6 +94,7 @@ instance ToSchema AdminlessReminderJob where <$> (.adminlessReminderJobTeamId) .= field "team_id" schema <*> (.adminlessReminderJobConversationId) .= field "conversation_id" schema <*> (.adminlessReminderJobOrigUserId) .= field "orig_user_id" schema + <*> (.adminlessReminderJobDaysUntilDeletion) .= field "days_until_deletion" schema -- | The generic scheduled-job families we currently need to persist. data ScheduledJobKind diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs index aa9527a3e69..026a393bbd3 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs @@ -75,6 +75,7 @@ testIsCellsConversationEvent = ConvReset -> isCellsConversationEvent e === False ConvMessageTimerUpdate -> isCellsConversationEvent e === False ConvHistoryUpdate -> isCellsConversationEvent e === False + ConvAdminlessReminder -> isCellsConversationEvent e === False ConvReceiptModeUpdate -> isCellsConversationEvent e === False ConvRename -> isCellsConversationEvent e === True MemberJoin -> isCellsConversationEvent e === True diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index 61fe088f0e9..6304bb1794d 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -345,6 +345,11 @@ data ConversationSubsystem m a where Local UserId -> Local ConvId -> ConversationSubsystem m () + InternalNotifyAdminlessReminder :: + Local UserId -> + Local ConvId -> + Int -> + ConversationSubsystem m () GetMLSPublicKeys :: Maybe MLSPublicKeyFormat -> ConversationSubsystem m (MLSKeysByPurpose (MLSKeys SomeKey)) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs index fe70e496b35..2a5c631bc7f 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs @@ -51,7 +51,7 @@ module Wire.ConversationSubsystem.Action addLocalUsersToRemoteConv, ConversationUpdate, ensureAllowed, - removeConversation + removeConversation, ) where @@ -116,13 +116,13 @@ import Wire.BrigAPIAccess qualified as E import Wire.CodeStore import Wire.CodeStore qualified as E import Wire.CodeStore.Code (CodeReferent (..)) +import Wire.ConversationStore (ConversationStore) import Wire.ConversationStore qualified as E import Wire.ConversationSubsystem.Action.Kick import Wire.ConversationSubsystem.Action.Leave import Wire.ConversationSubsystem.Action.Notify import Wire.ConversationSubsystem.Action.Reset import Wire.ConversationSubsystem.MLS.Conversation -import Wire.ConversationStore (ConversationStore) import Wire.ConversationSubsystem.MLS.Migration import Wire.ConversationSubsystem.MLS.Removal import Wire.ConversationSubsystem.Util diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 1c04ce2101d..1f9038a970e 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -215,6 +215,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Action.updateLocalConversationDeleteUnchecked lcnv InternalDeleteLocalAdminlessGroup lusr lcnv -> mapErrors $ Update.adminlessAutopromoteOrDelete lusr lcnv + InternalNotifyAdminlessReminder lusr lcnv daysUntilDeletion -> + mapErrors $ Update.adminlessAutopromoteOrSendReminder lusr lcnv daysUntilDeletion GetMLSPublicKeys fmt -> mapErrors $ MLS.getMLSPublicKeys fmt ResetMLSConversation lusr reset -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index 19c8a9c7282..ed06da0bd6b 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -43,6 +43,7 @@ module Wire.ConversationSubsystem.Update updateLocalStateOfRemoteConv, updateCellsState, adminlessAutopromoteOrDelete, + adminlessAutopromoteOrSendReminder, -- * Managing Members addQualifiedMembersUnqualified, @@ -1226,9 +1227,64 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do scheduleReminder now tid feature.config.deletionTimeout scheduleReminder now tid deletionTimeout reminderTimeout = do - when (reminderTimeout < deletionTimeout) do + when (reminderTimeout < deletionTimeout) $ do let reminderAt = addUTCTime (fromIntegral (deletionTimeout - reminderTimeout) * nominalDay) now - void $ scheduleAdminlessReminderJob lusr tid (qUnqualified (tUntagged lcnv)) reminderAt + void $ scheduleAdminlessReminderJob lusr tid (qUnqualified (tUntagged lcnv)) (fromIntegral reminderTimeout) reminderAt + +onAdminless :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member BrigAPIAccess r, + Member FeaturesConfigSubsystem r + ) => + Local ConvId -> + (StoredConversation -> LockableFeature PreventAdminlessGroupsConfig -> [(Qualified UserId, User.Name)] -> Sem r ()) -> + Sem r () +onAdminless lcnv action = do + conv <- getConversationWithError lcnv + for_ conv.metadata.cnvmTeam $ \tid -> do + (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid + let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers + when (feature.status == FeatureStatusEnabled && not adminExists) $ do + eligibleMembers <- eligibleAdminFallbackMembers lcnv Nothing conv + action conv feature eligibleMembers + +adminlessTryAutopromote :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r + ) => + Local UserId -> + Local ConvId -> + (StoredConversation -> Sem r ()) -> + Sem r () +adminlessTryAutopromote lusr lcnv altAction = do + onAdminless lcnv $ \conv feature eligibleMembers -> do + case eligibleMembers of + x : xs -> do + seed <- randomWord64 + let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) + update = (OtherMemberUpdate (Just roleNameWireAdmin)) + for_ autopromotionCandidates $ \candidate -> do + E.setOtherMember lcnv candidate update + void $ + sendConversationActionNotifications + (sing @'ConversationMemberUpdateTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + (ConversationMemberUpdate (tUntagged lusr) update) + def + [] -> altAction conv adminlessAutopromoteOrDelete :: ( Member ConversationStore r, @@ -1247,44 +1303,51 @@ adminlessAutopromoteOrDelete :: Local UserId -> Local ConvId -> Sem r () -adminlessAutopromoteOrDelete lusr lcnv = do - -- the local user is probably not a conversation member, it could be e.g. the user that had previously left - conv <- getConversationWithError lcnv - for_ conv.metadata.cnvmTeam $ \tid -> do - (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid - let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers - when (feature.status == FeatureStatusEnabled && not adminExists) $ do - eligibleMembers <- eligibleAdminFallbackMembers lcnv Nothing conv - case eligibleMembers of - x : xs -> do - seed <- randomWord64 - let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) - update = (OtherMemberUpdate (Just roleNameWireAdmin)) - for_ autopromotionCandidates $ \candidate -> do - E.setOtherMember lcnv candidate update - void $ - sendConversationActionNotifications - (sing @'ConversationMemberUpdateTag) - (tUntagged lusr) - False - Nothing - (qualifyAs lcnv conv) - (convBotsAndMembers conv) - (ConversationMemberUpdate (tUntagged lusr) update) - def - [] -> do - removeConversation (qualifyAs lcnv conv) - void $ - sendConversationActionNotifications - (sing @'ConversationDeleteTag) - (tUntagged lusr) - False - Nothing - (qualifyAs lcnv conv) - (convBotsAndMembers conv) - () - def +adminlessAutopromoteOrDelete lusr lcnv = adminlessTryAutopromote lusr lcnv orAlternativelyDeleteConv + where + orAlternativelyDeleteConv conv = do + removeConversation (qualifyAs lcnv conv) + void $ + sendConversationActionNotifications + (sing @'ConversationDeleteTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + () + def +adminlessAutopromoteOrSendReminder :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r + ) => + Local UserId -> + Local ConvId -> + Int -> + Sem r () +adminlessAutopromoteOrSendReminder lusr lcnv daysUntilDeletion = adminlessTryAutopromote lusr lcnv orAlternativelySendReminder + where + orAlternativelySendReminder _ = do + conv <- getConversationWithError lcnv + now <- Now.get + let event = + Event + (tUntagged lcnv) + Nothing + (EventFromUser (tUntagged lusr)) + now + (conv.metadata.cnvmTeam) + (EdAdminlessReminder (AdminlessReminder daysUntilDeletion)) + pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) [] -- Use eight random bytes and fold them into a big-endian Word64. This keeps -- the helper small, deterministic under tests, and free of extra Random API. diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index 53ec7afdb58..56cab7e0f15 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -59,7 +59,7 @@ data JobWorkerHandlers = JobWorkerHandlers data JobSubsystem m a where ScheduleAdminlessDeletionJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m ScheduledJob - ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m ScheduledJob + ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> Int -> UTCTime -> JobSubsystem m ScheduledJob StartJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> JobSubsystem m CleanupAction makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 12a2de8cb86..5457647baa0 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -57,7 +57,7 @@ interpretJobSubsystem conf = interpret \case ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor - ScheduleAdminlessReminderJob lusr tid cid scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid scheduledFor + ScheduleAdminlessReminderJob lusr tid cid daysUntilDeletion scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid daysUntilDeletion scheduledFor StartJobWorkers cfg handlers -> embed $ runJobWorkers cfg handlers scheduleAdminlessDeletionJob :: @@ -104,9 +104,10 @@ scheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> + Int -> UTCTime -> Sem r ScheduledJob -scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do +scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUntilDeletion scheduledFor = do arbiterEnv <- embed $ ArbiterHasql.createHasqlEnv @@ -123,7 +124,7 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId schedule scheduledJobScheduledFor = scheduledFor } arbiterJob = - (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr))) + (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) daysUntilDeletion)) { ArbiterCore.notVisibleUntil = Just scheduledFor } JobStore.createJob job diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 7e9abea8988..d43962035de 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -100,17 +100,15 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do runJob (ArbiterCore.payload job) cronJob = - case - ArbiterWorkerCron.cronJob - recurringJobRunnerJobName - (serializeCronSchedule recurringJobRunnerSchedule) - ArbiterWorkerCron.SkipOverlap - (\_ scheduledFor -> - (ArbiterCore.defaultGroupedJob recurringJobRunnerQueueName MeetingsCleanupJob) - { ArbiterCore.notVisibleUntil = Just scheduledFor - } - ) - of + case ArbiterWorkerCron.cronJob + recurringJobRunnerJobName + (serializeCronSchedule recurringJobRunnerSchedule) + ArbiterWorkerCron.SkipOverlap + ( \_ scheduledFor -> + (ArbiterCore.defaultGroupedJob recurringJobRunnerQueueName MeetingsCleanupJob) + { ArbiterCore.notVisibleUntil = Just scheduledFor + } + ) of Left err -> error $ "Invalid cron schedule for " <> T.unpack recurringJobRunnerJobName <> ": " <> err Right job -> job diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs index 4fca5b8686e..0ad45ed4617 100644 --- a/services/background-worker/src/Wire/AdminlessJobsWorker.hs +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -46,10 +46,19 @@ runAdminlessDeletionJob extEnv job = do internalDeleteLocalAdminlessGroup (loc job.adminlessDeletionJobOrigUserId) (loc job.adminlessDeletionJobConversationId) either (liftIO . fail . show) pure result -runAdminlessReminderJob :: AdminlessReminderJob -> AppT IO () -runAdminlessReminderJob job = do +runAdminlessReminderJob :: ExtEnv -> AdminlessReminderJob -> AppT IO () +runAdminlessReminderJob extEnv job = do env <- ask Log.info env.logger $ Log.msg (Log.val "Running adminless reminder job") . Log.field "team_id" (show (adminlessReminderJobTeamId job)) . Log.field "conversation_id" (show (adminlessReminderJobConversationId job)) + . Log.field "days_until_deletion" (show (adminlessReminderJobDaysUntilDeletion job)) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv (RequestId "adminless-reminder") Nothing $ + internalNotifyAdminlessReminder + (toLocalUnsafe env.federationDomain job.adminlessReminderJobOrigUserId) + (toLocalUnsafe env.federationDomain job.adminlessReminderJobConversationId) + job.adminlessReminderJobDaysUntilDeletion + either (liftIO . fail . show) pure result diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 9cf1b6fb804..459abcdf89f 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -1,5 +1,4 @@ {-# LANGUAGE DataKinds #-} - {-# LANGUAGE TypeFamilies #-} -- This file is part of the Wire Server implementation. @@ -53,7 +52,7 @@ startWorker config = do runAdminlessDeletionJob extEnv job, adminlessReminderJobRunnerRunJob = \job -> runAppT env $ - runAdminlessReminderJob job + runAdminlessReminderJob extEnv job } workersConfig = JobWorkersConfig From 1ebce4a7043114b44d87fc2dd7bb0830adb45910 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 26 Jun 2026 13:21:00 +0000 Subject: [PATCH 20/65] fixed parser for tean feature --- libs/wire-api/src/Wire/API/Team/FeatureFlags.hs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs index 6a77beeb88e..ed7e457a9a7 100644 --- a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs +++ b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs @@ -166,7 +166,11 @@ instance Default (FeatureDefaults SearchVisibilityAvailableConfig) where def = FeatureTeamSearchVisibilityAvailableByDefault instance ParseFeatureDefaults (FeatureDefaults SearchVisibilityAvailableConfig) where - parseFeatureDefaults obj = obj .: "teamSearchVisibility" + parseFeatureDefaults obj = do + -- Accept both the current feature key and the legacy team-scoped key. + mCurrent <- obj .:? "searchVisibility" + mLegacy <- obj .:? "teamSearchVisibility" + pure $ fromMaybe def (mCurrent <|> mLegacy) instance FromJSON (FeatureDefaults SearchVisibilityAvailableConfig) where parseJSON (String "enabled-by-default") = pure FeatureTeamSearchVisibilityAvailableByDefault From 34562ade9dd54e428ca27241bcb6c67787b49e82 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 26 Jun 2026 13:21:34 +0000 Subject: [PATCH 21/65] remove wire-server scheduled_job entry when job is finished --- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 5 +- .../src/Wire/JobSubsystem/Workers.hs | 6 +- .../src/Wire/AdminlessJobsWorker.hs | 78 ++++++++++++++----- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index 56cab7e0f15..7e7c1f34769 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -29,6 +29,7 @@ module Wire.JobSubsystem ) where +import Arbiter.Core.Job.Types (JobRead) import Data.ByteString qualified as ByteString import Data.Id import Data.Qualified @@ -53,8 +54,8 @@ data JobWorkersConfig = JobWorkersConfig data JobWorkerHandlers = JobWorkerHandlers { recurringJobRunnerRunJob :: MeetingsCleanupJob -> IO (), - adminlessDeletionJobRunnerRunJob :: AdminlessDeletionJob -> IO (), - adminlessReminderJobRunnerRunJob :: AdminlessReminderJob -> IO () + adminlessDeletionJobRunnerRunJob :: JobRead AdminlessDeletionJob -> IO (), + adminlessReminderJobRunnerRunJob :: JobRead AdminlessReminderJob -> IO () } data JobSubsystem m a where diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index d43962035de..b41a6d0e8cc 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -27,6 +27,7 @@ module Wire.JobSubsystem.Workers where import Arbiter.Core qualified as ArbiterCore +import Arbiter.Core.Job.Types (JobRead) import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Arbiter.Migrations qualified as ArbiterMigrations @@ -154,7 +155,7 @@ runOneOffJobRunner :: ) => Proxy registry -> OneOffJobRunnerConfig registry payload -> - (payload -> IO ()) -> + (JobRead payload -> IO ()) -> IO (IO ()) runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do Log.info oneOffJobRunnerLogger $ @@ -167,14 +168,13 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do registry oneOffJobRunnerArbiterConnStr oneOffJobRunnerSchemaName - let workerHandler _conn job = liftIO $ do Log.info oneOffJobRunnerLogger $ Log.msg (Log.val "Running one-off job") . Log.field "job_name" oneOffJobRunnerJobName . Log.field "queue_name" oneOffJobRunnerQueueName - runJob (ArbiterCore.payload job) + runJob job void $ ArbiterMigrations.runMigrationsForRegistry diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs index 0ad45ed4617..090c6d9c032 100644 --- a/services/background-worker/src/Wire/AdminlessJobsWorker.hs +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -12,8 +12,8 @@ -- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License -- for more details. -- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . module Wire.AdminlessJobsWorker ( runAdminlessDeletionJob, @@ -21,44 +21,84 @@ module Wire.AdminlessJobsWorker ) where -import Data.Id (RequestId (..)) -import Data.Qualified +import Arbiter.Core.Job.Types (JobRead, notVisibleUntil, payload) +import Data.Id (ConvId, RequestId (..), TeamId) +import Data.List qualified as List +import Data.Qualified (toLocalUnsafe) +import Data.Time.Clock (UTCTime) import Imports +import Polysemy import System.Logger qualified as Log -import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..)) +import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..), ScheduledJob (..), ScheduledJobKind (..)) import Wire.BackgroundWorker.Env (AppT, Env (..)) import Wire.ConversationSubsystem import Wire.Effects (runBackgroundWorkerEffects) import Wire.ExternalAccess.External (ExtEnv) +import Wire.JobStore (JobStore, deleteJob, findJobsByConversationId) -runAdminlessDeletionJob :: ExtEnv -> AdminlessDeletionJob -> AppT IO () +runAdminlessDeletionJob :: ExtEnv -> JobRead AdminlessDeletionJob -> AppT IO () runAdminlessDeletionJob extEnv job = do env <- ask - let loc :: a -> Local a - loc a = toLocalUnsafe env.federationDomain a + let jobPayload = payload job Log.info env.logger $ Log.msg (Log.val "Running adminless deletion job") - . Log.field "team_id" (show (adminlessDeletionJobTeamId job)) - . Log.field "conversation_id" (show (adminlessDeletionJobConversationId job)) + . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) + . Log.field "conversation_id" (show (adminlessDeletionJobConversationId jobPayload)) result <- liftIO $ runBackgroundWorkerEffects env extEnv (RequestId "adminless-deletion") Nothing $ - internalDeleteLocalAdminlessGroup (loc job.adminlessDeletionJobOrigUserId) (loc job.adminlessDeletionJobConversationId) + do + internalDeleteLocalAdminlessGroup + (toLocalUnsafe env.federationDomain (adminlessDeletionJobOrigUserId jobPayload)) + (toLocalUnsafe env.federationDomain (adminlessDeletionJobConversationId jobPayload)) + cleanupScheduledJob + AdminlessDeletion + (adminlessDeletionJobTeamId jobPayload) + (adminlessDeletionJobConversationId jobPayload) + (notVisibleUntil job) either (liftIO . fail . show) pure result -runAdminlessReminderJob :: ExtEnv -> AdminlessReminderJob -> AppT IO () +runAdminlessReminderJob :: ExtEnv -> JobRead AdminlessReminderJob -> AppT IO () runAdminlessReminderJob extEnv job = do env <- ask + let jobPayload = payload job Log.info env.logger $ Log.msg (Log.val "Running adminless reminder job") - . Log.field "team_id" (show (adminlessReminderJobTeamId job)) - . Log.field "conversation_id" (show (adminlessReminderJobConversationId job)) - . Log.field "days_until_deletion" (show (adminlessReminderJobDaysUntilDeletion job)) + . Log.field "team_id" (show (adminlessReminderJobTeamId jobPayload)) + . Log.field "conversation_id" (show (adminlessReminderJobConversationId jobPayload)) + . Log.field "days_until_deletion" (show (adminlessReminderJobDaysUntilDeletion jobPayload)) result <- liftIO $ runBackgroundWorkerEffects env extEnv (RequestId "adminless-reminder") Nothing $ - internalNotifyAdminlessReminder - (toLocalUnsafe env.federationDomain job.adminlessReminderJobOrigUserId) - (toLocalUnsafe env.federationDomain job.adminlessReminderJobConversationId) - job.adminlessReminderJobDaysUntilDeletion + do + internalNotifyAdminlessReminder + (toLocalUnsafe env.federationDomain (adminlessReminderJobOrigUserId jobPayload)) + (toLocalUnsafe env.federationDomain (adminlessReminderJobConversationId jobPayload)) + (adminlessReminderJobDaysUntilDeletion jobPayload) + cleanupScheduledJob + AdminlessReminder + (adminlessReminderJobTeamId jobPayload) + (adminlessReminderJobConversationId jobPayload) + (notVisibleUntil job) either (liftIO . fail . show) pure result + +cleanupScheduledJob :: + (Member JobStore r) => + ScheduledJobKind -> + TeamId -> + ConvId -> + Maybe UTCTime -> + Sem r () +cleanupScheduledJob _ _ _ Nothing = pure () +cleanupScheduledJob kind teamId convId (Just scheduledFor) = do + jobs <- findJobsByConversationId convId + let matchingJobs = + List.filter + ( \job -> + job.scheduledJobKind == kind + && job.scheduledJobTeamId == teamId + && job.scheduledJobConversationId == Just convId + && job.scheduledJobScheduledFor == scheduledFor + ) + jobs + traverse_ (deleteJob . scheduledJobId) matchingJobs From 933c2d4f1cbc5ea388b4ef6705a8b130221e30be Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 26 Jun 2026 13:21:58 +0000 Subject: [PATCH 22/65] test that jobs get created correctly --- integration/test/Notifications.hs | 3 + integration/test/Test/AdminlessGroups.hs | 76 +++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 67281da2d6e..04db1074594 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -205,6 +205,9 @@ isConvCreateNotifNotSelf n = isConvDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvDeleteNotif n = fieldEquals n "payload.0.type" "conversation.delete" +isConvAdminlessReminderNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isConvAdminlessReminderNotif n = fieldEquals n "payload.0.type" "conversation.adminless-reminder" + notifTypeIsEqual :: (HasCallStack, MakesValue a) => String -> a -> App Bool notifTypeIsEqual typ n = nPayload n %. "type" `isEqual` typ diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index d2bd97e5e21..19313eabe21 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -20,6 +20,8 @@ module Test.AdminlessGroups where import API.Brig import API.Galley import API.GalleyInternal hiding (getConversation) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Format.ISO8601 (iso8601ParseM) import MLS.Util import SetupHelpers hiding (deleteUser) import Testlib.Prelude @@ -100,20 +102,92 @@ testOnLastAdminLeaveNoEligibleMembersExist = do (alice, tid, _) <- createTeam OwnDomain 1 setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" - patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess + patchTeamFeature + OwnDomain + tid + "preventAdminlessGroups" + ( object + [ "status" .= "enabled", + "config" + .= object + [ "deletionTimeout" .= (1 :: Int), + "reminderTimeouts" .= ([0] :: [Int]), + "promotionStrategy" .= "random" + ] + ] + ) + >>= assertSuccess alice1 <- createMLSClient def alice void $ uploadNewKeyPackage def alice1 conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201 convId <- objConvId conv + convIdText <- conv %. "qualified_id.id" & asString createGroup def alice1 convId void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle + now <- liftIO getCurrentTime + -- alice leaves the conversation, no error, group will be marked for deletion bindResponse (removeMember alice conv alice) $ \resp -> do resp.status `shouldMatchInt` 200 + deletionJobs <- listArbiterJobs alice "adminless_deletion_jobs" >>= getJSON 200 + deletionJobs %. "jobs" + & asList >>= \jobs -> do + jobInfo <- forM jobs $ \job -> do + jobTeamId <- job %. "payload.team_id" & asString + jobConversationId <- job %. "payload.conversation_id" & asString + notVisibleUntil <- job %. "notVisibleUntil" & asString + pure (job, jobTeamId, jobConversationId, notVisibleUntil) + let matchingJobs = [job | (job, jobTeamId, jobConversationId, _) <- jobInfo, jobTeamId == tid && jobConversationId == convIdText] + assertBool + ( "expected one deletion job for team " + <> show tid + <> " and conversation " + <> convIdText + <> ", but saw: " + <> show [(team, conversation, visibleUntil) | (_, team, conversation, visibleUntil) <- jobInfo] + ) + (length matchingJobs == 1) + [job] <- pure matchingJobs + assertJobTimeout now (1 :: Int) job + + reminderJobs <- listArbiterJobs alice "adminless_reminder_jobs" >>= getJSON 200 + reminderJobs %. "jobs" + & asList >>= \jobs -> do + jobInfo <- forM jobs $ \job -> do + jobTeamId <- job %. "payload.team_id" & asString + jobConversationId <- job %. "payload.conversation_id" & asString + notVisibleUntil <- job %. "notVisibleUntil" & asString + pure (job, jobTeamId, jobConversationId, notVisibleUntil) + let matchingJobs = [job | (job, jobTeamId, jobConversationId, _) <- jobInfo, jobTeamId == tid && jobConversationId == convIdText] + assertBool + ( "expected one reminder job for team " + <> show tid + <> " and conversation " + <> convIdText + <> ", but saw: " + <> show [(team, conversation, visibleUntil) | (_, team, conversation, visibleUntil) <- jobInfo] + ) + (length matchingJobs == 1) + [job] <- pure matchingJobs + assertJobTimeout now (1 :: Int) job + where + assertJobTimeout scheduledAt expectedDays job = do + notVisibleUntilStr <- job %. "notVisibleUntil" & asString + notVisibleUntil <- assertJust ("expected ISO 8601 timestamp, got: " <> notVisibleUntilStr) $ iso8601ParseM @Maybe @UTCTime notVisibleUntilStr + let expected = addUTCTime (fromIntegral expectedDays * nominalDay) scheduledAt + tolerance = 120 :: NominalDiffTime + assertBool + ("notVisibleUntil " <> show notVisibleUntil <> " is not within tolerance of expected " <> show expected) + (notVisibleUntil >= addUTCTime (negate tolerance) expected && notVisibleUntil <= addUTCTime tolerance expected) + + listArbiterJobs user table = do + req <- baseRequest user Brig Unversioned $ joinHttpPath ["i", "jobs", "api", "v1", table, "jobs"] + submit "GET" $ req & addQueryParams [("limit", "100"), ("offset", "0")] + testOnLastAdminLeaveFeatureDisabled :: (HasCallStack) => App () testOnLastAdminLeaveFeatureDisabled = do -- bob is eligible From 46fbe9f7115e5f407cb60b730216ec9ea970ca40 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 26 Jun 2026 14:25:11 +0000 Subject: [PATCH 23/65] test execution of job --- integration/test/Test/AdminlessGroups.hs | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index 19313eabe21..b2ee9bb0213 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -20,6 +20,7 @@ module Test.AdminlessGroups where import API.Brig import API.Galley import API.GalleyInternal hiding (getConversation) +import qualified API.GalleyInternal as GalleyI import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime, nominalDay) import Data.Time.Format.ISO8601 (iso8601ParseM) import MLS.Util @@ -188,6 +189,71 @@ testOnLastAdminLeaveNoEligibleMembersExist = do req <- baseRequest user Brig Unversioned $ joinHttpPath ["i", "jobs", "api", "v1", table, "jobs"] submit "GET" $ req & addQueryParams [("limit", "100"), ("offset", "0")] +testAdminlessJobsExecuteViaArbiterApi :: (HasCallStack) => App () +testAdminlessJobsExecuteViaArbiterApi = do + (alice, tid, _) <- createTeam OwnDomain 1 + + setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" + patchTeamFeature + OwnDomain + tid + "preventAdminlessGroups" + ( object + [ "status" .= "enabled", + "config" + .= object + [ "deletionTimeout" .= (7 :: Int), + "reminderTimeouts" .= ([2] :: [Int]), + "promotionStrategy" .= "random" + ] + ] + ) + >>= assertSuccess + + alice1 <- createMLSClient def alice + void $ uploadNewKeyPackage def alice1 + + conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201 + convId <- objConvId conv + convIdText <- conv %. "qualified_id.id" & asString + aliceIdText <- alice %. "qualified_id.id" & asString + createGroup def alice1 convId + void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle + + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + + now <- liftIO getCurrentTime + let deletionAt = addUTCTime (-5) now + + postArbiterJob + alice + "adminless_deletion_jobs" + [ "payload" + .= object + [ "team_id" .= tid, + "conversation_id" .= convIdText, + "orig_user_id" .= aliceIdText + ], + "groupKey" .= convIdText, + "priority" .= (0 :: Int), + "notVisibleUntil" .= deletionAt, + "dedupKey" .= Null, + "maxAttempts" .= (10 :: Int) + ] + >>= assertSuccess + + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + + eventually $ do + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 404 + where + postArbiterJob user table body = do + req <- baseRequest user Brig Unversioned $ joinHttpPath ["i", "jobs", "api", "v1", table, "jobs"] + submit "POST" $ addJSONObject body req + testOnLastAdminLeaveFeatureDisabled :: (HasCallStack) => App () testOnLastAdminLeaveFeatureDisabled = do -- bob is eligible From b89993df0d3a791e6a37d3d523a929066ba626d4 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 06:38:12 +0000 Subject: [PATCH 24/65] make the arbiter config explicit --- .../src/Wire/JobSubsystem/Interpreter.hs | 2 + .../src/Wire/JobSubsystem/Workers.hs | 48 +++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 5457647baa0..539c31142fd 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -88,6 +88,7 @@ scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId schedule arbiterJob = (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName (AdminlessDeletionJob teamId convId (tUnqualified lusr))) { ArbiterCore.notVisibleUntil = Just scheduledFor + , ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job embed $ @@ -126,6 +127,7 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUnti arbiterJob = (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) daysUntilDeletion)) { ArbiterCore.notVisibleUntil = Just scheduledFor + , ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job embed $ diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index b41a6d0e8cc..03ac3b3bafe 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -108,6 +108,7 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do ( \_ scheduledFor -> (ArbiterCore.defaultGroupedJob recurringJobRunnerQueueName MeetingsCleanupJob) { ArbiterCore.notVisibleUntil = Just scheduledFor + , ArbiterCore.maxAttempts = Just 3 } ) of Left err -> error $ "Invalid cron schedule for " <> T.unpack recurringJobRunnerJobName <> ": " <> err @@ -133,7 +134,7 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do ) ) let workerConfig' = - workerConfig + applyExplicitDefaults workerConfig { ArbiterWorkerConfig.cronJobs = [cronJob] } @@ -195,12 +196,53 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do () ) ) + let workerConfig' = applyExplicitDefaults workerConfig workerAsync <- Async.async $ ArbiterHasql.runHasqlDb arbiterEnv $ - ArbiterWorker.runWorkerPool workerConfig + ArbiterWorker.runWorkerPool workerConfig' pure $ do - ArbiterWorker.shutdownWorker workerConfig + ArbiterWorker.shutdownWorker workerConfig' Async.cancel workerAsync + +-- | Make the effective Arbiter defaults explicit in our code, even when we +-- keep the same values as 'defaultWorkerConfig'. +-- +-- This is intentionally redundant with Arbiter's own defaults. The point is to +-- show the runtime behavior in our repository and keep the knobs in one place +-- if we need to tune them later. +applyExplicitDefaults :: + ArbiterWorker.WorkerConfig m payload result -> + ArbiterWorker.WorkerConfig m payload result +applyExplicitDefaults cfg = + cfg + { -- How often the dispatcher wakes up to look for newly visible jobs. + -- Lower values reduce discovery latency at the cost of more DB traffic. + ArbiterWorkerConfig.pollInterval = 5, + -- How long a claimed job stays invisible while a worker processes it. + -- Must exceed the job heartbeat interval so active jobs are not reclaimed. + ArbiterWorkerConfig.visibilityTimeout = 60, + -- How often a running job refreshes its visibility timeout. + -- Keeps long-running jobs from being reclaimed mid-flight. + ArbiterWorkerConfig.jobHeartbeatInterval = 30, + -- How often the worker process updates its own heartbeat and pause state. + -- This drives liveness, re-registration, and paused-state reconciliation. + ArbiterWorkerConfig.workerHeartbeatInterval = 10, + -- Retry strategy for transient worker failures. + -- Arbiter uses exponential backoff with jitter by default. + ArbiterWorkerConfig.backoffStrategy = ArbiterWorker.exponentialBackoff 2.0 1_048_576, + -- Jitter mode for retry delays. + -- Equal jitter smooths retry spikes without making them too aggressive. + ArbiterWorkerConfig.jitter = ArbiterWorker.EqualJitter, + -- How long the worker waits for in-flight jobs during shutdown. + -- If set, the pool exits after this grace period instead of waiting forever. + ArbiterWorkerConfig.gracefulShutdownTimeout = Just 30, + -- How often the reaper runs. It refreshes groups, sweeps stale workers, + -- and moves exhausted jobs to the DLQ. + ArbiterWorkerConfig.reaperInterval = 300, + -- How old a worker heartbeat may be before it is considered stale. + -- Stale workers are swept from the registry by the reaper. + ArbiterWorkerConfig.workerStaleThreshold = 300 + } From 5419adeac698c1017fabc4ce3d8ba62d896832b9 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 06:45:54 +0000 Subject: [PATCH 25/65] make arbiter worker parallelism setting explicit --- .../src/Wire/BackgroundWorker/ScheduledJobs.hs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 459abcdf89f..3d275e12c3a 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -33,6 +33,13 @@ import Wire.JobSubsystem import Wire.JobSubsystem.Workers import Wire.MeetingsCleanupWorker +-- | Initial worker pool size for the scheduled-jobs queues. +-- +-- Keep this explicit so the next tuning pass has a single obvious place to +-- change the parallelism for meetings cleanup and adminless jobs. +scheduledJobsWorkerThreads :: Int +scheduledJobsWorkerThreads = 1 + startWorker :: MeetingsCleanupConfig -> AppT IO CleanupAction startWorker config = do env <- ask @@ -62,7 +69,7 @@ startWorker config = do recurringJobRunnerSchedule = config.schedule, recurringJobRunnerArbiterConnStr = env.arbiterConnStr, recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, - recurringJobRunnerWorkerThreads = 1, + recurringJobRunnerWorkerThreads = scheduledJobsWorkerThreads, recurringJobRunnerJobName = "meetings-cleanup", recurringJobRunnerQueueName = meetingsCleanupQueueName }, @@ -71,7 +78,7 @@ startWorker config = do { oneOffJobRunnerLogger = env.logger, oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, - oneOffJobRunnerWorkerThreads = 1, + oneOffJobRunnerWorkerThreads = scheduledJobsWorkerThreads, oneOffJobRunnerJobName = "adminless-deletion", oneOffJobRunnerQueueName = adminlessDeletionQueueName }, @@ -80,7 +87,7 @@ startWorker config = do { oneOffJobRunnerLogger = env.logger, oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, - oneOffJobRunnerWorkerThreads = 1, + oneOffJobRunnerWorkerThreads = scheduledJobsWorkerThreads, oneOffJobRunnerJobName = "adminless-reminder", oneOffJobRunnerQueueName = adminlessReminderQueueName } From 1d8cadc5be4411807e94bde356b8b5a5911a17b3 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 09:01:58 +0000 Subject: [PATCH 26/65] use correct feature store --- services/background-worker/src/Wire/Effects.hs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 9f2324cf499..7fbbe5f1ee1 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -98,6 +98,7 @@ import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) +import Wire.MigrationLock (MigrationLockError) import Wire.NotificationSubsystem (NotificationSubsystem) import Wire.NotificationSubsystem.Interpreter import Wire.Options.Galley (GuestLinkTTLSeconds) @@ -129,6 +130,8 @@ import Wire.TeamCollaboratorsSubsystem.Interpreter (interpretTeamCollaboratorsSu import Wire.TeamFeatureStore (TeamFeatureStore) import Wire.TeamFeatureStore.Cassandra (interpretTeamFeatureStoreToCassandra) import Wire.TeamFeatureStore.Error (TeamFeatureStoreError) +import Wire.TeamFeatureStore.Migrating (interpretTeamFeatureStoreToCassandraAndPostgres) +import Wire.TeamFeatureStore.Postgres (interpretTeamFeatureStoreToPostgres) import Wire.TeamJournal (TeamJournal) import Wire.TeamJournal.Aws (interpretTeamJournal) import Wire.TeamStore (TeamStore) @@ -249,6 +252,7 @@ type BackgroundWorkerEffects = Error (Tagged CodeStoreNotFound ()), Error TeamFeatureStoreError, Error TeamCollaboratorsError, + Error MigrationLockError, Error UnreachableBackends, Error InternalError, Error MigrationError, @@ -297,6 +301,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . mapError @MigrationError (T.pack . show) . mapError @InternalError (TL.toStrict . internalErrorDescription) . mapError @UnreachableBackends (T.pack . show) + . mapError @MigrationLockError (const ("Migration lock error" :: Text)) . mapError @TeamCollaboratorsError (const ("Team collaborators error" :: Text)) . mapError @TeamFeatureStoreError (const ("Team feature store error" :: Text)) . mapError @(Tagged 'CodeStoreNotFound ()) (const ("Code store not found" :: Text)) @@ -324,7 +329,13 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretProposalStoreToCassandra . interpretServiceStoreToCassandra env.cassandraBrig . interpretUserGroupStoreToPostgres - . interpretTeamFeatureStoreToCassandra + . case env.postgresMigration.teamFeatures of + CassandraStorage -> + interpretTeamFeatureStoreToCassandra + MigrationToPostgresql -> + interpretTeamFeatureStoreToCassandraAndPostgres + PostgresqlStorage -> + interpretTeamFeatureStoreToPostgres . interpretUserClientIndexStoreToCassandra env.cassandraGalley . interpretConversationStoreByMigration env.postgresMigration.conversation env.cassandraGalley . interpretTeamStoreToCassandra From a7c55c3971db3789f20b8d75a0a207812625c196 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 09:03:01 +0000 Subject: [PATCH 27/65] logging --- .../src/Wire/AdminlessJobsWorker.hs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs index 090c6d9c032..beb990df321 100644 --- a/services/background-worker/src/Wire/AdminlessJobsWorker.hs +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -44,10 +44,20 @@ runAdminlessDeletionJob extEnv job = do Log.msg (Log.val "Running adminless deletion job") . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) . Log.field "conversation_id" (show (adminlessDeletionJobConversationId jobPayload)) + Log.info env.logger $ + Log.msg (Log.val "Adminless deletion job payload") + . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) + . Log.field "conversation_id" (show (adminlessDeletionJobConversationId jobPayload)) + . Log.field "orig_user_id" (show (adminlessDeletionJobOrigUserId jobPayload)) + . Log.field "scheduled_for" (show (notVisibleUntil job)) result <- liftIO $ runBackgroundWorkerEffects env extEnv (RequestId "adminless-deletion") Nothing $ do + Log.info env.logger $ + Log.msg (Log.val "Adminless deletion job: invoking conversation delete") + . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) + . Log.field "conversation_id" (show (adminlessDeletionJobConversationId jobPayload)) internalDeleteLocalAdminlessGroup (toLocalUnsafe env.federationDomain (adminlessDeletionJobOrigUserId jobPayload)) (toLocalUnsafe env.federationDomain (adminlessDeletionJobConversationId jobPayload)) @@ -56,6 +66,10 @@ runAdminlessDeletionJob extEnv job = do (adminlessDeletionJobTeamId jobPayload) (adminlessDeletionJobConversationId jobPayload) (notVisibleUntil job) + Log.info env.logger $ + Log.msg (Log.val "Adminless deletion job finished") + . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) + . Log.field "conversation_id" (show (adminlessDeletionJobConversationId jobPayload)) either (liftIO . fail . show) pure result runAdminlessReminderJob :: ExtEnv -> JobRead AdminlessReminderJob -> AppT IO () @@ -80,6 +94,10 @@ runAdminlessReminderJob extEnv job = do (adminlessReminderJobTeamId jobPayload) (adminlessReminderJobConversationId jobPayload) (notVisibleUntil job) + Log.info env.logger $ + Log.msg (Log.val "Adminless reminder job finished") + . Log.field "team_id" (show (adminlessReminderJobTeamId jobPayload)) + . Log.field "conversation_id" (show (adminlessReminderJobConversationId jobPayload)) either (liftIO . fail . show) pure result cleanupScheduledJob :: From 5bd6df0dadd2bea914f20429cabf3e61c38aa915 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 09:03:43 +0000 Subject: [PATCH 28/65] poll intervall configurable --- charts/wire-server/values.yaml | 6 ++++++ .../src/Wire/JobSubsystem/Workers.hs | 12 ++++++++---- .../background-worker.integration.yaml | 4 ++++ .../src/Wire/BackgroundWorker.hs | 2 +- .../src/Wire/BackgroundWorker/Options.hs | 9 +++++++++ .../src/Wire/BackgroundWorker/ScheduledJobs.hs | 16 +++++++++++++--- 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 59274ebf5fc..18e62025816 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -1002,6 +1002,12 @@ background-worker: # Total attempts, including the first try maxAttempts: 3 + # Scheduled-jobs dispatcher configuration. + scheduledJobs: + # Arbiter dispatcher poll interval for all scheduled jobs. + # Lower values reduce discovery latency, but increase DB polling. + pollInterval: 5s + # Meetings cleanup configuration meetingsCleanup: # Delete meetings older than this many hours (48 hours = 2 days) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 03ac3b3bafe..0efa170fdb4 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -39,6 +39,7 @@ import Data.ByteString qualified as ByteString import Data.Kind (Type) import Data.Proxy (Proxy (..)) import Data.Text qualified as T +import Data.Time.Clock (NominalDiffTime) import GHC.TypeLits (KnownSymbol) import Imports import System.Cron (CronSchedule, serializeCronSchedule) @@ -51,6 +52,7 @@ data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig recurringJobRunnerSchedule :: CronSchedule, recurringJobRunnerArbiterConnStr :: ByteString.ByteString, recurringJobRunnerSchemaName :: Text, + recurringJobRunnerPollInterval :: NominalDiffTime, recurringJobRunnerWorkerThreads :: Int, recurringJobRunnerJobName :: Text, recurringJobRunnerQueueName :: Text @@ -60,6 +62,7 @@ data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig { oneOffJobRunnerLogger :: Log.Logger, oneOffJobRunnerArbiterConnStr :: ByteString.ByteString, oneOffJobRunnerSchemaName :: Text, + oneOffJobRunnerPollInterval :: NominalDiffTime, oneOffJobRunnerWorkerThreads :: Int, oneOffJobRunnerJobName :: Text, oneOffJobRunnerQueueName :: Text @@ -134,7 +137,7 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do ) ) let workerConfig' = - applyExplicitDefaults workerConfig + applyExplicitDefaults recurringJobRunnerPollInterval workerConfig { ArbiterWorkerConfig.cronJobs = [cronJob] } @@ -196,7 +199,7 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do () ) ) - let workerConfig' = applyExplicitDefaults workerConfig + let workerConfig' = applyExplicitDefaults oneOffJobRunnerPollInterval workerConfig workerAsync <- Async.async $ @@ -214,13 +217,14 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do -- show the runtime behavior in our repository and keep the knobs in one place -- if we need to tune them later. applyExplicitDefaults :: + NominalDiffTime -> ArbiterWorker.WorkerConfig m payload result -> ArbiterWorker.WorkerConfig m payload result -applyExplicitDefaults cfg = +applyExplicitDefaults pollInterval cfg = cfg { -- How often the dispatcher wakes up to look for newly visible jobs. -- Lower values reduce discovery latency at the cost of more DB traffic. - ArbiterWorkerConfig.pollInterval = 5, + ArbiterWorkerConfig.pollInterval = pollInterval, -- How long a claimed job stays invisible while a worker processes it. -- Must exceed the job heartbeat interval so active jobs are not reclaimed. ArbiterWorkerConfig.visibilityTimeout = 60, diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index 97734fccd3b..7491379215f 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -65,6 +65,10 @@ backgroundJobs: jobTimeout: 5s maxAttempts: 3 +# Scheduled-jobs dispatcher configuration for integration +scheduledJobs: + pollInterval: 1s # Poll every second so due jobs are discovered promptly in tests + # Meetings cleanup configuration for integration meetingsCleanup: cleanOlderThanHours: 0.0014 # Clean meetings older than ~5 seconds diff --git a/services/background-worker/src/Wire/BackgroundWorker.hs b/services/background-worker/src/Wire/BackgroundWorker.hs index 1ffc8eb85f0..ffee3ff23bf 100644 --- a/services/background-worker/src/Wire/BackgroundWorker.hs +++ b/services/background-worker/src/Wire/BackgroundWorker.hs @@ -85,7 +85,7 @@ run opts galleyOpts = do cleanupScheduledJobs <- runAppT env $ withNamedLogger "scheduled-jobs" $ - ScheduledJobs.startWorker opts.meetingsCleanup + ScheduledJobs.startWorker opts.scheduledJobs opts.meetingsCleanup let cleanup = void $ runConcurrently $ diff --git a/services/background-worker/src/Wire/BackgroundWorker/Options.hs b/services/background-worker/src/Wire/BackgroundWorker/Options.hs index f89c52e8300..892904ee81b 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Options.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Options.hs @@ -55,6 +55,7 @@ data Opts = Opts migrateConversationCodes :: !Bool, migrateTeamFeatures :: !Bool, migrateDomainRegistration :: !Bool, + scheduledJobs :: ScheduledJobsConfig, meetingsCleanup :: MeetingsCleanupConfig, backgroundJobs :: BackgroundJobsConfig } @@ -99,6 +100,14 @@ data BackgroundJobsConfig = BackgroundJobsConfig deriving (Show, Generic) deriving (FromJSON) via Generically BackgroundJobsConfig +data ScheduledJobsConfig = ScheduledJobsConfig + { -- | Arbiter dispatcher poll interval for scheduled jobs. + -- Lower values reduce discovery latency for due jobs. + pollInterval :: Duration + } + deriving (Show, Generic) + deriving (FromJSON) via Generically ScheduledJobsConfig + data MeetingsCleanupConfig = MeetingsCleanupConfig { -- | Delete meetings older than this many hours cleanOlderThanHours :: Double, diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 3d275e12c3a..9f25ce35e96 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -22,11 +22,13 @@ module Wire.BackgroundWorker.ScheduledJobs (startWorker) where import Arbiter.Core qualified as ArbiterCore import Data.Id (RequestId (..)) +import Data.Misc (Duration, durationToMicros) +import Data.Time.Clock (NominalDiffTime) import Imports import Wire.API.Jobs import Wire.AdminlessJobsWorker (runAdminlessDeletionJob, runAdminlessReminderJob) import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) -import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) +import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..), ScheduledJobsConfig (..)) import Wire.Effects (runBackgroundWorkerEffects) import Wire.ExternalAccess.External (initExtEnv) import Wire.JobSubsystem @@ -40,8 +42,8 @@ import Wire.MeetingsCleanupWorker scheduledJobsWorkerThreads :: Int scheduledJobsWorkerThreads = 1 -startWorker :: MeetingsCleanupConfig -> AppT IO CleanupAction -startWorker config = do +startWorker :: ScheduledJobsConfig -> MeetingsCleanupConfig -> AppT IO CleanupAction +startWorker scheduledConfig config = do env <- ask extEnv <- liftIO $ initExtEnv True let cleanupConfig = @@ -69,6 +71,7 @@ startWorker config = do recurringJobRunnerSchedule = config.schedule, recurringJobRunnerArbiterConnStr = env.arbiterConnStr, recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + recurringJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, recurringJobRunnerWorkerThreads = scheduledJobsWorkerThreads, recurringJobRunnerJobName = "meetings-cleanup", recurringJobRunnerQueueName = meetingsCleanupQueueName @@ -78,6 +81,7 @@ startWorker config = do { oneOffJobRunnerLogger = env.logger, oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + oneOffJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, oneOffJobRunnerWorkerThreads = scheduledJobsWorkerThreads, oneOffJobRunnerJobName = "adminless-deletion", oneOffJobRunnerQueueName = adminlessDeletionQueueName @@ -87,6 +91,7 @@ startWorker config = do { oneOffJobRunnerLogger = env.logger, oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, + oneOffJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, oneOffJobRunnerWorkerThreads = scheduledJobsWorkerThreads, oneOffJobRunnerJobName = "adminless-reminder", oneOffJobRunnerQueueName = adminlessReminderQueueName @@ -97,3 +102,8 @@ startWorker config = do runBackgroundWorkerEffects env extEnv (RequestId "scheduled-jobs") Nothing $ startJobWorkers workersConfig jobHandlers either (liftIO . fail . show) pure result + +-- | Convert the explicit background-worker config into the Arbiter poll +-- interval in seconds. +scheduledJobsPollIntervalSeconds :: Duration -> NominalDiffTime +scheduledJobsPollIntervalSeconds = (/ 1_000_000) . fromIntegral . durationToMicros From 8e95209527895b9fc9094b6e08bd2b48384a67a3 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 09:21:44 +0000 Subject: [PATCH 29/65] comment and formatting --- .../src/Wire/JobSubsystem/Workers.hs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 0efa170fdb4..d4022f43b6d 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -110,14 +110,17 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do ArbiterWorkerCron.SkipOverlap ( \_ scheduledFor -> (ArbiterCore.defaultGroupedJob recurringJobRunnerQueueName MeetingsCleanupJob) - { ArbiterCore.notVisibleUntil = Just scheduledFor - , ArbiterCore.maxAttempts = Just 3 + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.maxAttempts = Just 3 } ) of Left err -> error $ "Invalid cron schedule for " <> T.unpack recurringJobRunnerJobName <> ": " <> err Right job -> job void $ + -- Arbiter can optionally use LISTEN/NOTIFY to wake workers sooner, but we + -- keep polling as the baseline and treat notifications as a latency + -- optimization rather than a correctness requirement. ArbiterMigrations.runMigrationsForRegistry registry recurringJobRunnerArbiterConnStr @@ -137,9 +140,11 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do ) ) let workerConfig' = - applyExplicitDefaults recurringJobRunnerPollInterval workerConfig - { ArbiterWorkerConfig.cronJobs = [cronJob] - } + applyExplicitDefaults + recurringJobRunnerPollInterval + workerConfig + { ArbiterWorkerConfig.cronJobs = [cronJob] + } workerAsync <- Async.async $ From 69744a7d02e9c79de5597ba8ebb67ef5a9f74862 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 09:21:59 +0000 Subject: [PATCH 30/65] formatting --- libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 539c31142fd..84db8aff397 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -87,8 +87,8 @@ scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId schedule } arbiterJob = (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName (AdminlessDeletionJob teamId convId (tUnqualified lusr))) - { ArbiterCore.notVisibleUntil = Just scheduledFor - , ArbiterCore.maxAttempts = Just 3 + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job embed $ @@ -126,8 +126,8 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUnti } arbiterJob = (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) daysUntilDeletion)) - { ArbiterCore.notVisibleUntil = Just scheduledFor - , ArbiterCore.maxAttempts = Just 3 + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job embed $ From 558609778db9fc817a0b0e625aec2d508b7a2bf0 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 09:26:20 +0000 Subject: [PATCH 31/65] update test for job excecution --- integration/test/Test/AdminlessGroups.hs | 87 ++++++++++++++++-------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index b2ee9bb0213..a56eea857de 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -193,21 +193,13 @@ testAdminlessJobsExecuteViaArbiterApi :: (HasCallStack) => App () testAdminlessJobsExecuteViaArbiterApi = do (alice, tid, _) <- createTeam OwnDomain 1 + -- we diable the feature and create an adminless group setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" patchTeamFeature OwnDomain tid "preventAdminlessGroups" - ( object - [ "status" .= "enabled", - "config" - .= object - [ "deletionTimeout" .= (7 :: Int), - "reminderTimeouts" .= ([2] :: [Int]), - "promotionStrategy" .= "random" - ] - ] - ) + (object ["status" .= "disabled"]) >>= assertSuccess alice1 <- createMLSClient def alice @@ -223,33 +215,68 @@ testAdminlessJobsExecuteViaArbiterApi = do bindResponse (removeMember alice conv alice) $ \resp -> do resp.status `shouldMatchInt` 200 - now <- liftIO getCurrentTime - let deletionAt = addUTCTime (-5) now + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 200 - postArbiterJob - alice - "adminless_deletion_jobs" - [ "payload" - .= object - [ "team_id" .= tid, - "conversation_id" .= convIdText, - "orig_user_id" .= aliceIdText - ], - "groupKey" .= convIdText, - "priority" .= (0 :: Int), - "notVisibleUntil" .= deletionAt, - "dedupKey" .= Null, - "maxAttempts" .= (10 :: Int) - ] + -- now we enabled the feature so that the job will get excecuted + patchTeamFeature + OwnDomain + tid + "preventAdminlessGroups" + ( object + [ "status" .= "enabled", + "config" + .= object + [ "deletionTimeout" .= (7 :: Int), + "reminderTimeouts" .= ([2] :: [Int]), + "promotionStrategy" .= "random" + ] + ] + ) >>= assertSuccess - bindResponse (GalleyI.getConversation conv) $ \resp -> do - resp.status `shouldMatchInt` 200 + now <- liftIO getCurrentTime + let deletionAt = addUTCTime (-5) now - eventually $ do + insertedJob <- + postArbiterJob + alice + "adminless_deletion_jobs" + [ "payload" + .= object + [ "team_id" .= tid, + "conversation_id" .= convIdText, + "orig_user_id" .= aliceIdText + ], + "groupKey" .= convIdText, + "priority" .= (0 :: Int), + "notVisibleUntil" .= deletionAt, + "dedupKey" .= Null, + "maxAttempts" .= (3 :: Int) + ] + >>= getJSON 200 + >>= (%. "job") + assertArbiterJobMatches insertedJob tid convIdText deletionAt + + retryT $ do bindResponse (GalleyI.getConversation conv) $ \resp -> do resp.status `shouldMatchInt` 404 where + assertArbiterJobMatches job expectedTeamId expectedConvId expectedNotVisibleUntil = do + jobTeamId <- job %. "payload.team_id" & asString + jobConversationId <- job %. "payload.conversation_id" & asString + jobTeamId `shouldMatch` expectedTeamId + jobConversationId `shouldMatch` expectedConvId + assertJobVisibleAt expectedNotVisibleUntil job + + assertJobVisibleAt expected job = do + notVisibleUntilStr <- job %. "notVisibleUntil" & asString + notVisibleUntil <- assertJust ("expected ISO 8601 timestamp, got: " <> notVisibleUntilStr) $ iso8601ParseM @Maybe @UTCTime notVisibleUntilStr + let tolerance = 5 :: NominalDiffTime + assertBool + ("notVisibleUntil " <> show notVisibleUntil <> " is not within tolerance of expected " <> show expected) + (notVisibleUntil >= addUTCTime (negate tolerance) expected && notVisibleUntil <= addUTCTime tolerance expected) + postArbiterJob user table body = do req <- baseRequest user Brig Unversioned $ joinHttpPath ["i", "jobs", "api", "v1", table, "jobs"] submit "POST" $ addJSONObject body req From baadc18305661fc75b9cd5b4da87fd7b500ff72d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 10:38:34 +0000 Subject: [PATCH 32/65] fix bw config map and add config options doc --- charts/wire-server/templates/background-worker/configmap.yaml | 4 ++++ docs/src/developer/reference/config-options.md | 1 + 2 files changed, 5 insertions(+) diff --git a/charts/wire-server/templates/background-worker/configmap.yaml b/charts/wire-server/templates/background-worker/configmap.yaml index e7e6f1d2262..ae4bee7d2e0 100644 --- a/charts/wire-server/templates/background-worker/configmap.yaml +++ b/charts/wire-server/templates/background-worker/configmap.yaml @@ -93,6 +93,10 @@ data: backgroundJobs: {{ toYaml . | indent 6 }} {{- end }} + scheduledJobs: + # Arbiter dispatcher poll interval for all scheduled jobs. + # Lower values reduce discovery latency, but increase DB polling. + pollInterval: {{ .scheduledJobs.pollInterval }} {{- with .meetingsCleanup }} meetingsCleanup: {{ toYaml . | indent 6 }} diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 407e7d9ef4e..30e11bcb597 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -2235,3 +2235,4 @@ Notes - The `migrate...` flags control the corresponding PostgreSQL backfill jobs for the current migration settings; leave them `false` for new installs and after migration. - `concurrency`, `jobTimeout`, and `maxAttempts` control parallelism and retry behavior of the consumer. - `brig` and `gundeck` endpoints default to in-cluster services; override via `background-worker.config.brig` and `.gundeck` if your service DNS/ports differ. +- `scheduledJobs.pollInterval` controls how often the background worker wakes up to check Arbiter for due jobs. From 777495b553c4d4ef9b2b37d967999f38d9ca7156 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 10:39:29 +0000 Subject: [PATCH 33/65] changelog --- changelog.d/0-release-notes/WPB-26489 | 1 + changelog.d/2-features/WPB-26489 | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/0-release-notes/WPB-26489 create mode 100644 changelog.d/2-features/WPB-26489 diff --git a/changelog.d/0-release-notes/WPB-26489 b/changelog.d/0-release-notes/WPB-26489 new file mode 100644 index 00000000000..ccadf9d1c74 --- /dev/null +++ b/changelog.d/0-release-notes/WPB-26489 @@ -0,0 +1 @@ +Background-worker scheduled jobs now run through Arbiter. The background-worker configuration includes `scheduledJobs.pollInterval`, which controls how often the scheduler wakes up to look for due jobs. Arbiter's internal UI is available under `/i/jobs`, and its internal REST API is available under `/i/jobs/api/v1`. diff --git a/changelog.d/2-features/WPB-26489 b/changelog.d/2-features/WPB-26489 new file mode 100644 index 00000000000..14def7ff4c3 --- /dev/null +++ b/changelog.d/2-features/WPB-26489 @@ -0,0 +1 @@ +Introduce Arbiter-backed schedulable background jobs, migrate meetings cleanup to the new job runner, and add the initial adminless reminder and deletion jobs. From 903b4be431ef654caf46fe74449e49747b9c879a Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 29 Jun 2026 12:50:14 +0000 Subject: [PATCH 34/65] formatting --- libs/wire-subsystems/src/Wire/Options/Keys.hs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/Options/Keys.hs b/libs/wire-subsystems/src/Wire/Options/Keys.hs index b44c51b44c6..ef25201119b 100644 --- a/libs/wire-subsystems/src/Wire/Options/Keys.hs +++ b/libs/wire-subsystems/src/Wire/Options/Keys.hs @@ -28,10 +28,6 @@ import Crypto.ECC hiding (KeyPair) import Crypto.Error import Crypto.PubKey.ECDSA qualified as ECDSA import Crypto.PubKey.Ed25519 qualified as Ed25519 -import "crypton-asn1-encoding" Data.ASN1.BinaryEncoding -import "crypton-asn1-types" Data.ASN1.BitArray -import "crypton-asn1-encoding" Data.ASN1.Encoding -import "crypton-asn1-types" Data.ASN1.Types import Data.Bifunctor import Data.ByteString.Lazy qualified as LBS import Data.PEM @@ -41,6 +37,10 @@ import Imports import Network.Wai.Utilities.Exception import Wire.API.MLS.CipherSuite import Wire.API.MLS.Keys +import "crypton-asn1-encoding" Data.ASN1.BinaryEncoding +import "crypton-asn1-encoding" Data.ASN1.Encoding +import "crypton-asn1-types" Data.ASN1.BitArray +import "crypton-asn1-types" Data.ASN1.Types type MLSPrivateKeyPaths = MLSKeysByPurpose (MLSKeys FilePath) From 3b4eb043fa3f49560da292a2647230689db0a4df Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 1 Jul 2026 12:16:14 +0000 Subject: [PATCH 35/65] wip: arbiter adapter --- .../src/Wire/JobSubsystem/ArbiterAdapter.hs | 85 +++++++++++++++++++ .../src/Wire/JobSubsystem/Interpreter.hs | 51 +++++------ libs/wire-subsystems/wire-subsystems.cabal | 1 + 3 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs new file mode 100644 index 00000000000..c3bb6b8ba8b --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -0,0 +1,85 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE UndecidableInstances #-} + +-- | Temporary adapter that lets wire-server run Arbiter operations against the +-- existing shared Hasql pool. +-- +-- The intent is to keep Arbiter logic generic over the monad while wire-server +-- provides the concrete pool and schema inputs from its own effect stack. +-- +-- This is intentionally incomplete: transaction pinning is the next boundary +-- that would need a real pool-level abstraction. +module Wire.JobSubsystem.ArbiterAdapter where + +import Arbiter.Core.Exceptions (throwInternal) +import Arbiter.Core.HasArbiterSchema (HasArbiterSchema (..)) +import Arbiter.Core.MonadArbiter (MonadArbiter (..)) +import Arbiter.Core.QueueRegistry (JobPayloadRegistry) +import Arbiter.Hasql.Decode qualified as Decode +import Arbiter.Hasql.Encode qualified as Encode +import Data.Text qualified as T +import Hasql.Connection qualified as Hasql +import Hasql.Pool qualified as HasqlPool +import Hasql.Session qualified as Session +import Hasql.Statement qualified as Statement +import Imports + +data WireArbiterEnv = WireArbiterEnv + { schemaName :: Text, + pool :: HasqlPool.Pool, + inTransaction :: Bool + } + +newtype WireArbiter (registry :: JobPayloadRegistry) a = WireArbiter {unWireArbiter :: ReaderT WireArbiterEnv IO a} + deriving newtype + ( Applicative, + Functor, + Monad, + MonadReader WireArbiterEnv + ) + +instance MonadIO (WireArbiter registry) where + liftIO = WireArbiter . liftIO + +runWireArbiter :: WireArbiterEnv -> WireArbiter registry a -> IO a +runWireArbiter env (WireArbiter action) = runReaderT action env + +instance HasArbiterSchema (WireArbiter registry) registry where + getSchema = WireArbiter $ asks schemaName + +instance MonadArbiter (WireArbiter registry) where + type Handler (WireArbiter registry) jobs result = jobs -> WireArbiter registry result + + executeQuery sql params codec = WireArbiter $ do + env <- ask + result <- + liftIO $ + HasqlPool.use env.pool $ + Session.statement + () + (Statement.unpreparable (Encode.convertPlaceholders sql) (Encode.buildEncoder params) (Decode.hasqlRowDecoder codec)) + case result of + Right rows -> pure rows + Left err -> throwInternal $ "hasql query error: " <> T.pack (show err) + + executeStatement sql params = WireArbiter $ do + env <- ask + result <- + liftIO $ + HasqlPool.use env.pool $ + Session.statement () (Encode.buildStatementRowCount sql params) + case result of + Right n -> pure n + Left err -> throwInternal $ "hasql statement error: " <> T.pack (show err) + + withDbTransaction (WireArbiter action) = do + pool <- asks (.pool) + + WireArbiter $ do + action + + runHandlerWithConnection handler jobs = + handler jobs diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 84db8aff397..0780e073a23 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -27,17 +27,19 @@ module Wire.JobSubsystem.Interpreter where import Arbiter.Core qualified as ArbiterCore -import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Data.Id import Data.Proxy (Proxy (..)) import Data.Qualified import Data.Time import Data.UUID.V4 qualified as UUID +import Hasql.Pool qualified as Hasql import Imports import Polysemy +import Polysemy.Input (Input, input) import Wire.API.Jobs import Wire.JobStore qualified as JobStore import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkerHandlers (..), JobWorkersConfig (..)) +import Wire.JobSubsystem.ArbiterAdapter import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) runJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> IO CleanupAction @@ -49,7 +51,8 @@ runJobWorkers JobWorkersConfig {..} JobWorkerHandlers {..} = do interpretJobSubsystem :: ( Member JobStore.JobStore r, - Member (Embed IO) r + Member (Embed IO) r, + Member (Input Hasql.Pool) r ) => JobSubsystemConfig -> InterpreterFor JobSubsystem r @@ -62,7 +65,7 @@ interpretJobSubsystem conf = scheduleAdminlessDeletionJob :: forall r. - (Member (Embed IO) r, Member JobStore.JobStore r) => + (Member (Embed IO) r, Member JobStore.JobStore r, Member (Input Hasql.Pool) r) => JobSubsystemConfig -> Local UserId -> TeamId -> @@ -70,12 +73,8 @@ scheduleAdminlessDeletionJob :: UTCTime -> Sem r ScheduledJob scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do - arbiterEnv <- - embed $ - ArbiterHasql.createHasqlEnv - (Proxy @ScheduledJobsRegistry) - jobSubsystemArbiterConnStr - jobSubsystemSchemaName + pool <- input + let env = WireArbiterEnv jobSubsystemSchemaName pool jobId <- embed $ Id <$> UUID.nextRandom let job = ScheduledJob @@ -91,16 +90,20 @@ scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId schedule ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job - embed $ - void $ - ArbiterHasql.runHasqlDb arbiterEnv $ + liftIO $ + runWireArbiter env $ + WireArbiter $ void $ - ArbiterCore.insertJob arbiterJob + unWireArbiter $ + ArbiterCore.insertJob + @(WireArbiter ScheduledJobsRegistry) + @ScheduledJobsRegistry + arbiterJob pure job scheduleAdminlessReminderJob :: forall r. - (Member (Embed IO) r, Member JobStore.JobStore r) => + (Member (Embed IO) r, Member JobStore.JobStore r, Member (Input Hasql.Pool) r) => JobSubsystemConfig -> Local UserId -> TeamId -> @@ -109,12 +112,8 @@ scheduleAdminlessReminderJob :: UTCTime -> Sem r ScheduledJob scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUntilDeletion scheduledFor = do - arbiterEnv <- - embed $ - ArbiterHasql.createHasqlEnv - (Proxy @ScheduledJobsRegistry) - jobSubsystemArbiterConnStr - jobSubsystemSchemaName + pool <- input + let env = WireArbiterEnv jobSubsystemSchemaName pool jobId <- embed $ Id <$> UUID.nextRandom let job = ScheduledJob @@ -130,9 +129,13 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUnti ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job - embed $ - void $ - ArbiterHasql.runHasqlDb arbiterEnv $ + liftIO $ + runWireArbiter env $ + WireArbiter $ void $ - ArbiterCore.insertJob arbiterJob + unWireArbiter $ + ArbiterCore.insertJob + @(WireArbiter ScheduledJobsRegistry) + @ScheduledJobsRegistry + arbiterJob pure job diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 74f0e292019..71533f5de9d 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -375,6 +375,7 @@ library Wire.JobStore Wire.JobStore.Postgres Wire.JobSubsystem + Wire.JobSubsystem.ArbiterAdapter Wire.JobSubsystem.Interpreter Wire.JobSubsystem.Workers Wire.LegalHold From fe879e326e50e06a4debdd35acaf27f13ae34c0b Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 09:08:06 +0200 Subject: [PATCH 36/65] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- integration/test/Test/AdminlessGroups.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index a56eea857de..75323f86726 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -193,7 +193,7 @@ testAdminlessJobsExecuteViaArbiterApi :: (HasCallStack) => App () testAdminlessJobsExecuteViaArbiterApi = do (alice, tid, _) <- createTeam OwnDomain 1 - -- we diable the feature and create an adminless group + -- we disable the feature and create an adminless group setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" patchTeamFeature OwnDomain From 1e7d6874cfe6c56fc2b26406994606334502617f Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 09:08:18 +0200 Subject: [PATCH 37/65] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- integration/test/Test/AdminlessGroups.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index 75323f86726..ed6b6baa940 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -218,7 +218,7 @@ testAdminlessJobsExecuteViaArbiterApi = do bindResponse (GalleyI.getConversation conv) $ \resp -> do resp.status `shouldMatchInt` 200 - -- now we enabled the feature so that the job will get excecuted + -- now we enabled the feature so that the job will get executed patchTeamFeature OwnDomain tid From b1b1be2cf42f4d682a48fbee8e344ae8e2a7e5a6 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 1 Jul 2026 12:16:14 +0000 Subject: [PATCH 38/65] wip: arbiter adapter --- libs/wire-subsystems/default.nix | 2 ++ libs/wire-subsystems/wire-subsystems.cabal | 1 + nix/manual-overrides.nix | 1 + 3 files changed, 4 insertions(+) diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 00ac3e0a708..2edbe4fcc02 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -65,6 +65,7 @@ , hasql-resource-pool , hasql-th , hasql-transaction +, hasql-transaction-io , hex , hscim , HsOpenSSL @@ -213,6 +214,7 @@ mkDerivation { hasql-resource-pool hasql-th hasql-transaction + hasql-transaction-io hex hscim HsOpenSSL diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 71533f5de9d..57318e115d9 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -521,6 +521,7 @@ library , hashable , HaskellNet , HaskellNet-SSL + , hasql-transaction-io , hex , HsOpenSSL , hspec diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index df47c94eda2..955a372fbf6 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -31,6 +31,7 @@ hself: hsuper: { hasql-resource-pool = hlib.dontCheck hsuper.hasql-resource-pool; hasql-migration = hlib.markUnbroken (hlib.doJailbreak (hlib.dontCheck hsuper.hasql-migration)); hasql-transaction = hlib.dontCheck hsuper.hasql-transaction_1_2_2; + hasql-transaction-io = hlib.markUnbroken (hlib.doJailbreak hsuper.hasql-transaction-io); postgresql-binary = hlib.dontCheck (hsuper.postgresql-binary_0_15_0_1); monad-logger-aeson = hlib.markUnbroken (hlib.dontCheck hsuper.monad-logger-aeson); arbiter-core = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-core); From 636542bd4b4234d39ecf1c8b8524b0fa787f0833 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 2 Jul 2026 07:36:07 +0000 Subject: [PATCH 39/65] Revert "wip: arbiter adapter" This reverts commit 3a535ab0347da200fc2e53d0e8740fed587fd7e8. --- libs/wire-subsystems/default.nix | 2 - .../src/Wire/JobSubsystem/ArbiterAdapter.hs | 85 ------------------- .../src/Wire/JobSubsystem/Interpreter.hs | 51 ++++++----- libs/wire-subsystems/wire-subsystems.cabal | 2 - nix/manual-overrides.nix | 1 - 5 files changed, 24 insertions(+), 117 deletions(-) delete mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 2edbe4fcc02..00ac3e0a708 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -65,7 +65,6 @@ , hasql-resource-pool , hasql-th , hasql-transaction -, hasql-transaction-io , hex , hscim , HsOpenSSL @@ -214,7 +213,6 @@ mkDerivation { hasql-resource-pool hasql-th hasql-transaction - hasql-transaction-io hex hscim HsOpenSSL diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs deleted file mode 100644 index c3bb6b8ba8b..00000000000 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs +++ /dev/null @@ -1,85 +0,0 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE FlexibleInstances #-} -{-# LANGUAGE MultiParamTypeClasses #-} -{-# LANGUAGE TypeFamilies #-} -{-# LANGUAGE UndecidableInstances #-} - --- | Temporary adapter that lets wire-server run Arbiter operations against the --- existing shared Hasql pool. --- --- The intent is to keep Arbiter logic generic over the monad while wire-server --- provides the concrete pool and schema inputs from its own effect stack. --- --- This is intentionally incomplete: transaction pinning is the next boundary --- that would need a real pool-level abstraction. -module Wire.JobSubsystem.ArbiterAdapter where - -import Arbiter.Core.Exceptions (throwInternal) -import Arbiter.Core.HasArbiterSchema (HasArbiterSchema (..)) -import Arbiter.Core.MonadArbiter (MonadArbiter (..)) -import Arbiter.Core.QueueRegistry (JobPayloadRegistry) -import Arbiter.Hasql.Decode qualified as Decode -import Arbiter.Hasql.Encode qualified as Encode -import Data.Text qualified as T -import Hasql.Connection qualified as Hasql -import Hasql.Pool qualified as HasqlPool -import Hasql.Session qualified as Session -import Hasql.Statement qualified as Statement -import Imports - -data WireArbiterEnv = WireArbiterEnv - { schemaName :: Text, - pool :: HasqlPool.Pool, - inTransaction :: Bool - } - -newtype WireArbiter (registry :: JobPayloadRegistry) a = WireArbiter {unWireArbiter :: ReaderT WireArbiterEnv IO a} - deriving newtype - ( Applicative, - Functor, - Monad, - MonadReader WireArbiterEnv - ) - -instance MonadIO (WireArbiter registry) where - liftIO = WireArbiter . liftIO - -runWireArbiter :: WireArbiterEnv -> WireArbiter registry a -> IO a -runWireArbiter env (WireArbiter action) = runReaderT action env - -instance HasArbiterSchema (WireArbiter registry) registry where - getSchema = WireArbiter $ asks schemaName - -instance MonadArbiter (WireArbiter registry) where - type Handler (WireArbiter registry) jobs result = jobs -> WireArbiter registry result - - executeQuery sql params codec = WireArbiter $ do - env <- ask - result <- - liftIO $ - HasqlPool.use env.pool $ - Session.statement - () - (Statement.unpreparable (Encode.convertPlaceholders sql) (Encode.buildEncoder params) (Decode.hasqlRowDecoder codec)) - case result of - Right rows -> pure rows - Left err -> throwInternal $ "hasql query error: " <> T.pack (show err) - - executeStatement sql params = WireArbiter $ do - env <- ask - result <- - liftIO $ - HasqlPool.use env.pool $ - Session.statement () (Encode.buildStatementRowCount sql params) - case result of - Right n -> pure n - Left err -> throwInternal $ "hasql statement error: " <> T.pack (show err) - - withDbTransaction (WireArbiter action) = do - pool <- asks (.pool) - - WireArbiter $ do - action - - runHandlerWithConnection handler jobs = - handler jobs diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 0780e073a23..84db8aff397 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -27,19 +27,17 @@ module Wire.JobSubsystem.Interpreter where import Arbiter.Core qualified as ArbiterCore +import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Data.Id import Data.Proxy (Proxy (..)) import Data.Qualified import Data.Time import Data.UUID.V4 qualified as UUID -import Hasql.Pool qualified as Hasql import Imports import Polysemy -import Polysemy.Input (Input, input) import Wire.API.Jobs import Wire.JobStore qualified as JobStore import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkerHandlers (..), JobWorkersConfig (..)) -import Wire.JobSubsystem.ArbiterAdapter import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) runJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> IO CleanupAction @@ -51,8 +49,7 @@ runJobWorkers JobWorkersConfig {..} JobWorkerHandlers {..} = do interpretJobSubsystem :: ( Member JobStore.JobStore r, - Member (Embed IO) r, - Member (Input Hasql.Pool) r + Member (Embed IO) r ) => JobSubsystemConfig -> InterpreterFor JobSubsystem r @@ -65,7 +62,7 @@ interpretJobSubsystem conf = scheduleAdminlessDeletionJob :: forall r. - (Member (Embed IO) r, Member JobStore.JobStore r, Member (Input Hasql.Pool) r) => + (Member (Embed IO) r, Member JobStore.JobStore r) => JobSubsystemConfig -> Local UserId -> TeamId -> @@ -73,8 +70,12 @@ scheduleAdminlessDeletionJob :: UTCTime -> Sem r ScheduledJob scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do - pool <- input - let env = WireArbiterEnv jobSubsystemSchemaName pool + arbiterEnv <- + embed $ + ArbiterHasql.createHasqlEnv + (Proxy @ScheduledJobsRegistry) + jobSubsystemArbiterConnStr + jobSubsystemSchemaName jobId <- embed $ Id <$> UUID.nextRandom let job = ScheduledJob @@ -90,20 +91,16 @@ scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId schedule ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job - liftIO $ - runWireArbiter env $ - WireArbiter $ + embed $ + void $ + ArbiterHasql.runHasqlDb arbiterEnv $ void $ - unWireArbiter $ - ArbiterCore.insertJob - @(WireArbiter ScheduledJobsRegistry) - @ScheduledJobsRegistry - arbiterJob + ArbiterCore.insertJob arbiterJob pure job scheduleAdminlessReminderJob :: forall r. - (Member (Embed IO) r, Member JobStore.JobStore r, Member (Input Hasql.Pool) r) => + (Member (Embed IO) r, Member JobStore.JobStore r) => JobSubsystemConfig -> Local UserId -> TeamId -> @@ -112,8 +109,12 @@ scheduleAdminlessReminderJob :: UTCTime -> Sem r ScheduledJob scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUntilDeletion scheduledFor = do - pool <- input - let env = WireArbiterEnv jobSubsystemSchemaName pool + arbiterEnv <- + embed $ + ArbiterHasql.createHasqlEnv + (Proxy @ScheduledJobsRegistry) + jobSubsystemArbiterConnStr + jobSubsystemSchemaName jobId <- embed $ Id <$> UUID.nextRandom let job = ScheduledJob @@ -129,13 +130,9 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUnti ArbiterCore.maxAttempts = Just 3 } JobStore.createJob job - liftIO $ - runWireArbiter env $ - WireArbiter $ + embed $ + void $ + ArbiterHasql.runHasqlDb arbiterEnv $ void $ - unWireArbiter $ - ArbiterCore.insertJob - @(WireArbiter ScheduledJobsRegistry) - @ScheduledJobsRegistry - arbiterJob + ArbiterCore.insertJob arbiterJob pure job diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 57318e115d9..74f0e292019 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -375,7 +375,6 @@ library Wire.JobStore Wire.JobStore.Postgres Wire.JobSubsystem - Wire.JobSubsystem.ArbiterAdapter Wire.JobSubsystem.Interpreter Wire.JobSubsystem.Workers Wire.LegalHold @@ -521,7 +520,6 @@ library , hashable , HaskellNet , HaskellNet-SSL - , hasql-transaction-io , hex , HsOpenSSL , hspec diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index 955a372fbf6..df47c94eda2 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -31,7 +31,6 @@ hself: hsuper: { hasql-resource-pool = hlib.dontCheck hsuper.hasql-resource-pool; hasql-migration = hlib.markUnbroken (hlib.doJailbreak (hlib.dontCheck hsuper.hasql-migration)); hasql-transaction = hlib.dontCheck hsuper.hasql-transaction_1_2_2; - hasql-transaction-io = hlib.markUnbroken (hlib.doJailbreak hsuper.hasql-transaction-io); postgresql-binary = hlib.dontCheck (hsuper.postgresql-binary_0_15_0_1); monad-logger-aeson = hlib.markUnbroken (hlib.dontCheck hsuper.monad-logger-aeson); arbiter-core = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-core); From c4a23c2476c0c216862c21b55ccbd6e6b1fc007f Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 07:23:54 +0000 Subject: [PATCH 40/65] typo --- libs/types-common/src/Data/Id.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs index 09db37239d7..0b89ecae0eb 100644 --- a/libs/types-common/src/Data/Id.hs +++ b/libs/types-common/src/Data/Id.hs @@ -137,7 +137,7 @@ idTagName Challenge = "Challenge" idTagName Job = "Job" idTagName Meeting = "Meeting" idTagName HistoryClient = "HistoryClient" -idTagName ScheduledJob = "ScheduledJob " +idTagName ScheduledJob = "ScheduledJob" class KnownIdTag (t :: IdTag) where idTagValue :: IdTag From acc582e7d4dcfbca2a933477f1e1178f1057dc0b Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 07:24:16 +0000 Subject: [PATCH 41/65] extract into where --- services/background-worker/src/Wire/Effects.hs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 7fbbe5f1ee1..cff50f29462 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -329,13 +329,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretProposalStoreToCassandra . interpretServiceStoreToCassandra env.cassandraBrig . interpretUserGroupStoreToPostgres - . case env.postgresMigration.teamFeatures of - CassandraStorage -> - interpretTeamFeatureStoreToCassandra - MigrationToPostgresql -> - interpretTeamFeatureStoreToCassandraAndPostgres - PostgresqlStorage -> - interpretTeamFeatureStoreToPostgres + . interpretTeamFeatureStore . interpretUserClientIndexStoreToCassandra env.cassandraGalley . interpretConversationStoreByMigration env.postgresMigration.conversation env.cassandraGalley . interpretTeamStoreToCassandra @@ -379,8 +373,12 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretTeamCollaboratorsSubsystem . interpretConversationSubsystem where - convCodesStoreInterpreter = - case env.postgresMigration.conversationCodes of + interpretTeamFeatureStore = case env.postgresMigration.teamFeatures of + CassandraStorage -> interpretTeamFeatureStoreToCassandra + MigrationToPostgresql -> interpretTeamFeatureStoreToCassandraAndPostgres + PostgresqlStorage -> interpretTeamFeatureStoreToPostgres + + convCodesStoreInterpreter = case env.postgresMigration.conversationCodes of CassandraStorage -> interpretCodeStoreToCassandra MigrationToPostgresql -> interpretCodeStoreToCassandraAndPostgres PostgresqlStorage -> interpretCodeStoreToPostgres From 12186b2d0b45f4db7337914afeccf6f2f7cae25d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 07:47:11 +0000 Subject: [PATCH 42/65] dependencies --- cabal.project | 14 -------------- services/background-worker/background-worker.cabal | 1 + services/background-worker/default.nix | 2 ++ services/brig/brig.cabal | 2 ++ services/brig/default.nix | 4 ++++ services/galley/default.nix | 2 ++ services/galley/galley.cabal | 1 + 7 files changed, 12 insertions(+), 14 deletions(-) diff --git a/cabal.project b/cabal.project index 16b8dd89809..345b1d52076 100644 --- a/cabal.project +++ b/cabal.project @@ -2,20 +2,6 @@ repository hackage.haskell.org url: https://hackage.haskell.org/ index-state: 2023-10-03T15:17:00Z -source-repository-package - type: git - location: https://github.com/velveteer/arbiter.git - tag: 96097411a0480e182d0cbce819c45023fe8aeec7 - subdir: - arbiter-core - arbiter-hasql - arbiter-migrations - arbiter-servant - arbiter-servant-ui - arbiter-simple - arbiter-worker - arbiter-test-common - packages: integration , libs/bilge/ diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index ffaadd8cfbd..f106300e8a7 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -61,6 +61,7 @@ library , polysemy-conc , polysemy-wire-zoo , prometheus-client + , resource-pool , retry , servant-client , servant-server diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 7a07604b93f..70dd953ddd9 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -37,6 +37,7 @@ , polysemy-wire-zoo , prometheus-client , QuickCheck +, resource-pool , retry , servant , servant-client @@ -91,6 +92,7 @@ mkDerivation { polysemy-conc polysemy-wire-zoo prometheus-client + resource-pool retry servant-client servant-server diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 065c824278f..1f35990ceac 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -217,6 +217,7 @@ library , arbiter-core , arbiter-servant , arbiter-servant-ui + , arbiter-simple , async >=2.1 , auto-update >=0.1 , base >=4 && <5 @@ -285,6 +286,7 @@ library , ram , random-shuffle >=0.0.3 , raw-strings-qq + , resource-pool , resourcet >=1.1 , retry >=0.7 , safe-exceptions >=0.1 diff --git a/services/brig/default.nix b/services/brig/default.nix index a79b7a5802a..ee9baf5a3c2 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -12,6 +12,7 @@ , arbiter-core , arbiter-servant , arbiter-servant-ui +, arbiter-simple , async , attoparsec , auto-update @@ -99,6 +100,7 @@ , random , random-shuffle , raw-strings-qq +, resource-pool , resourcet , retry , safe @@ -167,6 +169,7 @@ mkDerivation { arbiter-core arbiter-servant arbiter-servant-ui + arbiter-simple async auto-update base @@ -235,6 +238,7 @@ mkDerivation { ram random-shuffle raw-strings-qq + resource-pool resourcet retry safe-exceptions diff --git a/services/galley/default.nix b/services/galley/default.nix index 949e941b3a9..9dfefa01a9d 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -68,6 +68,7 @@ , ram , random , raw-strings-qq +, resource-pool , retry , safe-exceptions , servant @@ -157,6 +158,7 @@ mkDerivation { polysemy-wire-zoo prometheus-client raw-strings-qq + resource-pool retry safe-exceptions servant diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index f7c48e3bc00..dee78b3b25f 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -232,6 +232,7 @@ library , polysemy-wire-zoo , prometheus-client , raw-strings-qq >=1.0 + , resource-pool , retry >=0.5 , safe-exceptions >=0.1 , servant From 32cd24d25e013ec61797c004f0e8e1474e3134eb Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 10:47:48 +0000 Subject: [PATCH 43/65] give arbiter a resource pool of conncetion --- libs/extended/default.nix | 2 + libs/extended/extended.cabal | 1 + libs/extended/src/Hasql/Pool/Extended.hs | 48 +++- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 5 +- .../src/Wire/JobSubsystem/Interpreter.hs | 22 +- .../src/Wire/JobSubsystem/Workers.hs | 24 +- .../background-worker/background-worker.cabal | 2 + services/background-worker/default.nix | 150 ---------- .../src/Wire/BackgroundWorker/Env.hs | 6 +- .../Wire/BackgroundWorker/ScheduledJobs.hs | 7 + .../background-worker/src/Wire/Effects.hs | 29 +- .../Wire/BackendNotificationPusherSpec.hs | 2 + .../background-worker/test/Test/Wire/Util.hs | 1 + services/brig/brig.cabal | 1 - services/brig/default.nix | 2 - services/brig/src/Brig/App.hs | 21 +- services/galley/default.nix | 270 ------------------ services/galley/galley.cabal | 2 + services/galley/src/Galley/App.hs | 7 +- services/galley/src/Galley/Env.hs | 7 +- 20 files changed, 135 insertions(+), 474 deletions(-) diff --git a/libs/extended/default.nix b/libs/extended/default.nix index 1813326fc12..61ec5b50ae1 100644 --- a/libs/extended/default.nix +++ b/libs/extended/default.nix @@ -33,6 +33,7 @@ , prometheus-client , QuickCheck , ram +, resource-pool , retry , servant , servant-client @@ -81,6 +82,7 @@ mkDerivation { postgresql-connection-string prometheus-client ram + resource-pool retry servant servant-client diff --git a/libs/extended/extended.cabal b/libs/extended/extended.cabal index eee2d884228..c2e2e604e6e 100644 --- a/libs/extended/extended.cabal +++ b/libs/extended/extended.cabal @@ -113,6 +113,7 @@ library , postgresql-connection-string , prometheus-client , ram + , resource-pool , retry , servant , servant-client diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index 0cd8e4b61c9..5f57d4dc495 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -18,9 +18,16 @@ module Hasql.Pool.Extended where import Data.Aeson -import Data.Map as Map +import Data.ByteString qualified as ByteString +import Data.Map qualified as Map import Data.Misc +import Data.Pool qualified as Pool +import Data.Set qualified as Set +import Data.Text.Encoding qualified as Text +import Data.Time.Clock (diffUTCTime, getCurrentTime, secondsToDiffTime) +import Data.UUID import Hasql.Connection qualified +import Hasql.Connection qualified as Hasql import Hasql.Connection.Settings qualified as HasqlConnSettings import Hasql.Pool qualified as HasqlPool import Imports @@ -40,6 +47,15 @@ data PoolConfig = PoolConfig } deriving (Eq, Show) +defaultArbiterConnectionPoolConfig :: PoolConfig +defaultArbiterConnectionPoolConfig = + PoolConfig + { size = 10, + acquisitionTimeout = Duration (secondsToDiffTime 5), + agingTimeout = Duration (secondsToDiffTime 300), + idlenessTimeout = Duration (secondsToDiffTime 60) + } + instance FromJSON PoolConfig where parseJSON = withObject "PoolConfig" $ \o -> PoolConfig @@ -47,6 +63,36 @@ instance FromJSON PoolConfig where <*> o .: "acquisitionTimeout" <*> o .: "idlenessTimeout" +-- | Render a PostgreSQL connection string in libpq key-value format. +-- +-- Passwords from the optional secret file are inserted into the key-value map +-- before rendering so the resulting connection string can be reused by code +-- that expects a plain connection string. +postgresqlConnectionString :: Map Text Text -> Maybe FilePathSecrets -> IO Text +postgresqlConnectionString pgConfig mFpSecrets = do + mPw <- for mFpSecrets initCredentials + let pgConfig' = maybe pgConfig (\pw -> Map.insert "password" pw pgConfig) mPw + pure . PostgresqlConnectionString.toKeyValueString $ + PostgresqlConnectionString.fromKeyValueParams pgConfig' + +-- | Creates a dedicated raw connection pool for Arbiter-backed code paths. +-- +-- This intentionally returns 'Pool Connection' instead of the opaque +-- 'Hasql.Pool' wrapper so Arbiter can manage its own pool state directly. +initPostgresConnectionPool :: PoolConfig -> ByteString.ByteString -> IO (Pool.Pool Hasql.Connection) +initPostgresConnectionPool config connStr = + Pool.newPool $ + Pool.defaultPoolConfig + ( do + result <- Hasql.acquire (HasqlConnSettings.connectionString (Text.decodeUtf8 connStr)) + case result of + Right conn -> pure conn + Left err -> fail $ "Failed to acquire Arbiter Hasql connection: " <> show err + ) + Hasql.release + (realToFrac config.idlenessTimeout.duration) + (size config) + data HasqlPoolMetrics = HasqlPoolMetrics { readyForUseGauge :: Gauge, inUseGauge :: Gauge, diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index 7e7c1f34769..cd1eab5bf99 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -30,10 +30,11 @@ module Wire.JobSubsystem where import Arbiter.Core.Job.Types (JobRead) -import Data.ByteString qualified as ByteString import Data.Id +import Data.Pool qualified as Pool import Data.Qualified import Data.Time.Clock (UTCTime) +import Hasql.Connection qualified as Hasql import Imports import Polysemy import Wire.API.Jobs @@ -42,7 +43,7 @@ import Wire.JobSubsystem.Workers type CleanupAction = IO () data JobSubsystemConfig = JobSubsystemConfig - { jobSubsystemArbiterConnStr :: ByteString.ByteString, + { jobSubsystemArbiterPool :: Pool.Pool Hasql.Connection, jobSubsystemSchemaName :: Text } diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 84db8aff397..710805ccc83 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -70,12 +70,11 @@ scheduleAdminlessDeletionJob :: UTCTime -> Sem r ScheduledJob scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do - arbiterEnv <- - embed $ - ArbiterHasql.createHasqlEnv - (Proxy @ScheduledJobsRegistry) - jobSubsystemArbiterConnStr - jobSubsystemSchemaName + let arbiterEnv = + ArbiterHasql.createHasqlEnvWithPool + (Proxy @ScheduledJobsRegistry) + jobSubsystemArbiterPool + jobSubsystemSchemaName jobId <- embed $ Id <$> UUID.nextRandom let job = ScheduledJob @@ -109,12 +108,11 @@ scheduleAdminlessReminderJob :: UTCTime -> Sem r ScheduledJob scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUntilDeletion scheduledFor = do - arbiterEnv <- - embed $ - ArbiterHasql.createHasqlEnv - (Proxy @ScheduledJobsRegistry) - jobSubsystemArbiterConnStr - jobSubsystemSchemaName + let arbiterEnv = + ArbiterHasql.createHasqlEnvWithPool + (Proxy @ScheduledJobsRegistry) + jobSubsystemArbiterPool + jobSubsystemSchemaName jobId <- embed $ Id <$> UUID.nextRandom let job = ScheduledJob diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index d4022f43b6d..2cc8a98591f 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -37,10 +37,12 @@ import Arbiter.Worker.Cron qualified as ArbiterWorkerCron import Data.Aeson (FromJSON, ToJSON) import Data.ByteString qualified as ByteString import Data.Kind (Type) +import Data.Pool qualified as Pool import Data.Proxy (Proxy (..)) import Data.Text qualified as T import Data.Time.Clock (NominalDiffTime) import GHC.TypeLits (KnownSymbol) +import Hasql.Connection qualified as Hasql import Imports import System.Cron (CronSchedule, serializeCronSchedule) import System.Logger qualified as Log @@ -50,6 +52,7 @@ import Wire.API.Jobs (MeetingsCleanupJob (..)) data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig { recurringJobRunnerLogger :: Log.Logger, recurringJobRunnerSchedule :: CronSchedule, + recurringJobRunnerArbiterPool :: Pool.Pool Hasql.Connection, recurringJobRunnerArbiterConnStr :: ByteString.ByteString, recurringJobRunnerSchemaName :: Text, recurringJobRunnerPollInterval :: NominalDiffTime, @@ -60,6 +63,7 @@ data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig { oneOffJobRunnerLogger :: Log.Logger, + oneOffJobRunnerArbiterPool :: Pool.Pool Hasql.Connection, oneOffJobRunnerArbiterConnStr :: ByteString.ByteString, oneOffJobRunnerSchemaName :: Text, oneOffJobRunnerPollInterval :: NominalDiffTime, @@ -89,11 +93,11 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do . Log.field "queue_name" recurringJobRunnerQueueName . Log.field "schedule" (show recurringJobRunnerSchedule) - arbiterEnv <- - ArbiterHasql.createHasqlEnv - registry - recurringJobRunnerArbiterConnStr - recurringJobRunnerSchemaName + let arbiterEnv = + ArbiterHasql.createHasqlEnvWithPool + registry + recurringJobRunnerArbiterPool + recurringJobRunnerSchemaName let workerHandler _conn job = liftIO $ do @@ -172,11 +176,11 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do . Log.field "job_name" oneOffJobRunnerJobName . Log.field "queue_name" oneOffJobRunnerQueueName - arbiterEnv <- - ArbiterHasql.createHasqlEnv - registry - oneOffJobRunnerArbiterConnStr - oneOffJobRunnerSchemaName + let arbiterEnv = + ArbiterHasql.createHasqlEnvWithPool + registry + oneOffJobRunnerArbiterPool + oneOffJobRunnerSchemaName let workerHandler _conn job = liftIO $ do Log.info oneOffJobRunnerLogger $ diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index f106300e8a7..fdea0a1eea5 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -50,6 +50,8 @@ library , extended , extra , galley-types + , hasql + , hasql-pool , hasql-resource-pool , HsOpenSSL , http-client diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 70dd953ddd9..65e5a0050f9 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -2,153 +2,3 @@ # This file is generated by running hack/bin/generate-local-nix-packages.sh and # must be regenerated whenever local packages are added or removed, or # dependencies are added or removed. -{ mkDerivation -, aeson -, amqp -, arbiter-core -, base -, bilge -, bytestring -, bytestring-conversion -, cassandra-util -, containers -, cron -, data-default -, data-timeout -, exceptions -, extended -, extra -, federator -, galley-types -, hasql-resource-pool -, HsOpenSSL -, hspec -, http-client -, http-media -, http-types -, http2-manager -, imports -, lib -, metrics-wai -, monad-control -, optparse-applicative -, polysemy -, polysemy-conc -, polysemy-wire-zoo -, prometheus-client -, QuickCheck -, resource-pool -, retry -, servant -, servant-client -, servant-client-core -, servant-server -, ssl-util -, tagged -, text -, time -, tinylog -, transformers -, transformers-base -, types-common -, unliftio -, uri-bytestring -, wai -, wai-utilities -, wire-api -, wire-api-federation -, wire-subsystems -}: -mkDerivation { - pname = "background-worker"; - version = "0.1.0.0"; - src = ./.; - isLibrary = true; - isExecutable = true; - libraryHaskellDepends = [ - aeson - amqp - arbiter-core - base - bilge - bytestring - bytestring-conversion - cassandra-util - containers - cron - data-timeout - exceptions - extended - extra - galley-types - hasql-resource-pool - HsOpenSSL - http-client - http2-manager - imports - metrics-wai - monad-control - polysemy - polysemy-conc - polysemy-wire-zoo - prometheus-client - resource-pool - retry - servant-client - servant-server - ssl-util - tagged - text - time - tinylog - transformers - transformers-base - types-common - unliftio - uri-bytestring - wai-utilities - wire-api - wire-api-federation - wire-subsystems - ]; - executableHaskellDepends = [ - HsOpenSSL - imports - optparse-applicative - types-common - ]; - testHaskellDepends = [ - aeson - amqp - base - bytestring - containers - data-default - extended - federator - hspec - http-client - http-media - http-types - imports - prometheus-client - QuickCheck - servant - servant-client - servant-client-core - servant-server - text - tinylog - transformers - types-common - unliftio - wai - wai-utilities - wire-api - wire-api-federation - wire-subsystems - ]; - description = "Runs background work"; - license = lib.licenses.agpl3Only; - mainProgram = "background-worker"; -} diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 3f1917272d4..6af3d169a0e 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -30,8 +30,10 @@ import Data.Domain (Domain) import Data.Id (TeamId) import Data.Map.Strict qualified as Map import Data.Misc (HttpsUrl) +import Data.Pool qualified as Pool import Data.Text.Encoding qualified as Text import HTTP2.Client.Manager +import Hasql.Pool qualified as HasqlPool import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as Hasql import Imports @@ -90,8 +92,9 @@ data Env = Env cassandra :: ClientState, cassandraGalley :: ClientState, cassandraBrig :: ClientState, - hasqlPool :: Hasql.Pool, + hasqlPool :: HasqlPool.Pool, arbiterConnStr :: ByteString.ByteString, + arbiterPool :: Pool.Pool Hasql.Connection, -- Dedicated AMQP channels per concern amqpJobsPublisherChannel :: MVar Q.Channel, amqpBackendNotificationsChannel :: MVar Q.Channel, @@ -194,6 +197,7 @@ mkEnv opts galleyOpts = do workerRunningGauge <- mkWorkerRunningGauge hasqlPool <- initPostgresPool opts.postgresqlPool galleyOpts._postgresql galleyOpts._postgresqlPassword arbiterConnStr <- Text.encodeUtf8 <$> postgresqlConnectionString galleyOpts._postgresql galleyOpts._postgresqlPassword + arbiterPool <- initPostgresConnectionPool defaultArbiterConnectionPoolConfig arbiterConnStr Log.info logger $ Log.msg @Text "Opening RabbitMQ channel: background-worker-jobs-publisher..." amqpJobsPublisherChannel <- mkRabbitMqChannelMVar logger (Just "background-worker-jobs-publisher") $ diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 9f25ce35e96..7f3e9abfddf 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -69,6 +69,9 @@ startWorker scheduledConfig config = do RecurringJobRunnerConfig { recurringJobRunnerLogger = env.logger, recurringJobRunnerSchedule = config.schedule, + recurringJobRunnerArbiterPool = env.arbiterPool, + -- Arbiter still uses the connection string for LISTEN/NOTIFY. + -- The actual job DB access goes through 'arbiterPool'. recurringJobRunnerArbiterConnStr = env.arbiterConnStr, recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, recurringJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, @@ -79,6 +82,9 @@ startWorker scheduledConfig config = do adminlessDeletionJobRunnerConfig = OneOffJobRunnerConfig { oneOffJobRunnerLogger = env.logger, + oneOffJobRunnerArbiterPool = env.arbiterPool, + -- Arbiter still uses the connection string for LISTEN/NOTIFY. + -- The actual job DB access goes through 'arbiterPool'. oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, oneOffJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, @@ -89,6 +95,7 @@ startWorker scheduledConfig config = do adminlessReminderJobRunnerConfig = OneOffJobRunnerConfig { oneOffJobRunnerLogger = env.logger, + oneOffJobRunnerArbiterPool = env.arbiterPool, oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, oneOffJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index cff50f29462..8b7412f8f1a 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -91,8 +91,6 @@ import Wire.GalleyAPIAccess.Rpc (interpretGalleyAPIAccessToRpc) import Wire.GundeckAPIAccess import Wire.HashPassword (HashPassword) import Wire.HashPassword.Interpreter (runHashPassword) -import Wire.JobStore (JobStore) -import Wire.JobStore.Postgres (interpretJobStoreToPostgres) import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) @@ -219,7 +217,6 @@ type BackgroundWorkerEffects = TeamJournal, LegalHoldStore, JobSubsystem, - JobStore, TeamCollaboratorsStore, TeamStore, ConversationStore, @@ -329,18 +326,13 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretProposalStoreToCassandra . interpretServiceStoreToCassandra env.cassandraBrig . interpretUserGroupStoreToPostgres - . interpretTeamFeatureStore + . interpretTeamFeatureStore . interpretUserClientIndexStoreToCassandra env.cassandraGalley . interpretConversationStoreByMigration env.postgresMigration.conversation env.cassandraGalley . interpretTeamStoreToCassandra . interpretTeamCollaboratorsStoreToPostgres - . interpretJobStoreToPostgres - . interpretJobSubsystem - JobSubsystemConfig - { jobSubsystemArbiterConnStr = env.arbiterConnStr, - jobSubsystemSchemaName = ArbiterCore.defaultSchemaName - } - . interpretLegalHoldStoreToCassandra (env.conversationSubsystemConfig.legalholdDefaults) + . interpretJobSubsystem jobSubsystemConfig + . interpretLegalHoldStoreToCassandra FeatureLegalHoldDisabledPermanently . interpretTeamJournal Nothing . nowToIO . randomToIO @@ -379,9 +371,9 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = PostgresqlStorage -> interpretTeamFeatureStoreToPostgres convCodesStoreInterpreter = case env.postgresMigration.conversationCodes of - CassandraStorage -> interpretCodeStoreToCassandra - MigrationToPostgresql -> interpretCodeStoreToCassandraAndPostgres - PostgresqlStorage -> interpretCodeStoreToPostgres + CassandraStorage -> interpretCodeStoreToCassandra + MigrationToPostgresql -> interpretCodeStoreToCassandraAndPostgres + PostgresqlStorage -> interpretCodeStoreToPostgres legalHoldEnv = let makeReq fpr url rb = makeVerifiedRequestIO env.logger extEnv fpr url rb makeReqFresh fpr url rb = makeVerifiedRequestFreshManagerIO env.logger fpr url rb @@ -394,6 +386,15 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = http2Manager = env.http2Manager, requestId = requestId } + jobSubsystemConfig = + JobSubsystemConfig + { jobSubsystemArbiterPool = env.arbiterPool, + jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + } + getConversationSubsystemConfig :: + (Member GalleyAPIAccess r) => + Sem r ConversationSubsystemConfig + getConversationSubsystemConfig = getConversationConfig backendQueueEnv = BackendNotificationQueueAccess.Env { channelMVar = env.amqpBackendNotificationsChannel, diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index ff580236ae7..664458c7141 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -386,6 +386,7 @@ spec = do passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing arbiterConnStr = BS.empty + arbiterPool = undefined convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = @@ -450,6 +451,7 @@ spec = do passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing arbiterConnStr = BS.empty + arbiterPool = undefined convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index 4b149736d47..3919add5225 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -85,6 +85,7 @@ testEnv = do passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing arbiterConnStr = BS.empty + arbiterPool = undefined convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 1f35990ceac..3f59e8e7e62 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -286,7 +286,6 @@ library , ram , random-shuffle >=0.0.3 , raw-strings-qq - , resource-pool , resourcet >=1.1 , retry >=0.7 , safe-exceptions >=0.1 diff --git a/services/brig/default.nix b/services/brig/default.nix index ee9baf5a3c2..bf90995132f 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -100,7 +100,6 @@ , random , random-shuffle , raw-strings-qq -, resource-pool , resourcet , retry , safe @@ -238,7 +237,6 @@ mkDerivation { ram random-shuffle raw-strings-qq - resource-pool resourcet retry safe-exceptions diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index e5468672597..1fa4d113c8d 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -75,7 +75,6 @@ module Brig.App amqpJobsPublisherChannelLens, postgresMigrationLens, jobsApiAppLens, - jobsApiConnStrLens, initZAuth, initLogger, initPostgresPool, @@ -106,6 +105,7 @@ where import Arbiter.Core qualified as ArbiterCore import Arbiter.Servant.Server qualified as ArbServer import Arbiter.Servant.UI qualified as ArbUI +import Arbiter.Simple qualified as ArbSimple import Bilge qualified as RPC import Bilge.IO import Bilge.RPC (HasRequestId (..)) @@ -143,6 +143,7 @@ import Data.Text.IO qualified as Text import Data.Time.Clock import Database.Bloodhound qualified as ES import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) +import Hasql.Pool qualified as HasqlPool import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as HasqlPool import Imports @@ -228,7 +229,6 @@ data Env = Env enableSFTFederation :: Maybe Bool, rateLimitEnv :: RateLimitEnv, amqpJobsPublisherChannel :: MVar Q.Channel, - jobsApiConnStr :: Text, jobsApiApp :: Application, postgresMigration :: PostgresMigrationOpts } @@ -287,9 +287,19 @@ newEnv opts = do hasqlPool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword Log.info lgr $ Log.msg (Log.val "Initializing internal jobs API") jobsApiConnStr <- postgresqlConnectionString opts.postgresql opts.postgresqlPassword - jobsApiApp <- do - config <- ArbServer.initArbiterServer (Proxy @ScheduledJobsRegistry) (encodeUtf8 jobsApiConnStr) ArbiterCore.defaultSchemaName - pure $ ArbUI.arbiterAppWithAdmin config + arbiterEnv <- + ArbSimple.createSimpleEnv + (Proxy @ScheduledJobsRegistry) + (encodeUtf8 jobsApiConnStr) + ArbiterCore.defaultSchemaName + sseHub <- newMVar Nothing + let config = + ArbServer.ArbiterServerConfig + { serverEnv = arbiterEnv, + enableSSE = True, + sseHub = sseHub + } + jobsApiApp <- pure $ ArbUI.arbiterAppWithAdmin config Log.info lgr $ Log.msg (Log.val "Internal jobs API initialized") amqpJobsPublisherChannel <- Q.mkRabbitMqChannelMVar lgr (Just "brig") opts.rabbitmq pure $! @@ -334,7 +344,6 @@ newEnv opts = do enableSFTFederation = opts.multiSFT, rateLimitEnv, amqpJobsPublisherChannel, - jobsApiConnStr, jobsApiApp, postgresMigration = opts.postgresMigration } diff --git a/services/galley/default.nix b/services/galley/default.nix index 9dfefa01a9d..65e5a0050f9 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -2,273 +2,3 @@ # This file is generated by running hack/bin/generate-local-nix-packages.sh and # must be regenerated whenever local packages are added or removed, or # dependencies are added or removed. -{ mkDerivation -, aeson -, aeson-qq -, amazonka -, amqp -, arbiter-core -, async -, base -, base64-bytestring -, bilge -, binary -, bytestring -, bytestring-conversion -, call-stack -, cassandra-util -, cassava -, cereal -, conduit -, containers -, cookie -, crypton-pem -, currency-codes -, data-default -, data-timeout -, errors -, exceptions -, extended -, extra -, federator -, filepath -, galley-types -, hasql-resource-pool -, hs-opentelemetry-instrumentation-wai -, hs-opentelemetry-sdk -, HsOpenSSL -, http-api-data -, http-client -, http-client-openssl -, http-client-tls -, http-media -, http-types -, http2-manager -, imports -, kan-extensions -, lens -, lens-aeson -, lib -, metrics-core -, metrics-wai -, mtl -, network -, network-uri -, optparse-applicative -, polysemy -, polysemy-conc -, polysemy-plugin -, polysemy-wire-zoo -, process -, prometheus-client -, proto-lens -, protobuf -, QuickCheck -, quickcheck-instances -, ram -, random -, raw-strings-qq -, resource-pool -, retry -, safe-exceptions -, servant -, servant-client -, servant-client-core -, servant-server -, singletons -, sop-core -, split -, ssl-util -, stm -, streaming-commons -, string-conversions -, tagged -, tasty -, tasty-ant-xml -, tasty-cannon -, tasty-hunit -, temporary -, text -, time -, tinylog -, transformers -, types-common -, types-common-aws -, types-common-journal -, unix -, unliftio -, unordered-containers -, uri-bytestring -, utf8-string -, uuid -, wai -, wai-extra -, wai-middleware-gunzip -, wai-utilities -, warp -, warp-tls -, wire-api -, wire-api-federation -, wire-otel -, wire-subsystems -, yaml -}: -mkDerivation { - pname = "galley"; - version = "0.83.0"; - src = ./.; - isLibrary = true; - isExecutable = true; - libraryHaskellDepends = [ - aeson - amazonka - amqp - arbiter-core - async - base - bilge - bytestring - bytestring-conversion - cassandra-util - cassava - containers - data-default - errors - exceptions - extended - galley-types - hasql-resource-pool - hs-opentelemetry-instrumentation-wai - hs-opentelemetry-sdk - HsOpenSSL - http-client - http-client-openssl - http-media - http-types - http2-manager - imports - kan-extensions - lens - metrics-core - metrics-wai - optparse-applicative - polysemy - polysemy-conc - polysemy-plugin - polysemy-wire-zoo - prometheus-client - raw-strings-qq - resource-pool - retry - safe-exceptions - servant - servant-server - singletons - split - ssl-util - stm - text - time - tinylog - types-common - types-common-aws - unliftio - unordered-containers - uri-bytestring - utf8-string - uuid - wai - wai-extra - wai-middleware-gunzip - wai-utilities - wire-api - wire-api-federation - wire-otel - wire-subsystems - ]; - executableHaskellDepends = [ - aeson - aeson-qq - async - base - base64-bytestring - bilge - binary - bytestring - bytestring-conversion - call-stack - cassandra-util - cereal - conduit - containers - cookie - crypton-pem - currency-codes - data-default - data-timeout - errors - exceptions - extended - extra - federator - filepath - galley-types - HsOpenSSL - http-api-data - http-client - http-client-openssl - http-client-tls - http-types - imports - kan-extensions - lens - lens-aeson - mtl - network - network-uri - optparse-applicative - process - proto-lens - protobuf - QuickCheck - quickcheck-instances - ram - random - retry - servant-client - servant-client-core - servant-server - singletons - sop-core - ssl-util - streaming-commons - string-conversions - tagged - tasty - tasty-ant-xml - tasty-cannon - tasty-hunit - temporary - text - time - tinylog - transformers - types-common - types-common-aws - types-common-journal - unix - unliftio - unordered-containers - uuid - wai - wai-utilities - warp - warp-tls - wire-api - wire-api-federation - wire-subsystems - yaml - ]; - description = "Conversations"; - license = lib.meta.getLicenseFromSpdxId "AGPL-3.0-only"; -} diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index dee78b3b25f..c312d9c0f88 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -211,6 +211,8 @@ library , exceptions >=0.4 , extended , galley-types >=0.65.0 + , hasql + , hasql-pool , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 930c39ecfc1..a62b5ed15d6 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -64,7 +64,7 @@ import Galley.Queue qualified as Q import Galley.Types.Error import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool qualified as Hasql -import Hasql.Pool.Extended (initPostgresPool) +import Hasql.Pool.Extended (defaultArbiterConnectionPoolConfig, initPostgresConnectionPool, initPostgresPool, postgresqlConnectionString) import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports hiding (forkIO) import Network.AMQP.Extended (mkRabbitMqChannelMVar) @@ -333,6 +333,7 @@ createEnv o l = do codeURIcfg <- validateOptions o postgres <- initPostgresPool o._postgresqlPool o._postgresql o._postgresqlPassword galleyJobsApiConnStr <- postgresqlConnectionString o._postgresql o._postgresqlPassword + galleyJobsApiPool <- initPostgresConnectionPool defaultArbiterConnectionPoolConfig (TextEncoding.encodeUtf8 galleyJobsApiConnStr) let disableTlsV1 = True Env (RequestId defRequestId) o l mgr h2mgr (o ^. O.federator) (o ^. O.brig) cass postgres <$> Q.new 16000 @@ -342,7 +343,7 @@ createEnv o l = do <*> traverse (mkRabbitMqChannelMVar l (Just "galley")) (o ^. rabbitmq) <*> pure codeURIcfg <*> newRateLimitEnv (o ^. settings . passwordHashingRateLimit) - <*> pure galleyJobsApiConnStr + <*> pure galleyJobsApiPool initCassandra :: Opts -> Logger -> IO ClientState initCassandra o l = @@ -562,7 +563,7 @@ evalGalley e = . interpretJobStoreToPostgres . interpretJobSubsystem JobSubsystemConfig - { jobSubsystemArbiterConnStr = TextEncoding.encodeUtf8 (e ^. jobsApiConnStr), + { jobSubsystemArbiterPool = e ^. jobsApiPool, jobSubsystemSchemaName = ArbiterCore.defaultSchemaName } . interpretConversationSubsystem diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index cc7871ea567..ea5c36ad719 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -37,7 +37,7 @@ module Galley.Env rabbitmqChannel, convCodeURI, passwordHashingRateLimitEnv, - jobsApiConnStr, + jobsApiPool, reqIdMsg, notificationSubsystemConfig, currentFanoutLimitOpts, @@ -49,10 +49,13 @@ import Control.Lens hiding ((.=)) import Data.Domain (Domain) import Data.Id import Data.Misc (HttpsUrl) +import Data.Pool qualified as Pool import Data.Time.Clock.DiffTime (millisecondsToDiffTime) import Galley.Queue qualified as Q import HTTP2.Client.Manager (Http2Manager) import Hasql.Pool.Extended +import Hasql.Connection qualified as Hasql +import Hasql.Pool import Imports import Network.AMQP qualified as Q import Network.HTTP.Client @@ -89,7 +92,7 @@ data Env = Env _rabbitmqChannel :: Maybe (MVar Q.Channel), _convCodeURI :: Either HttpsUrl (Map Domain HttpsUrl), _passwordHashingRateLimitEnv :: RateLimitEnv, - _jobsApiConnStr :: Text + _jobsApiPool :: Pool.Pool Hasql.Connection } makeLenses ''Env From 0867a3680771074225bc686c544498c88fb59a5a Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 13:36:53 +0000 Subject: [PATCH 44/65] honor new feature config shape --- .../src/Wire/API/Event/Conversation.hs | 4 +-- libs/wire-api/src/Wire/API/Jobs.hs | 34 ++++++++++++++++--- .../unit/Test/Wire/API/Roundtrip/Aeson.hs | 5 +++ .../src/Wire/ConversationSubsystem.hs | 4 ++- .../Wire/ConversationSubsystem/Interpreter.hs | 4 +-- .../src/Wire/ConversationSubsystem/Update.hs | 31 ++++++++++++----- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 3 +- .../src/Wire/JobSubsystem/Interpreter.hs | 9 ++--- .../src/Wire/AdminlessJobsWorker.hs | 4 +-- .../background-worker/src/Wire/Effects.hs | 2 +- services/brig/src/Brig/App.hs | 2 +- 11 files changed, 75 insertions(+), 27 deletions(-) diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index 3746df1f8f8..5275806cfd2 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -501,7 +501,7 @@ instance ToSchema ConversationReset where <*> (.newGroupId) .= maybe_ (optField "new_group_id" schema) data AdminlessReminder = AdminlessReminder - { daysUntilDeletion :: Int + { deletionScheduledFor :: UTCTimeMillis } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform AdminlessReminder) @@ -511,7 +511,7 @@ instance ToSchema AdminlessReminder where schema = object $ AdminlessReminder - <$> (.daysUntilDeletion) .= field "days_until_deletion" schema + <$> (.deletionScheduledFor) .= field "deletion_scheduled_for" schema makePrisms ''EventData diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index 9be9d035505..a48f8f9ced4 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -25,10 +25,13 @@ module Wire.API.Jobs where import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) import Data.Id import Data.Int qualified as Int +import Data.Json.Util +import Data.OpenApi qualified as S import Data.Schema import Data.Time.Clock (UTCTime) import Data.UUID (UUID) import Imports +import Test.QuickCheck (Arbitrary (..), elements) import Wire.API.PostgresMarshall -- | Shared queue name for the scheduled meetings cleanup job. @@ -47,6 +50,9 @@ adminlessReminderQueueName = "adminless_reminder_jobs" data MeetingsCleanupJob = MeetingsCleanupJob deriving stock (Eq, Generic, Show) +instance Arbitrary MeetingsCleanupJob where + arbitrary = pure MeetingsCleanupJob + instance ToJSON MeetingsCleanupJob where toJSON MeetingsCleanupJob = Null @@ -64,7 +70,10 @@ data AdminlessDeletionJob = AdminlessDeletionJob adminlessDeletionJobOrigUserId :: UserId } deriving stock (Eq, Generic, Show) - deriving (ToJSON, FromJSON) via (Schema AdminlessDeletionJob) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessDeletionJob) + +instance Arbitrary AdminlessDeletionJob where + arbitrary = AdminlessDeletionJob <$> arbitrary <*> arbitrary <*> arbitrary instance ToSchema AdminlessDeletionJob where schema = @@ -82,10 +91,13 @@ data AdminlessReminderJob = AdminlessReminderJob { adminlessReminderJobTeamId :: TeamId, adminlessReminderJobConversationId :: ConvId, adminlessReminderJobOrigUserId :: UserId, - adminlessReminderJobDaysUntilDeletion :: Int + adminlessReminderJobDeletionScheduledFor :: UTCTimeMillis } deriving stock (Eq, Generic, Show) - deriving (ToJSON, FromJSON) via (Schema AdminlessReminderJob) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessReminderJob) + +instance Arbitrary AdminlessReminderJob where + arbitrary = AdminlessReminderJob <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary instance ToSchema AdminlessReminderJob where schema = @@ -94,7 +106,7 @@ instance ToSchema AdminlessReminderJob where <$> (.adminlessReminderJobTeamId) .= field "team_id" schema <*> (.adminlessReminderJobConversationId) .= field "conversation_id" schema <*> (.adminlessReminderJobOrigUserId) .= field "orig_user_id" schema - <*> (.adminlessReminderJobDaysUntilDeletion) .= field "days_until_deletion" schema + <*> (.adminlessReminderJobDeletionScheduledFor) .= field "deletion_scheduled_for" schema -- | The generic scheduled-job families we currently need to persist. data ScheduledJobKind @@ -103,6 +115,20 @@ data ScheduledJobKind | AdminlessSetup | AdminlessTeardown deriving stock (Bounded, Enum, Eq, Generic, Ord, Show) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema ScheduledJobKind) + +instance Arbitrary ScheduledJobKind where + arbitrary = elements [minBound .. maxBound] + +instance ToSchema ScheduledJobKind where + schema = + enum @Text $ + mconcat + [ element "adminless-reminder" AdminlessReminder, + element "adminless-deletion" AdminlessDeletion, + element "adminless-setup" AdminlessSetup, + element "adminless-teardown" AdminlessTeardown + ] scheduledJobKindToInt :: ScheduledJobKind -> Int scheduledJobKindToInt = fromEnum diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index ab1c1aea3c6..5d81586db37 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -42,6 +42,7 @@ import Wire.API.Event.Conversation qualified as Event.Conversation import Wire.API.Event.Team qualified as Event.Team import Wire.API.Event.WebSocketProtocol qualified as EventWebSocketProtocol import Wire.API.FederationStatus qualified as FederationStatus +import Wire.API.Jobs qualified as Jobs import Wire.API.Locale qualified as Locale import Wire.API.Message qualified as Message import Wire.API.OAuth qualified as OAuth @@ -109,6 +110,7 @@ tests = testRoundTrip @Connection.UserConnection, testRoundTrip @Connection.UserConnectionList, testRoundTrip @Connection.ConnectionUpdate, + testRoundTrip @Jobs.ScheduledJobKind, testRoundTrip @(Conversation.OwnConversation Conversation.GroupConvType), testRoundTrip @(Conversation.OwnConversation Conversation.GroupConvTypeLegacy), testRoundTrip @(Conversation.Conversation Conversation.GroupConvType), @@ -153,6 +155,9 @@ tests = testRoundTrip @Conversation.Role.ConversationRolesList, testRoundTrip @Conversation.Typing.TypingStatus, testRoundTrip @CustomBackend.CustomBackend, + testRoundTrip @Jobs.MeetingsCleanupJob, + testRoundTripWithSwagger @Jobs.AdminlessDeletionJob, + testRoundTripWithSwagger @Jobs.AdminlessReminderJob, testRoundTrip @EJPD.EJPDContact, testRoundTrip @Event.Conversation.Event, testRoundTrip @Event.Conversation.EventType, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index 6304bb1794d..dbc6bf01cfc 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -32,10 +32,12 @@ import Data.Code qualified as Code import Data.CommaSeparatedList (CommaSeparatedList) import Data.Domain import Data.Id +import Data.Json.Util (UTCTimeMillis) import Data.Misc (IpAddr) import Data.Qualified import Data.Range import Data.Singletons (Sing) +import Data.Time.Clock (UTCTime) import Imports import Polysemy import Wire.API.Bot (AddBot, RemoveBot) @@ -348,7 +350,7 @@ data ConversationSubsystem m a where InternalNotifyAdminlessReminder :: Local UserId -> Local ConvId -> - Int -> + UTCTimeMillis -> ConversationSubsystem m () GetMLSPublicKeys :: Maybe MLSPublicKeyFormat -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 1f9038a970e..884e707d313 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -215,8 +215,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Action.updateLocalConversationDeleteUnchecked lcnv InternalDeleteLocalAdminlessGroup lusr lcnv -> mapErrors $ Update.adminlessAutopromoteOrDelete lusr lcnv - InternalNotifyAdminlessReminder lusr lcnv daysUntilDeletion -> - mapErrors $ Update.adminlessAutopromoteOrSendReminder lusr lcnv daysUntilDeletion + InternalNotifyAdminlessReminder lusr lcnv deletionScheduledFor -> + mapErrors $ Update.adminlessAutopromoteOrSendReminder lusr lcnv deletionScheduledFor GetMLSPublicKeys fmt -> mapErrors $ MLS.getMLSPublicKeys fmt ResetMLSConversation lusr reset -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index ed06da0bd6b..8050aa97ab3 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -86,7 +86,7 @@ import Data.Misc import Data.Qualified import Data.Set qualified as Set import Data.Singletons -import Data.Time.Clock (addUTCTime, nominalDay) +import Data.Time.Clock (NominalDiffTime, addUTCTime) import Data.Vector qualified as V import Galley.Types.Error import Imports hiding (forkIO) @@ -1221,15 +1221,28 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do where scheduleDeletion tid feature = do now <- Now.get - let scheduledFor = addUTCTime (fromIntegral feature.config.deletionTimeout * nominalDay) now + let deletionTimeout = timeoutToNominalDiffTime feature.config.deletionTimeout + scheduledFor = addUTCTime deletionTimeout now + deletionScheduledFor = toUTCTimeMillis scheduledFor void $ scheduleAdminlessDeletionJob lusr tid (qUnqualified (tUntagged lcnv)) scheduledFor for_ feature.config.reminderTimeouts $ - scheduleReminder now tid feature.config.deletionTimeout + scheduleReminder now tid deletionScheduledFor deletionTimeout - scheduleReminder now tid deletionTimeout reminderTimeout = do + scheduleReminder now tid deletionScheduledFor deletionTimeout reminderTimeoutCfg = do + let reminderTimeout = timeoutToNominalDiffTime reminderTimeoutCfg when (reminderTimeout < deletionTimeout) $ do - let reminderAt = addUTCTime (fromIntegral (deletionTimeout - reminderTimeout) * nominalDay) now - void $ scheduleAdminlessReminderJob lusr tid (qUnqualified (tUntagged lcnv)) (fromIntegral reminderTimeout) reminderAt + let reminderAt = addUTCTime (deletionTimeout - reminderTimeout) now + void $ + scheduleAdminlessReminderJob + lusr + tid + (qUnqualified (tUntagged lcnv)) + deletionScheduledFor + reminderAt + + timeoutToNominalDiffTime :: PreventAdminlessTimeout -> NominalDiffTime + timeoutToNominalDiffTime = + realToFrac . duration . durationLiteralValue . preventAdminlessTimeoutLiteral onAdminless :: ( Member ConversationStore r, @@ -1332,9 +1345,9 @@ adminlessAutopromoteOrSendReminder :: ) => Local UserId -> Local ConvId -> - Int -> + UTCTimeMillis -> Sem r () -adminlessAutopromoteOrSendReminder lusr lcnv daysUntilDeletion = adminlessTryAutopromote lusr lcnv orAlternativelySendReminder +adminlessAutopromoteOrSendReminder lusr lcnv deletionScheduledFor = adminlessTryAutopromote lusr lcnv orAlternativelySendReminder where orAlternativelySendReminder _ = do conv <- getConversationWithError lcnv @@ -1346,7 +1359,7 @@ adminlessAutopromoteOrSendReminder lusr lcnv daysUntilDeletion = adminlessTryAut (EventFromUser (tUntagged lusr)) now (conv.metadata.cnvmTeam) - (EdAdminlessReminder (AdminlessReminder daysUntilDeletion)) + (EdAdminlessReminder (AdminlessReminder deletionScheduledFor)) pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) [] -- Use eight random bytes and fold them into a big-endian Word64. This keeps diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index cd1eab5bf99..04e8441490f 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -31,6 +31,7 @@ where import Arbiter.Core.Job.Types (JobRead) import Data.Id +import Data.Json.Util (UTCTimeMillis) import Data.Pool qualified as Pool import Data.Qualified import Data.Time.Clock (UTCTime) @@ -61,7 +62,7 @@ data JobWorkerHandlers = JobWorkerHandlers data JobSubsystem m a where ScheduleAdminlessDeletionJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m ScheduledJob - ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> Int -> UTCTime -> JobSubsystem m ScheduledJob + ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> UTCTimeMillis -> UTCTime -> JobSubsystem m ScheduledJob StartJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> JobSubsystem m CleanupAction makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 710805ccc83..82af3e64c14 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -29,6 +29,7 @@ where import Arbiter.Core qualified as ArbiterCore import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Data.Id +import Data.Json.Util (UTCTimeMillis) import Data.Proxy (Proxy (..)) import Data.Qualified import Data.Time @@ -57,7 +58,7 @@ interpretJobSubsystem conf = interpret \case ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor - ScheduleAdminlessReminderJob lusr tid cid daysUntilDeletion scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid daysUntilDeletion scheduledFor + ScheduleAdminlessReminderJob lusr tid cid deletionScheduledFor scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid deletionScheduledFor scheduledFor StartJobWorkers cfg handlers -> embed $ runJobWorkers cfg handlers scheduleAdminlessDeletionJob :: @@ -104,10 +105,10 @@ scheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> - Int -> + UTCTimeMillis -> UTCTime -> Sem r ScheduledJob -scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUntilDeletion scheduledFor = do +scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletionScheduledFor scheduledFor = do let arbiterEnv = ArbiterHasql.createHasqlEnvWithPool (Proxy @ScheduledJobsRegistry) @@ -123,7 +124,7 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId daysUnti scheduledJobScheduledFor = scheduledFor } arbiterJob = - (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) daysUntilDeletion)) + (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) deletionScheduledFor)) { ArbiterCore.notVisibleUntil = Just scheduledFor, ArbiterCore.maxAttempts = Just 3 } diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs index beb990df321..23471e1e75e 100644 --- a/services/background-worker/src/Wire/AdminlessJobsWorker.hs +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -80,7 +80,7 @@ runAdminlessReminderJob extEnv job = do Log.msg (Log.val "Running adminless reminder job") . Log.field "team_id" (show (adminlessReminderJobTeamId jobPayload)) . Log.field "conversation_id" (show (adminlessReminderJobConversationId jobPayload)) - . Log.field "days_until_deletion" (show (adminlessReminderJobDaysUntilDeletion jobPayload)) + . Log.field "deletion_scheduled_for" (show (adminlessReminderJobDeletionScheduledFor jobPayload)) result <- liftIO $ runBackgroundWorkerEffects env extEnv (RequestId "adminless-reminder") Nothing $ @@ -88,7 +88,7 @@ runAdminlessReminderJob extEnv job = do internalNotifyAdminlessReminder (toLocalUnsafe env.federationDomain (adminlessReminderJobOrigUserId jobPayload)) (toLocalUnsafe env.federationDomain (adminlessReminderJobConversationId jobPayload)) - (adminlessReminderJobDaysUntilDeletion jobPayload) + (adminlessReminderJobDeletionScheduledFor jobPayload) cleanupScheduledJob AdminlessReminder (adminlessReminderJobTeamId jobPayload) diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 8b7412f8f1a..1ed0761fc42 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -331,7 +331,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretConversationStoreByMigration env.postgresMigration.conversation env.cassandraGalley . interpretTeamStoreToCassandra . interpretTeamCollaboratorsStoreToPostgres - . interpretJobSubsystem jobSubsystemConfig + . interpretJobSubsystem jobSubsystemConfig . interpretLegalHoldStoreToCassandra FeatureLegalHoldDisabledPermanently . interpretTeamJournal Nothing . nowToIO diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 1fa4d113c8d..920acaf3d5f 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -299,7 +299,7 @@ newEnv opts = do enableSSE = True, sseHub = sseHub } - jobsApiApp <- pure $ ArbUI.arbiterAppWithAdmin config + let jobsApiApp = ArbUI.arbiterAppWithAdmin config Log.info lgr $ Log.msg (Log.val "Internal jobs API initialized") amqpJobsPublisherChannel <- Q.mkRabbitMqChannelMVar lgr (Just "brig") opts.rabbitmq pure $! From 4bb51e0ca30ea884446deca3118706039cee645c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 14:16:57 +0000 Subject: [PATCH 45/65] do not use arbiter api in tests, check for reminders and deletion instead --- integration/test/Test/AdminlessGroups.hs | 178 +++-------------------- 1 file changed, 22 insertions(+), 156 deletions(-) diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index ed6b6baa940..31cee6a75f7 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -21,9 +21,8 @@ import API.Brig import API.Galley import API.GalleyInternal hiding (getConversation) import qualified API.GalleyInternal as GalleyI -import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime, nominalDay) -import Data.Time.Format.ISO8601 (iso8601ParseM) import MLS.Util +import Notifications import SetupHelpers hiding (deleteUser) import Testlib.Prelude @@ -111,175 +110,42 @@ testOnLastAdminLeaveNoEligibleMembersExist = do [ "status" .= "enabled", "config" .= object - [ "deletionTimeout" .= (1 :: Int), - "reminderTimeouts" .= ([0] :: [Int]), + -- The reminders are due early (+1s and +2s), while deletion is + -- later (+10s). This gives Arbiter's 1s polling and serial + -- grouped-job processing enough room to emit both reminders + -- before the conversation is deleted. + [ "deletionTimeoutDuration" .= "10s", + "reminderTimeoutDurations" .= ["9s", "8s"], "promotionStrategy" .= "random" ] ] ) >>= assertSuccess - alice1 <- createMLSClient def alice - void $ uploadNewKeyPackage def alice1 - - conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201 - convId <- objConvId conv - convIdText <- conv %. "qualified_id.id" & asString - createGroup def alice1 convId - void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle - - now <- liftIO getCurrentTime - - -- alice leaves the conversation, no error, group will be marked for deletion - bindResponse (removeMember alice conv alice) $ \resp -> do + let newApp :: NewApp + newApp = def {name = "adminless-reminder-app", description = "non-eligible reminder recipient"} + app <- bindResponse (createApp alice tid newApp) $ \resp -> do resp.status `shouldMatchInt` 200 + resp.json %. "user" - deletionJobs <- listArbiterJobs alice "adminless_deletion_jobs" >>= getJSON 200 - deletionJobs %. "jobs" - & asList >>= \jobs -> do - jobInfo <- forM jobs $ \job -> do - jobTeamId <- job %. "payload.team_id" & asString - jobConversationId <- job %. "payload.conversation_id" & asString - notVisibleUntil <- job %. "notVisibleUntil" & asString - pure (job, jobTeamId, jobConversationId, notVisibleUntil) - let matchingJobs = [job | (job, jobTeamId, jobConversationId, _) <- jobInfo, jobTeamId == tid && jobConversationId == convIdText] - assertBool - ( "expected one deletion job for team " - <> show tid - <> " and conversation " - <> convIdText - <> ", but saw: " - <> show [(team, conversation, visibleUntil) | (_, team, conversation, visibleUntil) <- jobInfo] - ) - (length matchingJobs == 1) - [job] <- pure matchingJobs - assertJobTimeout now (1 :: Int) job - - reminderJobs <- listArbiterJobs alice "adminless_reminder_jobs" >>= getJSON 200 - reminderJobs %. "jobs" - & asList >>= \jobs -> do - jobInfo <- forM jobs $ \job -> do - jobTeamId <- job %. "payload.team_id" & asString - jobConversationId <- job %. "payload.conversation_id" & asString - notVisibleUntil <- job %. "notVisibleUntil" & asString - pure (job, jobTeamId, jobConversationId, notVisibleUntil) - let matchingJobs = [job | (job, jobTeamId, jobConversationId, _) <- jobInfo, jobTeamId == tid && jobConversationId == convIdText] - assertBool - ( "expected one reminder job for team " - <> show tid - <> " and conversation " - <> convIdText - <> ", but saw: " - <> show [(team, conversation, visibleUntil) | (_, team, conversation, visibleUntil) <- jobInfo] - ) - (length matchingJobs == 1) - [job] <- pure matchingJobs - assertJobTimeout now (1 :: Int) job - where - assertJobTimeout scheduledAt expectedDays job = do - notVisibleUntilStr <- job %. "notVisibleUntil" & asString - notVisibleUntil <- assertJust ("expected ISO 8601 timestamp, got: " <> notVisibleUntilStr) $ iso8601ParseM @Maybe @UTCTime notVisibleUntilStr - let expected = addUTCTime (fromIntegral expectedDays * nominalDay) scheduledAt - tolerance = 120 :: NominalDiffTime - assertBool - ("notVisibleUntil " <> show notVisibleUntil <> " is not within tolerance of expected " <> show expected) - (notVisibleUntil >= addUTCTime (negate tolerance) expected && notVisibleUntil <= addUTCTime tolerance expected) - - listArbiterJobs user table = do - req <- baseRequest user Brig Unversioned $ joinHttpPath ["i", "jobs", "api", "v1", table, "jobs"] - submit "GET" $ req & addQueryParams [("limit", "100"), ("offset", "0")] - -testAdminlessJobsExecuteViaArbiterApi :: (HasCallStack) => App () -testAdminlessJobsExecuteViaArbiterApi = do - (alice, tid, _) <- createTeam OwnDomain 1 - - -- we disable the feature and create an adminless group - setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" - patchTeamFeature - OwnDomain - tid - "preventAdminlessGroups" - (object ["status" .= "disabled"]) - >>= assertSuccess - - alice1 <- createMLSClient def alice - void $ uploadNewKeyPackage def alice1 + [alice1, app1] <- traverse (createMLSClient def) [alice, app] + traverse_ (uploadNewKeyPackage def) [alice1, app1] conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201 convId <- objConvId conv - convIdText <- conv %. "qualified_id.id" & asString - aliceIdText <- alice %. "qualified_id.id" & asString createGroup def alice1 convId - void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [app] >>= sendAndConsumeCommitBundle - bindResponse (removeMember alice conv alice) $ \resp -> do - resp.status `shouldMatchInt` 200 + withWebSockets [app] $ \[wsApp] -> do + -- alice leaves the conversation, no error, group will be marked for deletion + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 - bindResponse (GalleyI.getConversation conv) $ \resp -> do - resp.status `shouldMatchInt` 200 + void $ awaitNMatches 2 isConvAdminlessReminderNotif wsApp - -- now we enabled the feature so that the job will get executed - patchTeamFeature - OwnDomain - tid - "preventAdminlessGroups" - ( object - [ "status" .= "enabled", - "config" - .= object - [ "deletionTimeout" .= (7 :: Int), - "reminderTimeouts" .= ([2] :: [Int]), - "promotionStrategy" .= "random" - ] - ] - ) - >>= assertSuccess - - now <- liftIO getCurrentTime - let deletionAt = addUTCTime (-5) now - - insertedJob <- - postArbiterJob - alice - "adminless_deletion_jobs" - [ "payload" - .= object - [ "team_id" .= tid, - "conversation_id" .= convIdText, - "orig_user_id" .= aliceIdText - ], - "groupKey" .= convIdText, - "priority" .= (0 :: Int), - "notVisibleUntil" .= deletionAt, - "dedupKey" .= Null, - "maxAttempts" .= (3 :: Int) - ] - >>= getJSON 200 - >>= (%. "job") - assertArbiterJobMatches insertedJob tid convIdText deletionAt - - retryT $ do - bindResponse (GalleyI.getConversation conv) $ \resp -> do - resp.status `shouldMatchInt` 404 - where - assertArbiterJobMatches job expectedTeamId expectedConvId expectedNotVisibleUntil = do - jobTeamId <- job %. "payload.team_id" & asString - jobConversationId <- job %. "payload.conversation_id" & asString - jobTeamId `shouldMatch` expectedTeamId - jobConversationId `shouldMatch` expectedConvId - assertJobVisibleAt expectedNotVisibleUntil job - - assertJobVisibleAt expected job = do - notVisibleUntilStr <- job %. "notVisibleUntil" & asString - notVisibleUntil <- assertJust ("expected ISO 8601 timestamp, got: " <> notVisibleUntilStr) $ iso8601ParseM @Maybe @UTCTime notVisibleUntilStr - let tolerance = 5 :: NominalDiffTime - assertBool - ("notVisibleUntil " <> show notVisibleUntil <> " is not within tolerance of expected " <> show expected) - (notVisibleUntil >= addUTCTime (negate tolerance) expected && notVisibleUntil <= addUTCTime tolerance expected) - - postArbiterJob user table body = do - req <- baseRequest user Brig Unversioned $ joinHttpPath ["i", "jobs", "api", "v1", table, "jobs"] - submit "POST" $ addJSONObject body req + retryT $ do + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 404 testOnLastAdminLeaveFeatureDisabled :: (HasCallStack) => App () testOnLastAdminLeaveFeatureDisabled = do From a9e399be34aea289e0240cc96dd4526d9bb2763d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 14:30:22 +0000 Subject: [PATCH 46/65] remove arbiter API/UI --- changelog.d/0-release-notes/WPB-26489 | 2 +- libs/wire-api/src/Wire/API/Jobs.hs | 12 +++---- .../src/Wire/API/Routes/Internal/Brig.hs | 2 -- .../src/Wire/API/Routes/Internal/Jobs.hs | 34 ------------------- libs/wire-api/wire-api.cabal | 1 - libs/wire-subsystems/default.nix | 6 ---- libs/wire-subsystems/wire-subsystems.cabal | 2 -- nix/haskell-pins.nix | 3 -- nix/manual-overrides.nix | 3 -- services/brig/brig.cabal | 4 --- services/brig/default.nix | 8 ----- services/brig/src/Brig/API/Internal.hs | 7 +--- services/brig/src/Brig/App.hs | 27 +-------------- 13 files changed, 9 insertions(+), 102 deletions(-) delete mode 100644 libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs diff --git a/changelog.d/0-release-notes/WPB-26489 b/changelog.d/0-release-notes/WPB-26489 index ccadf9d1c74..f03cf536a19 100644 --- a/changelog.d/0-release-notes/WPB-26489 +++ b/changelog.d/0-release-notes/WPB-26489 @@ -1 +1 @@ -Background-worker scheduled jobs now run through Arbiter. The background-worker configuration includes `scheduledJobs.pollInterval`, which controls how often the scheduler wakes up to look for due jobs. Arbiter's internal UI is available under `/i/jobs`, and its internal REST API is available under `/i/jobs/api/v1`. +Background-worker scheduled jobs now run through Arbiter. The background-worker configuration includes `scheduledJobs.pollInterval`, which controls how often the scheduler wakes up to look for due jobs. diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index a48f8f9ced4..34d569843b0 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -61,9 +61,9 @@ instance FromJSON MeetingsCleanupJob where parseJSON _ = fail "MeetingsCleanupJob expects null" -- | Payload for adminless deletions. --- Keep the JSON encoding backwards compatible. The jobs API reads these payloads --- back from Arbiter, so changing field names or shapes without a migration will --- break job listing and management. +-- Keep the JSON encoding backwards compatible. Arbiter persists these payloads +-- and workers decode them later, so changing field names or shapes without a +-- migration will break already scheduled jobs. data AdminlessDeletionJob = AdminlessDeletionJob { adminlessDeletionJobTeamId :: TeamId, adminlessDeletionJobConversationId :: ConvId, @@ -84,9 +84,9 @@ instance ToSchema AdminlessDeletionJob where <*> (.adminlessDeletionJobOrigUserId) .= field "orig_user_id" schema -- | Payload for adminless reminders. --- Keep the JSON encoding backwards compatible. The jobs API reads these payloads --- back from Arbiter, so changing field names or shapes without a migration will --- break job listing and management. +-- Keep the JSON encoding backwards compatible. Arbiter persists these payloads +-- and workers decode them later, so changing field names or shapes without a +-- migration will break already scheduled jobs. data AdminlessReminderJob = AdminlessReminderJob { adminlessReminderJobTeamId :: TeamId, adminlessReminderJobConversationId :: ConvId, diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 67992643c7f..4a0e907c808 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -88,7 +88,6 @@ import Wire.API.Routes.Internal.Brig.EnterpriseLogin (EnterpriseLoginApi) import Wire.API.Routes.Internal.Brig.OAuth (OAuthAPI) import Wire.API.Routes.Internal.Brig.SearchIndex (ISearchIndexAPI) import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti qualified as Multi -import Wire.API.Routes.Internal.Jobs (JobsAppAPI) import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public (ZUser) @@ -726,7 +725,6 @@ type API = :<|> SAMLIdPAPI :<|> DeleteApp :<|> GetAppIds - :<|> JobsAppAPI ) type SAMLIdPAPI = diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs deleted file mode 100644 index e383e37590a..00000000000 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Jobs.hs +++ /dev/null @@ -1,34 +0,0 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE TypeOperators #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2026 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) --- any later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or --- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License --- for more details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Wire.API.Routes.Internal.Jobs - ( JobsAppAPI, - ) -where - -import Servant - --- | Internal mount point for the embedded Arbiter admin UI and REST API. --- --- The embedded Arbiter app serves both the UI and its API under one prefix, --- with the API living at @/api/v1@ relative to the app root. -type JobsAppAPI = - "jobs" - :> Raw diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index a48e7b70c29..26a5a410330 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -187,7 +187,6 @@ library Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti Wire.API.Routes.Internal.Galley.TeamsIntra Wire.API.Routes.Internal.Gundeck - Wire.API.Routes.Internal.Jobs Wire.API.Routes.Internal.Spar Wire.API.Routes.LowLevelStream Wire.API.Routes.MultiTablePaging diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 00ac3e0a708..3a6ae814248 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -14,8 +14,6 @@ , arbiter-core , arbiter-hasql , arbiter-migrations -, arbiter-servant -, arbiter-servant-ui , arbiter-worker , asn1-encoding , asn1-types @@ -164,8 +162,6 @@ mkDerivation { arbiter-core arbiter-hasql arbiter-migrations - arbiter-servant - arbiter-servant-ui arbiter-worker asn1-encoding asn1-types @@ -302,8 +298,6 @@ mkDerivation { arbiter-core arbiter-hasql arbiter-migrations - arbiter-servant - arbiter-servant-ui arbiter-worker asn1-encoding asn1-types diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 74f0e292019..f3b986d15b9 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -94,8 +94,6 @@ common common-all , arbiter-core , arbiter-hasql , arbiter-migrations - , arbiter-servant - , arbiter-servant-ui , arbiter-worker , asn1-encoding , asn1-types diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 40f17549dfc..b8d5b97f164 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -64,9 +64,6 @@ let arbiter-core = "arbiter-core"; arbiter-hasql = "arbiter-hasql"; arbiter-migrations = "arbiter-migrations"; - arbiter-servant = "arbiter-servant"; - arbiter-servant-ui = "arbiter-servant-ui"; - arbiter-simple = "arbiter-simple"; arbiter-test-common = "arbiter-test-common"; arbiter-worker = "arbiter-worker"; }; diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index df47c94eda2..dffe07d8a39 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -36,9 +36,6 @@ hself: hsuper: { arbiter-core = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-core); arbiter-hasql = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-hasql); arbiter-migrations = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-migrations); - arbiter-servant = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-servant); - arbiter-servant-ui = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-servant-ui); - arbiter-simple = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-simple); arbiter-worker = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-worker); # Test fixtures don't seem to be bundled for Hackage diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 3f59e8e7e62..7868d06447c 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -214,10 +214,6 @@ library , amazonka-ses >=2 , amazonka-sqs >=2 , amqp - , arbiter-core - , arbiter-servant - , arbiter-servant-ui - , arbiter-simple , async >=2.1 , auto-update >=0.1 , base >=4 && <5 diff --git a/services/brig/default.nix b/services/brig/default.nix index bf90995132f..e431f9c93db 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -9,10 +9,6 @@ , amazonka-ses , amazonka-sqs , amqp -, arbiter-core -, arbiter-servant -, arbiter-servant-ui -, arbiter-simple , async , attoparsec , auto-update @@ -165,10 +161,6 @@ mkDerivation { amazonka-ses amazonka-sqs amqp - arbiter-core - arbiter-servant - arbiter-servant-ui - arbiter-simple async auto-update base diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index d0e848509f2..21762a850e9 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -77,7 +77,6 @@ import Wire.API.MLS.CipherSuite import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Brig as BrigIRoutes import Wire.API.Routes.Internal.Brig.Connection -import Wire.API.Routes.Internal.Jobs qualified as JobsIRoutes import Wire.API.Routes.Named import Wire.API.Team.Export import Wire.API.Team.Feature @@ -191,7 +190,7 @@ servantSitemap :: Member ClientSubsystem r ) => ServerT BrigIRoutes.API (Handler r) -servantSitemap env = +servantSitemap _env = istatusAPI :<|> ejpdAPI :<|> accountAPI @@ -209,7 +208,6 @@ servantSitemap env = :<|> samlIdPApi :<|> Named @"i-delete-app" deleteAppH :<|> Named @"i-get-app-ids" getAppIdsH - :<|> jobsApp env istatusAPI :: forall r. ServerT BrigIRoutes.IStatusAPI (Handler r) istatusAPI = Named @"get-status" (pure NoContent) @@ -1056,6 +1054,3 @@ deleteAppH tid uid = lift . liftSem $ AppSubsystem.deleteApp tid uid >> pure NoC getAppIdsH :: (Member AppStore r) => TeamId -> Handler r [UserId] getAppIdsH tid = lift . liftSem $ map (.id) <$> AppStore.getApps tid - -jobsApp :: App.Env -> ServerT JobsIRoutes.JobsAppAPI (Handler r) -jobsApp env = Tagged env.jobsApiApp diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 920acaf3d5f..46c01726232 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -74,7 +74,6 @@ module Brig.App rateLimitEnvLens, amqpJobsPublisherChannelLens, postgresMigrationLens, - jobsApiAppLens, initZAuth, initLogger, initPostgresPool, @@ -102,10 +101,6 @@ module Brig.App ) where -import Arbiter.Core qualified as ArbiterCore -import Arbiter.Servant.Server qualified as ArbServer -import Arbiter.Servant.UI qualified as ArbUI -import Arbiter.Simple qualified as ArbSimple import Bilge qualified as RPC import Bilge.IO import Bilge.RPC (HasRequestId (..)) @@ -134,7 +129,6 @@ import Data.Credentials (Credentials (..)) import Data.Domain import Data.Id import Data.Misc -import Data.Proxy (Proxy (..)) import Data.Qualified import Data.Text qualified as Text import Data.Text.Encoding (encodeUtf8) @@ -145,13 +139,13 @@ import Database.Bloodhound qualified as ES import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool qualified as HasqlPool import Hasql.Pool.Extended +import Hasql.Pool.Extended (initPostgresPool, postgresqlConnectionString) import Hasql.Pool.Extended qualified as HasqlPool import Imports import Network.AMQP qualified as Q import Network.AMQP.Extended qualified as Q import Network.HTTP.Client (responseTimeoutMicro) import Network.HTTP.Client.OpenSSL -import Network.Wai (Application) import OpenSSL.EVP.Digest (Digest, getDigestByName) import OpenSSL.Session (SSLOption (..)) import OpenSSL.Session qualified as SSL @@ -167,7 +161,6 @@ import System.Logger.Extended qualified as Log import Util.Options import Util.SuffixNamer import Wire.API.Federation.Error (federationNotImplemented) -import Wire.API.Jobs (ScheduledJobsRegistry) import Wire.API.Locale (Locale) import Wire.API.Routes.Version import Wire.API.User.Identity @@ -229,7 +222,6 @@ data Env = Env enableSFTFederation :: Maybe Bool, rateLimitEnv :: RateLimitEnv, amqpJobsPublisherChannel :: MVar Q.Channel, - jobsApiApp :: Application, postgresMigration :: PostgresMigrationOpts } @@ -285,22 +277,6 @@ newEnv opts = do idxEnv <- mkIndexEnv opts.elasticsearch lgr (Opt.galley opts) mgr rateLimitEnv <- newRateLimitEnv opts.settings.passwordHashingRateLimit hasqlPool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword - Log.info lgr $ Log.msg (Log.val "Initializing internal jobs API") - jobsApiConnStr <- postgresqlConnectionString opts.postgresql opts.postgresqlPassword - arbiterEnv <- - ArbSimple.createSimpleEnv - (Proxy @ScheduledJobsRegistry) - (encodeUtf8 jobsApiConnStr) - ArbiterCore.defaultSchemaName - sseHub <- newMVar Nothing - let config = - ArbServer.ArbiterServerConfig - { serverEnv = arbiterEnv, - enableSSE = True, - sseHub = sseHub - } - let jobsApiApp = ArbUI.arbiterAppWithAdmin config - Log.info lgr $ Log.msg (Log.val "Internal jobs API initialized") amqpJobsPublisherChannel <- Q.mkRabbitMqChannelMVar lgr (Just "brig") opts.rabbitmq pure $! Env @@ -344,7 +320,6 @@ newEnv opts = do enableSFTFederation = opts.multiSFT, rateLimitEnv, amqpJobsPublisherChannel, - jobsApiApp, postgresMigration = opts.postgresMigration } where From 69eeecbf1814d36fe53f4e55d5090223c4170099 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 3 Jul 2026 14:46:29 +0000 Subject: [PATCH 47/65] removed not needed scheduled jobs table and migrations --- libs/types-common/src/Data/Id.hs | 7 - libs/wire-api/src/Wire/API/Jobs.hs | 92 +--------- .../unit/Test/Wire/API/Roundtrip/Aeson.hs | 1 - .../20260624095944-extend-job-model.sql | 28 --- libs/wire-subsystems/src/Wire/JobStore.hs | 44 ----- .../src/Wire/JobStore/Postgres.hs | 169 ------------------ libs/wire-subsystems/src/Wire/JobSubsystem.hs | 4 +- .../src/Wire/JobSubsystem/Interpreter.hs | 40 +---- .../ConversationSubsystem/InterpreterSpec.hs | 19 +- libs/wire-subsystems/wire-subsystems.cabal | 2 - nix/haskell-pins.nix | 1 + nix/manual-overrides.nix | 1 + postgres-schema.sql | 51 ------ .../src/Wire/AdminlessJobsWorker.hs | 39 +--- services/galley/src/Galley/App.hs | 4 - 15 files changed, 16 insertions(+), 486 deletions(-) delete mode 100644 libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql delete mode 100644 libs/wire-subsystems/src/Wire/JobStore.hs delete mode 100644 libs/wire-subsystems/src/Wire/JobStore/Postgres.hs diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs index 0b89ecae0eb..a1c0aedf911 100644 --- a/libs/types-common/src/Data/Id.hs +++ b/libs/types-common/src/Data/Id.hs @@ -59,7 +59,6 @@ module Data.Id ChallengeId, MeetingId, HistoryClientId, - ScheduledJobId, -- * Utils uuidSchema, @@ -119,7 +118,6 @@ data IdTag | Job | Meeting | HistoryClient - | ScheduledJob idTagName :: IdTag -> Text idTagName Asset = "Asset" @@ -137,7 +135,6 @@ idTagName Challenge = "Challenge" idTagName Job = "Job" idTagName Meeting = "Meeting" idTagName HistoryClient = "HistoryClient" -idTagName ScheduledJob = "ScheduledJob" class KnownIdTag (t :: IdTag) where idTagValue :: IdTag @@ -170,8 +167,6 @@ instance KnownIdTag 'Meeting where idTagValue = Meeting instance KnownIdTag 'HistoryClient where idTagValue = HistoryClient -instance KnownIdTag 'ScheduledJob where idTagValue = ScheduledJob - type AssetId = Id 'Asset type InvitationId = Id 'Invitation @@ -204,8 +199,6 @@ type MeetingId = Id 'Meeting type HistoryClientId = Id 'HistoryClient -type ScheduledJobId = Id 'ScheduledJob - -- Id ------------------------------------------------------------------------- data NoId = NoId deriving (Eq, Show, Generic) diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index 34d569843b0..2a18c0cefef 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeFamilies #-} -- This file is part of the Wire Server implementation. @@ -24,15 +23,11 @@ module Wire.API.Jobs where import Data.Aeson (FromJSON, ToJSON, Value (Null), parseJSON, toJSON) import Data.Id -import Data.Int qualified as Int import Data.Json.Util import Data.OpenApi qualified as S import Data.Schema -import Data.Time.Clock (UTCTime) -import Data.UUID (UUID) import Imports -import Test.QuickCheck (Arbitrary (..), elements) -import Wire.API.PostgresMarshall +import Test.QuickCheck (Arbitrary (..)) -- | Shared queue name for the scheduled meetings cleanup job. meetingsCleanupQueueName :: Text @@ -108,91 +103,6 @@ instance ToSchema AdminlessReminderJob where <*> (.adminlessReminderJobOrigUserId) .= field "orig_user_id" schema <*> (.adminlessReminderJobDeletionScheduledFor) .= field "deletion_scheduled_for" schema --- | The generic scheduled-job families we currently need to persist. -data ScheduledJobKind - = AdminlessReminder - | AdminlessDeletion - | AdminlessSetup - | AdminlessTeardown - deriving stock (Bounded, Enum, Eq, Generic, Ord, Show) - deriving (ToJSON, FromJSON, S.ToSchema) via (Schema ScheduledJobKind) - -instance Arbitrary ScheduledJobKind where - arbitrary = elements [minBound .. maxBound] - -instance ToSchema ScheduledJobKind where - schema = - enum @Text $ - mconcat - [ element "adminless-reminder" AdminlessReminder, - element "adminless-deletion" AdminlessDeletion, - element "adminless-setup" AdminlessSetup, - element "adminless-teardown" AdminlessTeardown - ] - -scheduledJobKindToInt :: ScheduledJobKind -> Int -scheduledJobKindToInt = fromEnum - -scheduledJobKindFromInt :: Int -> Maybe ScheduledJobKind -scheduledJobKindFromInt n - | n < fromEnum (minBound :: ScheduledJobKind) = Nothing - | n > fromEnum (maxBound :: ScheduledJobKind) = Nothing - | otherwise = Just (toEnum n) - --- | App-level metadata stored alongside Arbiter's runtime state. -data ScheduledJob = ScheduledJob - { scheduledJobId :: ScheduledJobId, - scheduledJobKind :: ScheduledJobKind, - scheduledJobTeamId :: TeamId, - scheduledJobConversationId :: Maybe ConvId, - scheduledJobScheduledFor :: UTCTime - } - deriving stock (Eq, Generic, Show) - -instance PostgresMarshall Int.Int32 ScheduledJobKind where - postgresMarshall = fromIntegral . scheduledJobKindToInt - -instance PostgresUnmarshall Int.Int32 ScheduledJobKind where - postgresUnmarshall n = - maybe (Left "invalid scheduled job kind") Right $ - scheduledJobKindFromInt (fromIntegral n) - -instance PostgresMarshall (ScheduledJobId, Int.Int32, TeamId, Maybe ConvId, UTCTime) ScheduledJob where - postgresMarshall ScheduledJob {..} = - ( scheduledJobId, - postgresMarshall scheduledJobKind, - scheduledJobTeamId, - scheduledJobConversationId, - scheduledJobScheduledFor - ) - -instance PostgresUnmarshall (ScheduledJobId, Int.Int32, TeamId, Maybe ConvId, UTCTime) ScheduledJob where - postgresUnmarshall (jobId, jobKind, teamId, conversationId, scheduledFor) = - ScheduledJob - <$> postgresUnmarshall jobId - <*> postgresUnmarshall jobKind - <*> postgresUnmarshall teamId - <*> postgresUnmarshall conversationId - <*> postgresUnmarshall scheduledFor - -instance PostgresMarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where - postgresMarshall ScheduledJob {..} = - ( toUUID scheduledJobId, - postgresMarshall scheduledJobKind, - toUUID scheduledJobTeamId, - toUUID <$> scheduledJobConversationId, - scheduledJobScheduledFor - ) - -instance PostgresUnmarshall (UUID, Int.Int32, UUID, Maybe UUID, UTCTime) ScheduledJob where - postgresUnmarshall (jobId, jobKind, teamId, conversationId, scheduledFor) = - ScheduledJob - <$> postgresUnmarshall jobId - <*> postgresUnmarshall jobKind - <*> postgresUnmarshall teamId - <*> postgresUnmarshall conversationId - <*> pure scheduledFor - -- | Registry for the scheduled jobs we expose via Arbiter. type ScheduledJobsRegistry = '[ '("meetings_cleanup_jobs", MeetingsCleanupJob), diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index 5d81586db37..06716dbe3b2 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -110,7 +110,6 @@ tests = testRoundTrip @Connection.UserConnection, testRoundTrip @Connection.UserConnectionList, testRoundTrip @Connection.ConnectionUpdate, - testRoundTrip @Jobs.ScheduledJobKind, testRoundTrip @(Conversation.OwnConversation Conversation.GroupConvType), testRoundTrip @(Conversation.OwnConversation Conversation.GroupConvTypeLegacy), testRoundTrip @(Conversation.Conversation Conversation.GroupConvType), diff --git a/libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql b/libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql deleted file mode 100644 index 9e8a3ee5b5b..00000000000 --- a/libs/wire-subsystems/postgres-migrations/20260624095944-extend-job-model.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Migration: Add a minimal scheduled-jobs catalog for app-level lookup --- Description: Stores the metadata we need to find and manage scheduled jobs --- while Arbiter owns runtime execution state. - -CREATE TABLE IF NOT EXISTS scheduled_jobs ( - id uuid NOT NULL, -- app-level job id - kind int NOT NULL, -- maps to a Haskell sum type - team_id uuid NOT NULL, -- team scope for teardown and lookup - conversation_id uuid, -- optional conversation scope for later lookups - scheduled_for timestamptz NOT NULL, -- when the job should run - PRIMARY KEY (id) -); - --- Find the next due jobs quickly. -CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_scheduled_for - ON scheduled_jobs (scheduled_for); - --- Find jobs by family. -CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_kind - ON scheduled_jobs (kind); - --- Find jobs for a team, optionally narrowed by family. -CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_team_kind - ON scheduled_jobs (team_id, kind); - --- Find jobs scoped to a conversation. -CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_conversation_id - ON scheduled_jobs (conversation_id); diff --git a/libs/wire-subsystems/src/Wire/JobStore.hs b/libs/wire-subsystems/src/Wire/JobStore.hs deleted file mode 100644 index c3e1b85fadb..00000000000 --- a/libs/wire-subsystems/src/Wire/JobStore.hs +++ /dev/null @@ -1,44 +0,0 @@ -{-# LANGUAGE TemplateHaskell #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2026 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) --- any later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Wire.JobStore - ( JobStore (..), - createJob, - deleteJob, - deleteJobsByTeamAndKind, - findJobById, - findJobsByConversationId, - findJobsByTeamAndKind, - ) -where - -import Data.Id (ConvId, ScheduledJobId, TeamId) -import Imports -import Polysemy -import Wire.API.Jobs - -data JobStore m a where - CreateJob :: ScheduledJob -> JobStore m () - FindJobById :: ScheduledJobId -> JobStore m (Maybe ScheduledJob) - FindJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobStore m [ScheduledJob] - FindJobsByConversationId :: ConvId -> JobStore m [ScheduledJob] - DeleteJob :: ScheduledJobId -> JobStore m () - DeleteJobsByTeamAndKind :: TeamId -> ScheduledJobKind -> JobStore m () - -makeSem ''JobStore diff --git a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs b/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs deleted file mode 100644 index cf1eb1219a7..00000000000 --- a/libs/wire-subsystems/src/Wire/JobStore/Postgres.hs +++ /dev/null @@ -1,169 +0,0 @@ -{-# LANGUAGE QuasiQuotes #-} -{-# LANGUAGE TypeApplications #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2026 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) --- any later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or --- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License --- for more details. --- --- You should have received a copy of the GNU Affero General Public License --- along with this program. If not, see . - -module Wire.JobStore.Postgres - ( interpretJobStoreToPostgres, - ) -where - -import Data.Id (ConvId, ScheduledJobId, TeamId) -import Hasql.Pool -import Hasql.Statement qualified as Hasql -import Hasql.TH -import Imports -import Polysemy -import Polysemy.Error (Error) -import Polysemy.Input -import Wire.API.Jobs -import Wire.API.PostgresMarshall -import Wire.JobStore -import Wire.Postgres - -interpretJobStoreToPostgres :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => - InterpreterFor JobStore r -interpretJobStoreToPostgres = - interpret $ \case - CreateJob job -> createJobImpl job - FindJobById jobId -> findJobByIdImpl jobId - FindJobsByTeamAndKind teamId kind -> findJobsByTeamAndKindImpl teamId kind - FindJobsByConversationId conversationId -> findJobsByConversationIdImpl conversationId - DeleteJob jobId -> deleteJobImpl jobId - DeleteJobsByTeamAndKind teamId kind -> deleteJobsByTeamAndKindImpl teamId kind - -createJobImpl :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => - ScheduledJob -> - Sem r () -createJobImpl job = - runStatement job insertJob - where - insertJob :: Hasql.Statement ScheduledJob () - insertJob = - lmapPG - [resultlessStatement| - INSERT INTO scheduled_jobs - (id, kind, team_id, conversation_id, scheduled_for) - VALUES - ($1 :: uuid, $2 :: int4, $3 :: uuid, $4 :: uuid?, $5 :: timestamptz) |] - -findJobByIdImpl :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => - ScheduledJobId -> - Sem r (Maybe ScheduledJob) -findJobByIdImpl jobId = - runStatement jobId selectJob - where - selectJob :: Hasql.Statement ScheduledJobId (Maybe ScheduledJob) - selectJob = - dimapPG - [maybeStatement| - SELECT - (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), - (scheduled_for :: timestamptz) - FROM scheduled_jobs - WHERE id = ($1 :: uuid) |] - -findJobsByTeamAndKindImpl :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => - TeamId -> - ScheduledJobKind -> - Sem r [ScheduledJob] -findJobsByTeamAndKindImpl teamId kind = - runStatement (teamId, kind) selectJobs - where - selectJobs :: Hasql.Statement (TeamId, ScheduledJobKind) [ScheduledJob] - selectJobs = - dimapPG - [vectorStatement| - SELECT - (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), - (scheduled_for :: timestamptz) - FROM scheduled_jobs - WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) - ORDER BY scheduled_for ASC, id ASC |] - -findJobsByConversationIdImpl :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => - ConvId -> - Sem r [ScheduledJob] -findJobsByConversationIdImpl conversationId = - runStatement conversationId selectJobs - where - selectJobs :: Hasql.Statement ConvId [ScheduledJob] - selectJobs = - dimapPG - [vectorStatement| - SELECT - (id :: uuid), (kind :: int4), (team_id :: uuid), (conversation_id :: uuid?), - (scheduled_for :: timestamptz) - FROM scheduled_jobs - WHERE conversation_id = ($1 :: uuid) - ORDER BY scheduled_for ASC, id ASC |] - -deleteJobImpl :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => - ScheduledJobId -> - Sem r () -deleteJobImpl jobId = - runStatement jobId deleteJobStmt - where - deleteJobStmt :: Hasql.Statement ScheduledJobId () - deleteJobStmt = - lmapPG - [resultlessStatement| - DELETE FROM scheduled_jobs - WHERE id = ($1 :: uuid) |] - -deleteJobsByTeamAndKindImpl :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => - TeamId -> - ScheduledJobKind -> - Sem r () -deleteJobsByTeamAndKindImpl teamId kind = - runStatement (teamId, kind) deleteJobsStmt - where - deleteJobsStmt :: Hasql.Statement (TeamId, ScheduledJobKind) () - deleteJobsStmt = - lmapPG - [resultlessStatement| - DELETE FROM scheduled_jobs - WHERE team_id = ($1 :: uuid) AND kind = ($2 :: int4) |] diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index 04e8441490f..a6c47b6d762 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -61,8 +61,8 @@ data JobWorkerHandlers = JobWorkerHandlers } data JobSubsystem m a where - ScheduleAdminlessDeletionJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m ScheduledJob - ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> UTCTimeMillis -> UTCTime -> JobSubsystem m ScheduledJob + ScheduleAdminlessDeletionJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m () + ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> UTCTimeMillis -> UTCTime -> JobSubsystem m () StartJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> JobSubsystem m CleanupAction makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 82af3e64c14..1d20df3582c 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -33,11 +33,9 @@ import Data.Json.Util (UTCTimeMillis) import Data.Proxy (Proxy (..)) import Data.Qualified import Data.Time -import Data.UUID.V4 qualified as UUID import Imports import Polysemy import Wire.API.Jobs -import Wire.JobStore qualified as JobStore import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkerHandlers (..), JobWorkersConfig (..)) import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) @@ -49,9 +47,7 @@ runJobWorkers JobWorkersConfig {..} JobWorkerHandlers {..} = do pure $ cleanupRecurring >> cleanupDeletion >> cleanupReminder interpretJobSubsystem :: - ( Member JobStore.JobStore r, - Member (Embed IO) r - ) => + (Member (Embed IO) r) => JobSubsystemConfig -> InterpreterFor JobSubsystem r interpretJobSubsystem conf = @@ -63,75 +59,53 @@ interpretJobSubsystem conf = scheduleAdminlessDeletionJob :: forall r. - (Member (Embed IO) r, Member JobStore.JobStore r) => + (Member (Embed IO) r) => JobSubsystemConfig -> Local UserId -> TeamId -> ConvId -> UTCTime -> - Sem r ScheduledJob + Sem r () scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do let arbiterEnv = ArbiterHasql.createHasqlEnvWithPool (Proxy @ScheduledJobsRegistry) jobSubsystemArbiterPool jobSubsystemSchemaName - jobId <- embed $ Id <$> UUID.nextRandom - let job = - ScheduledJob - { scheduledJobId = jobId, - scheduledJobKind = AdminlessDeletion, - scheduledJobTeamId = teamId, - scheduledJobConversationId = Just convId, - scheduledJobScheduledFor = scheduledFor - } - arbiterJob = + let arbiterJob = (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName (AdminlessDeletionJob teamId convId (tUnqualified lusr))) { ArbiterCore.notVisibleUntil = Just scheduledFor, ArbiterCore.maxAttempts = Just 3 } - JobStore.createJob job embed $ void $ ArbiterHasql.runHasqlDb arbiterEnv $ void $ ArbiterCore.insertJob arbiterJob - pure job scheduleAdminlessReminderJob :: forall r. - (Member (Embed IO) r, Member JobStore.JobStore r) => + (Member (Embed IO) r) => JobSubsystemConfig -> Local UserId -> TeamId -> ConvId -> UTCTimeMillis -> UTCTime -> - Sem r ScheduledJob + Sem r () scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletionScheduledFor scheduledFor = do let arbiterEnv = ArbiterHasql.createHasqlEnvWithPool (Proxy @ScheduledJobsRegistry) jobSubsystemArbiterPool jobSubsystemSchemaName - jobId <- embed $ Id <$> UUID.nextRandom - let job = - ScheduledJob - { scheduledJobId = jobId, - scheduledJobKind = AdminlessReminder, - scheduledJobTeamId = teamId, - scheduledJobConversationId = Just convId, - scheduledJobScheduledFor = scheduledFor - } - arbiterJob = + let arbiterJob = (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) deletionScheduledFor)) { ArbiterCore.notVisibleUntil = Just scheduledFor, ArbiterCore.maxAttempts = Just 3 } - JobStore.createJob job embed $ void $ ArbiterHasql.runHasqlDb arbiterEnv $ void $ ArbiterCore.insertJob arbiterJob - pure job diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 886d7e8e788..da60fb5045b 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -38,7 +38,6 @@ import Wire.API.Conversation.Role hiding (DeleteConversation) import Wire.API.Error.Galley (AdminlessConversation (..), GalleyError (..)) import Wire.API.Federation.Client (FederatorClient) import Wire.API.Federation.Error (FederationError) -import Wire.API.Jobs (ScheduledJob (..), ScheduledJobKind (AdminlessDeletion, AdminlessReminder)) import Wire.API.Team.Feature (AllTeamFeatures, FeatureStatus (..), LockStatus (..), LockableFeature (..), PreventAdminlessGroupsConfig, npProject, npUpdate) import Wire.API.User (AccountStatus (..), User (..), UserType (..), userId) import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess (..)) @@ -269,23 +268,9 @@ interpretJobSubsystem :: interpretJobSubsystem = interpret $ \case ScheduleAdminlessDeletionJob {} -> - pure - ScheduledJob - { scheduledJobId = Id UUID.nil, - scheduledJobKind = AdminlessDeletion, - scheduledJobTeamId = Id UUID.nil, - scheduledJobConversationId = Just (Id UUID.nil), - scheduledJobScheduledFor = defaultTime - } + pure () ScheduleAdminlessReminderJob {} -> - pure - ScheduledJob - { scheduledJobId = Id UUID.nil, - scheduledJobKind = AdminlessReminder, - scheduledJobTeamId = Id UUID.nil, - scheduledJobConversationId = Just (Id UUID.nil), - scheduledJobScheduledFor = defaultTime - } + pure () StartJobWorkers _ _ -> pure (pure ()) interpretRandom :: diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index f3b986d15b9..2cf2dff8b4d 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -370,8 +370,6 @@ library Wire.InternalEvent Wire.InvitationStore Wire.InvitationStore.Cassandra - Wire.JobStore - Wire.JobStore.Postgres Wire.JobSubsystem Wire.JobSubsystem.Interpreter Wire.JobSubsystem.Workers diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index b8d5b97f164..07b0f2c3fa3 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -64,6 +64,7 @@ let arbiter-core = "arbiter-core"; arbiter-hasql = "arbiter-hasql"; arbiter-migrations = "arbiter-migrations"; + arbiter-simple = "arbiter-simple"; arbiter-test-common = "arbiter-test-common"; arbiter-worker = "arbiter-worker"; }; diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index dffe07d8a39..e1096aec75b 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -36,6 +36,7 @@ hself: hsuper: { arbiter-core = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-core); arbiter-hasql = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-hasql); arbiter-migrations = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-migrations); + arbiter-simple = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-simple); arbiter-worker = hlib.markUnbroken (hlib.dontCheck hsuper.arbiter-worker); # Test fixtures don't seem to be bundled for Hackage diff --git a/postgres-schema.sql b/postgres-schema.sql index fceb33a5c84..c60938bceb0 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -360,21 +360,6 @@ CREATE TABLE public.remote_conversation_local_member ( ALTER TABLE public.remote_conversation_local_member OWNER TO "wire-server"; --- --- Name: scheduled_jobs; Type: TABLE; Schema: public; Owner: wire-server --- - -CREATE TABLE public.scheduled_jobs ( - id uuid NOT NULL, - kind integer NOT NULL, - team_id uuid NOT NULL, - conversation_id uuid, - scheduled_for timestamp with time zone NOT NULL -); - - -ALTER TABLE public.scheduled_jobs OWNER TO "wire-server"; - -- -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: wire-server -- @@ -623,14 +608,6 @@ ALTER TABLE ONLY public.remote_conversation_local_member ADD CONSTRAINT remote_conversation_local_member_pkey PRIMARY KEY ("user", conv_remote_domain, conv_remote_id); --- --- Name: scheduled_jobs scheduled_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server --- - -ALTER TABLE ONLY public.scheduled_jobs - ADD CONSTRAINT scheduled_jobs_pkey PRIMARY KEY (id); - - -- -- Name: subconversation subconversation_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -814,34 +791,6 @@ CREATE INDEX idx_meetings_end_time ON public.meetings USING btree (end_time); CREATE INDEX idx_meetings_start_time ON public.meetings USING btree (start_time); --- --- Name: idx_scheduled_jobs_conversation_id; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX idx_scheduled_jobs_conversation_id ON public.scheduled_jobs USING btree (conversation_id); - - --- --- Name: idx_scheduled_jobs_kind; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX idx_scheduled_jobs_kind ON public.scheduled_jobs USING btree (kind); - - --- --- Name: idx_scheduled_jobs_scheduled_for; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX idx_scheduled_jobs_scheduled_for ON public.scheduled_jobs USING btree (scheduled_for); - - --- --- Name: idx_scheduled_jobs_team_kind; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX idx_scheduled_jobs_team_kind ON public.scheduled_jobs USING btree (team_id, kind); - - -- -- Name: user_group_member_user_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs index 23471e1e75e..0fa08a09e9b 100644 --- a/services/background-worker/src/Wire/AdminlessJobsWorker.hs +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -22,19 +22,15 @@ module Wire.AdminlessJobsWorker where import Arbiter.Core.Job.Types (JobRead, notVisibleUntil, payload) -import Data.Id (ConvId, RequestId (..), TeamId) -import Data.List qualified as List +import Data.Id (RequestId (..)) import Data.Qualified (toLocalUnsafe) -import Data.Time.Clock (UTCTime) import Imports -import Polysemy import System.Logger qualified as Log -import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..), ScheduledJob (..), ScheduledJobKind (..)) +import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..)) import Wire.BackgroundWorker.Env (AppT, Env (..)) import Wire.ConversationSubsystem import Wire.Effects (runBackgroundWorkerEffects) import Wire.ExternalAccess.External (ExtEnv) -import Wire.JobStore (JobStore, deleteJob, findJobsByConversationId) runAdminlessDeletionJob :: ExtEnv -> JobRead AdminlessDeletionJob -> AppT IO () runAdminlessDeletionJob extEnv job = do @@ -61,11 +57,6 @@ runAdminlessDeletionJob extEnv job = do internalDeleteLocalAdminlessGroup (toLocalUnsafe env.federationDomain (adminlessDeletionJobOrigUserId jobPayload)) (toLocalUnsafe env.federationDomain (adminlessDeletionJobConversationId jobPayload)) - cleanupScheduledJob - AdminlessDeletion - (adminlessDeletionJobTeamId jobPayload) - (adminlessDeletionJobConversationId jobPayload) - (notVisibleUntil job) Log.info env.logger $ Log.msg (Log.val "Adminless deletion job finished") . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) @@ -89,34 +80,8 @@ runAdminlessReminderJob extEnv job = do (toLocalUnsafe env.federationDomain (adminlessReminderJobOrigUserId jobPayload)) (toLocalUnsafe env.federationDomain (adminlessReminderJobConversationId jobPayload)) (adminlessReminderJobDeletionScheduledFor jobPayload) - cleanupScheduledJob - AdminlessReminder - (adminlessReminderJobTeamId jobPayload) - (adminlessReminderJobConversationId jobPayload) - (notVisibleUntil job) Log.info env.logger $ Log.msg (Log.val "Adminless reminder job finished") . Log.field "team_id" (show (adminlessReminderJobTeamId jobPayload)) . Log.field "conversation_id" (show (adminlessReminderJobConversationId jobPayload)) either (liftIO . fail . show) pure result - -cleanupScheduledJob :: - (Member JobStore r) => - ScheduledJobKind -> - TeamId -> - ConvId -> - Maybe UTCTime -> - Sem r () -cleanupScheduledJob _ _ _ Nothing = pure () -cleanupScheduledJob kind teamId convId (Just scheduledFor) = do - jobs <- findJobsByConversationId convId - let matchingJobs = - List.filter - ( \job -> - job.scheduledJobKind == kind - && job.scheduledJobTeamId == teamId - && job.scheduledJobConversationId == Just convId - && job.scheduledJobScheduledFor == scheduledFor - ) - jobs - traverse_ (deleteJob . scheduledJobId) matchingJobs diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index a62b5ed15d6..bcfdc2875f4 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -126,8 +126,6 @@ import Wire.FireAndForget import Wire.GundeckAPIAccess (GundeckAPIAccess, runGundeckAPIAccess) import Wire.HashPassword import Wire.HashPassword.Interpreter -import Wire.JobStore (JobStore) -import Wire.JobStore.Postgres (interpretJobStoreToPostgres) import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) @@ -198,7 +196,6 @@ type GalleyEffects = '[ MeetingsSubsystem, ConversationSubsystem, JobSubsystem, - JobStore, FederationSubsystem, TeamCollaboratorsSubsystem, Input AllTeamFeatures, @@ -560,7 +557,6 @@ evalGalley e = . runInputSem getAllTeamFeaturesForServer . interpretTeamCollaboratorsSubsystem . runFederationSubsystem conversationSubsystemConfig.federationProtocols - . interpretJobStoreToPostgres . interpretJobSubsystem JobSubsystemConfig { jobSubsystemArbiterPool = e ^. jobsApiPool, From 3ee24daeb5bae8d2e4ccf53af54fa288415c11e7 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 6 Jul 2026 08:39:34 +0000 Subject: [PATCH 48/65] fixed some merge/rebase issues --- libs/wire-subsystems/src/Wire/ConversationSubsystem.hs | 1 - .../src/Wire/ConversationSubsystem/Action.hs | 5 +++-- services/background-worker/src/Wire/Effects.hs | 6 +----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index dbc6bf01cfc..a6bde14a526 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -37,7 +37,6 @@ import Data.Misc (IpAddr) import Data.Qualified import Data.Range import Data.Singletons (Sing) -import Data.Time.Clock (UTCTime) import Imports import Polysemy import Wire.API.Bot (AddBot, RemoveBot) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs index 2a5c631bc7f..d4ebac964ce 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs @@ -395,7 +395,8 @@ removeConversation :: Member ProposalStore r, Member CodeStore r ) => - Local StoredConversation -> Sem r () + Local StoredConversation -> + Sem r () removeConversation lconv = do let lcnv = fmap (.id_) lconv storedConv = tUnqualified lconv @@ -413,7 +414,7 @@ removeConversation lconv = do deleteGroup gidSub deleteGroup gidParent - key <- E.makeKey (tUnqualified lcnv) + key <- E.makeKey (CodeReferentConv (tUnqualified lcnv)) E.deleteCode key case convTeam storedConv of Nothing -> E.deleteConversation (tUnqualified lcnv) diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 1ed0761fc42..71b3d13b036 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -332,7 +332,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretTeamStoreToCassandra . interpretTeamCollaboratorsStoreToPostgres . interpretJobSubsystem jobSubsystemConfig - . interpretLegalHoldStoreToCassandra FeatureLegalHoldDisabledPermanently + . interpretLegalHoldStoreToCassandra (env.conversationSubsystemConfig.legalholdDefaults) . interpretTeamJournal Nothing . nowToIO . randomToIO @@ -391,10 +391,6 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = { jobSubsystemArbiterPool = env.arbiterPool, jobSubsystemSchemaName = ArbiterCore.defaultSchemaName } - getConversationSubsystemConfig :: - (Member GalleyAPIAccess r) => - Sem r ConversationSubsystemConfig - getConversationSubsystemConfig = getConversationConfig backendQueueEnv = BackendNotificationQueueAccess.Env { channelMVar = env.amqpBackendNotificationsChannel, From 23a78d305e13663050ee7327b2a989ed69afbf7a Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 6 Jul 2026 09:04:54 +0000 Subject: [PATCH 49/65] fixed postgres schema --- postgres-schema.sql | 168 -------------------------------------------- 1 file changed, 168 deletions(-) diff --git a/postgres-schema.sql b/postgres-schema.sql index c60938bceb0..607aefc83bf 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -54,22 +54,6 @@ CREATE TYPE public.recurrence_frequency AS ENUM ( ALTER TYPE public.recurrence_frequency OWNER TO "wire-server"; --- --- Name: update_updated_at(); Type: FUNCTION; Schema: public; Owner: wire-server --- - -CREATE FUNCTION public.update_updated_at() RETURNS trigger - LANGUAGE plpgsql - AS $$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$$; - - -ALTER FUNCTION public.update_updated_at() OWNER TO "wire-server"; - SET default_tablespace = ''; SET default_table_access_method = heap; @@ -90,33 +74,6 @@ CREATE TABLE public.apps ( ALTER TABLE public.apps OWNER TO "wire-server"; --- --- Name: asset; Type: TABLE; Schema: public; Owner: wire-server --- - -CREATE TABLE public.asset ( - user_id uuid NOT NULL, - typ integer NOT NULL, - key text NOT NULL, - size integer -); - - -ALTER TABLE public.asset OWNER TO "wire-server"; - --- --- Name: bot_conv; Type: TABLE; Schema: public; Owner: wire-server --- - -CREATE TABLE public.bot_conv ( - id uuid NOT NULL, - conv uuid NOT NULL, - conv_team uuid -); - - -ALTER TABLE public.bot_conv OWNER TO "wire-server"; - -- -- Name: collaborators; Type: TABLE; Schema: public; Owner: wire-server -- @@ -222,20 +179,6 @@ CREATE TABLE public.conversation_out_of_sync ( ALTER TABLE public.conversation_out_of_sync OWNER TO "wire-server"; --- --- Name: deleted_user; Type: TABLE; Schema: public; Owner: wire-server --- - -CREATE TABLE public.deleted_user ( - id uuid NOT NULL, - team uuid, - created_at timestamp with time zone NOT NULL, - deleted_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL -); - - -ALTER TABLE public.deleted_user OWNER TO "wire-server"; - -- -- Name: domain_registration; Type: TABLE; Schema: public; Owner: wire-server -- @@ -444,42 +387,6 @@ CREATE TABLE public.user_group_member ( ALTER TABLE public.user_group_member OWNER TO "wire-server"; --- --- Name: wire_user; Type: TABLE; Schema: public; Owner: wire-server --- - -CREATE TABLE public.wire_user ( - id uuid NOT NULL, - user_type integer NOT NULL, - accent_id integer NOT NULL, - activated boolean NOT NULL, - country text, - email text, - email_unvalidated text, - expires timestamp with time zone, - feature_conference_calling integer, - handle text, - language text, - managed_by integer, - name text NOT NULL, - password text, - picture jsonb, - provider uuid, - service uuid, - searchable boolean, - sso_id jsonb, - account_status integer, - supported_protocols integer, - team uuid, - text_status text, - rich_info jsonb, - created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL -); - - -ALTER TABLE public.wire_user OWNER TO "wire-server"; - -- -- Name: apps apps_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -488,14 +395,6 @@ ALTER TABLE ONLY public.apps ADD CONSTRAINT apps_pkey PRIMARY KEY (user_id); --- --- Name: bot_conv bot_conv_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server --- - -ALTER TABLE ONLY public.bot_conv - ADD CONSTRAINT bot_conv_pkey PRIMARY KEY (id); - - -- -- Name: collaborators collaborators_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -544,14 +443,6 @@ ALTER TABLE ONLY public.conversation ADD CONSTRAINT conversation_pkey PRIMARY KEY (id); --- --- Name: deleted_user deleted_user_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server --- - -ALTER TABLE ONLY public.deleted_user - ADD CONSTRAINT deleted_user_pkey PRIMARY KEY (id); - - -- -- Name: domain_registration_challenge domain_registration_challenge_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -656,43 +547,6 @@ ALTER TABLE ONLY public.user_group ADD CONSTRAINT user_group_pkey PRIMARY KEY (team_id, id); --- --- Name: wire_user wire_user_handle_key; Type: CONSTRAINT; Schema: public; Owner: wire-server --- - -ALTER TABLE ONLY public.wire_user - ADD CONSTRAINT wire_user_handle_key UNIQUE (handle); - - --- --- Name: wire_user wire_user_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server --- - -ALTER TABLE ONLY public.wire_user - ADD CONSTRAINT wire_user_pkey PRIMARY KEY (id); - - --- --- Name: asset_user_id_idx; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX asset_user_id_idx ON public.asset USING btree (user_id); - - --- --- Name: bot_conv_conv_idx; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX bot_conv_conv_idx ON public.bot_conv USING btree (conv); - - --- --- Name: bot_conv_team_idx; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX bot_conv_team_idx ON public.bot_conv USING btree (conv_team); - - -- -- Name: collaborators_team_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- @@ -798,28 +652,6 @@ CREATE INDEX idx_meetings_start_time ON public.meetings USING btree (start_time) CREATE INDEX user_group_member_user_id_idx ON public.user_group_member USING btree (user_id); --- --- Name: wire_user_service_idx; Type: INDEX; Schema: public; Owner: wire-server --- - -CREATE INDEX wire_user_service_idx ON public.wire_user USING btree (provider, service); - - --- --- Name: wire_user update_user_updated_at; Type: TRIGGER; Schema: public; Owner: wire-server --- - -CREATE TRIGGER update_user_updated_at BEFORE UPDATE ON public.wire_user FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); - - --- --- Name: bot_conv bot_conv_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server --- - -ALTER TABLE ONLY public.bot_conv - ADD CONSTRAINT bot_conv_id_fkey FOREIGN KEY (id) REFERENCES public.wire_user(id) ON DELETE CASCADE; - - -- -- Name: conversation_member conversation_member_conv_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server -- From 78419c2493a8d77c62b6834742f112f96810977d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 6 Jul 2026 09:39:51 +0000 Subject: [PATCH 50/65] fix --- postgres-schema.sql | 168 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/postgres-schema.sql b/postgres-schema.sql index 607aefc83bf..c60938bceb0 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -54,6 +54,22 @@ CREATE TYPE public.recurrence_frequency AS ENUM ( ALTER TYPE public.recurrence_frequency OWNER TO "wire-server"; +-- +-- Name: update_updated_at(); Type: FUNCTION; Schema: public; Owner: wire-server +-- + +CREATE FUNCTION public.update_updated_at() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.update_updated_at() OWNER TO "wire-server"; + SET default_tablespace = ''; SET default_table_access_method = heap; @@ -74,6 +90,33 @@ CREATE TABLE public.apps ( ALTER TABLE public.apps OWNER TO "wire-server"; +-- +-- Name: asset; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.asset ( + user_id uuid NOT NULL, + typ integer NOT NULL, + key text NOT NULL, + size integer +); + + +ALTER TABLE public.asset OWNER TO "wire-server"; + +-- +-- Name: bot_conv; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.bot_conv ( + id uuid NOT NULL, + conv uuid NOT NULL, + conv_team uuid +); + + +ALTER TABLE public.bot_conv OWNER TO "wire-server"; + -- -- Name: collaborators; Type: TABLE; Schema: public; Owner: wire-server -- @@ -179,6 +222,20 @@ CREATE TABLE public.conversation_out_of_sync ( ALTER TABLE public.conversation_out_of_sync OWNER TO "wire-server"; +-- +-- Name: deleted_user; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.deleted_user ( + id uuid NOT NULL, + team uuid, + created_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE public.deleted_user OWNER TO "wire-server"; + -- -- Name: domain_registration; Type: TABLE; Schema: public; Owner: wire-server -- @@ -387,6 +444,42 @@ CREATE TABLE public.user_group_member ( ALTER TABLE public.user_group_member OWNER TO "wire-server"; +-- +-- Name: wire_user; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.wire_user ( + id uuid NOT NULL, + user_type integer NOT NULL, + accent_id integer NOT NULL, + activated boolean NOT NULL, + country text, + email text, + email_unvalidated text, + expires timestamp with time zone, + feature_conference_calling integer, + handle text, + language text, + managed_by integer, + name text NOT NULL, + password text, + picture jsonb, + provider uuid, + service uuid, + searchable boolean, + sso_id jsonb, + account_status integer, + supported_protocols integer, + team uuid, + text_status text, + rich_info jsonb, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE public.wire_user OWNER TO "wire-server"; + -- -- Name: apps apps_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -395,6 +488,14 @@ ALTER TABLE ONLY public.apps ADD CONSTRAINT apps_pkey PRIMARY KEY (user_id); +-- +-- Name: bot_conv bot_conv_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.bot_conv + ADD CONSTRAINT bot_conv_pkey PRIMARY KEY (id); + + -- -- Name: collaborators collaborators_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -443,6 +544,14 @@ ALTER TABLE ONLY public.conversation ADD CONSTRAINT conversation_pkey PRIMARY KEY (id); +-- +-- Name: deleted_user deleted_user_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.deleted_user + ADD CONSTRAINT deleted_user_pkey PRIMARY KEY (id); + + -- -- Name: domain_registration_challenge domain_registration_challenge_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -547,6 +656,43 @@ ALTER TABLE ONLY public.user_group ADD CONSTRAINT user_group_pkey PRIMARY KEY (team_id, id); +-- +-- Name: wire_user wire_user_handle_key; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.wire_user + ADD CONSTRAINT wire_user_handle_key UNIQUE (handle); + + +-- +-- Name: wire_user wire_user_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.wire_user + ADD CONSTRAINT wire_user_pkey PRIMARY KEY (id); + + +-- +-- Name: asset_user_id_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX asset_user_id_idx ON public.asset USING btree (user_id); + + +-- +-- Name: bot_conv_conv_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX bot_conv_conv_idx ON public.bot_conv USING btree (conv); + + +-- +-- Name: bot_conv_team_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX bot_conv_team_idx ON public.bot_conv USING btree (conv_team); + + -- -- Name: collaborators_team_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- @@ -652,6 +798,28 @@ CREATE INDEX idx_meetings_start_time ON public.meetings USING btree (start_time) CREATE INDEX user_group_member_user_id_idx ON public.user_group_member USING btree (user_id); +-- +-- Name: wire_user_service_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX wire_user_service_idx ON public.wire_user USING btree (provider, service); + + +-- +-- Name: wire_user update_user_updated_at; Type: TRIGGER; Schema: public; Owner: wire-server +-- + +CREATE TRIGGER update_user_updated_at BEFORE UPDATE ON public.wire_user FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); + + +-- +-- Name: bot_conv bot_conv_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.bot_conv + ADD CONSTRAINT bot_conv_id_fkey FOREIGN KEY (id) REFERENCES public.wire_user(id) ON DELETE CASCADE; + + -- -- Name: conversation_member conversation_member_conv_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server -- From 7261074fc213752705114016677c3e566db484c7 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 9 Jul 2026 09:59:31 +0000 Subject: [PATCH 51/65] fix dependencies after rebase --- .../background-worker/background-worker.cabal | 1 - services/background-worker/default.nix | 152 ++++++++++ services/galley/default.nix | 272 ++++++++++++++++++ services/galley/galley.cabal | 1 - 4 files changed, 424 insertions(+), 2 deletions(-) diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index fdea0a1eea5..0fdb71b515f 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -51,7 +51,6 @@ library , extra , galley-types , hasql - , hasql-pool , hasql-resource-pool , HsOpenSSL , http-client diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 65e5a0050f9..ed554ccdcd4 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -2,3 +2,155 @@ # This file is generated by running hack/bin/generate-local-nix-packages.sh and # must be regenerated whenever local packages are added or removed, or # dependencies are added or removed. +{ mkDerivation +, aeson +, amqp +, arbiter-core +, base +, bilge +, bytestring +, bytestring-conversion +, cassandra-util +, containers +, cron +, data-default +, data-timeout +, exceptions +, extended +, extra +, federator +, galley-types +, hasql +, hasql-resource-pool +, HsOpenSSL +, hspec +, http-client +, http-media +, http-types +, http2-manager +, imports +, lib +, metrics-wai +, monad-control +, optparse-applicative +, polysemy +, polysemy-conc +, polysemy-wire-zoo +, prometheus-client +, QuickCheck +, resource-pool +, retry +, servant +, servant-client +, servant-client-core +, servant-server +, ssl-util +, tagged +, text +, time +, tinylog +, transformers +, transformers-base +, types-common +, unliftio +, uri-bytestring +, wai +, wai-utilities +, wire-api +, wire-api-federation +, wire-subsystems +}: +mkDerivation { + pname = "background-worker"; + version = "0.1.0.0"; + src = ./.; + isLibrary = true; + isExecutable = true; + libraryHaskellDepends = [ + aeson + amqp + arbiter-core + base + bilge + bytestring + bytestring-conversion + cassandra-util + containers + cron + data-timeout + exceptions + extended + extra + galley-types + hasql + hasql-resource-pool + HsOpenSSL + http-client + http2-manager + imports + metrics-wai + monad-control + polysemy + polysemy-conc + polysemy-wire-zoo + prometheus-client + resource-pool + retry + servant-client + servant-server + ssl-util + tagged + text + time + tinylog + transformers + transformers-base + types-common + unliftio + uri-bytestring + wai-utilities + wire-api + wire-api-federation + wire-subsystems + ]; + executableHaskellDepends = [ + HsOpenSSL + imports + optparse-applicative + types-common + ]; + testHaskellDepends = [ + aeson + amqp + base + bytestring + containers + data-default + extended + federator + hspec + http-client + http-media + http-types + imports + prometheus-client + QuickCheck + servant + servant-client + servant-client-core + servant-server + text + tinylog + transformers + types-common + unliftio + wai + wai-utilities + wire-api + wire-api-federation + wire-subsystems + ]; + description = "Runs background work"; + license = lib.licenses.agpl3Only; + mainProgram = "background-worker"; +} diff --git a/services/galley/default.nix b/services/galley/default.nix index 65e5a0050f9..87f90dccb09 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -2,3 +2,275 @@ # This file is generated by running hack/bin/generate-local-nix-packages.sh and # must be regenerated whenever local packages are added or removed, or # dependencies are added or removed. +{ mkDerivation +, aeson +, aeson-qq +, amazonka +, amqp +, arbiter-core +, async +, base +, base64-bytestring +, bilge +, binary +, bytestring +, bytestring-conversion +, call-stack +, cassandra-util +, cassava +, cereal +, conduit +, containers +, cookie +, crypton-pem +, currency-codes +, data-default +, data-timeout +, errors +, exceptions +, extended +, extra +, federator +, filepath +, galley-types +, hasql +, hasql-resource-pool +, hs-opentelemetry-instrumentation-wai +, hs-opentelemetry-sdk +, HsOpenSSL +, http-api-data +, http-client +, http-client-openssl +, http-client-tls +, http-media +, http-types +, http2-manager +, imports +, kan-extensions +, lens +, lens-aeson +, lib +, metrics-core +, metrics-wai +, mtl +, network +, network-uri +, optparse-applicative +, polysemy +, polysemy-conc +, polysemy-plugin +, polysemy-wire-zoo +, process +, prometheus-client +, proto-lens +, protobuf +, QuickCheck +, quickcheck-instances +, ram +, random +, raw-strings-qq +, resource-pool +, retry +, safe-exceptions +, servant +, servant-client +, servant-client-core +, servant-server +, singletons +, sop-core +, split +, ssl-util +, stm +, streaming-commons +, string-conversions +, tagged +, tasty +, tasty-ant-xml +, tasty-cannon +, tasty-hunit +, temporary +, text +, time +, tinylog +, transformers +, types-common +, types-common-aws +, types-common-journal +, unix +, unliftio +, unordered-containers +, uri-bytestring +, utf8-string +, uuid +, wai +, wai-extra +, wai-middleware-gunzip +, wai-utilities +, warp +, warp-tls +, wire-api +, wire-api-federation +, wire-otel +, wire-subsystems +, yaml +}: +mkDerivation { + pname = "galley"; + version = "0.83.0"; + src = ./.; + isLibrary = true; + isExecutable = true; + libraryHaskellDepends = [ + aeson + amazonka + amqp + arbiter-core + async + base + bilge + bytestring + bytestring-conversion + cassandra-util + cassava + containers + data-default + errors + exceptions + extended + galley-types + hasql + hasql-resource-pool + hs-opentelemetry-instrumentation-wai + hs-opentelemetry-sdk + HsOpenSSL + http-client + http-client-openssl + http-media + http-types + http2-manager + imports + kan-extensions + lens + metrics-core + metrics-wai + optparse-applicative + polysemy + polysemy-conc + polysemy-plugin + polysemy-wire-zoo + prometheus-client + raw-strings-qq + resource-pool + retry + safe-exceptions + servant + servant-server + singletons + split + ssl-util + stm + text + time + tinylog + types-common + types-common-aws + unliftio + unordered-containers + uri-bytestring + utf8-string + uuid + wai + wai-extra + wai-middleware-gunzip + wai-utilities + wire-api + wire-api-federation + wire-otel + wire-subsystems + ]; + executableHaskellDepends = [ + aeson + aeson-qq + async + base + base64-bytestring + bilge + binary + bytestring + bytestring-conversion + call-stack + cassandra-util + cereal + conduit + containers + cookie + crypton-pem + currency-codes + data-default + data-timeout + errors + exceptions + extended + extra + federator + filepath + galley-types + HsOpenSSL + http-api-data + http-client + http-client-openssl + http-client-tls + http-types + imports + kan-extensions + lens + lens-aeson + mtl + network + network-uri + optparse-applicative + process + proto-lens + protobuf + QuickCheck + quickcheck-instances + ram + random + retry + servant-client + servant-client-core + servant-server + singletons + sop-core + ssl-util + streaming-commons + string-conversions + tagged + tasty + tasty-ant-xml + tasty-cannon + tasty-hunit + temporary + text + time + tinylog + transformers + types-common + types-common-aws + types-common-journal + unix + unliftio + unordered-containers + uuid + wai + wai-utilities + warp + warp-tls + wire-api + wire-api-federation + wire-subsystems + yaml + ]; + description = "Conversations"; + license = lib.meta.getLicenseFromSpdxId "AGPL-3.0-only"; +} diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index c312d9c0f88..d8f06636218 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -212,7 +212,6 @@ library , extended , galley-types >=0.65.0 , hasql - , hasql-pool , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk From 778589b16c4f26177bca4b64984020158c94a271 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 9 Jul 2026 12:06:00 +0000 Subject: [PATCH 52/65] small fixes after rebase --- cabal.project | 11 +++++++++++ libs/extended/src/Hasql/Pool/Extended.hs | 3 --- .../src/Wire/BackgroundWorker/Env.hs | 4 ++-- services/brig/src/Brig/App.hs | 4 +--- services/galley/src/Galley/Env.hs | 3 +-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/cabal.project b/cabal.project index 345b1d52076..fb41d725c8e 100644 --- a/cabal.project +++ b/cabal.project @@ -97,3 +97,14 @@ package wire-message-proto-lens flags: +nix-dev-env package types-common-journal flags: +nix-dev-env + +source-repository-package + type: git + location: https://github.com/velveteer/arbiter.git + tag: 96097411a0480e182d0cbce819c45023fe8aeec7 + subdir: + arbiter-core + arbiter-hasql + arbiter-migrations + arbiter-worker + arbiter-test-common diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index 5f57d4dc495..e15e2c8b775 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -22,10 +22,8 @@ import Data.ByteString qualified as ByteString import Data.Map qualified as Map import Data.Misc import Data.Pool qualified as Pool -import Data.Set qualified as Set import Data.Text.Encoding qualified as Text import Data.Time.Clock (diffUTCTime, getCurrentTime, secondsToDiffTime) -import Data.UUID import Hasql.Connection qualified import Hasql.Connection qualified as Hasql import Hasql.Connection.Settings qualified as HasqlConnSettings @@ -52,7 +50,6 @@ defaultArbiterConnectionPoolConfig = PoolConfig { size = 10, acquisitionTimeout = Duration (secondsToDiffTime 5), - agingTimeout = Duration (secondsToDiffTime 300), idlenessTimeout = Duration (secondsToDiffTime 60) } diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 6af3d169a0e..14502e352c9 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -33,7 +33,7 @@ import Data.Misc (HttpsUrl) import Data.Pool qualified as Pool import Data.Text.Encoding qualified as Text import HTTP2.Client.Manager -import Hasql.Pool qualified as HasqlPool +import Hasql.Connection qualified as Hasql import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as Hasql import Imports @@ -92,7 +92,7 @@ data Env = Env cassandra :: ClientState, cassandraGalley :: ClientState, cassandraBrig :: ClientState, - hasqlPool :: HasqlPool.Pool, + hasqlPool :: Hasql.Pool, arbiterConnStr :: ByteString.ByteString, arbiterPool :: Pool.Pool Hasql.Connection, -- Dedicated AMQP channels per concern diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 46c01726232..dd1192ccf3f 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -137,9 +137,7 @@ import Data.Text.IO qualified as Text import Data.Time.Clock import Database.Bloodhound qualified as ES import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) -import Hasql.Pool qualified as HasqlPool -import Hasql.Pool.Extended -import Hasql.Pool.Extended (initPostgresPool, postgresqlConnectionString) +import Hasql.Pool.Extended (initPostgresPool) import Hasql.Pool.Extended qualified as HasqlPool import Imports import Network.AMQP qualified as Q diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index ea5c36ad719..ec61b7e663c 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -53,9 +53,8 @@ import Data.Pool qualified as Pool import Data.Time.Clock.DiffTime (millisecondsToDiffTime) import Galley.Queue qualified as Q import HTTP2.Client.Manager (Http2Manager) -import Hasql.Pool.Extended import Hasql.Connection qualified as Hasql -import Hasql.Pool +import Hasql.Pool.Extended import Imports import Network.AMQP qualified as Q import Network.HTTP.Client From 7ddfeead31ef7255babf628a7dce4cd185af298b Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 9 Jul 2026 13:41:19 +0000 Subject: [PATCH 53/65] monad arbeiter adapter --- flake.lock | 20 ++ .../src/Wire/JobSubsystem/ArbiterAdapter.hs | 172 ++++++++++++++++++ libs/wire-subsystems/wire-subsystems.cabal | 1 + 3 files changed, 193 insertions(+) create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs diff --git a/flake.lock b/flake.lock index 49d3242d64c..c5ecb3ff4ca 100644 --- a/flake.lock +++ b/flake.lock @@ -235,17 +235,37 @@ "hasql-resource-pool": { "flake": false, "locked": { +<<<<<<< HEAD "lastModified": 1783610992, "narHash": "sha256-xpnT7V2EddKVzK5cvaPeRYpCyRbWyqkQJrsBMpSbWXg=", +||||||| parent of d6db186443 (monad arbeiter adapter) + "lastModified": 1783526891, + "narHash": "sha256-Xa5VXbadJ1RPUUgInk0/kBYuus2Aw3sU2ZT6/KqkUWA=", +======= + "lastModified": 1783603733, + "narHash": "sha256-Xa8PBUaakNai6cxo4MdMtydfvKw+zW5t3S2Fp/axEZc=", +>>>>>>> d6db186443 (monad arbeiter adapter) "owner": "wireapp", "repo": "hasql-resource-pool", +<<<<<<< HEAD "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", +||||||| parent of d6db186443 (monad arbeiter adapter) + "rev": "209927f0e36da389d70235fd28034a78d1cc54af", +======= + "rev": "aaa14b2a754e23dc3871fc34b5aadc367fae07ce", +>>>>>>> d6db186443 (monad arbeiter adapter) "type": "github" }, "original": { "owner": "wireapp", "repo": "hasql-resource-pool", +<<<<<<< HEAD "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", +||||||| parent of d6db186443 (monad arbeiter adapter) + "rev": "209927f0e36da389d70235fd28034a78d1cc54af", +======= + "rev": "aaa14b2a754e23dc3871fc34b5aadc367fae07ce", +>>>>>>> d6db186443 (monad arbeiter adapter) "type": "github" } }, diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs new file mode 100644 index 00000000000..14c2c25bc17 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -0,0 +1,172 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE TypeFamilies #-} + +-- | Adapter that lets Arbiter run against wire-server's shared Hasql pool. +-- +-- The pool we want to reuse is 'HasqlPoolExt.Pool'. Internally that is backed +-- by @Data.Pool (Either ConnectionError Connection)@, but the underlying +-- resource pool is intentionally opaque in @hasql-resource-pool@. The only +-- missing piece is therefore a small exported helper from that package that +-- borrows one live 'Connection' for the duration of a callback. +module Wire.JobSubsystem.ArbiterAdapter where + +import Arbiter.Core.Codec (Params, RowCodec) +import Arbiter.Core.Exceptions (throwInternal) +import Arbiter.Core.HasArbiterSchema (HasArbiterSchema (..)) +import Arbiter.Core.MonadArbiter (MonadArbiter (..)) +import Arbiter.Core.QueueRegistry (JobPayloadRegistry) +import Arbiter.Hasql.Decode qualified as Decode +import Arbiter.Hasql.Encode qualified as Encode +import Control.Exception (mask, onException, try) +import Control.Monad.Reader +import Data.Text qualified as T +import Hasql.Connection qualified as HasqlConn +import Hasql.Decoders qualified as Decoders +import Hasql.Encoders qualified as Encoders +import Hasql.Pool qualified as HasqlPool +import Hasql.Pool.Extended qualified as HasqlPoolExt +import Hasql.Session qualified as Session +import Hasql.Statement qualified as Statement +import Imports + +data WireArbiterEnv = WireArbiterEnv + { schemaName :: Text, + connectionPool :: HasqlPoolExt.Pool, + activeConn :: Maybe HasqlConn.Connection, + transactionDepth :: Int, + preparedStatements :: Bool + } + +newtype WireArbiter (registry :: JobPayloadRegistry) a = WireArbiter + { unWireArbiter :: ReaderT WireArbiterEnv IO a + } + deriving newtype + ( Functor, + Applicative, + Monad, + MonadIO, + MonadReader WireArbiterEnv + ) + +runWireArbiter :: WireArbiterEnv -> WireArbiter registry a -> IO a +runWireArbiter env (WireArbiter action) = runReaderT action env + +instance HasArbiterSchema (WireArbiter registry) registry where + getSchema = asks schemaName + +instance MonadArbiter (WireArbiter registry) where + type Handler (WireArbiter registry) jobs result = HasqlConn.Connection -> jobs -> WireArbiter registry result + + executeQuery sql params codec = do + env <- ask + withConn env $ \conn -> + runQueryStatement env.preparedStatements conn sql params codec + + executeStatement sql params = do + env <- ask + withConn env $ \conn -> + runExecStatement conn sql params + + withDbTransaction (WireArbiter action) = WireArbiter $ do + env <- ask + case activeConn env of + Nothing -> + unWireArbiter $ + withPoolConnection env.connectionPool $ \conn -> + beginCommitOrRollback conn $ + runReaderT action env {activeConn = Just conn, transactionDepth = 1} + Just conn + | transactionDepth env <= 0 -> + liftIO $ + beginCommitOrRollback conn $ + runReaderT action env {transactionDepth = 1} + | otherwise -> + liftIO $ + beginSavepointTransaction (transactionDepth env) conn $ + runReaderT action env {transactionDepth = transactionDepth env + 1} + + runHandlerWithConnection handler jobs = do + env <- ask + case activeConn env of + Just conn -> handler conn jobs + Nothing -> throwInternal "runHandlerWithConnection: no active connection" + +withConn :: WireArbiterEnv -> (HasqlConn.Connection -> IO a) -> WireArbiter registry a +withConn env f = + case activeConn env of + Just conn -> liftIO $ f conn + Nothing -> withPoolConnection env.connectionPool f + +-- | Borrow a live connection from wire-server's shared pool. +withPoolConnection :: HasqlPoolExt.Pool -> (HasqlConn.Connection -> IO a) -> WireArbiter registry a +withPoolConnection pool f = do + result <- + liftIO $ + HasqlPool.withConnectionWithPoolAcquisitionTimeout + pool.poolAcquisitionTimeout + pool.rawPool + (fmap Right . f) + case result of + Right x -> pure x + Left HasqlPool.AcquisitionTimeoutUsageError -> do + liftIO $ HasqlPoolExt.recordHasqlPoolAcquisitionTimeout pool.metrics + throwInternal "hasql pool acquisition timeout" + Left (HasqlPool.ConnectionError err) -> do + liftIO $ HasqlPoolExt.recordHasqlPoolConnectionFailure pool.metrics + throwInternal $ "hasql connection error: " <> T.pack (show err) + Left (HasqlPool.SessionError err) -> do + liftIO $ HasqlPoolExt.recordHasqlPoolSessionFailure pool.metrics + throwInternal $ "hasql session error: " <> T.pack (show err) + +runQueryStatement :: Bool -> HasqlConn.Connection -> Text -> Params -> RowCodec a -> IO [a] +runQueryStatement prepare conn sql params codec = do + let mk = if prepare then Statement.preparable else Statement.unpreparable + stmt = + mk + (Encode.convertPlaceholders sql) + (Encode.buildEncoder params) + (Decode.hasqlRowDecoder codec) + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right rows -> pure rows + Left err -> fail $ "hasql query error: " <> show err + +runExecStatement :: HasqlConn.Connection -> Text -> Params -> IO Int64 +runExecStatement conn sql params = do + let stmt = Encode.buildStatementRowCount sql params + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right n -> pure n + Left err -> fail $ "hasql statement error: " <> show err + +runRawSql :: HasqlConn.Connection -> Text -> IO () +runRawSql conn sql = do + let stmt = Statement.unpreparable sql Encoders.noParams Decoders.noResult + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right () -> pure () + Left err -> fail $ "hasql sql error: " <> show err + +beginCommitOrRollback :: HasqlConn.Connection -> IO a -> IO a +beginCommitOrRollback conn action = mask $ \restore -> do + runRawSql conn "BEGIN" + result <- restore action `onException` rollbackSafely + runRawSql conn "COMMIT" + pure result + where + rollbackSafely = do + _ <- try (runRawSql conn "ROLLBACK") :: IO (Either SomeException ()) + pure () + +beginSavepointTransaction :: Int -> HasqlConn.Connection -> IO a -> IO a +beginSavepointTransaction depth conn action = mask $ \restore -> do + let spName = "arbiter_sp_" <> T.pack (show depth) + runRawSql conn ("SAVEPOINT " <> spName) + result <- + restore action + `onException` do + _ <- try (runRawSql conn ("ROLLBACK TO SAVEPOINT " <> spName)) :: IO (Either SomeException ()) + pure () + runRawSql conn ("RELEASE SAVEPOINT " <> spName) + pure result diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 2cf2dff8b4d..6f2ee5422ea 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -371,6 +371,7 @@ library Wire.InvitationStore Wire.InvitationStore.Cassandra Wire.JobSubsystem + Wire.JobSubsystem.ArbiterAdapter Wire.JobSubsystem.Interpreter Wire.JobSubsystem.Workers Wire.LegalHold From 7c7f99c6b91798a2285f386073a72b1481d15dc0 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 9 Jul 2026 14:05:28 +0000 Subject: [PATCH 54/65] wire monad arbiter adapter and remove second conn pool --- flake.lock | 20 ------ libs/extended/src/Hasql/Pool/Extended.hs | 32 +--------- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 5 +- .../src/Wire/JobSubsystem/ArbiterAdapter.hs | 7 ++- .../src/Wire/JobSubsystem/Interpreter.hs | 62 ++++++++++--------- .../src/Wire/JobSubsystem/Workers.hs | 51 ++++++++------- .../background-worker/background-worker.cabal | 2 - .../src/Wire/BackgroundWorker/Env.hs | 4 -- .../Wire/BackgroundWorker/ScheduledJobs.hs | 9 ++- .../background-worker/src/Wire/Effects.hs | 3 +- .../Wire/BackendNotificationPusherSpec.hs | 2 - .../background-worker/test/Test/Wire/Util.hs | 1 - services/galley/galley.cabal | 2 - services/galley/src/Galley/App.hs | 9 +-- services/galley/src/Galley/Env.hs | 6 +- 15 files changed, 77 insertions(+), 138 deletions(-) diff --git a/flake.lock b/flake.lock index c5ecb3ff4ca..49d3242d64c 100644 --- a/flake.lock +++ b/flake.lock @@ -235,37 +235,17 @@ "hasql-resource-pool": { "flake": false, "locked": { -<<<<<<< HEAD "lastModified": 1783610992, "narHash": "sha256-xpnT7V2EddKVzK5cvaPeRYpCyRbWyqkQJrsBMpSbWXg=", -||||||| parent of d6db186443 (monad arbeiter adapter) - "lastModified": 1783526891, - "narHash": "sha256-Xa5VXbadJ1RPUUgInk0/kBYuus2Aw3sU2ZT6/KqkUWA=", -======= - "lastModified": 1783603733, - "narHash": "sha256-Xa8PBUaakNai6cxo4MdMtydfvKw+zW5t3S2Fp/axEZc=", ->>>>>>> d6db186443 (monad arbeiter adapter) "owner": "wireapp", "repo": "hasql-resource-pool", -<<<<<<< HEAD "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", -||||||| parent of d6db186443 (monad arbeiter adapter) - "rev": "209927f0e36da389d70235fd28034a78d1cc54af", -======= - "rev": "aaa14b2a754e23dc3871fc34b5aadc367fae07ce", ->>>>>>> d6db186443 (monad arbeiter adapter) "type": "github" }, "original": { "owner": "wireapp", "repo": "hasql-resource-pool", -<<<<<<< HEAD "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", -||||||| parent of d6db186443 (monad arbeiter adapter) - "rev": "209927f0e36da389d70235fd28034a78d1cc54af", -======= - "rev": "aaa14b2a754e23dc3871fc34b5aadc367fae07ce", ->>>>>>> d6db186443 (monad arbeiter adapter) "type": "github" } }, diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index e15e2c8b775..9faa30e8f7e 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -18,14 +18,10 @@ module Hasql.Pool.Extended where import Data.Aeson -import Data.ByteString qualified as ByteString import Data.Map qualified as Map import Data.Misc -import Data.Pool qualified as Pool -import Data.Text.Encoding qualified as Text -import Data.Time.Clock (diffUTCTime, getCurrentTime, secondsToDiffTime) +import Data.Time.Clock (diffUTCTime, getCurrentTime) import Hasql.Connection qualified -import Hasql.Connection qualified as Hasql import Hasql.Connection.Settings qualified as HasqlConnSettings import Hasql.Pool qualified as HasqlPool import Imports @@ -45,14 +41,6 @@ data PoolConfig = PoolConfig } deriving (Eq, Show) -defaultArbiterConnectionPoolConfig :: PoolConfig -defaultArbiterConnectionPoolConfig = - PoolConfig - { size = 10, - acquisitionTimeout = Duration (secondsToDiffTime 5), - idlenessTimeout = Duration (secondsToDiffTime 60) - } - instance FromJSON PoolConfig where parseJSON = withObject "PoolConfig" $ \o -> PoolConfig @@ -72,24 +60,6 @@ postgresqlConnectionString pgConfig mFpSecrets = do pure . PostgresqlConnectionString.toKeyValueString $ PostgresqlConnectionString.fromKeyValueParams pgConfig' --- | Creates a dedicated raw connection pool for Arbiter-backed code paths. --- --- This intentionally returns 'Pool Connection' instead of the opaque --- 'Hasql.Pool' wrapper so Arbiter can manage its own pool state directly. -initPostgresConnectionPool :: PoolConfig -> ByteString.ByteString -> IO (Pool.Pool Hasql.Connection) -initPostgresConnectionPool config connStr = - Pool.newPool $ - Pool.defaultPoolConfig - ( do - result <- Hasql.acquire (HasqlConnSettings.connectionString (Text.decodeUtf8 connStr)) - case result of - Right conn -> pure conn - Left err -> fail $ "Failed to acquire Arbiter Hasql connection: " <> show err - ) - Hasql.release - (realToFrac config.idlenessTimeout.duration) - (size config) - data HasqlPoolMetrics = HasqlPoolMetrics { readyForUseGauge :: Gauge, inUseGauge :: Gauge, diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index a6c47b6d762..152c1349582 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -32,10 +32,8 @@ where import Arbiter.Core.Job.Types (JobRead) import Data.Id import Data.Json.Util (UTCTimeMillis) -import Data.Pool qualified as Pool import Data.Qualified import Data.Time.Clock (UTCTime) -import Hasql.Connection qualified as Hasql import Imports import Polysemy import Wire.API.Jobs @@ -44,8 +42,7 @@ import Wire.JobSubsystem.Workers type CleanupAction = IO () data JobSubsystemConfig = JobSubsystemConfig - { jobSubsystemArbiterPool :: Pool.Pool Hasql.Connection, - jobSubsystemSchemaName :: Text + { jobSubsystemSchemaName :: Text } data JobWorkersConfig = JobWorkersConfig diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs index 14c2c25bc17..67b6f17336a 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -19,6 +19,7 @@ import Arbiter.Core.QueueRegistry (JobPayloadRegistry) import Arbiter.Hasql.Decode qualified as Decode import Arbiter.Hasql.Encode qualified as Encode import Control.Exception (mask, onException, try) +import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow) import Control.Monad.Reader import Data.Text qualified as T import Hasql.Connection qualified as HasqlConn @@ -45,8 +46,12 @@ newtype WireArbiter (registry :: JobPayloadRegistry) a = WireArbiter ( Functor, Applicative, Monad, + MonadCatch, MonadIO, - MonadReader WireArbiterEnv + MonadMask, + MonadReader WireArbiterEnv, + MonadThrow, + MonadUnliftIO ) runWireArbiter :: WireArbiterEnv -> WireArbiter registry a -> IO a diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 1d20df3582c..a7d8b09f6e5 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -27,27 +27,29 @@ module Wire.JobSubsystem.Interpreter where import Arbiter.Core qualified as ArbiterCore -import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Data.Id import Data.Json.Util (UTCTimeMillis) -import Data.Proxy (Proxy (..)) import Data.Qualified import Data.Time +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports import Polysemy +import Polysemy.Input (input) import Wire.API.Jobs import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkerHandlers (..), JobWorkersConfig (..)) +import Wire.JobSubsystem.ArbiterAdapter (WireArbiter, WireArbiterEnv (..), runWireArbiter) import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) +import Wire.Postgres (PGConstraints) -runJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> IO CleanupAction -runJobWorkers JobWorkersConfig {..} JobWorkerHandlers {..} = do - cleanupRecurring <- runRecurringJobRunner (Proxy @ScheduledJobsRegistry) recurringJobRunnerConfig recurringJobRunnerRunJob - cleanupDeletion <- runOneOffJobRunner (Proxy @ScheduledJobsRegistry) adminlessDeletionJobRunnerConfig adminlessDeletionJobRunnerRunJob - cleanupReminder <- runOneOffJobRunner (Proxy @ScheduledJobsRegistry) adminlessReminderJobRunnerConfig adminlessReminderJobRunnerRunJob +runJobWorkers :: HasqlPoolExt.Pool -> JobWorkersConfig -> JobWorkerHandlers -> IO CleanupAction +runJobWorkers pool JobWorkersConfig {..} JobWorkerHandlers {..} = do + cleanupRecurring <- runRecurringJobRunner @ScheduledJobsRegistry pool recurringJobRunnerConfig recurringJobRunnerRunJob + cleanupDeletion <- runOneOffJobRunner @ScheduledJobsRegistry pool adminlessDeletionJobRunnerConfig adminlessDeletionJobRunnerRunJob + cleanupReminder <- runOneOffJobRunner @ScheduledJobsRegistry pool adminlessReminderJobRunnerConfig adminlessReminderJobRunnerRunJob pure $ cleanupRecurring >> cleanupDeletion >> cleanupReminder interpretJobSubsystem :: - (Member (Embed IO) r) => + (PGConstraints r) => JobSubsystemConfig -> InterpreterFor JobSubsystem r interpretJobSubsystem conf = @@ -55,11 +57,13 @@ interpretJobSubsystem conf = \case ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor ScheduleAdminlessReminderJob lusr tid cid deletionScheduledFor scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid deletionScheduledFor scheduledFor - StartJobWorkers cfg handlers -> embed $ runJobWorkers cfg handlers + StartJobWorkers cfg handlers -> do + pool <- input + embed $ runJobWorkers pool cfg handlers scheduleAdminlessDeletionJob :: forall r. - (Member (Embed IO) r) => + (PGConstraints r) => JobSubsystemConfig -> Local UserId -> TeamId -> @@ -67,25 +71,25 @@ scheduleAdminlessDeletionJob :: UTCTime -> Sem r () scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do + pool <- input let arbiterEnv = - ArbiterHasql.createHasqlEnvWithPool - (Proxy @ScheduledJobsRegistry) - jobSubsystemArbiterPool - jobSubsystemSchemaName + WireArbiterEnv + { schemaName = jobSubsystemSchemaName, + connectionPool = pool, + activeConn = Nothing, + transactionDepth = 0, + preparedStatements = False + } let arbiterJob = (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName (AdminlessDeletionJob teamId convId (tUnqualified lusr))) { ArbiterCore.notVisibleUntil = Just scheduledFor, ArbiterCore.maxAttempts = Just 3 } - embed $ - void $ - ArbiterHasql.runHasqlDb arbiterEnv $ - void $ - ArbiterCore.insertJob arbiterJob + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter ScheduledJobsRegistry) arbiterJob scheduleAdminlessReminderJob :: forall r. - (Member (Embed IO) r) => + (PGConstraints r) => JobSubsystemConfig -> Local UserId -> TeamId -> @@ -94,18 +98,18 @@ scheduleAdminlessReminderJob :: UTCTime -> Sem r () scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletionScheduledFor scheduledFor = do + pool <- input @HasqlPoolExt.Pool let arbiterEnv = - ArbiterHasql.createHasqlEnvWithPool - (Proxy @ScheduledJobsRegistry) - jobSubsystemArbiterPool - jobSubsystemSchemaName + WireArbiterEnv + { schemaName = jobSubsystemSchemaName, + connectionPool = pool, + activeConn = Nothing, + transactionDepth = 0, + preparedStatements = False + } let arbiterJob = (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) deletionScheduledFor)) { ArbiterCore.notVisibleUntil = Just scheduledFor, ArbiterCore.maxAttempts = Just 3 } - embed $ - void $ - ArbiterHasql.runHasqlDb arbiterEnv $ - void $ - ArbiterCore.insertJob arbiterJob + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter ScheduledJobsRegistry) arbiterJob diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 2cc8a98591f..5be7c510e7b 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -1,5 +1,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} -- This file is part of the Wire Server implementation. -- @@ -29,7 +31,6 @@ where import Arbiter.Core qualified as ArbiterCore import Arbiter.Core.Job.Types (JobRead) import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) -import Arbiter.Hasql.HasqlDb qualified as ArbiterHasql import Arbiter.Migrations qualified as ArbiterMigrations import Arbiter.Worker qualified as ArbiterWorker import Arbiter.Worker.Config qualified as ArbiterWorkerConfig @@ -37,22 +38,21 @@ import Arbiter.Worker.Cron qualified as ArbiterWorkerCron import Data.Aeson (FromJSON, ToJSON) import Data.ByteString qualified as ByteString import Data.Kind (Type) -import Data.Pool qualified as Pool import Data.Proxy (Proxy (..)) import Data.Text qualified as T import Data.Time.Clock (NominalDiffTime) import GHC.TypeLits (KnownSymbol) -import Hasql.Connection qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports import System.Cron (CronSchedule, serializeCronSchedule) import System.Logger qualified as Log import UnliftIO.Async qualified as Async import Wire.API.Jobs (MeetingsCleanupJob (..)) +import Wire.JobSubsystem.ArbiterAdapter (WireArbiter, WireArbiterEnv (..), runWireArbiter) data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig { recurringJobRunnerLogger :: Log.Logger, recurringJobRunnerSchedule :: CronSchedule, - recurringJobRunnerArbiterPool :: Pool.Pool Hasql.Connection, recurringJobRunnerArbiterConnStr :: ByteString.ByteString, recurringJobRunnerSchemaName :: Text, recurringJobRunnerPollInterval :: NominalDiffTime, @@ -63,7 +63,6 @@ data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig { oneOffJobRunnerLogger :: Log.Logger, - oneOffJobRunnerArbiterPool :: Pool.Pool Hasql.Connection, oneOffJobRunnerArbiterConnStr :: ByteString.ByteString, oneOffJobRunnerSchemaName :: Text, oneOffJobRunnerPollInterval :: NominalDiffTime, @@ -82,11 +81,11 @@ runRecurringJobRunner :: FromJSON MeetingsCleanupJob, ToJSON MeetingsCleanupJob ) => - Proxy registry -> + HasqlPoolExt.Pool -> RecurringJobRunnerConfig registry -> (MeetingsCleanupJob -> IO ()) -> IO (IO ()) -runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do +runRecurringJobRunner postgresPool RecurringJobRunnerConfig {..} runJob = do Log.info recurringJobRunnerLogger $ Log.msg (Log.val "Starting scheduled jobs worker") . Log.field "job_name" recurringJobRunnerJobName @@ -94,10 +93,13 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do . Log.field "schedule" (show recurringJobRunnerSchedule) let arbiterEnv = - ArbiterHasql.createHasqlEnvWithPool - registry - recurringJobRunnerArbiterPool - recurringJobRunnerSchemaName + WireArbiterEnv + { schemaName = recurringJobRunnerSchemaName, + connectionPool = postgresPool, + activeConn = Nothing, + transactionDepth = 0, + preparedStatements = False + } let workerHandler _conn job = liftIO $ do @@ -126,7 +128,7 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do -- keep polling as the baseline and treat notifications as a latency -- optimization rather than a correctness requirement. ArbiterMigrations.runMigrationsForRegistry - registry + (Proxy @registry) recurringJobRunnerArbiterConnStr recurringJobRunnerSchemaName ArbiterMigrations.defaultMigrationConfig @@ -138,7 +140,7 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do workerHandler :: IO ( ArbiterWorker.WorkerConfig - (ArbiterHasql.HasqlDb registry IO) + (WireArbiter registry) MeetingsCleanupJob () ) @@ -152,7 +154,7 @@ runRecurringJobRunner registry RecurringJobRunnerConfig {..} runJob = do workerAsync <- Async.async $ - ArbiterHasql.runHasqlDb arbiterEnv $ + runWireArbiter arbiterEnv $ ArbiterWorker.runWorkerPool workerConfig' pure $ do @@ -166,21 +168,24 @@ runOneOffJobRunner :: FromJSON payload, ToJSON payload ) => - Proxy registry -> + HasqlPoolExt.Pool -> OneOffJobRunnerConfig registry payload -> (JobRead payload -> IO ()) -> IO (IO ()) -runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do +runOneOffJobRunner postgresPool OneOffJobRunnerConfig {..} runJob = do Log.info oneOffJobRunnerLogger $ Log.msg (Log.val "Starting one-off jobs worker") . Log.field "job_name" oneOffJobRunnerJobName . Log.field "queue_name" oneOffJobRunnerQueueName let arbiterEnv = - ArbiterHasql.createHasqlEnvWithPool - registry - oneOffJobRunnerArbiterPool - oneOffJobRunnerSchemaName + WireArbiterEnv + { schemaName = oneOffJobRunnerSchemaName, + connectionPool = postgresPool, + activeConn = Nothing, + transactionDepth = 0, + preparedStatements = False + } let workerHandler _conn job = liftIO $ do Log.info oneOffJobRunnerLogger $ @@ -191,7 +196,7 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do void $ ArbiterMigrations.runMigrationsForRegistry - registry + (Proxy @registry) oneOffJobRunnerArbiterConnStr oneOffJobRunnerSchemaName ArbiterMigrations.defaultMigrationConfig @@ -203,7 +208,7 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do workerHandler :: IO ( ArbiterWorker.WorkerConfig - (ArbiterHasql.HasqlDb registry IO) + (WireArbiter registry) payload () ) @@ -212,7 +217,7 @@ runOneOffJobRunner registry OneOffJobRunnerConfig {..} runJob = do workerAsync <- Async.async $ - ArbiterHasql.runHasqlDb arbiterEnv $ + runWireArbiter arbiterEnv $ ArbiterWorker.runWorkerPool workerConfig' pure $ do diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index 0fdb71b515f..ffaadd8cfbd 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -50,7 +50,6 @@ library , extended , extra , galley-types - , hasql , hasql-resource-pool , HsOpenSSL , http-client @@ -62,7 +61,6 @@ library , polysemy-conc , polysemy-wire-zoo , prometheus-client - , resource-pool , retry , servant-client , servant-server diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 14502e352c9..3f1917272d4 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -30,10 +30,8 @@ import Data.Domain (Domain) import Data.Id (TeamId) import Data.Map.Strict qualified as Map import Data.Misc (HttpsUrl) -import Data.Pool qualified as Pool import Data.Text.Encoding qualified as Text import HTTP2.Client.Manager -import Hasql.Connection qualified as Hasql import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as Hasql import Imports @@ -94,7 +92,6 @@ data Env = Env cassandraBrig :: ClientState, hasqlPool :: Hasql.Pool, arbiterConnStr :: ByteString.ByteString, - arbiterPool :: Pool.Pool Hasql.Connection, -- Dedicated AMQP channels per concern amqpJobsPublisherChannel :: MVar Q.Channel, amqpBackendNotificationsChannel :: MVar Q.Channel, @@ -197,7 +194,6 @@ mkEnv opts galleyOpts = do workerRunningGauge <- mkWorkerRunningGauge hasqlPool <- initPostgresPool opts.postgresqlPool galleyOpts._postgresql galleyOpts._postgresqlPassword arbiterConnStr <- Text.encodeUtf8 <$> postgresqlConnectionString galleyOpts._postgresql galleyOpts._postgresqlPassword - arbiterPool <- initPostgresConnectionPool defaultArbiterConnectionPoolConfig arbiterConnStr Log.info logger $ Log.msg @Text "Opening RabbitMQ channel: background-worker-jobs-publisher..." amqpJobsPublisherChannel <- mkRabbitMqChannelMVar logger (Just "background-worker-jobs-publisher") $ diff --git a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs index 7f3e9abfddf..1d625c9f5db 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/ScheduledJobs.hs @@ -69,9 +69,9 @@ startWorker scheduledConfig config = do RecurringJobRunnerConfig { recurringJobRunnerLogger = env.logger, recurringJobRunnerSchedule = config.schedule, - recurringJobRunnerArbiterPool = env.arbiterPool, -- Arbiter still uses the connection string for LISTEN/NOTIFY. - -- The actual job DB access goes through 'arbiterPool'. + -- The actual job DB access goes through the shared Hasql pool + -- pulled from the JobSubsystem interpreter. recurringJobRunnerArbiterConnStr = env.arbiterConnStr, recurringJobRunnerSchemaName = ArbiterCore.defaultSchemaName, recurringJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, @@ -82,9 +82,9 @@ startWorker scheduledConfig config = do adminlessDeletionJobRunnerConfig = OneOffJobRunnerConfig { oneOffJobRunnerLogger = env.logger, - oneOffJobRunnerArbiterPool = env.arbiterPool, -- Arbiter still uses the connection string for LISTEN/NOTIFY. - -- The actual job DB access goes through 'arbiterPool'. + -- The actual job DB access goes through the shared Hasql pool + -- pulled from the JobSubsystem interpreter. oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, oneOffJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, @@ -95,7 +95,6 @@ startWorker scheduledConfig config = do adminlessReminderJobRunnerConfig = OneOffJobRunnerConfig { oneOffJobRunnerLogger = env.logger, - oneOffJobRunnerArbiterPool = env.arbiterPool, oneOffJobRunnerArbiterConnStr = env.arbiterConnStr, oneOffJobRunnerSchemaName = ArbiterCore.defaultSchemaName, oneOffJobRunnerPollInterval = scheduledJobsPollIntervalSeconds scheduledConfig.pollInterval, diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 71b3d13b036..947a03f25af 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -388,8 +388,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = } jobSubsystemConfig = JobSubsystemConfig - { jobSubsystemArbiterPool = env.arbiterPool, - jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + { jobSubsystemSchemaName = ArbiterCore.defaultSchemaName } backendQueueEnv = BackendNotificationQueueAccess.Env diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index 664458c7141..ff580236ae7 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -386,7 +386,6 @@ spec = do passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing arbiterConnStr = BS.empty - arbiterPool = undefined convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = @@ -451,7 +450,6 @@ spec = do passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing arbiterConnStr = BS.empty - arbiterPool = undefined convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index 3919add5225..4b149736d47 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -85,7 +85,6 @@ testEnv = do passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing arbiterConnStr = BS.empty - arbiterPool = undefined convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index d8f06636218..f7c48e3bc00 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -211,7 +211,6 @@ library , exceptions >=0.4 , extended , galley-types >=0.65.0 - , hasql , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk @@ -233,7 +232,6 @@ library , polysemy-wire-zoo , prometheus-client , raw-strings-qq >=1.0 - , resource-pool , retry >=0.5 , safe-exceptions >=0.1 , servant diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index bcfdc2875f4..778168d3c37 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -54,7 +54,6 @@ import Data.Misc import Data.Qualified import Data.Range import Data.Text qualified as Text -import Data.Text.Encoding qualified as TextEncoding import Galley.Effects.Queue qualified as GE import Galley.Env import Galley.External.LegalHoldService.Internal qualified as LHInternal @@ -64,7 +63,7 @@ import Galley.Queue qualified as Q import Galley.Types.Error import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool qualified as Hasql -import Hasql.Pool.Extended (defaultArbiterConnectionPoolConfig, initPostgresConnectionPool, initPostgresPool, postgresqlConnectionString) +import Hasql.Pool.Extended (initPostgresPool) import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports hiding (forkIO) import Network.AMQP.Extended (mkRabbitMqChannelMVar) @@ -329,8 +328,6 @@ createEnv o l = do h2mgr <- initHttp2Manager codeURIcfg <- validateOptions o postgres <- initPostgresPool o._postgresqlPool o._postgresql o._postgresqlPassword - galleyJobsApiConnStr <- postgresqlConnectionString o._postgresql o._postgresqlPassword - galleyJobsApiPool <- initPostgresConnectionPool defaultArbiterConnectionPoolConfig (TextEncoding.encodeUtf8 galleyJobsApiConnStr) let disableTlsV1 = True Env (RequestId defRequestId) o l mgr h2mgr (o ^. O.federator) (o ^. O.brig) cass postgres <$> Q.new 16000 @@ -340,7 +337,6 @@ createEnv o l = do <*> traverse (mkRabbitMqChannelMVar l (Just "galley")) (o ^. rabbitmq) <*> pure codeURIcfg <*> newRateLimitEnv (o ^. settings . passwordHashingRateLimit) - <*> pure galleyJobsApiPool initCassandra :: Opts -> Logger -> IO ClientState initCassandra o l = @@ -559,8 +555,7 @@ evalGalley e = . runFederationSubsystem conversationSubsystemConfig.federationProtocols . interpretJobSubsystem JobSubsystemConfig - { jobSubsystemArbiterPool = e ^. jobsApiPool, - jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + { jobSubsystemSchemaName = ArbiterCore.defaultSchemaName } . interpretConversationSubsystem . Meeting.interpretMeetingsSubsystem meetingValidityPeriod diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index ec61b7e663c..24da89559cd 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -37,7 +37,6 @@ module Galley.Env rabbitmqChannel, convCodeURI, passwordHashingRateLimitEnv, - jobsApiPool, reqIdMsg, notificationSubsystemConfig, currentFanoutLimitOpts, @@ -49,11 +48,9 @@ import Control.Lens hiding ((.=)) import Data.Domain (Domain) import Data.Id import Data.Misc (HttpsUrl) -import Data.Pool qualified as Pool import Data.Time.Clock.DiffTime (millisecondsToDiffTime) import Galley.Queue qualified as Q import HTTP2.Client.Manager (Http2Manager) -import Hasql.Connection qualified as Hasql import Hasql.Pool.Extended import Imports import Network.AMQP qualified as Q @@ -90,8 +87,7 @@ data Env = Env _mlsKeys :: Maybe (MLSKeysByPurpose MLSPrivateKeys), _rabbitmqChannel :: Maybe (MVar Q.Channel), _convCodeURI :: Either HttpsUrl (Map Domain HttpsUrl), - _passwordHashingRateLimitEnv :: RateLimitEnv, - _jobsApiPool :: Pool.Pool Hasql.Connection + _passwordHashingRateLimitEnv :: RateLimitEnv } makeLenses ''Env From 124d3050b09edd85365b749aa6f899e3e2545298 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 9 Jul 2026 14:40:00 +0000 Subject: [PATCH 55/65] dependency clean up --- libs/extended/default.nix | 2 -- libs/extended/extended.cabal | 1 - services/background-worker/default.nix | 4 ---- services/galley/default.nix | 4 ---- 4 files changed, 11 deletions(-) diff --git a/libs/extended/default.nix b/libs/extended/default.nix index 61ec5b50ae1..1813326fc12 100644 --- a/libs/extended/default.nix +++ b/libs/extended/default.nix @@ -33,7 +33,6 @@ , prometheus-client , QuickCheck , ram -, resource-pool , retry , servant , servant-client @@ -82,7 +81,6 @@ mkDerivation { postgresql-connection-string prometheus-client ram - resource-pool retry servant servant-client diff --git a/libs/extended/extended.cabal b/libs/extended/extended.cabal index c2e2e604e6e..eee2d884228 100644 --- a/libs/extended/extended.cabal +++ b/libs/extended/extended.cabal @@ -113,7 +113,6 @@ library , postgresql-connection-string , prometheus-client , ram - , resource-pool , retry , servant , servant-client diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index ed554ccdcd4..7a07604b93f 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -20,7 +20,6 @@ , extra , federator , galley-types -, hasql , hasql-resource-pool , HsOpenSSL , hspec @@ -38,7 +37,6 @@ , polysemy-wire-zoo , prometheus-client , QuickCheck -, resource-pool , retry , servant , servant-client @@ -82,7 +80,6 @@ mkDerivation { extended extra galley-types - hasql hasql-resource-pool HsOpenSSL http-client @@ -94,7 +91,6 @@ mkDerivation { polysemy-conc polysemy-wire-zoo prometheus-client - resource-pool retry servant-client servant-server diff --git a/services/galley/default.nix b/services/galley/default.nix index 87f90dccb09..949e941b3a9 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -33,7 +33,6 @@ , federator , filepath , galley-types -, hasql , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk @@ -69,7 +68,6 @@ , ram , random , raw-strings-qq -, resource-pool , retry , safe-exceptions , servant @@ -138,7 +136,6 @@ mkDerivation { exceptions extended galley-types - hasql hasql-resource-pool hs-opentelemetry-instrumentation-wai hs-opentelemetry-sdk @@ -160,7 +157,6 @@ mkDerivation { polysemy-wire-zoo prometheus-client raw-strings-qq - resource-pool retry safe-exceptions servant From ed24355574f529eaa9db140911f0c41e18f2912c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 9 Jul 2026 15:54:21 +0000 Subject: [PATCH 56/65] fix flake.lock From 12d04a8c8ba8ba32ea0e56aca0e0fc2e2c199e63 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 9 Jul 2026 16:02:48 +0000 Subject: [PATCH 57/65] adapt adapter --- libs/extended/src/Hasql/Pool/Extended.hs | 1 - libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index 9faa30e8f7e..341f8b48215 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -20,7 +20,6 @@ module Hasql.Pool.Extended where import Data.Aeson import Data.Map qualified as Map import Data.Misc -import Data.Time.Clock (diffUTCTime, getCurrentTime) import Hasql.Connection qualified import Hasql.Connection.Settings qualified as HasqlConnSettings import Hasql.Pool qualified as HasqlPool diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs index 67b6f17336a..a198c986d5a 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -21,6 +21,7 @@ import Arbiter.Hasql.Encode qualified as Encode import Control.Exception (mask, onException, try) import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow) import Control.Monad.Reader +import Data.Misc (durationToCeilingSeconds) import Data.Text qualified as T import Hasql.Connection qualified as HasqlConn import Hasql.Decoders qualified as Decoders @@ -109,7 +110,7 @@ withPoolConnection pool f = do result <- liftIO $ HasqlPool.withConnectionWithPoolAcquisitionTimeout - pool.poolAcquisitionTimeout + (durationToCeilingSeconds pool.poolAcquisitionTimeout) pool.rawPool (fmap Right . f) case result of From d4d78b4443b3a99a43eef290de23f63fe2dd7996 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 10 Jul 2026 10:42:53 +0000 Subject: [PATCH 58/65] adress PR findings --- libs/extended/src/Hasql/Pool/Extended.hs | 10 ++--- libs/types-common/src/Data/Secret.hs | 43 +++++++++++++++++++ libs/types-common/test/Main.hs | 4 +- libs/types-common/test/Test/Data/Secret.hs | 31 +++++++++++++ libs/types-common/types-common.cabal | 2 + .../src/Wire/JobSubsystem/ArbiterAdapter.hs | 6 +-- .../src/Wire/JobSubsystem/Workers.hs | 19 +++++--- .../src/Wire/AdminlessJobsWorker.hs | 4 -- .../src/Wire/BackgroundWorker/Env.hs | 8 ++-- .../Wire/BackendNotificationPusherSpec.hs | 6 +-- .../background-worker/test/Test/Wire/Util.hs | 4 +- 11 files changed, 108 insertions(+), 29 deletions(-) create mode 100644 libs/types-common/src/Data/Secret.hs create mode 100644 libs/types-common/test/Test/Data/Secret.hs diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index 341f8b48215..31c6bf67cab 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -20,6 +20,7 @@ module Hasql.Pool.Extended where import Data.Aeson import Data.Map qualified as Map import Data.Misc +import Data.Secret (SecretText, secretText) import Hasql.Connection qualified import Hasql.Connection.Settings qualified as HasqlConnSettings import Hasql.Pool qualified as HasqlPool @@ -50,13 +51,12 @@ instance FromJSON PoolConfig where -- | Render a PostgreSQL connection string in libpq key-value format. -- -- Passwords from the optional secret file are inserted into the key-value map --- before rendering so the resulting connection string can be reused by code --- that expects a plain connection string. -postgresqlConnectionString :: Map Text Text -> Maybe FilePathSecrets -> IO Text -postgresqlConnectionString pgConfig mFpSecrets = do +-- before rendering. The result is wrapped because it may contain the password. +postgresqlConnectionStringWithPassword :: Map Text Text -> Maybe FilePathSecrets -> IO SecretText +postgresqlConnectionStringWithPassword pgConfig mFpSecrets = do mPw <- for mFpSecrets initCredentials let pgConfig' = maybe pgConfig (\pw -> Map.insert "password" pw pgConfig) mPw - pure . PostgresqlConnectionString.toKeyValueString $ + pure . secretText . PostgresqlConnectionString.toKeyValueString $ PostgresqlConnectionString.fromKeyValueParams pgConfig' data HasqlPoolMetrics = HasqlPoolMetrics diff --git a/libs/types-common/src/Data/Secret.hs b/libs/types-common/src/Data/Secret.hs new file mode 100644 index 00000000000..73df5920d22 --- /dev/null +++ b/libs/types-common/src/Data/Secret.hs @@ -0,0 +1,43 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Data.Secret + ( SecretText, + secretText, + revealSecretText, + ) +where + +import Imports + +-- | Text that may contain credentials or other sensitive material. +-- +-- The constructor is intentionally opaque. 'revealSecretText' should only be +-- used at the narrow boundary where an external API requires the plaintext +-- representation. +newtype SecretText = SecretText Text + +instance Show SecretText where + show _ = "" + +-- | Wrap sensitive text without exposing it through the public constructor. +secretText :: Text -> SecretText +secretText = SecretText + +-- | Reveal sensitive text for an API that explicitly requires plaintext. +revealSecretText :: SecretText -> Text +revealSecretText (SecretText value) = value diff --git a/libs/types-common/test/Main.hs b/libs/types-common/test/Main.hs index 4814492dfd0..41bd84e1939 100644 --- a/libs/types-common/test/Main.hs +++ b/libs/types-common/test/Main.hs @@ -23,6 +23,7 @@ where import Imports import Test.Data.Mailbox qualified as Mailbox import Test.Data.PEMKeys qualified as PEMKeys +import Test.Data.Secret qualified as Secret import Test.Domain qualified as Domain import Test.Handle qualified as Handle import Test.Properties qualified as Properties @@ -41,5 +42,6 @@ main = Handle.tests, Qualified.tests, PEMKeys.tests, - Mailbox.tests + Mailbox.tests, + Secret.tests ] diff --git a/libs/types-common/test/Test/Data/Secret.hs b/libs/types-common/test/Test/Data/Secret.hs new file mode 100644 index 00000000000..65ab82c7cdc --- /dev/null +++ b/libs/types-common/test/Test/Data/Secret.hs @@ -0,0 +1,31 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Data.Secret (tests) where + +import Data.Secret +import Imports +import Test.Tasty +import Test.Tasty.HUnit + +tests :: TestTree +tests = + testGroup + "SecretText" + [ testCase "does not expose its value through Show" $ + show (secretText "database-password") @?= "" + ] diff --git a/libs/types-common/types-common.cabal b/libs/types-common/types-common.cabal index e2249067182..7d1590c0571 100644 --- a/libs/types-common/types-common.cabal +++ b/libs/types-common/types-common.cabal @@ -31,6 +31,7 @@ library Data.Qualified Data.Range Data.RetryAfter + Data.Secret Data.SizedHashMap Data.Text.Ascii Data.UUID.Tagged @@ -160,6 +161,7 @@ test-suite types-common-tests Paths_types_common Test.Data.Mailbox Test.Data.PEMKeys + Test.Data.Secret Test.Domain Test.Handle Test.Properties diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs index a198c986d5a..d2b30870f6e 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -136,7 +136,7 @@ runQueryStatement prepare conn sql params codec = do result <- HasqlConn.use conn (Session.statement () stmt) case result of Right rows -> pure rows - Left err -> fail $ "hasql query error: " <> show err + Left err -> throwInternal $ "hasql query error: " <> T.pack (show err) runExecStatement :: HasqlConn.Connection -> Text -> Params -> IO Int64 runExecStatement conn sql params = do @@ -144,7 +144,7 @@ runExecStatement conn sql params = do result <- HasqlConn.use conn (Session.statement () stmt) case result of Right n -> pure n - Left err -> fail $ "hasql statement error: " <> show err + Left err -> throwInternal $ "hasql statement error: " <> T.pack (show err) runRawSql :: HasqlConn.Connection -> Text -> IO () runRawSql conn sql = do @@ -152,7 +152,7 @@ runRawSql conn sql = do result <- HasqlConn.use conn (Session.statement () stmt) case result of Right () -> pure () - Left err -> fail $ "hasql sql error: " <> show err + Left err -> throwInternal $ "hasql sql error: " <> T.pack (show err) beginCommitOrRollback :: HasqlConn.Connection -> IO a -> IO a beginCommitOrRollback conn action = mask $ \restore -> do diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 5be7c510e7b..82b352df089 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -36,10 +36,11 @@ import Arbiter.Worker qualified as ArbiterWorker import Arbiter.Worker.Config qualified as ArbiterWorkerConfig import Arbiter.Worker.Cron qualified as ArbiterWorkerCron import Data.Aeson (FromJSON, ToJSON) -import Data.ByteString qualified as ByteString import Data.Kind (Type) import Data.Proxy (Proxy (..)) +import Data.Secret (SecretText, revealSecretText) import Data.Text qualified as T +import Data.Text.Encoding qualified as Text import Data.Time.Clock (NominalDiffTime) import GHC.TypeLits (KnownSymbol) import Hasql.Pool.Extended qualified as HasqlPoolExt @@ -53,7 +54,8 @@ import Wire.JobSubsystem.ArbiterAdapter (WireArbiter, WireArbiterEnv (..), runWi data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig { recurringJobRunnerLogger :: Log.Logger, recurringJobRunnerSchedule :: CronSchedule, - recurringJobRunnerArbiterConnStr :: ByteString.ByteString, + -- May contain the PostgreSQL password. Keep it wrapped until the Arbiter boundary. + recurringJobRunnerArbiterConnStr :: SecretText, recurringJobRunnerSchemaName :: Text, recurringJobRunnerPollInterval :: NominalDiffTime, recurringJobRunnerWorkerThreads :: Int, @@ -63,7 +65,8 @@ data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig { oneOffJobRunnerLogger :: Log.Logger, - oneOffJobRunnerArbiterConnStr :: ByteString.ByteString, + -- May contain the PostgreSQL password. Keep it wrapped until the Arbiter boundary. + oneOffJobRunnerArbiterConnStr :: SecretText, oneOffJobRunnerSchemaName :: Text, oneOffJobRunnerPollInterval :: NominalDiffTime, oneOffJobRunnerWorkerThreads :: Int, @@ -86,6 +89,7 @@ runRecurringJobRunner :: (MeetingsCleanupJob -> IO ()) -> IO (IO ()) runRecurringJobRunner postgresPool RecurringJobRunnerConfig {..} runJob = do + let arbiterConnStr = Text.encodeUtf8 (revealSecretText recurringJobRunnerArbiterConnStr) Log.info recurringJobRunnerLogger $ Log.msg (Log.val "Starting scheduled jobs worker") . Log.field "job_name" recurringJobRunnerJobName @@ -129,13 +133,13 @@ runRecurringJobRunner postgresPool RecurringJobRunnerConfig {..} runJob = do -- optimization rather than a correctness requirement. ArbiterMigrations.runMigrationsForRegistry (Proxy @registry) - recurringJobRunnerArbiterConnStr + arbiterConnStr recurringJobRunnerSchemaName ArbiterMigrations.defaultMigrationConfig workerConfig <- ( ArbiterWorker.defaultWorkerConfig - recurringJobRunnerArbiterConnStr + arbiterConnStr recurringJobRunnerWorkerThreads workerHandler :: IO @@ -173,6 +177,7 @@ runOneOffJobRunner :: (JobRead payload -> IO ()) -> IO (IO ()) runOneOffJobRunner postgresPool OneOffJobRunnerConfig {..} runJob = do + let arbiterConnStr = Text.encodeUtf8 (revealSecretText oneOffJobRunnerArbiterConnStr) Log.info oneOffJobRunnerLogger $ Log.msg (Log.val "Starting one-off jobs worker") . Log.field "job_name" oneOffJobRunnerJobName @@ -197,13 +202,13 @@ runOneOffJobRunner postgresPool OneOffJobRunnerConfig {..} runJob = do void $ ArbiterMigrations.runMigrationsForRegistry (Proxy @registry) - oneOffJobRunnerArbiterConnStr + arbiterConnStr oneOffJobRunnerSchemaName ArbiterMigrations.defaultMigrationConfig workerConfig <- ( ArbiterWorker.defaultWorkerConfig - oneOffJobRunnerArbiterConnStr + arbiterConnStr oneOffJobRunnerWorkerThreads workerHandler :: IO diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs index 0fa08a09e9b..2cc05d9bb4d 100644 --- a/services/background-worker/src/Wire/AdminlessJobsWorker.hs +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -40,10 +40,6 @@ runAdminlessDeletionJob extEnv job = do Log.msg (Log.val "Running adminless deletion job") . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) . Log.field "conversation_id" (show (adminlessDeletionJobConversationId jobPayload)) - Log.info env.logger $ - Log.msg (Log.val "Adminless deletion job payload") - . Log.field "team_id" (show (adminlessDeletionJobTeamId jobPayload)) - . Log.field "conversation_id" (show (adminlessDeletionJobConversationId jobPayload)) . Log.field "orig_user_id" (show (adminlessDeletionJobOrigUserId jobPayload)) . Log.field "scheduled_for" (show (notVisibleUntil job)) result <- diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 3f1917272d4..bc904921a4f 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -25,12 +25,11 @@ import Cassandra.Util (defInitCassandra) import Control.Monad.Base import Control.Monad.Catch import Control.Monad.Trans.Control -import Data.ByteString qualified as ByteString import Data.Domain (Domain) import Data.Id (TeamId) import Data.Map.Strict qualified as Map import Data.Misc (HttpsUrl) -import Data.Text.Encoding qualified as Text +import Data.Secret (SecretText) import HTTP2.Client.Manager import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as Hasql @@ -91,7 +90,8 @@ data Env = Env cassandraGalley :: ClientState, cassandraBrig :: ClientState, hasqlPool :: Hasql.Pool, - arbiterConnStr :: ByteString.ByteString, + -- May contain the PostgreSQL password. Do not unwrap outside the Arbiter boundary. + arbiterConnStr :: SecretText, -- Dedicated AMQP channels per concern amqpJobsPublisherChannel :: MVar Q.Channel, amqpBackendNotificationsChannel :: MVar Q.Channel, @@ -193,7 +193,7 @@ mkEnv opts galleyOpts = do checkGroupInfo = galleyOpts._settings._checkGroupInfo workerRunningGauge <- mkWorkerRunningGauge hasqlPool <- initPostgresPool opts.postgresqlPool galleyOpts._postgresql galleyOpts._postgresqlPassword - arbiterConnStr <- Text.encodeUtf8 <$> postgresqlConnectionString galleyOpts._postgresql galleyOpts._postgresqlPassword + arbiterConnStr <- postgresqlConnectionStringWithPassword galleyOpts._postgresql galleyOpts._postgresqlPassword Log.info logger $ Log.msg @Text "Opening RabbitMQ channel: background-worker-jobs-publisher..." amqpJobsPublisherChannel <- mkRabbitMqChannelMVar logger (Just "background-worker-jobs-publisher") $ diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index ff580236ae7..7222120d93a 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -25,7 +25,6 @@ import Control.Exception import Control.Monad.Trans.Except import Data.Aeson ((.=)) import Data.Aeson qualified as Aeson -import Data.ByteString qualified as BS import Data.ByteString.Builder qualified as Builder import Data.ByteString.Lazy qualified as LBS import Data.Default @@ -33,6 +32,7 @@ import Data.Domain import Data.Id import Data.Misc import Data.Range +import Data.Secret (secretText) import Data.Sequence qualified as Seq import Data.Text qualified as Text import Data.Text.Encoding qualified as Text @@ -385,7 +385,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing - arbiterConnStr = BS.empty + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = @@ -449,7 +449,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing - arbiterConnStr = BS.empty + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index 4b149736d47..5d89532bfec 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -19,12 +19,12 @@ module Test.Wire.Util where -import Data.ByteString qualified as BS import Data.Default import Data.Domain (Domain (Domain)) import Data.Misc import Data.Proxy import Data.Range +import Data.Secret (secretText) import Imports import Network.HTTP.Client hiding (Proxy) import System.Logger.Class qualified as Logger @@ -84,7 +84,7 @@ testEnv = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing - arbiterConnStr = BS.empty + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = From 471b651bd8e547e5888d13b7269bcb1623e37a8e Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 10 Jul 2026 14:01:23 +0000 Subject: [PATCH 59/65] dedup keys for jobs --- .../src/Wire/ConversationSubsystem/Update.hs | 1 + libs/wire-subsystems/src/Wire/JobSubsystem.hs | 4 ++-- .../src/Wire/JobSubsystem/Interpreter.hs | 16 ++++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index 8050aa97ab3..f2a4f2e20e0 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -1238,6 +1238,7 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do tid (qUnqualified (tUntagged lcnv)) deletionScheduledFor + reminderTimeout reminderAt timeoutToNominalDiffTime :: PreventAdminlessTimeout -> NominalDiffTime diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index 152c1349582..1967421c346 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -33,7 +33,7 @@ import Arbiter.Core.Job.Types (JobRead) import Data.Id import Data.Json.Util (UTCTimeMillis) import Data.Qualified -import Data.Time.Clock (UTCTime) +import Data.Time.Clock (NominalDiffTime, UTCTime) import Imports import Polysemy import Wire.API.Jobs @@ -59,7 +59,7 @@ data JobWorkerHandlers = JobWorkerHandlers data JobSubsystem m a where ScheduleAdminlessDeletionJob :: Local UserId -> TeamId -> ConvId -> UTCTime -> JobSubsystem m () - ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> UTCTimeMillis -> UTCTime -> JobSubsystem m () + ScheduleAdminlessReminderJob :: Local UserId -> TeamId -> ConvId -> UTCTimeMillis -> NominalDiffTime -> UTCTime -> JobSubsystem m () StartJobWorkers :: JobWorkersConfig -> JobWorkerHandlers -> JobSubsystem m CleanupAction makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index a7d8b09f6e5..a903a358a04 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -30,6 +30,7 @@ import Arbiter.Core qualified as ArbiterCore import Data.Id import Data.Json.Util (UTCTimeMillis) import Data.Qualified +import Data.Text qualified as Text import Data.Time import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports @@ -56,7 +57,7 @@ interpretJobSubsystem conf = interpret \case ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor - ScheduleAdminlessReminderJob lusr tid cid deletionScheduledFor scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid deletionScheduledFor scheduledFor + ScheduleAdminlessReminderJob lusr tid cid deletionScheduledFor reminderTimeout scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid deletionScheduledFor reminderTimeout scheduledFor StartJobWorkers cfg handlers -> do pool <- input embed $ runJobWorkers pool cfg handlers @@ -83,6 +84,7 @@ scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId schedule let arbiterJob = (ArbiterCore.defaultGroupedJob adminlessDeletionQueueName (AdminlessDeletionJob teamId convId (tUnqualified lusr))) { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessJobDedupKey "deletion" teamId convId, ArbiterCore.maxAttempts = Just 3 } embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter ScheduledJobsRegistry) arbiterJob @@ -95,9 +97,10 @@ scheduleAdminlessReminderJob :: TeamId -> ConvId -> UTCTimeMillis -> + NominalDiffTime -> UTCTime -> Sem r () -scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletionScheduledFor scheduledFor = do +scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletionScheduledFor reminderTimeout scheduledFor = do pool <- input @HasqlPoolExt.Pool let arbiterEnv = WireArbiterEnv @@ -110,6 +113,15 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletion let arbiterJob = (ArbiterCore.defaultGroupedJob adminlessReminderQueueName (AdminlessReminderJob teamId convId (tUnqualified lusr) deletionScheduledFor)) { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessReminderJobDedupKey teamId convId reminderTimeout, ArbiterCore.maxAttempts = Just 3 } embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter ScheduledJobsRegistry) arbiterJob + +adminlessJobDedupKey :: Text -> TeamId -> ConvId -> Text +adminlessJobDedupKey jobType teamId convId = + "adminless-" <> jobType <> ":" <> idToText teamId <> ":" <> idToText convId + +adminlessReminderJobDedupKey :: TeamId -> ConvId -> NominalDiffTime -> Text +adminlessReminderJobDedupKey teamId convId reminderTimeout = + adminlessJobDedupKey "reminder" teamId convId <> ":" <> Text.pack (show reminderTimeout) From 80c24655d34d9f20a588ca455949bfb6a3c37914 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 10 Jul 2026 14:06:25 +0000 Subject: [PATCH 60/65] reject non positive poll interval --- .../src/Wire/BackgroundWorker/Options.hs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/background-worker/src/Wire/BackgroundWorker/Options.hs b/services/background-worker/src/Wire/BackgroundWorker/Options.hs index 892904ee81b..e6e90c293a3 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Options.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Options.hs @@ -106,7 +106,15 @@ data ScheduledJobsConfig = ScheduledJobsConfig pollInterval :: Duration } deriving (Show, Generic) - deriving (FromJSON) via Generically ScheduledJobsConfig + +instance FromJSON ScheduledJobsConfig where + parseJSON = + withObject "ScheduledJobsConfig" $ \o -> do + pollInterval <- o .: "pollInterval" + when (duration pollInterval <= 0) $ + parserThrowError [Key "pollInterval"] $ + "pollInterval must be greater than 0, got: " <> show pollInterval + pure ScheduledJobsConfig {..} data MeetingsCleanupConfig = MeetingsCleanupConfig { -- | Delete meetings older than this many hours From 46673158a650760d97a4449fbfa0cfec5c623b55 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 10 Jul 2026 14:22:13 +0000 Subject: [PATCH 61/65] Initialize scheduled-job Arbiter schema before startup --- .../src/Wire/JobSubsystem/Interpreter.hs | 10 ++++- .../src/Wire/JobSubsystem/Workers.hs | 38 ++++++++++--------- services/galley/src/Galley/Run.hs | 10 ++++- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index a903a358a04..5bcf62c148c 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -39,11 +39,19 @@ import Polysemy.Input (input) import Wire.API.Jobs import Wire.JobSubsystem (CleanupAction, JobSubsystem (..), JobSubsystemConfig (..), JobWorkerHandlers (..), JobWorkersConfig (..)) import Wire.JobSubsystem.ArbiterAdapter (WireArbiter, WireArbiterEnv (..), runWireArbiter) -import Wire.JobSubsystem.Workers (runOneOffJobRunner, runRecurringJobRunner) +import Wire.JobSubsystem.Workers + ( RecurringJobRunnerConfig (..), + runOneOffJobRunner, + runRecurringJobRunner, + runScheduledJobsMigrations, + ) import Wire.Postgres (PGConstraints) runJobWorkers :: HasqlPoolExt.Pool -> JobWorkersConfig -> JobWorkerHandlers -> IO CleanupAction runJobWorkers pool JobWorkersConfig {..} JobWorkerHandlers {..} = do + runScheduledJobsMigrations + (recurringJobRunnerArbiterConnStr recurringJobRunnerConfig) + (recurringJobRunnerSchemaName recurringJobRunnerConfig) cleanupRecurring <- runRecurringJobRunner @ScheduledJobsRegistry pool recurringJobRunnerConfig recurringJobRunnerRunJob cleanupDeletion <- runOneOffJobRunner @ScheduledJobsRegistry pool adminlessDeletionJobRunnerConfig adminlessDeletionJobRunnerRunJob cleanupReminder <- runOneOffJobRunner @ScheduledJobsRegistry pool adminlessReminderJobRunnerConfig adminlessReminderJobRunnerRunJob diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 82b352df089..ed965275539 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -23,6 +23,7 @@ module Wire.JobSubsystem.Workers ( RecurringJobRunnerConfig (..), OneOffJobRunnerConfig (..), + runScheduledJobsMigrations, runRecurringJobRunner, runOneOffJobRunner, ) @@ -35,6 +36,7 @@ import Arbiter.Migrations qualified as ArbiterMigrations import Arbiter.Worker qualified as ArbiterWorker import Arbiter.Worker.Config qualified as ArbiterWorkerConfig import Arbiter.Worker.Cron qualified as ArbiterWorkerCron +import Control.Exception (throwIO) import Data.Aeson (FromJSON, ToJSON) import Data.Kind (Type) import Data.Proxy (Proxy (..)) @@ -46,9 +48,10 @@ import GHC.TypeLits (KnownSymbol) import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports import System.Cron (CronSchedule, serializeCronSchedule) +import System.IO.Error (userError) import System.Logger qualified as Log import UnliftIO.Async qualified as Async -import Wire.API.Jobs (MeetingsCleanupJob (..)) +import Wire.API.Jobs (MeetingsCleanupJob (..), ScheduledJobsRegistry) import Wire.JobSubsystem.ArbiterAdapter (WireArbiter, WireArbiterEnv (..), runWireArbiter) data RecurringJobRunnerConfig registry = RecurringJobRunnerConfig @@ -74,6 +77,22 @@ data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig oneOffJobRunnerQueueName :: Text } +-- | Apply all migrations for the scheduled-jobs registry before constructing +-- any worker pools or accepting scheduled jobs. +runScheduledJobsMigrations :: SecretText -> Text -> IO () +runScheduledJobsMigrations connStr schemaName = do + result <- + ArbiterMigrations.runMigrationsForRegistry + (Proxy @ScheduledJobsRegistry) + (Text.encodeUtf8 $ revealSecretText connStr) + schemaName + ArbiterMigrations.defaultMigrationConfig + case result of + ArbiterMigrations.MigrationSuccess -> pure () + ArbiterMigrations.MigrationError err -> + throwIO . userError $ + "Arbiter migrations failed for schema " <> T.unpack schemaName <> ": " <> err + -- This runner is specialized to the meetings cleanup payload for now. -- If we add another cron job later, we can either reuse this helper with a -- shared payload type or factor out the common Arbiter setup first. @@ -127,16 +146,6 @@ runRecurringJobRunner postgresPool RecurringJobRunnerConfig {..} runJob = do Left err -> error $ "Invalid cron schedule for " <> T.unpack recurringJobRunnerJobName <> ": " <> err Right job -> job - void $ - -- Arbiter can optionally use LISTEN/NOTIFY to wake workers sooner, but we - -- keep polling as the baseline and treat notifications as a latency - -- optimization rather than a correctness requirement. - ArbiterMigrations.runMigrationsForRegistry - (Proxy @registry) - arbiterConnStr - recurringJobRunnerSchemaName - ArbiterMigrations.defaultMigrationConfig - workerConfig <- ( ArbiterWorker.defaultWorkerConfig arbiterConnStr @@ -199,13 +208,6 @@ runOneOffJobRunner postgresPool OneOffJobRunnerConfig {..} runJob = do . Log.field "queue_name" oneOffJobRunnerQueueName runJob job - void $ - ArbiterMigrations.runMigrationsForRegistry - (Proxy @registry) - arbiterConnStr - oneOffJobRunnerSchemaName - ArbiterMigrations.defaultMigrationConfig - workerConfig <- ( ArbiterWorker.defaultWorkerConfig arbiterConnStr diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index 9ea6b3870ae..67a3b316f33 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -23,6 +23,7 @@ where import AWS.Util (readAuthExpiration) import Amazonka qualified as AWS +import Arbiter.Core qualified as ArbiterCore import Cassandra (runClient, shutdown) import Cassandra.Schema (versionCheck) import Control.Concurrent.Async qualified as Async @@ -45,7 +46,7 @@ import Galley.Cassandra import Galley.Env import Galley.Monad import Galley.Queue qualified as Q -import Hasql.Pool.Extended (rawPool) +import Hasql.Pool.Extended (postgresqlConnectionStringWithPassword, rawPool) import Imports import Network.HTTP.Media.RenderHeader qualified as HTTPMedia import Network.HTTP.Types qualified as HTTP @@ -67,6 +68,7 @@ import Wire.API.Routes.Public.Galley import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import Wire.AWS (awsEnv) +import Wire.JobSubsystem.Workers (runScheduledJobsMigrations) import Wire.OpenTelemetry (withTracerC) import Wire.Options.Galley import Wire.PostgresMigrations (runAllMigrations) @@ -76,6 +78,12 @@ run opts = lowerCodensity do tracer <- withTracerC (app, env) <- mkApp opts lift $ runAllMigrations env._hasqlPool.rawPool env._applog + arbiterConnStr <- + lift $ + postgresqlConnectionStringWithPassword + (opts ^. postgresql) + (opts ^. postgresqlPassword) + lift $ runScheduledJobsMigrations arbiterConnStr ArbiterCore.defaultSchemaName let settings' = newSettings $ defaultServer From b717dfdbc2efd0fa1ccdeed5764f2a12548a43c7 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 10 Jul 2026 14:36:30 +0000 Subject: [PATCH 62/65] remove redundant db query --- libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index f2a4f2e20e0..2c7e41ce082 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -1350,8 +1350,7 @@ adminlessAutopromoteOrSendReminder :: Sem r () adminlessAutopromoteOrSendReminder lusr lcnv deletionScheduledFor = adminlessTryAutopromote lusr lcnv orAlternativelySendReminder where - orAlternativelySendReminder _ = do - conv <- getConversationWithError lcnv + orAlternativelySendReminder conv = do now <- Now.get let event = Event From 864f4217c3d583f239a58579c0c11cf27acde079 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 13 Jul 2026 10:10:17 +0000 Subject: [PATCH 63/65] coordinate arbiter migration runs via advisory locks --- .../src/Wire/JobSubsystem/Workers.hs | 111 ++++++++++++++++-- 1 file changed, 98 insertions(+), 13 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index ed965275539..774f92ec18b 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -36,8 +36,9 @@ import Arbiter.Migrations qualified as ArbiterMigrations import Arbiter.Worker qualified as ArbiterWorker import Arbiter.Worker.Config qualified as ArbiterWorkerConfig import Arbiter.Worker.Cron qualified as ArbiterWorkerCron -import Control.Exception (throwIO) +import Control.Exception (bracket, throwIO) import Data.Aeson (FromJSON, ToJSON) +import Data.Hashable qualified as Hashable import Data.Kind (Type) import Data.Proxy (Proxy (..)) import Data.Secret (SecretText, revealSecretText) @@ -45,11 +46,17 @@ import Data.Text qualified as T import Data.Text.Encoding qualified as Text import Data.Time.Clock (NominalDiffTime) import GHC.TypeLits (KnownSymbol) +import Hasql.Connection qualified as HasqlConnection +import Hasql.Connection.Settings qualified as HasqlConnectionSettings import Hasql.Pool.Extended qualified as HasqlPoolExt +import Hasql.Session qualified as HasqlSession +import Hasql.Statement qualified as HasqlStatement +import Hasql.TH import Imports import System.Cron (CronSchedule, serializeCronSchedule) import System.IO.Error (userError) import System.Logger qualified as Log +import System.Timeout (timeout) import UnliftIO.Async qualified as Async import Wire.API.Jobs (MeetingsCleanupJob (..), ScheduledJobsRegistry) import Wire.JobSubsystem.ArbiterAdapter (WireArbiter, WireArbiterEnv (..), runWireArbiter) @@ -80,18 +87,96 @@ data OneOffJobRunnerConfig registry (payload :: Type) = OneOffJobRunnerConfig -- | Apply all migrations for the scheduled-jobs registry before constructing -- any worker pools or accepting scheduled jobs. runScheduledJobsMigrations :: SecretText -> Text -> IO () -runScheduledJobsMigrations connStr schemaName = do - result <- - ArbiterMigrations.runMigrationsForRegistry - (Proxy @ScheduledJobsRegistry) - (Text.encodeUtf8 $ revealSecretText connStr) - schemaName - ArbiterMigrations.defaultMigrationConfig - case result of - ArbiterMigrations.MigrationSuccess -> pure () - ArbiterMigrations.MigrationError err -> - throwIO . userError $ - "Arbiter migrations failed for schema " <> T.unpack schemaName <> ": " <> err +runScheduledJobsMigrations connStr schemaName = + withArbiterMigrationLock connStr schemaName $ do + result <- + ArbiterMigrations.runMigrationsForRegistry + (Proxy @ScheduledJobsRegistry) + (Text.encodeUtf8 $ revealSecretText connStr) + schemaName + ArbiterMigrations.defaultMigrationConfig + case result of + ArbiterMigrations.MigrationSuccess -> pure () + ArbiterMigrations.MigrationError err -> + throwIO . userError $ + "Arbiter migrations failed for schema " <> T.unpack schemaName <> ": " <> err + +-- | Serialize Arbiter schema migrations across all background-worker instances. +-- +-- Arbiter's migration API opens its own PostgreSQL connection, so the lock is +-- held on a separate dedicated connection for the entire migration call. This +-- is sufficient because every Wire Server migration caller enters through this +-- wrapper. The connection is kept open until the lock is released; otherwise a +-- session-level advisory lock would be released before the migrations finish. +withArbiterMigrationLock :: SecretText -> Text -> IO a -> IO a +withArbiterMigrationLock connStr schemaName action = do + bracket acquireConnection HasqlConnection.release $ \lockConnection -> do + bracket + (acquireArbiterMigrationLockWithTimeout lockConnection) + (const $ runAdvisoryLockStatement lockConnection releaseArbiterMigrationLock) + (const action) + where + lockId :: Int64 + lockId = fromIntegral . Hashable.hash $ ("wire-server:arbiter-migrations:" <> schemaName :: Text) + + acquireArbiterMigrationLockWithTimeout :: HasqlConnection.Connection -> IO () + acquireArbiterMigrationLockWithTimeout connection = do + acquired <- timeout arbiterMigrationLockWaitTimeoutMicros retryUntilAcquired + case acquired of + Just () -> pure () + Nothing -> + throwIO . userError $ + "Timed out waiting for the Arbiter migration lock for schema " <> T.unpack schemaName + where + retryUntilAcquired :: IO () + retryUntilAcquired = do + acquired <- runAdvisoryLockStatement connection tryArbiterMigrationLock + if acquired + then pure () + else do + threadDelay arbiterMigrationLockRetryIntervalMicros + retryUntilAcquired + + -- Do not let a stuck migration block service startup indefinitely. The + -- migration itself is not subject to this timeout once the lock is held. + arbiterMigrationLockRetryIntervalMicros :: Int + arbiterMigrationLockRetryIntervalMicros = 1_000_000 + + arbiterMigrationLockWaitTimeoutMicros :: Int + arbiterMigrationLockWaitTimeoutMicros = 1 * 60 * 1_000_000 + + acquireConnection :: IO HasqlConnection.Connection + acquireConnection = do + connectionResult <- HasqlConnection.acquire . HasqlConnectionSettings.connectionString $ revealSecretText connStr + either + ( \err -> + throwIO . userError $ + "Failed to acquire PostgreSQL connection for Arbiter migration lock: " <> show err + ) + pure + connectionResult + + runAdvisoryLockStatement :: + HasqlConnection.Connection -> + HasqlStatement.Statement Int64 a -> + IO a + runAdvisoryLockStatement connection statement = do + result <- HasqlConnection.use connection (HasqlSession.statement lockId statement) + either + ( \err -> + throwIO . userError $ + "Arbiter migration advisory lock query failed: " <> show err + ) + pure + result + + tryArbiterMigrationLock :: HasqlStatement.Statement Int64 Bool + tryArbiterMigrationLock = + [singletonStatement|SELECT (pg_try_advisory_lock($1 :: bigint) :: bool)|] + + releaseArbiterMigrationLock :: HasqlStatement.Statement Int64 () + releaseArbiterMigrationLock = + [resultlessStatement|SELECT (1 :: integer) FROM (SELECT pg_advisory_unlock($1 :: bigint))|] -- This runner is specialized to the meetings cleanup payload for now. -- If we add another cron job later, we can either reuse this helper with a From de85b4375534e477f9c4f14c0e42c2745ed5f7d3 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 13 Jul 2026 10:55:30 +0000 Subject: [PATCH 64/65] linter --- libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs index 774f92ec18b..9b322a628dc 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Workers.hs @@ -36,7 +36,7 @@ import Arbiter.Migrations qualified as ArbiterMigrations import Arbiter.Worker qualified as ArbiterWorker import Arbiter.Worker.Config qualified as ArbiterWorkerConfig import Arbiter.Worker.Cron qualified as ArbiterWorkerCron -import Control.Exception (bracket, throwIO) +import Control.Exception (bracket, bracket_, throwIO) import Data.Aeson (FromJSON, ToJSON) import Data.Hashable qualified as Hashable import Data.Kind (Type) @@ -111,10 +111,10 @@ runScheduledJobsMigrations connStr schemaName = withArbiterMigrationLock :: SecretText -> Text -> IO a -> IO a withArbiterMigrationLock connStr schemaName action = do bracket acquireConnection HasqlConnection.release $ \lockConnection -> do - bracket + bracket_ (acquireArbiterMigrationLockWithTimeout lockConnection) - (const $ runAdvisoryLockStatement lockConnection releaseArbiterMigrationLock) - (const action) + (runAdvisoryLockStatement lockConnection releaseArbiterMigrationLock) + action where lockId :: Int64 lockId = fromIntegral . Hashable.hash $ ("wire-server:arbiter-migrations:" <> schemaName :: Text) From aeba23a44915fa9bd9422dab9a1480e5687b5c7d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 13 Jul 2026 16:09:57 +0000 Subject: [PATCH 65/65] removed unused parameter --- services/brig/src/Brig/API/Internal.hs | 3 +-- services/brig/src/Brig/Run.hs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 21762a850e9..6a6f17dbb80 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -143,7 +143,6 @@ import Wire.VerificationCodeGen import Wire.VerificationCodeSubsystem servantSitemap :: - App.Env -> forall r p. ( Member BlockListStore r, Member (Concurrency 'Unsafe) r, @@ -190,7 +189,7 @@ servantSitemap :: Member ClientSubsystem r ) => ServerT BrigIRoutes.API (Handler r) -servantSitemap _env = +servantSitemap = istatusAPI :<|> ejpdAPI :<|> accountAPI diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 0964de7cbcd..6ef4967bcde 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -147,7 +147,7 @@ mkApp opts = do (customFormatters :. localDomain :. Servant.EmptyContext) ( docsAPI :<|> hoistServerWithDomain @BrigAPI (toServantHandler env) servantSitemap - :<|> hoistServerWithDomain @IAPI.API (toServantHandler env) (IAPI.servantSitemap env) + :<|> hoistServerWithDomain @IAPI.API (toServantHandler env) IAPI.servantSitemap :<|> hoistServerWithDomain @FederationAPI (toServantHandler env) federationSitemap :<|> hoistServerWithDomain @VersionAPI (toServantHandler env) versionAPI )