envio 3.5.0-alpha.2 → 3.5.0-rc.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 (129) hide show
  1. package/package.json +6 -6
  2. package/src/Batch.res +8 -8
  3. package/src/Batch.res.mjs +4 -3
  4. package/src/BatchProcessing.res +24 -0
  5. package/src/BatchProcessing.res.mjs +12 -0
  6. package/src/ChainFetching.res +20 -19
  7. package/src/ChainFetching.res.mjs +14 -14
  8. package/src/ChainId.res +58 -0
  9. package/src/ChainId.res.mjs +49 -0
  10. package/src/ChainId.resi +27 -0
  11. package/src/ChainMap.res +12 -26
  12. package/src/ChainMap.res.mjs +5 -41
  13. package/src/ChainMap.resi +5 -20
  14. package/src/ChainMetadata.res +1 -1
  15. package/src/ChainMetadata.res.mjs +3 -2
  16. package/src/ChainState.res +45 -24
  17. package/src/ChainState.res.mjs +40 -16
  18. package/src/ChainState.resi +3 -1
  19. package/src/Config.res +43 -26
  20. package/src/Config.res.mjs +19 -16
  21. package/src/ContractRegisterContext.res +1 -1
  22. package/src/ContractRegisterContext.res.mjs +1 -1
  23. package/src/Core.res +1 -0
  24. package/src/CrossChainState.res +90 -22
  25. package/src/CrossChainState.res.mjs +55 -16
  26. package/src/CrossChainState.resi +5 -1
  27. package/src/Env.res +0 -3
  28. package/src/Env.res.mjs +0 -3
  29. package/src/EventConfigBuilder.res +1 -1
  30. package/src/EventProcessing.res +4 -4
  31. package/src/EventProcessing.res.mjs +5 -5
  32. package/src/FetchState.res +489 -153
  33. package/src/FetchState.res.mjs +399 -129
  34. package/src/FinalizeBackfill.res +61 -0
  35. package/src/FinalizeBackfill.res.mjs +44 -0
  36. package/src/HandlerRegister.res +8 -8
  37. package/src/HandlerRegister.res.mjs +5 -4
  38. package/src/HandlerRegister.resi +1 -1
  39. package/src/InMemoryStore.res +4 -0
  40. package/src/InMemoryStore.res.mjs +1 -1
  41. package/src/InMemoryTable.res +15 -15
  42. package/src/InMemoryTable.res.mjs +14 -14
  43. package/src/IndexerLoop.res +9 -2
  44. package/src/IndexerLoop.res.mjs +8 -1
  45. package/src/IndexerState.res +52 -23
  46. package/src/IndexerState.res.mjs +52 -14
  47. package/src/IndexerState.resi +11 -6
  48. package/src/Internal.res +19 -17
  49. package/src/Internal.res.mjs +18 -9
  50. package/src/LoadLayer.res +10 -2
  51. package/src/LoadLayer.res.mjs +3 -1
  52. package/src/LogSelection.res +2 -2
  53. package/src/Main.res +7 -11
  54. package/src/Main.res.mjs +4 -6
  55. package/src/Metrics.res +6 -6
  56. package/src/Metrics.res.mjs +4 -3
  57. package/src/Persistence.res +23 -4
  58. package/src/PgStorage.res +447 -100
  59. package/src/PgStorage.res.mjs +285 -99
  60. package/src/RawEvent.res +1 -2
  61. package/src/RawEvent.res.mjs +1 -1
  62. package/src/Rollback.res +18 -20
  63. package/src/Rollback.res.mjs +9 -9
  64. package/src/RollbackCommit.res +2 -2
  65. package/src/RollbackCommit.res.mjs +3 -3
  66. package/src/SafeCheckpointTracking.res +2 -2
  67. package/src/SimulateDeadInputTracker.res +17 -21
  68. package/src/SimulateDeadInputTracker.res.mjs +9 -8
  69. package/src/SimulateItems.res +8 -10
  70. package/src/SimulateItems.res.mjs +9 -10
  71. package/src/Sink.res +2 -2
  72. package/src/Sink.res.mjs +3 -2
  73. package/src/TestIndexer.res +14 -14
  74. package/src/TestIndexer.res.mjs +16 -10
  75. package/src/UserContext.res +2 -2
  76. package/src/UserContext.res.mjs +2 -1
  77. package/src/Utils.res +0 -4
  78. package/src/Utils.res.mjs +1 -6
  79. package/src/bindings/ClickHouse.res +20 -2
  80. package/src/bindings/ClickHouse.res.mjs +25 -14
  81. package/src/db/EntityFilter.res +0 -4
  82. package/src/db/EntityFilter.res.mjs +1 -8
  83. package/src/db/EntityHistory.res +1 -1
  84. package/src/db/IndexCatalog.res +248 -0
  85. package/src/db/IndexCatalog.res.mjs +222 -0
  86. package/src/db/IndexDefinition.res +131 -0
  87. package/src/db/IndexDefinition.res.mjs +138 -0
  88. package/src/db/IndexManager.res +145 -0
  89. package/src/db/IndexManager.res.mjs +130 -0
  90. package/src/db/InternalTable.res +54 -23
  91. package/src/db/InternalTable.res.mjs +38 -23
  92. package/src/db/Table.res +24 -14
  93. package/src/db/Table.res.mjs +21 -16
  94. package/src/sources/AddressSet.res +5 -4
  95. package/src/sources/AddressStore.res +25 -14
  96. package/src/sources/AddressStore.res.mjs +10 -12
  97. package/src/sources/Evm.res +2 -2
  98. package/src/sources/Evm.res.mjs +1 -1
  99. package/src/sources/EvmChain.res +3 -3
  100. package/src/sources/EvmChain.res.mjs +3 -3
  101. package/src/sources/EvmHyperSyncSource.res +7 -7
  102. package/src/sources/EvmHyperSyncSource.res.mjs +6 -6
  103. package/src/sources/Fuel.res +2 -2
  104. package/src/sources/Fuel.res.mjs +1 -1
  105. package/src/sources/FuelHyperSyncClient.res +3 -0
  106. package/src/sources/FuelHyperSyncClient.res.mjs +1 -0
  107. package/src/sources/FuelHyperSyncSource.res +5 -7
  108. package/src/sources/FuelHyperSyncSource.res.mjs +5 -5
  109. package/src/sources/HyperSync.resi +2 -2
  110. package/src/sources/HyperSyncClient.res +5 -0
  111. package/src/sources/HyperSyncClient.res.mjs +1 -0
  112. package/src/sources/RpcSource.res +13 -14
  113. package/src/sources/RpcSource.res.mjs +13 -12
  114. package/src/sources/SimulateSource.res +42 -15
  115. package/src/sources/SimulateSource.res.mjs +27 -9
  116. package/src/sources/Source.res +1 -5
  117. package/src/sources/SourceManager.res +12 -12
  118. package/src/sources/SourceManager.res.mjs +11 -10
  119. package/src/sources/SourceManager.resi +2 -2
  120. package/src/sources/Svm.res +4 -5
  121. package/src/sources/Svm.res.mjs +5 -4
  122. package/src/sources/SvmHyperSyncClient.res +3 -0
  123. package/src/sources/SvmHyperSyncClient.res.mjs +1 -0
  124. package/src/sources/SvmHyperSyncSource.res +4 -4
  125. package/src/sources/SvmHyperSyncSource.res.mjs +3 -3
  126. package/src/tui/Tui.res +1 -1
  127. package/src/tui/Tui.res.mjs +2 -1
  128. package/src/tui/components/CustomHooks.res +5 -4
  129. package/src/tui/components/CustomHooks.res.mjs +4 -2
