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

Rework AsyncIter #811

Merged
merged 6 commits into from
Jun 10, 2024
Merged

Rework AsyncIter #811

merged 6 commits into from
Jun 10, 2024

Conversation

tbekas
Copy link
Contributor

@tbekas tbekas commented May 15, 2024

  • Separate modules for AsyncIter[T] and Iter[T]
  • Tests for both asynciter.nim and iter.nim
  • Helper methods for converting from QueryIter to AsyncIter

@tbekas tbekas force-pushed the asynciter branch 3 times, most recently from 4db0594 to bf84aa2 Compare May 15, 2024 16:46
codex/indexingstrategy.nim Outdated Show resolved Hide resolved
@tbekas tbekas force-pushed the asynciter branch 7 times, most recently from fc13503 to 23811a2 Compare May 20, 2024 15:23
@tbekas tbekas force-pushed the asynciter branch 5 times, most recently from fc555d8 to 9bbe0f6 Compare June 4, 2024 10:00
@tbekas tbekas marked this pull request as ready for review June 4, 2024 11:59
@tbekas tbekas requested review from benbierens and gmega June 4, 2024 12:25
Copy link
Contributor

@benbierens benbierens left a comment

Choose a reason for hiding this comment

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

Nice improvements all around.

): Future[?!AsyncIter[?!QueryResponse[T]]] {.async.} =
## Converts `QueryIter[T]` to `AsyncIter[?!QueryResponse[T]]` and automatically
## runs dispose whenever `QueryIter` finishes or whenever an error occurs (only
## if the flag finishOnErr is set to true)
Copy link
Contributor

Choose a reason for hiding this comment

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

There is another case in which a queryIter must be disposed. This is when an iteration is stopped early, before all items have been yielded. LevelDB showed that we were leaking unfinished iters. Sqlite would clean them up automatically on db.close. But LevelDB will throw instead.

I'm not sure this impacts your changes. But please make sure that the QueryIter is disposed correctly, even when the async iter does not error and does not run to completion.

See the TODO here reservations.nim in proc findAvailability:

# TODO: As soon as we're on ARC-ORC, we can use destructors

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for this comment. I realise that there's a flow like that, but I didn't realise we can address this flow when we have ARC. Current use cases are not finishing iteration prematurely, so either all items have been traversed or an error occurred - both cases are handled.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah it looks in general hard to detect an abandoned iterator unless you can tap into GC hooks. Maybe have an explicit destruct or close operation for query iterators which clients should call if they decide not consume its results anymore? The other option I can think of is a timeout, but that's even trickier I think...

Copy link
Contributor

Choose a reason for hiding this comment

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

We have this. The query iterator has a dispose you're supposed to call when you're done with the thing. It's done automatically when you complete the iterator and so no one calls dispose. Which is why it started causing problems in this case I linked when levelDB was rolled out. It made the problem visible.
Ideally, when the iterator object is cleaned up, we dispose the iterator handle. The current GC doesn't give us this hook, hence the TODO.

codex/utils/asynciter.nim Show resolved Hide resolved
codex/utils/iter.nim Show resolved Hide resolved
tds: TypedDatastore

setupAll:
tds = TypedDatastore.init(SQLiteDatastore.new(Memory).tryGet())
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be useful to use the LevelDB one, just because it is more picky about disposing iterators. It'll help find issues.
There is no in-memory LevelDB datastore. There's a helper called TempLevelDb that is used in tests for this. Examples everywhere! :D

Copy link
Contributor Author

@tbekas tbekas Jun 5, 2024

Choose a reason for hiding this comment

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

I prefer such tests to be done in memory only. Maybe there's some in-memory filesystem that we can use for LevelDb?

Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't looked into this. I don't mind either way. It's just that I noticed using the levelDB for real changes the timing behavior, which has already in the past revealed issues we didn't see. So you can keep the test Sqlite if you want. I'd still suggest you at least run it a few times with the levelDB thing instead, just to see nothing mysterious shows up.

teardownAll:
(await tds.close()).tryGet

test "Should auto-dispose when QueryIter finishes":
Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm reading this right, this test runs the iter to completion. It'd be really nice to have a test that does not. So it iterates only the first few items of a longer list.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure what such test would suppose to check?

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 @benbierens wants to guarantee that the query iterator is disposed even if it's not run to completion (which, I think, currently it won't be).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, not sure if that's possible.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yes!
You see, if you use the leveldb helper, then in the setup you create the DB and in teardown you dispose it. If your code does not correctly release the iterators, then your teardown will throw. This way you know there's a problem. (Sqlite doesn't do this, it quietly ignores undisposed iterators.)
So then simply having a test that partially iterates a list would reveal the problem if it's there.

Copy link
Contributor Author

@tbekas tbekas Jun 7, 2024

Choose a reason for hiding this comment

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

So then simply having a test that partially iterates a list would reveal the problem if it's there.

I mean we know that the iterator will not be disposed in this case. If I would add such test it would always fail on the teardown. Also such test would be impossible to fix currently according to the TODO mentioned in this comment.

We could try to assert that iter isn't disposed in such scenario, but that's even worse, because we would specify (tests are specs) that the expected behavior is not disposing an iterator. Therefore I just prefer to leave it unspecified.

If I'm missing something please provide me an example how such test should look like.

Copy link
Contributor

Choose a reason for hiding this comment

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

It sounds to me like using the query-iter-helper will guarantee we leak the iterator whenever a query is not run to completion. This can be solved by the other GC in the future. But I suppose in the meantime, we need a way to manually dispose the iter when the user of the iter knows that it's not going to complete.
Either asyncIter has to support manually disposing (for now), or we shouldn't use it instead of queryIter. :| This sucks, but crashes due to leaks or other memory issues are a nightmare to even detect.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Please elaborate on how using this module will guarantee that "we leak the iterator", maybe an example?

