-
Notifications
You must be signed in to change notification settings - Fork 69
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
RFC: Command abstraction for effects #291
base: master
Are you sure you want to change the base?
Conversation
3dd7b93
to
2870627
Compare
21663a8
to
9ec0f22
Compare
d9507fc
to
cdfdfcf
Compare
13c2001
to
b8443e4
Compare
b8443e4
to
14bb100
Compare
5044aa3
to
35c916f
Compare
dc3fd65
to
30d11c4
Compare
b48195e
to
ee89e74
Compare
@adwhit @arendjr @olliebatch I'd love any feedback if you can spare the time to review this 🙏🏻 |
ee89e74
to
58017d0
Compare
This is great stuff! I've read the description (so it seems that qualifies for the free beer 😉 ) , and from the sounds of it the priorities are well-explained, and as you've probably guessed I'm very aligned with this! The first thing that came to mind: It sounds as if the construction of a For the rest I'll let it sink a bit, and I'll have a peek at the code later. Nice work! |
@arendjr Thanks for looking!
It is certainly possible, and I'm hoping that the hassle of bubbling those back up to be returned will push people away from that design a bit more strongly than the current API (where it's relatively pain-free to do so). Definitely aggree that offering suggestions for patterns in documentation would be good. Thanks for the ideas around organising the codebase, we'll work them in when it comes time to write proper guides for this. |
Do you have an example of how the
Does the |
Yup, I expect that's how the With this PR, I want to first align around how this new API should work, and then separately solve the migration challenge, which is pretty chunky in its own right. I do imagine the move will end up looking similar to #282, but as much as we can I'd like to make it multiple steps - first enabling the new API to be used (perhaps with a feature flag or simultaneously somehow), adding capability support for them, then documenting the migration path thoroughly and deprecating the old APIs and finally removing them later down the line and tidying up. I know sizable codebases exist using the original API, so as much as we can I'd like them to be able to make the change over a period of time, not force them to wholesale adopt it or be stuck on an old version. |
Ah, the other expected change I forgot is that I might actually write a little example integration test with a new version of the App trait in it, to make the shape a bit clearer 🤔 |
Added a demo test in 223609e. |
Pushing that test a bit further made me realise that Maybe a |
Looking a bit deeper into this, I'm still contemplating this thing you mentioned among the downsides:
While I understand why the limitation is there, I'm still wondering whether you can't find some middle ground here. After all, during an I'm wondering whether it would be feasible to create something akin to a let command = Command::new(|ctx| async {
let response = ctx.request_from_shell(a_request).await;
let should_send_second_request = ctx.get_state(|model| inspect_model(model));
if !should_send_second_request {
return ctx.send_event(Event::Aborted);
}
let second_response = ctx.request_from_shell(make_another_request_from(response)).await;
ctx.send_event(Event::Done(second_response))
}) |
Note for reviewers
Have no fear! About 1500 of the total lines of this PR is in tests. They do demonstrate the APIs quite well, but hopefully don't require very thorough checking. Another 250 lines is a copy of this PR description (so it stays around).
I've left a number of comments in places starting
RFC:
- these are places where I have specific questions about the details of this proposal and would love your feedback. Thank you!!What is this RFC?
This is a proposed implementation of a new API for creating (requesting) side-effects in crux apps. It is quite a significant part of the Crux API surface, so I'd like feedback on the direction this is taking, rather than just hitting merge.
Why?
Why a new effect API, you may ask. Was there anything wrong with the original one? Not really. Not critically wrong anyway. One could achieve all the necessary work with it just fine, it enables quite complex effect orchestration with async Rust and ultimately enables Crux cores to stay fully pure and therefore be portable and very cheaply testable. This new proposed API is an evolution of the original, building on it heavily.
However.
There's a number of paper cuts, obscured intentions, limitations, and misaligned incentives which come with the original API:
The original API is oddly imperative, but non-blocking.
A typical call to a capability looks roughly like:
caps.some_capability.do_a_thing(inputs, Event::ThingDone)
. This call doesn't block, but also doesn't return any value. The effect request is magically registered, but is not represented as a value - it's gone. Fell through the floor and into the Crux runtime.Other than being a bit odd, this has two consequences. One is that it is impossible to combine or modify capability calls in any other way than executing them concurrently. This includes any type of transformation, like a declarative retry strategy or a timeout and, in particular, cancellation.
The more subtle consequence (and my favourite soap box) is that it encourages a style of architecture inside Crux apps, which Crux itself is working quite hard to discourage overall.
The temptation is to build custom types and functions, pass capability instances to them and call them deep down in the call stacks. This obscures the intended mental model in which the
update
call mutates state and results in some side-effects - it returns them back to the shell to execute. Except in the actual type signature ofupdate
it does not.The intent of Crux is to very strictly separate code updating state from code interacting with the outside world with side-effects. Borrowing ChatGPT's analogy - separate the brain of the robot from its body. The instructions for movement of the body should be an overall result of the brain's decision making, not sprinkled around it.
The practical consequence of the sprinkling is that code using capabilities is difficult to test fully without the
AppTester
for the same reason it is difficult to test any other code doing I/O of any sort. In our case it's practically impossible to test code using capabilities withou theAppTester
, because creating instances of capabilities is not easy.The original API doesn't make it obvious that the intent is to avoid this mixing and so people using it tend to try to mix it and find themselves in various levels of trouble.
In summary: Effects should be a return value from the
update
function, so that all code generating side effects makes it very explicit, making it part of its return type. That should in turn encourage more segregation of stateful but pure logic from effects.Access to
async
code is limited to capabilities and orchestration of effects is limited toasync
codeThis, like most things, started with good intention - people find async code to be "doing Rust on hard mode", and so for the most part Crux is designed to avoid async until you genuinely need it. Unfortunately it turned out to mean that you need async (or loads of extra events) as soon as any level of effect chaining is required.
For example - fetching an API auth token, followed by three HTTP API calls, followed by writing each response to disk (or local storage) is a sequence of purely side-effect operations. The recipe is known up front, and at no point are any decisions using the model being made.
With the original API the choices were either to introduce intermediate events between the steps of the effect state machine OR to implement the work in async rust in a capability, which gets to spawn async tasks. The limitation there was that the tasks can't easily call other capabilities.
With the introduction of the Compose capability that last limitation was removed, allowing composition of effects across capabilities, even within the
update
function body, so long as the capabilities offer an async API. The result was calls tocaps.compose.spawn
ending up all over and leading to creation of a new kind of type - a capability orchestrator, for example an API client, which is built from a few capabilities (lets say HTTP, KV and a custom authentication) and Compose. This kind of type is basically untestable on its own.In summary: It should be possible to do simple orchestration of effects without async code and gradually move into async code when its expressivity becomes more convenient.
Testing code with side-effects requires the AppTester
As covered above, the code producing side effects requires a special runtime in order to run fully and be tested, and so any code using capabilities automatically makes testing impossible without the AppTester. And of course apps themselves are only testable with the AppTester harness.
In summary: It should be possible for tests to call the update function and inspect the side effects like any other return value.
Capabilities have various annoying limitations
update
function.Compose
, capabilities are expected to emit their own assigned variant ofEffect
, preordained at the time the capability instance is created. This doesn't specifically stop one capability from asking another to emit an effect, but it is impossible to modify the second capability's request in any way - block specific one, retry it, combine it with a timeout, all the way up to completely virtualising it: resolving the effect using other data, like an in-memory cache, instead of passing it to the shell.In summary: Capabilities should not be special and should simply be code which encapsulates
App composition is not very flexible
This is really just another instance of the limitations of capabilities and the imperative effect API. Any transformations involved in making an app a "child" of another app has to be done up front. Specifically, this involves mapping or wrapping effects and events of the child app onto the effect and event type of the parent, typically with a
From
implementation which can't make contextual decisions.In summary: Instead, this mapping should be possible after the fact, and be case specific. It should be possible for apps to partially virtualise effects of the child apps, re-route or broadcast the events emitted by one child app to other children, etc.
How does this improve the situation?
So, with all (or at least most of) the limitations exposed, how is this proposed API better?
Enter
Command
As others before us, such as Elm and TCA, we end up with a solution where
update
returns effects. Specifically, it is expected to returns an effect orchestration which is the result of the update call as an instance of a new type calledCommand
.In a way,
Command
is the current effect executor in a box, with some extras. Command is a lot likeFuturesUnordered
- it holds one or more futures which it runs in the order they get woken up until they all complete.On top of this, Command provides to the futures a "context" - a type with an API which allows the futures within the command to submit effects and events. This is essentially identical to the current
CapabilityContext
. The difference is that the effects and events get collected in the Command and can be inspected, modified, forwarded or ignored.In a general shape, Command is a stream of Effect or Events created as an orchestration of futures. It also implements
Stream
, which means commands can wrap other commands and use them for any kind of orchestration.Orchestration with or without async
Since Commands are values, we can work with them after they are created. It's pretty simple to take several commands and join them into one which runs the originals concurrently:
Commands provide basic constructors for the primitives:
Command::done()
Command::event(Event::NextEvent))
Command::notification(my_notification)
Command::request_from_shell(my_request).then_send(Event::Response)
Command::stream_from_shell(my_request).then_send(Event::StreamEvent)
Notice that the latter two use chaining to register the event handler. This is because the other useful orchestration ability is chaining - creating a command with a result of a previous command. This requires a form of the builder pattern, since commands themselves are streams, not futures, and doing a simple
.then
would require a fair bit of boilerplate.Instead, to create a request followed by another request you can use the builder pattern as follows:
This works just the same with streams or combinations of requests and streams.
.then
andCommand::all
are nice, but on occasion, you will need the full power of async. The equivalent of the above with async works like this:Alternatively, you can create the futures from command builders:
You might be wondering why that's useful, and the answer is that it allows capabilities to return the result of
Command::request_from_shell
for simple shell interactions and not worry about whether they are being used in a sync or async context. It would be ideal if the command builders themselves could implementFuture
orStream
, but unfortunately, to be useful to us, the futures need access to the context which will only be created once theCommand
itself is created.Testing without AppTester
Commands can be tested by inspecting the resulting effects and events. The testing API consist of essentially three functions:
effects()
,events()
andis_done()
. All three first run the Command's underlying tasks until they settle and then return an iterator over the accumulated effects or events, and in the case ofis_done
a bool indicating whether there is any more work to do.An example test looks like this:
In apps, this will be very similar, except the
cmd
will be returned by the app'supdate
function.This API is mainly for testing, but is available to all consumers in all contexts, as it can easily become very useful for special cases when composing applications and virtualising commands in various ways.
Capabilities are no longer special
With the
Command
, capabilities become command creators and transformers. This makes them no different from user code in a lot of ways.The really basic ones can just be a set of functions. Any more complicated ones can now have state, call other capabilities, transform the commands produced by them, etc.
The expectation is that the majority of low-level capability APIs will return a
CommandBuilder
, so that they can be used from both event callback context and async context equally easily.Better app composition
Instead of transforming the app's
Capabilities
types in order to wrap them in another app up front, when composing apps the resulting commands get transformed. More specifically, this involves two map calls:The basic mapping is pretty straightforward, but can become as complex as required. For example, events produced by a child app can be consumed and re-routed, duplicated and broadcast across multiple children, etc. The mapping can also be done by fully wrapping the original Command in another using async
Other benefits and features
A grab bag of other things:
JoinHandle
which can be.await
ed.abort()
on aJoinHandle
AbortHandle
returned by.abort_handle()
. The handle can be stored in the model and used later.Limitations and drawbacks
I'm sure we'll find some. :)
For one, the return type signature for capabilities is not great, for example:
RequestBuilder<Effect, Event, impl Future<Output = AnOperationOutput>>
. The command builder chaining is using dirty trait tricks, and as a result requires theCommandBuilder
trait to be in scope - not completely ideal (but the same is true forFutureExt
andStreamExt
).One major perceived limitation which still remains is that
model
is not accesible from the effect code. This is by design, to avoid data races from concurrent access to the model. It should hopefully be a bit more obvious now that the effect code is returned from theupdate
function wrapped in a Command.I suspect there are ways around this by storing some
Arc<Mutex<T>>
s in the model and cloning them into the effect code, etc., but do so at your own peril - tracking causality will become a lot more complex in that world.Open questions and other considerations
Effect
type to implementFrom<Requst<Op>>
for any capability Operations it is used with. We should probably allow these trivial implentations to be macro derivedI thank and salute you for reading all the way down here. If you have, I 100% owe you a beer - tell me "I've read your entire RFC treatise, and I'm here for my free beer" next time you see me in or near a pub :).