package/src/PgStorage.res CHANGED
@@ -20,60 +20,73 @@ let makeClient = () => {
20
20
  )
21
21
  }
22
22
 
23
- let makeCreateIndexQuery = (~tableName, ~indexFields, ~pgSchema) => {
24
- let indexName = tableName ++ "_" ++ indexFields->Array.joinUnsafe("_")
25
-
26
- // Case for indexer before envio@2.28
27
- let index = indexFields->Array.map(idx => `"${idx}"`)->Array.joinUnsafe(", ")
28
- `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${pgSchema}"."${tableName}"(${index});`
29
- }
23
+ let formatSeconds = (timeRef: Performance.timeRef) =>
24
+ (Math.round(timeRef->Performance.secondsSince *. 100.) /. 100.)->Float.toString
25
+
26
+ // Every index build blocks writes to its table for as long as it runs, and on a
27
+ // large database that is not quick. Both build paths say so up front, so a
28
+ // stalled-looking indexer is explainable from the logs alone.
29
+ let slowOnLargeDatabaseNotice = "This can take a long time on a large database."
30
+
31
+ // Every index the entity schema promises: an `@index` field, a composite index,
32
+ // or the index backing a derived relationship. Deferred past the initial DDL and
33
+ // created in one transaction once backfill completes, so a resumed indexer that
34
+ // reports itself ready always has all of them.
35
+ //
36
+ // `entities` is the Postgres-backed set, and every `@derivedFrom` target within
37
+ // it resolves: config parsing rejects a Postgres entity deriving from one that
38
+ // isn't in Postgres (`validate_relationship_storage`).
39
+ let getSchemaIndexes = (~entities: array<Internal.entityConfig>): array<IndexDefinition.t> => {
40
+ let derivedSchema = Schema.make(entities->Array.map(e => e.table))
41
+ let all = []
42
+
43
+ entities->Array.forEach(({table}) => {
44
+ table
45
+ ->Table.getSingleIndexes
46
+ ->Array.forEach(column =>
47
+ all->Array.push(IndexDefinition.single(~tableName=table.tableName, ~column))->ignore
48
+ )
49
+ table
50
+ ->Table.getCompositeIndexes
51
+ ->Array.forEach(indexFields =>
52
+ all
53
+ ->Array.push(IndexDefinition.fromIndexFields(~tableName=table.tableName, ~indexFields))
54
+ ->ignore
55
+ )
56
+ })
30
57
 
31
- let directionToSql = (direction: Table.indexFieldDirection) =>
32
- switch direction {
33
- | Asc => ""
34
- | Desc => " DESC"
35
- }
58
+ entities->Array.forEach(({table}) =>
59
+ table
60
+ ->Table.getDerivedFromFields
61
+ ->Array.forEach(derivedFromField => {
62
+ let column =
63
+ derivedSchema->Schema.getDerivedFromPgFieldName(derivedFromField)->Utils.unwrapResultExn
64
+ all
65
+ ->Array.push(IndexDefinition.single(~tableName=derivedFromField.derivedFromEntity, ~column))
66
+ ->ignore
67
+ })
68
+ )
36
69
 
37
- let directionToIndexName = (direction: Table.indexFieldDirection) =>
38
- switch direction {
39
- | Asc => ""
40
- | Desc => "_desc"
41
- }
70
+ // An `@index` field and a derived relationship pointing at it describe the
71
+ // same index, so the list is deduped on identity rather than on name.
72
+ let seen = Utils.Set.make()
73
+ all->Array.filter(definition => {
74
+ let key = definition->IndexDefinition.key
75
+ if seen->Utils.Set.has(key) {
76
+ false
77
+ } else {
78
+ seen->Utils.Set.add(key)->ignore
79
+ true
80
+ }
81
+ })
82
+ }
42
83
 
