Skip to content

Instantly share code, notes, and snippets.

@knaeckeKami
Last active January 5, 2023 00:11
Show Gist options
  • Save knaeckeKami/99c515489535c88340d698e812a75ed1 to your computer and use it in GitHub Desktop.
Save knaeckeKami/99c515489535c88340d698e812a75ed1 to your computer and use it in GitHub Desktop.

Migrating Ferry to Isolates

Problem

Parsing large graphql responses and normalizing them is a potentially expensive operation that can lead to skipped frames.

Users complained about this in issues like this: gql-dart/ferry#394 (comment)

Goal

Make it possible for users to run their graphql client in a separate isolate, considering these goals:

  • The original API should stay available for users who do not want to migrate and for users on the Web.

  • Staying as close to the original API as possible. In particular, the TypedLink interface should be preserved so that the isolate client is mostly a drop-in replacement for the majority of users. Since Ferry was already Stream based and followed a Request -> Stream of responses structure, I believed this should be easily achievable over Isolates.

  • For use cases that are not possible to implement using the existing API (e.g. synchronous access to the cache), offer asynchronous alternatives

  • Initializing the client should be as easy as possible for users, even if they are not familiar with the Isolate API.

  • It must be possible for users to add custom communication between the ferry isolate and the main isolate, especially e.g. passing and storing authentication tokens.

Result

This is how the original PR looked like: gql-dart/ferry#405

I believe the first three goals were achieved. I received feedback from users that it works for them, and that jank got noticeably better.

A few users struggled with the last two goals though.

Especially enabling custom communication between the isolates by doing the "create a new ReceivePort, send the SendPort of the ReceivePort over the other SendPort"-handshake is hard to understand and probably should be abstracted away in a future version of ferry.

Architecture

The first challenge was to decide on a general Architecture. There are only a few resources on best practices on how to do non-trivial communication over Isolates.

Decisions I struggled with:

Should all communication be done over one ReceivePort/SendPort pair? Or should there be one "main" pair but every method call creates a new private ReceivePort for communication for this specific method call?

I did not know the performance impact on creating many ReceivePorts. But it seemed cheap enough that I went for the latter approach.

I went for the following architecture:

  • On the main isolate, an instance of IsolateClient holds the SendPort to communicate with the ferry Isolate.

  • The IsolateClient sends instances of IsolateCommand over the SendPort. Each IsolateCommand corresponds to a method call of the original API. The IsolateClient listens to responses from the other isolate and maps them to a Future or Stream.

    Simple example: ReadQueryCommand reads the result of a Query from the cache.

  • The IsolateClient is created by calling the IsolateClient.create method. Similarly to the compute() function, the user will pass a top-level function and the parameters. This function will be called on the new isolate to create the "real" client that executes requests (using the existing API of ferry). It's not called directly though, the actual entry point is here, it performs a [SendPort] handshake before initializing the client.

Challenges when implementing

Miscellaneous bugs that occurred

  • sending Network errors from the ferry isolate to the main isolate could fail because the thrown Exception would contain a reference the network request, which would have a reference to a MultiPartFile which is not serializable because it contains a NativeWrapper.

It was hard to pinpoint this issue because the exception thrown when trying to send the object is just

Illegal argument in isolate message: (object extends NativeWrapper - Library:'dart:io' Class: _RandomAccessFileOpsImpl@14069316)

If it would show the "path" in the object graph to the illegal field it would have made my life much easier!

Example error message that I wish would be thrown:

Illegal argument in isolate message: DioError -> request -> data -> (object extends NativeWrapper - Library:'dart:io' Class: _RandomAccessFileOpsImpl@14069316)

@mkustermann
Copy link

mkustermann commented Jan 4, 2023

Thank you very much for writing this up, @knaeckeKami !

A few notes:

Parsing large graphql responses and normalizing them is a potentially expensive operation that can lead to skipped frames.

The current implementation of DeepCollectionEquality is very slow - on a simple example 70x slower than manual code - (see dart-lang/collection#263), one could instead use a different implementation.

A few users struggled with the last two goals though.

Especially enabling custom communication between the isolates by doing the "create a new ReceivePort, send the SendPort of the ReceivePort over the other SendPort"-handshake is hard to understand and probably should be abstracted away in a future version of ferry.
...
I created an ad-hoc protocol for wiring Streams over SendPorts while preserving cancel/done events.

We do have an open issue regarding convenience layers on top of isolates and ports. See dart-lang/sdk#48579. Consider upvoting the issue & providing feedback what kind of convenience layers would be helpful for you.

For Flutter apps, people likely want to use getApplicationDocumentsDirectory() from path_provider. However, since this function calls Method channels, it's only available on the main isolate.

There was recently work in this area. I was under the impression that the work for flutter/flutter#13937 has been completed (see also this doc) and it's now possible to use method channels from any isolate, is this not true?

sending Network errors from the ferry isolate to the main isolate could fail because the thrown Exception would contain a reference the network request, which would have a reference to a MultiPartFile which is not serializable because it contains a NativeWrapper.

We do have a tracking bug for it dart-lang/sdk#48592. Consider upvoting the issue.

@knaeckeKami
Copy link
Author

Thanks so much for your thoughtful response!

The current implementation of DeepCollectionEquality is very slow - on a simple example 70x slower than manual code - (see dart-lang/collection#263), one could instead use a different implementation.

Thank you. I will consider switching to another implementation - We need to compare standard JSON-like HashMaps, so DeepCollectionEquality seems indeed like overkill. I was not aware of this considerable performance penalty for using it!

Maybe this will solve most of the user pain even without using Isolates...

There was recently work in this area. I was under the impression that the work for flutter/flutter#13937 has been completed (see also this doc) and it's now possible to use method channels from any isolate, is this not true?

Looks like it! Will definitely try this out on the master branch of flutter.

We do have a tracking bug for it dart-lang/sdk#48592. Consider upvoting the issue.

Thanks, upvoted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment