Skip to content

Commit 8d74b81

Browse files
Gudahttmcmirelegobeatadonesky1
authored
[queued-request-controller] Batch RPC requests (#3781)
## Explanation The `QueuedRequestController` will now batch RPC requests by origin. Each batch of requests will be processed in parallel, allowing existing features relating to groups of requests to function correctly with queuing enabled (e.g. the confirmation navigation buttons, and the "Reject all" button). The batching by origin also ensures that confirmations from different dapps are never displayed together in a group, which will help prevent users from confusing which dapp each confirmation is from. The meaning of `queuedRequestCount` has changed; it now represents the total number of queued requests, i.e. those that are queued but are **not** being processed. Previously it included processing requests as well. Given that the number of confirmations awaiting approval is already tracked elsewhere, it was not useful to double count them by including them in the queued request count as well. Additionally, when the `QueuedRequestController` requires the globally selected network to be changed, we now emit an event rather than triggering a confirmation. This aligns with recent designs for this use case. The actions `NetworkController:getNetworkConfigurationByNetworkClientId` and `ApprovalController:addRequest` were only used for triggering the switch network confirmation, so they are no longer needed now. They have been removed. This removes the last dependency of this controller on the `ApprovalController`, so it has been removed as a dependency as well. ## References Closes #3763 Fixes #3967 Closes #3983 ## Changelog ### `@metamask/queued-request-controller` #### Changed - **BREAKING**: The `QueuedRequestController` will now batch queued requests by origin - All of the requests in a single batch will be processed in parallel. - Requests get processed in order of insertion, even across origins/batches. - All requests get processed even in the event of preceding requests failing. - **BREAKING:** The `queuedRequestCount` state no longer includes requests that are currently being processed. It just counts requests that are queued. - **BREAKING:** The `QueuedRequestController` no longer triggers a confirmation when a network switch is needed - The network switch now happens automatically, with no confirmation. - A new `QueuedRequestController:networkSwitched` event has been added to communicate when this has happened. - The `QueuedRequestController` messenger no longer needs access to the actions `NetworkController:getNetworkConfigurationByNetworkClientId` and `ApprovalController:addRequest`. - The package `@metamask/approval-controller` has been completely removed as a dependency ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --------- Co-authored-by: Elliot Winkler <[email protected]> Co-authored-by: legobeat <[email protected]> Co-authored-by: Alex Donesky <[email protected]>
1 parent 6263cca commit 8d74b81

8 files changed

+761
-264
lines changed

packages/queued-request-controller/package.json

-2
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
"@metamask/utils": "^8.3.0"
5050
},
5151
"devDependencies": {
52-
"@metamask/approval-controller": "^5.1.3",
5352
"@metamask/auto-changelog": "^3.4.4",
5453
"@metamask/network-controller": "^17.2.1",
5554
"@metamask/selected-network-controller": "^9.0.0",
@@ -66,7 +65,6 @@
6665
"typescript": "~4.8.4"
6766
},
6867
"peerDependencies": {
69-
"@metamask/approval-controller": "^5.1.2",
7068
"@metamask/network-controller": "^17.2.0",
7169
"@metamask/selected-network-controller": "^9.0.0"
7270
},

packages/queued-request-controller/src/QueuedRequestController.test.ts

+585-162
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import type { AddApprovalRequest } from '@metamask/approval-controller';
21
import type {
32
ControllerGetStateAction,
43
ControllerStateChangeEvent,
54
RestrictedControllerMessenger,
65
} from '@metamask/base-controller';
76
import { BaseController } from '@metamask/base-controller';
8-
import { ApprovalType } from '@metamask/controller-utils';
97
import type {
10-
NetworkControllerGetNetworkConfigurationByNetworkClientId,
118
NetworkControllerGetStateAction,
129
NetworkControllerSetActiveNetworkAction,
1310
} from '@metamask/network-controller';
11+
import type { SelectedNetworkControllerGetNetworkClientIdForDomainAction } from '@metamask/selected-network-controller';
12+
import { createDeferredPromise } from '@metamask/utils';
1413

1514
import type { QueuedRequestMiddlewareJsonRpcRequest } from './types';
1615

@@ -36,6 +35,7 @@ export type QueuedRequestControllerEnqueueRequestAction = {
3635
};
3736

3837
export const QueuedRequestControllerEventTypes = {
38+
networkSwitched: `${controllerName}:networkSwitched` as const,
3939
stateChange: `${controllerName}:stateChange` as const,
4040
};
4141

@@ -45,8 +45,14 @@ export type QueuedRequestControllerStateChangeEvent =
4545
QueuedRequestControllerState
4646
>;
4747

