Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements Data.Task #50

Merged
merged 31 commits into from
Oct 16, 2016
Merged

Implements Data.Task #50

merged 31 commits into from
Oct 16, 2016

Conversation

robotlolita
Copy link
Member

The new implementation of Data.Task, which aims to be more lawful, make automatic resource management better, and allows one to take advantage of async/await with promises.

Tasks are now divided in many components:

  • Task — represents any asynchronous action, together with how resources are allocated and collected;
  • TaskExecution — represents the execution of a Task, allowing that execution to be cancelled, or its value to be depended on (which means you get memoisation for free here);
  • Future — represents the eventual result of running a task. This might be a successful resolution, a rejection, or a cancellation;
  • Deferred — a low-level mechanism for asynchronously providing values to futures. These are usually used under-the-hood, but they're exposed to user code as well.

Creating tasks

In order to construct asynchronous actions, users would use Task, as before. The new task function used to construct Tasks has a different signature however:

const { task } = require('folktale/data/task');
const delay = (ms) => task(
  resolver => {
    return setTimeout(resolver.resolve, ms);
  },
  {
    onCancelled(timer) { console.log(`${ms} timer was cancelled.`); },
    cleanup(timer) { clearTimeout(timer); }
  }
);

So task now takes a computation, which is a one-argument function taking a resolver object, which provides the methods resolver.resolve(value), resolver.reject(reason), and resolver.cancel(). The computation returns an object that tracks which resources it allocated in the process, and which must be collected when the task finishes executing or is cancelled.

The second argument to task is an object which may provide any of the two optional methods (Task falls back to a noop if they aren't provided), both are invoked with the resources allocated by the computation, and both are invoked asynchronously.

The onCancelled method is invoked if the task is cancelled, and is always invoked before cleanup. Because attempting to resolve a Task that has been cancelled is an error, Task authors must take special care to handle cancellations in their code.

The cleanup method is invoked after the task is settled (either by cancelling, rejecting, or resolving), and should collect any resources allocated by the task. In the example above, the only resource allocated is a timer, so the timer gets collected.

Running tasks

Tasks are now all ran with the new run() method. This returns a TaskExecution object, which has methods to deal with the execution of that task. In order to get the final value of a Task, one has to either get a Promise or a Future for the execution. Promises allow one to use async/await, but cancellations are handled as rejections, and objects with a .then method have to be wrapped in something so they don't get assimilated by the promise:

(async () => {
  console.log('Hello');
  const value = await delay(1000).run().promise();
  console.log('World');
})();

Futures are safer, and also allow one to work with them as monads, but they don't get a special syntax:

console.log('Hello');
delay(1000).run().future().chain(value => {
  console.log('World');
});

Finally, executions can be cancelled by calling .cancel(). Cancellation is always "safe", meaning it won't throw an exception if the execution has already settled.

console.log('Hello');
delay(1000).run().cancel();
console.log('World'); // logs right away

Combining tasks

For now, Tasks can be combined with the or and and methods. The first selects the first task to resolve, while the latter waits both tasks:

const a = delay(100).map(_ => 1);
const b = delay(500).map(_ => 10);

const x = await a.or(b).run().promise();
// => 1, after 100ms

const xs = await a.and(b).run().promise();
// => [1, 10], after 500ms

This closes #17

This allows people to use Futures as a data structure directly, instead
of Promises. The usage of Futures is pretty low level, however, since
one has to acquire a Deferred first, and then settle that deferred in
some resolution state.
This gives us async/await
matchWith has the expectation that the pattern matching is performed in the current snapshot of the data structure, and returns whatever the provided branch function does. Futures can't do that since they might not be resolved yet, and matching on pending snapshots leads to awkwardly easy race conditions.
@robotlolita robotlolita added this to the v2.0.0 milestone Oct 16, 2016
@robotlolita robotlolita merged commit 7cf6f60 into master Oct 16, 2016
@robotlolita robotlolita deleted the patch/data.task branch October 16, 2016 00:56
@safareli
Copy link

Why are we using chain in ap? I think it's much practical/usefull to execute tasks concurrently (fantasy-land/#179)

@robotlolita
Copy link
Member Author

Mmh, because a.ap(b) is observably different from b.chain(x => f(a)) in the presence of side-effects, since one runs those effects sequentially, and one runs them in parallel, so those two operations are not entirely interchangeable (but people expect them to be!), and this can lead to subtle, hard to debug problems.

The Semigroup instance was removed for similar reasons: it's not deterministic. Both of those operations were moved to different methods, so they're still there, their behaviour when composed with arbitrary expressions is just less confusing.

For concurrent executions, where you had:

const a = delay(100).map(x => y => f(x, y));
const b = delay(120);
const value = await a.ap(b).run().promise();

You'd now have:

const a = delay(100);
const b = delay(120);
const value = await a.and(b).map(([x, y]) => f(x, y)).run().promise();

For Semigroup, where you had:

const a = Task.empty();
const b = delay(100);
const value = await a.concat(b).run().promise();

You'd now have:

const a = Task.task(resolver => {});
const b = delay(100);
const value = await a.or(b).run().promise();

@robotlolita
Copy link
Member Author

We can provide a ParallelTask object that always combines tasks in Parallel, though, so it's always an Applicative, rather than a monad, that way you'd be able to write:

const a = delay(100).map(x => y => f(x, y));
const b = delay(120);
const value = await Parallel(a).ap(Parallel(b)).run().promise();

@abuseofnotation
Copy link
Contributor

Awesome work, robotlolita!

I particularly like the idea to have an API, that is somewhat similar to the one of the other ADT's (willMatchWith). In that line of thought, do you think it will be appropriate to include conversions to Either and Maybe in a somewhat similar manner (so for example Task.toMaybe() returns a successful task, holding a Maybe value).

Also nice that Tasks can be converted to promises. I imagine that I can write one module of my app in a pure setting using Tasks, and convert them to promises from another, impure module.

One question: what is the point of the taskExecution object - isn't it simpler for run to directly return a Future? Does it have to do with the cancel method not being exposed to it?

@robotlolita
Copy link
Member Author

@boris-marinov hm, you'd probably want to have a .toFutureMaybe() (probably not with this name) and similar that returns a successful Future holding a Maybe value.

As for the TaskExecution, its main purpose is to control who can and can't cancel the execution of a Task. Cancellation propagates, and any tiny innocent looking .cancel() call may affect quite a lot of stuff, so it makes sense to control which places have that power, to avoid errors that end up being pretty hard to debug.

The idea is that you'd mostly pass Futures (or Promises) around, so people can use the values of the tasks that produced them, and mostly use TaskExecution internally, in the process that executed the task. But in some cases you might want to extend that power to some other function, so you'd give it the TaskExecution object. This way you're guaranteed that most code won't be able to affect the Task process, only wait for its value, and it's much simpler to reason about than things like Cancellable Promises.

@safareli
Copy link

I think we should at least have ConcurrentTasktype which is not a monad (is only Applicative), plus functions to convert one to another ConcurrentTask.fromTask and Task.fromConcurrentTask


About and: it could be useful but with multiple .and(..) it looks sortof wired:

a.and(b).and(c).and(d).map(([[[a, b], c], d]) => ...)

And as we are using array what would be it's type?

and :: Task e a ~> Task e b -> Task e (Array ?)

@robotlolita
Copy link
Member Author

@safareli yeah, there'll be a Task.concurrently(taskArray) which you can use for that case: Task.concurrently([a, b, c, d]).map(([a, b, c, d]) => ...). Similarly a Task.race and Task.sequentially.

As for the types, even though it and uses an array, the type is described as a tuple, so you get:

and :: forall a, b, c: (Task a b).(Task a c) => Task a (b, c)

Where (b, c) is a tuple type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Implement Data.Task
3 participants