Skip to content

Fixes for contract registration registers and partitions - #281

Merged
JonoPrest merged 18 commits into
mainfrom
jp/fix-register-does-not-exist
Oct 25, 2024
Merged

Fixes for contract registration registers and partitions#281
JonoPrest merged 18 commits into
mainfrom
jp/fix-register-does-not-exist

Conversation

@JonoPrest

@JonoPrest JonoPrest commented Oct 23, 2024

Copy link
Copy Markdown
Collaborator

Closes #245

The first bug as linked above was caused by the fact that I used a list to hold partitions. The partition id was it's index in this list. When adding new partitions, since it's a list it gets added to the head. Meaning that the partition ID was not static. The bug occurs when there's an inflight "fetch block range" query for a partition, during this time a new partition gets added (shifting the partition indexes along the list) and the query response gets routed to the wrong partition.

It obviously presents as a crash when there are dynamic contract registers and it can't find the unique register id on the wrong partition. But it can happen silently when they are root register queries. This is not so bad since the events are ordered but it can give us bad "latestFetchedBlock" states between partitions. Causing strange behaviours of which partitions to execute next, or incorrect UI representation of fetch state etc.

I've fixed this by changing the data structure to use a dict to map the partitions.

The second bug is subtle and never actually presented but I added a fix for it anyhow.

It occurs when

  1. A dynamic contract fetchstate register is added upon a contract registration (say at block 100)
  2. Next query is actioned and is inflight (say querying block 100-500)
  3. A new fetchstate register is added between the first contract and it's upper bound query (say block 200)
  4. A response happens from the first register up to the queried block, and merges into the new register before it has queried blocks 2-500. Resulting in a missed block range for that new register.

This has been fixed by adding an array of pending dynamic contract registerations to the fetchState and lazily registering them when the fetch state gets updated or when a "nextQuery" is requested meaning that the actual creation of the fetchState register never occurs when a query is in flight.

It may be worth reviewing this PR one commit at a time.

@JonoPrest
JonoPrest requested a review from DZakh October 23, 2024 14:18
Comment on lines -58 to -63
type dynamicContractRegistration = {
registeringEventBlockNumber: int,
registeringEventLogIndex: int,
registeringEventChain: ChainMap.Chain.t,
dynamicContracts: array<TablesStatic.DynamicContractRegistry.t>,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to FetchState.res to avoid cyclical dependencies

Comment on lines +46 to +52
let merge: (dict<'a>, dict<'a>) => dict<'a> = %raw(`(dictA, dictB) => ({...dictA, ...dictB})`)

let updateImmutable: (
dict<'a>,
string,
'a,
) => dict<'a> = %raw(`(dict, key, value) => ({...dict, [key]: value})`)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added these raw js functions as I thought that would be the most performant way to do these two operations.

You can see what merge was doing before and I think spreads would be most performant.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I know spreads have terrible performance, so it's not guaranteed for them to be faster. But in the case probably it will be.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tested but would they be less performant than say:

let copied = Object.assign({}, dict);
return Object.assign(copied, {[key]: value})

Or what method of immutable update structure would you choose? Can use a Belt.Map for eg.

My assumption was that a spread can be optimised by the js engine.

@DZakh DZakh Oct 24, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this case, I'd just kept it as it is. I only have a comparison data with inlining the field set https://x.com/artalar_dev/status/1571839333694324736

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Object.assign need to do a benchmark, but I doubt there will be a difference which is worth to consider

Comment on lines +89 to +103
type registerData = {
latestFetchedBlock: blockNumberAndTimestamp,
contractAddressMapping: ContractAddressingMap.mapping,
//Events ordered from latest to earliest
fetchedEventQueue: array<Types.eventBatchQueueItem>,
//Used to prune dynamic contract registrations in the event
//of a rollback.
dynamicContracts: DynamicContractsMap.t,
isFetchingAtHead: bool,
firstEventBlockNumber: option<int>,
}

type rec t = {
registerType: register,
...fetchStateData,
type rec register = {
registerType: registerType,
...registerData,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some renaming going on over here. What was FetchState.t is now simply a "register", FetchState.t is a record that contains a "base register" and some additional state.

}
and registerType =
| RootRegister({endBlock: option<int>})
| DynamicContractRegister({id: EventUtils.eventIndex, nextRegister: register})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the payload of DynamicContractRegister to have a record argument rather than positional for more clear labelling.

Comment on lines +108 to +113
type dynamicContractRegistration = {
registeringEventBlockNumber: int,
registeringEventLogIndex: int,
registeringEventChain: ChainMap.Chain.t,
dynamicContracts: array<TablesStatic.DynamicContractRegistry.t>,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is moved from EventProcessing.res to avoid cyclical deps

//Used to prune dynamic contract registrations in the event
//of a rollback.
dynamicContracts: DynamicContractsMap.t,
isFetchingAtHead: bool,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed isFetchingAtHead since not every register needs to have it's own value for this.

Comment on lines +114 to +117
type t = {
baseRegister: register,
pendingDynamicContracts: array<dynamicContractRegistration>,
isFetchingAtHead: bool,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the new FetchState.t, it holds the baseRegister, an array of pending dynamic contracts that are to be lazily added and the "isFetchingAtHead" state is moved from each register to a single value here.

Comment on lines +350 to +429
/**
Inserts a dynamic contract register to the head of a given
register. It will then precede the given register in the chain
*/
let addNewRegisterToHead = (
self,
~registeringEventBlockNumber,
~registeringEventLogIndex,
~contractAddressMapping,
) => {
let id: dynamicContractId = {
blockNumber: registeringEventBlockNumber,
logIndex: registeringEventLogIndex,
}
let registerType = DynamicContractRegister({id, nextRegister: self})

let dynamicContracts =
DynamicContractsMap.empty->DynamicContractsMap.add(
id,
contractAddressMapping->ContractAddressingMap.getAllAddresses,
)

{
registerType,
latestFetchedBlock: {
blockNumber: registeringEventBlockNumber - 1,
blockTimestamp: 0,
},
contractAddressMapping,
dynamicContracts,
fetchedEventQueue: [],
firstEventBlockNumber: None,
}
}

/**
Adds a new dynamic contract registration. It inserts the registration ordered in the
chain from earliest registered contract to latest. So if this is being called on a batch
of registrations its best to do this in order of latest to earliest to reduce recursions
of this function.
*/
let rec addDynamicContractRegister = (
self: register,
~registeringEventBlockNumber,
~registeringEventLogIndex,
~dynamicContractRegistrations: array<TablesStatic.DynamicContractRegistry.t>,
~parent: option<Parent.t>=?,
) => {
let handleParent = updated =>
switch parent {
| Some(parent) => parent->Parent.joinChild(updated)
| None => updated
}

let addToHead = updated =>
updated
->addNewRegisterToHead(
~contractAddressMapping=dynamicContractRegistrations
->Array.map(d => (d.contractAddress, (d.contractType :> string)))
->ContractAddressingMap.fromArray,
~registeringEventLogIndex,
~registeringEventBlockNumber,
)
->handleParent

let latestFetchedBlockNumber = registeringEventBlockNumber - 1

switch self.registerType {
| RootRegister(_) => self->addToHead
| DynamicContractRegister(_) if latestFetchedBlockNumber <= self.latestFetchedBlock.blockNumber =>
self->addToHead
| DynamicContractRegister({id: dynamicContractId, nextRegister}) =>
nextRegister->addDynamicContractRegister(
~registeringEventBlockNumber,
~registeringEventLogIndex,
~dynamicContractRegistrations,
~parent=self->Parent.make(~dynamicContractId, ~parent),
)
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two functions were simply moved to be accessible to the functions below. No logic changes.

Comment on lines +431 to +444
/**
Adds a new dynamic contract registration. It appends the registration to the pending dynamic
contract registrations. These pending registrations are applied to the base register when next
query is called.
*/
let registerDynamicContract = (
self: t,
registration: dynamicContractRegistration,
~isFetchingAtHead,
) => {
...self,
pendingDynamicContracts: self.pendingDynamicContracts->Array.concat([registration]),
isFetchingAtHead,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When calling registerDynamicContract, it now simply appends to "pendingDynamicContracts" which will be handled later.

@JonoPrest
JonoPrest force-pushed the jp/fix-register-does-not-exist branch from c7d2c4d to a77630b Compare October 23, 2024 14:40
Comment on lines +494 to +501
let withNewDynamicContracts =
updatedRegister->addDynamicContractRegisters(pendingDynamicContracts)
let maybeMerged = withNewDynamicContracts->pruneAndMergeNextRegistered
{
baseRegister: maybeMerged->Option.getWithDefault(withNewDynamicContracts),
pendingDynamicContracts: [],
isFetchingAtHead,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update now first:

  • adds the new items to the targeted register,
  • then registers any pending dynamic contracts
  • then merges registers

This ensures registers remain ordered based on latestFetched block and can't be messed around by in flight queries

Comment on lines +636 to +651
//First apply pending dynamic contracts, then try and merge
//These steps should only happen on getNextQuery, to avoid in between states where a
//query is in flight and the underlying registers are changing
let maybeUpdatedFetchState = switch self->applyPendingDynamicContractRegistrations {
| Some(updatedWithDynamicContracts) =>
//After adding the pending dynamic contracts, try and merge registers
switch updatedWithDynamicContracts->mapMaybeMerge {
//Pass through the merged value if it updated anything
| Some(merged) => Some(merged)
//Even if the merge returned none, the pending dynamic contracts should be applied
//as an updated
| None => Some(updatedWithDynamicContracts)
}
//If no dynamic contracts were added just try and merge
| None => self->mapMaybeMerge
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getNextQuery now also first tries to apply pending dynamic contracts and then merge. Returning an optional updated state along with the query if anything changes.

Comment on lines +909 to +929
let getLatestFullyFetchedBlock = (self: t) => {
//Consider pending dynamic contracts when calculating the latest fully fetched block
//Since they are now registered lazily on query or update of the fetchstate, not when
//the register function is called
let minPendingDynamicContracts = self.pendingDynamicContracts->Belt.Array.reduce(None, (
acc,
contract,
) => {
let {registeringEventBlockNumber} = contract
minOfOption(registeringEventBlockNumber - 1, acc)->Some
})

switch (self.baseRegister.latestFetchedBlock, minPendingDynamicContracts) {
| ({blockNumber}, Some(pendingDynamicContractBlockNumber))
if pendingDynamicContractBlockNumber < blockNumber => {
blockNumber: pendingDynamicContractBlockNumber,
blockTimestamp: 0,
}
| (baseRegisterLatest, _) => baseRegisterLatest
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It used to be implicit that after a contract registeration the base register would contain the latest fetched block. But now there could be pending dynamic contracts so we need to check against those if there are any.

Comment on lines +5 to +6
newestPartitionIndex: partitionIndex,
partitions: dict<FetchState.t>,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data structure changed as described in PR description.

I needed immutable operations with an int key so it might be sensible to use a belt map, but I thought we could rather stay closer to native js and just use a dict. I therefore am converting the key back and forth to string and also added a util for dict spreading.

) => {
let partitions = switch partitions {
| list{head, ...tail} if head->FetchState.getNumContracts < maxAddrInPartition =>
let newestPartition = partitions->Js.Dict.unsafeGet(newestPartitionIndex->Int.toString)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I should rather do a runtime check here and explicitly fail if the partition does not exist.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do this, it won't harm.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think unsafeGet is good, when we 100% sure that the item will be present in the context of the function. But if we're talking about the whole system, it's better to do a check. Especially when it doesn't cost us anything.

Comment on lines -789 to -798
) =>
async chain => {
let chainFetcher = state.chainManager.chainFetchers->ChainMap.get(chain)
let {chainConfig: {chainWorker}, logger, currentBlockHeight} = chainFetcher

if !isRollingBack(state) {
let (nextQuery, nextStateIfChangeRequired) =
chainFetcher->ChainFetcher.getNextQuery(~maxPerChainQueueSize=state.maxPerChainQueueSize)

switch nextStateIfChangeRequired {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of these changes are just formatting. Either review with whitespace disabled or I separated the commit with the formatting changes so you should be able to see exactly what changed.

Comment on lines +314 to +323
baseRegister: {
registerType: RootRegister({endBlock: None}),
latestFetchedBlock: {
blockTimestamp: latestFetchedBlockTimestamp,
blockNumber: 0,
},
contractAddressMapping: ContractAddressingMap.make(),
fetchedEventQueue: item->Option.mapWithDefault([], v => [v]),
dynamicContracts: FetchState.DynamicContractsMap.empty,
firstEventBlockNumber: item->Option.map(v => v.blockNumber),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simply fixing the test with the new data structure

Comment on lines +685 to +687
it(
"Adding dynamic between two registers while query is mid flight does no result in early merged registers",
() => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test validates the 2nd bug described in the PR discription

)
})

it("Partition id never changes when adding new partitions", () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test validates the first bug described is fixed

@JonoPrest
JonoPrest force-pushed the jp/fix-register-does-not-exist branch 2 times, most recently from 1ffb583 to 7844914 Compare October 24, 2024 09:36

@DZakh DZakh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Really liked the renamings in FetchState file. I couldn't process all of the changes.

Mostly I have 3 comments:

  1. What about timestamp: 0
  2. Use array for partitions
  3. Consider logIndex for rollback of pending dynamic contracts

| ({blockNumber}, Some(pendingDynamicContractBlockNumber))
if pendingDynamicContractBlockNumber < blockNumber => {
blockNumber: pendingDynamicContractBlockNumber,
blockTimestamp: 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it fine to have 0 here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is the same behaviour as before where when you create a dynamic contract register it initialises with blockTimestamp 0 since we don't know what the timestamp is for that block.

Comment on lines +46 to +52
let merge: (dict<'a>, dict<'a>) => dict<'a> = %raw(`(dictA, dictB) => ({...dictA, ...dictB})`)

let updateImmutable: (
dict<'a>,
string,
'a,
) => dict<'a> = %raw(`(dict, key, value) => ({...dict, [key]: value})`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I know spreads have terrible performance, so it's not guaranteed for them to be faster. But in the case probably it will be.

let getNewestPartitionIndex = () => {
switch newestPartitionIndexRef.contents {
| Some(newestPartitionIndex) => newestPartitionIndex
| None => Js.Exn.raiseError("Unexpected no part")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should newestPartitionIndexRef.contents maybe always be int instead of option<int>?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be, just wasn't sure what the initial value should be. Could make it -1 for eg. to represent this case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you use an array, you won't need to have the counter anymore

) => {
let partitions = switch partitions {
| list{head, ...tail} if head->FetchState.getNumContracts < maxAddrInPartition =>
let newestPartition = partitions->Js.Dict.unsafeGet(newestPartitionIndex->Int.toString)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do this, it won't harm.

) => {
let partitions = switch partitions {
| list{head, ...tail} if head->FetchState.getNumContracts < maxAddrInPartition =>
let newestPartition = partitions->Js.Dict.unsafeGet(newestPartitionIndex->Int.toString)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think unsafeGet is good, when we 100% sure that the item will be present in the context of the function. But if we're talking about the whole system, it's better to do a check. Especially when it doesn't cost us anything.


let restartedFetchState =
restartedChainFetcher.fetchState.partitions->List.head->Option.getExn
restartedChainFetcher.fetchState.partitions->Js.Dict.values->Array.get(0)->Option.getExn

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we have numerical ids, I think it's better to turn partitions into an array? This way we still be able to get items by id - aka hashmap + no need to always call Js.Dict.values

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newestPartitionIndex can be calculated from length

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, the operation that I want to improve is updating a single partition in the array in an "immutable" way.

So I guess we can shallow copy the array and then set the given index on the array.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DZakh, I just made some benchmarks here: https://jsbm.dev/Gwg2BVoQDxavT

Super interesting. Object.assign is the fastest of the 4 cases.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's the slowest :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And noticiably

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh lol 🤣 I was reading the bars as runtime not ops/s

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweet, I like the idea of using arrays 👍🏼

Comment on lines +1030 to +1031
self.pendingDynamicContracts->Array.keep(({registeringEventBlockNumber}) =>
registeringEventBlockNumber <= lastKnownValidBlock.blockNumber

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it also consider logIndex?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No since when we rollback we always do a full block. If you look at the filters we add:

        cf
        ->ChainFetcher.rollbackToLastBlockHashes(~rolledBackLastBlockData)
        ->ChainFetcher.addProcessingFilter(
          ~filter=eventBatchQueueItem => {
            let {timestamp, blockNumber} = eventBatchQueueItem
            //Filter out events that occur passed the block where the query starts but
            //are lower than the timestamp where we rolled back to
            (timestamp, chain->ChainMap.Chain.toChainId, blockNumber) >
            (lastKnownValidBlockTimestamp, rollbackChainId, lastKnownValidBlockNumber)
          },
          ~isValid=(~fetchState) => {
            //Remove the event filter once the fetchState has fetched passed the
            //timestamp of the valid rollback block's timestamp
            let {blockTimestamp, blockNumber} = FetchState.getLatestFullyFetchedBlock(fetchState)
            (blockTimestamp, chain->ChainMap.Chain.toChainId, blockNumber) <=
            (lastKnownValidBlockTimestamp, rollbackChainId, lastKnownValidBlockNumber)
          },
        )

We only care about the block number

@JonoPrest
JonoPrest force-pushed the jp/fix-register-does-not-exist branch from 7844914 to b18f28e Compare October 24, 2024 14:51
@JonoPrest
JonoPrest requested a review from DZakh October 24, 2024 14:57

@DZakh DZakh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left a few comments on Discord. But they are mostly small nitpicks. The PR is good to merge 👍

@JonoPrest
JonoPrest force-pushed the jp/fix-register-does-not-exist branch from 4b3fcc6 to fbfa346 Compare October 25, 2024 08:54
@JonoPrest
JonoPrest enabled auto-merge (squash) October 25, 2024 08:54
@JonoPrest
JonoPrest merged commit 5fe4dbf into main Oct 25, 2024
@JonoPrest
JonoPrest deleted the jp/fix-register-does-not-exist branch October 25, 2024 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dynamic Contract Registering error

2 participants