Skip to content

Allow headless JS tasks to retry#23231

Closed
JoshuaJamesOng wants to merge 1 commit into
facebook:masterfrom
JoshuaJamesOng:feature/headless-js-retry
Closed

Allow headless JS tasks to retry#23231
JoshuaJamesOng wants to merge 1 commit into
facebook:masterfrom
JoshuaJamesOng:feature/headless-js-retry

Conversation

@JoshuaJamesOng
Copy link
Copy Markdown
Contributor

@JoshuaJamesOng JoshuaJamesOng commented Jan 31, 2019

Summary

setTimeout inside a headless JS task does not always works; the function does not get invoked until the user starts an Activity.

This was attempted to be used in the context of widgets. When the widget update or user interaction causes the process and React context to be created, the headless JS task may run before other app-specific JS initialisation logic has completed. If it's not possible to change the behaviour of the pre-requisites to be synchronous, then the headless JS task blocks such asynchronous JS work that it may depend on. A primitive solution is the use of setTimeout in order to wait for the pre-conditions to be met before continuing with the rest of the headless JS task. But as the function passed to setTimeout is not always called, the task will not run to completion.

This PR solves this scenario by allowing the task to be retried again with a delay. If the task returns a promise that resolves to a {'timeout': number} object, AppRegistry.js will not notify that the task has finished as per master, instead it will tell HeadlessJsContext to startTask again (cleaning up any posted Runnables beforehand) via a Handler within the HeadlessJsContext.

Documentation also updated here: facebook/react-native-website#771

AppRegistry.js

If the task provider does not return any data, or if the data it returns does not contain timeout as a number, then it behaves as master; notifies that the task has finished. If the response does contain {timeout: number}, then it will attempt to queue a retry. If that fails, then it will behaves as if the task provider returned no response i.e. behaves as master again. If the retry was successfully queued, then there is nothing to do as we do not want the Service to stop itself.

HeadlessJsTaskSupportModule.java

Similar to notify start/finished, we simply check if the context is running, and if so, pass the request onto HeadlessJsTaskContext. The only difference here is that we return a Promise, so that AppRegistry, as above, knows whether the enqueuing failed and thus needs to perform the usual task clean-up.

HeadlessJsTaskContext.java

Before retrying, we need to clean-up any timeout Runnable's posted for the first attempt. Then we need to copy the task config so that if this retry (second attempt) also fails, then on the third attempt (second retry) we do not run into a consumed exception. This is also why in startTask we copy the config before putting it in the Map, so that the initial attempt does leave the config's in the map as consumed. Then we post a Runnable to call startTask on the main thread's Handler. We use the same taskId because the Service is keeping track of active task IDs in order to calculate whether it needs to stopSelf. This negates the need to inform the Service of a new task id and us having to remove the old one.

Changelog

[Android][added] - Allow headless JS tasks to return a promise that will cause the task to be retried again with the specified delay

Test Plan

A fork containing this fix has been integrated and tested in a project like below:

export const headlessJsTask = () => () =>
    async function updateWidget() {
        const isReady = ...;
        if (!isReady) {
            return new Promise(resolve => resolve({'timeout': 1000})); // 1000ms
        }

        // Do work that relies on pre-requisite
    };

@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jan 31, 2019
Copy link
Copy Markdown

@analysis-bot analysis-bot left a comment

Choose a reason for hiding this comment

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

Code analysis results:

  • eslint found some issues.

Comment thread Libraries/ReactNative/AppRegistry.js Outdated
Copy link
Copy Markdown

@analysis-bot analysis-bot left a comment

Choose a reason for hiding this comment

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

Code analysis results:

  • eslint found some issues.

Comment thread Libraries/ReactNative/AppRegistry.js Outdated
Comment thread Libraries/ReactNative/AppRegistry.js Outdated
Comment thread Libraries/ReactNative/AppRegistry.js Outdated
@JoshuaJamesOng JoshuaJamesOng force-pushed the feature/headless-js-retry branch from 4fbcea4 to c249557 Compare February 1, 2019 01:13
Copy link
Copy Markdown

@analysis-bot analysis-bot left a comment

Choose a reason for hiding this comment

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

Code analysis results:

  • eslint found some issues.

Comment thread Libraries/ReactNative/AppRegistry.js Outdated
Copy link
Copy Markdown
Contributor

@matthargett matthargett left a comment

Choose a reason for hiding this comment

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

Can you add a new test to WritableNativeMapTest for the copy() method? I'd ask for tests to cover the other areas, but there doesn't appear to be any existing tests for those classes to build off of. So, bonus point if you can add a test one or two of those currently uncovered classes as well.

@cpojer
Copy link
Copy Markdown
Contributor

cpojer commented Mar 22, 2019

@JoshuaJamesOng did you see @matthargett's comment and could you address it?

@cpojer
Copy link
Copy Markdown
Contributor

cpojer commented Apr 7, 2019

@JoshuaJamesOng ping?

@JoshuaJamesOng JoshuaJamesOng force-pushed the feature/headless-js-retry branch 2 times, most recently from 51162d2 to ff5d391 Compare April 8, 2019 12:20
@ejanzer
Copy link
Copy Markdown

ejanzer commented May 2, 2019

@JoshuaJamesOng I think this makes sense, the only part that feels a little weird to me is returning the timeout from the JS task. What if we allow you to configure whether or not retries are enabled (along with the timeout in ms, # of retries, etc.) from the native side (in HeadlessJsTaskConfig) and then we just retry on any JS exception if so? Maybe something like:

export const headlessJsTask = () => () =>
    async function updateWidget() {
        const isReady = ...;
        if (!isReady) {
          throw new Error('Not ready');
        }
       // other stuff if it's ready
    };

and then something like this in getTaskConfig (not sure if this is an up-to-date example, pulled it from the docs):

  new HeadlessJsTaskConfig(
    "SomeTaskName",
    Arguments.fromBundle(extras),
    5000, // timeout
    false, // allowed in foreground
    5, // number of retries
    1000 // retry after ms
  );

What do you think? Would that do what you need?.

@cpojer
Copy link
Copy Markdown
Contributor

cpojer commented May 9, 2019

@JoshuaJamesOng hey! Did you see @ejanzer's reply to your PR? Would you be able to make the changes she proposed so we can merge this?

@JoshuaJamesOng JoshuaJamesOng force-pushed the feature/headless-js-retry branch from ff5d391 to ea6928b Compare May 9, 2019 14:06
Copy link
Copy Markdown

@analysis-bot analysis-bot left a comment

Choose a reason for hiding this comment

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

Code analysis results:

  • eslint found some issues. Run yarn lint --fix to automatically fix problems.

Comment thread Libraries/ReactNative/AppRegistry.js Outdated
@JoshuaJamesOng
Copy link
Copy Markdown
Contributor Author

Have made the changes suggested. Note however that this does change the behaviour. Previously the author of the could decide when to retry. The new implementation retries on all errors. I wonder if we should make it so that the user still has control over which errors are retried? I'm worried we get stuck in a loop. Thoughts?

@JoshuaJamesOng JoshuaJamesOng force-pushed the feature/headless-js-retry branch from ea6928b to 93d3647 Compare May 9, 2019 14:11
@cpojer
Copy link
Copy Markdown
Contributor

cpojer commented May 9, 2019

@JoshuaJamesOng I think that's a good idea. Could you add it?

Copy link
Copy Markdown

@analysis-bot analysis-bot left a comment

Choose a reason for hiding this comment

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

Code analysis results:

  • eslint found some issues. Run yarn lint --fix to automatically fix problems.

Comment thread Libraries/ReactNative/AppRegistry.js Outdated
Comment thread Libraries/ReactNative/AppRegistry.js Outdated
Comment thread Libraries/ReactNative/HeadlessJsTask.js Outdated
Comment thread Libraries/ReactNative/HeadlessJsTask.js Outdated
Comment thread Libraries/ReactNative/HeadlessJsTask.js Outdated
@JoshuaJamesOng JoshuaJamesOng force-pushed the feature/headless-js-retry branch from 0bf79c6 to 963ab4f Compare May 10, 2019 00:53
Copy link
Copy Markdown

@ejanzer ejanzer left a comment

Choose a reason for hiding this comment

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

Looks good to me, thanks for doing this!

Comment thread Libraries/ReactNative/HeadlessJsTask.js Outdated
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can you change the file name to HeadlessJsTaskError to match the class? Also, instead of putting it at the top level, can you put it /Utilities, maybe?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do we reuse the taskId just so JS can continue to use the same id? Or to avoid adding entries to the active tasks map? (I think it makes sense to use the same taskId, just curious if there's another reason why it's needed)

@cpojer
Copy link
Copy Markdown
Contributor

cpojer commented May 17, 2019

Hey @JoshuaJamesOng! Do you mind addressing @ejanzer's last small comments? Then I think we can land it finally :)

…will cause the task to be retried again with a delay of the resolved value

Enable retries on all errors via HeadlessJsTaskConfig

Only retry if HeadlessJsTaskError

Use class for retry policy so that public API does not need to be changed to add things like exponential backoff
@cpojer cpojer force-pushed the feature/headless-js-retry branch from 963ab4f to fa6cac4 Compare June 5, 2019 09:03
Copy link
Copy Markdown
Contributor

@cpojer cpojer left a comment

Choose a reason for hiding this comment

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

I took care of the nits in order to take this over the finish line.

Copy link
Copy Markdown
Contributor

@facebook-github-bot facebook-github-bot left a comment

Choose a reason for hiding this comment

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

@cpojer is landing this pull request. If you are a Facebook employee, you can view this diff on Phabricator.

@react-native-bot
Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @JoshuaJamesOng in ac7ec46.

When will my fix make it into a release? | Upcoming Releases

@react-native-bot react-native-bot added the Merged This PR has been merged. label Jun 6, 2019
M-i-k-e-l pushed a commit to M-i-k-e-l/react-native that referenced this pull request Mar 10, 2020
Summary:
`setTimeout` inside a headless JS task does not always works; the function does not get invoked until the user starts an `Activity`.

This was attempted to be used in the context of widgets. When the widget update or user interaction causes the process and React context to be created, the headless JS task may run before other app-specific JS initialisation logic has completed. If it's not possible to change the behaviour of the pre-requisites to be synchronous, then the headless JS task blocks such asynchronous JS work that it may depend on. A primitive solution is the use of `setTimeout` in order to wait for the pre-conditions to be met before continuing with the rest of the headless JS task. But as the function passed to `setTimeout` is not always called, the task will not run to completion.

This PR solves this scenario by allowing the task to be retried again with a delay. If the task returns a promise that resolves to a `{'timeout': number}` object, `AppRegistry.js` will not notify that the task has finished as per master, instead it will tell `HeadlessJsContext` to `startTask` again (cleaning up any posted `Runnable`s beforehand) via a `Handler` within the `HeadlessJsContext`.

Documentation also updated here: facebook/react-native-website#771

### AppRegistry.js
If the task provider does not return any data, or if the data it returns does not contain `timeout` as a number, then it behaves as `master`; notifies that the task has finished. If the response does contain `{timeout: number}`, then it will attempt to queue a retry. If that fails, then it will behaves as if the task provider returned no response i.e. behaves as `master` again. If the retry was successfully queued, then there is nothing to do as we do not want the `Service` to stop itself.

### HeadlessJsTaskSupportModule.java
Similar to notify start/finished, we simply check if the context is running, and if so, pass the request onto `HeadlessJsTaskContext`. The only difference here is that we return a `Promise`, so that `AppRegistry`, as above, knows whether the enqueuing failed and thus needs to perform the usual task clean-up.

### HeadlessJsTaskContext.java
Before retrying, we need to clean-up any timeout `Runnable`'s posted for the first attempt. Then we need to copy the task config so that if this retry (second attempt) also fails, then on the third attempt (second retry) we do not run into a consumed exception. This is also why in `startTask` we copy the config before putting it in the `Map`, so that the initial attempt does leave the config's in the map as consumed. Then we post a `Runnable` to call `startTask` on the main thread's `Handler`. We use the same `taskId` because the `Service` is keeping track of active task IDs in order to calculate whether it needs to `stopSelf`. This negates the need to inform the `Service` of a new task id and us having to remove the old one.

## Changelog
[Android][added] - Allow headless JS tasks to return a promise that will cause the task to be retried again with the specified delay
Pull Request resolved: facebook#23231

Differential Revision: D15646870

fbshipit-source-id: 4440f4b4392f1fa5c69aab7908b51b7007ba2c40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Merged This PR has been merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants