Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(indexeddb): fix IDB cursor.continue_() call after drop #2028

Merged
merged 19 commits into from
Feb 13, 2024

Conversation

borngraced
Copy link
Member

@borngraced borngraced commented Dec 9, 2023

If we try to get first result from indexeddb cursor like this

let maybe_height = block_db
.cursor_builder()
.only("ticker", &ticker)?
.bound("height", 0u32, u32::MAX)
.reverse()
.open_cursor(BlockDbTable::TICKER_HEIGHT_INDEX)
.await?
.next()
.await?;
this error is being logged on the browser Uncaught Error: closure invoked recursively or after being dropped on the web because CursorDriver next loop is still looping due to this
match cursor_action {
CursorAction::Continue => cursor.continue_().map_to_mm(|e| CursorError::AdvanceError {
description: stringify_js_error(&e),
})?,
CursorAction::ContinueWithValue(next_value) => {
cursor
.continue_with_key(&next_value)
.map_to_mm(|e| CursorError::AdvanceError {
description: stringify_js_error(&e),
})?
},
// Don't advance the cursor.
// Here we set the `stopped` flag so we return `Ok(None)` at the next iteration immediately.
// This is required because `item_action` can be `CollectItemAction::Include`,
// and at this iteration we will return `Ok(Some)`.
CursorAction::Stop => self.stopped = true,
}
after getting the first value we requested and Cursor is dropped as we called next() just once here .

I found a fix which introduces a method called where_ that takes a closure. The closure returns a boolean, allowing you to control when a loop should stop. This approach works reliably in various situations.

Example - Simple Condition

When calling the next method once, you can easily obtain the first available result
without needing to continue the cursor using a simple closure like where_first():

let cursor_builder = CursorBuilder::new();

// Apply the default condition to the cursor builder to return the first item
let updated_cursor_builder = cursor_builder.where_first();

Example - Complex Condition

For more complex conditions, such as deserializing an IndexedDB data, handling errors, and checking a specific condition, you can use a closure like the one below:

let where_ = move |block| {
   serde_json::from_value::<BlockHeaderStorageTable>(block)
       .map_to_mm(|err| CursorError::ErrorDeserializingItem(err.to_string()))
       .map(|header| header.bits != max_bits)
};

// Apply the closure to the cursor builder using the where_ method
let updated_cursor_builder = cursor_builder.where_(where_);

related: #2019

Signed-off-by: borngraced <samuelonoja970@gmail.com>
@laruh
Copy link
Member

laruh commented Dec 9, 2023

@borngraced please fix wasm lint and PR name lint (like "allow first result using IDB cursor")

Signed-off-by: borngraced <samuelonoja970@gmail.com>
@borngraced
Copy link
Member Author

@borngraced please fix wasm lint and PR name lint (like "allow first result using IDB cursor")

done

@borngraced borngraced changed the title feat(indexeddb): allow first result only while using Indexeddb Cursor feat(indexeddb): get first result using IDB cursor Dec 9, 2023
@borngraced borngraced changed the title feat(indexeddb): get first result using IDB cursor feat(indexeddb): get first result from IDB cursor Dec 9, 2023
Signed-off-by: borngraced <samuelonoja970@gmail.com>
Signed-off-by: borngraced <samuelonoja970@gmail.com>
Signed-off-by: borngraced <samuelonoja970@gmail.com>
Copy link
Member

@onur-ozkan onur-ozkan left a comment

Choose a reason for hiding this comment

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

Great work!

At the end, it would be super nice to have unit tests for fn next and fn first in mm2src/mm2_db/src/indexed_db/drivers/cursor/cursor.rs module.

Comment on lines 204 to 208
DbCursorEvent::NextItem {
result_tx,
first_result_only,
} => {
result_tx.send(cursor.next(first_result_only).await).ok();
Copy link
Member

Choose a reason for hiding this comment

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

Instead of making NextItem complicated, what do you think adding a new event called FirstItem?

@@ -241,7 +241,7 @@ impl CursorDriver {
})
}

