@eclesia/indexer-engine 2.14.4 → 2.16.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.
Files changed (121) hide show
  1. package/dist/_virtual/rolldown_runtime.cjs +20 -15
  2. package/dist/_virtual/rolldown_runtime.mjs +10 -30
  3. package/dist/constants.cjs +31 -2
  4. package/dist/constants.cjs.map +1 -1
  5. package/dist/constants.d.cts +27 -3
  6. package/dist/constants.d.cts.map +1 -1
  7. package/dist/constants.d.mts +27 -3
  8. package/dist/constants.d.mts.map +1 -1
  9. package/dist/constants.mjs +27 -3
  10. package/dist/constants.mjs.map +1 -1
  11. package/dist/emitter/index.cjs +25 -7
  12. package/dist/emitter/index.cjs.map +1 -1
  13. package/dist/emitter/index.d.cts +8 -1
  14. package/dist/emitter/index.d.cts.map +1 -1
  15. package/dist/emitter/index.d.mts +8 -1
  16. package/dist/emitter/index.d.mts.map +1 -1
  17. package/dist/emitter/index.mjs +25 -7
  18. package/dist/emitter/index.mjs.map +1 -1
  19. package/dist/index.cjs +6 -0
  20. package/dist/index.d.cts +3 -3
  21. package/dist/index.d.mts +3 -3
  22. package/dist/index.mjs +3 -3
  23. package/dist/indexer/index.cjs +498 -225
  24. package/dist/indexer/index.cjs.map +1 -1
  25. package/dist/indexer/index.d.cts +111 -7
  26. package/dist/indexer/index.d.cts.map +1 -1
  27. package/dist/indexer/index.d.mts +111 -7
  28. package/dist/indexer/index.d.mts.map +1 -1
  29. package/dist/indexer/index.mjs +496 -224
  30. package/dist/indexer/index.mjs.map +1 -1
  31. package/dist/metrics/index.cjs +14 -0
  32. package/dist/metrics/index.cjs.map +1 -1
  33. package/dist/metrics/index.d.cts +7 -0
  34. package/dist/metrics/index.d.cts.map +1 -1
  35. package/dist/metrics/index.d.mts +7 -0
  36. package/dist/metrics/index.d.mts.map +1 -1
  37. package/dist/metrics/index.mjs +14 -0
  38. package/dist/metrics/index.mjs.map +1 -1
  39. package/dist/mocks/index.cjs +3 -1
  40. package/dist/mocks/index.cjs.map +1 -1
  41. package/dist/mocks/index.d.cts +2 -2
  42. package/dist/mocks/index.d.mts +2 -2
  43. package/dist/mocks/index.mjs +4 -2
  44. package/dist/mocks/index.mjs.map +1 -1
  45. package/dist/mocks/rpc-client.cjs +185 -20
  46. package/dist/mocks/rpc-client.cjs.map +1 -1
  47. package/dist/mocks/rpc-client.d.cts +26 -10
  48. package/dist/mocks/rpc-client.d.cts.map +1 -1
  49. package/dist/mocks/rpc-client.d.mts +26 -10
  50. package/dist/mocks/rpc-client.d.mts.map +1 -1
  51. package/dist/mocks/rpc-client.mjs +184 -22
  52. package/dist/mocks/rpc-client.mjs.map +1 -1
  53. package/dist/promise-queue/index.cjs +10 -4
  54. package/dist/promise-queue/index.cjs.map +1 -1
  55. package/dist/promise-queue/index.d.cts +1 -4
  56. package/dist/promise-queue/index.d.cts.map +1 -1
  57. package/dist/promise-queue/index.d.mts +1 -4
  58. package/dist/promise-queue/index.d.mts.map +1 -1
  59. package/dist/promise-queue/index.mjs +10 -4
  60. package/dist/promise-queue/index.mjs.map +1 -1
  61. package/dist/types/index.cjs.map +1 -1
  62. package/dist/types/index.d.cts +28 -5
  63. package/dist/types/index.d.cts.map +1 -1
  64. package/dist/types/index.d.mts +28 -5
  65. package/dist/types/index.d.mts.map +1 -1
  66. package/dist/types/index.mjs.map +1 -1
  67. package/dist/utils/index.cjs +6 -1
  68. package/dist/utils/index.cjs.map +1 -1
  69. package/dist/utils/index.d.cts +3 -2
  70. package/dist/utils/index.d.mts +3 -2
  71. package/dist/utils/index.mjs +7 -2
  72. package/dist/utils/index.mjs.map +1 -1
  73. package/dist/utils/text.cjs +31 -0
  74. package/dist/utils/text.cjs.map +1 -1
  75. package/dist/utils/text.d.cts +24 -1
  76. package/dist/utils/text.d.cts.map +1 -1
  77. package/dist/utils/text.d.mts +24 -1
  78. package/dist/utils/text.d.mts.map +1 -1
  79. package/dist/utils/text.mjs +30 -1
  80. package/dist/utils/text.mjs.map +1 -1
  81. package/dist/utils/timeout.cjs +32 -0
  82. package/dist/utils/timeout.cjs.map +1 -0
  83. package/dist/utils/timeout.d.cts +18 -0
  84. package/dist/utils/timeout.d.cts.map +1 -0
  85. package/dist/utils/timeout.d.mts +18 -0
  86. package/dist/utils/timeout.d.mts.map +1 -0
  87. package/dist/utils/timeout.mjs +30 -0
  88. package/dist/utils/timeout.mjs.map +1 -0
  89. package/package.json +35 -34
  90. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/ascii.cjs +0 -32
  91. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/ascii.cjs.map +0 -1
  92. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/ascii.mjs +0 -29
  93. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/ascii.mjs.map +0 -1
  94. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/base64.cjs +0 -67
  95. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/base64.cjs.map +0 -1
  96. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/base64.mjs +0 -64
  97. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/base64.mjs.map +0 -1
  98. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/bech32.cjs +0 -80
  99. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/bech32.cjs.map +0 -1
  100. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/bech32.mjs +0 -77
  101. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/bech32.mjs.map +0 -1
  102. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/hex.cjs +0 -33
  103. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/hex.cjs.map +0 -1
  104. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/hex.mjs +0 -30
  105. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/hex.mjs.map +0 -1
  106. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/index.cjs +0 -106
  107. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/index.cjs.map +0 -1
  108. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/index.mjs +0 -103
  109. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/index.mjs.map +0 -1
  110. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/rfc3339.cjs +0 -52
  111. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/rfc3339.cjs.map +0 -1
  112. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/rfc3339.mjs +0 -49
  113. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/rfc3339.mjs.map +0 -1
  114. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/utf8.cjs +0 -31
  115. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/utf8.cjs.map +0 -1
  116. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/utf8.mjs +0 -28
  117. package/dist/node_modules/.pnpm/@cosmjs_encoding@0.36.0/node_modules/@cosmjs/encoding/build/utf8.mjs.map +0 -1
  118. package/dist/node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.cjs +0 -98
  119. package/dist/node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.cjs.map +0 -1
  120. package/dist/node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.mjs +0 -95
  121. package/dist/node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.mjs.map +0 -1
