Skip to content

Add memoization by instance - #185

Merged
haveyaseen merged 31 commits into
redux-orm:masterfrom
B-Reif:instance-memoize
Jun 6, 2018
Merged

haveyaseen merged 31 commits into
redux-orm:masterfrom
B-Reif:instance-memoize

Conversation

@B-Reif

@B-Reif B-Reif commented Jan 23, 2018 •

Copy link
Copy Markdown
Contributor

This PR refines redux-orm's memoization behavior to memoize by model instances rather than by the whole table. This is intended as a fix for #184 and related to discussion in #71.

This PR is INCOMPLETE don't merge it yet 😅

Comment thread src/Session.js Outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't know how to get the model's idAttribute, but this should reference whatever that is instead of just ".id".

I also don't know if it's safe to peer into the query result in this particular way?

@plandem plandem Jan 23, 2018 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

r.id => r.getId() ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

you can also try this[table].idAttribute

Comment thread src/memoize.js Outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Currently this memoizer still just behaves the same way as it did before, only it examines the keys of accessedModels since it's not an Array anymore. This memoizer ignores the stored list of accessed instances.

The shape of the invalidator map needs updating to accommodate instances, and the actual comparison function needs to compare at the instance level instead of at the table level. I'm not sure what that would look like.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hey, do you still need some help on this one, i can dig into it and propose some directions if you'd like.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes! Let me know what you find <3

@Aryk Aryk Feb 2, 2018 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

something like this?

export function eqCheck(a, b) {
  return a === b;
}

function instancesEqual(invalidatorMap, state) {
  return values(invalidatorMap).every(instanceEqualityChecker => instanceEqualityChecker(state));
}

export function memoize(func, equalityCheck = eqCheck, orm) {
  let lastOrmState = null;
  let lastResult = null;
  let lastArgs = null;
  const instanceEqualityCheckerByModelName = {};

  return (...args) => {
    const [dbState, ...otherArgs] = args;

    const argsAreEqual = lastArgs && otherArgs.every(
      (value, index) => equalityCheck(value, lastArgs[index]),
    );

    if (!argsAreEqual) {
      const dbIsEqual = lastOrmState === dbState ||
        instancesEqual(instanceEqualityCheckerByModelName, dbState);

      if (!dbIsEqual) {
        const session = orm.session(dbState);
        const newArgs = [session, ...otherArgs];
        const result = func(...newArgs);

        // If a selector has control flow branching, different
        // input arguments might result in a different set of
        // accessed models. On each run, we check if any new
        // models are accessed and add their invalidator functions.
        Object.keys(session.accessedModels).forEach((modelName) => {
          if (!instanceEqualityCheckerByModelName.hasOwnProperty(modelName)) {
            instanceEqualityCheckerByModelName[modelName] = nextState => {
              if (lastOrmState === null) {
                return false;
              }
              
              const lastModelState = lastOrmState[modelName];
              const nextModelState = nextState[modelName];

              if (lastModelState !== nextModelState) {
                return session.
                  accessedModels[modelName].
                  every(id => lastModelState.itemsById[id] === nextModelState.itemsById[id]);
              } else {
                return true;
              }
            };
          }
        });

        lastResult = result;
        lastOrmState = dbState;
        lastArgs = otherArgs;

      }
    }

    return lastResult;
  };
}

@tiii tiii Feb 2, 2018 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If we save the model data reference instead of true to session.accessedModels, can't we just make a === comparison against the new state?

@tiii tiii Feb 3, 2018 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ah, I see! 👍 But I would argue that those are still early function returns if you only look at the returned function from memoize.

What do you think about checking the equality for all args not only otherArgs, that way we would respect changes to the ormStates as well?

return (...args) => {
  const [nextOrmState, ...otherArgs] = args;

  if (argsAreEqual(lastArgs, args)) {
    return lastResult;
  }

  const session = orm.session(nextOrmState);
  if (accessedModelInstancesAreEqual(session.accessedModels, nextOrmState)) {
    return lastResult;
  }

  lastResult = func(...[session, ...otherArgs]);
  lastOrmState = nextOrmState;
  lastArgs = args;

  return lastResult;
};

