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

[Feat] simplified relationship state #4882

Closed

Conversation

runspired
Copy link
Contributor

@runspired runspired commented Mar 20, 2017

Simplified Relationship State

Original Commits https://github.com/runspired/data/tree/original/simple-relationship-state this PR has since been flattened for rebase to continue tracking master.

Description

This is a backwards compatible revisiting of Ember-Data's relationship state objects with the intent of simplifying data flows, improving our ability to reason about the relationship layer, and further optimize relationship management in the future.

How to Review

Each commit in this PR is a discrete step along the path of this refactor, and (with I think one exception) left ember-data in a working state with tests passing. To follow the logic, follow the PRs.

Summary

  • Creates a new ImplicitRelationship class which absorbed most of the previous OrderedSet behavior of Relationship
  • Converts the Relationship class to largely acting as a shared interface that BelongsToRelationship HasManyRelationship and ImplicitRelationship must implement.
  • Simplifies the state management of BelongsToRelationship and HasManyRelationship, each now has a currentState and canonicalState property. For belongs-to these are InternalModels while for has-many these are arrays of InternalModels.
  • Moves management of currentState for the HasManyRelationship out of ManyArray and into the relationship class. This brings ManyArray much closer to being a simple proxy Array.
  • Cleans up some test code encountered during this process
  • Cleans up some use of record => internalModel
  • Uses UniqueArray to make a few performance improvements to places where we were blindly concat-ing arrays leading to duplicate work before.
  • More intelligently iterates over relationships in Store._setupRelationships

Motivations

  • Make relationship state simpler to manage and easier reason about.
  • Enable better optimizations within the relationship layer

Performance Characteristics

With this refactor we

  • Allocate fewer objects (especially far fewer OrderedSets which are only used now for ImplicitRelationships).
  • Produce many fewer closures when iterating
  • No longer move back and forth between OrderedSet and Array
  • use strict assignment and === for managing BelongsToRelationship state
  • no longer duplicate diffing work for each state change within each relationship class layer as well as within ManyArray
  • broadcast fewer duplicate or unnecessary state changes

These changes were compared against #master to validate performance changes we expect to see. The test used the localhost:4200/api/complexes?included=foo%2Cbaz&limit=34 endpoint which returns 34 primary records and 204 included records of 3 total model types.

Before
screen shot 2017-03-20 at 5 16 06 pm

After
screen shot 2017-03-20 at 5 14 26 pm

Results of This PR against LinkedIn Feed
plot

