@effectstream/runtime 0.100.19 → 0.100.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/runtime",
3
- "version": "0.100.19",
3
+ "version": "0.100.22",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,20 +13,20 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/concise": "0.100.19",
17
- "@effectstream/config": "0.100.19",
18
- "@effectstream/coroutine": "0.100.19",
19
- "@effectstream/crypto": "0.100.19",
20
- "@effectstream/db": "0.100.19",
21
- "@effectstream/event-client": "0.100.19",
22
- "@effectstream/event-server": "0.100.19",
23
- "@effectstream/log": "0.100.19",
24
- "@effectstream/sm": "0.100.19",
25
- "@effectstream/sync": "0.100.19",
26
- "@effectstream/utils": "0.100.19",
16
+ "@effectstream/concise": "0.100.22",
17
+ "@effectstream/config": "0.100.22",
18
+ "@effectstream/coroutine": "0.100.22",
19
+ "@effectstream/crypto": "0.100.22",
20
+ "@effectstream/db": "0.100.22",
21
+ "@effectstream/event-client": "0.100.22",
22
+ "@effectstream/event-server": "0.100.22",
23
+ "@effectstream/log": "0.100.22",
24
+ "@effectstream/sm": "0.100.22",
25
+ "@effectstream/sync": "0.100.22",
26
+ "@effectstream/utils": "0.100.22",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@metamask/json-rpc-engine": "^10.0.3",
29
- "pg": "8.17.2",
29
+ "pg": "8.21.0",
30
30
  "effection": "^3.5.0",
31
31
  "@opentelemetry/api": "^1.9.0",
32
32
  "@opentelemetry/auto-instrumentations-node": "^0.54.0",
@@ -1,6 +1,5 @@
1
1
  import fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
2
2
  import { evmRpcEngine } from "./rpc-evm/eip1193.ts";
3
- import { appliedBlockStatus } from "./apply-status.ts";
4
3
  import type { Pool } from "pg";
5
4
  import cors from "@fastify/cors";
6
5
  import { run, until } from "effection";
