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

Convert fork into an operation #57

Merged
merged 9 commits into from
Jan 29, 2020
Merged

Conversation

cowboyd
Copy link
Member

@cowboyd cowboyd commented Dec 15, 2019

Motivation

Originally, this change came out of problems with sequencing a fork. Specifically, if there was an error inside of fork, and so it tried to throw() inside of its parent, then the error was hidden by the fact that [its parent generator was already running]. This was symptomatic of a deeper problem of the generator syntax being too tightly coupled to the underlying concept of the execution of a sequence of steps, and also of the concept of synchronous and asynchronous execution being too coupled.

As a purely practical consideration, we wondered "what if the parent generator was yielded at the time we tried to create the fork?" That way, the fork would be either be at its first yield point, or have
failed when we tried to resume the parent, but this turned out to be a profoundly positive re-orientation of the effection architecture to separate out the concepts of context and executions from the generator synax entirely which enables all sorts of nice things.

Fundamentally, this change makes effection a function-oriented effects library rather than a generator-oriented effects library. That is, you don't need generators to declare a tree of processes, it's just nicer to do it that way. For example:

spawn(function*() {
  yield fork(function*() {
    return 5;
  });
});

can now be re-written using the function-equivalent:

spawn(({ spawn }) => {
  spawn(fork(({ resume }) => resume(5)));
})

The nice effect that this has is that the generator syntax is implemented as just a plugin to the basic runtime, which means that in addition to solving the problem at hand, it also allows you to fork a control function directly, something that had always felt like it should work, but annoyingly didn't.

This feels like the right direction to take it, because it is the solution to several seemingly unrelated problems, and it even feels as though it could provide a pathway to an asynchronous halt operation by making not just fork but also halt an operation.

Finally, because structural concurrency is implemented ultimately as operations that do nothing more than manipulate the execution context, other structured effects (state, messaging, etc...) can be implemented in user space. Using this library, I was able to implement messaging operations based on the send-and-receive branch completely in user-space, and in which both send() and receive() were operations.

You can now seemlessly move between function syntax and generator syntax since they are all just operations that run inside the bounds of an execution context.

For example, here is a "hybrid" version of the previous example using a mixture of function syntax and generator syntax.

spawn(({ spawn }) => {
  spawn(fork(function*() {
    return 5;
  });
})

From the perspective of the JavaScript runtime, execution is still completely synchronous internally, and the code is much, much simpler.

Details

What was formerly known as a Fork and was coupled to a generator, has now become two objects, an ExecutionContext and a ControlFuction. Where the ExecutionContext tracks the state of the frame of execution and the control function calls resume, fail, or halt at the appropriate time. Every operation is eventually resolved to a control function.

The key new primitive that is introduced is the spawn function, which is passed into the control function with each execution context:

spawn(({ spawn }) => {
  call(function*() {
    yield timeout(100);
    return 5;
  });
});

This provisions a new execution context as a child of the current context, looks up the operation passsed in order to find its control function, and then enters that child context. Essentially, this is an implementation of a call stack, except instead of it being a stack, it's a tree.

Most effection code however, will never need to know about call, since most operations do not actually create any children. But for those that do such as the generator operation and the fork operation, it allows them to be treated uniformly.

By default, child contexts created by spawn are detached. This means that the only constraints is that their parents will not be considered finished until they themselves are finished, error handling, halt handling, and resume handling is up to the operation that calls spawn.

let child = spawn(operation);
child.ensure(() => {
  if (child.isErrored) {
    console.error(child.result);
  }
});

As mentioned previously, the default behavior is to cause the parent context to also fail.

It's worth noting that with this power, it is possible to implement completely in user-space try/catch/finally as operations, for example:

function*() {
  yield tcf({
    try: () => do something,
    catch: (e) => console.log('error = ', e),
    finally: () => teardown()
  })
}

Open Questions


  • I'm not sure if the interface for the controls is good. Right
    now, the full signature of a control function is ({ resume, fail, ensure, call, context }), but part of be feels like it should be
    more like a promise: ({ resolve, reject, ensure, call, context }). Although maybe even using pseudo reserved words would be better
    to re-enforce the concepts like ({ $continue, $throw, $call, $finally }).
  • compact context on fork? Currently, when you fork, the child operation is appended directly to the parent:
  - top
     - child (forked)

However, with this change, the fork will have a place in the call tree:

- top
  - fork
    - child

Is this what we want? We can get rid of it using the quasi-monadic trick that if a control function returns another control function, then the second control function is run inside the enclosing context. so that it looks the way it did before.

Update, by spawning in the parent, fork nicely avoids this suboptimal configuration

resolves #26, #33
closes #52

@cowboyd cowboyd force-pushed the fork-join-primitive-operations branch from 9d98bf3 to bf7944f Compare December 15, 2019 14:09
@cowboyd cowboyd force-pushed the fork-join-primitive-operations branch 3 times, most recently from 374c963 to 95d78ca Compare January 15, 2020 20:07
src/context.js Show resolved Hide resolved
@cowboyd cowboyd requested a review from jnicklas January 15, 2020 20:13
@cowboyd cowboyd force-pushed the fork-join-primitive-operations branch from 95d78ca to 8f73e34 Compare January 15, 2020 20:17
@cowboyd
Copy link
Member Author

cowboyd commented Jan 15, 2020

For some reason, this line is type checking when it shouldn't https://github.com/thefrontside/effection.js/blob/8f73e348e47e6d1b798a5d66751e431d8d180d26/types/sequence.test.ts#L20-L21 and I'm not certain why.

README.md Outdated Show resolved Hide resolved
src/context.js Outdated Show resolved Hide resolved
@taras
Copy link
Member

taras commented Jan 16, 2020

pretty epic


import { interruptable } from './inerruptable';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐞 "inerruptable"

package.json Outdated
@@ -6,7 +6,7 @@
"author": "Charles Lowell <cowboyd@frontside.io>",
"license": "MIT",
"private": false,
"types": "types/index.d.ts",
"types": "index.d.ts",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this change in master already? Not sure why we are getting a diff here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to rebase and include the fix and I think I mucked it up 😕

src/context.js Outdated
Comment on lines 147 to 153
Tried to call Fork#resume() on a Fork with state '${this.state}' This
should never happen and so is almost assuredly a bug in effection. All of
its users would be in your eternal debt were you to please take the time to
report this issue here:
https://github.com/thefrontside/effection.js/issues/new

Thanks!`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it just me or is this not actually true anymore? resume is part of the public API now, so someone creating a controller could very well call resume twice, which would still be wrong, but not a bug in effection?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is similar to the Promise API, it might be worth mimicking how that API works, where I think resolving after the first time is simply a no-op.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, not true at all.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be a warning?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With promises, this is a no-op without warning, so I think that behaviour is acceptable.

src/context.js Outdated Show resolved Hide resolved
src/context.js Outdated
}
}

call(operation, options = {}) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am somewhat confused by this method name, I think it doesn't describe the operation it performs very well. Maybe something like spawn would be better?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The analogy that I was holding in my mind was normal function evaluation, except instead of a call stack, we have a call tree.

When you invoke a function inside most languages, the rough algorithm is:

1. allocate a stack frame
   1.a set arguments on the new frame
   1.b set reference to parent frame for looking up lexical variables
2. transfer control to new stack frame
3. function body runs
4. transfer control back to parent frame with return value from child frame

I picked call because that's very similar to the semantics of what we're doing here except instead of a call stack, we have a call tree.

allocate a child, passing in a reference to the parent, enter it, and then handle the result.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can sort of see that, but when reading and trying to understand how this fits together, the name really threw me off. I didn't understand what this was for at all, and only by reading the source did I understand that this spawns a new child, so at least the name did not fit with my mental model of what call would usually refer to.

And regarding the algorithm you sketched out, the last step "transfer control back to parent frame with return value from child frame" doesn't really happen when we call call, we get back what is essentially a Future/Promise of a value, not the value itself. Awaiting it is a separate step. And crucially the operation is executed concurrently with other operations.

I think that spawn is a pretty common name for this, but fork might also be an acceptable alternative, though it'd be nice to not overload that term.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand what you're saying, call has a very strong association with synchronous function invocation. In fact, call is already a method on Function and the semantics of that are well known. In effection however, we could be synchronous or we could be asynchronous, and there's really no way to tell which it's going to be. On the other hand (at least for me) spawn and fork have very strong associations with asynchronous execution, but again, we could be synchronous, or we might just be asynchronous. There's no way to tell.

Really, what we have is something akin to scheme's call/cc (call with current continuation) but that would be a silly name to use. Ideally, we can find a term that's neutral with respect to timing, but does capture the idea of doing some abstract operation within a bounded context. A litmus test is that it should read "X this operation using that context" e.g.

  • call this operation using that context
  • spawn this operation using that context
  • etc....

invoke, start, begin, execute, perform, run are some alternatives.

I kinda like perform because it is used in ember-concurrency. It also has an association with other similar efforts at structured effects:

fwiw, I got the call nomenclature from https://github.com/phenax/algebraic-effects#state-effect-counter-example

@jnicklas
Copy link
Collaborator

jnicklas commented Jan 16, 2020

First of all, I'm super, super happy with this. This change makes Effection better in every way. One thing I am wondering if we can push even further towards similarity with the Promise API. I think it's becoming clearer and clearer that an ExecutionContext is really just a fancy promise with some additional stuff (ability to spawn children, halt, etc). Maybe it should also look even more like a promise. So if we have a promise like this:

let promise = new Promise((resolve) => {
  setTimeout(resolve, 2000);
});

Maybe we should be able to create an execution context in a similar way:

let context = new ExecutionContext(({ resolve }) => {
  setTimeout(resolve, 2000);
});

This isn't so different from what we have now, we're basically just combining the constructor and enter. The main difference would be in the returned object. The point of the way the Promise API is designed is to not leak any mutable state outside the constructor. Once constructed, and when passed to some other collaborator, a Promise is completely immutable. This is a very nice property, which I think we would do well to steal.

I am somewhat torn on whether using an object as the argument to this callback function is better or worse.

new ExecutionContext((resolve) => {}) // this?
new ExecutionContext(({ resolve }) => {}) // or this?

On one hand it makes the API much more convenient, especially since we'll have a bunch of functions we need to send in, but it does mean allocating an extra object for this. I think it's probably worth it, since we'll have at least four (I think?) methods which we want to send in, and four positional arguments is pretty awkward.

@jnicklas
Copy link
Collaborator

So just after leaving this comment I realized that it is pretty much nonsense, since we can't expose the constructor as a public API anyway. I'm leaving it here anyway, since I think there is definite value in seeing the similarity between Promise and ExecutionContext, and I think pushing further in this direction can only be a good thing.

@cowboyd
Copy link
Member Author

cowboyd commented Jan 16, 2020

I think there is definite value in seeing the similarity between Promise and ExecutionContext, and I think pushing further in this direction can only be a good thing.

So would you vote in favor of changing the flow control functions to align with the Promise API?

So:

  interface Controls<T> {
    resume(result: T): void;
    fail(error: Error): void;
    ensure(callback: (context: Context<T>) => void): void;
    call<R>(operation: Operation<R>): Context<R>
    context: Context<T>;
  }

becomes

  interface Controls<T> {
    resolve(result: T): void;
    reject(error: Error): void;
    finally(callback: (context: Context<T>) => void): void;
    call<R>(operation: Operation<R>): Context<R>
    context: Context<T>;
  }

It looks like you cannot have a variable named finally, so ({ finally }) => xyz would be an invalid control function.

This is definitely a step in that direction and would re-use the knowledge that people already have.

let good = enter(({ resolve }) => resolve('hello world'));
good.result //=> 'hello world'
good.isCompleted //=> true

let bad = enter(({ reject }) => reject(new Error('not good')));
bad.result //=> Error('not good')
bad.isErrored //=> true

Also, perhaps we could align our states with promises as well:

isCompleted -> isResolved
isErrored -> isRejected
isSettled = isCompleted || isErrored && !isWaiting

Thoughts?

@cowboyd
Copy link
Member Author

cowboyd commented Jan 16, 2020

For some reason, this line is type checking when it shouldn't

https://github.com/thefrontside/effection.js/blob/8f73e348e47e6d1b798a5d66751e431d8d180d26/types/sequence.test.ts#L20-L21

and I'm not certain why.

@taras @jnicklas any insight here would be very much appreciated since I'm completely stuck :)

You can see the error locally when running $yarn test:types. That line should be a type error, but for some reason it isn't.

@taras
Copy link
Member

taras commented Jan 16, 2020

You can see the error locally when running $yarn test:types. That line should be a type error, but for some reason it isn't.

@cowboyd is it only not throwing an error in CI?

@cowboyd
Copy link
Member Author

cowboyd commented Jan 16, 2020

@cowboyd is it only not throwing an error in CI?

No, it throws locally, but I can't figure out why 😖

@taras
Copy link
Member

taras commented Jan 16, 2020

@cowboyd is it only not throwing an error in CI?

No, it throws locally, but I can't figure out why 😖

It's supposed to throw wherever it runs. Is that not happening?

@cowboyd
Copy link
Member Author

cowboyd commented Jan 16, 2020

@cowboyd is it only not throwing an error in CI?

No, it throws locally, but I can't figure out why 😖

It's supposed to throw wherever it runs. Is that not happening?

No, it's not throwing with is weird. The TypeScript type checker is saying that the code is a-ok, but it should be a type error because 5 is not a valid operation. Something is amiss.

Motivation
----------

Originally, this change came out of problems with sequencing a
fork. Specifically, if there was an error inside of fork, and so it
tried to throw() inside of its parent, then the error was hidden by
the fact that [its parent generator was already running]. This was
symptomatic of a deeper problem of the generator syntax being too
tightly coupled to the underlying concept of the execution of a
sequence of steps, and also of the concept of synchronous and
asynchronous execution being too coupled.

As a purely practical consideration, we wondered "what if the parent
generator was yielded at the time we tried to create the fork?" That
way, the fork would be either be at its first yield point, or have
failed when we tried to resume the parent, but this turned out to be a
profoundly positive re-orientation of the effection architecture to
separate out the concepts of context and executions from the generator
synax entirely which enables all sorts of nice things.

Fundamentally, this change makes effection a function-oriented effects
library rather than a generator-oriented effects library. That is, you
don't _need_ generators to declare a tree of processes, it's just
nicer to do it that way. For example:

```js

enter(function*() {
  yield fork(function*() {
    return 5;
  });
});

```

can now be re-written using the function-equivalent:

```js
enter(({ call }) => {
  call(fork(({ resume }) => resume(5)));
})

The nice effect that this has is that the generator syntax is
implemented as just a plugin to the basic runtime, which means that in
addition to solving the problem at hand, it also [allows you to fork a
control function directly][2], something that had always felt like it
should work, but annoyingly didn't.

This feels like the right direction to take it, because it is the
solution to several seemingly unrelated problems, and it even feels as
though it could provide a pathway to an [asynchronous halt
operation][3] by making not just `fork` but also `halt` an operation.

Finally, because structural concurrency is implemented ultimately as
operations that do nothing more than manipulate the execution context,
other structured effects (state, messaging, etc...) can be implemented
in user space. Using this library, I was able to implement messaging
operations based on the [send-and-receive][4] branch completely in
user-space, and in which both `send()` and `receive()` were
operations.

You can now seemlessly move between function syntax and generator
syntax since they are all just operations that run inside the bounds
of an execution context.

For example, here is a "hybrid" version of the previous example using
a mixture of function syntax and generator syntax.

```js
enter(({ call }) => {
  call(fork(function*() {
    return 5;
  });
})
```

From the perspective of the JavaScript runtime, execution is still
completely synchronous internally, and the code is much, much simpler.

Details
-------

What was formerly known as a `Fork` and was coupled to a generator,
has now become two objects, an `ExecutionContext` and a
`ControlFuction`. Where the `ExecutionContext` tracks the state of the
frame of execution and the control function calls `resume`, `fail`, or
`halt` at the appropriate time. Every operation is eventually
resolved to a control function.

The key new primitive that is introduced is the `call` function, which
is passed into the control function with each execution context:

```js
enter(({ call }) => {
  call(function*() {
    yield timeout(100);
    return 5;
  });
});
```

This provisions a new execution context as a child of the current
context, looks up the operation passsed in order to find its control
function, and then enters that child context. Essentially, this is an
implementation of a call stack, except instead of it being a stack,
it's a tree.

Most effection code however, will never need to know about `call`,
since most operations do not actually create any children. But for
those that do such as the generator operation and the `fork`
operation, it allows them to be treated uniformly.

By default, child contexts created by `call` are considered "linked"
in the sense that if they fail, they cause the parent to fail, or if
they halt, then they cause their parent to fail because a required
child was. However, by passing callbacks to the `call` invocation, you
can override this behavior to "handle" an error, presumably by either
catching it or propagating it.

```js
call(operation, { fail: (e) => console.log('caught error: ', e) });
```
As mentioned previously, the default behavior is to cause the parent
context to also fail.

It's worth noting that with this power, it is possible to implement
completely in user-space try/catch/finally as operations, for example:

```js
function*() {
  yield tcf({
    try: () => do something,
    catch: (e) => console.log('error = ', e),
    finally: () => teardown()
  })
}
```

Open Questions
______________

- [ ] I'm not sure if the interface for the controls is good. Right
  now, the full signature of a control function is `({ resume, fail,
  ensure, call, context })`, but part of be feels like it should be
  more like a promise: `({ resolve, reject, ensure, call, context
  })`. Although maybe even using pseudo reserved words would be better
  to re-enforce the concepts like `({ $continue, $throw, $call,
  $finally })`.

[1]: #26
[2]: #33
[3]: #35
[4]: #49
@cowboyd cowboyd force-pushed the fork-join-primitive-operations branch from 08875e9 to d7d7ac5 Compare January 23, 2020 19:46
@frontsidejack
Copy link
Member

A preview package of this pull request has been released to NPM with the tag fork-join-primitive-operations.
You can try it out by running the following command:

$ npm install effection@fork-join-primitive-operations

or by updating your package.json to:

{
  "effection": "fork-join-primitive-operations"
}

Once the branch associated with this tag is deleted (usually once the PR is merged or closed), it will no longer be available. However, it currently references effection@0.4.0-891b693 which will be available to install forever.

Generated by 🚫 dangerJS against d7d7ac5

@cowboyd cowboyd force-pushed the fork-join-primitive-operations branch from d7d7ac5 to 11be079 Compare January 23, 2020 20:28
@frontsidejack
Copy link
Member

A preview package of this pull request has been released to NPM with the tag fork-join-primitive-operations.
You can try it out by running the following command:

$ npm install effection@fork-join-primitive-operations

or by updating your package.json to:

{
  "effection": "fork-join-primitive-operations"
}

Once the branch associated with this tag is deleted (usually once the PR is merged or closed), it will no longer be available. However, it currently references effection@0.4.0-7ebadb1 which will be available to install forever.

Generated by 🚫 dangerJS against 11be079

@frontsidejack
Copy link
Member

A preview package of this pull request has been released to NPM with the tag fork-join-primitive-operations.
You can try it out by running the following command:

$ npm install effection@fork-join-primitive-operations

or by updating your package.json to:

{
  "effection": "fork-join-primitive-operations"
}

Once the branch associated with this tag is deleted (usually once the PR is merged or closed), it will no longer be available. However, it currently references effection@0.4.0-680f556 which will be available to install forever.

Generated by 🚫 dangerJS against 25203c6

One of the niceties of the generator operations is that they can
elegantly express error handling in a computation by using the native
try/catch syntax.

```js
for (let tries = 0; tries < 5; tries++) {
  try {
    return yield dangerousOperation;
  } catch (error) {
    continue;
  }
}
throw new Error('Max retries exceeded. Giving up!');
```

In effection, if `dangerousOperation` fails, then we need to throw
that error inside the generator. If the generator doesn't catch that
error, then `generator.throw(error)` re-throws that error. We were
handling that case and failing the generator operation as a whole.

However, if the generator _catches_ the error, as in the example
above, then when we call  `generator.throw(errror)` it will return the
next iteration value. We were calling resume with the iterator value,
and not the next operation. That meant that you only saw weird
behavior when you caught an error and then re-tried the error handling
block.

This abstracts the handling of each iterator value with the `advance`
function, and makes sure that whether we are continuing a generator
because of a failure or because of a resumption of the computation, we
still handle the operation uniformly.
@frontsidejack
Copy link
Member

A preview package of this pull request has been released to NPM with the tag fork-join-primitive-operations.
You can try it out by running the following command:

$ npm install effection@fork-join-primitive-operations

or by updating your package.json to:

{
  "effection": "fork-join-primitive-operations"
}

Once the branch associated with this tag is deleted (usually once the PR is merged or closed), it will no longer be available. However, it currently references effection@0.4.0-c5a73d3 which will be available to install forever.

Generated by 🚫 dangerJS against d866303

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

Successfully merging this pull request may close these issues.

Forks that throw immediately try to abort their parent while the parent is still running
4 participants