48+
export type QueuedRequestControllerNetworkSwitched = {
49+
type: typeof QueuedRequestControllerEventTypes.networkSwitched;
50+
payload: [string];
51+
};
52+
4853
export type QueuedRequestControllerEvents =
49-
QueuedRequestControllerStateChangeEvent;
54+
| QueuedRequestControllerStateChangeEvent
55+
| QueuedRequestControllerNetworkSwitched;
5056

5157
export type QueuedRequestControllerActions =
5258
| QueuedRequestControllerGetStateAction
@@ -55,8 +61,7 @@ export type QueuedRequestControllerActions =
5561
export type AllowedActions =
5662
| NetworkControllerGetStateAction
5763
| NetworkControllerSetActiveNetworkAction
58-
| NetworkControllerGetNetworkConfigurationByNetworkClientId
59-
| AddApprovalRequest;
64+
| SelectedNetworkControllerGetNetworkClientIdForDomainAction;
6065

6166
export type QueuedRequestControllerMessenger = RestrictedControllerMessenger<
6267
typeof controllerName,
@@ -71,28 +76,62 @@ export type QueuedRequestControllerOptions = {
7176
};
7277

7378
/**
74-
* Controller for request queueing. The QueuedRequestController manages the orderly execution of enqueued requests
75-
* to prevent concurrency issues and ensure proper handling of asynchronous operations.
79+
* A queued request.
80+
*/
81+
type QueuedRequest = {
82+
/**
83+
* The origin of the queued request.
84+
*/
85+
origin: string;
86+
/**
87+
* A callback used to continue processing the request, called when the request is dequeued.
88+
*/
89+
processRequest: (error: unknown) => void;
90+
};
91+
92+
/**
93+
* Queue requests for processing in batches, by request origin.
7694
*
77-
* @param options - The controller options, including the restricted controller messenger for the QueuedRequestController.
78-
* @param options.messenger - The restricted controller messenger that facilitates communication with the QueuedRequestController.
95+
* Processing requests in batches allows us to completely separate sets of requests that originate
96+
* from different origins. This ensures that our UI will not display those requests as a set, which
97+
* could mislead users into thinking they are related.
7998
*
80-
* The QueuedRequestController maintains a count of enqueued requests, allowing you to monitor the queue's workload.
81-
* It processes requests sequentially, ensuring that each request is executed one after the other. The class offers
82-
* an `enqueueRequest` method for adding requests to the queue. The controller initializes with a count of zero and
83-
* registers message handlers for request enqueuing. It also publishes count changes to inform external observers.
99+
* Queuing requests in batches also allows us to ensure the globally selected network matches the
100+
* dapp-selected network, before the confirmation UI is rendered. This is important because the
101+
* data shown on some confirmation screens is only collected for the globally selected network.
102+
*
103+
* Requests get processed in order of insertion, even across batches. All requests get processed
104+
* even in the event of preceding requests failing.
84105
*/
85106
export class QueuedRequestController extends BaseController<
86107
typeof controllerName,
87108
QueuedRequestControllerState,
88109
QueuedRequestControllerMessenger
89110
> {
90-
private currentRequest: Promise<unknown> = Promise.resolve();
111+
/**
112+
* The origin of the current batch of requests being processed, or `undefined` if there are no
113+
* requests currently being processed.
114+
*/
115+
#originOfCurrentBatch: string | undefined;
91116

92117
/**
93-
* Constructs a QueuedRequestController, responsible for managing and processing enqueued requests sequentially.
94-
* @param options - The controller options, including the restricted controller messenger for the QueuedRequestController.
95-
* @param options.messenger - The restricted controller messenger that facilitates communication with the QueuedRequestController.
118+
* The list of all queued requests, in chronological order.
119+
*/
120+
#requestQueue: QueuedRequest[] = [];
121+
122+
/**
123+
* The number of requests currently being processed.
124+
*
125+
* Note that this does not include queued requests, just those being actively processed (i.e.
126+
* those in the "current batch").
127+
*/
128+
#processingRequestCount = 0;
129+
130+
/**
131+
* Construct a QueuedRequestController.
132+
*
133+
* @param options - Controller options.
134+
* @param options.messenger - The restricted controller messenger that facilitates communication with other controllers.
96135
*/
97136
constructor({ messenger }: QueuedRequestControllerOptions) {
98137
super({
@@ -111,118 +150,160 @@ export class QueuedRequestController extends BaseController<
111150

112151
#registerMessageHandlers(): void {
113152
this.messagingSystem.registerActionHandler(
114-
QueuedRequestControllerActionTypes.enqueueRequest,
153+
`${controllerName}:enqueueRequest`,
115154
this.enqueueRequest.bind(this),
116155
);
117156
}
118157

119158
/**
120-
* Switch the current globally selected network if necessary for processing the given
121-
* request.
159+
* Process the next batch of requests.
160+
*
161+
* This will trigger the next batch of requests with matching origins to be processed. Each
162+
* request in the batch is dequeued one at a time, in chronological order, but they all get
163+
* processed in parallel.
164+
*
165+
* This should be called after a batch of requests has finished processing, if the queue is non-
166+
* empty.
167+
*/
168+
async #processNextBatch() {
169+
const firstRequest = this.#requestQueue.shift() as QueuedRequest;
170+
this.#originOfCurrentBatch = firstRequest.origin;
171+
const batch = [firstRequest.processRequest];
172+
while (this.#requestQueue[0]?.origin === this.#originOfCurrentBatch) {
173+
const nextEntry = this.#requestQueue.shift() as QueuedRequest;
174+
batch.push(nextEntry.processRequest);
175+
}
176+
177+
// If globally selected network is different from origin selected network,
178+
// switch network before processing batch
179+
let networkSwitchError: unknown;
180+
try {
181+
await this.#switchNetworkIfNecessary();
182+
} catch (error: unknown) {
183+
networkSwitchError = error;
184+
}
185+
186+
for (const processRequest of batch) {
187+
processRequest(networkSwitchError);
188+
}
189+
this.#updateQueuedRequestCount();
190+
}
191+
192+
/**
193+
* Switch the globally selected network client to match the network
194+
* client of the current batch.
122195
*
123-
* @param request - The request currently being processed.
124196
* @throws Throws an error if the current selected `networkClientId` or the
125197
* `networkClientId` on the request are invalid.
126198
*/
127-
async #switchNetworkIfNecessary(
128-
request: QueuedRequestMiddlewareJsonRpcRequest,
129-
) {
199+
async #switchNetworkIfNecessary() {
200+
// This branch is unreachable; it's just here for type reasons.
201+
/* istanbul ignore next */
202+
if (!this.#originOfCurrentBatch) {
203+
throw new Error('Current batch origin must be initialized first');
204+
}
205+
const originNetworkClientId = this.messagingSystem.call(
206+
'SelectedNetworkController:getNetworkClientIdForDomain',
207+
this.#originOfCurrentBatch,
208+
);
130209
const { selectedNetworkClientId } = this.messagingSystem.call(
131210
'NetworkController:getState',
132211
);
133-
if (request.networkClientId === selectedNetworkClientId) {
212+
if (originNetworkClientId === selectedNetworkClientId) {
134213
return;
135214
}
136215

137-
const toNetworkConfiguration = this.messagingSystem.call(
138-
'NetworkController:getNetworkConfigurationByNetworkClientId',
139-
request.networkClientId,
140-
);
141-
const fromNetworkConfiguration = this.messagingSystem.call(
142-
'NetworkController:getNetworkConfigurationByNetworkClientId',
143-
selectedNetworkClientId,
144-
);
145-
if (!toNetworkConfiguration) {
146-
throw new Error(
147-
`Missing network configuration for ${request.networkClientId}`,
148-
);
149-
} else if (!fromNetworkConfiguration) {
150-
throw new Error(
151-
`Missing network configuration for ${selectedNetworkClientId}`,
152-
);
153-
}
154-
155-
const requestData = {
156-
toNetworkConfiguration,
157-
fromNetworkConfiguration,
158-
};
159216
await this.messagingSystem.call(
160-
'ApprovalController:addRequest',
161-
{
162-
origin: request.origin,
163-
type: ApprovalType.SwitchEthereumChain,
164-
requestData,
165-
},
166-
true,
217+
'NetworkController:setActiveNetwork',
218+
originNetworkClientId,
167219
);
168220

169-
await this.messagingSystem.call(
170-
'NetworkController:setActiveNetwork',
171-
request.networkClientId,
221+
this.messagingSystem.publish(
222+
'QueuedRequestController:networkSwitched',
223+
originNetworkClientId,
172224
);
173225
}
174226

175-
#updateCount(change: -1 | 1) {
227+
/**
228+
* Update the queued request count.
229+
*/
230+
#updateQueuedRequestCount() {
176231
this.update((state) => {
177-
state.queuedRequestCount += change;
232+
state.queuedRequestCount = this.#requestQueue.length;
178233
});
179234
}
180235

181236
/**
182-
* Enqueues a new request for sequential processing in the request queue. This function manages the order of
183-
* requests, ensuring they are executed one after the other to prevent concurrency issues and maintain proper
184-
* execution flow.
237+
* Enqueue a request to be processed in a batch with other requests from the same origin.
238+
*
239+
* We process requests one origin at a time, so that requests from different origins do not get
240+
* interwoven, and so that we can ensure that the globally selected network matches the dapp-
241+
* selected network.
242+
*
243+
* Requests get processed in order of insertion, even across origins/batches. All requests get
244+
* processed even in the event of preceding requests failing.
185245
*
186246
* @param request - The JSON-RPC request to process.
187-
* @param requestNext - A function representing the next steps for processing this request. It returns a promise that
188-
* resolves when the request is complete.
189-
* @returns A promise that resolves when the enqueued request and any subsequent asynchronous
190-
* operations are fully processed. This allows you to await the completion of the enqueued request before continuing
191-
* with additional actions. If there are multiple enqueued requests, this function ensures they are processed in
192-
* the order they were enqueued, guaranteeing sequential execution.
247+
* @param requestNext - A function representing the next steps for processing this request.
248+
* @returns A promise that resolves when the given request has been fully processed.
193249
*/
194250
async enqueueRequest(
195251
request: QueuedRequestMiddlewareJsonRpcRequest,
196252
requestNext: () => Promise<void>,
197-
) {
198-
this.#updateCount(1);
199-
if (this.state.queuedRequestCount > 1) {
200-
try {
201-
await this.currentRequest;
202-
} catch (_error) {
203-
// error ignored - this is handled in the middleware instead
204-
this.#updateCount(-1);
205-
}
253+
): Promise<void> {
254+
if (this.#originOfCurrentBatch === undefined) {
255+
this.#originOfCurrentBatch = request.origin;
206256
}
207257

208-
const processCurrentRequest = async () => {
209-
try {
210-
if (
211-
request.method !== 'wallet_switchEthereumChain' &&
212-
request.method !== 'wallet_addEthereumChain'
213-
) {
214-
await this.#switchNetworkIfNecessary(request);
215-
}
258+
try {
259+
// Queue request for later processing
260+
// Network switch is handled when this batch is processed
261+
if (
262+
this.state.queuedRequestCount > 0 ||
263+
this.#originOfCurrentBatch !== request.origin
264+
) {
265+
const {
266+
promise: waitForDequeue,
267+
reject,
268+
resolve,
269+
} = createDeferredPromise({
270+
suppressUnhandledRejection: true,
271+
});
272+
this.#requestQueue.push({
273+
origin: request.origin,
274+
processRequest: (error: unknown) => {
275+
if (error) {
276+
reject(error);
277+
} else {
278+
resolve();
279+
}
280+
},
281+
});
282+
this.#updateQueuedRequestCount();
216283

284+
await waitForDequeue;
285+
} else {
286+
// Process request immediately
287+
// Requires switching network now if necessary
288+
await this.#switchNetworkIfNecessary();
289+
}
290+
this.#processingRequestCount += 1;
291+
try {
217292
await requestNext();
218293
} finally {
219-
// The count is updated as part of the request processing to ensure
220-
// that it has been updated before the next request is run.
221-
this.#updateCount(-1);
294+
this.#processingRequestCount -= 1;
222295
}
223-
};
224-
225-
this.currentRequest = processCurrentRequest();
226-
await this.currentRequest;
296+
return undefined;
297+
} finally {
298+
if (this.#processingRequestCount === 0) {
299+
this.#originOfCurrentBatch = undefined;
300+
if (this.#requestQueue.length > 0) {
301+
// The next batch is triggered here. We intentionally omit the `await` because we don't
302+
// want the next batch to block resolution of the current request.
303+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
304+
this.#processNextBatch();
305+
}
306+
}
307+
}
227308
}
228309
}

packages/queued-request-controller/src/QueuedRequestMiddleware.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const getMockEnqueueRequest = () =>
2828
ReturnType<QueuedRequestControllerEnqueueRequestAction['handler']>,
2929
Parameters<QueuedRequestControllerEnqueueRequestAction['handler']>
3030
>()
31-
.mockImplementation((_origin, requestNext) => requestNext());
31+
.mockImplementation((_request, requestNext) => requestNext());
3232

3333
describe('createQueuedRequestMiddleware', () => {
3434
it('throws if not provided an origin', async () => {

packages/queued-request-controller/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export type {
33
QueuedRequestControllerEnqueueRequestAction,
44
QueuedRequestControllerGetStateAction,
55
QueuedRequestControllerStateChangeEvent,
6+
QueuedRequestControllerNetworkSwitched,
67
QueuedRequestControllerEvents,
78
QueuedRequestControllerActions,
89
QueuedRequestControllerMessenger,

0 commit comments

Comments
 (0)