envio 2.31.0-alpha.0 → 2.31.0-alpha.2
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 +5 -5
- package/src/Batch.res +400 -28
- package/src/Batch.res.js +286 -24
- package/src/EventRegister.res +9 -3
- package/src/EventRegister.res.js +6 -3
- package/src/EventRegister.resi +4 -1
- package/src/FetchState.res +116 -155
- package/src/FetchState.res.js +116 -106
- package/src/Internal.res +49 -0
- package/src/InternalConfig.res +1 -1
- package/src/Persistence.res +16 -1
- package/src/Persistence.res.js +1 -1
- package/src/PgStorage.res +49 -61
- package/src/PgStorage.res.js +44 -37
- package/src/Prometheus.res +7 -1
- package/src/Prometheus.res.js +8 -1
- package/src/ReorgDetection.res +222 -235
- package/src/ReorgDetection.res.js +34 -28
- package/src/SafeCheckpointTracking.res +132 -0
- package/src/SafeCheckpointTracking.res.js +95 -0
- package/src/Utils.res +64 -21
- package/src/Utils.res.js +61 -30
- package/src/db/EntityHistory.res +172 -294
- package/src/db/EntityHistory.res.js +98 -218
- package/src/db/InternalTable.gen.ts +13 -13
- package/src/db/InternalTable.res +286 -77
- package/src/db/InternalTable.res.js +160 -79
- package/src/db/Table.res +1 -0
- package/src/db/Table.res.js +1 -1
- package/src/sources/EventRouter.res +1 -1
- package/src/sources/Source.res +1 -1
package/src/db/EntityHistory.res
CHANGED
|
@@ -3,183 +3,48 @@ open Table
|
|
|
3
3
|
module RowAction = {
|
|
4
4
|
type t = SET | DELETE
|
|
5
5
|
let variants = [SET, DELETE]
|
|
6
|
-
let name = "
|
|
6
|
+
let name = "ENVIO_HISTORY_CHANGE"
|
|
7
7
|
let schema = S.enum(variants)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
type
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
block_number: 'a,
|
|
14
|
-
log_index: 'a,
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
type historyFields = historyFieldsGeneral<int>
|
|
10
|
+
type entityUpdateAction<'entityType> =
|
|
11
|
+
| Set('entityType)
|
|
12
|
+
| Delete
|
|
18
13
|
|
|
19
|
-
type
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type historyRow<'entity> = {
|
|
24
|
-
current: historyFields,
|
|
25
|
-
previous: option<historyFields>,
|
|
26
|
-
entityData: entityData<'entity>,
|
|
27
|
-
// In the event of a rollback, some entity updates may have been
|
|
28
|
-
// been affected by a rollback diff. If there was no rollback diff
|
|
29
|
-
// this will always be false.
|
|
30
|
-
// If there was a rollback diff, this will be false in the case of a
|
|
31
|
-
// new entity update (where entity affected is not present in the diff) b
|
|
32
|
-
// but true if the update is related to an entity that is
|
|
33
|
-
// currently present in the diff
|
|
34
|
-
// Optional since it's discarded during parsing/serialization
|
|
35
|
-
containsRollbackDiffChange?: bool,
|
|
14
|
+
type entityUpdate<'entityType> = {
|
|
15
|
+
entityId: string,
|
|
16
|
+
entityUpdateAction: entityUpdateAction<'entityType>,
|
|
17
|
+
checkpointId: int,
|
|
36
18
|
}
|
|
37
19
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let previousHistoryFieldsSchema = S.object(s => {
|
|
42
|
-
chain_id: s.field("previous_entity_history_chain_id", S.null(S.int)),
|
|
43
|
-
block_timestamp: s.field("previous_entity_history_block_timestamp", S.null(S.int)),
|
|
44
|
-
block_number: s.field("previous_entity_history_block_number", S.null(S.int)),
|
|
45
|
-
log_index: s.field("previous_entity_history_log_index", S.null(S.int)),
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
let currentHistoryFieldsSchema = S.object(s => {
|
|
49
|
-
chain_id: s.field("entity_history_chain_id", S.int),
|
|
50
|
-
block_timestamp: s.field("entity_history_block_timestamp", S.int),
|
|
51
|
-
block_number: s.field("entity_history_block_number", S.int),
|
|
52
|
-
log_index: s.field("entity_history_log_index", S.int),
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
let makeHistoryRowSchema: S.t<'entity> => S.t<historyRow<'entity>> = entitySchema => {
|
|
56
|
-
//Maps a schema object for the given entity with all fields nullable except for the id field
|
|
57
|
-
//Keeps any original nullable fields
|
|
58
|
-
let nullableEntitySchema: S.t<Js.Dict.t<unknown>> = S.schema(s =>
|
|
59
|
-
switch entitySchema->S.classify {
|
|
60
|
-
| Object({items}) =>
|
|
61
|
-
let nulldict = Js.Dict.empty()
|
|
62
|
-
items->Belt.Array.forEach(({location, schema}) => {
|
|
63
|
-
let nullableFieldSchema = switch (location, schema->S.classify) {
|
|
64
|
-
| ("id", _)
|
|
65
|
-
| (_, Null(_)) => schema //TODO double check this works for array types
|
|
66
|
-
| _ => S.null(schema)->S.toUnknown
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
nulldict->Js.Dict.set(location, s.matches(nullableFieldSchema))
|
|
70
|
-
})
|
|
71
|
-
nulldict
|
|
72
|
-
| _ =>
|
|
73
|
-
Js.Exn.raiseError(
|
|
74
|
-
"Failed creating nullableEntitySchema. Expected an object schema for entity",
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
let previousWithNullFields = {
|
|
80
|
-
chain_id: None,
|
|
81
|
-
block_timestamp: None,
|
|
82
|
-
block_number: None,
|
|
83
|
-
log_index: None,
|
|
84
|
-
}
|
|
20
|
+
// Prefix with envio_ to avoid colleasions
|
|
21
|
+
let changeFieldName = "envio_change"
|
|
22
|
+
let checkpointIdFieldName = "checkpoint_id"
|
|
85
23
|
|
|
24
|
+
let makeSetUpdateSchema: S.t<'entity> => S.t<entityUpdate<'entity>> = entitySchema => {
|
|
86
25
|
S.object(s => {
|
|
26
|
+
s.tag(changeFieldName, RowAction.SET)
|
|
87
27
|
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"action": s.field("action", RowAction.schema),
|
|
28
|
+
checkpointId: s.field(checkpointIdFieldName, S.int),
|
|
29
|
+
entityId: s.field("id", S.string),
|
|
30
|
+
entityUpdateAction: Set(s.flatten(entitySchema)),
|
|
92
31
|
}
|
|
93
|
-
})->S.transform(s => {
|
|
94
|
-
parser: v => {
|
|
95
|
-
current: v["current"],
|
|
96
|
-
previous: switch v["previous"] {
|
|
97
|
-
| {
|
|
98
|
-
chain_id: Some(chain_id),
|
|
99
|
-
block_timestamp: Some(block_timestamp),
|
|
100
|
-
block_number: Some(block_number),
|
|
101
|
-
log_index: Some(log_index),
|
|
102
|
-
} =>
|
|
103
|
-
Some({
|
|
104
|
-
chain_id,
|
|
105
|
-
block_timestamp,
|
|
106
|
-
block_number,
|
|
107
|
-
log_index,
|
|
108
|
-
})
|
|
109
|
-
| {chain_id: None, block_timestamp: None, block_number: None, log_index: None} => None
|
|
110
|
-
| _ => s.fail("Unexpected mix of null and non-null values in previous history fields")
|
|
111
|
-
},
|
|
112
|
-
entityData: switch v["action"] {
|
|
113
|
-
| SET => v["entityData"]->(Utils.magic: Js.Dict.t<unknown> => 'entity)->Set
|
|
114
|
-
| DELETE =>
|
|
115
|
-
let {id} = v["entityData"]->(Utils.magic: Js.Dict.t<unknown> => entityIdOnly)
|
|
116
|
-
Delete({id: id})
|
|
117
|
-
},
|
|
118
|
-
},
|
|
119
|
-
serializer: v => {
|
|
120
|
-
let (entityData, action) = switch v.entityData {
|
|
121
|
-
| Set(entityData) => (entityData->(Utils.magic: 'entity => Js.Dict.t<unknown>), RowAction.SET)
|
|
122
|
-
| Delete(entityIdOnly) => (
|
|
123
|
-
entityIdOnly->(Utils.magic: entityIdOnly => Js.Dict.t<unknown>),
|
|
124
|
-
DELETE,
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
{
|
|
129
|
-
"current": v.current,
|
|
130
|
-
"entityData": entityData,
|
|
131
|
-
"action": action,
|
|
132
|
-
"previous": switch v.previous {
|
|
133
|
-
| Some(historyFields) =>
|
|
134
|
-
historyFields->(Utils.magic: historyFields => previousHistoryFields) //Cast to previousHistoryFields (with "Some" field values)
|
|
135
|
-
| None => previousWithNullFields
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
32
|
})
|
|
140
33
|
}
|
|
141
34
|
|
|
142
35
|
type t<'entity> = {
|
|
143
36
|
table: table,
|
|
144
|
-
|
|
145
|
-
schema: S.t<historyRow<'entity>>,
|
|
37
|
+
setUpdateSchema: S.t<entityUpdate<'entity>>,
|
|
146
38
|
// Used for parsing
|
|
147
|
-
|
|
148
|
-
|
|
39
|
+
setUpdateSchemaRows: S.t<array<entityUpdate<'entity>>>,
|
|
40
|
+
makeInsertDeleteUpdatesQuery: (~pgSchema: string) => string,
|
|
41
|
+
makeGetRollbackRemovedIdsQuery: (~pgSchema: string) => string,
|
|
42
|
+
makeGetRollbackRestoredEntitiesQuery: (~pgSchema: string) => string,
|
|
149
43
|
}
|
|
150
44
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
external castInternal: t<'entity> => t<entityInternal> = "%identity"
|
|
154
|
-
external eval: string => 'a = "eval"
|
|
45
|
+
let historyTableName = (~entityName) => "envio_history_" ++ entityName
|
|
155
46
|
|
|
156
47
|
let fromTable = (table: table, ~schema: S.t<'entity>): t<'entity> => {
|
|
157
|
-
let entity_history_block_timestamp = "entity_history_block_timestamp"
|
|
158
|
-
let entity_history_chain_id = "entity_history_chain_id"
|
|
159
|
-
let entity_history_block_number = "entity_history_block_number"
|
|
160
|
-
let entity_history_log_index = "entity_history_log_index"
|
|
161
|
-
|
|
162
|
-
//NB: Ordered by hirarchy of event ordering
|
|
163
|
-
let currentChangeFieldNames = [
|
|
164
|
-
entity_history_block_timestamp,
|
|
165
|
-
entity_history_chain_id,
|
|
166
|
-
entity_history_block_number,
|
|
167
|
-
entity_history_log_index,
|
|
168
|
-
]
|
|
169
|
-
|
|
170
|
-
let currentHistoryFields =
|
|
171
|
-
currentChangeFieldNames->Belt.Array.map(fieldName =>
|
|
172
|
-
mkField(fieldName, Integer, ~fieldSchema=S.never, ~isPrimaryKey=true)
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
let previousChangeFieldNames =
|
|
176
|
-
currentChangeFieldNames->Belt.Array.map(fieldName => "previous_" ++ fieldName)
|
|
177
|
-
|
|
178
|
-
let previousHistoryFields =
|
|
179
|
-
previousChangeFieldNames->Belt.Array.map(fieldName =>
|
|
180
|
-
mkField(fieldName, Integer, ~fieldSchema=S.never, ~isNullable=true)
|
|
181
|
-
)
|
|
182
|
-
|
|
183
48
|
let id = "id"
|
|
184
49
|
|
|
185
50
|
let dataFields = table.fields->Belt.Array.keepMap(field =>
|
|
@@ -202,118 +67,97 @@ let fromTable = (table: table, ~schema: S.t<'entity>): t<'entity> => {
|
|
|
202
67
|
}
|
|
203
68
|
)
|
|
204
69
|
|
|
205
|
-
let
|
|
206
|
-
|
|
207
|
-
let actionField = mkField(actionFieldName, Custom(RowAction.name), ~fieldSchema=S.never)
|
|
70
|
+
let actionField = mkField(changeFieldName, Custom(RowAction.name), ~fieldSchema=S.never)
|
|
208
71
|
|
|
209
|
-
let
|
|
72
|
+
let checkpointIdField = mkField(
|
|
73
|
+
checkpointIdFieldName,
|
|
74
|
+
Integer,
|
|
75
|
+
~fieldSchema=S.int,
|
|
76
|
+
~isPrimaryKey=true,
|
|
77
|
+
)
|
|
210
78
|
|
|
211
|
-
let dataFieldNames = dataFields->Belt.Array.map(field => field->getFieldName)
|
|
79
|
+
// let dataFieldNames = dataFields->Belt.Array.map(field => field->getFieldName)
|
|
212
80
|
|
|
213
|
-
let
|
|
214
|
-
let historyTableName =
|
|
81
|
+
let entityTableName = table.tableName
|
|
82
|
+
let historyTableName = historyTableName(~entityName=entityTableName)
|
|
215
83
|
//ignore composite indices
|
|
216
84
|
let table = mkTable(
|
|
217
85
|
historyTableName,
|
|
218
|
-
~fields=Belt.Array.
|
|
219
|
-
currentHistoryFields,
|
|
220
|
-
previousHistoryFields,
|
|
221
|
-
dataFields,
|
|
222
|
-
[actionField, serialField],
|
|
223
|
-
]),
|
|
86
|
+
~fields=dataFields->Belt.Array.concat([checkpointIdField, actionField]),
|
|
224
87
|
)
|
|
225
88
|
|
|
226
|
-
let
|
|
227
|
-
|
|
228
|
-
let
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
let dataFieldNamesCommaSeparated = dataFieldNamesDoubleQuoted->Js.Array2.joinWith(", ")
|
|
250
|
-
|
|
251
|
-
`CREATE OR REPLACE FUNCTION ${insertFnName}(${historyRowArg} ${historyTablePath}, should_copy_current_entity BOOLEAN)
|
|
252
|
-
RETURNS void AS $$
|
|
253
|
-
DECLARE
|
|
254
|
-
v_previous_record RECORD;
|
|
255
|
-
v_origin_record RECORD;
|
|
256
|
-
BEGIN
|
|
257
|
-
-- Check if previous values are not provided
|
|
258
|
-
IF ${previousHistoryFieldsAreNullStr} THEN
|
|
259
|
-
-- Find the most recent record for the same id
|
|
260
|
-
SELECT ${currentChangeFieldNamesCommaSeparated} INTO v_previous_record
|
|
261
|
-
FROM ${historyTablePath}
|
|
262
|
-
WHERE ${id} = ${historyRowArg}.${id}
|
|
263
|
-
ORDER BY ${currentChangeFieldNames
|
|
264
|
-
->Belt.Array.map(fieldName => fieldName ++ " DESC")
|
|
265
|
-
->Js.Array2.joinWith(", ")}
|
|
266
|
-
LIMIT 1;
|
|
267
|
-
|
|
268
|
-
-- If a previous record exists, use its values
|
|
269
|
-
IF FOUND THEN
|
|
270
|
-
${Belt.Array.zip(currentChangeFieldNames, previousChangeFieldNames)
|
|
271
|
-
->Belt.Array.map(((currentFieldName, previousFieldName)) => {
|
|
272
|
-
`${historyRowArg}.${previousFieldName} := v_previous_record.${currentFieldName};`
|
|
273
|
-
})
|
|
274
|
-
->Js.Array2.joinWith(" ")}
|
|
275
|
-
ElSIF should_copy_current_entity THEN
|
|
276
|
-
-- Check if a value for the id exists in the origin table and if so, insert a history row for it.
|
|
277
|
-
SELECT ${dataFieldNamesCommaSeparated} FROM ${originTablePath} WHERE id = ${historyRowArg}.${id} INTO v_origin_record;
|
|
278
|
-
IF FOUND THEN
|
|
279
|
-
INSERT INTO ${historyTablePath} (${currentChangeFieldNamesCommaSeparated}, ${dataFieldNamesCommaSeparated}, "${actionFieldName}")
|
|
280
|
-
-- SET the current change data fields to 0 since we don't know what they were
|
|
281
|
-
-- and it doesn't matter provided they are less than any new values
|
|
282
|
-
VALUES (${currentChangeFieldNames
|
|
283
|
-
->Belt.Array.map(_ => "0")
|
|
284
|
-
->Js.Array2.joinWith(", ")}, ${dataFieldNames
|
|
285
|
-
->Belt.Array.map(fieldName => `v_origin_record."${fieldName}"`)
|
|
286
|
-
->Js.Array2.joinWith(", ")}, 'SET');
|
|
287
|
-
|
|
288
|
-
${previousChangeFieldNames
|
|
289
|
-
->Belt.Array.map(previousFieldName => {
|
|
290
|
-
`${historyRowArg}.${previousFieldName} := 0;`
|
|
291
|
-
})
|
|
292
|
-
->Js.Array2.joinWith(" ")}
|
|
293
|
-
END IF;
|
|
294
|
-
END IF;
|
|
295
|
-
END IF;
|
|
296
|
-
|
|
297
|
-
INSERT INTO ${historyTablePath} (${allFieldNamesDoubleQuoted->Js.Array2.joinWith(", ")})
|
|
298
|
-
VALUES (${allFieldNamesDoubleQuoted
|
|
299
|
-
->Belt.Array.map(fieldName => `${historyRowArg}.${fieldName}`)
|
|
300
|
-
->Js.Array2.joinWith(", ")});
|
|
301
|
-
END;
|
|
302
|
-
$$ LANGUAGE plpgsql;`
|
|
89
|
+
let setUpdateSchema = makeSetUpdateSchema(schema)
|
|
90
|
+
|
|
91
|
+
let makeInsertDeleteUpdatesQuery = {
|
|
92
|
+
// Get all field names for the INSERT statement
|
|
93
|
+
let allFieldNames = table.fields->Belt.Array.map(field => field->getFieldName)
|
|
94
|
+
let allFieldNamesStr =
|
|
95
|
+
allFieldNames->Belt.Array.map(name => `"${name}"`)->Js.Array2.joinWith(", ")
|
|
96
|
+
|
|
97
|
+
// Build the SELECT part: id from unnest, checkpoint_id from unnest, 'DELETE' for action, NULL for all other fields
|
|
98
|
+
let selectParts = allFieldNames->Belt.Array.map(fieldName => {
|
|
99
|
+
switch fieldName {
|
|
100
|
+
| "id" => "u.id"
|
|
101
|
+
| field if field == checkpointIdFieldName => "u.checkpoint_id"
|
|
102
|
+
| field if field == changeFieldName => "'DELETE'"
|
|
103
|
+
| _ => "NULL"
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
let selectPartsStr = selectParts->Js.Array2.joinWith(", ")
|
|
107
|
+
(~pgSchema) => {
|
|
108
|
+
`INSERT INTO "${pgSchema}"."${historyTableName}" (${allFieldNamesStr})
|
|
109
|
+
SELECT ${selectPartsStr}
|
|
110
|
+
FROM UNNEST($1::text[], $2::int[]) AS u(id, checkpoint_id)`
|
|
111
|
+
}
|
|
303
112
|
}
|
|
304
113
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
->
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
114
|
+
// Get data field names for rollback queries (exclude changeFieldName and checkpointIdFieldName)
|
|
115
|
+
let dataFieldNames =
|
|
116
|
+
table.fields
|
|
117
|
+
->Belt.Array.map(field => field->getFieldName)
|
|
118
|
+
->Belt.Array.keep(fieldName =>
|
|
119
|
+
fieldName != changeFieldName && fieldName != checkpointIdFieldName
|
|
120
|
+
)
|
|
121
|
+
let dataFieldsCommaSeparated =
|
|
122
|
+
dataFieldNames->Belt.Array.map(name => `"${name}"`)->Js.Array2.joinWith(", ")
|
|
123
|
+
|
|
124
|
+
// Returns entity IDs that were created after the rollback target and have no history before it.
|
|
125
|
+
// These entities should be deleted during rollback.
|
|
126
|
+
let makeGetRollbackRemovedIdsQuery = (~pgSchema) => {
|
|
127
|
+
`SELECT DISTINCT id
|
|
128
|
+
FROM "${pgSchema}"."${historyTableName}"
|
|
129
|
+
WHERE "${checkpointIdFieldName}" > $1
|
|
130
|
+
AND NOT EXISTS (
|
|
131
|
+
SELECT 1
|
|
132
|
+
FROM "${pgSchema}"."${historyTableName}" h
|
|
133
|
+
WHERE h.id = "${historyTableName}".id
|
|
134
|
+
AND h."${checkpointIdFieldName}" <= $1
|
|
135
|
+
)`
|
|
136
|
+
}
|
|
313
137
|
|
|
314
|
-
|
|
138
|
+
// Returns the most recent entity state for IDs that need to be restored during rollback.
|
|
139
|
+
// For each ID modified after the rollback target, retrieves its latest state at or before the target.
|
|
140
|
+
let makeGetRollbackRestoredEntitiesQuery = (~pgSchema) => {
|
|
141
|
+
`SELECT DISTINCT ON (id) ${dataFieldsCommaSeparated}
|
|
142
|
+
FROM "${pgSchema}"."${historyTableName}"
|
|
143
|
+
WHERE "${checkpointIdFieldName}" <= $1
|
|
144
|
+
AND EXISTS (
|
|
145
|
+
SELECT 1
|
|
146
|
+
FROM "${pgSchema}"."${historyTableName}" h
|
|
147
|
+
WHERE h.id = "${historyTableName}".id
|
|
148
|
+
AND h."${checkpointIdFieldName}" > $1
|
|
149
|
+
)
|
|
150
|
+
ORDER BY id, "${checkpointIdFieldName}" DESC`
|
|
151
|
+
}
|
|
315
152
|
|
|
316
|
-
{
|
|
153
|
+
{
|
|
154
|
+
table,
|
|
155
|
+
setUpdateSchema,
|
|
156
|
+
setUpdateSchemaRows: S.array(setUpdateSchema),
|
|
157
|
+
makeInsertDeleteUpdatesQuery,
|
|
158
|
+
makeGetRollbackRemovedIdsQuery,
|
|
159
|
+
makeGetRollbackRestoredEntitiesQuery,
|
|
160
|
+
}
|
|
317
161
|
}
|
|
318
162
|
|
|
319
163
|
type safeReorgBlocks = {
|
|
@@ -323,60 +167,94 @@ type safeReorgBlocks = {
|
|
|
323
167
|
|
|
324
168
|
// We want to keep only the minimum history needed to survive chain reorgs and delete everything older.
|
|
325
169
|
// Each chain gives us a "safe block": we assume reorgs will never happen at that block.
|
|
170
|
+
// The latest checkpoint belonging to safe blocks of all chains is the safe checkpoint id.
|
|
326
171
|
//
|
|
327
172
|
// What we keep per entity id:
|
|
328
|
-
// - The latest history row at or before the safe block (the "anchor"). This is the last state that could
|
|
329
|
-
// ever be relevant during a rollback.
|
|
330
173
|
// - If there are history rows in reorg threshold (after the safe block), we keep the anchor and delete all older rows.
|
|
331
174
|
// - If there are no history rows in reorg threshold (after the safe block), even the anchor is redundant, so we delete it too.
|
|
175
|
+
// Anchor is the latest history row at or before the safe checkpoint id.
|
|
176
|
+
// This is the last state that could ever be relevant during a rollback.
|
|
332
177
|
//
|
|
333
178
|
// Why this is safe:
|
|
334
|
-
// - Rollbacks will not cross the safe
|
|
335
|
-
// - If nothing changed in reorg threshold (after the safe
|
|
179
|
+
// - Rollbacks will not cross the safe checkpoint id, so rows older than the anchor can never be referenced again.
|
|
180
|
+
// - If nothing changed in reorg threshold (after the safe checkpoint), the current state for that id can be reconstructed from the
|
|
336
181
|
// origin table; we do not need a pre-safe anchor for it.
|
|
337
|
-
//
|
|
338
|
-
// Performance notes:
|
|
339
|
-
// - Multi-chain batching: inputs are expanded with unnest, letting one prepared statement prune many chains and
|
|
340
|
-
// enabling the planner to use indexes per chain_id efficiently.
|
|
341
|
-
// - Minimal row touches: we only compute keep_serial per id and delete strictly older rows; this reduces write
|
|
342
|
-
// amplification and vacuum pressure compared to broad time-based purges.
|
|
343
|
-
// - Contention-awareness: the DELETE joins on ids first, narrowing target rows early to limit locking and buffer churn.
|
|
344
182
|
let makePruneStaleEntityHistoryQuery = (~entityName, ~pgSchema) => {
|
|
345
|
-
let
|
|
346
|
-
let historyTableRef = `"${pgSchema}"."${historyTableName}"`
|
|
183
|
+
let historyTableRef = `"${pgSchema}"."${historyTableName(~entityName)}"`
|
|
347
184
|
|
|
348
|
-
`WITH
|
|
349
|
-
SELECT
|
|
350
|
-
FROM
|
|
351
|
-
),
|
|
352
|
-
max_before_safe AS (
|
|
353
|
-
SELECT t.id, MAX(t.serial) AS keep_serial
|
|
354
|
-
FROM ${historyTableRef} t
|
|
355
|
-
JOIN safe s
|
|
356
|
-
ON s.chain_id = t.entity_history_chain_id
|
|
357
|
-
AND t.entity_history_block_number <= s.block_number
|
|
185
|
+
`WITH anchors AS (
|
|
186
|
+
SELECT t.id, MAX(t.${checkpointIdFieldName}) AS keep_checkpoint_id
|
|
187
|
+
FROM ${historyTableRef} t WHERE t.${checkpointIdFieldName} <= $1
|
|
358
188
|
GROUP BY t.id
|
|
359
|
-
),
|
|
360
|
-
post_safe AS (
|
|
361
|
-
SELECT DISTINCT t.id
|
|
362
|
-
FROM ${historyTableRef} t
|
|
363
|
-
JOIN safe s
|
|
364
|
-
ON s.chain_id = t.entity_history_chain_id
|
|
365
|
-
AND t.entity_history_block_number > s.block_number
|
|
366
189
|
)
|
|
367
190
|
DELETE FROM ${historyTableRef} d
|
|
368
|
-
USING
|
|
369
|
-
|
|
370
|
-
WHERE d.id = m.id
|
|
191
|
+
USING anchors a
|
|
192
|
+
WHERE d.id = a.id
|
|
371
193
|
AND (
|
|
372
|
-
d
|
|
373
|
-
OR (
|
|
194
|
+
d.${checkpointIdFieldName} < a.keep_checkpoint_id
|
|
195
|
+
OR (
|
|
196
|
+
d.${checkpointIdFieldName} = a.keep_checkpoint_id AND
|
|
197
|
+
NOT EXISTS (
|
|
198
|
+
SELECT 1 FROM ${historyTableRef} ps
|
|
199
|
+
WHERE ps.id = d.id AND ps.${checkpointIdFieldName} > $1
|
|
200
|
+
)
|
|
201
|
+
)
|
|
374
202
|
);`
|
|
375
203
|
}
|
|
376
204
|
|
|
377
|
-
let pruneStaleEntityHistory = (sql, ~entityName, ~pgSchema, ~
|
|
205
|
+
let pruneStaleEntityHistory = (sql, ~entityName, ~pgSchema, ~safeCheckpointId): promise<unit> => {
|
|
378
206
|
sql->Postgres.preparedUnsafe(
|
|
379
207
|
makePruneStaleEntityHistoryQuery(~entityName, ~pgSchema),
|
|
380
|
-
|
|
208
|
+
[safeCheckpointId]->Utils.magic,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// If an entity doesn't have a history before the update
|
|
213
|
+
// we create it automatically with checkpoint_id 0
|
|
214
|
+
let makeBackfillHistoryQuery = (~pgSchema, ~entityName) => {
|
|
215
|
+
`WITH target_ids AS (
|
|
216
|
+
SELECT UNNEST($1::${(Text: Table.fieldType :> string)}[]) AS id
|
|
217
|
+
),
|
|
218
|
+
missing_history AS (
|
|
219
|
+
SELECT e.*
|
|
220
|
+
FROM "${pgSchema}"."${entityName}" e
|
|
221
|
+
JOIN target_ids t ON e.id = t.id
|
|
222
|
+
LEFT JOIN "${pgSchema}"."${historyTableName(~entityName)}" h ON h.id = e.id
|
|
223
|
+
WHERE h.id IS NULL
|
|
224
|
+
)
|
|
225
|
+
INSERT INTO "${pgSchema}"."${historyTableName(~entityName)}"
|
|
226
|
+
SELECT *, 0 AS ${checkpointIdFieldName}, '${(RowAction.SET :> string)}' as ${changeFieldName}
|
|
227
|
+
FROM missing_history;`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let backfillHistory = (sql, ~pgSchema, ~entityName, ~ids: array<string>) => {
|
|
231
|
+
sql
|
|
232
|
+
->Postgres.preparedUnsafe(makeBackfillHistoryQuery(~entityName, ~pgSchema), [ids]->Obj.magic)
|
|
233
|
+
->Promise.ignoreValue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let insertDeleteUpdates = (
|
|
237
|
+
sql,
|
|
238
|
+
~pgSchema,
|
|
239
|
+
~entityHistory,
|
|
240
|
+
~batchDeleteEntityIds,
|
|
241
|
+
~batchDeleteCheckpointIds,
|
|
242
|
+
) => {
|
|
243
|
+
sql
|
|
244
|
+
->Postgres.preparedUnsafe(
|
|
245
|
+
entityHistory.makeInsertDeleteUpdatesQuery(~pgSchema),
|
|
246
|
+
(batchDeleteEntityIds, batchDeleteCheckpointIds)->Obj.magic,
|
|
247
|
+
)
|
|
248
|
+
->Promise.ignoreValue
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let rollback = (sql, ~pgSchema, ~entityName, ~rollbackTargetCheckpointId: int) => {
|
|
252
|
+
sql
|
|
253
|
+
->Postgres.preparedUnsafe(
|
|
254
|
+
`DELETE FROM "${pgSchema}"."${historyTableName(
|
|
255
|
+
~entityName,
|
|
256
|
+
)}" WHERE "${checkpointIdFieldName}" > $1;`,
|
|
257
|
+
[rollbackTargetCheckpointId]->Utils.magic,
|
|
381
258
|
)
|
|
259
|
+
->Promise.ignoreValue
|
|
382
260
|
}
|