pub(crate) async fn next(&mut self) -> CursorResult<Option<(ItemId, Json)>> {
pub(crate) async fn next(&mut self, first_result_only: bool) -> CursorResult<Option<(ItemId, Json)>> {
Copy link
Member

Choose a reason for hiding this comment

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

I believe this function should have consistent behavior without relying on conditional flags. Could we possibly create a new function called fn first and retain the existing function as is? The idea is to separate the common parts, which can then be called from both this function and fn first.

Copy link
Member Author

Choose a reason for hiding this comment

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

thanks done this and it improved the code greatly!

Copy link
Member

Choose a reason for hiding this comment

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

yeah, agree, now it looks beautiful.

@laruh
Copy link
Member

laruh commented Dec 11, 2023

Will duplicate note in the PR.
fixes till this commit 7ae981b solve runtime error. However in the last commit closure invoked issues occur again.

Also there is a problem with oneshot canceled when mm2 was stopped
Screenshot from 2023-12-11 13-29-09

@onur-ozkan onur-ozkan added in progress Changes will be made from the author and removed under review labels Dec 11, 2023
@artemii235
Copy link
Member

artemii235 commented Dec 11, 2023

However in the last commit closure invoked issues occur again.

I actually wanted to ask whether it solves the issue with cursor only when we need the first found item. I assume a more general fix is preferred because this one looks like a temporary workaround.

@laruh
Copy link
Member

laruh commented Dec 11, 2023

However in the last commit closure invoked issues occur again.

I actually wanted to ask whether it solves the issue with cursor only when we need the first found item. I assume a more general fix is preferred because this one looks like a temporary workaround.

It turned out that whole problem was in obtaining the first item. In CursorDriver::next method.
So fix should solve issue.

UPD: previously when we were trying to get one item by next(), the process continued in loop

Signed-off-by: borngraced <samuelonoja970@gmail.com>
@borngraced
Copy link
Member Author

borngraced commented Dec 11, 2023

However in the last commit closure invoked issues occur again.

I actually wanted to ask whether it solves the issue with cursor only when we need the first found item. I assume a more general fix is preferred because this one looks like a temporary workaround.

CursorAction doesn't know Closure was dropped immediately we returned from the function that called
next() to get first result so it tries to send another event to get the next result just millisecs after CursorDriver/Closure was dropped so I think Implementing first() can be a good fix too as we also don't need to loop to request first result.
cc. @artemii235

pub(crate) async fn next(&mut self) -> CursorResult<Option<(ItemId, Json)>> {
        loop {
            ...
            match cursor_action {
                CursorAction::Continue => cursor.continue_().map_to_mm(|e| CursorError::AdvanceError {
                    description: stringify_js_error(&e),
                })?,
                CursorAction::ContinueWithValue(next_value) => {
                    cursor
                        .continue_with_key(&next_value)
                        .map_to_mm(|e| CursorError::AdvanceError {
                            description: stringify_js_error(&e),
                        })?
                },
                // Don't advance the cursor.
                // Here we set the `stopped` flag so we return `Ok(None)` at the next iteration immediately.
                // This is required because `item_action` can be `CollectItemAction::Include`,
                // and at this iteration we will return `Ok(Some)`.
                CursorAction::Stop => self.stopped = true,
            };

            match item_action {
                CursorItemAction::Include => return Ok(Some(item.into_pair())),
                // Try to fetch the next item.
                CursorItemAction::Skip => (),
            }
        }
    }


     /// if getting the first result is what we want
    pub(crate) async fn first(&mut self) -> CursorResult<Option<(ItemId, Json)>> {
        let event = match self.cursor_item_rx.next().await {
            Some(event) => event,
            None => return Ok(None),
        };

        let _cursor_event = event.map_to_mm(|e| CursorError::ErrorOpeningCursor {
            description: stringify_js_error(&e),
        })?;

        let cursor = match cursor_from_request(&self.cursor_request)? {
            Some(cursor) => cursor,
            // No more items.
            None => return Ok(None),
        };

        let (_, js_value) = match (cursor.key(), cursor.value()) {
            (Ok(key), Ok(js_value)) => (key, js_value),
            // No more items.
            _ => return Ok(None),
        };

        let item: InternalItem =
            deserialize_from_js(js_value).map_to_mm(|e| CursorError::ErrorDeserializingItem(e.to_string()))?;

        Ok(Some(item.into_pair()))
    }
    ```
    

@borngraced
Copy link
Member Author

@laruh i improved the impl. so you can test it again. thanks

@laruh
Copy link
Member

laruh commented Dec 11, 2023

@borngraced recheck clippy or fmt please, CI fails

@borngraced
Copy link
Member Author

@laruh
Copy link
Member

laruh commented Dec 11, 2023

not an issue with my pr? https://github.com/KomodoPlatform/komodo-defi-framework/actions/runs/7166781305/job/19511514652?pr=2028#step:3:8

Hmm lets rerun to check

UPD: yeah, its not your branch issue. locally its fine.

Signed-off-by: borngraced <samuelonoja970@gmail.com>
@borngraced borngraced added under review and removed in progress Changes will be made from the author labels Dec 11, 2023
@borngraced
Copy link
Member Author

borngraced commented Jan 11, 2024

I actually wanted to ask whether it solves the issue with cursor only when we need the first found item. I assume a more general fix is preferred because this one looks like a temporary workaround.

I agree with this, we should maybe have a limit function instead of first where we specify limit as 1 for first item. We can also add an until function which loops until a condition is found, maybe instead of until we can add where condition that can be used with limit. Since this case is not solved

while let Some((_item_id, header)) = cursor
.next()
.await
.map_err(|err| BlockHeaderStorageError::get_err(&ticker, err.to_string()))?
{
if header.bits == max_bits {
continue;
}
let serialized = &hex::decode(header.raw_header).map_err(|e| BlockHeaderStorageError::DecodeError {
coin: ticker.clone(),
reason: e.to_string(),
})?;
let mut reader = Reader::new_with_coin_variant(serialized, ticker.as_str().into());
let header: BlockHeader =
reader
.read()
.map_err(|e: serialization::Error| BlockHeaderStorageError::DecodeError {
coin: ticker,
reason: e.to_string(),
})?;
return Ok(Some(header));
}

After many hours of debugging I found another fix which introduces a method called where_ that takes a closure. The closure returns a boolean, allowing you to control when a loop should stop. This approach works reliably in various situations.

full explanation: #2028 (comment)

cc. @shamardy @laruh

@borngraced borngraced changed the title feat(indexeddb): get first result from IDB cursor fix(indexeddb): fix IDB cursor.continue_() call after drop Jan 11, 2024
@laruh
Copy link
Member

laruh commented Jan 12, 2024

@borngraced
can confirm that the last commit f8f1940 from impl_first_method_for_cursor branch + this commit from impl_first_method_for_cursor-test_nft_with_where_ branch dont have runtime errors
Screenshot from 2024-01-12 18-43-54

You can cherry pick the last commit from impl_first_method_for_cursor-test_nft_with_where_

@shamardy
Copy link
Collaborator

we should maybe have a limit function instead of first

@borngraced Thanks for the great find and fix. Can we use condition to request a certain number of items? I asked for a limit or similar functionalities to not get all items for cases like below where we currently fetch all items then we just return the limit requested (requested page).

let items = match (&filter.my_coin, &filter.other_coin) {
(Some(my_coin), Some(other_coin)) => {
my_swaps_table
.cursor_builder()
.only("my_coin", my_coin)?
.only("other_coin", other_coin)?
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("with_my_other_coins")
.await?
.collect()
.await?
},
(Some(my_coin), None) => {
my_swaps_table
.cursor_builder()
.only("my_coin", my_coin)?
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("with_my_coin")
.await?
.collect()
.await?
},
(None, Some(other_coin)) => {
my_swaps_table
.cursor_builder()
.only("other_coin", other_coin)?
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("with_other_coin")
.await?
.collect()
.await?
},
(None, None) => {
my_swaps_table
.cursor_builder()
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("started_at")
.await?
.collect()
.await?
},
};
let uuids: BTreeSet<OrderedUuid> = items
.into_iter()
.map(|(_item_id, item)| OrderedUuid::from(item))
.collect();
match paging_options {
Some(paging) => take_according_to_paging_opts(uuids, paging),
None => {
let total_count = uuids.len();
Ok(MyRecentSwapsUuids {
uuids_and_types: uuids
.into_iter()
.map(|ordered| (ordered.uuid, ordered.swap_type))
.collect(),
total_count,
skipped: 0,
})
},
}

You can see how limit and offset is used with sqlite to get only the values required
let skipped = match paging_options {
Some(paging) => {
// calculate offset, page_number is ignored if from_uuid is set
let offset = match paging.from_uuid {
Some(uuid) => offset_by_uuid(conn, &query_builder, &params, &uuid)?,
None => (paging.page_number.get() - 1) * paging.limit,
};
query_builder.limit(paging.limit);
query_builder.offset(offset);
offset
},
None => 0,
};

P.S. Maybe this enhancement can be done in another PR if it's too much to do here since it's unrelated to closure invoked recursively or after being dropped error. I actually don't know how much overhead we currently have by fetching all values.

@borngraced
Copy link
Member Author

borngraced commented Jan 17, 2024

we should maybe have a limit function instead of first

@borngraced Thanks for the great find and fix. Can we use condition to request a certain number of items? I asked for a limit or similar functionalities to not get all items for cases like below where we currently fetch all items then we just return the limit requested (requested page).

let items = match (&filter.my_coin, &filter.other_coin) {
(Some(my_coin), Some(other_coin)) => {
my_swaps_table
.cursor_builder()
.only("my_coin", my_coin)?
.only("other_coin", other_coin)?
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("with_my_other_coins")
.await?
.collect()
.await?
},
(Some(my_coin), None) => {
my_swaps_table
.cursor_builder()
.only("my_coin", my_coin)?
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("with_my_coin")
.await?
.collect()
.await?
},
(None, Some(other_coin)) => {
my_swaps_table
.cursor_builder()
.only("other_coin", other_coin)?
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("with_other_coin")
.await?
.collect()
.await?
},
(None, None) => {
my_swaps_table
.cursor_builder()
.bound("started_at", from_timestamp, to_timestamp)
.open_cursor("started_at")
.await?
.collect()
.await?
},
};
let uuids: BTreeSet<OrderedUuid> = items
.into_iter()
.map(|(_item_id, item)| OrderedUuid::from(item))
.collect();
match paging_options {
Some(paging) => take_according_to_paging_opts(uuids, paging),
None => {
let total_count = uuids.len();
Ok(MyRecentSwapsUuids {
uuids_and_types: uuids
.into_iter()
.map(|ordered| (ordered.uuid, ordered.swap_type))
.collect(),
total_count,
skipped: 0,
})
},
}

You can see how limit and offset is used with sqlite to get only the values required

let skipped = match paging_options {
Some(paging) => {
// calculate offset, page_number is ignored if from_uuid is set
let offset = match paging.from_uuid {
Some(uuid) => offset_by_uuid(conn, &query_builder, &params, &uuid)?,
None => (paging.page_number.get() - 1) * paging.limit,
};
query_builder.limit(paging.limit);
query_builder.offset(offset);
offset
},
None => 0,
};

P.S. Maybe this enhancement can be done in another PR if it's too much to do here since it's unrelated to closure invoked recursively or after being dropped error. I actually don't know how much overhead we currently have by fetching all values.

We can use indexedb curosor.advance to achieve similar behavior as offset since there's no such functionalities in indexeded yet

and something like this for limit

const limit = 10;
let count = 0;
if cursor && count < limit {
  cursor.continue();
  count++;
}

but I'll research and explore other options for sure

Copy link
Member

@laruh laruh left a comment

Choose a reason for hiding this comment

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

I have small note :)

UPD not so small as I thought first time

laruh
laruh previously approved these changes Jan 19, 2024
Copy link
Member

@laruh laruh left a comment

Choose a reason for hiding this comment

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

Thanks! LGTM!

Copy link
Collaborator

@shamardy shamardy left a comment

Choose a reason for hiding this comment

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

Thank you for the fixes! A few more comments :)

mm2src/mm2_db/src/indexed_db/drivers/cursor/cursor.rs Outdated Show resolved Hide resolved
// We need to provide any constraint on the `height` property
// since `ticker_height` consists of both `ticker` and `height` properties.
.bound("height", BeBigUint::from(0u64), BeBigUint::from(u64::MAX))
// Cursor returns values from the lowest to highest key indexes.
// But we need to get the most highest height, so reverse the cursor direction.
.reverse()
.where_(condition)
Copy link
Collaborator

Choose a reason for hiding this comment

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

What would happen if there are more than one object/record that satisfies the condition? I see that we return the first object, are we sure we won't get Uncaught Error: closure invoked recursively or after being dropped. This is part of the reason I wanted where and limit, so we can get more than one object that satisfies the condition, if we want this in other cases (other than the case here where we want only the first object).

P.S. If we don't get the error when there are more than one object that satisfies the condition, then the inclusion of limit and refactoring of where to return more than one object if they exist can be done in a different PR since this PR purpose is to fix the error only.

Copy link
Member Author

Choose a reason for hiding this comment

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

hmm this seems like another enhancement that's not related to this PR.

This PR only handle case for retrieving the first item that meets a condition(if specified) which solves js exception error: Uncaught Error: closure invoked recursively or after being But for sure this PR indeed fixed that and also optimizes calls to indexeddb.

I will work on the later issue during next print.

shamardy
shamardy previously approved these changes Feb 13, 2024
Copy link
Collaborator

@shamardy shamardy left a comment

Choose a reason for hiding this comment

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

LGTM but for a few nits! Please fix the first comment so that I can finally merge this PR.

mm2src/mm2_db/src/indexed_db/drivers/cursor/cursor.rs Outdated Show resolved Hide resolved
Copy link
Collaborator

@shamardy shamardy left a comment

Choose a reason for hiding this comment

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

Re-approve

@shamardy shamardy merged commit a877aca into dev Feb 13, 2024
23 of 30 checks passed
@shamardy shamardy deleted the impl_first_method_for_cursor branch February 13, 2024 15:33
dimxy added a commit to dimxy/komodo-defi-framework that referenced this pull request Feb 18, 2024
* evm-hd-wallet: (27 commits)
  Fix todo comments
  Fix HDAddressOps::Address trait bounds
  fix(indexeddb): fix IDB cursor.continue_() call after drop (KomodoPlatform#2028)
  security bump for `h2` (KomodoPlatform#2062)
  fix(makerbot): allow more than one prices url in makerbot (KomodoPlatform#2027)
  fix(wasm worker env): refactor direct usage of `window` (KomodoPlatform#1953)
  feat(nft): nft abi in withdraw_nft RPC, clear_nft_db RPC (KomodoPlatform#2039)
  refactor(utxo): refactor utxo output script creation (KomodoPlatform#1960)
  feat(ETH): balance event streaming for ETH (KomodoPlatform#2041)
  chore(release): bump mm2 version to 2.1.0-beta (KomodoPlatform#2044)
  feat(trezor): add segwit support for withdraw with trezor (KomodoPlatform#1984)
  chore(config): remove vscode launchjson (KomodoPlatform#2040)
  feat(trading-proto-upgrade): wasm DB, kickstart, refund states, v2 RPCs (KomodoPlatform#2015)
  feat(UTXO): balance event streaming for Electrum clients (KomodoPlatform#2013)
  feat(tx): add new sign_raw_transaction rpc for UTXO and EVM coins (KomodoPlatform#1930)
  fix(p2p): handle encode_and_sign errors (KomodoPlatform#2038)
  chore(release): add changelog entries for v2.0.0-beta (KomodoPlatform#2037)
  chore(network): write network information to stdout (KomodoPlatform#2034)
  fix(price_endpoints): add cached url (KomodoPlatform#2032)
  deps(network): sync with upstream yamux (KomodoPlatform#2030)
  ...
dimxy added a commit to dimxy/komodo-defi-framework that referenced this pull request Feb 25, 2024
* dev:
  feat(zcoin): ARRR WASM implementation (KomodoPlatform#1957)
  feat(trading-proto-upgrade): locked amounts, kmd burn and other impl (KomodoPlatform#2046)
  fix(indexeddb): set stop on success cursor condition (KomodoPlatform#2067)
  feat(config): add `max_concurrent_connections` to mm2 config (KomodoPlatform#2063)
  feat(stats_swaps): add gui/mm_version in stats db (KomodoPlatform#2061)
  fix(indexeddb): fix IDB cursor.continue_() call after drop (KomodoPlatform#2028)
  security bump for `h2` (KomodoPlatform#2062)
  fix(makerbot): allow more than one prices url in makerbot (KomodoPlatform#2027)
  fix(wasm worker env): refactor direct usage of `window` (KomodoPlatform#1953)
  feat(nft): nft abi in withdraw_nft RPC, clear_nft_db RPC (KomodoPlatform#2039)
  refactor(utxo): refactor utxo output script creation (KomodoPlatform#1960)
  feat(ETH): balance event streaming for ETH (KomodoPlatform#2041)
  chore(release): bump mm2 version to 2.1.0-beta (KomodoPlatform#2044)
  feat(trezor): add segwit support for withdraw with trezor (KomodoPlatform#1984)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants