Building on the core
An engine, on this page, is a library with its own state, queue or scheduling
rules. Build yours on the core: extend JobBase<T> for the job and
JobContextBase for the context its body gets. The base classes keep the
lifetime the other pages describe: the start, the children, the
cancellation, the cleanup and the
outcome. Your subclasses add the engine’s behavior through their
protected API. The third type, JobStatus, says where a job is in its life:
created, running or finished.
The three live in package:async_job/engine.dart, not in the main import: an
app that only runs jobs never needs them. An engine imports that file in place
of async_job.dart: engine.dart exports everything async_job.dart does.
The protocol is outside the semantic versioning of the README: the three types
and their protected members can change in a minor release of async_job, and
the Engine protocol section of its changelog says how. A change to a type of
the main import that an engine has to answer, such as a member of JobContext
its context overrides, is listed there too. Hold async_job to one minor
version, such as >=1.0.0 <1.1.0, and release the engine with each minor
release of the core.
This page builds the job of an engine:
- A job of your own: the job, its context and the protected API the engine reaches them through.
- Deferred start: a plain
Jobthat starts when the caller chooses, with no engine. - Finishing a job by hand: what
finishskips that a cancellation does.
An engine of your own builds the rest of the engine on that job: a queue that runs the jobs one at a time and a rule that stops them at a sign-out.
A job of your own
Section titled “A job of your own”A job of your own creates the context its body gets and runs the body with it. Two private wrappers let the rest of the engine start the job and wait for it. Nothing on this page calls them: the queue of A queue of your own on the engine page does.
import 'package:async_job/engine.dart';
final class MyJob<T> extends JobBase<T> implements ChildJob<T> { // Dropped once the job is over, as the core drops its own: the body // holds everything it captured, and a job is held to read its outcome // long after. Future<T> Function(MyContext ctx)? _body;
MyJob( Future<T> Function(MyContext ctx) body, { super.key, super.observer, super.cancellable, }) : _body = body;
@override JobContextBase createContext() => MyContext(this);
@override Future<T> execute(covariant MyContext ctx) => _body!(ctx);
@override void finished() => _body = null;
// `start` and `untilFinished` are protected: the rest of the engine calls // them through these wrappers, private to its library. The queue starts // a job later, from a zone of its own, so `_launch` starts it in the zone // it was created in. void _launch() => creationZone.run(start);
Future<void> get _untilFinished => untilFinished;}
final class MyContext extends JobContextBase { MyContext(super.owner);}A @protected member is for the subclass alone, and an engine reaches a job
from the side, from its queue. The wrappers are private, so the engine lives in
the Dart library of MyJob, and nobody else who holds the job can start it
behind the engine’s back. untilFinished waits for the job without
observing its outcome, so a failure
nobody looked at is still answered for;
waiting for done would count as looking.
A body runs a job of your engine as its child with ctx.run(job), and
ctx.run takes a ChildJob: that is why MyJob declares
implements ChildJob<T>. A job a body may not run as a child leaves it out.
ChildJob adds no member, start included: a parent starts the job through
ctx.run, your engine through its queue. DeferredJob is a ChildJob with a
public start(), for a job that whoever holds it may start.
Code that holds only the context makes a child with ctx.child, and in
JobContextBase that is a Job.deferred, a child of the core. An engine whose
children are jobs of its own, or whose context keeps rules of its own,
overrides child in its context with @useResult and returns an unstarted job
of its own there. ctx.each takes its child from child as well. child
starts nothing and checks nothing: ctx.run, ctx.start and ctx.runAll do
the refusing.
start runs the body in the zone it is called from, and a queue calls it
later, from wherever the queue runs. creationZone.run(start) starts the job
in the zone it was created in: the body gets the timers, the zone values and
the error handler of the code that made the job. A future of that zone that
fails reaches the body there. Started under another runZonedGuarded, the body
would never hear of the failure and would wait for good, and the queue with it.
For a job made inside the work of ctx.unattended, creationZone is the zone
that work was started from. start called inside that work moves to the same
zone by itself.
Override started() and finished() to follow the lifecycle:
started()runs as the body is about to start, with the job already running.finished()runs once the job has its outcome. It runs for a job dropped before its body ran too, with nostarted()before it.- An error
started()orfinished()throws goes toonErrorand then to the zone, unless the observer catches it inonUnanswered. That is the way of an error no outcome carries, as Where each error goes on the error rules page describes. The job goes on as it would have, and what is left half-done is your own bookkeeping, so keep both hooks short and unconditional.
MyJob drops its body in finished(): kept, it would hold everything it
captured for as long as anyone holds the job.
For the engine to answer itself for an error that would otherwise go to the
zone, do what solo does. Put an observer of your own on every job, mix
JobAnswerer into it and override its onUnanswered. Where that answer
reaches and where it does not:
- A child adopted without an observer takes its parent’s, so the engine’s answer reaches the children as well.
- A child that came with an observer of its own answers through its own.
- A continuation made by
thenis no job of your engine. It belongs to whoever calledthen, with the observer passed tothenand no other. A failure the continuation takes over from your job reaches that observer as the continuation’s own. - What goes wrong in the continuation’s own code is for the caller of
thento answer, in the caller’s zone, so do not hand the continuation your observer.
When the onUnanswered of the engine’s observer has nobody to hand an error
to, super.onUnanswered(job, error, stackTrace) sends it to the zone the job
was created in and keeps a Cancelled out of the zone. It does so whichever
job the error came from: a child adopted without an observer may be no job of
your engine. reportToZone does the same for an engine whose answer lives
outside the observer. It is protected, so the engine reaches it through a
wrapper, as it reaches start, and only on jobs of its own.
A context method that must not be called from the work of ctx.unattended
calls throwIfUnattended first. That work is the subject of
Work the job does not wait for on
the errors page.
The full protected API is in the reference of JobBase and JobContextBase.
Deferred start
Section titled “Deferred start”A plain Job starts on the next microtask. If the caller needs to choose when
work begins, use Job.deferred(body) instead: it returns a DeferredJob<T>
that waits until something starts it. You can call its public start()
yourself, let a queue start the job, or pass it to a parent with
ctx.run(child), as in Running a child on the
children page.
final job = Job.deferred<void>((ctx) => ctx.abandonable(work));// ... later, or from a queue of your ownjob.start();start() runs the body in the zone it is called from. A queue that calls it
later, from a zone of its own, should keep the zone the job was made in and
start the job there: final zone = Zone.current; next to Job.deferred, then
zone.run(job.start). A job of your own says why.
This recipe is for a Job.deferred, not for a job of your engine. The queue
cannot reach the creationZone of a Job.deferred, since that member is
protected; a job of your engine starts with creationZone.run(start), the way
_launch does.
Finishing a job by hand
Section titled “Finishing a job by hand”finish ends a job with the outcome handed in. An engine may reach for it to
drop a running job, but it is no way to do that: it skips what a cancellation
does.
| A cancellation | finish |
|---|---|
| Waits for the children and stops them | waits for none and stops none |
Runs the onCancel callbacks |
runs none |
Unwinds the cleanup stack, dispose and discard included |
unwinds nothing: what the body opened stays open |
Since finish unwinds nothing, an engine that finishes a running job by hand
releases what the job holds first. Only the debug channel, switched on with
Job.debug = print;, says how many cleanups finish left behind.
A job that has accepted a cancellation ends Cancelled whatever finish is
handed. A Done or a Failed gives way to the cancellation the job accepted,
and a value handed in goes nowhere. Only another Cancelled handed in stands.
On any job finished by hand, a failure of the body, thrown before finish or
after, is in no outcome unless it is the very one handed in. onError hears
it, and it goes on to onUnanswered or the zone, as the failure of the body of
a cancelled job does. ignoreFailure() on the job stops it at onError.
Two narrow cases are in the dartdoc of JobBase.finish:
- A
Donehanded to a branch ofrunAllafter its group heard the body fail. - One
Cancelledhanded to two jobs, throughfinishorcancelWith. This is whyonSignOutin Not waiting for the checkpoint on the engine page builds aCancelledat every call.
A Cancelled that a body throws, or a rule from check(), may be shared, a
const one included: the job ends with one the core builds from it.