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

Deferred updates, part 3: dependency tree scheduling #1795

Merged
merged 7 commits into from Jul 10, 2015

Conversation

mbest
Copy link
Member

@mbest mbest commented May 15, 2015

This is the third, and final, part of supporting deferred updates in Knockout (#1728).

With multiple deferred computed observables in a dependency tree, use "dirty" events to schedule the complete set of updates in the correct order and mark each computed observable as "dirty" so it can be evaluated if needed with the latest value.

… "dirty" events to schedule the complete set of updates in the correct order and mark each computed observable as "dirty" so it can be evaluated if needed with the latest value.
@mbest
Copy link
Member Author

mbest commented May 16, 2015

Originally, in #1728, I thought that we could use the observable versioning system, which is used to efficiently to update sleeping pure computed observables, to accomplish a similar task for deferred updates. At the very least, I wanted to try it and compare it to using a dirty event. After completing #1753 (part 2), though, I took a detour with #1775 to develop a system to properly order subscriptions, especially for deferred updates. After spending a couple of weeks working on it, I realized that it wouldn't be enough to accomplish the goal for deferred updates: to update pure computed observables in a breadth-first order without any duplicate updates. Combining it with version checking might help, but the whole thing was big and not very efficient.

So I went back to trying the dirty event, understanding that it could solve both problems for deferred updates. Scheduling (or rescheduling) the update of each computed observable as it receives dirty notifications results in the correct update order. And because this marks each observable as dirty in the process, it efficiently causes them to evaluate to the latest value if accessed while an update is pending.

expect(function() {
jasmine.Clock.tick(1);
}).toThrowContaining('Too much recursion');
});
Copy link
Member Author

Choose a reason for hiding this comment

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

This test presents a concrete example of how the recursive task check could come into play (and is the reason I added the check in the deferred updates plugin). It's interesting, though, that this example doesn't result in an infinite loop before the code changes here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Are you regarding this as not a breaking change because the previous behavior was not well defined? I'm not sure what the previous behavior would have been, as it does seem impossible for 'x' to satisfy the requirements of both 'select' boxes simultaneously.

Copy link
Member Author

Choose a reason for hiding this comment

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

Right. I'd say that the previous behavior was undefined.

@mbest
Copy link
Member Author

mbest commented May 21, 2015

@SteveSanderson, I'm hoping to get your feedback here. This obviously involves some code changes within dependentObservable.js, but I hope that the logic is isolated enough to be easily digestible.

expect(dependentComputed()).toEqual('C');
});

it('Should *not* delay update of dependent deferred computed observable', function () {
Copy link
Contributor

Choose a reason for hiding this comment

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

This subtlety - how notifications and re-evaluations differ between regular, ratelimited, and deferred computeds is very subtle indeed and is going to require careful attention in docs. Probably some big table of comparisons, with notes that provide justifications for all the differences.

I think I can guess why these differences exist, but it was still surprising to me at first.

@SteveSanderson
Copy link
Contributor

Thanks for this! Comments and questions added.

@mbest
Copy link
Member Author

mbest commented Jun 2, 2015

Another point to consider before merging this is that "dirty" events will cause a computed to re-evaluate even if the underlying observable doesn't change. In my plugin, I dealt with this by having two levels of "dirty". A dirty event marks a computed as possibly dirty, and a change event marks it as actually dirty. When possibly dirty, it will be evaluated on demand, but only if actually dirty will it be evaluated during task processing. Do you think I should try to include the same logic here?

mbest added 2 commits June 3, 2015 14:15
…rvables update on demand when dependent on deferred observables, maintaining similar behavior for rate-limited computeds between deferred and non-deferred usage. This commit also removes the ability to turn off deferred updates for an observable because it would result in an observable that appears to be rate-limited but actually updates synchronously.
…eWhenNodeIsRemoved option) won't respond to "dirty" events. This prevents conflicting bindings from causing recursive updates.
@mbest
Copy link
Member Author

mbest commented Jun 10, 2015

@SteveSanderson I've made some changes that address most of the concerns you brought up. Please check them over and give me your thoughts.

@@ -125,4 +125,14 @@ describe("Deferred bindings", function() {
expect(testNode.childNodes[0]).toContainHtml('<span data-bind="text: childprop">moving child</span><span data-bind="text: childprop">first child</span><span data-bind="text: childprop">second child</span>');
expect(testNode.childNodes[0].childNodes[targetIndex]).not.toBe(itemNode); // node was create anew so it's not the same
});

it('Should not throw an exception for value binding on multiple select boxes', function() {
testNode.innerHTML = "<select data-bind=\"options: ['abc','def','ghi'], value: x\"></select><select data-bind=\"options: ['xyz','uvw'], value: x\"></select>";
Copy link
Member Author

Choose a reason for hiding this comment

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

Given that the non-deferred scenario doesn't throw an exception, I decided to give this some more thought. I realized that if the computed observables used for updating the binding don't respond to "dirty" events, that would stop the recursion as long as the underlying observable suppresses notifications for non-changing updates. So this will still cause a "recursion" exception if the values are objects instead of strings. @SteveSanderson, do you think this change is helpful or not?

Copy link
Contributor

Choose a reason for hiding this comment

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

If this brings the deferred behavior more into line with non-deferred, then I guess it's a good thing, but it's hard to think of a case where someone would be doing this and expect something useful to happen :)

Copy link
Member Author

Choose a reason for hiding this comment

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

I think we may need to take a closer look at how bindings interact with the deferred updates feature, as you've described in #1758. So this code may need to change some more anyway.

@SteveSanderson
Copy link
Contributor

Thanks again for implementing this, and sorry it's taken a few weeks to complete the review.

This looks good to me - the notion of 'dirty' events propagating through any unbroken chain of deferreds to spread awareness that a change is coming, and hence that they should schedule a re-evaluation (and re-evaluate proactively if read) seems like a good way to model this.

I'm happy for this to go in (so please merge it if you're ready, or let me know and I will), but I just want to ask a couple of clarifying questions in case you want to add further tweaks:

  • What's the reason why we can no longer support turning off deferred? Is it that we'd have to recreate all the subscriptions to dependencies (and would it be prohibitively difficult to implement that)? I know you put in the comment that "it would result in an observable that appears to be rate-limited but actually updates synchronously" but I'm unsure why that behavior is necessary.
    • I've added a check that you don't try to use .extend({ deferred : false }) as that code would actually enable deferred :)
    • It seems odd that you actually can turn off deferred by turning on rateLimit (which you can do at arbitrary times). Would this lead to bugs? Why is it safe to enable rateLimit and hence disable deferred, if you can't just disable deferred on its own?
  • Is it true that you can't have an observable/computed that is both deferred and rate-limited at the same time?
    • I hope not, because then there are so many combinations of [observable/computed/pureComputed] * [deferred on/off] * [rateLimit on/off] and I don't have any idea how the deferred+rateLimited combinations should behave
    • Should we add an exception if you try to use both extenders on the same thing?

