Skip to content

Scoped jobs

ctx.jobs is the local job runtime decision-13 shaped, decision-38 made function-based, decision-21 scoped and decision-65 worker-first: a job is a decorated plain function whose first parameter is the WorkerContext the runtime injects and whose remaining keyword parameters are typed the way an action’s are. The plugin names the jobs it exposes, submission is ctx.jobs.<name>.delay(**inputs) and nothing else, and the handle you get back is typed end to end. Since 0.9.0 the whole body runs on a worker: the driving thread submits, validates and delivers, and never calls your function. qgis_sdk.jobs is QGIS-free; it runs on the step scheduler and a host drives JobService.tick() from whatever event loop it already has.

from dataclasses import dataclass
from qgis_sdk import QgisSdkPlugin
from qgis_sdk.core import PluginContext, WorkerContext
from qgis_sdk.jobs import JobHandle, job
@dataclass(frozen=True)
class SumResult:
total: int
@job(id="demo.sum", title="Add two numbers")
def add(ctx: WorkerContext, left: int, right: int = 0) -> SumResult:
# The whole body is on a worker: blocking work is already off the GUI
# thread, and there is nothing to await.
ctx.progress.report(0.5)
return SumResult(left + right)
app = QgisSdkPlugin(id="demo", title="Demo")
app.jobs = {"add": add} # what an action reaches as `ctx.jobs.add`
@app.action(id="sum", title="Sum")
def sum_action(ctx: PluginContext, left: int, right: int) -> JobHandle:
handle = ctx.jobs.add.delay(left=left, right=right) # returns at once
ctx.jobs.watch(
handle,
on_progress=lambda fraction: bar.setValue(int(fraction * 100)),
on_success=lambda result: label.setText(str(result.total)),
on_failure=lambda error: label.setText(str(error)),
on_cancelled=lambda: label.setText("cancelled"),
)
return handle

The decorator adds describe() but does not wrap add, so a unit test calls add(fake_context, left=1, right=2) directly. A bare @job derives its id and title from the function name. A plain def is the only accepted form: async def and a generator are refused at bind time, because a worker runs no event loop. GUI work belongs in a subscriber, which runs on the driving thread; ctx.jobs.watch(handle, ...) subscribes all four events to the calling owner’s scope in one call.

A plugin declares the jobs an action may reach, in either of two forms:

app.jobs = {"add": add, "count": count_features} # explicit keys
app.jobs = (add, count_features) # names derived from the functions
@app.job(id="demo.sum", title="Add two numbers") # decorator, same registry
def add(ctx: WorkerContext, left: int, right: int = 0) -> SumResult: ...
@app.job() # name, id and title derived from the function
def count_features(ctx: WorkerContext, layer_id: str) -> int: ...

@app.job(...) is the spelling to reach for when the job is written next to the plugin: it applies @job itself (so the function describes on its own), derives the name the way a sequence entry does, and takes key= to rename it the way a mapping key does. @app.job(id=..., title=...) gives the explicit form, and @app.job without parentheses works too. All three spellings share one registry, so a name declared twice - by assignment and by decorator, or by two decorators - is refused when the plugin is defined.

The second form derives each name from the function’s own name - the same snake_case rule a bare @job uses for its id, without the module suffix - so ctx.jobs.count_features.delay(layer_id=...) is what a sequence entry of def count_features(...) buys you. A @job(id=...) does not change the derived name: the name is how you reach the job, the id is how it is described.

Use the mapping form when two jobs share a Python name (two modules, one count_features) or when you want the public name to differ from the function; a sequence that derives the same name twice is refused at startup with that instruction. So is an entry that is not a @job function, a key that is not a plain identifier, a key that shadows what ctx.jobs itself reads (active, after, delay, bind, describe, faults, names, plugin, registry, scope, watch, …), and two names for one job id.

Everything is checked when the plugin starts, not when a job is submitted: the runtime builds the registry once, and ctx.jobs.<name> is a dictionary read that neither imports nor inspects anything. An unknown name raises AttributeError naming what the plugin does declare, so a typo never looks like a job that failed to start.

bind is deliberately not part of this: it stays on qgis_sdk.jobs.JobService for hosts and tests, and author code reaches a job by the name the plugin declared. Two limitations come with that, both by design: ctx.jobs.<name> is a runtime lookup, so an editor cannot autocomplete or type-check it (generated per-plugin typing is TASK-124), and the web bridge keeps carrying a handle as {id, status} rather than a live subscription - a page is told the id and the state, not the progress stream.