43
- let makeCreateCompositeIndexQuery = (
44
- ~tableName,
45
- ~indexFields: array<Table.compositeIndexField>,
84
+ let makeCreateTableQuery = (
85
+ table: Table.table,
46
86
  ~pgSchema,
87
+ ~isNumericArrayAsText,
88
+ ~chainIdMode: ChainId.mode=Int32,
47
89
  ) => {
48
- let indexName =
49
- tableName ++
50
- "_" ++
51
- indexFields
52
- ->Array.map(f => f.fieldName ++ directionToIndexName(f.direction))
53
- ->Array.joinUnsafe("_")
54
- let index =
55
- indexFields
56
- ->Array.map(f => `"${f.fieldName}"${directionToSql(f.direction)}`)
57
- ->Array.joinUnsafe(", ")
58
- `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${pgSchema}"."${tableName}"(${index});`
59
- }
60
-
61
- let makeCreateTableIndicesQuery = (table: Table.table, ~pgSchema) => {
62
- let tableName = table.tableName
63
- let createIndex = indexField =>
64
- makeCreateIndexQuery(~tableName, ~indexFields=[indexField], ~pgSchema)
65
- let createCompositeIndex = indexFields => {
66
- makeCreateCompositeIndexQuery(~tableName, ~indexFields, ~pgSchema)
67
- }
68
-
69
- let singleIndices = table->Table.getSingleIndices
70
- let compositeIndices = table->Table.getCompositeIndices
71
-
72
- singleIndices->Array.map(createIndex)->Array.joinUnsafe("\n") ++
73
- compositeIndices->Array.map(createCompositeIndex)->Array.joinUnsafe("\n")
74
- }
75
-
76
- let makeCreateTableQuery = (table: Table.table, ~pgSchema, ~isNumericArrayAsText) => {
77
90
  let fieldsMapped =
78
91
  table
79
92
  ->Table.getFields
@@ -83,6 +96,7 @@ let makeCreateTableQuery = (table: Table.table, ~pgSchema, ~isNumericArrayAsText
83
96
 
84
97
  {
85
98
  `"${fieldName}" ${Table.getPgFieldType(
99
+ ~chainIdMode,
86
100
  ~fieldType,
87
101
  ~pgSchema,
88
102
  ~isArray,
@@ -152,7 +166,7 @@ let getEntityHistory = (~entityConfig: Internal.entityConfig): EntityHistory.pgE
152
166
  ~entityName=entityTableName,
153
167
  ~entityIndex=entityConfig.index,
154
168
  )
155
- //ignore composite indices
169
+ //ignore composite indexes
156
170
  let table = Table.mkTable(
157
171
  historyTableName,
158
172
  ~fields=dataFields->Array.concat([checkpointIdField, actionField]),
@@ -183,6 +197,11 @@ let makeInitializeTransaction = (
183
197
  ~entities=[],
184
198
  ~enums=[],
185
199
  ~isEmptyPgSchema=false,
200
+ // Backfill writes are far cheaper without the schema's read indexes, so the
201
+ // initial DDL creates only tables, primary keys, views and chain rows; the
202
+ // rest is created by `finalizeBackfill` before the indexer reports ready.
203
+ ~deferSchemaIndexes=false,
204
+ ~chainIdMode: ChainId.mode=Int32,
186
205
  ) => {
187
206
  let generalTables = [
188
207
  InternalTable.Chains.table,
@@ -192,13 +211,12 @@ let makeInitializeTransaction = (
192
211
  ]
193
212
 
194
213
  let allTables = generalTables->Array.copy
195
- let allEntityTables = []
196
214
  entities->Array.forEach((entityConfig: Internal.entityConfig) => {
197
- allEntityTables->Array.push(entityConfig.table)->ignore
198
215
  allTables->Array.push(entityConfig.table)->ignore
199
216
  allTables->Array.push(getEntityHistory(~entityConfig).table)->ignore
200
217
  })
201
- let derivedSchema = Schema.make(allEntityTables)
218
+
219
+ let schemaIndexes = getSchemaIndexes(~entities)
202
220
 
203
221
  let query = ref(
204
222
  (
@@ -229,34 +247,15 @@ GRANT ALL ON SCHEMA "${pgSchema}" TO public;`,
229
247
  query :=
230
248
  query.contents ++
231
249
  "\n" ++
232
- makeCreateTableQuery(table, ~pgSchema, ~isNumericArrayAsText=isHasuraEnabled)
233
- })
234
-
235
- // Then batch all indices (better performance when tables exist)
236
- allTables->Array.forEach((table: Table.table) => {
237
- let indices = makeCreateTableIndicesQuery(table, ~pgSchema)
238
- if indices !== "" {
239
- query := query.contents ++ "\n" ++ indices
240
- }
250
+ makeCreateTableQuery(table, ~pgSchema, ~isNumericArrayAsText=isHasuraEnabled, ~chainIdMode)
241
251
  })
242
252
 
243
- // Add derived indices
244
- entities->Array.forEach((entity: Internal.entityConfig) => {
245
- entity.table
246
- ->Table.getDerivedFromFields
247
- ->Array.forEach(derivedFromField => {
248
- let indexField =
249
- derivedSchema->Schema.getDerivedFromPgFieldName(derivedFromField)->Utils.unwrapResultExn
250
- query :=
251
- query.contents ++
252
- "\n" ++
253
- makeCreateIndexQuery(
254
- ~tableName=derivedFromField.derivedFromEntity,
255
- ~indexFields=[indexField],
256
- ~pgSchema,
257
- )
253
+ // Then batch all indexes (better performance when tables exist)
254
+ if !deferSchemaIndexes {
255
+ schemaIndexes->Array.forEach(definition => {
256
+ query := query.contents ++ "\n" ++ definition->IndexDefinition.makeCreateQuery(~pgSchema)
258
257
  })
259
- })
258
+ }
260
259
 
261
260
  // Create views for Hasura integration
262
261
  query := query.contents ++ "\n" ++ InternalTable.Views.makeMetaViewQuery(~pgSchema)
@@ -366,9 +365,15 @@ let makeLoadAllQuery = (~pgSchema, ~tableName) => {
366
365
  `SELECT * FROM "${pgSchema}"."${tableName}";`
367
366
  }
368
367
 
369
- let makeInsertUnnestSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema, ~isRawEvents) => {
368
+ let makeInsertUnnestSetQuery = (
369
+ ~pgSchema,
370
+ ~table: Table.table,
371
+ ~itemSchema,
372
+ ~isRawEvents,
373
+ ~chainIdMode: ChainId.mode=Int32,
374
+ ) => {
370
375
  let {quotedFieldNames, quotedNonPrimaryFieldNames, arrayFieldTypes} =
371
- table->Table.toSqlParams(~schema=itemSchema, ~pgSchema)
376
+ table->Table.toSqlParams(~schema=itemSchema, ~pgSchema, ~chainIdMode)
372
377
 
373
378
  let primaryKeyFieldNames = Table.getPgPrimaryKeyFieldNames(table)
374
379
 
@@ -396,9 +401,15 @@ SELECT * FROM unnest(${arrayFieldTypes
396
401
  } ++ ";"
397
402
  }
398
403
 
399
- let makeInsertValuesSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema, ~itemsCount) => {
404
+ let makeInsertValuesSetQuery = (
405
+ ~pgSchema,
406
+ ~table: Table.table,
407
+ ~itemSchema,
408
+ ~itemsCount,
409
+ ~chainIdMode: ChainId.mode=Int32,
410
+ ) => {
400
411
  let {quotedFieldNames, quotedNonPrimaryFieldNames} =
401
- table->Table.toSqlParams(~schema=itemSchema, ~pgSchema)
412
+ table->Table.toSqlParams(~schema=itemSchema, ~pgSchema, ~chainIdMode)
402
413
 
403
414
  let primaryKeyFieldNames = Table.getPgPrimaryKeyFieldNames(table)
404
415
  let fieldsCount = quotedFieldNames->Array.length
@@ -441,8 +452,14 @@ VALUES${placeholders.contents}` ++
441
452
  // Constants for chunking
442
453
  let maxItemsPerQuery = 500
443
454
 
444
- let makeTableBatchSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema: S.t<'item>) => {
445
- let {dbSchema, hasArrayField} = table->Table.toSqlParams(~schema=itemSchema, ~pgSchema)
455
+ let makeTableBatchSetQuery = (
456
+ ~pgSchema,
457
+ ~table: Table.table,
458
+ ~itemSchema: S.t<'item>,
459
+ ~chainIdMode: ChainId.mode=Int32,
460
+ ) => {
461
+ let {dbSchema, hasArrayField} =
462
+ table->Table.toSqlParams(~schema=itemSchema, ~pgSchema, ~chainIdMode)
446
463
 
447
464
  // Should move this to a better place
448
465
  // We need it for the isRawEvents check in makeTableBatchSet
@@ -466,7 +483,7 @@ let makeTableBatchSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema: S.t<'
466
483
 
467
484
  if (isRawEvents || !hasArrayField) && !isHistoryUpdate {
468
485
  {
469
- "query": makeInsertUnnestSetQuery(~pgSchema, ~table, ~itemSchema, ~isRawEvents),
486
+ "query": makeInsertUnnestSetQuery(~pgSchema, ~table, ~itemSchema, ~isRawEvents, ~chainIdMode),
470
487
  "convertOrThrow": S.compile(
471
488
  S.unnest(dbSchema),
472
489
  ~input=Value,
@@ -483,6 +500,7 @@ let makeTableBatchSetQuery = (~pgSchema, ~table: Table.table, ~itemSchema: S.t<'
483
500
  ~table,
484
501
  ~itemSchema,
485
502
  ~itemsCount=maxItemsPerQuery,
503
+ ~chainIdMode,
486
504
  ),
487
505
  "convertOrThrow": S.compile(
488
506
  S.unnest(itemSchema)->S.preprocess(_ => {
@@ -571,7 +589,14 @@ let classifyWriteError = (~specificError: ref<option<exn>>, ~table: Table.table,
571
589
 
572
590
  // WeakMap for caching table batch set queries
573
591
  let setQueryCache = Utils.WeakMap.make()
574
- let setOrThrow = async (sql, ~items, ~table: Table.table, ~itemSchema, ~pgSchema) => {
592
+ let setOrThrow = async (
593
+ sql,
594
+ ~items,
595
+ ~table: Table.table,
596
+ ~itemSchema,
597
+ ~pgSchema,
598
+ ~chainIdMode: ChainId.mode=Int32,
599
+ ) => {
575
600
  if items->Array.length === 0 {
576
601
  ()
577
602
  } else {
@@ -583,6 +608,7 @@ let setOrThrow = async (sql, ~items, ~table: Table.table, ~itemSchema, ~pgSchema
583
608
  ~pgSchema,
584
609
  ~table,
585
610
  ~itemSchema=itemSchema->S.toUnknown,
611
+ ~chainIdMode,
586
612
  )
587
613
  setQueryCache->Utils.WeakMap.set(table, newQuery)->ignore
588
614
  newQuery
@@ -603,7 +629,13 @@ let setOrThrow = async (sql, ~items, ~table: Table.table, ~itemSchema, ~pgSchema
603
629
  let response = isFullChunk
604
630
  ? sql->Postgres.preparedUnsafe(data["query"], params)
605
631
  : sql->Postgres.unpreparedUnsafe(
606
- makeInsertValuesSetQuery(~pgSchema, ~table, ~itemSchema, ~itemsCount=chunkSize),
632
+ makeInsertValuesSetQuery(
633
+ ~pgSchema,
634
+ ~table,
635
+ ~itemSchema,
636
+ ~itemsCount=chunkSize,
637
+ ~chainIdMode,
638
+ ),
607
639
  params,
608
640
  )
609
641
  responses->Array.push(response)->ignore
@@ -857,6 +889,7 @@ let rec writeBatch = async (
857
889
  ~escapeTables=?,
858
890
  ) => {
859
891
  try {
892
+ let chainIdMode = config.chainIdMode
860
893
  let shouldSaveHistory = config->Config.shouldSaveHistory(~isInReorgThreshold)
861
894
 
862
895
  let specificError = ref(None)
@@ -871,7 +904,7 @@ let rec writeBatch = async (
871
904
  | Internal.Event(_) =>
872
905
  let coordinate = `${item
873
906
  ->Internal.getItemChainId
874
- ->Int.toString}-${item
907
+ ->ChainId.toString}-${item
875
908
  ->Internal.getItemBlockNumber
876
909
  ->Int.toString}-${item->Internal.getItemLogIndex->Int.toString}`
877
910
  if seenLogCoordinates->Utils.Set.has(coordinate) {
@@ -901,6 +934,7 @@ let rec writeBatch = async (
901
934
  ~table=InternalTable.RawEvents.table,
902
935
  ~itemSchema=InternalTable.RawEvents.schema,
903
936
  ~pgSchema,
937
+ ~chainIdMode,
904
938
  )
905
939
  }, ~items=rawEvents)
906
940
  } catch {
@@ -1020,6 +1054,7 @@ let rec writeBatch = async (
1020
1054
  ~itemSchema=entityHistory.setChangeSchema,
1021
1055
  ~table=entityHistory.table,
1022
1056
  ~pgSchema,
1057
+ ~chainIdMode,
1023
1058
  ),
1024
1059
  )
1025
1060
  ->ignore
@@ -1036,6 +1071,7 @@ let rec writeBatch = async (
1036
1071
  ~table=entityConfig.table,
1037
1072
  ~itemSchema=entityConfig.schema,
1038
1073
  ~pgSchema,
1074
+ ~chainIdMode,
1039
1075
  ),
1040
1076
  )
1041
1077
  }
@@ -1067,14 +1103,19 @@ let rec writeBatch = async (
1067
1103
  | Some({targetCheckpointId: rollbackTargetCheckpointId}) =>
1068
1104
  Some(
1069
1105
  sql => {
1070
- let promises = allEntities->Array.map(entityConfig => {
1071
- sql->EntityHistory.rollback(
1072
- ~pgSchema,
1073
- ~entityName=entityConfig.name,
1074
- ~entityIndex=entityConfig.index,
1075
- ~rollbackTargetCheckpointId,
1076
- )
1077
- })
1106
+ // Postgres owns history tables only for Postgres-backed entities;
1107
+ // ClickHouse-only entities have none to roll back.
1108
+ let promises =
1109
+ allEntities
1110
+ ->Array.filter(entityConfig => entityConfig.storage.postgres)
1111
+ ->Array.map(entityConfig => {
1112
+ sql->EntityHistory.rollback(
1113
+ ~pgSchema,
1114
+ ~entityName=entityConfig.name,
1115
+ ~entityIndex=entityConfig.index,
1116
+ ~rollbackTargetCheckpointId,
1117
+ )
1118
+ })
1078
1119
  promises
1079
1120
  ->Array.push(
1080
1121
  sql->InternalTable.Checkpoints.rollback(~pgSchema, ~rollbackTargetCheckpointId),
@@ -1131,6 +1172,7 @@ let rec writeBatch = async (
1131
1172
  ~checkpointBlockNumbers=batch.checkpointBlockNumbers,
1132
1173
  ~checkpointBlockHashes=batch.checkpointBlockHashes,
1133
1174
  ~checkpointEventsProcessed=batch.checkpointEventsProcessed,
1175
+ ~chainIdMode,
1134
1176
  )
1135
1177
  )
1136
1178
  }
@@ -1277,6 +1319,7 @@ let make = (
1277
1319
  ~pgDatabase,
1278
1320
  ~pgPassword,
1279
1321
  ~isHasuraEnabled,
1322
+ ~chainIdMode: ChainId.mode=Int32,
1280
1323
  ~sink: option<Sink.t>=?,
1281
1324
  ~onInitialize=?,
1282
1325
  ~onNewTables=?,
@@ -1293,6 +1336,44 @@ let make = (
1293
1336
  "cache",
1294
1337
  ])
1295
1338
 
1339
+ // The metric label, the `storage` field on this backend's logs, and the
1340
+ // storage record's own name are all the same string.
1341
+ let storageName = "postgres"
1342
+
1343
+ let indexManager = IndexManager.make()
1344
+
1345
+ let loadCatalogRows = (sql, ~indexName=?) =>
1346
+ sql
1347
+ ->Postgres.unsafe(IndexCatalog.makeQuery(~pgSchema, ~indexName?))
1348
+ ->Promise.thenResolve(rows => rows->S.parseOrThrow(IndexCatalog.rowsSchema))
1349
+
1350
+ // The whole-schema snapshot, taken on a clean initialize and on every resume.
1351
+ // Individual indexes are re-read from the catalog as they are built, so this
1352
+ // is the only point where the full listing is needed.
1353
+ let refreshIndexCatalog = async () => {
1354
+ let rows = await sql->loadCatalogRows
1355
+ indexManager->IndexManager.reload(~rows)
1356
+ }
1357
+
1358
+ let reloadIndexCatalog = async () => {
1359
+ let catalog = await refreshIndexCatalog()
1360
+ switch catalog->IndexCatalog.invalidNames {
1361
+ | [] => ()
1362
+ | invalidIndexNames =>
1363
+ Logging.warn({
1364
+ "storage": storageName,
1365
+ "msg": `Ignoring invalid PostgreSQL indexes in schema "${pgSchema}". They can't serve queries, so the indexer builds its own alongside them.`,
1366
+ "indexes": invalidIndexNames,
1367
+ })
1368
+ }
1369
+ Logging.info({
1370
+ "storage": storageName,
1371
+ "msg": `Found ${catalog
1372
+ ->IndexCatalog.size
1373
+ ->Int.toString} existing indexes in the indexer schema.`,
1374
+ })
1375
+ }
1376
+
1296
1377
  let isInitialized = async () => {
1297
1378
  let envioTables = await sql->Postgres.unsafe(
1298
1379
  `SELECT table_schema FROM information_schema.tables WHERE table_schema = '${pgSchema}' AND (table_name = '${// This is for indexer before envio@2.28
@@ -1484,6 +1565,8 @@ let make = (
1484
1565
  ~chainConfigs,
1485
1566
  ~isEmptyPgSchema=schemaTableNames->Utils.Array.isEmpty,
1486
1567
  ~isHasuraEnabled,
1568
+ ~deferSchemaIndexes=true,
1569
+ ~chainIdMode,
1487
1570
  )
1488
1571
  // Execute all queries within a single transaction for integrity.
1489
1572
  // The envio_info row is written in the same transaction so a successful
@@ -1511,15 +1594,25 @@ let make = (
1511
1594
  })
1512
1595
  })
1513
1596
  if ids->Array.length > 0 {
1597
+ let addrChainIdArrayType = Table.getPgFieldType(
1598
+ ~fieldType=ChainId,
1599
+ ~pgSchema,
1600
+ ~isArray=true,
1601
+ ~isNumericArrayAsText=false,
1602
+ ~isNullable=false,
1603
+ ~chainIdMode,
1604
+ )
1514
1605
  await sql->Postgres.unpreparedUnsafe(
1515
1606
  `INSERT INTO "${pgSchema}"."${Config.EnvioAddresses.table.tableName}" ("id", "chain_id", "registration_block", "registration_log_index", "contract_name")
1516
- SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::text[]) AS t(id, chain_id, contract_name);`,
1607
+ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::${addrChainIdArrayType},$3::text[]) AS t(id, chain_id, contract_name);`,
1517
1608
  (ids, addrChainIds, addrContractNames)->(Utils.magic: _ => unknown),
1518
1609
  )
1519
1610
  }
1520
1611
 
1521
1612
  let cache = await restoreEffectCache(~withUpload=true)
1522
1613
 
1614
+ await reloadIndexCatalog()
1615
+
1523
1616
  // Integration with other tools like Hasura
1524
1617
  switch onInitialize {
1525
1618
  | Some(onInitialize) => await onInitialize()
@@ -1576,6 +1669,253 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1576
1669
  }
1577
1670
  }