@tiii tiii Feb 4, 2018 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I just realized, that with instanceEqualityCheckerByModelName[modelName] = invalidatorFunction we basically introduced a global accessedModel store. Before that was kind of implicit since we did only memoize the tables.

import every from 'lodash/every';
import merge from 'lodash/merge';

function defaultEqualityCheck(a, b) { a === b };

function const argsAreEqual(a, b) { b.every((value, index) => equalityCheck(value, a[index])) }

function accessedModelInstancesAreEqual(accessedModels, lastOrmState, nextOrmState) {
  every(accessedModels, (accessedModelsForModelName, modelName) => {
    const nextModelState = nextOrmState[modelName];

    if (lastOrmState === null) {
      return false;
    }

    const lastModelState = lastOrmState[modelName];
    if (lastModelState === nextModelState) {
      return true;
    }

    return 
      Object
        .keys(accessedModelsForModelName)
        .every(id => lastModelState.itemsById(id) === nextModelState.itemsById[id]);
  });
}

export function memoize(func, equalityCheck = defaultEqualityCheck, orm) {
  let lastOrmState = null;
  let lastArgs = null;
  let lastResult = null;
  let allAccessedModels = {};

  return (...args) => {
    const [nextOrmState, ...otherArgs] = args;

    if (lastArgs && argsAreEqual(lastArgs, args)) {
      return lastResult;
    }

    if (accessedModelInstancesAreEqual(allAccessedModels, lastOrmState, nextOrmState)) {
      return lastResult;
    }

    const session = orm.session(nextOrmState);
    lastOrmState = nextOrmState;
    lastArgs = args;
    lastResult = func(...[session, ...otherArgs]);
    allAccessedModels = allAccessedModels.merge(session.accessedModels);

    return lastResult;
  };
}

Though we could persist that global accessedModel state in the memoize function I would rather persist it within the session itself? This makes the comment on the PR about the higher order component reseting accessedModels after render completion viable again..

@B-Reif is it ok if I hop into this with you (adding commits). It get's kind of hard keeping track of all the "meta" thoughts on on those inline comments... :P

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What do you think about checking the equality for all args not only otherArgs, that way we would respect changes to the ormStates as well?

👍

looks cleaner

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@tiii I added you to collab on my fork. Anyone else who'd like to commit there, feel free to reach out to me :>

@haveyaseen haveyaseen May 5, 2018 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@B-Reif I'd love to be able to commit on your fork 🙂

@tiii Caching all arguments breaks the redux integration test at line 57 because db.getEmptyState() creates a new object every time. No idea if this change in API is welcome or not, but I think the memoization should still work even when the root reference changes. We already compare accessed model instances, this should be enough.

lastModelState.itemsById(id) should probably be lastModelState.itemsById[id].

allAccessedModels.merge(session.accessedModels); throws an error, this method does not exist (you may have intended something like merge(allAccessedModels, session.accessedModels);). The object spread syntax may actually be enough here, as we do not actually want to merge any IDs, but we want to replace the map of accessed IDs for each model - during each call we only want to check if any of the instances that were accessed during the previous function call have changed. The previous map can thus be removed without consequences.

Further, I also very strongly favor early returns. We cannot split up the initial check into two if statements though, as this turns the boolean AND into a boolean OR. What do you think of this version? Also added some comments for clarity. Please correct me if I misunderstood something 😀

const defaultEqualityCheck = (a, b) => a === b;

const argsAreEqual = (lastArgs, nextArgs, equalityCheck) => (
    nextArgs.every((arg, index) =>
        equalityCheck(arg, lastArgs[index])
    )
);

const accessedModelInstancesAreEqual = (accessedModelInstances, lastOrmState, nextOrmState) => (
    every(accessedModelInstances, (accessedInstances, modelName) => {
        const nextModelState = nextOrmState[modelName];
        const lastModelState = lastOrmState[modelName];

        if (lastModelState === nextModelState) {
            return true;
        }

        const accessedIds = Object.keys(accessedInstances);
        return accessedIds.every(id =>
            lastModelState.itemsById[id] ===
            nextModelState.itemsById[id]
        );
    })
);

