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(client): asyncEventEmitter to not silence unhandled exceptions raised in event handlers #1247

Merged
merged 6 commits into from
May 30, 2024

Conversation

samwillis
Copy link
Contributor

@samwillis samwillis commented May 9, 2024

Currently if there is an unhandled exception in an event handler of asyncEventEmitter they are silenced and don't make it to a global error handler or console. This changes it to re-throw them async using queueMicrotask so that they happen outside of the promise awaited by allSettled.

@samwillis samwillis requested review from msfstef and kevin-dp May 9, 2024 09:11
Copy link
Contributor

@msfstef msfstef left a comment

Choose a reason for hiding this comment

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

I feel like it would be better to allow listeners to specify error handling as well and forward the error handling to them but this is definitely better than suppressing the error entirely!

Could you add a test for this as well? we can mock the global error handler in the test to assert it catches this

@samwillis
Copy link
Contributor Author

@msfstef

it would be better to allow listeners to specify error handling as well

I think a listener should handle it's own errors in the usual way, they can wrap their whole handler in a try if needed.

@msfstef
Copy link
Contributor

msfstef commented May 9, 2024

@samwillis definitely possible to leave it like that, although making the listener accept an error handler and wrapping it internally feels cleaner and forces the subscriber to handle errors (i.e. not a choice)

@msfstef msfstef force-pushed the samwillis/fix-asyncEventEmmitter-errors branch from 2aaa322 to 8d40d05 Compare May 21, 2024 14:49
private async _applySubscriptionData(
changes: InitialDataChange[],
lsn: LSN,
additionalStmts: Statement[] = [],
subscriptionId?: string
) {
): Promise<boolean> {
Copy link
Contributor

Choose a reason for hiding this comment

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

really ugly fix but this is a symptom of how we've mixed and matched the architecture here - if _applySubscriptionData fails, it still resolves but fires _handleSubscriptionError. However the error handler resets the state of the subscriptionManager, so the afterApply call in lines 513-516 still fires because _applySubscriptionData resolves without an issue but because the state of the subscription manager has been reset it attempts to resolve promises that are no longer present.

I think satellite probably needs a bit of rearchitecting as we're seeing various issues w.r.t. timing and async.

@msfstef
Copy link
Contributor

msfstef commented May 21, 2024

@samwillis turns out the underlying issue was one resolved by #1251, but I still changed the processing logic in the event emitter to expose a way to be able to wait for any currently running async listeners.

I've made the client shutdown async in order to make sure that things finish running before shutting it down completely - it's still not as clean as I'd like but I think it should resolve the test issues for now.

Rebasing also exposed the issue reported by #1283 so I fixed that as well in this PR as it was related.

@samwillis
Copy link
Contributor Author

@msfstef Looks good to me, at least for now. I agree we need to re-arch some of this.

There is a failing test, not sure if that's the flaky one or a new one?

@msfstef
Copy link
Contributor

msfstef commented May 22, 2024

@samwillis the e2e was not flaky, the async event emitter change revealed a minor regression we had during the PG in the client integration where bytearrays were not being deserialized to pure Uint8Arrays - not a significant issue, I doubt any real users would encounter it, but it was making better-sqlite3 throw for the empty bytearray edge case.

@msfstef msfstef linked an issue May 22, 2024 that may be closed by this pull request
Copy link
Contributor

@kevin-dp kevin-dp left a comment

Choose a reason for hiding this comment

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

Left one comment wrt queueMicrotask() function, for the rest LGTM.

// If a listener throws an error, we re-throw it asynchronously so that the queue can continue
// to be processed, this ensures that the exception isn't swallowed by allSettled below.
// It will likely be caught by a global error handler, or be logged to the console.
queueMicrotask(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is queueMicrotask available in all environments?
The JS docs say:

The queueMicrotask() method, which is exposed on the Window or Worker interface

So they are talking about browser environments.
Looked it up for node and node seems to support it as well.
Not sure about native environments such as react-native?

Perhaps we don't need this queueMicrotask trick since allSettled returns an array of promise statuses (fulfilled or rejected). So we could loop over the processingProm array, aggregate all errors into one, and then throw the aggregated error. Would that work?

Copy link
Contributor

@msfstef msfstef May 23, 2024

Choose a reason for hiding this comment

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

I'm pretty sure queueMicrotask is widely supported, but I agree with your suggestion and I think looking at the settled promises and rethrowing is more idiomatic/clear.

It does change the behaviour slightly though as it will only throw once everything has settled, whereas with queueMicrotask the error would be bubbled up as soon as it happens. @samwillis what's your opinion?

https://github.com/electric-sql/electric/pull/1247/files#diff-503f0d4f6fe4b83c4f32b152d83374bb5a0f8f100a5604cfddc3ffa3cfee40f6R149-R169

Copy link
Contributor

Choose a reason for hiding this comment

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

I've merged the PR to avoid having it linger for too long as I think it's an important one to have in main, but if there's any disagreements on this last change let me know.

@msfstef msfstef merged commit 17e793c into main May 30, 2024
10 checks passed
@msfstef msfstef deleted the samwillis/fix-asyncEventEmmitter-errors branch May 30, 2024 08:29
// be suppressed and bugs may go unnoticed
settledPromises.forEach((rejectedProm) => {
if (rejectedProm.status === 'rejected') {
throw rejectedProm.reason
Copy link
Contributor

Choose a reason for hiding this comment

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

@msfstef One question. If there were 2 failing promises, wouldn't this only throw one of them? The throw would be stopping the forEach.

Copy link
Contributor

@msfstef msfstef May 30, 2024

Choose a reason for hiding this comment

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

@davidmartos96 you're absolutely right (and I also just noticed that they'recalled rejectedProm before the check because my initial impl was using a filter clause that TS complained about -_-) - it will interrupt the forEach so this is a bit confusing, but I liked it more than using an explicit loop or throwing the zeroth element of the filtered list.

That being said the previous fix using queueMicrotask would not suppress any errors the way this does... so perhaps either we reintroduce something like that, or bundle all of the errors together in a sort of error aggregator.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey @msfstef

just catching up on this (little time yesterday). I considered what @kevin-dp suggested but went with queueMicrotask as it would immediately re-throw the error and not wait for all to complete (which could be never)

Copy link
Contributor

Choose a reason for hiding this comment

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

@samwillis you're right about the possibility of the promises never resolving and thus never throwing - I think then we all agree to revert the throwing to the listener execution, only detail is whether we want to use queueMicrotask or perhaps setTimeout if we have compatibility concerns.

From what I'm seeing I doubt we'll have issues with queueMicrotask so we might as well go with that and address any compatibility issues if and when they come up.

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.

Possible issue in the new ShapeManager code.
4 participants