1578
1671
 
1672
+ // The physical columns a filter reads, deduped and in first-seen order.
1673
+ // Unknown field names are left to `loadOrThrow`, which reports them properly.
1674
+ let filterColumns = (~table: Table.table, ~filters: array<EntityFilter.t>) => {
1675
+ let queryFields = table->Table.queryFields
1676
+ let columns = []
1677
+ let seen = Utils.Set.make()
1678
+ let rec collect = (filter: EntityFilter.t) =>
1679
+ switch filter {
1680
+ | Eq({fieldName}) | Gt({fieldName}) | Lt({fieldName}) | In({fieldName}) =>
1681
+ switch queryFields->Utils.Dict.dangerouslyGetNonOption(fieldName) {
1682
+ | Some({pgDbFieldName}) =>
1683
+ if !(seen->Utils.Set.has(pgDbFieldName)) {
1684
+ seen->Utils.Set.add(pgDbFieldName)->ignore
1685
+ columns->Array.push(pgDbFieldName)->ignore
1686
+ }
1687
+ | None => ()
1688
+ }
1689
+ | And({filters}) => filters->Array.forEach(collect)
1690
+ }
1691
+ filters->Array.forEach(collect)
1692
+ columns
1693
+ }
1694
+
1695
+ // Runs a prepared build's DDL and reads the index back from pg_catalog before
1696
+ // it counts as existing. `sql` is the transaction's handle when there is one,
1697
+ // so a failed verification rolls the DDL back with it.
1698
+ let runAndVerify = async (sql, prepared: IndexManager.prepared) => {
1699
+ // Sequential rather than Promise.all: inside a transaction they share one
1700
+ // connection, and a rebuild's DROP has to land before its CREATE.
1701
+ for idx in 0 to prepared.queries->Array.length - 1 {
1702
+ let _ = await sql->Postgres.unsafe(prepared.queries->Array.getUnsafe(idx))
1703
+ }
1704
+ let rows = await sql->loadCatalogRows(~indexName=prepared.name)
1705
+ prepared->IndexManager.verifyOrThrow(~rows, ~pgSchema)
1706
+ }
1707
+
1708
+ // A build outside a transaction can commit its DDL and still fail — the
1709
+ // read-back is a second round trip. Re-reading the index puts the catalog
1710
+ // back in step, so the next attempt plans against what the database holds
1711
+ // instead of retrying a create that can only raise "already exists".
1712
+ //
1713
+ // If this read fails too, the next attempt does waste a create before landing
1714
+ // here again — bounded, and it recovers as soon as the database answers.
1715
+ let resyncIndex = async name =>
1716
+ switch await sql->loadCatalogRows(~indexName=name) {
1717
+ | rows => indexManager->IndexManager.resync(~name, ~rows)
1718
+ | exception exn =>
1719
+ Logging.debug({
1720
+ "storage": storageName,
1721
+ "msg": `Could not re-read the index "${name}" after a failed build. The next attempt reads it again.`,
1722
+ "err": exn->Utils.prettifyExn,
1723
+ })
1724
+ }
1725
+
1726
+ let ensureQueryIndexes = async (~table: Table.table, ~filters: array<EntityFilter.t>) => {
1727
+ let columns = filterColumns(~table, ~filters)
1728
+ let _ = await columns
1729
+ ->Array.map(column => {
1730
+ let definition = IndexDefinition.single(~tableName=table.tableName, ~column)
1731
+ indexManager
1732
+ ->IndexManager.ensure(~definition, ~coverage=LeadingColumns, ~build=async () => {
1733
+ // Resolved before logging so a rebuild is reported as one, and an
1734
+ // unrelated index holding the name fails before any DDL runs.
1735
+ switch indexManager->IndexManager.prepare(
1736
+ ~definition,
1737
+ ~coverage=LeadingColumns,
1738
+ ~pgSchema,
1739
+ ) {
1740
+ | None => ()
1741
+ | Some(prepared) =>
1742
+ let verb = prepared.isRebuild ? "Rebuilding unusable index" : "Creating index"
1743
+ // Logged from inside the build so it reports the one request that
1744
+ // actually creates the index, not the ones waiting on it.
1745
+ Logging.info({
1746
+ "storage": storageName,
1747
+ "msg": `${verb} "${prepared.name}" to serve a getWhere query on "${table.tableName}". Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
1748
+ })
1749
+ let timeRef = Performance.now()
1750
+ let entry = await sql->runAndVerify(prepared)
1751
+ indexManager->IndexManager.record(entry)
1752
+ Logging.info({
1753
+ "storage": storageName,
1754
+ "msg": `Index "${prepared.name}" is ready after ${timeRef->formatSeconds}s. Resuming indexing.`,
1755
+ })
1756
+ }
1757
+ })
1758
+ // A failed build records nothing, so the next getWhere retries. Meanwhile
1759
+ // the query still runs — just without the index.
1760
+ ->Promise.catch(async exn => {
1761
+ Logging.warn({
1762
+ "storage": storageName,
1763
+ "msg": `Failed to create an index on "${table.tableName}"("${column}") for a getWhere query. The query runs without it.`,
1764
+ "err": exn->Utils.prettifyExn,
1765
+ })
1766
+ await resyncIndex(definition->IndexDefinition.name)
1767
+ })
1768
+ })
1769
+ ->Promise.all
1770
+ }
1771
+
1772
+ // Goes through `IndexManager.ensure`, unlike `finalizeBackfill` below: that
1773
+ // one runs with processing paused, while this runs on a resumed indexer that
1774
+ // is already ready, so handlers may be issuing getWhere queries alongside it
1775
+ // and the per-table queues are what keep the two from colliding. Nothing here
1776
+ // writes `ready_at` — the chains already carry theirs.
1777
+ let ensureSchemaIndexes = async (~entities: array<Internal.entityConfig>) => {
1778
+ let schemaIndexes = getSchemaIndexes(
1779
+ ~entities=entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres),
1780
+ )
1781
+
1782
+ let _ = await schemaIndexes
1783
+ ->Array.map(definition =>
1784
+ indexManager
1785
+ ->IndexManager.ensure(~definition, ~coverage=Exact, ~build=async () => {
1786
+ switch indexManager->IndexManager.prepare(~definition, ~coverage=Exact, ~pgSchema) {
1787
+ | None => ()
1788
+ | Some(prepared) =>
1789
+ let verb = prepared.isRebuild ? "Rebuilding unusable index" : "Creating missing index"
1790
+ Logging.info({
1791
+ "storage": storageName,
1792
+ "msg": `${verb} "${prepared.name}" the schema promises but the database no longer has. Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
1793
+ })
1794
+ let timeRef = Performance.now()
1795
+ let entry = await sql->runAndVerify(prepared)
1796
+ indexManager->IndexManager.record(entry)
1797
+ Logging.info({
1798
+ "storage": storageName,
1799
+ "msg": `Index "${prepared.name}" is ready after ${timeRef->formatSeconds}s.`,
1800
+ })
1801
+ }
1802
+ })
1803
+ ->Promise.catch(async exn => {
1804
+ Logging.warn({
1805
+ "storage": storageName,
1806
+ "msg": `Failed to restore the schema index "${definition->IndexDefinition.name}". Queries relying on it run unindexed until the next restart.`,
1807
+ "err": exn->Utils.prettifyExn,
1808
+ })
1809
+ await resyncIndex(definition->IndexDefinition.name)
1810
+ })
1811
+ )
1812
+ ->Promise.all
1813
+ }
1814
+
1815
+ // Unlike `ensureQueryIndexes`, this doesn't go through `IndexManager.ensure`.
1816
+ // It's safe because the caller guarantees exclusivity — `FinalizeBackfill.run`
1817
+ // is reached from the processing loop with processing already paused, so no
1818
+ // handler can be running a getWhere.
1819
+ //
1820
+ // Each index is built on its own rather than in one transaction with
1821
+ // `ready_at`: a build that dies half way through a large schema would
1822
+ // otherwise roll back every index before it and make the retry start over.
1823
+ let finalizeBackfill = async (
1824
+ ~entities: array<Internal.entityConfig>,
1825
+ ~chainIds: array<ChainId.t>,
1826
+ ~readyAt: Date.t,
1827
+ ) => {
1828
+ let schemaIndexes = getSchemaIndexes(
1829
+ ~entities=entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres),
1830
+ )
1831
+
1832
+ // Resolved up front so a name held by an unrelated index fails before any
1833
+ // DDL runs, rather than part way through the set.
1834
+ //
1835
+ // `Exact`, so the set built here is decided by the schema alone: matching a
1836
+ // declared index against a composite that happens to lead with the same
1837
+ // column would make a fresh database and an upgraded one end up holding
1838
+ // different tables.
1839
+ let missing =
1840
+ schemaIndexes->Array.filterMap(definition =>
1841
+ indexManager->IndexManager.prepare(~definition, ~coverage=Exact, ~pgSchema)
1842
+ )
1843
+
1844
+ switch missing->Array.filter((prepared: IndexManager.prepared) => prepared.isRebuild) {
1845
+ | [] => ()
1846
+ | rebuilt =>
1847
+ Logging.warn({
1848
+ "storage": storageName,
1849
+ "msg": `PostgreSQL reports ${rebuilt
1850
+ ->Array.length
1851
+ ->Int.toString} of the indexer's own indexes as invalid, so they can't serve queries. Rebuilding them.`,
1852
+ "indexes": rebuilt->Array.map((prepared: IndexManager.prepared) => prepared.name),
1853
+ })
1854
+ }
1855
+
1856
+ switch missing {
1857
+ | [] =>
1858
+ Logging.info({
1859
+ "storage": storageName,
1860
+ "msg": `All ${schemaIndexes
1861
+ ->Array.length
1862
+ ->Int.toString} schema indexes are already in place. Marking the indexer ready.`,
1863
+ })
1864
+ | _ =>
1865
+ Logging.info({
1866
+ "storage": storageName,
1867
+ "msg": `Creating the ${missing
1868
+ ->Array.length
1869
+ ->Int.toString} remaining schema indexes before the indexer reports ready. Writes are paused until they are committed. ${slowOnLargeDatabaseNotice}`,
1870
+ "indexes": missing->Array.map((prepared: IndexManager.prepared) => prepared.name),
1871
+ })
1872
+ }
1873
+ let timeRef = Performance.now()
1874
+
1875
+ // Sequential, one committed index at a time: whatever is built before a
1876
+ // failure stays built and recorded, so the retry owes only the rest.
1877
+ for idx in 0 to missing->Array.length - 1 {
1878
+ let prepared = missing->Array.getUnsafe(idx)
1879
+ switch await sql->runAndVerify(prepared) {
1880
+ | entry => indexManager->IndexManager.record(entry)
1881
+ | exception exn =>
1882
+ // The DDL is outside a transaction, so a create that commits and then
1883
+ // fails its read-back leaves the index in place and unrecorded.
1884
+ // Re-reading it means the retry plans against the database rather than
1885
+ // replaying a create that can only raise "already exists".
1886
+ await resyncIndex(prepared.name)
1887
+ throw(exn)
1888
+ }
1889
+ }
1890
+
1891
+ // Reached only once every definition is verified against pg_catalog, so a
1892
+ // crash either leaves `ready_at` null and the retry finds the indexes
1893
+ // already built, or commits readiness the schema backs.
1894
+ //
1895
+ // One transaction for the whole set: readiness is an indexer-wide fact, and
1896
+ // a crash part way through would otherwise leave some chains stamped and
1897
+ // some not, reporting the indexer as half ready.
1898
+ let setReadyAtQuery = InternalTable.Chains.makeSetReadyAtQuery(~pgSchema)
1899
+ let _ = await sql->Postgres.beginSql(async sql => {
1900
+ for idx in 0 to chainIds->Array.length - 1 {
1901
+ let _ = await sql->Postgres.preparedUnsafe(
1902
+ setReadyAtQuery,
1903
+ [
1904
+ readyAt->(Utils.magic: Date.t => unknown),
1905
+ chainIds->Array.getUnsafe(idx)->(Utils.magic: ChainId.t => unknown),
1906
+ ]->(Utils.magic: array<unknown> => unknown),
1907
+ )
1908
+ }
1909
+ })
1910
+
1911
+ Logging.info({
1912
+ "storage": storageName,
1913
+ "msg": `Committed ${missing
1914
+ ->Array.length
1915
+ ->Int.toString} schema indexes and the ready timestamp in ${timeRef->formatSeconds}s.`,
1916
+ })
1917
+ }
1918
+
1579
1919
  let setOrThrow = (
1580
1920
  type item,
1581
1921
  ~items: array<item>,
@@ -1720,12 +2060,14 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1720
2060
  InternalTable.EnvioInfo.read(sql, ~pgSchema),
1721
2061
  ))
