envio 3.15.0 → 3.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 (99) hide show
  1. package/README.md +40 -34
  2. package/evm.schema.json +11 -2
  3. package/fuel.schema.json +10 -1
  4. package/index.d.ts +6 -0
  5. package/licenses/README.md +1 -1
  6. package/package.json +8 -8
  7. package/src/Batch.res.mjs +5 -5
  8. package/src/BatchProcessing.res +1 -0
  9. package/src/BatchProcessing.res.mjs +1 -0
  10. package/src/ChainState.res +48 -7
  11. package/src/ChainState.res.mjs +27 -11
  12. package/src/Clock.res +57 -0
  13. package/src/Clock.res.mjs +72 -0
  14. package/src/Config.res +10 -3
  15. package/src/Config.res.mjs +4 -2
  16. package/src/Core.res +4 -40
  17. package/src/EffectState.res +10 -2
  18. package/src/EffectState.res.mjs +8 -4
  19. package/src/EffectState.resi +3 -1
  20. package/src/EntityId.res +2 -7
  21. package/src/EntityId.res.mjs +1 -8
  22. package/src/EntityTables.res +4 -1
  23. package/src/EntityTables.res.mjs +1 -1
  24. package/src/FetchState.res +157 -69
  25. package/src/FetchState.res.mjs +144 -49
  26. package/src/FinalizeBackfill.res +2 -4
  27. package/src/FinalizeBackfill.res.mjs +3 -4
  28. package/src/InMemoryTable.res +17 -16
  29. package/src/InMemoryTable.res.mjs +14 -15
  30. package/src/IndexerState.res +28 -4
  31. package/src/IndexerState.res.mjs +19 -6
  32. package/src/IndexerState.resi +4 -0
  33. package/src/Internal.res +0 -49
  34. package/src/Internal.res.mjs +1 -80
  35. package/src/LoadLayer.res +14 -8
  36. package/src/LoadLayer.res.mjs +8 -6
  37. package/src/Main.res.mjs +1 -1
  38. package/src/Persistence.res +3 -23
  39. package/src/Persistence.res.mjs +3 -3
  40. package/src/Rollback.res +0 -1
  41. package/src/Rollback.res.mjs +1 -1
  42. package/src/SafeCheckpointTracking.res +4 -4
  43. package/src/SafeCheckpointTracking.res.mjs +1 -1
  44. package/src/StagedRows.res +106 -0
  45. package/src/StagedRows.res.mjs +102 -0
  46. package/src/Staging.res +32 -14
  47. package/src/Staging.res.mjs +17 -20
  48. package/src/Storage.res +277 -948
  49. package/src/Storage.res.mjs +215 -707
  50. package/src/Supervisor.res +75 -56
  51. package/src/Supervisor.res.mjs +55 -26
  52. package/src/TestIndexer.res +22 -27
  53. package/src/TestIndexer.res.mjs +20 -17
  54. package/src/Throttler.res +15 -8
  55. package/src/Throttler.res.mjs +9 -6
  56. package/src/Throttler.resi +1 -1
  57. package/src/Utils.res +0 -6
  58. package/src/Utils.res.mjs +0 -5
  59. package/src/Writing.res +0 -1
  60. package/src/Writing.res.mjs +1 -1
  61. package/src/bindings/ClickHouse.res +0 -322
  62. package/src/bindings/ClickHouse.res.mjs +0 -338
  63. package/src/bindings/StorageClient.res +107 -174
  64. package/src/bindings/StorageClient.res.mjs +19 -8
  65. package/src/bindings/Vitest.res +0 -26
  66. package/src/bindings/Vitest.res.mjs +0 -3
  67. package/src/db/Table.res +24 -150
  68. package/src/db/Table.res.mjs +38 -272
  69. package/src/sources/ChainSources.res +2 -0
  70. package/src/sources/ChainSources.res.mjs +2 -2
  71. package/src/sources/EvmChain.res +3 -0
  72. package/src/sources/EvmChain.res.mjs +4 -2
  73. package/src/sources/EvmHyperSyncSource.res +2 -2
  74. package/src/sources/EvmHyperSyncSource.res.mjs +1 -1
  75. package/src/sources/EvmRpcClient.res +13 -3
  76. package/src/sources/EvmRpcClient.res.mjs +4 -3
  77. package/src/sources/EvmRpcWs.res +4 -3
  78. package/src/sources/EvmRpcWs.res.mjs +2 -2
  79. package/src/sources/HeightFeed.res +31 -10
  80. package/src/sources/HeightFeed.res.mjs +10 -6
  81. package/src/sources/HeightStream.res +32 -17
  82. package/src/sources/HeightStream.res.mjs +10 -10
  83. package/src/sources/HyperSyncSSE.res +4 -6
  84. package/src/sources/HyperSyncSSE.res.mjs +2 -2
  85. package/src/sources/RpcSource.res +10 -1
  86. package/src/sources/RpcSource.res.mjs +4 -3
  87. package/src/sources/Source.res +3 -0
  88. package/src/sources/SourceManager.res +84 -77
  89. package/src/sources/SourceManager.res.mjs +17 -20
  90. package/src/sources/SourceManager.resi +3 -4
  91. package/src/sources/StartBlockResolver.res +1 -0
  92. package/src/sources/StartBlockResolver.res.mjs +3 -2
  93. package/src/sources/SvmHyperSyncSource.res +2 -2
  94. package/src/sources/SvmHyperSyncSource.res.mjs +1 -1
  95. package/svm.schema.json +11 -2
  96. package/src/PgWriting.res +0 -36
  97. package/src/PgWriting.res.mjs +0 -34
  98. package/src/db/IndexDefinition.res +0 -47
  99. package/src/db/IndexDefinition.res.mjs +0 -67
package/src/Storage.res CHANGED
@@ -1,10 +1,3 @@
1
- let formatSeconds = seconds => (Math.round(seconds *. 100.) /. 100.)->Float.toString
2
-
3
- // Every index build blocks writes to its table for as long as it runs, and on a
4
- // large database that is not quick. Both build paths say so up front, so a
5
- // stalled-looking indexer is explainable from the logs alone.
6
- let slowOnLargeDatabaseNotice = "This can take a long time on a large database."
7
-
8
1
  let storeName = (store: StorageClient.store) =>
