envio 3.9.0 → 3.10.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 (110) hide show
  1. package/evm.schema.json +26 -0
  2. package/fuel.schema.json +26 -0
  3. package/index.d.ts +35 -3
  4. package/package.json +6 -7
  5. package/src/Batch.res +9 -0
  6. package/src/ChainState.res +35 -39
  7. package/src/ChainState.res.mjs +14 -11
  8. package/src/ChainState.resi +15 -7
  9. package/src/Config.res +52 -44
  10. package/src/Config.res.mjs +51 -17
  11. package/src/Core.res +4 -0
  12. package/src/CrossChainState.res +15 -23
  13. package/src/CrossChainState.res.mjs +10 -18
  14. package/src/CrossChainState.resi +4 -1
  15. package/src/Ecosystem.res +7 -2
  16. package/src/Envio.res +6 -4
  17. package/src/EventConfigBuilder.res +169 -26
  18. package/src/EventConfigBuilder.res.mjs +124 -15
  19. package/src/HandlerRegister.res +21 -4
  20. package/src/HandlerRegister.res.mjs +24 -5
  21. package/src/InMemoryStore.res +9 -7
  22. package/src/InMemoryStore.res.mjs +3 -3
  23. package/src/IndexerState.res +43 -6
  24. package/src/IndexerState.res.mjs +36 -7
  25. package/src/IndexerState.resi +4 -4
  26. package/src/Internal.res +16 -17
  27. package/src/Metrics.res +72 -6
  28. package/src/Metrics.res.mjs +30 -2
  29. package/src/Persistence.res +36 -30
  30. package/src/Persistence.res.mjs +1 -20
  31. package/src/PgStorage.res +104 -56
  32. package/src/PgStorage.res.mjs +57 -35
  33. package/src/PruneStaleHistory.res +85 -52
  34. package/src/PruneStaleHistory.res.mjs +63 -37
  35. package/src/Rollback.res +68 -45
  36. package/src/Rollback.res.mjs +38 -19
  37. package/src/RollbackCommit.res +7 -9
  38. package/src/RollbackCommit.res.mjs +5 -6
  39. package/src/SafeCheckpointTracking.res +11 -11
  40. package/src/SafeCheckpointTracking.res.mjs +7 -6
  41. package/src/SimulateItems.res +33 -18
  42. package/src/SimulateItems.res.mjs +26 -22
  43. package/src/Sink.res +38 -26
  44. package/src/Sink.res.mjs +21 -16
  45. package/src/TestIndexer.res +5 -5
  46. package/src/TestIndexer.res.mjs +1 -1
  47. package/src/Utils.res +170 -0
  48. package/src/Utils.res.mjs +153 -0
  49. package/src/Writing.res +2 -2
  50. package/src/Writing.res.mjs +1 -1
  51. package/src/bindings/ClickHouse.res +366 -698
  52. package/src/bindings/ClickHouse.res.mjs +371 -421
  53. package/src/bindings/ClickHouseSink.res +337 -0
  54. package/src/bindings/ClickHouseSink.res.mjs +246 -0
  55. package/src/bindings/EventSource.res +8 -2
  56. package/src/bindings/NodeJs.res +6 -0
  57. package/src/bindings/Vitest.res +23 -0
  58. package/src/bindings/Vitest.res.mjs +3 -0
  59. package/src/bindings/WebSocket.res +9 -10
  60. package/src/db/CheckpointBounds.res +53 -0
  61. package/src/db/CheckpointBounds.res.mjs +44 -0
  62. package/src/db/EntityFilter.res +36 -17
  63. package/src/db/EntityFilter.res.mjs +25 -14
  64. package/src/db/EntityHistory.res +47 -22
  65. package/src/db/EntityHistory.res.mjs +19 -12
  66. package/src/db/InternalTable.res +84 -35
  67. package/src/db/InternalTable.res.mjs +56 -23
  68. package/src/db/RollbackFloors.res +55 -0
  69. package/src/db/RollbackFloors.res.mjs +92 -0
  70. package/src/db/Table.res +22 -4
  71. package/src/db/Table.res.mjs +30 -9
  72. package/src/sources/Evm.res +2 -0
  73. package/src/sources/Evm.res.mjs +2 -0
  74. package/src/sources/EvmHyperSyncSource.res +3 -3
  75. package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
  76. package/src/sources/EvmRpcWs.res +124 -0
  77. package/src/sources/EvmRpcWs.res.mjs +117 -0
  78. package/src/sources/Fuel.res +2 -0
  79. package/src/sources/Fuel.res.mjs +2 -0
  80. package/src/sources/FuelHyperSyncSource.res +1 -1
  81. package/src/sources/FuelHyperSyncSource.res.mjs +2 -1
  82. package/src/sources/HeightFeed.res +568 -0
  83. package/src/sources/HeightFeed.res.mjs +452 -0
  84. package/src/sources/HeightStream.res +257 -0
  85. package/src/sources/HeightStream.res.mjs +194 -0
  86. package/src/sources/HyperSync.res +5 -0
  87. package/src/sources/HyperSync.res.mjs +3 -0
  88. package/src/sources/HyperSync.resi +4 -0
  89. package/src/sources/HyperSyncSSE.res +49 -0
  90. package/src/sources/HyperSyncSSE.res.mjs +79 -0
  91. package/src/sources/RpcSource.res +1 -1
  92. package/src/sources/RpcSource.res.mjs +2 -2
  93. package/src/sources/Source.res +54 -1
  94. package/src/sources/Source.res.mjs +25 -0
  95. package/src/sources/SourceManager.res +220 -227
  96. package/src/sources/SourceManager.res.mjs +139 -164
  97. package/src/sources/SourceManager.resi +8 -0
  98. package/src/sources/Svm.res +2 -0
  99. package/src/sources/Svm.res.mjs +2 -0
  100. package/src/sources/SvmHyperSyncClient.res +70 -127
  101. package/src/sources/SvmHyperSyncClient.res.mjs +51 -25
  102. package/src/sources/SvmHyperSyncSource.res +18 -15
  103. package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
  104. package/svm.schema.json +1 -75
  105. package/src/MemoryStorage.res +0 -729
  106. package/src/MemoryStorage.res.mjs +0 -586
  107. package/src/sources/HyperSyncHeightStream.res +0 -129
  108. package/src/sources/HyperSyncHeightStream.res.mjs +0 -110
  109. package/src/sources/RpcWebSocketHeightStream.res +0 -175
  110. package/src/sources/RpcWebSocketHeightStream.res.mjs +0 -180
@@ -1,169 +1,32 @@
1
- // ClickHouse client bindings for @clickhouse/client
2
-
3
- type client
4
-
5
- type clientConfig = {
6
- url?: string,
7
- database?: string,
8
- username?: string,
9
- password?: string,
10
- }
11
-
12
- type execParams = {query: string}
13
-
14
- @module("@clickhouse/client")
15
- external createClient: clientConfig => client = "createClient"
16
-
17
- // `command`, not `exec`: exec hands its response stream to the caller and holds
18
- // the socket until it is consumed, which nothing here does. The pool is 10
19
- // sockets wide, so a schema whose DDL needs more than that — initialize issues
20
- // 2N+3 statements for N entities — stalled every statement past the tenth until
21
- // the 30s request timeout freed one. command destroys the stream for us.
22
- @send
23
- external command: (client, execParams) => promise<unit> = "command"
24
-
25
- @send
26
- external close: client => promise<unit> = "close"
27
-
28
- type insertParams<'a> = {
29
- table: string,
30
- values: array<'a>,
31
- format: string,
32
- }
33
-
34
- @send
35
- external insert: (client, insertParams<'a>) => promise<unit> = "insert"
36
-
37
- type queryParams = {query: string}
38
- type queryResult<'a>
39
-
40
- @send
41
- external query: (client, queryParams) => promise<queryResult<'a>> = "query"
42
-
43
- // The default `JSON` query format resolves to a `ResponseJSON` wrapper whose
44
- // rows live under `data`, not at the top level.
45
- @send
46
- external json: queryResult<'a> => promise<{"data": array<'a>}> = "json"
47
-
48
- let getClickHouseFieldType = (
49
- ~fieldType: Table.fieldType,
50
- ~isNullable: bool,
51
- ~isArray: bool,
52
- ~chainIdMode: ChainId.mode=Int32,
53
- ): string => {
54
- let baseType = switch fieldType {
55
- | Int32 => "Int32"
56
- | ChainId =>
57
- switch chainIdMode {
58
- | Int32 => "Int32"
59
- | Int64 => "UInt64"
60
- }
61
- | Uint32 => "UInt32"
62
- | UInt52 => "UInt64"
63
- // Internal-only column types, never on an entity the sink mirrors.
64
- | SmallInt
65
- | Bytea =>
66
- JsError.throwWithMessage(
67
- "ClickHouse doesn't support the internal SmallInt and Bytea column types",
68
- )
69
- | UInt64 => "UInt64"
70
- | Serial => "Int32"
71
- | BigSerial => "Int64"
72
- | BigInt({?precision}) =>
73
- switch precision {
74
- | None => "String" // Fallback for unbounded BigInt
75
- | Some(precision) =>
76
- if precision > 38 {
77
- "String"
78
- } else {
79
- `Decimal(${precision->Int.toString},0)`
80
- }
81
- }
82
- | BigDecimal({?config}) =>
83
- switch config {
84
- | None => "String" // Fallback for unbounded BigDecimal
85
- | Some((precision, scale)) =>
86
- if precision > 38 || scale > precision {
87
- "String"
88
- } else {
89
- `Decimal(${precision->Int.toString},${scale->Int.toString})`
90
- }
91
- }
92
- | Boolean => "Bool"
93
- | Number => "Float64"
94
- | String => "String"
95
- | Json => "String"
96
- | Date => "DateTime64(3, 'UTC')"
97
- | Enum({config}) => {
98
- let variantsLength = config.variants->Array.length
99
- // Theoretically we can store 256 variants in Enum8,
100
- // but it'd require to explicitly start with a negative index (probably)
101
- let enumType = variantsLength <= 127 ? "Enum8" : "Enum16"
102
- let enumValues =
103
- config.variants
104
- ->Array.map(variant => {
105
- let variantStr = variant->(Utils.magic: 'a => string)
106
- `'${variantStr}'`
107
- })
108
- ->Array.joinUnsafe(", ")
109
- `${enumType}(${enumValues})`
1
+ let clickHouseFieldSchema = (f: Table.field) => {
2
+ let shaped = baseSchema =>
3
+ if f.isNullable {
4
+ Utils.Schema.nullTolerant(baseSchema)->S.toUnknown
5
+ } else if f.isArray {
6
+ S.array(baseSchema)->S.toUnknown
7
+ } else {
8
+ baseSchema
110
9
  }
10
+ switch f.fieldType {
11
+ | Date => shaped(Utils.Schema.clickHouseDate->S.toUnknown)
12
+ | Bytea => shaped(S.json(~validate=false)->S.toUnknown)
13
+ | ChainId => ChainId.schema->S.toUnknown
14
+ | Json if !f.isArray => Utils.Schema.clickHouseJson->S.toUnknown
15
+ | _ => f.fieldSchema
111
16
  }
112
-
113
- let baseType = if isArray {
114
- `Array(${baseType})`
115
- } else {
116
- baseType
117
- }
118
-
119
- isNullable ? `Nullable(${baseType})` : baseType
120
17
  }
121
18
 
122
- // Creates an entity schema from table definition, using clickHouseDate for Date fields.
123
- // Serialized keys are the db column names, while the entity values are keyed
124
- // by API field names (they only differ when column renaming is configured).
125
19
  let makeClickHouseEntitySchema = (table: Table.table): S.t<Internal.entity> => {
126
20
  S.object(s => {
127
21
  let dict = Dict.make()
128
22
  table.fields->Array.forEach(field => {
129
23
  switch field {
130
- | Field(f) => {
131
- let fieldName = f->Table.getClickHouseDbFieldName
132
- let fieldSchema = switch f.fieldType {
133
- | Date => {
134
- let dateSchema = Utils.Schema.clickHouseDate->S.toUnknown
135
- if f.isNullable {
136
- S.null(dateSchema)->S.toUnknown
137
- } else if f.isArray {
138
- S.array(dateSchema)->S.toUnknown
139
- } else {
140
- dateSchema
141
- }
142
- }
143
- | ChainId => ChainId.schema->S.toUnknown
144
- // ClickHouse returns UInt64 values as strings, need to parse to float
145
- | UInt52 => {
146
- let uint52Schema =
147
- S.float
148
- ->S.preprocess(
149
- _ => {
150
- parser: unknown => unknown->(Utils.magic: unknown => string)->Float.parseFloat,
151
- },
152
- )
153
- ->S.toUnknown
154
- if f.isNullable {
155
- S.null(uint52Schema)->S.toUnknown
156
- } else if f.isArray {
157
- S.array(uint52Schema)->S.toUnknown
158
- } else {
159
- uint52Schema
160
- }
161
- }
162
- | _ => f.fieldSchema
163
- }
164
- dict->Dict.set(f->Table.getApiFieldName, s.field(fieldName, fieldSchema))
165
- }
166
- | DerivedFrom(_) => () // Skip derived fields
24
+ | Field(f) =>
25
+ dict->Dict.set(
26
+ f->Table.getApiFieldName,
27
+ s.field(f->Table.getClickHouseDbFieldName, clickHouseFieldSchema(f)),
28
+ )
29
+ | DerivedFrom(_) => ()
167
30
  }
168
31
  })
169
32
  dict->(Utils.magic: dict<unknown> => Internal.entity)
@@ -172,604 +35,409 @@ let makeClickHouseEntitySchema = (table: Table.table): S.t<Internal.entity> => {
172
35
 
173
36
  let logger = Logging.createChild(~params={"context": "ClickHouse"})
174
37
 
175
- // On transient failure, split values in half and retry each half.
176
- // If only 1 row remains, retry with delay.
177
- // Delay scales from 100ms to 1000ms as retries decrease.
178
- let rec insertWithRetry = async (
179
- client,
180
- ~table: string,
181
- ~values: array<'a>,
182
- ~format: string,
183
- ~retries=8,
184
- ) => {
185
- try {
186
- await client->insert({table, values, format})
187
- } catch {
188
- | exn if retries > 0 =>
189
- let delayMs = Math.Int.min(1000, 100 + 900 * (8 - retries) / 7)
190
- if Array.length(values) > 1 {
191
- logger->Logging.childWarn({
192
- "msg": "ClickHouse insert failed, splitting batch in half and retrying",
193
- "table": table,
194
- "batchSize": Array.length(values),
195
- "retriesLeft": retries,
196
- "err": exn->Utils.prettifyExn,
197
- })
198
- await Utils.delay(delayMs)
199
- let mid = Array.length(values) / 2
200
- let first = values->Array.slice(~start=0, ~end=mid)
201
- let second = values->Array.slice(~start=mid)
202
- await insertWithRetry(client, ~table, ~values=first, ~format, ~retries=retries - 1)
203
- await insertWithRetry(client, ~table, ~values=second, ~format, ~retries=retries - 1)
204
- } else {
205
- logger->Logging.childWarn({
206
- "msg": "ClickHouse insert failed, retrying after delay",
207
- "table": table,
208
- "retriesLeft": retries,
209
- "err": exn->Utils.prettifyExn,
210
- })
211
- await Utils.delay(delayMs)
212
- await insertWithRetry(client, ~table, ~values, ~format, ~retries=retries - 1)
213
- }
214
- }
38
+ let makeSink = (~host, ~username, ~password, ~database, ~chainIdMode) =>
39
+ ClickHouseSink.make(~url=host, ~username, ~password, ~database, ~chainIdMode, ~onWarning=msg =>
40
+ logger->Logging.childWarn({"msg": msg})
41
+ )
42
+
43
+ let makeColumnSpec = (
44
+ ~name,
45
+ ~fieldName=name,
46
+ ~fieldType: Table.fieldType,
47
+ ~isNullable=false,
48
+ ~isArray=false,
49
+ ): ClickHouseSink.columnSpec => {
50
+ name,
51
+ fieldName,
52
+ fieldType: switch fieldType {
53
+ | String => "String"
54
+ | Boolean => "Boolean"
55
+ | Uint32 => "Uint32"
56
+ | UInt52 => "UInt52"
57
+ | SmallInt =>
58
+ JsError.throwWithMessage("ClickHouse doesn't support the internal SmallInt column type")
59
+ | Bytea => "Bytea"
60
+ | UInt64 => "UInt64"
61
+ | Int32 => "Int32"
62
+ | ChainId => "ChainId"
63
+ | Number => "Number"
64
+ | Serial => "Serial"
65
+ | BigSerial => "BigSerial"
66
+ | Json => "Json"
67
+ | Date => "Date"
68
+ | BigInt(_) => "BigInt"
69
+ | BigDecimal(_) => "BigDecimal"
70
+ | Enum(_) => "Enum"
71
+ },
72
+ isNullable,
73
+ isArray,
74
+ precision: ?switch fieldType {
75
+ | BigInt({?precision}) => precision
76
+ | BigDecimal({?config}) => config->Option.map(((precision, _)) => precision)
77
+ | _ => None
78
+ },
79
+ scale: ?switch fieldType {
80
+ | BigDecimal({?config}) => config->Option.map(((_, scale)) => scale)
81
+ | _ => None
82
+ },
83
+ enumVariants: ?switch fieldType {
84
+ | Enum({config}) =>
85
+ Some(config.variants->Array.map(variant => variant->(Utils.magic: Table.enum => string)))
86
+ | _ => None
87
+ },
215
88
  }
216
89
 
217
- let setCheckpointsOrThrow = async (client, ~batch: Batch.t, ~database: string) => {
218
- let checkpointsCount = batch.checkpointIds->Array.length
219
- if checkpointsCount === 0 {
220
- ()
221
- } else {
222
- // Convert columnar data to row format for JSONCompactEachRow
223
- let checkpointRows = []
224
- for idx in 0 to checkpointsCount - 1 {
225
- checkpointRows
226
- ->Array.push((
227
- batch.checkpointIds->Array.getUnsafe(idx)->BigInt.toString,
228
- batch.checkpointChainIds->Array.getUnsafe(idx),
229
- batch.checkpointBlockNumbers->Array.getUnsafe(idx),
230
- batch.checkpointBlockHashes->Array.getUnsafe(idx),
231
- batch.checkpointEventsProcessed->Array.getUnsafe(idx),
232
- ))
233
- ->ignore
234
- }
90
+ type checkpointColumn = {
91
+ spec: ClickHouseSink.columnSpec,
92
+ valuesOf: Batch.t => array<unknown>,
93
+ }
235
94
 
236
- try {
237
- await insertWithRetry(
238
- client,
239
- ~table=`${database}.\`${InternalTable.Checkpoints.table.tableName}\``,
240
- ~values=checkpointRows,
241
- ~format="JSONCompactEachRow",
242
- )
243
- } catch {
244
- | exn =>
245
- throw(
246
- Persistence.StorageError({
247
- message: `Failed to insert checkpoints into ClickHouse table "${InternalTable.Checkpoints.table.tableName}"`,
248
- reason: exn->Utils.prettifyExn,
249
- }),
250
- )
251
- }
95
+ // Registration and staging both read this one list, so the column a batch's
96
+ // values are written to is the column that was registered in its place.
97
+ let checkpointColumns = InternalTable.Checkpoints.columns->Array.filterMap(({
98
+ field,
99
+ clickHouseFieldType,
100
+ valuesOf,
101
+ }) =>
102
+ switch field {
103
+ | Table.Field({fieldName, isNullable}) =>
104
+ Some({
105
+ spec: makeColumnSpec(~name=fieldName, ~fieldType=clickHouseFieldType, ~isNullable),
106
+ valuesOf,
107
+ })
108
+ | DerivedFrom(_) => None
252
109
  }
253
- }
110
+ )
254
111
 
255
- type setUpdatesCache = {
256
- tableName: string,
257
- convertOrThrow: array<Change.t<Internal.entity>> => array<JSON.t>,
258
- }
112
+ let checkpointColumnSpecs = checkpointColumns->Array.map(({spec}) => spec)
259
113
 
260
- let setUpdatesOrThrow = async (
261
- client,
262
- ~cache: dict<setUpdatesCache>,
263
- ~changes: array<Change.t<Internal.entity>>,
264
- ~entityConfig: Internal.entityConfig,
265
- ~scope: Internal.chainScope,
266
- ~database: string,
267
- ) => {
268
- if changes->Array.length === 0 {
269
- ()
270
- } else {
271
- let chainIdField = entityConfig.table->Table.getChainIdField
272
- let scopeChainId = scope->Internal.chainScopeChainId
273
- // A delete row carries no entity to stamp, so the chain id is baked into
274
- // the schema instead — which is why the cache is keyed per scope.
275
- let cacheKey = `${entityConfig.name}|${scope->Internal.chainScopeToString}`
276
- let {convertOrThrow, tableName} = switch cache->Utils.Dict.dangerouslyGetNonOption(cacheKey) {
277
- | Some(cached) => cached
278
- | None =>
279
- let cached: setUpdatesCache = {
280
- tableName: `${database}.\`${EntityHistory.historyTableName(
281
- ~entityName=entityConfig.name,
282
- ~entityIndex=entityConfig.index,
283
- )}\``,
284
- convertOrThrow: S.compile(
285
- S.array(
286
- S.union([
287
- EntityHistory.makeSetUpdateSchema(
288
- ~idSchema=entityConfig.table->Table.getIdSchema,
289
- makeClickHouseEntitySchema(entityConfig.table),
290
- ),
291
- S.object(s => {
292
- s.tag(EntityHistory.changeFieldName, EntityHistory.RowAction.DELETE)
293
- switch (chainIdField, scopeChainId) {
294
- | (Some(field), Some(chainId)) =>
295
- s.tag(field->Table.getClickHouseDbFieldName, chainId)
296
- | _ => ()
297
- }
298
- Change.Delete({
299
- entityId: s.field(Table.idFieldName, entityConfig.table->Table.getIdSchema),
300
- checkpointId: s.field(
301
- EntityHistory.checkpointIdFieldName,
302
- EntityHistory.unsafeCheckpointIdSchema,
303
- ),
304
- })
305
- }),
306
- ]),
114
+ let entitySpec = (~entityConfig: Internal.entityConfig): ClickHouseSink.entitySpec => {
115
+ let options = entityConfig.storage.clickhouseOptions
116
+ {
117
+ name: entityConfig.name,
118
+ historyTable: EntityHistory.historyTableName(
119
+ ~entityName=entityConfig.name,
120
+ ~entityIndex=entityConfig.index,
121
+ ),
122
+ columns: entityConfig.table.fields->Array.filterMap(field =>
123
+ switch field {
124
+ | Table.Field(f) =>
125
+ Some(
126
+ makeColumnSpec(
127
+ ~name=f->Table.getClickHouseDbFieldName,
128
+ ~fieldName=f.fieldName,
129
+ ~fieldType=f.fieldType,
130
+ ~isNullable=f.isNullable,
131
+ ~isArray=f.isArray,
307
132
  ),
308
- ~input=Value,
309
- ~output=Json,
310
- ~typeValidation=false,
311
- ~mode=Sync,
312
- )->(
313
- Utils.magic: (array<Change.t<Internal.entity>> => JSON.t) => array<
314
- Change.t<Internal.entity>,
315
- > => array<JSON.t>
316
- ),
133
+ )
134
+ | DerivedFrom(_) => None
317
135
  }
318
-
319
- cache->Dict.set(cacheKey, cached)
320
- cached
321
- }
322
-
323
- let changes = switch (chainIdField, scopeChainId) {
324
- | (Some(field), Some(chainId)) =>
325
- changes->Array.map(change =>
326
- switch change {
327
- | Change.Set(set) =>
328
- Change.Set({
329
- ...set,
330
- entity: set.entity->Internal.stampChainId(~fieldName=field.fieldName, ~chainId),
331
- })
332
- | Delete(_) => change
333
- }
136
+ ),
137
+ chainIdColumn: ?(
138
+ entityConfig.table->Table.getChainIdField->Option.map(Table.getClickHouseDbFieldName)
139
+ ),
140
+ partitionBy: ?(options->Option.flatMap(options => options.partitionBy)),
141
+ orderBy: ?(options->Option.flatMap(options => options.orderBy)),
142
+ ttl: ?(options->Option.flatMap(options => options.ttl)),
143
+ skippingIndexes: ?(
144
+ options->Option.flatMap(options =>
145
+ options.skippingIndexes->Option.map(indexes =>
146
+ indexes->Array.map(
147
+ (index): ClickHouseSink.skippingIndexSpec => {
148
+ name: index.name,
149
+ expr: index.expr,
150
+ indexType: index.type_,
151
+ granularity: ?index.granularity,
152
+ },
153
+ )
154
+ )
334
155
  )
335
- | _ => changes
336
- }
156
+ ),
157
+ }
158
+ }
337
159
 
338
- try {
339
- // The entity history table is the source of truth for ClickHouse, so every
340
- // intermediate change must be persisted, not only the current value.
341
- let values = changes->convertOrThrow
160
+ type cell = Change.t<Internal.entity> => unknown
342
161
 
343
- await insertWithRetry(client, ~table=tableName, ~values, ~format="JSONEachRow")
344
- } catch {
345
- | exn =>
346
- throw(
347
- Persistence.StorageError({
348
- message: `Failed to insert items into ClickHouse table "${tableName}"`,
349
- reason: exn->Utils.prettifyExn,
350
- }),
351
- )
352
- }
353
- }
162
+ type converters = {
163
+ setCells: array<cell>,
164
+ deleteCells: array<cell>,
354
165
  }
355
166
 
356
- // A plain database created with ON CLUSTER doesn't turn subsequent DDL into
357
- // cluster-wide statements; ClickHouse keeps no "this database is clustered"
358
- // flag. Without a Replicated database engine, every CREATE must carry its own
359
- // ON CLUSTER to reach all replicas, otherwise it runs only on the connected
360
- // node. With a Replicated database engine the DDL propagates via the database's
361
- // own log, and combining it with ON CLUSTER is rejected/double-applied — so
362
- // table-level DDL must carry the clause only in the plain-database case.
363
- // The '{cluster}' macro resolves to each node's configured cluster name.
364
- let onClusterClause = (~onCluster: bool) => onCluster ? ` ON CLUSTER '{cluster}'` : ""
167
+ type registry = {
168
+ entities: dict<ClickHouseSink.table>,
169
+ mutable checkpoints: option<ClickHouseSink.table>,
170
+ converters: dict<converters>,
171
+ }
365
172
 
366
- // ReplicatedMergeTree drops an insert whose block hash is already in Keeper, and
367
- // mutations don't clear those hashes. Crash recovery trims the history tail past
368
- // the committed Postgres checkpoint with ALTER ... DELETE and then replays it, so
369
- // an identical replayed block would be discarded while still reporting success —
370
- // a permanent gap that nothing surfaces. Trim-then-replay is what makes recovery
371
- // correct here, and the duplicates dedup would have caught are already collapsed
372
- // by the entity view's `LIMIT 1 BY`. Plain MergeTree goes by
373
- // non_replicated_deduplication_window (0 by default), so it needs no clause.
374
- let replicatedTableSettingsClause = (~replicated: bool) =>
375
- replicated ? "\nSETTINGS replicated_deduplication_window = 0" : ""
173
+ let makeRegistry = () => {
174
+ entities: Dict.make(),
175
+ checkpoints: None,
176
+ converters: Dict.make(),
177
+ }
376
178
 
377
- // Strip both engine arguments `(...)` and a trailing `SETTINGS ...` clause to
378
- // get the bare engine name, e.g. `Replicated('/p','{shard}','{replica}') SETTINGS x=1`
379
- // and `Replicated SETTINGS x=1` both yield `Replicated`.
380
- let databaseEngineName = (engineSpec: string) =>
381
- engineSpec
382
- ->String.trim
383
- ->String.split("(")
384
- ->Array.getUnsafe(0)
385
- ->String.split(" ")
386
- ->Array.getUnsafe(0)
387
- ->String.trim
179
+ @get external changeEntity: Change.t<Internal.entity> => dict<unknown> = "entity"
388
180
 
389
- // Generate CREATE TABLE query for entity history table
390
- let makeCreateHistoryTableQuery = (
391
- ~entityConfig: Internal.entityConfig,
392
- ~database: string,
393
- ~replicated: bool=false,
394
- ~onCluster: bool=false,
395
- ~chainIdMode: ChainId.mode=Int32,
396
- ) => {
397
- let tableEngine = replicated ? "ReplicatedMergeTree" : "MergeTree()"
398
- let fieldDefinitions = entityConfig.table.fields->Array.filterMap(field => {
399
- switch field {
400
- | Field(field) =>
401
- Some({
402
- let fieldName = field->Table.getClickHouseDbFieldName
403
- let clickHouseType = getClickHouseFieldType(
404
- ~fieldType=field.fieldType,
405
- ~isNullable=field.isNullable,
406
- ~isArray=field.isArray,
407
- ~chainIdMode,
408
- )
409
- `\`${fieldName}\` ${clickHouseType}`
410
- })
411
- | DerivedFrom(_) => None
412
- }
413
- })
181
+ %%private(let absent: unknown = %raw(`undefined`))
414
182
 
415
- let (partitionBy, orderBy, ttl, skippingIndexes) = switch entityConfig.storage.clickhouseOptions {
416
- | Some(options) => (options.partitionBy, options.orderBy, options.ttl, options.skippingIndexes)
417
- | None => (None, None, None, None)
418
- }
183
+ %%private(
184
+ let compileSerializer = schema =>
185
+ S.compile(schema, ~input=Value, ~output=Json, ~typeValidation=false, ~mode=Sync)->(
186
+ Utils.magic: (unknown => JSON.t) => unknown => unknown
187
+ )
188
+ )
419
189
 
420
- // Schema field name -> ClickHouse column name, so @storage(clickhouse: {...})
421
- // options can reference fields the way they're written in the schema and get
422
- // renames (`column_name_format: snake_case`) and linked-entity `_id` suffixes
423
- // resolved here.
424
- let columnByFieldName = Dict.make()
425
- entityConfig.table.fields->Array.forEach(field =>
426
- switch field {
427
- | Field(f) => columnByFieldName->Dict.set(f.fieldName, f->Table.getClickHouseDbFieldName)
428
- | DerivedFrom(_) => ()
190
+ %%private(
191
+ let makeConverters = (
192
+ ~entityConfig: Internal.entityConfig,
193
+ ~scope: Internal.chainScope,
194
+ ~table: ClickHouseSink.table,
195
+ ): converters => {
196
+ let {name: tableName, columns} = table
197
+ let sourceTable = entityConfig.table
198
+ let chainIdTag = switch (
199
+ sourceTable->Table.getChainIdField,
200
+ scope->Internal.chainScopeChainId,
201
+ ) {
202
+ | (Some(field), Some(chainId)) =>
203
+ Some((field->Table.getClickHouseDbFieldName, chainId->(Utils.magic: ChainId.t => unknown)))
204
+ | _ => None
429
205
  }
430
- )
206
+ let fieldsByColumn = Dict.make()
207
+ sourceTable.fields->Array.forEach(field =>
208
+ switch field {
209
+ | Table.Field(f) => fieldsByColumn->Dict.set(f->Table.getClickHouseDbFieldName, f)
210
+ | DerivedFrom(_) => ()
211
+ }
212
+ )
431
213
 
432
- let orderByColumns = switch orderBy {
433
- | Some(fieldNames) =>
434
- // envio_checkpoint_id stays appended so the sorting key keeps a
435
- // deterministic tie-break and the view's checkpoint dedup gets a clean
436
- // ascending run per prefix. id is dropped: ClickHouse entities are
437
- // read-only, so nothing looks history rows up by id.
438
- let userColumns =
439
- fieldNames
440
- ->Array.map(fieldName =>
441
- switch columnByFieldName->Dict.get(fieldName) {
442
- | Some(column) => `\`${column}\``
443
- | None =>
444
- // Validated at codegen, so a miss means the schema and the
445
- // persisted config diverged.
214
+ let setCells = []
215
+ let deleteCells = []
216
+ let registeredNames = columns->Array.map(column => column.name)->Utils.Set.fromArray
217
+ columns->Array.forEach(({name}) => {
218
+ let (set, delete) = if name === EntityHistory.checkpointIdFieldName {
219
+ let cell = change => change->Change.getCheckpointId->(Utils.magic: bigint => unknown)
220
+ (cell, cell)
221
+ } else if name === EntityHistory.changeFieldName {
222
+ (
223
+ _ => (EntityHistory.RowAction.SET :> string)->(Utils.magic: string => unknown),
224
+ _ => (EntityHistory.RowAction.DELETE :> string)->(Utils.magic: string => unknown),
225
+ )
226
+ } else {
227
+ switch (chainIdTag, fieldsByColumn->Utils.Dict.dangerouslyGetNonOption(name)) {
228
+ | (Some((column, chainId)), _) if column === name => (_ => chainId, _ => chainId)
229
+ | (_, Some(f)) =>
230
+ let serialize = compileSerializer(clickHouseFieldSchema(f))
231
+ let apiName = f->Table.getApiFieldName
232
+ if apiName === Table.idFieldName {
233
+ let cell = change =>
234
+ serialize(change->Change.getEntityId->(Utils.magic: EntityId.t => unknown))
235
+ (cell, cell)
236
+ } else {
237
+ (change => serialize(change->changeEntity->Dict.getUnsafe(apiName)), _ => absent)
238
+ }
239
+ | (_, None) =>
446
240
  JsError.throwWithMessage(
447
- `ClickHouse orderBy field "${fieldName}" is not defined on entity "${entityConfig.name}"`,
241
+ `ClickHouse table "${tableName}" registered a column "${name}" that entity "${entityConfig.name}" has no value for`,
448
242
  )
449
243
  }
450
- )
451
- ->Array.joinUnsafe(", ")
452
- `${userColumns}, ${EntityHistory.checkpointIdFieldName}`
453
- | None => `${Table.idFieldName}, ${EntityHistory.checkpointIdFieldName}`
454
- }
244
+ }
245
+ setCells->Array.push(set)
246
+ deleteCells->Array.push(delete)
247
+ })
455
248
 
456
- // partitionBy/ttl are raw ClickHouse expressions. Rewrite any bare identifier
457
- // that names an entity field to that field's ClickHouse column, leaving
458
- // functions, keywords, numbers, string literals and already-backticked
459
- // identifiers untouched (a quoted token never matches a bare field name).
460
- let resolveExpressionColumns = expression =>
461
- expression->String.replaceRegExpBy0Unsafe(/'[^']*'|`[^`]*`|[A-Za-z_][A-Za-z0-9_]*/g, (
462
- ~match,
463
- ~offset as _,
464
- ~input as _,
465
- ) =>
466
- switch columnByFieldName->Dict.get(match) {
467
- | Some(column) => `\`${column}\``
468
- | None => match
249
+ fieldsByColumn->Dict.forEachWithKey((f, column) =>
250
+ if !(registeredNames->Utils.Set.has(column)) {
251
+ JsError.throwWithMessage(
252
+ `ClickHouse table "${tableName}" registered no column for field "${f.fieldName}" of entity "${entityConfig.name}"`,
253
+ )
469
254
  }
470
255
  )
471
256
 
472
- let partitionByClause = switch partitionBy {
473
- | Some(expression) => `\nPARTITION BY ${expression->resolveExpressionColumns}`
474
- | None => ""
475
- }
476
- let ttlClause = switch ttl {
477
- | Some(expression) => `\nTTL ${expression->resolveExpressionColumns}`
478
- | None => ""
257
+ {setCells, deleteCells}
479
258
  }
259
+ )
480
260
 
481
- // Data skipping indexes live inside the column list, after the last column.
482
- // GRANULARITY is omitted when unset, leaving ClickHouse's default of 1.
483
- let skippingIndexDefinitions = switch skippingIndexes {
484
- | Some(skippingIndexes) =>
485
- skippingIndexes
486
- ->Array.map(index => {
487
- let granularityClause = switch index.granularity {
488
- | Some(granularity) => ` GRANULARITY ${granularity->Int.toString}`
489
- | None => ""
261
+ %%private(
262
+ let fillBuilders = (
263
+ ~table: ClickHouseSink.table,
264
+ ~converters,
265
+ ~changes: array<Change.t<Internal.entity>>,
266
+ ) => {
267
+ let rows = changes->Array.length
268
+ let builders = table.columns->Array.map(ClickHouseSink.makeBuilder(_, ~rows))
269
+ let columns = builders->Array.length
270
+ for row in 0 to rows - 1 {
271
+ let change = changes->Array.getUnsafe(row)
272
+ let (cells, write) = switch change {
273
+ | Change.Set(_) => (converters.setCells, ClickHouseSink.writeValue)
274
+ | Delete(_) => (converters.deleteCells, ClickHouseSink.writeDeletedValue)
490
275
  }
491
- `,\n INDEX \`${index.name}\` ${index.expr->resolveExpressionColumns} TYPE ${index.type_}${granularityClause}`
492
- })
493
- ->Array.joinUnsafe("")
494
- | None => ""
276
+ for column in 0 to columns - 1 {
277
+ builders->Array.getUnsafe(column)->write(~row, (cells->Array.getUnsafe(column))(change))
278
+ }
279
+ }
280
+ builders
495
281
  }
496
-
497
- `CREATE TABLE IF NOT EXISTS ${database}.\`${EntityHistory.historyTableName(
498
- ~entityName=entityConfig.name,
499
- ~entityIndex=entityConfig.index,
500
- )}\`${onClusterClause(~onCluster)} (
501
- ${fieldDefinitions->Array.joinUnsafe(",\n ")},
502
- \`${EntityHistory.checkpointIdFieldName}\` ${getClickHouseFieldType(
503
- ~fieldType=UInt64,
504
- ~isNullable=false,
505
- ~isArray=false,
506
- )},
507
- \`${EntityHistory.changeFieldName}\` ${getClickHouseFieldType(
508
- ~fieldType=Enum({config: EntityHistory.RowAction.config->Table.fromGenericEnumConfig}),
509
- ~isNullable=false,
510
- ~isArray=false,
511
- )}${skippingIndexDefinitions}
512
282
  )
513
- ENGINE = ${tableEngine}${partitionByClause}
514
- ORDER BY (${orderByColumns})${ttlClause}${replicatedTableSettingsClause(~replicated)}`
515
- }
516
283
 
517
- // Generate CREATE TABLE query for checkpoints
518
- let makeCreateCheckpointsTableQuery = (
519
- ~database: string,
520
- ~replicated: bool=false,
521
- ~onCluster: bool=false,
522
- ~chainIdMode: ChainId.mode=Int32,
523
- ) => {
524
- let tableEngine = replicated ? "ReplicatedMergeTree" : "MergeTree()"
525
- let idField = (#id: InternalTable.Checkpoints.field :> string)
526
- let chainIdField = (#chain_id: InternalTable.Checkpoints.field :> string)
527
- let blockNumberField = (#block_number: InternalTable.Checkpoints.field :> string)
528
- let blockHashField = (#block_hash: InternalTable.Checkpoints.field :> string)
529
- let eventsProcessedField = (#events_processed: InternalTable.Checkpoints.field :> string)
284
+ let entityTable = (sink, ~registry, ~entityConfig: Internal.entityConfig) =>
285
+ switch registry.entities->Utils.Dict.dangerouslyGetNonOption(entityConfig.name) {
286
+ | Some(table) => table
287
+ | None =>
288
+ let spec = entitySpec(~entityConfig)
289
+ let table =
290
+ sink
291
+ ->ClickHouseSink.registerEntityTable(spec)
292
+ ->ClickHouseSink.makeTable(~name=spec.historyTable)
293
+ registry.entities->Dict.set(entityConfig.name, table)
294
+ table
295
+ }
530
296
 
531
- `CREATE TABLE IF NOT EXISTS ${database}.\`${InternalTable.Checkpoints.table.tableName}\`${onClusterClause(
532
- ~onCluster,
533
- )} (
534
- \`${idField}\` ${getClickHouseFieldType(~fieldType=UInt64, ~isNullable=false, ~isArray=false)},
535
- \`${chainIdField}\` ${getClickHouseFieldType(
536
- ~fieldType=ChainId,
537
- ~isNullable=false,
538
- ~isArray=false,
539
- ~chainIdMode,
540
- )},
541
- \`${blockNumberField}\` ${getClickHouseFieldType(
542
- ~fieldType=Int32,
543
- ~isNullable=false,
544
- ~isArray=false,
545
- )},
546
- \`${blockHashField}\` ${getClickHouseFieldType(
547
- ~fieldType=String,
548
- ~isNullable=true,
549
- ~isArray=false,
550
- )},
551
- \`${eventsProcessedField}\` ${getClickHouseFieldType(
552
- ~fieldType=UInt64,
553
- ~isNullable=false,
554
- ~isArray=false,
555
- )}
556
- )
557
- ENGINE = ${tableEngine}
558
- ORDER BY (${idField})${replicatedTableSettingsClause(~replicated)}`
559
- }
297
+ let checkpointsTable = (sink, ~registry) =>
298
+ switch registry.checkpoints {
299
+ | Some(table) => table
300
+ | None =>
301
+ let table =
302
+ sink
303
+ ->ClickHouseSink.registerCheckpointsTable(checkpointColumnSpecs)
304
+ ->ClickHouseSink.makeTable(~name=InternalTable.Checkpoints.table.tableName)
305
+ registry.checkpoints = Some(table)
306
+ table
307
+ }
560
308
 
561
- // Generate CREATE VIEW query for entity current state
562
- let makeCreateViewQuery = (
563
- ~entityConfig: Internal.entityConfig,
564
- ~database: string,
565
- ~onCluster: bool=false,
566
- ) => {
567
- let historyTableName = EntityHistory.historyTableName(
568
- ~entityName=entityConfig.name,
569
- ~entityIndex=entityConfig.index,
309
+ let stageBuilders = (sink, ~table: ClickHouseSink.table, ~builders, ~rows) =>
310
+ sink->ClickHouseSink.stage(
311
+ ~table=table.handle,
312
+ ~rows,
313
+ ~columns=builders->Array.map(ClickHouseSink.builderPayload),
570
314
  )
571
315
 
572
- // A per-chain entity's rows are only comparable within a chain, so the
573
- // current-state dedup keys on (id, chain id).
574
- let dedupKey =
575
- switch entityConfig.table->Table.getChainIdField {
576
- | Some(field) => [Table.idFieldName, field->Table.getClickHouseDbFieldName]
577
- | None => [Table.idFieldName]
578
- }
579
- ->Array.map(name => `\`${name}\``)
580
- ->Array.joinUnsafe(", ")
581
-
582
- let checkpointsTableName = InternalTable.Checkpoints.table.tableName
583
- let checkpointIdField = (#id: InternalTable.Checkpoints.field :> string)
584
-
585
- let entityFields =
586
- entityConfig.table.fields
587
- ->Array.filterMap(field => {
588
- switch field {
589
- | Field(field) => {
590
- let fieldName = field->Table.getClickHouseDbFieldName
591
- Some(`\`${fieldName}\``)
316
+ let stageCheckpointsOrThrow = (sink, ~registry, ~batch: Batch.t) => {
317
+ let rows = batch.checkpointIds->Array.length
318
+ if rows === 0 {
319
+ Null.null
320
+ } else {
321
+ let table = sink->checkpointsTable(~registry)
322
+ try {
323
+ // The table was registered from `checkpointColumns`, in this order.
324
+ let builders = table.columns->Array.map(ClickHouseSink.makeBuilder(_, ~rows))
325
+ builders->Array.forEachWithIndex((builder, index) => {
326
+ let columnValues = (checkpointColumns->Array.getUnsafe(index)).valuesOf(batch)
327
+ for row in 0 to rows - 1 {
328
+ builder->ClickHouseSink.writeValue(~row, columnValues->Array.getUnsafe(row))
592
329
  }
593
- | DerivedFrom(_) => None
594
- }
595
- })
596
- ->Array.joinUnsafe(", ")
597
-
598
- `CREATE VIEW IF NOT EXISTS ${database}.\`${entityConfig.name}\`${onClusterClause(~onCluster)} AS
599
- SELECT ${entityFields}
600
- FROM (
601
- SELECT ${entityFields}, \`${EntityHistory.changeFieldName}\`
602
- FROM ${database}.\`${historyTableName}\`
603
- WHERE \`${EntityHistory.checkpointIdFieldName}\` <= (SELECT max(${checkpointIdField}) FROM ${database}.\`${checkpointsTableName}\`)
604
- ORDER BY \`${EntityHistory.checkpointIdFieldName}\` DESC
605
- LIMIT 1 BY ${dedupKey}
606
- )
607
- WHERE \`${EntityHistory.changeFieldName}\` = '${(EntityHistory.RowAction.SET :> string)}'`
608
- }
609
-
610
- // Initialize ClickHouse tables for entities
611
- let initialize = async (
612
- client,
613
- ~database: string,
614
- ~entities: array<Internal.entityConfig>,
615
- ~enums as _: array<Table.enumConfig<Table.enum>>,
616
- ~chainIdMode: ChainId.mode=Int32,
617
- ) => {
618
- try {
619
- let databaseEngine = Env.ClickHouse.databaseEngine()
620
- let databaseEngineClause = switch databaseEngine {
621
- | Some(engine) => ` ENGINE = ${engine}`
622
- | None => ""
623
- }
624
- let hasReplicatedDatabaseEngine = switch databaseEngine {
625
- | Some(engine) => engine->databaseEngineName === "Replicated"
626
- | None => false
627
- }
628
- let envReplicated = Env.ClickHouse.replicated()
629
- // A Replicated database engine only replicates data when its tables use the
630
- // ReplicatedMergeTree engine, so it implies replicated mode even when
631
- // ENVIO_CLICKHOUSE_REPLICATED is unset.
632
- let replicated = envReplicated || hasReplicatedDatabaseEngine
633
- if hasReplicatedDatabaseEngine && !envReplicated {
634
- Logging.info(
635
- "ENVIO_CLICKHOUSE_DATABASE_ENGINE is Replicated; enabling replicated mode so tables use the ReplicatedMergeTree engine.",
330
+ })
331
+ Null.make(sink->stageBuilders(~table, ~builders, ~rows))
332
+ } catch {
333
+ | exn =>
334
+ throw(
335
+ Persistence.StorageError({
336
+ message: `Failed to convert checkpoints for ClickHouse table "${table.name}"`,
337
+ reason: exn->Utils.prettifyExn,
338
+ }),
636
339
  )
637
340
  }
638
- let databaseOnClusterClause = onClusterClause(~onCluster=replicated)
639
- // DDL that a Replicated database engine propagates itself must not carry
640
- // ON CLUSTER on top of it — the clause is only for the plain-database case.
641
- let ddlOnCluster = replicated && !hasReplicatedDatabaseEngine
341
+ }
342
+ }
642
343
 
643
- switch databaseEngine {
644
- | Some(engineSpec) => {
645
- let expectedEngineName = engineSpec->databaseEngineName
646
- let existingResult = await client->query({
647
- query: `SELECT engine FROM system.databases WHERE name = '${database}'`,
648
- })
649
- let rows = (await existingResult->json)["data"]
650
- switch rows->Array.get(0) {
651
- | Some(row) if row["engine"] !== expectedEngineName =>
652
- JsError.throwWithMessage(
653
- `ClickHouse database "${database}" exists with engine "${row["engine"]}" but ENVIO_CLICKHOUSE_DATABASE_ENGINE specifies "${expectedEngineName}". Drop the database manually to change its engine.`,
654
- )
655
- | _ => ()
656
- }
344
+ let stageUpdatesOrThrow = (
345
+ sink,
346
+ ~registry,
347
+ ~changes: array<Change.t<Internal.entity>>,
348
+ ~entityConfig: Internal.entityConfig,
349
+ ~scope: Internal.chainScope,
350
+ ) => {
351
+ let rows = changes->Array.length
352
+ if rows === 0 {
353
+ None
354
+ } else {
355
+ let table = sink->entityTable(~registry, ~entityConfig)
356
+ let tableName = table.name
357
+ let cacheKey = `${entityConfig.name}|${scope->Internal.chainScopeToString}`
358
+ try {
359
+ let converters = switch registry.converters->Utils.Dict.dangerouslyGetNonOption(cacheKey) {
360
+ | Some(cached) => cached
361
+ | None =>
362
+ let cached = makeConverters(~entityConfig, ~scope, ~table)
363
+ registry.converters->Dict.set(cacheKey, cached)
364
+ cached
657
365
  }
658
- | None => ()
366
+ let builders = fillBuilders(~table, ~converters, ~changes)
367
+ Some(sink->stageBuilders(~table, ~builders, ~rows))
368
+ } catch {
369
+ | exn =>
370
+ throw(
371
+ Persistence.StorageError({
372
+ message: `Failed to convert items for ClickHouse table "${tableName}"`,
373
+ reason: exn->Utils.prettifyExn,
374
+ }),
375
+ )
659
376
  }
377
+ }
378
+ }
660
379
 
661
- if hasReplicatedDatabaseEngine {
662
- // TRUNCATE DATABASE is unsupported on Replicated databases, so a reset
663
- // has to DROP and recreate instead (plain databases keep the TRUNCATE
664
- // fallback below). This requires the ClickHouse user to hold the DROP
665
- // privilege; without it the reset fails here with ACCESS_DENIED. ON
666
- // CLUSTER removes the database from every node — the engine's own log
667
- // can't replicate the drop of the database it lives in — and SYNC waits
668
- // for the drop to finish before the CREATE below.
669
- await client->command({
670
- query: `DROP DATABASE IF EXISTS ${database} ON CLUSTER '{cluster}' SYNC`,
671
- })
672
- } else {
673
- await client->command({
674
- query: `TRUNCATE DATABASE IF EXISTS ${database}${onClusterClause(~onCluster=ddlOnCluster)}`,
675
- })
676
- }
677
- await client->command({
678
- query: `CREATE DATABASE IF NOT EXISTS ${database}${databaseOnClusterClause}${databaseEngineClause}`,
679
- })
380
+ let writeStagedOrThrow = async (sink, ~entities, ~checkpoints) =>
381
+ try await sink->ClickHouseSink.writeBatch(~entities, ~checkpoints) catch {
382
+ | exn =>
383
+ throw(
384
+ Persistence.StorageError({
385
+ message: "Failed to write a batch to ClickHouse",
386
+ reason: exn->Utils.prettifyExn,
387
+ }),
388
+ )
389
+ }
680
390
 
681
- await Promise.all(
682
- entities->Array.map(entityConfig =>
683
- client->command({
684
- query: makeCreateHistoryTableQuery(
685
- ~entityConfig,
686
- ~database,
687
- ~replicated,
688
- ~onCluster=ddlOnCluster,
689
- ~chainIdMode,
690
- ),
691
- })
692
- ),
693
- )->Utils.Promise.ignoreValue
694
- await client->command({
695
- query: makeCreateCheckpointsTableQuery(
696
- ~database,
697
- ~replicated,
698
- ~onCluster=ddlOnCluster,
699
- ~chainIdMode,
700
- ),
391
+ // Tables are not registered here: an indexer that finds an existing storage
392
+ // never runs this, so the write path registers them on first use either way.
393
+ let initialize = async (sink, ~entities: array<Internal.entityConfig>) => {
394
+ try {
395
+ await sink->ClickHouseSink.initialize({
396
+ entities: entities->Array.map(entityConfig => entitySpec(~entityConfig)),
397
+ checkpointColumns: checkpointColumnSpecs,
398
+ replicated: Env.ClickHouse.replicated(),
399
+ databaseEngine: ?Env.ClickHouse.databaseEngine(),
701
400
  })
702
401
 
703
- // The client pools HTTP connections, so consecutive statements may reach
704
- // different replicas, while a Replicated database applies DDL from its
705
- // Keeper log asynchronously. A CREATE VIEW is analyzed against the node's
706
- // local metadata and can land on a replica that hasn't applied the table
707
- // creates yet, failing with UNKNOWN_TABLE. Block until every replica has
708
- // caught up before creating the views. ON CLUSTER must precede the
709
- // database name in this command's grammar.
710
- if hasReplicatedDatabaseEngine {
711
- await client->command({
712
- query: `SYSTEM SYNC DATABASE REPLICA ON CLUSTER '{cluster}' ${database}`,
713
- })
714
- }
715
-
716
- await Promise.all(
717
- entities->Array.map(entityConfig =>
718
- client->command({
719
- query: makeCreateViewQuery(~entityConfig, ~database, ~onCluster=ddlOnCluster),
720
- })
721
- ),
722
- )->Utils.Promise.ignoreValue
723
-
724
402
  Logging.trace("ClickHouse storage initialization completed successfully")
725
403
  } catch {
726
404
  | exn => {
727
405
  Logging.errorWithExn(exn, "Failed to initialize ClickHouse storage")
728
- JsError.throwWithMessage("ClickHouse initialization failed")
406
+ throw(
407
+ Persistence.StorageError({
408
+ message: "Failed to initialize ClickHouse storage",
409
+ reason: exn->Utils.prettifyExn,
410
+ }),
411
+ )
729
412
  }
730
413
  }
731
414
  }
732
415
 
733
- // Resume ClickHouse sink after reorg by deleting rows with checkpoint IDs higher than target
734
- let resume = async (client, ~database: string, ~checkpointId: Internal.checkpointId) => {
735
- try {
736
- // Try to use the database - will throw if it doesn't exist
737
- try {
738
- await client->command({query: `USE ${database}`})
739
- } catch {
740
- | exn =>
741
- Logging.errorWithExn(
742
- exn,
743
- `ClickHouse storage database "${database}" not found. Please run 'envio start -r' to reinitialize the indexer (it'll also drop Postgres database).`,
744
- )
745
- JsError.throwWithMessage("ClickHouse resume failed")
746
- }
747
-
748
- // Get all history tables
749
- let tablesResult = await client->query({
750
- query: `SHOW TABLES FROM ${database} LIKE '${EntityHistory.historyTablePrefix}%'`,
751
- })
752
- let tables = (await tablesResult->json)["data"]
753
-
754
- // Delete rows with checkpoint IDs higher than the target for each history table
755
- await Promise.all(
756
- tables->Array.map(table => {
757
- let tableName = table["name"]
758
- client->command({
759
- query: `ALTER TABLE ${database}.\`${tableName}\` DELETE WHERE \`${EntityHistory.checkpointIdFieldName}\` > ${checkpointId->BigInt.toString}`,
760
- })
761
- }),
762
- )->Utils.Promise.ignoreValue
763
-
764
- // Delete stale checkpoints
765
- await client->command({
766
- query: `DELETE FROM ${database}.\`${InternalTable.Checkpoints.table.tableName}\` WHERE \`${Table.idFieldName}\` > ${checkpointId->BigInt.toString}`,
767
- })
768
- } catch {
769
- | Persistence.StorageError(_) as exn => throw(exn)
416
+ let resume = async (
417
+ sink,
418
+ ~checkpointId: Internal.checkpointId,
419
+ ~chains: array<Persistence.initialChainState>,
420
+ ~entities: array<Internal.entityConfig>,
421
+ ) => {
422
+ let chainProgress = chains->Array.map(chain => {
423
+ ClickHouseSink.chainId: chain.id->ChainId.toString,
424
+ progressBlockNumber: chain.progressBlockNumber,
425
+ })
426
+ try await sink->ClickHouseSink.resume({
427
+ checkpointId: checkpointId->BigInt.toString,
428
+ chainProgress,
429
+ historyTables: entities->Array.map(entityConfig => entitySpec(~entityConfig).historyTable),
430
+ replicated: Env.ClickHouse.replicated(),
431
+ databaseEngine: ?Env.ClickHouse.databaseEngine(),
432
+ }) catch {
770
433
  | exn => {
771
434
  Logging.errorWithExn(exn, "Failed to resume ClickHouse storage")
772
- JsError.throwWithMessage("ClickHouse resume failed")
435
+ throw(
436
+ Persistence.StorageError({
437
+ message: "Failed to resume ClickHouse storage",
438
+ reason: exn->Utils.prettifyExn,
439
+ }),
440
+ )
773
441
  }
774
442
  }
775
443
  }