@mbest
Copy link
Member Author

mbest commented Jul 4, 2015

@SteveSanderson, I'm glad to get your feedback. Hopefully we can get this in soon.

What's the reason why we can no longer support turning off deferred?

One reason is that I realized that it creates a new class of observables that would need to be thoroughly tested (and the implementation probably adjusted accordingly). Another reason is that I decided that I didn't want to encourage turning off deferred updates for an individual observable when using the global deferUpdates option, mostly because I couldn't decide how such a scenario should behave.

Is it true that you can't have an observable/computed that is both deferred and rate-limited at the same time?

That's correct. Both use the internal limit function and setting one turns off the other.

Why is it safe to enable rateLimit and hence disable deferred, if you can't just disable deferred on its own?

When using deferred updates globally, it still makes sense that people will sometimes want to use rateLimit for specific observables. The rate-limited observable will behave as expected whether used with other normal or deferred observables.

Should we add an exception if you try to use both extenders on the same thing?

We need to support setting rateLimit on a deferred observable but not vice versa. We could add an exception for the latter case, although I don't think it's necessary.

@brianmhunt
Copy link
Member

Is it true that you can't have an observable/computed that is both deferred and rate-limited at the same time?
That's correct. Both use the internal limit function and setting one turns off the other.

Is deferring analogous to rateLimit: 0 (or 1), much like setTimeout(fn, 0)? If not, how are they different?

@mbest
Copy link
Member Author

mbest commented Jul 4, 2015

@brianmhunt That's a good question. If you use deferred for a single observable, it could be equivalent to rateLimit: 0, at least on older browsers. Once you have multiple deferred observables, you'll see more benefit because all updates happen together with no delay between them. This might help:

  1. Default - Notifications happen immediately and synchronously.
  2. Deferred - Notifications happen asynchronously as soon as possible, hopefully before any redraws.
  3. Rate-limit - Notifications happen after a specified period of time (minimum of 2-10 ms depending on the browser).

@brianmhunt
Copy link
Member

@mbest Got it – that's very helpful (and probably worthwhile to reference in the docs!).

mbest added a commit that referenced this pull request Jul 10, 2015
Deferred updates, part 3: dependency tree scheduling
@mbest mbest merged commit 6fa03bb into master Jul 10, 2015
@mbest mbest deleted the deferred-updates-part-3 branch July 10, 2015 21:27
@mbest
Copy link
Member Author

mbest commented Jul 10, 2015

This is merged now.

@rniemeyer rniemeyer mentioned this pull request Aug 30, 2015
10 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants