solo 0.4.0
solo: ^0.4.0 copied to clipboard
One job at a time: sequential jobs with exclusive state ownership, declarative rules and cooperative cancellation. Pure Dart.
solo #
solo manages state and asynchronous work in Dart. A controller holds the
current state and processes jobs one at a time, hence the name. Each job can
declare which states allow it to start and continue, and callers can await its
result or request cancellation.
Use it for screens, sessions and devices where operations share state and need
an explicit order. The package has no Flutter dependency.
flutter_solo adds the Flutter side:
SoloListenable, a mixin that makes a controller a ValueListenable, the
widgets SoloBuilder and SoloSelector, and listen, which hands back a
subscription to cancel instead of asking you to keep the callback.
Install #
dart pub add solo
For Flutter, install flutter_solo. It re-exports solo:
flutter pub add flutter_solo
solo re-exports async_job, which
provides jobs, cancellation and resource cleanup. One import,
package:solo/solo.dart, gives access to both APIs. You do not need to learn
the underlying package before using the examples below.
Quick start #
A controller exposes methods for application operations. Here, load() loads a
profile name and reports progress through four immutable states:
import 'package:solo/solo.dart';
sealed class ProfileState {
const ProfileState();
}
final class Initial extends ProfileState {
const Initial();
}
final class Loading extends ProfileState {
const Loading();
}
final class Loaded extends ProfileState {
final String name;
const Loaded(this.name);
}
final class Failure extends ProfileState {
final Object error;
const Failure(this.error);
}
The example API returns a name after a short delay. Replace it with your application's API client:
class ProfileApi {
Future<String> fetchName() => Future.delayed(
const Duration(milliseconds: 20),
() => 'Ada Lovelace',
);
}
final class ProfileController extends Solo<ProfileState> {
final ProfileApi api;
ProfileController(this.api) : super(const Initial());
Job<String> load() => enqueue<ProfileState, String>(
key: 'load',
policy: Policy.droppable,
ifFailed: (state, error, stackTrace) => Failure(error),
ifCancelled: (state, cancelled) => const Initial(),
(ctx) async {
ctx.emit(const Loading());
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
},
);
}
enqueue<ProfileState, String> creates and queues a job. ProfileState is the
job's working type W: the states its body works with, which may be narrower
than the controller's state type S. String is the job's result type. Both
can be left to inference: W is then the whole of S, and the result type is
what the body returns. The body receives a context, ctx, which provides state
updates and cancellation-aware waiting:
ctx.emitupdates the controller's state.ctx.abandonablewaits for the API response, or throwsCancelledif the job accepts cancellation while waiting.
Two optional parameters of enqueue give the state to publish when the job
does not succeed:
ifFailedreturns the state to publish if the job fails.ifCancelledreturns the state to publish if a started job is cancelled.
The state handlers run after the body and its cleanup. In this example, the
state becomes Loaded on success, Failure on error, or Initial on
cancellation. Without a handler, the state stays where the body left it: an
error or a cancellation here would leave Loading. A handler that hands back
the state it was given, the same object, publishes nothing. The job's outcome
stays Failed or Cancelled even when a handler updates the state.
Policy.droppable and key: 'load' make repeated calls share the queued or
running load. A second call returns the existing job. Once that job finishes,
another call can start a new load.
The caller uses the returned Job<String> to await this particular load:
Future<void> main() async {
final profile = ProfileController(ProfileApi());
void onChange() => print(profile.currentState);
profile.addListener(onChange);
try {
final job = profile.load();
profile.load(); // the existing job is returned
print(await job.value);
if (profile.currentState case Loaded(:final name)) {
print(name);
}
} finally {
profile.removeListener(onChange);
await profile.close();
}
}
profile.currentState is available synchronously, and addListener calls back
synchronously too, inside the change itself. job.value returns the loaded
name, or throws the job's error or Cancelled. The finally block removes the
listener and closes the controller even if loading fails.
A Failed that nobody observes goes on to the zone the job was created in,
even when ifFailed has published a state, unless Solo.unansweredHandler
answers for it. Reading the job's done or value observes the outcome; a
caller that needs no result says so with ignoreFailure(), as in
profile.load().ignoreFailure(). See
Handled and unhandled failures
on the errors page.
For a broadcast stream as well, delivered on the next microtask, mix in
SoloStream:
final class ProfileController extends Solo<ProfileState> with SoloStream {
// ...same as above...
}
See Observing state on the state page for the
full delivery picture, including SoloListenable from flutter_solo.
Cancellation uses the same job object. This separate example requests cancellation immediately, so the job is still in the queue:
Future<void> cancelLoading() async {
final profile = ProfileController(ProfileApi());
final job = profile.load();
await job.cancel();
print(job.outcome); // Cancelled(manual)
await profile.close();
}
cancel() waits for the job to finish, including cleanup if it started. A job
cancelled before its body starts does not call ifCancelled. Jobs can start
child jobs as part of their work; the starting job is their parent and waits
for them before finishing. The next queued job starts only after the previous
job finishes its body, children, cleanup and state handler.
The guides below explain results, queue policies and cancellation in more detail. In particular, cancellation of a job does not automatically stop an API request that has already been sent.
The dozen calls #
Everything reached for day to day, in one controller. PlayerState with its
states, Track, Download, Api and Device belong to the application; the
rest is the package.
enum _Op { play }
final class Player extends Solo<PlayerState> {
final Api api;
final Device device;
Player(this.api, this.device) : super(const Idle()) {
// A fact from outside the queue: published at once, and the rule of
// the running job is asked about it.
device.onDisconnect = () => externalSetState(const Disconnected());
}
/// A job on the controller's queue. Root jobs run one at a time, and
/// this one ends when the track starts: the device plays it on its own.
Job<Track> play(String id) => enqueue<PlayerState, Track>(
// Any object is a key. This one keys the operation, not a track.
key: _Op.play,
// A new play cancels the one running under the same key.
policy: Policy.restart,
// A rule: asked before the start, and on every change of state
// while the body runs, except one the body makes with `ctx.emit`.
keepWhile: (state) => state is! Disconnected,
// The state to publish when the job fails or is cancelled. If the
// job is cancelled by the rule, nothing is published and
// `Disconnected` stays. A play replaced by a newer one hands back
// the state it was given, which publishes nothing.
ifFailed: (state, error, stackTrace) => const Idle(),
ifCancelled: (state, cancelled) =>
cancelled.reason is ReplacedCancelReason ? state : const Idle(),
(ctx) async {
// The body's way to change state. There is no setter outside.
ctx.emit(const Loading());
// Waited out whatever happens: the track playing now stops
// before another one loads.
await ctx.join(device.stop);
// Cancellation ends this wait at once; the request may go on.
final track = await ctx.abandonable(() => api.fetch(id));
// Waited out as well. `dispose` closes the download when the
// job ends, or as soon as the call returns if the job was
// cancelled meanwhile.
final download = await ctx.join(
() => api.download(track),
dispose: (download) => download.close(),
);
// A child job: the device reads the download in, and its stream
// ends when the track starts. The body awaits its `value` and
// goes on once the track plays.
await ctx.each(device.load(download), (childCtx, percent) {
childCtx.emit(Buffering(track, percent));
}).value;
ctx.emit(Playing(track));
return track;
},
);
/// Events that share one queued job: only the last volume matters.
late final _volume = accumulate<PlayerState, double, void>(
(ctx, value) => ctx.join(() => device.setVolume(value)),
merge: (previous, incoming) => incoming,
timing: AccumulationTiming.debounce(const Duration(milliseconds: 50)),
);
Job<void> setVolume(double value) => _volume.add(value);
// The callback is removed before the state is final: after that, a call
// to `externalSetState` would throw a `StateError`.
@override
void onClose() => device.onDisconnect = null;
}
At the call site a job is a handle: await its outcome, or cancel it as the quick start does.
final player = Player(api, device);
final job = player.play('t-1');
switch (await job.done) {
case Done(:final value):
print('playing $value');
case Failed(:final error):
print('failed: $error');
case Cancelled(:final reason):
print('cancelled: $reason');
}
player.setVolume(0.4);
// Stop taking new work now, run what is already queued, then close.
await player.close(mode: SoloCloseMode.drain);
setVolume hands back a job too, and the call site drops it on purpose:
nothing here can answer for a volume the device refuses. Its failure goes on to
the zone, as Quick start says, since no unansweredHandler is
set, and ends this plain Dart program. A caller that can answer for it reads
the job's done, as the call site does for play.
Why currentState and not state #
Say the name in Quick start is fetched for the user of the
session. The session is another controller, and ProfileController keeps it in
the field session. The profile API now takes the user: fetchName(user). A
method that reloads a loaded profile:
Job<String> reload() => enqueue<Loaded, String>(
(ctx) async {
// Another controller's snapshot: a plain read, and the name says
// as much.
final user = session.currentState.user;
final name = await ctx.abandonable(() => api.fetchName(user));
// This job's own state: a checkpoint that throws `Cancelled` if
// the job has been cancelled or the state is no longer `Loaded`.
if (ctx.state.name != name) {
ctx.emit(Loaded(name));
}
return name;
},
);
A job body is a closure inside a method of the controller, so every member of
the controller is in scope there. ctx.state is a checkpoint. Before it reads,
it checks that the job has not been cancelled and that its rules still hold:
the working type, here Loaded, and the keepWhile rule of a job that has
one, like play above. A plain read named state would look exactly like
ctx.state, and a body that typed it out of habit would read past a
cancellation it was supposed to honour: no Cancelled, no check of the rules,
and the whole of S instead of W, with no name to read. So the
controller's own read is named currentState: a bare state in a body does
not compile, and nobody types currentState by habit where a checkpoint is
meant.
Outside a job currentState is the read to use. Inside one it stays right for
a different controller: session.currentState above is somebody else's
snapshot, and this job's rules have nothing to say about it.
Guides #
Also on the documentation site, with search.
The first three pages are for every controller, in this order: jobs, state, cancellation. The next five come when a controller needs what they cover: resources, children, streams, accumulation and its rules. Diagnostics, errors and testing are for a controller about to ship, the common mistakes are a list to check against, and the reference names every member with the section that explains it. Flutter is for a controller behind a screen, the camera example puts the pages together in one controller, and solo and bloc side by side is for a reader who comes from bloc.
| Page | What it covers |
|---|---|
| Jobs and the queue | Submitting work, outcomes, keys, queue policies |
| State | Emitting, rules, observing, external changes, state after failure |
| Cancellation | abandonable, join, pause, uncancellable, timeout, stopping the operation, closing |
| Resources and cleanup | onDispose, dispose and discard, transfer, ordering |
| Children and chains | Child jobs, chains, steps in a row |
| Streams | ctx.each in a controller, following another controller |
| Event accumulation | collect, accumulate, debounce and throttle in four recipes |
| Accumulation rules | When a group is ready, AccumulationPolicy, one job for a group and shared(), merge, close() |
| Diagnostics | SoloObserver, pending, logs, a slow cancellation |
| Errors | onError, unansweredHandler, catching and retrying, ctx.unattended |
| Testing | Awaiting outcomes, fake time, timeouts |
| Common mistakes | Seven mistakes that often pass a first test, and the right way for each |
| Reference | Every member of Solo, its context, jobs, queue and accumulators, with the section that explains it |
| Flutter | The flutter_solo package: SoloListenable, owning a controller, rebuilding a screen |
| Camera example | One controller with rules, cleanup and a device |
| solo and bloc, side by side | Eleven scenarios in both packages |
Recipes #
Situations that come up, and what to reach for. Each one is explained in the section linked beside it.
| Situation | Reach for | Where |
|---|---|---|
| Only the last of a burst of commands matters | accumulate with a merge that keeps the incoming value |
Commands where only the last one counts |
| Typing into a search box | accumulate with AccumulationTiming.debounce |
A search that fires on every keystroke |
| A later request must not be dropped as a duplicate of an earlier one | a record key, (_Op.load, id) |
A key for each request |
| Queued work is made pointless by what just arrived | queue.removeWhere before submitting, or cancelAll() if it may be running |
When they are separate jobs after all |
| A last batch has to go out before the screen goes away | close(mode: SoloCloseMode.drain) |
Cancelling and closing a controller |
close() does not come back |
controller.pending |
What is holding the controller |
| A journal needs to say which operation changed the state | SoloTransition in onChange |
Watching every controller |
| A cancelled job must wait for the call it started | ctx.join |
Each wait in its place |
| A call fails now and then and should be tried again | a loop in the body with ctx.pause between attempts |
Retrying a failed call |
| A step of several calls must not be interrupted halfway | ctx.uncancellable |
Protecting a step or a whole job |
| A resource opened by a call nobody waited for still has to close | dispose or discard on ctx.abandonable and ctx.join |
Taking a resource from a call |
| A widget rebuilds for state it does not use | SoloSelector from flutter_solo |
Selecting one value |
| The queue has to stand still for a while | a job waiting on a Completer at the head of it |
Pausing the queue |
| A stream event arrives a microtask late, and that is too late | addListener, which calls back inside the change |
Observing state |
Coming from bloc #
Callers invoke controller methods and receive a job for each operation. Queue policy is selected per call, and all root jobs share one queue. solo and bloc, side by side holds the API correspondences and compares eleven application scenarios with implementations in both packages.
The package does not include retry policies, worker pools, dependency
injection, persistence or state equality filtering. If work needs cancellation
and cleanup but no state rules or controller queue,
async_job can be used directly. For a
value with no asynchronous lifecycle, a ValueNotifier may suffice.