export function memoize(func, argEqualityCheck = defaultEqualityCheck, orm) {
    /* cached result of previous function call */
    let lastResult = null;
    /* cached arguments to previous function call (excluding ORM state) */
    let lastArgs = null;
    /* cached previous ORM state for the sake of comparing */
    let lastOrmState = null;
    /**
     * cached map of which model instances have been accessed
     * during previous function call (contains only IDs of accessed instances)
     * format (e.g.): { Book: { 1: true, 3: true } }
     */
    let lastAccessedModelInstances = {};

    return (...args) => {
        const [nextOrmState, ...otherArgs] = args;

        if (lastArgs &&
            lastOrmState &&
            argsAreEqual(lastArgs, otherArgs, argEqualityCheck) &&
            accessedModelInstancesAreEqual(lastAccessedModelInstances, lastOrmState, nextOrmState)) {
            /**
             * the instances that were accessed as well as
             * the arguments that were passed to func the previous time that
             * func was called have not changed
             */
            return lastResult;
        }

        /* previous result is no longer valid, update cached values */
        const session = orm.session(nextOrmState);
        lastOrmState = nextOrmState;
        lastArgs = otherArgs;
        lastResult = func(...[session, ...otherArgs]);

        lastAccessedModelInstances = {
            ...lastAccessedModelInstances,
            ...session.accessedModels,
        };

        return lastResult;
    };
}

Comment thread src/Session.js Outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This reduce strikes me as a little clunky; I mostly wanted to retain a similar structure to the function as it used to be, but we could probably just examine the getDataForModel results instead of combing through this.sessionBoundModels.

Comment thread src/Model.js Outdated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It looks like this was actually broken? markAccessed expects a modelName but this was passing in the whole model.

@nealoke

nealoke commented Jan 23, 2018

Copy link
Copy Markdown

Would love to see this improvement ❤️

Comment thread src/Session.js Outdated

@Aryk Aryk Jan 23, 2018 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

food for thought:

  1. Make modelIds optional. If not provided, grab all the IDs from the model and use those.
  2. This would make this change backwards compatible in case people called markAccessed from within their apps

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

what's the best way to grab all of the ids from the model? this is my first guess:

    idsFromModelName(modelName) {
        const model = this.sessionBoundModels
            .filter(model => model.modelName === modelName);
        return model.all().toRefArray().map(r => r.id);
    }

@Aryk Aryk Jan 29, 2018 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Well they also get assigned to this (see here), so you could do:

   idsFromModelName(modelName) {
    const modelClass = this[modelName];
     return modelClass.all().toRefArray().map(r => r[modelClass.idAttribute]);
    }

i think...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually I don't think this would even work in this context since toRefArray starts a query itself, which would in turn run markAccessed itself.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

oh, interesting, didn't know that about toRefArray...

Comment thread src/Session.js Outdated

@Aryk Aryk Jan 23, 2018 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My recommendation would be to start this off as a hash, not an array. Could be just a hash of IDs => true/false.

Making an array is probably better for memory, but to @plandem's point, if we have 10k+ instances in a model, imagine that the @connect function is doing a state compare on those 10000 objects, it has to literally loop through each component, and run a search for the instance IDs in the array of 10000 ids and then has to go through the other 10000 components on the page and do the same thing. This will be slow.

You might say, well we won't have 10000 ids in the array because it is only for changed objects...well that is true unless you want to invalidate the whole model, and you add every single ID into the accessed array.

For this reason, I say, go with the object lookup approach first. The users of your website will thank you... :)

@B-Reif

B-Reif commented Feb 1, 2018 •

Copy link
Copy Markdown
Contributor Author

Just pushed a commit with a few of the fixes from above.

Out of curiosity about the performance impact, I ran npm run test a few times at this commit, and the last commit before any changes on the branch. The testIntegrations test has a few benchmarks and a total time.

Before changes: 11.912s, 10.555s, and 10.362s (avg 10.94s)
With changes: 11.359s, 10.429s, and 11.088s (avg 10.95s)

