on master help sign in
25 files changed+1988−875
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml+63 .github/workflows/hlint.yml+26 .github/workflows/nix.yaml+34 .gitignore+1 .hlint.yaml+2 CHANGELOG.md+4 README.md+26 default.nix+10 examples/OddJobsCliExample.lhs+24−32 flake.lock+44 flake.nix+44 odd-jobs.cabal+59−19 package.yaml+7−4 shell.nix+10 src/OddJobs/Cli.hs+138−121 src/OddJobs/ConfigBuilder.hs+100−48 src/OddJobs/Endpoints.hs+49−36 src/OddJobs/Job.hs+398−155 src/OddJobs/Migrations.hs+104−20 src/OddJobs/Types.hs+118−19 src/OddJobs/Web.hs+89−61 stack.yaml+3−65 test/Test.hs+635−205 test/Try.hs−28 test/Try2.hs−62
.github/workflows/ci.ymlnew file+63
@@ -0,0 +1,63 @@
1+name: CI
2+
3+on:
4+ pull_request:
5+ branches:
6+ - '*'
7+
8+jobs:
9+ stack:
10+ name: ${{ matrix.os }} / stack / ghc ${{ matrix.ghc }}
11+ runs-on: ${{ matrix.os }}
12+ env:
13+ ACTIONS_ALLOW_UNSECURE_COMMANDS: true
14+ strategy:
15+ matrix:
16+ os: [ubuntu-latest]
17+ stack: ["2.11.1"]
18+ ghc: ["8.8.4", "8.10.7", "9.2.8", "9.4.5"]
19+
20+ services:
21+ postgres:
22+ image: postgres
23+ env:
24+ POSTGRES_PASSWORD: jobs_test
25+ POSTGRES_USER: jobs_test
26+ POSTGRES_DB: jobs_test
27+ options: >-
28+ --health-cmd pg_isready
29+ --health-interval 10s
30+ --health-timeout 5s
31+ --health-retries 5
32+ ports:
33+ - 5432:5432
34+
35+ steps:
36+ - name: confirm that postgres is working
37+ run: |
38+ PGPASSWORD=jobs_test psql -U jobs_test -h localhost -c 'select 1'
39+
40+ - uses: actions/checkout@v2
41+
42+ - uses: actions/setup-haskell@v1.1
43+ name: Setup Haskell Stack
44+ with:
45+ enable-stack: true
46+ stack-no-global: true
47+ stack-setup-ghc: true
48+ stack-version: ${{ matrix.stack }}
49+ ghc-version: ${{ matrix.ghc }}
50+
51+ - uses: actions/cache@v2
52+ name: Cache ~/.stack
53+ with:
54+ path: ~/.stack
55+ key: ${{ matrix.os }}-${{ matrix.ghc }}-stack
56+
57+ - name: Build
58+ run: |
59+ stack build
60+
61+ - name: Test
62+ run: |
63+ stack test
.github/workflows/hlint.ymlnew file+26
@@ -0,0 +1,26 @@
1+name: hlint
2+
3+on:
4+ pull_request:
5+ push:
6+
7+jobs:
8+ build:
9+ name: hlint-3.5
10+ runs-on: ubuntu-latest
11+
12+ steps:
13+ - uses: actions/checkout@v3
14+ with:
15+ submodules: true
16+
17+ - uses: haskell/actions/hlint-setup@v2
18+ name: Set up HLint
19+ with:
20+ version: "3.5"
21+
22+ - uses: haskell/actions/hlint-run@v2
23+ name: hlint
24+ with:
25+ path: ./
26+ fail-on: suggestion
.github/workflows/nix.yamlnew file+34
@@ -0,0 +1,34 @@
1+name: "Nix"
2+on:
3+ pull_request:
4+ push:
5+jobs:
6+ tests:
7+ runs-on: ubuntu-latest
8+ steps:
9+ - uses: actions/checkout@v3
10+ - name: Local cache
11+ uses: actions/cache@v3
12+ with:
13+ path: /nix/store
14+ key: "${{ runner.os }}-nix-cache"
15+ - uses: cachix/install-nix-action@v20
16+ with:
17+ nix_path: nixpkgs=channel:nixos-unstable
18+ extra_nix_config: |
19+ access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
20+
21+ # follow guide: https://nix.dev/tutorials/continuous-integration-github-actions
22+ # this uses the tokens which are revokable
23+ - uses: cachix/cachix-action@v12
24+ with:
25+ name: jappie
26+ # If you chose API tokens for write access OR if you have a private cache
27+ # authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
28+ signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}'
29+ - run: nix build
30+ - run: nix flake check
31+ - run: nix develop -c echo OK
32+ # legacy actions
33+ - run: nix-build
34+ - run: nix-shell --run "echo OK"
.gitignore+1
@@ -2,3 +2,4 @@ stack.yaml.lock
22test.log
33TAGS
44.stack-work/
5+dist-newstyle/
.hlint.yamlnew file+2
@@ -0,0 +1,2 @@
1+# Warnings currently triggered by your code
2+- ignore: {name: "Use newtype instead of data"} # 1 hint ignored because newtypes can't have strictness annotations.
CHANGELOG.md+4
@@ -1,5 +1,9 @@
11# Changelog
22
3+## 0.2.3
4++ Upgrade resource pool
5++ Add ability to kill jobs
6+
37## 0.2.2
48
59- Bugfix in the `deleteJobQuery` function
README.md+26
@@ -1,3 +1,5 @@
1+[![GitHub CI](https://github.com/saurabhnanda/odd-jobs/workflows/CI/badge.svg)](https://github.com/saurabhnanda/odd-jobs/actions)
2+
13# Introduction
24
35- [Odd Jobs home page](https://www.haskelltutorials.com/odd-jobs) - contains a description of top-level features of this library
@@ -14,3 +16,27 @@ If you are already using, or considering using, `odd-jobs` in production, please
1416# Contributing
1517
1618Please read the [contribution guidelines](./CONTRIBUTING.md)
19+
20+## Development
21+
22+### Prerequisites
23+
24+- [The Haskell Tool Stack](https://docs.haskellstack.org/en/stable/README/#how-to-install)
25+- `libpq-dev` library (required for PostgreSQL dependency)
26+
27+### Build
28+
29+```bash
30+stack build
31+```
32+
33+
34+### Running tests
35+
36+Add the users to postgresql:
37+
38+
39+```
40+CREATE USER jobs_test WITH SUPERUSER PASSWORD 'jobs_test';
41+CREATE DATABASE jobs_test OWNER jobs_test;
42+```
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+24−32
@@ -4,7 +4,7 @@ In this example, our jobs table will be called `jobs_test`
44
55<div class="lhs-code">
66```
7−ghci> import Datasbe.PostgreSQL.Simple (connectPostgreSQL)
7+ghci> import Database.PostgreSQL.Simple (connectPostgreSQL)
88ghci> import OddJobs.Migrations
99ghci> conn <- connectPostgreSQL "dbname=jobs_test user=jobs_test password=jobs_test host=localhost"
1010ghci> createJobTable conn "jobs_test"
@@ -18,21 +18,22 @@ Ideally, this module should be compiled into a separate executable and should de
1818\begin{code}
1919{-# LANGUAGE DeriveAnyClass #-}
2020{-# LANGUAGE DeriveGeneric #-}
21−{-# LANGUAGE NamedFieldPuns #-}
2221{-# LANGUAGE OverloadedStrings #-}
2322{-# LANGUAGE LambdaCase #-}
23+{-# LANGUAGE RecordWildCards #-}
2424
2525module OddJobsCliExample where
2626
27−import OddJobs.Job (Job(..), ConcurrencyControl(..), Config(..), throwParsePayload)
28−import OddJobs.ConfigBuilder (mkConfig, withConnectionPool, defaultTimedLogger, defaultLogStr, defaultJobType)
29−import OddJobs.Cli (defaultMain)
27+import OddJobs.Job (Job(..), ConcurrencyControl(..), Config(..), throwParsePayload, startJobRunner, LogLevel(..), LogEvent(..))
28+import OddJobs.ConfigBuilder (mkConfig, withConnectionPool, defaultTimedLogger, defaultLogStr, defaultJobType, mkUIConfig)
29+import OddJobs.Cli (runCli, defaultWebUI, CliType(..))
3030
3131-- Note: It is not necessary to use fast-logger. You can use any logging library
3232-- that can give you a logging function in the IO monad.
33−import System.Log.FastLogger(withTimedFastLogger, LogType'(..), defaultBufSize)
33+import System.Log.FastLogger(withTimedFastLogger, LogType'(..), defaultBufSize, newTimedFastLogger)
3434import System.Log.FastLogger.Date (newTimeCache, simpleTimeFormat)
35−
35+import Database.PostgreSQL.Simple as PGS
36+import Data.Pool
3637import Data.Text (Text)
3738import Data.Aeson as Aeson
3839import GHC.Generics
@@ -70,16 +71,16 @@ In this example, the core job-runner function is in the `IO` monad. In all proba
7071\begin{code}
7172myJobRunner :: Job -> IO ()
7273myJobRunner job = do
73− (throwParsePayload job) >>= \case
74+ throwParsePayload job >>= \case
7475 SendWelcomeEmail userId -> do
7576 putStrLn $ "This should call the function that actually sends the welcome email. " <>
7677 "\nWe are purposely waiting 60 seconds before completing this job so that graceful shutdown can be demonstrated."
7778 delaySeconds (Seconds 60)
78− putStrLn "60 second wait is now over..."
79− SendPasswordResetEmail tkn ->
79+ putStrLn $ "SendWelcomeEmail to user: " <> show userId <> " complete (60 second wait is now over...)"
80+ SendPasswordResetEmail _tkn ->
8081 putStrLn "This should call the function that actually sends the password-reset email"
81− SetupSampleData userId -> do
82− Prelude.error "User onboarding is incomplete"
82+ SetupSampleData _userId -> do
83+ _ <- Prelude.error "User onboarding is incomplete"
8384 putStrLn "This should call the function that actually sets up sample data in a newly registered user's account"
8485\end{code}
8586
@@ -87,32 +88,23 @@ myJobRunner job = do
8788
8889\begin{code}
8990main :: IO ()
90−main = do
91− defaultMain startJobMonitor
91+main = runCli CliBoth{..}
9292 where
93− -- A callback-within-callback function. If the commands-line args contain a
94− -- `start` command, this function will be called. Once this function has
95− -- constructed the 'Config' (which requires setting up a logging function,
96− -- and a DB pool) it needs to execute the `callback` function that is passed
97− -- to it.
98− startJobMonitor callback =
99−
100− -- a utility function provided by `OddJobs.ConfigBuilder` which ensures
101− -- that the DB pool is gracefully destroyed upon shutdown.
93+ cliStartJobRunner cfgOverrideFn = do
10294 withConnectionPool (Left "dbname=jobs_test user=jobs_test password=jobs_test host=localhost")$ \dbPool -> do
103−
104− -- Boilerplate code to setup a TimedFastLogger (from the fast-logger library)
10595 tcache <- newTimeCache simpleTimeFormat
10696 withTimedFastLogger tcache (LogFileNoRotate "oddjobs.log" defaultBufSize) $ \logger -> do
107−
108− -- Using the default string-based logging provided by
109− -- `OddJobs.ConfigBuilder`. If you want to actually use
110− -- structured-logging you'll need to define your own logging function.
11197 let jobLogger = defaultTimedLogger logger (defaultLogStr defaultJobType)
112− cfg = mkConfig jobLogger "jobs" dbPool (MaxConcurrentJobs 50) myJobRunner Prelude.id
98+ startJobRunner $
99+ mkConfig jobLogger "jobs" dbPool (MaxConcurrentJobs 50) myJobRunner cfgOverrideFn
113100
114− -- Finally, executing the callback function that was passed to me...
115− callback cfg
101+ cliStartWebUI uiStartArgs cfgOverrideFn = do
102+ withConnectionPool (Left "dbname=jobs_test user=jobs_test password=jobs_test host=localhost")$ \dbPool -> do
103+ tcache <- newTimeCache simpleTimeFormat
104+ withTimedFastLogger tcache (LogFileNoRotate "oddjobs-web.log" defaultBufSize) $ \logger -> do
105+ let jobLogger = defaultTimedLogger logger (defaultLogStr defaultJobType)
106+ defaultWebUI uiStartArgs $
107+ mkUIConfig jobLogger "jobs" dbPool cfgOverrideFn
116108\end{code}
117109
118110=== 6. Compile and start the Odd Jobs runner
flake.locknew file+44
@@ -0,0 +1,44 @@
1+{
2+ "nodes": {
3+ "flake-compat": {
4+ "flake": false,
5+ "locked": {
6+ "lastModified": 1673956053,
7+ "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
8+ "owner": "edolstra",
9+ "repo": "flake-compat",
10+ "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
11+ "type": "github"
12+ },
13+ "original": {
14+ "owner": "edolstra",
15+ "repo": "flake-compat",
16+ "type": "github"
17+ }
18+ },
19+ "nixpkgs": {
20+ "locked": {
21+ "lastModified": 1688556768,
22+ "narHash": "sha256-mhd6g0iJGjEfOr3+6mZZOclUveeNr64OwxdbNtLc8mY=",
23+ "owner": "nixos",
24+ "repo": "nixpkgs",
25+ "rev": "27bd67e55fe09f9d68c77ff151c3e44c4f81f7de",
26+ "type": "github"
27+ },
28+ "original": {
29+ "owner": "nixos",
30+ "ref": "nixpkgs-unstable",
31+ "repo": "nixpkgs",
32+ "type": "github"
33+ }
34+ },
35+ "root": {
36+ "inputs": {
37+ "flake-compat": "flake-compat",
38+ "nixpkgs": "nixpkgs"
39+ }
40+ }
41+ },
42+ "root": "root",
43+ "version": 7
44+}
flake.nixnew file+44
@@ -0,0 +1,44 @@
1+# I used chatgpt to generate this template and then just
2+# modified to how I normally use these things.
3+{
4+ description = "odd jobs";
5+
6+ inputs = {
7+ nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
8+ flake-compat = {
9+ url = "github:edolstra/flake-compat";
10+ flake = false;
11+ };
12+ };
13+
14+ outputs = { self, nixpkgs, flake-compat }:
15+ let
16+ pkgs = nixpkgs.legacyPackages.x86_64-linux;
17+ hpkgs = pkgs.haskellPackages.override {
18+ overrides = hnew: hold: {
19+ odd-jobs = pkgs.haskell.lib.dontCheck # TOOD need to figure out how to make it call nixosTest end setup a postgres
20+ (hnew.callCabal2nix "odd-jobs" ./. { });
21+ servant-static-th = pkgs.haskell.lib.dontCheck (pkgs.haskell.lib.markUnbroken hold.servant-static-th);
22+ resource-pool = hnew.callHackageDirect {
23+ pkg = "resource-pool";
24+ ver = "0.4.0.0";
25+ sha256 = "sha256-X3VI1LnkyB28ZumRzOFUNG1UIJiW1UH63ZW/CPqPln4=";
26+ } {};
27+ };
28+ };
29+ in
30+ {
31+ defaultPackage.x86_64-linux = hpkgs.odd-jobs;
32+ inherit pkgs;
33+ devShell.x86_64-linux = hpkgs.shellFor {
34+ packages = ps : [ ps."odd-jobs" ];
35+ withHoogle = true;
36+
37+ buildInputs = [
38+ hpkgs.haskell-language-server
39+ pkgs.ghcid
40+ pkgs.cabal-install
41+ ];
42+ };
43+ };
44+}
odd-jobs.cabal+59−19
@@ -1,13 +1,13 @@
11cabal-version: 1.12
22
3−-- This file has been generated from package.yaml by hpack version 0.31.2.
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: e6616995768a7a1fd8654b632013cf28cfa6e31926533c4470e8e970d1d2d3bd
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
65− ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields
64+ default-extensions:
65+ NamedFieldPuns
66+ LambdaCase
67+ TemplateHaskell
68+ ScopedTypeVariables
69+ GeneralizedNewtypeDeriving
70+ QuasiQuotes
71+ OverloadedStrings
72+ 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− , direct-daemonize
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
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
119− ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields -threaded -with-rtsopts=-N -main-is DevelMain
127+ default-extensions:
128+ NamedFieldPuns
129+ LambdaCase
130+ TemplateHaskell
131+ ScopedTypeVariables
132+ GeneralizedNewtypeDeriving
133+ QuasiQuotes
134+ OverloadedStrings
135+ 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− , direct-daemonize
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
166− ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields -threaded -with-rtsopts=-N -main-is OddJobsCliExample
183+ default-extensions:
184+ NamedFieldPuns
185+ LambdaCase
186+ TemplateHaskell
187+ ScopedTypeVariables
188+ GeneralizedNewtypeDeriving
189+ QuasiQuotes
190+ OverloadedStrings
191+ 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− , direct-daemonize
172197 , directory
173198 , either
174199 , fast-logger
175200 , filepath
176201 , friendly-time
177202 , generic-deriving
203+ , hdaemonize
178204 , hostname
179205 , lucid
180206 , monad-control
@@ -207,25 +233,40 @@ test-suite jobrunner
207233 main-is: Test.hs
208234 other-modules:
209235 CliParser
210− Try
211− Try2
236+ OddJobs.Cli
237+ OddJobs.ConfigBuilder
238+ OddJobs.Endpoints
239+ OddJobs.Job
240+ OddJobs.Migrations
241+ OddJobs.Types
242+ OddJobs.Web
243+ UI
212244 Paths_odd_jobs
213245 hs-source-dirs:
214246 test
215− default-extensions: NamedFieldPuns LambdaCase TemplateHaskell ScopedTypeVariables GeneralizedNewtypeDeriving QuasiQuotes OverloadedStrings
216− ghc-options: -Wall -fno-warn-orphans -fno-warn-unused-imports -fno-warn-dodgy-exports -Werror=missing-fields -threaded -with-rtsopts=-N -main-is Test
247+ src
248+ default-extensions:
249+ NamedFieldPuns
250+ LambdaCase
251+ TemplateHaskell
252+ ScopedTypeVariables
253+ GeneralizedNewtypeDeriving
254+ QuasiQuotes
255+ OverloadedStrings
256+ 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
217257 build-depends:
218258 aeson
259+ , async ==2.2.4
219260 , base >=4.7 && <5
220261 , bytestring
221262 , containers
222− , direct-daemonize
223263 , directory
224264 , either
225265 , fast-logger
226266 , filepath
227267 , friendly-time
228268 , generic-deriving
269+ , hdaemonize
229270 , hedgehog
230271 , hostname
231272 , lifted-async
@@ -235,7 +276,6 @@ test-suite jobrunner
235276 , monad-control
236277 , monad-logger
237278 , mtl
238− , odd-jobs
239279 , optparse-applicative
240280 , postgresql-simple
241281 , random
package.yaml+7−4
@@ -45,6 +45,7 @@ ghc-options:
4545 - -fno-warn-unused-imports
4646 - -fno-warn-dodgy-exports
4747 - -Werror=missing-fields
48+ - -Werror=incomplete-patterns
4849
4950default-extensions:
5051 - NamedFieldPuns
@@ -57,6 +58,8 @@ default-extensions:
5758
5859dependencies:
5960 - base >= 4.7 && < 5
61+ - async ==2.2.4
62+ - hdaemonize
6063 - text
6164 - postgresql-simple
6265 - bytestring
@@ -82,9 +85,9 @@ dependencies:
8285 - servant-server
8386 - servant-lucid
8487 - warp
88+ - containers
8589 - unordered-containers
8690 - optparse-applicative
87− - direct-daemonize
8891 - filepath
8992 - directory
9093 - generic-deriving
@@ -133,17 +136,17 @@ tests:
133136 - -threaded
134137 - -with-rtsopts=-N
135138 main: Test
136− source-dirs: test
139+ source-dirs:
140+ - test
141+ - src
137142 dependencies:
138143 - tasty
139144 - tasty-discover
140145 - hedgehog
141146 - tasty-hedgehog
142− - odd-jobs
143147 - tasty-hunit
144148 - random
145149 - monad-control
146150 - mmorph
147151 - lifted-base
148152 - lifted-async
149− - 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+138−121
@@ -5,17 +5,24 @@
55module OddJobs.Cli where
66
77import Options.Applicative as Opts
8+import Control.Concurrent.Async (race)
9+import Control.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar, tryTakeMVar)
10+import Data.Coerce (coerce)
11+import Data.Functor (void)
812import Data.Text
9−import OddJobs.Job (startJobRunner, Config(..))
10−import System.Daemonize (DaemonOptions(..), daemonize)
11−import System.FilePath (FilePath)
13+import OddJobs.Job (startJobRunner, Config(..), LogLevel(..), LogEvent(..))
14+import OddJobs.Types (UIConfig(..), Seconds(..), delaySeconds)
15+import qualified System.Posix.Daemonize as Daemonize
16+import System.FilePath (FilePath, takeBaseName, takeDirectory)
1217import System.Posix.Process (getProcessID)
18+import System.Posix.Signals (Handler(CatchOnce), installHandler, sigTERM)
1319import qualified System.Directory as Dir
1420import qualified System.Exit as Exit
1521import System.Environment (getProgName)
16−import OddJobs.Types (Seconds(..), delaySeconds)
1722import qualified System.Posix.Signals as Sig
1823import qualified UnliftIO.Async as Async
24+import UnliftIO (bracket_)
25+import Safe (fromJustNote)
1926import qualified OddJobs.Endpoints as UI
2027import Servant.Server as Servant
2128import Servant.API
@@ -23,6 +30,7 @@ import Data.Proxy
2330import Data.Text.Encoding (decodeUtf8)
2431import Network.Wai.Handler.Warp as Warp
2532import Debug.Trace
33+import Data.String.Conv (toS)
2634
2735-- * Introduction
2836--
@@ -57,6 +65,11 @@ import Debug.Trace
5765-- #defaultBehaviour#
5866--
5967
68+
69+data CliType = CliOnlyJobRunner { cliStartJobRunner :: (Config -> Config) -> IO () }
70+ | CliOnlyWebUi { cliStartWebUI :: UIStartArgs -> (UIConfig -> UIConfig) -> IO () }
71+ | CliBoth { cliStartJobRunner :: (Config -> Config) -> IO (), cliStartWebUI :: UIStartArgs -> (UIConfig -> UIConfig) -> IO () }
72+
6073{-|
6174Please do not get scared by the type-signature of the first argument.
6275Conceptually, it's a callback, within another callback.
@@ -81,20 +94,44 @@ Please take a look at the [getting started
8194guide](https://www.haskelltutorials.com/odd-jobs/guide.html#getting-started) for
8295an example of how to use this function.
8396-}
84−defaultMain :: ((Config -> IO ()) -> IO ())
85− -- ^ A callback function that will be executed once the dameon has
86− -- forked into the background.
87− -> IO ()
88−defaultMain startFn = do
89− Args{argsCommand} <- customExecParser defaultCliParserPrefs defaultCliInfo
97+runCli :: CliType -> IO ()
98+runCli cliType = do
99+ Args{argsCommand} <- customExecParser defaultCliParserPrefs (defaultCliInfo cliType)
90100 case argsCommand of
91− Start cmdArgs -> do
92− defaultStartCommand cmdArgs startFn
101+ Start commonArgs mUIArgs ->
102+ defaultStartCommand commonArgs mUIArgs cliType
93103 Stop cmdArgs -> do
94104 defaultStopCommand cmdArgs
95105 Status ->
96106 Prelude.error "not implemented yet"
97107
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+
98135{-| Used by 'defaultMain' if the 'Start' command is issued via the CLI. If
99136@--daemonize@ switch is also passed, it checks for 'startPidFile':
100137
@@ -104,57 +141,57 @@ defaultMain startFn = do
104141* If it exists, it refuses to start, to prevent multiple invocations of the same
105142 background daemon.
106143-}
107−defaultStartCommand :: StartArgs
108− -> ((Config -> IO ()) -> IO ())
109− -- ^ the same callback-within-callback function described in
110− -- 'defaultMain'
144+defaultStartCommand :: CommonStartArgs
145+ -> Maybe UIStartArgs
146+ -> CliType
111147 -> IO ()
112−defaultStartCommand args@StartArgs{..} startFn = do
113− progName <- getProgName
114− case startDaemonize of
115− False -> do
116− startFn coreStartupFn
117− True -> do
118− (Dir.doesPathExist startPidFile) >>= \case
119− True -> do
120− putStrLn $
121− "PID file already exists. Please check if " <> progName <> " is still running in the background." <>
122− " If not, you can safely delete this file and start " <> progName <> " again: " <> startPidFile
123− Exit.exitWith (Exit.ExitFailure 1)
124− False -> do
125− daemonize defaultDaemonOptions (pure ()) $ const $ do
126− pid <- getProcessID
127− writeFile startPidFile (show pid)
128− putStrLn $ "Started " <> progName <> " in background with PID=" <> show pid <> ". PID written to " <> startPidFile
129− startFn $ \cfg -> coreStartupFn cfg{cfgPidFile = Just startPidFile}
148+defaultStartCommand CommonStartArgs{..} mUIArgs cliType = do
149+ if startDaemonize then do
150+ Daemonize.serviced
151+ $ Daemonize.simpleDaemon
152+ { Daemonize.program = \() -> withGracefulTermination_ coreStartupFn
153+ , Daemonize.name = Just $ takeBaseName startPidFile
154+ , Daemonize.pidfileDirectory = Just $ takeDirectory startPidFile
155+ }
156+ else
157+ coreStartupFn
130158 where
131− coreStartupFn cfg = do
132− Async.withAsync (defaultWebUi args cfg) $ \_ -> do
133− startJobRunner cfg
134−
135−
136−defaultWebUi :: StartArgs
137− -> Config
159+ uiArgs = fromJustNote "Please specify Web UI Startup Args" $ traceShowId mUIArgs
160+ coreStartupFn =
161+ case cliType of
162+ CliOnlyJobRunner{..} -> do
163+ cliStartJobRunner Prelude.id
164+ CliOnlyWebUi{..} -> do
165+ traceM "CliOnlyWebUi before"
166+ cliStartWebUI uiArgs Prelude.id
167+ CliBoth{..} -> do
168+ traceM "CliBoth before"
169+ Async.withAsync (cliStartWebUI uiArgs Prelude.id) $ \_ -> do
170+ traceM "CliBoth inside withAsync"
171+ cliStartJobRunner Prelude.id
172+ traceM "CliBoth end"
173+
174+defaultWebUI :: UIStartArgs
175+ -> UIConfig
138176 -> IO ()
139−defaultWebUi StartArgs{..} cfg@Config{..} = do
140− env <- UI.mkEnv cfg ("/" <>)
141− case startWebUiAuth of
142− Nothing -> pure ()
143− Just AuthNone ->
144− let app = UI.server cfg env Prelude.id
145− in Warp.run startWebUiPort $
146− Servant.serve (Proxy :: Proxy UI.FinalAPI) app
147− Just (AuthBasic u p) ->
177+defaultWebUI UIStartArgs{..} uicfg@UIConfig{..} = do
178+ env <- UI.mkEnv uicfg ("/" <>)
179+ case uistartAuth of
180+ AuthNone -> do
181+ let app = UI.server uicfg env Prelude.id
182+ uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> toS (show uistartPort)
183+ Warp.run uistartPort $ Servant.serve (Proxy :: Proxy UI.FinalAPI) app
184+ (AuthBasic u p) -> do
148185 let api = Proxy :: Proxy (BasicAuth "OddJobs Admin UI" OddJobsUser :> UI.FinalAPI)
149186 ctx = defaultBasicAuth (u, p) :. EmptyContext
150187 -- Now the app will receive an extra argument for OddJobsUser,
151188 -- which we aren't really interested in.
152− app _ = UI.server cfg env Prelude.id
153− in Warp.run startWebUiPort $
154− Servant.serveWithContext api ctx app
189+ app _ = UI.server uicfg env Prelude.id
190+ uicfgLogger LevelInfo $ LogText $ "Starting admin UI on port " <> toS (show uistartPort)
191+ Warp.run uistartPort $ Servant.serveWithContext api ctx app
155192
156193{-| Used by 'defaultMain' if 'Stop' command is issued via the CLI. Sends a
157−@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
158195of 'shutTimeout' seconds (controller by @--timeout@) for the daemon to shutdown
159196gracefully, after which a @SIGKILL@ is issued
160197-}
@@ -162,31 +199,15 @@ defaultStopCommand :: StopArgs
162199 -> IO ()
163200defaultStopCommand StopArgs{..} = do
164201 progName <- getProgName
165− pid <- read <$> (readFile shutPidFile)
166− if (shutTimeout == Seconds 0)
167− then forceKill pid
168− else do putStrLn $ "Sending SIGINT to pid=" <> show pid <>
169− " and waiting " <> (show $ unSeconds shutTimeout) <> " seconds for graceful stop"
170− Sig.signalProcess Sig.sigINT pid
171− (Async.race (delaySeconds shutTimeout) checkProcessStatus) >>= \case
172− Right _ -> do
173− putStrLn $ progName <> " seems to have exited gracefully."
174− Exit.exitSuccess
175− Left _ -> do
176− putStrLn $ progName <> " has still not exited."
177− forceKill pid
178− where
179− forceKill pid = do
180− putStrLn $ "Sending SIGKILL to pid=" <> show pid
181− Sig.signalProcess Sig.sigKILL pid
182−
183− checkProcessStatus = do
184− Dir.doesPathExist shutPidFile >>= \case
185− True -> do
186− delaySeconds (Seconds 1)
187− checkProcessStatus
188− False -> do
189− pure ()
202+ putStrLn $ "Sending SIGTERM to " <> show progName <>
203+ " and waiting " <> show (unSeconds shutTimeout) <> " seconds for graceful stop"
204+ flip Daemonize.serviced' Daemonize.Stop
205+ $ Daemonize.simpleDaemon
206+ { Daemonize.name = Just $ takeBaseName shutPidFile
207+ , Daemonize.pidfileDirectory = Just $ takeDirectory shutPidFile
208+ , Daemonize.killWait = Just $ coerce shutTimeout
209+ }
210+
190211
191212-- * Default CLI parsers
192213--
@@ -206,47 +227,43 @@ data Args = Args
206227
207228
208229-- | The top-level command-line parser
209−argParser :: Parser Args
210−argParser = Args <$> commandParser
230+argParser :: CliType -> Parser Args
231+argParser cliType = Args <$> commandParser cliType
211232
212233-- ** Top-level command parser
213234
214235-- | CLI commands are parsed into this data-structure by 'commandParser'
215236data Command
216− = Start StartArgs
237+ = Start CommonStartArgs (Maybe UIStartArgs)
217238 | Stop StopArgs
218239 | Status
219240 deriving (Eq, Show)
220241
221242-- Parser for 'argsCommand'
222−commandParser :: Parser Command
223−commandParser = hsubparser
224− ( command "start" (info startParser (progDesc "start the odd-jobs runner")) <>
225− command "stop" (info stopParser (progDesc "stop the odd-jobs runner")) <>
243+commandParser :: CliType -> Parser Command
244+commandParser cliType = hsubparser
245+ ( command "start" (info (startCmdParser cliType) (progDesc "start the odd-jobs runner and/or admin UI")) <>
246+ command "stop" (info stopParser (progDesc "stop the odd-jobs runner and/or admin UI")) <>
226247 command "status" (info statusParser (progDesc "print status of all active jobs"))
227248 )
228249
250+
251+data UIStartArgs = UIStartArgs
252+ { uistartAuth :: !WebUiAuth
253+ , uistartPort :: !Int
254+ } deriving (Eq, Show)
255+
229256-- ** Start command
230257
231258-- | @start@ command is parsed into this data-structure by 'startParser'
232−data StartArgs = StartArgs
233− {
234− -- | Switch to pick the authentication mechanism for web UI. __Note:__ We
235− -- don't have a separate switch to enable\/disable the web UI. Picking an
236− -- authentication mechanism by specifying the relevant options,
237− -- automatically enables the web UI. Ref: 'webUiAuthParser'
238− startWebUiAuth :: !(Maybe WebUiAuth)
239− -- | Port on which the web UI will run.
240− , startWebUiPort :: !Int
241− -- | You'll need to pass the @--daemonize@ switch to fork the job-runner as
242− -- a background daemon, else it will keep running as a foreground process.
243− , startDaemonize :: !Bool
259+data CommonStartArgs = CommonStartArgs
260+ { startDaemonize :: !Bool
244261 -- | PID file for the background dameon. Ref: 'pidFileParser'
245262 , startPidFile :: !FilePath
246263 } deriving (Eq, Show)
247264
248−startParser :: Parser Command
249−startParser = fmap Start $ StartArgs
265+uiStartArgsParser :: Parser UIStartArgs
266+uiStartArgsParser = UIStartArgs
250267 <$> webUiAuthParser
251268 <*> option auto ( long "web-ui-port" <>
252269 metavar "PORT" <>
@@ -254,11 +271,23 @@ startParser = fmap Start $ StartArgs
254271 showDefault <>
255272 help "The port on which the Web UI listens. Please note, to actually enable the Web UI you need to pick one of the available auth schemes"
256273 )
257− <*> switch ( long "daemonize" <>
274+
275+commonStartArgsParser :: Parser CommonStartArgs
276+commonStartArgsParser = CommonStartArgs
277+ <$> switch ( long "daemonize" <>
258278 help "Fork the job-runner as a background daemon. If omitted, the job-runner remains in the foreground."
259279 )
260280 <*> pidFileParser
261281
282+startCmdParser :: CliType -> Parser Command
283+startCmdParser cliType = Start
284+ <$> commonStartArgsParser
285+ <*> (case cliType of
286+ CliOnlyJobRunner _ -> pure Nothing
287+ CliOnlyWebUi _ -> Just <$> uiStartArgsParser
288+ CliBoth _ _ -> optional uiStartArgsParser
289+ )
290+
262291data WebUiAuth
263292 = AuthNone
264293 | AuthBasic !Text !Text
@@ -269,10 +298,10 @@ data WebUiAuth
269298-- * No auth - @--web-ui-no-auth@ __NOT RECOMMENDED__
270299-- * Basic auth - @--web-ui-basic-auth-user <USER>@ and
271300-- @--web-ui-basic-auth-password <PASS>@
272−webUiAuthParser :: Parser (Maybe WebUiAuth)
273−webUiAuthParser = basicAuthParser <|> noAuthParser <|> (pure Nothing)
301+webUiAuthParser :: Parser WebUiAuth
302+webUiAuthParser = basicAuthParser <|> noAuthParser
274303 where
275− basicAuthParser = fmap Just $ AuthBasic
304+ basicAuthParser = AuthBasic
276305 <$> strOption ( long "web-ui-basic-auth-user" <>
277306 metavar "USER" <>
278307 help "Username for basic auth"
@@ -281,7 +310,7 @@ webUiAuthParser = basicAuthParser <|> noAuthParser <|> (pure Nothing)
281310 metavar "PASS" <>
282311 help "Password for basic auth"
283312 )
284− noAuthParser = flag' (Just AuthNone)
313+ noAuthParser = flag' AuthNone
285314 ( long "web-ui-no-auth" <>
286315 help "Start the web UI with any authentication. NOT RECOMMENDED."
287316 )
@@ -293,7 +322,7 @@ webUiAuthParser = basicAuthParser <|> noAuthParser <|> (pure Nothing)
293322-- 'shutTimeout' seconds. If the daemon doesn't shut down cleanly within that
294323-- time, it sends a @SIGKILL@ to kill immediately.
295324data StopArgs = StopArgs
296− { -- | 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
297326 -- @SIGKILL@
298327 shutTimeout :: !Seconds
299328 -- | PID file of the deamon. Ref: 'pidFileParser'
@@ -305,8 +334,6 @@ stopParser = fmap Stop $ StopArgs
305334 <$> option (Seconds <$> auto) ( long "timeout" <>
306335 metavar "TIMEOUT" <>
307336 help "Maximum seconds to wait before force-killing the background daemon."
308− -- value defaultTimeout <>
309− -- showDefaultWith (show . unSeconds)
310337 )
311338 <*> pidFileParser
312339
@@ -335,19 +362,9 @@ defaultCliParserPrefs = prefs $
335362 showHelpOnError <>
336363 showHelpOnEmpty
337364
338−defaultCliInfo :: ParserInfo Args
339−defaultCliInfo =
340− info (argParser <**> helper) fullDesc
341−
342−defaultDaemonOptions :: DaemonOptions
343−defaultDaemonOptions = DaemonOptions
344− { daemonShouldChangeDirectory = False
345− , daemonShouldCloseStandardStreams = False
346− , daemonShouldIgnoreSignals = True
347− , daemonUserToChangeTo = Nothing
348− , daemonGroupToChangeTo = Nothing
349− }
350−
365+defaultCliInfo :: CliType -> ParserInfo Args
366+defaultCliInfo cliType =
367+ info (argParser cliType <**> helper) fullDesc
351368
352369-- ** Auth implementations for the default Web UI
353370
src/OddJobs/ConfigBuilder.hs+100−48
@@ -1,5 +1,6 @@
11{-# LANGUAGE RankNTypes #-}
22{-# LANGUAGE FlexibleContexts #-}
3+{-# LANGUAGE CPP #-}
34
45module OddJobs.ConfigBuilder where
56
@@ -13,7 +14,6 @@ import Data.Maybe (fromMaybe)
1314import Data.List as DL
1415import Data.Aeson as Aeson hiding (Success)
1516import qualified Data.Text as T
16−import qualified Data.HashMap.Lazy as HM
1717import GHC.Generics
1818import Data.Proxy (Proxy(..))
1919import Generics.Deriving.ConNames
@@ -24,6 +24,16 @@ import qualified Data.ByteString as BS
2424import UnliftIO (MonadUnliftIO, withRunInIO, bracket, liftIO)
2525import qualified System.Log.FastLogger as FLogger
2626
27+# if MIN_VERSION_aeson(2, 0, 0)
28+import qualified Data.Aeson.KeyMap as HM
29+import qualified Data.Aeson.Key as Key
30+keyToText :: Key.Key -> T.Text
31+keyToText = Key.toText
32+# else
33+import qualified Data.HashMap.Lazy as HM
34+keyToText :: T.Text -> T.Text
35+keyToText = id
36+# endif
2737
2838-- | This function gives you a 'Config' with a bunch of sensible defaults
2939-- already applied. It requires the bare minimum configuration parameters that
@@ -56,27 +66,59 @@ mkConfig :: (LogLevel -> LogEvent -> IO ())
5666mkConfig logger tname dbpool ccControl jrunner configOverridesFn =
5767 let cfg = configOverridesFn $ Config
5868 { cfgPollingInterval = defaultPollingInterval
59− , cfgOnJobSuccess = (const $ pure ())
69+ , cfgOnJobSuccess = const $ pure ()
6070 , cfgOnJobFailed = []
6171 , cfgJobRunner = jrunner
6272 , cfgLogger = logger
6373 , cfgDbPool = dbpool
64− , cfgOnJobStart = (const $ pure ())
74+ , cfgOnJobStart = const $ pure ()
6575 , cfgDefaultMaxAttempts = 10
6676 , cfgTableName = tname
67− , cfgOnJobTimeout = (const $ pure ())
77+ , cfgOnJobTimeout = const $ pure ()
6878 , cfgConcurrencyControl = ccControl
69− , cfgPidFile = Nothing
7079 , cfgJobType = defaultJobType
7180 , cfgDefaultJobTimeout = Seconds 600
72− , cfgJobToHtml = defaultJobToHtml (cfgJobType cfg)
73− , cfgAllJobTypes = (defaultDynamicJobTypes (cfgTableName cfg) (cfgJobTypeSql cfg))
74− , cfgJobTypeSql = defaultJobTypeSql
81+ , cfgDeleteSuccessfulJobs = True
82+ , cfgDefaultRetryBackoff = \attempts -> pure $ Seconds $ 2 ^ attempts
83+ }
84+ in cfg
85+
86+
87+mkUIConfig :: (LogLevel -> LogEvent -> IO ())
88+ -- ^ "Structured logging" function. Ref: 'uicfgLogger'
89+ -> TableName
90+ -- ^ DB table which holds your jobs. Ref: 'uicfgTableName'
91+ -> Pool Connection
92+ -- ^ DB connection-pool to be used by web UI. Ref: 'uicfgDbPool'
93+ -> (UIConfig -> UIConfig)
94+ -- ^ A function that allows you to modify the \"interim UI config\".
95+ -- The \"interim config\" will cotain a bunch of in-built default
96+ -- config params, along with the config params that you\'ve just
97+ -- provided (i.e. logging function, table name, DB pool, etc). You
98+ -- can use this function to override values in the \"interim UI
99+ -- config\". If you do not wish to modify the \"interim UI config\"
100+ -- just pass 'Prelude.id' as an argument to this parameter. __Note:__
101+ -- it is strongly recommended that you __do not__ modify the
102+ -- generated 'Config' outside of this function, unless you know what
103+ -- you're doing.
104+ -> UIConfig
105+ -- ^ The final 'UIConfig' that needs to be passed to
106+ -- 'OddJobs.Endpoint.mkEnv' and 'OddJobs.Cli.defaultWebUI'
107+mkUIConfig logger tname dbpool configOverridesFn =
108+ let cfg = configOverridesFn $ UIConfig
109+ { uicfgLogger = logger
110+ , uicfgDbPool = dbpool
111+ , uicfgTableName = tname
112+ , uicfgJobType = defaultJobType
113+ , uicfgJobToHtml = defaultJobsToHtml (uicfgJobType cfg)
114+ , uicfgAllJobTypes = defaultDynamicJobTypes (uicfgTableName cfg) (uicfgJobTypeSql cfg)
115+ , uicfgJobTypeSql = defaultJobTypeSql
75116 }
76117 in cfg
77118
78119
79120
121+
80122-- | If you aren't interested in structured logging, you can use this function
81123-- to emit plain-text logs (or define your own).
82124defaultLogStr :: (Job -> Text)
@@ -84,10 +126,10 @@ defaultLogStr :: (Job -> Text)
84126 -> LogEvent
85127 -> LogStr
86128defaultLogStr jobTypeFn logLevel logEvent =
87− (toLogStr $ show logLevel) <> " | " <> str
129+ toLogStr (show logLevel) <> " | " <> str
88130 where
89131 jobToLogStr job@Job{jobId} =
90− "JobId=" <> (toLogStr $ show jobId) <> " JobType=" <> (toLogStr $ jobTypeFn job)
132+ "JobId=" <> toLogStr (show jobId) <> " JobType=" <> toLogStr (jobTypeFn job)
91133
92134 str = case logEvent of
93135 LogJobStart j ->
@@ -96,12 +138,16 @@ defaultLogStr jobTypeFn logLevel logEvent =
96138 let tag = case fm of
97139 FailWithRetry -> "Failed (retry)"
98140 FailPermanent -> "Failed (permanent)"
99− 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)
100142 LogJobSuccess j t ->
101− "Success | " <> (jobToLogStr j) <> " | runtime=" <> (toLogStr $ show t)
143+ "Success | " <> jobToLogStr j <> " | runtime=" <> toLogStr (show t)
102144 LogJobTimeout j@Job{jobLockedAt, jobLockedBy} ->
103− "Timeout | " <> jobToLogStr j <> " | lockedBy=" <> (toLogStr $ maybe "unknown" unJobRunnerName jobLockedBy) <>
104− " 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)"
105151 LogPoll ->
106152 "Polling jobs table"
107153 LogWebUIRequest ->
@@ -109,28 +155,30 @@ defaultLogStr jobTypeFn logLevel logEvent =
109155 LogText t ->
110156 toLogStr t
111157
158+defaultJobsToHtml :: (Job -> Text)
159+ -> [Job]
160+ -> IO [Html ()]
161+defaultJobsToHtml jobType js = pure $ DL.map (defaultJobToHtml jobType) js
162+
163+
112164defaultJobToHtml :: (Job -> Text)
113− -> [Job]
114− -> IO [Html ()]
115−defaultJobToHtml jobType js =
116− pure $ DL.map jobToHtml js
117− where
118− jobToHtml :: Job -> Html ()
119− jobToHtml j = do
120− div_ [ class_ "job" ] $ do
121− div_ [ class_ "job-type" ] $ do
122− toHtml $ jobType j
123− div_ [ class_ "job-payload" ] $ do
124− defaultPayloadToHtml $ defaultJobContent $ jobPayload j
125− case jobLastError j of
126− Nothing -> mempty
127− Just e -> do
128− div_ [ class_ "job-error collapsed" ] $ do
129− a_ [ href_ "javascript: void(0);", onclick_ "toggleError(this)" ] $ do
130− span_ [ class_ "badge badge-secondary error-expand" ] "+ Last error"
131− span_ [ class_ "badge badge-secondary error-collapse d-none" ] "- Last error"
132− " "
133− defaultErrorToHtml e
165+ -> Job
166+ -> Html ()
167+defaultJobToHtml jobType j =
168+ div_ [ class_ "job" ] $ do
169+ div_ [ class_ "job-type" ] $ do
170+ toHtml $ jobType j
171+ div_ [ class_ "job-payload" ] $ do
172+ defaultPayloadToHtml $ defaultJobContent $ jobPayload j
173+ case jobLastError j of
174+ Nothing -> mempty
175+ Just e -> do
176+ div_ [ class_ "job-error collapsed" ] $ do
177+ a_ [ href_ "javascript: void(0);", onclick_ "toggleError(this)" ] $ do
178+ span_ [ class_ "badge badge-secondary error-expand" ] "+ Last error"
179+ span_ [ class_ "badge badge-secondary error-collapse d-none" ] "- Last error"
180+ " "
181+ defaultErrorToHtml e
134182
135183
136184defaultErrorToHtml :: Value -> Html ()
@@ -150,9 +198,7 @@ defaultErrorToHtml e =
150198
151199defaultJobContent :: Value -> Value
152200defaultJobContent v = case v of
153− Aeson.Object o -> case HM.lookup "contents" o of
154− Nothing -> v
155− Just c -> c
201+ Aeson.Object o -> fromMaybe v (HM.lookup "contents" o)
156202 _ -> v
157203
158204defaultPayloadToHtml :: Value -> Html ()
@@ -161,7 +207,7 @@ defaultPayloadToHtml v = case v of
161207 toHtml ("{ " :: Text)
162208 forM_ (HM.toList o) $ \(k, v2) -> do
163209 span_ [ class_ " key-value-pair " ] $ do
164− span_ [ class_ "key" ] $ toHtml $ k <> ":"
210+ span_ [ class_ "key" ] $ toHtml $ keyToText k <> ":"
165211 span_ [ class_ "value" ] $ defaultPayloadToHtml v2
166212 toHtml (" }" :: Text)
167213 Aeson.Array a -> do
@@ -188,7 +234,7 @@ defaultDynamicJobTypes :: TableName
188234 -> PGS.Query
189235 -> AllJobTypes
190236defaultDynamicJobTypes tname jobTypeSql = AJTSql $ \conn -> do
191− fmap (DL.map ((fromMaybe "(unknown)") . fromOnly)) $ PGS.query_ conn $ "select distinct(" <> jobTypeSql <> ") from " <> tname <> " order by 1 nulls last"
237+ DL.map (fromMaybe "(unknown)" . fromOnly) <$> PGS.query conn ("select distinct(" <> jobTypeSql <> ") from ? order by 1 nulls last") (Only tname)
192238
193239-- | This makes __two important assumptions__. First, this /assumes/ that jobs
194240-- in your app are represented by a sum-type. For example:
@@ -245,9 +291,9 @@ withConnectionPool connConfig action = withRunInIO $ \runInIO -> do
245291 poolCreator = liftIO $
246292 case connConfig of
247293 Left connString ->
248− createPool (PGS.connectPostgreSQL connString) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
294+ newPool $ defaultPoolConfig (PGS.connectPostgreSQL connString) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
249295 Right connInfo ->
250− createPool (PGS.connect connInfo) PGS.close 1 (fromIntegral $ 2 * (unSeconds defaultPollingInterval)) 8
296+ newPool $ defaultPoolConfig (PGS.connect connInfo) PGS.close (fromIntegral $ 2 * unSeconds defaultPollingInterval) 8
251297
252298-- | A convenience function to help you define a timed-logger with some sensible
253299-- defaults.
@@ -259,8 +305,8 @@ defaultTimedLogger :: FLogger.TimedFastLogger
259305defaultTimedLogger logger logStrFn logLevel logEvent =
260306 if logLevel == LevelDebug
261307 then pure ()
262− else logger $ \t -> (toLogStr t) <> " | " <>
263− (logStrFn logLevel logEvent) <>
308+ else logger $ \t -> toLogStr t <> " | " <>
309+ logStrFn logLevel logEvent <>
264310 "\n"
265311
266312
@@ -269,7 +315,7 @@ defaultJsonLogEvent logEvent =
269315 case logEvent of
270316 LogJobStart job ->
271317 Aeson.object [ "tag" Aeson..= ("LogJobStart" :: Text)
272− , "contents" Aeson..= (defaultJsonJob job) ]
318+ , "contents" Aeson..= defaultJsonJob job ]
273319 LogJobSuccess job runTime ->
274320 Aeson.object [ "tag" Aeson..= ("LogJobSuccess" :: Text)
275321 , "contents" Aeson..= (defaultJsonJob job, runTime) ]
@@ -278,7 +324,13 @@ defaultJsonLogEvent logEvent =
278324 , "contents" Aeson..= (defaultJsonJob job, show e, defaultJsonFailureMode fm, runTime) ]
279325 LogJobTimeout job ->
280326 Aeson.object [ "tag" Aeson..= ("LogJobTimeout" :: Text)
281− , "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 ]
282334 LogPoll ->
283335 Aeson.object [ "tag" Aeson..= ("LogJobPoll" :: Text)]
284336 LogWebUIRequest ->
@@ -288,7 +340,7 @@ defaultJsonLogEvent logEvent =
288340 , "contents" Aeson..= t ]
289341
290342defaultJsonJob :: Job -> Aeson.Value
291−defaultJsonJob job = genericToJSON Aeson.defaultOptions job
343+defaultJsonJob = genericToJSON Aeson.defaultOptions
292344
293345defaultJsonFailureMode :: FailureMode -> Aeson.Value
294−defaultJsonFailureMode fm = genericToJSON Aeson.defaultOptions fm
346+defaultJsonFailureMode = genericToJSON Aeson.defaultOptions
src/OddJobs/Endpoints.hs+49−36
@@ -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
@@ -74,8 +76,8 @@ data Env = Env
7476 , envJobRunnersRef :: IORef [JobRunnerName]
7577 }
7678
77−mkEnv :: (MonadIO m) => Config -> (Text -> Text) -> m Env
78−mkEnv cfg@Config{..} linksFn = do
79+mkEnv :: (MonadIO m) => UIConfig -> (Text -> Text) -> m Env
80+mkEnv cfg@UIConfig{} linksFn = do
7981 allJobTypes <- fetchAllJobTypes cfg
8082 allJobRunners <- fetchAllJobRunners cfg
8183 envJobTypesRef <- newIORef allJobTypes
@@ -101,76 +103,86 @@ stopApp = pure ()
101103
102104
103105server :: forall m . (MonadIO m)
104− => Config
106+ => UIConfig
105107 -> Env
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 }
120123
121−server2 :: Config
124+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 }
132136
133137
134−refreshJobRunners :: Config
138+refreshJobRunners :: UIConfig
135139 -> Env
136140 -> Handler NoContent
137−refreshJobRunners cfg@Config{..} Env{envRoutes=Web.Routes{..}, envJobRunnersRef} = do
141+refreshJobRunners cfg@UIConfig{} Env{envRoutes=Web.Routes{..}, envJobRunnersRef} = do
138142 allJobRunners <- fetchAllJobRunners cfg
139− atomicModifyIORef' envJobRunnersRef (\_ -> (allJobRunners, ()))
143+ atomicModifyIORef' envJobRunnersRef (const (allJobRunners, ()))
140144 throwError $ err302{errHeaders=[("Location", toS $ rFilterResults Nothing)]}
141145
142−refreshJobTypes :: Config
146+refreshJobTypes :: UIConfig
143147 -> Env
144148 -> Handler NoContent
145149refreshJobTypes cfg Env{envRoutes=Web.Routes{..}, envJobTypesRef} = do
146150 allJobTypes <- fetchAllJobTypes cfg
147− atomicModifyIORef' envJobTypesRef (\_ -> (allJobTypes, ()))
151+ atomicModifyIORef' envJobTypesRef (const (allJobTypes, ()))
148152 throwError $ err302{errHeaders=[("Location", toS $ rFilterResults Nothing)]}
149153
150−cancelJob :: Config
154+cancelJob :: UIConfig
151155 -> Env
152156 -> JobId
153157 -> Handler NoContent
154−cancelJob Config{..} env jid = do
155− liftIO $ withResource cfgDbPool $ \conn -> void $ cancelJobIO conn cfgTableName jid
158+cancelJob UIConfig{..} env jid = do
159+ liftIO $ withResource uicfgDbPool $ \conn -> void $ cancelJobIO conn uicfgTableName jid
156160 redirectToHome env
157161
158−runJobNow :: Config
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+
170+runJobNow :: UIConfig
159171 -> Env
160172 -> JobId
161173 -> Handler NoContent
162−runJobNow Config{..} env jid = do
163− liftIO $ withResource cfgDbPool $ \conn -> void $ runJobNowIO conn cfgTableName jid
174+runJobNow UIConfig{..} env jid = do
175+ liftIO $ withResource uicfgDbPool $ \conn -> void $ runJobNowIO conn uicfgTableName jid
164176 redirectToHome env
165177
166−enqueueJob :: Config
178+enqueueJob :: UIConfig
167179 -> Env
168180 -> JobId
169181 -> Handler NoContent
170−enqueueJob Config{..} env jid = do
171− liftIO $ withResource cfgDbPool $ \conn -> do
172− void $ unlockJobIO conn cfgTableName jid
173− void $ runJobNowIO conn cfgTableName jid
182+enqueueJob UIConfig{..} env jid = do
183+ liftIO $ withResource uicfgDbPool $ \conn -> do
184+ void $ unlockJobIO conn uicfgTableName jid
185+ void $ runJobNowIO conn uicfgTableName jid
174186 redirectToHome env
175187
176188redirectToHome :: Env -> Handler NoContent
@@ -178,17 +190,17 @@ redirectToHome Env{envRoutes=Web.Routes{..}} = do
178190 throwError $ err301{errHeaders=[("Location", toS $ rFilterResults Nothing)]}
179191
180192
181−filterResults :: Config
193+filterResults :: UIConfig
182194 -> Env
183195 -> Maybe Filter
184196 -> Handler (Html ())
185−filterResults cfg@Config{cfgJobToHtml, cfgDbPool} Env{..} mFilter = do
197+filterResults cfg@UIConfig{uicfgJobToHtml, uicfgDbPool} Env{..} mFilter = do
186198 let filters = fromMaybe mempty mFilter
187− (jobs, runningCount) <- liftIO $ Pool.withResource cfgDbPool $ \conn -> (,)
188− <$> (filterJobs cfg conn filters)
189− <*> (countJobs cfg conn filters{ filterStatuses = [Job.Locked] })
199+ (jobs, runningCount) <- liftIO $ Pool.withResource uicfgDbPool $ \conn -> (,)
200+ <$> filterJobs cfg conn filters
201+ <*> countJobs cfg conn filters{ filterStatuses = [Job.Locked] }
190202 t <- liftIO getCurrentTime
191− js <- liftIO $ fmap (DL.zip jobs) $ cfgJobToHtml jobs
203+ js <- liftIO (DL.zip jobs <$> uicfgJobToHtml jobs)
192204 allJobTypes <- readIORef envJobTypesRef
193205 let navHtml = Web.sideNav envRoutes allJobTypes [] t filters
194206 bodyHtml = Web.resultsPanel envRoutes t filters js runningCount
@@ -200,6 +212,7 @@ routes linkFn = Web.Routes
200212 , Web.rEnqueue = rEnqueue
201213 , Web.rRunNow = rRunNow
202214 , Web.rCancel = rCancel
215+ , Web.rKill = rKill
203216 , Web.rRefreshJobTypes = rRefreshJobTypes
204217 , Web.rRefreshJobRunners = rRefreshJobRunners
205218 , Web.rStaticAsset = linkFn
src/OddJobs/Job.hs+398−155
@@ -1,6 +1,5 @@
1−{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, TupleSections, DeriveGeneric, UndecidableInstances #-}
2−{-# LANGUAGE ExistentialQuantification #-}
3−{-# LANGUAGE ScopedTypeVariables #-}
1+{-# LANGUAGE RankNTypes, FlexibleInstances, FlexibleContexts, PartialTypeSignatures, UndecidableInstances #-}
2+{-# LANGUAGE ExistentialQuantification, RecordWildCards, ScopedTypeVariables #-}
43
54module OddJobs.Job
65 (
@@ -14,6 +13,7 @@ module OddJobs.Job
1413 -- $config
1514 , Config(..)
1615 , ConcurrencyControl(..)
16+ , ResourceCfg(..)
1717
1818 -- * Creating/scheduling jobs
1919 --
@@ -21,6 +21,9 @@ module OddJobs.Job
2121 , createJob
2222 , scheduleJob
2323
24+ , createJobWithResources
25+ , scheduleJobWithResources
26+
2427 -- * @Job@ and associated data-types
2528 --
2629 -- $dataTypes
@@ -33,6 +36,9 @@ module OddJobs.Job
3336 , Seconds(..)
3437 , JobErrHandler(..)
3538 , AllJobTypes(..)
39+ , ResourceId(..)
40+ , FunctionName
41+ , RunnerEnv(..)
3642
3743 -- ** Structured logging
3844 --
@@ -47,6 +53,7 @@ module OddJobs.Job
4753 , jobEventListener
4854 , jobPoller
4955 , jobPollingSql
56+ , pollRunJob
5057 , JobRunner
5158 , HasJobRunner (..)
5259
@@ -58,7 +65,9 @@ module OddJobs.Job
5865 , runJobNowIO
5966 , unlockJobIO
6067 , cancelJobIO
68+ , killJobIO
6169 , jobDbColumns
70+ , jobPollingIO
6271 , concatJobDbColumns
6372 , fetchAllJobTypes
6473 , fetchAllJobRunners
@@ -74,11 +83,12 @@ module OddJobs.Job
7483where
7584
7685import OddJobs.Types
77−import Data.Pool
86+import qualified Data.Pool as Pool
87+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)
@@ -130,7 +143,8 @@ import Database.PostgreSQL.Simple.ToField as PGS (toField)
130143class (MonadUnliftIO m, MonadBaseControl IO m) => HasJobRunner m where
131144 getPollingInterval :: m Seconds
132145 onJobSuccess :: Job -> m ()
133− onJobFailed :: forall a . m [JobErrHandler a]
146+ deleteSuccessfulJobs :: m Bool
147+ onJobFailed :: m [JobErrHandler]
134148 getJobRunner :: m (Job -> IO ())
135149 getDbPool :: m (Pool Connection)
136150 getTableName :: m TableName
@@ -138,10 +152,10 @@ class (MonadUnliftIO m, MonadBaseControl IO m) => HasJobRunner m where
138152 getDefaultMaxAttempts :: m Int
139153 getRunnerEnv :: m RunnerEnv
140154 getConcurrencyControl :: m ConcurrencyControl
141− getPidFile :: m (Maybe FilePath)
142155 log :: LogLevel -> LogEvent -> m ()
143156 getDefaultJobTimeout :: m Seconds
144−
157+ onJobTimeout :: Job -> m ()
158+ getDefaultRetryBackoff :: Int -> m Seconds
145159
146160-- $logging
147161--
@@ -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,31 +181,38 @@ 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− getJobRunner = cfgJobRunner . envConfig <$> ask
176− getDbPool = cfgDbPool . envConfig <$> ask
177− getTableName = cfgTableName . envConfig <$> ask
189+ deleteSuccessfulJobs = asks (cfgDeleteSuccessfulJobs . envConfig)
190+
191+ getJobRunner = asks (cfgJobRunner . envConfig)
192+ getDbPool = asks (cfgDbPool . envConfig)
193+ getTableName = asks (cfgTableName . envConfig)
178194 onJobStart job = do
179− fn <- cfgOnJobStart . envConfig <$> ask
195+ fn <- asks (cfgOnJobStart . envConfig)
180196 logCallbackErrors (jobId job) "onJobStart" $ liftIO $ fn job
181197
182− getDefaultMaxAttempts = cfgDefaultMaxAttempts . envConfig <$> ask
198+ getDefaultMaxAttempts = asks (cfgDefaultMaxAttempts . envConfig)
183199
184200 getRunnerEnv = ask
185201
186− getConcurrencyControl = (cfgConcurrencyControl . envConfig <$> ask)
187−
188− getPidFile = cfgPidFile . 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
212+
213+ getDefaultRetryBackoff attempts = do
214+ retryFn <- asks (cfgDefaultRetryBackoff . envConfig)
215+ liftIO $ retryFn attempts
195216
196217-- | Start the job-runner in the /current/ thread, i.e. you'll need to use
197218-- 'forkIO' or 'async' manually, if you want the job-runner to run in the
@@ -199,18 +220,20 @@ instance HasJobRunner RunnerM where
199220-- standalone daemon.
200221startJobRunner :: Config -> IO ()
201222startJobRunner jm = do
202− r <- newIORef []
223+ traceM "startJobRunner top"
224+ r <- newIORef DM.empty
203225 let monitorEnv = RunnerEnv
204226 { envConfig = jm
205227 , envJobThreadsRef = r
206228 }
229+ traceM "before runReaderT"
207230 runReaderT jobMonitor monitorEnv
208231
209232jobWorkerName :: IO String
210233jobWorkerName = do
211234 pid <- getProcessID
212235 hname <- getHostName
213− pure $ hname ++ ":" ++ (show pid)
236+ pure $ hname ++ ":" ++ show pid
214237
215238-- | If you are writing SQL queries where you want to return ALL columns from
216239-- the jobs table it is __recommended__ that you do not issue a @SELECT *@ or
@@ -241,12 +264,16 @@ concatJobDbColumns :: (IsString s, Semigroup s) => s
241264concatJobDbColumns = concatJobDbColumns_ jobDbColumns ""
242265 where
243266 concatJobDbColumns_ [] x = x
244− concatJobDbColumns_ (col:[]) x = x <> col
267+ concatJobDbColumns_ [col] x = x <> col
245268 concatJobDbColumns_ (col:cols) x = concatJobDbColumns_ cols (x <> col <> ", ")
246269
247270
248−findJobByIdQuery :: TableName -> PGS.Query
249−findJobByIdQuery tname = "SELECT " <> concatJobDbColumns <> " FROM " <> tname <> " WHERE id = ?"
271+findJobByIdQuery :: PGS.Query
272+findJobByIdQuery = "SELECT " <> concatJobDbColumns <> " FROM ? WHERE id = ?"
273+
274+withResource :: MonadUnliftIO m => Pool a -> (a -> m b) -> m b
275+withResource pool fa =
276+ withRunInIO $ \runInIO -> Pool.withResource pool (runInIO . fa)
250277
251278withDbConnection :: (HasJobRunner m)
252279 => (Connection -> m a)
@@ -275,17 +302,17 @@ findJobById jid = do
275302 withDbConnection $ \conn -> liftIO $ findJobByIdIO conn tname jid
276303
277304findJobByIdIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
278−findJobByIdIO conn tname jid = PGS.query conn (findJobByIdQuery tname) (Only jid) >>= \case
305+findJobByIdIO conn tname jid = PGS.query conn findJobByIdQuery (tname, jid) >>= \case
279306 [] -> pure Nothing
280307 [j] -> pure (Just j)
281− 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
282309
283310
284−saveJobQuery :: TableName -> PGS.Query
285−saveJobQuery tname = "UPDATE " <> tname <> " set run_at = ?, status = ?, payload = ?, last_error = ?, attempts = ?, locked_at = ?, locked_by = ? WHERE id = ? RETURNING " <> concatJobDbColumns
311+saveJobQuery :: PGS.Query
312+saveJobQuery = "UPDATE ? set run_at = ?, status = ?, payload = ?, last_error = ?, attempts = ?, locked_at = ?, locked_by = ? WHERE id = ? RETURNING " <> concatJobDbColumns
286313
287−deleteJobQuery :: TableName -> PGS.Query
288−deleteJobQuery tname = "DELETE FROM " <> tname <> " WHERE id = ?"
314+deleteJobQuery :: PGS.Query
315+deleteJobQuery = "DELETE FROM ? WHERE id = ?"
289316
290317saveJob :: (HasJobRunner m) => Job -> m Job
291318saveJob j = do
@@ -294,8 +321,9 @@ saveJob j = do
294321
295322saveJobIO :: Connection -> TableName -> Job -> IO Job
296323saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttempts, jobLockedBy, jobLockedAt, jobId} = do
297− rs <- PGS.query conn (saveJobQuery tname)
298− ( jobRunAt
324+ rs <- PGS.query conn saveJobQuery
325+ ( tname
326+ , jobRunAt
299327 , jobStatus
300328 , jobPayload
301329 , jobLastError
@@ -305,9 +333,9 @@ saveJobIO conn tname Job{jobRunAt, jobStatus, jobPayload, jobLastError, jobAttem
305333 , jobId
306334 )
307335 case rs of
308− [] -> 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
309337 [j] -> pure j
310− 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
311339
312340deleteJob :: (HasJobRunner m) => JobId -> m ()
313341deleteJob jid = do
@@ -316,33 +344,37 @@ deleteJob jid = do
316344
317345deleteJobIO :: Connection -> TableName -> JobId -> IO ()
318346deleteJobIO conn tname jid = do
319− void $ PGS.execute conn (deleteJobQuery tname) (Only jid)
347+ void $ PGS.execute conn deleteJobQuery (tname, jid)
320348
321349runJobNowIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
322350runJobNowIO conn tname jid = do
323351 t <- getCurrentTime
324− updateJobHelper tname conn (Queued, [Queued, Retry, Failed], Just t, jid)
352+ updateJobHelper tname conn (Queued, [Queued, Retry, Failed, Cancelled], Just t, jid)
325353
326354-- | TODO: First check in all job-runners if this job is still running, or not,
327355-- and somehow send an uninterruptibleCancel to that thread.
328356unlockJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
329357unlockJobIO conn tname jid = do
330− fmap listToMaybe $ PGS.query conn q (Retry, jid, In [Locked])
358+ listToMaybe <$> PGS.query conn q (tname, Retry, jid, In [Locked])
331359 where
332− q = "update " <> tname <> " set status=?, run_at=now(), locked_at=null, locked_by=null where id=? and status in ? returning " <> concatJobDbColumns
360+ q = "update ? set status=?, run_at=now(), locked_at=null, locked_by=null where id=? and status in ? returning " <> concatJobDbColumns
333361
334362cancelJobIO :: Connection -> TableName -> JobId -> IO (Maybe Job)
335363cancelJobIO conn tname jid =
336− 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)
337369
338370updateJobHelper :: TableName
339371 -> Connection
340372 -> (Status, [Status], Maybe UTCTime, JobId)
341373 -> IO (Maybe Job)
342374updateJobHelper tname conn (newStatus, existingStates, mRunAt, jid) =
343− fmap listToMaybe $ PGS.query conn q (newStatus, runAt, jid, PGS.In existingStates)
375+ listToMaybe <$> PGS.query conn q (tname, newStatus, runAt, jid, PGS.In existingStates)
344376 where
345− q = "update " <> tname <> " set attempts=0, status=?, run_at=? where id=? and status in ? returning " <> concatJobDbColumns
377+ q = "update ? set attempts=0, status=?, run_at=? where id=? and status in ? returning " <> concatJobDbColumns
346378 runAt = case mRunAt of
347379 Nothing -> PGS.toField $ PGS.Identifier "run_at"
348380 Just t -> PGS.toField t
@@ -351,47 +383,58 @@ updateJobHelper tname conn (newStatus, existingStates, mRunAt, jid) =
351383data TimeoutException = TimeoutException deriving (Eq, Show)
352384instance Exception TimeoutException
353385
386+-- | this spawns the job on a a seperate thread, and
387+-- also spawns a monitoring thread, whichever finishes
388+-- first kills the other.
354389runJobWithTimeout :: (HasJobRunner m)
355390 => Seconds
356391 -> Job
357392 -> m ()
358−runJobWithTimeout timeoutSec job = do
393+runJobWithTimeout timeoutSec job@Job{jobId} = do
359394 threadsRef <- envJobThreadsRef <$> getRunnerEnv
360395 jobRunner_ <- getJobRunner
361396
362397 a <- async $ liftIO $ jobRunner_ job
363398
364− 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+
365404 -- liftIO $ putStrLn $ "Threads: " <> show x
366405 log LevelDebug $ LogText $ toS $ "Spawned job in " <> show (asyncThreadId a)
367406
368407 t <- async $ do
369408 delaySeconds timeoutSec
370− uninterruptibleCancel a
371409 throwIO TimeoutException
372410
373411 void $ finally
374412 (waitEitherCancel a t)
375− (atomicModifyIORef' threadsRef $ \threads -> (DL.delete a threads, ()))
413+ (atomicModifyIORef' threadsRef $ \threads -> (DM.delete jobId threads, ()))
376414
377415
416+-- | runs a job, blocks for as long as it's in progress
378417runJob :: (HasJobRunner m) => JobId -> m ()
379418runJob jid = do
380− (findJobById jid) >>= \case
419+ findJobById jid >>= \case
381420 Nothing -> Prelude.error $ "Could not find job id=" <> show jid
382421 Just job -> do
383422 startTime <- liftIO getCurrentTime
384423 lockTimeout <- getDefaultJobTimeout
385− (flip catches) [Handler $ timeoutHandler job startTime, Handler $ exceptionHandler job startTime] $ do
424+ log LevelInfo $ LogJobStart job
425+ flip catch (exceptionHandler job startTime) $ do
426+ onJobStart job
386427 runJobWithTimeout lockTimeout job
387428 endTime <- liftIO getCurrentTime
388− deleteJob jid
389− let newJob = job{jobStatus=Success, jobLockedBy=Nothing, jobLockedAt=Nothing}
429+ shouldDeleteJob <- deleteSuccessfulJobs
430+ let newJob = job{jobStatus=Success, jobLockedBy=Nothing, jobLockedAt=Nothing, jobUpdatedAt = endTime}
431+ if shouldDeleteJob
432+ then deleteJob jid
433+ else void $ saveJob newJob
390434 log LevelInfo $ LogJobSuccess newJob (diffUTCTime endTime startTime)
391435 onJobSuccess newJob
392436 pure ()
393437 where
394− timeoutHandler job startTime (e :: TimeoutException) = retryOrFail (toException e) job startTime
395438 exceptionHandler job startTime (e :: SomeException) = retryOrFail (toException e) job startTime
396439 retryOrFail e job@Job{jobAttempts} startTime = do
397440 endTime <- liftIO getCurrentTime
@@ -401,35 +444,61 @@ runJob jid = do
401444 then ( Failed, FailPermanent, LevelError )
402445 else ( Retry, FailWithRetry, LevelWarn )
403446 t <- liftIO getCurrentTime
447+ backoffInSeconds <- getDefaultRetryBackoff jobAttempts
404448 newJob <- saveJob job{ jobStatus=newStatus
405449 , jobLockedBy=Nothing
406450 , jobLockedAt=Nothing
407− , jobLastError=(Just $ toJSON $ show e) -- TODO: convert errors to json properly
408− , jobRunAt=(addUTCTime (fromIntegral $ (2::Int) ^ jobAttempts) t)
451+ , jobLastError=Just $ toJSON $ show e -- TODO: convert errors to json properly
452+ , jobRunAt=addUTCTime (fromIntegral $ unSeconds backoffInSeconds) t
409453 }
410454 case fromException e :: Maybe TimeoutException of
411− Nothing -> log logLevel $ LogJobFailed newJob e failureMode runTime
412− Just _ -> log logLevel $ LogJobTimeout newJob
413−
414− let tryHandler (JobErrHandler handler) res = case fromException e of
415− Nothing -> res
416− Just e_ -> handler e_ newJob failureMode
417− handlers <- onJobFailed
418− 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+
419467 pure ()
420468
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+
421488-- TODO: This might have a resource leak.
422489restartUponCrash :: (HasJobRunner m, Show a) => Text -> m a -> m ()
423490restartUponCrash name_ action = do
491+ traceM "restartUponCrash top"
424492 a <- async action
425493 finally (waitCatch a >>= fn) $ do
426− (log LevelInfo $ LogText $ "Received shutdown: " <> toS name_)
494+ log LevelInfo $ LogText $ "Received shutdown: " <> toS name_
427495 cancel a
428496 where
429497 fn x = do
430498 case x of
431499 Left (e :: SomeException) -> log LevelError $ LogText $ name_ <> " seems to have exited with an error. Restarting: " <> toS (show e)
432500 Right r -> log LevelError $ LogText $ name_ <> " seems to have exited with the folloing result: " <> toS (show r) <> ". Restaring."
501+ traceM "CRASH OCCURRED"
433502 restartUponCrash name_ action
434503
435504-- | Spawns 'jobPoller' and 'jobEventListener' in separate threads and restarts
@@ -438,45 +507,86 @@ restartUponCrash name_ action = do
438507-- executed to finish execution before exiting the main thread.
439508jobMonitor :: forall m . (HasJobRunner m) => m ()
440509jobMonitor = do
510+ traceM "jobMonitor top"
441511 a1 <- async $ restartUponCrash "Job poller" jobPoller
442512 a2 <- async $ restartUponCrash "Job event listener" jobEventListener
443− finally (void $ waitAnyCatch [a1, a2]) $ do
513+ a3 <- async $ restartUponCrash "Job Kill poller" killJobPoller
514+ traceM "jobMonitor after async"
515+ finally (void $ waitAnyCatch [a1, a2, a3]) $ do
444516 log LevelInfo (LogText "Stopping jobPoller and jobEventListener threads.")
517+ cancel a3
445518 cancel a2
446519 cancel a1
447520 log LevelInfo (LogText "Waiting for jobs to complete.")
448521 waitForJobs
449− getPidFile >>= \case
450− Nothing -> pure ()
451− Just f -> do
452− log LevelInfo $ LogText $ "Removing PID file: " <> toS f
453− liftIO $ Dir.removePathForcibly f
454522
455523-- | Ref: 'jobPoller'
456−jobPollingSql :: TableName -> Query
457−jobPollingSql tname = "update " <> tname <> " set status = ?, locked_at = ?, locked_by = ?, attempts=attempts+1 WHERE id in (select id from " <> tname <> " where (run_at<=? AND ((status in ?) OR (status = ? and locked_at<?))) ORDER BY run_at ASC LIMIT 1 FOR UPDATE) RETURNING id"
524+jobPollingSql :: Query
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"
458545
459546waitForJobs :: (HasJobRunner m)
460547 => m ()
461548waitForJobs = do
462− threadsRef <- envJobThreadsRef <$> getRunnerEnv
463− readIORef threadsRef >>= \case
549+ curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef) <&> DM.elems
550+ case curJobs of
464551 [] -> log LevelInfo $ LogText "All job-threads exited"
465552 as -> do
466553 tid <- myThreadId
467554 void $ waitAnyCatch as
468− 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
469556 delaySeconds (Seconds 1)
470557 waitForJobs
471558
559+data ConcurrencyAction
560+ = DontPoll
561+ | PollAny
562+ | PollWithResources ResourceCfg
563+
472564getConcurrencyControlFn :: (HasJobRunner m)
473− => m (m Bool)
565+ => m (Connection -> m ConcurrencyAction)
474566getConcurrencyControlFn = getConcurrencyControl >>= \case
475− UnlimitedConcurrentJobs -> pure $ pure True
476− MaxConcurrentJobs maxJobs -> pure $ do
477− curJobs <- getRunnerEnv >>= (readIORef . envJobThreadsRef)
478− pure $ (DL.length curJobs) < maxJobs
479− 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
573+
574+ where
575+ pollIf cond = if cond then PollAny else DontPoll
576+
577+jobPollingIO :: Connection -> String -> TableName -> Seconds -> IO [Only JobId]
578+jobPollingIO pollerDbConn processName tname lockTimeout = do
579+ t <- getCurrentTime
580+ PGS.query pollerDbConn jobPollingSql
581+ ( tname
582+ , Locked
583+ , t
584+ , processName
585+ , tname
586+ , t
587+ , In [Queued, Retry]
588+ , Locked
589+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t)
480590
481591-- | Executes 'jobPollingSql' every 'cfgPollingInterval' seconds to pick up jobs
482592-- for execution. Uses @UPDATE@ along with @SELECT...FOR UPDATE@ to efficiently
@@ -492,35 +602,107 @@ getConcurrencyControlFn = getConcurrencyControl >>= \case
492602-- job was picked up execution, but didn't complete on time (possible
493603-- because the thread/process executing it crashed without being able to
494604-- update the DB)
495−
496605jobPoller :: (HasJobRunner m) => m ()
497606jobPoller = do
498607 processName <- liftIO jobWorkerName
499− pool <- getDbPool
500− tname <- getTableName
501− lockTimeout <- getDefaultJobTimeout
502608 log LevelInfo $ LogText $ toS $ "Starting the job monitor via DB polling with processName=" <> processName
503609 concurrencyControlFn <- getConcurrencyControlFn
504− withResource pool $ \pollerDbConn -> forever $ concurrencyControlFn >>= \case
505− False -> log LevelWarn $ LogText $ "NOT polling the job queue due to concurrency control"
506− True -> do
507− nextAction <- mask_ $ do
508− log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
509− t <- liftIO getCurrentTime
510− r <- liftIO $
511− PGS.query pollerDbConn (jobPollingSql tname)
512− (Locked, t, processName, t, (In [Queued, Retry]), Locked, (addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t))
513− case r of
514− -- When we don't have any jobs to run, we can relax a bit...
515− [] -> pure delayAction
516−
517− -- When we find a job to run, fork and try to find the next job without any delay...
518− [Only (jid :: JobId)] -> do
519− void $ async $ runJob jid
610+ pool <- getDbPool
611+ forever $ do
612+ concurencyPolicy <- withResource pool concurrencyControlFn
613+ case concurencyPolicy of
614+ DontPoll -> log LevelWarn $ LogText "NOT polling the job queue due to concurrency control"
615+ PollAny -> void $ pollRunJob processName Nothing
616+ PollWithResources resCfg -> void $ pollRunJob processName (Just resCfg)
617+
618+-- | Polls a job and runs it, or executes a delay action if no job was found
619+--
620+-- returns an async to the task that was started, allowing you
621+-- to block until it's finished
622+pollRunJob :: (HasJobRunner m) => String -> Maybe ResourceCfg -> m (Maybe (Async ()))
623+pollRunJob processName mResCfg = do
624+ tname <- getTableName
625+ -- note it's better to use fine grained pool connection,
626+ -- since data.pool already has it's internal resource cache.
627+ -- this way the user can configure how long a connection
628+ -- needs to remain open.
629+ pool <- getDbPool
630+ lockTimeout <- getDefaultJobTimeout
631+ join $ withResource pool $ \pollerDbConn -> mask_ $ do
632+ log LevelDebug $ LogText $ toS $ "[" <> processName <> "] Polling the job queue.."
633+ t <- liftIO getCurrentTime
634+ r <- case mResCfg of
635+ Nothing -> liftIO $
636+ PGS.query pollerDbConn jobPollingSql
637+ ( tname
638+ , Locked
639+ , t
640+ , processName
641+ , tname
642+ , t
643+ , In [Queued, Retry]
644+ , Locked
645+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t)
646+ Just ResourceCfg{..} -> liftIO $
647+ PGS.query pollerDbConn jobPollingWithResourceSql
648+ ( tname
649+ , Locked
650+ , t
651+ , processName
652+ , tname
653+ , t
654+ , In [Queued, Retry]
655+ , Locked
656+ , addUTCTime (fromIntegral $ negate $ unSeconds lockTimeout) t
657+ , resCfgCheckResourceFunction
658+ )
659+ case r of
660+ -- When we don't have any jobs to run, we can relax a bit...
661+ [] -> pure (Nothing <$ delayAction)
662+
663+ -- When we find a job to run, fork and try to find the next job without any delay...
664+ [Only (jid :: JobId)] -> do
665+ x <- async $ runJob jid
666+ pure $ Just x <$ noDelayAction
667+
668+ x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ show x
669+ where
670+ delayAction = delaySeconds =<< getPollingInterval
671+ noDelayAction = pure ()
672+
673+-- | Executes 'killJobPollingSql' every 'cfgPollingInterval' seconds to pick up jobs
674+-- that are cancelled and need to be killed. Uses @UPDATE@ along with @SELECT...
675+-- ..FOR UPDATE@ to efficiently find a job that matches /all/ of the following
676+-- conditions:
677+--
678+-- * 'jobStatus' should be 'cancelled'
679+-- * 'jobLockedAt' should be in the past
680+-- * 'jobLockedBy' should be the current job worker name
681+
682+killJobPoller :: (HasJobRunner m) => m ()
683+killJobPoller = do
684+ processName <- liftIO jobWorkerName
685+ pool <- getDbPool
686+ tname <- getTableName
687+
688+ let pollJobToKill conn = join $ mask_ $ do
689+ currentTime <- liftIO getCurrentTime
690+ result <- liftIO $ PGS.query conn killJobPollingSql
691+ (tname, tname, Cancelled, processName, currentTime)
692+
693+ case result of
694+ [] ->
695+ pure delayAction
696+
697+ [Only (jobId :: JobId)] -> do
698+ void $ async $ killJob jobId
520699 pure noDelayAction
521700
522− x -> error $ "WTF just happened? I was supposed to get only a single row, but got: " ++ (show x)
523− nextAction
701+ x ->
702+ error $ "I was supposed to get only a single row, but got: " ++ show x
703+
704+ withResource pool (forever . pollJobToKill)
705+
524706 where
525707 delayAction = delaySeconds =<< getPollingInterval
526708 noDelayAction = pure ()
@@ -536,43 +718,56 @@ jobEventListener = do
536718 jwName <- liftIO jobWorkerName
537719 concurrencyControlFn <- getConcurrencyControlFn
538720
539− let tryLockingJob jid = do
540− let q = "UPDATE " <> tname <> " SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 WHERE id=? AND status in ? RETURNING id"
541− (withDbConnection $ \conn -> (liftIO $ PGS.query conn q (Locked, jwName, jid, In [Queued, Retry]))) >>= \case
721+ let tryLockingJob jid mResCfg = withDbConnection $ \conn -> do
722+ let q = "UPDATE ? SET status=?, locked_at=now(), locked_by=?, attempts=attempts+1 WHERE id=? AND status in ? RETURNING id"
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
542732 [] -> do
543733 log LevelDebug $ LogText $ toS $ "Job was locked by someone else before I could start. Skipping it. JobId=" <> show jid
544734 pure Nothing
545735 [Only (_ :: JobId)] -> pure $ Just jid
546− 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
547737
548738 withResource pool $ \monitorDbConn -> do
549− void $ liftIO $ PGS.execute monitorDbConn ("LISTEN " <> pgEventName tname) ()
739+ void $ liftIO $ PGS.execute monitorDbConn "LISTEN ?" (Only $ pgEventName tname)
550740 forever $ do
551− log LevelDebug $ LogText "[LISTEN/NOFIFY] Event loop"
741+ log LevelDebug $ LogText "[LISTEN/NOTIFY] Event loop"
552742 notif <- liftIO $ getNotification monitorDbConn
553− concurrencyControlFn >>= \case
554− False -> log LevelWarn $ LogText "Received job event, but ignoring it due to concurrency control"
555− True -> do
556− let pload = notificationData notif
557− log LevelDebug $ LogText $ toS $ "NOTIFY | " <> show pload
558− case (eitherDecode $ toS pload) of
559− Left e -> log LevelError $ LogText $ toS $ "Unable to decode notification payload received from Postgres. Payload=" <> show pload <> " Error=" <> show e
560−
561− -- Checking if job needs to be fired immediately AND it is not already
562− -- taken by some othe thread, by the time it got to us
563− Right (v :: Value) -> case (Aeson.parseMaybe parser v) of
564− Nothing -> log LevelError $ LogText $ toS $ "Unable to extract id/run_at/locked_at from " <> show pload
565− Just (jid, runAt_, mLockedAt_) -> do
566− t <- liftIO getCurrentTime
567− if (runAt_ <= t) && (isNothing mLockedAt_)
568− then do log LevelDebug $ LogText $ toS $ "Job needs needs to be run immediately. Attempting to fork in background. JobId=" <> show jid
569− void $ async $ do
570− -- Let's try to lock the job first... it is possible that it has already
571− -- been picked up by the poller by the time we get here.
572− tryLockingJob jid >>= \case
573− Nothing -> pure ()
574− Just lockedJid -> runJob lockedJid
575− 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)
576771 where
577772 parser :: Value -> Aeson.Parser (JobId, UTCTime, Maybe UTCTime)
578773 parser = withObject "expecting an object to parse job.run_at and job.locked_at" $ \o -> do
@@ -583,8 +778,14 @@ jobEventListener = do
583778
584779
585780
586−createJobQuery :: TableName -> PGS.Query
587−createJobQuery tname = "INSERT INTO " <> tname <> "(run_at, status, payload, last_error, attempts, locked_at, locked_by) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING " <> concatJobDbColumns
781+createJobQuery :: PGS.Query
782+createJobQuery = "INSERT INTO ? (run_at, status, payload, last_error, attempts, locked_at, locked_by) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING " <> concatJobDbColumns
783+
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 (?, ?, ?)"
588789
589790-- $createJobs
590791--
@@ -623,14 +824,57 @@ scheduleJob :: ToJSON p
623824 -> UTCTime -- ^ when should the job be executed
624825 -> IO Job
625826scheduleJob conn tname payload runAt = do
626− let args = ( runAt, Queued, toJSON payload, Nothing :: Maybe Value, 0 :: Int, Nothing :: Maybe Text, Nothing :: Maybe Text )
627− queryFormatter = toS <$> (PGS.formatQuery conn (createJobQuery tname) args)
628− rs <- PGS.query conn (createJobQuery tname) args
827+ let args = ( tname, runAt, Queued, toJSON payload, Nothing :: Maybe Value, 0 :: Int, Nothing :: Maybe Text, Nothing :: Maybe Text )
828+ queryFormatter = toS <$> PGS.formatQuery conn createJobQuery args
829+ rs <- PGS.query conn createJobQuery args
629830 case rs of
630− [] -> (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
631868 [r] -> pure r
632− _ -> (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
633870
871+ forM_ resources $ \(resourceId, usage) -> do
872+ void $ PGS.execute conn ensureResource (resCfgResourceTable, rawResourceId resourceId, resCfgDefaultLimit)
873+ void $ PGS.execute conn registerResourceUsage (resCfgUsageTable, jobId job, rawResourceId resourceId, usage)
874+
875+ PGS.commit conn
876+
877+ pure job
634878
635879-- getRunnerEnv :: (HasJobRunner m) => m RunnerEnv
636880-- getRunnerEnv = ask
@@ -638,14 +882,14 @@ scheduleJob conn tname payload runAt = do
638882eitherParsePayload :: (FromJSON a)
639883 => Job
640884 -> Either String a
641−eitherParsePayload job =
642− eitherParsePayloadWith parseJSON job
885+eitherParsePayload =
886+ eitherParsePayloadWith parseJSON
643887
644888throwParsePayload :: (FromJSON a)
645889 => Job
646890 -> IO a
647−throwParsePayload job =
648− throwParsePayloadWith parseJSON job
891+throwParsePayload =
892+ throwParsePayloadWith parseJSON
649893
650894eitherParsePayloadWith :: (Aeson.Value -> Aeson.Parser a)
651895 -> Job
@@ -660,18 +904,18 @@ throwParsePayloadWith :: (Aeson.Value -> Aeson.Parser a)
660904 -> Job
661905 -> IO a
662906throwParsePayloadWith parser job =
663− either throwString (pure . Prelude.id) (eitherParsePayloadWith parser job)
907+ either throwString pure (eitherParsePayloadWith parser job)
664908
665909
666910-- | Used by the web\/admin UI to fetch a \"master list\" of all known
667911-- job-types. Ref: 'cfgAllJobTypes'
668912fetchAllJobTypes :: (MonadIO m)
669− => Config
913+ => UIConfig
670914 -> m [Text]
671−fetchAllJobTypes Config{cfgAllJobTypes, cfgDbPool} = liftIO $ do
672− case cfgAllJobTypes of
915+fetchAllJobTypes UIConfig{uicfgAllJobTypes, uicfgDbPool} = liftIO $ do
916+ case uicfgAllJobTypes of
673917 AJTFixed jts -> pure jts
674− AJTSql fn -> withResource cfgDbPool fn
918+ AJTSql fn -> withResource uicfgDbPool fn
675919 AJTCustom fn -> fn
676920
677921-- | Used by web\/admin IO to fetch a \"master list\" of all known job-runners.
@@ -681,8 +925,7 @@ fetchAllJobTypes Config{cfgAllJobTypes, cfgDbPool} = liftIO $ do
681925-- discover only those job-runners that are actively executing at least one
682926-- job at the time this function is executed.
683927fetchAllJobRunners :: (MonadIO m)
684− => Config
928+ => UIConfig
685929 -> m [JobRunnerName]
686−fetchAllJobRunners Config{cfgTableName, cfgDbPool} = liftIO $ withResource cfgDbPool $ \conn -> do
687− fmap (mapMaybe fromOnly) $ PGS.query_ conn $ "select distinct locked_by from " <> cfgTableName
688−
930+fetchAllJobRunners UIConfig{uicfgTableName, uicfgDbPool} = liftIO $ withResource uicfgDbPool $ \conn -> do
931+ mapMaybe fromOnly <$> PGS.query conn "select distinct locked_by from ?" (Only uicfgTableName)
src/OddJobs/Migrations.hs+104−20
@@ -1,3 +1,4 @@
1+{-# LANGUAGE RecordWildCards #-}
12module OddJobs.Migrations
23 ( module OddJobs.Migrations
34 , module OddJobs.Types
@@ -5,11 +6,12 @@ module OddJobs.Migrations
56where
67
78import Database.PostgreSQL.Simple as PGS
9+import Database.PostgreSQL.Simple.Types as PGS
810import Data.Functor (void)
911import OddJobs.Types
1012
11−createJobTableQuery :: TableName -> Query
12−createJobTableQuery tname = "CREATE TABLE " <> tname <>
13+createJobTableQuery :: Query
14+createJobTableQuery = "CREATE TABLE IF NOT EXISTS ?" <>
1315 "( id serial primary key" <>
1416 ", created_at timestamp with time zone default now() not null" <>
1517 ", updated_at timestamp with time zone default now() not null" <>
@@ -20,29 +22,111 @@ createJobTableQuery tname = "CREATE TABLE " <> tname <>
2022 ", attempts int not null default 0" <>
2123 ", locked_at timestamp with time zone null" <>
2224 ", locked_by text null" <>
23− ", 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')))" <>
2428 ");" <>
25− "create index idx_" <> tname <> "_created_at on " <> tname <> "(created_at);" <>
26− "create index idx_" <> tname <> "_updated_at on " <> tname <> "(updated_at);" <>
27− "create index idx_" <> tname <> "_locked_at on " <> tname <> "(locked_at);" <>
28− "create index idx_" <> tname <> "_locked_by on " <> tname <> "(locked_by);" <>
29− "create index idx_" <> tname <> "_status on " <> tname <> "(status);" <>
30− "create index idx_" <> tname <> "_run_at on " <> tname <> "(run_at);"
31−
32−createNotificationTrigger :: TableName -> Query
33−createNotificationTrigger tname = "create or replace function " <> fnName <> "() returns trigger as $$" <>
29+ "create index if not exists ? on ?(created_at);" <>
30+ "create index if not exists ? on ?(updated_at);" <>
31+ "create index if not exists ? on ?(locked_at);" <>
32+ "create index if not exists ? on ?(locked_by);" <>
33+ "create index if not exists ? on ?(status);" <>
34+ "create index if not exists ? on ?(run_at);"
35+
36+createNotificationTrigger :: Query
37+createNotificationTrigger = "create or replace function ?() returns trigger as $$" <>
3438 "begin \n" <>
35− " perform pg_notify('" <> pgEventName tname <> "', row_to_json(new)::text); \n" <>
39+ " perform pg_notify('?', \n" <>
40+ " json_build_object('id', new.id, 'run_at', new.run_at, 'locked_at', new.locked_at)::text); \n" <>
3641 " return new; \n" <>
3742 "end; \n" <>
3843 "$$ language plpgsql;" <>
39− "create trigger " <> trgName <> " after insert on " <> tname <> " for each row execute procedure " <> fnName <> "();"
40− where
41− fnName = "notify_job_monitor_for_" <> tname
42− trgName = "trg_notify_job_monitor_for_" <> tname
43−
44+ "drop trigger if exists ? on ?;" <>
45+ "create trigger ? after insert on ? for each row execute procedure ?();"
4446
4547createJobTable :: Connection -> TableName -> IO ()
4648createJobTable conn tname = void $ do
47− PGS.execute_ conn (createJobTableQuery tname)
48− PGS.execute_ conn (createNotificationTrigger tname)
49+ let tnameTxt = getTnameTxt tname
50+ _ <- PGS.execute conn createJobTableQuery
51+ ( tname
52+ , PGS.Identifier $ "idx_" <> tnameTxt <> "_created_at"
53+ , tname
54+ , PGS.Identifier $ "idx_" <> tnameTxt <> "_updated_at"
55+ , tname
56+ , PGS.Identifier $ "idx_" <> tnameTxt <> "_locked_at"
57+ , tname
58+ , PGS.Identifier $ "idx_" <> tnameTxt <> "_locked_by"
59+ , tname
60+ , PGS.Identifier $ "idx_" <> tnameTxt <> "_status"
61+ , tname
62+ , PGS.Identifier $ "idx_" <> tnameTxt <> "_run_at"
63+ , tname
64+ )
65+ PGS.execute conn createNotificationTrigger
66+ ( fnName
67+ , pgEventName tname
68+ , trgName
69+ , tname
70+ , trgName
71+ , tname
72+ , fnName
73+ )
74+ where
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
132+ getTnameTxt (PGS.QualifiedIdentifier _ tname') = tname'
src/OddJobs/Types.hs+118−19
@@ -7,6 +7,7 @@
77module OddJobs.Types where
88
99import Database.PostgreSQL.Simple as PGS
10+import Database.PostgreSQL.Simple.Types as PGS
1011import UnliftIO (MonadIO)
1112import UnliftIO.Concurrent (threadDelay)
1213import Data.Text.Conversions
@@ -19,11 +20,12 @@ import Data.Text (Text)
1920import GHC.Generics
2021import Data.Aeson as Aeson hiding (Success)
2122import Data.String.Conv
22−import Lucid (Html(..))
23+import Lucid (Html)
2324import Data.Pool (Pool)
2425import Control.Monad.Logger (LogLevel)
2526
26−-- | An alias for 'Query' type. Since this type has an instance of 'IsString'
27+-- | An alias for 'QualifiedIdentifier' type. It is used for the job table name.
28+-- Since this type has an instance of 'IsString',
2729-- you do not need to do anything special to create a value for this type. Just
2830-- ensure you have the @OverloadedStrings@ extention enabled. For example:
2931--
@@ -33,10 +35,21 @@ import Control.Monad.Logger (LogLevel)
3335-- myJobsTable :: TableName
3436-- myJobsTable = "my_jobs"
3537-- @
36−type TableName = PGS.Query
38+--
39+-- This should also work for table names qualified by the schema name. For example:
40+--
41+-- @
42+-- {-\# LANGUAGE OverloadedStrings \#-}
43+--
44+-- myJobsTable :: TableName
45+-- myJobsTable = "odd_jobs.jobs"
46+-- @
47+
48+type TableName = PGS.QualifiedIdentifier
3749
38−pgEventName :: TableName -> Query
39−pgEventName tname = "job_created_" <> tname
50+pgEventName :: TableName -> PGS.Identifier
51+pgEventName (PGS.QualifiedIdentifier Nothing tname) = PGS.Identifier $ "jobs_created_" <> tname
52+pgEventName (PGS.QualifiedIdentifier (Just schema) tname) = PGS.Identifier $ "jobs_created_" <> schema <> "_" <> tname
4053
4154newtype Seconds = Seconds { unSeconds :: Int } deriving (Eq, Show, Ord, Num, Read)
4255
@@ -59,6 +72,11 @@ data LogEvent
5972 | LogJobFailed !Job !SomeException !FailureMode !NominalDiffTime
6073 -- | Emitted when a job times out and is picked-up again for execution
6174 | 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
6280 -- | Emitted whenever 'OddJobs.Job.jobPoller' polls the DB table
6381 | LogPoll
6482 -- | TODO
@@ -94,8 +112,32 @@ data FailureMode
94112-- notifications](https://www.haskelltutorials.com/odd-jobs/guide.html#alerts)
95113-- in the implementation guide to understand how to use the machinery provided
96114-- by 'JobErrHandler' and 'cfgOnJobFailed'.
97−data JobErrHandler a = forall e . (Exception e) => JobErrHandler (e -> Job -> FailureMode -> IO a)
115+data JobErrHandler = forall a e . (Exception e) => JobErrHandler (e -> Job -> FailureMode -> IO a)
98116
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)
99141
100142-- | __Note:__ Please read the section on [controlling
101143-- concurrency](https://www.haskelltutorials.com/odd-jobs/guide.html#controlling-concurrency)
@@ -112,6 +154,15 @@ data ConcurrencyControl
112154 -- such as, CPU, memory, disk IOPS, or network bandwidth.
113155 | UnlimitedConcurrentJobs
114156
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+
115166 -- | Use this to dynamically determine if the next job should be picked-up, or
116167 -- not. This is useful to write custom-logic to determine whether a limited
117168 -- resource is below a certain usage threshold (eg. CPU usage is below 80%).
@@ -122,6 +173,7 @@ instance Show ConcurrencyControl where
122173 show cc = case cc of
123174 MaxConcurrentJobs n -> "MaxConcurrentJobs " <> show n
124175 UnlimitedConcurrentJobs -> "UnlimitedConcurrentJobs"
176+ ResourceLimits cfg -> "ResourceLimits " <> show cfg
125177 DynamicConcurrency _ -> "DynamicConcurrency (IO Bool)"
126178
127179type JobId = Int
@@ -135,8 +187,11 @@ data Status
135187 -- | Jobs in 'Queued' status /may/ be picked up by the job-runner on the basis
136188 -- of the 'jobRunAt' field.
137189 | Queued
138− -- | 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.
139191 | Failed
192+ -- | Jobs with 'Cancelled' status are cancelled by the user and will not be
193+ -- retried by the job-runner
194+ | Cancelled
140195 -- | Jobs in 'Retry' status will be retried by the job-runner on the basis of
141196 -- the 'jobRunAt' field.
142197 | Retry
@@ -180,6 +235,7 @@ instance ToText Status where
180235 Queued -> "queued"
181236 Retry -> "retry"
182237 Failed -> "failed"
238+ Cancelled -> "cancelled"
183239 Locked -> "locked"
184240
185241instance (StringConv Text a) => FromText (Either a Status) where
@@ -187,14 +243,16 @@ instance (StringConv Text a) => FromText (Either a Status) where
187243 "success" -> Right Success
188244 "queued" -> Right Queued
189245 "failed" -> Right Failed
246+ "cancelled" -> Right Cancelled
190247 "retry" -> Right Retry
191248 "locked" -> Right Locked
192249 x -> Left $ toS $ "Unknown job status: " <> x
193250
194251instance FromField Status where
195− fromField f mBS = (fromText <$> (fromField f mBS)) >>= \case
252+ fromField f mBS = fromField f mBS >>= (\case
196253 Left e -> FromField.returnError PGS.ConversionFailed f e
197− Right s -> pure s
254+ Right s -> pure s)
255+ . fromText
198256
199257instance ToField Status where
200258 toField s = toField $ toText s
@@ -289,7 +347,7 @@ data Config = Config
289347 -- | User-defined error-handler that is called whenever a job fails (indicated
290348 -- by 'cfgJobRunner' throwing an unhandled runtime exception). Please refer to
291349 -- 'JobErrHandler' for documentation on how to use this.
292− , cfgOnJobFailed :: forall a . [JobErrHandler a]
350+ , cfgOnJobFailed :: [JobErrHandler]
293351
294352 -- | User-defined callback function that is called whenever a job starts
295353 -- execution.
@@ -302,7 +360,7 @@ data Config = Config
302360 -- | File to store the PID of the job-runner process. This is used only when
303361 -- invoking the job-runner as an independent background deemon (the usual mode
304362 -- of deployment).
305− , cfgPidFile :: Maybe FilePath
363+ -- , cfgPidFile :: Maybe FilePath
306364
307365 -- | A "structured logging" function that __you__ need to provide. The
308366 -- @odd-jobs@ library does NOT use the standard logging interface provided by
@@ -318,17 +376,49 @@ data Config = Config
318376 -- 'OddJobs.ConfigBuilder.defaultJobType'
319377 , cfgJobType :: Job -> Text
320378
379+ -- | How long can a job run after which it is considered to be "crashed" and
380+ -- picked up for execution again
381+ , cfgDefaultJobTimeout :: Seconds
382+
383+ -- | Should successful jobs be deleted from the queue to save on table space?
384+ , cfgDeleteSuccessfulJobs :: Bool
385+
386+ -- | How far into the future should jobs which can be retried be queued for?
387+ --
388+ -- The 'Int' argument is the number of times the job has been attepted. It will
389+ -- always be at least 1, since the job will have to have started at least once
390+ -- in order to fail and be retried. The default implementation is an exponential
391+ -- backoff of @'Seconds' $ 2 ^ 'jobAttempts'@.
392+ , cfgDefaultRetryBackoff :: Int -> IO Seconds
393+ }
394+
395+
396+data UIConfig = UIConfig
397+ { -- | The DB table which holds your jobs. Please note, this should have been
398+ -- created by the 'OddJobs.Migrations.createJobTable' function.
399+ uicfgTableName :: TableName
400+
401+ -- | The DB connection-pool to use for the web UI. __Note:__ the same DB
402+ -- pool used by your job-runner can be passed here if it has a sufficient
403+ -- number of connections to satisfy both use-scases. Else create a separate
404+ -- DB pool to be used only by the web UI (this DB pool can have just 1-3
405+ -- connection, because __typically__ the web UI doesn't serve too many
406+ -- concurrent user in most real-life cases)
407+ , uicfgDbPool :: Pool Connection
408+
409+ -- | How to extract the "job type" from a 'Job'. If you are overriding this,
410+ -- please consider overriding 'cfgJobTypeSql' as well. __Note:__ Usually
411+ -- 'cfgJobType' and 'uicfgJobType' would use the same value. Related:
412+ -- 'OddJobs.ConfigBuilder.defaultJobType'
413+ , uicfgJobType :: Job -> Text
414+
321415 -- | How to extract the \"job type\" directly in SQL. There are many places,
322416 -- especially in the web\/admin UI, where we need to know a job's type
323417 -- directly in SQL (because transferrring the entire @payload@ column to
324418 -- Haskell, and then parsing it into JSON, and then applying the
325419 -- 'cfgJobType' function on it would be too inefficient). Ref:
326− -- 'OddJobs.ConfigBuilder.defaultJobTypeSql' and 'cfgJobType'
327− , cfgJobTypeSql :: PGS.Query
328−
329− -- | How long can a job run after which it is considered to be "crashed" and
330− -- picked up for execution again
331− , cfgDefaultJobTimeout :: Seconds
420+ -- 'OddJobs.ConfigBuilder.defaultJobTypeSql' and 'uicfgJobType'
421+ , uicfgJobTypeSql :: PGS.Query
332422
333423 -- | How to convert a list of 'Job's to a list of HTML fragments. This is
334424 -- used in the Web\/Admin UI. This function accepts a /list/ of jobs and
@@ -336,12 +426,21 @@ data Config = Config
336426 -- another table to fetch some metadata (eg. convert a primary-key to a
337427 -- human-readable name), you can do it efficiently instead of resulting in
338428 -- an N+1 SQL bug. Ref: 'defaultJobToHtml'
339− , cfgJobToHtml :: [Job] -> IO [Html ()]
429+ , uicfgJobToHtml :: [Job] -> IO [Html ()]
340430
341431 -- | How to get a list of all known job-types? This is used by the
342432 -- Web\/Admin UI to power the \"filter by job-type\" functionality. The
343433 -- default value for this is 'OddJobs.ConfigBuilder.defaultDynamicJobTypes'
344434 -- which does a @SELECT DISTINCT payload ->> ...@ to get a list of job-types
345435 -- directly from the DB.
346− , cfgAllJobTypes :: AllJobTypes
436+ , uicfgAllJobTypes :: AllJobTypes
437+
438+ -- | A "structured logging" function that __you__ need to provide. The
439+ -- @odd-jobs@ library does NOT use the standard logging interface provided by
440+ -- 'monad-logger' on purpose. Also look at 'cfgJobType' and 'defaultLogStr'
441+ --
442+ -- __Note:__ Please take a look at the section on [structured
443+ -- logging](https://www.haskelltutorials.com/odd-jobs/guide.html#structured-logging)
444+ -- to find out how to use this to log in JSON.
445+ , uicfgLogger :: LogLevel -> LogEvent -> IO ()
347446 }
src/OddJobs/Web.hs+89−61
@@ -1,4 +1,5 @@
1−{-# LANGUAGE DeriveGeneric, NamedFieldPuns, TypeOperators, DataKinds, RecordWildCards, DeriveAnyClass #-}
1+{-# LANGUAGE DeriveGeneric, NamedFieldPuns, DataKinds, RecordWildCards #-}
2+{-# LANGUAGE CPP #-}
23module OddJobs.Web where
34
45import OddJobs.Types
@@ -24,7 +25,6 @@ import Lucid
2425import Lucid.Html5
2526import Lucid.Base
2627import Data.String.Conv
27−import qualified Data.HashMap.Strict as HM
2828import Data.List as DL hiding (filter, and)
2929import Control.Monad
3030import Data.Time.Format.Human (humanReadableTime')
@@ -32,6 +32,12 @@ import Data.Time.Convenience (timeSince, Unit(..), Direction(..))
3232import Data.Text.Conversions (fromText, toText)
3333import Prelude hiding (filter, and)
3434
35+# if MIN_VERSION_aeson(2, 0, 0)
36+import qualified Data.Aeson.KeyMap as HM
37+# else
38+import qualified Data.HashMap.Strict as HM
39+# endif
40+
3541data OrderDirection = Asc | Desc deriving (Eq, Show, Generic, Enum)
3642
3743data OrderByField = OrdCreatedAt
@@ -110,6 +116,7 @@ instance ToHttpApiData Filter where
110116-- , rEnqueue :: route :- "enqueue" :> Capture "jobId" JobId :> Post '[HTML] NoContent
111117-- , rRunNow :: route :- "run" :> Capture "jobId" JobId :> Post '[HTML] NoContent
112118-- , rCancel :: route :- "cancel" :> Capture "jobId" JobId :> Post '[HTML] NoContent
119+-- , rKill :: route :- "kill" :> Capture "jobId" JobId :> Post '[HTML] NoContent
113120-- , rRefreshJobTypes :: route :- "refresh-job-types" :> Post '[HTML] NoContent
114121-- , rRefreshJobRunners :: route :- "refresh-job-runners" :> Post '[HTML] NoContent
115122-- } deriving (Generic)
@@ -120,16 +127,17 @@ data Routes = Routes
120127 , rEnqueue :: JobId -> Text
121128 , rRunNow :: JobId -> Text
122129 , rCancel :: JobId -> Text
130+ , rKill :: JobId -> Text
123131 , rRefreshJobTypes :: Text
124132 , rRefreshJobRunners :: Text
125133 , rStaticAsset :: Text -> Text
126134 }
127135
128136
129−filterJobsQuery :: Config -> Filter -> (PGS.Query, [Action])
130−filterJobsQuery Config{cfgTableName, cfgJobTypeSql} Filter{..} =
131− ( "SELECT " <> Job.concatJobDbColumns <> " FROM " <> cfgTableName <> whereClause <> " " <> (orderClause $ fromMaybe (OrdUpdatedAt, Desc) filterOrder) <> " " <> limitOffsetClause
132− , whereActions
137+filterJobsQuery :: UIConfig -> Filter -> (PGS.Query, [Action])
138+filterJobsQuery UIConfig{uicfgTableName, uicfgJobTypeSql} Filter{..} =
139+ ( "SELECT " <> Job.concatJobDbColumns <> " FROM ?" <> whereClause <> " " <> orderClause (fromMaybe (OrdUpdatedAt, Desc) filterOrder) <> " " <> limitOffsetClause
140+ , toRow (Only uicfgTableName) ++ whereActions
133141 )
134142 where
135143 orderClause (flt, dir) =
@@ -160,7 +168,7 @@ filterJobsQuery Config{cfgTableName, cfgJobTypeSql} Filter{..} =
160168
161169 statusClause = if Prelude.null filterStatuses
162170 then Nothing
163− else Just ("status IN ?", toRow $ (Only (In filterStatuses)))
171+ else Just ("status IN ?", toRow (Only (In filterStatuses)))
164172
165173 createdAfterClause = Prelude.fmap (\x -> ("created_at >= ?", toRow $ Only x)) filterCreatedAfter
166174 createdBeforeClause = Prelude.fmap (\x -> ("created_at < ?", toRow $ Only x)) filterCreatedBefore
@@ -172,11 +180,11 @@ filterJobsQuery Config{cfgTableName, cfgJobTypeSql} Filter{..} =
172180 jobTypeClause = case filterJobTypes of
173181 [] -> Nothing
174182 xs ->
175− let qFragment = "(" <> cfgJobTypeSql <> ")=?"
183+ let qFragment = "(" <> uicfgJobTypeSql <> ")=?"
176184 build ys (q, vs) = case ys of
177185 [] -> (q, vs)
178− (y:[]) -> (qFragment <> q, (toField y):vs)
179− (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)
180188 in Just $ build xs (mempty, [])
181189
182190 jobRunnerClause :: Maybe (Query, [Action])
@@ -192,12 +200,12 @@ filterJobsQuery Config{cfgTableName, cfgJobTypeSql} Filter{..} =
192200
193201 -- orderClause = _
194202
195−filterJobs :: Config -> Connection -> Filter -> IO [Job]
203+filterJobs :: UIConfig -> Connection -> Filter -> IO [Job]
196204filterJobs cfg conn f = do
197205 let (q, queryArgs) = filterJobsQuery cfg f
198206 PGS.query conn q queryArgs
199207
200−countJobs :: Config -> Connection -> Filter -> IO Int
208+countJobs :: UIConfig -> Connection -> Filter -> IO Int
201209countJobs cfg conn f = do
202210 let (q, queryArgs) = filterJobsQuery cfg f
203211 finalqry = "SELECT count(*) FROM (" <> q <> ") a"
@@ -220,10 +228,10 @@ pageNav Routes{..} = do
220228 div_ [ class_ "navbar-header" ] $ do
221229 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" ]
222230 button_ [ class_ "navbar-toggle collapsed", data_ "toggle" "collapse", data_ "target" "#navcol-1" ] $ do
223− span_ [ class_ "sr-only" ] $ "Toggle navigation"
224− span_ [ class_ "icon-bar" ] $ ""
225− span_ [ class_ "icon-bar" ] $ ""
226− 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" ] ""
227235 -- div_ [ class_ "collapse navbar-collapse", id_ "navcol-1" ] $ ul_ [ class_ "nav navbar-nav navbar-right" ] $ do
228236 -- li_ [ class_ "active", role_ "presentation" ] $ a_ [ href_ "#" ] $ "First Item"
229237 -- li_ [ role_ "presentation" ] $ a_ [ href_ "#" ] $ "Second Item"
@@ -253,12 +261,12 @@ pageLayout routes@Routes{..} navHtml bodyHtml = do
253261 div_ $ div_ [ class_ "container-fluid", style_ "/*background-color:#f2f2f2;*/" ] $ div_ [ class_ "row" ] $ do
254262 div_ [ class_ "d-none d-md-block col-md-2" ] navHtml
255263 div_ [ class_ "col-12 col-md-10" ] bodyHtml
256− script_ [ src_ $ rStaticAsset "assets/js/jquery.min.js" ] $ ("" :: Text)
257− script_ [ src_ $ rStaticAsset "assets/bootstrap/js/bootstrap.min.js" ] $ ("" :: Text)
258− 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)
259267
260268sideNav :: Routes -> [Text] -> [JobRunnerName] -> UTCTime -> Filter -> Html ()
261−sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
269+sideNav Routes{..} jobTypes jobRunnerNames _t filter@Filter{..} = do
262270 div_ [ class_ "filters mt-3" ] $ do
263271 jobStatusFilters
264272 jobTypeFilters
@@ -268,14 +276,14 @@ sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
268276 h6_ "Filter by job status"
269277 div_ [ class_ "card" ] $ do
270278 ul_ [ class_ "list-group list-group-flush" ] $ do
271− li_ [ class_ ("list-group-item " <> if filterStatuses == [] then "active-nav" else "") ] $ do
272− 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}
273281 a_ [ href_ lnk ] $ do
274282 "all"
275283 -- span_ [ class_ "badge badge-pill badge-secondary float-right" ] "12"
276284 forM_ ((\\) (enumFrom minBound) [Job.Success]) $ \st -> do
277− li_ [ class_ ("list-group-item " <> if (st `elem` filterStatuses) then "active-nav" else "") ] $ do
278− 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}
279287 a_ [ href_ lnk ] $ do
280288 toHtml $ toText st
281289 -- span_ [ class_ "badge badge-pill badge-secondary float-right" ] "12"
@@ -289,11 +297,11 @@ sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
289297
290298 div_ [ class_ "card" ] $ do
291299 ul_ [ class_ "list-group list-group-flush" ] $ do
292− li_ [ class_ ("list-group-item " <> if filterJobRunner == [] then "active-nav" else "") ] $ do
293− 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}
294302 a_ [ href_ lnk ] "all"
295303 forM_ jobRunnerNames $ \jr -> do
296− 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
297305 a_ [ href_ "#" ] $ toHtml $ unJobRunnerName jr
298306
299307 jobTypeFilters = do
@@ -305,15 +313,15 @@ sideNav Routes{..} jobTypes jobRunnerNames t filter@Filter{..} = do
305313
306314 div_ [ class_ "card" ] $ do
307315 ul_ [ class_ "list-group list-group-flush" ] $ do
308− li_ [ class_ ("list-group-item " <> if filterJobTypes == [] then "active-nav" else "") ] $ do
309− 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}
310318 a_ [ href_ lnk ] "all"
311319 forM_ jobTypes $ \jt -> do
312− 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
313321 a_ [ href_ (rFilterResults $ Just filter{filterJobTypes=[jt]}) ] $ toHtml jt
314322
315323searchBar :: Routes -> UTCTime -> Filter -> Html ()
316−searchBar Routes{..} t filter@Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterJobTypes, filterRunAfter} = do
324+searchBar Routes{..} _t filter@Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterJobTypes, filterRunAfter} = do
317325 form_ [ style_ "padding-top: 2em;" ] $ do
318326 div_ [ class_ "form-group" ] $ do
319327 div_ [ class_ "search-container" ] $ do
@@ -326,7 +334,7 @@ searchBar Routes{..} t filter@Filter{filterStatuses, filterCreatedAfter, filterC
326334 maybe mempty (\x -> renderFilter "Run after" (showText x) (rFilterResults $ Just filter{filterRunAfter = Nothing})) filterRunAfter
327335 forM_ filterJobTypes $ \x -> renderFilter "Job type" x (rFilterResults $ Just filter{filterJobTypes = filterJobTypes \\ [x]})
328336
329− button_ [ class_ "btn btn-default search-button", type_ "button" ] $ "Search"
337+ button_ [ class_ "btn btn-default search-button", type_ "button" ] "Search"
330338 -- ul_ [ class_ "list-inline" ] $ do
331339 -- li_ $ span_ $ strong_ "Common searches:"
332340 -- li_ $ a_ [ href_ (rFilterResults $ Just mempty) ] $ "All jobs"
@@ -345,7 +353,7 @@ searchBar Routes{..} t filter@Filter{filterStatuses, filterCreatedAfter, filterC
345353 span_ [ class_ "filter-name" ] $ toHtml k
346354 span_ [ class_ "filter-value" ] $ do
347355 toHtml v
348− a_ [ href_ u, class_ "text-danger" ] $ i_ [ class_ "glyphicon glyphicon-remove" ] $ ""
356+ a_ [ href_ u, class_ "text-danger" ] $ i_ [ class_ "glyphicon glyphicon-remove" ] ""
349357
350358
351359timeDuration :: UTCTime -> UTCTime -> (Int, String)
@@ -353,22 +361,20 @@ timeDuration from to = (diff, str)
353361 where
354362 str = if diff <= 0
355363 then "under 1s"
356− else (if d>0 then (show d) <> "d" else "") <>
357− (if m>0 then (show m) <> "m" else "") <>
358− (if s>0 then (show s) <> "s" else "")
359− 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
360368 (m', s) = diff `divMod` 60
361369 (h', m) = m' `divMod` 60
362− (d, h) = h' `divMod` 24
370+ (d, _h) = h' `divMod` 24
363371
364372showText :: (Show a) => a -> Text
365373showText a = toS $ show a
366374
367375jobContent :: Value -> Value
368376jobContent v = case v of
369− Aeson.Object o -> case HM.lookup "contents" o of
370− Nothing -> v
371− Just c -> c
377+ Aeson.Object o -> fromMaybe v (HM.lookup "contents" o)
372378 _ -> v
373379
374380jobRow :: Routes -> UTCTime -> (Job, Html ()) -> Html ()
@@ -378,6 +384,7 @@ jobRow routes t (job@Job{..}, jobHtml) = do
378384 let statusFn = case jobStatus of
379385 Job.Success -> statusSuccess
380386 Job.Failed -> statusFailed
387+ Job.Cancelled -> statusCancelled
381388 Job.Queued -> if jobRunAt > t
382389 then statusFuture
383390 else statusWaiting
@@ -388,39 +395,54 @@ jobRow routes t (job@Job{..}, jobHtml) = do
388395 td_ jobHtml
389396 td_ $ do
390397 let actionsFn = case jobStatus of
391− Job.Success -> (const mempty)
398+ Job.Success -> const mempty
392399 Job.Failed -> actionsFailed
400+ Job.Cancelled -> actionsCancelled
393401 Job.Queued -> if jobRunAt > t
394402 then actionsFuture
395403 else actionsWaiting
396404 Job.Retry -> actionsRetry
397− Job.Locked -> (const mempty)
405+ Job.Locked -> actionsLocked
398406 actionsFn routes job
399407
400408
401409actionsFailed :: Routes -> Job -> Html ()
402410actionsFailed Routes{..} Job{..} = do
403411 form_ [ action_ (rEnqueue jobId), method_ "post" ] $ do
404− 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"
405422
406423actionsRetry :: Routes -> Job -> Html ()
407424actionsRetry Routes{..} Job{..} = do
408425 form_ [ action_ (rRunNow jobId), method_ "post" ] $ do
409− button_ [ class_ "btn btn-secondary", type_ "submit" ] $ "Run now"
426+ button_ [ class_ "btn btn-secondary", type_ "submit" ] "Run now"
410427
411428actionsFuture :: Routes -> Job -> Html ()
412429actionsFuture Routes{..} Job{..} = do
413430 form_ [ action_ (rRunNow jobId), method_ "post" ] $ do
414− button_ [ class_ "btn btn-secondary", type_ "submit" ] $ "Run now"
431+ button_ [ class_ "btn btn-secondary", type_ "submit" ] "Run now"
415432
416433actionsWaiting :: Routes -> Job -> Html ()
417434actionsWaiting Routes{..} Job{..} = do
418435 form_ [ action_ (rCancel jobId), method_ "post" ] $ do
419− 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"
420442
421443statusSuccess :: UTCTime -> Job -> Html ()
422444statusSuccess t Job{..} = do
423− span_ [ class_ "badge badge-success" ] $ "Success"
445+ span_ [ class_ "badge badge-success" ] "Success"
424446 span_ [ class_ "job-run-time" ] $ do
425447 let (d, s) = timeDuration jobCreatedAt jobUpdatedAt
426448 abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Completed " <> humanReadableTime' t jobUpdatedAt <> ". "
@@ -428,19 +450,25 @@ statusSuccess t Job{..} = do
428450
429451statusFailed :: UTCTime -> Job -> Html ()
430452statusFailed t Job{..} = do
431− span_ [ class_ "badge badge-danger" ] $ "Failed"
453+ span_ [ class_ "badge badge-danger" ] "Failed"
432454 span_ [ class_ "job-run-time" ] $ do
433455 abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Failed " <> humanReadableTime' t jobUpdatedAt <> " after " <> show jobAttempts <> " attempts"
434456
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+
435463statusFuture :: UTCTime -> Job -> Html ()
436464statusFuture t Job{..} = do
437− span_ [ class_ "badge badge-secondary" ] $ "Future"
465+ span_ [ class_ "badge badge-secondary" ] "Future"
438466 span_ [ class_ "job-run-time" ] $ do
439467 abbr_ [ title_ (showText jobRunAt) ] $ toHtml $ humanReadableTime' t jobRunAt
440468
441469statusWaiting :: UTCTime -> Job -> Html ()
442−statusWaiting t Job{..} = do
443− span_ [ class_ "badge badge-warning" ] $ "Waiting"
470+statusWaiting _t Job{} = do
471+ span_ [ class_ "badge badge-warning" ] "Waiting"
444472 -- span_ [ class_ "job-run-time" ] ("Waiting to be picked up" :: Text)
445473
446474statusRetry :: UTCTime -> Job -> Html ()
@@ -451,7 +479,7 @@ statusRetry t Job{..} = do
451479 abbr_ [ title_ (showText jobRunAt)] $ toHtml $ "Next retry in " <> humanReadableTime' t jobRunAt
452480
453481statusLocked :: UTCTime -> Job -> Html ()
454−statusLocked t Job{..} = do
482+statusLocked _t Job{} = do
455483 span_ [ class_ "badge badge-info" ] $ toHtml ("Locked" :: Text)
456484 -- span_ [ class_ "job-run-time" ] $ do
457485 -- abbr_ [ title_ (showText jobUpdatedAt) ] $ toHtml $ "Retried " <> humanReadableTime' t jobUpdatedAt <> ". "
@@ -480,24 +508,24 @@ resultsPanel routes@Routes{..} t filter@Filter{filterPage} js runningCount = do
480508 prevLink = do
481509 let (extraClass, lnk) = case filterPage of
482510 Nothing -> ("disabled", "")
483− Just (l, 0) -> ("disabled", "")
511+ Just (_l, 0) -> ("disabled", "")
484512 Just (l, o) -> ("", rFilterResults $ Just $ filter {filterPage = Just (l, max 0 $ o - l)})
485513 li_ [ class_ ("page-item previous " <> extraClass) ] $ do
486− a_ [ class_ "page-link", href_ lnk ] $ "Prev"
514+ a_ [ class_ "page-link", href_ lnk ] "Prev"
487515
488516 nextLink = do
489517 let (extraClass, lnk) = case filterPage of
490518 Nothing ->
491− if (DL.length js) < 10
519+ if DL.length js < 10
492520 then ("disabled", "")
493− else ("", (rFilterResults $ Just $ filter {filterPage = Just (10, 10)}))
521+ else ("", rFilterResults $ Just $ filter {filterPage = Just (10, 10)})
494522 Just (l, o) ->
495− if (DL.length js) < l
523+ if DL.length js < l
496524 then ("disabled", "")
497− else ("", (rFilterResults $ Just $ filter {filterPage = Just (l, o + l)}))
525+ else ("", rFilterResults $ Just $ filter {filterPage = Just (l, o + l)})
498526 li_ [ class_ ("page-item next " <> extraClass) ] $ do
499− a_ [ class_ "page-link", href_ lnk ] $ "Next"
527+ a_ [ class_ "page-link", href_ lnk ] "Next"
500528
501529ariaExpanded_ :: Text -> Attribute
502−ariaExpanded_ v = makeAttribute "aria-expanded" v
530+ariaExpanded_ = makeAttribute "aria-expanded"
503531
stack.yaml+3−65
@@ -1,68 +1,6 @@
1−# This file was automatically generated by 'stack init'
2−#
3−# Some commonly used options have been documented as comments in this file.
4−# For advanced use and comprehensive documentation of the format, please see:
5−# https://docs.haskellstack.org/en/stable/yaml_configuration/
6−
7−# Resolver to choose a 'specific' stackage snapshot or a compiler version.
8−# A snapshot resolver dictates the compiler version and the set of packages
9−# to be used for project dependencies. For example:
10−#
11−# resolver: lts-3.5
12−# resolver: nightly-2015-09-21
13−# resolver: ghc-7.10.2
14−#
15−# The location of a snapshot can be provided as a file or url. Stack assumes
16−# a snapshot provided as a file might change, whereas a url resource does not.
17−#
18−# resolver: ./custom-snapshot.yaml
19−# resolver: https://example.com/snapshots/2018-01-01.yaml
20−resolver: lts-15.6
21−
22−# User packages to be built.
23−# Various formats can be used as shown in the example below.
24−#
25−# packages:
26−# - some-directory
27−# - https://example.com/foo/bar/baz-0.0.2.tar.gz
28−# subdirs:
29−# - auto-update
30−# - wai
1+resolver: lts-20.26
312packages:
32−- .
33−# Dependency packages to be pulled from upstream that are not in the resolver.
34−# These entries can reference officially published versions as well as
35−# forks / in-progress versions pinned to a git hash. For example:
36−#
37−# extra-deps:
38−# - acme-missiles-0.3
39−# - git: https://github.com/commercialhaskell/stack.git
40−# commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a
41−#
3+ - .
424extra-deps:
435 - timing-convenience-0.1@sha256:7ff807a9a9e5596f2b18d45c5a01aefb91d4a98f6a1008d183b5c550f68f7cb7,2092
44− - direct-daemonize-3.1@sha256:f33505932b8613267cd8ad2d51470af2c5708d750ddf55a5dd38292f04962dcc,1630
45−
46−# Override default flag values for local packages and extra-deps
47−# flags: {}
48−
49−# Extra package databases containing global packages
50−# extra-package-dbs: []
51−
52−# Control whether we use the GHC we find on the path
53−# system-ghc: true
54−#
55−# Require a specific version of stack, using version ranges
56−# require-stack-version: -any # Default
57−# require-stack-version: ">=2.1"
58−#
59−# Override the architecture used by stack, especially useful on Windows
60−# arch: i386
61−# arch: x86_64
62−#
63−# Extra directories used by stack for building
64−# extra-include-dirs: [/path/to/dir]
65−# extra-lib-dirs: [/path/to/dir]
66−#
67−# Allow a newer minor version of GHC than the snapshot specifies
68−# compiler-check: newer-minor
6+ - resource-pool-0.4.0.0@sha256:9c1e448a159875e21a7e68697feee2b61a4e584720974fa465a2fa1bc0776c73,1342
test/Test.hs+635−205
@@ -1,6 +1,11 @@
1−{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, NamedFieldPuns, DeriveGeneric, FlexibleContexts, TypeFamilies, StandaloneDeriving #-}
1+{-# LANGUAGE FlexibleInstances, NamedFieldPuns, DeriveGeneric, FlexibleContexts, TypeFamilies, StandaloneDeriving, RankNTypes #-}
2+{-# LANGUAGE CPP #-}
3+{-# LANGUAGE PartialTypeSignatures #-}
4+{-# LANGUAGE RecordWildCards #-}
5+{-# OPTIONS_GHC -Wno-missing-signatures -Wno-partial-type-signatures #-}
26module Test where
37
8+import Control.Monad.Trans.Control
49import Test.Tasty as Tasty
510import qualified OddJobs.Migrations as Migrations
611import qualified OddJobs.Job as Job
@@ -9,15 +14,18 @@ import Data.Functor (void)
914import Data.Pool as Pool
1015import Test.Tasty.HUnit
1116import Debug.Trace
12−import Control.Exception.Lifted (finally, catch, bracket)
17+-- import Control.Exception.Lifted (finally, catch, bracket)
18+import Control.Monad (void)
1319import Control.Monad.Logger
1420import Control.Monad.Reader
1521import Data.Aeson as Aeson
1622import Data.Aeson.TH as Aeson
17−import Control.Concurrent.Lifted
18−import Control.Concurrent.Async.Lifted
23+-- import Control.Concurrent.Lifted
24+-- import Control.Concurrent.Async.Lifted
1925import OddJobs.Job (Job(..), JobId, delaySeconds, Seconds(..))
20−import System.Log.FastLogger (fromLogStr, withFastLogger, LogType(..), defaultBufSize, FastLogger, FileLogSpec(..), newTimedFastLogger)
26+import System.Log.FastLogger ( fromLogStr, withFastLogger, LogType'(..)
27+ , defaultBufSize, FastLogger, FileLogSpec(..), newTimedFastLogger
28+ , withTimedFastLogger)
2129import System.Log.FastLogger.Date (newTimeCache, simpleTimeFormat')
2230import Data.String.Conv (toS)
2331import Data.Time
@@ -29,7 +37,6 @@ import Test.Tasty.Hedgehog
2937import qualified System.Random as R
3038import Data.String (fromString)
3139import qualified Data.IntMap.Strict as Map
32−import Control.Monad.Trans.Control (liftWith, restoreT)
3340import Control.Monad.Morph (hoist)
3441import Data.List as DL
3542import OddJobs.Web as Web
@@ -37,6 +44,10 @@ import qualified Data.Time.Convenience as Time
3744import qualified Data.Text as T
3845import Data.Ord (comparing, Down(..))
3946import Data.Maybe (fromMaybe)
47+import qualified OddJobs.ConfigBuilder as Job
48+import UnliftIO
49+import Control.Exception (ArithException)
50+import Data.Bifunctor(first)
4051
4152$(Aeson.deriveJSON Aeson.defaultOptions ''Seconds)
4253
@@ -54,16 +65,14 @@ main = do
5465 , connectDatabase = "jobs_test"
5566 }
5667
57− createAppPool = createPool
58− (PGS.connect connInfo) -- cretea a new resource
59− (PGS.close) -- destroy resource
60− 1 -- stripes
68+ createAppPool = Pool.newPool $ Pool.defaultPoolConfig
69+ (PGS.connect connInfo) -- create a new resource
70+ PGS.close -- destroy resource
6171 (fromRational 10) -- number of seconds unused resources are kept around
6272 45
63− createJobPool = createPool
64− (PGS.connect connInfo) -- cretea a new resource
65− (PGS.close) -- destroy resource
66− 1 -- stripes
73+ createJobPool = Pool.newPool $ Pool.defaultPoolConfig
74+ (PGS.connect connInfo) -- create a new resource
75+ PGS.close -- destroy resource
6776 (fromRational 10) -- number of seconds unused resources are kept around
6877 45
6978
@@ -73,11 +82,21 @@ tests appPool jobPool = testGroup "All tests"
7382 , testJobScheduling appPool jobPool
7483 , testJobFailure appPool jobPool
7584 , testEnsureShutdown appPool jobPool
76− , testGracefuleShutdown appPool jobPool
77− ]
78− , testGroup "property tests" [ testEverything appPool jobPool
79− -- , propFilterJobs appPool jobPool
85+ , testGracefulShutdown appPool jobPool
86+ , testPushFailedJobEndQueue jobPool
87+ , testRetryBackoff appPool jobPool
88+ , testGroup "callback tests"
89+ [ testOnJobFailed appPool jobPool
90+ , testOnJobStart appPool jobPool
91+ , testOnJobSuccess appPool jobPool
92+ , testOnJobTimeout appPool jobPool
8093 ]
94+ , testResourceLimitedScheduling appPool jobPool
95+ , testKillJob appPool jobPool
96+ ]
97+ -- , testGroup "property tests" [ testEverything appPool jobPool
98+ -- -- , propFilterJobs appPool jobPool
99+ -- ]
81100 ]
82101
83102myTestCase
@@ -115,20 +134,24 @@ testPayload :: Value
115134testPayload = toJSON (10 :: Int)
116135
117136jobRunner :: Job.Job -> IO ()
118−jobRunner Job{jobPayload, jobAttempts} = case (fromJSON jobPayload) of
137+jobRunner Job{jobPayload, jobAttempts} = case fromJSON jobPayload of
119138 Aeson.Error e -> error e
120139 Success (j :: JobPayload) ->
121140 let recur pload idx = case pload of
122− PayloadAlwaysFail delay -> (delaySeconds delay) >> (error $ "Forced error after " <> show delay <> " seconds")
123− PayloadSucceed delay -> (delaySeconds delay) >> pure ()
141+ PayloadAlwaysFail delay -> delaySeconds delay >> error ("Forced error after " <> show delay <> " seconds")
142+ PayloadSucceed delay -> delaySeconds delay
124143 PayloadFail delay innerpload -> if idx<jobAttempts
125144 then recur innerpload (idx + 1)
126− 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)
146+ PayloadThrowStringException s -> throwString s
147+ PayloadThrowDivideByZero -> seq (1 `div` 0 :: Integer) (pure ())
127148 in recur j 0
128149
129150data JobPayload = PayloadSucceed Seconds
130151 | PayloadFail Seconds JobPayload
131152 | PayloadAlwaysFail Seconds
153+ | PayloadThrowStringException String
154+ | PayloadThrowDivideByZero
132155 deriving (Eq, Show, Generic)
133156
134157instance ToJSON JobPayload where
@@ -138,93 +161,495 @@ instance FromJSON JobPayload where
138161 parseJSON = genericParseJSON Aeson.defaultOptions
139162
140163
141−assertJobIdStatus :: (HasCallStack) => Connection -> Job.TableName -> String -> Job.Status -> JobId -> Assertion
142−assertJobIdStatus conn tname msg st jid = Job.findJobByIdIO conn tname jid >>= \case
143− Nothing -> assertFailure $ "Not expecting job to be deleted. JobId=" <> show jid
144− Just (Job{jobStatus}) -> assertEqual msg st jobStatus
164+logEventToJob :: Job.LogEvent -> Maybe Job.Job
165+logEventToJob le = case le of
166+ Job.LogJobStart j -> Just j
167+ Job.LogJobSuccess j _ -> Just j
168+ Job.LogJobFailed j _ _ _ -> Just j
169+ Job.LogJobTimeout j -> Just j
170+ Job.LogKillJobSuccess j -> Just j
171+ Job.LogKillJobFailed j -> Just j
172+ Job.LogPoll -> Nothing
173+ Job.LogWebUIRequest -> Nothing
174+ Job.LogText _ -> Nothing
175+
176+assertJobIdStatus :: HasCallStack
177+ => Connection
178+ -> Job.TableName
179+ -> IORef [Job.LogEvent]
180+ -> String
181+ -> Job.Status
182+ -> JobId
183+ -> Assertion
184+assertJobIdStatus conn tname logRef msg st jid = do
185+ logs <- readIORef logRef
186+ let _mjid = Just jid
187+ case st of
188+ Job.Success ->
189+ assertBool (msg <> ": Success event not found in job-logs for JobId=" <> show jid) $
190+ flip DL.any logs $ \case
191+ Job.LogJobSuccess j _ -> jid == Job.jobId j
192+ _ -> False
193+
194+ Job.Queued ->
195+ assertBool (msg <> ": Not expecting to find a queued job in the the job-logs JobId=" <> show jid) $
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+
207+ Job.Failed ->
208+ assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
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
145218
146−ensureJobId :: (HasCallStack) => Connection -> Job.TableName -> JobId -> IO Job
219+ Job.Retry ->
220+ assertBool (msg <> ": Failed event not found in job-logs for JobId=" <> show jid) $
221+ flip DL.any logs $ \case
222+ Job.LogJobFailed j _ _ _ -> jid == Job.jobId j
223+ _ -> False
224+
225+ Job.Locked ->
226+ assertBool (msg <> ": Start event should be present in the log for a locked job JobId=" <> show jid) $
227+ flip DL.any logs $ \case
228+ Job.LogJobStart j -> jid == Job.jobId j
229+ _ -> False
230+
231+ when (st /= Job.Success) $ do
232+ Job.findJobByIdIO conn tname jid >>= \case
233+ Nothing -> assertFailure $ "Not expecting job to be deleted. JobId=" <> show jid
234+ Just (Job{jobStatus}) -> assertEqual msg st jobStatus
235+
236+ensureJobId :: HasCallStack => Connection -> Job.TableName -> JobId -> IO Job
147237ensureJobId conn tname jid = Job.findJobByIdIO conn tname jid >>= \case
148238 Nothing -> error $ "Not expecting job to be deleted. JobId=" <> show jid
149239 Just j -> pure j
150240
151−-- 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
152243withRandomTable jobPool action = do
153− (tname :: Job.TableName) <- liftIO ((("jobs_" <>) . fromString) <$> (replicateM 10 (R.randomRIO ('a', 'z'))))
244+ runInIO <- askRunInIO
245+ (tname :: Job.TableName) <- liftIO (fromString . ("jobs_" <>) <$> replicateM 10 (R.randomRIO ('a', 'z')))
154246 finally
155− ((Pool.withResource jobPool $ \conn -> (liftIO $ Migrations.createJobTable conn tname)) >> (action tname))
156− (Pool.withResource jobPool $ \conn -> liftIO $ void $ PGS.execute_ conn ("drop table if exists " <> 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+ }
157287
158−-- withNewJobMonitor :: (Pool Connection) -> (TableName -> Assertion) -> Assertion
159−withNewJobMonitor jobPool actualTest = withRandomTable jobPool $ \tname -> withNamedJobMonitor tname jobPool (actualTest tname)
160−
161−withNamedJobMonitor tname jobPool actualTest = do
162− (defaults, cleanup) <- Test.defaultJobMonitor tname jobPool
163− let jobMonitorSettings = defaults{ Job.monitorJobRunner = jobRunner
164− , Job.monitorDefaultMaxAttempts = 3
165− }
166288 finally
167− (withAsync (Job.runJobMonitor jobMonitorSettings) (const actualTest))
168− (cleanup)
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))
295+
296+testMaxAttempts :: Int
297+testMaxAttempts = 3
169298
299+-- withNewJobMonitor :: (Pool Connection) -> (TableName -> Assertion) -> Assertion
300+withNewJobMonitor jobPool actualTest = do
301+ withRandomTable jobPool $ \tname -> do
302+ withNamedJobMonitor tname jobPool Prelude.id (actualTest tname)
303+
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
311+ logRef :: IORef [Job.LogEvent] <- newIORef []
312+ tcache <- newTimeCache simpleTimeFormat'
313+ withTimedFastLogger tcache LogNone $ \tlogger -> do
314+ let flogger logLevel logEvent = do
315+ tlogger $ \t -> toLogStr t <> " | " <> Job.defaultLogStr Job.defaultJobType logLevel logEvent
316+ atomicModifyIORef' logRef (\logs -> (logEvent:logs, ()))
317+ cfg = Job.mkConfig flogger tname jobPool Job.UnlimitedConcurrentJobs jobRunner (\cfg' -> cfgFn $ cfg'{Job.cfgDefaultMaxAttempts=testMaxAttempts})
318+ configConsumer logRef cfg
170319
171320payloadGen :: MonadGen m => m JobPayload
172321payloadGen = Gen.recursive Gen.choice nonRecursive recursive
173322 where
174323 nonRecursive = [ PayloadAlwaysFail <$> Gen.element [1, 2, 3]
175324 , PayloadSucceed <$> Gen.element [1, 2, 3]]
176− recursive = [ PayloadFail <$> (Gen.element [1, 2, 3]) <*> payloadGen ]
177−
178−testJobCreation appPool jobPool = testCase "job creation" $ withNewJobMonitor jobPool $ \tname -> Pool.withResource appPool $ \conn -> do
179− Job{jobId} <- Job.createJob conn tname (PayloadSucceed 0)
180− delaySeconds $ Seconds 6
181− assertJobIdStatus conn tname "Expecting job to tbe successful by now" Job.Success jobId
182−
183−testEnsureShutdown appPool jobPool = testCase "ensure shutdown" $ withRandomTable jobPool $ \tname -> do
184− jid <- scheduleJob tname
185− delaySeconds (2 * Job.defaultPollingInterval)
186− Pool.withResource appPool $ \conn ->
187− assertJobIdStatus conn tname "Job should still be in queued state if job-monitor is no longer running" Job.Queued jid
325+ recursive = [ PayloadFail <$> Gen.element [1, 2, 3] <*> payloadGen ]
326+
327+testJobCreation appPool jobPool = testCase "job creation" $ do
328+ withNewJobMonitor jobPool $ \tname logRef -> do
329+ Pool.withResource appPool $ \conn -> do
330+ Job{jobId} <- Job.createJob conn tname (PayloadSucceed 0)
331+ delaySeconds $ Seconds 6
332+ assertJobIdStatus conn tname logRef "Expecting job to be successful by now" Job.Success jobId
333+
334+testEnsureShutdown appPool jobPool = testCase "ensure shutdown" $ do
335+ withRandomTable jobPool $ \tname -> do
336+ (jid, logRef) <- scheduleJob tname
337+ delaySeconds (2 * Job.defaultPollingInterval)
338+ Pool.withResource appPool $ \conn -> do
339+ assertJobIdStatus conn tname logRef "Job should still be in queued state if job-monitor is no longer running" Job.Queued jid
188340 where
189− scheduleJob tname = withNamedJobMonitor tname jobPool $ do
341+ scheduleJob tname = withNamedJobMonitor tname jobPool Prelude.id $ \logRef -> do
342+ t <- getCurrentTime
343+ Pool.withResource appPool $ \conn -> do
344+ Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral (2 * unSeconds Job.defaultPollingInterval)) t)
345+ assertJobIdStatus conn tname logRef "Job is scheduled in future, should still be queueud" Job.Queued jobId
346+ pure (jobId, logRef)
347+
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
353+ 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
362+ assertJobIdStatus conn tname logRef "Expecting the first job to be completed successfully if graceful shutdown is implemented correctly" Job.Success (jobId j1)
363+ 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)
364+
365+testJobScheduling appPool jobPool = testCase "job scheduling" $ do
366+ withNewJobMonitor jobPool $ \tname logRef -> do
367+ Pool.withResource appPool $ \conn -> do
190368 t <- getCurrentTime
369+ job@Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral (3600 :: Integer)) t)
370+ delaySeconds $ Seconds 2
371+ assertJobIdStatus conn tname logRef "Job is scheduled in the future. It should NOT have been successful by now" Job.Queued jobId
372+ _ <- Job.saveJobIO conn tname job{jobRunAt = addUTCTime (fromIntegral (-1 :: Integer)) t}
373+ delaySeconds (Job.defaultPollingInterval + Seconds 2)
374+ assertJobIdStatus conn tname logRef "Job had a runAt date in the past. It should have been successful by now" Job.Success jobId
375+
376+testJobFailure appPool jobPool = testCase "job failure" $ do
377+ withNewJobMonitor jobPool $ \tname _logRef -> do
378+ Pool.withResource appPool $ \conn -> do
379+ Job{jobId} <- Job.createJob conn tname (PayloadAlwaysFail 0)
380+ delaySeconds $ calculateRetryDelay testMaxAttempts + Seconds 3
381+ Job{jobAttempts, jobStatus} <- ensureJobId conn tname jobId
382+ assertEqual "Exepcting job to be in Failed status" Job.Failed jobStatus
383+ assertEqual ("Expecting job attempts to be 3. Found " <> show jobAttempts) 3 jobAttempts
384+
385+testPushFailedJobEndQueue :: Pool Connection -> TestTree
386+testPushFailedJobEndQueue jobPool = testCase "testPushFailedJobEndQueue" $ do
387+ withRandomTable jobPool $ \tname -> do
388+ Pool.withResource jobPool $ \conn -> do
389+ job1 <- Job.createJob conn tname (PayloadAlwaysFail 0)
390+ job2 <- Job.createJob conn tname (PayloadAlwaysFail 0)
391+ _ <- Job.saveJobIO conn tname (job1 {jobAttempts = 1})
392+ [Only resId] <- Job.jobPollingIO conn "testPushFailedJobEndQueue" tname 5
393+ assertEqual
394+ "Expecting the current job to be 2 since job 1 has been modified"
395+ (jobId job2) resId
396+
397+testOnJobFailed appPool jobPool = testCase "job error handler" $ do
398+ withRandomTable jobPool $ \tname -> do
399+ Pool.withResource appPool $ \conn -> do
400+ _ <- Job.createJob conn tname (PayloadThrowStringException "forced exception")
401+ _ <- Job.createJob conn tname PayloadThrowDivideByZero
402+ mvar1 <- newMVar False
403+ mvar2 <- newMVar False
404+ let addErrHandlers cfg = cfg { Job.cfgOnJobFailed = [ Job.JobErrHandler errHandler2
405+ , Job.JobErrHandler errHandler1
406+ ]
407+ }
408+ errHandler1 (_ :: StringException) _ _ = swapMVar mvar1 True
409+ errHandler2 (_ :: ArithException) _ _ = swapMVar mvar2 True
410+ withNamedJobMonitor tname jobPool addErrHandlers $ \_logRef -> do
411+ delaySeconds (Job.defaultPollingInterval * 3)
412+ readMVar mvar1 >>= assertEqual "Error Handler 1 doesn't seem to have run" True
413+ readMVar mvar2 >>= assertEqual "Error Handler 2 doesn't seem to have run" True
414+
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+
445+testRetryBackoff appPool jobPool = testCase "retry backoff" $ do
446+ withRandomTable jobPool $ \tname -> do
447+ withNamedJobMonitor tname jobPool modifyRetryBackoff $ \_logRef -> do
191448 Pool.withResource appPool $ \conn -> do
192− Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral (2 * (unSeconds Job.defaultPollingInterval))) t)
193− assertJobIdStatus conn tname "Job is scheduled in future, should still be queueud" Job.Queued jobId
194− pure jobId
195−
196−testGracefuleShutdown appPool jobPool = testCase "ensure graceful shutdown" $ withRandomTable jobPool $ \tname -> do
197− (j1, j2) <- withNamedJobMonitor tname jobPool $ Pool.withResource appPool $ \conn -> do
198− t <- getCurrentTime
199− j1 <- Job.createJob conn tname (PayloadSucceed $ 2 * Job.defaultPollingInterval)
200− j2 <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral $ unSeconds $ Job.defaultPollingInterval) t)
201− liftIO $ putStrLn "created"
202− pure (j1, j2)
203− Pool.withResource appPool $ \conn -> do
204− delaySeconds 1
205− assertJobIdStatus conn tname "Expecting the first job to be in locked state because it should be running" Job.Locked (jobId j1)
206− assertJobIdStatus conn tname "Expecting the second job to be queued because no new job should be picked up during graceful shutdown" Job.Queued (jobId j2)
207− delaySeconds $ 3 * Job.defaultPollingInterval
208− assertJobIdStatus conn tname "Expecting the first job to be completed successfully if graceful shutdown is implemented correctly" Job.Success (jobId j1)
209− assertJobIdStatus conn tname "Expecting the second job to be queued because no new job should be picked up during graceful shutdown" Job.Queued (jobId j2)
210−
211− pure ()
212−
213−testJobScheduling appPool jobPool = testCase "job scheduling" $ withNewJobMonitor jobPool $ \tname -> Pool.withResource appPool $ \conn -> do
214− t <- getCurrentTime
215− job@Job{jobId} <- Job.scheduleJob conn tname (PayloadSucceed 0) (addUTCTime (fromIntegral 3600) t)
216− delaySeconds $ Seconds 2
217− assertJobIdStatus conn tname "Job is scheduled in the future. It should NOT have been successful by now" Job.Queued jobId
218− j <- Job.saveJobIO conn tname job{jobRunAt = (addUTCTime (fromIntegral (-1)) t)}
219− delaySeconds (Job.defaultPollingInterval + (Seconds 2))
220− assertJobIdStatus conn tname "Job had a runAt date in the past. It should have been successful by now" Job.Success jobId
221−
222−testJobFailure appPool jobPool = testCase "job retry" $ withNewJobMonitor jobPool $ \tname -> Pool.withResource appPool $ \conn -> do
223− Job{jobId} <- Job.createJob conn tname (PayloadAlwaysFail 0)
224− delaySeconds $ Seconds 15
225− Job{jobAttempts, jobStatus} <- ensureJobId conn tname jobId
226− assertEqual "Exepcting job to be in Failed status" Job.Failed jobStatus
227− assertEqual ("Expecting job attempts to be 3. Found " <> show jobAttempts) 3 jobAttempts
449+ jobQueueTime <- getCurrentTime
450+ Job{jobId} <- Job.createJob conn tname (PayloadAlwaysFail 0)
451+ delaySeconds (2 * Job.defaultPollingInterval)
452+
453+ let assertBackedOff = do
454+ Job{jobAttempts, jobStatus, jobRunAt} <- ensureJobId conn tname jobId
455+ assertEqual "Exepcting job to be in Retry status" Job.Retry jobStatus
456+ assertBool "Expecting job runAt to be in the future"
457+ (jobRunAt >= addUTCTime (fromIntegral $ unSeconds $ backoff jobAttempts) jobQueueTime)
458+
459+ assertBackedOff
460+
461+ -- Run it for another attempt and check the backoff scales
462+ job <- ensureJobId conn tname jobId
463+ _ <- Job.saveJobIO conn tname (job {jobRunAt = jobQueueTime})
464+ delaySeconds (2 * Job.defaultPollingInterval)
465+
466+ assertBackedOff
467+ where
468+
469+ backoff attempts = Seconds $ 300 * attempts
470+
471+ modifyRetryBackoff cfg
472+ = cfg { Job.cfgDefaultRetryBackoff = pure . backoff }
473+
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
228653
229654data JobEvent = JobStart
230655 | JobRetry
@@ -232,110 +657,111 @@ data JobEvent = JobStart
232657 | JobFailed
233658 deriving (Eq, Show)
234659
235−testEverything appPool jobPool = testProperty "test everything" $ property $ do
660+-- testEverything appPool jobPool = testProperty "test everything" $ property $ do
236661
237− jobPayloads <- forAll $ Gen.list (Range.linear 300 1000) payloadGen
238− jobsMVar <- liftIO $ newMVar (Map.empty :: Map.IntMap [(JobEvent, Job.Job)])
662+-- jobPayloads <- forAll $ Gen.list (Range.linear 300 1000) payloadGen
663+-- jobsMVar <- liftIO $ newMVar (Map.empty :: Map.IntMap [(JobEvent, Job.Job)])
239664
240− let maxDelay = sum $ map (payloadDelay jobPollingInterval) jobPayloads
241− completeRun = ((unSeconds maxDelay) `div` concurrencyFactor) + (2 * (unSeconds testPollingInterval))
665+-- let maxDelay = sum $ map (payloadDelay jobPollingInterval) jobPayloads
666+-- completeRun = ((unSeconds maxDelay) `div` concurrencyFactor) + (2 * (unSeconds testPollingInterval))
242667
243− shutdownAfter <- forAll $ Gen.choice [ pure $ Seconds completeRun -- Either we allow all jobs to be completed properly
244− , (Seconds <$> (Gen.int $ Range.linear 1 completeRun)) -- Or, we shutdown early after a random number of seconds
245− ]
668+-- shutdownAfter <- forAll $ Gen.choice
669+-- [ pure $ Seconds completeRun -- Either we allow all jobs to be completed properly
670+-- , (Seconds <$> (Gen.int $ Range.linear 1 completeRun)) -- Or, we shutdown early after a random number of seconds
671+-- ]
246672
247− test $ withRandomTable jobPool $ \tname -> do
248− (defaults, cleanup) <- liftIO $ Test.defaultJobMonitor tname jobPool
249− let jobMonitorSettings = defaults { Job.monitorJobRunner = jobRunner
250− , Job.monitorTableName = tname
251− , Job.monitorOnJobStart = onJobEvent JobStart jobsMVar
252− , Job.monitorOnJobFailed = onJobEvent JobRetry jobsMVar
253− , Job.monitorOnJobPermanentlyFailed = onJobEvent JobFailed jobsMVar
254− , Job.monitorOnJobSuccess = onJobEvent JobSuccess jobsMVar
255− , Job.monitorDefaultMaxAttempts = 3
256− , Job.monitorPollingInterval = jobPollingInterval
257− }
673+-- test $ withRandomTable jobPool $ \tname -> do
674+-- (defaults, cleanup) <- liftIO $ Test.defaultJobMonitor tname jobPool
675+-- let jobMonitorSettings = defaults { Job.monitorJobRunner = jobRunner
676+-- , Job.monitorTableName = tname
677+-- , Job.monitorOnJobStart = onJobEvent JobStart jobsMVar
678+-- , Job.monitorOnJobFailed = onJobEvent JobRetry jobsMVar
679+-- , Job.monitorOnJobPermanentlyFailed = onJobEvent JobFailed jobsMVar
680+-- , Job.monitorOnJobSuccess = onJobEvent JobSuccess jobsMVar
681+-- , Job.monitorDefaultMaxAttempts = 3
682+-- , Job.monitorPollingInterval = jobPollingInterval
683+-- }
258684
259− (jobs :: [Job]) <- withAsync
260− (liftIO $ Job.runJobMonitor jobMonitorSettings)
261− (const $ finally
262− (liftIO $ actualTest shutdownAfter jobPayloads tname jobsMVar)
263− (liftIO cleanup))
685+-- (jobs :: [Job]) <- withAsync
686+-- (liftIO $ Job.runJobMonitor jobMonitorSettings)
687+-- (const $ finally
688+-- (liftIO $ actualTest shutdownAfter jobPayloads tname jobsMVar)
689+-- (liftIO cleanup))
264690
265− jobAudit <- takeMVar jobsMVar
691+-- jobAudit <- takeMVar jobsMVar
266692
267− [(Only (lockedJobCount :: Int))] <- liftIO $ Pool.withResource appPool $ \conn ->
268− PGS.query conn ("SELECT coalesce(count(id), 0) FROM " <> tname <> " where status=?") (Only Job.Locked)
693+-- [(Only (lockedJobCount :: Int))] <- liftIO $ Pool.withResource appPool $ \conn ->
694+-- PGS.query conn ("SELECT coalesce(count(id), 0) FROM " <> tname <> " where status=?") (Only Job.Locked)
269695
270− -- ALL jobs should show up in the audit, which means they should have
271− -- been attempted /at least/ once
272− (DL.sort $ map jobId jobs) === (DL.sort $ Map.keys jobAudit)
696+-- -- ALL jobs should show up in the audit, which means they should have
697+-- -- been attempted /at least/ once
698+-- (DL.sort $ map jobId jobs) === (DL.sort $ Map.keys jobAudit)
273699
274− -- No job should be in a locked state
275− 0 === lockedJobCount
700+-- -- No job should be in a locked state
701+-- 0 === lockedJobCount
276702
277− -- No job should've been simultaneously picked-up by more than one
278− -- worker
279− True === (Map.foldl (\m js -> m && noRaceCondition js) True jobAudit)
703+-- -- No job should've been simultaneously picked-up by more than one
704+-- -- worker
705+-- True === (Map.foldl (\m js -> m && noRaceCondition js) True jobAudit)
280706
281− liftIO $ print $ "Test passed with job-count = " <> show (length jobPayloads)
707+-- liftIO $ print $ "Test passed with job-count = " <> show (length jobPayloads)
282708
283− where
709+-- where
284710
285− testPollingInterval = 5
286− concurrencyFactor = 5
287−
288− noRaceCondition js = DL.foldl (&&) True $ DL.zipWith (\(x,_) (y,_) -> not $ x==JobStart && y==JobStart) js (tail js)
289−
290− jobPollingInterval = Seconds 2
291−
292− onJobEvent evt jobsMVar job@Job{jobId} = void $ modifyMVar_ jobsMVar $ \jobMap -> do
293− pure $ Map.insertWith (++) jobId [(evt, job)] jobMap
294−
295− actualTest :: Seconds -> [JobPayload] -> Job.TableName -> MVar (Map.IntMap [(JobEvent, Job.Job)]) -> IO [Job]
296− actualTest shutdownAfter jobPayloads tname jobsMVar = do
297− jobs <- forConcurrently jobPayloads $ \pload ->
298− Pool.withResource appPool $ \conn ->
299− liftIO $ Job.createJob conn tname pload
300− let poller nextAction = case nextAction of
301− Left s -> pure $ Left s
302− Right remaining ->
303− if remaining == Seconds 0
304− then pure (Right $ Seconds 0)
305− else do delaySeconds testPollingInterval
306− print $ "------- Polling (remaining = " <> show (unSeconds remaining) <> " sec)------"
307− x <- withMVar jobsMVar $ \jobMap ->
308− if (Map.foldl (\m js -> m && (isJobTerminalState js)) True jobMap)
309− then pure (Right $ Seconds 0)
310− else if remaining < testPollingInterval
311− then pure (Left $ "Timeout. Job count=" <> show (length jobPayloads) <> " shutdownAfter=" <> show shutdownAfter)
312− else pure $ Right (remaining - testPollingInterval)
313− poller x
314−
315− poller (Right shutdownAfter) >>= \case
316− Left s -> pure jobs -- Prelude.error s
317− Right _ -> pure jobs
318−
319− isJobTerminalState js = case js of
320− [] -> False
321− (_, j):_ -> case (Job.jobStatus j) of
322− Job.Failed -> True
323− Job.Success -> True
324− _ -> False
711+-- testPollingInterval = 5
712+-- concurrencyFactor = 5
713+
714+-- noRaceCondition js = DL.foldl (&&) True $ DL.zipWith (\(x,_) (y,_) -> not $ x==JobStart && y==JobStart) js (tail js)
715+
716+-- jobPollingInterval = Seconds 2
717+
718+-- onJobEvent evt jobsMVar job@Job{jobId} = void $ modifyMVar_ jobsMVar $ \jobMap -> do
719+-- pure $ Map.insertWith (++) jobId [(evt, job)] jobMap
720+
721+-- actualTest :: Seconds -> [JobPayload] -> Job.TableName -> MVar (Map.IntMap [(JobEvent, Job.Job)]) -> IO [Job]
722+-- actualTest shutdownAfter jobPayloads tname jobsMVar = do
723+-- jobs <- forConcurrently jobPayloads $ \pload ->
724+-- Pool.withResource appPool $ \conn ->
725+-- liftIO $ Job.createJob conn tname pload
726+-- let poller nextAction = case nextAction of
727+-- Left s -> pure $ Left s
728+-- Right remaining ->
729+-- if remaining == Seconds 0
730+-- then pure (Right $ Seconds 0)
731+-- else do delaySeconds testPollingInterval
732+-- print $ "------- Polling (remaining = " <> show (unSeconds remaining) <> " sec)------"
733+-- x <- withMVar jobsMVar $ \jobMap ->
734+-- if (Map.foldl (\m js -> m && (isJobTerminalState js)) True jobMap)
735+-- then pure (Right $ Seconds 0)
736+-- else if remaining < testPollingInterval
737+-- then pure (Left $ "Timeout. Job count=" <> show (length jobPayloads) <> " shutdownAfter=" <> show shutdownAfter)
738+-- else pure $ Right (remaining - testPollingInterval)
739+-- poller x
740+
741+-- poller (Right shutdownAfter) >>= \case
742+-- Left s -> pure jobs -- Prelude.error s
743+-- Right _ -> pure jobs
744+
745+-- isJobTerminalState js = case js of
746+-- [] -> False
747+-- (_, j):_ -> case (Job.jobStatus j) of
748+-- Job.Failed -> True
749+-- Job.Success -> True
750+-- _ -> False
325751
326752
327753
328754payloadDelay :: Seconds -> JobPayload -> Seconds
329−payloadDelay jobPollingInterval pload = payloadDelay_ (Seconds 0) pload
755+payloadDelay jobPollingInterval = payloadDelay_ (Seconds 0)
330756 where
331− payloadDelay_ total p = case p of
332− PayloadAlwaysFail x -> total + x + jobPollingInterval
333− PayloadSucceed x -> total + x + jobPollingInterval
334− PayloadFail x ip -> payloadDelay_ (total + x + jobPollingInterval) ip
335−
336−
337−
338−-- TODO: test to ensure that errors in callback do not affect the running of jobs
757+ payloadDelay_ total p =
758+ let defaultDelay x = total + x + jobPollingInterval
759+ in case p of
760+ PayloadAlwaysFail x -> defaultDelay x
761+ PayloadSucceed x -> defaultDelay x
762+ PayloadFail x ip -> payloadDelay_ (defaultDelay x) ip
763+ PayloadThrowStringException _ -> defaultDelay 0
764+ PayloadThrowDivideByZero -> defaultDelay 0
339765
340766deriving instance Enum Time.Unit
341767deriving instance Enum Time.Direction
@@ -348,7 +774,7 @@ timeGen t d = do
348774 pure $ Time.timeSince t (fromInteger i) u d
349775
350776anyTimeGen :: MonadGen m => UTCTime -> m UTCTime
351−anyTimeGen t = (Gen.element (enumFrom Time.Ago)) >>= (timeGen t)
777+anyTimeGen t = Gen.element (enumFrom Time.Ago) >>= timeGen t
352778
353779futureTimeGen :: MonadGen m => UTCTime -> m UTCTime
354780futureTimeGen t = timeGen t Time.FromThat
@@ -400,8 +826,8 @@ genFilter t = do
400826 updatedBefore <- case updatedAfter of
401827 Nothing -> Gen.maybe (anyTimeGen t)
402828 Just x -> Gen.maybe (futureTimeGen x)
403− orderClause <- Gen.maybe ((,) <$> (Gen.element $ enumFrom Web.OrdCreatedAt) <*> (Gen.element $ enumFrom Web.Asc))
404− 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))
405831 runAfter <- Gen.maybe (futureTimeGen t)
406832 pure Web.blankFilter
407833 { filterStatuses = statuses
@@ -419,23 +845,23 @@ filterJobs :: Filter -> [Job] -> [Job]
419845filterJobs Web.Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, filterUpdatedAfter, filterUpdatedBefore, filterOrder, filterPage, filterRunAfter} js =
420846 applyLimitOffset $
421847 applyOrdering (fromMaybe (Web.OrdUpdatedAt, Web.Desc) filterOrder) $
422− (flip DL.filter) js $ \j -> (filterByStatus j) &&
423− (filterByCreatedAfter j) &&
424− (filterByCreatedBefore j) &&
425− (filterByUpdatedAfter j) &&
426− (filterByUpdatedBefore j) &&
427− (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
428854 where
429− 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
430856
431857 applyOrdering (fld, dir) lst =
432858 let comparer = resultOrder $ case fld of
433− Web.OrdCreatedAt -> (comparing jobCreatedAt)
434− Web.OrdUpdatedAt -> (comparing jobUpdatedAt)
435− Web.OrdLockedAt -> (comparing jobLockedAt)
436− Web.OrdStatus -> (comparing jobStatus)
437− Web.OrdJobType -> comparing Job.jobType
438− resultOrder fn = \x y -> case fn x y of
859+ Web.OrdCreatedAt -> comparing jobCreatedAt
860+ Web.OrdUpdatedAt -> comparing jobUpdatedAt
861+ Web.OrdLockedAt -> comparing jobLockedAt
862+ Web.OrdStatus -> comparing jobStatus
863+ Web.OrdJobType -> comparing Job.defaultJobType
864+ resultOrder fn x y = case fn x y of
439865 EQ -> compare (Down $ jobId x) (Down $ jobId y)
440866 LT -> case dir of
441867 Web.Asc -> LT
@@ -445,22 +871,26 @@ filterJobs Web.Filter{filterStatuses, filterCreatedAfter, filterCreatedBefore, f
445871 Web.Desc -> LT
446872 in sortBy comparer lst
447873
448− filterByStatus Job.Job{jobStatus} = if Prelude.null filterStatuses
449− then True
450− else jobStatus `elem` filterStatuses
874+ filterByStatus Job.Job{jobStatus} = Prelude.null filterStatuses
875+ || (jobStatus `elem` filterStatuses)
451876 filterByCreatedAfter Job.Job{jobCreatedAt} = maybe True (<= jobCreatedAt) filterCreatedAfter
452877 filterByCreatedBefore Job.Job{jobCreatedAt} = maybe True (> jobCreatedAt) filterCreatedBefore
453878 filterByUpdatedAfter Job.Job{jobUpdatedAt} = maybe True (<= jobUpdatedAt) filterUpdatedAfter
454879 filterByUpdatedBefore Job.Job{jobUpdatedAt} = maybe True (> jobUpdatedAt) filterUpdatedBefore
455880 filterByRunAfter Job.Job{jobRunAt} = maybe True (< jobRunAt) filterRunAfter
456881
457−defaultJobMonitor :: Job.TableName
458− -> Pool Connection
459− -> IO (Job.JobMonitor, IO ())
460−defaultJobMonitor tname pool = do
461− tcache <- newTimeCache simpleTimeFormat'
462− (tlogger, cleanup) <- newTimedFastLogger tcache LogNone
463− let flogger loc lsource llevel lstr = tlogger $ \t -> toLogStr t <> " | " <> defaultLogStr loc lsource llevel lstr
464− pure ( Job.defaultJobMonitor flogger tname pool
465− , cleanup
466− )
882+-- defaultJobMonitor :: Job.TableName
883+-- -> Pool Connection
884+-- -> (Job -> IO ())
885+-- -> IO (Job.JobMonitor, IO ())
886+-- defaultJobMonitor tname pool = do
887+-- tcache <- newTimeCache simpleTimeFormat'
888+-- (tlogger, cleanup) <- newTimedFastLogger tcache LogNone
889+-- let flogger loc lsource llevel lstr = tlogger $ \t -> toLogStr t <> " | " <> defaultLogStr loc lsource llevel lstr
890+-- Job.mkConfig _loggingFn tname pool Job.UnlimitedConcurrentJobs (pure ())
891+-- pure ( Job.defaultJobMonitor flogger tname pool
892+-- , cleanup
893+-- )
894+
895+calculateRetryDelay :: Int -> Seconds
896+calculateRetryDelay n = Seconds $ foldl' (\s x -> s + (2 ^ x)) 0 [1..n]
test/Try.hsdeleted−28
@@ -1,28 +0,0 @@
1−{-# LANGUAGE FlexibleContexts #-}
2−module Try where
3−
4−import Hedgehog
5−import qualified Hedgehog.Gen as Gen
6−import qualified Hedgehog.Range as Range
7−import Control.Exception.Lifted
8−import Control.Concurrent.Async.Lifted
9−import Control.Monad
10−import Data.Pool as Pool
11−import Debug.Trace
12−import Control.Monad.IO.Unlift (liftIO)
13−import qualified System.Random as R
14−import Data.String (fromString)
15−
16−withRandomTable pool action = do
17− tname <- liftIO ((("jobs_" <>) . fromString) <$> (replicateM 10 (R.randomRIO ('a', 'z'))))
18− finally
19− (Pool.withResource pool $ \conn -> (liftIO $ traceM "I will create the random table here") >> (action tname))
20− (Pool.withResource pool $ \conn -> liftIO $ traceM "I will drop the random table here")
21−
22−myTest pool = property $ do
23− randomData <- forAll $ Gen.list (Range.linear 1 100) (Gen.element [1, 2, 3])
24− test $ withRandomTable pool $ \tname -> do
25− withAsync
26− (traceM "I will be a long-running background thread")
27− (const $ traceM $ "hooray... I got the random table name " <> tname)
28− True === True
test/Try2.hsdeleted−62
@@ -1,62 +0,0 @@
1−module Try2 where
2−
3−-- import Control.Concurrent.Lifted
4−-- import Control.Concurrent.Async.Lifted
5−-- import Control.Exception.Lifted
6−import UnliftIO.Concurrent
7−import UnliftIO.Async
8−import UnliftIO.Exception
9−import Control.Concurrent.Async (AsyncCancelled(..))
10−import Debug.Trace
11−import Control.Monad
12−import Data.Functor
13−import Control.Monad.Reader
14−import Data.Maybe
15−
16−-- oneSec :: Int
17−-- oneSec = 1000000
18−
19−
20−-- main :: IO ()
21−-- main = finally
22−-- (withAsync
23−-- (catch runMonitoringThread (\AsyncCancelled -> print "---- delivered in main"))
24−-- (const $ threadDelay $ oneSec * 5))
25−-- (print "finally executed")
26−
27−
28−-- myPrint = liftIO . print
29−
30−-- runMonitoringThread :: IO ()
31−-- runMonitoringThread = do
32−-- print "something random"
33−-- runReaderT runMonitoringThread2 10
34−
35−-- runMonitoringThread2 :: ReaderT Int IO ()
36−-- runMonitoringThread2 = do
37−-- x <- ask
38−-- myPrint $ "==== got the number " <> show x
39−-- runMonitoringThread_ Nothing Nothing
40−-- where
41−-- runMonitoringThread_ mThread1 mThread2= do
42−-- thread1 <- maybe (async task1) pure mThread1
43−-- thread2 <- maybe (async task2) pure mThread2
44−-- -- catch (myWait thread1 thread2) (\AsyncCancelled -> myPrint "signal delivered in myWait" >> cancel thread1 >> cancel thread2)
45−-- finally
46−-- (myWait thread1 thread2)
47−-- (myPrint "FINALLY" >> cancel thread1 >> cancel thread2)
48−
49−-- task1 = forever $ do
50−-- myPrint "thread a is running"
51−-- threadDelay oneSec
52−
53−-- task2 = forever $ do
54−-- myPrint "thread 2 is running"
55−-- threadDelay (oneSec * 2)
56−
57−-- myWait :: Async () -> Async () -> ReaderT Int IO ()
58−-- myWait thread1 thread2 = do
59−-- waitEitherCatch thread1 thread2 >>= \case
60−-- Left _ -> runMonitoringThread_ Nothing (Just thread2)
61−-- Right _ -> runMonitoringThread_ (Just thread1) Nothing
62−-- myPrint "this should never get executed"