Why a job
What a job adds to a plain Future, a flag and CancelableOperation, and a
fuller example where it does all of that at once.
A plain Future has no cancellation. A flag can ask work to stop, but the work
has to ask the flag after every await, and a finally closes what it has
opened:
var cancelled = false;
Future<void> load() async { final db = await Database.open(); try { if (cancelled) return; final rows = await db.readAll(); if (cancelled) return; use(rows); } finally { await db.close(); }}Every new await needs a check of its own, and a check left out lets the work
go on after the cancellation, with nothing pointing at the place. And load
returns the same way whether it was cancelled or ran to its end, so its caller
cannot tell the two apart.
Dart can already stop the waiting. Future.timeout stops it after a time
limit, and CancelableOperation from package:async stops delivering its
value on cancel(). Neither holds on to what the work opens: a database that
opens once the waiting has stopped stays open. CancelableOperation catches it
only in an onCancel written for that one wait:
final open = Database.open();final operation = CancelableOperation.fromFuture( open, // cancel() stops the waiting; this closes the database once it opens. onCancel: () async => (await open).close(),);Once the database has been handed over, cancel() does nothing: the read that
follows runs on, and the work is back to a flag and a finally. A cancelled
operation never completes its value either, so whoever awaits it waits for
good.
A job adds three things:
- A checkpoint at every call it awaits through the context.
ctx.joinchecks before the call and before the value comes back, and the job endsCancelled, which its caller can tell fromDone. - Cleanup registered where the resource opens.
disposecloses the database however the job ends, and a database that opens after the cancellation is closed too, with no code written for the cancellation. - Children it waits for. A job ends after its children and its cleanup, and a cancellation it accepts passes to its children.
async_job does not provide state management, a task queue or scheduling
rules:
solo
adds them. Neither package provides retries or a task pool. A deadline is built
in: Job(body, timeout: limit) cancels the job once the limit has run out, and
it ends Cancelled(timeout); see A deadline on the time
page.
A fuller example
Section titled “A fuller example”A job opens a database, migrates it, marks it ready and returns the open database to its caller. If the job fails or is cancelled, it closes the database instead:
final job = Job<Database>((ctx) async { final database = await ctx.join( Database.open, discard: (database) => database.close(), );
final stop = CancelToken(); ctx.onCancel(stop.cancel);
await ctx.join(() => database.migrate(stop)); await ctx.uncancellable(() async { await database.writeVersion(); await ctx.run(database.readyFlag()); });
return database;});
// Somebody changed their mind while the database was opening.await Future<void>.delayed(const Duration(milliseconds: 10));await job.cancel();
final outcome = await job.done; // Cancelled(manual)ctx.join(Database.open)waits for the open even after a cancellation, then throwsCancelled, or the open’s own error if it failed. An error that comes after the cancellation has no outcome to carry it; see Where errors go.discard: (database) => database.close()closes the database when the job endsFailedorCancelled; withDone(database)it stays open for the caller. Seedisposefor what the job keeps.ctx.onCancel(stop.cancel)hands the cancellation to the client’s own token the moment the job accepts it.CancelTokenbelongs to the database client, not to this package.ctx.join(() => database.migrate(stop))waits for the migration, and on a cancellation for it to stop. A client that stops by throwing sends that error to the job’s observer and then to the answer, the zone by default, on every cancellation, unless the app names it once inJob.isCancellationError; see Errors of a cancellation.ctx.uncancellable(() async { ... })keeps the version and its ready flag together: the child starts and a cancellation waits for the step, yet the job still endsCancelledanddiscardcloses the database. See Holding the cancellation back.ctx.run(database.readyFlag())starts, as a child, the job that writes the ready flag;readyFlag()makes it withJob.deferred, which leaves its start toctx.run. See Children.
The complete runnable example, with a fake Database and CancelToken, is in
example/example.dart. It runs the job four times: once to the end, and
cancelled while the database opens, while it migrates and while the version is
written. It prints:
Nobody changes their mind:database openedmigratedversion writtenready flag writtenDone(database)database closed
Somebody changes their mind while the database opens:database openeddatabase closedCancelled(manual)
While it migrates, the token stops the migration:database openedmigration stoppeddatabase closedCancelled(manual)
While the version is written, the step ends whole:database openedmigratedversion writtenready flag writtendatabase closedCancelled(manual)