[4] Rollback diff - #333
Conversation
|
In the next PR, I'm going to add a whole bunch more tests for entity history 👍🏼, and add some refactors including removing dead code from before. |
| ) => { | ||
| let (optPreviousEventIdentifier, entityHistoryItems) = prev | ||
|
|
||
| let {eventIdentifier, shouldSaveHistory, entityUpdateAction, entityId} = entityUpdate |
There was a problem hiding this comment.
I've refactored entity history in the in memory table to simplify things (and exposed a bug).
The inMemTable would hold "latestItem" and "history", where history did not include latest item. The problem is that each item would need to keep track of whether it should be saved to history or not, and the latest item would need to be added to history when requesting history.
Now it does not need to keep track of that, when calling "set" purely based on the "shouldSaveHistory" flag it either adds to history or not. The only minor knock is that you have a duplicate value at the head of "history" and in the "latestItem" position.
| if !shouldSaveHistory || | ||
| //Rollback initial state cases should not save history | ||
| !previous_values.latest.shouldSaveHistory || | ||
| // This prevents two db actions in the same event on the same entity from being recorded to the history table. | ||
| previous_values.latest.eventIdentifier == entityUpdate.eventIdentifier => |
There was a problem hiding this comment.
This logic was not fully sound. It meant that if 2 updates came from the same event, the history item wold be that of the first and not the latest.
There was a problem hiding this comment.
I don't know, but this looks wrong 😱
There was a problem hiding this comment.
Clutchplay recently reported that they can't find an entity that is clearly set. I need to doublecheck whether it's related or not.
| let {entityRow, entityIndices} = switch inMemTable.table->get(entityUpdate.entityId) { | ||
| | Some({entityRow: InitialReadFromDb(entity_read), entityIndices}) => | ||
| let entityRow = Types.Updated({ | ||
| initial: Retrieved(entity_read), |
There was a problem hiding this comment.
"initial" was not being used anywhere so I removed it.
| module EntityTables = { | ||
| type t = dict<InMemoryTable.Entity.t<Entities.internalEntity>> | ||
| exception UndefinedEntity(string) | ||
| let make = (entities: array<module(Entities.InternalEntity)>): t => { | ||
| let init = Js.Dict.empty() | ||
| entities->Belt.Array.forEach(entity => { | ||
| let module(Entity) = entity | ||
| init->Js.Dict.set(Entity.key, InMemoryTable.Entity.make()) | ||
| }) | ||
| init | ||
| } | ||
|
|
||
| let get = (type entity, self: t, entityMod: module(Entities.Entity with type t = entity)) => { | ||
| let module(Entity) = entityMod | ||
| switch self->Utils.Dict.dangerouslyGetNonOption(Entity.key) { | ||
| | Some(table) => | ||
| table->( | ||
| Utils.magic: InMemoryTable.Entity.t<Entities.internalEntity> => InMemoryTable.Entity.t< | ||
| entity, | ||
| > | ||
| ) | ||
|
|
||
| | None => | ||
| UndefinedEntity(Entity.key)->ErrorHandling.mkLogAndRaise( | ||
| ~msg="Unexpected, entity InMemoryTable is undefined", | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
I've refactored inMemoryStore to be library like where it does not codegen a record for each entity but uses a dict to hold each entity.
If you look at the commits you should be able to see the refactor diff before the file move.
| let executeBatch = async (sql, ~inMemoryStore: InMemoryStore.t, ~isInReorgThreshold) => { | ||
| let executeBatch = async (sql, ~inMemoryStore: InMemoryStore.t, ~isInReorgThreshold, ~config) => { | ||
| let entityDbExecutionComposer = | ||
| RegisterHandlers.getConfig()->Config.shouldSaveHistory(~isInReorgThreshold) |
There was a problem hiding this comment.
The problem with referencing the codegen config is that tests which pass in a different config have mixed states. I had this problem with a test that was using unordered multichain mode for some functions and not for others resulting in some weird behaviours
There was a problem hiding this comment.
I didn't do this in the beginning for simplicity, but this was the goal to have it like this
| let diff = await DbFunctions.sql->DbFunctions.EntityHistory.getRollbackDiff( | ||
| isUnorderedMultichainMode | ||
| ? UnorderedMultichain({ | ||
| reorgChainId: chainId, | ||
| safeBlockNumber: blockNumber, | ||
| }) | ||
| : OrderedMultichain({ | ||
| safeBlockTimestamp: blockTimestamp, | ||
| reorgChainId: chainId, | ||
| safeBlockNumber: blockNumber, | ||
| }), | ||
| ~entityMod, | ||
| ) |
There was a problem hiding this comment.
The diffing now has a different way to treat ordered/unordered multichain mode there's a good explanation in DbFunctions.res
| type existingValueInDb<'entityType> = | ||
| | Retrieved(entityValueAtStartOfBatch<'entityType>) | ||
| // NOTE: We use an postgres function solve the issue of this entities previous value not being known. | ||
| | Unknown | ||
|
|
||
| type updatedValue<'entityType> = { | ||
| // Initial value within a batch | ||
| initial: existingValueInDb<'entityType>, |
| shouldSaveHistory: bool, | ||
| entityId: id, | ||
| entityUpdateAction: entityUpdateAction<'entityType>, | ||
| } | ||
|
|
||
| let mkEntityUpdate = (~shouldSaveHistory=true, ~eventIdentifier, ~entityId, entityUpdateAction) => { | ||
| let mkEntityUpdate = (~eventIdentifier, ~entityId, entityUpdateAction) => { | ||
| entityId, | ||
| shouldSaveHistory, |
There was a problem hiding this comment.
No longer needed after inMemoryTable refactor
| let allEntities: array<module(InternalEntity)> = | ||
| [ |
There was a problem hiding this comment.
Expose an array of each entity module rather than just the table
| let rec rollBackToBlockNumberLtInternal = ( | ||
| ~blockNumber: int, |
There was a problem hiding this comment.
We now just rollback to a state where all blocks are lower than the block of the first change to an entity on the given chain.
| /** | ||
| Uses two different methods for determining the first change event after rollback block | ||
|
|
||
| This is needed since unordered multichain mode only cares about any changes that | ||
| occurred after the first change on the reorg chain. To prevent skipping or double processing events | ||
| on the other chains. If for instance there are no entity changes based on the reorg chain, the other | ||
| chains do not need to be rolled back, and if the reorg chain has new included events, it does not matter | ||
| that if those events are processed out of order from other chains since this is "unordered_multichain_mode" | ||
|
|
||
| Ordered multichain mode needs to ensure that all chains rollback to any event that occurred after the reorg chain | ||
| block number. Regardless of whether the reorg chain incurred any changes or not to entities. | ||
| */ | ||
| let makeGetFirstChangeSerial = (self: t, ~entityName) => |
There was a problem hiding this comment.
This comment helps explain the two different approaches of rolling back/getting a diff.
| } | ||
|
|
||
| Some({entity, eventIdentifier}) | ||
| let deleteAllEntityHistoryAfterEventIdentifier = (~isUnorderedMultichainMode) => ( |
There was a problem hiding this comment.
This just kept it's naming/signature but I plan on doing some refactoring for all the delete functions that happen on a rollback in the next PR
DZakh
left a comment
There was a problem hiding this comment.
Amazing work. After looking at the recent PRs I want to start calling you an SQL wizard at Envio 😂 Left some nitpicks, probably the most important comment is about the serial column.
| history | ||
| ->Belt.Array.concat([latest]) | ||
| ->getEntityHistoryItems | ||
| let entityHistoryItems = history->getEntityHistoryItems |
There was a problem hiding this comment.
non-blocking: Not super urgent, considering the importance of the rollbacks fix, but the reduce looks very slow (relatively to the rest js execution). I'd suggest to rewrite it to a forEach with pushes
There was a problem hiding this comment.
I'm adding it to the refactor list I have but this update hasn't introduced the reduce 👍🏼
| let executeBatch = async (sql, ~inMemoryStore: InMemoryStore.t, ~isInReorgThreshold) => { | ||
| let executeBatch = async (sql, ~inMemoryStore: InMemoryStore.t, ~isInReorgThreshold, ~config) => { | ||
| let entityDbExecutionComposer = | ||
| RegisterHandlers.getConfig()->Config.shouldSaveHistory(~isInReorgThreshold) |
There was a problem hiding this comment.
I didn't do this in the beginning for simplicity, but this was the goal to have it like this
| inMemStore.{{entity.name.uncapitalized}}->InMemoryTable.Entity.set( | ||
| Delete->Types.mkEntityUpdate(~eventIdentifier=rollBackEventIdentifier, ~entityId, ~shouldSaveHistory), | ||
| ~shouldSaveHistory, | ||
| await Utils.Array.awaitEach(Entities.allEntities, async entityMod => { |
There was a problem hiding this comment.
Promise.all? I think you mentioned this place
| @genType | ||
| type rawEventsKey = { |
There was a problem hiding this comment.
Not sure possibly a relic from tests. I didn't add it now 👍🏼
| @genType | ||
| type dynamicContractRegistryKey = { |
There was a problem hiding this comment.
Same as above ☝🏼
|
|
||
| let rec rollBackToBlockTimestampLteInternal = ( | ||
| ~blockTimestamp: int, | ||
| let min = (arrInt: array<int>) => { |
There was a problem hiding this comment.
| sql`${constrQuery(sql)}${i === dynQueryConstructors.length - 1 ? sql`` : sql`, ` | ||
| }`, | ||
| sql`${constrQuery(sql)}${ | ||
| i === dynQueryConstructors.length - 1 ? sql`` : sql`, ` |
There was a problem hiding this comment.
Sorry this is just formatting changes. But yes it's all part of building dynamic queries with postgresjs. I actually think I prefer building these queries with eval. So another potential refactor
There was a problem hiding this comment.
| COALESCE(before.id, after.id) AS id, | ||
| COALESCE(before.action, 'DELETE') AS action, | ||
| -- Deleting at 0 values will work fine for future rollbacks | ||
| COALESCE(before.entity_history_block_number, 0) AS entity_history_block_number, | ||
| COALESCE(before.entity_history_block_timestamp, 0) AS entity_history_block_timestamp, | ||
| COALESCE(before.entity_history_chain_id, 0) AS entity_history_chain_id, | ||
| COALESCE(before.entity_history_log_index, 0) AS entity_history_log_index | ||
| FROM | ||
| -- Use a RIGHT JOIN, to ensure that nulls get returned if there is no "before" row | ||
| public.${sql(entityName + "_history")} before | ||
| RIGHT JOIN rollback_ids after ON before.id = after.id | ||
| AND before.entity_history_block_timestamp = after.previous_entity_history_block_timestamp | ||
| AND before.entity_history_chain_id = after.previous_entity_history_chain_id | ||
| AND before.entity_history_block_number = after.previous_entity_history_block_number | ||
| AND before.entity_history_log_index = after.previous_entity_history_log_index; |
There was a problem hiding this comment.
Yes, but this one gives me less headaches reading than the first one I implemented 😓
| -- In the case where no previous row exists, coalesce the needed values since this new entity | ||
| -- will need to be deleted | ||
| COALESCE(before.id, after.id) AS id, | ||
| COALESCE(before.action, 'DELETE') AS action, | ||
| -- Deleting at 0 values will work fine for future rollbacks |
There was a problem hiding this comment.
Should we maybe handle it on js side with rescript-schema instead of using COALESCE here?
There was a problem hiding this comment.
No particular reason, besides the fact that the query looks already quite complex
There was a problem hiding this comment.
We can't, we just get null values then. We could handle it with just coalescing id and then have a more advanced schema for inferring delete and 0 values for the others. But I think this works fine.
|
|
||
| let actionField = mkField(actionFieldName, Custom(Enums.EntityHistoryRowAction.enum.name)) | ||
|
|
||
| let serialField = mkField("serial", Serial, ~isNullable=true) |
There was a problem hiding this comment.
Also, I don't understand why it's nullable
There was a problem hiding this comment.
I think it should have an index
I agree, I want to add a few indexes actually.
Or even a PRIMARY KEY
It could be primary key but we need a unique constraint on chainId, blockNumber, logIndex and id. Well it doesn't need to be enforced technically but it's already helped catch a few bugs.
Also, I don't understand why it's nullable,
It's just because the serial is calculated on postgres side. Marking "nullable" here simply means the the field doesn't get a "NOT NULL" added on table creation. And you can pass "Null" on inserts to let postgres deal with the row creation.
There was a problem hiding this comment.
What about something like NOT NULL DEFAULT nextval('table_name_id_seq')?
There was a problem hiding this comment.
If it complicates things we can keep it as nullable
6cb09a7 to
8c734a3
Compare
64e94b6 to
fe8a277
Compare
Sorry for all the changes in 1 PR, it was very difficult to get independent parts to work correctly without all the changes.
This PR finishes the code necessary to complete a rollback successfully from the new entity history tables.
It allows