1800-01-01

My Advice Is To Do What Your Parents Did And Get A Job Sir!

IHP Jobs are designed to be run in parallel with the normal request/response cycles of your application. For instance you may have a job that scrapes information from an external database to populate your own or a job that summarizes the data currently stored in your system and sends an email to management with the highlights.

The guide describes how to set up and run your jobs. Here we'll focus on the type classes and data structures that define an IHP Job.

The design of the Jobs is quite convenient. In IHP each new Job corresponds to a new record in a Jobs table in your database. When you want to trigger a new job you create a new record in the Jobs database. In this respect working with Jobs is the same process as working with the rest of the data in your application.

The Job type class has two member functions perform and maxAttempts.

class Job job where
    perform :: (?modelContext :: ModelContext, ?context :: FrameworkConfig) => job -> IO ()

    maxAttempts :: (?job :: job) => Int
    maxAttempts = 10

The perform member function is where we place all the tasks and logic that make up a job. Notice that it is carrying around the ?modelContext and ?context implicit parameters so everything in that function will have a reference to the application's database and can fetch/create/update/delete records as if they were in a normal IHP Controller.

Here is an example. Let us say we have a table of Reports. The application allows the user to upload a report to the database. If the reports are very large we may want to delay the work of parsing them until things are quiet and the computer can focus on its task. To this end we can define a job ParseReportJob:

instance Job ParseReportJob where
    perform ParseReportJob { .. } = do
--Fetches any uploaded reports and adds them to the system.
      reports <- query @Report
                 |> filterWhere(#status, ReportUploaded)
                 |> fetch

      mapM_ parseReport reports

This job can be triggered to run on a button click or timed.

Remember we said Jobs are just rows in a jobs table? I.e. Jobs are just more data. Here's what the ParseReportJob looks like in the Schema.sql.

CREATE TABLE parse_report_jobs (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
    status JOB_STATUS DEFAULT 'job_status_not_started' NOT NULL,
    last_error TEXT DEFAULT NULL,
    attempts_count INT DEFAULT 0 NOT NULL,
    locked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
    locked_by UUID DEFAULT NULL,
    notes TEXT DEFAULT '' NOT NULL
);

Like the rest of our data it follows the IHP naming convention and the code generator builds our data types and makes them instances of all the usual helpful classes.

The next type class to note is Worker:

class Worker application where
    workers :: application -> [JobWorker]
newtype JobWorker = JobWorker (JobWorkerArgs -> IO (Async.Async ()))

and JobWorkerArgs is :

data JobWorkerArgs = JobWorkerArgs
    { allJobs :: IORef [Async.Async ()]
    , workerId :: UUID
    , modelContext :: ModelContext
    , frameworkConfig :: FrameworkConfig }

You need to nest the Worker type classes as deep as the application goes and then register them with Main.hs:

instance Worker RootApplication where
    workers _ = workers WebApplication

So we will also define:

instance Worker WebApplication where
    workers _ =
        [ worker @ParseReportJob
          -- Generator Marker
        ]

And this is where we shall discretely draw a veil over the proceedings. For the curious you can look in IHP/Job/Runner.hs to see how the worker function is defined. It brings in the Control.Exception and Control.Concurrent libraries and is a bit of a tour de force in Asynchronous IO in Haskell.