@@ -1,9 +1,10 @@
1
- import { CONNECT_TIMEOUT_MS, DEFAULT_BATCH_SIZE, DEFAULT_HEALTH_CHECK_PORT, DEFAULT_POLLING_INTERVAL_MS, DEFAULT_PROMETHEUS_PORT, DEFAULT_START_HEIGHT, GENESIS_BATCH_SIZE, PAGINATION_LIMITS, PERIODIC_INTERVALS, QUEUE_DEQUEUE_TIMEOUT_MS, RPC_TIMEOUT_MS } from "../constants.mjs";
1
+ import { CONNECT_TIMEOUT_MS, DEFAULT_BATCH_SIZE, DEFAULT_BIND_HOST, DEFAULT_HEALTH_CHECK_PORT, DEFAULT_POLLING_INTERVAL_MS, DEFAULT_PROMETHEUS_PORT, DEFAULT_START_HEIGHT, GENESIS_BATCH_SIZE, IDLE_CHECK_INTERVAL_MS, MAX_FAILURES_PER_BLOCK, PAGINATION_LIMITS, PERIODIC_INTERVALS, RETRY_BASE_DELAY_MS, RETRY_MAX_DELAY_MS, RPC_TIMEOUT_MS } from "../constants.mjs";
2
2
  import { EclesiaEmitter } from "../emitter/index.mjs";
3
- import { RPCError } from "../errors/index.mjs";
3
+ import { ConfigurationError, RPCError } from "../errors/index.mjs";
4
4
  import { IndexerMetrics } from "../metrics/index.mjs";
5
5
  import { CircularBuffer } from "../promise-queue/index.mjs";
6
- import { decodeAttr } from "../utils/text.mjs";
6
+ import { decodeAttr, hasBlockEventMode, redactUrl } from "../utils/text.mjs";
7
+ import { retryDelay, withTimeout } from "../utils/timeout.mjs";
7
8
  import "../utils/index.mjs";
8
9
  import { validateFilePath, validatePort, validatePositiveInteger, validateUrl } from "../validation/index.mjs";
9
10
  import { createHash } from "node:crypto";
@@ -14,12 +15,11 @@ import { QueryValidatorsRequest, QueryValidatorsResponse } from "cosmjs-types/co
14
15
  import { Tx } from "cosmjs-types/cosmos/tx/v1beta1/tx.js";
15
16
  import Fastify from "fastify";
16
17
  import { chain } from "stream-chain";
17
- import Parser from "stream-json";
18
- import Pick from "stream-json/filters/Pick.js";
19
- import StreamArray from "stream-json/streamers/StreamArray.js";
20
- import StreamValues from "stream-json/streamers/StreamValues.js";
21
- import Batch from "stream-json/utils/Batch.js";
22
- import { v4 } from "uuid";
18
+ import pick from "stream-json/filters/pick.js";
19
+ import parser from "stream-json/parser.js";
20
+ import streamArray from "stream-json/streamers/stream-array.js";
21
+ import streamValues from "stream-json/streamers/stream-values.js";
22
+ import batch from "stream-json/utils/batch.js";
23
23
  import * as winston from "winston";
24
24
 
25
25
  //#region src/indexer/index.ts