EDIT: The CI build failed on some many-to-many relations taking too long :( from the failing build:

    console.log src/test/testIntegrations.js:800
      Creating 10000 objects took 0.478s
    console.log src/test/testIntegrations.js:820
      Looking up 10000 objects by id took 0.395s
    console.log src/test/testIntegrations.js:863
      Adding 2500 relations took 10.269s
    console.log src/test/testIntegrations.js:889
      Performing 500 queries took 11.929s
    console.log src/test/testIntegrations.js:915
      Removing 1000 relations took 7.478s

@plandem What's your take on this level of performance? Any ideas?

@tiii

tiii commented Feb 3, 2018

Copy link
Copy Markdown
Collaborator

Over application lifetime, we keep adding up models to our session.accessedModels object and therefore incrementally checking models that did not change for changes over time.

I was thinking if, for redux, we could flush the session.accessedModels after each application render, since our potential changes are then rendered to the dom and only changes to models that occured after the last render need to be checked for equality again.

By providing an API to reset session.accessedModels and a higher order component that's placed early in the react component tree that calls that reset function in componentDidUpdate() we could keep checking the number of accessedModels that need to be checked for equality to a minimum.

What are your thoughts on this? Am I missing a situation, where this may lead up to missing changes? Otherwise this sure would be a big performance improvement over app lifetime.

Comment thread src/test/testSession.js Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess we should also test if models are marked as accessed if they are resolved by the query function?

Comment thread src/memoize.js Outdated

@tiii tiii Feb 3, 2018 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ah, I see! 👍 But I would argue that those are still early function returns if you only look at the returned function from memoize.

What do you think about checking the equality for all args not only otherArgs, that way we would respect changes to the ormStates as well?

return (...args) => {
  const [nextOrmState, ...otherArgs] = args;

  if (argsAreEqual(lastArgs, args)) {
    return lastResult;
  }

  const session = orm.session(nextOrmState);
  if (accessedModelInstancesAreEqual(session.accessedModels, nextOrmState)) {
    return lastResult;
  }

  lastResult = func(...[session, ...otherArgs]);
  lastOrmState = nextOrmState;
  lastArgs = args;

  return lastResult;
};

@tiii

tiii commented Feb 3, 2018

Copy link
Copy Markdown
Collaborator

Ok, now I see, that i completely misunderstood the problem here.. Just ignore my last comment :)

@tiii

tiii commented Feb 4, 2018

Copy link
Copy Markdown
Collaborator

Turns out #185 (comment) might be relevant after all...

@fatfatson

Copy link
Copy Markdown

a simple question:
how could we know the model doesn't change?
now I just make a deep-compare in shouldComponentUpdate, if we just move the check from there to internal of redux-orm, it may be more convenient to use but the performance doesn't improve

@B-Reif

B-Reif commented Feb 12, 2018

Copy link
Copy Markdown
Contributor Author

Currently the comparison happens in the memoize function within redux-orm. Right now, this function just checks if the previous state's table is === equal to the next state's table. Per @tiii's changes, we'd like to do the same thing--a quick reference check--against each model instance.

Reference checking each instance is substantially more performant than deep-comparing all of the properties for each instance (as shouldComponentUpdate would be doing), along with the other benefits of memoization like caching computed data.

@B-Reif

B-Reif commented Feb 21, 2018

Copy link
Copy Markdown
Contributor Author

I made some changes to implement the memoize function per @tiii's ideas. On this last commit, the selectors are currently broken and don't re-run when new models are added to the table. For different tables, the same query can return a different set of instances. AFAIK, it's not possible to determine which instances a query will yield without actually running it.

For example, some selector that looks for Book.all() won't re-run when a new Book has been added. The first time, the selector stores which instances it got. The second time, it compares those instances and sees they're all equal, so it doesn't re-run the same query which would, if ran, yield a different set of instances.

We could just add a check like lastModelState.items === nextModelState.items. This would cause other cases to re-run the selector unnecessarily. A query that looks for Book.withId(0) would re-run whenever a new Book is added or removed.

I also feel like we should add a createSelectorCreator to allow developers to make the call about when to re-run if they'd like to.

Comment thread src/memoize.js Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since our accessedModels are nested by table, doesn't this change overwrite previously accessed models for the same table?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this should be merged more deeply than this.

@tiii

tiii commented Feb 21, 2018 •

Copy link
Copy Markdown
Collaborator

I just refactored the existing code along with @Aryk's proposal, so all props go to him 👍

I feel like the model.items equality check is the way to go. If the selector only uses withId it should run pretty fast, and in the end - since nothing changed - the references are still the same and react shouldn't rerender or at least not rerender to the DOM (or it's equivalent).

