Features

Jobs

At some point, you may want to run some long-running tasks, for example, to send an email or process a file upload, that would take too long to run during a normal request-response cycle. In those cases, you can use asynchronous jobs to run those tasks in the background.

Expanse provides a simple and intuitive way to create and manage background jobs that is powered by its own message bus. This means that you must configure at least one transport for the message bus to be able to use jobs. You can find more information on how to configure transports in the message bus documentation.

Creating jobs

A job is a unit of work that can be executed asynchronously in the background. it must be defined as a class that inherits from the Job class provided by Expanse and specifies a payload type.

You can generate a new job using the make job command of the CLI:

expanse make job ProcessFileUpload
expanse make job ProcessFileUpload --sync

This will generate a new job class in the app.jobs.process_file_upload module with the following content:

from dataclasses import dataclass

from expanse.jobs.asynchronous.job import Job


@dataclass
class ProcessFileUploadPayload:

    file_path: str


class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    async def execute(self) -> None:
        file_path = payload.file_path

        # Process the file
        ...
from dataclasses import dataclass

from expanse.jobs.synchronous.job import Job


@dataclass
class ProcessFileUploadPayload:

    file_path: str


class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    def execute(self) -> None:
        file_path = self.payload.file_path

        # Process the file
        ...

Similarly to messages, Expanse supports dataclasses, Pydantic models and msgspec structs as job payloads.

Dependency injection

The execute method supports dependency injection just like route handlers or commands. For instance, if you need to access the database to process the file upload, you can inject a Session instance directly into the method:

class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    async def execute(self, storage: StorageManager) -> None:
        file = await storage.get(self.payload.file_path)

        # Process the file
        ...

Dispatching jobs

Once you have created your job, you can dispatch it using the dispatch() (or dispatch_sync()) method.

from expanse.http.upload_file import UploadFile
from expanse.jobs.job_dispatcher import JobDispatcher

from app.jobs.process_file_upload import ProcessFileUpload, ProcessFileUploadPayload


async def upload_file(
    file_: UploadFile
) -> Response:
    file_path = await file_.save("files")

    await ProcessFileUpload(
        ProcessFileUploadPayload(file_path=file_path)
    ).dispatch()

    ...
from expanse.http.upload_file import UploadFile
from expanse.jobs.job_dispatcher import JobDispatcher

from app.jobs.process_file_upload import ProcessFileUpload, ProcessFileUploadPayload


def upload_file(
    file_: UploadFile
) -> Response:
    file_path = file_.save_sync("files")

    ProcessFileUpload(
        ProcessFileUploadPayload(file_path=file_path)
    ).dispatch_sync()

    ...

Delaying jobs

By default, jobs are dispatched immediately, but you can also delay them to be executed at a later time. To do that you can prepare the job before dispatching it and use the delay method to specify the delay in seconds:

await ProcessFileUpload(
    ProcessFileUploadPayload(file_path=file_path)
).delay(60).dispatch()   # Delay the job for 60 seconds
ProcessFileUpload(
    ProcessFileUploadPayload(file_path=file_path)
).delay(60).dispatch_sync()   # Delay the job for 60 seconds

Alternatively, you can also specify the delay directly in the job definition, either by using the delay() decorator or by setting the delay option in the job options:

from expanse.jobs.decorators import delay


@delay(60)  # Delay the job for 60 seconds
class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    ...
from expanse.types.jobs.job_options import JobOptions


class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    options: JobOptions = {
        "delay": 60  # Delay the job for 60 seconds
    }

Dispatching jobs to specific transports

By default, jobs are dispatched to the default transport of the message bus, but you can also specify a different transport to dispatch the job to. To do that you can prepare the job before dispatching it and use the via method to specify the transport to use:

await ProcessFileUpload(
    ProcessFileUploadPayload(file_path=file_path)
).via("other_transport").dispatch_sync()
ProcessFileUpload(
    ProcessFileUploadPayload(file_path=file_path)
).via("other_transport").dispatch_sync()

Alternatively, you can also specify the transport directly in the job definition, either by using the via() decorator or by setting the transport option in the job options:

from expanse.jobs.decorators import transport


@transport("other_transport")  # Dispatch the job to the "other_transport" transport
class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    ...
from expanse.types.jobs.job_options import JobOptions


class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    options: JobOptions = {
        "transport": "other_transport"  # Dispatch the job to the "other_transport" transport
    }

Ensuring uniqueness

If you want to ensure that a job is unique and not dispatched multiple times, you can use the unique() decorator or by setting the unique option in the job definition. This will prevent the same job from being dispatched if it is already in the queue.

from expanse.jobs.decorators import unique


@unique()
class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    ...
from expanse.types.jobs.job_options import JobOptions


class ProcessFileUpload(Job[ProcessFileUploadPayload]):

    options: JobOptions = {
        "unique": True
    }