From a04a6ae64c2c8d32f10b9e0f7cce0942ea2c1f9c Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Tue, 1 Nov 2022 01:35:34 +0800 Subject: [PATCH 1/8] Draft RFC for services Signed-off-by: Michael X. Grey --- rfcs/yy-services.md | 396 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 rfcs/yy-services.md diff --git a/rfcs/yy-services.md b/rfcs/yy-services.md new file mode 100644 index 00000000..f3a7c8dd --- /dev/null +++ b/rfcs/yy-services.md @@ -0,0 +1,396 @@ +# Feature Name: Topics + +:warning: This proposal depends on [one-shot systems](https://github.com/bevyengine/bevy/pull/4090), which is not yet a merged feature. + +## Summary + +This RFC introduces a feature to help with developing reactive applications. +Services are an asynchronous one-to-one request -> response mechanism. +A client submits a request to a service provider and is immediately given a promise. +The service provider processes the request asynchronously and eventually delivers on the promise. + +## Motivation + +Services are a common mechanism for performing Remote Procedure Calls or querying data over distributed systems, supported by middlewares like HTTP-REST, ROS, ZeroMQ, gRPC, and many others. +Service providers do not actively publish messages. +Instead, a client submits a request, and a service provider reacts to that request and delivers a response at some later point. +Services are asynchronous, which allows the service provider to spend time processing the request as needed, possibly making calls to remote systems, without blocking the rest of the application's workflow. + +This RFC proposes implementing services inside of Bevy as ergonomic Systems, Resources, and Components. + +Services can be used inside of Bevy to help Bevy users implement their applications using a "distributed" architecture to decouple and organize event flows from various agents/entities. +They are especially helpful for managing request-response data exchanges that may require time to process, other events to occur, or remote calls before a response can be given. + +At the same time, defining this generic ergonomic pipeline for services within Bevy should help facilitate the integration of various middlewares into Bevy. +For example, an `HTTP-REST` plugin could simply be implemented as a generic service provider within Bevy. +Bevy systems can make service requests without being concerned about whether the service provider is local or in another process or on a remote machine. +The Bevy systems can also be agnostic to what kind of middleware is being used to fulfill the request, allowing a Bevy application developer to test out different middleware options and mix-and-match without any changes to how their Bevy systems are implemented. + +## User-facing explanation + +These are the key structs for services: +* `Service`: a component or resource that represents a type of service that can be provided, defined by a `Request` input data structure and a `Response` output data structure. +The `Description` is an optional generic parameter that allows each service instance to describe its purpose or its behavior. +The description can be used by clients to decide which service provider they want to use for a given service type. +* `Promise`: a component for awaiting the result of a service request that has been issued. +A `Promise` can be polled to check if the response is available yet. +Alternatively it can be loaded with a callback system which will be queued as soon as the response is available. +* `Domain`: a component that allows service providers to advertise their services so that potential clients can find them. +* `GlobalDomain`: a resource that can be used as a service domain. + +### Making Requests +To request a service, use the `Service::request` method: + +```rust +pub struct RequestPath { + pub from_place: Place, + pub to_place: Place, +} + +struct SolvedPath(pub Vec); + +pub enum SearchType { + /// Search for the optimal path + OptimalPath, + /// Find any valid path as quickly as possible + QuickSearch, +} + +type PathSearch = Service; + +fn request_a_path( + commands: Commands, + path_service: Query<&PathSearch>, +) { + let path_promise = path_service.single().request( + RequestPath { + from_place: Place::NEW_YORK, + to_place: Place::BOSTON, + } + ); + commands.spawn().insert(path_promise); +} +``` + +Note that an entity is spawned to hold onto the Promise. +When a Promise is dropped, the Service will no longer be expected to fulfill it. +You could also store the promise as a field in a component or resource to prevent it from dropping. + +If you don't care about receiving the response, you can call `Promise::release` which will tell the service to fulfill the Promise even though it is being dropped. +`release` is especially useful after you have attached a callback to your promise. +For example: + +```rust +pub struct Arrival { + pub place: Place, + pub time: Time, +} + +type Drive = Service; + +#[derive(SystemParam)] +struct DriveParam<'w, 's> { + commands: Commands<'w, 's>, + driver: Query<'w, 's, &Drive>, +} + +fn request_a_path( + path_service: Query<&PathSearch>, +) { + path_service.single() + .request( + RequestPath { + from_place: Place::NEW_YORK, + to_place: Place::BOSTON, + } + ) + .then( + |response: SolvedPath, param: DriveParam| { + let drive_promise = param.driver.single().request(response); + param.commands.spawn().insert(drive_promise); + } + ) + .release(); +} +``` + +In the above example, a `PathSearch` service is requested to find a path from New York to Boston. +Once the path is found, a `Drive` service is requested to follow the path. +The promise of the `Drive` service is saved as a component of a newly spawned entity so that it can be monitored and perhaps cancelled by dropping the promise later. +The outer promise is released because we are not interested in monitoring or cancelling it. + +Using `.then` on a Promise will consume the original Promise and give back a new Promise whose "promised value" matches the return type of the inner function. +For example: + +```rust +fn find_path_cost( + commands: Commands, + path_service: Query<&PathSearch>, +) { + let cost_promise: Promise = path_service.single() + .request( + RequestPath { + from_place: Place::NEW_YORK, + to_place: Place::BOSTON, + } + ) + .then( + |response: SolvedPath, cost_calculator: Res| { + cost_calculator.calculate(response) + } + ); + + commands.spawn().insert(cost_promise); +} +``` + +The promised value that we are left with after calling `.then` will be the return value of the callback after its system has been run with the response of the original service. + +If the return type of the callback is itself a `Promise` you can instead use `.and_then` to flatten the outer promise to a `Promise` instead of being a `Promise>`: + +```rust +fn drive_to_place( + commands: Commands, + path_service: Query<&PathSearch>, +) { + let arrival_promise: Promise = path_service.single() + .request( + RequestPath { + from_place: Place::NEW_YORK, + to_place: Place::BOSTON, + } + ) + .and_then( + |response: SolvePath, driver: Query<&Drive>| { + // Returns a Promise + driver.single().request(response) + } + ); + + commands.spawn().insert(arrival_promise); +} +``` + +To track whether a promise has been fulfilled, you can poll the promise: + +```rust +fn watch_for_arrivals( + commands: Commands, + driving: Query>, +) { + for (e, arrival) in &mut driving { + if let PromiseStatus::Resolved(arrived) = arrival.poll() { + commands.entity(e).insert(arrived); + commands.entity(e).remove::>(); + } + } +} +``` + +The `PromiseStatus` returned by `Promise::poll` is an enum that lets you track the status of your promise: + +```rust +pub enum PromiseStatus { + /// The Promise is being processed by the service provider + Pending, + /// Receive the result. This can only be received once in order to support + /// cases where T does not have the Clone trait. + Resolved(T), + /// The result has been consumed by an earlier call to `Promise::poll` and + /// can no longer be obtained. + Consumed, + /// The service provider has dropped, so the Promise can never be fulfilled. + Dropped, + /// The service provider experienced an operational error (e.g. lost + /// connection to remote service) so the Promise can never be fulfilled. + Failure(anyhow::Error), +} +``` + +### Providing Services +To implement a service provider, create a system that queries for the service to check for unassigned requests. +For convenience, you can assign a `bevy_tasks::Task`, at which point it will no longer show up in the unassigned list. +It will remain in the pending list until the task is completed. + +```rust +fn handle_path_search_requests( + path_services: Query<&mut PathSearch>, + planner: Res>, + pool: Res, +) { + for service in &mut path_services { + let search_type = service.description(); + for unassigned in &mut service.unassigned_requests() { + let planner = planner.clone(); + let search_type = search_type.clone(); + if let Some(request) = unassigned.take_request() { + unassigned.assign( + pool.spawn(async move { + planner.search(request, search_type); + }) + ); + } + } + } +} +``` + +Once the task is completed, the request will no longer be in the pending list, and the next time its Promise is polled, it will return `Resolved(T)` with the result that was given by the task. + +Alternatively, if a task cannot encapsulate the work that needs to be done for the service, a system can directly resolve pending requests: + +```rust +fn detect_arrival( + drivers: Query<&mut Drive>, + current_place: Res, + mut current_path: ResMut>, + clock: Res, +) { + for service in &mut drivers { + for unassigned in &mut service.unassigned_requests() { + if let Some(request) = pending.peek_request() { + current_path.extend(request.iter()); + } + // This request will no longer show up in the unassigned list, but + // will still be visible in the pending list. + request.assigned(); + } + + for pending in &mut service.pending_requests() { + if let Some(request) = pending.peek_request() { + if request.0.last() == Some(*current_place) { + pending.resolve(Arrival { + place: current_place.clone(), + time: clock.now(), + }); + } + } + } + } +} +``` + +### Discovery +The `Domain` can be used to advertise the service of an entity: + +```rust +fn make_path_search_service( + commands: Commands, + domain: ResMut>, +) { + let optimal_search = commands.spawn().insert( + PathSearch::new(SearchType::OptimalSearch) + ).id(); + + let quick_search = commands.spawn().insert( + PathSearch::new(SearchType::QuickSearch) + ).id(); + + domain.advertise::(optimal_search, "driving_path".to_string()); + domain.advertise::(quick_search, "driving_path".to_string()); +} +``` + +Then a client can discover these services and select one of them based on the description: + +```rust +fn find_path_search_service( + domain: Res>, +) { + let service = domain.discover::("driving_path".to_string()) + .filter(|service| { + service.description() == SearchType::Optimal + }) + .next(); + + if let Some(service) = service { + service.request( + RequestPath { + from_place: Place::NEW_YORK, + to_place: Place::BOSTON, + }) + .and_then( + |response: SolvePath, driver: Query<&Drive>| { + // Returns a Promise + driver.single().request(response) + } + ) + .release(); + } +} +``` + +## Implementation strategy + +Many details of this implementation proposal will need significant iteration with help from someone more familiar than I am with the internals of Bevy. +I am currently not clear on how exactly dynamic system construction can/does work, nor the right way to leverage one-shot systems to make this work. +Chaining promises may be especially challenging. + +### Promises +* `Promise` instances are created by `Service` instances + * Channels are created between the promise and the service using `crossbeam` + * When the result is ready, the service will send it over a channel + * When a callback gets chained to a promise, it uses a different channel to push the callback to the service +* The promise will maintain a `PromiseStatus` to track its current state + * When the user calls `Promise::poll(&mut self)`, the `PromiseStatus` is consumed. + * If the `PromiseStatus` was `Resolved(T)` then the next `PromiseStatus` will be `Consumed` + * For all other `PromiseStatus` values, the next value will be a clone of the last. + * The user can `peek_status(&self)` instead to get a reference to the status and not consume it. + +### Services +* Services keep track of requests that have been issued to them +* Requests that are being tracked by a service can have an "unassigned" status which indicates that the service has not started working on it yet + * All new requests are automatically set to unassigned + * A request can be assigned to a `bevy_task::Task` to automate its fulfilment +* Requests that have not been fulfilled yet will have a "pending" status (all unassigned requests are also pending) + +### Domain management + +The domain keeps track of which topics the service providers want to be discovered on and what type of service they provide for that topic. +This will likely be a HashMap to a HashSet of entities. + +The domain will also need to store "service stubs" for all the services so that users can call `request` on the discovered services without needing to Query for the service components. +Maybe this can be done by sharing an `Arc` between the real service component and the stub that's held by the domain. + +## Drawbacks + +Many aspects of this proposal might be very difficult to implement. There are currently many gaping holes in the implementation proposal. + +It might be necessary to use [trait queries](https://crates.io/crates/bevy-trait-query) to implement some aspects of this proposal. + +## Rationale and alternatives + +It is often difficult and awkward for users to figure out how to structure their code when they need to handle futures that require polling. +Services attempt to eliminate the need for users to think in terms of futures by giving a simple way for them to describe the entire asynchronous data flow that they want in a single location. +Being able to chain reactions is especially valuable for dealing with complex multi-stage data flows. + +This design pattern also cuts down on the boilerplate that users need to write for dealing with asynchronous programming. +They will no longer need to write any system whose entire job is to poll for tasks that are running inside of task pools. +Instead usages of task pools can be written more ergonomically as services. + +## Prior art + +The ergonomics of promises proposed here is very similar to JavaScript promises, which I think are very effective at expressing asynchronous data flow (even if I have less positive opinions about the JavaScript language more generally..). + +We could draw inspiration from other implementations of Promises that have been done in Rust [[1]](https://docs.rs/promises/0.2.0/promises/struct.Promise.html)[[2]](https://docs.rs/promises/0.2.0/promises/struct.Promise.html). It's not clear to me if any of those crates can be used directly or if we need to customize the implementation too much because of the ECS backbone. + +## Unresolved questions + +### Network / Connection Errors +What kind of impossible-to-deliver errors should be supported, and how should they be supported? +For example, if a service needs to make remote call but no network is available, is `PromiseStatus::Failure(anyhow::Error)` adequate? +The `Response` type of the service could be a Result with a user-defined error type, but I think it would be nicer if the `Response` type of the service should only contain domain-specific concerns related to the `Request` and should not care about service pipeline issues. +Otherwise the `Response` type becomes sensitive to the choice of service middleware (or lack thereof). + +### Service Progress +For services that take a long time to finish, a user may want to be able to track the progress that is being made. + +One option is to provide a new type like `Action` which mimics the [ROS Action terminology](https://docs.ros.org/en/foxy/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Actions/Understanding-ROS2-Actions.html) or we could add a `Progress` generic to service with a default of `()`: `Service`. + +Either way, the `Promise` given back for a request could be loaded with a callback that gets triggered each time the service/action issues a progress update. +Before the final response is delivered, the service system will ensure that the queue of all progress updates has been drained, and the service provider will be blocked from issuing any further progress updates after the response is delivered. + +### Chaining a promise after it is fulfilled + +If a promise has been fulfilled, it should still be possible to chain a new callback to it, as long as the response has not been consumed. +Somehow the existence of the new stage needs to get packaged up with the response data and processed by the service systems. From bf151338af3b33683ff476928b37a65fc56ea7fb Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 2 Nov 2022 00:00:34 +0800 Subject: [PATCH 2/8] Iterating Signed-off-by: Michael X. Grey --- rfcs/yy-services.md | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/rfcs/yy-services.md b/rfcs/yy-services.md index f3a7c8dd..e4ed0849 100644 --- a/rfcs/yy-services.md +++ b/rfcs/yy-services.md @@ -1,6 +1,6 @@ # Feature Name: Topics -:warning: This proposal depends on [one-shot systems](https://github.com/bevyengine/bevy/pull/4090), which is not yet a merged feature. +:warning: This proposal depends on [one-shot systems](https://github.com/bevyengine/bevy/pull/4090), which is not yet a merged feature, and possibly [trait queries](https://github.com/bevyengine/rfcs/pull/39) which is not yet a merged RFC. ## Summary @@ -29,8 +29,7 @@ The Bevy systems can also be agnostic to what kind of middleware is being used t ## User-facing explanation These are the key structs for services: -* `Service`: a component or resource that represents a type of service that can be provided, defined by a `Request` input data structure and a `Response` output data structure. -The `Description` is an optional generic parameter that allows each service instance to describe its purpose or its behavior. +* `Service`: a component or resource that represents a type of service that can be provided, defined by a `Request` input data structure and a `Response` output data structure. The description can be used by clients to decide which service provider they want to use for a given service type. * `Promise`: a component for awaiting the result of a service request that has been issued. A `Promise` can be polled to check if the response is available yet. @@ -219,7 +218,7 @@ fn handle_path_search_requests( pool: Res, ) { for service in &mut path_services { - let search_type = service.description(); + let search_type = service.get_description::().unwrap_or(SearchType::Optimal); for unassigned in &mut service.unassigned_requests() { let planner = planner.clone(); let search_type = search_type.clone(); @@ -279,11 +278,11 @@ fn make_path_search_service( domain: ResMut>, ) { let optimal_search = commands.spawn().insert( - PathSearch::new(SearchType::OptimalSearch) + PathSearch::new().with_description(SearchType::OptimalSearch) ).id(); let quick_search = commands.spawn().insert( - PathSearch::new(SearchType::QuickSearch) + PathSearch::new().with_description(SearchType::QuickSearch) ).id(); domain.advertise::(optimal_search, "driving_path".to_string()); @@ -294,12 +293,14 @@ fn make_path_search_service( Then a client can discover these services and select one of them based on the description: ```rust -fn find_path_search_service( +fn find_optimal_path_search_service( domain: Res>, ) { let service = domain.discover::("driving_path".to_string()) .filter(|service| { - service.description() == SearchType::Optimal + service.get_description::() + .filter(|d| *d == SearchType::Optimal) + .is_some() }) .next(); @@ -384,11 +385,20 @@ Otherwise the `Response` type becomes sensitive to the choice of service middlew ### Service Progress For services that take a long time to finish, a user may want to be able to track the progress that is being made. - -One option is to provide a new type like `Action` which mimics the [ROS Action terminology](https://docs.ros.org/en/foxy/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Actions/Understanding-ROS2-Actions.html) or we could add a `Progress` generic to service with a default of `()`: `Service`. - -Either way, the `Promise` given back for a request could be loaded with a callback that gets triggered each time the service/action issues a progress update. -Before the final response is delivered, the service system will ensure that the queue of all progress updates has been drained, and the service provider will be blocked from issuing any further progress updates after the response is delivered. +I can think of a few ways to do this: + +* Provide a new type like `Action` which mimics the [ROS Action semantics](https://docs.ros.org/en/foxy/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Actions/Understanding-ROS2-Actions.html). + * Advantages: Clear semantic distinction from `Service`, which will only provide a Response + * Disadvantages: A lot of functional duplication with `Service` +* Add a `Progress` generic to `Service` with a default of `()`, i.e.: `Service`. + * Advantages: No functional duplication and easy to ignore the Progress capability if it's not needed + * Disadvantages: Services providers that provide different `Progress` types will be categorized as entirely different service types even if they have the same (`Request`, `Response`) pair +* Use type erasure to hide progress types inside of the `Service` similar to the service description. Clients can query the `Service` to see what types of Progress updates it supports. + * Advantages + * Any service providers with the same (`Request`, `Response`) pair will be discoverable together + * A service provider can support multiple types of `Progress` updates + * A client can receive different multiple types of `Progress` updates from the same service + * Disadvantages: harder to implement? ### Chaining a promise after it is fulfilled From bc81a81393800cc70dbcd72d0ec176792eefad8b Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 2 Nov 2022 00:11:47 +0800 Subject: [PATCH 3/8] Iterating Signed-off-by: Michael X. Grey --- rfcs/yy-services.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/rfcs/yy-services.md b/rfcs/yy-services.md index e4ed0849..cdb3f74f 100644 --- a/rfcs/yy-services.md +++ b/rfcs/yy-services.md @@ -48,14 +48,7 @@ pub struct RequestPath { struct SolvedPath(pub Vec); -pub enum SearchType { - /// Search for the optimal path - OptimalPath, - /// Find any valid path as quickly as possible - QuickSearch, -} - -type PathSearch = Service; +type PathSearch = Service; fn request_a_path( commands: Commands, @@ -210,8 +203,16 @@ pub enum PromiseStatus { To implement a service provider, create a system that queries for the service to check for unassigned requests. For convenience, you can assign a `bevy_tasks::Task`, at which point it will no longer show up in the unassigned list. It will remain in the pending list until the task is completed. +The `SearchType` description helps inform how the service should behave. ```rust +pub enum SearchType { + /// Search for the optimal path + OptimalPath, + /// Find any valid path as quickly as possible + QuickSearch, +} + fn handle_path_search_requests( path_services: Query<&mut PathSearch>, planner: Res>, @@ -369,6 +370,13 @@ This design pattern also cuts down on the boilerplate that users need to write f They will no longer need to write any system whose entire job is to poll for tasks that are running inside of task pools. Instead usages of task pools can be written more ergonomically as services. +### Service Descriptions +In the proposal, generic descriptions are stored inside of the `Service` and queried by clients. +This is done to keep the description information encapsulated inside of the `Service` component. + +Should these descriptions actually be saved as separate components on the entity instead of buried inside of the `Service` component? +That approach would align better with an ECS design philosophy but might be less convenient for users. + ## Prior art The ergonomics of promises proposed here is very similar to JavaScript promises, which I think are very effective at expressing asynchronous data flow (even if I have less positive opinions about the JavaScript language more generally..). From 3ed9a32e95a0eac049a44dccba379c02eea757e0 Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 2 Nov 2022 00:21:08 +0800 Subject: [PATCH 4/8] Tweak example code Signed-off-by: Michael X. Grey --- rfcs/yy-services.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/rfcs/yy-services.md b/rfcs/yy-services.md index cdb3f74f..27d5b532 100644 --- a/rfcs/yy-services.md +++ b/rfcs/yy-services.md @@ -97,8 +97,8 @@ fn request_a_path( } ) .then( - |response: SolvedPath, param: DriveParam| { - let drive_promise = param.driver.single().request(response); + |path: SolvedPath, param: DriveParam| { + let drive_promise = param.driver.single().request(path); param.commands.spawn().insert(drive_promise); } ) @@ -127,8 +127,8 @@ fn find_path_cost( } ) .then( - |response: SolvedPath, cost_calculator: Res| { - cost_calculator.calculate(response) + |path: SolvedPath, cost_calculator: Res| { + cost_calculator.calculate(path) } ); @@ -153,9 +153,9 @@ fn drive_to_place( } ) .and_then( - |response: SolvePath, driver: Query<&Drive>| { + |path: SolvePath, driver: Query<&Drive>| { // Returns a Promise - driver.single().request(response) + driver.single().request(path) } ); @@ -220,7 +220,7 @@ fn handle_path_search_requests( ) { for service in &mut path_services { let search_type = service.get_description::().unwrap_or(SearchType::Optimal); - for unassigned in &mut service.unassigned_requests() { + for unassigned in service.unassigned_requests_mut() { let planner = planner.clone(); let search_type = search_type.clone(); if let Some(request) = unassigned.take_request() { @@ -247,7 +247,7 @@ fn detect_arrival( clock: Res, ) { for service in &mut drivers { - for unassigned in &mut service.unassigned_requests() { + for unassigned in service.unassigned_requests_mut() { if let Some(request) = pending.peek_request() { current_path.extend(request.iter()); } @@ -256,7 +256,7 @@ fn detect_arrival( request.assigned(); } - for pending in &mut service.pending_requests() { + for pending in service.pending_requests_mut() { if let Some(request) = pending.peek_request() { if request.0.last() == Some(*current_place) { pending.resolve(Arrival { @@ -312,9 +312,9 @@ fn find_optimal_path_search_service( to_place: Place::BOSTON, }) .and_then( - |response: SolvePath, driver: Query<&Drive>| { + |path: SolvePath, driver: Query<&Drive>| { // Returns a Promise - driver.single().request(response) + driver.single().request(path) } ) .release(); From 58e6ee71bec8eab9b164a02a4a6e64b888297d64 Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 2 Nov 2022 00:27:58 +0800 Subject: [PATCH 5/8] Iterating Signed-off-by: Michael X. Grey --- rfcs/yy-services.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/rfcs/yy-services.md b/rfcs/yy-services.md index 27d5b532..ca1caf20 100644 --- a/rfcs/yy-services.md +++ b/rfcs/yy-services.md @@ -251,14 +251,16 @@ fn detect_arrival( if let Some(request) = pending.peek_request() { current_path.extend(request.iter()); } - // This request will no longer show up in the unassigned list, but - // will still be visible in the pending list. + // After calling assigned(), this request will no longer show up in + // the unassigned list, but will still be visible in the pending list. request.assigned(); } for pending in service.pending_requests_mut() { if let Some(request) = pending.peek_request() { if request.0.last() == Some(*current_place) { + // After calling resolve(~), the request will no longer show + // up in the pending list. pending.resolve(Arrival { place: current_place.clone(), time: clock.now(), @@ -286,8 +288,8 @@ fn make_path_search_service( PathSearch::new().with_description(SearchType::QuickSearch) ).id(); - domain.advertise::(optimal_search, "driving_path".to_string()); - domain.advertise::(quick_search, "driving_path".to_string()); + domain.advertise::(optimal_search, "driving_path"); + domain.advertise::(quick_search, "driving_path"); } ``` @@ -297,7 +299,7 @@ Then a client can discover these services and select one of them based on the de fn find_optimal_path_search_service( domain: Res>, ) { - let service = domain.discover::("driving_path".to_string()) + let service = domain.discover::("driving_path") .filter(|service| { service.get_description::() .filter(|d| *d == SearchType::Optimal) @@ -306,8 +308,7 @@ fn find_optimal_path_search_service( .next(); if let Some(service) = service { - service.request( - RequestPath { + service.request(RequestPath { from_place: Place::NEW_YORK, to_place: Place::BOSTON, }) From dadcea5f5fe416f1e3a259ff76fd7a95a888ed96 Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 2 Nov 2022 00:44:27 +0800 Subject: [PATCH 6/8] Iterating Signed-off-by: Michael X. Grey --- rfcs/yy-services.md | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/rfcs/yy-services.md b/rfcs/yy-services.md index ca1caf20..2c110a82 100644 --- a/rfcs/yy-services.md +++ b/rfcs/yy-services.md @@ -153,7 +153,7 @@ fn drive_to_place( } ) .and_then( - |path: SolvePath, driver: Query<&Drive>| { + |path: SolvedPath, driver: Query<&Drive>| { // Returns a Promise driver.single().request(path) } @@ -313,7 +313,7 @@ fn find_optimal_path_search_service( to_place: Place::BOSTON, }) .and_then( - |path: SolvePath, driver: Query<&Drive>| { + |path: SolvedPath, driver: Query<&Drive>| { // Returns a Promise driver.single().request(path) } @@ -330,22 +330,26 @@ I am currently not clear on how exactly dynamic system construction can/does wor Chaining promises may be especially challenging. ### Promises -* `Promise` instances are created by `Service` instances +* `Promise` instances are created by `Service` instances * Channels are created between the promise and the service using `crossbeam` * When the result is ready, the service will send it over a channel * When a callback gets chained to a promise, it uses a different channel to push the callback to the service * The promise will maintain a `PromiseStatus` to track its current state - * When the user calls `Promise::poll(&mut self)`, the `PromiseStatus` is consumed. + * When the user calls `Promise::poll(&mut self)`, the current `PromiseStatus` will be moved out as the return value. * If the `PromiseStatus` was `Resolved(T)` then the next `PromiseStatus` will be `Consumed` * For all other `PromiseStatus` values, the next value will be a clone of the last. - * The user can `peek_status(&self)` instead to get a reference to the status and not consume it. + * The user can `peek_status(&self)` instead to get a reference to the current status and not consume it. ### Services * Services keep track of requests that have been issued to them * Requests that are being tracked by a service can have an "unassigned" status which indicates that the service has not started working on it yet * All new requests are automatically set to unassigned * A request can be assigned to a `bevy_task::Task` to automate its fulfilment -* Requests that have not been fulfilled yet will have a "pending" status (all unassigned requests are also pending) + * Otherwise a service provider can use the `.assigned()` method to mark an unassigned request as assigned +* Requests that have not been fulfilled yet will have a "pending" status + * All unassigned requests are also pending + * Requests will no longer be pending once they are resolved + * If an unassigned request gets resolved, it will be removed from both the pending list and the unassigned list ### Domain management @@ -363,17 +367,18 @@ It might be necessary to use [trait queries](https://crates.io/crates/bevy-trait ## Rationale and alternatives +### Why services instead of tasks It is often difficult and awkward for users to figure out how to structure their code when they need to handle futures that require polling. -Services attempt to eliminate the need for users to think in terms of futures by giving a simple way for them to describe the entire asynchronous data flow that they want in a single location. +Services attempt to eliminate the need for users to think in terms of actively polling futures by giving a simple way for them to describe the entire asynchronous data flow that they want in a simple declaration. Being able to chain reactions is especially valuable for dealing with complex multi-stage data flows. This design pattern also cuts down on the boilerplate that users need to write for dealing with asynchronous programming. They will no longer need to write any system whose entire job is to poll for tasks that are running inside of task pools. -Instead usages of task pools can be written more ergonomically as services. +Instead usages of task pools can be written more ergonomically as services and callbacks. ### Service Descriptions -In the proposal, generic descriptions are stored inside of the `Service` and queried by clients. -This is done to keep the description information encapsulated inside of the `Service` component. +In the proposal, generic descriptions are stored inside of the `Service` and queried from the `Service` by interested clients. +This is done to keep the description information tightly encapsulated inside of the `Service` component and easy for clients to access. Should these descriptions actually be saved as separate components on the entity instead of buried inside of the `Service` component? That approach would align better with an ECS design philosophy but might be less convenient for users. @@ -388,16 +393,16 @@ We could draw inspiration from other implementations of Promises that have been ### Network / Connection Errors What kind of impossible-to-deliver errors should be supported, and how should they be supported? -For example, if a service needs to make remote call but no network is available, is `PromiseStatus::Failure(anyhow::Error)` adequate? -The `Response` type of the service could be a Result with a user-defined error type, but I think it would be nicer if the `Response` type of the service should only contain domain-specific concerns related to the `Request` and should not care about service pipeline issues. +For example, if a service needs to make a remote call but no network is available, is `PromiseStatus::Failure(anyhow::Error)` adequate? +The `Response` type of the service could be a `Result` with a user-defined error type, but I think it would be nicer if the `Response` type of the service should only contain domain-specific concerns related to the `Request` and should not care about service pipeline issues. Otherwise the `Response` type becomes sensitive to the choice of service middleware (or lack thereof). ### Service Progress For services that take a long time to finish, a user may want to be able to track the progress that is being made. I can think of a few ways to do this: -* Provide a new type like `Action` which mimics the [ROS Action semantics](https://docs.ros.org/en/foxy/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Actions/Understanding-ROS2-Actions.html). - * Advantages: Clear semantic distinction from `Service`, which will only provide a Response +* Provide a different type like `Action` which mimics the [ROS Action semantics](https://docs.ros.org/en/foxy/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Actions/Understanding-ROS2-Actions.html). + * Advantages: Clear semantic distinction from `Service` which only provides a Response * Disadvantages: A lot of functional duplication with `Service` * Add a `Progress` generic to `Service` with a default of `()`, i.e.: `Service`. * Advantages: No functional duplication and easy to ignore the Progress capability if it's not needed From 9c3fe5c01240519fe9f7254c97ebf52cab1b79c4 Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 2 Nov 2022 00:57:01 +0800 Subject: [PATCH 7/8] Incorporate the PR number Signed-off-by: Michael X. Grey --- rfcs/{yy-services.md => 66-services.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rfcs/{yy-services.md => 66-services.md} (100%) diff --git a/rfcs/yy-services.md b/rfcs/66-services.md similarity index 100% rename from rfcs/yy-services.md rename to rfcs/66-services.md From 4117424e20ad03fd760a0a5ca76db0fbad88238a Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 2 Nov 2022 00:58:01 +0800 Subject: [PATCH 8/8] Fix feature name Signed-off-by: Michael X. Grey --- rfcs/66-services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/66-services.md b/rfcs/66-services.md index 2c110a82..41cf3339 100644 --- a/rfcs/66-services.md +++ b/rfcs/66-services.md @@ -1,4 +1,4 @@ -# Feature Name: Topics +# Feature Name: Services :warning: This proposal depends on [one-shot systems](https://github.com/bevyengine/bevy/pull/4090), which is not yet a merged feature, and possibly [trait queries](https://github.com/bevyengine/rfcs/pull/39) which is not yet a merged RFC.