on master help sign in
22 files changed+1259−487
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml+2−2 .github/workflows/hlint.yml+26 .github/workflows/nix.yaml+34 .gitignore+1 .hlint.yaml+2 CHANGELOG.md+4 default.nix+10 examples/OddJobsCliExample.lhs+5−6 flake.lock+44 flake.nix+44 odd-jobs.cabal+46−12 package.yaml+3−2 shell.nix+10 src/OddJobs/Cli.hs+61−52 src/OddJobs/ConfigBuilder.hs+30−22 src/OddJobs/Endpoints.hs+30−17 src/OddJobs/Job.hs+333−122 src/OddJobs/Migrations.hs+62−4 src/OddJobs/Types.hs+50−5 src/OddJobs/Web.hs+77−55 stack.yaml+3−66 test/Test.hs+382−122
.github/workflows/ci.yml+2−2
@@ -14,8 +14,8 @@ jobs:
1414 strategy:
1515 matrix:
1616 os: [ubuntu-latest]
17− stack: ["2.3.3"]
18− ghc: ["8.8.3"]
17+ stack: ["2.11.1"]
18+ ghc: ["8.8.4", "8.10.7", "9.2.8", "9.4.5"]
1919
2020 services:
2121 postgres:
.github/workflows/hlint.ymlnew file+26
@@ -0,0 +1,26 @@
1+name: hlint
2+
3+on:
4+ pull_request:
5+ push:
6+
7+jobs:
8+ build:
9+ name: hlint-3.5
10+ runs-on: ubuntu-latest
11+
12+ steps:
13+ - uses: actions/checkout@v3
14+ with:
15+ submodules: true
16+
17+ - uses: haskell/actions/hlint-setup@v2
18+ name: Set up HLint
19+ with:
20+ version: "3.5"
21+
22+ - uses: haskell/actions/hlint-run@v2
23+ name: hlint
24+ with:
25+ path: ./
26+ fail-on: suggestion
.github/workflows/nix.yamlnew file+34
@@ -0,0 +1,34 @@
1+name: "Nix"
2+on:
3+ pull_request:
4+ push:
5+jobs:
6+ tests:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - uses: actions/checkout@v3
10+ - name: Local cache
11+ uses: actions/cache@v3
12+ with:
13+ path: /nix/store
14+ key: "${{ runner.os }}-nix-cache"
15+ - uses: cachix/install-nix-action@v20
16+ with:
17+ nix_path: nixpkgs=channel:nixos-unstable
18+ extra_nix_config: |
19+ access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
20+
21+ # follow guide: https://nix.dev/tutorials/continuous-integration-github-actions
22+ # this uses the tokens which are revokable
23+ - uses: cachix/cachix-action@v12
24+ with:
25+ name: jappie
26+ # If you chose API tokens for write access OR if you have a private cache
27+ # authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
28+ signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}'
29+ - run: nix build
30+ - run: nix flake check
31+ - run: nix develop -c echo OK
32+ # legacy actions
33+ - run: nix-build
34+ - run: nix-shell --run "echo OK"
.gitignore+1
@@ -2,3 +2,4 @@ stack.yaml.lock
22test.log
33TAGS
44.stack-work/
5+dist-newstyle/
.hlint.yamlnew file+2
@@ -0,0 +1,2 @@
1+# Warnings currently triggered by your code
2+- ignore: {name: "Use newtype instead of data"} # 1 hint ignored because newtypes can't have strictness annotations.
CHANGELOG.md+4
@@ -1,5 +1,9 @@
11# Changelog
22
3+## 0.2.3
4++ Upgrade resource pool
5++ Add ability to kill jobs
6+
37## 0.2.2
48
59- Bugfix in the `deleteJobQuery` function
default.nixnew file+10
@@ -0,0 +1,10 @@
1+(import
2+ (
3+ let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
4+ fetchTarball {
5+ url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
6+ sha256 = lock.nodes.flake-compat.locked.narHash;
7+ }
8+ )
9+ { src = ./.; }
10+).defaultNix
examples/OddJobsCliExample.lhs+5−6
@@ -18,7 +18,6 @@ Ideally, this module should be compiled into a separate executable and should de
1818\begin{code}
1919{-# LANGUAGE DeriveAnyClass #-}
2020{-# LANGUAGE DeriveGeneric #-}
21−{-# LANGUAGE NamedFieldPuns #-}
2221{-# LANGUAGE OverloadedStrings #-}
2322{-# LANGUAGE LambdaCase #-}
2423{-# LANGUAGE RecordWildCards #-}
@@ -72,16 +71,16 @@ In this example, the core job-runner function is in the `IO` monad. In all proba
7271\begin{code}
7372myJobRunner :: Job -> IO ()
7473myJobRunner job = do
75− (throwParsePayload job) >>= \case
74+ throwParsePayload job >>= \case
7675 SendWelcomeEmail userId -> do
7776 putStrLn $ "This should call the function that actually sends the welcome email. " <>
7877 "\nWe are purposely waiting 60 seconds before completing this job so that graceful shutdown can be demonstrated."
7978 delaySeconds (Seconds 60)
80− putStrLn "60 second wait is now over..."
81− SendPasswordResetEmail tkn ->
79+ putStrLn $ "SendWelcomeEmail to user: " <> show userId <> " complete (60 second wait is now over...)"
80+ SendPasswordResetEmail _tkn ->
8281 putStrLn "This should call the function that actually sends the password-reset email"
83− SetupSampleData userId -> do
84− Prelude.error "User onboarding is incomplete"
82+ SetupSampleData _userId -> do
83+ _ <- Prelude.error "User onboarding is incomplete"
8584 putStrLn "This should call the function that actually sets up sample data in a newly registered user's account"
8685\end{code}
8786
flake.locknew file+44
@@ -0,0 +1,44 @@
1+{
2+ "nodes": {
3+ "flake-compat": {
4+ "flake": false,
5+ "locked": {
6+ "lastModified": 1673956053,
7+ "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
8+ "owner": "edolstra",
9+ "repo": "flake-compat",
10+ "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
11+ "type": "github"
12+ },
13+ "original": {
14+ "owner": "edolstra",
15+ "repo": "flake-compat",
16+ "type": "github"
17+ }
18+ },
19+ "nixpkgs": {
20+ "locked": {
21+ "lastModified": 1688556768,
22+ "narHash": "sha256-mhd6g0iJGjEfOr3+6mZZOclUveeNr64OwxdbNtLc8mY=",
23+ "owner": "nixos",
24+ "repo": "nixpkgs",
25+ "rev": "27bd67e55fe09f9d68c77ff151c3e44c4f81f7de",
26+ "type": "github"
27+ },
28+ "original": {
29+ "owner": "nixos",
30+ "ref": "nixpkgs-unstable",
31+ "repo": "nixpkgs",
32+ "type": "github"
33+ }
34+ },
35+ "root": {
36+ "inputs": {
37+ "flake-compat": "flake-compat",
38+ "nixpkgs": "nixpkgs"
39+ }
40+ }
41+ },
42+ "root": "root",
43+ "version": 7
44+}
flake.nixnew file+44
@@ -0,0 +1,44 @@
1+# I used chatgpt to generate this template and then just
2+# modified to how I normally use these things.
3+{
4+ description = "odd jobs";
5+
6+ inputs = {
7+ nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
8+ flake-compat = {
9+ url = "github:edolstra/flake-compat";
10+ flake = false;
11+ };
12+ };
13+
14+ outputs = { self, nixpkgs, flake-compat }:
15+ let
16+ pkgs = nixpkgs.legacyPackages.x86_64-linux;
17+ hpkgs = pkgs.haskellPackages.override {
18+ overrides = hnew: hold: {
19+ odd-jobs = pkgs.haskell.lib.dontCheck # TOOD need to figure out how to make it call nixosTest end setup a postgres
20+ (hnew.callCabal2nix "odd-jobs" ./. { });
21+ servant-static-th = pkgs.haskell.lib.dontCheck (pkgs.haskell.lib.markUnbroken hold.servant-static-th);
22+ resource-pool = hnew.callHackageDirect {
23+ pkg = "resource-pool";
24+ ver = "0.4.0.0";
25+ sha256 = "sha256-X3VI1LnkyB28ZumRzOFUNG1UIJiW1UH63ZW/CPqPln4=";
26+ } {};
27+ };
28+ };
29+ in
30+ {
31+ defaultPackage.x86_64-linux = hpkgs.odd-jobs;
32+ inherit pkgs;
33+ devShell.x86_64-linux = hpkgs.shellFor {
34+ packages = ps : [ ps."odd-jobs" ];
35+ withHoogle = true;
36+
37+ buildInputs = [
38+ hpkgs.haskell-language-server
39+ pkgs.ghcid
40+ pkgs.cabal-install
41+ ];
42+ };
43+ };
44+}
odd-jobs.cabal+46−12
@@ -1,13 +1,13 @@
11cabal-version: 1.12
22
3−-- This file has been generated from package.yaml by hpack version 0.33.0.
3+-- This file has been generated from package.yaml by hpack version 0.35.2.
44--
55-- see: https://github.com/sol/hpack
66--
7−-- hash: 81dabf5fa65d737ec98a4f07762968283cccc36292941ff7fff10a9a398e84b0
7+-- hash: dff1ced317b40e54688799203ec3743b119bf71f2fc98109a5e79f12c6e10fba
88
99name: odd-jobs
10−version: 0.2.2
10+version: 0.2.3
1111synopsis: A full-featured PostgreSQL-backed job queue (with an admin UI)
1212description: - Background jobs library for Haskell.
1313 - Extracted from production code at [Vacation Labs](https://www.vacationlabs.com).
@@ -61,19 +61,28 @@ library
6161 Paths_odd_jobs
6262 hs-source-dirs:
6363 src
64− default-extensions: NamedFieldPuns LambdaCase TemplateHaskell ScopedTypeVariables GeneralizedNewtypeDeriving QuasiQuotes OverloadedStrings
64+ default-extensions:
65+ NamedFieldPuns
66+ LambdaCase
67+ TemplateHaskell
68+ ScopedTypeVariables
69+ GeneralizedNewtypeDeriving
70+ QuasiQuotes
71+ OverloadedStrings
6572 ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields -Werror=incomplete-patterns
6673 build-depends:
6774 aeson
75+ , async ==2.2.4
6876 , base >=4.7 && <5
6977 , bytestring
70− , daemons
78+ , containers
7179 , directory
7280 , either
7381 , fast-logger
7482 , filepath
7583 , friendly-time
7684 , generic-deriving
85+ , hdaemonize
7786 , hostname
7887 , lucid
7988 , monad-control
@@ -81,7 +90,7 @@ library
8190 , mtl
8291 , optparse-applicative
8392 , postgresql-simple
84− , resource-pool >= 0.2.0.0 && < 0.4.0.0
93+ , resource-pool >= 0.4.0.0 && < 0.5.0.0
8594 , safe
8695 , servant
8796 , servant-lucid
@@ -115,13 +124,21 @@ executable devel
115124 hs-source-dirs:
116125 dev
117126 src
118− default-extensions: NamedFieldPuns LambdaCase TemplateHaskell ScopedTypeVariables GeneralizedNewtypeDeriving QuasiQuotes OverloadedStrings
127+ default-extensions:
128+ NamedFieldPuns
129+ LambdaCase
130+ TemplateHaskell
131+ ScopedTypeVariables
132+ GeneralizedNewtypeDeriving
133+ QuasiQuotes
134+ OverloadedStrings
119135 ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields -Werror=incomplete-patterns -threaded -with-rtsopts=-N -main-is DevelMain
120136 build-depends:
121137 aeson
138+ , async ==2.2.4
122139 , base >=4.7 && <5
123140 , bytestring
124− , daemons
141+ , containers
125142 , directory
126143 , either
127144 , fast-logger
@@ -129,6 +146,7 @@ executable devel
129146 , foreign-store
130147 , friendly-time
131148 , generic-deriving
149+ , hdaemonize
132150 , hostname
133151 , lucid
134152 , monad-control
@@ -162,19 +180,27 @@ executable odd-jobs-cli-example
162180 Paths_odd_jobs
163181 hs-source-dirs:
164182 examples
165− default-extensions: NamedFieldPuns LambdaCase TemplateHaskell ScopedTypeVariables GeneralizedNewtypeDeriving QuasiQuotes OverloadedStrings
183+ default-extensions:
184+ NamedFieldPuns
185+ LambdaCase
186+ TemplateHaskell
187+ ScopedTypeVariables
188+ GeneralizedNewtypeDeriving
189+ QuasiQuotes
190+ OverloadedStrings
166191 ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields -Werror=incomplete-patterns -threaded -with-rtsopts=-N -main-is OddJobsCliExample
167192 build-depends:
168193 aeson
194+ , async ==2.2.4
169195 , base >=4.7 && <5
170196 , bytestring
171− , daemons
172197 , directory
173198 , either
174199 , fast-logger
175200 , filepath
176201 , friendly-time
177202 , generic-deriving
203+ , hdaemonize
178204 , hostname
179205 , lucid
180206 , monad-control
@@ -219,20 +245,28 @@ test-suite jobrunner
219245 hs-source-dirs:
220246 test
221247 src
222− default-extensions: NamedFieldPuns LambdaCase TemplateHaskell ScopedTypeVariables GeneralizedNewtypeDeriving QuasiQuotes OverloadedStrings
248+ default-extensions:
249+ NamedFieldPuns
250+ LambdaCase
251+ TemplateHaskell
252+ ScopedTypeVariables
253+ GeneralizedNewtypeDeriving
254+ QuasiQuotes
255+ OverloadedStrings
223256 ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields -Werror=incomplete-patterns -threaded -with-rtsopts=-N -main-is Test
224257 build-depends:
225258 aeson
259+ , async ==2.2.4
226260 , base >=4.7 && <5
227261 , bytestring
228262 , containers
229− , daemons
230263 , directory
231264 , either
232265 , fast-logger
233266 , filepath
234267 , friendly-time
235268 , generic-deriving
269+ , hdaemonize
236270 , hedgehog
237271 , hostname
238272 , lifted-async
package.yaml+3−2
@@ -58,6 +58,8 @@ default-extensions:
5858
5959dependencies:
6060 - base >= 4.7 && < 5
61+ - async ==2.2.4
62+ - hdaemonize
6163 - text
6264 - postgresql-simple
6365 - bytestring
@@ -83,9 +85,9 @@ dependencies:
8385 - servant-server
8486 - servant-lucid
8587 - warp
88+ - containers
8689 - unordered-containers
8790 - optparse-applicative
88− - daemons
8991 - filepath
9092 - directory
9193 - generic-deriving
@@ -148,4 +150,3 @@ tests:
148150 - mmorph
149151 - lifted-base
150152 - lifted-async
151− - containers
shell.nixnew file+10
@@ -0,0 +1,10 @@
1+(import
2+ (
3+ let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
4+ fetchTarball {
5+ url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
6+ sha256 = lock.nodes.flake-compat.locked.narHash;
7+ }
8+ )
9+ { src = ./.; }
10+).shellNix
src/OddJobs/Cli.hs+61−52
@@ -5,18 +5,20 @@
55module OddJobs.Cli where
66
77import Options.Applicative as Opts
8+import Control.Concurrent.Async (race)
9+import Control.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar, tryTakeMVar)
10+import Data.Coerce (coerce)
11+import Data.Functor (void)
812import Data.Text
9−import OddJobs.Job (startJobRunner, Config(..))
10−import OddJobs.Types (UIConfig(..))
11−import OddJobs.Job(LogLevel(..), LogEvent(..))
12−-- import System.Daemonize (DaemonOptions(..), daemonize)
13−import qualified System.Posix.Daemon as Daemon
14−import System.FilePath (FilePath)
13+import OddJobs.Job (startJobRunner, Config(..), LogLevel(..), LogEvent(..))
14+import OddJobs.Types (UIConfig(..), Seconds(..), delaySeconds)
15+import qualified System.Posix.Daemonize as Daemonize
16+import System.FilePath (FilePath, takeBaseName, takeDirectory)
1517import System.Posix.Process (getProcessID)
18+import System.Posix.Signals (Handler(CatchOnce), installHandler, sigTERM)
1619import qualified System.Directory as Dir
1720import qualified System.Exit as Exit
1821import System.Environment (getProgName)
19−import OddJobs.Types (Seconds(..), delaySeconds)
2022import qualified System.Posix.Signals as Sig
2123import qualified UnliftIO.Async as Async
2224import UnliftIO (bracket_)
@@ -103,6 +105,33 @@ runCli cliType = do
103105 Status ->
104106 Prelude.error "not implemented yet"
105107
108+-- | Ensure that @SIGTERM@ is handled gracefully, because it's how containers are stopped.
109+--
110+-- @action@ will receive an 'AsyncCancelled' exception if @SIGTERM@ is received by the process.
111+--
112+-- Typical use:
113+--
114+-- > main :: IO ()
115+-- > main = withGracefulTermination_ $ do
116+--
117+-- Note that although the Haskell runtime handles @SIGINT@ it doesn't do anything with @SIGTERM@.
118+-- Therefore, the program will be killed immediately and no cleanup will be performed. In particular,
119+-- exception handlers in `bracket`, `finally`, `onException`, etc. won't be run. However, if the
120+-- program is running as PID 1 in a container, @SIGTERM@ will be ignored and the program will keep
121+-- running. This will likely result in a @SIGKILL@ being sent a short while later and cleanup still
122+-- won't be performed.
123+withGracefulTermination :: IO a -> IO (Maybe a)
124+withGracefulTermination ioAction = do
125+ var <- newEmptyMVar
126+ let terminate = void $ tryPutMVar var ()
127+ waitForTermination = takeMVar var
128+ void $ installHandler sigTERM (CatchOnce terminate) Nothing
129+ either (const Nothing) Just <$> race waitForTermination ioAction
130+
131+-- | Like 'withGracefulTermination' but ignoring the return value
132+withGracefulTermination_ :: IO a -> IO ()
133+withGracefulTermination_ = void . withGracefulTermination
134+
106135{-| Used by 'defaultMain' if the 'Start' command is issued via the CLI. If
107136@--daemonize@ switch is also passed, it checks for 'startPidFile':
108137
@@ -117,11 +146,15 @@ defaultStartCommand :: CommonStartArgs
117146 -> CliType
118147 -> IO ()
119148defaultStartCommand CommonStartArgs{..} mUIArgs cliType = do
120− case startDaemonize of
121− False ->
122− coreStartupFn
123− True -> do
124− Daemon.runDetached (Just startPidFile) (Daemon.ToFile "/tmp/oddjobs.out") coreStartupFn
149+ if startDaemonize then do
150+ Daemonize.serviced
151+ $ Daemonize.simpleDaemon
152+ { Daemonize.program = \() -> withGracefulTermination_ coreStartupFn
153+ , Daemonize.name = Just $ takeBaseName startPidFile
154+ , Daemonize.pidfileDirectory = Just $ takeDirectory startPidFile
155+ }
156+ else
157+ coreStartupFn
125158 where
126159 uiArgs = fromJustNote "Please specify Web UI Startup Args" $ traceShowId mUIArgs
127160 coreStartupFn =
@@ -146,7 +179,7 @@ defaultWebUI UIStartArgs{..} uicfg@UIConfig{..} = do
146179 case uistartAuth of
147180 AuthNone -> do
148181 let app = UI.server uicfg env Prelude.id
149− uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> (toS $ show uistartPort)
182+ uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> toS (show uistartPort)
150183 Warp.run uistartPort $ Servant.serve (Proxy :: Proxy UI.FinalAPI) app
151184 (AuthBasic u p) -> do
152185 let api = Proxy :: Proxy (BasicAuth "OddJobs Admin UI" OddJobsUser :> UI.FinalAPI)
@@ -154,11 +187,11 @@ defaultWebUI UIStartArgs{..} uicfg@UIConfig{..} = do
154187 -- Now the app will receive an extra argument for OddJobsUser,
155188 -- which we aren't really interested in.
156189 app _ = UI.server uicfg env Prelude.id
157− uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> (toS $ show uistartPort)
190+ uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> toS (show uistartPort)
158191 Warp.run uistartPort $ Servant.serveWithContext api ctx app
159192
160193{-| Used by 'defaultMain' if 'Stop' command is issued via the CLI. Sends a
161−@SIGINT@ signal to the process indicated by 'shutPidFile'. Waits for a maximum
194+@SIGTERM@ signal to the process indicated by 'shutPidFile'. Waits for a maximum
162195of 'shutTimeout' seconds (controller by @--timeout@) for the daemon to shutdown
163196gracefully, after which a @SIGKILL@ is issued
164197-}
@@ -166,27 +199,15 @@ defaultStopCommand :: StopArgs
166199 -> IO ()
167200defaultStopCommand StopArgs{..} = do
168201 progName <- getProgName
169− -- pid <- read <$> (readFile shutPidFile)
170− if (shutTimeout == Seconds 0)
171− then Daemon.brutalKill shutPidFile
172− else do putStrLn $ "Sending sigINT to " <> show progName <>
173− " and waiting " <> (show $ unSeconds shutTimeout) <> " seconds for graceful stop"
174− readFile shutPidFile >>= (pure . read) >>= Sig.signalProcess Sig.sigINT
175− (Async.race (delaySeconds shutTimeout) checkProcessStatus) >>= \case
176− Right _ -> do
177− putStrLn $ progName <> " seems to have exited gracefully."
178− Exit.exitSuccess
179− Left _ -> do
180− putStrLn $ progName <> " has still not exited. Sending sigKILL for forced exit."
181− Daemon.brutalKill shutPidFile
182− where
183− checkProcessStatus = do
184− Daemon.isRunning shutPidFile >>= \case
185− True -> do
186− delaySeconds (Seconds 1)
187− checkProcessStatus
188− False -> do
189− pure ()
202+ putStrLn $ "Sending SIGTERM to " <> show progName <>
203+ " and waiting " <> show (unSeconds shutTimeout) <> " seconds for graceful stop"
204+ flip Daemonize.serviced' Daemonize.Stop
205+ $ Daemonize.simpleDaemon
206+ { Daemonize.name = Just $ takeBaseName shutPidFile
207+ , Daemonize.pidfileDirectory = Just $ takeDirectory shutPidFile
208+ , Daemonize.killWait = Just $ coerce shutTimeout
209+ }
210+
190211
191212-- * Default CLI parsers
192213--
@@ -207,7 +228,7 @@ data Args = Args
207228
208229-- | The top-level command-line parser
209230argParser :: CliType -> Parser Args
210−argParser cliType = Args <$> (commandParser cliType)
231+argParser cliType = Args <$> commandParser cliType
211232
212233-- ** Top-level command parser
213234
@@ -264,7 +285,7 @@ startCmdParser cliType = Start
264285 <*> (case cliType of
265286 CliOnlyJobRunner _ -> pure Nothing
266287 CliOnlyWebUi _ -> Just <$> uiStartArgsParser
267− CliBoth _ _ -> (Just <$> uiStartArgsParser) <|> (pure Nothing)
288+ CliBoth _ _ -> optional uiStartArgsParser
268289 )
269290
270291data WebUiAuth
@@ -301,7 +322,7 @@ webUiAuthParser = basicAuthParser <|> noAuthParser
301322-- 'shutTimeout' seconds. If the daemon doesn't shut down cleanly within that
302323-- time, it sends a @SIGKILL@ to kill immediately.
303324data StopArgs = StopArgs
304− { -- | After sending a @SIGINT@, how many seconds to wait before sending a
325+ { -- | After sending a @SIGTERM@, how many seconds to wait before sending a
305326 -- @SIGKILL@
306327 shutTimeout :: !Seconds
307328 -- | PID file of the deamon. Ref: 'pidFileParser'
@@ -313,8 +334,6 @@ stopParser = fmap Stop $ StopArgs
313334 <$> option (Seconds <$> auto) ( long "timeout" <>
314335 metavar "TIMEOUT" <>
315336 help "Maximum seconds to wait before force-killing the background daemon."
316− -- value defaultTimeout <>
317− -- showDefaultWith (show . unSeconds)
318337 )
319338 <*> pidFileParser
320339
@@ -345,17 +364,7 @@ defaultCliParserPrefs = prefs $
345364
346365defaultCliInfo :: CliType -> ParserInfo Args
347366defaultCliInfo cliType =
348− info ((argParser cliType) <**> helper) fullDesc
349−
350−-- defaultDaemonOptions :: DaemonOptions
351−-- defaultDaemonOptions = DaemonOptions
352−-- { daemonShouldChangeDirectory = False
353−-- , daemonShouldCloseStandardStreams = False
354−-- , daemonShouldIgnoreSignals = True
355−-- , daemonUserToChangeTo = Nothing
356−-- , daemonGroupToChangeTo = Nothing
357−-- }
358−
367+ info (argParser cliType <**> helper) fullDesc
359368
360369-- ** Auth implementations for the default Web UI
361370
src/OddJobs/ConfigBuilder.hs+30−22
@@ -66,15 +66,15 @@ mkConfig :: (LogLevel -> LogEvent -> IO ())
6666mkConfig logger tname dbpool ccControl jrunner configOverridesFn =
6767 let cfg = configOverridesFn $ Config
6868 { cfgPollingInterval = defaultPollingInterval
69− , cfgOnJobSuccess = (const $ pure ())
69+ , cfgOnJobSuccess = const $ pure ()
7070 , cfgOnJobFailed = []
7171 , cfgJobRunner = jrunner
7272 , cfgLogger = logger
7373 , cfgDbPool = dbpool
74− , cfgOnJobStart = (const $ pure ())
74+ , cfgOnJobStart = const $ pure ()
7575 , cfgDefaultMaxAttempts = 10
7676 , cfgTableName = tname
77− , cfgOnJobTimeout = (const $ pure ())
77+ , cfgOnJobTimeout = const $ pure ()
7878 , cfgConcurrencyControl = ccControl
7979 , cfgJobType = defaultJobType
8080 , cfgDefaultJobTimeout = Seconds 600
@@ -111,7 +111,7 @@ mkUIConfig logger tname dbpool configOverridesFn =
111111 , uicfgTableName = tname
112112 , uicfgJobType = defaultJobType
113113 , uicfgJobToHtml = defaultJobsToHtml (uicfgJobType cfg)
114− , uicfgAllJobTypes = (defaultDynamicJobTypes (uicfgTableName cfg) (uicfgJobTypeSql cfg))
114+ , uicfgAllJobTypes = defaultDynamicJobTypes (uicfgTableName cfg) (uicfgJobTypeSql cfg)
115115 , uicfgJobTypeSql = defaultJobTypeSql
116116 }
117117 in cfg
@@ -126,10 +126,10 @@ defaultLogStr :: (Job -> Text)
126126 -> LogEvent
127127 -> LogStr
128128defaultLogStr jobTypeFn logLevel logEvent =
129− (toLogStr $ show logLevel) <> " | " <> str
129+ toLogStr (show logLevel) <> " | " <> str
130130 where
131131 jobToLogStr job@Job{jobId} =
132− "JobId=" <> (toLogStr $ show jobId) <> " JobType=" <> (toLogStr $ jobTypeFn job)
132+ "JobId=" <> toLogStr (show jobId) <> " JobType=" <> toLogStr (jobTypeFn job)
133133
134134 str = case logEvent of
135135 LogJobStart j ->
@@ -138,12 +138,16 @@ defaultLogStr jobTypeFn logLevel logEvent =
138138 let tag = case fm of
139139 FailWithRetry -> "Failed (retry)"
140140 FailPermanent -> "Failed (permanent)"
141− in tag <> " | " <> jobToLogStr j <> " | runtime=" <> (toLogStr $ show t) <> " | error=" <> (toLogStr $ show e)
141+ in tag <> " | " <> jobToLogStr j <> " | runtime=" <> toLogStr (show t) <> " | error=" <> toLogStr (show e)
142142 LogJobSuccess j t ->
143− "Success | " <> (jobToLogStr j) <> " | runtime=" <> (toLogStr $ show t)
143+ "Success | " <> jobToLogStr j <> " | runtime=" <> toLogStr (show t)
144144 LogJobTimeout j@Job{jobLockedAt, jobLockedBy} ->
145− "Timeout | " <> jobToLogStr j <> " | lockedBy=" <> (toLogStr $ maybe "unknown" unJobRunnerName jobLockedBy) <>
146− " lockedAt=" <> (toLogStr $ maybe "unknown" show jobLockedAt)
145+ "Timeout | " <> jobToLogStr j <> " | lockedBy=" <> toLogStr (maybe "unknown" unJobRunnerName jobLockedBy) <>
146+ " lockedAt=" <> toLogStr (maybe "unknown" show jobLockedAt)
147+ LogKillJobSuccess j ->
148+ "Kill Job Success | " <> jobToLogStr j
149+ LogKillJobFailed j ->
150+ "Kill Job Failed | " <> jobToLogStr j <> "(the job might have completed or timed out)"
147151 LogPoll ->
148152 "Polling jobs table"
149153 LogWebUIRequest ->
@@ -194,9 +198,7 @@ defaultErrorToHtml e =
194198
195199defaultJobContent :: Value -> Value
196200defaultJobContent v = case v of
197− Aeson.Object o -> case HM.lookup "contents" o of
198− Nothing -> v
199− Just c -> c
201+ Aeson.Object o -> fromMaybe v (HM.lookup "contents" o)
200202 _ -> v
201203
202204defaultPayloadToHtml :: Value -> Html ()
@@ -232,7 +234,7 @@ defaultDynamicJobTypes :: TableName
232234 -> PGS.Query
233235 -> AllJobTypes
234236defaultDynamicJobTypes tname jobTypeSql = AJTSql $ \conn -> do
235− fmap (DL.map ((fromMaybe "(unknown)") . fromOnly)) $ PGS.query conn ("select distinct(" <> jobTypeSql <> ") from ? order by 1 nulls last") (Only tname)
237+ DL.map (fromMaybe "(unknown)" . fromOnly) <$> PGS.query conn ("select distinct(" <> jobTypeSql <> ") from ? order by 1 nulls last") (Only tname)
236238
237239-- | This makes __two important assumptions__. First, this /assumes/ that jobs
238240-- in your app are represented by a sum-type. For example:
@@ -289,9 +291,9 @@ withConnectionPool connConfig action = withRunInIO $ \runInIO -> do
289291 poolCreator = liftIO $
290292 case connConfig of
291293 Left connString ->
292− createPool (PGS.connectPostgreSQL connString) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
294+ newPool $ defaultPoolConfig (PGS.connectPostgreSQL connString) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
293295 Right connInfo ->
294− createPool (PGS.connect connInfo) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
296+ newPool $ defaultPoolConfig (PGS.connect connInfo) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
295297
296298-- | A convenience function to help you define a timed-logger with some sensible
297299-- defaults.
@@ -303,8 +305,8 @@ defaultTimedLogger :: FLogger.TimedFastLogger
303305defaultTimedLogger logger logStrFn logLevel logEvent =
304306 if logLevel == LevelDebug
305307 then pure ()
306− else logger $ \t -> (toLogStr t) <> " | " <>
307− (logStrFn logLevel logEvent) <>
308+ else logger $ \t -> toLogStr t <> " | " <>
309+ logStrFn logLevel logEvent <>
308310 "\n"
309311
310312
@@ -313,7 +315,7 @@ defaultJsonLogEvent logEvent =
313315 case logEvent of
314316 LogJobStart job ->
315317 Aeson.object [ "tag" Aeson..= ("LogJobStart" :: Text)
316− , "contents" Aeson..= (defaultJsonJob job) ]
318+ , "contents" Aeson..= defaultJsonJob job ]
317319 LogJobSuccess job runTime ->
318320 Aeson.object [ "tag" Aeson..= ("LogJobSuccess" :: Text)
319321 , "contents" Aeson..= (defaultJsonJob job, runTime) ]
@@ -322,7 +324,13 @@ defaultJsonLogEvent logEvent =
322324 , "contents" Aeson..= (defaultJsonJob job, show e, defaultJsonFailureMode fm, runTime) ]
323325 LogJobTimeout job ->
324326 Aeson.object [ "tag" Aeson..= ("LogJobTimeout" :: Text)
325− , "contents" Aeson..= (defaultJsonJob job) ]
327+ , "contents" Aeson..= defaultJsonJob job ]
328+ LogKillJobSuccess job ->
329+ Aeson.object [ "tag" Aeson..= ("LogKillJobSuccess" :: Text)
330+ , "contents" Aeson..= defaultJsonJob job ]
331+ LogKillJobFailed job ->
332+ Aeson.object [ "tag" Aeson..= ("LogKillJobFailed" :: Text)
333+ , "contents" Aeson..= defaultJsonJob job ]
326334 LogPoll ->
327335 Aeson.object [ "tag" Aeson..= ("LogJobPoll" :: Text)]
328336 LogWebUIRequest ->
@@ -332,7 +340,7 @@ defaultJsonLogEvent logEvent =
332340 , "contents" Aeson..= t ]
333341
334342defaultJsonJob :: Job -> Aeson.Value
335−defaultJsonJob job = genericToJSON Aeson.defaultOptions job
343+defaultJsonJob = genericToJSON Aeson.defaultOptions
336344
337345defaultJsonFailureMode :: FailureMode -> Aeson.Value
338−defaultJsonFailureMode fm = genericToJSON Aeson.defaultOptions fm
346+defaultJsonFailureMode = genericToJSON Aeson.defaultOptions
src/OddJobs/Endpoints.hs+30−17
@@ -45,6 +45,7 @@ import UnliftIO.IORef
4545import Debug.Trace
4646import qualified OddJobs.ConfigBuilder as Builder
4747import Servant.Static.TH (createApiAndServerDecs)
48+import Control.Monad (void)
4849
4950-- startApp :: IO ()
5051-- startApp = undefined
@@ -59,13 +60,14 @@ data Routes route = Routes
5960 , rEnqueue :: route :- "enqueue" :> Capture "jobId" JobId :> Post '[HTML] NoContent
6061 , rRunNow :: route :- "run" :> Capture "jobId" JobId :> Post '[HTML] NoContent
6162 , rCancel :: route :- "cancel" :> Capture "jobId" JobId :> Post '[HTML] NoContent
63+ , rKill :: route :- "kill" :> Capture "jobId" JobId :> Post '[HTML] NoContent
6264 , rRefreshJobTypes :: route :- "refresh-job-types" :> Post '[HTML] NoContent
6365 , rRefreshJobRunners :: route :- "refresh-job-runners" :> Post '[HTML] NoContent
6466 } deriving (Generic)
6567
6668
6769type FinalAPI =
68− (ToServant Routes AsApi) :<|>
70+ ToServant Routes AsApi :<|>
6971 "assets" :> StaticAssetRoutes
7072
7173data Env = Env
@@ -75,7 +77,7 @@ data Env = Env
7577 }
7678
7779mkEnv :: (MonadIO m) => UIConfig -> (Text -> Text) -> m Env
78−mkEnv cfg@UIConfig{..} linksFn = do
80+mkEnv cfg@UIConfig{} linksFn = do
7981 allJobTypes <- fetchAllJobTypes cfg
8082 allJobRunners <- fetchAllJobRunners cfg
8183 envJobTypesRef <- newIORef allJobTypes
@@ -106,14 +108,15 @@ server :: forall m . (MonadIO m)
106108 -> (forall a . Handler a -> m a)
107109 -> ServerT FinalAPI m
108110server cfg env nt =
109− (toServant routeServer) :<|> staticAssetServer
111+ toServant routeServer :<|> staticAssetServer
110112 where
111113 routeServer :: Routes (AsServerT m)
112114 routeServer = Routes
113− { rFilterResults = nt . (filterResults cfg env)
114− , rEnqueue = nt . (enqueueJob cfg env)
115− , rCancel = nt . (cancelJob cfg env)
116− , rRunNow = nt . (runJobNow cfg env)
115+ { rFilterResults = nt . filterResults cfg env
116+ , rEnqueue = nt . enqueueJob cfg env
117+ , rCancel = nt . cancelJob cfg env
118+ , rKill = nt . killJob cfg env
119+ , rRunNow = nt . runJobNow cfg env
117120 , rRefreshJobTypes = nt $ refreshJobTypes cfg env
118121 , rRefreshJobRunners = nt $ refreshJobRunners cfg env
119122 }
@@ -122,10 +125,11 @@ server2 :: UIConfig
122125 -> Env
123126 -> Routes AsServer
124127server2 cfg env = Routes
125− { rFilterResults = (filterResults cfg env)
126− , rEnqueue = (enqueueJob cfg env)
127− , rCancel = (cancelJob cfg env)
128− , rRunNow = (runJobNow cfg env)
128+ { rFilterResults = filterResults cfg env
129+ , rEnqueue = enqueueJob cfg env
130+ , rCancel = cancelJob cfg env
131+ , rKill = killJob cfg env
132+ , rRunNow = runJobNow cfg env
129133 , rRefreshJobTypes = refreshJobTypes cfg env
130134 , rRefreshJobRunners = refreshJobRunners cfg env
131135 }
@@ -134,9 +138,9 @@ server2 cfg env = Routes
134138refreshJobRunners :: UIConfig
135139 -> Env
136140 -> Handler NoContent
137−refreshJobRunners cfg@UIConfig{..} Env{envRoutes=Web.Routes{..}, envJobRunnersRef} = do
141+refreshJobRunners cfg@UIConfig{} Env{envRoutes=Web.Routes{..}, envJobRunnersRef} = do
138142 allJobRunners <- fetchAllJobRunners cfg
139− atomicModifyIORef' envJobRunnersRef (\_ -> (allJobRunners, ()))
143+ atomicModifyIORef' envJobRunnersRef (const (allJobRunners, ()))
140144 throwError $ err302{errHeaders=[("Location", toS $ rFilterResults Nothing)]}
141145
142146refreshJobTypes :: UIConfig
@@ -144,7 +148,7 @@ refreshJobTypes :: UIConfig
144148 -> Handler NoContent
145149refreshJobTypes cfg Env{envRoutes=Web.Routes{..}, envJobTypesRef} = do
146150 allJobTypes <- fetchAllJobTypes cfg
147− atomicModifyIORef' envJobTypesRef (\_ -> (allJobTypes, ()))
151+ atomicModifyIORef' envJobTypesRef (const (allJobTypes, ()))
148152 throwError $ err302{errHeaders=[("Location", toS $ rFilterResults Nothing)]}
149153
150154cancelJob :: UIConfig
@@ -155,6 +159,14 @@ cancelJob UIConfig{..} env jid = do
155159 liftIO $ withResource uicfgDbPool $ \conn -> void $ cancelJobIO conn uicfgTableName jid
156160 redirectToHome env
157161
162+killJob :: UIConfig
163+ -> Env
164+ -> JobId
165+ -> Handler NoContent
166+killJob UIConfig{..} env jid = do
167+ liftIO $ withResource uicfgDbPool $ \conn -> void $ killJobIO conn uicfgTableName jid
168+ redirectToHome env
169+
158170runJobNow :: UIConfig
159171 -> Env
160172 -> JobId
@@ -185,10 +197,10 @@ filterResults :: UIConfig
185197filterResults cfg@UIConfig{uicfgJobToHtml, uicfgDbPool} Env{..} mFilter = do
186198 let filters = fromMaybe mempty mFilter
187199 (jobs, runningCount) <- liftIO $ Pool.withResource uicfgDbPool $ \conn -> (,)
188− <$> (filterJobs cfg conn filters)
189− <*> (countJobs cfg conn filters{ filterStatuses = [Job.Locked] })
200+ <$> filterJobs cfg conn filters
201+ <*> countJobs cfg conn filters{ filterStatuses = [Job.Locked] }
190202 t <- liftIO getCurrentTime
191− js <- liftIO $ fmap (DL.zip jobs) $ uicfgJobToHtml jobs
203+ js <- liftIO (DL.zip jobs <$> uicfgJobToHtml jobs)
192204 allJobTypes <- readIORef envJobTypesRef
193205 let navHtml = Web.sideNav envRoutes allJobTypes [] t filters
194206 bodyHtml = Web.resultsPanel envRoutes t filters js runningCount
@@ -200,6 +212,7 @@ routes linkFn = Web.Routes
200212 , Web.rEnqueue = rEnqueue
201213 , Web.rRunNow = rRunNow
202214 , Web.rCancel = rCancel
215+ , Web.rKill = rKill
203216 , Web.rRefreshJobTypes = rRefreshJobTypes
204217 , Web.rRefreshJobRunners = rRefreshJobRunners
205218 , Web.rStaticAsset = linkFn
src/OddJobs/Job.hs+333−122
@@ -1,6 +1,5 @@
1−{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, TupleSections, DeriveGeneric, UndecidableInstances #-}
2−{-# LANGUAGE ExistentialQuantification #-}
3−{-# LANGUAGE ScopedTypeVariables #-}
1+{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, UndecidableInstances #-}
2+{-# LANGUAGE ExistentialQuantification, RecordWildCards, ScopedTypeVariables #-}
43
54module OddJobs.Job
65 (
@@ -14,6 +13,7 @@ module OddJobs.Job
1413 -- $config
1514 , Config(..)
1615 , ConcurrencyControl(..)
16+ , ResourceCfg(..)
1717
1818 -- * Creating/scheduling jobs
1919 --
@@ -21,6 +21,9 @@ module OddJobs.Job
2121 , createJob
2222 , scheduleJob
2323
24+ , createJobWithResources
25+ , scheduleJobWithResources
26+
2427 -- * @Job@ and associated data-types
2528 --
2629 -- $dataTypes
@@ -33,6 +36,9 @@ module OddJobs.Job
3336 , Seconds(..)
3437 , JobErrHandler(..)
3538 , AllJobTypes(..)
39+ , ResourceId(..)
40+ , FunctionName
41+ , RunnerEnv(..)
3642
3743 -- ** Structured logging
3844 --
@@ -47,6 +53,7 @@ module OddJobs.Job
4753 , jobEventListener
4854 , jobPoller
4955 , jobPollingSql
56+ , pollRunJob
5057 , JobRunner
5158 , HasJobRunner (..)
5259
@@ -58,6 +65,7 @@ module OddJobs.Job
5865 , runJobNowIO
5966 , unlockJobIO
6067 , cancelJobIO
68+ , killJobIO
6169 , jobDbColumns
6270 , jobPollingIO
6371 , concatJobDbColumns
@@ -80,7 +88,7 @@ import Data.Pool(Pool)
8088import Data.Text as T
8189import Database.PostgreSQL.Simple as PGS
8290import Database.PostgreSQL.Simple.Notification
83−import UnliftIO.Async
91+import UnliftIO.Async hiding (poll)
8492import UnliftIO.Concurrent (threadDelay, myThreadId)
8593import Data.String
8694import System.Posix.Process (getProcessID)
@@ -91,7 +99,8 @@ import Control.Monad.Logger as MLogger (LogLevel(..), LogStr, toLogStr)
9199import UnliftIO.IORef
92100import UnliftIO.Exception ( SomeException(..), try, catch, finally
93101 , catchAny, bracket, Exception(..), throwIO
94− , catches, Handler(..), mask_, throwString
102+ , catches, Handler(..), mask_, onException
103+ , throwString
95104 )
96105import Data.Proxy
97106import Control.Monad.Trans.Control
@@ -102,14 +111,16 @@ import Data.Aeson hiding (Success)
102111import qualified Data.Aeson as Aeson
103112import qualified Data.Aeson.Types as Aeson (Parser, parseMaybe)
104113import Data.String.Conv (StringConv(..), toS)
105−import Data.Functor (void)
106−import Control.Monad (forever)
114+import Data.Functor ((<&>), void)
115+import Control.Monad (forever, forM_, join)
107116import Data.Maybe (isNothing, maybe, fromMaybe, listToMaybe, mapMaybe)
108117import Data.Either (either)
109118import Control.Monad.Reader
110119import GHC.Generics
120+import Data.Map (Map)
111121import qualified Data.HashMap.Strict as HM
112122import qualified Data.List as DL
123+import qualified Data.Map as DM
113124import qualified Data.ByteString as BS
114125import qualified Data.ByteString.Lazy as BSL
115126import System.FilePath (FilePath)
@@ -143,6 +154,7 @@ class (MonadUnliftIO m, MonadBaseControl IO m) => HasJobRunner m where
143154 getConcurrencyControl :: m ConcurrencyControl
144155 log :: LogLevel -> LogEvent -> m ()
145156 getDefaultJobTimeout :: m Seconds
157+ onJobTimeout :: Job -> m ()
146158 getDefaultRetryBackoff :: Int -> m Seconds
147159
148160-- $logging
@@ -160,7 +172,7 @@ class (MonadUnliftIO m, MonadBaseControl IO m) => HasJobRunner m where
160172
161173data RunnerEnv = RunnerEnv
162174 { envConfig :: !Config
163− , envJobThreadsRef :: !(IORef [Async ()])
175+ , envJobThreadsRef :: !(IORef (Map JobId (Async ())))
164176 }
165177
166178type RunnerM = ReaderT RunnerEnv IO
@@ -169,34 +181,37 @@ logCallbackErrors :: (HasJobRunner m) => JobId -> Text -> m () -> m ()
169181logCallbackErrors jid msg action = catchAny action $ \e -> log LevelError $ LogText $ msg <> " Job ID=" <> toS (show jid) <> ": " <> toS (show e)
170182
171183instance HasJobRunner RunnerM where
172− getPollingInterval = cfgPollingInterval . envConfig <$> ask
173− onJobFailed = do { env <- ask; pure (cfgOnJobFailed (envConfig env)) }
184+ getPollingInterval = asks (cfgPollingInterval . envConfig)
185+ onJobFailed = asks (cfgOnJobFailed . envConfig)
174186 onJobSuccess job = do
175− fn <- cfgOnJobSuccess . envConfig <$> ask
187+ fn <- asks (cfgOnJobSuccess . envConfig)
176188 logCallbackErrors (jobId job) "onJobSuccess" $ liftIO $ fn job
177− deleteSuccessfulJobs = cfgDeleteSuccessfulJobs . envConfig <$> ask
189+ deleteSuccessfulJobs = asks (cfgDeleteSuccessfulJobs . envConfig)
178190
179− getJobRunner = cfgJobRunner . envConfig <$> ask
180− getDbPool = cfgDbPool . envConfig <$> ask
181− getTableName = cfgTableName . envConfig <$> ask
191+ getJobRunner = asks (cfgJobRunner . envConfig)
192+ getDbPool = asks (cfgDbPool . envConfig)
193+ getTableName = asks (cfgTableName . envConfig)
182194 onJobStart job = do
183− fn <- cfgOnJobStart . envConfig <$> ask
195+ fn <- asks (cfgOnJobStart . envConfig)
184196 logCallbackErrors (jobId job) "onJobStart" $ liftIO $ fn job
185197
186− getDefaultMaxAttempts = cfgDefaultMaxAttempts . envConfig <$> ask
198+ getDefaultMaxAttempts = asks (cfgDefaultMaxAttempts . envConfig)
187199
188200 getRunnerEnv = ask
189201
190− getConcurrencyControl = (cfgConcurrencyControl . envConfig <$> ask)
202+ getConcurrencyControl = asks (cfgConcurrencyControl . envConfig)
191203
192204 log logLevel logEvent = do
193− loggerFn <- cfgLogger . envConfig <$> ask
205+ loggerFn <- asks (cfgLogger . envConfig)
194206 liftIO $ loggerFn logLevel logEvent
195207
196− getDefaultJobTimeout = cfgDefaultJobTimeout . envConfig <$> ask
208+ getDefaultJobTimeout = asks (cfgDefaultJobTimeout . envConfig)
209+ onJobTimeout job = do
210+ fn <- asks (cfgOnJobTimeout . envConfig)
211+ logCallbackErrors (jobId job) "onJobTimeout" $ liftIO $ fn job
197212
198213 getDefaultRetryBackoff attempts = do
199− retryFn <- cfgDefaultRetryBackoff . envConfig <$> ask
214+ retryFn <- asks (cfgDefaultRetryBackoff . envConfig)
200215 liftIO $ retryFn attempts
201216
202217-- | Start the job-runner in the /current/ thread, i.e. you'll need to use
@@ -206,7 +221,7 @@ instance HasJobRunner RunnerM where
206221startJobRunner :: Config -> IO ()
207222startJobRunner jm = do
208223 traceM "startJobRunner top"
209− r <- newIORef []
224+ r <- newIORef DM.empty
210225 let monitorEnv = RunnerEnv
211226 { envConfig = jm
212227 , envJobThreadsRef = r
@@ -218,7 +233,7 @@ jobWorkerName :: IO String
218233jobWorkerName = do
219234 pid <- getProcessID
220235 hname <- getHostName
221− pure $ hname ++ ":" ++ (show pid)
236+ pure $ hname ++ ":" ++ show pid
222237
223238-- | If you are writing SQL queries where you want to return ALL columns from
224239-- the jobs table it is __recommended__ that you do not issue a @SELECT *@ or
@@ -249,7 +264,7 @@ concatJobDbColumns :: (IsString s, Semigroup s) => s
249264concatJobDbColumns = concatJobDbColumns_ jobDbColumns ""
250265 where
251266 concatJobDbColumns_ [] x = x
252− concatJobDbColumns_ (col:[]) x = x <> col
267+ concatJobDbColumns_ [col] x = x <> col
253268 concatJobDbColumns_ (col:cols) x = concatJobDbColumns_ cols (x <> col <> ", ")
254269
255270
@@ -290,7 +305,7 @@ findJobByIdIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
290305findJobByIdIO conn tname jid = PGS.query conn findJobByIdQuery (tname, jid) >>= \case
291306 [] -> pure Nothing
292307 [j] -> pure (Just j)
293− js -> Prelude.error $ "Not expecting to find multiple jobs by id=" <> (show jid)
308+ _js -> Prelude.error $ "Not expecting to find multiple jobs by id=" <> show jid
294309
295310
296311saveJobQuery :: PGS.Query
@@ -307,7 +322,7 @@ saveJob j = do
307322saveJobIO :: Connection -> TableName -> Job -> IO Job
308323saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttempts, jobLockedBy, jobLockedAt, jobId} = do
309324 rs <- PGS.query conn saveJobQuery
310− ( tname
325+ ( tname
311326 , jobRunAt
312327 , jobStatus
313328 , jobPayload
@@ -318,9 +333,9 @@ saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttem
318333 , jobId
319334 )
320335 case rs of
321− [] -> Prelude.error $ "Could not find job while updating it id=" <> (show jobId)
336+ [] -> Prelude.error $ "Could not find job while updating it id=" <> show jobId
322337 [j] -> pure j
323− js -> Prelude.error $ "Not expecting multiple rows to ber returned when updating job id=" <> (show jobId)
338+ _js -> Prelude.error $ "Not expecting multiple rows to ber returned when updating job id=" <> show jobId
324339
325340deleteJob :: (HasJobRunner m) => JobId -> m ()
326341deleteJob jid = do
@@ -334,26 +349,30 @@ deleteJobIO conn tname jid = do
334349runJobNowIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
335350runJobNowIO conn tname jid = do
336351 t <- getCurrentTime
337− updateJobHelper tname conn (Queued, [Queued, Retry, Failed], Just t, jid)
352+ updateJobHelper tname conn (Queued, [Queued, Retry, Failed, Cancelled], Just t, jid)
338353
339354-- | TODO: First check in all job-runners if this job is still running, or not,
340355-- and somehow send an uninterruptibleCancel to that thread.
341356unlockJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
342357unlockJobIO conn tname jid = do
343− fmap listToMaybe $ PGS.query conn q (tname, Retry, jid, In [Locked])
358+ listToMaybe <$> PGS.query conn q (tname, Retry, jid, In [Locked])
344359 where
345360 q = "update ? set status=?, run_at=now(), locked_at=null, locked_by=null where id=? and status in ? returning " <> concatJobDbColumns
346361
347362cancelJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
348363cancelJobIO conn tname jid =
349− updateJobHelper tname conn (Failed, [Queued, Retry], Nothing, jid)
364+ updateJobHelper tname conn (Cancelled, [Queued, Retry], Nothing, jid)
365+
366+killJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
367+killJobIO conn tname jid =
368+ updateJobHelper tname conn (Cancelled, [Locked], Nothing, jid)
350369
351370updateJobHelper :: TableName
352371 -> Connection
353372 -> (Status, [Status], Maybe UTCTime, JobId)
354373 -> IO (Maybe Job)
355374updateJobHelper tname conn (newStatus, existingStates, mRunAt, jid) =
356− fmap listToMaybe $ PGS.query conn q (tname, newStatus, runAt, jid, PGS.In existingStates)
375+ listToMaybe <$> PGS.query conn q (tname, newStatus, runAt, jid, PGS.In existingStates)
357376 where
358377 q = "update ? set attempts=0, status=?, run_at=? where id=? and status in ? returning " <> concatJobDbColumns
359378 runAt = case mRunAt of
@@ -364,39 +383,47 @@ updateJobHelper tname conn (newStatus, existingStates, mRunAt, jid) =
364383data TimeoutException = TimeoutException deriving (Eq, Show)
365384instance Exception TimeoutException
366385
386+-- | this spawns the job on a a seperate thread, and
387+-- also spawns a monitoring thread, whichever finishes
388+-- first kills the other.
367389runJobWithTimeout :: (HasJobRunner m)
368390 => Seconds
369391 -> Job
370392 -> m ()
371−runJobWithTimeout timeoutSec job = do
393+runJobWithTimeout timeoutSec job@Job{jobId} = do
372394 threadsRef <- envJobThreadsRef <$> getRunnerEnv
373395 jobRunner_ <- getJobRunner
374396
375397 a <- async $ liftIO $ jobRunner_ job
376398
377− x <- atomicModifyIORef' threadsRef $ \threads -> (a:threads, DL.map asyncThreadId (a:threads))
399+ _x <- atomicModifyIORef' threadsRef $ \threads ->
400+ ( DM.insert jobId a threads
401+ , DL.map asyncThreadId $ DM.elems $ DM.insert jobId a threads
402+ )
403+
378404 -- liftIO $ putStrLn $ "Threads: " <> show x
379405 log LevelDebug $ LogText $ toS $ "Spawned job in " <> show (asyncThreadId a)
380406
381407 t <- async $ do
382408 delaySeconds timeoutSec
383− uninterruptibleCancel a
384409 throwIO TimeoutException
385410
386411 void $ finally
387412 (waitEitherCancel a t)
388− (atomicModifyIORef' threadsRef $ \threads -> (DL.delete a threads, ()))
413+ (atomicModifyIORef' threadsRef $ \threads -> (DM.delete jobId threads, ()))
389414
390415
416+-- | runs a job, blocks for as long as it's in progress
391417runJob :: (HasJobRunner m) => JobId -> m ()
392418runJob jid = do
393− (findJobById jid) >>= \case
419+ findJobById jid >>= \case
394420 Nothing -> Prelude.error $ "Could not find job id=" <> show jid
395421 Just job -> do
396422 startTime <- liftIO getCurrentTime
397423 lockTimeout <- getDefaultJobTimeout
398424 log LevelInfo $ LogJobStart job
399− (flip catches) [Handler $ timeoutHandler job startTime, Handler $ exceptionHandler job startTime] $ do
425+ flip catch (exceptionHandler job startTime) $ do
426+ onJobStart job
400427 runJobWithTimeout lockTimeout job
401428 endTime <- liftIO getCurrentTime
402429 shouldDeleteJob <- deleteSuccessfulJobs
@@ -408,7 +435,6 @@ runJob jid = do
408435 onJobSuccess newJob
409436 pure ()
410437 where
411− timeoutHandler job startTime (e :: TimeoutException) = retryOrFail (toException e) job startTime
412438 exceptionHandler job startTime (e :: SomeException) = retryOrFail (toException e) job startTime
413439 retryOrFail e job@Job{jobAttempts} startTime = do
414440 endTime <- liftIO getCurrentTime
@@ -422,27 +448,50 @@ runJob jid = do
422448 newJob <- saveJob job{ jobStatus=newStatus
423449 , jobLockedBy=Nothing
424450 , jobLockedAt=Nothing
425− , jobLastError=(Just $ toJSON $ show e) -- TODO: convert errors to json properly
426− , jobRunAt=(addUTCTime (fromIntegral $ unSeconds backoffInSeconds) t)
451+ , jobLastError=Just $ toJSON $ show e -- TODO: convert errors to json properly
452+ , jobRunAt=addUTCTime (fromIntegral $ unSeconds backoffInSeconds) t
427453 }
428454 case fromException e :: Maybe TimeoutException of
429− Nothing -> log logLevel $ LogJobFailed newJob e failureMode runTime
430− Just _ -> log logLevel $ LogJobTimeout newJob
431−
432− let tryHandler (JobErrHandler handler) res = case fromException e of
433− Nothing -> res
434− Just e_ -> void $ handler e_ newJob failureMode
435− handlers <- onJobFailed
436− liftIO $ void $ Prelude.foldr tryHandler (throwIO e) handlers
455+ Nothing -> do
456+ log logLevel $ LogJobFailed newJob e failureMode runTime
457+ let tryHandler (JobErrHandler handler) res = case fromException e of
458+ Nothing -> res
459+ Just e_ -> void $ handler e_ newJob failureMode
460+ handlers <- onJobFailed
461+ liftIO $ void $ Prelude.foldr tryHandler (throwIO e) handlers
462+
463+ Just _ -> do
464+ log logLevel $ LogJobTimeout newJob
465+ onJobTimeout newJob
466+
437467 pure ()
438468
469+killJob :: (HasJobRunner m) => JobId -> m ()
470+killJob jid = do
471+ threadsRef <- envJobThreadsRef <$> getRunnerEnv
472+ threads <- liftIO $ readIORef threadsRef
473+ mJob <- findJobById jid
474+
475+ case (mJob, jid `DM.lookup` threads) of
476+ (Just job, Just thread) -> do
477+ log LevelInfo $ LogKillJobSuccess job
478+ void $ finally
479+ (uninterruptibleCancel thread)
480+ (atomicModifyIORef' threadsRef $ \threads' -> (DM.delete jid threads', ()))
481+
482+ (Just job, Nothing) -> do
483+ log LevelInfo $ LogKillJobFailed job
484+
485+ (Nothing, _) ->
486+ log LevelError $ LogText $ "Unable to find job in db to kill, jobId = " <> toS (show jid)
487+
439488-- TODO: This might have a resource leak.
440489restartUponCrash :: (HasJobRunner m, Show a) => Text -> m a -> m ()
441490restartUponCrash name_ action = do
442491 traceM "restartUponCrash top"
443492 a <- async action
444493 finally (waitCatch a >>= fn) $ do
445− (log LevelInfo $ LogText $ "Received shutdown: " <> toS name_)
494+ log LevelInfo $ LogText $ "Received shutdown: " <> toS name_
446495 cancel a
447496 where
448497 fn x = do
@@ -461,9 +510,11 @@ jobMonitor = do
461510 traceM "jobMonitor top"
462511 a1 <- async $ restartUponCrash "Job poller" jobPoller
463512 a2 <- async $ restartUponCrash "Job event listener" jobEventListener
513+ a3 <- async $ restartUponCrash "Job Kill poller" killJobPoller
464514 traceM "jobMonitor after async"
465− finally (void $ waitAnyCatch [a1, a2]) $ do
515+ finally (void $ waitAnyCatch [a1, a2, a3]) $ do
466516 log LevelInfo (LogText "Stopping jobPoller and jobEventListener threads.")
517+ cancel a3
467518 cancel a2
468519 cancel a1
469520 log LevelInfo (LogText "Waiting for jobs to complete.")
@@ -471,30 +522,57 @@ jobMonitor = do
471522
472523-- | Ref: 'jobPoller'
473524jobPollingSql :: Query
474−jobPollingSql = "update ? set status = ?, locked_at = ?, locked_by = ?, attempts=attempts+1 WHERE id in (select id from ? where (run_at<=? AND ((status in ?) OR (status = ? and locked_at<?))) ORDER BY attempts ASC, run_at ASC LIMIT 1 FOR UPDATE) RETURNING id"
525+jobPollingSql =
526+ "update ? set status = ?, locked_at = ?, locked_by = ?, attempts=attempts+1\
527+ \ WHERE id in (select id from ? where (run_at<=? AND ((status in ?) OR (status = ? and locked_at<?))) \
528+ \ ORDER BY attempts ASC, run_at ASC LIMIT 1 FOR UPDATE) RETURNING id"
529+
530+jobPollingWithResourceSql :: Query
531+jobPollingWithResourceSql =
532+ " UPDATE ? SET status = ?, locked_at = ?, locked_by = ?, attempts = attempts + 1 \
533+ \ WHERE id in (select id from ? where (run_at<=? AND ((status in ?) OR (status = ? and locked_at<?))) \
534+ \ AND ?(id) \
535+ \ ORDER BY attempts ASC, run_at ASC LIMIT 1) \
536+ \ RETURNING id"
537+
538+-- | Ref: 'killJobPoller'
539+killJobPollingSql :: Query
540+killJobPollingSql =
541+ "UPDATE ? SET locked_at = NULL, locked_by = NULL \
542+ \ WHERE id IN (SELECT id FROM ? WHERE status = ? AND locked_by = ? AND locked_at <= ? \
543+ \ ORDER BY locked_at ASC LIMIT 1 FOR UPDATE \
544+ \ ) RETURNING id"
475545
476546waitForJobs :: (HasJobRunner m)
477547 => m ()
478548waitForJobs = do
479− threadsRef <- envJobThreadsRef <$> getRunnerEnv
480− readIORef threadsRef >>= \case
549+ curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef) <&> DM.elems
550+ case curJobs of
481551 [] -> log LevelInfo $ LogText "All job-threads exited"
482552 as -> do
483553 tid <- myThreadId
484554 void $ waitAnyCatch as
485− log LevelDebug $ LogText $ toS $ "Waiting for " <> show (DL.length as) <> " jobs to complete before shutting down. myThreadId=" <> (show tid)
555+ log LevelDebug $ LogText $ toS $ "Waiting for " <> show (DL.length as) <> " jobs to complete before shutting down. myThreadId=" <> show tid
486556 delaySeconds (Seconds 1)
487557 waitForJobs
488558
559+data ConcurrencyAction
560+ = DontPoll
561+ | PollAny
562+ | PollWithResources ResourceCfg
563+
489564getConcurrencyControlFn :: (HasJobRunner m)
490− => m (m Bool)
565+ => m (Connection -> m ConcurrencyAction)
491566getConcurrencyControlFn = getConcurrencyControl >>= \case
492− UnlimitedConcurrentJobs -> pure $ pure True
493− MaxConcurrentJobs maxJobs -> pure $ do
494− curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef)
495− pure $ (DL.length curJobs) < maxJobs
496− DynamicConcurrency fn -> pure $ liftIO fn
567+ UnlimitedConcurrentJobs -> pure $ const $ pure PollAny
568+ MaxConcurrentJobs maxJobs -> pure $ const $ do
569+ curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef) <&> DM.elems
570+ pure $ pollIf $ DL.length curJobs < maxJobs
571+ ResourceLimits resCfg -> pure $ const $ pure $ PollWithResources resCfg
572+ DynamicConcurrency fn -> pure $ const $ pollIf <$> liftIO fn
497573
574+ where
575+ pollIf cond = if cond then PollAny else DontPoll
498576
499577jobPollingIO :: Connection -> String -> TableName -> Seconds -> IO [Only JobId]
500578jobPollingIO pollerDbConn processName tname lockTimeout = do
@@ -506,9 +584,9 @@ jobPollingIO pollerDbConn processName tname lockTimeout = do
506584 , processName
507585 , tname
508586 , t
509− , (In [Queued, Retry])
587+ , In [Queued, Retry]
510588 , Locked
511− , (addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t))
589+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t)
512590
513591-- | Executes 'jobPollingSql' every 'cfgPollingInterval' seconds to pick up jobs
514592-- for execution. Uses @UPDATE@ along with @SELECT...FOR UPDATE@ to efficiently
@@ -527,32 +605,104 @@ jobPollingIO pollerDbConn processName tname lockTimeout = do
527605jobPoller :: (HasJobRunner m) => m ()
528606jobPoller = do
529607 processName <- liftIO jobWorkerName
530− pool <- getDbPool
531− tname <- getTableName
532− lockTimeout <- getDefaultJobTimeout
533− traceM "jobPoller just before log statement"
534608 log LevelInfo $ LogText $ toS $ "Starting the job monitor via DB polling with processName=" <> processName
535609 concurrencyControlFn <- getConcurrencyControlFn
536− withResource pool $ \pollerDbConn -> forever $ concurrencyControlFn >>= \case
537− False -> do
538− log LevelWarn $ LogText $ "NOT polling the job queue due to concurrency control"
539− -- If we can't run any jobs ATM, relax and wait for resources to free up
540− delayAction
541− True -> do
542− nextAction <- mask_ $ do
543− log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
544− r <- liftIO $ jobPollingIO pollerDbConn processName tname lockTimeout
545− case r of
546− -- When we don't have any jobs to run, we can relax a bit...
547− [] -> pure delayAction
548−
549− -- When we find a job to run, fork and try to find the next job without any delay...
550− [Only (jid :: JobId)] -> do
551− void $ async $ runJob jid
610+ pool <- getDbPool
611+ forever $ do
612+ concurencyPolicy <- withResource pool concurrencyControlFn
613+ case concurencyPolicy of
614+ DontPoll -> log LevelWarn $ LogText "NOT polling the job queue due to concurrency control"
615+ PollAny -> void $ pollRunJob processName Nothing
616+ PollWithResources resCfg -> void $ pollRunJob processName (Just resCfg)
617+
618+-- | Polls a job and runs it, or executes a delay action if no job was found
619+--
620+-- returns an async to the task that was started, allowing you
621+-- to block until it's finished
622+pollRunJob :: (HasJobRunner m) => String -> Maybe ResourceCfg -> m (Maybe (Async ()))
623+pollRunJob processName mResCfg = do
624+ tname <- getTableName
625+ -- note it's better to use fine grained pool connection,
626+ -- since data.pool already has it's internal resource cache.
627+ -- this way the user can configure how long a connection
628+ -- needs to remain open.
629+ pool <- getDbPool
630+ lockTimeout <- getDefaultJobTimeout
631+ join $ withResource pool $ \pollerDbConn -> mask_ $ do
632+ log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
633+ t <- liftIO getCurrentTime
634+ r <- case mResCfg of
635+ Nothing -> liftIO $
636+ PGS.query pollerDbConn jobPollingSql
637+ ( tname
638+ , Locked
639+ , t
640+ , processName
641+ , tname
642+ , t
643+ , In [Queued, Retry]
644+ , Locked
645+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t)
646+ Just ResourceCfg{..} -> liftIO $
647+ PGS.query pollerDbConn jobPollingWithResourceSql
648+ ( tname
649+ , Locked
650+ , t
651+ , processName
652+ , tname
653+ , t
654+ , In [Queued, Retry]
655+ , Locked
656+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t
657+ , resCfgCheckResourceFunction
658+ )
659+ case r of
660+ -- When we don't have any jobs to run, we can relax a bit...
661+ [] -> pure (Nothing <$ delayAction)
662+
663+ -- When we find a job to run, fork and try to find the next job without any delay...
664+ [Only (jid :: JobId)] -> do
665+ x <- async $ runJob jid
666+ pure $ Just x <$ noDelayAction
667+
668+ x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ show x
669+ where
670+ delayAction = delaySeconds =<< getPollingInterval
671+ noDelayAction = pure ()
672+
673+-- | Executes 'killJobPollingSql' every 'cfgPollingInterval' seconds to pick up jobs
674+-- that are cancelled and need to be killed. Uses @UPDATE@ along with @SELECT...
675+-- ..FOR UPDATE@ to efficiently find a job that matches /all/ of the following
676+-- conditions:
677+--
678+-- * 'jobStatus' should be 'cancelled'
679+-- * 'jobLockedAt' should be in the past
680+-- * 'jobLockedBy' should be the current job worker name
681+
682+killJobPoller :: (HasJobRunner m) => m ()
683+killJobPoller = do
684+ processName <- liftIO jobWorkerName
685+ pool <- getDbPool
686+ tname <- getTableName
687+
688+ let pollJobToKill conn = join $ mask_ $ do
689+ currentTime <- liftIO getCurrentTime
690+ result <- liftIO $ PGS.query conn killJobPollingSql
691+ (tname, tname, Cancelled, processName, currentTime)
692+
693+ case result of
694+ [] ->
695+ pure delayAction
696+
697+ [Only (jobId :: JobId)] -> do
698+ void $ async $ killJob jobId
552699 pure noDelayAction
553700
554− x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ (show x)
555− nextAction
701+ x ->
702+ error $ "I was supposed to get only a single row, but got: " ++ show x
703+
704+ withResource pool (forever . pollJobToKill)
705+
556706 where
557707 delayAction = delaySeconds =<< getPollingInterval
558708 noDelayAction = pure ()
@@ -568,43 +718,56 @@ jobEventListener = do
568718 jwName <- liftIO jobWorkerName
569719 concurrencyControlFn <- getConcurrencyControlFn
570720
571− let tryLockingJob jid = do
721+ let tryLockingJob jid mResCfg = withDbConnection $ \conn -> do
572722 let q = "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 WHERE id=? AND status in ? RETURNING id"
573− (withDbConnection $ \conn -> (liftIO $ PGS.query conn q (tname, Locked, jwName, jid, In [Queued, Retry]))) >>= \case
723+ qWithResources =
724+ "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 \
725+ \ WHERE id=? AND status in ? AND ?(id) RETURNING id"
726+ result <- case mResCfg of
727+ Nothing -> liftIO $ PGS.query conn q (tname, Locked, jwName, jid, In [Queued, Retry])
728+ Just ResourceCfg{..} -> liftIO $ PGS.query conn qWithResources
729+ (tname, Locked, jwName, jid, In [Queued, Retry], resCfgCheckResourceFunction)
730+
731+ case result of
574732 [] -> do
575733 log LevelDebug $ LogText $ toS $ "Job was locked by someone else before I could start. Skipping it. JobId=" <> show jid
576734 pure Nothing
577735 [Only (_ :: JobId)] -> pure $ Just jid
578− x -> error $ "WTF just happned? Was expecting a single row to be returned, received " ++ (show x)
736+ x -> error $ "WTF just happned? Was expecting a single row to be returned, received " ++ show x
579737
580738 withResource pool $ \monitorDbConn -> do
581− void $ liftIO $ PGS.execute monitorDbConn ("LISTEN ?") (Only $ pgEventName tname)
739+ void $ liftIO $ PGS.execute monitorDbConn "LISTEN ?" (Only $ pgEventName tname)
582740 forever $ do
583741 log LevelDebug $ LogText "[LISTEN/NOTIFY] Event loop"
584742 notif <- liftIO $ getNotification monitorDbConn
585− concurrencyControlFn >>= \case
586− False -> log LevelWarn $ LogText "Received job event, but ignoring it due to concurrency control"
587− True -> do
588− let pload = notificationData notif
589− log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
590− case (eitherDecode $ toS pload) of
591− Left e -> log LevelError $ LogText $ toS $ "Unable to decode notification payload received from Postgres. Payload=" <> show pload <> " Error=" <> show e
592−
593− -- Checking if job needs to be fired immediately AND it is not already
594− -- taken by some othe thread, by the time it got to us
595− Right (v :: Value) -> case (Aeson.parseMaybe parser v) of
596− Nothing -> log LevelError $ LogText $ toS $ "Unable to extract id/run_at/locked_at from " <> show pload
597− Just (jid, runAt_, mLockedAt_) -> do
598− t <- liftIO getCurrentTime
599− if (runAt_ <= t) && (isNothing mLockedAt_)
600− then do log LevelDebug $ LogText $ toS $ "Job needs needs to be run immediately. Attempting to fork in background. JobId=" <> show jid
601− void $ async $ do
602− -- Let's try to lock the job first... it is possible that it has already
603− -- been picked up by the poller by the time we get here.
604− tryLockingJob jid >>= \case
605− Nothing -> pure ()
606− Just lockedJid -> runJob lockedJid
607− else log LevelDebug $ LogText $ toS $ "Job is either for future, or is already locked. Skipping. JobId=" <> show jid
743+
744+ let pload = notificationData notif
745+ runNotifWithFilter :: HasJobRunner m => Maybe ResourceCfg -> m ()
746+ runNotifWithFilter mResCfg = do
747+ log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
748+ case eitherDecode $ toS pload of
749+ Left e -> log LevelError $ LogText $ toS $ "Unable to decode notification payload received from Postgres. Payload=" <> show pload <> " Error=" <> show e
750+
751+ -- Checking if job needs to be fired immediately AND it is not already
752+ -- taken by some othe thread, by the time it got to us
753+ Right (v :: Value) -> case Aeson.parseMaybe parser v of
754+ Nothing -> log LevelError $ LogText $ toS $ "Unable to extract id/run_at/locked_at from " <> show pload
755+ Just (jid, runAt_, mLockedAt_) -> do
756+ t <- liftIO getCurrentTime
757+ if (runAt_ <= t) && isNothing mLockedAt_
758+ then do log LevelDebug $ LogText $ toS $ "Job needs needs to be run immediately. Attempting to fork in background. JobId=" <> show jid
759+ void $ async $ do
760+ -- Let's try to lock the job first... it is possible that it has already
761+ -- been picked up by the poller by the time we get here.
762+ tryLockingJob jid mResCfg >>= \case
763+ Nothing -> pure ()
764+ Just lockedJid -> runJob lockedJid
765+ else log LevelDebug $ LogText $ toS $ "Job is either for future, is already locked, or would violate concurrency constraints. Skipping. JobId=" <> show jid
766+
767+ concurrencyControlFn monitorDbConn >>= \case
768+ DontPoll -> log LevelWarn $ LogText "Received job event, but ignoring it due to concurrency control"
769+ PollAny -> runNotifWithFilter Nothing
770+ PollWithResources resCfg -> runNotifWithFilter (Just resCfg)
608771 where
609772 parser :: Value -> Aeson.Parser (JobId, UTCTime, Maybe UTCTime)
610773 parser = withObject "expecting an object to parse job.run_at and job.locked_at" $ \o -> do
@@ -618,6 +781,12 @@ jobEventListener = do
618781createJobQuery :: PGS.Query
619782createJobQuery = "INSERT INTO ? (run_at, status, payload, last_error, attempts, locked_at, locked_by) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING " <> concatJobDbColumns
620783
784+ensureResource :: PGS.Query
785+ensureResource = "INSERT INTO ? (id, usage_limit) VALUES (?, ?) ON CONFLICT DO NOTHING"
786+
787+registerResourceUsage :: PGS.Query
788+registerResourceUsage = "INSERT INTO ? (job_id, resource_id, usage) VALUES (?, ?, ?)"
789+
621790-- $createJobs
622791--
623792-- Ideally you'd want to create wrappers for 'createJob' and 'scheduleJob' in
@@ -656,13 +825,56 @@ scheduleJob :: ToJSON p
656825 -> IO Job
657826scheduleJob conn tname payload runAt = do
658827 let args = ( tname, runAt, Queued, toJSON payload, Nothing :: Maybe Value, 0 :: Int, Nothing :: Maybe Text, Nothing :: Maybe Text )
659− queryFormatter = toS <$> (PGS.formatQuery conn createJobQuery args)
828+ queryFormatter = toS <$> PGS.formatQuery conn createJobQuery args
660829 rs <- PGS.query conn createJobQuery args
661830 case rs of
662− [] -> (Prelude.error . (<> "Not expecting a blank result set when creating a job. Query=")) <$> queryFormatter
831+ [] -> Prelude.error . (<> "Not expecting a blank result set when creating a job. Query=") <$> queryFormatter
832+ [r] -> pure r
833+ _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
834+
835+type ResourceList = [(ResourceId, Int)]
836+
837+createJobWithResources
838+ :: ToJSON p
839+ => Connection
840+ -> TableName
841+ -> ResourceCfg
842+ -> p
843+ -> ResourceList
844+ -> IO Job
845+createJobWithResources conn tname resCfg payload resources = do
846+ t <- getCurrentTime
847+ scheduleJobWithResources conn tname resCfg payload resources t
848+
849+scheduleJobWithResources
850+ :: ToJSON p
851+ => Connection
852+ -> TableName
853+ -> ResourceCfg
854+ -> p
855+ -> ResourceList
856+ -> UTCTime
857+ -> IO Job
858+scheduleJobWithResources conn tname ResourceCfg{..} payload resources runAt = do
859+ -- We insert everything in a single transaction to delay @NOTIFY@ calls,
860+ -- so a job isn't picked up before its resources are inserted.
861+ PGS.begin conn
862+ let args = ( tname, runAt, Queued, toJSON payload, Nothing :: Maybe Value, 0 :: Int, Nothing :: Maybe Text, Nothing :: Maybe Text )
863+ queryFormatter = toS <$> PGS.formatQuery conn createJobQuery args
864+ rs <- PGS.query conn createJobQuery args
865+
866+ job <- flip onException (PGS.rollback conn) $ case rs of
867+ [] -> Prelude.error . (<> "Not expecting a blank result set when creating a job. Query=") <$> queryFormatter
663868 [r] -> pure r
664− _ -> (Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=")) <$> queryFormatter
869+ _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
870+
871+ forM_ resources $ \(resourceId, usage) -> do
872+ void $ PGS.execute conn ensureResource (resCfgResourceTable, rawResourceId resourceId, resCfgDefaultLimit)
873+ void $ PGS.execute conn registerResourceUsage (resCfgUsageTable, jobId job, rawResourceId resourceId, usage)
665874
875+ PGS.commit conn
876+
877+ pure job
666878
667879-- getRunnerEnv :: (HasJobRunner m) => m RunnerEnv
668880-- getRunnerEnv = ask
@@ -670,14 +882,14 @@ scheduleJob conn tname payload runAt = do
670882eitherParsePayload :: (FromJSON a)
671883 => Job
672884 -> Either String a
673−eitherParsePayload job =
674− eitherParsePayloadWith parseJSON job
885+eitherParsePayload =
886+ eitherParsePayloadWith parseJSON
675887
676888throwParsePayload :: (FromJSON a)
677889 => Job
678890 -> IO a
679−throwParsePayload job =
680− throwParsePayloadWith parseJSON job
891+throwParsePayload =
892+ throwParsePayloadWith parseJSON
681893
682894eitherParsePayloadWith :: (Aeson.Value -> Aeson.Parser a)
683895 -> Job
@@ -692,7 +904,7 @@ throwParsePayloadWith :: (Aeson.Value -> Aeson.Parser a)
692904 -> Job
693905 -> IO a
694906throwParsePayloadWith parser job =
695− either throwString (pure . Prelude.id) (eitherParsePayloadWith parser job)
907+ either throwString pure (eitherParsePayloadWith parser job)
696908
697909
698910-- | Used by the web\/admin UI to fetch a \"master list\" of all known
@@ -716,5 +928,4 @@ fetchAllJobRunners :: (MonadIO m)
716928 => UIConfig
717929 -> m [JobRunnerName]
718930fetchAllJobRunners UIConfig{uicfgTableName, uicfgDbPool} = liftIO $ withResource uicfgDbPool $ \conn -> do
719− fmap (mapMaybe fromOnly) $ PGS.query conn "select distinct locked_by from ?" (Only uicfgTableName)
720−
931+ mapMaybe fromOnly <$> PGS.query conn "select distinct locked_by from ?" (Only uicfgTableName)
src/OddJobs/Migrations.hs+62−4
@@ -1,3 +1,4 @@
1+{-# LANGUAGE RecordWildCards #-}
12module OddJobs.Migrations
23 ( module OddJobs.Migrations
34 , module OddJobs.Types
@@ -21,7 +22,9 @@ createJobTableQuery = "CREATE TABLE IF NOT EXISTS ?" <>
2122 ", attempts int not null default 0" <>
2223 ", locked_at timestamp with time zone null" <>
2324 ", locked_by text null" <>
24− ", constraint incorrect_locking_info CHECK ((status <> 'locked' and locked_at is null and locked_by is null) or (status = 'locked' and locked_at is not null and locked_by is not null))" <>
25+ ", constraint incorrect_locking_info CHECK (" <>
26+ "(locked_at is null and locked_by is null and status <> 'locked') or " <>
27+ "(locked_at is not null and locked_by is not null and (status = 'locked' or status = 'cancelled')))" <>
2528 ");" <>
2629 "create index if not exists ? on ?(created_at);" <>
2730 "create index if not exists ? on ?(updated_at);" <>
@@ -44,7 +47,7 @@ createNotificationTrigger = "create or replace function ?() returns trigger as $
4447createJobTable :: Connection -> TableName -> IO ()
4548createJobTable conn tname = void $ do
4649 let tnameTxt = getTnameTxt tname
47− PGS.execute conn createJobTableQuery
50+ _ <- PGS.execute conn createJobTableQuery
4851 ( tname
4952 , PGS.Identifier $ "idx_" <> tnameTxt <> "_created_at"
5053 , tname
@@ -69,6 +72,61 @@ createJobTable conn tname = void $ do
6972 , fnName
7073 )
7174 where
72− fnName = PGS.Identifier $ "notify_job_monitor_for_" <> (getTnameTxt tname)
73− trgName = PGS.Identifier $ "trg_notify_job_monitor_for_" <> (getTnameTxt tname)
75+ fnName = PGS.Identifier $ "notify_job_monitor_for_" <> getTnameTxt tname
76+ trgName = PGS.Identifier $ "trg_notify_job_monitor_for_" <> getTnameTxt tname
77+ getTnameTxt (PGS.QualifiedIdentifier _ tname') = tname'
78+
79+createResourceTableQuery :: Query
80+createResourceTableQuery = "CREATE TABLE IF NOT EXISTS ?" <>
81+ "( id text primary key" <>
82+ ", usage_limit int not null" <>
83+ ")";
84+
85+createUsageTableQuery :: Query
86+createUsageTableQuery = "CREATE TABLE IF NOT EXISTS ?" <>
87+ "( job_id serial not null REFERENCES ? ON DELETE CASCADE" <>
88+ ", resource_id text not null REFERENCES ? ON DELETE CASCADE" <>
89+ ", usage int not null" <>
90+ ", PRIMARY KEY (job_id, resource_id)" <>
91+ ");"
92+
93+createUsageFunction :: Query
94+createUsageFunction = "CREATE OR REPLACE FUNCTION ?(resourceId text) RETURNS int as $$" <>
95+ " SELECT coalesce(sum(usage), 0) FROM ? AS j INNER JOIN ? AS jr ON j.id = jr.job_id " <>
96+ " WHERE jr.resource_id = $1 AND j.status = ? " <>
97+ " $$ LANGUAGE SQL;"
98+
99+createCheckResourceFunction :: Query
100+createCheckResourceFunction = "CREATE OR REPLACE FUNCTION ?(jobId int) RETURNS bool as $$" <>
101+ " SELECT coalesce(bool_and(?(resource.id) + job_resource.usage <= resource.usage_limit), true) FROM " <>
102+ " ? AS job_resource INNER JOIN ? AS resource ON job_resource.resource_id = resource.id " <>
103+ " WHERE job_resource.job_id = $1" <>
104+ " $$ LANGUAGE SQL;"
105+
106+createResourceTables
107+ :: Connection
108+ -> TableName -- ^ Name of the jobs table
109+ -> ResourceCfg
110+ -> IO ()
111+createResourceTables conn jobTableName ResourceCfg{..} = do
112+ void $ PGS.execute conn createResourceTableQuery (PGS.Only resCfgResourceTable)
113+ void $ PGS.execute conn createUsageTableQuery
114+ ( resCfgUsageTable
115+ , jobTableName
116+ , resCfgResourceTable
117+ )
118+ void $ PGS.execute conn createUsageFunction
119+ ( usageFnName
120+ , jobTableName
121+ , resCfgUsageTable
122+ , Locked
123+ )
124+ void $ PGS.execute conn createCheckResourceFunction
125+ ( resCfgCheckResourceFunction
126+ , usageFnName
127+ , resCfgUsageTable
128+ , resCfgResourceTable
129+ )
130+ where
131+ usageFnName = PGS.Identifier $ "calculate_usage_for_resource_from_" <> getTnameTxt resCfgUsageTable
74132 getTnameTxt (PGS.QualifiedIdentifier _ tname') = tname'
src/OddJobs/Types.hs+50−5
@@ -20,7 +20,7 @@ import Data.Text (Text)
2020import GHC.Generics
2121import Data.Aeson as Aeson hiding (Success)
2222import Data.String.Conv
23−import Lucid (Html(..))
23+import Lucid (Html)
2424import Data.Pool (Pool)
2525import Control.Monad.Logger (LogLevel)
2626
@@ -35,7 +35,7 @@ import Control.Monad.Logger (LogLevel)
3535-- myJobsTable :: TableName
3636-- myJobsTable = "my_jobs"
3737-- @
38−--
38+--
3939-- This should also work for table names qualified by the schema name. For example:
4040--
4141-- @
@@ -72,6 +72,11 @@ data LogEvent
7272 | LogJobFailed !Job !SomeException !FailureMode !NominalDiffTime
7373 -- | Emitted when a job times out and is picked-up again for execution
7474 | LogJobTimeout !Job
75+ -- | Emitted when user kills a job and the job thread sucessfully cancelled thereafter.
76+ | LogKillJobSuccess !Job
77+ -- | Emitted when user kills a job and the job thread is not found in the threadRef
78+ -- | (most likely the job has either got completed or timed out).
79+ | LogKillJobFailed !Job
7580 -- | Emitted whenever 'OddJobs.Job.jobPoller' polls the DB table
7681 | LogPoll
7782 -- | TODO
@@ -109,6 +114,30 @@ data FailureMode
109114-- by 'JobErrHandler' and 'cfgOnJobFailed'.
110115data JobErrHandler = forall a e . (Exception e) => JobErrHandler (e -> Job -> FailureMode -> IO a)
111116
117+type FunctionName = PGS.Identifier
118+
119+data ResourceCfg = ResourceCfg
120+ { resCfgResourceTable :: TableName
121+ -- ^ Table to use for tracking resources and their limits. Both this and
122+ -- 'resCfgUsageTable' should be created by 'OddJobs.Migrations.createResourceTables'.
123+
124+ , resCfgUsageTable :: TableName
125+ -- ^ Table to use for tracking how jobs use resources.
126+
127+ , resCfgCheckResourceFunction :: FunctionName
128+ -- ^ Name of the function that checks that the resources required to run a
129+ -- job are all available (i.e. that the total usage of each resource, plus
130+ -- the usage the job needs to run, does not exceed the resource limit). The
131+ -- function should have the signature @(int) RETURNS bool@, and return @TRUE@
132+ -- if the job with the given ID has its resources available.
133+
134+ , resCfgDefaultLimit :: Int
135+ -- ^ When a job requires a resource not already in 'resCfgResourceTable',
136+ -- what should its limit be set to?
137+ } deriving (Show)
138+
139+newtype ResourceId = ResourceId { rawResourceId :: Text }
140+ deriving (Show)
112141
113142-- | __Note:__ Please read the section on [controlling
114143-- concurrency](https://www.haskelltutorials.com/odd-jobs/guide.html#controlling-concurrency)
@@ -125,6 +154,15 @@ data ConcurrencyControl
125154 -- such as, CPU, memory, disk IOPS, or network bandwidth.
126155 | UnlimitedConcurrentJobs
127156
157+ -- | Limit jobs according to their access to resources, as tracked in the DB
158+ -- according to the 'ResourceCfg'.
159+ --
160+ -- __Warning:__ without sufficient limits, this can easily hit the same problems
161+ -- as 'UnlimitedConcurrentJobs' where jobs are able to exhaust system resources.
162+ -- It is therefore recommended that all jobs in your system use /some/ DB-tracked
163+ -- resource.
164+ | ResourceLimits ResourceCfg
165+
128166 -- | Use this to dynamically determine if the next job should be picked-up, or
129167 -- not. This is useful to write custom-logic to determine whether a limited
130168 -- resource is below a certain usage threshold (eg. CPU usage is below 80%).
@@ -135,6 +173,7 @@ instance Show ConcurrencyControl where
135173 show cc = case cc of
136174 MaxConcurrentJobs n -> "MaxConcurrentJobs " <> show n
137175 UnlimitedConcurrentJobs -> "UnlimitedConcurrentJobs"
176+ ResourceLimits cfg -> "ResourceLimits " <> show cfg
138177 DynamicConcurrency _ -> "DynamicConcurrency (IO Bool)"
139178
140179type JobId = Int
@@ -148,8 +187,11 @@ data Status
148187 -- | Jobs in 'Queued' status /may/ be picked up by the job-runner on the basis
149188 -- of the 'jobRunAt' field.
150189 | Queued
151− -- | Jobs in 'Failed' status will will not be retried by the job-runner.
190+ -- | Jobs in 'Failed' status will not be retried by the job-runner.
152191 | Failed
192+ -- | Jobs with 'Cancelled' status are cancelled by the user and will not be
193+ -- retried by the job-runner
194+ | Cancelled
153195 -- | Jobs in 'Retry' status will be retried by the job-runner on the basis of
154196 -- the 'jobRunAt' field.
155197 | Retry
@@ -193,6 +235,7 @@ instance ToText Status where
193235 Queued -> "queued"
194236 Retry -> "retry"
195237 Failed -> "failed"
238+ Cancelled -> "cancelled"
196239 Locked -> "locked"
197240
198241instance (StringConv Text a) => FromText (Either a Status) where
@@ -200,14 +243,16 @@ instance (StringConv Text a) => FromText (Either a Status) where
200243 "success" -> Right Success
201244 "queued" -> Right Queued
202245 "failed" -> Right Failed
246+ "cancelled" -> Right Cancelled
203247 "retry" -> Right Retry
204248 "locked" -> Right Locked
205249 x -> Left $ toS $ "Unknown job status: " <> x
206250
207251instance FromField Status where
208− fromField f mBS = (fromText <$> (fromField f mBS)) >>= \case
252+ fromField f mBS = fromField f mBS >>= (\case
209253 Left e -> FromField.returnError PGS.ConversionFailed f e
210− Right s -> pure s
254+ Right s -> pure s)
255+ . fromText
211256
212257instance ToField Status where
213258 toField s = toField $ toText s
src/OddJobs/Web.hs+77−55
@@ -1,4 +1,4 @@
1−{-# LANGUAGE DeriveGeneric, NamedFieldPuns, TypeOperators, DataKinds, RecordWildCards, DeriveAnyClass #-}
1+{-# LANGUAGE DeriveGeneric, NamedFieldPuns, DataKinds, RecordWildCards #-}
22{-# LANGUAGE CPP #-}
33module OddJobs.Web where
44
@@ -116,6 +116,7 @@ instance ToHttpApiData Filter where
116116-- , rEnqueue :: route :- "enqueue" :> Capture "jobId" JobId :> Post '[HTML] NoContent
117117-- , rRunNow :: route :- "run" :> Capture "jobId" JobId :> Post '[HTML] NoContent
118118-- , rCancel :: route :- "cancel" :> Capture "jobId" JobId :> Post '[HTML] NoContent
119+-- , rKill :: route :- "kill" :> Capture "jobId" JobId :> Post '[HTML] NoContent
119120-- , rRefreshJobTypes :: route :- "refresh-job-types" :> Post '[HTML] NoContent
120121-- , rRefreshJobRunners :: route :- "refresh-job-runners" :> Post '[HTML] NoContent
121122-- } deriving (Generic)
@@ -126,6 +127,7 @@ data Routes = Routes
126127 , rEnqueue :: JobId -> Text
127128 , rRunNow :: JobId -> Text
128129 , rCancel :: JobId -> Text
130+ , rKill :: JobId -> Text
129131 , rRefreshJobTypes :: Text
130132 , rRefreshJobRunners :: Text
131133 , rStaticAsset :: Text -> Text
@@ -134,8 +136,8 @@ data Routes = Routes
134136
135137filterJobsQuery :: UIConfig -> Filter -> (PGS.Query, [Action])
136138filterJobsQuery UIConfig{uicfgTableName, uicfgJobTypeSql} Filter{..} =
137− ( "SELECT " <> Job.concatJobDbColumns <> " FROM ?" <> whereClause <> " " <> (orderClause $ fromMaybe (OrdUpdatedAt, Desc) filterOrder) <> " " <> limitOffsetClause
138− , (toRow $ Only uicfgTableName) ++ whereActions
139+ ( "SELECT " <> Job.concatJobDbColumns <> " FROM ?" <> whereClause <> " " <> orderClause (fromMaybe (OrdUpdatedAt, Desc) filterOrder) <> " " <> limitOffsetClause
140+ , toRow (Only uicfgTableName) ++ whereActions
139141 )
140142 where
141143 orderClause (flt, dir) =
@@ -166,7 +168,7 @@ filterJobsQuery UIConfig{uicfgTableName, uicfgJobTypeSql} Filter{..} =
166168
167169 statusClause = if Prelude.null filterStatuses
168170 then Nothing
169− else Just ("status IN ?", toRow $ (Only (In filterStatuses)))
171+ else Just ("status IN ?", toRow (Only (In filterStatuses)))
170172
171173 createdAfterClause = Prelude.fmap (\x -> ("created_at >= ?", toRow $ Only x)) filterCreatedAfter
172174 createdBeforeClause = Prelude.fmap (\x -> ("created_at < ?", toRow $ Only x)) filterCreatedBefore
@@ -181,8 +183,8 @@ filterJobsQuery UIConfig{uicfgTableName, uicfgJobTypeSql} Filter{..} =
181183 let qFragment = "(" <> uicfgJobTypeSql <> ")=?"
182184 build ys (q, vs) = case ys of
183185 [] -> (q, vs)
184− (y:[]) -> (qFragment <> q, (toField y):vs)
185− (y:ys_) -> build ys_ (" OR " <> qFragment <> q, (toField y):vs)
186+ [y] -> (qFragment <> q, toField y : vs)
187+ (y:ys_) -> build ys_ (" OR " <> qFragment <> q, toField y : vs)
186188 in Just $ build xs (mempty, [])
187189
188190 jobRunnerClause :: Maybe (Query, [Action])
@@ -226,10 +228,10 @@ pageNav Routes{..} = do
226228 div_ [ class_ "navbar-header" ] $ do
227229 a_ [ class_ "navbar-brand navbar-link", href_ "#", style_ "margin-left: 2px; padding: 0px;" ] $ img_ [ src_ $ rStaticAsset "assets/odd-jobs-color-logo.png", title_ "Odd Jobs Logo" ]
228230 button_ [ class_ "navbar-toggle collapsed", data_ "toggle" "collapse", data_ "target" "#navcol-1" ] $ do
229− span_ [ class_ "sr-only" ] $ "Toggle navigation"
230− span_ [ class_ "icon-bar" ] $ ""
231− span_ [ class_ "icon-bar" ] $ ""
232− span_ [ class_ "icon-bar" ] $ ""
231+ span_ [ class_ "sr-only" ] "Toggle navigation"
232+ span_ [ class_ "icon-bar" ] ""
233+ span_ [ class_ "icon-bar" ] ""
234+ span_ [ class_ "icon-bar" ] ""
233235 -- div_ [ class_ "collapse navbar-collapse", id_ "navcol-1" ] $ ul_ [ class_ "nav navbar-nav navbar-right" ] $ do
234236 -- li_ [ class_ "active", role_ "presentation" ] $ a_ [ href_ "#" ] $ "First Item"
235237 -- li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "Second Item"
@@ -259,12 +261,12 @@ pageLayout routes@Routes{..} navHtml bodyHtml = do
259261 div_ $ div_ [ class_ "container-fluid", style_ "/*background-color:#f2f2f2;*/" ] $ div_ [ class_ "row" ] $ do
260262 div_ [ class_ "d-none d-md-block col-md-2" ] navHtml
261263 div_ [ class_ "col-12 col-md-10" ] bodyHtml
262− script_ [ src_ $ rStaticAsset "assets/js/jquery.min.js" ] $ ("" :: Text)
263− script_ [ src_ $ rStaticAsset "assets/bootstrap/js/bootstrap.min.js" ] $ ("" :: Text)
264− script_ [ src_ $ rStaticAsset "assets/js/custom.js" ] $ ("" :: Text)
264+ script_ [ src_ $ rStaticAsset "assets/js/jquery.min.js" ] ("" :: Text)
265+ script_ [ src_ $ rStaticAsset "assets/bootstrap/js/bootstrap.min.js" ] ("" :: Text)
266+ script_ [ src_ $ rStaticAsset "assets/js/custom.js" ] ("" :: Text)
265267
266268sideNav :: Routes -> [Text] -> [JobRunnerName] -> UTCTime -> Filter -> Html ()
267−sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
269+sideNav Routes{..} jobTypes jobRunnerNames _t filter@Filter{..} = do
268270 div_ [ class_ "filters mt-3" ] $ do
269271 jobStatusFilters
270272 jobTypeFilters
@@ -274,14 +276,14 @@ sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
274276 h6_ "Filter by job status"
275277 div_ [ class_ "card" ] $ do
276278 ul_ [ class_ "list-group list-group-flush" ] $ do
277− li_ [ class_ ("list-group-item " <> if filterStatuses == [] then "active-nav" else "") ] $ do
278− let lnk = (rFilterResults $ Just filter{filterStatuses = [], filterPage = (OddJobs.Web.filterPage blankFilter)})
279+ li_ [ class_ ("list-group-item " <> if null filterStatuses then "active-nav" else "") ] $ do
280+ let lnk = rFilterResults $ Just filter{filterStatuses = [], filterPage = OddJobs.Web.filterPage blankFilter}
279281 a_ [ href_ lnk ] $ do
280282 "all"
281283 -- span_ [ class_ "badge badge-pill badge-secondary float-right" ] "12"
282284 forM_ ((\\) (enumFrom minBound) [Job.Success]) $ \st -> do
283− li_ [ class_ ("list-group-item " <> if (st `elem` filterStatuses) then "active-nav" else "") ] $ do
284− let lnk = (rFilterResults $ Just filter{filterStatuses = [st], filterPage = Nothing})
285+ li_ [ class_ ("list-group-item " <> if st `elem` filterStatuses then "active-nav" else "") ] $ do
286+ let lnk = rFilterResults $ Just filter{filterStatuses = [st], filterPage = Nothing}
285287 a_ [ href_ lnk ] $ do
286288 toHtml $ toText st
287289 -- span_ [ class_ "badge badge-pill badge-secondary float-right" ] "12"
@@ -295,11 +297,11 @@ sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
295297
296298 div_ [ class_ "card" ] $ do
297299 ul_ [ class_ "list-group list-group-flush" ] $ do
298− li_ [ class_ ("list-group-item " <> if filterJobRunner == [] then "active-nav" else "") ] $ do
299− let lnk = (rFilterResults $ Just filter{filterJobRunner = [], filterPage = (OddJobs.Web.filterPage blankFilter)})
300+ li_ [ class_ ("list-group-item " <> if null filterJobRunner then "active-nav" else "") ] $ do
301+ let lnk = rFilterResults $ Just filter{filterJobRunner = [], filterPage = OddJobs.Web.filterPage blankFilter}
300302 a_ [ href_ lnk ] "all"
301303 forM_ jobRunnerNames $ \jr -> do
302− li_ [ class_ ("list-group-item" <> if (jr `elem` filterJobRunner) then " active-nav" else "")] $ do
304+ li_ [ class_ ("list-group-item" <> if jr `elem` filterJobRunner then " active-nav" else "")] $ do
303305 a_ [ href_ "#" ] $ toHtml $ unJobRunnerName jr
304306
305307 jobTypeFilters = do
@@ -311,15 +313,15 @@ sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
311313
312314 div_ [ class_ "card" ] $ do
313315 ul_ [ class_ "list-group list-group-flush" ] $ do
314− li_ [ class_ ("list-group-item " <> if filterJobTypes == [] then "active-nav" else "") ] $ do
315− let lnk = (rFilterResults $ Just filter{filterJobTypes = [], filterPage = (OddJobs.Web.filterPage blankFilter)})
316+ li_ [ class_ ("list-group-item " <> if null filterJobTypes then "active-nav" else "") ] $ do
317+ let lnk = rFilterResults $ Just filter{filterJobTypes = [], filterPage = OddJobs.Web.filterPage blankFilter}
316318 a_ [ href_ lnk ] "all"
317319 forM_ jobTypes $ \jt -> do
318− li_ [ class_ ("list-group-item" <> if (jt `elem` filterJobTypes) then " active-nav" else "")] $ do
320+ li_ [ class_ ("list-group-item" <> if jt `elem` filterJobTypes then " active-nav" else "")] $ do
319321 a_ [ href_ (rFilterResults $ Just filter{filterJobTypes=[jt]}) ] $ toHtml jt
320322
321323searchBar :: Routes -> UTCTime -> Filter -> Html ()
322−searchBar Routes{..} t filter@Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterJobTypes, filterRunAfter} = do
324+searchBar Routes{..} _t filter@Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterJobTypes, filterRunAfter} = do
323325 form_ [ style_ "padding-top: 2em;" ] $ do
324326 div_ [ class_ "form-group" ] $ do
325327 div_ [ class_ "search-container" ] $ do
@@ -332,7 +334,7 @@ searchBar Routes{..} t filter@Filter{filterStatuses, filterCreatedAfter, filterC
332334 maybe mempty (\x -> renderFilter "Run after" (showText x) (rFilterResults $ Just filter{filterRunAfter = Nothing})) filterRunAfter
333335 forM_ filterJobTypes $ \x -> renderFilter "Job type" x (rFilterResults $ Just filter{filterJobTypes = filterJobTypes \\ [x]})
334336
335− button_ [ class_ "btn btn-default search-button", type_ "button" ] $ "Search"
337+ button_ [ class_ "btn btn-default search-button", type_ "button" ] "Search"
336338 -- ul_ [ class_ "list-inline" ] $ do
337339 -- li_ $ span_ $ strong_ "Common searches:"
338340 -- li_ $ a_ [ href_ (rFilterResults $ Just mempty) ] $ "All jobs"
@@ -351,7 +353,7 @@ searchBar Routes{..} t filter@Filter{filterStatuses, filterCreatedAfter, filterC
351353 span_ [ class_ "filter-name" ] $ toHtml k
352354 span_ [ class_ "filter-value" ] $ do
353355 toHtml v
354− a_ [ href_ u, class_ "text-danger" ] $ i_ [ class_ "glyphicon glyphicon-remove" ] $ ""
356+ a_ [ href_ u, class_ "text-danger" ] $ i_ [ class_ "glyphicon glyphicon-remove" ] ""
355357
356358
357359timeDuration :: UTCTime -> UTCTime -> (Int, String)
@@ -359,22 +361,20 @@ timeDuration from to = (diff, str)
359361 where
360362 str = if diff <= 0
361363 then "under 1s"
362− else (if d>0 then (show d) <> "d" else "") <>
363− (if m>0 then (show m) <> "m" else "") <>
364− (if s>0 then (show s) <> "s" else "")
365− diff = (abs $ round $ diffUTCTime from to)
364+ else (if d>0 then show d <> "d" else "") <>
365+ (if m>0 then show m <> "m" else "") <>
366+ (if s>0 then show s <> "s" else "")
367+ diff = abs $ round $ diffUTCTime from to
366368 (m', s) = diff `divMod` 60
367369 (h', m) = m' `divMod` 60
368− (d, h) = h' `divMod` 24
370+ (d, _h) = h' `divMod` 24
369371
370372showText :: (Show a) => a -> Text
371373showText a = toS $ show a
372374
373375jobContent :: Value -> Value
374376jobContent v = case v of
375− Aeson.Object o -> case HM.lookup "contents" o of
376− Nothing -> v
377− Just c -> c
377+ Aeson.Object o -> fromMaybe v (HM.lookup "contents" o)
378378 _ -> v
379379
380380jobRow :: Routes -> UTCTime -> (Job, Html ()) -> Html ()
@@ -384,6 +384,7 @@ jobRow routes t (job@Job{..}, jobHtml) = do
384384 let statusFn = case jobStatus of
385385 Job.Success -> statusSuccess
386386 Job.Failed -> statusFailed
387+ Job.Cancelled -> statusCancelled
387388 Job.Queued -> if jobRunAt > t
388389 then statusFuture
389390 else statusWaiting
@@ -394,39 +395,54 @@ jobRow routes t (job@Job{..}, jobHtml) = do
394395 td_ jobHtml
395396 td_ $ do
396397 let actionsFn = case jobStatus of
397− Job.Success -> (const mempty)
398+ Job.Success -> const mempty
398399 Job.Failed -> actionsFailed
400+ Job.Cancelled -> actionsCancelled
399401 Job.Queued -> if jobRunAt > t
400402 then actionsFuture
401403 else actionsWaiting
402404 Job.Retry -> actionsRetry
403− Job.Locked -> (const mempty)
405+ Job.Locked -> actionsLocked
404406 actionsFn routes job
405407
406408
407409actionsFailed :: Routes -> Job -> Html ()
408410actionsFailed Routes{..} Job{..} = do
409411 form_ [ action_ (rEnqueue jobId), method_ "post" ] $ do
410− button_ [ class_ "btn btn-secondary", type_ "submit" ] $ "Enqueue again"
412+ button_ [ class_ "btn btn-secondary", type_ "submit" ] "Enqueue again"
413+
414+actionsCancelled :: Routes -> Job -> Html ()
415+actionsCancelled Routes{..} Job{..} = case (jobLockedAt, jobLockedBy) of
416+ (Just _, Just _) -> do
417+ span_ [ class_ "badge badge-light" ] "Killing job"
418+
419+ _ -> do
420+ form_ [ action_ (rEnqueue jobId), method_ "post" ] $ do
421+ button_ [ class_ "btn btn-secondary", type_ "submit" ] "Enqueue again"
411422
412423actionsRetry :: Routes -> Job -> Html ()
413424actionsRetry Routes{..} Job{..} = do
414425 form_ [ action_ (rRunNow jobId), method_ "post" ] $ do
415− button_ [ class_ "btn btn-secondary", type_ "submit" ] $ "Run now"
426+ button_ [ class_ "btn btn-secondary", type_ "submit" ] "Run now"
416427
417428actionsFuture :: Routes -> Job -> Html ()
418429actionsFuture Routes{..} Job{..} = do
419430 form_ [ action_ (rRunNow jobId), method_ "post" ] $ do
420− button_ [ class_ "btn btn-secondary", type_ "submit" ] $ "Run now"
431+ button_ [ class_ "btn btn-secondary", type_ "submit" ] "Run now"
421432
422433actionsWaiting :: Routes -> Job -> Html ()
423434actionsWaiting Routes{..} Job{..} = do
424435 form_ [ action_ (rCancel jobId), method_ "post" ] $ do
425− button_ [ class_ "btn btn-danger", type_ "submit" ] $ "Cancel"
436+ button_ [ class_ "btn btn-danger", type_ "submit" ] "Cancel"
437+
438+actionsLocked :: Routes -> Job -> Html ()
439+actionsLocked Routes{..} Job{..} = do
440+ form_ [ action_ (rKill jobId), method_ "post" ] $ do
441+ button_ [ class_ "btn btn-warning", type_ "submit" ] "Kill"
426442
427443statusSuccess :: UTCTime -> Job -> Html ()
428444statusSuccess t Job{..} = do
429− span_ [ class_ "badge badge-success" ] $ "Success"
445+ span_ [ class_ "badge badge-success" ] "Success"
430446 span_ [ class_ "job-run-time" ] $ do
431447 let (d, s) = timeDuration jobCreatedAt jobUpdatedAt
432448 abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Completed " <> humanReadableTime' t jobUpdatedAt <> ". "
@@ -434,19 +450,25 @@ statusSuccess t Job{..} = do
434450
435451statusFailed :: UTCTime -> Job -> Html ()
436452statusFailed t Job{..} = do
437− span_ [ class_ "badge badge-danger" ] $ "Failed"
453+ span_ [ class_ "badge badge-danger" ] "Failed"
438454 span_ [ class_ "job-run-time" ] $ do
439455 abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Failed " <> humanReadableTime' t jobUpdatedAt <> " after " <> show jobAttempts <> " attempts"
440456
457+statusCancelled :: UTCTime -> Job -> Html ()
458+statusCancelled t Job{..} = do
459+ span_ [ class_ "badge badge-danger" ] "Cancelled"
460+ span_ [ class_ "job-run-time" ] $ do
461+ abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Cancelled " <> humanReadableTime' t jobUpdatedAt
462+
441463statusFuture :: UTCTime -> Job -> Html ()
442464statusFuture t Job{..} = do
443− span_ [ class_ "badge badge-secondary" ] $ "Future"
465+ span_ [ class_ "badge badge-secondary" ] "Future"
444466 span_ [ class_ "job-run-time" ] $ do
445467 abbr_ [ title_ (showText jobRunAt) ] $ toHtml $ humanReadableTime' t jobRunAt
446468
447469statusWaiting :: UTCTime -> Job -> Html ()
448−statusWaiting t Job{..} = do
449− span_ [ class_ "badge badge-warning" ] $ "Waiting"
470+statusWaiting _t Job{} = do
471+ span_ [ class_ "badge badge-warning" ] "Waiting"
450472 -- span_ [ class_ "job-run-time" ] ("Waiting to be picked up" :: Text)
451473
452474statusRetry :: UTCTime -> Job -> Html ()
@@ -457,7 +479,7 @@ statusRetry t Job{..} = do
457479 abbr_ [ title_ (showText jobRunAt)] $ toHtml $ "Next retry in " <> humanReadableTime' t jobRunAt
458480
459481statusLocked :: UTCTime -> Job -> Html ()
460−statusLocked t Job{..} = do
482+statusLocked _t Job{} = do
461483 span_ [ class_ "badge badge-info" ] $ toHtml ("Locked" :: Text)
462484 -- span_ [ class_ "job-run-time" ] $ do
463485 -- abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Retried " <> humanReadableTime' t jobUpdatedAt <> ". "
@@ -486,24 +508,24 @@ resultsPanel routes@Routes{..} t filter@Filter{filterPage} js runningCount = do
486508 prevLink = do
487509 let (extraClass, lnk) = case filterPage of
488510 Nothing -> ("disabled", "")
489− Just (l, 0) -> ("disabled", "")
511+ Just (_l, 0) -> ("disabled", "")
490512 Just (l, o) -> ("", rFilterResults $ Just $ filter {filterPage = Just (l, max 0 $ o - l)})
491513 li_ [ class_ ("page-item previous " <> extraClass) ] $ do
492− a_ [ class_ "page-link", href_ lnk ] $ "Prev"
514+ a_ [ class_ "page-link", href_ lnk ] "Prev"
493515
494516 nextLink = do
495517 let (extraClass, lnk) = case filterPage of
496518 Nothing ->
497− if (DL.length js) < 10
519+ if DL.length js < 10
498520 then ("disabled", "")
499− else ("", (rFilterResults $ Just $ filter {filterPage = Just (10, 10)}))
521+ else ("", rFilterResults $ Just $ filter {filterPage = Just (10, 10)})
500522 Just (l, o) ->
501− if (DL.length js) < l
523+ if DL.length js < l
502524 then ("disabled", "")
503− else ("", (rFilterResults $ Just $ filter {filterPage = Just (l, o + l)}))
525+ else ("", rFilterResults $ Just $ filter {filterPage = Just (l, o + l)})
504526 li_ [ class_ ("page-item next " <> extraClass) ] $ do
505− a_ [ class_ "page-link", href_ lnk ] $ "Next"
527+ a_ [ class_ "page-link", href_ lnk ] "Next"
506528
507529ariaExpanded_ :: Text -> Attribute
508−ariaExpanded_ v = makeAttribute "aria-expanded" v
530+ariaExpanded_ = makeAttribute "aria-expanded"
509531
stack.yaml+3−66
@@ -1,69 +1,6 @@
1−# This file was automatically generated by 'stack init'
2−#
3−# Some commonly used options have been documented as comments in this file.
4−# For advanced use and comprehensive documentation of the format, please see:
5−# https://docs.haskellstack.org/en/stable/yaml_configuration/
6−
7−# Resolver to choose a 'specific' stackage snapshot or a compiler version.
8−# A snapshot resolver dictates the compiler version and the set of packages
9−# to be used for project dependencies. For example:
10−#
11−# resolver: lts-3.5
12−# resolver: nightly-2015-09-21
13−# resolver: ghc-7.10.2
14−#
15−# The location of a snapshot can be provided as a file or url. Stack assumes
16−# a snapshot provided as a file might change, whereas a url resource does not.
17−#
18−# resolver: ./custom-snapshot.yaml
19−# resolver: https://example.com/snapshots/2018-01-01.yaml
20−resolver: lts-15.6
21−
22−# User packages to be built.
23−# Various formats can be used as shown in the example below.
24−#
25−# packages:
26−# - some-directory
27−# - https://example.com/foo/bar/baz-0.0.2.tar.gz
28−# subdirs:
29−# - auto-update
30−# - wai
1+resolver: lts-20.26
312packages:
32−- .
33−# Dependency packages to be pulled from upstream that are not in the resolver.
34−# These entries can reference officially published versions as well as
35−# forks / in-progress versions pinned to a git hash. For example:
36−#
37−# extra-deps:
38−# - acme-missiles-0.3
39−# - git: https://github.com/commercialhaskell/stack.git
40−# commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a
41−#
3+ - .
424extra-deps:
435 - timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
44− - daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
45−
46−
47−# Override default flag values for local packages and extra-deps
48−# flags: {}
49−
50−# Extra package databases containing global packages
51−# extra-package-dbs: []
52−
53−# Control whether we use the GHC we find on the path
54−# system-ghc: true
55−#
56−# Require a specific version of stack, using version ranges
57−# require-stack-version: -any # Default
58−# require-stack-version: ">=2.1"
59−#
60−# Override the architecture used by stack, especially useful on Windows
61−# arch: i386
62−# arch: x86_64
63−#
64−# Extra directories used by stack for building
65−# extra-include-dirs: [/path/to/dir]
66−# extra-lib-dirs: [/path/to/dir]
67−#
68−# Allow a newer minor version of GHC than the snapshot specifies
69−# compiler-check: newer-minor
6+ - resource-pool-0.4.0.0@sha256:9c1e448a159875e21a7e68697feee2b61a4e584720974fa465a2fa1bc0776c73,1342
test/Test.hs+382−122
@@ -1,6 +1,11 @@
1−{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, NamedFieldPuns, DeriveGeneric, FlexibleContexts, TypeFamilies, StandaloneDeriving, RankNTypes #-}
1+{-# LANGUAGE FlexibleInstances, NamedFieldPuns, DeriveGeneric, FlexibleContexts, TypeFamilies, StandaloneDeriving, RankNTypes #-}
2+{-# LANGUAGE CPP #-}
3+{-# LANGUAGE PartialTypeSignatures #-}
4+{-# LANGUAGE RecordWildCards #-}
5+{-# OPTIONS_GHC -Wno-missing-signatures -Wno-partial-type-signatures #-}
26module Test where
37
8+import Control.Monad.Trans.Control
49import Test.Tasty as Tasty
510import qualified OddJobs.Migrations as Migrations
611import qualified OddJobs.Job as Job
@@ -10,6 +15,7 @@ import Data.Pool as Pool
1015import Test.Tasty.HUnit
1116import Debug.Trace
1217-- import Control.Exception.Lifted (finally, catch, bracket)
18+import Control.Monad (void)
1319import Control.Monad.Logger
1420import Control.Monad.Reader
1521import Data.Aeson as Aeson
@@ -31,7 +37,6 @@ import Test.Tasty.Hedgehog
3137import qualified System.Random as R
3238import Data.String (fromString)
3339import qualified Data.IntMap.Strict as Map
34−import Control.Monad.Trans.Control (liftWith, restoreT)
3540import Control.Monad.Morph (hoist)
3641import Data.List as DL
3742import OddJobs.Web as Web
@@ -42,6 +47,7 @@ import Data.Maybe (fromMaybe)
4247import qualified OddJobs.ConfigBuilder as Job
4348import UnliftIO
4449import Control.Exception (ArithException)
50+import Data.Bifunctor(first)
4551
4652$(Aeson.deriveJSON Aeson.defaultOptions ''Seconds)
4753
@@ -59,16 +65,14 @@ main = do
5965 , connectDatabase = "jobs_test"
6066 }
6167
62− createAppPool = createPool
63− (PGS.connect connInfo) -- cretea a new resource
64− (PGS.close) -- destroy resource
65− 1 -- stripes
68+ createAppPool = Pool.newPool $ Pool.defaultPoolConfig
69+ (PGS.connect connInfo) -- create a new resource
70+ PGS.close -- destroy resource
6671 (fromRational 10) -- number of seconds unused resources are kept around
6772 45
68− createJobPool = createPool
69− (PGS.connect connInfo) -- cretea a new resource
70− (PGS.close) -- destroy resource
71− 1 -- stripes
73+ createJobPool = Pool.newPool $ Pool.defaultPoolConfig
74+ (PGS.connect connInfo) -- create a new resource
75+ PGS.close -- destroy resource
7276 (fromRational 10) -- number of seconds unused resources are kept around
7377 45
7478
@@ -79,9 +83,16 @@ tests appPool jobPool = testGroup "All tests"
7983 , testJobFailure appPool jobPool
8084 , testEnsureShutdown appPool jobPool
8185 , testGracefulShutdown appPool jobPool
82− , testJobErrHandler appPool jobPool
8386 , testPushFailedJobEndQueue jobPool
8487 , testRetryBackoff appPool jobPool
88+ , testGroup "callback tests"
89+ [ testOnJobFailed appPool jobPool
90+ , testOnJobStart appPool jobPool
91+ , testOnJobSuccess appPool jobPool
92+ , testOnJobTimeout appPool jobPool
93+ ]
94+ , testResourceLimitedScheduling appPool jobPool
95+ , testKillJob appPool jobPool
8596 ]
8697 -- , testGroup "property tests" [ testEverything appPool jobPool
8798 -- -- , propFilterJobs appPool jobPool
@@ -123,17 +134,17 @@ testPayload :: Value
123134testPayload = toJSON (10 :: Int)
124135
125136jobRunner :: Job.Job -> IO ()
126−jobRunner Job{jobPayload, jobAttempts} = case (fromJSON jobPayload) of
137+jobRunner Job{jobPayload, jobAttempts} = case fromJSON jobPayload of
127138 Aeson.Error e -> error e
128139 Success (j :: JobPayload) ->
129140 let recur pload idx = case pload of
130− PayloadAlwaysFail delay -> (delaySeconds delay) >> (error $ "Forced error after " <> show delay <> " seconds")
131− PayloadSucceed delay -> (delaySeconds delay) >> pure ()
141+ PayloadAlwaysFail delay -> delaySeconds delay >> error ("Forced error after " <> show delay <> " seconds")
142+ PayloadSucceed delay -> delaySeconds delay
132143 PayloadFail delay innerpload -> if idx<jobAttempts
133144 then recur innerpload (idx + 1)
134− else (delaySeconds delay) >> (error $ "Forced error after " <> show delay <> " seconds. step=" <> show idx)
145+ else delaySeconds delay >> error ("Forced error after " <> show delay <> " seconds. step=" <> show idx)
135146 PayloadThrowStringException s -> throwString s
136− PayloadThrowDivideByZero -> seq (1 `div` 0) (pure ())
147+ PayloadThrowDivideByZero -> seq (1 `div` 0 :: Integer) (pure ())
137148 in recur j 0
138149
139150data JobPayload = PayloadSucceed Seconds
@@ -156,11 +167,13 @@ logEventToJob le = case le of
156167 Job.LogJobSuccess j _ -> Just j
157168 Job.LogJobFailed j _ _ _ -> Just j
158169 Job.LogJobTimeout j -> Just j
170+ Job.LogKillJobSuccess j -> Just j
171+ Job.LogKillJobFailed j -> Just j
159172 Job.LogPoll -> Nothing
160173 Job.LogWebUIRequest -> Nothing
161174 Job.LogText _ -> Nothing
162175
163−assertJobIdStatus :: (HasCallStack)
176+assertJobIdStatus :: HasCallStack
164177 => Connection
165178 -> Job.TableName
166179 -> IORef [Job.LogEvent]
@@ -170,57 +183,115 @@ assertJobIdStatus :: (HasCallStack)
170183 -> Assertion
171184assertJobIdStatus conn tname logRef msg st jid = do
172185 logs <- readIORef logRef
173− let mjid = Just jid
186+ let _mjid = Just jid
174187 case st of
175188 Job.Success ->
176189 assertBool (msg <> ": Success event not found in job-logs for JobId=" <> show jid) $
177− (flip DL.any) logs $ \logEvent -> case logEvent of
178− Job.LogJobSuccess j _ -> jid == Job.jobId j
179− _ -> False
190+ flip DL.any logs $ \case
191+ Job.LogJobSuccess j _ -> jid == Job.jobId j
192+ _ -> False
193+
180194 Job.Queued ->
181195 assertBool (msg <> ": Not expecting to find a queued job in the the job-logs JobId=" <> show jid) $
182− not $ (flip DL.any) logs $ \logEvent -> case logEvent of
183− Job.LogJobStart j -> jid == Job.jobId j
184− Job.LogJobSuccess j _ -> jid == Job.jobId j
185− Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
186− Job.LogJobTimeout j -> jid == Job.jobId j
187− Job.LogWebUIRequest -> False
188− Job.LogText _ -> False
189− Job.LogPoll -> False
196+ not $ flip DL.any logs $ \case
197+ Job.LogJobStart j -> jid == Job.jobId j
198+ Job.LogJobSuccess j _ -> jid == Job.jobId j
199+ Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
200+ Job.LogJobTimeout j -> jid == Job.jobId j
201+ Job.LogKillJobSuccess _ -> False
202+ Job.LogKillJobFailed _ -> False
203+ Job.LogWebUIRequest -> False
204+ Job.LogText _ -> False
205+ Job.LogPoll -> False
206+
190207 Job.Failed ->
191208 assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
192− (flip DL.any) logs $ \logEvent -> case logEvent of
193− Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
194− _ -> False
209+ flip DL.any logs $ \case
210+ Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
211+ _ -> False
212+
213+ Job.Cancelled ->
214+ assertBool (msg <> ": Job killed event not found in job-logs for JobId=" <> show jid) $
215+ flip DL.any logs $ \case
216+ Job.LogKillJobSuccess j -> jid == Job.jobId j
217+ _ -> False
195218
196219 Job.Retry ->
197220 assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
198− (flip DL.any) logs $ \logEvent -> case logEvent of
199− Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
200− _ -> False
221+ flip DL.any logs $ \case
222+ Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
223+ _ -> False
201224
202225 Job.Locked ->
203226 assertBool (msg <> ": Start event should be present in the log for a locked job JobId=" <> show jid) $
204− (flip DL.any) logs $ \logEvent -> case logEvent of
205− Job.LogJobStart j -> jid == Job.jobId j
206− _ -> False
227+ flip DL.any logs $ \case
228+ Job.LogJobStart j -> jid == Job.jobId j
229+ _ -> False
207230
208231 when (st /= Job.Success) $ do
209232 Job.findJobByIdIO conn tname jid >>= \case
210233 Nothing -> assertFailure $ "Not expecting job to be deleted. JobId=" <> show jid
211234 Just (Job{jobStatus}) -> assertEqual msg st jobStatus
212235
213−ensureJobId :: (HasCallStack) => Connection -> Job.TableName -> JobId -> IO Job
236+ensureJobId :: HasCallStack => Connection -> Job.TableName -> JobId -> IO Job
214237ensureJobId conn tname jid = Job.findJobByIdIO conn tname jid >>= \case
215238 Nothing -> error $ "Not expecting job to be deleted. JobId=" <> show jid
216239 Just j -> pure j
217240
218−-- withRandomTable :: (MonadIO m) => Pool Connection -> (Job.TableName -> m a) -> m a
241+withRandomTable :: (MonadBaseControl
242+ IO m, MonadUnliftIO m) => Pool Connection -> (Job.TableName -> m a) -> m a
219243withRandomTable jobPool action = do
220− (tname :: Job.TableName) <- liftIO ((fromString . ("jobs_" <>)) <$> (replicateM 10 (R.randomRIO ('a', 'z'))))
244+ runInIO <- askRunInIO
245+ (tname :: Job.TableName) <- liftIO (fromString . ("jobs_" <>) <$> replicateM 10 (R.randomRIO ('a', 'z')))
221246 finally
222− ((Pool.withResource jobPool $ \conn -> (liftIO $ Migrations.createJobTable conn tname)) >> (action tname))
223− (Pool.withResource jobPool $ \conn -> liftIO $ void $ PGS.execute conn "drop table if exists ?" (Only tname))
247+ (liftIO $ Pool.withResource jobPool (\conn -> liftIO $ Migrations.createJobTable conn tname) >> runInIO (action tname))
248+ (liftIO $ Pool.withResource jobPool $ \conn -> liftIO $ void $ PGS.execute conn "drop table if exists ?" (Only tname))
249+
250+runSingleJobFromQueueBlock :: Job.Config -> IO (Maybe (Either SomeException ()))
251+runSingleJobFromQueueBlock cfg =
252+ traverse waitCatch =<< runSingleJobFromQueue cfg
253+
254+-- | waits untill the job has started
255+runSingleJobFromQueue :: Job.Config -> IO (Maybe (Async ()))
256+runSingleJobFromQueue config' = do
257+ r <- liftIO $ newIORef mempty
258+ waitTillJobStart <- newEmptyMVar
259+ let config = config' {
260+ Job.cfgPollingInterval = 0
261+ , Job.cfgJobRunner = \job -> do
262+ putMVar waitTillJobStart ()
263+ jobRunner job
264+ }
265+ let monitorEnv = Job.RunnerEnv
266+ { Job.envConfig = config
267+ , Job.envJobThreadsRef = r
268+ }
269+ result <- runReaderT (Job.pollRunJob "runSingleJobFromQueue" $ readResourceConfig config) monitorEnv
270+ forM_ result $ const $ readMVar waitTillJobStart
271+ pure result
272+
273+readResourceConfig :: Job.Config -> Maybe Job.ResourceCfg
274+readResourceConfig cfg = case Job.cfgConcurrencyControl cfg of
275+ Job.ResourceLimits res -> Just res
276+ _ -> Nothing
277+
278+withRandomResourceTables :: (MonadBaseControl IO m, MonadUnliftIO m) => Int -> Pool Connection -> Job.TableName -> (Job.ResourceCfg -> m a) -> m a
279+withRandomResourceTables defaultLimit jobPool tname action = do
280+ resCfgResourceTable <- liftIO $ fromString . ("resources_" <>) <$> replicateM 10 (R.randomRIO ('a', 'z'))
281+ resCfgUsageTable <- liftIO $ fromString . ("usage_" <>) <$> replicateM 10 (R.randomRIO ('a', 'z'))
282+ resCfgCheckResourceFunction <- liftIO $ fromString . ("check_resource_fn_" <>) <$> replicateM 10 (R.randomRIO ('a', 'z'))
283+ let resCfg = Job.ResourceCfg
284+ { Job.resCfgDefaultLimit = defaultLimit
285+ , ..
286+ }
287+
288+ finally
289+ (do
290+ liftIO $ Pool.withResource jobPool $ \conn -> liftIO $ Migrations.createResourceTables conn tname resCfg
291+ action resCfg)
292+ (liftIO $ Pool.withResource jobPool $ \conn -> liftIO $ void $ PGS.execute conn
293+ "drop function if exists ?; drop table if exists ?; drop table if exists ?"
294+ (resCfgCheckResourceFunction, resCfgUsageTable, resCfgResourceTable))
224295
225296testMaxAttempts :: Int
226297testMaxAttempts = 3
@@ -230,22 +301,28 @@ withNewJobMonitor jobPool actualTest = do
230301 withRandomTable jobPool $ \tname -> do
231302 withNamedJobMonitor tname jobPool Prelude.id (actualTest tname)
232303
233−withNamedJobMonitor tname jobPool cfgFn actualTest = do
304+withNamedJobMonitor :: Job.TableName -> Pool Connection -> (Job.Config -> Job.Config) -> (IORef [Job.LogEvent] -> IO a) -> IO a
305+withNamedJobMonitor tname jobPool cfgFn actualTest =
306+ withConfig tname jobPool cfgFn $ \logRef cfg -> do
307+ withAsync (Job.startJobRunner cfg) (const $ actualTest logRef)
308+
309+withConfig :: Job.TableName -> Pool Connection -> (Job.Config -> Job.Config) -> (IORef [Job.LogEvent] -> Job.Config -> IO a) -> IO a
310+withConfig tname jobPool cfgFn configConsumer = do
234311 logRef :: IORef [Job.LogEvent] <- newIORef []
235312 tcache <- newTimeCache simpleTimeFormat'
236313 withTimedFastLogger tcache LogNone $ \tlogger -> do
237314 let flogger logLevel logEvent = do
238− tlogger $ \t -> toLogStr t <> " | " <> (Job.defaultLogStr Job.defaultJobType logLevel logEvent)
315+ tlogger $ \t -> toLogStr t <> " | " <> Job.defaultLogStr Job.defaultJobType logLevel logEvent
239316 atomicModifyIORef' logRef (\logs -> (logEvent:logs, ()))
240− cfg = Job.mkConfig flogger tname jobPool Job.UnlimitedConcurrentJobs jobRunner (\cfg -> cfgFn $ cfg{Job.cfgDefaultMaxAttempts=testMaxAttempts})
241− withAsync (Job.startJobRunner cfg) (const $ actualTest logRef)
317+ cfg = Job.mkConfig flogger tname jobPool Job.UnlimitedConcurrentJobs jobRunner (\cfg' -> cfgFn $ cfg'{Job.cfgDefaultMaxAttempts=testMaxAttempts})
318+ configConsumer logRef cfg
242319
243320payloadGen :: MonadGen m => m JobPayload
244321payloadGen = Gen.recursive Gen.choice nonRecursive recursive
245322 where
246323 nonRecursive = [ PayloadAlwaysFail <$> Gen.element [1, 2, 3]
247324 , PayloadSucceed <$> Gen.element [1, 2, 3]]
248− recursive = [ PayloadFail <$> (Gen.element [1, 2, 3]) <*> payloadGen ]
325+ recursive = [ PayloadFail <$> Gen.element [1, 2, 3] <*> payloadGen ]
249326
250327testJobCreation appPool jobPool = testCase "job creation" $ do
251328 withNewJobMonitor jobPool $ \tname logRef -> do
@@ -264,109 +341,117 @@ testEnsureShutdown appPool jobPool = testCase "ensure shutdown" $ do
264341 scheduleJob tname = withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
265342 t <- getCurrentTime
266343 Pool.withResource appPool $ \conn -> do
267− Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral (2 * (unSeconds Job.defaultPollingInterval))) t)
344+ Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral (2 * unSeconds Job.defaultPollingInterval)) t)
268345 assertJobIdStatus conn tname logRef "Job is scheduled in future, should still be queueud" Job.Queued jobId
269346 pure (jobId, logRef)
270347
271−testGracefulShutdown appPool jobPool = testCase "ensure graceful shutdown" $ do
272− withRandomTable jobPool $ \tname -> do
273− setupPreconditions 3 tname >>= \case
274− Nothing -> fail "Unable to setup preconditions for this test, even after trying thrice"
275− Just (j1, j2, logRef) -> do
348+testGracefulShutdown :: Pool Connection -> Pool Connection -> TestTree
349+testGracefulShutdown appPool jobPool =
350+ testCase "ensure wait for all jobs is correct" $
351+ withRandomTable jobPool $ \tname -> do
352+ waitTillJobStart <- newEmptyMVar
276353 Pool.withResource appPool $ \conn -> do
354+ j1 <- Job.createJob conn tname (PayloadSucceed $ 4 * Job.defaultPollingInterval)
355+ time<- getCurrentTime
356+ j2 <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral $ unSeconds $ Job.defaultPollingInterval * 2) time)
357+ logRef <- withNamedJobMonitor tname jobPool (\cfg -> cfg { Job.cfgOnJobStart = \ _ -> do
358+ putMVar waitTillJobStart ()
359+ } ) $ \logRef -> do
360+ readMVar waitTillJobStart -- as soon as the job has started we quit the monitor:w
361+ pure logRef -- eg closing the withNamedJobMonitor closure shutsdwon the monitor
277362 assertJobIdStatus conn tname logRef "Expecting the first job to be completed successfully if graceful shutdown is implemented correctly" Job.Success (jobId j1)
278363 assertJobIdStatus conn tname logRef "Expecting the second job to be queued because no new job should be picked up during graceful shutdown" Job.Queued (jobId j2)
279− where
280− -- When this block completes execution, the graceful shutdown should have happened.
281− setupPreconditions i tname =
282− if i==0
283− then pure Nothing
284− else do
285− withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
286− Pool.withResource appPool $ \conn -> do
287− t <- getCurrentTime
288− j1 <- Job.createJob conn tname (PayloadSucceed $ 4 * Job.defaultPollingInterval)
289− j2 <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral $ unSeconds $ Job.defaultPollingInterval * 2) t)
290− let waitForPrecondition = do
291− k1 <- Job.findJobByIdIO conn tname (jobId j1)
292− k2 <- Job.findJobByIdIO conn tname (jobId j2)
293− case (jobStatus <$> k1, jobStatus <$> k2) of
294− (Nothing, _) -> do
295− traceM "Not expecting first job to be completed so quickly. Trying again."
296− setupPreconditions (i-1) tname
297− (_, Nothing) -> do
298− traceM "Not expecting second job to be completed so quickly. Trying again."
299− setupPreconditions (i-1) tname
300− (Just Job.Locked, Just Job.Queued) -> pure $ Just (j1, j2, logRef) -- precondition met
301− (Just Job.Success, _) -> setupPreconditions (i-1) tname
302− (_, Just Job.Success) -> setupPreconditions (i-1) tname
303− (Just Job.Queued, Just Job.Queued) -> do
304− delaySeconds 1
305− waitForPrecondition
306− x -> fail $ "Unexpeted job statuses while setting up the test: " <> show x
307− waitForPrecondition
308364
309365testJobScheduling appPool jobPool = testCase "job scheduling" $ do
310366 withNewJobMonitor jobPool $ \tname logRef -> do
311367 Pool.withResource appPool $ \conn -> do
312368 t <- getCurrentTime
313− job@Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral 3600) t)
369+ job@Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral (3600 :: Integer)) t)
314370 delaySeconds $ Seconds 2
315371 assertJobIdStatus conn tname logRef "Job is scheduled in the future. It should NOT have been successful by now" Job.Queued jobId
316− j <- Job.saveJobIO conn tname job{jobRunAt = (addUTCTime (fromIntegral (-1)) t)}
317− delaySeconds (Job.defaultPollingInterval + (Seconds 2))
372+ _ <- Job.saveJobIO conn tname job{jobRunAt = addUTCTime (fromIntegral (-1 :: Integer)) t}
373+ delaySeconds (Job.defaultPollingInterval + Seconds 2)
318374 assertJobIdStatus conn tname logRef "Job had a runAt date in the past. It should have been successful by now" Job.Success jobId
319375
320376testJobFailure appPool jobPool = testCase "job failure" $ do
321− withNewJobMonitor jobPool $ \tname logRef -> do
377+ withNewJobMonitor jobPool $ \tname _logRef -> do
322378 Pool.withResource appPool $ \conn -> do
323379 Job{jobId} <- Job.createJob conn tname (PayloadAlwaysFail 0)
324− delaySeconds $ (calculateRetryDelay testMaxAttempts) + (Seconds 3)
380+ delaySeconds $ calculateRetryDelay testMaxAttempts + Seconds 3
325381 Job{jobAttempts, jobStatus} <- ensureJobId conn tname jobId
326382 assertEqual "Exepcting job to be in Failed status" Job.Failed jobStatus
327383 assertEqual ("Expecting job attempts to be 3. Found " <> show jobAttempts) 3 jobAttempts
328384
329−
330385testPushFailedJobEndQueue :: Pool Connection -> TestTree
331386testPushFailedJobEndQueue jobPool = testCase "testPushFailedJobEndQueue" $ do
332387 withRandomTable jobPool $ \tname -> do
333388 Pool.withResource jobPool $ \conn -> do
334389 job1 <- Job.createJob conn tname (PayloadAlwaysFail 0)
335390 job2 <- Job.createJob conn tname (PayloadAlwaysFail 0)
336− Job.saveJobIO conn tname (job1 {jobAttempts = 1})
391+ _ <- Job.saveJobIO conn tname (job1 {jobAttempts = 1})
337392 [Only resId] <- Job.jobPollingIO conn "testPushFailedJobEndQueue" tname 5
338393 assertEqual
339394 "Expecting the current job to be 2 since job 1 has been modified"
340395 (jobId job2) resId
341396
342−testJobErrHandler appPool jobPool = testCase "job error handler" $ do
397+testOnJobFailed appPool jobPool = testCase "job error handler" $ do
343398 withRandomTable jobPool $ \tname -> do
344399 Pool.withResource appPool $ \conn -> do
345− Job.createJob conn tname (PayloadThrowStringException "forced exception")
346− Job.createJob conn tname PayloadThrowDivideByZero
400+ _ <- Job.createJob conn tname (PayloadThrowStringException "forced exception")
401+ _ <- Job.createJob conn tname PayloadThrowDivideByZero
347402 mvar1 <- newMVar False
348403 mvar2 <- newMVar False
349404 let addErrHandlers cfg = cfg { Job.cfgOnJobFailed = [ Job.JobErrHandler errHandler2
350405 , Job.JobErrHandler errHandler1
351406 ]
352407 }
353− errHandler1 (e :: StringException) _ _ = swapMVar mvar1 True
354− errHandler2 (e :: ArithException) _ _ = swapMVar mvar2 True
355− withNamedJobMonitor tname jobPool addErrHandlers $ \logRef -> do
408+ errHandler1 (_ :: StringException) _ _ = swapMVar mvar1 True
409+ errHandler2 (_ :: ArithException) _ _ = swapMVar mvar2 True
410+ withNamedJobMonitor tname jobPool addErrHandlers $ \_logRef -> do
356411 delaySeconds (Job.defaultPollingInterval * 3)
357412 readMVar mvar1 >>= assertEqual "Error Handler 1 doesn't seem to have run" True
358413 readMVar mvar2 >>= assertEqual "Error Handler 2 doesn't seem to have run" True
359414
415+testOnJobStart appPool jobPool = testCase "onJobStart" $ do
416+ withRandomTable jobPool $ \tname -> do
417+ callbackRef <- newIORef False
418+ let callback = const $ modifyIORef' callbackRef (const True)
419+ withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobStart=callback}) $ \_logRef -> do
420+ Pool.withResource appPool $ \conn -> do
421+ _ <- Job.createJob conn tname (PayloadSucceed 0)
422+ delaySeconds (2 * Job.defaultPollingInterval)
423+ readIORef callbackRef >>= assertBool "It seems that onJobStart callback has not been called"
424+
425+testOnJobSuccess appPool jobPool = testCase "onJobSuccess" $ do
426+ withRandomTable jobPool $ \tname -> do
427+ callbackRef <- newIORef False
428+ let callback = const $ modifyIORef' callbackRef (const True)
429+ withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobSuccess=callback}) $ \_logRef -> do
430+ Pool.withResource appPool $ \conn -> do
431+ _ <- Job.createJob conn tname (PayloadSucceed 0)
432+ delaySeconds (2 * Job.defaultPollingInterval)
433+ readIORef callbackRef >>= assertBool "It seems that onJobSuccess callback has not been called"
434+
435+testOnJobTimeout appPool jobPool = testCase "onJobTimeout" $ do
436+ withRandomTable jobPool $ \tname -> do
437+ callbackRef <- newIORef False
438+ let callback = const $ modifyIORef' callbackRef (const True)
439+ withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobTimeout=callback, Job.cfgDefaultJobTimeout=Seconds 2}) $ \_logRef -> do
440+ Pool.withResource appPool $ \conn -> do
441+ _ <- Job.createJob conn tname (PayloadSucceed 10)
442+ delaySeconds (2 * Job.defaultPollingInterval)
443+ readIORef callbackRef >>= assertBool "It seems that onJobTimeout callback has not been called"
444+
360445testRetryBackoff appPool jobPool = testCase "retry backoff" $ do
361446 withRandomTable jobPool $ \tname -> do
362− withNamedJobMonitor tname jobPool modifyRetryBackoff $ \logRef -> do
447+ withNamedJobMonitor tname jobPool modifyRetryBackoff $ \_logRef -> do
363448 Pool.withResource appPool $ \conn -> do
364449 jobQueueTime <- getCurrentTime
365450 Job{jobId} <- Job.createJob conn tname (PayloadAlwaysFail 0)
366451 delaySeconds (2 * Job.defaultPollingInterval)
367452
368453 let assertBackedOff = do
369− job@Job{jobAttempts, jobStatus, jobRunAt} <- ensureJobId conn tname jobId
454+ Job{jobAttempts, jobStatus, jobRunAt} <- ensureJobId conn tname jobId
370455 assertEqual "Exepcting job to be in Retry status" Job.Retry jobStatus
371456 assertBool "Expecting job runAt to be in the future"
372457 (jobRunAt >= addUTCTime (fromIntegral $ unSeconds $ backoff jobAttempts) jobQueueTime)
@@ -375,7 +460,7 @@ testRetryBackoff appPool jobPool = testCase "retry backoff" $ do
375460
376461 -- Run it for another attempt and check the backoff scales
377462 job <- ensureJobId conn tname jobId
378− Job.saveJobIO conn tname (job {jobRunAt = jobQueueTime})
463+ _ <- Job.saveJobIO conn tname (job {jobRunAt = jobQueueTime})
379464 delaySeconds (2 * Job.defaultPollingInterval)
380465
381466 assertBackedOff
@@ -386,6 +471,186 @@ testRetryBackoff appPool jobPool = testCase "retry backoff" $ do
386471 modifyRetryBackoff cfg
387472 = cfg { Job.cfgDefaultRetryBackoff = pure . backoff }
388473
474+testResourceLimitedScheduling _appPool jobPool = testGroup "concurrency control by resources"
475+ [ testCase "resources control how jobs run" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
476+ let firstResource = [(Job.ResourceId "first", 1)]
477+ secondResource = [(Job.ResourceId "second", 1)]
478+
479+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
480+ Job{jobId = secondJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
481+ Job{jobId = thirdJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) secondResource
482+
483+ _jone <- runSingleJobFromQueue cfg
484+ _jtwo <- runSingleJobFromQueue cfg
485+ _jthree <- runSingleJobFromQueue cfg
486+
487+ assertJobIdStatus conn tname logRef "Job was created first - it should be running" Job.Locked firstJob
488+ assertJobIdStatus conn tname logRef "Job uses same resources as first - it shouldn't be running" Job.Queued secondJob
489+ assertJobIdStatus conn tname logRef "Job uses different resources - it should be running" Job.Locked thirdJob
490+
491+ , testCase "resource-less jobs run normally" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
492+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) []
493+ Job{jobId = secondJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) []
494+
495+ _jone <- runSingleJobFromQueue cfg
496+ _jtwo <- runSingleJobFromQueue cfg
497+
498+ assertJobIdStatus conn tname logRef "First job uses no resources - it should be running" Job.Locked firstJob
499+ assertJobIdStatus conn tname logRef "Second job uses no resources - it should be running" Job.Locked secondJob
500+
501+ , testCase "jobs can hold multiple resources" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
502+ let firstResource = [(Job.ResourceId "first", 1)]
503+ secondResource = [(Job.ResourceId "second", 1)]
504+
505+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) (firstResource <> secondResource)
506+ Job{jobId = secondJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
507+ Job{jobId = thirdJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) secondResource
508+
509+ jone <- runSingleJobFromQueue cfg
510+ jtwo <- runSingleJobFromQueue cfg
511+ jthree <- runSingleJobFromQueue cfg
512+ assertEqual "first job claimed the work" (void jone) (Just ())
513+ assertEqual "second thread was blocked" (void jtwo) Nothing
514+ assertEqual "third thread was blocked " (void jthree) Nothing
515+
516+ assertJobIdStatus conn tname logRef "Job was created first - it should be running" Job.Locked firstJob
517+ assertJobIdStatus conn tname logRef "Job uses same resources as first - it shouldn't be running" Job.Queued secondJob
518+ assertJobIdStatus conn tname logRef "Job uses same resources as first - it shouldn't be running" Job.Queued thirdJob
519+
520+ , testCase "resources are freed when jobs succeed" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
521+ let firstResource = [(Job.ResourceId "first", 1)]
522+
523+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
524+ Job{jobId = secondJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
525+
526+ jone <- runSingleJobFromQueue cfg
527+ _jtwo <- runSingleJobFromQueue cfg
528+
529+ assertJobIdStatus conn tname logRef "Job was created first - it should be running" Job.Locked firstJob
530+ assertJobIdStatus conn tname logRef "Job uses same resources as first - it shouldn't be running" Job.Queued secondJob
531+
532+ forM_ jone wait
533+
534+ _three <- runSingleJobFromQueue cfg
535+
536+ assertJobIdStatus conn tname logRef "First job should have succeeded by now" Job.Success firstJob
537+ assertJobIdStatus conn tname logRef "Second job should be running after first job suceeded" Job.Locked secondJob
538+
539+ , testCase "resources are freed when jobs fail" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
540+ let firstResource = [(Job.ResourceId "first", 1)]
541+
542+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadAlwaysFail 10) firstResource
543+ Job{jobId = secondJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
544+
545+ jone <- runSingleJobFromQueue cfg
546+ _jtwo <- runSingleJobFromQueue cfg
547+
548+ assertJobIdStatus conn tname logRef "Job was created first - it should be running" Job.Locked firstJob
549+ assertJobIdStatus conn tname logRef "Job uses same resources as first - it shouldn't be running" Job.Queued secondJob
550+
551+ jobRes <- forM jone waitCatch
552+ assertEqual "should've thrown an exception" (Just (Left ())) (first (const ()) <$> jobRes)
553+
554+ _three <- runSingleJobFromQueue cfg
555+
556+ assertJobIdStatus conn tname logRef "First job should have failed by now" Job.Failed firstJob
557+ assertJobIdStatus conn tname logRef "Second job should be running after first job failed" Job.Locked secondJob
558+
559+ , testCase "resources allow for multiple usage" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
560+ let firstResource = [(Job.ResourceId "first", 1)]
561+ resCfg' = resCfg { Job.resCfgDefaultLimit = 2 }
562+
563+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg' (PayloadSucceed 10) firstResource
564+ Job{jobId = secondJob} <- Job.createJobWithResources conn tname resCfg' (PayloadSucceed 10) firstResource
565+
566+ _jone <- runSingleJobFromQueue cfg
567+ _jtwo <- runSingleJobFromQueue cfg
568+
569+ assertJobIdStatus conn tname logRef "First job uses some resources - it should be running" Job.Locked firstJob
570+ assertJobIdStatus conn tname logRef "Second job uses some resources - it should be running" Job.Locked secondJob
571+
572+ , testCase "resource usage can differ by job" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
573+ let firstResource n = [(Job.ResourceId "first", n)]
574+ resCfg' = resCfg { Job.resCfgDefaultLimit = 5 }
575+
576+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg' (PayloadSucceed 10) (firstResource 4)
577+ Job{jobId = secondJob} <- Job.createJobWithResources conn tname resCfg' (PayloadSucceed 10) (firstResource 3)
578+ Job{jobId = thirdJob} <- Job.createJobWithResources conn tname resCfg' (PayloadSucceed 10) (firstResource 2)
579+
580+ jone <- runSingleJobFromQueue cfg
581+ _jtwo <- runSingleJobFromQueue cfg
582+ _jthree <- runSingleJobFromQueue cfg
583+
584+ -- TODO, so it's not locked but already succeeded, which is werid, but the rest is valid?
585+ assertJobIdStatus conn tname logRef "Job was created first - it should be running" Job.Locked firstJob
586+ assertJobIdStatus conn tname logRef "Job doesn't fit alongside first job - it shouldn't be running" Job.Queued secondJob
587+ assertJobIdStatus conn tname logRef "Job doesn't fit alongside first job - it shouldn't be running" Job.Queued thirdJob
588+
589+ forM_ jone wait
590+
591+ _jfour <- runSingleJobFromQueue cfg
592+ _jfive <- runSingleJobFromQueue cfg
593+
594+ assertJobIdStatus conn tname logRef "First job should have succeeded by now" Job.Success firstJob
595+ assertJobIdStatus conn tname logRef "Second job should be running after first job suceeded" Job.Locked secondJob
596+ assertJobIdStatus conn tname logRef "Third job fits alongside second job - it should be running after first job suceeded" Job.Locked thirdJob
597+
598+ , testCase "resource limits permanently block too-big jobs" $ setup jobPool $ \tname resCfg logRef conn cfg -> do
599+ let firstResource = [(Job.ResourceId "first", 2)]
600+
601+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
602+
603+ void $ runSingleJobFromQueueBlock cfg
604+
605+ assertJobIdStatus conn tname logRef "Job requires too many resources - shouldn't be running" Job.Queued firstJob
606+
607+ , testCase "resources with 0 limit block jobs" $ setup' jobPool 0 $ \tname resCfg logRef conn cfg -> do
608+ let firstResource = [(Job.ResourceId "first", 1)]
609+
610+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
611+
612+ void $ runSingleJobFromQueueBlock cfg
613+
614+ assertJobIdStatus conn tname logRef "Job can't access 0-limited resources - shouldn't be running" Job.Queued firstJob
615+
616+ , testCase "resources with negative limit block jobs" $ setup' jobPool (-5) $ \tname resCfg logRef conn cfg -> do
617+ let firstResource = [(Job.ResourceId "first", 1)]
618+
619+ Job{jobId = firstJob} <- Job.createJobWithResources conn tname resCfg (PayloadSucceed 10) firstResource
620+
621+ void $ runSingleJobFromQueueBlock cfg
622+
623+ assertJobIdStatus conn tname logRef "Job can't access negative-limited resources - shouldn't be running" Job.Queued firstJob
624+ ]
625+
626+setup jobPool = setup' jobPool 1
627+
628+setup' jobPool defaultLimit action =
629+ withRandomTable jobPool $ \tname ->
630+ withRandomResourceTables defaultLimit jobPool tname $ \resCfg ->
631+ withConfig tname jobPool (cfgFn resCfg) $ \logRef cfg -> do
632+ Pool.withResource jobPool $ \conn -> action tname resCfg logRef conn cfg
633+
634+ where
635+ cfgFn resCfg cfg = cfg
636+ { Job.cfgDefaultMaxAttempts = 1 -- Simplifies some tests where we have a failing job
637+ , Job.cfgConcurrencyControl = Job.ResourceLimits resCfg
638+ , Job.cfgDeleteSuccessfulJobs = False
639+ }
640+
641+testKillJob appPool jobPool = testCase "killing a ongoing job" $ do
642+ withRandomTable jobPool $ \tname -> withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
643+ Pool.withResource appPool $ \conn -> do
644+ Job{jobId = jid} <- Job.createJob conn tname (PayloadSucceed 60)
645+ delaySeconds $ Job.defaultPollingInterval + Seconds 2
646+
647+ assertJobIdStatus conn tname logRef "Job should be running" Job.Locked jid
648+
649+ _ <- Job.killJobIO conn tname jid
650+ delaySeconds $ Job.defaultPollingInterval + Seconds 2
651+
652+ assertJobIdStatus conn tname logRef "Job is cancelled and the job thread should be killed" Job.Cancelled jid
653+
389654data JobEvent = JobStart
390655 | JobRetry
391656 | JobSuccess
@@ -487,7 +752,7 @@ data JobEvent = JobStart
487752
488753
489754payloadDelay :: Seconds -> JobPayload -> Seconds
490−payloadDelay jobPollingInterval pload = payloadDelay_ (Seconds 0) pload
755+payloadDelay jobPollingInterval = payloadDelay_ (Seconds 0)
491756 where
492757 payloadDelay_ total p =
493758 let defaultDelay x = total + x + jobPollingInterval
@@ -498,10 +763,6 @@ payloadDelay jobPollingInterval pload = payloadDelay_ (Seconds 0) pload
498763 PayloadThrowStringException _ -> defaultDelay 0
499764 PayloadThrowDivideByZero -> defaultDelay 0
500765
501−
502−
503−-- TODO: test to ensure that errors in callback do not affect the running of jobs
504−
505766deriving instance Enum Time.Unit
506767deriving instance Enum Time.Direction
507768
@@ -513,7 +774,7 @@ timeGen t d = do
513774 pure $ Time.timeSince t (fromInteger i) u d
514775
515776anyTimeGen :: MonadGen m => UTCTime -> m UTCTime
516−anyTimeGen t = (Gen.element (enumFrom Time.Ago)) >>= (timeGen t)
777+anyTimeGen t = Gen.element (enumFrom Time.Ago) >>= timeGen t
517778
518779futureTimeGen :: MonadGen m => UTCTime -> m UTCTime
519780futureTimeGen t = timeGen t Time.FromThat
@@ -565,8 +826,8 @@ genFilter t = do
565826 updatedBefore <- case updatedAfter of
566827 Nothing -> Gen.maybe (anyTimeGen t)
567828 Just x -> Gen.maybe (futureTimeGen x)
568− orderClause <- Gen.maybe ((,) <$> (Gen.element $ enumFrom Web.OrdCreatedAt) <*> (Gen.element $ enumFrom Web.Asc))
569− limitOffset <- Gen.maybe ((,) <$> (Gen.int (Range.constant 5 10)) <*> (Gen.int (Range.constant 0 30)))
829+ orderClause <- Gen.maybe ((,) <$> Gen.element (enumFrom Web.OrdCreatedAt) <*> Gen.element (enumFrom Web.Asc))
830+ limitOffset <- Gen.maybe ((,) <$> Gen.int (Range.constant 5 10) <*> Gen.int (Range.constant 0 30))
570831 runAfter <- Gen.maybe (futureTimeGen t)
571832 pure Web.blankFilter
572833 { filterStatuses = statuses
@@ -584,23 +845,23 @@ filterJobs :: Filter -> [Job] -> [Job]
584845filterJobs Web.Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterOrder, filterPage, filterRunAfter} js =
585846 applyLimitOffset $
586847 applyOrdering (fromMaybe (Web.OrdUpdatedAt, Web.Desc) filterOrder) $
587− (flip DL.filter) js $ \j -> (filterByStatus j) &&
588− (filterByCreatedAfter j) &&
589− (filterByCreatedBefore j) &&
590− (filterByUpdatedAfter j) &&
591− (filterByUpdatedBefore j) &&
592− (filterByRunAfter j)
848+ flip DL.filter js $ \j -> filterByStatus j &&
849+ filterByCreatedAfter j &&
850+ filterByCreatedBefore j &&
851+ filterByUpdatedAfter j &&
852+ filterByUpdatedBefore j &&
853+ filterByRunAfter j
593854 where
594− applyLimitOffset = maybe Prelude.id (\(l, o) -> (Prelude.take l). (Prelude.drop o)) filterPage
855+ applyLimitOffset = maybe Prelude.id (\(l, o) -> Prelude.take l . Prelude.drop o) filterPage
595856
596857 applyOrdering (fld, dir) lst =
597858 let comparer = resultOrder $ case fld of
598− Web.OrdCreatedAt -> (comparing jobCreatedAt)
599− Web.OrdUpdatedAt -> (comparing jobUpdatedAt)
600− Web.OrdLockedAt -> (comparing jobLockedAt)
601− Web.OrdStatus -> (comparing jobStatus)
859+ Web.OrdCreatedAt -> comparing jobCreatedAt
860+ Web.OrdUpdatedAt -> comparing jobUpdatedAt
861+ Web.OrdLockedAt -> comparing jobLockedAt
862+ Web.OrdStatus -> comparing jobStatus
602863 Web.OrdJobType -> comparing Job.defaultJobType
603− resultOrder fn = \x y -> case fn x y of
864+ resultOrder fn x y = case fn x y of
604865 EQ -> compare (Down $ jobId x) (Down $ jobId y)
605866 LT -> case dir of
606867 Web.Asc -> LT
@@ -610,9 +871,8 @@ filterJobs Web.Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, f
610871 Web.Desc -> LT
611872 in sortBy comparer lst
612873
613− filterByStatus Job.Job{jobStatus} = if Prelude.null filterStatuses
614− then True
615− else jobStatus `elem` filterStatuses
874+ filterByStatus Job.Job{jobStatus} = Prelude.null filterStatuses
875+ || (jobStatus `elem` filterStatuses)
616876 filterByCreatedAfter Job.Job{jobCreatedAt} = maybe True (<= jobCreatedAt) filterCreatedAfter
617877 filterByCreatedBefore Job.Job{jobCreatedAt} = maybe True (> jobCreatedAt) filterCreatedBefore
618878 filterByUpdatedAfter Job.Job{jobUpdatedAt} = maybe True (<= jobUpdatedAt) filterUpdatedAfter