export default class UniqueArray {
constructor(key) {
this.key = key;
this.seen = Object.create(null);
Copy link
Contributor

Choose a reason for hiding this comment

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

consider not initializing seen until array is > say 20 items.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

Copy link
Member

Choose a reason for hiding this comment

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

This seems awfully similar to Ember.OrderedSet We should just tweak that, rather then adding something similar. Note: Ember basically provides OrderedSet and friends only for ember-data.

I believe we can improve the performance of (small N) ordered set by taking the approach outlined above by @krisselden, it performs quite well for large N.

We should also expose list as a public attribute on it.

array.push(relationship.currentState, relationship.canonicalState);
return;
case 'has-many':
additions = [].concat(relationship.currentState, relationship.canonicalState);
Copy link
Contributor

Choose a reason for hiding this comment

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

do 2 pushes instead of concat

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was debating this, any particular reason why?

destroy() {
if (!this.inverseKey) { return; }

let allMembers =
Copy link
Contributor

Choose a reason for hiding this comment

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

can we not copy to an array just to iterate over?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Seeing as this doesn't unique the sets either it's doubly inefficient.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(also fwiw this is the legacy path that everything used to do, now only implicit does it, I haven't spent much time optimizing implicit's code yet)


addInternalModels(internalModels, idx) {
heimdall.increment(addInternalModels);
internalModels.forEach(internalModel => {
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 is a hot path consider avoiding forEach

Copy link
Member

Choose a reason for hiding this comment

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

👍


if (hasData) {
results = [];
members.forEach((member) => {
currentState.forEach((member) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

if hot path, avoid forEach.

testem.js Outdated
@@ -8,7 +8,7 @@ module.exports = {
"PhantomJS"
],
"launch_in_dev": [
"PhantomJS",
// "PhantomJS",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this crept in, should remove it.

}

pushed.length = 0;
this._pushedInternalModels = Object.create(null);
Copy link
Member

@pangratz pangratz Mar 22, 2017

Choose a reason for hiding this comment

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

Do we need to reset _hasPushedInternalModels as well? As far as I can tell, otherwise the _setupRelationships is only invoked once in the first runloop and never afterwards?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the catch! We do need to. I have a fix for this in a different branch where I was rebasing and will bring it back over to here.

@@ -18,4 +18,9 @@ export default class Relationship {
this.hasLoaded = false;
this.meta = null;
}

destroy() {
this.currentState = null;
Copy link
Member

Choose a reason for hiding this comment

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

Check if this is still there


export default class BelongsToRelationship extends Relationship {
constructor(store, internalModel, inverseKey, relationshipMeta) {
super(store, internalModel, inverseKey, relationshipMeta);
this.kind = 'belongs-to';
Copy link
Member

Choose a reason for hiding this comment

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

I think we camelCase this

newRecord._implicitRelationships[this.inverseKeyForImplicit] = new ImplicitRelationship(this.store, newRecord, this.key, { options: {} });
}
newRecord._implicitRelationships[this.inverseKeyForImplicit].addRecord(this.record);
// TODO implicit-legacy @runspired is this needed?
Copy link
Member

Choose a reason for hiding this comment

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

Leftover comment

return record;
});
return this.store.findBelongsTo(this.internalModel, this.link, this.relationshipMeta)
.then((internalModel) => {
Copy link
Member

Choose a reason for hiding this comment

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

Can you keep existing indentation please

export default class Relationship {
constructor(store, internalModel, inverseKey, relationshipMeta) {
let asyncOptionValue = relationshipMeta.options.async;

this.rel_id = REL_ID++;
Copy link
Member

Choose a reason for hiding this comment

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

Lets leave a comment as to why we are doing this

@@ -31,6 +31,28 @@ const {

const assign = Ember.assign || Ember.merge;

class UniqueArray {
Copy link
Member

Choose a reason for hiding this comment

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

Can you document this please, and maybe take it out in a separate file?

this.items = [];
}

push(...additions) {
Copy link
Member

Choose a reason for hiding this comment

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

Can we make this more like a util to make it clear it's not a stateful array

if (this.inverseKey) {
this.removeRecordFromInverse(internalModel);
} else {
if (internalModel._implicitRelationships[this.inverseKeyForImplicit]) {
Copy link
Member

Choose a reason for hiding this comment

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

} else if { ?

Copy link
Member

Choose a reason for hiding this comment

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

👍


if (this.inverseKey) {
this.removeCanonicalRecordFromInverse(internalModel);
} else {
Copy link
Member

Choose a reason for hiding this comment

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

} else if {

Copy link
Member

Choose a reason for hiding this comment

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

👍

@@ -129,8 +127,7 @@ export default Ember.Object.extend(Ember.MutableArray, Ember.Evented, {
*/
this.relationship = this.relationship || null;

this.currentState = [];
this.flushCanonical(false);
// this.flushCanonical(false);
Copy link
Member

Choose a reason for hiding this comment

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

remove?

this.canonicalState = null;
this.kind = 'belongs-to';
}
get inverseRecord() {
Copy link
Member

Choose a reason for hiding this comment

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

likely can be removed

return this.currentState;
}

set inverseRecord(v) {
Copy link
Member

Choose a reason for hiding this comment

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

likely can be removed

if (this.inverseKey) {
this.removeRecordFromInverse(internalModel);
} else {
if (internalModel._implicitRelationships[this.inverseKeyForImplicit]) {
Copy link
Member

Choose a reason for hiding this comment

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

👍


if (this.inverseKey) {
this.removeCanonicalRecordFromInverse(internalModel);
} else {
Copy link
Member

Choose a reason for hiding this comment

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

👍

return;
}

this.removeCanonicalRecordFromOwn(internalModel);
Copy link
Member

Choose a reason for hiding this comment

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

187's index can be reused here, allowing removeCanonicalRecordFromOwn to avoid the re-scan.


addInternalModels(internalModels, idx) {
heimdall.increment(addInternalModels);
internalModels.forEach(internalModel => {
Copy link
Member

Choose a reason for hiding this comment

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

👍

heimdall.increment(newRelationship);

this.members = new OrderedSet();
this.canonicalMembers = new OrderedSet();
Copy link
Member

Choose a reason for hiding this comment

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

we should create this lazily?

if (this._pushedInternalModels.push(internalModel, data) !== 2) {
return;
}
let hash = this._pushedInternalModels;
Copy link
Member

Choose a reason for hiding this comment

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

i believe this can be restored, but during _setupRelationships you should keep a cache of relationshipsByName._key.list by modelName around to avoid the lookups bellow.

export default class UniqueArray {
constructor(key) {
this.key = key;
this.seen = Object.create(null);
Copy link
Member

Choose a reason for hiding this comment

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

This seems awfully similar to Ember.OrderedSet We should just tweak that, rather then adding something similar. Note: Ember basically provides OrderedSet and friends only for ember-data.

I believe we can improve the performance of (small N) ordered set by taking the approach outlined above by @krisselden, it performs quite well for large N.

We should also expose list as a public attribute on it.

@runspired runspired force-pushed the feat/simple-relationship-state branch 2 times, most recently from eec7047 to d9eb9f1 Compare April 20, 2017 16:21
@runspired
Copy link
Contributor Author

hrm, that wasn't failing locally. Might have missed something in the commit.

@@ -429,17 +429,30 @@ export default class InternalModel {
to or has many.
*/
_directlyRelatedInternalModels() {
let array = [];
this.type.eachRelationship((key, relationship) => {
let uniqueSet = new OrderedSet();
Copy link
Member

Choose a reason for hiding this comment

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

all sets are unique, lets just name this variable set

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the name is more so signify the intentional purpose of the set (to generate a unique set), can change if still desired.

Copy link
Member

Choose a reason for hiding this comment

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

best to change it. uniqueSet is confusing (like hashMapWithKeysAndValues)


return this.size;
};

OrderedSet.prototype.addWithIndex = function(obj, idx) {
let guid = guidFor(obj);
let presenceSet = this.presenceSet;
Copy link
Member

@stefanpenner stefanpenner Apr 24, 2017

Choose a reason for hiding this comment

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

This is unfortunately private API that will change. Ember-data shouldn't be reaching into private state.. hmmm

Copy link
Member

Choose a reason for hiding this comment

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

I think the only thing is to add this to ember's, and have ember's lazily create presenceSet for old versions of ED.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This part is pre-existing, but I agree. I feel we really just need to build an OrderedSet with good perf and tailored to ember-data, given that it exists inside of Ember mostly for ember-data to begin with.

extend implicit from relationship

base relationships off of implicit

remove dependency on members from belongs-to relationship

remove dependence on members from has-many

remove belongs-to dependency on super into implicit

decouple belongsTo

optimize _directlyRelatedInternalModels

remove duplication from Relationship base class

cleanup currentState entanglement and begin prepping has-many for decoupling from implicit as a base class

test cleanup

decouple has-many from implicit

remove unneeded code paths from implicit relationships

fix(has-many): ensure ManyArray is instantiated lazily

optimize setupRelationships

clean up has-many noise
…tionships that builds a cache as it goes instead of upfront
@stefanpenner
Copy link
Member

rebasing

@stefanpenner stefanpenner force-pushed the feat/simple-relationship-state branch from fda4dca to 5abad65 Compare April 25, 2017 18:54
// This will convert relationships specified as IDs into DS.Model instances
// (possibly unloaded) and also create the data structures used to track
// relationships.
for (let i = 0; i < pushed.length; i += 2) {
Copy link
Member

@stefanpenner stefanpenner Apr 26, 2017

Choose a reason for hiding this comment

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

this produces quite sketchy ES5 output, lots of nested functions to handle the let TDZ

  _setupRelationships: function _setupRelationships() {
    var pushed = this._pushedInternalModels;
    var store = this;

    // Cache the inverse maps for each modelClass that we visit during this
    // payload push.  In the common case where we are pushing many more
    // instances than types we want to minimize the cost of looking up the
    // inverse map and the overhead of Ember.get adds up.
    var modelNameToInverseMap = Object.create(null);
    var modelNameToRelationshipsMap = Object.create(null);

    var _loop2 = function _loop2(i) {
      var internalModel = pushed[i];
      var data = pushed[i + 1];
      var modelName = internalModel.modelName;
      var relationshipsByName = modelNameToRelationshipsMap[modelName];

      if (relationshipsByName === undefined) {
        relationshipsByName = modelNameToRelationshipsMap[modelName] = get$3(internalModel.modelClass, 'relationshipsByName');
      }

      var relationshipNames = relationshipsByName._keys.list;
      var relationships = internalModel._relationships;

      var _loop3 = function _loop3(keyIndex) {
        var relationshipName = relationshipNames[keyIndex];
        var relationshipData = data.relationships[relationshipName];

        if (relationshipData !== undefined) {
          var relationshipRequiresNotification = relationships.has(relationshipName) || isInverseRelationshipInitialized(store, internalModel, data, relationshipName, modelNameToInverseMap);

          if (relationshipRequiresNotification) {
            relationships.get(relationshipName).push(relationshipData);
          }

          // in debug, assert payload validity eagerly
        }
      };

      for (var keyIndex = 0; keyIndex < relationshipNames.length; keyIndex++) {
        _loop3(keyIndex);
      }
    };

    for (var i = 0; i < pushed.length; i += 2) {
      _loop2(i);
    }

    pushed.length = 0;
  }

screen shot 2017-04-25 at 5 28 05 pm

@@ -324,7 +324,7 @@ export default class RelationshipPayloads {
return;
}

if (Array.isArray(data)) {
if (data.length) { // dirty-check for "is-array"
Copy link
Member

Choose a reason for hiding this comment

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

whats wrong with Array.isArray ?

Copy link

Choose a reason for hiding this comment

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

I'd recommend to stick to Array.isArray, which gives you predictable performance, unless you now for sure that data is always an array.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

@@ -16,6 +16,18 @@ OrderedSet.prototype = Object.create(EmberOrderedSet.prototype);
OrderedSet.prototype.constructor = OrderedSet;
OrderedSet.prototype._super$constructor = EmberOrderedSet;

OrderedSet.prototype.pushMany = function pushMany(additions) {
Copy link
Member

Choose a reason for hiding this comment

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

unit test

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

this.currentState = this.canonicalState = internalModel;
this.setupInverseRelationship(internalModel, true);

// this.flushCanonicalLater();
Copy link
Member

Choose a reason for hiding this comment

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

should these be removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes

}

//TODO(Igor) make this less abysmally slow
this._currentState = this.canonicalState.copy();
Copy link
Member

Choose a reason for hiding this comment

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

should we use the currentState setter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it doesn't matter here in theory but I did mean to change this to use it once I added the setter.

}

// in debug, assert payload validity eagerly
runInDebug(() => {
Copy link

Choose a reason for hiding this comment

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

If you'd strip the runInDebug before running Babel on it, then Babel might be able to avoid the closures.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

@runspired
Copy link
Contributor Author

We should still do this, but with #5344 coming and other work including many cleanup commits and lazy-relationships landing over the past year, this has diverged too far from Master. A clean reboot will come soon :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Discarded
Development

Successfully merging this pull request may close these issues.

8 participants