@@ -14,7 +13,6 @@ import {
14
13
  getTableSchema,
15
14
  type IGetAllAddressesResult,
16
15
  type IGetAllTableNamesResult,
17
- poolErrors,
18
16
  releaseDBMutex,
19
17
  runPreparedQuery,
20
18
  waitUntilFree,
@@ -366,29 +364,19 @@ export const startHttpServer = function* (
366
364
  yield* until(apiRouter(server, dbConn));
367
365
  }
368
366
 
369
- const HealthDbSchema = Type.Object({
370
- consecutive: Type.Number(),
371
- firstFailureAt: Type.Number(),
372
- sustainedDurationMs: Type.Number(),
373
- sustained: Type.Boolean(),
374
- });
375
- const HealthResponseSchema = Type.Object({
376
- status: Type.String(),
377
- db: HealthDbSchema,
378
- });
379
367
  server.get("/health", {
380
368
  schema: {
381
369
  tags: ["status"],
382
370
  response: {
383
- 200: HealthResponseSchema,
384
- 503: HealthResponseSchema,
371
+ 200: Type.Object({
372
+ status: Type.String(),
373
+ }),
385
374
  },
386
375
  },
387
- }, (_req, reply) => {
388
- const db = poolErrors.state();
389
- return reply
390
- .status(db.sustained ? 503 : 200)
391
- .send({ status: db.sustained ? "db-unreachable" : "ok", db });
376
+ }, () => {
377
+ return {
378
+ status: "ok",
379
+ };
392
380
  });
393
381
 
394
382
  server.get("/addresses", {
@@ -481,42 +469,6 @@ export const startHttpServer = function* (
481
469
  });
482
470
 
483
471
  if (ENV.ENABLE_DEV_AND_DEBUG_ENDPOINTS) {
484
- server.get("/debug/metrics", {
485
- schema: {
486
- tags: ["developer"],
487
- response: {
488
- 200: Type.Object({}, { additionalProperties: true }),
489
- },
490
- },
491
- }, () => {
492
- const appliedTs = appliedBlockStatus.timestamp;
493
- return {
494
- timestamp: Date.now(),
495
- uptimeSeconds: process.uptime(),
496
- memory: process.memoryUsage(),
497
- protocols: syncProtocols.map((p) => ({
498
- name: p.name,
499
- buf: p.bufferedData.size(),
500
- ownBlockNumber: p.lastPage?.ownBlockNumber ?? null,
501
- // Backpressure observability
502
- cap: p.bufferCap,
503
- bufHighWater: p.bufferHighWater,
504
- pausedNow: p.pausedNow,
505
- pauses: p.backpressurePauses,
506
- pausedMs: p.backpressurePausedMs,
507
- })),
508
- // Apply-stage lag: how old the last-applied block is. Unlike `buf` (fetch
509
- // backlog) this stays high when the node is write/apply-bound.
510
- applied: {
511
- blockNumber: appliedBlockStatus.blockNumber,
512
- timestamp: appliedTs,
513
- lagSeconds: appliedTs != null
514
- ? Math.round((Date.now() - appliedTs) / 100) / 10
515
- : null,
516
- },
517
- };
518
- });
519
-
520
472
  server.get("/debug/sync-protocols", {
521
473
  schema: {
522
474
  tags: ["developer"],
package/src/main.ts CHANGED
@@ -6,11 +6,13 @@ import {
6
6
  import {
7
7
  acquireDBMutex,
8
8
  createDynamicTables,
9
+ detectCapabilities,
9
10
  getConnection,
10
11
  getLastNonEmptyBlockHash,
11
12
  releaseDBMutex,
12
13
  resetPublicTables,
13
14
  runSnapshotLoop,
15
+ selectViewStrategy,
14
16
  } from "@effectstream/db";
15
17
  import { EventBroker } from "@effectstream/event-server";
16
18
  import { ENV } from "@effectstream/utils/node-env";
@@ -22,18 +24,15 @@ import { startMerge, startSync } from "@effectstream/sync";
22
24
  import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
23
25
  import {
24
26
  createChannel,
27
+ each,
25
28
  type Operation,
26
29
  sleep,
27
30
  spawn,
28
31
  until,
29
32
  } from "effection";
30
33
  import { initTelemetry } from "./telemetry.ts";
31
- import {
32
- type PendingEvent,
33
- processFinalizedBlockWithRetry,
34
- } from "./process-blocks.ts";
34
+ import { type PendingEvent, processFinalizedBlock } from "./process-blocks.ts";
35
35
  import { startHttpServer } from "./api/http-server.ts";
36
- import { recordAppliedBlock } from "./api/apply-status.ts";
37
36
  import type { StartConfig } from "./types.ts";
38
37
  import type { Client } from "pg";
39
38
  import type { EffectstreamBlockHash } from "@effectstream/utils";
@@ -98,14 +97,6 @@ export function* start(config: StartConfig): Operation<void> {
98
97
 
99
98
  const finalizedBlockStream = createChannel<ChainBlock>();
100
99
 
101
- // Subscribe BEFORE spawning the merge: effection channels drop sends with no
102
- // active subscriber, so a fast restart (in-memory state, no RPC wait) could
103
- // emit blocks before we subscribe and silently stall at the pre-restart height.
104
- const finalizedBlocks = yield* finalizedBlockStream;
105
- // NOTE: this subscriber's queue is unbounded. During deep catch-up the merge
106
- // can outpace the per-block processing below and grow it without bound — the
107
- // downstream side of the Fix C backpressure issue (see sync/CLAUDE.md).
108
-
109
100
  yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
110
101
 
111
102
  const heartbeatIntervalMs = 60_000;
@@ -148,26 +139,37 @@ export function* start(config: StartConfig): Operation<void> {
148
139
  yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
149
140
  }
150
141
 
151
- let nextBlock = yield* finalizedBlocks.next();
152
- for (; !nextBlock.done; nextBlock = yield* finalizedBlocks.next()) {
153
- const value = nextBlock.value;
154
- // processFinalizedBlockWithRetry owns connection checkout, the per-block
155
- // DB mutex (PGLite), and transient-pg retry/backoff. App events it
156
- // collects are flushed below ONLY after it returns — i.e. strictly after
157
- // the block's COMMIT.
158
- const result = yield* processFinalizedBlockWithRetry(
159
- value,
160
- config,
161
- dbConn as any, // Pool,
162
- blockHash,
163
- );
164
- const blockAppEvents: PendingEvent[] = result.events;
165
- if (result.blockHash !== "0x0") {
166
- blockHash = result.blockHash;
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
+ // Custom app events emitted by STFs during this block. Collected by
148
+ // processFinalizedBlock and flushed below ONLY after it returns — i.e.
149
+ // strictly after the block's COMMIT. See plan invariant I1.
150
+ let blockAppEvents: PendingEvent[] = [];
151
+ try {
152
+ yield* acquireDBMutex(`processing-blocks:${value.blockNumber}`);
153
+ dbClient = yield* until((dbConn as any).connect()); // Client,
154
+
155
+ const result = yield* processFinalizedBlock(
156
+ value,
157
+ config,
158
+ dbClient as any, // Client,
159
+ blockHash,
160
+ );
161
+ const resultHash = result.blockHash;
162
+ blockAppEvents = result.events;
163
+ if (resultHash !== "0x0") {
164
+ blockHash = resultHash;
165
+ }
166
+ } finally {
167
+ releaseDBMutex(`processing-blocks:${value.blockNumber}`);
168
+ if (dbClient) {
169
+ (dbClient as any).release(); // Client,
170
+ }
167
171
  }
168
172
 
169
- recordAppliedBlock(value); // apply-stage liveness for /debug/metrics
170
-
171
173
  // Used to emit & log the block range for each protocol.
172
174
  const contentBlocksForProtocol = getRangesForSyncProtocols(value);
173
175
 
@@ -218,7 +220,7 @@ export function* start(config: StartConfig): Operation<void> {
218
220
  }...${lagSuffix} | ${JSON.stringify(contentBlocksForProtocol)}`,
219
221
  ),
220
222
  );
221
- if (config.dev?.applyDelayMs) yield* sleep(config.dev.applyDelayMs);
223
+ yield* each.next();
222
224
  }
223
225
  }
224
226
 
@@ -320,11 +322,15 @@ function* startup(
320
322
  const syncProtocols = yield* genSyncProtocols(dbConn as any, // Client,
321
323
  syncInfo);
322
324
 
325
+ const capabilities = yield* until(detectCapabilities(dbConn as any));
326
+ const viewStrategy = selectViewStrategy(capabilities);
327
+
323
328
  yield* createDynamicTables(
324
329
  versionInfo,
325
330
  lastBlockHeight,
326
331
  dbConn as any, // Client,
327
332
  syncProtocols,
333
+ viewStrategy,
328
334
  );
329
335
 
330
336
  releaseDBMutex(`startup-node`);
@@ -1,6 +1,6 @@
1
1
  import type { ChainBlock } from "@effectstream/sync";
2
- import { call, type Operation, sleep, until } from "effection";
3
- import type { Pool, PoolClient } from "pg";
2
+ import { call, type Operation, until } from "effection";
3
+ import type { Pool } from "pg";
4
4
  import { type BaseStfInput, type EmitFn, primitiveTransitionFunction } from "@effectstream/sm";
5
5
  import { PreparedQuery } from "@pgtyped/runtime";
6
6
  import type {
@@ -9,24 +9,19 @@ import type {
9
9
  SyncStateUpdateStream,
10
10
  } from "@effectstream/coroutine";
11
11
  import {
12
- acquireDBMutex,
13
12
  blockHeightDone,
14
13
  deleteEmptyBlocks,
15
14
  deleteScheduled,
16
15
  getFutureGameInputByBlockHeight,
17
16
  getFutureGameInputByMaxTimestamp,
18
17
  insertGameInputResult,
19
- isTransientPgError,
20
- poolErrors,
21
- releaseDBMutex,
22
18
  removePages,
23
19
  saveLastBlock,
24
- upsertPage,
25
20
  } from "@effectstream/db";
26
21
  import { Buffer } from "node:buffer";
27
22
  import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
28
23
  import type { StartConfig } from "./types.ts";
29
- import type { EffectstreamBlockHash } from "@effectstream/utils";
24
+ import type { BlockNumber, EffectstreamBlockHash } from "@effectstream/utils";
30
25
  import { generateEffectstreamBlockHash, Prando } from "@effectstream/crypto";
31
26
  import { applyUserMigrations } from "./version-migrations.ts";
32
27
  import type { EventPathAndDef } from "@effectstream/event-client";
@@ -313,53 +308,36 @@ export function* processFinalizedBlock(
313
308
  }, dbConn)
314
309
  );
315
310
 
316
- /* STEP 7: Persist the block-accurate resume marker per protocol, inside this
317
- * block's transaction (the runtime is the sole writer of the resume
318
- * position). Upsert at page_number = ownBlockNumber, then prune
319
- * everything below → one row per protocol = the committed watermark.
320
- * Full mechanism: @effectstream/sync CLAUDE.md, design idea #5.
311
+ /* STEP 7: Update page state for all sync protocols consumed
312
+ * This is to mark what blocks where consumed by this specific
313
+ * block, to avoid re-processing the same blocks more than once
314
+ * if the sync protocol is restarted.
321
315
  */
322
- for (const { protocol_name, lastPage } of value.resumePages) {
323
- yield* until(
324
- upsertPage.run({
325
- protocol_name,
326
- page_number: lastPage.ownBlockNumber,
327
- page: JSON.stringify(lastPage),
328
- }, dbConn),
329
- );
316
+
317
+ // Get latest block for each sync protocol.
318
+ const latestBlocks: Record<string, BlockNumber> = {};
319
+ for (const blockInfo of value.blockInfo) {
320
+ if (
321
+ !latestBlocks[blockInfo.protocol_name] ||
322
+ latestBlocks[blockInfo.protocol_name] < blockInfo.block_number
323
+ ) {
324
+ latestBlocks[blockInfo.protocol_name] = blockInfo.block_number;
325
+ }
326
+ }
327
+
328
+ for (const [protocolName, blockNumber] of Object.entries(latestBlocks)) {
330
329
  yield* until(
331
330
  removePages.run({
332
- protocol_name,
333
- page_number: lastPage.ownBlockNumber,
331
+ protocol_name: protocolName,
332
+ page_number: blockNumber,
334
333
  }, dbConn),
335
334
  );
336
335
  }
337
336
 
338
337
  /* STEP 8: Commit the transaction. */
339
338
  yield* until(dbConn.query("COMMIT"));
340
- // A successful COMMIT proves the DB is reachable end-to-end — reset the
341
- // pool failure clock so the write path also feeds the health tracker
342
- // (it otherwise only sees floating pool errors and the read-side API).
343
- poolErrors.markHealthy();
344
339
  } catch (err) {
345
- // Best-effort rollback. If the socket is dead the ROLLBACK throws too;
346
- // swallow that so it doesn't mask the original error — the caller's retry
347
- // loop keys off the original (transient) error, not a rollback failure.
348
- try {
349
- yield* until(dbConn.query("ROLLBACK"));
350
- } catch (rollbackErr) {
351
- log.remote(
352
- ComponentNames.EFFECTSTREAM_SYNC,
353
- "block-processing",
354
- SeverityNumber.WARN,
355
- (log) =>
356
- log(
357
- `ROLLBACK failed for block ${value.blockNumber} (connection likely dead): ${
358
- String(rollbackErr)
359
- }`,
360
- ),
361
- );
362
- }
340
+ yield* until(dbConn.query("ROLLBACK"));
363
341
  log.remote(
364
342
  ComponentNames.EFFECTSTREAM_SYNC,
365
343
  "block-processing",
@@ -367,89 +345,12 @@ export function* processFinalizedBlock(
367
345
  (log) =>
368
346
  log(`Error processing block ${value.blockNumber}: ${String(err)}`),
369
347
  );
370
- // Re-throw: `processFinalizedBlockWithRetry` decides whether this is a
371
- // retryable transient pg failure or a fatal bug.
348
+ // We cannot recover from this error.
372
349
  throw err;
373
350
  }
374
351
 
375
- return {
376
- blockHash: hasOperations ? blockHash : "0x0" as PaimaBlockHash,
377
- events: blockEvents
352
+ return {
353
+ blockHash: hasOperations ? blockHash : "0x0" as PaimaBlockHash,
354
+ events: blockEvents
378
355
  };
379
356
  }
380
-
381
- /** Caps the exponential backoff between block-retry attempts. */
382
- const MAX_RETRY_BACKOFF_MS = 5_000;
383
-
384
- /**
385
- * Run {@link processFinalizedBlock} against a freshly checked-out pool client,
386
- * retrying the whole block on transient pg failures (Neon cold-starts,
387
- * idle-socket evictions, TLS blips).
388
- *
389
- * The previous socket is dead after such a failure, so each attempt
390
- * re-acquires a client and reprocesses the block from `BEGIN` — safe because
391
- * the failed tx never committed and the caller only advances the block stream
392
- * after this returns.
393
- *
394
- * `poolErrors` governs escalation: it logs WARN→ERROR as the outage persists
395
- * and calls `process.exit(1)` once it crosses the fatal threshold, letting the
396
- * orchestrator restart us. A non-transient error (a real SQL/logic bug) is
397
- * re-thrown and crashes, as before.
398
- *
399
- * Owns the per-block DB mutex (for PGLite) and client lifecycle so the caller
400
- * doesn't have to.
401
- */
402
- export function* processFinalizedBlockWithRetry(
403
- value: ChainBlock,
404
- config: StartConfig,
405
- pool: Pool,
406
- previousBlockHash: EffectstreamBlockHash | null,
407
- ): Operation<ProcessFinalizedBlockResult> {
408
- const lockName = `processing-blocks:${value.blockNumber}`;
409
- let attempt = 0;
410
- while (true) {
411
- let dbClient: PoolClient | undefined;
412
- let transientErr: unknown;
413
- try {
414
- yield* acquireDBMutex(lockName);
415
- dbClient = yield* until(pool.connect());
416
- return yield* processFinalizedBlock(
417
- value,
418
- config,
419
- dbClient as unknown as Pool, // a checked-out client, used as the tx conn
420
- previousBlockHash,
421
- );
422
- } catch (err) {
423
- if (!isTransientPgError(err)) throw err;
424
- transientErr = err;
425
- } finally {
426
- releaseDBMutex(lockName);
427
- if (dbClient) {
428
- // Passing the error destroys the client instead of recycling it, so
429
- // the pool never hands the dead socket back out. On success
430
- // `transientErr` is undefined and the client returns to the pool.
431
- try {
432
- dbClient.release(transientErr as Error | undefined);
433
- } catch {
434
- /* already released/destroyed */
435
- }
436
- }
437
- }
438
-
439
- attempt++;
440
- poolErrors.log(transientErr, ["sync-write"]);
441
- const backoffMs = Math.min(500 * 2 ** (attempt - 1), MAX_RETRY_BACKOFF_MS);
442
- log.local(
443
- ComponentNames.EFFECTSTREAM_SYNC,
444
- "block-processing",
445
- SeverityNumber.WARN,
446
- (l) =>
447
- l(
448
- `transient db error on block ${value.blockNumber}; retry #${attempt} in ${backoffMs}ms: ${
449
- String(transientErr)
450
- }`,
451
- ),
452
- );
453
- yield* sleep(backoffMs);
454
- }
455
- }
package/src/types.ts CHANGED
@@ -79,12 +79,5 @@ export type StartConfig = {
79
79
  * state such as `bufferedData.size()`. No effect in production.
80
80
  */
81
81
  onStarted?: (ctx: { syncProtocols: AllSyncProtocols[] }) => void;
82
- /**
83
- * Test/diagnostic only: sleep this many ms after each applied block, slowing
84
- * the drain so the per-chain fetch buffers fill to their cap and the
85
- * backpressure fix becomes observable (otherwise drain ≈ fetch and the buffer
86
- * never grows). No effect in production (leave unset / 0).
87
- */
88
- applyDelayMs?: number;
89
82
  };
90
83
  };