envio 3.5.0-alpha.1 → 3.5.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +10 -6
- package/package.json +6 -6
- package/src/BatchProcessing.res +27 -0
- package/src/BatchProcessing.res.mjs +12 -0
- package/src/ChainFetching.res +5 -5
- package/src/ChainFetching.res.mjs +3 -4
- package/src/ChainState.res +48 -25
- package/src/ChainState.res.mjs +23 -29
- package/src/ChainState.resi +2 -6
- package/src/Change.res +3 -3
- package/src/Config.res +2 -6
- package/src/Config.res.mjs +2 -12
- package/src/Core.res +3 -0
- package/src/CrossChainState.res +33 -10
- package/src/CrossChainState.res.mjs +21 -6
- package/src/CrossChainState.resi +3 -0
- package/src/EntityId.res +15 -0
- package/src/EntityId.res.mjs +9 -0
- package/src/EventConfigBuilder.res +1 -58
- package/src/EventConfigBuilder.res.mjs +1 -33
- package/src/FetchState.res +510 -460
- package/src/FetchState.res.mjs +413 -428
- package/src/FinalizeBackfill.res +34 -0
- package/src/FinalizeBackfill.res.mjs +26 -0
- package/src/HandlerRegister.res +45 -0
- package/src/HandlerRegister.res.mjs +43 -0
- package/src/InMemoryStore.res +11 -4
- package/src/InMemoryTable.res +26 -24
- package/src/InMemoryTable.res.mjs +19 -18
- package/src/IndexerState.res +71 -2
- package/src/IndexerState.res.mjs +67 -3
- package/src/IndexerState.resi +5 -0
- package/src/Internal.res +17 -8
- package/src/LoadLayer.res +9 -1
- package/src/LoadLayer.res.mjs +1 -0
- package/src/LogSelection.res +2 -1
- package/src/Metrics.res +37 -6
- package/src/Metrics.res.mjs +5 -1
- package/src/Persistence.res +12 -1
- package/src/PgStorage.res +411 -57
- package/src/PgStorage.res.mjs +262 -41
- package/src/SimulateItems.res +2 -2
- package/src/TestIndexer.res +9 -5
- package/src/TestIndexer.res.mjs +5 -2
- package/src/UserContext.res +3 -3
- package/src/Writing.res +12 -2
- package/src/Writing.res.mjs +13 -2
- package/src/bindings/ClickHouse.res +5 -3
- package/src/bindings/ClickHouse.res.mjs +2 -5
- package/src/bindings/Vitest.res +22 -1
- package/src/bindings/Vitest.res.mjs +15 -0
- package/src/db/EntityFilter.res +0 -4
- package/src/db/EntityFilter.res.mjs +1 -8
- package/src/db/EntityHistory.res +16 -7
- package/src/db/EntityHistory.res.mjs +7 -6
- package/src/db/IndexRegistry.res +219 -0
- package/src/db/IndexRegistry.res.mjs +195 -0
- package/src/db/InternalTable.res +7 -0
- package/src/db/InternalTable.res.mjs +7 -0
- package/src/db/Table.res +53 -22
- package/src/db/Table.res.mjs +56 -18
- package/src/sources/AddressSet.res +48 -0
- package/src/sources/AddressSet.res.mjs +11 -0
- package/src/sources/AddressStore.res +152 -0
- package/src/sources/AddressStore.res.mjs +97 -0
- package/src/sources/EvmChain.res +3 -0
- package/src/sources/EvmChain.res.mjs +4 -2
- package/src/sources/EvmHyperSyncSource.res +6 -3
- package/src/sources/EvmHyperSyncSource.res.mjs +3 -3
- package/src/sources/EvmRpcClient.res +6 -3
- package/src/sources/EvmRpcClient.res.mjs +3 -3
- package/src/sources/FuelHyperSync.res +4 -3
- package/src/sources/FuelHyperSync.res.mjs +3 -3
- package/src/sources/FuelHyperSync.resi +2 -1
- package/src/sources/FuelHyperSyncClient.res +12 -6
- package/src/sources/FuelHyperSyncClient.res.mjs +2 -2
- package/src/sources/FuelHyperSyncSource.res +7 -4
- package/src/sources/FuelHyperSyncSource.res.mjs +3 -3
- package/src/sources/HyperSync.res +6 -4
- package/src/sources/HyperSync.res.mjs +2 -3
- package/src/sources/HyperSync.resi +1 -1
- package/src/sources/HyperSyncClient.res +18 -9
- package/src/sources/HyperSyncClient.res.mjs +4 -4
- package/src/sources/RpcSource.res +15 -10
- package/src/sources/RpcSource.res.mjs +3 -4
- package/src/sources/SimulateSource.res +26 -11
- package/src/sources/SimulateSource.res.mjs +13 -5
- package/src/sources/Source.res +4 -2
- package/src/sources/SourceManager.res +2 -3
- package/src/sources/SourceManager.res.mjs +2 -3
- package/src/sources/Svm.res +1 -2
- package/src/sources/Svm.res.mjs +1 -1
- package/src/sources/SvmHyperSyncClient.res +12 -3
- package/src/sources/SvmHyperSyncClient.res.mjs +2 -2
- package/src/sources/SvmHyperSyncSource.res +10 -5
- package/src/sources/SvmHyperSyncSource.res.mjs +5 -4
- package/src/IndexingAddresses.res +0 -151
- package/src/IndexingAddresses.res.mjs +0 -130
- package/src/IndexingAddresses.resi +0 -39
package/src/PgStorage.res
CHANGED
|
@@ -58,7 +58,7 @@ let makeCreateCompositeIndexQuery = (
|
|
|
58
58
|
`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${pgSchema}"."${tableName}"(${index});`
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
let
|
|
61
|
+
let makeCreateTableIndexesQuery = (table: Table.table, ~pgSchema) => {
|
|
62
62
|
let tableName = table.tableName
|
|
63
63
|
let createIndex = indexField =>
|
|
64
64
|
makeCreateIndexQuery(~tableName, ~indexFields=[indexField], ~pgSchema)
|
|
@@ -66,11 +66,138 @@ let makeCreateTableIndicesQuery = (table: Table.table, ~pgSchema) => {
|
|
|
66
66
|
makeCreateCompositeIndexQuery(~tableName, ~indexFields, ~pgSchema)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
let
|
|
70
|
-
let
|
|
69
|
+
let singleIndexes = table->Table.getSingleIndexes
|
|
70
|
+
let compositeIndexes = table->Table.getCompositeIndexes
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
singleIndexes->Array.map(createIndex)->Array.joinUnsafe("\n") ++
|
|
73
|
+
compositeIndexes->Array.map(createCompositeIndex)->Array.joinUnsafe("\n")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One index the schema promises: an `@index` field, a composite index, or the
|
|
77
|
+
// index backing a derived relationship. Carries enough to derive its name, its
|
|
78
|
+
// DDL and its registry key, so the deferred creation at the end of backfill and
|
|
79
|
+
// the registry stay described by the same value.
|
|
80
|
+
type schemaIndex = {
|
|
81
|
+
tableName: string,
|
|
82
|
+
indexFields: array<Table.compositeIndexField>,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `<Entity>_<column>` for a single column, with each further column appended in
|
|
86
|
+
// order (and `_desc` where it applies). The full description, before Postgres'
|
|
87
|
+
// identifier limit is applied — `validateIndexNamesOrThrow` compares these, so
|
|
88
|
+
// it can tell two descriptions apart after they truncate to the same name.
|
|
89
|
+
let schemaIndexDescription = ({tableName, indexFields}: schemaIndex) =>
|
|
90
|
+
tableName ++
|
|
91
|
+
"_" ++
|
|
92
|
+
indexFields
|
|
93
|
+
->Array.map(f => f.fieldName ++ directionToIndexName(f.direction))
|
|
94
|
+
->Array.joinUnsafe("_")
|
|
95
|
+
|
|
96
|
+
// The identifier Postgres actually stores. Automatic and schema-defined indexes
|
|
97
|
+
// share it, so both are named the same way and both stay reachable through
|
|
98
|
+
// `CREATE INDEX IF NOT EXISTS`.
|
|
99
|
+
let schemaIndexName = schemaIndex =>
|
|
100
|
+
schemaIndex->schemaIndexDescription->IndexRegistry.truncateIndexName
|
|
101
|
+
|
|
102
|
+
let schemaIndexKey = ({tableName, indexFields}: schemaIndex) =>
|
|
103
|
+
IndexRegistry.makeKey(
|
|
104
|
+
~tableName,
|
|
105
|
+
~columns=indexFields->IndexRegistry.fromIndexFields,
|
|
106
|
+
~method=IndexRegistry.btree,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Plain DDL, not CONCURRENTLY: it builds from a single table scan instead of
|
|
110
|
+
// two, and the SHARE lock it takes blocks writes but not reads, so queries keep
|
|
111
|
+
// being served while it runs. The indexer is the only writer, and both callers
|
|
112
|
+
// are happy to wait on it.
|
|
113
|
+
let makeCreateSchemaIndexQuery = (schemaIndex, ~pgSchema) => {
|
|
114
|
+
let {tableName, indexFields} = schemaIndex
|
|
115
|
+
let columns =
|
|
116
|
+
indexFields
|
|
117
|
+
->Array.map(f => `"${f.fieldName}"${directionToSql(f.direction)}`)
|
|
118
|
+
->Array.joinUnsafe(", ")
|
|
119
|
+
`CREATE INDEX IF NOT EXISTS "${schemaIndex->schemaIndexName}" ON "${pgSchema}"."${tableName}"(${columns});`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let formatSeconds = (timeRef: Performance.timeRef) =>
|
|
123
|
+
(Math.round(timeRef->Performance.secondsSince *. 100.) /. 100.)->Float.toString
|
|
124
|
+
|
|
125
|
+
// Every index build blocks writes to its table for as long as it runs, and on a
|
|
126
|
+
// large database that is not quick. Both build paths say so up front, so a
|
|
127
|
+
// stalled-looking indexer is explainable from the logs alone.
|
|
128
|
+
let slowOnLargeDatabaseNotice = "This can take a long time on a large database."
|
|
129
|
+
|
|
130
|
+
// An index Postgres reports as INVALID still owns its name, so
|
|
131
|
+
// `CREATE INDEX IF NOT EXISTS` would quietly skip it and leave the query
|
|
132
|
+
// unserved. REINDEX rebuilds it from its own stored definition — a repair, not
|
|
133
|
+
// a replacement — which is right only when that definition is the one we
|
|
134
|
+
// wanted. Runs inside a transaction, unlike REINDEX CONCURRENTLY.
|
|
135
|
+
let makeReindexQuery = (~pgSchema, ~indexName) => `REINDEX INDEX "${pgSchema}"."${indexName}";`
|
|
136
|
+
|
|
137
|
+
// A name taken by an invalid index the indexer didn't create isn't ours to
|
|
138
|
+
// touch: rebuilding it reuses its own definition, so it would come back valid
|
|
139
|
+
// but still not serve the query, and dropping it could take out a unique
|
|
140
|
+
// constraint someone else relies on.
|
|
141
|
+
let nameCollisionError = (~indexName, ~pgSchema) =>
|
|
142
|
+
Utils.Error.make(
|
|
143
|
+
`Cannot create the index "${indexName}" in schema "${pgSchema}". An index of that name already exists and PostgreSQL reports it as invalid, but it isn't one the indexer created — it covers different columns, or it is a unique or partial index — so it is left untouched. Drop or repair it by hand, then restart the indexer.`,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
let makeSingleColumnSchemaIndex = (~tableName, ~column): schemaIndex => {
|
|
147
|
+
tableName,
|
|
148
|
+
indexFields: [{fieldName: column, direction: Table.Asc}],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Every index the entity schema promises. Deferred past the initial DDL and
|
|
152
|
+
// created in one transaction once backfill completes, so a resumed indexer that
|
|
153
|
+
// reports itself ready always has all of them.
|
|
154
|
+
//
|
|
155
|
+
// `entities` is the Postgres-backed set, and every `@derivedFrom` target within
|
|
156
|
+
// it resolves: config parsing rejects a Postgres entity deriving from one that
|
|
157
|
+
// isn't in Postgres (`validate_relationship_storage`).
|
|
158
|
+
let getSchemaIndexes = (~entities: array<Internal.entityConfig>): array<schemaIndex> => {
|
|
159
|
+
let derivedSchema = Schema.make(entities->Array.map(e => e.table))
|
|
160
|
+
let all = []
|
|
161
|
+
|
|
162
|
+
entities->Array.forEach(({table}) => {
|
|
163
|
+
table
|
|
164
|
+
->Table.getSingleIndexes
|
|
165
|
+
->Array.forEach(column =>
|
|
166
|
+
all->Array.push(makeSingleColumnSchemaIndex(~tableName=table.tableName, ~column))->ignore
|
|
167
|
+
)
|
|
168
|
+
table
|
|
169
|
+
->Table.getCompositeIndexes
|
|
170
|
+
->Array.forEach(indexFields =>
|
|
171
|
+
all->Array.push({tableName: table.tableName, indexFields})->ignore
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
entities->Array.forEach(({table}) =>
|
|
176
|
+
table
|
|
177
|
+
->Table.getDerivedFromFields
|
|
178
|
+
->Array.forEach(derivedFromField => {
|
|
179
|
+
let column =
|
|
180
|
+
derivedSchema->Schema.getDerivedFromPgFieldName(derivedFromField)->Utils.unwrapResultExn
|
|
181
|
+
all
|
|
182
|
+
->Array.push(
|
|
183
|
+
makeSingleColumnSchemaIndex(~tableName=derivedFromField.derivedFromEntity, ~column),
|
|
184
|
+
)
|
|
185
|
+
->ignore
|
|
186
|
+
})
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
// An `@index` field and a derived relationship pointing at it describe the
|
|
190
|
+
// same index, so the list is deduped on identity rather than on name.
|
|
191
|
+
let seen = Utils.Set.make()
|
|
192
|
+
all->Array.filter(schemaIndex => {
|
|
193
|
+
let key = schemaIndex->schemaIndexKey
|
|
194
|
+
if seen->Utils.Set.has(key) {
|
|
195
|
+
false
|
|
196
|
+
} else {
|
|
197
|
+
seen->Utils.Set.add(key)->ignore
|
|
198
|
+
true
|
|
199
|
+
}
|
|
200
|
+
})
|
|
74
201
|
}
|
|
75
202
|
|
|
76
203
|
let makeCreateTableQuery = (table: Table.table, ~pgSchema, ~isNumericArrayAsText) => {
|
|
@@ -152,13 +279,16 @@ let getEntityHistory = (~entityConfig: Internal.entityConfig): EntityHistory.pgE
|
|
|
152
279
|
~entityName=entityTableName,
|
|
153
280
|
~entityIndex=entityConfig.index,
|
|
154
281
|
)
|
|
155
|
-
//ignore composite
|
|
282
|
+
//ignore composite indexes
|
|
156
283
|
let table = Table.mkTable(
|
|
157
284
|
historyTableName,
|
|
158
285
|
~fields=dataFields->Array.concat([checkpointIdField, actionField]),
|
|
159
286
|
)
|
|
160
287
|
|
|
161
|
-
let setChangeSchema = EntityHistory.makeSetUpdateSchema(
|
|
288
|
+
let setChangeSchema = EntityHistory.makeSetUpdateSchema(
|
|
289
|
+
~idSchema=entityConfig.table->Table.getIdSchema,
|
|
290
|
+
entityConfig.schema,
|
|
291
|
+
)
|
|
162
292
|
|
|
163
293
|
{
|
|
164
294
|
EntityHistory.table,
|
|
@@ -180,6 +310,10 @@ let makeInitializeTransaction = (
|
|
|
180
310
|
~entities=[],
|
|
181
311
|
~enums=[],
|
|
182
312
|
~isEmptyPgSchema=false,
|
|
313
|
+
// Backfill writes are far cheaper without the schema's read indexes, so the
|
|
314
|
+
// initial DDL creates only tables, primary keys, views and chain rows; the
|
|
315
|
+
// rest is created by `finalizeBackfill` before the indexer reports ready.
|
|
316
|
+
~deferSchemaIndexes=false,
|
|
183
317
|
) => {
|
|
184
318
|
let generalTables = [
|
|
185
319
|
InternalTable.Chains.table,
|
|
@@ -189,13 +323,13 @@ let makeInitializeTransaction = (
|
|
|
189
323
|
]
|
|
190
324
|
|
|
191
325
|
let allTables = generalTables->Array.copy
|
|
192
|
-
let allEntityTables = []
|
|
193
326
|
entities->Array.forEach((entityConfig: Internal.entityConfig) => {
|
|
194
|
-
allEntityTables->Array.push(entityConfig.table)->ignore
|
|
195
327
|
allTables->Array.push(entityConfig.table)->ignore
|
|
196
328
|
allTables->Array.push(getEntityHistory(~entityConfig).table)->ignore
|
|
197
329
|
})
|
|
198
|
-
|
|
330
|
+
|
|
331
|
+
let schemaIndexes = getSchemaIndexes(~entities)
|
|
332
|
+
IndexRegistry.validateIndexNamesOrThrow(schemaIndexes->Array.map(schemaIndexDescription))
|
|
199
333
|
|
|
200
334
|
let query = ref(
|
|
201
335
|
(
|
|
@@ -229,31 +363,12 @@ GRANT ALL ON SCHEMA "${pgSchema}" TO public;`,
|
|
|
229
363
|
makeCreateTableQuery(table, ~pgSchema, ~isNumericArrayAsText=isHasuraEnabled)
|
|
230
364
|
})
|
|
231
365
|
|
|
232
|
-
// Then batch all
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
query := query.contents ++ "\n" ++ indices
|
|
237
|
-
}
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
// Add derived indices
|
|
241
|
-
entities->Array.forEach((entity: Internal.entityConfig) => {
|
|
242
|
-
entity.table
|
|
243
|
-
->Table.getDerivedFromFields
|
|
244
|
-
->Array.forEach(derivedFromField => {
|
|
245
|
-
let indexField =
|
|
246
|
-
derivedSchema->Schema.getDerivedFromPgFieldName(derivedFromField)->Utils.unwrapResultExn
|
|
247
|
-
query :=
|
|
248
|
-
query.contents ++
|
|
249
|
-
"\n" ++
|
|
250
|
-
makeCreateIndexQuery(
|
|
251
|
-
~tableName=derivedFromField.derivedFromEntity,
|
|
252
|
-
~indexFields=[indexField],
|
|
253
|
-
~pgSchema,
|
|
254
|
-
)
|
|
366
|
+
// Then batch all indexes (better performance when tables exist)
|
|
367
|
+
if !deferSchemaIndexes {
|
|
368
|
+
schemaIndexes->Array.forEach(schemaIndex => {
|
|
369
|
+
query := query.contents ++ "\n" ++ makeCreateSchemaIndexQuery(schemaIndex, ~pgSchema)
|
|
255
370
|
})
|
|
256
|
-
}
|
|
371
|
+
}
|
|
257
372
|
|
|
258
373
|
// Create views for Hasura integration
|
|
259
374
|
query := query.contents ++ "\n" ++ InternalTable.Views.makeMetaViewQuery(~pgSchema)
|
|
@@ -355,8 +470,8 @@ let makeDeleteByIdQuery = (~pgSchema, ~tableName) => {
|
|
|
355
470
|
`DELETE FROM "${pgSchema}"."${tableName}" WHERE id = $1;`
|
|
356
471
|
}
|
|
357
472
|
|
|
358
|
-
let makeDeleteByIdsQuery = (~pgSchema, ~tableName) => {
|
|
359
|
-
`DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1
|
|
473
|
+
let makeDeleteByIdsQuery = (~pgSchema, ~tableName, ~idPgType) => {
|
|
474
|
+
`DELETE FROM "${pgSchema}"."${tableName}" WHERE id = ANY($1::${idPgType}[]);`
|
|
360
475
|
}
|
|
361
476
|
|
|
362
477
|
let makeLoadAllQuery = (~pgSchema, ~tableName) => {
|
|
@@ -744,18 +859,26 @@ let getConnectedPsqlExec = {
|
|
|
744
859
|
}
|
|
745
860
|
}
|
|
746
861
|
|
|
747
|
-
let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids
|
|
862
|
+
let deleteByIdsOrThrow = async (sql, ~pgSchema, ~ids: array<EntityId.t>, ~table: Table.table) => {
|
|
863
|
+
// A JSON array of the serialized ids. For a single id the query binds it as
|
|
864
|
+
// `$1` directly (the array is the positional-params array); for many it binds
|
|
865
|
+
// the whole array to `$1` behind an `ANY(...)`.
|
|
866
|
+
let idsJson = table->Table.encodeIdsToJson(ids)
|
|
748
867
|
switch await (
|
|
749
868
|
switch ids {
|
|
750
869
|
| [_] =>
|
|
751
870
|
sql->Postgres.preparedUnsafe(
|
|
752
871
|
makeDeleteByIdQuery(~pgSchema, ~tableName=table.tableName),
|
|
753
|
-
|
|
872
|
+
idsJson->Obj.magic,
|
|
754
873
|
)
|
|
755
874
|
| _ =>
|
|
756
875
|
sql->Postgres.preparedUnsafe(
|
|
757
|
-
makeDeleteByIdsQuery(
|
|
758
|
-
|
|
876
|
+
makeDeleteByIdsQuery(
|
|
877
|
+
~pgSchema,
|
|
878
|
+
~tableName=table.tableName,
|
|
879
|
+
~idPgType=table->Table.getIdPgFieldType(~pgSchema),
|
|
880
|
+
),
|
|
881
|
+
[idsJson]->Obj.magic,
|
|
759
882
|
)
|
|
760
883
|
}
|
|
761
884
|
) {
|
|
@@ -811,9 +934,11 @@ let makeInsertDeleteUpdatesQuery = (~entityConfig: Internal.entityConfig, ~pgSch
|
|
|
811
934
|
~isNullable=false,
|
|
812
935
|
)
|
|
813
936
|
|
|
937
|
+
let idPgType = entityConfig.table->Table.getIdPgFieldType(~pgSchema)
|
|
938
|
+
|
|
814
939
|
`INSERT INTO "${pgSchema}"."${historyTableName}" (${allHistoryFieldNamesStr})
|
|
815
940
|
SELECT ${selectPartsStr}
|
|
816
|
-
FROM UNNEST($1
|
|
941
|
+
FROM UNNEST($1::${idPgType}[], $2::${checkpointIdPgType}[]) AS u(${Table.idFieldName}, ${EntityHistory.checkpointIdFieldName})`
|
|
817
942
|
}
|
|
818
943
|
|
|
819
944
|
let executeSet = (
|
|
@@ -912,17 +1037,21 @@ let rec writeBatch = async (
|
|
|
912
1037
|
// Single pass over the change log: track each id's latest change (the last
|
|
913
1038
|
// one seen) and, when saving history, fan every non-diff change out to the
|
|
914
1039
|
// history-table batches.
|
|
1040
|
+
// Keyed/deduped in memory by the id's string key (toKey), while the
|
|
1041
|
+
// batches sent to SQL keep the real id values so they serialize with the
|
|
1042
|
+
// id column's type.
|
|
915
1043
|
let latestChangeById = Dict.make()
|
|
916
1044
|
let orderedIds = []
|
|
917
1045
|
changes->Array.forEach(change => {
|
|
918
1046
|
let entityId = change->Change.getEntityId
|
|
919
|
-
|
|
1047
|
+
let entityKey = entityId->EntityId.toKey
|
|
1048
|
+
if latestChangeById->Utils.Dict.dangerouslyGetNonOption(entityKey)->Option.isNone {
|
|
920
1049
|
orderedIds->Array.push(entityId)
|
|
921
1050
|
}
|
|
922
|
-
latestChangeById->Dict.set(
|
|
1051
|
+
latestChangeById->Dict.set(entityKey, change)
|
|
923
1052
|
if shouldSaveHistory {
|
|
924
1053
|
if Some(change->Change.getCheckpointId) === diffCheckpointId {
|
|
925
|
-
idsWithDiff->Utils.Set.add(
|
|
1054
|
+
idsWithDiff->Utils.Set.add(entityKey)->ignore
|
|
926
1055
|
} else {
|
|
927
1056
|
switch change {
|
|
928
1057
|
| Delete({entityId, checkpointId}) =>
|
|
@@ -936,13 +1065,14 @@ let rec writeBatch = async (
|
|
|
936
1065
|
|
|
937
1066
|
let backfillHistoryIds = Utils.Set.make()
|
|
938
1067
|
orderedIds->Array.forEach(entityId => {
|
|
939
|
-
|
|
1068
|
+
let entityKey = entityId->EntityId.toKey
|
|
1069
|
+
switch latestChangeById->Dict.getUnsafe(entityKey) {
|
|
940
1070
|
| Set({entity}) => entitiesToSet->Array.push(entity)
|
|
941
1071
|
| Delete({entityId}) => idsToDelete->Array.push(entityId)
|
|
942
1072
|
}
|
|
943
1073
|
|
|
944
1074
|
// An id needs a history backfill iff none of its changes is the diff.
|
|
945
|
-
if shouldSaveHistory && !(idsWithDiff->Utils.Set.has(
|
|
1075
|
+
if shouldSaveHistory && !(idsWithDiff->Utils.Set.has(entityKey)) {
|
|
946
1076
|
backfillHistoryIds->Utils.Set.add(entityId)->ignore
|
|
947
1077
|
}
|
|
948
1078
|
})
|
|
@@ -962,7 +1092,7 @@ let rec writeBatch = async (
|
|
|
962
1092
|
await EntityHistory.backfillHistory(
|
|
963
1093
|
sql,
|
|
964
1094
|
~pgSchema,
|
|
965
|
-
~
|
|
1095
|
+
~table=entityConfig.table,
|
|
966
1096
|
~entityIndex=entityConfig.index,
|
|
967
1097
|
~ids=backfillHistoryIds->Utils.Set.toArray,
|
|
968
1098
|
)
|
|
@@ -974,7 +1104,7 @@ let rec writeBatch = async (
|
|
|
974
1104
|
->Postgres.preparedUnsafe(
|
|
975
1105
|
makeInsertDeleteUpdatesQuery(~entityConfig, ~pgSchema),
|
|
976
1106
|
(
|
|
977
|
-
batchDeleteEntityIds,
|
|
1107
|
+
entityConfig.table->Table.encodeIdsToJson(batchDeleteEntityIds),
|
|
978
1108
|
batchDeleteCheckpointIds->Utils.BigInt.arrayToStringArray,
|
|
979
1109
|
)->Obj.magic,
|
|
980
1110
|
)
|
|
@@ -1230,10 +1360,25 @@ let makeGetRollbackRemovedIdsQuery = (~entityConfig: Internal.entityConfig, ~pgS
|
|
|
1230
1360
|
)`
|
|
1231
1361
|
}
|
|
1232
1362
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1363
|
+
// Memoized per table so the id is parsed with that entity's id schema (a
|
|
1364
|
+
// numeric id comes back from Postgres as a number, not a string) and the
|
|
1365
|
+
// schema's operations compile once rather than per rollback row.
|
|
1366
|
+
let rollbackRowStateSchema: Table.table => S.t<(
|
|
1367
|
+
EntityId.t,
|
|
1368
|
+
EntityHistory.RowAction.t,
|
|
1369
|
+
)> = Utils.WeakMap.memoize(table =>
|
|
1370
|
+
S.object(s => (
|
|
1371
|
+
s.field(Table.idFieldName, table->Table.getIdSchema),
|
|
1372
|
+
s.field(EntityHistory.changeFieldName, EntityHistory.RowAction.schema),
|
|
1373
|
+
))
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
// Same reason as above for the id-only rows: both rollback queries must yield
|
|
1377
|
+
// ids in the entity's own representation, or the two halves of the diff would
|
|
1378
|
+
// disagree (Postgres hands back a NUMERIC id as a string, not a bigint).
|
|
1379
|
+
let rollbackRemovedIdsSchema: Table.table => S.t<array<EntityId.t>> = Utils.WeakMap.memoize(table =>
|
|
1380
|
+
S.array(S.object(s => s.field(Table.idFieldName, table->Table.getIdSchema)))
|
|
1381
|
+
)
|
|
1237
1382
|
|
|
1238
1383
|
let make = (
|
|
1239
1384
|
~sql: Postgres.sql,
|
|
@@ -1260,6 +1405,37 @@ let make = (
|
|
|
1260
1405
|
"cache",
|
|
1261
1406
|
])
|
|
1262
1407
|
|
|
1408
|
+
// The metric label, the `storage` field on this backend's logs, and the
|
|
1409
|
+
// storage record's own name are all the same string.
|
|
1410
|
+
let storageName = "postgres"
|
|
1411
|
+
|
|
1412
|
+
let indexRegistry = IndexRegistry.make()
|
|
1413
|
+
|
|
1414
|
+
// The only point where the catalog is consulted. Afterwards this process is
|
|
1415
|
+
// the sole writer of the schema, so the registry is kept in sync by adding
|
|
1416
|
+
// each index right after its DDL succeeds.
|
|
1417
|
+
let reloadIndexRegistry = async () => {
|
|
1418
|
+
let rows =
|
|
1419
|
+
(await sql->Postgres.unsafe(IndexRegistry.makeCatalogQuery(~pgSchema)))->S.parseOrThrow(
|
|
1420
|
+
IndexRegistry.catalogRowsSchema,
|
|
1421
|
+
)
|
|
1422
|
+
switch indexRegistry->IndexRegistry.reload(~rows) {
|
|
1423
|
+
| [] => ()
|
|
1424
|
+
| invalidIndexNames =>
|
|
1425
|
+
Logging.warn({
|
|
1426
|
+
"storage": storageName,
|
|
1427
|
+
"msg": `Ignoring invalid PostgreSQL indexes in schema "${pgSchema}". They are left untouched — drop and recreate them manually if you need them.`,
|
|
1428
|
+
"indexes": invalidIndexNames,
|
|
1429
|
+
})
|
|
1430
|
+
}
|
|
1431
|
+
Logging.info({
|
|
1432
|
+
"storage": storageName,
|
|
1433
|
+
"msg": `Found ${indexRegistry
|
|
1434
|
+
->IndexRegistry.size
|
|
1435
|
+
->Int.toString} existing indexes in the indexer schema.`,
|
|
1436
|
+
})
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1263
1439
|
let isInitialized = async () => {
|
|
1264
1440
|
let envioTables = await sql->Postgres.unsafe(
|
|
1265
1441
|
`SELECT table_schema FROM information_schema.tables WHERE table_schema = '${pgSchema}' AND (table_name = '${// This is for indexer before envio@2.28
|
|
@@ -1451,6 +1627,7 @@ let make = (
|
|
|
1451
1627
|
~chainConfigs,
|
|
1452
1628
|
~isEmptyPgSchema=schemaTableNames->Utils.Array.isEmpty,
|
|
1453
1629
|
~isHasuraEnabled,
|
|
1630
|
+
~deferSchemaIndexes=true,
|
|
1454
1631
|
)
|
|
1455
1632
|
// Execute all queries within a single transaction for integrity.
|
|
1456
1633
|
// The envio_info row is written in the same transaction so a successful
|
|
@@ -1487,6 +1664,8 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1487
1664
|
|
|
1488
1665
|
let cache = await restoreEffectCache(~withUpload=true)
|
|
1489
1666
|
|
|
1667
|
+
await reloadIndexRegistry()
|
|
1668
|
+
|
|
1490
1669
|
// Integration with other tools like Hasura
|
|
1491
1670
|
switch onInitialize {
|
|
1492
1671
|
| Some(onInitialize) => await onInitialize()
|
|
@@ -1543,6 +1722,177 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1543
1722
|
}
|
|
1544
1723
|
}
|
|
1545
1724
|
|
|
1725
|
+
// The physical columns a filter reads, deduped and in first-seen order.
|
|
1726
|
+
// Unknown field names are left to `loadOrThrow`, which reports them properly.
|
|
1727
|
+
let filterColumns = (~table: Table.table, ~filters: array<EntityFilter.t>) => {
|
|
1728
|
+
let queryFields = table->Table.queryFields
|
|
1729
|
+
let columns = []
|
|
1730
|
+
let seen = Utils.Set.make()
|
|
1731
|
+
let rec collect = (filter: EntityFilter.t) =>
|
|
1732
|
+
switch filter {
|
|
1733
|
+
| Eq({fieldName}) | Gt({fieldName}) | Lt({fieldName}) | In({fieldName}) =>
|
|
1734
|
+
switch queryFields->Utils.Dict.dangerouslyGetNonOption(fieldName) {
|
|
1735
|
+
| Some({pgDbFieldName}) =>
|
|
1736
|
+
if !(seen->Utils.Set.has(pgDbFieldName)) {
|
|
1737
|
+
seen->Utils.Set.add(pgDbFieldName)->ignore
|
|
1738
|
+
columns->Array.push(pgDbFieldName)->ignore
|
|
1739
|
+
}
|
|
1740
|
+
| None => ()
|
|
1741
|
+
}
|
|
1742
|
+
| And({filters}) => filters->Array.forEach(collect)
|
|
1743
|
+
}
|
|
1744
|
+
filters->Array.forEach(collect)
|
|
1745
|
+
columns
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// The DDL that leaves `schemaIndex` in place. Normally a create; a repair
|
|
1749
|
+
// when an invalid index already holds the name with the definition we want.
|
|
1750
|
+
let makeEnsureIndexQuery = schemaIndex => {
|
|
1751
|
+
let indexName = schemaIndex->schemaIndexName
|
|
1752
|
+
switch indexRegistry->IndexRegistry.getInvalid(indexName) {
|
|
1753
|
+
| None => makeCreateSchemaIndexQuery(schemaIndex, ~pgSchema)
|
|
1754
|
+
| Some({key, isRepairable: true}) if key === schemaIndex->schemaIndexKey =>
|
|
1755
|
+
makeReindexQuery(~pgSchema, ~indexName)
|
|
1756
|
+
| Some(_) => throw(nameCollisionError(~indexName, ~pgSchema))
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
let isRepair = schemaIndex =>
|
|
1761
|
+
indexRegistry->IndexRegistry.getInvalid(schemaIndex->schemaIndexName)->Option.isSome
|
|
1762
|
+
|
|
1763
|
+
let ensureQueryIndexes = async (~table: Table.table, ~filters: array<EntityFilter.t>) => {
|
|
1764
|
+
let missing = filterColumns(~table, ~filters)->Array.filterMap(column => {
|
|
1765
|
+
let schemaIndex = makeSingleColumnSchemaIndex(~tableName=table.tableName, ~column)
|
|
1766
|
+
indexRegistry->IndexRegistry.has(schemaIndex->schemaIndexKey) ? None : Some(schemaIndex)
|
|
1767
|
+
})
|
|
1768
|
+
|
|
1769
|
+
if missing->Utils.Array.notEmpty {
|
|
1770
|
+
let _ = await missing
|
|
1771
|
+
->Array.map(schemaIndex => {
|
|
1772
|
+
let indexName = schemaIndex->schemaIndexName
|
|
1773
|
+
indexRegistry
|
|
1774
|
+
->IndexRegistry.ensure(
|
|
1775
|
+
~key=schemaIndex->schemaIndexKey,
|
|
1776
|
+
~tableName=table.tableName,
|
|
1777
|
+
~build=async () => {
|
|
1778
|
+
// Resolved before logging so a repair is reported as one, and an
|
|
1779
|
+
// unrelated index holding the name fails before any DDL runs.
|
|
1780
|
+
let query = makeEnsureIndexQuery(schemaIndex)
|
|
1781
|
+
let verb = schemaIndex->isRepair ? "Repairing invalid index" : "Creating index"
|
|
1782
|
+
// Logged from inside the build so it reports the one request that
|
|
1783
|
+
// actually creates the index, not the ones waiting on it.
|
|
1784
|
+
Logging.info({
|
|
1785
|
+
"storage": storageName,
|
|
1786
|
+
"msg": `${verb} "${indexName}" to serve a getWhere query on "${table.tableName}". Writes to the table are paused until it completes. ${slowOnLargeDatabaseNotice}`,
|
|
1787
|
+
})
|
|
1788
|
+
let timeRef = Performance.now()
|
|
1789
|
+
let _ = await sql->Postgres.unsafe(query)
|
|
1790
|
+
indexRegistry->IndexRegistry.clearInvalidName(indexName)
|
|
1791
|
+
Logging.info({
|
|
1792
|
+
"storage": storageName,
|
|
1793
|
+
"msg": `Index "${indexName}" is ready after ${timeRef->formatSeconds}s. Resuming indexing.`,
|
|
1794
|
+
})
|
|
1795
|
+
},
|
|
1796
|
+
)
|
|
1797
|
+
// A failed build leaves the registry untouched, so the next getWhere
|
|
1798
|
+
// retries. Meanwhile the query still runs — just without the index.
|
|
1799
|
+
->Utils.Promise.catchResolve(exn => {
|
|
1800
|
+
Logging.warn({
|
|
1801
|
+
"storage": storageName,
|
|
1802
|
+
"msg": `Failed to create the index "${indexName}" for a getWhere query. The query runs without it.`,
|
|
1803
|
+
"err": exn->Utils.prettifyExn,
|
|
1804
|
+
})
|
|
1805
|
+
})
|
|
1806
|
+
})
|
|
1807
|
+
->Promise.all
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
let finalizeBackfill = async (
|
|
1812
|
+
~entities: array<Internal.entityConfig>,
|
|
1813
|
+
~chainIds: array<int>,
|
|
1814
|
+
~readyAt: Date.t,
|
|
1815
|
+
) => {
|
|
1816
|
+
let schemaIndexes = getSchemaIndexes(
|
|
1817
|
+
~entities=entities->Array.filter((e: Internal.entityConfig) => e.storage.postgres),
|
|
1818
|
+
)
|
|
1819
|
+
IndexRegistry.validateIndexNamesOrThrow(schemaIndexes->Array.map(schemaIndexDescription))
|
|
1820
|
+
|
|
1821
|
+
let missing =
|
|
1822
|
+
schemaIndexes->Array.filter(schemaIndex =>
|
|
1823
|
+
!(indexRegistry->IndexRegistry.has(schemaIndex->schemaIndexKey))
|
|
1824
|
+
)
|
|
1825
|
+
|
|
1826
|
+
// `IF NOT EXISTS` would quietly skip an index whose name is taken by an
|
|
1827
|
+
// invalid one, and the indexer would then report ready with an index the
|
|
1828
|
+
// planner can't use. Those are repaired in the same transaction instead.
|
|
1829
|
+
let repaired = missing->Array.filter(isRepair)
|
|
1830
|
+
if repaired->Utils.Array.notEmpty {
|
|
1831
|
+
Logging.warn({
|
|
1832
|
+
"storage": storageName,
|
|
1833
|
+
"msg": `PostgreSQL reports ${repaired
|
|
1834
|
+
->Array.length
|
|
1835
|
+
->Int.toString} existing indexes as invalid, so they can't serve queries. Rebuilding them.`,
|
|
1836
|
+
"indexes": repaired->Array.map(schemaIndexName),
|
|
1837
|
+
})
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
switch missing {
|
|
1841
|
+
| [] =>
|
|
1842
|
+
Logging.info({
|
|
1843
|
+
"storage": storageName,
|
|
1844
|
+
"msg": `All ${schemaIndexes
|
|
1845
|
+
->Array.length
|
|
1846
|
+
->Int.toString} schema indexes are already in place. Marking the indexer ready.`,
|
|
1847
|
+
})
|
|
1848
|
+
| _ =>
|
|
1849
|
+
Logging.info({
|
|
1850
|
+
"storage": storageName,
|
|
1851
|
+
"msg": `Creating the ${missing
|
|
1852
|
+
->Array.length
|
|
1853
|
+
->Int.toString} remaining schema indexes before the indexer reports ready. Writes are paused until they are committed. ${slowOnLargeDatabaseNotice}`,
|
|
1854
|
+
"indexes": missing->Array.map(schemaIndexName),
|
|
1855
|
+
})
|
|
1856
|
+
}
|
|
1857
|
+
// Resolved up front so a name held by an unrelated invalid index fails
|
|
1858
|
+
// before the transaction opens, rather than rolling one back.
|
|
1859
|
+
let indexQueries = missing->Array.map(makeEnsureIndexQuery)
|
|
1860
|
+
let timeRef = Performance.now()
|
|
1861
|
+
|
|
1862
|
+
await sql->Postgres.beginSql(async sql => {
|
|
1863
|
+
// Sequential rather than Promise.all: they share one connection inside
|
|
1864
|
+
// the transaction, and several of them target the same table.
|
|
1865
|
+
for idx in 0 to indexQueries->Array.length - 1 {
|
|
1866
|
+
let _ = await sql->Postgres.unsafe(indexQueries->Array.getUnsafe(idx))
|
|
1867
|
+
}
|
|
1868
|
+
await sql
|
|
1869
|
+
->Postgres.preparedUnsafe(
|
|
1870
|
+
InternalTable.Chains.makeSetReadyAtQuery(~pgSchema),
|
|
1871
|
+
[
|
|
1872
|
+
readyAt->(Utils.magic: Date.t => unknown),
|
|
1873
|
+
chainIds->(Utils.magic: array<int> => unknown),
|
|
1874
|
+
]->(Utils.magic: array<unknown> => unknown),
|
|
1875
|
+
)
|
|
1876
|
+
->Utils.Promise.ignoreValue
|
|
1877
|
+
})
|
|
1878
|
+
|
|
1879
|
+
// Only after the commit: a rollback takes both the indexes and ready_at
|
|
1880
|
+
// with it, and the registry must not claim what the schema doesn't have.
|
|
1881
|
+
missing->Array.forEach(schemaIndex =>
|
|
1882
|
+
indexRegistry->IndexRegistry.add(schemaIndex->schemaIndexKey)
|
|
1883
|
+
)
|
|
1884
|
+
repaired->Array.forEach(schemaIndex =>
|
|
1885
|
+
indexRegistry->IndexRegistry.clearInvalidName(schemaIndex->schemaIndexName)
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
Logging.info({
|
|
1889
|
+
"storage": storageName,
|
|
1890
|
+
"msg": `Committed ${missing
|
|
1891
|
+
->Array.length
|
|
1892
|
+
->Int.toString} schema indexes and the ready timestamp in ${timeRef->formatSeconds}s.`,
|
|
1893
|
+
})
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1546
1896
|
let setOrThrow = (
|
|
1547
1897
|
type item,
|
|
1548
1898
|
~items: array<item>,
|
|
@@ -1687,6 +2037,8 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1687
2037
|
InternalTable.EnvioInfo.read(sql, ~pgSchema),
|
|
1688
2038
|
))
|
|
1689
2039
|
|
|
2040
|
+
await reloadIndexRegistry()
|
|
2041
|
+
|
|
1690
2042
|
let checkpointId = (checkpointIdResult->Array.getUnsafe(0))["id"]->BigInt.fromStringOrThrow
|
|
1691
2043
|
|
|
1692
2044
|
// Convert string checkpoint IDs from DB to bigint
|
|
@@ -1757,7 +2109,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1757
2109
|
makeGetRollbackRemovedIdsQuery(~entityConfig, ~pgSchema),
|
|
1758
2110
|
[rollbackTargetCheckpointId->BigInt.toString]->(Utils.magic: array<string> => unknown),
|
|
1759
2111
|
)
|
|
1760
|
-
->(Utils.magic: promise<unknown> => promise<array<
|
|
2112
|
+
->(Utils.magic: promise<unknown> => promise<array<unknown>>),
|
|
1761
2113
|
// Get the latest pre-target row, including its SET or DELETE action.
|
|
1762
2114
|
sql
|
|
1763
2115
|
->Postgres.preparedUnsafe(
|
|
@@ -1767,10 +2119,10 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1767
2119
|
->(Utils.magic: promise<unknown> => promise<array<unknown>>),
|
|
1768
2120
|
))
|
|
1769
2121
|
|
|
1770
|
-
let removedIds = removedIdRows->
|
|
2122
|
+
let removedIds = removedIdRows->S.parseOrThrow(rollbackRemovedIdsSchema(entityConfig.table))
|
|
1771
2123
|
let restoredEntitiesResult = []
|
|
1772
2124
|
rollbackRows->Array.forEach(row => {
|
|
1773
|
-
let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema)
|
|
2125
|
+
let (entityId, action) = row->S.parseOrThrow(rollbackRowStateSchema(entityConfig.table))
|
|
1774
2126
|
switch action {
|
|
1775
2127
|
| SET => restoredEntitiesResult->Array.push(row)->ignore
|
|
1776
2128
|
| DELETE => removedIds->Array.push(entityId)->ignore
|
|
@@ -1836,17 +2188,19 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1836
2188
|
~sinkPromise,
|
|
1837
2189
|
~chainMetaData,
|
|
1838
2190
|
)
|
|
1839
|
-
onWrite(~storage=
|
|
2191
|
+
onWrite(~storage=storageName, ~timeSeconds=primaryTimerRef->Performance.secondsSince)
|
|
1840
2192
|
}
|
|
1841
2193
|
|
|
1842
2194
|
let close = () => sql->Postgres.endSql
|
|
1843
2195
|
|
|
1844
2196
|
{
|
|
1845
|
-
name:
|
|
2197
|
+
name: storageName,
|
|
1846
2198
|
isInitialized,
|
|
1847
2199
|
initialize,
|
|
1848
2200
|
resumeInitialState,
|
|
1849
2201
|
loadOrThrow,
|
|
2202
|
+
ensureQueryIndexes,
|
|
2203
|
+
finalizeBackfill,
|
|
1850
2204
|
dumpEffectCache,
|
|
1851
2205
|
reset,
|
|
1852
2206
|
setChainMeta,
|