envio 3.12.0 → 3.13.0-alpha.0
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/package.json +6 -6
- package/src/BatchProcessing.res +9 -9
- package/src/BatchProcessing.res.mjs +5 -5
- package/src/Bin.res +24 -17
- package/src/Bin.res.mjs +5 -0
- package/src/ChainFetching.res +31 -13
- package/src/ChainFetching.res.mjs +9 -4
- package/src/ChainState.res +55 -3
- package/src/ChainState.res.mjs +46 -3
- package/src/ChainState.resi +3 -1
- package/src/Config.res +35 -0
- package/src/Config.res.mjs +39 -0
- package/src/Core.res +4 -0
- package/src/Core.res.mjs +4 -0
- package/src/CrossChainState.res +61 -4
- package/src/CrossChainState.res.mjs +39 -5
- package/src/CrossChainState.resi +10 -1
- package/src/Env.res +4 -0
- package/src/FetchState.res +51 -46
- package/src/FetchState.res.mjs +54 -36
- package/src/InMemoryTable.res +155 -67
- package/src/InMemoryTable.res.mjs +151 -60
- package/src/IndexerLoop.res +2 -0
- package/src/IndexerLoop.res.mjs +1 -0
- package/src/IndexerState.res +41 -1
- package/src/IndexerState.res.mjs +43 -4
- package/src/IndexerState.resi +10 -0
- package/src/LoadLayer.res +57 -34
- package/src/LoadLayer.res.mjs +45 -62
- package/src/LoadLayer.resi +1 -1
- package/src/Logging.res +38 -6
- package/src/Logging.res.mjs +32 -5
- package/src/Main.res +131 -268
- package/src/Main.res.mjs +28 -152
- package/src/Metrics.res +263 -102
- package/src/Metrics.res.mjs +227 -48
- package/src/Persistence.res +27 -2
- package/src/Persistence.res.mjs +9 -2
- package/src/PgStorage.res +109 -71
- package/src/PgStorage.res.mjs +88 -58
- package/src/Server.res +181 -0
- package/src/Server.res.mjs +143 -0
- package/src/Supervisor.res +415 -0
- package/src/Supervisor.res.mjs +325 -0
- package/src/TestIndexer.res +9 -25
- package/src/TestIndexer.res.mjs +5 -4
- package/src/UserContext.res +13 -32
- package/src/UserContext.res.mjs +1 -7
- package/src/Utils.res +1 -4
- package/src/Utils.res.mjs +7 -16
- package/src/Worker.res +95 -0
- package/src/Worker.res.mjs +80 -0
- package/src/bindings/NodeJs.res +41 -0
- package/src/db/EntityFilter.res +487 -275
- package/src/db/EntityFilter.res.mjs +557 -309
- package/src/db/InternalTable.res +8 -1
- package/src/db/InternalTable.res.mjs +5 -1
- package/src/db/Table.res +21 -6
- package/src/db/Table.res.mjs +13 -4
- package/src/sources/BlockStore.res +7 -2
- package/src/sources/EvmHyperSyncSource.res +2 -0
- package/src/sources/EvmHyperSyncSource.res.mjs +2 -2
- package/src/sources/FuelHyperSyncSource.res +1 -0
- package/src/sources/FuelHyperSyncSource.res.mjs +1 -1
- package/src/sources/HyperSync.res +4 -0
- package/src/sources/HyperSync.res.mjs +4 -2
- package/src/sources/HyperSync.resi +1 -0
- package/src/sources/HyperSyncClient.res +3 -0
- package/src/sources/HyperSyncSSE.res +1 -1
- package/src/sources/HyperSyncSSE.res.mjs +4 -10
- package/src/sources/RpcSource.res +1 -0
- package/src/sources/RpcSource.res.mjs +1 -1
- package/src/sources/SimulateSource.res +1 -0
- package/src/sources/SimulateSource.res.mjs +1 -1
- package/src/sources/Source.res +7 -0
- package/src/sources/SourceManager.res +4 -3
- package/src/sources/SourceManager.res.mjs +2 -2
- package/src/sources/SvmHyperSyncClient.res +5 -0
- package/src/sources/SvmHyperSyncSource.res +3 -0
- package/src/sources/SvmHyperSyncSource.res.mjs +4 -2
- package/src/tui/Tui.res +24 -0
- package/src/tui/Tui.res.mjs +18 -0
- package/src/tui/components/SyncETA.res +12 -6
- package/src/tui/components/SyncETA.res.mjs +12 -8
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Env from "./Env.res.mjs";
|
|
4
|
+
import * as ChainId from "./ChainId.res.mjs";
|
|
5
|
+
import * as Logging from "./Logging.res.mjs";
|
|
6
|
+
import * as Metrics from "./Metrics.res.mjs";
|
|
7
|
+
import Express from "express";
|
|
8
|
+
import * as Process from "process";
|
|
9
|
+
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
10
|
+
import * as S$RescriptSchema from "rescript-schema/src/S.res.mjs";
|
|
11
|
+
|
|
12
|
+
function toChainData(m) {
|
|
13
|
+
return {
|
|
14
|
+
chainId: m.chainId,
|
|
15
|
+
poweredByHyperSync: m.poweredByHyperSync,
|
|
16
|
+
firstEventBlockNumber: m.firstEventBlockNumber,
|
|
17
|
+
latestProcessedBlock: m.latestProcessedBlock,
|
|
18
|
+
timestampCaughtUpToHeadOrEndblock: m.timestampCaughtUpToHeadOrEndblock,
|
|
19
|
+
numEventsProcessed: m.numEventsProcessed,
|
|
20
|
+
latestFetchedBlockNumber: m.latestFetchedBlockNumber,
|
|
21
|
+
currentBlockHeight: m.knownHeight,
|
|
22
|
+
numBatchesFetched: m.numBatchesFetched,
|
|
23
|
+
startBlock: m.startBlock,
|
|
24
|
+
endBlock: m.endBlock,
|
|
25
|
+
numAddresses: m.numAddresses
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let chainDataSchema = S$RescriptSchema.schema(s => ({
|
|
30
|
+
chainId: s.m(ChainId.schema),
|
|
31
|
+
poweredByHyperSync: s.m(S$RescriptSchema.bool),
|
|
32
|
+
firstEventBlockNumber: s.m(S$RescriptSchema.option(S$RescriptSchema.int)),
|
|
33
|
+
latestProcessedBlock: s.m(S$RescriptSchema.option(S$RescriptSchema.int)),
|
|
34
|
+
timestampCaughtUpToHeadOrEndblock: s.m(S$RescriptSchema.option(S$RescriptSchema.datetime(S$RescriptSchema.string, undefined))),
|
|
35
|
+
numEventsProcessed: s.m(S$RescriptSchema.float),
|
|
36
|
+
latestFetchedBlockNumber: s.m(S$RescriptSchema.int),
|
|
37
|
+
currentBlockHeight: s.m(S$RescriptSchema.int),
|
|
38
|
+
numBatchesFetched: s.m(S$RescriptSchema.int),
|
|
39
|
+
startBlock: s.m(S$RescriptSchema.int),
|
|
40
|
+
endBlock: s.m(S$RescriptSchema.option(S$RescriptSchema.int)),
|
|
41
|
+
numAddresses: s.m(S$RescriptSchema.int)
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
let stateSchema = S$RescriptSchema.union([
|
|
45
|
+
S$RescriptSchema.literal({
|
|
46
|
+
status: "disabled"
|
|
47
|
+
}),
|
|
48
|
+
S$RescriptSchema.literal({
|
|
49
|
+
status: "initializing"
|
|
50
|
+
}),
|
|
51
|
+
S$RescriptSchema.schema(s => ({
|
|
52
|
+
status: "active",
|
|
53
|
+
envioVersion: s.m(S$RescriptSchema.string),
|
|
54
|
+
chains: s.m(S$RescriptSchema.array(chainDataSchema)),
|
|
55
|
+
indexerStartTime: s.m(S$RescriptSchema.datetime(S$RescriptSchema.string, undefined)),
|
|
56
|
+
isPreRegisteringDynamicContracts: false,
|
|
57
|
+
rollbackOnReorg: s.m(S$RescriptSchema.bool)
|
|
58
|
+
}))
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function startServer(getMetrics, envioVersion, onSyncCache, collectRuntime, isDevelopmentMode) {
|
|
62
|
+
let app = Express();
|
|
63
|
+
let consoleCorsMiddleware = (req, res, next) => {
|
|
64
|
+
let origin = req.headers["origin"];
|
|
65
|
+
if (origin !== undefined && (origin === Env.prodEnvioAppUrl || origin === Env.envioAppUrl)) {
|
|
66
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
67
|
+
}
|
|
68
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
69
|
+
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
|
|
70
|
+
if (req.method === "OPTIONS") {
|
|
71
|
+
res.sendStatus(200);
|
|
72
|
+
return;
|
|
73
|
+
} else {
|
|
74
|
+
return next();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
app.use("/console", consoleCorsMiddleware);
|
|
78
|
+
app.use("/metrics", consoleCorsMiddleware);
|
|
79
|
+
app.use("/metrics/runtime", consoleCorsMiddleware);
|
|
80
|
+
app.get("/healthz", (_req, res) => {
|
|
81
|
+
res.sendStatus(200);
|
|
82
|
+
});
|
|
83
|
+
app.get("/console/state", (_req, res) => {
|
|
84
|
+
let state;
|
|
85
|
+
if (isDevelopmentMode) {
|
|
86
|
+
let metrics = getMetrics();
|
|
87
|
+
state = metrics !== undefined ? ({
|
|
88
|
+
status: "active",
|
|
89
|
+
envioVersion: envioVersion,
|
|
90
|
+
chains: metrics.chains.map(toChainData),
|
|
91
|
+
indexerStartTime: metrics.startTime,
|
|
92
|
+
isPreRegisteringDynamicContracts: false,
|
|
93
|
+
rollbackOnReorg: metrics.rollbackEnabled
|
|
94
|
+
}) : ({
|
|
95
|
+
status: "initializing"
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
state = {
|
|
99
|
+
status: "disabled"
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
res.json(S$RescriptSchema.reverseConvertToJsonOrThrow(state, stateSchema));
|
|
103
|
+
});
|
|
104
|
+
app.post("/console/syncCache", (_req, res) => {
|
|
105
|
+
if (isDevelopmentMode) {
|
|
106
|
+
Stdlib_Promise.$$catch(onSyncCache().then(() => {
|
|
107
|
+
res.json(true);
|
|
108
|
+
}), exn => {
|
|
109
|
+
Logging.errorWithExn(exn, "Failed to sync the effect cache");
|
|
110
|
+
res.json(false);
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
res.json(false);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
app.get("/metrics", (_req, res) => {
|
|
118
|
+
res.set("Content-Type", Metrics.contentType);
|
|
119
|
+
res.end(Metrics.collect(getMetrics()));
|
|
120
|
+
});
|
|
121
|
+
app.get("/metrics/runtime", (_req, res) => {
|
|
122
|
+
res.set("Content-Type", Metrics.contentType);
|
|
123
|
+
res.end(collectRuntime());
|
|
124
|
+
});
|
|
125
|
+
let server = app.listen(Env.serverPort);
|
|
126
|
+
server.on("error", err => {
|
|
127
|
+
let code = err.code;
|
|
128
|
+
if (code === "EADDRINUSE") {
|
|
129
|
+
Logging.error(`Port ` + Env.serverPort.toString() + ` is already in use. To fix this either:` + (`\n 1. Kill the process using the port: lsof -ti :` + Env.serverPort.toString() + ` | xargs kill -9`) + `\n 2. Use a different port by setting the ENVIO_INDEXER_PORT environment variable: ENVIO_INDEXER_PORT=9899 envio start`);
|
|
130
|
+
} else {
|
|
131
|
+
Logging.errorWithExn(err, "Failed to start indexer server");
|
|
132
|
+
}
|
|
133
|
+
Process.exit(1);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
toChainData,
|
|
139
|
+
chainDataSchema,
|
|
140
|
+
stateSchema,
|
|
141
|
+
startServer,
|
|
142
|
+
}
|
|
143
|
+
/* chainDataSchema Not a pure module */
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// One worker process and the chains it drives. `maxConnections` is its slice of
|
|
2
|
+
// the run's connection budget, which the pool it opens is capped to.
|
|
3
|
+
type worker = {chainIds: array<ChainId.t>, maxConnections: int}
|
|
4
|
+
|
|
5
|
+
// Every worker needs enough connections to read and write without serializing
|
|
6
|
+
// on a single one, so the budget buys workers two at a time.
|
|
7
|
+
let minConnectionsPerWorker = 2
|
|
8
|
+
|
|
9
|
+
// How to spend a connection budget on the chains a run indexes. `None` keeps
|
|
10
|
+
// the run in one process, which is what a budget too small to afford two
|
|
11
|
+
// workers, or a config with nothing to split, has to do.
|
|
12
|
+
//
|
|
13
|
+
// Chains are dealt in config order and the direction reverses each pass, so
|
|
14
|
+
// the first chains lead different workers and the worker that took the first
|
|
15
|
+
// picks up the last. How much work a chain has is the contracts' to decide,
|
|
16
|
+
// not the chain's, so config order is the one ranking the run can be given:
|
|
17
|
+
// listing chains busiest-first in config.yaml is what balances the layout.
|
|
18
|
+
let plan = (~chainIds: array<ChainId.t>, ~maxConnections: int): option<array<worker>> => {
|
|
19
|
+
let workerCount = Pervasives.min(chainIds->Array.length, maxConnections / minConnectionsPerWorker)
|
|
20
|
+
if workerCount < 2 {
|
|
21
|
+
None
|
|
22
|
+
} else {
|
|
23
|
+
// The remainder is handed out one connection at a time rather than left
|
|
24
|
+
// unspent, so a budget with slack widens the earliest workers' pools.
|
|
25
|
+
let evenShare = maxConnections / workerCount
|
|
26
|
+
let remainder = mod(maxConnections, workerCount)
|
|
27
|
+
Some(
|
|
28
|
+
Array.fromInitializer(~length=workerCount, workerIndex => {
|
|
29
|
+
chainIds: chainIds->Array.filterWithIndex((_, dealIndex) => {
|
|
30
|
+
let position = mod(dealIndex, workerCount)
|
|
31
|
+
let isReversePass = mod(dealIndex / workerCount, 2) === 1
|
|
32
|
+
(isReversePass ? workerCount - 1 - position : position) === workerIndex
|
|
33
|
+
}),
|
|
34
|
+
maxConnections: evenShare + (workerIndex < remainder ? 1 : 0),
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether this run splits, and how. A schema that shares entities across chains
|
|
41
|
+
// can't be split: workers each advance their own checkpoint sequence, which only
|
|
42
|
+
// holds while no entity has rows another chain can reach. A run that is already
|
|
43
|
+
// one chain's process doesn't split again — whoever started it owns the layout.
|
|
44
|
+
let planForRun = (~config: Config.t, ~maxConnections=Env.Db.maxConnections) =>
|
|
45
|
+
if config.isolated || !(config->Config.isPerChain) {
|
|
46
|
+
None
|
|
47
|
+
} else {
|
|
48
|
+
plan(~chainIds=config.chainMap->ChainMap.values->Array.map(chain => chain.id), ~maxConnections)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One forked worker: the process, the chains it drives, and the last snapshot
|
|
52
|
+
// it reported. `None` until it reports, which is what makes a run that hasn't
|
|
53
|
+
// heard from anyone yet render as initializing rather than as empty.
|
|
54
|
+
type running = {
|
|
55
|
+
worker: worker,
|
|
56
|
+
child: NodeJs.ChildProcess.child,
|
|
57
|
+
mutable snapshot: option<Metrics.t>,
|
|
58
|
+
mutable runtime: option<Metrics.runtimeSample>,
|
|
59
|
+
// A spawn failure can raise `error` and `exit` both, and a worker counted
|
|
60
|
+
// twice would end the run while its siblings are still indexing.
|
|
61
|
+
mutable settled: bool,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A worker is named by the chains it drives, which is what an operator reading
|
|
65
|
+
// its memory or its event loop wants to know.
|
|
66
|
+
let name = (worker: worker) => worker.chainIds->Array.map(ChainId.toString)->Array.joinUnsafe(";")
|
|
67
|
+
|
|
68
|
+
let label = (worker: worker) => `[chain ${worker->name}]`
|
|
69
|
+
|
|
70
|
+
// Workers append to files of their own. Pino writes a line per call, and
|
|
71
|
+
// several processes appending to one file can still tear a long line apart.
|
|
72
|
+
let logFilePath = (~workerIndex, ~path=Env.logFilePath) => {
|
|
73
|
+
let suffix = `.worker-${workerIndex->Int.toString}`
|
|
74
|
+
// A dot in a directory name isn't an extension, and a path with no dot at
|
|
75
|
+
// all has none either: `./logs/envio` takes the suffix at the end.
|
|
76
|
+
let dot = path->String.lastIndexOf(".")
|
|
77
|
+
if dot > path->String.lastIndexOf("/") {
|
|
78
|
+
`${path->String.slice(~start=0, ~end=dot)}${suffix}${path->String.slice(
|
|
79
|
+
~start=dot,
|
|
80
|
+
~end=path->String.length,
|
|
81
|
+
)}`
|
|
82
|
+
} else {
|
|
83
|
+
`${path}${suffix}`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A pipe hands over whatever has been flushed, so a chunk boundary falls
|
|
88
|
+
// wherever the OS put it: the tail of a chunk is a line only once the chunk
|
|
89
|
+
// that ends it arrives. Reading pairs with a flush, since a process that dies
|
|
90
|
+
// mid-line still wrote what it managed to — which is when it matters most.
|
|
91
|
+
let readLines = (~onLine) => {
|
|
92
|
+
let pending = ref("")
|
|
93
|
+
let read = chunk => {
|
|
94
|
+
let parts = (pending.contents ++ chunk)->String.split("\n")
|
|
95
|
+
pending := parts->Array.pop->Option.getOr("")
|
|
96
|
+
parts->Array.forEach(onLine)
|
|
97
|
+
}
|
|
98
|
+
let flush = () =>
|
|
99
|
+
switch pending.contents {
|
|
100
|
+
| "" => ()
|
|
101
|
+
| line => {
|
|
102
|
+
pending := ""
|
|
103
|
+
onLine(line)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
(read, flush)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Whether this process's own output is a terminal. `pino-pretty` colorizes on
|
|
110
|
+
// that test, and a piped worker would fail it for a run the operator is
|
|
111
|
+
// watching in colour.
|
|
112
|
+
@val external stdoutIsTty: Nullable.t<bool> = "process.stdout.isTTY"
|
|
113
|
+
|
|
114
|
+
let fork = (
|
|
115
|
+
worker: worker,
|
|
116
|
+
~workerIndex,
|
|
117
|
+
// Whether this worker waits for the run before going realtime. False when
|
|
118
|
+
// every chain resumed already caught up: there is nothing left to wait for,
|
|
119
|
+
// and a barrier nobody can open would hold the run forever.
|
|
120
|
+
~holdRealtime,
|
|
121
|
+
// The entry this process was itself started from, so a worker is the same
|
|
122
|
+
// program as its supervisor however the package was installed.
|
|
123
|
+
~entryPath=NodeJs.Process.argv->Array.getUnsafe(1),
|
|
124
|
+
// A run that draws a display reads its workers' output instead of letting
|
|
125
|
+
// them write to the terminal behind the frame's back.
|
|
126
|
+
~pipeOutput=false,
|
|
127
|
+
~onOutput=Console.log,
|
|
128
|
+
) => {
|
|
129
|
+
let env = NodeJs.Process.process.env->Dict.copy
|
|
130
|
+
env->Dict.set(
|
|
131
|
+
Worker.envVar,
|
|
132
|
+
{Worker.chainIds: worker.chainIds, holdRealtime}->S.reverseConvertToJsonStringOrThrow(
|
|
133
|
+
Worker.configSchema,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
// The worker's slice of the budget. Read when the worker's own Env module
|
|
137
|
+
// loads, which is why it rides in the spawn environment rather than a message.
|
|
138
|
+
env->Dict.set("ENVIO_PG_MAX_CONNECTIONS", worker.maxConnections->Int.toString)
|
|
139
|
+
env->Dict.set("LOG_FILE", logFilePath(~workerIndex))
|
|
140
|
+
if pipeOutput && stdoutIsTty->Nullable.toOption->Option.getOr(false) {
|
|
141
|
+
env->Dict.set("FORCE_COLOR", "1")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let child = NodeJs.ChildProcess.fork(
|
|
145
|
+
entryPath,
|
|
146
|
+
[],
|
|
147
|
+
{
|
|
148
|
+
env,
|
|
149
|
+
serialization: "advanced",
|
|
150
|
+
stdio: pipeOutput
|
|
151
|
+
? ["inherit", "pipe", "pipe", "ipc"]
|
|
152
|
+
: ["inherit", "inherit", "inherit", "ipc"],
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
if pipeOutput {
|
|
156
|
+
// Both streams become one stream of lines: the supervisor logs them the way
|
|
157
|
+
// it logs its own, which is the only way ink can keep them out of its frame.
|
|
158
|
+
[child->NodeJs.ChildProcess.stdout, child->NodeJs.ChildProcess.stderr]->Array.forEach(stream =>
|
|
159
|
+
switch stream->Null.toOption {
|
|
160
|
+
| Some(stream) => {
|
|
161
|
+
let (read, flush) = readLines(~onLine=onOutput)
|
|
162
|
+
stream->NodeJs.ChildProcess.setEncoding("utf8")
|
|
163
|
+
stream->NodeJs.ChildProcess.onData(read)
|
|
164
|
+
stream->NodeJs.ChildProcess.onEnd(flush)
|
|
165
|
+
}
|
|
166
|
+
| None => ()
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
let running = {worker, child, snapshot: None, runtime: None, settled: false}
|
|
171
|
+
child->NodeJs.ChildProcess.onMessage(message =>
|
|
172
|
+
switch message {
|
|
173
|
+
| Worker.Snapshot({metrics, runtime}) => {
|
|
174
|
+
running.snapshot = Some(metrics)
|
|
175
|
+
running.runtime = Some(runtime)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
running
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The forked workers of one run, and whether their supervisor is the one
|
|
183
|
+
// taking them down. A stop it asked for is expected; every other way a worker
|
|
184
|
+
// can end is a failure.
|
|
185
|
+
type group = {running: array<running>, mutable stopping: bool}
|
|
186
|
+
|
|
187
|
+
let stop = group => {
|
|
188
|
+
group.stopping = true
|
|
189
|
+
group.running->Array.forEach(r => r.child->NodeJs.ChildProcess.kill("SIGTERM")->ignore)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The dev console's cache dump, which belongs to the supervisor rather than to
|
|
193
|
+
// its workers: a dump copies every effect cache table in the schema to a file
|
|
194
|
+
// named after the effect, so a worker asked to do it would copy its siblings'
|
|
195
|
+
// chains too, and several asked at once would write the same files at the same
|
|
196
|
+
// time. Nothing in it is a worker's to know — the rows it copies are the ones
|
|
197
|
+
// already committed.
|
|
198
|
+
//
|
|
199
|
+
// Requests that overlap join the dump in flight, for the same reason.
|
|
200
|
+
let syncCache = {
|
|
201
|
+
let inFlight = ref(None)
|
|
202
|
+
(~dump) =>
|
|
203
|
+
switch inFlight.contents {
|
|
204
|
+
| Some(dumping) => dumping
|
|
205
|
+
| None =>
|
|
206
|
+
let dumping = dump()->Promise.finally(() => inFlight := None)
|
|
207
|
+
inFlight := Some(dumping)
|
|
208
|
+
dumping
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The supervisor handed its connections to the workers, so a dump opens one of
|
|
213
|
+
// its own for as long as it takes. That puts the run one connection over its
|
|
214
|
+
// budget, deliberately: the console that asks for a dump is `envio dev` only,
|
|
215
|
+
// one connection is a cheaper price than pausing the indexing to free one, and
|
|
216
|
+
// the pool is capped at that one.
|
|
217
|
+
let dumpCache = (~config) => {
|
|
218
|
+
let storage = PgStorage.makeStorageFromEnv(~config, ~sql=PgStorage.makeClient(~maxConnections=1))
|
|
219
|
+
storage.dumpEffectCache()->Promise.finally(() => storage.close()->Promise.ignore)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// How a group ended. `Finished` is every worker exiting cleanly on its own,
|
|
223
|
+
// which is what indexing to every end block looks like.
|
|
224
|
+
type outcome = Finished | Stopped
|
|
225
|
+
|
|
226
|
+
// Resolves once every worker has ended. Throws if any of them ended in a way
|
|
227
|
+
// the supervisor didn't ask for, having first taken the rest down: one worker
|
|
228
|
+
// short leaves its chains unindexed, and a run that kept the others going would
|
|
229
|
+
// look healthy while falling behind.
|
|
230
|
+
let awaitExit = async (group): outcome => {
|
|
231
|
+
let failed = ref(false)
|
|
232
|
+
let alive = ref(group.running->Array.length)
|
|
233
|
+
|
|
234
|
+
await Promise.make((resolve, _) => {
|
|
235
|
+
let onGone = (r, ~failure) =>
|
|
236
|
+
if !r.settled {
|
|
237
|
+
r.settled = true
|
|
238
|
+
if failure {
|
|
239
|
+
failed := true
|
|
240
|
+
if !group.stopping {
|
|
241
|
+
group->stop
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
alive := alive.contents - 1
|
|
245
|
+
if alive.contents === 0 {
|
|
246
|
+
resolve()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
group.running->Array.forEach(r => {
|
|
251
|
+
r.child->NodeJs.ChildProcess.onExit(
|
|
252
|
+
(code, _signal) =>
|
|
253
|
+
// Only an exit the supervisor asked for is expected. Anything else — a
|
|
254
|
+
// non-zero code, or a signal like the kernel's out-of-memory kill.
|
|
255
|
+
r->onGone(~failure=!group.stopping && code->Null.toOption !== Some(0)),
|
|
256
|
+
)
|
|
257
|
+
r.child->NodeJs.ChildProcess.onChildError(
|
|
258
|
+
exn => {
|
|
259
|
+
Logging.errorWithExn(exn, `${r.worker->label} failed to start`)
|
|
260
|
+
r->onGone(~failure=true)
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
if failed.contents {
|
|
267
|
+
JsError.throwWithMessage("An indexer process exited with a failure. Stopped the others.")
|
|
268
|
+
}
|
|
269
|
+
group.stopping ? Stopped : Finished
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// How often the supervisor asks whether the run may go realtime. Matches the
|
|
273
|
+
// rate its workers report at: nothing changes in between.
|
|
274
|
+
%%private(let releaseCheckIntervalMillis = 500)
|
|
275
|
+
|
|
276
|
+
// Whether a run holding its workers back may let them go: every worker has
|
|
277
|
+
// reported, and every one of them has got as far as it can on its own. What
|
|
278
|
+
// counts as arrived is the worker's own conclusion — the supervisor only asks
|
|
279
|
+
// each of them the question an unsplit run asks itself.
|
|
280
|
+
let isRunAtHead = (snapshots: array<Metrics.t>, ~workerCount) =>
|
|
281
|
+
snapshots->Array.length === workerCount && snapshots->Array.every(snapshot =>
|
|
282
|
+
snapshot.hasArrivedAtHead
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
// Runs the group: creates the schema for every chain, forks a worker per plan
|
|
286
|
+
// entry, and serves the run's metrics, console and display from what they
|
|
287
|
+
// report. Returns once every worker has exited; throws if any of them failed.
|
|
288
|
+
let run = async (~config: Config.t, ~workers: array<worker>, ~reset) => {
|
|
289
|
+
// Every chain's state has to exist before a worker resumes it: an isolated
|
|
290
|
+
// run refuses to initialize, precisely so it can't create rows for its own
|
|
291
|
+
// chains and leave the chains it skipped with nothing to resume. It is the
|
|
292
|
+
// same initialization an unsplit run does, and the supervisor hands the
|
|
293
|
+
// connections it used to its workers.
|
|
294
|
+
let persistence = PgStorage.makePersistenceFromConfig(~config)
|
|
295
|
+
await persistence->Persistence.initForRun(
|
|
296
|
+
~config,
|
|
297
|
+
~reset,
|
|
298
|
+
~isDevelopmentMode=config.isDev,
|
|
299
|
+
~requireInitialized=false,
|
|
300
|
+
)
|
|
301
|
+
await persistence.storage.close()
|
|
302
|
+
|
|
303
|
+
let startTime = Date.make()
|
|
304
|
+
let startTimeRef = Performance.now()
|
|
305
|
+
|
|
306
|
+
Logging.info(
|
|
307
|
+
`Splitting ${config.chainMap
|
|
308
|
+
->ChainMap.values
|
|
309
|
+
->Array.length
|
|
310
|
+
->Int.toString} chains across ${workers
|
|
311
|
+
->Array.length
|
|
312
|
+
->Int.toString} processes, from a budget of ${Env.Db.maxConnections->Int.toString} database connections.`,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
// Decided before the first fork: it is what makes a worker's output the
|
|
316
|
+
// supervisor's to print.
|
|
317
|
+
let shouldUseTui = Tui.shouldUse()
|
|
318
|
+
// A run that resumed with every chain already caught up owes nobody a wait:
|
|
319
|
+
// its workers start realtime and there is no barrier to open.
|
|
320
|
+
let holdRealtime =
|
|
321
|
+
(persistence->Persistence.getInitializedState).chains->Array.some(chain =>
|
|
322
|
+
chain.timestampCaughtUpToHeadOrEndblock->Option.isNone
|
|
323
|
+
)
|
|
324
|
+
let group = {
|
|
325
|
+
running: workers->Array.mapWithIndex((worker, workerIndex) =>
|
|
326
|
+
worker->fork(~workerIndex, ~holdRealtime, ~pipeOutput=shouldUseTui)
|
|
327
|
+
),
|
|
328
|
+
stopping: false,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let reported = () => group.running->Array.filterMap(r => r.snapshot)
|
|
332
|
+
let merge = snapshots =>
|
|
333
|
+
Metrics.merge(
|
|
334
|
+
snapshots,
|
|
335
|
+
~startTime,
|
|
336
|
+
~metricTime=Date.make(),
|
|
337
|
+
~elapsedSeconds=startTimeRef->Performance.secondsSince,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
Server.startServer(
|
|
341
|
+
// Nothing to report until a worker has: the run reads as initializing
|
|
342
|
+
// rather than as an indexer with no chains.
|
|
343
|
+
~getMetrics=() =>
|
|
344
|
+
switch reported() {
|
|
345
|
+
| [] => None
|
|
346
|
+
| snapshots => Some(snapshots->merge)
|
|
347
|
+
},
|
|
348
|
+
~envioVersion=Utils.EnvioPackage.value.version,
|
|
349
|
+
// The workers' readings, each under a `worker` label: theirs are the memory
|
|
350
|
+
// and the event loop the indexing runs on.
|
|
351
|
+
~collectRuntime=() =>
|
|
352
|
+
Metrics.renderRuntime(
|
|
353
|
+
group.running->Array.filterMap(r =>
|
|
354
|
+
r.runtime->Option.map(runtime => (`worker="${r.worker->name}"`, runtime))
|
|
355
|
+
),
|
|
356
|
+
),
|
|
357
|
+
~isDevelopmentMode=config.isDev,
|
|
358
|
+
~onSyncCache=() => syncCache(~dump=() => dumpCache(~config)),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
// Chains enter the reorg threshold and go realtime as one indexer, which in a
|
|
362
|
+
// split run only the supervisor can see. Every worker is held until the last
|
|
363
|
+
// one arrives, then released together, so the run switches over exactly as an
|
|
364
|
+
// unsplit one does.
|
|
365
|
+
let releaseCheck = ref(None)
|
|
366
|
+
let stopReleaseCheck = () => {
|
|
367
|
+
releaseCheck.contents->Option.forEach(clearInterval)
|
|
368
|
+
releaseCheck := None
|
|
369
|
+
}
|
|
370
|
+
if holdRealtime {
|
|
371
|
+
releaseCheck :=
|
|
372
|
+
Some(
|
|
373
|
+
setInterval(() =>
|
|
374
|
+
if reported()->isRunAtHead(~workerCount=group.running->Array.length) {
|
|
375
|
+
stopReleaseCheck()
|
|
376
|
+
group.running->Array.forEach(r =>
|
|
377
|
+
r.child->NodeJs.ChildProcess.send(Worker.ReleaseRealtime)->ignore
|
|
378
|
+
)
|
|
379
|
+
Logging.info("Every chain has reached the head. Switching the run to realtime.")
|
|
380
|
+
}
|
|
381
|
+
, releaseCheckIntervalMillis),
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if shouldUseTui {
|
|
386
|
+
let _rerender = Tui.start(~config, ~getMetrics=() => reported()->merge)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Whichever signal asks the run to stop, the supervisor is the one that
|
|
390
|
+
// stops the workers: an interrupt from the terminal reaches them too, but
|
|
391
|
+
// they leave it to the supervisor.
|
|
392
|
+
NodeJs.Process.onSignal("SIGTERM", () => group->stop)
|
|
393
|
+
NodeJs.Process.onSignal("SIGINT", () => group->stop)
|
|
394
|
+
|
|
395
|
+
// The server and the signal handlers would keep this process up after its
|
|
396
|
+
// last worker is gone, so the group's end has to end the process. A display
|
|
397
|
+
// is the exception, as it is for a single process: it keeps the final state
|
|
398
|
+
// on screen until the terminal closes it.
|
|
399
|
+
let outcome = await group->awaitExit
|
|
400
|
+
// Nothing left to release, and a display keeps this process alive long enough
|
|
401
|
+
// for the check to reach children that are gone.
|
|
402
|
+
stopReleaseCheck()
|
|
403
|
+
|
|
404
|
+
switch outcome {
|
|
405
|
+
| Stopped => NodeJs.process->NodeJs.exitWithCode(Success)
|
|
406
|
+
| Finished if !shouldUseTui =>
|
|
407
|
+
Logging.info("Exiting with success")
|
|
408
|
+
NodeJs.process->NodeJs.exitWithCode(Success)
|
|
409
|
+
| Finished =>
|
|
410
|
+
// With nothing left to stop, the stop signals end the display instead.
|
|
411
|
+
// Registering a handler above took over from Node's default exit.
|
|
412
|
+
NodeJs.Process.onSignal("SIGTERM", () => NodeJs.process->NodeJs.exitWithCode(Success))
|
|
413
|
+
NodeJs.Process.onSignal("SIGINT", () => NodeJs.process->NodeJs.exitWithCode(Success))
|
|
414
|
+
}
|
|
415
|
+
}
|