1722
2062
 
2063
+ await reloadIndexCatalog()
2064
+
1723
2065
  let checkpointId = (checkpointIdResult->Array.getUnsafe(0))["id"]->BigInt.fromStringOrThrow
1724
2066
 
1725
2067
  // Convert string checkpoint IDs from DB to bigint
1726
2068
  let reorgCheckpoints = Array.map(reorgCheckpoints, (raw): Internal.reorgCheckpoint => {
1727
2069
  checkpointId: raw["id"]->BigInt.fromStringOrThrow,
1728
- chainId: raw["chain_id"],
2070
+ chainId: raw["chain_id"]->ChainId.normalizeOrThrow,
1729
2071
  blockNumber: raw["block_number"],
1730
2072
  blockHash: raw["block_hash"],
1731
2073
  })
@@ -1869,17 +2211,20 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
1869
2211
  ~sinkPromise,
1870
2212
  ~chainMetaData,
1871
2213
  )
1872
- onWrite(~storage="postgres", ~timeSeconds=primaryTimerRef->Performance.secondsSince)
2214
+ onWrite(~storage=storageName, ~timeSeconds=primaryTimerRef->Performance.secondsSince)
1873
2215
  }
1874
2216
 
1875
2217
  let close = () => sql->Postgres.endSql
