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 36 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.

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

Checking that the response header is set, with a node listening on port 4000

curl -X POST -D - http://localhost:4000/v1/graphql \
-H "Content-Type: application/json" \
-d '{
    "query": "{ contract(id:\"0x7e2becd64cd598da59b4d1064b711661898656c6b1f4918a787156b8965dc83c\") { id bytecode } }"
}'
HTTP/1.1 200 OK
content-type: application/json
current_fuel_block_height: 219
content-length: 10993
access-control-allow-origin: *
access-control-allow-methods: *
access-control-allow-headers: *
date: Mon, 16 Dec 2024 13:05:04 GMT

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.

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.

.query_with_headers(
query,
vec![(
"REQUIRED_FUEL_BLOCK_HEIGHT".to_string(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, I'm not 100% satisfied with this name. Especially since we provide a block height as input to some queries. When squinting your eyes it could be confusing if the "required_bock_height" is part of the query, when it is more a notion of the state of the node to ensure we're in sync.

How would REQUIRED_CHAIN_TIP_HEIGHT sound to you? Here it's clear that we're referring to the block height of the chain tip.

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 could have a better name (maybe CHAIN_HEAD instead of CHAIN_TIP) , but if we change it we have to notify other teams as well as we are deviating from the spec: see for example FuelLabs/fuels-ts#3443

Comment on lines 1105 to 1110
pub async fn balance_with_required_block_header(
&self,
owner: &Address,
asset_id: Option<&AssetId>,
required_block_height: u32,
) -> io::Result<u128> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm a bit hesitant to have the required_block_height be a parameter here. It feels like we're cluttering the interface and are increasing the cognitive load for our users. This feels more like a state that should be maintained by the client and not even directly exposed to the user.

I'd imagine the client maintaining a chain_tip parameter locally which is updated whenever it receives a response, and it's appended to the header of all requests made.

What are your thoughts on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I understood correctly you suggest to change the client to have the headers it will send in the requests in its state. I think that makes a lot of sense, let me have a go :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That was a good suggestion :) I have implemented functionalities to set/remove headers for the client in d9091e6 and it looks much better.

};

#[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

Comment on lines 47 to 48
Request as AxumRequest,
Response as AxumResponse,
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: This is a re-export so I'd alias it to HttpRequest, or even better just import axum::http and keep the namespacing: http::Request.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

agreed and done: 90c411f

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?

) -> Result<&mut Self, Error> {
let header_value: HeaderValue = value
.try_into()
.map_err(|_err| anyhow::anyhow!("Cannot parse value for header"))?;
Copy link
Contributor

Choose a reason for hiding this comment

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

If we now have an proper Error, maybe we should introduce specific variant instead of anyhow?

Comment on lines 274 to 276
for (header_name, header_value) in self.headers.iter() {
request_builder = request_builder.header(header_name, header_value);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we use headers() to avoid manual loop, like:

    request_builder = request_builder.headers(&self.headers);

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

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

@acerone85 I believe it would because I have seen the following scenario happening a few times when submitting TXs sequentially:

flowchart TD
    %% Transaction n1
    A1[Start TX n1] --> A2[Query coinsToSpend to fetch resources]
    A2 --> A3[Submit TX n1 using submitAndAwaitStatus]
    A3 --> A4[Await status update]
    A4 --> A5[Success]
    A5 --> B1[Start TX n2]

%% Transaction n+1
    B1 --> B2[Query coinsToSpend]
    B2 --> B3[Query hits unsync node, fetching a spent UTXO on TX n1]
    B3 --> B4[Submit TX n2 using submitAndAwaitStatus]
    B4 --> B5[Sync node validates UTXO was spent]
    B5 --> B6[Error]
Loading

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