@risk-labs/serverless-orchestration 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +19 -0
- package/index.js +1 -0
- package/package.json +49 -0
- package/src/ServerlessHub.js +700 -0
- package/src/ServerlessSpoke.js +130 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @notice This script reads in a global configuration file stored and executes parallel serverless instances for each
|
|
3
|
+
* configured bot. This enables one global config file to define all bot instances. This drastically simplifying the
|
|
4
|
+
* devops and management overhead for spinning up new instances as this can be done by simply updating a single config
|
|
5
|
+
* file. This script is designed to be run within a number of different environments:
|
|
6
|
+
* 1) GCP Cloud Run (or cloud function) environment with a permissioned service account. This enables infinite scalability
|
|
7
|
+
* to run thousands of parallel bot processes.
|
|
8
|
+
* 2) Local machine to enable simple orchestration between a number of bot processes all running on one main process.
|
|
9
|
+
* Configurations for the bots are pulled from from either a) localStorage, b) github or c) GCP storage bucket.
|
|
10
|
+
* The main configurations for the serverless hub are:
|
|
11
|
+
* 1) PORT: local port to run the hub on. if not specified will default to 8080
|
|
12
|
+
* 2) SPOKE_URL: http url to a serverless spoke instance. This could be local host (if running locally) or a GCP
|
|
13
|
+
* cloud run/cloud function URL which will spin up new instances for each parallel bot execution.
|
|
14
|
+
* 3) SPOKE_URLS: An optional argument in the form of a stringified JSON Object in the form of Record<string,string>
|
|
15
|
+
* Keys are a name for the spoke, and values are the spoke urls. This is only needed when we want to specificy
|
|
16
|
+
* different spoke urls for each configuration. Select by using the parameter "spokeUrlName" on the config file for each bot.
|
|
17
|
+
* 4) CUSTOM_NODE_URL: an ethereum node used to fetch the latest block number when the script runs.
|
|
18
|
+
* 5) HUB_CONFIG: JSON object configuring configRetrieval to define where to pull configs from, saveQueriedBlock to
|
|
19
|
+
* define where to save last queried block numbers and spokeRunner to define the execution environment for the spoke process.
|
|
20
|
+
* This script assumes the caller is providing a HTTP POST with a body formatted as:
|
|
21
|
+
* {"bucket":"<config-bucket>","configFile":"<config-file-name>"}
|
|
22
|
+
*/
|
|
23
|
+
const assert = require("assert");
|
|
24
|
+
const viem = require("viem");
|
|
25
|
+
const retry = require("async-retry");
|
|
26
|
+
const express = require("express");
|
|
27
|
+
const hub = express();
|
|
28
|
+
hub.use(express.json()); // Enables json to be parsed by the express process.
|
|
29
|
+
require("dotenv").config();
|
|
30
|
+
const fetch = require("node-fetch");
|
|
31
|
+
const fetchWithRetry = require("fetch-retry")(fetch);
|
|
32
|
+
const { URL } = require("url");
|
|
33
|
+
const lodash = require("lodash");
|
|
34
|
+
|
|
35
|
+
// GCP helpers.
|
|
36
|
+
const { GoogleAuth } = require("google-auth-library"); // Used to get authentication headers to execute cloud run & cloud functions.
|
|
37
|
+
const auth = new GoogleAuth();
|
|
38
|
+
const { Storage } = require("@google-cloud/storage"); // Used to get global config objects to parameterize bots.
|
|
39
|
+
|
|
40
|
+
const { WAIT_FOR_LOGGER_DELAY, GCP_STORAGE_CONFIG } = process.env;
|
|
41
|
+
|
|
42
|
+
// Enabling retry in case of transient timeout issues.
|
|
43
|
+
const DEFAULT_RETRIES = 1;
|
|
44
|
+
|
|
45
|
+
// Assign key name to variable since it's referenced multiple times.
|
|
46
|
+
const RUN_IDENTIFIER_KEY = "RUN_IDENTIFIER";
|
|
47
|
+
|
|
48
|
+
// Allows the environment to customize the config that's used to interact with google cloud storage.
|
|
49
|
+
// Relevant options can be found here: https://googleapis.dev/nodejs/storage/latest/global.html#StorageOptions.
|
|
50
|
+
// Specific fields of interest:
|
|
51
|
+
// - timeout: allows the env to set the timeout for all http requests.
|
|
52
|
+
// - retryOptions: object that allows the caller to specify how the library retries.
|
|
53
|
+
const storageConfig = GCP_STORAGE_CONFIG
|
|
54
|
+
? JSON.parse(GCP_STORAGE_CONFIG)
|
|
55
|
+
: { autoRetry: true, maxRetries: DEFAULT_RETRIES };
|
|
56
|
+
const storage = new Storage(storageConfig);
|
|
57
|
+
|
|
58
|
+
const { Datastore } = require("@google-cloud/datastore"); // Used to read/write the last block number the monitor used.
|
|
59
|
+
const datastore = new Datastore();
|
|
60
|
+
const { delay, createNewLogger, generateRandomRunId } = require("@risk-labs/logger");
|
|
61
|
+
|
|
62
|
+
let customLogger;
|
|
63
|
+
let spokeUrl;
|
|
64
|
+
// spokeUrlTable is an optional table populated through the env var SPOKE_URLS. SPOKE_URLS is expected to be a
|
|
65
|
+
// stringified JSON object in the form Record<string:string>. Where keys are a name for the spoke url
|
|
66
|
+
// and the values are the spoke urls. The env gets parsed into spokeUrlTable. Bots can select a size with
|
|
67
|
+
// the spokeUrlName="large" on the configuration object.
|
|
68
|
+
// For Example:
|
|
69
|
+
// {
|
|
70
|
+
// large:"https://large-spoke-url",
|
|
71
|
+
// small:"https://small-spoke-url",
|
|
72
|
+
// }
|
|
73
|
+
let spokeUrlTable = {};
|
|
74
|
+
let customNodeUrl;
|
|
75
|
+
let hubConfig = {};
|
|
76
|
+
|
|
77
|
+
// Lets us specify spoke url by a name or fallback to default spoke pool url.
|
|
78
|
+
// this should allow us to create multiple levels of spoke pool hardware (small,medium,large)
|
|
79
|
+
// and switch between urls based on the bot config.
|
|
80
|
+
function getSpokeUrl(name) {
|
|
81
|
+
if (name) {
|
|
82
|
+
// this will check if you have specified a name, and do a lookup. If a name is specified but does not exist this
|
|
83
|
+
// will be an error
|
|
84
|
+
const url = spokeUrlTable?.[name];
|
|
85
|
+
if (!url) throw new Error("No valid spoke url available for name: " + name);
|
|
86
|
+
return url;
|
|
87
|
+
// if no name specified just return spokeUrl. This may possibly be undefined, but this is compatible with past
|
|
88
|
+
// behavior.
|
|
89
|
+
} else return spokeUrl;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const defaultHubConfig = {
|
|
93
|
+
configRetrieval: "localStorage",
|
|
94
|
+
saveQueriedBlock: "localStorage",
|
|
95
|
+
spokeRunner: "localStorage",
|
|
96
|
+
rejectSpokeDelay: 120, // 2 min.
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const waitForLoggerDelay = WAIT_FOR_LOGGER_DELAY || 5;
|
|
100
|
+
|
|
101
|
+
hub.post("/", async (req, res) => {
|
|
102
|
+
// Use a custom logger if provided. Otherwise, initialize a local logger.
|
|
103
|
+
// Note: no reason to put this into the try-catch since a logger is required to throw the error.
|
|
104
|
+
const logger = customLogger || createNewLogger();
|
|
105
|
+
try {
|
|
106
|
+
logger.debug({ at: "ServerlessHub", message: "Running Serverless hub query", reqBody: req.body, hubConfig });
|
|
107
|
+
|
|
108
|
+
// Validate the post request has both the `bucket` and `configFile` params.
|
|
109
|
+
if (!req.body.bucket || !req.body.configFile) {
|
|
110
|
+
throw new Error("Body missing json bucket or file parameters!");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Allow the request to override the spoke rejection timeout.
|
|
114
|
+
const spokeRejectionTimeout =
|
|
115
|
+
req.body.rejectSpokeDelay !== undefined ? parseInt(req.body.rejectSpokeDelay) : hubConfig.rejectSpokeDelay;
|
|
116
|
+
|
|
117
|
+
// Get the config file from the GCP bucket if running in production mode. Else, pull the config from env.
|
|
118
|
+
const configObject = await _fetchConfig(req.body.bucket, req.body.configFile);
|
|
119
|
+
if (!configObject)
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Serverless hub missing a config object! GCPBucket:${req.body.bucket} configFile:${req.body.configFile}`
|
|
122
|
+
);
|
|
123
|
+
logger.debug({
|
|
124
|
+
at: "ServerlessHub",
|
|
125
|
+
message: "Executing Serverless query from config file",
|
|
126
|
+
spokeUrl,
|
|
127
|
+
spokeUrlTable,
|
|
128
|
+
botsExecuted: Object.keys(configObject),
|
|
129
|
+
configObject: hubConfig.printHubConfig ? configObject : "REDACTED",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// As a first pass, loop over all config objects in the config file and fetch the last queried block number and
|
|
133
|
+
// head block for each unique chain ID. The reason why we precompute these block numbers for each chain ID is so
|
|
134
|
+
// that each bot connected to the same chain will use the same block number parameters, which is a convenient
|
|
135
|
+
// assumption if there are many bots running on the same chain.
|
|
136
|
+
let blockNumbersForChain = {
|
|
137
|
+
// (chainId: int): {
|
|
138
|
+
// lastQueriedBlockNumber: <int>
|
|
139
|
+
// latestBlockNumber: <int>
|
|
140
|
+
// }
|
|
141
|
+
};
|
|
142
|
+
let nodeUrlToChainIdCache = {
|
|
143
|
+
// (url: string): <int>
|
|
144
|
+
};
|
|
145
|
+
for (const botName in configObject) {
|
|
146
|
+
// Check if bot is running on a non-default chain, and fetch last block number seen on this or the default chain.
|
|
147
|
+
const [provider, spokeCustomNodeUrl] = _getProviderAndUrl(configObject[botName]);
|
|
148
|
+
|
|
149
|
+
const singleChainId = await _getChainId(provider);
|
|
150
|
+
|
|
151
|
+
// Cache the chain id for this node url.
|
|
152
|
+
nodeUrlToChainIdCache[spokeCustomNodeUrl] = singleChainId;
|
|
153
|
+
|
|
154
|
+
// Fetch last seen block for this chain and get the head block for the chosen chain, which we'll use to override the last queried block number
|
|
155
|
+
// stored in GCP at the end of this hub execution. Fetch only if we haven't cached them already.
|
|
156
|
+
// Keep them as promises as we might still have multichain block numbers to fetch.
|
|
157
|
+
let blockNumberPromises = [];
|
|
158
|
+
if (!blockNumbersForChain[singleChainId]) {
|
|
159
|
+
blockNumberPromises.push(
|
|
160
|
+
_getLastQueriedBlockNumber(req.body.configFile, singleChainId, logger),
|
|
161
|
+
_getLatestBlockNumber(provider),
|
|
162
|
+
new Promise((resolve) => {
|
|
163
|
+
resolve(singleChainId);
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If STORE_MULTI_CHAIN_BLOCK_NUMBERS is set then this bot requires to know a number of last seen blocks across
|
|
169
|
+
// a set of chainIds. Append a batch promise to evaluate the latest block number for each chainId.
|
|
170
|
+
if (configObject[botName]?.environmentVariables?.STORE_MULTI_CHAIN_BLOCK_NUMBERS) {
|
|
171
|
+
const multiChainIds = configObject[botName]?.environmentVariables?.STORE_MULTI_CHAIN_BLOCK_NUMBERS;
|
|
172
|
+
for (const chainId of multiChainIds) {
|
|
173
|
+
// If we've seen this chain ID or it is covered by the singleChainId we can skip it.
|
|
174
|
+
if (blockNumbersForChain[chainId] || chainId === singleChainId) continue;
|
|
175
|
+
|
|
176
|
+
blockNumberPromises.push(
|
|
177
|
+
_getLastQueriedBlockNumber(req.body.configFile, chainId, logger),
|
|
178
|
+
_getBlockNumberOnChainIdMultiChain(configObject[botName], chainId),
|
|
179
|
+
new Promise((resolve) => {
|
|
180
|
+
resolve(chainId);
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const blockNumberResults = await Promise.all(blockNumberPromises);
|
|
187
|
+
blockNumberResults.forEach((_, index) => {
|
|
188
|
+
// This is flat array where each chain has 3 elements (lastQueriedBlockNumber, latestBlockNumber and chainId).
|
|
189
|
+
if (index % 3 !== 0) return;
|
|
190
|
+
let [lastQueriedBlockNumber, latestBlockNumber, chainId] = blockNumberResults.slice(index, index + 3);
|
|
191
|
+
|
|
192
|
+
// If the last queried block number stored on GCP Data Store is undefined, then its possible that this is
|
|
193
|
+
// the first time that the hub is being run for this chain. Therefore, try setting it to the head block number
|
|
194
|
+
// for the chosen node.
|
|
195
|
+
lastQueriedBlockNumber ??= latestBlockNumber;
|
|
196
|
+
|
|
197
|
+
// If the last queried number is still undefined at this point, then exit with an error.
|
|
198
|
+
if (isNaN(lastQueriedBlockNumber)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`No block number for chain ID stored on GCP and cannot read head block from node! chainID:${chainId}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Store block number data for this chain ID which we'll use to update the GCP cache later.
|
|
205
|
+
blockNumbersForChain[chainId] = {
|
|
206
|
+
lastQueriedBlockNumber: Number(lastQueriedBlockNumber),
|
|
207
|
+
latestBlockNumber: Number(latestBlockNumber),
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
logger.debug({
|
|
212
|
+
at: "ServerlessHub",
|
|
213
|
+
message: "Updated block numbers for networks",
|
|
214
|
+
nodeUrlToChainIdCache,
|
|
215
|
+
blockNumbersForChain,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Now, that we've precomputed all of the last seen blocks for each chain, we can update their values in the
|
|
219
|
+
// GCP Data Store. These will all be the fetched as the "lastQueriedBlockNumber" in the next iteration when the
|
|
220
|
+
// hub is called again.
|
|
221
|
+
await _saveQueriedBlockNumber(req.body.configFile, blockNumbersForChain, logger);
|
|
222
|
+
|
|
223
|
+
// Finally, loop over all config objects in the config file and for each append a call promise to the promiseArray.
|
|
224
|
+
// Note that each promise is a race between the serverlessSpoke command and a `_rejectAfterDelay`. This places an
|
|
225
|
+
// upper bound on how long each spoke can take to respond, acting as a timeout for each spoke call.
|
|
226
|
+
let promiseArray = [];
|
|
227
|
+
let botConfigs = {};
|
|
228
|
+
for (const botName in configObject) {
|
|
229
|
+
const [, spokeCustomNodeUrl] = _getProviderAndUrl(configObject[botName]);
|
|
230
|
+
const singleChainId = nodeUrlToChainIdCache[spokeCustomNodeUrl];
|
|
231
|
+
|
|
232
|
+
// Execute the spoke's command:
|
|
233
|
+
const botConfig = _appendEnvVars(
|
|
234
|
+
configObject[botName],
|
|
235
|
+
botName,
|
|
236
|
+
singleChainId,
|
|
237
|
+
blockNumbersForChain,
|
|
238
|
+
configObject[botName]?.environmentVariables?.STORE_MULTI_CHAIN_BLOCK_NUMBERS
|
|
239
|
+
);
|
|
240
|
+
botConfigs[botName] = botConfig;
|
|
241
|
+
// Gets a spoke url based on execution size or fallback to default spoke url if non specified
|
|
242
|
+
if (botConfig.spokeUrlName)
|
|
243
|
+
logger.debug({
|
|
244
|
+
at: "ServerlessHub",
|
|
245
|
+
message: `Attempting to execute ${botName} serverless spoke using named spoke ${botConfig.spokeUrlName}`,
|
|
246
|
+
});
|
|
247
|
+
const spokeUrl = getSpokeUrl(botConfig.spokeUrlName);
|
|
248
|
+
const runId = botConfig.environmentVariables[RUN_IDENTIFIER_KEY];
|
|
249
|
+
promiseArray.push(
|
|
250
|
+
Promise.race([
|
|
251
|
+
_executeServerlessSpoke(spokeUrl, botConfig, botName),
|
|
252
|
+
_rejectAfterDelay(spokeRejectionTimeout, botName),
|
|
253
|
+
]).then(
|
|
254
|
+
(value) => ({ ...value, runIds: [runId] }),
|
|
255
|
+
(err) => Promise.reject({ ...err, runIds: [runId] })
|
|
256
|
+
)
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
logger.debug({ at: "ServerlessHub", message: "Executing Serverless spokes", botConfigs });
|
|
260
|
+
|
|
261
|
+
// Loop through promise array and submit all in parallel. `allSettled` does not fail early if a promise is rejected.
|
|
262
|
+
// This `results` object will contain all information sent back from the spokes. This contains the process exit code,
|
|
263
|
+
// and importantly the full execution output which can be used in debugging.
|
|
264
|
+
const results = await Promise.allSettled(promiseArray);
|
|
265
|
+
|
|
266
|
+
// Validate that the promises returned correctly. If any spokes rejected it is possible that it was due to a networking
|
|
267
|
+
// or internal GCP error. Re-try these executions. If a response is code 500 or contains an error then log it as an error.
|
|
268
|
+
let errorOutputs = {};
|
|
269
|
+
let validOutputs = {};
|
|
270
|
+
let retriedOutputs = [];
|
|
271
|
+
results.forEach((result, index) => {
|
|
272
|
+
if (result.status == "rejected") {
|
|
273
|
+
// If it is rejected, then store the name so we can try re-run the spoke call.
|
|
274
|
+
retriedOutputs.push(Object.keys(configObject)[index]); // Add to retriedOutputs to re-run the call.
|
|
275
|
+
return; // go to next result in the forEach loop.
|
|
276
|
+
}
|
|
277
|
+
// Process the spoke response. This extracts useful log information and discern if the spoke had generated an error.
|
|
278
|
+
_processSpokeResponse(Object.keys(configObject)[index], result, validOutputs, errorOutputs);
|
|
279
|
+
});
|
|
280
|
+
// Re-try the rejected outputs in a separate promise.all array.
|
|
281
|
+
if (retriedOutputs.length > 0) {
|
|
282
|
+
logger.debug({
|
|
283
|
+
at: "ServerlessHub",
|
|
284
|
+
message: "One or more spoke calls were rejected - Retrying",
|
|
285
|
+
retriedOutputs,
|
|
286
|
+
});
|
|
287
|
+
let rejectedRetryPromiseArray = [];
|
|
288
|
+
retriedOutputs.forEach((botName) => {
|
|
289
|
+
const spokeUrl = getSpokeUrl(botConfigs[botName].spokeUrlName);
|
|
290
|
+
|
|
291
|
+
// Swap out the run identifer for one with an `r` appended to signify a retry.
|
|
292
|
+
const runId = botConfigs[botName].environmentVariables[RUN_IDENTIFIER_KEY];
|
|
293
|
+
const retryRunId = `${runId}r`;
|
|
294
|
+
botConfigs[botName].environmentVariables[RUN_IDENTIFIER_KEY] = retryRunId;
|
|
295
|
+
|
|
296
|
+
rejectedRetryPromiseArray.push(
|
|
297
|
+
Promise.race([
|
|
298
|
+
_executeServerlessSpoke(spokeUrl, botConfigs[botName], botName),
|
|
299
|
+
_rejectAfterDelay(spokeRejectionTimeout, botName),
|
|
300
|
+
]).then(
|
|
301
|
+
(value) => ({ ...value, runIds: [runId, retryRunId] }),
|
|
302
|
+
(err) => Promise.reject({ ...err, runIds: [runId, retryRunId] })
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
const rejectedRetryResults = await Promise.allSettled(rejectedRetryPromiseArray);
|
|
307
|
+
rejectedRetryResults.forEach((result, index) => {
|
|
308
|
+
_processSpokeResponse(retriedOutputs[index], result, validOutputs, errorOutputs);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// If there are any error outputs(from the original loop or from re-tried calls) then throw.
|
|
312
|
+
if (Object.keys(errorOutputs).length > 0) {
|
|
313
|
+
throw { errorOutputs, validOutputs, retriedOutputs };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// If no errors and got to this point correctly then return a 200 success status.
|
|
317
|
+
// Note: we don't log the error outputs since it is empty in this branch.
|
|
318
|
+
logger.debug({
|
|
319
|
+
at: "ServerlessHub",
|
|
320
|
+
message: "All calls returned correctly",
|
|
321
|
+
output: { validOutputs: Object.keys(validOutputs), retriedOutputs },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Log each bot's output separately to avoid creating huge log messages.
|
|
325
|
+
// Note: no need to loop through errorOutputs since the length has been
|
|
326
|
+
for (const [botName, output] of Object.entries(validOutputs)) {
|
|
327
|
+
logger.debug({ at: "ServerlessHub", message: `Bot ${botName} succeeded`, output });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await delay(waitForLoggerDelay); // Wait a few seconds to be sure the the winston logs are processed upstream.
|
|
331
|
+
res
|
|
332
|
+
.status(200)
|
|
333
|
+
.send({ message: "All calls returned correctly", output: { errorOutputs, validOutputs, retriedOutputs } });
|
|
334
|
+
} catch (errorOutput) {
|
|
335
|
+
// If the errorOutput is an instance of Error then we know that error was produced within the hub. Else, it is from
|
|
336
|
+
// one of the upstream spoke calls. Depending on the kind of error, process the logs differently.
|
|
337
|
+
if (errorOutput instanceof Error) {
|
|
338
|
+
logger.error({
|
|
339
|
+
at: "ServerlessHub",
|
|
340
|
+
message: "A fatal error occurred in the hub",
|
|
341
|
+
output: errorOutput.stack,
|
|
342
|
+
notificationPath: "infrastructure-error",
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
// Else, the error was produced within one of the spokes. If this is the case then we need to process the errors a bit.
|
|
346
|
+
logger.debug({
|
|
347
|
+
at: "ServerlessHub",
|
|
348
|
+
message: "Some spoke calls returned errors (details)🚨",
|
|
349
|
+
output: errorOutput,
|
|
350
|
+
});
|
|
351
|
+
logger.error({
|
|
352
|
+
at: "ServerlessHub",
|
|
353
|
+
message: "Some spoke calls returned errors 🚨",
|
|
354
|
+
retriedSpokes: errorOutput.retriedOutputs,
|
|
355
|
+
errorOutputs: Object.keys(errorOutput.errorOutputs).map((spokeName) => {
|
|
356
|
+
try {
|
|
357
|
+
return {
|
|
358
|
+
spokeName: spokeName,
|
|
359
|
+
errorReported:
|
|
360
|
+
errorOutput.errorOutputs[spokeName]?.execResponse?.stderr ??
|
|
361
|
+
errorOutput.errorOutputs[spokeName].message ??
|
|
362
|
+
errorOutput.errorOutputs[spokeName].reason ??
|
|
363
|
+
errorOutput.errorOutputs[spokeName],
|
|
364
|
+
};
|
|
365
|
+
} catch (err) {
|
|
366
|
+
return "Hub unable to parse error"; // `errorMessages` is in an unexpected JSON shape.
|
|
367
|
+
}
|
|
368
|
+
}), // eslint-disable-line indent
|
|
369
|
+
validOutputs: Object.keys(errorOutput.validOutputs), // eslint-disable-line indent
|
|
370
|
+
notificationPath: "infrastructure-error",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await delay(waitForLoggerDelay); // Wait a few seconds to be sure the the winston logs are processed upstream.
|
|
375
|
+
res.status(500).send({
|
|
376
|
+
message: errorOutput instanceof Error ? "A fatal error occurred in the hub" : "Some spoke calls returned errors",
|
|
377
|
+
output: errorOutput instanceof Error ? errorOutput.message : errorOutput,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Execute a serverless POST command on a given `url` with a provided json `body`. This is used to initiate the spoke
|
|
383
|
+
// instance from the hub. If running in gcp mode then local service account must be permissioned to execute this command.
|
|
384
|
+
const _executeServerlessSpoke = async (url, body, botName) => {
|
|
385
|
+
try {
|
|
386
|
+
if (hubConfig.spokeRunner == "gcp") {
|
|
387
|
+
const targetAudience = new URL(url).origin;
|
|
388
|
+
|
|
389
|
+
const client = await auth.getIdTokenClient(targetAudience);
|
|
390
|
+
const res = await client.request({ url: url, method: "post", data: body });
|
|
391
|
+
|
|
392
|
+
return res.data;
|
|
393
|
+
} else if (hubConfig.spokeRunner == "localStorage") {
|
|
394
|
+
return _postJson(url, body);
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return Promise.reject({ status: "error", message: err.toString(), childProcessIdentifier: botName });
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// Fetch configs for serverless hub. Either read from a gcp bucket, local storage or a git repo. Github configs can pull
|
|
402
|
+
// from a private github repo using the provided Authorization token. GCP uses a readStream which is converted into a
|
|
403
|
+
// buffer such that the config file does not need to first be downloaded from the bucket. This will use the local service
|
|
404
|
+
// account. Local configs are read directly from the process's environment variables.
|
|
405
|
+
const _fetchConfig = async (bucket, file) => {
|
|
406
|
+
let config;
|
|
407
|
+
if (hubConfig.configRetrieval == "git") {
|
|
408
|
+
const response = await fetchWithRetry(
|
|
409
|
+
`https://api.github.com/repos/${hubConfig.gitSettings.organization}/${hubConfig.gitSettings.repoName}/contents/${bucket}/${file}`,
|
|
410
|
+
{
|
|
411
|
+
method: "GET",
|
|
412
|
+
headers: {
|
|
413
|
+
Authorization: `token ${hubConfig.gitSettings.accessToken}`,
|
|
414
|
+
"Content-type": "application/json",
|
|
415
|
+
Accept: "application/vnd.github.v3.raw",
|
|
416
|
+
"Accept-Charset": "utf-8",
|
|
417
|
+
},
|
|
418
|
+
retries: DEFAULT_RETRIES,
|
|
419
|
+
}
|
|
420
|
+
);
|
|
421
|
+
config = await response.json(); // extract JSON from the http response
|
|
422
|
+
// If there is a message in the config response then something went wrong in fetching from github api.
|
|
423
|
+
if (config.message) throw new Error(`Could not fetch config! :${JSON.stringify(config)}`);
|
|
424
|
+
}
|
|
425
|
+
if (hubConfig.configRetrieval == "gcp") {
|
|
426
|
+
const requestPromise = new Promise((resolve, reject) => {
|
|
427
|
+
let buf = "";
|
|
428
|
+
storage
|
|
429
|
+
.bucket(bucket)
|
|
430
|
+
.file(file)
|
|
431
|
+
.createReadStream()
|
|
432
|
+
.on("data", (d) => (buf += d))
|
|
433
|
+
.on("end", () => resolve(buf))
|
|
434
|
+
.on("error", (e) => reject(e));
|
|
435
|
+
});
|
|
436
|
+
config = JSON.parse(await requestPromise);
|
|
437
|
+
} else if (hubConfig.configRetrieval == "localStorage") {
|
|
438
|
+
const stringConfig = process.env[`${bucket}-${file}`];
|
|
439
|
+
if (!stringConfig) {
|
|
440
|
+
throw new Error(`No local storage stringConfig found for ${bucket}-${file}`);
|
|
441
|
+
}
|
|
442
|
+
config = JSON.parse(stringConfig);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// If the config contains a "commonConfig" field, append it it to all configs downstream and then remove common config
|
|
446
|
+
// from the final config object. The config for a given bot will take precedence for each key. Use deep merge.
|
|
447
|
+
if (Object.keys(config).includes("commonConfig")) {
|
|
448
|
+
for (let configKey in config) {
|
|
449
|
+
if (configKey != "commonConfig") config[configKey] = lodash.merge({}, config.commonConfig, config[configKey]);
|
|
450
|
+
}
|
|
451
|
+
delete config.commonConfig;
|
|
452
|
+
}
|
|
453
|
+
return config;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Save a the last blocknumber seen by the hub to GCP datastore. BlockNumberLog is the entity kind and configIdentifier
|
|
457
|
+
// is the entity ID. Each entity has a column "<chainID>" which stores the latest block seen for a network.
|
|
458
|
+
async function _saveQueriedBlockNumber(configIdentifier, blockNumbersForChain, logger) {
|
|
459
|
+
// Sometimes the GCP datastore can be flaky and return errors when fetching data. Use re-try logic to re-run on error.
|
|
460
|
+
await retry(
|
|
461
|
+
async () => {
|
|
462
|
+
if (hubConfig.saveQueriedBlock == "gcp") {
|
|
463
|
+
const key = datastore.key(["BlockNumberLog", configIdentifier]);
|
|
464
|
+
const latestBlockNumbersForChain = {};
|
|
465
|
+
Object.keys(blockNumbersForChain).forEach((chainId) => {
|
|
466
|
+
latestBlockNumbersForChain[chainId] = blockNumbersForChain[chainId].latestBlockNumber;
|
|
467
|
+
});
|
|
468
|
+
const dataBlob = { key: key, data: latestBlockNumbersForChain };
|
|
469
|
+
await datastore.save(dataBlob); // Overwrites the entire entity
|
|
470
|
+
} else if (hubConfig.saveQueriedBlock == "localStorage") {
|
|
471
|
+
Object.keys(blockNumbersForChain).forEach((chainId) => {
|
|
472
|
+
process.env[`lastQueriedBlockNumber-${chainId}-${configIdentifier}`] =
|
|
473
|
+
blockNumbersForChain[chainId].latestBlockNumber;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
retries: 2,
|
|
479
|
+
minTimeout: 2000, // delay between retries in ms
|
|
480
|
+
onRetry: (error) => {
|
|
481
|
+
logger.debug({
|
|
482
|
+
at: "serverlessHub",
|
|
483
|
+
message: "An error was thrown when saving the previously queried block number - retrying",
|
|
484
|
+
error: typeof error === "string" ? new Error(error) : error,
|
|
485
|
+
});
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Query entity kind `BlockNumberLog` with unique entity ID of `configIdentifier`. Used to get the last block number
|
|
492
|
+
// for a network ID recorded by the bot to inform where searches should start from. Each entity has a column for each
|
|
493
|
+
// chain ID storing the last seen block number for the corresponding network.
|
|
494
|
+
async function _getLastQueriedBlockNumber(configIdentifier, chainId, logger) {
|
|
495
|
+
// sometimes the GCP datastore can be flaky and return errors when saving data. Use re-try logic to re-run on error.
|
|
496
|
+
return await retry(
|
|
497
|
+
async () => {
|
|
498
|
+
if (hubConfig.saveQueriedBlock == "gcp") {
|
|
499
|
+
const key = datastore.key(["BlockNumberLog", configIdentifier]);
|
|
500
|
+
const [dataField] = await datastore.get(key);
|
|
501
|
+
return dataField[chainId];
|
|
502
|
+
} else if (hubConfig.saveQueriedBlock == "localStorage") {
|
|
503
|
+
return process.env[`lastQueriedBlockNumber-${chainId}-${configIdentifier}`] | 0;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
retries: 2,
|
|
508
|
+
minTimeout: 2000, // delay between retries in ms
|
|
509
|
+
onRetry: (error) => {
|
|
510
|
+
logger.debug({
|
|
511
|
+
at: "serverlessHub",
|
|
512
|
+
message: "An error was thrown when fetching the most recent block number - retrying",
|
|
513
|
+
error: typeof error === "string" ? new Error(error) : error,
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function _getProviderAndUrl(botConfig) {
|
|
521
|
+
const env = botConfig?.environmentVariables;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* NODE_RETRY CONFIG = [
|
|
525
|
+
* { url: "https://...", retries: 2 },
|
|
526
|
+
* ]
|
|
527
|
+
*/
|
|
528
|
+
const defaultConfig = [
|
|
529
|
+
{ url: env?.CUSTOM_NODE_URL ?? customNodeUrl, retries: 2 }, // 2 retries if there's only a single provider.
|
|
530
|
+
];
|
|
531
|
+
|
|
532
|
+
const config = env?.NODE_RETRY_CONFIG ?? defaultConfig;
|
|
533
|
+
assert(
|
|
534
|
+
config.length > 0 && config.every(({ url, retries }) => url !== undefined && retries > 0),
|
|
535
|
+
"Missing or malformed mainnet RPC provider definitions (NODE_RETRY_CONFIG, CUSTOM_NODE_URL)"
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const provider = viem.createPublicClient({
|
|
539
|
+
transport: viem.fallback(
|
|
540
|
+
config.map(({ url, retries }) => viem.http(url, { retryCount: retries ?? DEFAULT_RETRIES }))
|
|
541
|
+
),
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return [provider, config[0].url];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function _getBlockNumberOnChainIdMultiChain(botConfig, chainId) {
|
|
548
|
+
const env = botConfig?.environmentVariables;
|
|
549
|
+
const urls = env?.[`NODE_URLS_${chainId}`] ?? env?.[`NODE_URL_${chainId}`];
|
|
550
|
+
if (!urls) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`ServerlessHub::_getBlockNumberOnChainIdMultiChain NODE_URLS_${chainId} or NODE_URL_${chainId} in botConfig: ${botConfig}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const retryConfig = lodash.castArray(urls).map((url) => ({ url }));
|
|
557
|
+
const provider = viem.createPublicClient({
|
|
558
|
+
transport: viem.fallback(retryConfig.map(({ url }) => viem.http(url, { retryCount: DEFAULT_RETRIES }))),
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
return _getLatestBlockNumber(provider);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Get the latest block number from either `overrideNodeUrl` or `CUSTOM_NODE_URL`. Used to update the `
|
|
565
|
+
// lastSeenBlockNumber` after each run.
|
|
566
|
+
async function _getLatestBlockNumber(provider) {
|
|
567
|
+
const blockNumber = await provider.getBlockNumber();
|
|
568
|
+
return Number(blockNumber);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function _getChainId(provider) {
|
|
572
|
+
return provider.getChainId();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Add additional environment variables for a given config file. Used to attach starting and ending block numbers.
|
|
576
|
+
function _appendEnvVars(config, botName, singleChainId, blockNumbersForChain, multiChainBlocks) {
|
|
577
|
+
// The starting block number should be one block after the last queried block number to not double report that block.
|
|
578
|
+
config.environmentVariables["STARTING_BLOCK_NUMBER"] =
|
|
579
|
+
Number(blockNumbersForChain[singleChainId].lastQueriedBlockNumber) + 1;
|
|
580
|
+
config.environmentVariables["ENDING_BLOCK_NUMBER"] = blockNumbersForChain[singleChainId].latestBlockNumber;
|
|
581
|
+
config.environmentVariables["BOT_IDENTIFIER"] = botName;
|
|
582
|
+
config.environmentVariables[RUN_IDENTIFIER_KEY] = generateRandomRunId();
|
|
583
|
+
if (multiChainBlocks)
|
|
584
|
+
multiChainBlocks.forEach((chainId) => {
|
|
585
|
+
config.environmentVariables[`STARTING_BLOCK_NUMBER_${chainId}`] =
|
|
586
|
+
Number(blockNumbersForChain[chainId].lastQueriedBlockNumber) + 1;
|
|
587
|
+
config.environmentVariables[`ENDING_BLOCK_NUMBER_${chainId}`] = blockNumbersForChain[chainId].latestBlockNumber;
|
|
588
|
+
});
|
|
589
|
+
return config;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Execute a post query on a arbitrary `url` with a given json `body. Used to test the hub script locally.
|
|
593
|
+
async function _postJson(url, body) {
|
|
594
|
+
const response = await fetch(url, {
|
|
595
|
+
method: "POST",
|
|
596
|
+
body: JSON.stringify(body),
|
|
597
|
+
headers: { "Content-type": "application/json", Accept: "application/json", "Accept-Charset": "utf-8" },
|
|
598
|
+
});
|
|
599
|
+
return await response.json(); // extract JSON from the http response
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Takes in a spokeResponse object for a given botKey and identifies if the response includes an error. If it does,
|
|
603
|
+
// append the error information to the errorOutputs. An error could be rejected from the spoke, timeout in the spoke,
|
|
604
|
+
// an error code from the spoke or the stdout is a blank string. If there is no error, append to validOutputs.
|
|
605
|
+
function _processSpokeResponse(botKey, spokeResponse, validOutputs, errorOutputs) {
|
|
606
|
+
if (spokeResponse.status == "rejected" && spokeResponse.reason.status == "timeout") {
|
|
607
|
+
errorOutputs[botKey] = {
|
|
608
|
+
status: "timeout",
|
|
609
|
+
message: spokeResponse.reason.message,
|
|
610
|
+
botIdentifier: botKey,
|
|
611
|
+
runIds: spokeResponse.reason.runIds,
|
|
612
|
+
};
|
|
613
|
+
} else if (
|
|
614
|
+
spokeResponse.status == "rejected" ||
|
|
615
|
+
(spokeResponse.value && !spokeResponse.value.success) ||
|
|
616
|
+
(spokeResponse.reason && spokeResponse.reason.code == "500")
|
|
617
|
+
) {
|
|
618
|
+
errorOutputs[botKey] = {
|
|
619
|
+
status: spokeResponse.status,
|
|
620
|
+
error: spokeResponse.value?.error || spokeResponse.reason?.error,
|
|
621
|
+
botIdentifier: botKey,
|
|
622
|
+
runIds: spokeResponse?.reason?.runIds || spokeResponse?.value?.runIds,
|
|
623
|
+
};
|
|
624
|
+
} else {
|
|
625
|
+
validOutputs[botKey] = {
|
|
626
|
+
status: spokeResponse.status,
|
|
627
|
+
success: true,
|
|
628
|
+
botIdentifier: botKey,
|
|
629
|
+
runIds: spokeResponse?.value?.runIds,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Returns a promise that is rejected after seconds delay. Used to limit how long a spoke can run for.
|
|
635
|
+
const _rejectAfterDelay = (seconds, childProcessIdentifier) =>
|
|
636
|
+
new Promise((_, reject) => {
|
|
637
|
+
setTimeout(reject, seconds * 1000, {
|
|
638
|
+
status: "timeout",
|
|
639
|
+
message: `The spoke call took longer than ${seconds} seconds to reply`,
|
|
640
|
+
childProcessIdentifier,
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Start the hub's async listening process. Enables injection of a logging instance & port for testing.
|
|
645
|
+
async function Poll(_customLogger, port = 8080, _spokeURL, _CustomNodeUrl, _hubConfig, spokeURLS) {
|
|
646
|
+
customLogger = _customLogger;
|
|
647
|
+
// The Serverless hub should have a configured URL to define the remote instance & a local node URL to boot.
|
|
648
|
+
if (!_spokeURL || !_CustomNodeUrl) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
"Bad environment! Specify a `SPOKE_URL` & `CUSTOM_NODE_URL` to point to the a Serverless spoke instance and an Ethereum node"
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Use custom logger if passed in. Otherwise, create a local logger.
|
|
655
|
+
const logger = customLogger || createNewLogger();
|
|
656
|
+
|
|
657
|
+
// Set configs to be used in the sererless execution.
|
|
658
|
+
spokeUrl = _spokeURL;
|
|
659
|
+
// This should be specified as an object Record<size:string,url:string>
|
|
660
|
+
spokeUrlTable = spokeURLS;
|
|
661
|
+
customNodeUrl = _CustomNodeUrl;
|
|
662
|
+
if (_hubConfig) hubConfig = { ...defaultHubConfig, ..._hubConfig };
|
|
663
|
+
else hubConfig = defaultHubConfig;
|
|
664
|
+
|
|
665
|
+
return hub.listen(port, () => {
|
|
666
|
+
logger.debug({
|
|
667
|
+
at: "ServerlessHub",
|
|
668
|
+
message: "Serverless hub initialized",
|
|
669
|
+
spokeUrl,
|
|
670
|
+
spokeUrlTable,
|
|
671
|
+
customNodeUrl,
|
|
672
|
+
hubConfig,
|
|
673
|
+
port,
|
|
674
|
+
env: process.env,
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
// If called directly by node, start the Poll process. If imported as a module then do nothing.
|
|
679
|
+
if (require.main === module) {
|
|
680
|
+
// add the logger, port, protocol runnerURL and custom node URL as params.
|
|
681
|
+
let hubConfig;
|
|
682
|
+
try {
|
|
683
|
+
hubConfig = process.env.HUB_CONFIG ? JSON.parse(process.env.HUB_CONFIG) : null;
|
|
684
|
+
} catch (error) {
|
|
685
|
+
console.error("Malformed hub config!", hubConfig);
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
let spokeURLS;
|
|
689
|
+
try {
|
|
690
|
+
spokeURLS = process.env.SPOKE_URLS ? JSON.parse(process.env.SPOKE_URLS) : {};
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error("Malformed SPOKE_URLS env!");
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
Poll(null, process.env.PORT, process.env.SPOKE_URL, process.env.CUSTOM_NODE_URL, hubConfig, spokeURLS).then(() => {});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
hub.Poll = Poll;
|
|
700
|
+
module.exports = hub;
|