@effectstream/runtime 0.100.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 +34 -0
- package/src/api/http-server.ts +942 -0
- package/src/api/pagination.ts +456 -0
- package/src/api/rpc-evm/eip1193.ts +920 -0
- package/src/api/rpc-evm/rpc-payloads.ts +297 -0
- package/src/api/rpc-evm/rpc-types.ts +87 -0
- package/src/api/rpc-evm/rpc-utils.ts +50 -0
- package/src/config-snapshot.ts +192 -0
- package/src/main.ts +317 -0
- package/src/mod.ts +3 -0
- package/src/process-blocks.ts +302 -0
- package/src/telemetry.ts +37 -0
- package/src/types.ts +76 -0
- package/src/version-migrations.ts +163 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AllSyncProtocols,
|
|
3
|
+
type ChainBlock,
|
|
4
|
+
genSyncProtocols,
|
|
5
|
+
} from "@effectstream/sync";
|
|
6
|
+
import {
|
|
7
|
+
acquireDBMutex,
|
|
8
|
+
createDynamicTables,
|
|
9
|
+
getConnection,
|
|
10
|
+
releaseDBMutex,
|
|
11
|
+
resetPublicTables,
|
|
12
|
+
runSnapshotLoop,
|
|
13
|
+
} from "@effectstream/db";
|
|
14
|
+
import { EventBroker } from "@effectstream/event-server";
|
|
15
|
+
import {
|
|
16
|
+
BuiltinEvents,
|
|
17
|
+
EventManager,
|
|
18
|
+
} from "@effectstream/event-client";
|
|
19
|
+
import { startMerge, startSync } from "@effectstream/sync";
|
|
20
|
+
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
21
|
+
import {
|
|
22
|
+
createChannel,
|
|
23
|
+
each,
|
|
24
|
+
type Operation,
|
|
25
|
+
sleep,
|
|
26
|
+
spawn,
|
|
27
|
+
until,
|
|
28
|
+
} from "effection";
|
|
29
|
+
import { initTelemetry } from "./telemetry.ts";
|
|
30
|
+
import { processFinalizedBlock } from "./process-blocks.ts";
|
|
31
|
+
import { startHttpServer } from "./api/http-server.ts";
|
|
32
|
+
import type { StartConfig } from "./types.ts";
|
|
33
|
+
import type { Client } from "pg";
|
|
34
|
+
import type { EffectstreamBlockHash } from "@effectstream/utils";
|
|
35
|
+
import { applySystemMigrations } from "./version-migrations.ts";
|
|
36
|
+
import { getLastBlockHeight, getVersionInfo } from "@effectstream/db/version";
|
|
37
|
+
import { type SyncProtocolWithNetwork, ConfigNetworkType } from "@effectstream/config";
|
|
38
|
+
import { builtInPrimitivesMap } from "@effectstream/sm";
|
|
39
|
+
import { validateAndSnapshotConfig } from "./config-snapshot.ts";
|
|
40
|
+
|
|
41
|
+
export function* init() {
|
|
42
|
+
// Prevent transient network errors (broken pipes, closed connections) from
|
|
43
|
+
// crashing the process via unhandled promise rejections. The sync loop's
|
|
44
|
+
// tryYield already retries on error — this just keeps the process alive.
|
|
45
|
+
globalThis.addEventListener("unhandledrejection", (event) => {
|
|
46
|
+
log.local(
|
|
47
|
+
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
48
|
+
"unhandled-rejection",
|
|
49
|
+
SeverityNumber.WARN,
|
|
50
|
+
(l) => l("Suppressed unhandled rejection:", event.reason),
|
|
51
|
+
);
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// initialize OpenTelemetry
|
|
56
|
+
yield* initTelemetry();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Main entry point to start the Paima Engine Node.
|
|
61
|
+
*
|
|
62
|
+
* This will launch the networks/primitives synchronization sub-processes,
|
|
63
|
+
* the HTTP server, and the merge and effectstream-block generation process.
|
|
64
|
+
*
|
|
65
|
+
* @param config - Paima Engine Node configuration object.
|
|
66
|
+
*/
|
|
67
|
+
export function* start(config: StartConfig): Operation<void> {
|
|
68
|
+
const { syncInfo } = config;
|
|
69
|
+
|
|
70
|
+
const dbConn = getConnection();
|
|
71
|
+
|
|
72
|
+
const syncProtocols = yield* startup(dbConn as any, // Client,
|
|
73
|
+
syncInfo, config);
|
|
74
|
+
|
|
75
|
+
log.remote(
|
|
76
|
+
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
77
|
+
[],
|
|
78
|
+
SeverityNumber.INFO,
|
|
79
|
+
(log) => log("start sync", syncProtocols.map(p => p.name)),
|
|
80
|
+
);
|
|
81
|
+
for (const syncProtocol of syncProtocols) {
|
|
82
|
+
yield* startSync(syncProtocol);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Create MQTT Broker
|
|
86
|
+
new EventBroker("effectstream-engine").createServer();
|
|
87
|
+
|
|
88
|
+
yield* spawn(function* () {
|
|
89
|
+
yield* startHttpServer(
|
|
90
|
+
dbConn,
|
|
91
|
+
syncProtocols,
|
|
92
|
+
config.apiRouter,
|
|
93
|
+
config.grammar,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const ntpConfig = syncInfo.find(s => s.networkType === ConfigNetworkType.NTP);
|
|
98
|
+
const ntpBlockTimeMS = (ntpConfig?.network as { blockTimeMS: number } | undefined)?.blockTimeMS;
|
|
99
|
+
const lagThresholdMs = ntpBlockTimeMS != null ? ntpBlockTimeMS * 10 : undefined;
|
|
100
|
+
|
|
101
|
+
const finalizedBlockStream = createChannel<ChainBlock>();
|
|
102
|
+
|
|
103
|
+
yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
|
|
104
|
+
|
|
105
|
+
const heartbeatIntervalMs = 60_000;
|
|
106
|
+
yield* spawn(function* () {
|
|
107
|
+
while (true) {
|
|
108
|
+
yield* sleep(heartbeatIntervalMs);
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const status = syncProtocols.map((p) => {
|
|
111
|
+
const page = p.lastPage;
|
|
112
|
+
if (page == null) return `${p.name}: waiting for first sync`;
|
|
113
|
+
const ageMs = now - (page.root as number);
|
|
114
|
+
let line = `${p.name}: block ${page.ownBlockNumber} | buf ${p.bufferedData.size()} | age ${(ageMs / 1000).toFixed(1)}s`;
|
|
115
|
+
if (p.consecutiveErrors > 0) {
|
|
116
|
+
const sinceLast = p.lastErrorTimestamp > 0
|
|
117
|
+
? ` ${((now - p.lastErrorTimestamp) / 1000).toFixed(0)}s ago`
|
|
118
|
+
: "";
|
|
119
|
+
line += ` | ERRORS: ${p.consecutiveErrors}${sinceLast}`;
|
|
120
|
+
} else if (p.lastSuccessfulFetchMs > 0) {
|
|
121
|
+
const idleMs = now - p.lastSuccessfulFetchMs;
|
|
122
|
+
if (idleMs > heartbeatIntervalMs * 2) {
|
|
123
|
+
line += ` | IDLE: ${(idleMs / 1000).toFixed(0)}s`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return line;
|
|
127
|
+
});
|
|
128
|
+
log.local(
|
|
129
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
130
|
+
"heartbeat",
|
|
131
|
+
SeverityNumber.INFO,
|
|
132
|
+
(l) => l(status.join(" | ")),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let blockHash: EffectstreamBlockHash | null = null;
|
|
138
|
+
if (config.snapshotConfig) {
|
|
139
|
+
yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const value of yield* each(finalizedBlockStream)) {
|
|
143
|
+
// We request a dbClient for a non-shared dbConn object.
|
|
144
|
+
// For PGLite, this is not enough, as the can only be one connection at a time.
|
|
145
|
+
// So we request a DBMutex as well.
|
|
146
|
+
let dbClient: Client | undefined;
|
|
147
|
+
try {
|
|
148
|
+
yield* acquireDBMutex(`processing-blocks:${value.blockNumber}`);
|
|
149
|
+
dbClient = yield* until((dbConn as any).connect()); // Client,
|
|
150
|
+
|
|
151
|
+
blockHash = yield* processFinalizedBlock(
|
|
152
|
+
value,
|
|
153
|
+
config,
|
|
154
|
+
dbClient as any, // Client,
|
|
155
|
+
blockHash,
|
|
156
|
+
);
|
|
157
|
+
} finally {
|
|
158
|
+
releaseDBMutex(`processing-blocks:${value.blockNumber}`);
|
|
159
|
+
if (dbClient) {
|
|
160
|
+
(dbClient as any).release(); // Client,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Used to emit & log the block range for each protocol.
|
|
165
|
+
const contentBlocksForProtocol = getRangesForSyncProtocols(value);
|
|
166
|
+
|
|
167
|
+
yield* until(
|
|
168
|
+
emitLatestBlocks(
|
|
169
|
+
value.blockNumber,
|
|
170
|
+
value.timestamp,
|
|
171
|
+
contentBlocksForProtocol,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const lagMs = Date.now() - value.timestamp;
|
|
176
|
+
const lagSuffix = lagThresholdMs != null && lagMs > lagThresholdMs
|
|
177
|
+
? ` | lag: ${(lagMs / 1000).toFixed(1)}s`
|
|
178
|
+
: "";
|
|
179
|
+
log.local(
|
|
180
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
181
|
+
"block-merge",
|
|
182
|
+
SeverityNumber.INFO,
|
|
183
|
+
(log) =>
|
|
184
|
+
log(
|
|
185
|
+
`finalized block ${value.blockNumber} @ ${
|
|
186
|
+
blockHash?.slice(0, 8)
|
|
187
|
+
}...${lagSuffix} | ${JSON.stringify(contentBlocksForProtocol)}`,
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
yield* each.next();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getRangesForSyncProtocols(value: ChainBlock): Record<string, [number, number]> {
|
|
195
|
+
const contentBlocksForProtocol: Record<string, [number, number]> = {};
|
|
196
|
+
for (const block of value.blockInfo) {
|
|
197
|
+
if (!contentBlocksForProtocol[block.protocol_name]) {
|
|
198
|
+
contentBlocksForProtocol[block.protocol_name] = [block.block_number, block.block_number];
|
|
199
|
+
}
|
|
200
|
+
contentBlocksForProtocol[block.protocol_name] = [
|
|
201
|
+
Math.min(contentBlocksForProtocol[block.protocol_name][0], block.block_number),
|
|
202
|
+
Math.max(contentBlocksForProtocol[block.protocol_name][1], block.block_number),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
return contentBlocksForProtocol;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function emitLatestBlocks(
|
|
209
|
+
rollUpBlockHeight: number,
|
|
210
|
+
rollUpBlockTimestamp: number,
|
|
211
|
+
syncChains: Record<string, [number, number]>,
|
|
212
|
+
) {
|
|
213
|
+
return await Promise.all([
|
|
214
|
+
EventManager.Instance.sendMessage(BuiltinEvents.RollupBlock, {
|
|
215
|
+
block: rollUpBlockHeight,
|
|
216
|
+
timestamp: rollUpBlockTimestamp,
|
|
217
|
+
}),
|
|
218
|
+
...Object.entries(syncChains).map(([chainName, [_, toBlock]]) =>
|
|
219
|
+
EventManager.Instance.sendMessage(BuiltinEvents.SyncChains, {
|
|
220
|
+
chain: chainName,
|
|
221
|
+
block: toBlock,
|
|
222
|
+
rollup: rollUpBlockHeight
|
|
223
|
+
})
|
|
224
|
+
),
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function* startup(
|
|
229
|
+
dbConn: Client,
|
|
230
|
+
syncInfo: SyncProtocolWithNetwork[],
|
|
231
|
+
config: StartConfig,
|
|
232
|
+
): Operation<AllSyncProtocols[]> {
|
|
233
|
+
const versionInfo = yield* getVersionInfo(dbConn);
|
|
234
|
+
const lastBlockHeight = yield* getLastBlockHeight(versionInfo, dbConn);
|
|
235
|
+
// Create Runtime Primitives Instances
|
|
236
|
+
syncInfo.forEach((syncProtocol) => {
|
|
237
|
+
syncProtocol.primitives.forEach((primitive, primitiveIndex) => {
|
|
238
|
+
processPrimitives(
|
|
239
|
+
syncProtocol.primitives,
|
|
240
|
+
primitiveIndex,
|
|
241
|
+
config.userDefinedPrimitives
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
yield* acquireDBMutex(`startup-node`);
|
|
248
|
+
|
|
249
|
+
// Dev-only reset of user-owned public tables
|
|
250
|
+
if (config.dev?.resetPublicData) {
|
|
251
|
+
yield* resetPublicTables(dbConn as any); // Client,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// When the node is started, we apply system migrations.
|
|
255
|
+
// Either system initial migrations, or migrations given a Paima Engine Update.
|
|
256
|
+
yield* applySystemMigrations(
|
|
257
|
+
config.appVersion,
|
|
258
|
+
versionInfo,
|
|
259
|
+
lastBlockHeight,
|
|
260
|
+
dbConn,
|
|
261
|
+
config.migrations,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Validate that immutable config fields (e.g. NTP startTime, startBlockHeight)
|
|
265
|
+
// have not changed since the last run. Persists a snapshot on first start.
|
|
266
|
+
// Must run after system migrations so the snapshot table exists.
|
|
267
|
+
yield* validateAndSnapshotConfig(syncInfo, dbConn);
|
|
268
|
+
|
|
269
|
+
const syncProtocols = yield* genSyncProtocols(dbConn as any, // Client,
|
|
270
|
+
syncInfo);
|
|
271
|
+
|
|
272
|
+
yield* createDynamicTables(
|
|
273
|
+
versionInfo,
|
|
274
|
+
lastBlockHeight,
|
|
275
|
+
dbConn as any, // Client,
|
|
276
|
+
syncProtocols,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
releaseDBMutex(`startup-node`);
|
|
280
|
+
return syncProtocols;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Convert the primitive config to the final primitive instance
|
|
284
|
+
const processPrimitives = (
|
|
285
|
+
primitives: {primitive: any, id: string}[],
|
|
286
|
+
primitiveIndex: number,
|
|
287
|
+
userDefinedPrimitives?: Record<string, any>,
|
|
288
|
+
) => {
|
|
289
|
+
const primitiveType = primitives[primitiveIndex].primitive.type;
|
|
290
|
+
const primitiveUniqueName = primitives[primitiveIndex].id;
|
|
291
|
+
const primitiveConfig = primitives[primitiveIndex].primitive;
|
|
292
|
+
const isBuiltInPrimitive = primitiveType in builtInPrimitivesMap;
|
|
293
|
+
const isUserDefinedPrimitive = userDefinedPrimitives && primitiveType in userDefinedPrimitives;
|
|
294
|
+
if (isBuiltInPrimitive && isUserDefinedPrimitive) {
|
|
295
|
+
throw new Error(`User defined primitive cannot have the same name as a built-in primitive.
|
|
296
|
+
Built-in values: ${Object.keys(builtInPrimitivesMap).join(", ")}`);
|
|
297
|
+
}
|
|
298
|
+
if (!isBuiltInPrimitive && !isUserDefinedPrimitive) {
|
|
299
|
+
throw new Error(`PrimitiveUniqueName "${primitiveUniqueName}" is not built-in and not user-defined.
|
|
300
|
+
Available values: ${Object.keys([
|
|
301
|
+
...Object.keys(builtInPrimitivesMap),
|
|
302
|
+
...Object.keys(userDefinedPrimitives || {}),
|
|
303
|
+
]).join(", ")}`);
|
|
304
|
+
}
|
|
305
|
+
let p = null;
|
|
306
|
+
const classConfig = {
|
|
307
|
+
...primitiveConfig,
|
|
308
|
+
instanceName: primitiveUniqueName,
|
|
309
|
+
}
|
|
310
|
+
if (isBuiltInPrimitive) {
|
|
311
|
+
p = new builtInPrimitivesMap[primitiveType as keyof typeof builtInPrimitivesMap](classConfig as any) ;
|
|
312
|
+
} else if (isUserDefinedPrimitive) {
|
|
313
|
+
p = new userDefinedPrimitives[primitiveType as keyof typeof userDefinedPrimitives](classConfig);
|
|
314
|
+
}
|
|
315
|
+
// Update the primitive with the final configuration
|
|
316
|
+
primitives[primitiveIndex].primitive = p.getConfig();
|
|
317
|
+
}
|
package/src/mod.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { ChainBlock } from "@effectstream/sync";
|
|
2
|
+
import { call, type Operation, until } from "effection";
|
|
3
|
+
import type { Pool } from "pg";
|
|
4
|
+
import { type BaseStfInput, primitiveTransitionFunction } from "@effectstream/sm";
|
|
5
|
+
import { PreparedQuery } from "@pgtyped/runtime";
|
|
6
|
+
import type {
|
|
7
|
+
ExecPromise,
|
|
8
|
+
QueuedUpdate,
|
|
9
|
+
SyncStateUpdateStream,
|
|
10
|
+
} from "@effectstream/coroutine";
|
|
11
|
+
import {
|
|
12
|
+
blockHeightDone,
|
|
13
|
+
deleteScheduled,
|
|
14
|
+
getFutureGameInputByBlockHeight,
|
|
15
|
+
getFutureGameInputByMaxTimestamp,
|
|
16
|
+
insertGameInputResult,
|
|
17
|
+
removePages,
|
|
18
|
+
saveLastBlock,
|
|
19
|
+
} from "@effectstream/db";
|
|
20
|
+
import { Buffer } from "node:buffer";
|
|
21
|
+
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
22
|
+
import type { StartConfig } from "./types.ts";
|
|
23
|
+
import type { BlockNumber, EffectstreamBlockHash } from "@effectstream/utils";
|
|
24
|
+
import { generateEffectstreamBlockHash, Prando } from "@effectstream/crypto";
|
|
25
|
+
import { applyUserMigrations } from "./version-migrations.ts";
|
|
26
|
+
|
|
27
|
+
/** Helper to check if a SyncStateUpdateStream object is a WorldResolve */
|
|
28
|
+
function isWorldResolve(value: any): value is QueuedUpdate {
|
|
29
|
+
return value && Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
/** Helper to check if a SyncStateUpdateStream object is a promise */
|
|
32
|
+
function isPromise(value: any): value is ExecPromise<any> {
|
|
33
|
+
return value && typeof value === "object" && "promise" in value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* We need to process all the SQL calls of an STF update in an all-or-nothing manner
|
|
38
|
+
* STF updates can fail (since the data for them comes from arbitrary onchain data)
|
|
39
|
+
* But we can't allow a single user's bad transaction to DOS the game for everybody else
|
|
40
|
+
* So failures should be isolated to just the specific input, and not the full block
|
|
41
|
+
* (recall: without this, in pgsql, if a query fails during a db transaction, the entire dbTx becomes invalid)
|
|
42
|
+
*/
|
|
43
|
+
async function tryOrRollback<T>(
|
|
44
|
+
dbTx: Pool,
|
|
45
|
+
func: () => Promise<T>,
|
|
46
|
+
): Promise<{ status: "success" | "error"; value: T | undefined }> {
|
|
47
|
+
const checkpointName = `game_state_transition`;
|
|
48
|
+
await dbTx.query(`SAVEPOINT ${checkpointName}`);
|
|
49
|
+
try {
|
|
50
|
+
const value = await func();
|
|
51
|
+
return { status: "success", value };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
await dbTx.query(`ROLLBACK TO SAVEPOINT ${checkpointName}`);
|
|
54
|
+
log.remote(
|
|
55
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
56
|
+
"block-processing",
|
|
57
|
+
SeverityNumber.INFO,
|
|
58
|
+
(log) =>
|
|
59
|
+
log(`Database error on ${checkpointName}. Rolling back.` + String(err)),
|
|
60
|
+
);
|
|
61
|
+
return { status: "error", value: undefined };
|
|
62
|
+
} finally {
|
|
63
|
+
await dbTx.query(`RELEASE SAVEPOINT ${checkpointName}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* This function is used to execute a generator step by step.
|
|
69
|
+
* Each step returns either a Query or a StateMachineExecution.
|
|
70
|
+
* The results are processed, then the generator is resumed.
|
|
71
|
+
*/
|
|
72
|
+
function* executeGeneratorStepByStep(
|
|
73
|
+
generator: SyncStateUpdateStream<void>,
|
|
74
|
+
dbConn: Pool,
|
|
75
|
+
): Operation<any> {
|
|
76
|
+
let result = generator.next();
|
|
77
|
+
|
|
78
|
+
// NOTE: sync generators cannot execute promises
|
|
79
|
+
// so we pause the execution, and resolve them here.
|
|
80
|
+
// NOTE: there are 2 types of operations we need to handle
|
|
81
|
+
// 1. state machine executions, these return a series of queries to run.
|
|
82
|
+
// 2. generic promises required in the primitives (e.g., verifySignature)
|
|
83
|
+
while (!result.done) {
|
|
84
|
+
// We resolve the generators promises here.
|
|
85
|
+
// As generators cannot execute promises.
|
|
86
|
+
const operations: unknown[] = [];
|
|
87
|
+
const value = result.value;
|
|
88
|
+
|
|
89
|
+
if (isWorldResolve(value)) {
|
|
90
|
+
const [queryIR, params] = value;
|
|
91
|
+
const query = new PreparedQuery(queryIR);
|
|
92
|
+
const queryResult = yield* call(() => query.run(params, dbConn));
|
|
93
|
+
operations.push(queryResult);
|
|
94
|
+
} else if (isPromise(value)) {
|
|
95
|
+
// This means that we have non-database promises in the generator.
|
|
96
|
+
// Each time yield is called, we catch the intention and resolve it.
|
|
97
|
+
const queryResult = yield* until(value.promise);
|
|
98
|
+
// We wrap the result, but it will be flattened by the generator.
|
|
99
|
+
operations.push([queryResult]);
|
|
100
|
+
} else {
|
|
101
|
+
console.error("Yielding unhandled type", result);
|
|
102
|
+
throw new Error("Unhandled type in generator");
|
|
103
|
+
}
|
|
104
|
+
result = generator.next(operations.flat());
|
|
105
|
+
}
|
|
106
|
+
return result.value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* This function is the main entry point for processing a produced block.
|
|
111
|
+
* It is called when a block can be processed and finalized.
|
|
112
|
+
* It runs the entire pipeline in a transaction, with sub-transactions for each StateMachineExecution.
|
|
113
|
+
*
|
|
114
|
+
* Process Order:
|
|
115
|
+
* 1. Create a temporal block record
|
|
116
|
+
* 2. Process the migrations for this block height
|
|
117
|
+
* 4. Extract the scheduled data from the primitives in the block
|
|
118
|
+
* 4. Process the scheduled data for this block height, and time
|
|
119
|
+
* 5. Mark the block as done, and add the hash.
|
|
120
|
+
* 6. Commit the transaction
|
|
121
|
+
*
|
|
122
|
+
* IMPORTANT: This function must receive a non-shared dbConn object.
|
|
123
|
+
* as it will be used in a transaction.
|
|
124
|
+
*/
|
|
125
|
+
export function* processFinalizedBlock(
|
|
126
|
+
value: ChainBlock,
|
|
127
|
+
config: StartConfig,
|
|
128
|
+
dbConn: Pool,
|
|
129
|
+
previousBlockHash: EffectstreamBlockHash | null,
|
|
130
|
+
): Operation<EffectstreamBlockHash> {
|
|
131
|
+
const { gameStateTransitions, migrations } = config;
|
|
132
|
+
const blockHash: EffectstreamBlockHash = generateEffectstreamBlockHash(
|
|
133
|
+
value,
|
|
134
|
+
previousBlockHash,
|
|
135
|
+
);
|
|
136
|
+
try {
|
|
137
|
+
/* STEP 0: Start the transaction. */
|
|
138
|
+
yield* until(dbConn.query("BEGIN"));
|
|
139
|
+
const randomGenerator = new Prando(blockHash);
|
|
140
|
+
|
|
141
|
+
/* STEP 1: Create a temporal block record */
|
|
142
|
+
yield* call(() =>
|
|
143
|
+
saveLastBlock.run({
|
|
144
|
+
// TODO: Check these values
|
|
145
|
+
block_height: value.blockNumber,
|
|
146
|
+
ver: 0,
|
|
147
|
+
main_chain_block_hash: Buffer.from(value.blockNumber.toString()),
|
|
148
|
+
seed: randomGenerator.seed.toString(),
|
|
149
|
+
ms_timestamp: new Date(value.timestamp),
|
|
150
|
+
}, dbConn)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/* STEP 2: Process the migrations. */
|
|
154
|
+
if (migrations) {
|
|
155
|
+
yield* applyUserMigrations(
|
|
156
|
+
value.blockNumber,
|
|
157
|
+
dbConn as any, // Client,
|
|
158
|
+
migrations,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* STEP 3: Process the primitives. */
|
|
163
|
+
for (const primitive of value.primitives) {
|
|
164
|
+
const generator = primitiveTransitionFunction(
|
|
165
|
+
value.blockNumber,
|
|
166
|
+
primitive,
|
|
167
|
+
);
|
|
168
|
+
yield* executeGeneratorStepByStep(generator, dbConn);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* STEP 4: Extract the scheduled data. */
|
|
172
|
+
const scheduledData1 = yield* call(() =>
|
|
173
|
+
getFutureGameInputByBlockHeight.run({
|
|
174
|
+
block_height: value.blockNumber,
|
|
175
|
+
}, dbConn)
|
|
176
|
+
);
|
|
177
|
+
/* STEP 4.b: Extract the scheduled data by timestamp. */
|
|
178
|
+
const scheduledData2 = yield* call(() =>
|
|
179
|
+
getFutureGameInputByMaxTimestamp.run({
|
|
180
|
+
max_timestamp: new Date(value.timestamp),
|
|
181
|
+
}, dbConn)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
/* STEP 5: Process the scheduled data in the State Machine. */
|
|
185
|
+
// TODO What should be the order of the scheduled data - per id?
|
|
186
|
+
const scheduledData = [...scheduledData1, ...scheduledData2];
|
|
187
|
+
let index_in_block = 0;
|
|
188
|
+
|
|
189
|
+
if (gameStateTransitions && scheduledData.length > 0) {
|
|
190
|
+
randomGenerator.skip();
|
|
191
|
+
for (const data of scheduledData) {
|
|
192
|
+
let success = true;
|
|
193
|
+
try {
|
|
194
|
+
const input: BaseStfInput = {
|
|
195
|
+
blockTimestamp: value.timestamp,
|
|
196
|
+
blockHeight: value.blockNumber,
|
|
197
|
+
conciseInput: data.input_data,
|
|
198
|
+
signerAddress: data.from_address,
|
|
199
|
+
signerAddressType: data.from_address_type,
|
|
200
|
+
randomGenerator,
|
|
201
|
+
// TODO: We might want to add this to the scheduled data.
|
|
202
|
+
//
|
|
203
|
+
// Or do we resolve it here. Account might change.
|
|
204
|
+
// Should we preserve the original accountId or the signer's
|
|
205
|
+
// current accountId?
|
|
206
|
+
accountId: undefined, // data.accountId,
|
|
207
|
+
};
|
|
208
|
+
const gameSTFGenerator = gameStateTransitions(
|
|
209
|
+
value.blockNumber,
|
|
210
|
+
input,
|
|
211
|
+
);
|
|
212
|
+
yield* executeGeneratorStepByStep(gameSTFGenerator, dbConn);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
success = false;
|
|
215
|
+
log.remote(
|
|
216
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
217
|
+
"block-processing",
|
|
218
|
+
SeverityNumber.ERROR,
|
|
219
|
+
(log) =>
|
|
220
|
+
log(
|
|
221
|
+
`Error processing block ${value.blockNumber}: ${
|
|
222
|
+
String(err)
|
|
223
|
+
}. Payload: ${JSON.stringify(data)}`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const conciseInputHash = `0x${
|
|
228
|
+
Array(64).fill(0).map(() =>
|
|
229
|
+
Math.floor(Math.random() * 16).toString(16)
|
|
230
|
+
)
|
|
231
|
+
.join("")
|
|
232
|
+
}`;
|
|
233
|
+
yield* until(
|
|
234
|
+
insertGameInputResult.run({
|
|
235
|
+
id: data.id,
|
|
236
|
+
success,
|
|
237
|
+
effectstream_tx_hash: Buffer.from(conciseInputHash),
|
|
238
|
+
index_in_block,
|
|
239
|
+
block_height: value.blockNumber,
|
|
240
|
+
}, dbConn),
|
|
241
|
+
);
|
|
242
|
+
index_in_block++;
|
|
243
|
+
|
|
244
|
+
// Remove the scheduled data from the database.
|
|
245
|
+
yield* until(
|
|
246
|
+
deleteScheduled.run({
|
|
247
|
+
id: data.id,
|
|
248
|
+
}, dbConn),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* STEP 6: Mark the block as done. */
|
|
254
|
+
yield* call(() =>
|
|
255
|
+
blockHeightDone.run({
|
|
256
|
+
block_hash: Buffer.from(blockHash),
|
|
257
|
+
block_height: value.blockNumber,
|
|
258
|
+
}, dbConn)
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
/* STEP 7: Update page state for all sync protocols consumed
|
|
262
|
+
* This is to mark what blocks where consumed by this specific
|
|
263
|
+
* block, to avoid re-processing the same blocks more than once
|
|
264
|
+
* if the sync protocol is restarted.
|
|
265
|
+
*/
|
|
266
|
+
|
|
267
|
+
// Get latest block for each sync protocol.
|
|
268
|
+
const latestBlocks: Record<string, BlockNumber> = {};
|
|
269
|
+
for (const blockInfo of value.blockInfo) {
|
|
270
|
+
if (
|
|
271
|
+
!latestBlocks[blockInfo.protocol_name] ||
|
|
272
|
+
latestBlocks[blockInfo.protocol_name] < blockInfo.block_number
|
|
273
|
+
) {
|
|
274
|
+
latestBlocks[blockInfo.protocol_name] = blockInfo.block_number;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const [protocolName, blockNumber] of Object.entries(latestBlocks)) {
|
|
279
|
+
yield* until(
|
|
280
|
+
removePages.run({
|
|
281
|
+
protocol_name: protocolName,
|
|
282
|
+
page_number: blockNumber,
|
|
283
|
+
}, dbConn),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* STEP 8: Commit the transaction. */
|
|
288
|
+
yield* until(dbConn.query("COMMIT"));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
yield* until(dbConn.query("ROLLBACK"));
|
|
291
|
+
log.remote(
|
|
292
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
293
|
+
"block-processing",
|
|
294
|
+
SeverityNumber.ERROR,
|
|
295
|
+
(log) =>
|
|
296
|
+
log(`Error processing block ${value.blockNumber}: ${String(err)}`),
|
|
297
|
+
);
|
|
298
|
+
// We cannot recover from this error.
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
return blockHash;
|
|
302
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
2
|
+
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
3
|
+
import opentelemetry from "@opentelemetry/api";
|
|
4
|
+
import { defaultOtelSetup, PaimaTelemetryContext } from "@effectstream/log";
|
|
5
|
+
import type { Operation } from "effection";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { parse } from "jsonc-parser";
|
|
9
|
+
|
|
10
|
+
// TODO: this is hacky, but there is no proper way to do this unfortunately
|
|
11
|
+
// We're blocked by any of the following
|
|
12
|
+
// 1. Allowing `type: jsonc` imports: https://github.com/denoland/deno/issues/20374#issuecomment-2543118482
|
|
13
|
+
// 2. Allowing `type: tesxt` imports: https://github.com/denoland/deno/issues/25354
|
|
14
|
+
const version = "0.3.0";
|
|
15
|
+
// 3. import.meta.dirname is undefined when loaded as a jsr package
|
|
16
|
+
// const DenoConfig = parse(fs.readFileSync(
|
|
17
|
+
// path.resolve(import.meta.dirname!, "../", "./deno.json"),
|
|
18
|
+
// "utf-8",
|
|
19
|
+
// ));
|
|
20
|
+
|
|
21
|
+
export function* initTelemetry(): Operation<void> {
|
|
22
|
+
const sdk = new NodeSDK({
|
|
23
|
+
...defaultOtelSetup("effectstream-node", version), // DenoConfig.version),
|
|
24
|
+
// TODO: set OTEL_EXPORTER_OTLP_PROTOCOL to json for @effectstream/collector support until it supports protobuf
|
|
25
|
+
instrumentations: [getNodeAutoInstrumentations()],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const tracer = opentelemetry.trace.getTracer("paima", version); // DenoConfig.version);
|
|
29
|
+
yield* PaimaTelemetryContext.set(
|
|
30
|
+
{
|
|
31
|
+
tracer,
|
|
32
|
+
span: undefined,
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
sdk.start();
|
|
37
|
+
}
|