Skip to content

Web worker for amp-bind#7436

Merged
dreamofabear merged 20 commits intoampproject:masterfrom
dreamofabear:bind-worker
Feb 13, 2017
Merged

Web worker for amp-bind#7436
dreamofabear merged 20 commits intoampproject:masterfrom
dreamofabear:bind-worker

Conversation

@dreamofabear
Copy link
Copy Markdown

Fixes #6910, partial for #6199.

  • Implements a Worker in a new entry point compiled to dist/ww.js
  • Adds AmpWorker class that wraps the Worker with a Promise-based API
  • Use in amp-bind to parse and evaluate expressions on background thread
  • Flagged under new "web-worker" experiment

@dreamofabear
Copy link
Copy Markdown
Author

/to @cramforce @jridgewell /cc @kmh287

PTAL, working on tests now.

Copy link
Copy Markdown
Member

@cramforce cramforce left a comment

Choose a reason for hiding this comment

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

Just a few comments. This is exciting!


this.evaluator_ = new BindEvaluator();
const parseErrors = this.evaluator_.setBindings(bindings);
// Parse on web worker if experiment is enabled.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you making this an experiment to not disrupt your other development, for easy benchmarking, or is there a different reason?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Mostly to reduce risk of breakages as we start talking to publishers (e.g. #7329). I can remove this shortly after verifying correctness in prod.

const {property, expressionString, previousResult} =
boundProperty;

// Rewrite attribute value if necessary.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Definitely fine for now. The URL API may be exposed to workers.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The problem is that parseUrl uses document.createElement, which IIUC is unavailable to workers.

To workaround, we could either change this implementation to not use DOM or even use Worker#postMessage to delegate this to main page. Thoughts?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

He's talking about the URL API.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ohh I see, thanks. Add a TODO here.

*/
sendMessage_(method, args) {
const promise = new Promise((resolve, reject) => {
if (!this.messages_[method]) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This works, but it might be simpler to have a map of outstanding messages with a simple running index that you increment for each message.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair, done.

}
}
if (empty) {
delete this.messages_[method];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As explained above, this is simpler with a running index and one map independent of method name.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.


let returnValue;

switch (method) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please add TODO and file issue to add error reporting to this.

We can slightly generify the code in src/service-worker/error-reporting.js

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. Filed #7453.

} else {
throw new Error(`${method}: BindEvaluator is not initialized.`);
}
break;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Throw on default case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

Copy link
Copy Markdown
Author

@dreamofabear dreamofabear left a comment

Choose a reason for hiding this comment

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

Thanks for the quick review.


this.evaluator_ = new BindEvaluator();
const parseErrors = this.evaluator_.setBindings(bindings);
// Parse on web worker if experiment is enabled.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Mostly to reduce risk of breakages as we start talking to publishers (e.g. #7329). I can remove this shortly after verifying correctness in prod.

*/
sendMessage_(method, args) {
const promise = new Promise((resolve, reject) => {
if (!this.messages_[method]) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair, done.

}
}
if (empty) {
delete this.messages_[method];
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.


let returnValue;

switch (method) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. Filed #7453.

} else {
throw new Error(`${method}: BindEvaluator is not initialized.`);
}
break;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

const {property, expressionString, previousResult} =
boundProperty;

// Rewrite attribute value if necessary.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The problem is that parseUrl uses document.createElement, which IIUC is unavailable to workers.

To workaround, we could either change this implementation to not use DOM or even use Worker#postMessage to delegate this to main page. Thoughts?

return invokeWebWorker(this.win_, 'bind.initialize', [bindings]);
} else {
this.evaluator_ = new BindEvaluator();
const parseErrors = this.evaluator_.setBindings(bindings);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No need to wrap this in a promise instance, just return parseErrors.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

}

dev().fine(TAG, `Initialized ${bindings.length} bindings from ` +
`${boundElements.length} elements.`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not just a worker.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

const {property, expressionString, previousResult} =
boundProperty;

// Rewrite attribute value if necessary.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

He's talking about the URL API.

return Promise.reject(`Experiment "${TAG}" is disabled.`);
}
if (!win.Worker) {
return Promise.reject('Worker not supported in window: ' + win);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The + win will generate a useless "[object Window]" string.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed.

var opts = Object.assign({}, options);

return compileJs('./src/web-worker/', 'web-worker.js', './dist/', {
toName: 'ww.max.js',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This will require special handling on our servers. /cc @vitaliybl

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good to know, I'll also reach out.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also CC @erwinmombay: A new JS binary on the way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

got it.

* @private
*/
sendMessage_(method, args) {
const promise = new Promise((resolve, reject) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: return here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

const {method, returnValue, id} =
/** @type {FromWorkerMessageDef} */ (event.data);

const message = this.messages_[id];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I hate this id based on messages.length. Let's use a monotonic AmpWorker#id, increasing on every message. Then do a binary search when we receive a message. (We're already doing O(n) on every message receipt)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Splice completed messages from the array and binary search to find them? Seems more complex and slower, I don't see the benefit.

...hate leads to suffering, suffering leads to the dark side.

😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We have a never ending array this way...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Only while messages are in flight which shouldn't be an issue for current functionality.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed this to use a counter per Malte's comment above.

let empty = true;
for (let i = 0; i < this.messages_.length && empty; i++) {
if (this.messages_[i] !== undefined) {
empty = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Break afterwards.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The termination condition contains && empty and @camelburrito hates break.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should be a while loop, then. 😛

}
}
if (empty) {
this.messages_ = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: message.length = 0 will truncate and save the array instance.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

👍


// TODO(choumx): Add error reporting.
switch (method) {
case 'bind.initialize':
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: I dislike massive switch statements. Can we split this these into functions (we can keep the switch, then), or split them into functions and use some sort of map from "method" -> function.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Point taken. With only two cases at the moment, using a <string, Function> map is less readable IMO. Besides, this file will need to be significantly refactored to support other use cases in the future anyways.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I mean something like:

switch (method) {
  case "whatever":
    returnValue = whatever(...);
    break;
  case "other":
    returnValue = other(...);
    break;
}

Copy link
Copy Markdown
Author

@dreamofabear dreamofabear left a comment

Choose a reason for hiding this comment

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

Thanks for the review.

return invokeWebWorker(this.win_, 'bind.initialize', [bindings]);
} else {
this.evaluator_ = new BindEvaluator();
const parseErrors = this.evaluator_.setBindings(bindings);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

}

dev().fine(TAG, `Initialized ${bindings.length} bindings from ` +
`${boundElements.length} elements.`);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

const {property, expressionString, previousResult} =
boundProperty;

// Rewrite attribute value if necessary.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ohh I see, thanks. Add a TODO here.

return Promise.reject(`Experiment "${TAG}" is disabled.`);
}
if (!win.Worker) {
return Promise.reject('Worker not supported in window: ' + win);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed.

* @private
*/
sendMessage_(method, args) {
const promise = new Promise((resolve, reject) => {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

const {method, returnValue, id} =
/** @type {FromWorkerMessageDef} */ (event.data);

const message = this.messages_[id];
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Splice completed messages from the array and binary search to find them? Seems more complex and slower, I don't see the benefit.

...hate leads to suffering, suffering leads to the dark side.

😄

let empty = true;
for (let i = 0; i < this.messages_.length && empty; i++) {
if (this.messages_[i] !== undefined) {
empty = false;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The termination condition contains && empty and @camelburrito hates break.

}
}
if (empty) {
this.messages_ = [];
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

👍


// TODO(choumx): Add error reporting.
switch (method) {
case 'bind.initialize':
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Point taken. With only two cases at the moment, using a <string, Function> map is less readable IMO. Besides, this file will need to be significantly refactored to support other use cases in the future anyways.

var opts = Object.assign({}, options);

return compileJs('./src/web-worker/', 'web-worker.js', './dist/', {
toName: 'ww.max.js',
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good to know, I'll also reach out.

break;

case 'bind.evaluate':
if (evaluator_) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do web workers terminate? If so, evaluator_ could be undefined even if it was initialized previously.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think they do. But we'll find out via error reporting.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

As far as I can tell, they only terminate on request.

* Array of in-flight messages pending response from worker.
* @const @private {!Array<(PendingMessageDef|undefined)>}
*/
this.messages_ = [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry, missed this earlier: This should be an object. Makes the deletion after messages are done so much easier.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good idea, done.

*/
sendMessage_(method, args) {
return new Promise((resolve, reject) => {
this.messages_[this.counter_] = {method, resolve, reject};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

While we don't need to be thread safe, this is still dangerous if anything throws an exception.

Something like const messageIndex = this.counter_++ would be more obvious, I think.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

*/
this.digestQueuedAfterScan_ = false;

/** @const @private {boolean} */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We also need to check that web workers are supported. Might be better to export a function from the amp-worker.js module.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Looks like web workers are available for all browsers we care about, so I don't think we should bother supporting the fallback case. It's still useful to check this in amp-worker.js so we can reject invokeWebWorker with a meaningful error message.


/**
* Array of in-flight messages pending response from worker.
* @const @private {!Object<number, (PendingMessageDef|undefined)>}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No need for the |undefined.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

sendMessage_(method, args) {
return new Promise((resolve, reject) => {
const id = this.counter_++;
this.messages_[id] = {method, resolve, reject};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

reject isn't used?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

May be useful for returning warnings or errors to the caller. Error reporting for the worker is next: #7453

let returnValue;

// TODO(choumx): Add error reporting.
switch (method) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's wrap this in a try-catch, then post back an error if something goes wrong.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good idea. Tracked in #7453.

fakeWin = {Worker: fakeWorkerClass};

toggleExperiment(fakeWin, 'web-worker', true);
isExperimentOnStub = sandbox.stub(experiments, 'isExperimentOn');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is supppppperrrrr icky, and not valid ES6. import * as experiments means experiments is not an mutable object.

Why was this changed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Test was failing due to a recent change in the implementation of toggleExperiment and figured stubbing is less brittle. Is there no good way to stub exported methods?

Reverted and changed to avoid the source of the original error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You have to export a setter method from the module:

export function method() {}

export function setMethodForTesting(fn) {
  method = fn;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ew. 😄

Thanks for the review.

@dreamofabear dreamofabear merged commit e0b62b0 into ampproject:master Feb 13, 2017
@dreamofabear dreamofabear deleted the bind-worker branch February 13, 2017 22:59
torch2424 pushed a commit to torch2424/amphtml that referenced this pull request Feb 14, 2017
* initial commit for bind worker

* move URL rewriting to bind service, add promise api

* clean up, fix lint and type errors

* install AmpWorker service

* add web-worker experiment

* add worker experiment fallback

* type check web-worker.js

* comments and tweaks

* mark postMessage with OK

* fix dep check and tests

* add tests for amp-worker.js

* malte's comments

* move typedefs to separate file to avoid browserify from bundling polyfills into worker

* fix test and type error

* justin's comments

* malte's comment

* increment counter first

* justin's comments

* fix unit test

* fix unit test in a different way
mrjoro pushed a commit to mrjoro/amphtml that referenced this pull request Apr 28, 2017
* initial commit for bind worker

* move URL rewriting to bind service, add promise api

* clean up, fix lint and type errors

* install AmpWorker service

* add web-worker experiment

* add worker experiment fallback

* type check web-worker.js

* comments and tweaks

* mark postMessage with OK

* fix dep check and tests

* add tests for amp-worker.js

* malte's comments

* move typedefs to separate file to avoid browserify from bundling polyfills into worker

* fix test and type error

* justin's comments

* malte's comment

* increment counter first

* justin's comments

* fix unit test

* fix unit test in a different way
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.

4 participants