Repository: FuelLabs/fuels-ts
Pull request #3788: chore: eliminate chain requests across concurrent provider instances
Author: login: petertonysmith94
Labels: name: chore
description: Issue is a chore
Created: 2025-03-18T17:24:32Z | Merged: 2025-03-19T10:25:39Z

- Closes #3766

# Release notes

In this release, we:

- Eliminated duplicate chain requests across multiple concurrent provider instances

# Summary

- Multiple instances of a provider could request the chain info simultaneously, causing the cached values to be missing. This ensures that only a single request is made across multiple provider instances.

# Checklist

- [X] All **changes** are **covered** by **tests** (or not applicable)
- [X] All **changes** are **documented** (or not applicable)
- [X] I **reviewed** the **entire PR** myself (preferably, on GH UI)
- [X] I **described** all **Breaking Changes** (or there's none)


Linked issue #3766: Eliminate concurrent provider chain requests
# Summary

Multiple providers can be instantiated simultaneously, which can lead to multiple redundant chain + node info requests. We should ensure that multiple providers only request the chain + node info once. 

Diff:
diff --git a/.changeset/nice-camels-marry.md b/.changeset/nice-camels-marry.md
new file mode 100644
index 00000000000..5b0cf141019
--- /dev/null
+++ b/.changeset/nice-camels-marry.md
@@ -0,0 +1,5 @@
+---
+"@fuel-ts/account": patch
+---
+
+chore: eliminate chain requests across concurrent provider instances
diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts
index 8cf2ed72f19..b56a1adce2f 100644
--- a/packages/account/src/providers/provider.test.ts
+++ b/packages/account/src/providers/provider.test.ts
@@ -2448,4 +2448,32 @@ describe('Provider', () => {
     // Then: we should not perform any fetch requests
     expect(fetch).not.toHaveBeenCalled();
   });
+
+  it('should make a single request for chain or node info across multiple instances', async () => {
+    // Given: three provider instances, connected to the same node
+    using launched = await setupTestProviderAndWallets();
+    const {
+      provider: { url },
+    } = launched;
+    const fetchSpy = vi.spyOn(global, 'fetch');
+    const provider1 = new Provider(url);
+    const provider2 = new Provider(url);
+    const provider3 = new Provider(url);
+    Provider.clearChainAndNodeCaches();
+
+    // When: we initialize all three provider instances
+    await Promise.all([provider1.init(), provider2.init(), provider3.init()]);
+
+    // Then: we should only perform a single fetch request for the chain and node info
+    expect(fetchSpy.mock.calls).toEqual([
+      [
+        url,
+        expect.objectContaining({
+          method: 'POST',
+          body: expect.stringMatching(/query getChainAndNodeInfo/),
+        }),
+      ],
+    ]);
+    expect(fetchSpy).toHaveBeenCalledTimes(1);
+  });
 });
diff --git a/packages/account/src/providers/provider.ts b/packages/account/src/providers/provider.ts
index 811b14cfd8a..83169b1f3b0 100644
--- a/packages/account/src/providers/provider.ts
+++ b/packages/account/src/providers/provider.ts
@@ -14,6 +14,8 @@ import type { GraphQLClientResponse, GraphQLResponse } from 'graphql-request/src
 import gql from 'graphql-tag';
 import { clone } from 'ramda';
 
+import { deferPromise } from '../connectors/utils/promises';
+
 import { getSdk as getOperationsSdk } from './__generated__/operations';
 import type {
   GqlReceiptFragment as TransactionReceiptJson,
@@ -406,6 +408,7 @@ export default class Provider {
 
   /** @hidden */
   static clearChainAndNodeCaches() {
+    Provider.inflightFetchChainAndNodeInfoRequests = {};
     Provider.nodeInfoCache = {};
     Provider.chainInfoCache = {};
   }
@@ -420,6 +423,8 @@ export default class Provider {
     amount128: false,
   };
 
+  /** @hidden */
+  private static inflightFetchChainAndNodeInfoRequests: Record<string, Promise<number>> = {};
   /** @hidden */
   private static chainInfoCache: ChainInfoCache = {};
   /** @hidden */
@@ -613,7 +618,9 @@ export default class Provider {
    * @param ignoreCache - If true, ignores the cache and re-fetch configs.
    * @returns A promise that resolves to the Chain and NodeInfo.
    */
-  async fetchChainAndNodeInfo(ignoreCache: boolean = false) {
+  async fetchChainAndNodeInfo(
+    ignoreCache: boolean = false
+  ): Promise<{ chain: ChainInfo; nodeInfo: NodeInfo }> {
     let nodeInfo: NodeInfo;
     let chain: ChainInfo;
 
@@ -627,18 +634,32 @@ export default class Provider {
         throw new Error(`Jumps to the catch block and re-fetch`);
       }
     } catch (_err) {
-      const data = await this.operations.getChainAndNodeInfo();
-
-      nodeInfo = deserializeNodeInfo(data.nodeInfo);
+      const inflightRequest: Promise<number> =
+        Provider.inflightFetchChainAndNodeInfoRequests[this.urlWithoutAuth];
+
+      // When an inflight is request is available, wait for it to complete
+      // Then fetch (which will hit the cached values)
+      if (inflightRequest) {
+        const now = await inflightRequest;
+        this.consensusParametersTimestamp = now;
+        return this.fetchChainAndNodeInfo();
+      }
 
-      Provider.setIncompatibleNodeVersionMessage(nodeInfo);
+      const { promise, resolve } = deferPromise<number>();
+      Provider.inflightFetchChainAndNodeInfoRequests[this.urlWithoutAuth] = promise;
 
+      const data = await this.operations.getChainAndNodeInfo();
+      nodeInfo = deserializeNodeInfo(data.nodeInfo);
       chain = deserializeChain(data.chain);
 
+      Provider.setIncompatibleNodeVersionMessage(nodeInfo);
       Provider.chainInfoCache[this.urlWithoutAuth] = chain;
       Provider.nodeInfoCache[this.urlWithoutAuth] = nodeInfo;
 
-      this.consensusParametersTimestamp = Date.now();
+      const now = Date.now();
+      this.consensusParametersTimestamp = now;
+      resolve(now);
+      delete Provider.inflightFetchChainAndNodeInfoRequests[this.urlWithoutAuth];
     }
 
     return {


Reviews:
user: login: danielbate
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T09:03:22Z
user: login: petertonysmith94
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T09:47:19Z
user: login: danielbate
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T10:01:00Z
user: login: danielbate
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T10:01:09Z
user: login: arboleya
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T10:25:06Z

Review comments:
diff_hunk: @@ -627,18 +634,32 @@ export default class Provider {
         throw new Error(`Jumps to the catch block and re-fetch`);
       }
     } catch (_err) {
-      const data = await this.operations.getChainAndNodeInfo();
-
-      nodeInfo = deserializeNodeInfo(data.nodeInfo);
+      const inflightRequest: Promise<number> =
+        Provider.inflightFetchChainAndNodeInfoRequests[this.urlWithoutAuth];
+
+      // When an inflight is request is available, wait for it to complete
+      // Then fetch (which will hit the cached values)
+      if (inflightRequest) {
+        const now = await inflightRequest;
+        this.consensusParametersTimestamp = now;
+        return this.fetchChainAndNodeInfo();
+      }
path: packages/account/src/providers/provider.ts
user: login: danielbate
body: Really nice implementation, but should this have some kind of max retries on it before it attempts it's own fetch? Otherwise all concurrent requests will just infinitely loop awaiting the result the first. 
created_at: 2025-03-19T09:03:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -627,18 +634,32 @@ export default class Provider {
         throw new Error(`Jumps to the catch block and re-fetch`);
       }
     } catch (_err) {
-      const data = await this.operations.getChainAndNodeInfo();
-
-      nodeInfo = deserializeNodeInfo(data.nodeInfo);
+      const inflightRequest: Promise<number> =
+        Provider.inflightFetchChainAndNodeInfoRequests[this.urlWithoutAuth];
+
+      // When an inflight is request is available, wait for it to complete
+      // Then fetch (which will hit the cached values)
+      if (inflightRequest) {
+        const now = await inflightRequest;
+        this.consensusParametersTimestamp = now;
+        return this.fetchChainAndNodeInfo();
+      }
path: packages/account/src/providers/provider.ts
user: login: petertonysmith94
body: How do you mean "before it attempts it's own fetch"?

```ts
// We await the first request performed by a provider
// After this is settled, the cache should be set
const now = await inflightRequest;

// We will only hit this fetch once
return this.fetchChainAndNodeInfo();

// And that is just to perform (L628-L629)
nodeInfo = Provider.nodeInfoCache[this.urlWithoutAuth];
chain = Provider.chainInfoCache[this.urlWithoutAuth];
```

This will not loop infinitely. I can change this to the following, if it helps reduce the confusion.

```ts
const now = await inflightRequest;
this.consensusParametersTimestamp = now;
return {
  nodeInfo: Provider.nodeInfoCache[this.urlWithoutAuth],
  chain: Provider.chainInfoCache[this.urlWithoutAuth],
}
```
created_at: 2025-03-19T09:34:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -627,18 +634,32 @@ export default class Provider {
         throw new Error(`Jumps to the catch block and re-fetch`);
       }
     } catch (_err) {
-      const data = await this.operations.getChainAndNodeInfo();
-
-      nodeInfo = deserializeNodeInfo(data.nodeInfo);
+      const inflightRequest: Promise<number> =
+        Provider.inflightFetchChainAndNodeInfoRequests[this.urlWithoutAuth];
+
+      // When an inflight is request is available, wait for it to complete
+      // Then fetch (which will hit the cached values)
+      if (inflightRequest) {
+        const now = await inflightRequest;
+        this.consensusParametersTimestamp = now;
+        return this.fetchChainAndNodeInfo();
+      }
path: packages/account/src/providers/provider.ts
user: login: danielbate
body: Ah makes sense, I'd missed the `await inflightRequest`. I think it's okay as is.
created_at: 2025-03-19T10:01:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-18T17:24:37Z
body: [vc]: #YkYfFicIAy+2whZ1tEmyIxRZr+Zhu5OMrBsam962T18=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ0cy1kb2NzIiwicm9vdERpcmVjdG9yeSI6ImFwcHMvZG9jcyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJ0cy1kb2NzLWdpdC1wcy1jaG9yZWVsaW1pYXRlLWNvbmN1cnJlbnQtcHJvdmlkLTNhYTU0MC1mdWVsLWxhYnMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vZnVlbC1sYWJzL3RzLWRvY3MvMm9ReUE1RjRib2pqNW4xbkQyZmZnTFlLOVVFcyIsInByZXZpZXdVcmwiOiJ0cy1kb2NzLWdpdC1wcy1jaG9yZWVsaW1pYXRlLWNvbmN1cnJlbnQtcHJvdmlkLTNhYTU0MC1mdWVsLWxhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6ImZ1ZWxzLXRlbXBsYXRlIiwicm9vdERpcmVjdG9yeSI6InRlbXBsYXRlcy92aXRlIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2Z1ZWwtbGFicy9mdWVscy10ZW1wbGF0ZS81anZnNmZGOGs5SHRNMnlIdXA5VUpHZjVINk50IiwicHJldmlld1VybCI6ImZ1ZWxzLXRlbXBsYXRlLWdpdC1wcy1jaG9yZWVsaW1pYXRlLWNvbmN1cnJlbnQtZjg4YmU0LWZ1ZWwtbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImZ1ZWxzLXRlbXBsYXRlLWdpdC1wcy1jaG9yZWVsaW1pYXRlLWNvbmN1cnJlbnQtZjg4YmU0LWZ1ZWwtbGFicy52ZXJjZWwuYXBwIn19LHsibmFtZSI6InRzLWRvY3MtYXBpIiwicm9vdERpcmVjdG9yeSI6ImFwcHMvZG9jcy1hcGkiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidHMtZG9jcy1hcGktZ2l0LXBzLWNob3JlZWxpbWlhdGUtY29uY3VycmVudC1wci0xNTVmN2ItZnVlbC1sYWJzLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2Z1ZWwtbGFicy90cy1kb2NzLWFwaS84bjJ1c24zWkRlWHl0Y0xQUEpTTEJ5a21SMnp5IiwicHJldmlld1VybCI6InRzLWRvY3MtYXBpLWdpdC1wcy1jaG9yZWVsaW1pYXRlLWNvbmN1cnJlbnQtcHItMTU1ZjdiLWZ1ZWwtbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **fuels-template** | ✅ Ready ([Inspect](https://vercel.com/fuel-labs/fuels-template/5jvg6fF8k9HtM2yHup9UJGf5H6Nt)) | [Visit Preview](https://fuels-template-git-ps-choreelimiate-concurrent-f88be4-fuel-labs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/fuels-template-git-ps-choreelimiate-concurrent-f88be4-fuel-labs.vercel.app?via=pr-comment-feedback-link) | Mar 19, 2025 8:07am |
| **ts-docs** | ✅ Ready ([Inspect](https://vercel.com/fuel-labs/ts-docs/2oQyA5F4bojj5n1nD2ffgLYK9UEs)) | [Visit Preview](https://ts-docs-git-ps-choreelimiate-concurrent-provid-3aa540-fuel-labs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/ts-docs-git-ps-choreelimiate-concurrent-provid-3aa540-fuel-labs.vercel.app?via=pr-comment-feedback-link) | Mar 19, 2025 8:07am |
| **ts-docs-api** | ✅ Ready ([Inspect](https://vercel.com/fuel-labs/ts-docs-api/8n2usn3ZDeXytcLPPJSLBykmR2zy)) | [Visit Preview](https://ts-docs-api-git-ps-choreelimiate-concurrent-pr-155f7b-fuel-labs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/ts-docs-api-git-ps-choreelimiate-concurrent-pr-155f7b-fuel-labs.vercel.app?via=pr-comment-feedback-link) | Mar 19, 2025 8:07am |




author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-19T08:24:07Z
body: 
  ***Coverage Report:***
  

| Lines          | Branches       | Functions   | Statements     |
| -------------- | -------------- | ----------- | -------------- |
| 77.46%(+0.03%) | 71.29%(+0.06%) | 75.51%(+0%) | 77.46%(+0.03%) |

  <details>

  <summary>Changed Files:</summary>

  | Ok | File (✨=New File)                                                                    | Lines              | Branches           | Functions     | Statements         |
| -- | ------------------------------------------------------------------------------------ | ------------------ | ------------------ | ------------- | ------------------ |
| 🔴 | packages/account/src/providers/provider.ts       | 68.62%<br>(+0.78%) | 63.42%<br>(+0.34%) | 69%<br>(+0%)  | 68.44%<br>(+0.77%) |
| 🔴 | packages/account/src/providers/resource-cache.ts | 97.95%<br>(+0%)    | 95%<br>(+5%)       | 100%<br>(+0%) | 97.95%<br>(+0%)    |


  </details>
  
<!-- thollander/actions-comment-pull-request "diff" -->
author_association: CONTRIBUTOR