@@ -46,7 +46,7 @@ const defaultIndexerConfig = {
46
46
  * Core blockchain indexer that connects to Tendermint RPC and processes blocks
47
47
  * Extends EclesiaEmitter to provide event-driven architecture for modules
48
48
  */
49
- var EcleciaIndexer = class extends EclesiaEmitter {
49
+ var EclesiaIndexer = class extends EclesiaEmitter {
50
50
  /** Indexer configuration settings */
51
51
  config;
52
52
  /** Fastify HTTP server for health checks */
@@ -81,6 +81,31 @@ var EcleciaIndexer = class extends EclesiaEmitter {
81
81
  subscription = null;
82
82
  /** Timeout handler for block reception */
83
83
  blockTimeout = null;
84
+ /** Timer for the next poll in polling mode */
85
+ pollTimer = null;
86
+ /** Bumped on every (re)start and stop so a polling chain from a previous run exits */
87
+ pollGeneration = 0;
88
+ /** Bumped on every start() so callbacks left over from a previous run cannot trigger recovery in this one */
89
+ runGeneration = 0;
90
+ /**
91
+ * Rejecters of waits parked in waitForBlockData(). Each entry is removed as soon as its block
92
+ * arrives, so a healthy run keeps this empty instead of accumulating one entry per block.
93
+ */
94
+ blockWaiters = /* @__PURE__ */ new Set();
95
+ /** Pending restart timer */
96
+ retryTimer = null;
97
+ /** Resolves when the indexer has stopped for good: stop() was called, endHeight was reached, or it gave up */
98
+ stopped = Promise.resolve();
99
+ resolveStopped = () => {};
100
+ /** Next height the fetcher will request; advances as fetches are enqueued */
101
+ nextFetchHeight = 0;
102
+ /** Whether a fetcher loop is active, and for which run */
103
+ fetcherRunning = false;
104
+ fetcherGeneration = 0;
105
+ fetcherToken = 0;
106
+ /** Height of the block whose processing failed most recently, and how many times in a row */
107
+ lastFailedHeight;
108
+ sameHeightFailures = 0;
84
109
  /**
85
110
  * Creates a new Eclesia indexer instance
86
111
  * @param config - Indexer configuration options
@@ -94,40 +119,44 @@ var EcleciaIndexer = class extends EclesiaEmitter {
94
119
  if (config.prometheusPort !== void 0) validatePort(config.prometheusPort, "prometheusPort");
95
120
  if (config.startHeight !== void 0) validatePositiveInteger(config.startHeight, "startHeight");
96
121
  if (config.pollingInterval !== void 0) validatePositiveInteger(config.pollingInterval, "pollingInterval");
122
+ const provided = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
97
123
  this.config = {
98
124
  ...defaultIndexerConfig,
99
- ...config
125
+ ...provided
100
126
  };
101
- const { printf } = winston.format;
102
- const eclesiaFormat = printf(({ level, message, timestamp }) => {
103
- return `${timestamp} [${level.toUpperCase()}]:\t${message}`;
127
+ const errorFormat = winston.format((info) => {
128
+ const error = info.error;
129
+ if (error instanceof Error) info.error = {
130
+ name: error.name,
131
+ message: error.message,
132
+ stack: error.stack
133
+ };
134
+ else if (error !== void 0 && (typeof error !== "object" || error === null)) info.error = { message: String(error) };
135
+ return info;
136
+ });
137
+ const textFormat = winston.format.printf(({ level, message, timestamp, error }) => {
138
+ const detail = error;
139
+ const suffix = detail ? "\n" + (detail.stack ?? detail.message ?? "") : "";
140
+ return `${timestamp} [${level.toUpperCase()}]:\t${message}${suffix}`;
104
141
  });
105
142
  this.log = winston.createLogger({
106
143
  level: this.config.logLevel,
107
144
  defaultMeta: { service: "Eclesia Indexer" },
108
- transports: [
109
- new winston.transports.File({
110
- filename: "error.log",
111
- level: "error"
112
- }),
113
- new winston.transports.File({ filename: "combined.log" }),
114
- new winston.transports.Console({ format: winston.format.combine(winston.format.splat(), winston.format.timestamp(), eclesiaFormat, winston.format.colorize({ all: true })) })
115
- ]
145
+ transports: [new winston.transports.Console({ format: this.config.logFormat === "json" ? winston.format.combine(winston.format.splat(), winston.format.timestamp(), errorFormat(), winston.format.json()) : winston.format.combine(winston.format.splat(), winston.format.timestamp(), errorFormat(), textFormat, winston.format.colorize({ all: true })) })]
116
146
  });
147
+ const protocol = new URL(this.config.rpcUrl).protocol;
148
+ if (!this.config.usePolling && (protocol === "http:" || protocol === "https:")) {
149
+ this.log.warn("rpcUrl " + redactUrl(this.config.rpcUrl) + " is HTTP, which cannot deliver block subscriptions; polling every " + this.config.pollingInterval + " ms instead (use a ws:// or wss:// URL for WebSocket mode)");
150
+ this.config.usePolling = true;
151
+ }
117
152
  const queueErrorHandler = (e) => {
118
153
  this.prometheus?.recordError("rpc");
119
- this.log.error("Error enqueueing block data: " + e);
154
+ this.log.error("Error enqueueing block data", { error: e });
120
155
  };
121
156
  if (this.config.minimal) this.blockQueue = new CircularBuffer(this.config.batchSize, queueErrorHandler);
122
157
  else this.blockQueue = new CircularBuffer(this.config.batchSize, queueErrorHandler);
123
158
  this.on("_unhandled", (msg) => {
124
- if (msg.uuid) {
125
- this.log.verbose("Unhandled event: " + msg.type);
126
- this.emit("uuid", {
127
- status: true,
128
- uuid: msg.uuid
129
- });
130
- }
159
+ if (msg.type !== "uuid" && this.log.isVerboseEnabled()) this.log.verbose("Unhandled event: " + msg.type);
131
160
  });
132
161
  if (this.config.enablePrometheus) {
133
162
  this.prometheus = new IndexerMetrics();
@@ -139,10 +168,10 @@ var EcleciaIndexer = class extends EclesiaEmitter {
139
168
  const prometheusPort = this.config.prometheusPort ?? (process.env.PROMETHEUS_PORT ? parseInt(process.env.PROMETHEUS_PORT, 10) : DEFAULT_PROMETHEUS_PORT);
140
169
  this.prometheusServer.listen({
141
170
  port: prometheusPort,
142
- host: "0.0.0.0"
171
+ host: this.config.prometheusHost ?? DEFAULT_BIND_HOST
143
172
  }, (err) => {
144
173
  if (err) {
145
- this.log.error("Prometheus error: " + err);
174
+ this.log.error("Prometheus server error", { error: err });
146
175
  this.prometheus?.recordError("metrics_server");
147
176
  this.emit("fatal-error", {
148
177
  error: err,
@@ -154,16 +183,16 @@ var EcleciaIndexer = class extends EclesiaEmitter {
154
183
  if (this.config.enableHealthcheck) {
155
184
  this.fastify = Fastify({ logger: false });
156
185
  this.fastify.get("/health", async (_request, reply) => {
157
- const code = this.healthCheck.status == "OK" ? 200 : 503;
186
+ const code = this.healthCheck.status == "OK" || this.healthCheck.status == "WAITING" ? 200 : 503;
158
187
  reply.code(code).send(this.healthCheck);
159
188
  });
160
189
  const healthPort = this.config.healthCheckPort ?? (process.env.HEALTH_CHECK_PORT ? parseInt(process.env.HEALTH_CHECK_PORT, 10) : DEFAULT_HEALTH_CHECK_PORT);
161
190
  this.fastify.listen({
162
191
  port: healthPort,
163
- host: "0.0.0.0"
192
+ host: this.config.healthCheckHost ?? DEFAULT_BIND_HOST
164
193
  }, (err) => {
165
194
  if (err) {
166
- this.log.error("Health check server error: " + err);
195
+ this.log.error("Health check server error", { error: err });
167
196
  this.prometheus?.recordError("health_check_server");
168
197
  this.emit("fatal-error", {
169
198
  error: err,
@@ -175,10 +204,110 @@ var EcleciaIndexer = class extends EclesiaEmitter {
175
204
  }
176
205
  setStatus(status) {
177
206
  this.healthCheck.status = status;
207
+ this.prometheus?.setWaiting(status === "WAITING");
208
+ }
209
+ /**
210
+ * Marks the current run for recovery and wakes the main loop if it is parked waiting for a
211
+ * block. Callbacks left over from a previous run pass their generation and are ignored.
212
+ */
213
+ requestRecovery(reason, generation = this.runGeneration) {
214
+ if (generation !== this.runGeneration) {
215
+ this.log.debug("Ignoring recovery request from a previous run: " + reason);
216
+ return;
217
+ }
218
+ if (!this.tryToRecover) this.log.warn("Recovery requested: " + reason);
219
+ this.tryToRecover = true;
220
+ this.wakeBlockWaiters("Recovery requested while waiting for block data");
221
+ }
222
+ /** Rejects every wait parked in waitForBlockData() */
223
+ wakeBlockWaiters(reason) {
224
+ const waiters = [...this.blockWaiters];
225
+ this.blockWaiters.clear();
226
+ for (const reject of waiters) reject(new RPCError(reason));
227
+ }
228
+ /**
229
+ * Refuses to index a chain other than the configured one. An RPC pool that mixes networks, or
230
+ * a wrong URL, would otherwise write a different chain's blocks into the database.
231
+ */
232
+ assertChainId(network) {
233
+ if (this.config.chainId !== void 0 && network !== this.config.chainId) throw new ConfigurationError("RPC serves chain " + network + " but chainId is configured as " + this.config.chainId, {
234
+ expected: this.config.chainId,
235
+ actual: network
236
+ });
237
+ }
238
+ /** Counts consecutive processing failures per block height */
239
+ noteBlockFailure(height) {
240
+ if (height === this.lastFailedHeight) this.sameHeightFailures++;
241
+ else {
242
+ this.lastFailedHeight = height;
243
+ this.sameHeightFailures = 1;
244
+ }
245
+ }
246
+ /** True once one block has failed maxFailuresPerBlock times in a row */
247
+ isStuck() {
248
+ return this.lastFailedHeight !== void 0 && this.sameHeightFailures >= (this.config.maxFailuresPerBlock ?? MAX_FAILURES_PER_BLOCK);
249
+ }
250
+ /**
251
+ * Waits for the next dequeued block but wakes early when recovery or stop is requested, so a
252
+ * loop parked on an empty queue never waits for a block that will not come.
253
+ */
254
+ waitForBlockData(dequeued) {
255
+ if (this.tryToRecover || !this.started) return Promise.reject(new RPCError("Recovery requested while waiting for block data"));
256
+ return new Promise((resolve, reject) => {
257
+ this.blockWaiters.add(reject);
258
+ dequeued.then((value) => {
259
+ this.blockWaiters.delete(reject);
260
+ resolve(value);
261
+ }, (error) => {
262
+ this.blockWaiters.delete(reject);
263
+ reject(error);
264
+ });
265
+ });
266
+ }
267
+ /** (Re)arms the idle check that runs when no block has been announced for a while */
268
+ armIdleCheck() {
269
+ if (this.blockTimeout) clearTimeout(this.blockTimeout);
270
+ this.blockTimeout = setTimeout(() => {
271
+ this.checkLiveness();
272
+ }, IDLE_CHECK_INTERVAL_MS);
273
+ }
274
+ /**
275
+ * Runs when no block has been announced for IDLE_CHECK_INTERVAL_MS. A chain that has stopped
276
+ * producing blocks (halt, upgrade, slow chain) is not an error: the indexer reports WAITING and
277
+ * checks again later. Recovery is requested only when the chain has moved on without us, which
278
+ * means the subscription is dead, or when the RPC cannot be reached at all.
279
+ */
280
+ async checkLiveness() {
281
+ if (!this.started) return;
282
+ const generation = this.runGeneration;
283
+ try {
284
+ const status = await withTimeout(this.client.status(), RPC_TIMEOUT_MS, new RPCError("RPC status call timed out"));
285
+ if (!this.started || generation !== this.runGeneration) return;
286
+ const chainHeight = status.syncInfo.latestBlockHeight;
287
+ if (chainHeight > this.latestHeight) {
288
+ this.requestRecovery("chain is at " + chainHeight + " but nothing was announced since " + this.latestHeight, generation);
289
+ return;
290
+ }
291
+ this.log.info("No new block for " + IDLE_CHECK_INTERVAL_MS / 1e3 + " s, chain height is still " + chainHeight);
292
+ this.setStatus("WAITING");
293
+ this.armIdleCheck();
294
+ } catch (e) {
295
+ this.log.error("Liveness check failed", { error: e });
296
+ this.requestRecovery("RPC unreachable during liveness check", generation);
297
+ }
178
298
  }
179
- blockListener = { next: (data) => {
180
- this.newBlockReceived(data.header.height);
181
- } };
299
+ blockListener = {
300
+ next: (data) => {
301
+ this.newBlockReceived(data.header.height);
302
+ },
303
+ error: (error) => {
304
+ this.log.error("Block subscription error", { error });
305
+ this.requestRecovery("block subscription errored");
306
+ },
307
+ complete: () => {
308
+ if (this.started) this.requestRecovery("block subscription closed by the node");
309
+ }
310
+ };
182
311
  isMinimal(_blockqueue) {
183
312
  if (this.config.minimal) return true;
184
313
  else return false;
@@ -195,38 +324,55 @@ var EcleciaIndexer = class extends EclesiaEmitter {
195
324
  } catch (_e) {}
196
325
  this.log.verbose("Disconnected from RPC");
197
326
  }
198
- const connectTimeoutPromise = new Promise((resolve, reject) => {
199
- setTimeout(reject, CONNECT_TIMEOUT_MS, new RPCError("RPC connection timed out"));
200
- });
201
- this.log.info("Attempting to connected to RPC: " + this.config.rpcUrl);
202
- this.client = await Promise.race([connectComet(this.config.rpcUrl), connectTimeoutPromise]);
203
- await Promise.race([this.client.status(), connectTimeoutPromise]);
327
+ this.log.info("Attempting to connect to RPC: " + redactUrl(this.config.rpcUrl));
328
+ this.client = await this.connectWithTimeout();
329
+ await withTimeout(this.client.status(), CONNECT_TIMEOUT_MS, new RPCError("RPC status call timed out"));
204
330
  this.log.info("Connected to RPC for ad hoc queries");
205
- this.blockClient = await Promise.race([connectComet(this.config.rpcUrl), connectTimeoutPromise]);
206
- await Promise.race([this.blockClient.status(), connectTimeoutPromise]);
331
+ this.blockClient = await this.connectWithTimeout();
332
+ await withTimeout(this.blockClient.status(), CONNECT_TIMEOUT_MS, new RPCError("RPC status call timed out"));
207
333
  this.log.info("Connected to RPC for block & validator info");
208
334
  return true;
209
335
  } catch (error) {
210
- this.log.error("RPC connection error: " + error);
336
+ this.log.error("RPC connection error", { error });
211
337
  this.prometheus?.recordError("rpc");
212
- this.tryToRecover = true;
338
+ this.requestRecovery("RPC connection failed");
213
339
  return false;
214
340
  }
215
341
  }
342
+ /**
343
+ * Opens one CometBFT client with its own timeout. If the timeout wins, the client
344
+ * that may still arrive is disconnected so a slow RPC never leaks a socket.
345
+ */
346
+ async connectWithTimeout() {
347
+ let timedOut = false;
348
+ const pending = connectComet(this.config.rpcUrl);
349
+ pending.then((client) => {
350
+ if (timedOut) try {
351
+ client.disconnect();
352
+ } catch (_e) {}
353
+ }).catch(() => {});
354
+ try {
355
+ return await withTimeout(pending, CONNECT_TIMEOUT_MS, new RPCError("RPC connection timed out"));
356
+ } catch (e) {
357
+ timedOut = true;
358
+ throw e;
359
+ }
360
+ }
216
361
  async initialize() {
217
362
  if (!this.initialized) {
218
363
  try {
219
364
  if (this.config.init) await this.config.init();
220
365
  } catch (e) {
221
- this.log.error("Failed to initialize indexer: " + e);
366
+ this.log.error("Failed to initialize indexer", { error: e });
222
367
  this.prometheus?.recordError("init_error");
223
368
  this.setStatus("FAILED");
224
369
  throw e;
225
370
  }
226
371
  if (await this.config.shouldProcessGenesis()) try {
227
372
  if (this.config.genesisPath) await this.parseGenesis();
373
+ else this.log.warn("shouldProcessGenesis() returned true but no genesisPath is configured, skipping genesis import");
228
374
  } catch (e) {
229
- this.log.error("Failed to parse genesis: " + e);
375
+ this.log.error("Failed to parse genesis", { error: e });
230
376
  this.prometheus?.recordError("genesis_error");
231
377
  this.setStatus("FAILED");
232
378
  throw e;
@@ -234,8 +380,40 @@ var EcleciaIndexer = class extends EclesiaEmitter {
234
380
  this.initialized = true;
235
381
  }
236
382
  }
237
- stop() {
383
+ /**
384
+ * Stops the indexer and releases everything that would keep the process alive:
385
+ * the block subscription, polling and inactivity timers, both RPC clients and the
386
+ * health and metrics servers. Safe to call more than once.
387
+ */
388
+ async stop() {
238
389
  this.started = false;
390
+ this.resolveStopped();
391
+ if (this.retryTimer) {
392
+ clearTimeout(this.retryTimer);
393
+ this.retryTimer = null;
394
+ }
395
+ this.wakeBlockWaiters("Indexer stopped while waiting for block data");
396
+ this.stopPolling();
397
+ if (this.blockTimeout) {
398
+ clearTimeout(this.blockTimeout);
399
+ this.blockTimeout = null;
400
+ }
401
+ if (this.subscription) {
402
+ try {
403
+ this.subscription.removeListener(this.blockListener);
404
+ } catch (_e) {}
405
+ this.subscription = null;
406
+ }
407
+ for (const client of [this.client, this.blockClient]) try {
408
+ client?.disconnect();
409
+ } catch (_e) {}
410
+ const servers = [this.fastify, this.prometheusServer];
411
+ this.fastify = null;
412
+ this.prometheusServer = null;
413
+ await Promise.all(servers.map((server) => server?.close().catch((e) => {
414
+ this.log.warn("Error closing HTTP server", { error: e });
415
+ })));
416
+ this.log.info("Indexer stopped");
239
417
  }
240
418
  clearBlockQueue() {
241
419
  if (this.blockQueue) {
@@ -255,65 +433,88 @@ var EcleciaIndexer = class extends EclesiaEmitter {
255
433
  this.log.verbose("Removed existing block listener and subscription");
256
434
  }
257
435
  if (!this.config.usePolling) this.subscription = this.client.subscribeNewBlock ? this.client.subscribeNewBlock() : null;
258
- const statusPromise = new Promise((resolve, reject) => {
259
- setTimeout(reject, RPC_TIMEOUT_MS, new RPCError("RPC status call timed out"));
260
- });
261
- this.latestHeight = (await Promise.race([this.client.status(), statusPromise])).syncInfo.latestBlockHeight;
262
- this.log.info("Current chain height: " + this.latestHeight);
436
+ const status = await withTimeout(this.client.status(), RPC_TIMEOUT_MS, new RPCError("RPC status call timed out"));
437
+ this.assertChainId(status.nodeInfo.network);
438
+ this.latestHeight = status.syncInfo.latestBlockHeight;
439
+ this.log.info("Connected to " + status.nodeInfo.network + ", current chain height: " + this.latestHeight);
263
440
  this.heightToProcess = await this.config.getNextHeight();
264
- if (this.config.usePolling) this.pollForBlock();
441
+ this.nextFetchHeight = this.heightToProcess;
442
+ if (this.config.usePolling) this.startPolling();
265
443
  else if (this.subscription) this.subscription.addListener(this.blockListener);
266
444
  else {
267
445
  this.prometheus?.recordError("rpc");
268
446
  throw new Error("Could not subscribe to new blocks");
269
447
  }
448
+ this.armIdleCheck();
270
449
  } catch (e) {
271
- this.log.error("Failed to set up block listening: " + e);
450
+ this.log.error("Failed to set up block listening", { error: e });
272
451
  this.prometheus?.recordError("rpc");
273
452
  this.setStatus("FAILED");
274
453
  throw e;
275
454
  }
276
455
  }
456
+ /**
457
+ * Resolves once the indexer has stopped for good: stop() was called, endHeight was reached, or
458
+ * a fatal-error was emitted. Restarts with backoff do not resolve it. Use it to keep a caller
459
+ * waiting for the whole run rather than for the first loop exit.
460
+ */
461
+ whenStopped() {
462
+ return this.stopped;
463
+ }
277
464
  async start() {
465
+ if (!this.started) this.stopped = new Promise((resolve) => {
466
+ this.resolveStopped = resolve;
467
+ });
278
468
  this.started = true;
469
+ this.runGeneration++;
470
+ const generation = this.runGeneration;
471
+ this.tryToRecover = false;
472
+ this.blockWaiters.clear();
279
473
  this.clearBlockQueue();
280
474
  await this.initialize();
281
475
  try {
282
476
  await this.setupBlockListening();
283
477
  this.log.debug("Starting main processing loop");
284
- this.tryToRecover = false;
285
478
  this.fetcher().catch((e) => {
286
479
  this.setStatus("FAILED");
287
480
  this.prometheus?.recordError("rpc");
288
- throw new Error("Error in fetching service: " + e);
481
+ this.log.error("Error in fetching service", { error: e });
482
+ this.requestRecovery("fetcher failed", generation);
289
483
  });
290
- } catch (_e) {}
291
- while (this.started && !this.tryToRecover && (this.config.endHeight === void 0 || this.heightToProcess <= this.config.endHeight) && this.blockQueue.size() > 0) {
484
+ } catch (e) {
485
+ this.requestRecovery("block listening setup failed: " + e, generation);
486
+ }
487
+ let lastProcessed;
488
+ while (this.started && !this.tryToRecover) {
489
+ let txOpen = false;
490
+ let failingHeight;
292
491
  try {
293
492
  this.prometheus?.updateRetryCount(this.retryCount);
294
- await this.config.beginTransaction();
295
- this.log.silly("Started db tx");
296
- let height, timestamp;
493
+ if (this.blockQueue.synced && this.blockQueue.size() <= 1) this.setStatus("WAITING");
494
+ let height;
495
+ let timestamp;
297
496
  if (this.isMinimal(this.blockQueue)) {
298
- const timeoutPromise = new Promise((resolve, reject) => {
299
- setTimeout(reject, QUEUE_DEQUEUE_TIMEOUT_MS, []);
300
- });
301
- const toProcess = await Promise.race([this.blockQueue.dequeue(), timeoutPromise]);
497
+ const toProcess = await this.waitForBlockData(this.blockQueue.dequeue());
302
498
  this.log.silly("Retrieved block data");
303
499
  if (!toProcess || !toProcess[0] || !toProcess[1]) throw new RPCError("Could not fetch block(minimal)");
304
500
  height = toProcess[0].block.header.height;
501
+ failingHeight = height;
305
502
  timestamp = toRfc3339WithNanoseconds(toProcess[0].block.header.time);
503
+ await this.config.beginTransaction();
504
+ txOpen = true;
505
+ this.log.silly("Started db tx");
306
506
  await this.processBlock(toProcess[0], toProcess[1]);
307
507
  } else {
308
- const timeoutPromise = new Promise((resolve, reject) => {
309
- setTimeout(reject, QUEUE_DEQUEUE_TIMEOUT_MS, []);
310
- });
311
- const toProcess = await Promise.race([this.blockQueue.dequeue(), timeoutPromise]);
508
+ const toProcess = await this.waitForBlockData(this.blockQueue.dequeue());
312
509
  this.log.silly("Retrieved block data");
313
510
  if (!toProcess || !toProcess[0] || !toProcess[1] || !toProcess[2]) throw new RPCError("Could not fetch block(full)");
314
511
  this.log.silly("Decoded block");
315
512
  height = toProcess[0].block.header.height;
513
+ failingHeight = height;
316
514
  timestamp = toRfc3339WithNanoseconds(toProcess[0].block.header.time);
515
+ await this.config.beginTransaction();
516
+ txOpen = true;
517
+ this.log.silly("Started db tx");
317
518
  await this.processBlock(toProcess[0], toProcess[1], QueryValidatorsResponse.decode(toProcess[2]).validators);
318
519
  }
319
520
  if (height % PERIODIC_INTERVALS.LARGE == 0) await this.asyncEmit("periodic/large", {
@@ -333,65 +534,86 @@ var EcleciaIndexer = class extends EclesiaEmitter {
333
534
  });
334
535
  this.log.silly("Handled periodic events");
335
536
  await this.config.endTransaction(true);
537
+ txOpen = false;
538
+ lastProcessed = height;
539
+ this.lastFailedHeight = void 0;
540
+ this.sameHeightFailures = 0;
336
541
  this.log.silly("Committed db tx");
337
542
  } catch (e) {
338
- this.prometheus?.recordError("block");
339
- this.log.error("Block processing error: " + e);
340
- this.setStatus("FAILED");
341
- try {
543
+ if (txOpen) try {
342
544
  await this.config.endTransaction(false);
343
545
  } catch (dbe) {
344
546
  this.prometheus?.recordError("database");
345
- this.log.error("Error ending transaction. Must be a DB error: " + dbe);
547
+ this.log.error("Error ending transaction", { error: dbe });
346
548
  }
347
- this.tryToRecover = true;
549
+ if (!this.started) break;
550
+ if (failingHeight !== void 0) this.noteBlockFailure(failingHeight);
551
+ this.prometheus?.recordError("block");
552
+ this.log.error("Block processing error", { error: e });
553
+ this.setStatus("FAILED");
554
+ this.requestRecovery("block processing failed", generation);
348
555
  break;
349
556
  }
350
557
  this.retryCount = 0;
351
558
  this.setStatus("OK");
559
+ if (this.config.endHeight !== void 0 && lastProcessed !== void 0 && lastProcessed >= this.config.endHeight) {
560
+ this.log.info("Reached configured end height " + this.config.endHeight + ". Stopping indexer.");
561
+ await this.stop();
562
+ return;
563
+ }
352
564
  }
353
565
  if (!this.started) {
354
566
  this.log.info("Indexer manually stopped.");
355
567
  return;
356
568
  }
357
- if (this.config.endHeight !== void 0 && this.heightToProcess >= this.config.endHeight) {
358
- this.log.info("Reached configured end height. Stopping indexer.");
359
- this.stop();
569
+ if (this.isStuck()) {
570
+ const height = this.lastFailedHeight;
571
+ this.log.error("Block " + height + " failed " + this.sameHeightFailures + " times in a row. This is a deterministic failure in a handler or the data, not an outage. Giving up.");
572
+ this.started = false;
573
+ this.emit("fatal-error", {
574
+ error: /* @__PURE__ */ new Error("Block " + height + " failed " + this.sameHeightFailures + " times in a row"),
575
+ message: "Block processing is stuck",
576
+ retryCount: this.retryCount,
577
+ height
578
+ });
579
+ this.resolveStopped();
360
580
  return;
361
581
  }
362
- if (this.retryCount < 3) {
363
- this.log.debug("Indexer retryCount: " + this.retryCount);
364
- this.retryCount++;
365
- this.log.info("Indexer is restarting");
366
- setTimeout(() => this.start(), this.retryCount * 5e3);
367
- } else {
368
- this.log.info("Indexer failed too many times. Exiting.");
582
+ this.retryCount++;
583
+ if (this.config.maxRetries !== void 0 && this.retryCount > this.config.maxRetries) {
584
+ this.log.error("Indexer failed " + this.retryCount + " times in a row, giving up (maxRetries=" + this.config.maxRetries + ")");
585
+ this.started = false;
369
586
  this.emit("fatal-error", {
370
587
  error: /* @__PURE__ */ new Error("Max retry attempts exceeded"),
371
588
  message: "Indexer failed too many times",
372
589
  retryCount: this.retryCount
373
590
  });
591
+ this.resolveStopped();
592
+ return;
374
593
  }
594
+ const delay = retryDelay(this.retryCount, RETRY_BASE_DELAY_MS, RETRY_MAX_DELAY_MS);
595
+ this.log.warn("Indexer is restarting in " + delay / 1e3 + " s (attempt " + this.retryCount + ")");
596
+ this.retryTimer = setTimeout(() => {
597
+ this.retryTimer = null;
598
+ this.start().catch((e) => {
599
+ this.log.error("Restart failed", { error: e });
600
+ });
601
+ }, delay);
375
602
  }
603
+ /**
604
+ * Emits an event and waits for its handlers. Handlers run one after another in registration
605
+ * order: they share one database connection and one transaction, so interleaving them at
606
+ * await points would let two handlers read and write the same rows in an unpredictable order,
607
+ * and a failure in one would leave the others mid-flight while the block is rolled back. The
608
+ * first rejection propagates and stops the remaining handlers.
609
+ */
376
610
  asyncEmit = async (type, event) => {
377
- event.uuid = v4();
378
- let listenerCount = this.handled.get(type);
379
- if (!listenerCount) listenerCount = 1;
380
- let listenersResponded = 0;
381
- const prom = new Promise((resolve, reject) => {
382
- const returnFunc = (ev) => {
383
- if (ev.uuid == event.uuid) if (ev.status) {
384
- listenersResponded++;
385
- if (listenersResponded == listenerCount) {
386
- this.off("uuid", returnFunc);
387
- resolve();
388
- }
389
- } else reject(ev.error);
390
- };
391
- this.on("uuid", returnFunc);
392
- });
393
- this.emit(type, event);
394
- return prom;
611
+ const handlers = this.handlersFor(type);
612
+ if (handlers.length === 0) {
613
+ this.emit(type, event);
614
+ return;
615
+ }
616
+ for (const handler of handlers) await handler(event);
395
617
  };
396
618
  async processBlock(block, block_results, validators) {
397
619
  const endTimer = this.prometheus?.timeBlockProcessing();
@@ -411,8 +633,8 @@ var EcleciaIndexer = class extends EclesiaEmitter {
411
633
  let beginBlockEvents;
412
634
  let endBlockEvents;
413
635
  if (block_results.finalizeBlockEvents) {
414
- beginBlockEvents = block_results.finalizeBlockEvents.filter((x) => x.attributes.find((a) => decodeAttr(a.key) == "mode" && decodeAttr(a.value) == "begin_block"));
415
- endBlockEvents = block_results.finalizeBlockEvents.filter((x) => x.attributes.find((a) => decodeAttr(a.key) == "mode" && decodeAttr(a.value) == "end_block"));
636
+ beginBlockEvents = block_results.finalizeBlockEvents.filter((x) => hasBlockEventMode(x, "BeginBlock"));
637
+ endBlockEvents = block_results.finalizeBlockEvents.filter((x) => hasBlockEventMode(x, "EndBlock"));
416
638
  } else {
417
639
  beginBlockEvents = block_results.beginBlockEvents;
418
640
  endBlockEvents = block_results.endBlockEvents;
@@ -448,7 +670,13 @@ var EcleciaIndexer = class extends EclesiaEmitter {
448
670
  timestamp
449
671
  });
450
672
  }
451
- let events = txlog ? JSON.parse(txlog) : [];
673
+ let events = [];
674
+ if (txlog) try {
675
+ const parsed = JSON.parse(txlog);
676
+ if (Array.isArray(parsed)) events = parsed;
677
+ } catch (_e) {
678
+ this.log.silly("Tx log is not JSON, using msg_index attributes instead");
679
+ }
452
680
  if (events.length == 0) {
453
681
  const eventsToAdd = [];
454
682
  this.log.silly("No events found in tx log. Parsing events for msg_index");
@@ -470,8 +698,8 @@ var EcleciaIndexer = class extends EclesiaEmitter {
470
698
  }
471
699
  const msgs = tx.body?.messages;
472
700
  if (msgs) for (let i = 0; i < msgs.length; i++) {
473
- this.log.silly("Indexer broadcasting msg for handling: " + msgs[i].typeUrl);
474
- const msgevents = msgs.length > 1 ? events.find((x) => x.msg_index == i)?.events : events[0].events;
701
+ if (this.log.isSillyEnabled()) this.log.silly("Indexer broadcasting msg for handling: " + msgs[i].typeUrl);
702
+ const msgevents = msgs.length > 1 ? events.find((x) => x.msg_index == i)?.events : events[0]?.events ?? [];
475
703
  await this.asyncEmit(msgs[i].typeUrl, {
476
704
  value: {
477
705
  tx: msgs[i].value,
@@ -483,7 +711,7 @@ var EcleciaIndexer = class extends EclesiaEmitter {
483
711
  if (msgs[i].typeUrl == "/cosmos.authz.v1beta1.MsgExec") {
484
712
  const authzMsgs = MsgExec.decode(msgs[i].value).msgs;
485
713
  if (authzMsgs) for (let r = 0; r < authzMsgs.length; r++) {
486
- this.log.silly("Indexer broadcasting msg for handling: " + authzMsgs[r].typeUrl);
714
+ if (this.log.isSillyEnabled()) this.log.silly("Indexer broadcasting msg for handling: " + authzMsgs[r].typeUrl);
487
715
  const authzMsgEvents = msgevents?.reduce((events$1, evt) => {
488
716
  if (evt.attributes.filter((x) => decodeAttr(x.key) == "authz_msg_index" && decodeAttr(x.value) == "" + r).length > 0) events$1.push(evt);
489
717
  return events$1;
@@ -511,136 +739,166 @@ var EcleciaIndexer = class extends EclesiaEmitter {
511
739
  endTimer?.();
512
740
  this.prometheus?.updateBlockMetrics(height, this.latestHeight, this.blockQueue.size());
513
741
  }
742
+ /**
743
+ * Fetches every height from nextFetchHeight up to latestHeight, waiting for queue space
744
+ * before each fetch. Serves the initial catch-up and live blocks alike: a new announcement
745
+ * only moves latestHeight and starts this loop if it is not already running, so bursts and
746
+ * skipped announcements are handled by the same code and the queue can never overflow.
747
+ */
514
748
  async fetcher() {
515
- for (let i = this.heightToProcess; i <= this.latestHeight; i++) {
516
- if (this.tryToRecover) {
517
- this.log.verbose("Exiting fetcher loop. Attempting to recover indexer");
518
- break;
519
- }
520
- this.log.debug("Fetching: " + i);
521
- try {
522
- if (this.isMinimal(this.blockQueue)) {
523
- const timeoutPromise = new Promise((resolve, reject) => {
524
- setTimeout(reject, RPC_TIMEOUT_MS, false);
525
- });
526
- const toIndex = Promise.race([Promise.all([this.blockClient.block(i), this.blockClient.blockResults(i)]), timeoutPromise]).catch((e) => {
527
- this.log.error("Error fetching block: " + i + " : " + e);
528
- this.prometheus?.recordError("rpc");
529
- this.tryToRecover = true;
530
- return Promise.resolve([]);
531
- });
532
- this.blockQueue.enqueue(toIndex);
533
- } else {
534
- const timeoutPromise = new Promise((resolve, reject) => {
535
- setTimeout(reject, RPC_TIMEOUT_MS, false);
536
- });
537
- const q = QueryValidatorsRequest.fromPartial({ pagination: { limit: PAGINATION_LIMITS.VALIDATORS } });
538
- const vals = QueryValidatorsRequest.encode(q).finish();
539
- const toIndex = Promise.race([Promise.all([
540
- this.blockClient.block(i),
541
- this.blockClient.blockResults(i),
542
- this.callABCI("/cosmos.staking.v1beta1.Query/Validators", vals, i, false)
543
- ]), timeoutPromise]).catch((e) => {
544
- this.log.error("Error fetching block: " + i + " : " + e);
545
- this.tryToRecover = true;
546
- return Promise.resolve([]);
547
- });
548
- this.blockQueue.enqueue(toIndex);
749
+ if (this.fetcherRunning && this.fetcherGeneration === this.runGeneration) return;
750
+ const generation = this.runGeneration;
751
+ const token = ++this.fetcherToken;
752
+ this.fetcherRunning = true;
753
+ this.fetcherGeneration = generation;
754
+ try {
755
+ while (this.nextFetchHeight <= this.latestHeight && (this.config.endHeight === void 0 || this.nextFetchHeight <= this.config.endHeight)) {
756
+ if (this.tryToRecover || !this.started || generation !== this.runGeneration) {
757
+ this.log.verbose("Exiting fetcher loop. Attempting to recover indexer");
758
+ break;
549
759
  }
550
- } catch (e) {
551
- this.log.error("Fetching error: " + e);
552
- break;
760
+ const i = this.nextFetchHeight;
761
+ this.log.debug("Fetching: " + i);
762
+ try {
763
+ if (this.isMinimal(this.blockQueue)) {
764
+ const toIndex = withTimeout(Promise.all([this.blockClient.block(i), this.blockClient.blockResults(i)]), RPC_TIMEOUT_MS, new RPCError("Timed out fetching block " + i)).catch((e) => {
765
+ this.log.error("Error fetching block " + i, { error: e });
766
+ this.prometheus?.recordError("rpc");
767
+ this.requestRecovery("fetch failed for block " + i, generation);
768
+ return Promise.resolve([]);
769
+ });
770
+ this.blockQueue.enqueue(toIndex);
771
+ } else {
772
+ const toIndex = withTimeout(Promise.all([
773
+ this.blockClient.block(i),
774
+ this.blockClient.blockResults(i),
775
+ this.fetchValidatorSet(i)
776
+ ]), RPC_TIMEOUT_MS, new RPCError("Timed out fetching block " + i)).catch((e) => {
777
+ this.log.error("Error fetching block " + i, { error: e });
778
+ this.prometheus?.recordError("rpc");
779
+ this.requestRecovery("fetch failed for block " + i, generation);
780
+ return Promise.resolve([]);
781
+ });
782
+ this.blockQueue.enqueue(toIndex);
783
+ }
784
+ } catch (e) {
785
+ this.log.error("Fetching error", { error: e });
786
+ break;
787
+ }
788
+ this.nextFetchHeight = i + 1;
789
+ await this.blockQueue.continue();
553
790
  }
554
- await this.blockQueue.continue();
555
- if (this.tryToRecover) {
556
- this.log.verbose("Exiting fetcher loop. Attempting to recover indexer");
557
- break;
791
+ if (!this.tryToRecover && this.started && generation === this.runGeneration && !this.blockQueue.synced) {
792
+ this.blockQueue.setSynced();
793
+ this.log.info("Synced to latest height");
558
794
  }
559
- }
560
- if (!this.tryToRecover) {
561
- this.blockQueue.setSynced();
562
- this.log.info("Synced to latest height");
795
+ } finally {
796
+ if (this.fetcherToken === token) this.fetcherRunning = false;
563
797
  }
564
798
  }
799
+ /**
800
+ * Runs an ABCI query. A transport failure (RPC down, timeout, empty reply) requests a recovery.
801
+ * A reply with a non-zero code is the chain answering "no" (pruned height, unknown path, bad
802
+ * key): it is thrown as an RPCError with the code and log, and no recovery is requested for
803
+ * ad-hoc queries, so modules can catch it. Block-pipeline queries reject into the fetcher,
804
+ * which requests recovery itself.
805
+ */
565
806
  async callABCI(path, data, height, adHoc = true) {
807
+ let abciq;
808
+ const endTimer = this.prometheus?.timeRpcCall(path) ?? void 0;
566
809
  try {
567
- const endTimer = this.prometheus?.timeRpcCall(path) ?? void 0;
568
- const abciq = await (adHoc ? this.client : this.blockClient).abciQuery({
810
+ abciq = await (adHoc ? this.client : this.blockClient).abciQuery({
569
811
  path,
570
812
  data,
571
813
  height
572
814
  });
815
+ } catch (e) {
816
+ this.setStatus("FAILED");
817
+ this.prometheus?.recordError("rpc");
818
+ this.requestRecovery("ABCI query failed for " + path);
819
+ throw new RPCError("RPC not responding. Query at: " + path + " (" + e + ")");
820
+ } finally {
573
821
  endTimer?.();
574
- if (abciq) return abciq.value;
575
- else {
576
- this.tryToRecover = true;
577
- this.setStatus("FAILED");
578
- this.prometheus?.recordError("rpc");
579
- throw new Error("RPC not responding. Query at: " + path);
580
- }
581
- } catch (_e) {
582
- this.tryToRecover = true;
822
+ }
823
+ if (!abciq) {
583
824
  this.setStatus("FAILED");
584
- throw new Error("RPC not responding. Query at: " + path);
825
+ this.prometheus?.recordError("rpc");
826
+ this.requestRecovery("empty ABCI response for " + path);
827
+ throw new RPCError("RPC not responding. Query at: " + path);
585
828
  }
829
+ if (abciq.code) {
830
+ this.prometheus?.recordError("rpc");
831
+ throw new RPCError("ABCI query " + path + " failed with code " + abciq.code + (abciq.log ? ": " + abciq.log : ""));
832
+ }
833
+ return abciq.value;
834
+ }
835
+ /**
836
+ * Fetches the complete validator set at a height, following pagination, and returns it
837
+ * re-encoded as a single QueryValidatorsResponse so the block queue payload keeps its shape.
838
+ * Chains with more validators than one page (1000) were silently truncated before.
839
+ */
840
+ async fetchValidatorSet(height) {
841
+ const validators = [];
842
+ let key;
843
+ do {
844
+ const request = QueryValidatorsRequest.fromPartial({ pagination: key ? {
845
+ limit: PAGINATION_LIMITS.VALIDATORS,
846
+ key
847
+ } : { limit: PAGINATION_LIMITS.VALIDATORS } });
848
+ const page = QueryValidatorsResponse.decode(await this.callABCI("/cosmos.staking.v1beta1.Query/Validators", QueryValidatorsRequest.encode(request).finish(), height, false));
849
+ validators.push(...page.validators);
850
+ key = page.pagination?.nextKey && page.pagination.nextKey.length > 0 ? page.pagination.nextKey : void 0;
851
+ } while (key);
852
+ return QueryValidatorsResponse.encode(QueryValidatorsResponse.fromPartial({ validators })).finish();
586
853
  }
587
854
  newBlockReceived(height) {
588
- if (this.blockTimeout) clearTimeout(this.blockTimeout);
589
- this.blockTimeout = setTimeout(() => {
590
- this.tryToRecover = true;
591
- }, 3e4);
855
+ this.armIdleCheck();
592
856
  this.log.info("Received new block: %d", height);
593
- if (height == this.latestHeight) return;
594
- if (this.blockQueue.synced && !this.tryToRecover) {
595
- this.latestHeight = height;
596
- if (this.blockQueue.size() + 1 == this.config.batchSize) {
597
- this.prometheus?.recordError("block");
598
- this.log.error("Block queue is full. Cannot add new block");
599
- this.tryToRecover = true;
600
- return;
601
- }
602
- try {
603
- if (this.isMinimal(this.blockQueue)) this.blockQueue.enqueue(Promise.all([this.client.block(height), this.client.blockResults(height)]).catch((e) => {
604
- this.log.error("Error fetching block: " + height + " : " + e);
605
- return Promise.resolve([]);
606
- }));
607
- else {
608
- const q = QueryValidatorsRequest.fromPartial({ pagination: { limit: 1000n } });
609
- const vals = QueryValidatorsRequest.encode(q).finish();
610
- this.blockQueue.enqueue(Promise.all([
611
- this.client.block(height),
612
- this.client.blockResults(height),
613
- this.callABCI("/cosmos.staking.v1beta1.Query/Validators", vals, height, false)
614
- ]).catch((e) => {
615
- this.log.error("Error fetching block: " + height + " : " + e);
616
- return Promise.resolve([]);
617
- }));
618
- }
619
- } catch (e) {
620
- this.log.error("New Block Received error: " + e);
621
- }
622
- } else this.latestHeight = height;
857
+ if (height <= this.latestHeight) return;
858
+ this.latestHeight = height;
859
+ if (this.tryToRecover || !this.started) return;
860
+ this.fetcher().catch((e) => {
861
+ this.log.error("Error in fetching service", { error: e });
862
+ this.requestRecovery("fetcher failed");
863
+ });
864
+ }
865
+ /** Starts a single polling chain, retiring any chain left over from a previous run */
866
+ startPolling() {
867
+ this.stopPolling();
868
+ const generation = ++this.pollGeneration;
869
+ this.pollForBlock(generation);
623
870
  }
624
- async pollForBlock() {
871
+ stopPolling() {
872
+ this.pollGeneration++;
873
+ if (this.pollTimer) {
874
+ clearTimeout(this.pollTimer);
875
+ this.pollTimer = null;
876
+ }
877
+ }
878
+ async pollForBlock(generation) {
879
+ if (!this.started || generation !== this.pollGeneration) return;
625
880
  try {
626
881
  const status = await this.client.status();
627
- if (status.syncInfo.latestBlockHeight > this.latestHeight) while (this.latestHeight < status.syncInfo.latestBlockHeight) this.newBlockReceived(this.latestHeight + 1);
628
- setTimeout(() => {
629
- this.pollForBlock();
630
- }, this.config.pollingInterval);
882
+ if (!this.started || generation !== this.pollGeneration) return;
883
+ if (status.syncInfo.latestBlockHeight > this.latestHeight) this.newBlockReceived(status.syncInfo.latestBlockHeight);
631
884
  } catch (e) {
632
- this.log.error("Error polling for new block: " + e);
633
- this.tryToRecover = true;
885
+ this.log.error("Error polling for new block", { error: e });
886
+ this.prometheus?.recordError("rpc");
887
+ this.requestRecovery("polling failed");
888
+ return;
634
889
  }
890
+ this.pollTimer = setTimeout(() => {
891
+ this.pollForBlock(generation);
892
+ }, this.config.pollingInterval);
635
893
  }
636
894
  readGenesis() {
637
- if (this.config.genesisPath) return fs.createReadStream(this.config.genesisPath).pipe(Parser.parser());
895
+ if (this.config.genesisPath) return fs.createReadStream(this.config.genesisPath);
638
896
  else throw new Error("Genesis path not set");
639
897
  }
640
898
  async setArrayReader(path, processor) {
641
899
  return new Promise((resolve, reject) => {
642
900
  try {
643
- const pickers = path.split(".").map((filter) => Pick.pick({ filter }));
901
+ const pickers = path.split(".").map((filter) => pick({ filter }));
644
902
  let counter = 0;
645
903
  let chunkCounter = 0;
646
904
  const chunkProcessor = async (data) => {
@@ -652,48 +910,59 @@ var EcleciaIndexer = class extends EclesiaEmitter {
652
910
  await this.config.endTransaction(true);
653
911
  await this.config.beginTransaction();
654
912
  }
913
+ return data;
655
914
  };
656
915
  chain([
657
916
  this.readGenesis(),
917
+ parser(),
658
918
  ...pickers,
659
- StreamArray.streamArray(),
660
- Batch.batch({ batchSize: GENESIS_BATCH_SIZE }),
919
+ streamArray(),
920
+ batch({ batchSize: GENESIS_BATCH_SIZE }),
661
921
  chunkProcessor
662
922
  ]).on("data", (data) => {
663
923
  if (data && Array.isArray(data)) counter = counter + data.length;
664
924
  }).on("end", () => {
665
925
  this.log.info(`Processed ${counter} entries in ${chunkCounter} chunks`);
666
926
  resolve(true);
927
+ }).on("error", (e) => {
928
+ this.log.error("Error reading genesis array " + path, { error: e });
929
+ reject(e);
667
930
  });
668
- } catch (_e) {
669
- this.log.verbose("Error in setArrayReader: " + _e);
670
- reject();
931
+ } catch (e) {
932
+ this.log.verbose("Error in setArrayReader: " + e);
933
+ reject(e);
671
934
  }
672
935
  });
673
936
  }
674
937
  async setValueReader(path, processor) {
675
938
  return new Promise((resolve, reject) => {
676
939
  try {
677
- const pickers = path.split(".").map((filter) => Pick.pick({ filter }));
940
+ const pickers = path.split(".").map((filter) => pick({ filter }));
678
941
  let counter = 0;
679
942
  chain([
680
943
  this.readGenesis(),
944
+ parser(),
681
945
  ...pickers,
682
- StreamValues.streamValues(),
946
+ streamValues(),
683
947
  processor
684
948
  ]).on("data", (_data) => {
685
949
  counter++;
686
950
  }).on("end", () => {
687
951
  this.log.info(`Processed ${counter} entries`);
688
952
  resolve(true);
953
+ }).on("error", (e) => {
954
+ this.log.error("Error reading genesis value " + path, { error: e });
955
+ reject(e);
689
956
  });
690
- } catch (_e) {
691
- reject();
957
+ } catch (e) {
958
+ this.log.verbose("Error in setValueReader: " + e);
959
+ reject(e);
692
960
  }
693
961
  });
694
962
  }
695
963
  async parseGenesis() {
696
964
  this.log.info("Parsing genesis");
965
+ await this.config.onGenesisStart?.();
697
966
  await this.config.beginTransaction();
698
967
  try {
699
968
  this.log.info("Starting genesis import");
@@ -721,6 +990,7 @@ var EcleciaIndexer = class extends EclesiaEmitter {
721
990
  }
722
991
  return data;
723
992
  });
993
+ await this.config.onGenesisComplete?.();
724
994
  await this.config.endTransaction(true);
725
995
  this.log.info("Finished importing");
726
996
  } catch (e) {
@@ -728,14 +998,16 @@ var EcleciaIndexer = class extends EclesiaEmitter {
728
998
  await this.config.endTransaction(false);
729
999
  } catch (dbe) {
730
1000
  this.prometheus?.recordError("database");
731
- this.log.error("Error ending transaction. Must be a DB error: " + dbe);
1001
+ this.log.error("Error ending transaction", { error: dbe });
732
1002
  }
733
1003
  this.log.error("Failed to import genesis");
734
1004
  throw e;
735
1005
  }
736
1006
  }
737
1007
  };
1008
+ /** @deprecated Misspelling kept for compatibility, use EclesiaIndexer. Removed in 3.0. */
1009
+ const EcleciaIndexer = EclesiaIndexer;
738
1010
 
739
1011
  //#endregion
740
- export { EcleciaIndexer, defaultIndexerConfig };
1012
+ export { EcleciaIndexer, EclesiaIndexer, defaultIndexerConfig };
741
1013
  //# sourceMappingURL=index.mjs.map