Copy link
Member

@gmega gmega left a comment

Choose a reason for hiding this comment

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

I am puzzled by some things, but maybe because I'm lacking context. I won't block the approval of this PR though as you have enough people asking for things there already. :-)

@@ -120,7 +120,7 @@ proc getPendingBlocks(
CatchableError,
"Future for block id not found, tree cid: " & $manifest.treeCid & ", index: " & $index)

Iter.new(genNext, isFinished)
newAsyncIter[(?!bt.Block, int)](genNext, isFinished)
Copy link
Member

Choose a reason for hiding this comment

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

Hm... this is sort of different from our style guide. Any reason to have it done like that instead of AsyncIter.new?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The new style will not work well with generics to my knowledge. This call is not going to work: AsyncIter.new[T](...) hence the old style used here and to keep it consistent it's used in both Iter and AsyncIter.

Copy link
Member

@gmega gmega Jun 7, 2024

Choose a reason for hiding this comment

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

Really? This seems to compile fine here:

type MyGeneric[U, V] = ref object of RootObj
  u: U
  v: V

proc new*[U, V](t: type MyGeneric[U, V], u: U, v: V): MyGeneric[U, V] = 
  MyGeneric[U, V](u: u, v: v)

var a = MyGeneric[int, string].new(1, "hello")
echo a.u, " ", a.v

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I haven't tried to use

AsyncIter[T].new(...)

I just tried

AsyncIter.new[T](...)

and that lead to compilation errors. I will the first notation and see if it works.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I used AsyncIter[T].new(...) and it works! Thanks for the suggestion @gmega 👍

codex/erasure/erasure.nim Outdated Show resolved Hide resolved
codex/indexingstrategy.nim Show resolved Hide resolved
): Future[?!AsyncIter[?!QueryResponse[T]]] {.async.} =
## Converts `QueryIter[T]` to `AsyncIter[?!QueryResponse[T]]` and automatically
## runs dispose whenever `QueryIter` finishes or whenever an error occurs (only
## if the flag finishOnErr is set to true)
Copy link
Member

Choose a reason for hiding this comment

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

Yeah it looks in general hard to detect an abandoned iterator unless you can tap into GC hooks. Maybe have an explicit destruct or close operation for query iterators which clients should call if they decide not consume its results anymore? The other option I can think of is a timeout, but that's even trickier I think...

codex/utils/asynciter.nim Show resolved Hide resolved
codex/utils/asynciter.nim Show resolved Hide resolved
codex/utils/asynciter.nim Show resolved Hide resolved
isFinished = () => iter.finished
)

proc mapFilter*[T, U](iter: Iter[T], mapPredicate: Function[T, Option[U]]): Iter[U] =
Copy link
Member

Choose a reason for hiding this comment

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

Why do we use result objects and handle errors in mapPredicate/iter.next in the async version, but here we don't?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well there's some handling already provided by newIter, so the iter will finish normally. However it's not perfect (last item before error will get lost I think). It's simply a bit more difficult in non-async version, so I decided to not implement it. I will add it though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Last item before error should no longer get lost.

teardownAll:
(await tds.close()).tryGet

test "Should auto-dispose when QueryIter finishes":
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 @benbierens wants to guarantee that the query iterator is disposed even if it's not run to completion (which, I think, currently it won't be).

check:
items == @[1, 3]

test "Should leave only odd items using `mapFilter`":
Copy link
Member

Choose a reason for hiding this comment

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

I don't see a test path for the error handling.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In line 79 there's a test that checks if iter finishes on err.

Copy link
Member

Choose a reason for hiding this comment

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

This file only goes until line 77 😂

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Probably you look on the outdated version of this file. I added tests for checking errors after @benbierens comments.

Copy link
Member

@gmega gmega left a comment

Choose a reason for hiding this comment

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

Nothing major on my end so provided you address the rest LGTM.

Copy link
Member

@AuHau AuHau left a comment

Choose a reason for hiding this comment

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

Generally LGTM, I would only like to point out the "catch" of CancelledError being inherited from CatchableError which should be then reraised and not swallowed. For more see Mark's post about it: https://discord.com/channels/895609329053474826/1175098439504244767/1242444718231523338

I think I found one potential place where this should be handled (see the other comment), but generally please have a look on your changes from this POV if maybe there are some other non-handled cases.

futU.complete(u)
nextFutU = some(futU)
break
except CatchableError as err:
Copy link
Member

Choose a reason for hiding this comment

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

I am not completely sure, but potentially you should check for CancelledError and re-raise it here: https://discord.com/channels/895609329053474826/1175098439504244767/1242444718231523338

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Very valueable comment, thanks for pointing it out. I will fix it and add tests to make sure cancellation gets properly propagated.

tests/codex/utils/testasynciter.nim Show resolved Hide resolved
proc fromSlice*[U, V: Ordinal](_: type Iter, slice: HSlice[U, V]): Iter[U] =
## Creates new iterator from slice
##
proc new*[U, V: Ordinal](_: type AsyncIter[U], slice: HSlice[U, V]): AsyncIter[U] =
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not clear anymore what any of this constructors do by themself. Lets add some comments to document it please.

Copy link
Contributor

@dryajov dryajov left a comment

Choose a reason for hiding this comment

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

LGTM, except for the documentation comment. But we can address in a separate PR.

@gmega gmega added this pull request to the merge queue Jun 10, 2024
Merged via the queue into master with commit f51ef52 Jun 10, 2024
10 checks passed
@gmega gmega deleted the asynciter branch June 10, 2024 23:48
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.

None yet

5 participants