-
Notifications
You must be signed in to change notification settings - Fork 24
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
Conversation
9d98bf3
to
bf7944f
Compare
374c963
to
95d78ca
Compare
95d78ca
to
8f73e34
Compare
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. |
8f73e34
to
7eed027
Compare
pretty epic |
examples/countdown.js
Outdated
|
||
import { interruptable } from './inerruptable'; |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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!`); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
} | ||
} | ||
|
||
call(operation, options = {}) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 contextspawn
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:
- https://github.com/macabeus/js-proposal-algebraic-effects
- https://esdiscuss.org/topic/one-shot-delimited-continuations-with-effect-handlers
fwiw, I got the call nomenclature from https://github.com/phenax/algebraic-effects#state-effect-counter-example
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 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. |
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. |
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>;
}
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:
Thoughts? |
@taras @jnicklas any insight here would be very much appreciated since I'm completely stuck :) You can see the error locally when running |
@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 |
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
08875e9
to
d7d7ac5
Compare
A preview package of this pull request has been released to NPM with the tag $ 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. |
d7d7ac5
to
11be079
Compare
A preview package of this pull request has been released to NPM with the tag $ 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. |
A preview package of this pull request has been released to NPM with the tag $ 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. |
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.
A preview package of this pull request has been released to NPM with the tag $ 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. |
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:
can now be re-written using the function-equivalent:
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 alsohalt
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()
andreceive()
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.
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, anExecutionContext
and aControlFuction
. Where theExecutionContext
tracks the state of the frame of execution and the control function callsresume
,fail
, orhalt
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: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 thefork
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.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:
Open Questions
now, the full signature of a control function is
({ resume, fail, ensure, call, context })
, but part of be feels like it should bemore like a promise:
({ resolve, reject, ensure, call, context })
. Although maybe even using pseudo reserved words would be betterto re-enforce the concepts like
({ $continue, $throw, $call, $finally })
.fork
? Currently, when you fork, the child operation is appended directly to the parent:However, with this change, the fork will have a place in the call tree: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.
resolves #26, #33
closes #52