describe_job(add) returns the JobDescription (id, title, the parameter names, which are required, the result type, and the object schema of the inputs plus the result schema from the declarations generator) without calling the function; a function without @job, without a WorkerContext first parameter, with an unannotated parameter or without a declared result is refused with QgisSdkDeclarationError when the plugin starts (the registry describes every declared entry once) or when bind is handed the function, and so is a class (the class form was removed in 0.6.0 - see the migration note below). An async def body and a JobContext annotation are refused the same way, each with the migration it needs. JobDescription.asynchronous stays in the transport description and is always False since 0.9.0, so a 0.8 reader still parses one. Keywords that do not fit the signature - missing, unexpected or of the wrong shape - are refused by delay with QgisSdkJobRejectedError naming every problem before anything runs; positional arguments are a TypeError; a result of the wrong type fails the job, not the tick.

The whole body runs on a worker thread (decision-65), so it is handed a WorkerContext: progress and cancellation, and nothing else. Reading any other attribute - ctx.layers, ctx.project, ctx.ui, ctx.algorithms - fails with QgisSdkUnsupportedCapabilityError naming the capability and the reason (“layers are GUI-affine; pass plain values and re-read them on delivery”), because a Qt object touched from another thread crashes QGIS instead of raising. Prepare GUI state before submitting, pass plain values (ids, paths, numbers) in, and apply GUI changes in the success callback, which is delivered on the driving thread.

Two guards stand behind that, and neither is a proof of thread safety:

  • qgis-sdk check reads the source: JOB001 for GUI state reached from a job body (iface, a Qt or QGIS GUI class, QgsProject.instance(), a module-level name holding one of those), JOB002 for a context attribute a WorkerContext refuses, and JOB003 as a warning for a by-name import it cannot follow. A name the body rebinds locally - a parameter called iface, an assignment - is not reported. The three rows are in the rule table, which the Project checks section of this guide carries.
  • the runtime remembers which thread assembled a context. Reading a GUI-bound capability (desktop, ui, layers, project, files, streams, actions) from another thread warns once per capability and thread, and raises in a development session (qgis-sdk dev), which is what catches the captured reference - service = ctx.desktop taken on the driving thread and used inside a job - that a source check cannot see (decision-69).

What is left to you: a helper the body calls, a live object passed in as an argument, and anything a by-name import reaches. Neither guard turns a data race into a reported error, and neither is a sandbox.

Up to qgis-sdk-runtime 0.5 a job was a class with __init__(self, ctx) and a typed run(self, inputs), submitted as bind(Cls).delay(InputsDataclass(...)). In 0.6 the same job is the body of run as a module-level function:

0.5
@job(id="demo.sum", title="Sum")
class Sum:
def __init__(self, ctx: JobContext) -> None:
self.ctx = ctx
def run(self, inputs: SumInputs) -> SumResult:
self.ctx.progress.report(0.5)
return SumResult(inputs.a + inputs.b)
handle = ctx.jobs.bind(Sum).delay(SumInputs(1, 2))
# 0.6
@job(id="demo.sum", title="Sum")
def add(ctx: JobContext, a: int, b: int) -> SumResult:
ctx.progress.report(0.5)
return SumResult(a + b)
handle = ctx.jobs.bind(add).delay(a=1, b=2)

Move self.ctx to the injected ctx argument, spread the inputs dataclass into keyword parameters (a dataclass is still a fine parameter type when the inputs are genuinely one object), and call delay with keywords. A class handed to @job or bind is refused with a message that points here.

handle.cancel() returns whether the job was still active and sets cancel_requested; status becomes cancelled only when the job acknowledges it (or before the worker ever started it). Nothing interrupts a worker thread: the body polls ctx.cancellation.is_cancelled(), the request is forwarded to the backend, and a callable still queued on a real pool reports without running. A request that arrives before the worker reports settles the attempt at that report, so a body that runs to the end after being asked to stop ends cancelled rather than publishing a result nobody is waiting for. Work that never reports stays running; quiesce() names it as a blocker instead of pretending it stopped.

Two hosts go further and abandon work instead of waiting for it, and they say so differently because they know different things:

  • Unloading the plugin abandons everything through the scheduler’s own rule, so a job that never started reports the reason it never started (QgisSdkScopeClosedError) and a job that had started reports cancelled.
  • A QGIS Server request has no pump at all, so the request scope’s jobs are abandoned by name: status reads cancelled at once, owner_closed is True, result / get() raise QgisSdkJobCancelledError, and the scheduler stops holding the job. That reading is pinned by JobService.request_scoped and the server host’s per-request context; the plugin’s own work, reached through ctx.jobs.plugin, is untouched.

Callbacks run on the tick, never on a worker

Section titled “Callbacks run on the tick, never on a worker”

Progress reported from a worker thread is coalesced to the latest fraction and delivered on the next tick; each terminal outcome is delivered exactly once, in subscription order. Subscribing late replays the current state synchronously as one transition; a terminal replay completes the subscription. A callback that raises is detached and reported through faults / on_fault, never raised into the tick. handle.get(timeout) blocks only off the GUI thread - on it, QgisSdkJobWaitRefusedError; a timeout is QgisSdkJobNotSettledError. handle.result reads the same outcome without waiting: the typed result, the job’s own exception for a failed job, QgisSdkJobCancelledError for a cancelled one, and QgisSdkJobNotSettledError only while the job is queued or running. A tick() entered while another is on the stack - a job or a callback opened a modal dialog and its nested event loop fired the host timer - is ignored and returns 0; a nested quiesce() is refused with the blocker a tick is already in progress on this thread.

ctx.jobs in a view is plugin_jobs.scoped(view_scope): closing the view disposes every subscription it owns first, then asks each of its jobs to stop. Hiding a dock is not disposal. A finished handle whose view closed afterwards refuses new subscriptions rather than replaying into it. Plugin-lifetime work is explicit - ctx.jobs.plugin.<name>.delay(...), or jobs.plugin on a view’s facade - and outlives the view. An action the plugin instance handles submits the calling view’s work by default: the runtime carries the invoking scope with the dispatch, so self.ctx.jobs.add.delay(...) inside a view-started action is that window’s job, and a toolbar or page-level call is the plugin’s. Closing the plugin scope abandons everything through the scheduler’s existing rule; nobody is called. A subscription may name another owner (handle.on_success(cb, owner=other_scope)); that owner’s closure detaches it, and the service keeps one close hook per such owner, released as the job finishes, so a long-lived owner does not accumulate finished jobs.

There are no automatic retries: resubmission is delay again, with a new logical id, and attempt is carried as 1 so a later, explicit retry policy has somewhere to count.

Hosts compose the service once per plugin:

from qgis_sdk.adapters.qgis import QgsJobPump, QgsTaskWorkers
from qgis_sdk.jobs import JobService
jobs = JobService(plugin_scope, workers=QgsTaskWorkers(plugin_scope), network=network)
pump = QgsJobPump(jobs) # a QTimer that calls jobs.tick() on the GUI thread
pump.start() # up with the plugin, pump.stop() before it unloads

A PluginRuntime does this itself when the plugin declares jobs: it validates the registry, builds the service over the plugin scope with the host’s workers, and adds jobs to the context it was given, so runtime.context.has("jobs") is true and runtime.context.jobs.<name> is the author’s call. The runtime is the owner of both: runtime.jobs is the service (what a host ticks) and runtime.job_registry the validated names. host() in a QGIS Desktop session starts a QgsJobPump in initGui and stops it before unload, which is why callbacks arrive without the plugin writing a timer.

workers is not optional any more: a JobService built without one refuses every submission with QgisSdkJobRejectedError instead of quietly running the body on the driving thread (decision-65). A job is driven in three moves: tick() submits, the worker runs the body, tick() delivers.

QgsTaskWorkers is the native backend, and native_plugin_context already gives you one as ctx.workers. It runs each job body as one QgsTask under QgsApplication.taskManager(), so the job is a single entry in the QGIS task bar: the title is the job’s title, its bar follows ctx.progress (the worker reports, the task bar draws), and pressing cancel files the same cooperative request handle.cancel() does - the body stops when it next polls ctx.cancellation, and nothing is interrupted. There are no sub-tasks and no parent task; a Processing algorithm keeps being the QgsTask Processing already started. ThreadPoolWorkers stays available for a host without a task manager (and is what tests/test_jobs_native.py drives); both report through the scheduler’s Handoff, so nothing about delivery changes between them.

In tests, qgis_sdk.testing.FakeWorkers holds the bodies until the test says run_one() / run_all() - which is how the suite stages races between workers, ticks and disposal, and a test passes run_all to a thread of its own when it wants the body to run off the driving thread - and qgis_sdk.testing.ThreadWorkers is a real ThreadPoolExecutor for the cases that want one. tests/features/scoped_jobs.feature states the rules as scenarios (bound by tests/test_jobs_bdd.py), tests/test_jobs.py holds the examples, tests/test_jobs_properties.py a Hypothesis state machine over submit / tick / cancel / worker reports / close view / close listener / close plugin, tests/test_jobs_native.py the thread evidence over a real pool and a real Qt event loop, and tests/test_jobs_tasks_native.py the task-manager evidence (one task per job, its title, mirrored progress, the cancel button).

ctx.jobs.after(handle) submits work that runs only after the jobs you name succeeded:

prepare = ctx.jobs.load_layer.delay(layer_id=layer_id)
second = ctx.jobs.after(prepare).run_report.delay(output=path)
# the same thing, chained: ctx.jobs.run_report.after(prepare).delay(output=path)

The ordering is enforced before the dependent body is handed to a worker, so it holds for every backend and the task bar never shows work it must not start yet: the dependent job is queued, then running while it waits, and its body runs after the prerequisite’s. A prerequisite that fails or is cancelled fails the dependent job with QgisSdkJobPrerequisiteError naming the prerequisite and its outcome, and the dependent body never runs. A chain works the same way

  • each after takes the handles of what it waits for - and a dependent job can still be cancelled while it waits.

0.8 ran the body on the driving thread and left worker work to the author: async def plus await ctx.workers.offload(work). 0.9 does the offloading itself, so a job becomes a plain def and the await goes away. Both old shapes are refused at bind time with an actionable message; nothing silently keeps the old execution.

0.8
@job(id="demo.count", title="Count")
async def count(ctx: JobContext, layer_id: str) -> CountResult:
def work() -> int:
return len(feature_ids(layer_id))
total = await ctx.workers.offload(work)
return CountResult(total)
# 0.9
@job(id="demo.count", title="Count")
def count(ctx: WorkerContext, layer_id: str) -> CountResult:
return CountResult(total=len(feature_ids(layer_id)))

What to move:

  • The context. ctx: JobContext becomes ctx: WorkerContext, which carries progress and cancellation only. project, algorithms, layers, iface, network, notifications, ui, view, files, scope and workers are gone, and reading one raises QgisSdkUnsupportedCapabilityError naming the capability and why a worker cannot have it.
  • GUI preparation, before submission. Read the project, resolve a path, collect plain values and let the user confirm - all on the driving thread, before delay. Pass plain values (ids, numbers, dataclasses) into the body, not live layers or widgets.
  • GUI updates, after delivery. Anything that touches a widget or the message bar belongs in on_success / on_failure / on_cancelled, which run on the driving thread.
  • Long work, in the body. A blocking call that used to need an offload is now simply called; the whole body is already off the GUI thread. There is no reason to create a second pool, and ctx.workers is absent.
  • What the check now says. qgis-sdk check reports JOB001 and JOB002 for a body that reaches GUI state or a refused attribute, with JOB003 as a warning for a by-name import; the runtime warns once when a GUI-bound capability is read off the thread that assembled the context, and raises in a development session. Fix the body, not the guard.
  • Cancellation, cooperatively. Poll ctx.cancellation.is_cancelled() in a loop and stop; the request is a signal, never a killed thread.
  • A host without a worker backend (a script that only wants to call the function, a test double) should call the function directly or run the code without JobService; submitting is refused rather than silently degrading.
  • Naming, instead of passing functions. 0.8 submitted the function itself: ctx.jobs.bind(add).delay(left=1, right=2), or ctx.jobs.delay(add, ...) as the one-call spelling. 0.9 has the plugin declare what it exposes (app.jobs = {"add": add}) and the action name it: ctx.jobs.add.delay(left=1, right=2). bind still exists on qgis_sdk.jobs.JobService, but it is the host’s and the test’s route, not the author’s.
  • The plugin owner, spelled out. ctx.jobs.parent is now ctx.jobs.plugin: view code that must outlive the window submits through ctx.jobs.plugin.<name>.delay(...) and the job is the plugin’s, not the view’s. Everything else a view submits closes with the window.
  • Ordering, if you needed it. ctx.jobs.after(handle) replaces any hand-rolled “start the second job from the first one’s success callback”: the dependency is declared where the job is submitted, and a failed prerequisite fails the dependent job instead of silently skipping it.
  • The task bar. On Desktop each job is now one task there. A job whose title reads well in a user interface is worth naming in @job(title=...), and a long job should report ctx.progress so its bar moves.