on master help sign in
23 files changed+981−300
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml+2−2 .github/workflows/nix.yaml+34 CHANGELOG.md+4 default.nix+10 examples/OddJobsCliExample.lhs+2−2 flake.lock+44 flake.nix+44 ghc-8.10.7.stack.yaml−8 ghc-8.10.7.stack.yaml.lock−40 ghc-9.2.5.stack.yaml−6 ghc-9.2.5.stack.yaml.lock−26 odd-jobs.cabal+46−12 package.yaml+3−2 shell.nix+10 src/OddJobs/Cli.hs+50−38 src/OddJobs/ConfigBuilder.hs+12−9 src/OddJobs/Endpoints.hs+13 src/OddJobs/Job.hs+297−84 src/OddJobs/Migrations.hs+59−1 src/OddJobs/Types.hs+46−2 src/OddJobs/Web.hs+25−1 stack.yaml+2−2 test/Test.hs+278−65
.github/workflows/ci.yml+2−2
@@ -14,8 +14,8 @@ jobs:
1414 strategy:
1515 matrix:
1616 os: [ubuntu-latest]
17− stack: ["2.9.1"]
18− ghc: ["8.8.4", "8.10.7"]
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/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"
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+2−2
@@ -72,11 +72,11 @@ In this example, the core job-runner function is in the `IO` monad. In all proba
7272myJobRunner :: Job -> IO ()
7373myJobRunner job = do
7474 throwParsePayload job >>= \case
75− SendWelcomeEmail _userId -> do
75+ SendWelcomeEmail userId -> do
7676 putStrLn $ "This should call the function that actually sends the welcome email. " <>
7777 "\nWe are purposely waiting 60 seconds before completing this job so that graceful shutdown can be demonstrated."
7878 delaySeconds (Seconds 60)
79− putStrLn "60 second wait is now over..."
79+ putStrLn $ "SendWelcomeEmail to user: " <> show userId <> " complete (60 second wait is now over...)"
8080 SendPasswordResetEmail _tkn ->
8181 putStrLn "This should call the function that actually sends the password-reset email"
8282 SetupSampleData _userId -> do
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+}
ghc-8.10.7.stack.yamldeleted−8
@@ -1,8 +0,0 @@
1−resolver: lts-18.28
2−packages:
3− - .
4−extra-deps:
5− - daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
6− - servant-lucid-0.9.0.5@sha256:c46780eefc12544b862af1dd7c7f88715e5fb45d954da538b0641d42c7b5f5c6,1787
7− - servant-static-th-1.0.0.0@sha256:7dc46a426d8c78ee0386729159e8bbc18ff98e0dd700a2c723c87901b204fa6c,3883
8− - timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
ghc-8.10.7.stack.yaml.lockdeleted−40
@@ -1,40 +0,0 @@
1−# This file was autogenerated by Stack.
2−# You should not edit this file by hand.
3−# For more information, please see the documentation at:
4−# https://docs.haskellstack.org/en/stable/lock_files
5−
6−packages:
7−- completed:
8− hackage: daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
9− pantry-tree:
10− sha256: 26b1a5279be340c896edbc72baa6d95b071384b9bf7e19cd146025cf13d21769
11− size: 918
12− original:
13− hackage: daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
14−- completed:
15− hackage: servant-lucid-0.9.0.5@sha256:c46780eefc12544b862af1dd7c7f88715e5fb45d954da538b0641d42c7b5f5c6,1787
16− pantry-tree:
17− sha256: 7d529522482f4f83a62430a0fe245afc6272a5b9a63f2581388ac602654806c0
18− size: 392
19− original:
20− hackage: servant-lucid-0.9.0.5@sha256:c46780eefc12544b862af1dd7c7f88715e5fb45d954da538b0641d42c7b5f5c6,1787
21−- completed:
22− hackage: servant-static-th-1.0.0.0@sha256:7dc46a426d8c78ee0386729159e8bbc18ff98e0dd700a2c723c87901b204fa6c,3883
23− pantry-tree:
24− sha256: 13269fcc1c871ee1548d31010f0278484318bffbd9b8e0c0bfdfab4e27d6c3d5
25− size: 1493
26− original:
27− hackage: servant-static-th-1.0.0.0@sha256:7dc46a426d8c78ee0386729159e8bbc18ff98e0dd700a2c723c87901b204fa6c,3883
28−- completed:
29− hackage: timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
30− pantry-tree:
31− sha256: fbcb00f5b8a4f7b8fd94b034b7f507ffdd7579848de782ce60ad2b661dcb2b42
32− size: 469
33− original:
34− hackage: timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
35−snapshots:
36−- completed:
37− sha256: 428ec8d5ce932190d3cbe266b9eb3c175cd81e984babf876b64019e2cbe4ea68
38− size: 590100
39− url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/18/28.yaml
40− original: lts-18.28
ghc-9.2.5.stack.yamldeleted−6
@@ -1,6 +0,0 @@
1−resolver: lts-20.3
2−packages:
3− - .
4−extra-deps:
5− - daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
6− - timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
ghc-9.2.5.stack.yaml.lockdeleted−26
@@ -1,26 +0,0 @@
1−# This file was autogenerated by Stack.
2−# You should not edit this file by hand.
3−# For more information, please see the documentation at:
4−# https://docs.haskellstack.org/en/stable/lock_files
5−
6−packages:
7−- completed:
8− hackage: daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
9− pantry-tree:
10− sha256: 26b1a5279be340c896edbc72baa6d95b071384b9bf7e19cd146025cf13d21769
11− size: 918
12− original:
13− hackage: daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
14−- completed:
15− hackage: timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
16− pantry-tree:
17− sha256: fbcb00f5b8a4f7b8fd94b034b7f507ffdd7579848de782ce60ad2b661dcb2b42
18− size: 469
19− original:
20− hackage: timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
21−snapshots:
22−- completed:
23− sha256: 03cec7d96ed78877b03b5c2bc5e31015b47f69af2fe6a62d994a42b7a43c5805
24− size: 648659
25− url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/20/3.yaml
26− original: lts-20.3
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+50−38
@@ -5,13 +5,17 @@
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
913import OddJobs.Job (startJobRunner, Config(..), LogLevel(..), LogEvent(..))
1014import OddJobs.Types (UIConfig(..), Seconds(..), delaySeconds)
11−-- import System.Daemonize (DaemonOptions(..), daemonize)
12−import qualified System.Posix.Daemon as Daemon
13−import System.FilePath (FilePath)
15+import qualified System.Posix.Daemonize as Daemonize
16+import System.FilePath (FilePath, takeBaseName, takeDirectory)
1417import System.Posix.Process (getProcessID)
18+import System.Posix.Signals (Handler(CatchOnce), installHandler, sigTERM)
1519import qualified System.Directory as Dir
1620import qualified System.Exit as Exit
1721import System.Environment (getProgName)
@@ -101,6 +105,33 @@ runCli cliType = do
101105 Status ->
102106 Prelude.error "not implemented yet"
103107
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+
104135{-| Used by 'defaultMain' if the 'Start' command is issued via the CLI. If
105136@--daemonize@ switch is also passed, it checks for 'startPidFile':
106137
@@ -116,7 +147,12 @@ defaultStartCommand :: CommonStartArgs
116147 -> IO ()
117148defaultStartCommand CommonStartArgs{..} mUIArgs cliType = do
118149 if startDaemonize then do
119− Daemon.runDetached (Just startPidFile) (Daemon.ToFile "/tmp/oddjobs.out") coreStartupFn
150+ Daemonize.serviced
151+ $ Daemonize.simpleDaemon
152+ { Daemonize.program = \() -> withGracefulTermination_ coreStartupFn
153+ , Daemonize.name = Just $ takeBaseName startPidFile
154+ , Daemonize.pidfileDirectory = Just $ takeDirectory startPidFile
155+ }
120156 else
121157 coreStartupFn
122158 where
@@ -155,7 +191,7 @@ defaultWebUI UIStartArgs{..} uicfg@UIConfig{..} = do
155191 Warp.run uistartPort $ Servant.serveWithContext api ctx app
156192
157193{-| Used by 'defaultMain' if 'Stop' command is issued via the CLI. Sends a
158−@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
159195of 'shutTimeout' seconds (controller by @--timeout@) for the daemon to shutdown
160196gracefully, after which a @SIGKILL@ is issued
161197-}
@@ -163,27 +199,15 @@ defaultStopCommand :: StopArgs
163199 -> IO ()
164200defaultStopCommand StopArgs{..} = do
165201 progName <- getProgName
166− -- pid <- read <$> (readFile shutPidFile)
167− if shutTimeout == Seconds 0
168− then Daemon.brutalKill shutPidFile
169− else do putStrLn $ "Sending sigINT to " <> show progName <>
202+ putStrLn $ "Sending SIGTERM to " <> show progName <>
170203 " and waiting " <> show (unSeconds shutTimeout) <> " seconds for graceful stop"
171− readFile shutPidFile >>= Sig.signalProcess Sig.sigINT . read
172− Async.race (delaySeconds shutTimeout) checkProcessStatus >>= \case
173− Right _ -> do
174− putStrLn $ progName <> " seems to have exited gracefully."
175− Exit.exitSuccess
176− Left _ -> do
177− putStrLn $ progName <> " has still not exited. Sending sigKILL for forced exit."
178− Daemon.brutalKill shutPidFile
179− where
180− checkProcessStatus = do
181− Daemon.isRunning shutPidFile >>= \case
182− True -> do
183− delaySeconds (Seconds 1)
184− checkProcessStatus
185− False -> do
186− pure ()
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+
187211
188212-- * Default CLI parsers
189213--
@@ -298,7 +322,7 @@ webUiAuthParser = basicAuthParser <|> noAuthParser
298322-- 'shutTimeout' seconds. If the daemon doesn't shut down cleanly within that
299323-- time, it sends a @SIGKILL@ to kill immediately.
300324data StopArgs = StopArgs
301− { -- | 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
302326 -- @SIGKILL@
303327 shutTimeout :: !Seconds
304328 -- | PID file of the deamon. Ref: 'pidFileParser'
@@ -310,8 +334,6 @@ stopParser = fmap Stop $ StopArgs
310334 <$> option (Seconds <$> auto) ( long "timeout" <>
311335 metavar "TIMEOUT" <>
312336 help "Maximum seconds to wait before force-killing the background daemon."
313− -- value defaultTimeout <>
314− -- showDefaultWith (show . unSeconds)
315337 )
316338 <*> pidFileParser
317339
@@ -344,16 +366,6 @@ defaultCliInfo :: CliType -> ParserInfo Args
344366defaultCliInfo cliType =
345367 info (argParser cliType <**> helper) fullDesc
346368
347−-- defaultDaemonOptions :: DaemonOptions
348−-- defaultDaemonOptions = DaemonOptions
349−-- { daemonShouldChangeDirectory = False
350−-- , daemonShouldCloseStandardStreams = False
351−-- , daemonShouldIgnoreSignals = True
352−-- , daemonUserToChangeTo = Nothing
353−-- , daemonGroupToChangeTo = Nothing
354−-- }
355−
356−
357369-- ** Auth implementations for the default Web UI
358370
359371-- *** Basic Auth
src/OddJobs/ConfigBuilder.hs+12−9
@@ -144,6 +144,10 @@ defaultLogStr jobTypeFn logLevel logEvent =
144144 LogJobTimeout j@Job{jobLockedAt, jobLockedBy} ->
145145 "Timeout | " <> jobToLogStr j <> " | lockedBy=" <> toLogStr (maybe "unknown" unJobRunnerName jobLockedBy) <>
146146 " 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 ->
@@ -286,17 +290,10 @@ withConnectionPool connConfig action = withRunInIO $ \runInIO -> do
286290 where
287291 poolCreator = liftIO $
288292 case connConfig of
289−#if MIN_VERSION_resource_pool(0,3,0)
290293 Left connString ->
291− newPool $ PoolConfig (PGS.connectPostgreSQL connString) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
294+ newPool $ defaultPoolConfig (PGS.connectPostgreSQL connString) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
292295 Right connInfo ->
293− newPool $ PoolConfig (PGS.connect connInfo) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
294−#else
295− Left connString ->
296− createPool (PGS.connectPostgreSQL connString) PGS.close 1 (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
297− Right connInfo ->
298− createPool (PGS.connect connInfo) PGS.close 1 (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
299−#endif
296+ newPool $ defaultPoolConfig (PGS.connect connInfo) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
300297
301298-- | A convenience function to help you define a timed-logger with some sensible
302299-- defaults.
@@ -328,6 +325,12 @@ defaultJsonLogEvent logEvent =
328325 LogJobTimeout job ->
329326 Aeson.object [ "tag" Aeson..= ("LogJobTimeout" :: Text)
330327 , "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 ]
331334 LogPoll ->
332335 Aeson.object [ "tag" Aeson..= ("LogJobPoll" :: Text)]
333336 LogWebUIRequest ->
src/OddJobs/Endpoints.hs+13
@@ -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,6 +60,7 @@ 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)
@@ -113,6 +115,7 @@ server cfg env nt =
113115 { rFilterResults = nt . filterResults cfg env
114116 , rEnqueue = nt . enqueueJob cfg env
115117 , rCancel = nt . cancelJob cfg env
118+ , rKill = nt . killJob cfg env
116119 , rRunNow = nt . runJobNow cfg env
117120 , rRefreshJobTypes = nt $ refreshJobTypes cfg env
118121 , rRefreshJobRunners = nt $ refreshJobRunners cfg env
@@ -125,6 +128,7 @@ server2 cfg env = Routes
125128 { rFilterResults = filterResults cfg env
126129 , rEnqueue = enqueueJob cfg env
127130 , rCancel = cancelJob cfg env
131+ , rKill = killJob cfg env
128132 , rRunNow = runJobNow cfg env
129133 , rRefreshJobTypes = refreshJobTypes cfg env
130134 , rRefreshJobRunners = refreshJobRunners cfg env
@@ -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
@@ -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+297−84
@@ -1,4 +1,5 @@
11{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, UndecidableInstances #-}
2+{-# LANGUAGE ExistentialQuantification, RecordWildCards, ScopedTypeVariables #-}
23
34module OddJobs.Job
45 (
@@ -12,6 +13,7 @@ module OddJobs.Job
1213 -- $config
1314 , Config(..)
1415 , ConcurrencyControl(..)
16+ , ResourceCfg(..)
1517
1618 -- * Creating/scheduling jobs
1719 --
@@ -19,6 +21,9 @@ module OddJobs.Job
1921 , createJob
2022 , scheduleJob
2123
24+ , createJobWithResources
25+ , scheduleJobWithResources
26+
2227 -- * @Job@ and associated data-types
2328 --
2429 -- $dataTypes
@@ -31,6 +36,9 @@ module OddJobs.Job
3136 , Seconds(..)
3237 , JobErrHandler(..)
3338 , AllJobTypes(..)
39+ , ResourceId(..)
40+ , FunctionName
41+ , RunnerEnv(..)
3442
3543 -- ** Structured logging
3644 --
@@ -45,6 +53,7 @@ module OddJobs.Job
4553 , jobEventListener
4654 , jobPoller
4755 , jobPollingSql
56+ , pollRunJob
4857 , JobRunner
4958 , HasJobRunner (..)
5059
@@ -56,6 +65,7 @@ module OddJobs.Job
5665 , runJobNowIO
5766 , unlockJobIO
5867 , cancelJobIO
68+ , killJobIO
5969 , jobDbColumns
6070 , jobPollingIO
6171 , concatJobDbColumns
@@ -78,7 +88,7 @@ import Data.Pool(Pool)
7888import Data.Text as T
7989import Database.PostgreSQL.Simple as PGS
8090import Database.PostgreSQL.Simple.Notification
81−import UnliftIO.Async
91+import UnliftIO.Async hiding (poll)
8292import UnliftIO.Concurrent (threadDelay, myThreadId)
8393import Data.String
8494import System.Posix.Process (getProcessID)
@@ -89,7 +99,8 @@ import Control.Monad.Logger as MLogger (LogLevel(..), LogStr, toLogStr)
8999import UnliftIO.IORef
90100import UnliftIO.Exception ( SomeException(..), try, catch, finally
91101 , catchAny, bracket, Exception(..), throwIO
92− , catches, Handler(..), mask_, throwString
102+ , catches, Handler(..), mask_, onException
103+ , throwString
93104 )
94105import Data.Proxy
95106import Control.Monad.Trans.Control
@@ -100,14 +111,16 @@ import Data.Aeson hiding (Success)
100111import qualified Data.Aeson as Aeson
101112import qualified Data.Aeson.Types as Aeson (Parser, parseMaybe)
102113import Data.String.Conv (StringConv(..), toS)
103−import Data.Functor (void)
104−import Control.Monad (forever)
114+import Data.Functor ((<&>), void)
115+import Control.Monad (forever, forM_, join)
105116import Data.Maybe (isNothing, maybe, fromMaybe, listToMaybe, mapMaybe)
106117import Data.Either (either)
107118import Control.Monad.Reader
108119import GHC.Generics
120+import Data.Map (Map)
109121import qualified Data.HashMap.Strict as HM
110122import qualified Data.List as DL
123+import qualified Data.Map as DM
111124import qualified Data.ByteString as BS
112125import qualified Data.ByteString.Lazy as BSL
113126import System.FilePath (FilePath)
@@ -141,6 +154,7 @@ class (MonadUnliftIO m, MonadBaseControl IO m) => HasJobRunner m where
141154 getConcurrencyControl :: m ConcurrencyControl
142155 log :: LogLevel -> LogEvent -> m ()
143156 getDefaultJobTimeout :: m Seconds
157+ onJobTimeout :: Job -> m ()
144158 getDefaultRetryBackoff :: Int -> m Seconds
145159
146160-- $logging
@@ -158,7 +172,7 @@ class (MonadUnliftIO m, MonadBaseControl IO m) => HasJobRunner m where
158172
159173data RunnerEnv = RunnerEnv
160174 { envConfig :: !Config
161− , envJobThreadsRef :: !(IORef [Async ()])
175+ , envJobThreadsRef :: !(IORef (Map JobId (Async ())))
162176 }
163177
164178type RunnerM = ReaderT RunnerEnv IO
@@ -192,6 +206,9 @@ instance HasJobRunner RunnerM where
192206 liftIO $ loggerFn logLevel logEvent
193207
194208 getDefaultJobTimeout = asks (cfgDefaultJobTimeout . envConfig)
209+ onJobTimeout job = do
210+ fn <- asks (cfgOnJobTimeout . envConfig)
211+ logCallbackErrors (jobId job) "onJobTimeout" $ liftIO $ fn job
195212
196213 getDefaultRetryBackoff attempts = do
197214 retryFn <- asks (cfgDefaultRetryBackoff . envConfig)
@@ -204,7 +221,7 @@ instance HasJobRunner RunnerM where
204221startJobRunner :: Config -> IO ()
205222startJobRunner jm = do
206223 traceM "startJobRunner top"
207− r <- newIORef []
224+ r <- newIORef DM.empty
208225 let monitorEnv = RunnerEnv
209226 { envConfig = jm
210227 , envJobThreadsRef = r
@@ -305,7 +322,7 @@ saveJob j = do
305322saveJobIO :: Connection -> TableName -> Job -> IO Job
306323saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttempts, jobLockedBy, jobLockedAt, jobId} = do
307324 rs <- PGS.query conn saveJobQuery
308− ( tname
325+ ( tname
309326 , jobRunAt
310327 , jobStatus
311328 , jobPayload
@@ -332,7 +349,7 @@ deleteJobIO conn tname jid = do
332349runJobNowIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
333350runJobNowIO conn tname jid = do
334351 t <- getCurrentTime
335− updateJobHelper tname conn (Queued, [Queued, Retry, Failed], Just t, jid)
352+ updateJobHelper tname conn (Queued, [Queued, Retry, Failed, Cancelled], Just t, jid)
336353
337354-- | TODO: First check in all job-runners if this job is still running, or not,
338355-- and somehow send an uninterruptibleCancel to that thread.
@@ -344,7 +361,11 @@ unlockJobIO conn tname jid = do
344361
345362cancelJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
346363cancelJobIO conn tname jid =
347− 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)
348369
349370updateJobHelper :: TableName
350371 -> Connection
@@ -362,30 +383,37 @@ updateJobHelper tname conn (newStatus, existingStates, mRunAt, jid) =
362383data TimeoutException = TimeoutException deriving (Eq, Show)
363384instance Exception TimeoutException
364385
386+-- | this spawns the job on a a seperate thread, and
387+-- also spawns a monitoring thread, whichever finishes
388+-- first kills the other.
365389runJobWithTimeout :: (HasJobRunner m)
366390 => Seconds
367391 -> Job
368392 -> m ()
369−runJobWithTimeout timeoutSec job = do
393+runJobWithTimeout timeoutSec job@Job{jobId} = do
370394 threadsRef <- envJobThreadsRef <$> getRunnerEnv
371395 jobRunner_ <- getJobRunner
372396
373397 a <- async $ liftIO $ jobRunner_ job
374398
375− _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+
376404 -- liftIO $ putStrLn $ "Threads: " <> show x
377405 log LevelDebug $ LogText $ toS $ "Spawned job in " <> show (asyncThreadId a)
378406
379407 t <- async $ do
380408 delaySeconds timeoutSec
381− uninterruptibleCancel a
382409 throwIO TimeoutException
383410
384411 void $ finally
385412 (waitEitherCancel a t)
386− (atomicModifyIORef' threadsRef $ \threads -> (DL.delete a threads, ()))
413+ (atomicModifyIORef' threadsRef $ \threads -> (DM.delete jobId threads, ()))
387414
388415
416+-- | runs a job, blocks for as long as it's in progress
389417runJob :: (HasJobRunner m) => JobId -> m ()
390418runJob jid = do
391419 findJobById jid >>= \case
@@ -394,7 +422,8 @@ runJob jid = do
394422 startTime <- liftIO getCurrentTime
395423 lockTimeout <- getDefaultJobTimeout
396424 log LevelInfo $ LogJobStart job
397− flip catches [Handler $ timeoutHandler job startTime, Handler $ exceptionHandler job startTime] $ do
425+ flip catch (exceptionHandler job startTime) $ do
426+ onJobStart job
398427 runJobWithTimeout lockTimeout job
399428 endTime <- liftIO getCurrentTime
400429 shouldDeleteJob <- deleteSuccessfulJobs
@@ -406,7 +435,6 @@ runJob jid = do
406435 onJobSuccess newJob
407436 pure ()
408437 where
409− timeoutHandler job startTime (e :: TimeoutException) = retryOrFail (toException e) job startTime
410438 exceptionHandler job startTime (e :: SomeException) = retryOrFail (toException e) job startTime
411439 retryOrFail e job@Job{jobAttempts} startTime = do
412440 endTime <- liftIO getCurrentTime
@@ -424,16 +452,39 @@ runJob jid = do
424452 , jobRunAt=addUTCTime (fromIntegral $ unSeconds backoffInSeconds) t
425453 }
426454 case fromException e :: Maybe TimeoutException of
427− Nothing -> log logLevel $ LogJobFailed newJob e failureMode runTime
428− Just _ -> log logLevel $ LogJobTimeout newJob
429−
430− let tryHandler (JobErrHandler handler) res = case fromException e of
431− Nothing -> res
432− Just e_ -> void $ handler e_ newJob failureMode
433− handlers <- onJobFailed
434− 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+
435467 pure ()
436468
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+
437488-- TODO: This might have a resource leak.
438489restartUponCrash :: (HasJobRunner m, Show a) => Text -> m a -> m ()
439490restartUponCrash name_ action = do
@@ -459,9 +510,11 @@ jobMonitor = do
459510 traceM "jobMonitor top"
460511 a1 <- async $ restartUponCrash "Job poller" jobPoller
461512 a2 <- async $ restartUponCrash "Job event listener" jobEventListener
513+ a3 <- async $ restartUponCrash "Job Kill poller" killJobPoller
462514 traceM "jobMonitor after async"
463− finally (void $ waitAnyCatch [a1, a2]) $ do
515+ finally (void $ waitAnyCatch [a1, a2, a3]) $ do
464516 log LevelInfo (LogText "Stopping jobPoller and jobEventListener threads.")
517+ cancel a3
465518 cancel a2
466519 cancel a1
467520 log LevelInfo (LogText "Waiting for jobs to complete.")
@@ -469,13 +522,32 @@ jobMonitor = do
469522
470523-- | Ref: 'jobPoller'
471524jobPollingSql :: Query
472−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"
473545
474546waitForJobs :: (HasJobRunner m)
475547 => m ()
476548waitForJobs = do
477− threadsRef <- envJobThreadsRef <$> getRunnerEnv
478− readIORef threadsRef >>= \case
549+ curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef) <&> DM.elems
550+ case curJobs of
479551 [] -> log LevelInfo $ LogText "All job-threads exited"
480552 as -> do
481553 tid <- myThreadId
@@ -484,15 +556,23 @@ waitForJobs = do
484556 delaySeconds (Seconds 1)
485557 waitForJobs
486558
559+data ConcurrencyAction
560+ = DontPoll
561+ | PollAny
562+ | PollWithResources ResourceCfg
563+
487564getConcurrencyControlFn :: (HasJobRunner m)
488− => m (m Bool)
565+ => m (Connection -> m ConcurrencyAction)
489566getConcurrencyControlFn = getConcurrencyControl >>= \case
490− UnlimitedConcurrentJobs -> pure $ pure True
491− MaxConcurrentJobs maxJobs -> pure $ do
492− curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef)
493− pure $ DL.length curJobs < maxJobs
494− 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
495573
574+ where
575+ pollIf cond = if cond then PollAny else DontPoll
496576
497577jobPollingIO :: Connection -> String -> TableName -> Seconds -> IO [Only JobId]
498578jobPollingIO pollerDbConn processName tname lockTimeout = do
@@ -525,32 +605,104 @@ jobPollingIO pollerDbConn processName tname lockTimeout = do
525605jobPoller :: (HasJobRunner m) => m ()
526606jobPoller = do
527607 processName <- liftIO jobWorkerName
528− pool <- getDbPool
529− tname <- getTableName
530− lockTimeout <- getDefaultJobTimeout
531− traceM "jobPoller just before log statement"
532608 log LevelInfo $ LogText $ toS $ "Starting the job monitor via DB polling with processName=" <> processName
533609 concurrencyControlFn <- getConcurrencyControlFn
534− withResource pool $ \pollerDbConn -> forever $ concurrencyControlFn >>= \case
535− False -> do
536− log LevelWarn $ LogText "NOT polling the job queue due to concurrency control"
537− -- If we can't run any jobs ATM, relax and wait for resources to free up
538− delayAction
539− True -> do
540− join
541− (mask_ $ do
542− log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
543− r <- liftIO $ jobPollingIO pollerDbConn processName tname lockTimeout
544− case r of
545− -- When we don't have any jobs to run, we can relax a bit...
546− [] -> pure delayAction
547−
548− -- When we find a job to run, fork and try to find the next job without any delay...
549− [Only (jid :: JobId)] -> do
550− void $ async $ runJob jid
551− pure noDelayAction
552−
553− x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ show x)
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
699+ pure noDelayAction
700+
701+ x ->
702+ error $ "I was supposed to get only a single row, but got: " ++ show x
703+
704+ withResource pool (forever . pollJobToKill)
705+
554706 where
555707 delayAction = delaySeconds =<< getPollingInterval
556708 noDelayAction = pure ()
@@ -566,9 +718,17 @@ jobEventListener = do
566718 jwName <- liftIO jobWorkerName
567719 concurrencyControlFn <- getConcurrencyControlFn
568720
569− let tryLockingJob jid = do
721+ let tryLockingJob jid mResCfg = withDbConnection $ \conn -> do
570722 let q = "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 WHERE id=? AND status in ? RETURNING id"
571− 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
572732 [] -> do
573733 log LevelDebug $ LogText $ toS $ "Job was locked by someone else before I could start. Skipping it. JobId=" <> show jid
574734 pure Nothing
@@ -580,29 +740,34 @@ jobEventListener = do
580740 forever $ do
581741 log LevelDebug $ LogText "[LISTEN/NOTIFY] Event loop"
582742 notif <- liftIO $ getNotification monitorDbConn
583− concurrencyControlFn >>= \case
584− False -> log LevelWarn $ LogText "Received job event, but ignoring it due to concurrency control"
585− True -> do
586− let pload = notificationData notif
587− log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
588− case eitherDecode $ toS pload of
589− Left e -> log LevelError $ LogText $ toS $ "Unable to decode notification payload received from Postgres. Payload=" <> show pload <> " Error=" <> show e
590−
591− -- Checking if job needs to be fired immediately AND it is not already
592− -- taken by some othe thread, by the time it got to us
593− Right (v :: Value) -> case Aeson.parseMaybe parser v of
594− Nothing -> log LevelError $ LogText $ toS $ "Unable to extract id/run_at/locked_at from " <> show pload
595− Just (jid, runAt_, mLockedAt_) -> do
596− t <- liftIO getCurrentTime
597− if (runAt_ <= t) && isNothing mLockedAt_
598− then do log LevelDebug $ LogText $ toS $ "Job needs needs to be run immediately. Attempting to fork in background. JobId=" <> show jid
599− void $ async $ do
600− -- Let's try to lock the job first... it is possible that it has already
601− -- been picked up by the poller by the time we get here.
602− tryLockingJob jid >>= \case
603− Nothing -> pure ()
604− Just lockedJid -> runJob lockedJid
605− 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)
606771 where
607772 parser :: Value -> Aeson.Parser (JobId, UTCTime, Maybe UTCTime)
608773 parser = withObject "expecting an object to parse job.run_at and job.locked_at" $ \o -> do
@@ -616,6 +781,12 @@ jobEventListener = do
616781createJobQuery :: PGS.Query
617782createJobQuery = "INSERT INTO ? (run_at, status, payload, last_error, attempts, locked_at, locked_by) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING " <> concatJobDbColumns
618783
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+
619790-- $createJobs
620791--
621792-- Ideally you'd want to create wrappers for 'createJob' and 'scheduleJob' in
@@ -659,8 +830,51 @@ scheduleJob conn tname payload runAt = do
659830 case rs of
660831 [] -> Prelude.error . (<> "Not expecting a blank result set when creating a job. Query=") <$> queryFormatter
661832 [r] -> pure r
662− _ -> Prelude.error . (<> "Not expecting multiple rows when creating a single job. Query=") <$> queryFormatter
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
663865
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
868+ [r] -> pure r
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)
874+
875+ PGS.commit conn
876+
877+ pure job
664878
665879-- getRunnerEnv :: (HasJobRunner m) => m RunnerEnv
666880-- getRunnerEnv = ask
@@ -715,4 +929,3 @@ fetchAllJobRunners :: (MonadIO m)
715929 -> m [JobRunnerName]
716930fetchAllJobRunners UIConfig{uicfgTableName, uicfgDbPool} = liftIO $ withResource uicfgDbPool $ \conn -> do
717931 mapMaybe fromOnly <$> PGS.query conn "select distinct locked_by from ?" (Only uicfgTableName)
718−
src/OddJobs/Migrations.hs+59−1
@@ -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);" <>
@@ -72,3 +75,58 @@ createJobTable conn tname = void $ do
7275 fnName = PGS.Identifier $ "notify_job_monitor_for_" <> getTnameTxt tname
7376 trgName = PGS.Identifier $ "trg_notify_job_monitor_for_" <> getTnameTxt tname
7477 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
132+ getTnameTxt (PGS.QualifiedIdentifier _ tname') = tname'
src/OddJobs/Types.hs+46−2
@@ -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,6 +243,7 @@ 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
src/OddJobs/Web.hs+25−1
@@ -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
@@ -382,6 +384,7 @@ jobRow routes t (job@Job{..}, jobHtml) = do
382384 let statusFn = case jobStatus of
383385 Job.Success -> statusSuccess
384386 Job.Failed -> statusFailed
387+ Job.Cancelled -> statusCancelled
385388 Job.Queued -> if jobRunAt > t
386389 then statusFuture
387390 else statusWaiting
@@ -394,11 +397,12 @@ jobRow routes t (job@Job{..}, jobHtml) = do
394397 let actionsFn = case jobStatus of
395398 Job.Success -> const mempty
396399 Job.Failed -> actionsFailed
400+ Job.Cancelled -> actionsCancelled
397401 Job.Queued -> if jobRunAt > t
398402 then actionsFuture
399403 else actionsWaiting
400404 Job.Retry -> actionsRetry
401− Job.Locked -> const mempty
405+ Job.Locked -> actionsLocked
402406 actionsFn routes job
403407
404408
@@ -407,6 +411,15 @@ actionsFailed Routes{..} Job{..} = do
407411 form_ [ action_ (rEnqueue jobId), method_ "post" ] $ do
408412 button_ [ class_ "btn btn-secondary", type_ "submit" ] "Enqueue again"
409413
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"
422+
410423actionsRetry :: Routes -> Job -> Html ()
411424actionsRetry Routes{..} Job{..} = do
412425 form_ [ action_ (rRunNow jobId), method_ "post" ] $ do
@@ -422,6 +435,11 @@ actionsWaiting Routes{..} Job{..} = do
422435 form_ [ action_ (rCancel jobId), method_ "post" ] $ do
423436 button_ [ class_ "btn btn-danger", type_ "submit" ] "Cancel"
424437
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"
442+
425443statusSuccess :: UTCTime -> Job -> Html ()
426444statusSuccess t Job{..} = do
427445 span_ [ class_ "badge badge-success" ] "Success"
@@ -436,6 +454,12 @@ statusFailed t Job{..} = do
436454 span_ [ class_ "job-run-time" ] $ do
437455 abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Failed " <> humanReadableTime' t jobUpdatedAt <> " after " <> show jobAttempts <> " attempts"
438456
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+
439463statusFuture :: UTCTime -> Job -> Html ()
440464statusFuture t Job{..} = do
441465 span_ [ class_ "badge badge-secondary" ] "Future"
stack.yaml+2−2
@@ -1,6 +1,6 @@
1−resolver: lts-20.3
1+resolver: lts-20.26
22packages:
33 - .
44extra-deps:
5− - daemons-0.3.0@sha256:356edaf06a8cf5e9f19685f9ce793ad6d2b6bce2e55379f6b2aff1b0c07a633f,3178
65 - timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
6+ - resource-pool-0.4.0.0@sha256:9c1e448a159875e21a7e68697feee2b61a4e584720974fa465a2fa1bc0776c73,1342
test/Test.hs+278−65
@@ -1,8 +1,11 @@
11{-# LANGUAGE FlexibleInstances, NamedFieldPuns, DeriveGeneric, FlexibleContexts, TypeFamilies, StandaloneDeriving, RankNTypes #-}
22{-# LANGUAGE CPP #-}
3−{-# OPTIONS_GHC -Wno-missing-signatures #-}
3+{-# LANGUAGE PartialTypeSignatures #-}
4+{-# LANGUAGE RecordWildCards #-}
5+{-# OPTIONS_GHC -Wno-missing-signatures -Wno-partial-type-signatures #-}
46module Test where
57
8+import Control.Monad.Trans.Control
69import Test.Tasty as Tasty
710import qualified OddJobs.Migrations as Migrations
811import qualified OddJobs.Job as Job
@@ -34,7 +37,6 @@ import Test.Tasty.Hedgehog
3437import qualified System.Random as R
3538import Data.String (fromString)
3639import qualified Data.IntMap.Strict as Map
37−import Control.Monad.Trans.Control (liftWith, restoreT)
3840import Control.Monad.Morph (hoist)
3941import Data.List as DL
4042import OddJobs.Web as Web
@@ -45,6 +47,7 @@ import Data.Maybe (fromMaybe)
4547import qualified OddJobs.ConfigBuilder as Job
4648import UnliftIO
4749import Control.Exception (ArithException)
50+import Data.Bifunctor(first)
4851
4952$(Aeson.deriveJSON Aeson.defaultOptions ''Seconds)
5053
@@ -62,31 +65,16 @@ main = do
6265 , connectDatabase = "jobs_test"
6366 }
6467
65−#if MIN_VERSION_resource_pool(0,3,0)
66− createAppPool = Pool.newPool $ Pool.PoolConfig
68+ createAppPool = Pool.newPool $ Pool.defaultPoolConfig
6769 (PGS.connect connInfo) -- create a new resource
6870 PGS.close -- destroy resource
6971 (fromRational 10) -- number of seconds unused resources are kept around
7072 45
71− createJobPool = Pool.newPool $ Pool.PoolConfig
73+ createJobPool = Pool.newPool $ Pool.defaultPoolConfig
7274 (PGS.connect connInfo) -- create a new resource
7375 PGS.close -- destroy resource
7476 (fromRational 10) -- number of seconds unused resources are kept around
7577 45
76−#else
77− createAppPool = Pool.createPool
78− (PGS.connect connInfo) -- create a new resource
79− PGS.close -- destroy resource
80− 1 -- stripes
81− (fromRational 10) -- number of seconds unused resources are kept around
82− 45
83− createJobPool = Pool.createPool
84− (PGS.connect connInfo) -- create a new resource
85− PGS.close -- destroy resource
86− 1 -- stripes
87− (fromRational 10) -- number of seconds unused resources are kept around
88− 45
89−#endif
9078
9179tests appPool jobPool = testGroup "All tests"
9280 [
@@ -103,6 +91,8 @@ tests appPool jobPool = testGroup "All tests"
10391 , testOnJobSuccess appPool jobPool
10492 , testOnJobTimeout appPool jobPool
10593 ]
94+ , testResourceLimitedScheduling appPool jobPool
95+ , testKillJob appPool jobPool
10696 ]
10797 -- , testGroup "property tests" [ testEverything appPool jobPool
10898 -- -- , propFilterJobs appPool jobPool
@@ -149,7 +139,7 @@ jobRunner Job{jobPayload, jobAttempts} = case fromJSON jobPayload of
149139 Success (j :: JobPayload) ->
150140 let recur pload idx = case pload of
151141 PayloadAlwaysFail delay -> delaySeconds delay >> error ("Forced error after " <> show delay <> " seconds")
152− PayloadSucceed delay -> void $ delaySeconds delay
142+ PayloadSucceed delay -> delaySeconds delay
153143 PayloadFail delay innerpload -> if idx<jobAttempts
154144 then recur innerpload (idx + 1)
155145 else delaySeconds delay >> error ("Forced error after " <> show delay <> " seconds. step=" <> show idx)
@@ -177,6 +167,8 @@ logEventToJob le = case le of
177167 Job.LogJobSuccess j _ -> Just j
178168 Job.LogJobFailed j _ _ _ -> Just j
179169 Job.LogJobTimeout j -> Just j
170+ Job.LogKillJobSuccess j -> Just j
171+ Job.LogKillJobFailed j -> Just j
180172 Job.LogPoll -> Nothing
181173 Job.LogWebUIRequest -> Nothing
182174 Job.LogText _ -> Nothing
@@ -206,6 +198,8 @@ assertJobIdStatus conn tname logRef msg st jid = do
206198 Job.LogJobSuccess j _ -> jid == Job.jobId j
207199 Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
208200 Job.LogJobTimeout j -> jid == Job.jobId j
201+ Job.LogKillJobSuccess _ -> False
202+ Job.LogKillJobFailed _ -> False
209203 Job.LogWebUIRequest -> False
210204 Job.LogText _ -> False
211205 Job.LogPoll -> False
@@ -216,6 +210,12 @@ assertJobIdStatus conn tname logRef msg st jid = do
216210 Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
217211 _ -> False
218212
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
218+
219219 Job.Retry ->
220220 assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
221221 flip DL.any logs $ \case
@@ -238,12 +238,60 @@ ensureJobId conn tname jid = Job.findJobByIdIO conn tname jid >>= \case
238238 Nothing -> error $ "Not expecting job to be deleted. JobId=" <> show jid
239239 Just j -> pure j
240240
241−-- 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
242243withRandomTable jobPool action = do
244+ runInIO <- askRunInIO
243245 (tname :: Job.TableName) <- liftIO (fromString . ("jobs_" <>) <$> replicateM 10 (R.randomRIO ('a', 'z')))
244246 finally
245− (Pool.withResource jobPool (\conn -> liftIO $ Migrations.createJobTable conn tname) >> action tname)
246− (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))
247295
248296testMaxAttempts :: Int
249297testMaxAttempts = 3
@@ -253,7 +301,13 @@ withNewJobMonitor jobPool actualTest = do
253301 withRandomTable jobPool $ \tname -> do
254302 withNamedJobMonitor tname jobPool Prelude.id (actualTest tname)
255303
256−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
257311 logRef :: IORef [Job.LogEvent] <- newIORef []
258312 tcache <- newTimeCache simpleTimeFormat'
259313 withTimedFastLogger tcache LogNone $ \tlogger -> do
@@ -261,7 +315,7 @@ withNamedJobMonitor tname jobPool cfgFn actualTest = do
261315 tlogger $ \t -> toLogStr t <> " | " <> Job.defaultLogStr Job.defaultJobType logLevel logEvent
262316 atomicModifyIORef' logRef (\logs -> (logEvent:logs, ()))
263317 cfg = Job.mkConfig flogger tname jobPool Job.UnlimitedConcurrentJobs jobRunner (\cfg' -> cfgFn $ cfg'{Job.cfgDefaultMaxAttempts=testMaxAttempts})
264− withAsync (Job.startJobRunner cfg) (const $ actualTest logRef)
318+ configConsumer logRef cfg
265319
266320payloadGen :: MonadGen m => m JobPayload
267321payloadGen = Gen.recursive Gen.choice nonRecursive recursive
@@ -291,43 +345,22 @@ testEnsureShutdown appPool jobPool = testCase "ensure shutdown" $ do
291345 assertJobIdStatus conn tname logRef "Job is scheduled in future, should still be queueud" Job.Queued jobId
292346 pure (jobId, logRef)
293347
294−testGracefulShutdown appPool jobPool = testCase "ensure graceful shutdown" $ do
295− withRandomTable jobPool $ \tname -> do
296− setupPreconditions 3 tname >>= \case
297− Nothing -> fail "Unable to setup preconditions for this test, even after trying thrice"
298− 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
299353 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
300362 assertJobIdStatus conn tname logRef "Expecting the first job to be completed successfully if graceful shutdown is implemented correctly" Job.Success (jobId j1)
301363 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)
302− where
303− -- When this block completes execution, the graceful shutdown should have happened.
304− setupPreconditions i tname =
305− if i == (0 :: Integer)
306− then pure Nothing
307− else do
308− withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
309− Pool.withResource appPool $ \conn -> do
310− t <- getCurrentTime
311− j1 <- Job.createJob conn tname (PayloadSucceed $ 4 * Job.defaultPollingInterval)
312− j2 <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral $ unSeconds $ Job.defaultPollingInterval * 2) t)
313− let waitForPrecondition = do
314− k1 <- Job.findJobByIdIO conn tname (jobId j1)
315− k2 <- Job.findJobByIdIO conn tname (jobId j2)
316− case (jobStatus <$> k1, jobStatus <$> k2) of
317− (Nothing, _) -> do
318− traceM "Not expecting first job to be completed so quickly. Trying again."
319− setupPreconditions (i-1) tname
320− (_, Nothing) -> do
321− traceM "Not expecting second job to be completed so quickly. Trying again."
322− setupPreconditions (i-1) tname
323− (Just Job.Locked, Just Job.Queued) -> pure $ Just (j1, j2, logRef) -- precondition met
324− (Just Job.Success, _) -> setupPreconditions (i-1) tname
325− (_, Just Job.Success) -> setupPreconditions (i-1) tname
326− (Just Job.Queued, Just Job.Queued) -> do
327− delaySeconds 1
328− waitForPrecondition
329− x -> fail $ "Unexpeted job statuses while setting up the test: " <> show x
330− waitForPrecondition
331364
332365testJobScheduling appPool jobPool = testCase "job scheduling" $ do
333366 withNewJobMonitor jobPool $ \tname logRef -> do
@@ -383,9 +416,9 @@ testOnJobStart appPool jobPool = testCase "onJobStart" $ do
383416 withRandomTable jobPool $ \tname -> do
384417 callbackRef <- newIORef False
385418 let callback = const $ modifyIORef' callbackRef (const True)
386− withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobStart=callback}) $ \logRef -> do
419+ withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobStart=callback}) $ \_logRef -> do
387420 Pool.withResource appPool $ \conn -> do
388− Job.createJob conn tname (PayloadSucceed 0)
421+ _ <- Job.createJob conn tname (PayloadSucceed 0)
389422 delaySeconds (2 * Job.defaultPollingInterval)
390423 readIORef callbackRef >>= assertBool "It seems that onJobStart callback has not been called"
391424
@@ -393,9 +426,9 @@ testOnJobSuccess appPool jobPool = testCase "onJobSuccess" $ do
393426 withRandomTable jobPool $ \tname -> do
394427 callbackRef <- newIORef False
395428 let callback = const $ modifyIORef' callbackRef (const True)
396− withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobSuccess=callback}) $ \logRef -> do
429+ withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobSuccess=callback}) $ \_logRef -> do
397430 Pool.withResource appPool $ \conn -> do
398− Job.createJob conn tname (PayloadSucceed 0)
431+ _ <- Job.createJob conn tname (PayloadSucceed 0)
399432 delaySeconds (2 * Job.defaultPollingInterval)
400433 readIORef callbackRef >>= assertBool "It seems that onJobSuccess callback has not been called"
401434
@@ -403,9 +436,9 @@ testOnJobTimeout appPool jobPool = testCase "onJobTimeout" $ do
403436 withRandomTable jobPool $ \tname -> do
404437 callbackRef <- newIORef False
405438 let callback = const $ modifyIORef' callbackRef (const True)
406− withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobTimeout=callback, Job.cfgDefaultJobTimeout=Seconds 2}) $ \logRef -> do
439+ withNamedJobMonitor tname jobPool (\cfg -> cfg{Job.cfgOnJobTimeout=callback, Job.cfgDefaultJobTimeout=Seconds 2}) $ \_logRef -> do
407440 Pool.withResource appPool $ \conn -> do
408− Job.createJob conn tname (PayloadSucceed 10)
441+ _ <- Job.createJob conn tname (PayloadSucceed 10)
409442 delaySeconds (2 * Job.defaultPollingInterval)
410443 readIORef callbackRef >>= assertBool "It seems that onJobTimeout callback has not been called"
411444
@@ -438,6 +471,186 @@ testRetryBackoff appPool jobPool = testCase "retry backoff" $ do
438471 modifyRetryBackoff cfg
439472 = cfg { Job.cfgDefaultRetryBackoff = pure . backoff }
440473
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+
441654data JobEvent = JobStart
442655 | JobRetry
443656 | JobSuccess