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

RPC Consistency Proposal #2473

Open
wants to merge 51 commits into
base: master
Choose a base branch
from
Open

Conversation

acerone85
Copy link
Contributor

@acerone85 acerone85 commented Dec 5, 2024

Linked Issues/PRs

Related issue: #1897

See https://www.notion.so/fuellabs/RPC-Consistency-Proposal-V2-13b2f2293f31809bbce0d93a4c28d633 for the proposal specs.

This PR implements a variation of the proposal in the link, due to techincal limitations of the HTTP protocol when using graphql subscriptions.

for context: Headers cannot be used, the reason being that subscriptions will return multiple graphql responses in a single HTTP response (and therefore have a single HeaderMap), while we require that each item in the response has its associated CURRENT_FUEL_BLOCK_HEIGHT field. Moreover the Response metadata for subscriptions is returned before the actual response body (as it is standard with HTTP).

in the end graphql has a (very undocumented) way to add an extensions field to responses, at the same level of data/errors field. This does not require to tweak anything at the HTTP level.

We will implement an improvement where the server node will wait for a configurable number of blocks before returning a PRECONDITION FAILED response, in a follow-up PR.

Entrypoint for reviews:

Manual testing

TODO:

  • Tests

Description

Checklist

  • Breaking changes are clearly marked as such in the PR description and changelog
  • New behavior is reflected in tests
  • The specification matches the implemented behavior (link update PR if changes are needed)

Before requesting review

  • I have reviewed the code myself
  • I have created follow-up issues caused by this PR and linked them here

After merging, notify other teams

[Add or remove entries as needed]

@acerone85 acerone85 requested a review from Voxelot December 5, 2024 12:09
@acerone85 acerone85 linked an issue Dec 5, 2024 that may be closed by this pull request
@acerone85 acerone85 requested a review from a team December 5, 2024 12:09
@acerone85 acerone85 self-assigned this Dec 5, 2024
pub async fn query_with_headers<ResponseData, Vars>(
&self,
q: Operation<ResponseData, Vars>,
headers: impl IntoIterator<Item = (String, String)>,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: HeadersMap here?

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Forgot to mention this is done, see d9091e6

};

#[cfg(test)]
mod tests;

// The last known block height that was processed by the GraphQL service.
pub static LAST_KNOWN_BLOCK_HEIGHT: OnceLock<AtomicU32> = OnceLock::new();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

todo: remove Static and store in Service? Should still be accessible from middleware

Copy link
Contributor

Choose a reason for hiding this comment

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

Is this so performance critical that we can't do a database lookup for this value? I'd default to querying this from the database, to ensure we don't have any weird consistency issues when this field and the db isn't in sync. I guess this isn't a big problem right now, but it just doesn't feel right to allow for inconsistencies in a field used to synchronize with other nodes.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We shouldn't use static. Each extension has access to the context and you can get ReadDatabase from the context. ReadDatabase knows the latest block height.

You can check how ViewExtension works

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree that we should fetch the value from the DB (it is probably going to be fetched from the memtable anyway, since it's frequently updated). I don't agree that we should do this with async_graphql Extensions, the reason being that support for headers is pretty limited in there.

  1. Headers do not seem to be set when building the query context in async_graphql (see https://github.com/async-graphql/async-graphql/blob/7f1791488463d4e9c5adcd543962173e2f6cbd34/src/schema.rs#L927).
    I have confirmed this by applying the patch below to my PR and running the test. The output confirms that the header is not set by the time we execute the query.
diff --git a/crates/fuel-core/src/schema/balance.rs b/crates/fuel-core/src/schema/balance.rs
index b6b95228e8..956d5961bb 100644
--- a/crates/fuel-core/src/schema/balance.rs
+++ b/crates/fuel-core/src/schema/balance.rs
@@ -60,6 +60,10 @@ impl BalanceQuery {
         #[graphql(desc = "address of the owner")] owner: Address,
         #[graphql(desc = "asset_id of the coin")] asset_id: AssetId,
     ) -> async_graphql::Result<Balance> {
+        println!(
+            "header set: {}",
+            ctx.http_header_contains("REQUIRED_FUEL_BLOCK_HEIGHT")
+        );
         let query = ctx.read_view()?;
         let base_asset_id = *ctx
             .data_unchecked::<ConsensusProvider>()
  1. Maybe we could get the header somehow from the ExtensionContext, but this is going to be an ad hoc, undocumented solution and likely not readable.

What I think we should do instead is stick with Axum's middleware: get a handle to the OnchainDatabase from the CombinedDatabase (just because I don't want to wrap the instance to the CombinedDatabase inside an Arc), and use it to fetch the value of the last height in the middleware.

Let me know what you think.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry completely lost this discussion, I think this makes sense. I guess you've already implemented this. Re-reviewing now...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

for context: Headers cannot be used anymore, the reason being that subscriptions will return multiple graphql responses in a single HTTP response (and therefore have a single HeaderMap), while we require that each item in the response has its associated CURRENT_FUEL_BLOCK_HEIGHT field. Moreover the Response metadata for subscriptions is returned before the actual response body (as it is standard with HTTP).

in the end graphql has a (very undocumented) way to add an extensions field to responses, at the same level of data/errors field. This does not require to tweak anything at the HTTP level.

netrome
netrome previously approved these changes Dec 5, 2024
Copy link
Contributor

@netrome netrome left a comment

Choose a reason for hiding this comment

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

Nice stuff! I have some thoughts and comments, but no showstoppers.

pub async fn query_with_headers<ResponseData, Vars>(
&self,
q: Operation<ResponseData, Vars>,
headers: impl IntoIterator<Item = (String, String)>,
Copy link
Contributor

Choose a reason for hiding this comment

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

crates/client/src/client.rs Outdated Show resolved Hide resolved
crates/client/src/client.rs Outdated Show resolved Hide resolved
};

#[cfg(test)]
mod tests;

// The last known block height that was processed by the GraphQL service.
pub static LAST_KNOWN_BLOCK_HEIGHT: OnceLock<AtomicU32> = OnceLock::new();
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this so performance critical that we can't do a database lookup for this value? I'd default to querying this from the database, to ensure we don't have any weird consistency issues when this field and the db isn't in sync. I guess this isn't a big problem right now, but it just doesn't feel right to allow for inconsistencies in a field used to synchronize with other nodes.

Comment on lines 237 to 238
.get()
//Maybe too strict?
Copy link
Contributor

Choose a reason for hiding this comment

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

I find it weird that this can be unset. We could always default to 0 as well, which would just mean that we're not in sync yet (which I presume we're not if this value hasn't been set).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It will be unset until the first block is processed. But if we are going to fetch the value directly from the DB then it won't be the case anymore. I am waiting to hear what approach @xgreenx prefers before refactoring this bit

crates/fuel-core/src/graphql_api/api_service.rs Outdated Show resolved Hide resolved
crates/fuel-core/src/graphql_api/api_service.rs Outdated Show resolved Hide resolved
@Torres-ssf
Copy link

Hi @acerone85,

Could you kindly confirm if the response header for subscriptions is also intended to include current_fuel_block_height?

@acerone85
Copy link
Contributor Author

Hi @acerone85,

Could you kindly confirm if the response header for subscriptions is also intended to include current_fuel_block_height?

In the current implementation there is no response header for the current_fuel_block_height. Would it be desirable to have one?

Copy link
Contributor

@netrome netrome left a comment

Choose a reason for hiding this comment

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

Looks good to me. The current solution pushes more responsibility on the caller for constructing and maintaining the headers in the client. While this is fine, I wonder if we should take more responsibility for this particular header to relieve users from having to think about it.

I'd imagine FuelClient to just hold a required_fuel_block_height parameter which it maintains internally (it could be exposed to users as well), and uses to construct the header when making requests. When receiving responses it would update the required_fuel_block_height with the returned block height for subsequent requests.

serde_json::to_value(self.extensions.clone())?,
);

println!("{}", serde_json::to_string_pretty(&operation_json).unwrap());
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
println!("{}", serde_json::to_string_pretty(&operation_json).unwrap());

.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

println!("Response: {}", response);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
println!("Response: {}", response);

Comment on lines +291 to +293
let operation_object = operation_json
.as_object_mut()
.expect("Graphql operation is a valid json object");
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think that you can be sure that it is an object. It would be better if you checked it via if let Value::Object() ...

Comment on lines +370 to +372
let response = schema.execute(req.0).await;

response.into()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
let response = schema.execute(req.0).await;
response.into()
schema.execute(req.0).await.into()

}

async fn graphql_subscription_handler(
schema: Extension<CoreSchema>,
req: Json<Request>,
) -> Sse<impl Stream<Item = anyhow::Result<Event, serde_json::Error>>> {
let request = req.0;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
let request = req.0;

let stream = schema
.execute_stream(req.0)
.execute_stream(request)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
.execute_stream(request)
.execute_stream(req.0)

@@ -223,6 +250,34 @@ impl FuelClient {
Self::from_str(url.as_ref())
}

pub fn set_header(
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we don't use it and can remove

self.extensions.remove("required_fuel_block_height");
self
}

/// Send the GraphQL query to the client.
pub async fn query<ResponseData, Vars>(
Copy link
Collaborator

Choose a reason for hiding this comment

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

What about fn subscribe? We also want to support setting heights for the submit_and_await_commit like queries


#[tokio::test]
async fn current_fuel_block_height_header_is_present_on_successful_request() {
// TODO: Figure out a way to get the current fuel block height from FuelClient queries
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it is very important to extract the current height in fuel-core-client. But yeah, it can be done in a follow up PR. Could you create an issue to track it and put link in todos(below you have the same todos).

Copy link
Collaborator

@xgreenx xgreenx left a comment

Choose a reason for hiding this comment

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

Also in proposal we wanted to return 412. But based on the implementation we just return an error. Can we customize the number?

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.

RPC Consistency Proposal
5 participants