1876
2218
 
1877
2219
  {
1878
- name: "postgres",
2220
+ name: storageName,
1879
2221
  isInitialized,
1880
2222
  initialize,
1881
2223
  resumeInitialState,
1882
2224
  loadOrThrow,
2225
+ ensureQueryIndexes,
2226
+ ensureSchemaIndexes,
2227
+ finalizeBackfill,
1883
2228
  dumpEffectCache,
1884
2229
  reset,
1885
2230
  setChainMeta,
@@ -1907,6 +2252,7 @@ let makeStorageFromEnv = (
1907
2252
  ~pgPort=Env.Db.port,
1908
2253
  ~pgDatabase=Env.Db.database,
1909
2254
  ~pgPassword=Env.Db.password,
2255
+ ~chainIdMode=config.chainIdMode,
1910
2256
  ~sink=?{
1911
2257
  // Internally ClickHouse storage is implemented as a sync of the
1912
2258
  // Postgres storage. Required env vars are validated here only when
@@ -1939,6 +2285,7 @@ let makeStorageFromEnv = (
1939
2285
  ~database=database->Option.getUnsafe,
1940
2286
  ~username=username->Option.getUnsafe,
1941
2287
  ~password=password->Option.getUnsafe,
2288
+ ~chainIdMode=config.chainIdMode,
1942
2289
  ),
1943
2290
  )
1944
2291
  } else {