on master help sign in
25 files changed+1183−487
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml+2−3 .github/workflows/hlint.yml+1−1 .github/workflows/nix.yaml+34 .hlint.yaml+1−12 CHANGELOG.md+4 default.nix+10 examples/OddJobsCliExample.lhs+3−3 flake.lock+44 flake.nix+44 ghc-8.10.7.stack.yaml−8 ghc-8.10.7.stack.yaml.lock−40 ghc-9.2.4.stack.yaml−6 ghc-9.2.4.stack.yaml.lock−26 odd-jobs.cabal+46−12 package.yaml+3−2 shell.nix+10 src/OddJobs/Cli.hs+56−44 src/OddJobs/ConfigBuilder.hs+29−28 src/OddJobs/Endpoints.hs+25−12 src/OddJobs/Job.hs+329−116 src/OddJobs/Migrations.hs+61−3 src/OddJobs/Types.hs+46−2 src/OddJobs/Web.hs+70−48 stack.yaml+2−2 test/Test.hs+363−119
.github/workflows/ci.yml+2−3
@@ -1,7 +1,6 @@
11name: CI
22
33on:
4− push:
54 pull_request:
65 branches:
76 - '*'
@@ -15,8 +14,8 @@ jobs:
1514 strategy:
1615 matrix:
1716 os: [ubuntu-latest]
18− stack: ["2.9.1"]
19− ghc: ["8.10.7"]
17+ stack: ["2.11.1"]
18+ ghc: ["8.8.4", "8.10.7", "9.2.8", "9.4.5"]
2019
2120 services:
2221 postgres:
.github/workflows/hlint.yml+1−1
@@ -6,7 +6,7 @@ on:
66
77jobs:
88 build:
9− name: hlint-3.4
9+ name: hlint-3.5
1010 runs-on: ubuntu-latest
1111
1212 steps:
.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"
.hlint.yaml+1−12
@@ -1,13 +1,2 @@
11# Warnings currently triggered by your code
2−- ignore: {name: "Eta reduce"} # 6 hints
3−- ignore: {name: "Move brackets to avoid $"} # 19 hints
4−- ignore: {name: "Redundant $"} # 21 hints
5−- ignore: {name: "Redundant bracket"} # 118 hints
6−- ignore: {name: "Redundant if"} # 1 hint
7−- ignore: {name: "Redundant lambda"} # 1 hint
8−- ignore: {name: "Replace case with fromMaybe"} # 2 hints
9−- ignore: {name: "Use asks"} # 13 hints
10−- ignore: {name: "Use lambda-case"} # 5 hints
11−- ignore: {name: "Use newtype instead of data"} # 1 hint
12−- ignore: {name: "Use null"} # 3 hints
13−- ignore: {name: "Use optional"} # 1 hint
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+3−3
@@ -71,12 +71,12 @@ In this example, the core job-runner function is in the `IO` monad. In all proba
7171\begin{code}
7272myJobRunner :: Job -> IO ()
7373myJobRunner job = do
74− (throwParsePayload job) >>= \case
75− SendWelcomeEmail _userId -> do
74+ throwParsePayload job >>= \case
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.4.stack.yamldeleted−6
@@ -1,6 +0,0 @@
1−resolver: nightly-2022-10-11
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.4.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: d3ba2e94c3ccbd3da4949243c83b59667c5eb9ffc7646148661c16741bbad1d4
24− size: 643623
25− url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2022/10/11.yaml
26− original: nightly-2022-10-11
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+56−44
@@ -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
@@ -143,7 +179,7 @@ defaultWebUI UIStartArgs{..} uicfg@UIConfig{..} = do
143179 case uistartAuth of
144180 AuthNone -> do
145181 let app = UI.server uicfg env Prelude.id
146− uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> (toS $ show uistartPort)
182+ uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> toS (show uistartPort)
147183 Warp.run uistartPort $ Servant.serve (Proxy :: Proxy UI.FinalAPI) app
148184 (AuthBasic u p) -> do
149185 let api = Proxy :: Proxy (BasicAuth "OddJobs Admin UI" OddJobsUser :> UI.FinalAPI)
@@ -151,11 +187,11 @@ defaultWebUI UIStartArgs{..} uicfg@UIConfig{..} = do
151187 -- Now the app will receive an extra argument for OddJobsUser,
152188 -- which we aren't really interested in.
153189 app _ = UI.server uicfg env Prelude.id
154− uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> (toS $ show uistartPort)
190+ uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> toS (show uistartPort)
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 <>
170− " 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 ()
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+
187211
188212-- * Default CLI parsers
189213--
@@ -204,7 +228,7 @@ data Args = Args
204228
205229-- | The top-level command-line parser
206230argParser :: CliType -> Parser Args
207−argParser cliType = Args <$> (commandParser cliType)
231+argParser cliType = Args <$> commandParser cliType
208232
209233-- ** Top-level command parser
210234
@@ -261,7 +285,7 @@ startCmdParser cliType = Start
261285 <*> (case cliType of
262286 CliOnlyJobRunner _ -> pure Nothing
263287 CliOnlyWebUi _ -> Just <$> uiStartArgsParser
264− CliBoth _ _ -> (Just <$> uiStartArgsParser) <|> (pure Nothing)
288+ CliBoth _ _ -> optional uiStartArgsParser
265289 )
266290
267291data WebUiAuth
@@ -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
@@ -342,17 +364,7 @@ defaultCliParserPrefs = prefs $
342364
343365defaultCliInfo :: CliType -> ParserInfo Args
344366defaultCliInfo cliType =
345− info ((argParser cliType) <**> helper) fullDesc
346−
347−-- defaultDaemonOptions :: DaemonOptions
348−-- defaultDaemonOptions = DaemonOptions
349−-- { daemonShouldChangeDirectory = False
350−-- , daemonShouldCloseStandardStreams = False
351−-- , daemonShouldIgnoreSignals = True
352−-- , daemonUserToChangeTo = Nothing
353−-- , daemonGroupToChangeTo = Nothing
354−-- }
355−
367+ info (argParser cliType <**> helper) fullDesc
356368
357369-- ** Auth implementations for the default Web UI
358370
src/OddJobs/ConfigBuilder.hs+29−28
@@ -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 ()
@@ -288,17 +290,10 @@ withConnectionPool connConfig action = withRunInIO $ \runInIO -> do
288290 where
289291 poolCreator = liftIO $
290292 case connConfig of
291−#if MIN_VERSION_resource_pool(0,3,0)
292293 Left connString ->
293− 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
294295 Right connInfo ->
295− newPool $ PoolConfig (PGS.connect connInfo) PGS.close (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
296−#else
297− Left connString ->
298− createPool (PGS.connectPostgreSQL connString) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
299− Right connInfo ->
300− createPool (PGS.connect connInfo) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
301−#endif
296+ newPool $ defaultPoolConfig (PGS.connect connInfo) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
302297
303298-- | A convenience function to help you define a timed-logger with some sensible
304299-- defaults.
@@ -310,8 +305,8 @@ defaultTimedLogger :: FLogger.TimedFastLogger
310305defaultTimedLogger logger logStrFn logLevel logEvent =
311306 if logLevel == LevelDebug
312307 then pure ()
313− else logger $ \t -> (toLogStr t) <> " | " <>
314− (logStrFn logLevel logEvent) <>
308+ else logger $ \t -> toLogStr t <> " | " <>
309+ logStrFn logLevel logEvent <>
315310 "\n"
316311
317312
@@ -320,7 +315,7 @@ defaultJsonLogEvent logEvent =
320315 case logEvent of
321316 LogJobStart job ->
322317 Aeson.object [ "tag" Aeson..= ("LogJobStart" :: Text)
323− , "contents" Aeson..= (defaultJsonJob job) ]
318+ , "contents" Aeson..= defaultJsonJob job ]
324319 LogJobSuccess job runTime ->
325320 Aeson.object [ "tag" Aeson..= ("LogJobSuccess" :: Text)
326321 , "contents" Aeson..= (defaultJsonJob job, runTime) ]
@@ -329,7 +324,13 @@ defaultJsonLogEvent logEvent =
329324 , "contents" Aeson..= (defaultJsonJob job, show e, defaultJsonFailureMode fm, runTime) ]
330325 LogJobTimeout job ->
331326 Aeson.object [ "tag" Aeson..= ("LogJobTimeout" :: Text)
332− , "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 ]
333334 LogPoll ->
334335 Aeson.object [ "tag" Aeson..= ("LogJobPoll" :: Text)]
335336 LogWebUIRequest ->
@@ -339,7 +340,7 @@ defaultJsonLogEvent logEvent =
339340 , "contents" Aeson..= t ]
340341
341342defaultJsonJob :: Job -> Aeson.Value
342−defaultJsonJob job = genericToJSON Aeson.defaultOptions job
343+defaultJsonJob = genericToJSON Aeson.defaultOptions
343344
344345defaultJsonFailureMode :: FailureMode -> Aeson.Value
345−defaultJsonFailureMode fm = genericToJSON Aeson.defaultOptions fm
346+defaultJsonFailureMode = genericToJSON Aeson.defaultOptions
src/OddJobs/Endpoints.hs+25−12
@@ -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
@@ -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 }
@@ -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,8 +197,8 @@ 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
191203 js <- liftIO (DL.zip jobs <$> uicfgJobToHtml jobs)
192204 allJobTypes <- readIORef envJobTypesRef
@@ -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+329−116
@@ -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
@@ -167,34 +181,37 @@ logCallbackErrors :: (HasJobRunner m) => JobId -> Text -> m () -> m ()
167181logCallbackErrors jid msg action = catchAny action $ \e -> log LevelError $ LogText $ msg <> " Job ID=" <> toS (show jid) <> ": " <> toS (show e)
168182
169183instance HasJobRunner RunnerM where
170− getPollingInterval = cfgPollingInterval . envConfig <$> ask
171− onJobFailed = cfgOnJobFailed . envConfig <$> ask
184+ getPollingInterval = asks (cfgPollingInterval . envConfig)
185+ onJobFailed = asks (cfgOnJobFailed . envConfig)
172186 onJobSuccess job = do
173− fn <- cfgOnJobSuccess . envConfig <$> ask
187+ fn <- asks (cfgOnJobSuccess . envConfig)
174188 logCallbackErrors (jobId job) "onJobSuccess" $ liftIO $ fn job
175− deleteSuccessfulJobs = cfgDeleteSuccessfulJobs . envConfig <$> ask
189+ deleteSuccessfulJobs = asks (cfgDeleteSuccessfulJobs . envConfig)
176190
177− getJobRunner = cfgJobRunner . envConfig <$> ask
178− getDbPool = cfgDbPool . envConfig <$> ask
179− getTableName = cfgTableName . envConfig <$> ask
191+ getJobRunner = asks (cfgJobRunner . envConfig)
192+ getDbPool = asks (cfgDbPool . envConfig)
193+ getTableName = asks (cfgTableName . envConfig)
180194 onJobStart job = do
181− fn <- cfgOnJobStart . envConfig <$> ask
195+ fn <- asks (cfgOnJobStart . envConfig)
182196 logCallbackErrors (jobId job) "onJobStart" $ liftIO $ fn job
183197
184− getDefaultMaxAttempts = cfgDefaultMaxAttempts . envConfig <$> ask
198+ getDefaultMaxAttempts = asks (cfgDefaultMaxAttempts . envConfig)
185199
186200 getRunnerEnv = ask
187201
188− getConcurrencyControl = (cfgConcurrencyControl . envConfig <$> ask)
202+ getConcurrencyControl = asks (cfgConcurrencyControl . envConfig)
189203
190204 log logLevel logEvent = do
191− loggerFn <- cfgLogger . envConfig <$> ask
205+ loggerFn <- asks (cfgLogger . envConfig)
192206 liftIO $ loggerFn logLevel logEvent
193207
194− 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
195212
196213 getDefaultRetryBackoff attempts = do
197− retryFn <- cfgDefaultRetryBackoff . envConfig <$> ask
214+ retryFn <- asks (cfgDefaultRetryBackoff . envConfig)
198215 liftIO $ retryFn attempts
199216
200217-- | Start the job-runner in the /current/ thread, i.e. you'll need to use
@@ -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
@@ -216,7 +233,7 @@ jobWorkerName :: IO String
216233jobWorkerName = do
217234 pid <- getProcessID
218235 hname <- getHostName
219− pure $ hname ++ ":" ++ (show pid)
236+ pure $ hname ++ ":" ++ show pid
220237
221238-- | If you are writing SQL queries where you want to return ALL columns from
222239-- the jobs table it is __recommended__ that you do not issue a @SELECT *@ or
@@ -288,7 +305,7 @@ findJobByIdIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
288305findJobByIdIO conn tname jid = PGS.query conn findJobByIdQuery (tname, jid) >>= \case
289306 [] -> pure Nothing
290307 [j] -> pure (Just j)
291− _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
292309
293310
294311saveJobQuery :: PGS.Query
@@ -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
@@ -316,9 +333,9 @@ saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttem
316333 , jobId
317334 )
318335 case rs of
319− [] -> 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
320337 [j] -> pure j
321− _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
322339
323340deleteJob :: (HasJobRunner m) => JobId -> m ()
324341deleteJob jid = do
@@ -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,39 +383,47 @@ 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
391− (findJobById jid) >>= \case
419+ findJobById jid >>= \case
392420 Nothing -> Prelude.error $ "Could not find job id=" <> show jid
393421 Just job -> 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
@@ -420,27 +448,50 @@ runJob jid = do
420448 newJob <- saveJob job{ jobStatus=newStatus
421449 , jobLockedBy=Nothing
422450 , jobLockedAt=Nothing
423− , jobLastError=(Just $ toJSON $ show e) -- TODO: convert errors to json properly
424− , 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
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
440491 traceM "restartUponCrash top"
441492 a <- async action
442493 finally (waitCatch a >>= fn) $ do
443− (log LevelInfo $ LogText $ "Received shutdown: " <> toS name_)
494+ log LevelInfo $ LogText $ "Received shutdown: " <> toS name_
444495 cancel a
445496 where
446497 fn x = 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,30 +522,57 @@ 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
482554 void $ waitAnyCatch as
483− 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
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
@@ -504,9 +584,9 @@ jobPollingIO pollerDbConn processName tname lockTimeout = do
504584 , processName
505585 , tname
506586 , t
507− , (In [Queued, Retry])
587+ , In [Queued, Retry]
508588 , Locked
509− , (addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t))
589+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t)
510590
511591-- | Executes 'jobPollingSql' every 'cfgPollingInterval' seconds to pick up jobs
512592-- for execution. Uses @UPDATE@ along with @SELECT...FOR UPDATE@ to efficiently
@@ -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,43 +718,56 @@ 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
575735 [Only (_ :: JobId)] -> pure $ Just jid
576− 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
577737
578738 withResource pool $ \monitorDbConn -> do
579− void $ liftIO $ PGS.execute monitorDbConn ("LISTEN ?") (Only $ pgEventName tname)
739+ void $ liftIO $ PGS.execute monitorDbConn "LISTEN ?" (Only $ pgEventName tname)
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
@@ -654,13 +825,56 @@ scheduleJob :: ToJSON p
654825 -> IO Job
655826scheduleJob conn tname payload runAt = do
656827 let args = ( tname, runAt, Queued, toJSON payload, Nothing :: Maybe Value, 0 :: Int, Nothing :: Maybe Text, Nothing :: Maybe Text )
657− queryFormatter = toS <$> (PGS.formatQuery conn createJobQuery args)
828+ queryFormatter = toS <$> PGS.formatQuery conn createJobQuery args
658829 rs <- PGS.query conn createJobQuery args
659830 case rs of
660− [] -> (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
661868 [r] -> pure r
662− _ -> (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)
663874
875+ PGS.commit conn
876+
877+ pure job
664878
665879-- getRunnerEnv :: (HasJobRunner m) => m RunnerEnv
666880-- getRunnerEnv = ask
@@ -668,14 +882,14 @@ scheduleJob conn tname payload runAt = do
668882eitherParsePayload :: (FromJSON a)
669883 => Job
670884 -> Either String a
671−eitherParsePayload job =
672− eitherParsePayloadWith parseJSON job
885+eitherParsePayload =
886+ eitherParsePayloadWith parseJSON
673887
674888throwParsePayload :: (FromJSON a)
675889 => Job
676890 -> IO a
677−throwParsePayload job =
678− throwParsePayloadWith parseJSON job
891+throwParsePayload =
892+ throwParsePayloadWith parseJSON
679893
680894eitherParsePayloadWith :: (Aeson.Value -> Aeson.Parser a)
681895 -> Job
@@ -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+61−3
@@ -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);" <>
@@ -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+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+70−48
@@ -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,9 +261,9 @@ 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 ()
267269sideNav Routes{..} jobTypes jobRunnerNames _t filter@Filter{..} = do
@@ -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,11 +313,11 @@ 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 ()
@@ -332,7 +334,7 @@ searchBar Routes{..} _t filter@Filter{filterStatuses, filterCreatedAfter, filter
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, filter
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,10 +361,10 @@ 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
368370 (d, _h) = h' `divMod` 24
@@ -372,9 +374,7 @@ showText 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 ()
448470statusWaiting _t Job{} = do
449− span_ [ class_ "badge badge-warning" ] $ "Waiting"
471+ span_ [ class_ "badge badge-warning" ] "Waiting"
450472 -- span_ [ class_ "job-run-time" ] ("Waiting to be picked up" :: Text)
451473
452474statusRetry :: UTCTime -> Job -> Html ()
@@ -489,21 +511,21 @@ resultsPanel routes@Routes{..} t filter@Filter{filterPage} js runningCount = do
489511 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+2−2
@@ -1,6 +1,6 @@
1−resolver: nightly-2022-10-11
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+363−119
@@ -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
68− (PGS.close) -- destroy resource
70+ 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
73− (PGS.close) -- destroy resource
75+ 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 [
@@ -95,9 +83,16 @@ tests appPool jobPool = testGroup "All tests"
9583 , testJobFailure appPool jobPool
9684 , testEnsureShutdown appPool jobPool
9785 , testGracefulShutdown appPool jobPool
98− , testJobErrHandler appPool jobPool
9986 , testPushFailedJobEndQueue jobPool
10087 , 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
10196 ]
10297 -- , testGroup "property tests" [ testEverything appPool jobPool
10398 -- -- , propFilterJobs appPool jobPool
@@ -139,15 +134,15 @@ testPayload :: Value
139134testPayload = toJSON (10 :: Int)
140135
141136jobRunner :: Job.Job -> IO ()
142−jobRunner Job{jobPayload, jobAttempts} = case (fromJSON jobPayload) of
137+jobRunner Job{jobPayload, jobAttempts} = case fromJSON jobPayload of
143138 Aeson.Error e -> error e
144139 Success (j :: JobPayload) ->
145140 let recur pload idx = case pload of
146− PayloadAlwaysFail delay -> (delaySeconds delay) >> (error $ "Forced error after " <> show delay <> " seconds")
147− PayloadSucceed delay -> void $ delaySeconds delay
141+ PayloadAlwaysFail delay -> delaySeconds delay >> error ("Forced error after " <> show delay <> " seconds")
142+ PayloadSucceed delay -> delaySeconds delay
148143 PayloadFail delay innerpload -> if idx<jobAttempts
149144 then recur innerpload (idx + 1)
150− 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)
151146 PayloadThrowStringException s -> throwString s
152147 PayloadThrowDivideByZero -> seq (1 `div` 0 :: Integer) (pure ())
153148 in recur j 0
@@ -172,11 +167,13 @@ logEventToJob le = case le of
172167 Job.LogJobSuccess j _ -> Just j
173168 Job.LogJobFailed j _ _ _ -> Just j
174169 Job.LogJobTimeout j -> Just j
170+ Job.LogKillJobSuccess j -> Just j
171+ Job.LogKillJobFailed j -> Just j
175172 Job.LogPoll -> Nothing
176173 Job.LogWebUIRequest -> Nothing
177174 Job.LogText _ -> Nothing
178175
179−assertJobIdStatus :: (HasCallStack)
176+assertJobIdStatus :: HasCallStack
180177 => Connection
181178 -> Job.TableName
182179 -> IORef [Job.LogEvent]
@@ -190,53 +187,111 @@ assertJobIdStatus conn tname logRef msg st jid = do
190187 case st of
191188 Job.Success ->
192189 assertBool (msg <> ": Success event not found in job-logs for JobId=" <> show jid) $
193− (flip DL.any) logs $ \logEvent -> case logEvent of
194− Job.LogJobSuccess j _ -> jid == Job.jobId j
195− _ -> False
190+ flip DL.any logs $ \case
191+ Job.LogJobSuccess j _ -> jid == Job.jobId j
192+ _ -> False
193+
196194 Job.Queued ->
197195 assertBool (msg <> ": Not expecting to find a queued job in the the job-logs JobId=" <> show jid) $
198− not $ (flip DL.any) logs $ \logEvent -> case logEvent of
199− Job.LogJobStart j -> jid == Job.jobId j
200− Job.LogJobSuccess j _ -> jid == Job.jobId j
201− Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
202− Job.LogJobTimeout j -> jid == Job.jobId j
203− Job.LogWebUIRequest -> False
204− Job.LogText _ -> False
205− 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+
206207 Job.Failed ->
207208 assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
208− (flip DL.any) logs $ \logEvent -> case logEvent of
209− Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
210− _ -> 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
211218
212219 Job.Retry ->
213220 assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
214− (flip DL.any) logs $ \logEvent -> case logEvent of
215− Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
216− _ -> False
221+ flip DL.any logs $ \case
222+ Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
223+ _ -> False
217224
218225 Job.Locked ->
219226 assertBool (msg <> ": Start event should be present in the log for a locked job JobId=" <> show jid) $
220− (flip DL.any) logs $ \logEvent -> case logEvent of
221− Job.LogJobStart j -> jid == Job.jobId j
222− _ -> False
227+ flip DL.any logs $ \case
228+ Job.LogJobStart j -> jid == Job.jobId j
229+ _ -> False
223230
224231 when (st /= Job.Success) $ do
225232 Job.findJobByIdIO conn tname jid >>= \case
226233 Nothing -> assertFailure $ "Not expecting job to be deleted. JobId=" <> show jid
227234 Just (Job{jobStatus}) -> assertEqual msg st jobStatus
228235
229−ensureJobId :: (HasCallStack) => Connection -> Job.TableName -> JobId -> IO Job
236+ensureJobId :: HasCallStack => Connection -> Job.TableName -> JobId -> IO Job
230237ensureJobId conn tname jid = Job.findJobByIdIO conn tname jid >>= \case
231238 Nothing -> error $ "Not expecting job to be deleted. JobId=" <> show jid
232239 Just j -> pure j
233240
234−-- 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
235243withRandomTable jobPool action = do
236− (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')))
237246 finally
238− ((Pool.withResource jobPool $ \conn -> (liftIO $ Migrations.createJobTable conn tname)) >> (action tname))
239− (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))
240295
241296testMaxAttempts :: Int
242297testMaxAttempts = 3
@@ -246,22 +301,28 @@ withNewJobMonitor jobPool actualTest = do
246301 withRandomTable jobPool $ \tname -> do
247302 withNamedJobMonitor tname jobPool Prelude.id (actualTest tname)
248303
249−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
250311 logRef :: IORef [Job.LogEvent] <- newIORef []
251312 tcache <- newTimeCache simpleTimeFormat'
252313 withTimedFastLogger tcache LogNone $ \tlogger -> do
253314 let flogger logLevel logEvent = do
254− tlogger $ \t -> toLogStr t <> " | " <> (Job.defaultLogStr Job.defaultJobType logLevel logEvent)
315+ tlogger $ \t -> toLogStr t <> " | " <> Job.defaultLogStr Job.defaultJobType logLevel logEvent
255316 atomicModifyIORef' logRef (\logs -> (logEvent:logs, ()))
256317 cfg = Job.mkConfig flogger tname jobPool Job.UnlimitedConcurrentJobs jobRunner (\cfg' -> cfgFn $ cfg'{Job.cfgDefaultMaxAttempts=testMaxAttempts})
257− withAsync (Job.startJobRunner cfg) (const $ actualTest logRef)
318+ configConsumer logRef cfg
258319
259320payloadGen :: MonadGen m => m JobPayload
260321payloadGen = Gen.recursive Gen.choice nonRecursive recursive
261322 where
262323 nonRecursive = [ PayloadAlwaysFail <$> Gen.element [1, 2, 3]
263324 , PayloadSucceed <$> Gen.element [1, 2, 3]]
264− recursive = [ PayloadFail <$> (Gen.element [1, 2, 3]) <*> payloadGen ]
325+ recursive = [ PayloadFail <$> Gen.element [1, 2, 3] <*> payloadGen ]
265326
266327testJobCreation appPool jobPool = testCase "job creation" $ do
267328 withNewJobMonitor jobPool $ \tname logRef -> do
@@ -280,47 +341,26 @@ testEnsureShutdown appPool jobPool = testCase "ensure shutdown" $ do
280341 scheduleJob tname = withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
281342 t <- getCurrentTime
282343 Pool.withResource appPool $ \conn -> do
283− 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)
284345 assertJobIdStatus conn tname logRef "Job is scheduled in future, should still be queueud" Job.Queued jobId
285346 pure (jobId, logRef)
286347
287−testGracefulShutdown appPool jobPool = testCase "ensure graceful shutdown" $ do
288− withRandomTable jobPool $ \tname -> do
289− setupPreconditions 3 tname >>= \case
290− Nothing -> fail "Unable to setup preconditions for this test, even after trying thrice"
291− 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
292353 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
293362 assertJobIdStatus conn tname logRef "Expecting the first job to be completed successfully if graceful shutdown is implemented correctly" Job.Success (jobId j1)
294363 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)
295− where
296− -- When this block completes execution, the graceful shutdown should have happened.
297− setupPreconditions i tname =
298− if i == (0 :: Integer)
299− then pure Nothing
300− else do
301− withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
302− Pool.withResource appPool $ \conn -> do
303− t <- getCurrentTime
304− j1 <- Job.createJob conn tname (PayloadSucceed $ 4 * Job.defaultPollingInterval)
305− j2 <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral $ unSeconds $ Job.defaultPollingInterval * 2) t)
306− let waitForPrecondition = do
307− k1 <- Job.findJobByIdIO conn tname (jobId j1)
308− k2 <- Job.findJobByIdIO conn tname (jobId j2)
309− case (jobStatus <$> k1, jobStatus <$> k2) of
310− (Nothing, _) -> do
311− traceM "Not expecting first job to be completed so quickly. Trying again."
312− setupPreconditions (i-1) tname
313− (_, Nothing) -> do
314− traceM "Not expecting second job to be completed so quickly. Trying again."
315− setupPreconditions (i-1) tname
316− (Just Job.Locked, Just Job.Queued) -> pure $ Just (j1, j2, logRef) -- precondition met
317− (Just Job.Success, _) -> setupPreconditions (i-1) tname
318− (_, Just Job.Success) -> setupPreconditions (i-1) tname
319− (Just Job.Queued, Just Job.Queued) -> do
320− delaySeconds 1
321− waitForPrecondition
322− x -> fail $ "Unexpeted job statuses while setting up the test: " <> show x
323− waitForPrecondition
324364
325365testJobScheduling appPool jobPool = testCase "job scheduling" $ do
326366 withNewJobMonitor jobPool $ \tname logRef -> do
@@ -329,20 +369,19 @@ testJobScheduling appPool jobPool = testCase "job scheduling" $ do
329369 job@Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral (3600 :: Integer)) t)
330370 delaySeconds $ Seconds 2
331371 assertJobIdStatus conn tname logRef "Job is scheduled in the future. It should NOT have been successful by now" Job.Queued jobId
332− _ <- Job.saveJobIO conn tname job{jobRunAt = (addUTCTime (fromIntegral (-1 :: Integer)) t)}
333− delaySeconds (Job.defaultPollingInterval + (Seconds 2))
372+ _ <- Job.saveJobIO conn tname job{jobRunAt = addUTCTime (fromIntegral (-1 :: Integer)) t}
373+ delaySeconds (Job.defaultPollingInterval + Seconds 2)
334374 assertJobIdStatus conn tname logRef "Job had a runAt date in the past. It should have been successful by now" Job.Success jobId
335375
336376testJobFailure appPool jobPool = testCase "job failure" $ do
337377 withNewJobMonitor jobPool $ \tname _logRef -> do
338378 Pool.withResource appPool $ \conn -> do
339379 Job{jobId} <- Job.createJob conn tname (PayloadAlwaysFail 0)
340− delaySeconds $ (calculateRetryDelay testMaxAttempts) + (Seconds 3)
380+ delaySeconds $ calculateRetryDelay testMaxAttempts + Seconds 3
341381 Job{jobAttempts, jobStatus} <- ensureJobId conn tname jobId
342382 assertEqual "Exepcting job to be in Failed status" Job.Failed jobStatus
343383 assertEqual ("Expecting job attempts to be 3. Found " <> show jobAttempts) 3 jobAttempts
344384
345−
346385testPushFailedJobEndQueue :: Pool Connection -> TestTree
347386testPushFailedJobEndQueue jobPool = testCase "testPushFailedJobEndQueue" $ do
348387 withRandomTable jobPool $ \tname -> do
@@ -355,7 +394,7 @@ testPushFailedJobEndQueue jobPool = testCase "testPushFailedJobEndQueue" $ do
355394 "Expecting the current job to be 2 since job 1 has been modified"
356395 (jobId job2) resId
357396
358−testJobErrHandler appPool jobPool = testCase "job error handler" $ do
397+testOnJobFailed appPool jobPool = testCase "job error handler" $ do
359398 withRandomTable jobPool $ \tname -> do
360399 Pool.withResource appPool $ \conn -> do
361400 _ <- Job.createJob conn tname (PayloadThrowStringException "forced exception")
@@ -373,6 +412,36 @@ testJobErrHandler appPool jobPool = testCase "job error handler" $ do
373412 readMVar mvar1 >>= assertEqual "Error Handler 1 doesn't seem to have run" True
374413 readMVar mvar2 >>= assertEqual "Error Handler 2 doesn't seem to have run" True
375414
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+
376445testRetryBackoff appPool jobPool = testCase "retry backoff" $ do
377446 withRandomTable jobPool $ \tname -> do
378447 withNamedJobMonitor tname jobPool modifyRetryBackoff $ \_logRef -> do
@@ -402,6 +471,186 @@ testRetryBackoff appPool jobPool = testCase "retry backoff" $ do
402471 modifyRetryBackoff cfg
403472 = cfg { Job.cfgDefaultRetryBackoff = pure . backoff }
404473
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+
405654data JobEvent = JobStart
406655 | JobRetry
407656 | JobSuccess
@@ -503,7 +752,7 @@ data JobEvent = JobStart
503752
504753
505754payloadDelay :: Seconds -> JobPayload -> Seconds
506−payloadDelay jobPollingInterval pload = payloadDelay_ (Seconds 0) pload
755+payloadDelay jobPollingInterval = payloadDelay_ (Seconds 0)
507756 where
508757 payloadDelay_ total p =
509758 let defaultDelay x = total + x + jobPollingInterval
@@ -514,10 +763,6 @@ payloadDelay jobPollingInterval pload = payloadDelay_ (Seconds 0) pload
514763 PayloadThrowStringException _ -> defaultDelay 0
515764 PayloadThrowDivideByZero -> defaultDelay 0
516765
517−
518−
519−-- TODO: test to ensure that errors in callback do not affect the running of jobs
520−
521766deriving instance Enum Time.Unit
522767deriving instance Enum Time.Direction
523768
@@ -529,7 +774,7 @@ timeGen t d = do
529774 pure $ Time.timeSince t (fromInteger i) u d
530775
531776anyTimeGen :: MonadGen m => UTCTime -> m UTCTime
532−anyTimeGen t = (Gen.element (enumFrom Time.Ago)) >>= (timeGen t)
777+anyTimeGen t = Gen.element (enumFrom Time.Ago) >>= timeGen t
533778
534779futureTimeGen :: MonadGen m => UTCTime -> m UTCTime
535780futureTimeGen t = timeGen t Time.FromThat
@@ -581,8 +826,8 @@ genFilter t = do
581826 updatedBefore <- case updatedAfter of
582827 Nothing -> Gen.maybe (anyTimeGen t)
583828 Just x -> Gen.maybe (futureTimeGen x)
584− orderClause <- Gen.maybe ((,) <$> (Gen.element $ enumFrom Web.OrdCreatedAt) <*> (Gen.element $ enumFrom Web.Asc))
585− 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))
586831 runAfter <- Gen.maybe (futureTimeGen t)
587832 pure Web.blankFilter
588833 { filterStatuses = statuses
@@ -600,23 +845,23 @@ filterJobs :: Filter -> [Job] -> [Job]
600845filterJobs Web.Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterOrder, filterPage, filterRunAfter} js =
601846 applyLimitOffset $
602847 applyOrdering (fromMaybe (Web.OrdUpdatedAt, Web.Desc) filterOrder) $
603− (flip DL.filter) js $ \j -> (filterByStatus j) &&
604− (filterByCreatedAfter j) &&
605− (filterByCreatedBefore j) &&
606− (filterByUpdatedAfter j) &&
607− (filterByUpdatedBefore j) &&
608− (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
609854 where
610− 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
611856
612857 applyOrdering (fld, dir) lst =
613858 let comparer = resultOrder $ case fld of
614− Web.OrdCreatedAt -> (comparing jobCreatedAt)
615− Web.OrdUpdatedAt -> (comparing jobUpdatedAt)
616− Web.OrdLockedAt -> (comparing jobLockedAt)
617− Web.OrdStatus -> (comparing jobStatus)
859+ Web.OrdCreatedAt -> comparing jobCreatedAt
860+ Web.OrdUpdatedAt -> comparing jobUpdatedAt
861+ Web.OrdLockedAt -> comparing jobLockedAt
862+ Web.OrdStatus -> comparing jobStatus
618863 Web.OrdJobType -> comparing Job.defaultJobType
619− resultOrder fn = \x y -> case fn x y of
864+ resultOrder fn x y = case fn x y of
620865 EQ -> compare (Down $ jobId x) (Down $ jobId y)
621866 LT -> case dir of
622867 Web.Asc -> LT
@@ -626,9 +871,8 @@ filterJobs Web.Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, f
626871 Web.Desc -> LT
627872 in sortBy comparer lst
628873
629− filterByStatus Job.Job{jobStatus} = if Prelude.null filterStatuses
630− then True
631− else jobStatus `elem` filterStatuses
874+ filterByStatus Job.Job{jobStatus} = Prelude.null filterStatuses
875+ || (jobStatus `elem` filterStatuses)
632876 filterByCreatedAfter Job.Job{jobCreatedAt} = maybe True (<= jobCreatedAt) filterCreatedAfter
633877 filterByCreatedBefore Job.Job{jobCreatedAt} = maybe True (> jobCreatedAt) filterCreatedBefore
634878 filterByUpdatedAfter Job.Job{jobUpdatedAt} = maybe True (<= jobUpdatedAt) filterUpdatedAfter