9
2
  switch store {
10
3
  | Postgres => "postgres"
@@ -13,88 +6,11 @@ let storeName = (store: StorageClient.store) =>
13
6
 
14
7
  let postgresName = Postgres->storeName
15
8
 
16
- let logIndexEvent = (~logger, ~pgSchema, event: StorageClient.indexEvent) =>
17
- switch event {
18
- | Invalid({names}) =>
19
- logger->Logger.warn(
20
- `Ignoring invalid PostgreSQL indexes in schema "${pgSchema}". They can't serve queries, so the indexer builds its own alongside them.`,
21
- ~params={"storage": postgresName, "indexes": names},
22
- )
23
- | Planned({declared, missing, rebuilt}) =>
24
- if rebuilt->Utils.Array.notEmpty {
25
- logger->Logger.warn(
26
- `PostgreSQL reports ${rebuilt
27
- ->Array.length
28
- ->Int.toString} of the indexer's own indexes as invalid, so they can't serve queries. Rebuilding them.`,
29
- ~params={"storage": postgresName, "indexes": rebuilt},
30
- )
31
- }
32
- switch missing {
33
- // The line that matters next is the indexer reporting itself ready, which
34
- // finalization logs.
35
- | [] =>
36
- logger->Logger.info(
37
- `All ${declared->Int.toString} schema indexes are already in place. Marking the indexer ready.`,
38
- ~params={"storage": postgresName},
39
- )
40
- | _ =>
41
- logger->Logger.info(
42
- `Creating the ${missing
43
- ->Array.length
44
- ->Int.toString} remaining schema indexes before the indexer reports ready. Writes are paused until they are committed. ${slowOnLargeDatabaseNotice}`,
45
- ~params={"storage": postgresName, "indexes": missing},
46
- )
47
- }
48
- | Building({purpose: Query, name, tableName, isRebuild}) =>
49
- logger->Logger.info(
50
- `${isRebuild
51
- ? "Rebuilding unusable index"
52
- : "Creating index"} "${name}" to serve a getWhere query on "${tableName}". Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
53
- ~params={"storage": postgresName},
54
- )
55
- | Building({purpose: Schema, name, isRebuild}) =>
56
- logger->Logger.info(
57
- `${isRebuild
58
- ? "Rebuilding unusable index"
59
- : "Creating missing index"} "${name}" the schema promises but the database no longer has. Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
60
- ~params={"storage": postgresName},
61
- )
62
- | Built({purpose, name, seconds}) =>
63
- logger->Logger.info(
64
- `Index "${name}" is ready after ${seconds->formatSeconds}s.${switch purpose {
65
- | Query => " Resuming indexing."
66
- | Schema => ""
67
- }}`,
68
- ~params={"storage": postgresName},
69
- )
70
- | Failed({purpose: Query, tableName, columns, error, ?code}) =>
71
- logger->Logger.warn(
72
- `Failed to create an index on "${tableName}"(${columns
73
- ->Array.map(column => `"${column}"`)
74
- ->Array.joinUnsafe(", ")}) for a getWhere query. The query runs without it.`,
75
- ~params={"storage": postgresName, "err": error, "code": code},
76
- )
77
- | Failed({purpose: Schema, name, error, ?code}) =>
78
- logger->Logger.warn(
79
- `Failed to restore the schema index "${name}". Queries relying on it run unindexed until the next restart.`,
80
- ~params={"storage": postgresName, "err": error, "code": code},
81
- )
82
- | ResyncFailed({name, error, ?code}) =>
83
- logger->Logger.trace(
84
- `Could not re-read the index "${name}" after a failed build. The next attempt reads it again.`,
85
- ~params={"storage": postgresName, "err": error, "code": code},
86
- )
87
- | Committed({count, seconds}) =>
88
- logger->Logger.info(
89
- `Committed ${count->Int.toString} schema indexes and the ready timestamp in ${seconds->formatSeconds}s.`,
90
- ~params={"storage": postgresName},
91
- )
92
- }
93
-
94
9
  let postgresOptions = (
95
10
  ~pgSchema=Env.Db.publicSchema,
96
11
  ~isHasuraEnabled=false,
97
12
  ~maxConnections=Env.Db.maxConnections,
13
+ ~isolated=false,
98
14
  ): StorageClient.postgresOptions => {
99
15
  {
100
16
  host: Env.Db.host,
@@ -111,435 +27,27 @@ let postgresOptions = (
111
27
  // Hasura cannot read a `numeric[]`, so a schema it tracks stores those as
112
28
  // `text[]` (issue #788).
113
29
  isHasuraEnabled,
30
+ isolated,
114
31
  }
115
32
  }
116
33
 
117
- // One client per storage: the stores and the chain-id mode it was created with
118
- // decide every statement the addon builds.
34
+ // One client per storage: the config it was created with decides every table
35
+ // and every statement the addon builds. Without one it keeps no entities.
119
36
  let makeClient = (
120
37
  ~logger: Logger.t,
121
- ~chainIdMode: ChainId.mode=Int32,
38
+ ~config: option<Config.t>=?,
122
39
  ~postgres: StorageClient.postgresOptions,
123
40
  ~clickhouse: option<StorageClient.clickHouseOptions>=?,
124
41
  ): StorageClient.t =>
125
42
  StorageClient.make(
126
- {chainIdMode: (chainIdMode :> string), postgres, ?clickhouse},
127
- ~onIndexEvent=event => logIndexEvent(~logger, ~pgSchema=postgres.pgSchema, event),
128
- ~onWarning=message => logger->Logger.warn(message, ~params={"storage": ClickHouse->storeName}),
129
- )
130
-
131
- // A per-chain entity's rows are partitioned by the chain that owns them, so a
132
- // chain-filtered read scans one chain's partition rather than the whole table.
133
- // `$` can't occur in a GraphQL entity name, so a partition name can never
134
- // collide with the table another entity claims; past the identifier limit the
135
- // entity index keeps what survives truncation unique.
136
- let partitionTableName = (~entityConfig: Internal.entityConfig, ~chainId: ChainId.t) => {
137
- let chainIdStr = chainId->ChainId.toString
138
- Table.fitPgTableName(
139
- `${entityConfig.table.tableName}$${chainIdStr}`,
140
- ~uniqueSuffix=`$${entityConfig.index->Int.toString}$${chainIdStr}`,
141
- )
142
- }
143
-
144
- // The physical tables an index on the entity is built on. A per-chain entity's
145
- // rows are partitioned by chain, and an index declared on the parent cascades
146
- // to every partition, which is what a run driving every chain wants: one
147
- // declaration, one build. An isolated run instead builds on its own chains'
148
- // partitions only: the planner uses a partition's own index either way, and
149
- // building one chain's index then neither waits for nor locks the rows of a
150
- // chain a sibling process drives.
151
- let indexTableNames = (entityConfig: Internal.entityConfig, ~partitionChainIds) =>
152
- switch (entityConfig.table->Table.getChainIdField, partitionChainIds) {
153
- | (Some(_), Some(chainIds)) =>
154
- chainIds->Array.map(chainId => partitionTableName(~entityConfig, ~chainId))
155
- | _ => [entityConfig.table.tableName]
156
- }
157
-
158
- // Every index the entity schema promises: an `@index` field, a composite index,
159
- // or the index backing a derived relationship. Deferred past the initial DDL
160
- // and created once backfill completes, so a chain that reports itself ready
161
- // always has all of them. With `partitionChainIds`, a per-chain entity's index
162
- // is one per partition of those chains rather than one on the parent.
163
- //
164
- // `entities` is the Postgres-backed set, and every `@derivedFrom` target within
165
- // it resolves: config parsing rejects a Postgres entity deriving from one that
166
- // isn't in Postgres (`validate_relationship_storage`).
167
- let getSchemaIndexes = (
168
- ~entities: array<Internal.entityConfig>,
169
- ~partitionChainIds: option<array<ChainId.t>>=?,
170
- ): array<IndexDefinition.t> => {
171
- let derivedSchema = Schema.make(entities->Array.map(e => e.table))
172
- let all = []
173
-
174
- entities->Array.forEach(({table}) => {
175
- table
176
- ->Table.getSingleIndexes
177
- ->Array.forEach(column =>
178
- all->Array.push(IndexDefinition.single(~tableName=table.tableName, ~column))->ignore
179
- )
180
- table
181
- ->Table.getCompositeIndexes
182
- ->Array.forEach(indexFields =>
183
- all
184
- ->Array.push(IndexDefinition.fromIndexFields(~tableName=table.tableName, ~indexFields))
185
- ->ignore
186
- )
187
- })
188
-
189
- entities->Array.forEach(({table}) =>
190
- table
191
- ->Table.getDerivedFromFields
192
- ->Array.forEach(derivedFromField => {
193
- let column =
194
- derivedSchema->Schema.getDerivedFromPgFieldName(derivedFromField)->Utils.unwrapResultExn
195
- all
196
- ->Array.push(IndexDefinition.single(~tableName=derivedFromField.derivedFromEntity, ~column))
197
- ->ignore
198
- })
199
- )
200
-
201
- // An `@index` field and a derived relationship pointing at it describe the
202
- // same index, so the list is deduped on identity rather than on name.
203
- let seen = Utils.Set.make()
204
- let entityByTableName = Dict.make()
205
- entities->Array.forEach(entityConfig =>
206
- entityByTableName->Dict.set(entityConfig.table.tableName, entityConfig)
207
- )
208
-
209
- all
210
- ->Array.filter(definition => {
211
- let identity = definition->IndexDefinition.describe
212
- if seen->Utils.Set.has(identity) {
213
- false
214
- } else {
215
- seen->Utils.Set.add(identity)->ignore
216
- true
217
- }
218
- })
219
- ->Array.flatMap(definition =>
220
- entityByTableName
221
- ->Dict.get(definition.tableName)
222
- ->Option.getOrThrow
223
- ->indexTableNames(~partitionChainIds)
224
- ->Array.map(tableName => {...definition, IndexDefinition.tableName})
225
- )
226
- }
227
-
228
- let pgColumnInput = (field: Table.field): Core.pgColumnInput => {
229
- let (fieldType, precision, scale, enumName) = field.fieldType->Table.pgFieldTypeParts
230
- {
231
- name: field->Table.getPgDbFieldName,
232
- fieldType,
233
- isArray: field.isArray,
234
- isNullable: field.isNullable,
235
- isPrimaryKey: field.isPrimaryKey,
236
- defaultValue: ?field.defaultValue,
237
- ?precision,
238
- ?scale,
239
- ?enumName,
240
- }
241
- }
242
-
243
- // The entity as it's stored: the handler-visible schema plus the chain-id
244
- // column a per-chain entity's table carries. The value for that column is
245
- // stamped from the flush group's scope right before serialization, so it never
246
- // has to be re-derived from a checkpoint.
247
- let rowSchemaCache = Utils.WeakMap.make()
248
- let getRowSchema = (entityConfig: Internal.entityConfig): S.t<Internal.entity> =>
249
- switch rowSchemaCache->Utils.WeakMap.get(entityConfig) {
250
- | Some(cached) => cached
251
- | None =>
252
- let schema = switch entityConfig.table->Table.getChainIdField {
253
- | None => entityConfig.schema
254
- | Some(chainIdField) =>
255
- S.schema(s => {
256
- let dict = Dict.make()
257
- switch entityConfig.schema->S.classify {
258
- | Object({items}) =>
259
- items->Array.forEach(({location, schema}) => dict->Dict.set(location, s.matches(schema)))
260
- | _ =>
261
- JsError.throwWithMessage(
262
- `Unexpected non-object schema for entity "${entityConfig.name}".`,
263
- )
264
- }
265
- dict->Dict.set(chainIdField.fieldName, s.matches(ChainId.schema->S.toUnknown))
266
- dict
267
- })->(Utils.magic: S.t<dict<unknown>> => S.t<Internal.entity>)
268
- }
269
- rowSchemaCache->Utils.WeakMap.set(entityConfig, schema)->ignore
270
- schema
271
- }
272
-
273
- // How a batch of a Postgres table's rows crosses over: staged into an arena
274
- // column by column, or rendered a parameter per cell when it has an array
275
- // column.
276
- type postgresTable = {staged: option<array<Staging.column>>}
277
-
278
- // A table as the addon writes it: its handle, the columns a batch's rows carry
279
- // in the order the addon expects them, and what each store that keeps it made
280
- // of it.
281
- type registered = {
282
- handle: int,
283
- home: StorageClient.store,
284
- table: Table.table,
285
- fields: array<Table.field>,
286
- postgres: option<postgresTable>,
287
- clickhouse: option<ClickHouse.table>,
288
- }
289
-
290
- %%private(
291
- let register = (
292
- client: StorageClient.t,
293
- ~table: Table.table,
294
- ~itemSchema: S.t<unknown>,
295
- ~postgres=true,
296
- ~clickhouse: option<StorageClient.clickHouseTable>=?,
297
- ~appendOnly=false,
298
- ~history=?,
299
- ): registered => {
300
- let fields = table->Table.schemaOrderedFields(~schema=itemSchema)
301
- let chainIdField = table->Table.getChainIdField
302
- let registered = client->StorageClient.registerTable({
303
- name: table.tableName,
304
- postgres: ?(
305
- postgres
306
- ? Some({
307
- Core.columns: table->Table.getFields->Array.map(pgColumnInput),
308
- writeColumns: fields->Array.map(Table.getPgDbFieldName),
309
- partitionByColumn: ?(chainIdField->Option.map(Table.getPgDbFieldName)),
310
- appendOnly,
311
- historyTable: ?history,
312
- chainIdColumn: ?switch history {
313
- | Some(_) => chainIdField->Option.map(Table.getPgDbFieldName)
314
- | None => None
315
- },
316
- })
317
- : None
318
- ),
319
- ?clickhouse,
320
- })
321
- {
322
- handle: registered.handle,
323
- home: registered.home,
324
- table,
325
- fields,
326
- postgres: registered.postgres->Option.map(({?kinds}): postgresTable => {
327
- staged: kinds->Option.map(kinds =>
328
- PgWriting.columns(fields, ~kinds=kinds->Array.map(Staging.kindOfOrdinal))
329
- ),
330
- }),
331
- // Named for where its rows go, which for an entity is its history table.
332
- clickhouse: registered.clickhouse->Option.map(
333
- ClickHouse.makeTable(
334
- ~handle=registered.handle,
335
- ~name=history->Option.getOr(table.tableName),
336
- _,
337
- ),
338
- ),
339
- }
340
- }
341
- )
342
-
343
- let registerEntity = (client, entityConfig: Internal.entityConfig) =>
344
- client->register(
345
- ~table=entityConfig.table,
346
- ~itemSchema=entityConfig->getRowSchema->S.toUnknown,
347
- ~postgres=entityConfig.storage.postgres,
348
- ~clickhouse=?entityConfig.storage.clickhouse
349
- ? Some(ClickHouse.entityTable(entityConfig))
350
- : None,
351
- ~history=EntityHistory.historyTableName(
352
- ~entityName=entityConfig.name,
353
- ~entityIndex=entityConfig.index,
354
- ),
355
- )
356
-
357
- // A json column holds a document, which is as readily a string or a boolean as
358
- // an object. By the time a parameter is rendered there is only the value to go
359
- // on, and there a string is text and a boolean is `t` — neither of which the
360
- // server will read back as the document it was. So the document becomes its own
361
- // text here, where the column it belongs to is still known.
362
- //
363
- // JSON `null` is a document of its own, which a required column stores as one.
364
- // A column that takes NULL keeps the two apart the other way: there `null` is
365
- // the absent value, since only one of the two can survive the field's schema.
366
- %%private(
367
- let renderDocument = (value, ~isNullable) =>
368
- switch value->(Utils.magic: unknown => Nullable.t<unknown>) {
369
- | Value(_) => value->Sql.stringifyDocument->(Utils.magic: string => unknown)
370
- | Null if !isNullable => "null"->(Utils.magic: string => unknown)
371
- | Null | Undefined => value
372
- }
373
- )
374
-
375
- // A list of documents is bound as an array whose elements are each their own
376
- // document's text.
377
- %%private(
378
- let renderDocuments = (columns: array<array<unknown>>, ~at: array<(int, Table.field)>) => {
379
- at->Array.forEach(((index, field)) => {
380
- let values = columns->Array.getUnsafe(index)
381
- for row in 0 to values->Array.length - 1 {
382
- let value = values->Array.getUnsafe(row)
383
- values->Array.setUnsafe(
384
- row,
385
- switch value->(Utils.magic: unknown => Nullable.t<array<unknown>>)->Nullable.toOption {
386
- | Some(documents) if field.isArray =>
387
- documents
388
- ->Array.map(document => document->renderDocument(~isNullable=true))
389
- ->(Utils.magic: array<unknown> => unknown)
390
- | _ => value->renderDocument(~isNullable=field.isNullable)
391
- },
392
- )
393
- }
394
- })
395
- columns
396
- }
397
- )
398
-
399
- // An item schema compiled for one table's columns: rows in, one array per
400
- // column out. The values are validated by the type system on the way in, and a
401
- // value its column cannot take is refused by the server, which says why.
402
- type converters = Utils.WeakMap.t<S.t<unknown>, array<unknown> => array<array<unknown>>>
403
- let arenaConverters: converters = Utils.WeakMap.make()
404
- let cellConverters: converters = Utils.WeakMap.make()
405
- let converterOf = (registered, ~forArena, ~itemSchema) => {
406
- let converters = forArena ? arenaConverters : cellConverters
407
- switch converters->Utils.WeakMap.get(itemSchema) {
408
- | Some(convert) => convert
409
- | None =>
410
- let documents =
411
- registered.fields->Array.filterMapWithIndex((field, index) =>
412
- field.fieldType === Table.Json ? Some((index, field)) : None
413
- )
414
- // Booleans travel as 1/0 and bigints as their digits, which is what an
415
- // arena's slots hold.
416
- let schema = forArena ? registered.table->Table.toDbSchema(~schema=itemSchema) : itemSchema
417
- let convert = S.compile(
418
- S.unnest(schema)
419
- ->S.preprocess(_ => {
420
- serializer: columns =>
421
- columns
422
- ->(Utils.magic: unknown => array<array<unknown>>)
423
- ->renderDocuments(~at=documents)
424
- ->(Utils.magic: array<array<unknown>> => unknown),
425
- })
426
- ->S.toUnknown,
427
- ~input=Value,
428
- ~output=Unknown,
429
- ~mode=Sync,
430
- ~typeValidation=false,
431
- )->(Utils.magic: (unknown => unknown) => array<unknown> => array<array<unknown>>)
432
- converters->Utils.WeakMap.set(itemSchema, convert)->ignore
433
- convert
434
- }
435
- }
436
-
437
- // Staging refuses a non-finite float for a staged table. A table bound a cell
438
- // at a time has to refuse it too: the server would store it, and the field's
439
- // schema would then refuse it on every load.
440
- %%private(
441
- let refuseNonFinite = (values: array<array<unknown>>, ~fields: array<Table.field>) =>
442
- fields->Array.forEachWithIndex((field, index) =>
443
- if field.fieldType === Table.Number {
444
- let column = field->Table.getPgDbFieldName
445
- let check = (value: Nullable.t<float>) =>
446
- switch value {
447
- | Value(number) => number->Staging.finiteOrThrow(~column)->ignore
448
- | Null | Undefined => ()
449
- }
450
- values
451
- ->Array.getUnsafe(index)
452
- ->Array.forEach(value =>
453
- if field.isArray {
454
- switch value->(Utils.magic: unknown => Nullable.t<array<Nullable.t<float>>>) {
455
- | Value(elements) => elements->Array.forEach(check)
456
- | Null | Undefined => ()
457
- }
458
- } else {
459
- check(value->(Utils.magic: unknown => Nullable.t<float>))
460
- }
461
- )
462
- }
463
- )
464
- )
465
-
466
- // What a batch has laid out on its way to the addon: the staged handles, freed
467
- // if the write never reaches it, and each store's time spent laying its rows
468
- // out, which is part of writing to it.
469
- type staged = {handles: array<int>, seconds: dict<float>}
470
-
471
- let stagedFor = (staged, ~store, stage) => {
472
- let started = Performance.now()
473
- let result = stage()
474
- let name = store->storeName
475
- staged.seconds->Dict.set(
476
- name,
477
- staged.seconds->Dict.get(name)->Option.getOr(0.) +. started->Performance.secondsSince,
43
+ {postgres, ?clickhouse},
44
+ ~configJson=?config->Option.map(config => config.envioInfo->JSON.stringify),
45
+ ~logger,
478
46
  )
479
- result
480
- }
481
47
 
482
- // A Postgres table's rows for a write. A staged batch is handed back by handle.
483
- %%private(
484
- let rowsOrThrow = (
485
- client: StorageClient.t,
486
- registered,
487
- ~itemSchema,
488
- items: array<unknown>,
489
- ~staged: staged,
490
- ): StorageClient.rows =>
491
- staged->stagedFor(~store=Postgres, () => {
492
- let rows = items->Array.length
493
- try {
494
- switch registered.postgres {
495
- | Some({staged: Some(columns)}) =>
496
- let values = (registered->converterOf(~forArena=true, ~itemSchema))(items)
497
- let handle =
498
- client
499
- ->StorageClient.arena(~store=Postgres)
500
- ->PgWriting.stage(~table=registered.handle, ~columns, ~values, ~rows)
501
- staged.handles->Array.push(handle)
502
- ({staged: handle, rows}: StorageClient.rows)
503
- | Some({staged: None}) =>
504
- let values = (registered->converterOf(~forArena=false, ~itemSchema))(items)
505
- values->refuseNonFinite(~fields=registered.fields)
506
- {
507
- cells: values->Utils.Array.flatten->Sql.params,
508
- rows,
509
- }
510
- | None => JsError.throwWithMessage(`Postgres doesn't keep "${registered.table.tableName}"`)
511
- }
512
- } catch {
513
- | S.Raised(_) as exn =>
514
- throw(
515
- Persistence.StorageError({
516
- message: `Failed to convert items for table "${registered.table.tableName}"`,
517
- reason: exn,
518
- }),
519
- )
520
- | exn =>
521
- throw(
522
- Persistence.StorageError({
523
- message: `Failed to insert items into table "${registered.table.tableName}"`,
524
- reason: exn->Utils.prettifyExn,
525
- }),
526
- )
527
- }
528
- })
529
- )
530
-
531
- // Ids as the text their column reads them from.
532
- let renderIds = (table: Table.table, ids: array<EntityId.t>) =>
533
- table
534
- ->Table.encodeIdsToJson(ids)
535
- ->(Utils.magic: JSON.t => array<unknown>)
536
- ->Array.map(Sql.render)
537
-
538
- let bounds = (
539
- {sequence, byChain}: CheckpointSequence.checkpointBoundsByChain,
540
- ): StorageClient.bounds => {
541
- let (chainIds, checkpointIds) = byChain->Frontier.unnestParams
542
- {sequence, chainIds, checkpointIds}
48
+ let bounds = ({byChain}: CheckpointSequence.checkpointBoundsByChain): StorageClient.bounds => {
49
+ chainIds: byChain->Frontier.chainIds,
50
+ checkpointIds: byChain->Frontier.chainIds->Array.map(chainId => byChain->Frontier.get(chainId)),
543
51
  }
544
52
 
545
53
  let progress = (chain: InternalTable.Chains.progressedChain): StorageClient.progress => {
@@ -583,16 +91,6 @@ let storageErrorOf = exn =>
583
91
  | _ => exn
584
92
  }
585
93
 
586
- let rollbackRowStateSchema: Table.table => S.t<(
587
- EntityId.t,
588
- EntityHistory.RowAction.t,
589
- )> = Utils.WeakMap.memoize(table =>
590
- S.object(s => (
591
- s.field(Table.idFieldName, table->Table.getIdSchema),
592
- s.field(EntityHistory.changeFieldName, EntityHistory.RowAction.schema),
593
- ))
594
- )
595
-
596
94
  // The chain a rollback row belongs to, read from the chain-id column both
597
95
  // rollback queries select. None for a cross-chain entity, which has no column.
598
96
  let rollbackChainIdSchema: Table.table => option<S.t<ChainId.t>> = Utils.WeakMap.memoize(table =>
@@ -608,20 +106,94 @@ let rollbackRemovedIdSchema: Table.table => S.t<EntityId.t> = Utils.WeakMap.memo
608
106
  S.object(s => s.field(Table.idFieldName, table->Table.getIdSchema))
609
107
  )
610
108
 
109
+ // A change set is the entity's fields, then each change's checkpoint and 1
110
+ // for a change that deletes the entity.
111
+ let changeColumns: array<Staging.column> = [
112
+ {name: EntityHistory.checkpointIdFieldName, kind: I64, isNullable: false},
113
+ {name: EntityHistory.changeFieldName, kind: F64, isNullable: false},
114
+ ]
115
+
116
+ // Fills a stage the addon lent for `rows`, handing it back once every value
117
+ // is in, or abandoning it if one is refused.
118
+ let stageOrThrow = (arena, ~columns, ~rows, fill) => {
119
+ let stage = arena->Staging.begin(~rows, ~columns)
120
+ try {
121
+ fill(stage)
122
+ stage->Staging.commit
123
+ } catch {
124
+ | exn =>
125
+ stage->Staging.abort
126
+ throw(exn)
127
+ }
128
+ }
129
+
130
+ // Rows of a fixed layout, each value read off the item by its column's name.
131
+ let stageRowsOrThrow = (arena, ~writers, items: array<dict<unknown>>) =>
132
+ arena->stageOrThrow(~columns=writers->StagedRows.columnsOf, ~rows=items->Array.length, stage =>
133
+ items->Array.forEachWithIndex((item, row) =>
134
+ writers->Array.forEachWithIndex(
135
+ ({field}: StagedRows.writer, column) =>
136
+ stage->StagedRows.writeField(~writers, ~column, ~row, item->Dict.getUnsafe(field.name)),
137
+ )
138
+ )
139
+ )
140
+
141
+ // Every change a group made, in order: what a set wrote, or the id a delete
142
+ // removed. Each store reads what it keeps off the same rows.
143
+ let stageChangesOrThrow = (arena, ~writers, ~changes: array<Change.t<Internal.entity>>) => {
144
+ let fields = writers->Array.length
145
+ arena->stageOrThrow(
146
+ ~columns=writers->StagedRows.columnsOf->Array.concat(changeColumns),
147
+ ~rows=changes->Array.length,
148
+ stage =>
149
+ changes->Array.forEachWithIndex((change, row) => {
150
+ let entityId = change->Change.getEntityId->(Utils.magic: EntityId.t => unknown)
151
+ switch change {
152
+ | Set({entity}) =>
153
+ let values = entity->(Utils.magic: Internal.entity => dict<unknown>)
154
+ writers->Array.forEachWithIndex(
155
+ ({field}: StagedRows.writer, column) =>
156
+ stage->StagedRows.writeField(
157
+ ~writers,
158
+ ~column,
159
+ ~row,
160
+ field.name === Table.idFieldName ? entityId : values->Dict.getUnsafe(field.name),
161
+ ),
162
+ )
163
+ | Delete(_) =>
164
+ writers->Array.forEachWithIndex(
165
+ ({field}: StagedRows.writer, column) =>
166
+ if field.name === Table.idFieldName {
167
+ stage->StagedRows.writeField(~writers, ~column, ~row, entityId)
168
+ } else {
169
+ stage->Staging.writeDeletedValue(~column, ~row, %raw(`undefined`))
170
+ },
171
+ )
172
+ }
173
+ stage->Staging.writeValue(
174
+ ~column=fields,
175
+ ~row,
176
+ change->Change.getCheckpointId->(Utils.magic: bigint => unknown),
177
+ )
178
+ stage->Staging.writeValue(
179
+ ~column=fields + 1,
180
+ ~row,
181
+ switch change {
182
+ | Set(_) => 0.
183
+ | Delete(_) => 1.
184
+ }->(Utils.magic: float => unknown),
185
+ )
186
+ }),
187
+ )
188
+ }
189
+
611
190
  let make = (
612
191
  ~client: StorageClient.t,
613
- ~logger: Logger.t,
614
- // The entities a load can name before initialize or resume has.
615
- ~entities: array<Internal.entityConfig>=[],
616
192
  // Where the effect cache is dumped to and uploaded from.
617
193
  ~cacheDir: option<NodeJs.Path.t>=?,
618
194
  // Decides how wide an address key is, both when the config's addresses are
619
195
  // encoded at initialize and when stored rows are grouped on resume.
620
196
  ~ecosystem: Ecosystem.name,
621
- // An `envio start --chain` process: builds and looks for a per-chain entity's
622
- // indexes on its own chains' partitions, never on the parent table, so no
623
- // build reaches into the rows a sibling process drives.
624
- ~isolated=false,
625
197
  ~onInitialize=?,
626
198
  ): Persistence.storage => {
627
199
  let cacheDirPath = switch cacheDir {
@@ -634,140 +206,43 @@ let make = (
634
206
  ])
635
207
  }
636
208
 
637
- // Every table is registered with the addon once, by name: the handle is what
638
- // an operation names it by.
639
- let registry: dict<registered> = Dict.make()
640
- let registered = (~table: Table.table, register) =>
641
- switch registry->Utils.Dict.dangerouslyGetNonOption(table.tableName) {
642
- | Some(registered) => registered
643
- | None =>
644
- let registered = register()
645
- registry->Dict.set(table.tableName, registered)
646
- registered
647
- }
648
- let entityTable = (entityConfig: Internal.entityConfig) =>
649
- registered(~table=entityConfig.table, () => client->registerEntity(entityConfig))
650
- let ownTable = (~table: Table.table, ~itemSchema: S.t<unknown>, ~appendOnly=?) =>
651
- registered(~table, () => client->register(~table, ~itemSchema, ~appendOnly?))
652
- let rawEventsTable = () =>
653
- ownTable(
654
- ~table=InternalTable.RawEvents.table,
655
- ~itemSchema=InternalTable.RawEvents.schema->S.toUnknown,
656
- ~appendOnly=true,
657
- )
658
- // Every cache table has the same two columns, whatever its effect's output.
659
- let cacheTable = (table: Table.table) =>
660
- ownTable(~table, ~itemSchema=Internal.cacheItemSchema->S.toUnknown)
661
- entities->Array.forEach(entityConfig => entityConfig->entityTable->ignore)
662
- let tableOf = (table: Table.table) =>
663
- switch registry->Utils.Dict.dangerouslyGetNonOption(table.tableName) {
664
- | Some(registered) => registered
665
- // An effect's cache is loaded before anything wrote or created it.
666
- | None if Internal.EffectCache.fromTableName(table.tableName)->Option.isSome =>
667
- cacheTable(table)
668
- | None => JsError.throwWithMessage(`The storage has no table "${table.tableName}".`)
669
- }
670
-
671
- // A per-chain entity's Postgres rows live in one partition per chain.
672
- let partitions = (entities: array<Internal.entityConfig>, ~chainIds) =>
673
- entities
674
- ->Array.filter(entityConfig =>
675
- entityConfig.storage.postgres && entityConfig.table->Table.getChainIdField->Option.isSome
676
- )
677
- ->Array.flatMap(entityConfig =>
678
- chainIds->Array.map((chainId): StorageClient.partition => {
679
- table: (entityConfig->entityTable).handle,
680
- chainId,
681
- name: partitionTableName(~entityConfig, ~chainId),
682
- })
683
- )
209
+ let layouts = client->StorageClient.layouts
210
+ let entityIndexByName = Dict.make()
211
+ layouts.entities->Array.forEachWithIndex((layout, index) =>
212
+ entityIndexByName->Dict.set(layout.name, index)
213
+ )
214
+ let entityWriters = layouts.entities->Array.map(layout => layout.fields->StagedRows.writersOf)
215
+ let rawEventWriters = layouts.rawEvents->StagedRows.writersOf
216
+ let effectCacheWriters = layouts.effectCache->StagedRows.writersOf
684
217
 
685
218
  let holding = () => client->StorageClient.holding
686
219
 
687
- // Scans .envio/cache into a list of (cache table, absolute TSV path). Flat
688
- // `<name>.tsv` files map to cross-chain caches; a numeric subdirectory
689
- // `<chainId>/<name>.tsv` maps to a chain-scoped cache. Exactly one directory
690
- // level is supported. A non-numeric directory that contains TSVs is rejected.
691
- // Returns [] when .envio/cache doesn't exist.
692
- let scanCacheDir = async () => {
693
- let topEntries = try {
694
- await NodeJs.Fs.Promises.readdir(cacheDirPath)
695
- } catch {
696
- | _ => []
697
- }
698
- let result = []
699
- let _ = await topEntries
700
- ->Array.map(async entry => {
701
- let entryPath = NodeJs.Path.join(cacheDirPath, entry)
702
- let isDir = (await NodeJs.Fs.Promises.stat(entryPath))->NodeJs.Fs.Promises.statsIsDirectory
703
- if isDir {
704
- let subEntries = await NodeJs.Fs.Promises.readdir(entryPath)
705
- let tsvs = subEntries->Array.filter(sub => sub->String.endsWith(".tsv"))
706
- switch Internal.EffectCache.parseChainId(entry) {
707
- | Some(chainId) =>
708
- tsvs->Array.forEach(sub => {
709
- let effectName = sub->String.slice(~start=0, ~end=-4)
710
- let table = Internal.makeCacheTable(~effectName, ~scope=Chain(chainId))
711
- result
712
- ->Array.push((table, NodeJs.Path.join(entryPath, sub)->NodeJs.Path.toString))
713
- ->ignore
714
- })
715
- | None =>
716
- if tsvs->Utils.Array.notEmpty {
717
- JsError.throwWithMessage(
718
- `Invalid effect cache directory ".envio/cache/${entry}". Chain cache directories must be named by a numeric chain id (e.g. "1"). Found cache files: ${tsvs->Array.joinUnsafe(
719
- ", ",
720
- )}.`,
721
- )
722
- }
723
- }
724
- } else if entry->String.endsWith(".tsv") {
725
- let effectName = entry->String.slice(~start=0, ~end=-4)
726
- let table = Internal.makeCacheTable(~effectName, ~scope=CrossChain)
727
- result->Array.push((table, entryPath->NodeJs.Path.toString))->ignore
728
- }
729
- })
730
- ->Promise.all
731
- result
732
- }
733
-
734
220
  let restoreEffectCache = async (~withUpload) => {
735
- if withUpload {
736
- switch await scanCacheDir() {
737
- | [] => logger->Logger.info("No saved effect cache to load from .envio/cache.")
738
- | entries =>
739
- try {
740
- await client->StorageClient.uploadEffectCache(
741
- entries->Array.map(((table, path)): StorageClient.cacheUpload => {
742
- table: (table->cacheTable).handle,
743
- path,
744
- }),
745
- )
746
- logger->Logger.info("Successfully uploaded cache.")
747
- } catch {
748
- | exn => logger->Logger.errorWithExn("Failed to upload cache, continuing without it.", exn)
749
- }
750
- }
751
- }
752
-
753
221
  let cache = Dict.make()
754
- (await client->StorageClient.effectCacheTables)->Array.forEach(({tableName, rows}) => {
755
- switch Internal.EffectCache.fromTableName(tableName) {
756
- | Some((effectName, scope)) =>
757
- cache->Dict.set(
758
- tableName,
759
- ({effectName, scope, tableName, count: rows}: Persistence.effectCacheRecord),
760
- )
761
- | None => ()
762
- }
763
- })
222
+
223
+ (
224
+ await client->StorageClient.restoreEffectCache(cacheDirPath->NodeJs.Path.toString, withUpload)
225
+ )->Array.forEach(({effectName, ?chainId, tableName, rows}) =>
226
+ cache->Dict.set(
227
+ tableName,
228
+ (
229
+ {
230
+ effectName,
231
+ scope: switch chainId {
232
+ | Some(chainId) => Chain(chainId)
233
+ | None => CrossChain
234
+ },
235
+ tableName,
236
+ count: rows,
237
+ }: Persistence.effectCacheRecord
238
+ ),
239
+ )
240
+ )
764
241
  cache
765
242
  }
766
243
 
767
244
  let initialize = async (
768
245
  ~chainConfigs=[],
769
- ~entities=[],
770
- ~enums=[],
771
246
  ~contractMapping,
772
247
  ~envioInfo,
773
248
  ): Persistence.initialState => {
@@ -777,17 +252,6 @@ let make = (
777
252
  )
778
253
 
779
254
  await client->StorageClient.initialize({
780
- sequence: CheckpointSequence.fromEntities(entities),
781
- tables: [rawEventsTable().handle]->Array.concat(
782
- entities->Array.map(entityConfig => (entityConfig->entityTable).handle),
783
- ),
784
- partitions: entities->partitions(
785
- ~chainIds=chainConfigs->Array.map((chainConfig: Config.chain) => chainConfig.id),
786
- ),
787
- enums: enums->Array.map((enumConfig: Table.enumConfig<Table.enum>): StorageClient.enum => {
788
- name: enumConfig.name,
789
- variants: enumConfig.variants->(Utils.magic: array<Table.enum> => array<string>),
790
- }),
791
255
  chains: chainConfigs->Array.map(toChainConfig),
792
256
  envioInfo: envioInfo->JSON.stringify,
793
257
  contractNames: contractMapping->ContractMapping.names,
@@ -829,7 +293,11 @@ let make = (
829
293
 
830
294
  let loadOrThrow = async (~filter: EntityFilter.t, ~table: Table.table) => {
831
295
  let conditions = LoadFilter.make(~filter, ~table, ~column=field => field.pgDbFieldName)
832
- switch await client->StorageClient.loadWhere(~table=(table->tableOf).handle, ~conditions) {
296
+ let loading = switch entityIndexByName->Utils.Dict.dangerouslyGetNonOption(table.tableName) {
297
+ | Some(entity) => client->StorageClient.loadWhere(~entity, ~conditions)
298
+ | None => client->StorageClient.loadEffectCache(~table=table.tableName, ~conditions)
299
+ }
300
+ switch await loading {
833
301
  | exception exn =>
834
302
  throw(
835
303
  Persistence.StorageError({
@@ -873,85 +341,30 @@ let make = (
873
341
  columns
874
342
  }
875
343
 
876
- let partitionChainIds = chainIds => isolated ? Some(chainIds) : None
877
-
878
- // The physical table a query index for `scope` is built on. A per-chain
879
- // entity's query carries the scope's chain id, so it is planned against that
880
- // chain's partition, which an index on the parent covers by cascading.
881
- let queryTableName = (~entityConfig: Internal.entityConfig, ~scope: Internal.chainScope) =>
882
- switch scope {
883
- | Chain(chainId) =>
884
- indexTableNames(
885
- entityConfig,
886
- ~partitionChainIds=partitionChainIds([chainId]),
887
- )->Array.getUnsafe(0)
888
- | CrossChain => entityConfig.table.tableName
889
- }
890
-
891
344
  let ensureQueryIndexes = async (
892
345
  ~entityConfig: Internal.entityConfig,
893
346
  ~scope: Internal.chainScope,
894
347
  ~filters: array<EntityFilter.t>,
895
348
  ) =>
896
349
  switch client->StorageClient.ensureQueryIndexes(
897
- (entityConfig->entityTable).handle,
898
- queryTableName(~entityConfig, ~scope),
899
- filterColumns(~table=entityConfig.table, ~filters),
350
+ ~entity=entityConfig.index,
351
+ ~chainId=scope->Internal.chainScopeChainId->Null.fromOption,
352
+ ~columns=filterColumns(~table=entityConfig.table, ~filters),
900
353
  ) {
901
354
  | Value(building) => await building
902
355
  | Null => ()
903
356
  }
904
357
 
905
- let schemaIndexes = (~entities: array<Internal.entityConfig>, ~chainIds) =>
906
- getSchemaIndexes(
907
- ~entities=entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres),
908
- ~partitionChainIds=?partitionChainIds(chainIds),
909
- )->Array.map(IndexDefinition.toInput)
910
-
911
358
  // Runs on a resumed indexer that is already ready, so handlers may be issuing
912
359
  // getWhere queries alongside it. Nothing here writes `ready_at` — the chains
913
360
  // already carry theirs.
914
- let ensureSchemaIndexes = (~entities, ~chainIds) =>
915
- client->StorageClient.ensureSchemaIndexes(schemaIndexes(~entities, ~chainIds))
361
+ let ensureSchemaIndexes = (~chainIds) => client->StorageClient.ensureSchemaIndexes(chainIds)
916
362
 
917
- let finalizeBackfill = (~entities, ~chainIds, ~readyAt: Date.t) =>
918
- client->StorageClient.finalizeBackfill(
919
- schemaIndexes(~entities, ~chainIds),
920
- chainIds,
921
- readyAt->Date.getTime,
922
- )
363
+ let finalizeBackfill = (~chainIds, ~readyAt: Date.t) =>
364
+ client->StorageClient.finalizeBackfill(chainIds, readyAt->Date.getTime)
923
365
 
924
- let dumpEffectCache = async () => {
925
- try {
926
- let tables =
927
- (await client->StorageClient.effectCacheTables)->Array.filter(({rows}) => rows > 0)
928
- if tables->Utils.Array.notEmpty {
929
- logger->Logger.info(
930
- `Dumping cache: ${tables
931
- ->Array.map(({tableName, rows}) => tableName ++ " (" ++ rows->Int.toString ++ " rows)")
932
- ->Array.joinUnsafe(", ")}`,
933
- )
934
- await client->StorageClient.dumpEffectCache(
935
- tables->Array.filterMap(({tableName}) =>
936
- Internal.EffectCache.fromTableName(tableName)->Option.map(((
937
- effectName,
938
- scope,
939
- )): StorageClient.cacheDump => {
940
- tableName,
941
- // Chain-scoped caches dump into a directory of their chain's.
942
- path: NodeJs.Path.join(
943
- cacheDirPath,
944
- Internal.EffectCache.toCachePath(~effectName, ~scope),
945
- )->NodeJs.Path.toString,
946
- })
947
- ),
948
- )
949
- logger->Logger.info(`Successfully dumped cache to ${cacheDirPath->NodeJs.Path.toString}`)
950
- }
951
- } catch {
952
- | exn => logger->Logger.errorWithExn(`Failed to dump cache.`, exn)
953
- }
954
- }
366
+ let dumpEffectCache = () =>
367
+ client->StorageClient.dumpEffectCache(cacheDirPath->NodeJs.Path.toString)
955
368
 
956
369
  let readAddressRows = (result): array<AddressRows.row> =>
957
370
  client
@@ -994,27 +407,18 @@ let make = (
994
407
  }
995
408
  }
996
409
 
997
- let addChain = (~chainConfig: Config.chain, ~entities, ~contractMapping) =>
410
+ let addChain = (~chainConfig: Config.chain, ~contractMapping) =>
998
411
  client->StorageClient.addChain({
999
412
  chain: chainConfig->toChainConfig,
1000
- partitions: entities->partitions(~chainIds=[chainConfig.id]),
1001
413
  addresses: chainConfig
1002
414
  ->ChainState.configStorageRows(~ecosystem, ~contractMapping)
1003
415
  ->addressColumns,
1004
416
  })
1005
417
 
1006
- let resumeInitialState = async (
1007
- ~entities: array<Internal.entityConfig>,
1008
- ~chainIds,
1009
- ~contractMapping,
1010
- ): Persistence.initialState => {
418
+ let resumeInitialState = async (~chainIds, ~contractMapping): Persistence.initialState => {
1011
419
  let (cache, resumed) = await Promise.all2((
1012
420
  restoreEffectCache(~withUpload=false),
1013
- client->StorageClient.resume({
1014
- sequence: CheckpointSequence.fromEntities(entities),
1015
- tables: entities->Array.map(entityConfig => (entityConfig->entityTable).handle),
1016
- chainIds,
1017
- }),
421
+ client->StorageClient.resume(chainIds),
1018
422
  ))
1019
423
  let addressRowsByChainId = resumed.addresses->readAddressRows->AddressRows.group
1020
424
  let stored =
@@ -1037,12 +441,12 @@ let make = (
1037
441
  sourceBlockNumber: chain.sourceBlock,
1038
442
  })
1039
443
  let checkpointFrontier = Frontier.fromEntries(
1040
- stored->Array.map(((id, chain)) => (id, chain.checkpointId->BigInt.fromStringOrThrow)),
444
+ stored->Array.map(((id, chain)) => (id, chain.checkpointId)),
1041
445
  )
1042
446
  let reorgCheckpoints = resumed.reorgCheckpoints->Array.map((
1043
447
  checkpoint
1044
448
  ): Internal.reorgCheckpoint => {
1045
- checkpointId: checkpoint.id->BigInt.fromStringOrThrow,
449
+ checkpointId: checkpoint.id,
1046
450
  chainId: checkpoint.chainId->ChainId.normalizeOrThrow,
1047
451
  blockNumber: checkpoint.blockNumber,
1048
452
  blockHash: checkpoint.blockHash,
@@ -1079,19 +483,13 @@ let make = (
1079
483
  let pruneStaleCheckpoints = (~safeCheckpoints) =>
1080
484
  client->StorageClient.pruneCheckpoints(safeCheckpoints->bounds)
1081
485
 
1082
- let pruneStaleEntityHistory = (~entityConfig, ~safeCheckpoints) =>
1083
- client->StorageClient.pruneHistory((entityConfig->entityTable).handle, safeCheckpoints->bounds)
486
+ let pruneStaleEntityHistory = (~entityConfig: Internal.entityConfig, ~safeCheckpoints) =>
487
+ client->StorageClient.pruneHistory(entityConfig.index, safeCheckpoints->bounds)
1084
488
 
1085
- let getRollbackTargetCheckpoint = async (~reorgChainId, ~lastKnownValidBlockNumber, ~sequence) =>
489
+ let getRollbackTargetCheckpoint = async (~reorgChainId, ~lastKnownValidBlockNumber) =>
1086
490
  (
1087
- await client->StorageClient.rollbackTargetCheckpoint(
1088
- reorgChainId,
1089
- lastKnownValidBlockNumber,
1090
- sequence,
1091
- )
1092
- )
1093
- ->Nullable.toOption
1094
- ->Option.map(BigInt.fromStringOrThrow)
491
+ await client->StorageClient.rollbackTargetCheckpoint(reorgChainId, lastKnownValidBlockNumber)
492
+ )->Nullable.toOption
1095
493
 
1096
494
  let getRollbackProgressDiff = async (~floors: RollbackFloors.t) =>
1097
495
  (
@@ -1106,7 +504,7 @@ let make = (
1106
504
 
1107
505
  let getRollbackData = async (~entityConfig: Internal.entityConfig, ~floors: RollbackFloors.t) => {
1108
506
  let {removed, restored} = await client->StorageClient.rollbackData(
1109
- (entityConfig->entityTable).handle,
507
+ entityConfig.index,
1110
508
  floors.checkpointBounds->bounds,
1111
509
  )
1112
510
  let removedIdRows = try client->StorageClient.read(removed) catch {
@@ -1122,183 +520,57 @@ let make = (
1122
520
  | None => Internal.CrossChain
1123
521
  | Some(schema) => Internal.Chain(row->S.parseOrThrow(schema))
1124
522
  }
1125
- let removals = removedIdRows->Array.map((row): Persistence.rollbackRemoval => {
1126
- entityId: row->S.parseOrThrow(rollbackRemovedIdSchema(entityConfig.table)),
1127
- scope: scopeOf(row),
1128
- })
1129
- let restoredEntitiesResult = []
1130
- rollbackRows->Array.forEach(row => {
1131
- let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema(entityConfig.table))
1132
- switch action {
1133
- | SET => restoredEntitiesResult->Array.push(row)->ignore
1134
- | DELETE => removals->Array.push({entityId, scope: scopeOf(row)})->ignore
1135
- }
1136
- })
1137
-
1138
523
  (
1139
- removals,
1140
- restoredEntitiesResult
524
+ removedIdRows->Array.map((row): Persistence.rollbackRemoval => {
525
+ entityId: row->S.parseOrThrow(rollbackRemovedIdSchema(entityConfig.table)),
526
+ scope: scopeOf(row),
527
+ }),
528
+ rollbackRows
1141
529
  ->(Utils.magic: array<dict<unknown>> => array<unknown>)
1142
530
  ->S.parseOrThrow(entityConfig.table->Table.pgRowsSchema)
1143
531
  ->(Utils.magic: array<unknown> => array<Internal.entity>),
1144
532
  )
1145
533
  }
1146
534
 
1147
- // One write group's changes, laid out for every store that keeps the entity:
1148
- // for Postgres the latest change of each id for the entity table, and every
1149
- // change for its history; for ClickHouse every change as a history row.
1150
- let entityWrite = (
1151
- {entityConfig, scope, changes, shouldSaveHistory}: Persistence.updatedEntity,
1152
- ~rollback: option<Persistence.rollback>,
1153
- ~config: Config.t,
1154
- ~staged,
1155
- ): StorageClient.entityWrite => {
1156
- let table = entityConfig->entityTable
1157
-
1158
- let chainIdStamp = switch (entityConfig.table->Table.getChainIdField, scope) {
1159
- | (Some(field), Internal.Chain(chainId)) => Some((field, chainId))
1160
- | _ => None
1161
- }
1162
-
1163
- let postgresWrite = (): StorageClient.postgresEntityWrite => {
1164
- // Every row in this group belongs to the group's scope, so the chain id
1165
- // is stamped once here instead of being looked up per row downstream.
1166
- let stamped = switch chainIdStamp {
1167
- | Some((field, chainId)) =>
1168
- changes->Array.map(change =>
1169
- switch change {
1170
- | Change.Set(set) =>
1171
- Change.Set({
1172
- ...set,
1173
- entity: set.entity->Internal.stampChainId(~fieldName=field.fieldName, ~chainId),
1174
- })
1175
- | Delete(_) => change
1176
- }
1177
- )
1178
- | None => changes
1179
- }
1180
-
1181
- // The rollback-diff change is written to the entity table only, never the
1182
- // history table; when present it is an id's oldest change.
1183
- let diffCheckpointId =
1184
- rollback->Option.flatMap(r =>
1185
- config.checkpointSequence->CheckpointSequence.findForScope(r.diffFrontier, ~scope)
1186
- )
1187
-
1188
- let historySets = []
1189
- let historySetCheckpointIds = []
1190
- let historyDeleteIds = []
1191
- let historyDeleteCheckpointIds = []
1192
- let idsWithDiff = Utils.Set.make()
1193
-
1194
- // Each id's latest change (the last one seen) and, when saving history,
1195
- // every change but the diff for the history table. Keyed by the id's
1196
- // string key, while what goes to SQL keeps the real id so it serializes
1197
- // with the id column's type.
1198
- let latestChangeById = Dict.make()
1199
- let orderedIds = []
1200
- stamped->Array.forEach(change => {
1201
- let entityId = change->Change.getEntityId
1202
- let entityKey = entityId->EntityId.toKey
1203
- if latestChangeById->Utils.Dict.dangerouslyGetNonOption(entityKey)->Option.isNone {
1204
- orderedIds->Array.push(entityId)
1205
- }
1206
- latestChangeById->Dict.set(entityKey, change)
1207
- if shouldSaveHistory {
1208
- if Some(change->Change.getCheckpointId) === diffCheckpointId {
1209
- idsWithDiff->Utils.Set.add(entityKey)->ignore
1210
- } else {
1211
- switch change {
1212
- | Delete({entityId, checkpointId}) =>
1213
- historyDeleteIds->Array.push(entityId)
1214
- historyDeleteCheckpointIds->Array.push(checkpointId->BigInt.toString)
1215
- | Set({entity, checkpointId}) =>
1216
- historySets->Array.push(entity)
1217
- historySetCheckpointIds->Array.push(checkpointId->BigInt.toString)
1218
- }
1219
- }
1220
- }
1221
- })
1222
-
1223
- let sets = []
1224
- let deletes = []
1225
- let backfill = []
1226
- orderedIds->Array.forEach(entityId => {
1227
- let entityKey = entityId->EntityId.toKey
1228
- switch latestChangeById->Dict.getUnsafe(entityKey) {
1229
- | Set({entity}) => sets->Array.push(entity)
1230
- | Delete({entityId}) => deletes->Array.push(entityId)
1231
- }
1232
-
1233
- // An id needs a history backfill iff none of its changes is the diff.
1234
- if shouldSaveHistory && !(idsWithDiff->Utils.Set.has(entityKey)) {
1235
- backfill->Array.push(entityId)
1236
- }
1237
- })
1238
-
1239
- let rowsOf = entities =>
1240
- entities->Utils.Array.notEmpty
1241
- ? Some(
1242
- client->rowsOrThrow(
1243
- table,
1244
- ~itemSchema=entityConfig->getRowSchema->S.toUnknown,
1245
- entities->(Utils.magic: array<Internal.entity> => array<unknown>),
1246
- ~staged,
1247
- ),
1248
- )
1249
- : None
1250
- {
1251
- sets: ?rowsOf(sets),
1252
- deletes: entityConfig.table->renderIds(deletes),
1253
- history: ?(
1254
- shouldSaveHistory
1255
- ? Some(
1256
- (
1257
- {
1258
- backfill: entityConfig.table->renderIds(backfill),
1259
- sets: ?rowsOf(historySets),
1260
- setCheckpointIds: historySetCheckpointIds,
1261
- deleteIds: entityConfig.table->renderIds(historyDeleteIds),
1262
- deleteCheckpointIds: historyDeleteCheckpointIds,
1263
- }: StorageClient.historyWrite
1264
- ),
1265
- )
1266
- : None
1267
- ),
1268
- }
1269
- }
535
+ let stageRows = (~table, ~writers, items) =>
536
+ client
537
+ ->StorageClient.arena((~rows) => client->StorageClient.beginRows(~table, ~rows))
538
+ ->stageRowsOrThrow(~writers, items)
1270
539
 
1271
- {
1272
- table: table.handle,
1273
- chainId: ?(chainIdStamp->Option.map(((_, chainId)) => chainId)),
1274
- postgres: ?(table.postgres->Option.map(_ => postgresWrite())),
1275
- clickhouse: ?switch (table.clickhouse, changes) {
1276
- | (_, []) | (None, _) => None
1277
- | (Some(chTable), _) =>
1278
- let handle = staged->stagedFor(~store=ClickHouse, () =>
1279
- client
1280
- ->StorageClient.arena(~store=ClickHouse)
1281
- ->ClickHouse.stageChangesOrThrow(chTable, ~entityConfig, ~scope, ~changes)
1282
- )
1283
- staged.handles->Array.push(handle)
1284
- Some(handle)
1285
- },
1286
- }
1287
- }
540
+ let stageChanges = ({entityConfig, changes}: Persistence.updatedEntity) =>
541
+ client
542
+ ->StorageClient.arena((~rows) =>
543
+ client->StorageClient.beginChanges(~entity=entityConfig.index, ~rows)
544
+ )
545
+ ->stageChangesOrThrow(~writers=entityWriters->Array.getUnsafe(entityConfig.index), ~changes)
546
+
547
+ // A value a store can't take is reported as the store's own refusal: the
548
+ // store of record's, for an entity it keeps.
549
+ let changesErrorOf = (entityConfig: Internal.entityConfig, exn) => Persistence.StorageError({
550
+ message: entityConfig.storage.postgres
551
+ ? `Failed to insert items into table "${entityConfig.table.tableName}"`
552
+ : `Failed to convert items for ClickHouse table "${EntityHistory.historyTableName(
553
+ ~entityName=entityConfig.name,
554
+ ~entityIndex=entityConfig.index,
555
+ )}"`,
556
+ reason: exn->Utils.prettifyExn,
557
+ })
1288
558
 
1289
559
  let writeBatch = async (
1290
560
  ~batch: Batch.t,
1291
561
  ~rollback: option<Persistence.rollback>,
1292
562
  ~config: Config.t,
1293
- ~allEntities: array<Internal.entityConfig>,
1294
563
  ~updatedEffectsCache: array<Persistence.updatedEffectCache>,
1295
564
  ~updatedEntities: array<Persistence.updatedEntity>,
1296
565
  ~registeredAddresses: array<AddressRows.staged>,
1297
566
  ~chainMetaData,
1298
567
  ~onWrite,
1299
568
  ) => {
1300
- let (frontierChainIds, frontierCheckpointIds) =
1301
- Persistence.writtenFrontier(~batch, ~rollback)->Frontier.unnestParams
569
+ let (frontierChainIds, frontierCheckpointIds) = {
570
+ let frontier = Persistence.writtenFrontier(~batch, ~rollback)
571
+ let chainIds = frontier->Frontier.chainIds
572
+ (chainIds, chainIds->Array.map(chainId => frontier->Frontier.get(chainId)))
573
+ }
1302
574
 
1303
575
  // A single on-chain log fans out to one item per matching registration;
1304
576
  // `raw_events` records the log itself, so it is deduped by its coordinate
@@ -1323,7 +595,14 @@ let make = (
1323
595
  []
1324
596
  }
1325
597
 
1326
- let staged = {handles: [], seconds: Dict.make()}
598
+ let handles = []
599
+ let staged = handle => {
600
+ handles->Array.push(handle)
601
+ handle
602
+ }
603
+ let stagingStarted = Performance.now()
604
+ let clickHouseStagingSeconds = ref(0.)
605
+ let clickHouseOnlyStagingSeconds = ref(0.)
1327
606
  let input: StorageClient.batch = try {
1328
607
  rollback: ?(
1329
608
  rollback->Option.map(({
@@ -1333,7 +612,6 @@ let make = (
1333
612
  diffCheckpoints,
1334
613
  }): StorageClient.rollbackWrite => {
1335
614
  bounds: floors.checkpointBounds->bounds,
1336
- tables: allEntities->Array.map(entityConfig => (entityConfig->entityTable).handle),
1337
615
  progress: progressedChains->Array.map(progress),
1338
616
  removedAddresses: {
1339
617
  chainIds: rolledBackAddresses->Array.map(key => key.chainId),
@@ -1342,7 +620,7 @@ let make = (
1342
620
  },
1343
621
  diffCheckpoints: diffCheckpoints->Array.map((diff): StorageClient.diffCheckpoint => {
1344
622
  chainId: diff.chainId,
1345
- checkpointId: diff.checkpointId->BigInt.toString,
623
+ checkpointId: diff.checkpointId,
1346
624
  blockNumber: diff.blockNumber,
1347
625
  }),
1348
626
  })
@@ -1358,26 +636,49 @@ let make = (
1358
636
  ),
1359
637
  rawEvents: ?(
1360
638
  rawEvents->Utils.Array.notEmpty
1361
- ? {
1362
- let table = rawEventsTable()
1363
- Some(
1364
- (
1365
- {
1366
- table: table.handle,
1367
- rows: client->rowsOrThrow(
1368
- table,
1369
- ~itemSchema=InternalTable.RawEvents.schema->S.toUnknown,
1370
- rawEvents->(Utils.magic: array<Internal.rawEvent> => array<unknown>),
1371
- ~staged,
1372
- ),
1373
- }: StorageClient.tableWrite
1374
- ),
1375
- )
1376
- }
639
+ ? Some(
640
+ try stageRows(
641
+ ~table=RawEvents,
642
+ ~writers=rawEventWriters,
643
+ rawEvents->(Utils.magic: array<Internal.rawEvent> => array<dict<unknown>>),
644
+ )->staged catch {
645
+ | exn =>
646
+ throw(
647
+ Persistence.StorageError({
648
+ message: `Failed to insert items into table "${InternalTable.RawEvents.table.tableName}"`,
649
+ reason: exn->Utils.prettifyExn,
650
+ }),
651
+ )
652
+ },
653
+ )
1377
654
  : None
1378
655
  ),
1379
- entities: updatedEntities->Array.map(update =>
1380
- update->entityWrite(~rollback, ~config, ~staged)
656
+ changes: updatedEntities->Array.filterMap(update =>
657
+ switch update.changes {
658
+ | [] => None
659
+ | _ =>
660
+ let started = Performance.now()
661
+ let handle = try update->stageChanges->staged catch {
662
+ | exn => throw(changesErrorOf(update.entityConfig, exn))
663
+ }
664
+ let seconds = started->Performance.secondsSince
665
+ if update.entityConfig.storage.clickhouse {
666
+ clickHouseStagingSeconds := clickHouseStagingSeconds.contents +. seconds
667
+ }
668
+ if !update.entityConfig.storage.postgres {
669
+ clickHouseOnlyStagingSeconds := clickHouseOnlyStagingSeconds.contents +. seconds
670
+ }
671
+ Some(
672
+ (
673
+ {
674
+ entity: update.entityConfig.index,
675
+ chainId: ?(update.scope->Internal.chainScopeChainId),
676
+ keepsHistory: update.shouldSaveHistory,
677
+ changes: handle,
678
+ }: StorageClient.changes
679
+ ),
680
+ )
681
+ }
1381
682
  ),
1382
683
  chainMeta: chainMetaData->Option.mapOr([], chainMeta),
1383
684
  addresses: registeredAddresses->Array.map(staged => staged.row)->addressColumns,
@@ -1385,7 +686,7 @@ let make = (
1385
686
  // A checkpoint anchors the history its chain keeps, which the batch
1386
687
  // decides chain by chain.
1387
688
  checkpoints: {
1388
- ids: batch.checkpointIds->Array.map(id => id->BigInt.toString),
689
+ ids: batch.checkpointIds,
1389
690
  chainIds: batch.checkpointChainIds,
1390
691
  blockNumbers: batch.checkpointBlockNumbers,
1391
692
  blockHashes: batch.checkpointBlockHashes,
@@ -1401,44 +702,70 @@ let make = (
1401
702
  items,
1402
703
  shouldInitialize,
1403
704
  }): StorageClient.effectCacheWrite => {
1404
- let registered = table->cacheTable
1405
- {
1406
- table: registered.handle,
1407
- create: shouldInitialize,
1408
- rows: client->rowsOrThrow(
1409
- registered,
1410
- ~itemSchema=itemSchema->S.toUnknown,
1411
- items->(Utils.magic: array<Internal.effectCacheItem> => array<unknown>),
1412
- ~staged,
705
+ table: table.tableName,
706
+ create: shouldInitialize,
707
+ rows: try stageRows(
708
+ ~table=EffectCache,
709
+ ~writers=effectCacheWriters,
710
+ items->Array.map(item =>
711
+ item
712
+ ->S.reverseConvertOrThrow(itemSchema)
713
+ ->(Utils.magic: unknown => dict<unknown>)
1413
714
  ),
1414
- }
715
+ )->staged catch {
716
+ | S.Raised(_) as exn =>
717
+ throw(
718
+ Persistence.StorageError({
719
+ message: `Failed to convert items for table "${table.tableName}"`,
720
+ reason: exn,
721
+ }),
722
+ )
723
+ | exn =>
724
+ throw(
725
+ Persistence.StorageError({
726
+ message: `Failed to insert items into table "${table.tableName}"`,
727
+ reason: exn->Utils.prettifyExn,
728
+ }),
729
+ )
730
+ },
1415
731
  }),
1416
732
  } catch {
1417
733
  | exn =>
1418
- client->StorageClient.discardStaged(staged.handles)
734
+ client->StorageClient.discardStaged(handles)
1419
735
  throw(exn)
1420
736
  }
737
+ // Each store is charged for staging what it writes: an entity both keep
738
+ // is staged once for the two.
739
+ let postgresStagingSeconds =
740
+ stagingStarted->Performance.secondsSince -. clickHouseOnlyStagingSeconds.contents
1421
741
 
1422
742
  let timings = try await client->StorageClient.writeBatch(input) catch {
1423
743
  | exn =>
1424
744
  // Batches the addon never took, because it refused the call itself.
1425
- client->StorageClient.discardStaged(staged.handles)
745
+ client->StorageClient.discardStaged(handles)
1426
746
  throw(exn->storageErrorOf)
1427
747
  }
1428
- timings->Array.forEach(({store, seconds}) => {
1429
- let name = store->storeName
748
+ timings->Array.forEach(({store, seconds}) =>
1430
749
  onWrite(
1431
- ~storage=name,
1432
- ~timeSeconds=seconds +. staged.seconds->Dict.get(name)->Option.getOr(0.),
750
+ ~storage=store->storeName,
751
+ ~timeSeconds=seconds +.
752
+ switch store {
753
+ | Postgres => postgresStagingSeconds
754
+ | ClickHouse => clickHouseStagingSeconds.contents
755
+ },
1433
756
  )
1434
- })
757
+ )
1435
758
  }
1436
759
 
1437
760
  let close = () => client->StorageClient.close
1438
761
 
1439
762
  {
1440
763
  name: postgresName,
1441
- entityStoreName: entityConfig => (entityConfig->entityTable).home->storeName,
764
+ entityStoreName: entityConfig =>
765
+ (layouts.entities->Array.getUnsafe(entityConfig.index)).home->Option.mapOr(
766
+ postgresName,
767
+ storeName,
768
+ ),
1442
769
  holding,
1443
770
  initialize,
1444
771
  readStoredConfig,
@@ -1470,8 +797,13 @@ let clientFromEnv = (
1470
797
  ) =>
1471
798
  makeClient(
1472
799
  ~logger,
1473
- ~chainIdMode=config.chainIdMode,
1474
- ~postgres=postgresOptions(~pgSchema, ~isHasuraEnabled, ~maxConnections?),
800
+ ~config,
801
+ ~postgres=postgresOptions(
802
+ ~pgSchema,
803
+ ~isHasuraEnabled,
804
+ ~maxConnections?,
805
+ ~isolated=config.isolated,
806
+ ),
1475
807
  ~clickhouse=?config.storage.clickhouse ? Some(ClickHouse.optionsFromEnvOrThrow()) : None,
1476
808
  )
1477
809
 
@@ -1485,11 +817,8 @@ let makeStorageFromEnv = (
1485
817
  ) => {
1486
818
  make(
1487
819
  ~client=clientFromEnv(~config, ~logger, ~pgSchema, ~isHasuraEnabled, ~maxConnections?),
1488
- ~logger,
1489
- ~entities=config.userEntities,
1490
820
  ~cacheDir?,
1491
821
  ~ecosystem=config.ecosystem.name,
1492
- ~isolated=config.isolated,
1493
822
  ~onInitialize=?isHasuraEnabled
1494
823
  ? Some(
1495
824
  () =>