I guess we could also store the accessedModels per selector function and check if only withId is called, but that seems utterly complex and pretty much overkill.

@tiii

tiii commented Feb 21, 2018

Copy link
Copy Markdown
Collaborator

I just realized we already memoize per selector. So we could also track the used query methods to find out if only withId is used.

I hope I find time to provide some test cases / documentation soon..

@B-Reif

B-Reif commented Feb 21, 2018 •

Copy link
Copy Markdown
Contributor Author

This implementation is still broken for ‘filter’ and ‘exclude’ queries, since they might hit new instances on each change.

EDIT: Additionally, a 'withId' query is actually just a shorthand for creating a 'filter' query. A basic 'withId' lookup might look like:

{
    table: "Book"
    clauses: Array(1)
        0:
            payload: {id: 2}
            type: "REDUX_ORM_FILTER"
}

@B-Reif

B-Reif commented Feb 21, 2018

Copy link
Copy Markdown
Contributor Author

To be honest, I'm not sure if there's a good strategy to apply across all possible query types. Some query types, like filter, have to actually examine every instance in the table. Others, like withId, only care about one given instance. Others still, like first or at(index), are concerned with the ordering of the QuerySet.

I think what I'd like to do is:

  • Expose a version of createSelectorCreator to allow developers to specify their own memoization function.
  • Keep the changes that mark individual instances as accessed, so that users of createSelectorCreator can memoize to the instance level where it's appropriate to do so.
  • Remove the changes to memoize, and keep the default memoization at the table level.

These changes would allow developers to take responsibility of memoization, and give developers the information they need to make meaningful memoization functions. But it would also leave the weak table-level default for most developers. And storing the instance ids in the map leaves queries (slightly) slower for everyone.

These changes would also retain most backward compatibility, excepting session.accessedModels, which will be a map instead of an array. I don't think session.accessedModels is intended as public API anyway.

The alternative would be to come up with a default memoization strategy that attempts to cover all possible use cases. This strategy would have to track which queries were used and their arguments to divine what level of the state it should be comparing at.

@Aryk

Aryk commented Feb 22, 2018 •

Copy link
Copy Markdown

@B-Reif when you said "This implementation is still broken for ‘filter’ and ‘exclude’ queries, since they might hit new instances on each change."

Are you saying that potentially new records could be added to the tables and hence the memoized result would not pick up the new records? Wasn't that handled before by just adding the model to accessed Models? Why don't we keep the old functionality in this way alongside the new per-id model. Maybe we could add in the map something like {[modelName]: true} or something like that.

When a filter or exclude happens you could add a flag to check the IDs of the table. You could use an efficient "===" on just the IDs (items key) by looking at the state directly. It would be db.Model.items in the state which is an array of the IDs. If an add or remove happened that array would be invalidated.

@B-Reif

B-Reif commented Feb 22, 2018

Copy link
Copy Markdown
Contributor Author

That’s not the problem; the problem is that existing instances could change such that they now pass the filter, when they previously did not.

For example, suppose we have a filter query that looks for Book models with a name starting with “P”. The first run, it caches the applicable model instances. Then I add a “P” to some other instance. Our filter query then doesn’t re-run, because the change was outside the cached instances.

@Aryk

Aryk commented Feb 22, 2018 •

Copy link
Copy Markdown

Right, that is why I am proposing anytime you hit something (filter/exclude) that relies on "bypassed" or "skipped" records, that you put {Book: true} into the accessedModels. That is basically like old functionality.

The difference is that now, because we have instance level caching, when you render an individual book with withId on a connected component, you won't actually have to rerender that entire component just because another instance got updated/add/deleted, whereas even if you have a connected component on one instance with Book ID "5", it will rerender when Book Id "6" gets updated, as it is currently today.

Many times in applications, it is possible the developer might keep a list of IDs to display in a list which is not dependent on attributes from the instances themselves, so you wouldn't be using filter/excude/etc. In those cases (which are many), the list of components tied to the individual redux-orm instances would not get updated and the list itself would be displayed and rerendered very optimally which is why i feel we need to include this functionality by default.

If you touch exclude/filter and maybe some other commands we would just do an entire table validation.

I hope we are talking about the same stuff. :)

@Aryk

Aryk commented Apr 10, 2018

Copy link
Copy Markdown

