envio 3.5.0-alpha.2 → 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/package.json +6 -6
- package/src/BatchProcessing.res +14 -0
- package/src/BatchProcessing.res.mjs +8 -0
- package/src/ChainState.res +10 -4
- package/src/ChainState.res.mjs +7 -6
- package/src/ChainState.resi +1 -1
- package/src/Config.res +2 -2
- package/src/Config.res.mjs +2 -2
- package/src/CrossChainState.res +33 -10
- package/src/CrossChainState.res.mjs +21 -6
- package/src/CrossChainState.resi +3 -0
- package/src/FetchState.res +230 -66
- package/src/FetchState.res.mjs +213 -67
- package/src/FinalizeBackfill.res +34 -0
- package/src/FinalizeBackfill.res.mjs +26 -0
- package/src/InMemoryTable.res +15 -15
- package/src/InMemoryTable.res.mjs +14 -14
- package/src/IndexerState.res +12 -0
- package/src/IndexerState.res.mjs +19 -0
- package/src/IndexerState.resi +3 -0
- package/src/LoadLayer.res +9 -1
- package/src/LoadLayer.res.mjs +1 -0
- package/src/Persistence.res +11 -0
- package/src/PgStorage.res +356 -35
- package/src/PgStorage.res.mjs +237 -24
- package/src/TestIndexer.res +3 -0
- package/src/TestIndexer.res.mjs +2 -0
- package/src/db/EntityFilter.res +0 -4
- package/src/db/EntityFilter.res.mjs +1 -8
- 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 +13 -13
- package/src/db/Table.res.mjs +12 -12
package/src/PgStorage.res.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import * as Performance from "./bindings/Performance.res.mjs";
|
|
|
18
18
|
import * as Persistence from "./Persistence.res.mjs";
|
|
19
19
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
20
20
|
import * as EntityHistory from "./db/EntityHistory.res.mjs";
|
|
21
|
+
import * as IndexRegistry from "./db/IndexRegistry.res.mjs";
|
|
21
22
|
import * as InternalTable from "./db/InternalTable.res.mjs";
|
|
22
23
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
23
24
|
import * as Child_process from "child_process";
|
|
@@ -72,13 +73,87 @@ function makeCreateCompositeIndexQuery(tableName, indexFields, pgSchema) {
|
|
|
72
73
|
return `CREATE INDEX IF NOT EXISTS "` + indexName + `" ON "` + pgSchema + `"."` + tableName + `"(` + index + `);`;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
function
|
|
76
|
+
function makeCreateTableIndexesQuery(table, pgSchema) {
|
|
76
77
|
let tableName = table.tableName;
|
|
77
78
|
let createIndex = indexField => makeCreateIndexQuery(tableName, [indexField], pgSchema);
|
|
78
79
|
let createCompositeIndex = indexFields => makeCreateCompositeIndexQuery(tableName, indexFields, pgSchema);
|
|
79
|
-
let
|
|
80
|
-
let
|
|
81
|
-
return
|
|
80
|
+
let singleIndexes = Table.getSingleIndexes(table);
|
|
81
|
+
let compositeIndexes = Table.getCompositeIndexes(table);
|
|
82
|
+
return singleIndexes.map(createIndex).join("\n") + compositeIndexes.map(createCompositeIndex).join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function schemaIndexDescription(param) {
|
|
86
|
+
return param.tableName + "_" + param.indexFields.map(f => f.fieldName + directionToIndexName(f.direction)).join("_");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function schemaIndexName(schemaIndex) {
|
|
90
|
+
return IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function schemaIndexKey(param) {
|
|
94
|
+
return IndexRegistry.makeKey(param.tableName, IndexRegistry.fromIndexFields(param.indexFields), IndexRegistry.btree);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeCreateSchemaIndexQuery(schemaIndex, pgSchema) {
|
|
98
|
+
let columns = schemaIndex.indexFields.map(f => `"` + f.fieldName + `"` + directionToSql(f.direction)).join(", ");
|
|
99
|
+
return `CREATE INDEX IF NOT EXISTS "` + IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex)) + `" ON "` + pgSchema + `"."` + schemaIndex.tableName + `"(` + columns + `);`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatSeconds(timeRef) {
|
|
103
|
+
return (Math.round(Performance.secondsSince(timeRef) * 100) / 100).toString();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let slowOnLargeDatabaseNotice = "This can take a long time on a large database.";
|
|
107
|
+
|
|
108
|
+
function makeReindexQuery(pgSchema, indexName) {
|
|
109
|
+
return `REINDEX INDEX "` + pgSchema + `"."` + indexName + `";`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function nameCollisionError(indexName, pgSchema) {
|
|
113
|
+
return new Error(`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.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function makeSingleColumnSchemaIndex(tableName, column) {
|
|
117
|
+
return {
|
|
118
|
+
tableName: tableName,
|
|
119
|
+
indexFields: [{
|
|
120
|
+
fieldName: column,
|
|
121
|
+
direction: "Asc"
|
|
122
|
+
}]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getSchemaIndexes(entities) {
|
|
127
|
+
let derivedSchema = Schema.make(entities.map(e => e.table));
|
|
128
|
+
let all = [];
|
|
129
|
+
entities.forEach(param => {
|
|
130
|
+
let table = param.table;
|
|
131
|
+
Table.getSingleIndexes(table).forEach(column => {
|
|
132
|
+
all.push(makeSingleColumnSchemaIndex(table.tableName, column));
|
|
133
|
+
});
|
|
134
|
+
Table.getCompositeIndexes(table).forEach(indexFields => {
|
|
135
|
+
all.push({
|
|
136
|
+
tableName: table.tableName,
|
|
137
|
+
indexFields: indexFields
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
entities.forEach(param => {
|
|
142
|
+
Table.getDerivedFromFields(param.table).forEach(derivedFromField => {
|
|
143
|
+
let column = Utils.unwrapResultExn(Schema.getDerivedFromPgFieldName(derivedSchema, derivedFromField));
|
|
144
|
+
all.push(makeSingleColumnSchemaIndex(derivedFromField.derivedFromEntity, column));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
let seen = new Set();
|
|
148
|
+
return all.filter(schemaIndex => {
|
|
149
|
+
let key = schemaIndexKey(schemaIndex);
|
|
150
|
+
if (seen.has(key)) {
|
|
151
|
+
return false;
|
|
152
|
+
} else {
|
|
153
|
+
seen.add(key);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
82
157
|
}
|
|
83
158
|
|
|
84
159
|
function makeCreateTableQuery(table, pgSchema, isNumericArrayAsText) {
|
|
@@ -169,11 +244,12 @@ function getEntityHistory(entityConfig) {
|
|
|
169
244
|
return cache$1;
|
|
170
245
|
}
|
|
171
246
|
|
|
172
|
-
function makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigsOpt, entitiesOpt, enumsOpt, isEmptyPgSchemaOpt) {
|
|
247
|
+
function makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigsOpt, entitiesOpt, enumsOpt, isEmptyPgSchemaOpt, deferSchemaIndexesOpt) {
|
|
173
248
|
let chainConfigs = chainConfigsOpt !== undefined ? chainConfigsOpt : [];
|
|
174
249
|
let entities = entitiesOpt !== undefined ? entitiesOpt : [];
|
|
175
250
|
let enums = enumsOpt !== undefined ? enumsOpt : [];
|
|
176
251
|
let isEmptyPgSchema = isEmptyPgSchemaOpt !== undefined ? isEmptyPgSchemaOpt : false;
|
|
252
|
+
let deferSchemaIndexes = deferSchemaIndexesOpt !== undefined ? deferSchemaIndexesOpt : false;
|
|
177
253
|
let generalTables = [
|
|
178
254
|
InternalTable.Chains.table,
|
|
179
255
|
InternalTable.EnvioInfo.table,
|
|
@@ -181,13 +257,12 @@ function makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfi
|
|
|
181
257
|
InternalTable.RawEvents.table
|
|
182
258
|
];
|
|
183
259
|
let allTables = generalTables.slice();
|
|
184
|
-
let allEntityTables = [];
|
|
185
260
|
entities.forEach(entityConfig => {
|
|
186
|
-
allEntityTables.push(entityConfig.table);
|
|
187
261
|
allTables.push(entityConfig.table);
|
|
188
262
|
allTables.push(getEntityHistory(entityConfig).table);
|
|
189
263
|
});
|
|
190
|
-
let
|
|
264
|
+
let schemaIndexes = getSchemaIndexes(entities);
|
|
265
|
+
IndexRegistry.validateIndexNamesOrThrow(schemaIndexes.map(schemaIndexDescription));
|
|
191
266
|
let query = {
|
|
192
267
|
contents: (
|
|
193
268
|
isEmptyPgSchema && pgSchema === "public" ? `CREATE SCHEMA IF NOT EXISTS "` + pgSchema + `";\n` : `DROP SCHEMA IF EXISTS "` + pgSchema + `" CASCADE;
|
|
@@ -202,19 +277,11 @@ GRANT ALL ON SCHEMA "` + pgSchema + `" TO public;`)
|
|
|
202
277
|
allTables.forEach(table => {
|
|
203
278
|
query.contents = query.contents + "\n" + makeCreateTableQuery(table, pgSchema, isHasuraEnabled);
|
|
204
279
|
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
query.contents = query.contents + "\n" + indices;
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
});
|
|
212
|
-
entities.forEach(entity => {
|
|
213
|
-
Table.getDerivedFromFields(entity.table).forEach(derivedFromField => {
|
|
214
|
-
let indexField = Utils.unwrapResultExn(Schema.getDerivedFromPgFieldName(derivedSchema, derivedFromField));
|
|
215
|
-
query.contents = query.contents + "\n" + makeCreateIndexQuery(derivedFromField.derivedFromEntity, [indexField], pgSchema);
|
|
280
|
+
if (!deferSchemaIndexes) {
|
|
281
|
+
schemaIndexes.forEach(schemaIndex => {
|
|
282
|
+
query.contents = query.contents + "\n" + makeCreateSchemaIndexQuery(schemaIndex, pgSchema);
|
|
216
283
|
});
|
|
217
|
-
}
|
|
284
|
+
}
|
|
218
285
|
query.contents = query.contents + "\n" + InternalTable.Views.makeMetaViewQuery(pgSchema);
|
|
219
286
|
query.contents = query.contents + "\n" + InternalTable.Views.makeChainMetadataViewQuery(pgSchema);
|
|
220
287
|
let initialChainsValuesQuery = InternalTable.Chains.makeInitialValuesQuery(pgSchema, chainConfigs);
|
|
@@ -850,6 +917,23 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
|
|
|
850
917
|
env: psqlExecOptions_env
|
|
851
918
|
};
|
|
852
919
|
let cacheDirPath = Path.resolve(".envio", "cache");
|
|
920
|
+
let storageName = "postgres";
|
|
921
|
+
let indexRegistry = IndexRegistry.make();
|
|
922
|
+
let reloadIndexRegistry = async () => {
|
|
923
|
+
let rows = S$RescriptSchema.parseOrThrow(await sql.unsafe(IndexRegistry.makeCatalogQuery(pgSchema)), IndexRegistry.catalogRowsSchema);
|
|
924
|
+
let invalidIndexNames = IndexRegistry.reload(indexRegistry, rows);
|
|
925
|
+
if (invalidIndexNames.length !== 0) {
|
|
926
|
+
Logging.warn({
|
|
927
|
+
storage: storageName,
|
|
928
|
+
msg: `Ignoring invalid PostgreSQL indexes in schema "` + pgSchema + `". They are left untouched — drop and recreate them manually if you need them.`,
|
|
929
|
+
indexes: invalidIndexNames
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
return Logging.info({
|
|
933
|
+
storage: storageName,
|
|
934
|
+
msg: `Found ` + IndexRegistry.size(indexRegistry).toString() + ` existing indexes in the indexer schema.`
|
|
935
|
+
});
|
|
936
|
+
};
|
|
853
937
|
let isInitialized = async () => Utils.$$Array.notEmpty(await sql.unsafe(`SELECT table_schema FROM information_schema.tables WHERE table_schema = '` + pgSchema + `' AND (table_name = '` + "event_sync_state" + `' OR table_name = '` + InternalTable.Chains.table.tableName + `');`));
|
|
854
938
|
let scanCacheDir = async () => {
|
|
855
939
|
let topEntries;
|
|
@@ -969,7 +1053,7 @@ function make(sql, pgHost, pgSchema, pgPort, pgUser, pgDatabase, pgPassword, isH
|
|
|
969
1053
|
if (sink !== undefined) {
|
|
970
1054
|
await sink.initialize(chainConfigs, chEntities, enums);
|
|
971
1055
|
}
|
|
972
|
-
let queries = makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigs, pgEntities, enums, Utils.$$Array.isEmpty(schemaTableNames));
|
|
1056
|
+
let queries = makeInitializeTransaction(pgSchema, pgUser, isHasuraEnabled, chainConfigs, pgEntities, enums, Utils.$$Array.isEmpty(schemaTableNames), true);
|
|
973
1057
|
await sql.begin(async sql => {
|
|
974
1058
|
await Promise.all(queries.map(query => sql.unsafe(query)));
|
|
975
1059
|
return await InternalTable.EnvioInfo.write(sql, pgSchema, envioInfo);
|
|
@@ -995,6 +1079,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
995
1079
|
]);
|
|
996
1080
|
}
|
|
997
1081
|
let cache = await restoreEffectCache(true);
|
|
1082
|
+
await reloadIndexRegistry();
|
|
998
1083
|
if (onInitialize !== undefined) {
|
|
999
1084
|
await onInitialize();
|
|
1000
1085
|
}
|
|
@@ -1045,6 +1130,121 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1045
1130
|
};
|
|
1046
1131
|
}
|
|
1047
1132
|
};
|
|
1133
|
+
let filterColumns = (table, filters) => {
|
|
1134
|
+
let queryFields = Table.queryFields(table);
|
|
1135
|
+
let columns = [];
|
|
1136
|
+
let seen = new Set();
|
|
1137
|
+
let collect = filter => {
|
|
1138
|
+
if (filter.operator === "and") {
|
|
1139
|
+
filter.filters.forEach(collect);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
let match = queryFields[filter.fieldName];
|
|
1143
|
+
if (match === undefined) {
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
let pgDbFieldName = match.pgDbFieldName;
|
|
1147
|
+
if (!seen.has(pgDbFieldName)) {
|
|
1148
|
+
seen.add(pgDbFieldName);
|
|
1149
|
+
columns.push(pgDbFieldName);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
filters.forEach(collect);
|
|
1154
|
+
return columns;
|
|
1155
|
+
};
|
|
1156
|
+
let makeEnsureIndexQuery = schemaIndex => {
|
|
1157
|
+
let indexName = IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex));
|
|
1158
|
+
let match = IndexRegistry.getInvalid(indexRegistry, indexName);
|
|
1159
|
+
if (match === undefined) {
|
|
1160
|
+
return makeCreateSchemaIndexQuery(schemaIndex, pgSchema);
|
|
1161
|
+
}
|
|
1162
|
+
if (match.isRepairable) {
|
|
1163
|
+
if (match.key === schemaIndexKey(schemaIndex)) {
|
|
1164
|
+
return makeReindexQuery(pgSchema, indexName);
|
|
1165
|
+
}
|
|
1166
|
+
throw nameCollisionError(indexName, pgSchema);
|
|
1167
|
+
}
|
|
1168
|
+
throw nameCollisionError(indexName, pgSchema);
|
|
1169
|
+
};
|
|
1170
|
+
let isRepair = schemaIndex => Stdlib_Option.isSome(IndexRegistry.getInvalid(indexRegistry, IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex))));
|
|
1171
|
+
let ensureQueryIndexes = async (table, filters) => {
|
|
1172
|
+
let missing = Stdlib_Array.filterMap(filterColumns(table, filters), column => {
|
|
1173
|
+
let schemaIndex = makeSingleColumnSchemaIndex(table.tableName, column);
|
|
1174
|
+
if (IndexRegistry.has(indexRegistry, schemaIndexKey(schemaIndex))) {
|
|
1175
|
+
return;
|
|
1176
|
+
} else {
|
|
1177
|
+
return schemaIndex;
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
if (Utils.$$Array.notEmpty(missing)) {
|
|
1181
|
+
await Promise.all(missing.map(schemaIndex => {
|
|
1182
|
+
let indexName = IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex));
|
|
1183
|
+
return IndexRegistry.ensure(indexRegistry, schemaIndexKey(schemaIndex), table.tableName, async () => {
|
|
1184
|
+
let query = makeEnsureIndexQuery(schemaIndex);
|
|
1185
|
+
let verb = isRepair(schemaIndex) ? "Repairing invalid index" : "Creating index";
|
|
1186
|
+
Logging.info({
|
|
1187
|
+
storage: storageName,
|
|
1188
|
+
msg: verb + ` "` + indexName + `" to serve a getWhere query on "` + table.tableName + `". Writes to the table are paused until it completes. ` + slowOnLargeDatabaseNotice
|
|
1189
|
+
});
|
|
1190
|
+
let timeRef = Performance.now();
|
|
1191
|
+
await sql.unsafe(query);
|
|
1192
|
+
IndexRegistry.clearInvalidName(indexRegistry, indexName);
|
|
1193
|
+
return Logging.info({
|
|
1194
|
+
storage: storageName,
|
|
1195
|
+
msg: `Index "` + indexName + `" is ready after ` + formatSeconds(timeRef) + `s. Resuming indexing.`
|
|
1196
|
+
});
|
|
1197
|
+
}).catch(exn => Logging.warn({
|
|
1198
|
+
storage: storageName,
|
|
1199
|
+
msg: `Failed to create the index "` + indexName + `" for a getWhere query. The query runs without it.`,
|
|
1200
|
+
err: Utils.prettifyExn(exn)
|
|
1201
|
+
}));
|
|
1202
|
+
}));
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
let finalizeBackfill = async (entities, chainIds, readyAt) => {
|
|
1207
|
+
let schemaIndexes = getSchemaIndexes(entities.filter(e => e.storage.postgres));
|
|
1208
|
+
IndexRegistry.validateIndexNamesOrThrow(schemaIndexes.map(schemaIndexDescription));
|
|
1209
|
+
let missing = schemaIndexes.filter(schemaIndex => !IndexRegistry.has(indexRegistry, schemaIndexKey(schemaIndex)));
|
|
1210
|
+
let repaired = missing.filter(isRepair);
|
|
1211
|
+
if (Utils.$$Array.notEmpty(repaired)) {
|
|
1212
|
+
Logging.warn({
|
|
1213
|
+
storage: storageName,
|
|
1214
|
+
msg: `PostgreSQL reports ` + repaired.length.toString() + ` existing indexes as invalid, so they can't serve queries. Rebuilding them.`,
|
|
1215
|
+
indexes: repaired.map(schemaIndexName)
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
if (missing.length !== 0) {
|
|
1219
|
+
Logging.info({
|
|
1220
|
+
storage: storageName,
|
|
1221
|
+
msg: `Creating the ` + missing.length.toString() + ` remaining schema indexes before the indexer reports ready. Writes are paused until they are committed. ` + slowOnLargeDatabaseNotice,
|
|
1222
|
+
indexes: missing.map(schemaIndexName)
|
|
1223
|
+
});
|
|
1224
|
+
} else {
|
|
1225
|
+
Logging.info({
|
|
1226
|
+
storage: storageName,
|
|
1227
|
+
msg: `All ` + schemaIndexes.length.toString() + ` schema indexes are already in place. Marking the indexer ready.`
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
let indexQueries = missing.map(makeEnsureIndexQuery);
|
|
1231
|
+
let timeRef = Performance.now();
|
|
1232
|
+
await sql.begin(async sql => {
|
|
1233
|
+
for (let idx = 0, idx_finish = indexQueries.length; idx < idx_finish; ++idx) {
|
|
1234
|
+
await sql.unsafe(indexQueries[idx]);
|
|
1235
|
+
}
|
|
1236
|
+
return await sql.unsafe(InternalTable.Chains.makeSetReadyAtQuery(pgSchema), [
|
|
1237
|
+
readyAt,
|
|
1238
|
+
chainIds
|
|
1239
|
+
], {prepare: true});
|
|
1240
|
+
});
|
|
1241
|
+
missing.forEach(schemaIndex => IndexRegistry.add(indexRegistry, schemaIndexKey(schemaIndex)));
|
|
1242
|
+
repaired.forEach(schemaIndex => IndexRegistry.clearInvalidName(indexRegistry, IndexRegistry.truncateIndexName(schemaIndexDescription(schemaIndex))));
|
|
1243
|
+
return Logging.info({
|
|
1244
|
+
storage: storageName,
|
|
1245
|
+
msg: `Committed ` + missing.length.toString() + ` schema indexes and the ready timestamp in ` + formatSeconds(timeRef) + `s.`
|
|
1246
|
+
});
|
|
1247
|
+
};
|
|
1048
1248
|
let setOrThrow$1 = (items, table, itemSchema) => setOrThrow(sql, items, table, itemSchema, pgSchema);
|
|
1049
1249
|
let setEffectCacheOrThrow = async (table, itemSchema, items, initialize) => {
|
|
1050
1250
|
if (initialize) {
|
|
@@ -1121,6 +1321,7 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1121
1321
|
sql.unsafe(InternalTable.Checkpoints.makeGetReorgCheckpointsQuery(pgSchema)),
|
|
1122
1322
|
InternalTable.EnvioInfo.read(sql, pgSchema)
|
|
1123
1323
|
]);
|
|
1324
|
+
await reloadIndexRegistry();
|
|
1124
1325
|
let checkpointId = BigInt(match[2][0].id);
|
|
1125
1326
|
let reorgCheckpoints = match[3].map(raw => ({
|
|
1126
1327
|
id: BigInt(raw.id),
|
|
@@ -1193,15 +1394,17 @@ SELECT id, chain_id, -1, -1, contract_name FROM unnest($1::text[],$2::int[],$3::
|
|
|
1193
1394
|
}
|
|
1194
1395
|
let primaryTimerRef = Performance.now();
|
|
1195
1396
|
await writeBatch(sql, batch, pgSchema, rollback, isInReorgThreshold, config, allEntities, setEffectCacheOrThrow, updatedEffectsCache, pgUpdates, sinkPromise, chainMetaData, undefined);
|
|
1196
|
-
return onWrite(
|
|
1397
|
+
return onWrite(storageName, Performance.secondsSince(primaryTimerRef));
|
|
1197
1398
|
};
|
|
1198
1399
|
let close = () => sql.end();
|
|
1199
1400
|
return {
|
|
1200
|
-
name:
|
|
1401
|
+
name: storageName,
|
|
1201
1402
|
isInitialized: isInitialized,
|
|
1202
1403
|
initialize: initialize,
|
|
1203
1404
|
resumeInitialState: resumeInitialState,
|
|
1204
1405
|
loadOrThrow: loadOrThrow,
|
|
1406
|
+
ensureQueryIndexes: ensureQueryIndexes,
|
|
1407
|
+
finalizeBackfill: finalizeBackfill,
|
|
1205
1408
|
dumpEffectCache: dumpEffectCache,
|
|
1206
1409
|
reset: reset,
|
|
1207
1410
|
setChainMeta: setChainMeta,
|
|
@@ -1271,7 +1474,17 @@ export {
|
|
|
1271
1474
|
directionToSql,
|
|
1272
1475
|
directionToIndexName,
|
|
1273
1476
|
makeCreateCompositeIndexQuery,
|
|
1274
|
-
|
|
1477
|
+
makeCreateTableIndexesQuery,
|
|
1478
|
+
schemaIndexDescription,
|
|
1479
|
+
schemaIndexName,
|
|
1480
|
+
schemaIndexKey,
|
|
1481
|
+
makeCreateSchemaIndexQuery,
|
|
1482
|
+
formatSeconds,
|
|
1483
|
+
slowOnLargeDatabaseNotice,
|
|
1484
|
+
makeReindexQuery,
|
|
1485
|
+
nameCollisionError,
|
|
1486
|
+
makeSingleColumnSchemaIndex,
|
|
1487
|
+
getSchemaIndexes,
|
|
1275
1488
|
makeCreateTableQuery,
|
|
1276
1489
|
entityHistoryCache,
|
|
1277
1490
|
getEntityHistory,
|
package/src/TestIndexer.res
CHANGED
|
@@ -485,6 +485,9 @@ let makeInMemoryStorage = (~state: testIndexerState): Persistence.storage => {
|
|
|
485
485
|
state
|
|
486
486
|
->handleLoad(~tableName=table.tableName, ~filter)
|
|
487
487
|
->(Utils.magic: array<Internal.entity> => array<unknown>),
|
|
488
|
+
// The in-memory storage has no indexes to build, and it's always ready.
|
|
489
|
+
ensureQueryIndexes: async (~table as _, ~filters as _) => (),
|
|
490
|
+
finalizeBackfill: async (~entities as _, ~chainIds as _, ~readyAt as _) => (),
|
|
488
491
|
writeBatch: async (
|
|
489
492
|
~batch,
|
|
490
493
|
~rollback as _,
|
package/src/TestIndexer.res.mjs
CHANGED
|
@@ -315,6 +315,8 @@ function makeInMemoryStorage(state) {
|
|
|
315
315
|
initialize: async (param, param$1, param$2, param$3) => Stdlib_JsError.throwWithMessage("TestIndexer: initialize should not be called; the initial state is derived from config."),
|
|
316
316
|
resumeInitialState: async () => Stdlib_JsError.throwWithMessage("TestIndexer: resumeInitialState should not be called; the initial state is derived from config."),
|
|
317
317
|
loadOrThrow: async (filter, table) => handleLoad(state, table.tableName, filter),
|
|
318
|
+
ensureQueryIndexes: async (param, param$1) => {},
|
|
319
|
+
finalizeBackfill: async (param, param$1, param$2) => {},
|
|
318
320
|
dumpEffectCache: async () => {},
|
|
319
321
|
reset: async () => {},
|
|
320
322
|
setChainMeta: async param => {},
|
package/src/db/EntityFilter.res
CHANGED
|
@@ -176,10 +176,6 @@ let parseGetWhereOrThrow = (filter: dict<dict<unknown>>, ~entityName, ~table: Ta
|
|
|
176
176
|
JsError.throwWithMessage(
|
|
177
177
|
`The field "${apiFieldName}" on entity "${entityName}" is a derived field and cannot be used in getWhere(). Use the source entity's indexed field instead.`,
|
|
178
178
|
)
|
|
179
|
-
| Some(Field({isPrimaryKey: false, isIndex: false, linkedEntity: None})) =>
|
|
180
|
-
JsError.throwWithMessage(
|
|
181
|
-
`The field "${apiFieldName}" on entity "${entityName}" does not have an index. To use it in getWhere(), add the @index directive in your schema.graphql:\n\n ${apiFieldName}: ... @index\n\nThen run 'pnpm envio codegen' to regenerate.`,
|
|
182
|
-
)
|
|
183
179
|
| Some(Field(_)) => ()
|
|
184
180
|
}
|
|
185
181
|
|
|
@@ -142,14 +142,7 @@ function parseGetWhereOrThrow(filter, entityName, table) {
|
|
|
142
142
|
});
|
|
143
143
|
let match = Table.getFieldByApiName(table, apiFieldName);
|
|
144
144
|
if (match !== undefined) {
|
|
145
|
-
if (match.TAG
|
|
146
|
-
let match$1 = match._0;
|
|
147
|
-
if (match$1.isPrimaryKey || match$1.isIndex || match$1.linkedEntity !== undefined) {
|
|
148
|
-
|
|
149
|
-
} else {
|
|
150
|
-
Stdlib_JsError.throwWithMessage(`The field "` + apiFieldName + `" on entity "` + entityName + `" does not have an index. To use it in getWhere(), add the @index directive in your schema.graphql:\n\n ` + apiFieldName + `: ... @index\n\nThen run 'pnpm envio codegen' to regenerate.`);
|
|
151
|
-
}
|
|
152
|
-
} else {
|
|
145
|
+
if (match.TAG !== "Field") {
|
|
153
146
|
Stdlib_JsError.throwWithMessage(`The field "` + apiFieldName + `" on entity "` + entityName + `" is a derived field and cannot be used in getWhere(). Use the source entity's indexed field instead.`);
|
|
154
147
|
}
|
|
155
148
|
} else {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// An in-memory mirror of the indexes PostgreSQL holds for the indexer schema.
|
|
2
|
+
// Postgres stays authoritative: the registry is loaded from pg_catalog at
|
|
3
|
+
// startup/resume and afterwards only grows — optimistically, right after a
|
|
4
|
+
// CREATE INDEX we issued succeeds. Only one Indexer instance writes to the
|
|
5
|
+
// schema, so no catalog refresh is needed between those two points.
|
|
6
|
+
|
|
7
|
+
let btree = "btree"
|
|
8
|
+
|
|
9
|
+
type column = {
|
|
10
|
+
name: string,
|
|
11
|
+
direction: Table.indexFieldDirection,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let fromIndexFields = (indexFields: array<Table.compositeIndexField>) =>
|
|
15
|
+
indexFields->Array.map(({fieldName, direction}) => {name: fieldName, direction})
|
|
16
|
+
|
|
17
|
+
// Identity of an index is table + ordered columns + direction + method. Names
|
|
18
|
+
// are deliberately not part of it: the catalog is matched on what the index
|
|
19
|
+
// actually covers, not on how it was spelled.
|
|
20
|
+
let makeKey = (~tableName, ~columns: array<column>, ~method) =>
|
|
21
|
+
`${tableName}|${method}|${columns
|
|
22
|
+
->Array.map(({name, direction}) =>
|
|
23
|
+
switch direction {
|
|
24
|
+
| Table.Asc => name
|
|
25
|
+
| Desc => name ++ " DESC"
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
->Array.joinUnsafe(",")}`
|
|
29
|
+
|
|
30
|
+
type catalogRow = {
|
|
31
|
+
tableName: string,
|
|
32
|
+
indexName: string,
|
|
33
|
+
method: string,
|
|
34
|
+
// 1/0 rather than a bool: postgres.js hands booleans back inconsistently
|
|
35
|
+
// depending on the driver's type resolution, so the query casts it.
|
|
36
|
+
isValid: int,
|
|
37
|
+
// 1 only for an index the indexer could have created: plain btree over plain
|
|
38
|
+
// columns. A unique or partial index carries a definition our key doesn't
|
|
39
|
+
// capture, so it can never be one of ours to rebuild.
|
|
40
|
+
isPlain: int,
|
|
41
|
+
columns: array<string>,
|
|
42
|
+
// "ASC"/"DESC" per column, in the same order as `columns`.
|
|
43
|
+
directions: array<string>,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let catalogRowsSchema = S.array(
|
|
47
|
+
S.object((s): catalogRow => {
|
|
48
|
+
tableName: s.field("tableName", S.string),
|
|
49
|
+
indexName: s.field("indexName", S.string),
|
|
50
|
+
method: s.field("method", S.string),
|
|
51
|
+
isValid: s.field("isValid", S.int),
|
|
52
|
+
isPlain: s.field("isPlain", S.int),
|
|
53
|
+
columns: s.field("columns", S.array(S.string)),
|
|
54
|
+
directions: s.field("directions", S.array(S.string)),
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// One row per index, with its key columns aggregated in ordinal order.
|
|
59
|
+
// INCLUDE columns are excluded (they don't affect what the index can serve),
|
|
60
|
+
// and expression columns come back as their printed definition so an
|
|
61
|
+
// expression index can never be mistaken for a plain-column one.
|
|
62
|
+
let makeCatalogQuery = (~pgSchema) =>
|
|
63
|
+
`SELECT
|
|
64
|
+
t.relname AS "tableName",
|
|
65
|
+
i.relname AS "indexName",
|
|
66
|
+
am.amname AS "method",
|
|
67
|
+
CASE WHEN ix.indisvalid AND ix.indisready THEN 1 ELSE 0 END AS "isValid",
|
|
68
|
+
CASE
|
|
69
|
+
WHEN ix.indisunique OR ix.indpred IS NOT NULL OR ix.indexprs IS NOT NULL THEN 0
|
|
70
|
+
ELSE 1
|
|
71
|
+
END AS "isPlain",
|
|
72
|
+
array_agg(
|
|
73
|
+
CASE WHEN k.attnum = 0
|
|
74
|
+
THEN pg_get_indexdef(ix.indexrelid, k.ord::int, true)
|
|
75
|
+
ELSE a.attname
|
|
76
|
+
END ORDER BY k.ord
|
|
77
|
+
) AS "columns",
|
|
78
|
+
array_agg(
|
|
79
|
+
CASE WHEN (ix.indoption[k.ord - 1] & 1) = 1 THEN 'DESC' ELSE 'ASC' END ORDER BY k.ord
|
|
80
|
+
) AS "directions"
|
|
81
|
+
FROM pg_index ix
|
|
82
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
83
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
84
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
85
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
86
|
+
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) ON k.ord <= ix.indnkeyatts
|
|
87
|
+
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
88
|
+
WHERE n.nspname = '${pgSchema}'
|
|
89
|
+
GROUP BY t.relname, i.relname, am.amname, ix.indisvalid, ix.indisready,
|
|
90
|
+
ix.indisunique, ix.indpred, ix.indexprs;`
|
|
91
|
+
|
|
92
|
+
// What we know about an index that came back invalid, enough to decide whether
|
|
93
|
+
// rebuilding it would produce the index we actually wanted.
|
|
94
|
+
type invalidIndex = {
|
|
95
|
+
key: string,
|
|
96
|
+
isRepairable: bool,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type t = {
|
|
100
|
+
keys: Utils.Set.t<string>,
|
|
101
|
+
// Every index Postgres reports as invalid, by name. Kept because
|
|
102
|
+
// `CREATE INDEX IF NOT EXISTS` matches on name: with an invalid index already
|
|
103
|
+
// under the name we want, the DDL is a silent no-op and we'd register a key
|
|
104
|
+
// for something the planner refuses to use.
|
|
105
|
+
invalidByName: dict<invalidIndex>,
|
|
106
|
+
// Keyed by index key so identical concurrent requests await one build.
|
|
107
|
+
inflight: dict<promise<unit>>,
|
|
108
|
+
// Tail of the build chain per table, so two different indexes on the same
|
|
109
|
+
// table are built one after the other rather than at once.
|
|
110
|
+
tableQueue: dict<promise<unit>>,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let make = () => {
|
|
114
|
+
keys: Utils.Set.make(),
|
|
115
|
+
invalidByName: Dict.make(),
|
|
116
|
+
inflight: Dict.make(),
|
|
117
|
+
tableQueue: Dict.make(),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let has = (registry, key) => registry.keys->Utils.Set.has(key)
|
|
121
|
+
|
|
122
|
+
let add = (registry, key) => registry.keys->Utils.Set.add(key)->ignore
|
|
123
|
+
|
|
124
|
+
let size = registry => registry.keys->Utils.Set.size
|
|
125
|
+
|
|
126
|
+
let toArray = registry => registry.keys->Utils.Set.toArray->Array.toSorted(String.compare)
|
|
127
|
+
|
|
128
|
+
// A plain CREATE INDEX either commits or leaves nothing behind, so an index can
|
|
129
|
+
// only be invalid because something before us left it that way — the startup
|
|
130
|
+
// snapshot stays accurate until the next restart.
|
|
131
|
+
let getInvalid = (registry, name) => registry.invalidByName->Utils.Dict.dangerouslyGetNonOption(name)
|
|
132
|
+
|
|
133
|
+
let clearInvalidName = (registry, name) => registry.invalidByName->Utils.Dict.deleteInPlace(name)
|
|
134
|
+
|
|
135
|
+
// Replaces the whole registry with what the catalog reports. Returns the names
|
|
136
|
+
// of indexes Postgres flagged invalid (eg a CREATE INDEX CONCURRENTLY that died
|
|
137
|
+
// midway). They stay out of the registry, so the next request that needs one
|
|
138
|
+
// repairs it.
|
|
139
|
+
let reload = (registry, ~rows: array<catalogRow>) => {
|
|
140
|
+
registry.keys->Utils.Set.clear
|
|
141
|
+
registry.invalidByName
|
|
142
|
+
->Dict.keysToArray
|
|
143
|
+
->Array.forEach(name => registry.invalidByName->Utils.Dict.deleteInPlace(name))
|
|
144
|
+
let invalidIndexNames = []
|
|
145
|
+
rows->Array.forEach(row => {
|
|
146
|
+
let key = makeKey(
|
|
147
|
+
~tableName=row.tableName,
|
|
148
|
+
~columns=row.columns->Array.mapWithIndex((name, idx) => {
|
|
149
|
+
name,
|
|
150
|
+
direction: switch row.directions->Array.get(idx) {
|
|
151
|
+
| Some("DESC") => Table.Desc
|
|
152
|
+
| _ => Asc
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
~method=row.method,
|
|
156
|
+
)
|
|
157
|
+
if row.isValid === 0 {
|
|
158
|
+
invalidIndexNames->Array.push(row.indexName)->ignore
|
|
159
|
+
registry.invalidByName->Dict.set(row.indexName, {key, isRepairable: row.isPlain === 1})
|
|
160
|
+
} else {
|
|
161
|
+
registry->add(key)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
invalidIndexNames
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Runs `build` unless the index is already registered. The key is added only
|
|
168
|
+
// after `build` resolves, so a failed build leaves the registry untouched and
|
|
169
|
+
// the next request retries.
|
|
170
|
+
let ensure = (registry, ~key, ~tableName, ~build) =>
|
|
171
|
+
if registry->has(key) {
|
|
172
|
+
Promise.resolve()
|
|
173
|
+
} else {
|
|
174
|
+
switch registry.inflight->Utils.Dict.dangerouslyGetNonOption(key) {
|
|
175
|
+
| Some(promise) => promise
|
|
176
|
+
| None =>
|
|
177
|
+
let tail = switch registry.tableQueue->Utils.Dict.dangerouslyGetNonOption(tableName) {
|
|
178
|
+
| Some(tail) => tail
|
|
179
|
+
| None => Promise.resolve()
|
|
180
|
+
}
|
|
181
|
+
let promise =
|
|
182
|
+
tail
|
|
183
|
+
// The queued build may have been satisfied while waiting behind another
|
|
184
|
+
// one on the same table (eg a finalize pass created it).
|
|
185
|
+
->Promise.then(() => registry->has(key) ? Promise.resolve() : build())
|
|
186
|
+
->Promise.thenResolve(() => registry->add(key))
|
|
187
|
+
registry.inflight->Dict.set(key, promise)
|
|
188
|
+
registry.tableQueue->Dict.set(tableName, promise->Utils.Promise.silentCatch)
|
|
189
|
+
promise->Promise.finally(() => registry.inflight->Utils.Dict.deleteInPlace(key))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let pgMaxIdentifierLength = 63
|
|
194
|
+
|
|
195
|
+
// Postgres truncates identifiers past 63 characters on its own. Doing it here
|
|
196
|
+
// instead keeps the name we emit, log and read back from the catalog identical
|
|
197
|
+
// to the one it stores. Descriptions are ASCII (GraphQL names are), so
|
|
198
|
+
// characters and Postgres' byte limit line up.
|
|
199
|
+
let truncateIndexName = description =>
|
|
200
|
+
description->String.length > pgMaxIdentifierLength
|
|
201
|
+
? description->String.slice(~start=0, ~end=pgMaxIdentifierLength)
|
|
202
|
+
: description
|
|
203
|
+
|
|
204
|
+
// Two distinct descriptions can collapse onto one truncated name — and the
|
|
205
|
+
// second `CREATE INDEX IF NOT EXISTS` would then be skipped without a trace,
|
|
206
|
+
// leaving the registry claiming an index that doesn't exist.
|
|
207
|
+
let validateIndexNamesOrThrow = (descriptions: array<string>) => {
|
|
208
|
+
let byName = Dict.make()
|
|
209
|
+
descriptions->Array.forEach(description => {
|
|
210
|
+
let name = description->truncateIndexName
|
|
211
|
+
switch byName->Utils.Dict.dangerouslyGetNonOption(name) {
|
|
212
|
+
| Some(existing) if existing !== description =>
|
|
213
|
+
JsError.throwWithMessage(
|
|
214
|
+
`Index names "${existing}" and "${description}" both truncate to "${name}" at PostgreSQL's ${pgMaxIdentifierLength->Int.toString}-character identifier limit. Rename a field or an entity so the generated index names stay distinct.`,
|
|
215
|
+
)
|
|
216
|
+
| _ => byName->Dict.set(name, description)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}
|