Just curious, have there been any updates on this feature? Any ETA on when it will get into the main branch?

@B-Reif

B-Reif commented Apr 11, 2018

Copy link
Copy Markdown
Contributor Author

I've stopped working on this as I no longer have time to pursue it. We can close this PR or have other people take up work on it.

My solution on my app's side: I use reselect selectors for components that consume arrays of ids, with a selector function that does a shallow compare of the id list. This isn't as good as just receiving the same array from redux-orm, but for my needs it's an acceptable workaround.

@haveyaseen

haveyaseen commented May 6, 2018 •

Copy link
Copy Markdown
Collaborator

@Aryk Created a fork and re-added the functionality for marking full tables as accessed (called it fullTableScanned). All queries now result in the scanned refs being marked as accessed, regardless of whether they have been accessed through the idAttribute or through filter/reject/orderBy. The whole logic happens inside session.query(), where I hook into the query type to determine which records had to be accessed to fulfill the query.

To be sure everything works, I added a few Redux integration tests. The only case where the selector still doesn't get re-executed (i.e. the cache is not invalidated) is when an update is being performed on a Model. For some reason the updated Model instance does not get a new reference - and the entire session.state and each session.state[Model] don't either, for what it's worth. Probably has to do with the session being immutable. I feel there must be a bug somewhere in Table.update(), though. Otherwise the reference comparison fails by design.

What I am still unsure about is whether or not CREATE, UPDATE and DELETE actions being performed in selectors have to actually constitute a table access or not. Technically they would have to trigger an invalidation of selectors, right? They are side effects and this means that a deletion would have to trigger a full table scan the next time the selector is called. In that case it is not enough to depend on session.query() to mark accessed model instances.

@haveyaseen

haveyaseen commented May 7, 2018 •

Copy link
Copy Markdown
Collaborator

So it turns out this feature collides with batched mutations.

The only immutability currently ensured is the idea that once any table changes in any way, the session.state reference changes as well. This happens in session.applyUpdate() and depends on the calculations done using the immutable-ops library. Specifically, each session generates a unique batchToken using immutable-ops' getBatchToken(), which is passed through to the immutable operations being performed on the rows and tables in various Table functions and also in replaceTableState() within src/db/Database.js.

This token being the same among all these calls causes immutable-ops to actually mutate the passed rows and tables in order to speed up the operations. Now, to my surprise I couldn't find any place where the library still uses immutable operations to change the state.

What does this mean for instance-based selectors?

  1. Table.update() does not create a new reference, even when the row actually receives changes. I made a change to Session.applyUpdate so that for each update a new batch token is generated, and thus mutations are forbidden.
    Fortunately, for CREATE and DELETE actions we do not need to disable mutations as they result in either new references being created or existing ones being deleted.
  2. We should consider making it possible to disable instance-based memoization in the session for the sake of improving the performance of UPDATE actions. The problem with this is that we create a separate session for each selector, meaning that this flag would not be available to memoize(). Now if we did allow this, we should make it abundantly clear to users that selectors will not react to update events. Selectors should then be configurable to not use instance-based memoization and only react to a changed session.state, same as they previously did.
  3. We can only apply these optimizations if mutations are turned off (session.withMutations == false). Otherwise we would have to perform deep equality checks as references will stay equal. In fact, now that I'm thinking about it, this could be done anyways. Yet another reason to provide more flexibility to users.
    While exposing a custom createSelectorCreator sounds great in theory, I doubt anyone would be able to implement their own memoization function without extensive knowledge of this library's internals (figuring all of this out took me a long time). Instead, it would probably be better to add an argument allowing users to specify a custom comparator function on the instance level as well. Right now this isn't even possible for the additional arguments, although it may seem so due to the default parameter eqCheck.

Bruce Reif and others added 25 commits June 6, 2018 21:33
ignore order of clauses (EXCLUDE and ORDER_BY are always executed after id-based filters)
@haveyaseen
haveyaseen merged commit cd9c714 into redux-orm:master Jun 6, 2018
@haveyaseen
haveyaseen deleted the instance-memoize branch June 6, 2018 20:35
@haveyaseen haveyaseen added the type: Enhancement This intends to enhance the project. label Jun 10, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: Enhancement This intends to enhance the project.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants