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

Storage and invalidation for custom field read functions. #5667

Merged
merged 12 commits into from
Dec 12, 2019

Conversation

benjamn
Copy link
Member

@benjamn benjamn commented Dec 10, 2019

Custom field read functions might need to perform expensive computations, so caching the results of read functions is encouraged. Unfortunately, there is no good way for the InMemoryCache to provide that caching automatically, since read function results could depend on any/all/some/none of the arguments passed to the field, and only the application developer knows which arguments are important.

At this point you might be thinking, "Doesn't the developer already have an opportunity to tell the cache which arguments are important by configuring keyArgs: [...] in the field policy?" While that statement is true, it's only the beginning of the story. When you specify keyArgs, you're telling the cache how to distinguish multiple values for a given field, but the read and merge functions have access to the complete set of arguments, so keyArgs alone is not enough to differentiate between all possible read function return values.

In fact, a common pattern when implementing custom read and merge functions is to pass keyArgs: false to disable the default argument-based differentiation entirely, so the read and merge functions can take full responsibility for interpreting the arguments passed to the field. In this common scenario, the cache knows nothing about how read results should be stored. In short, read function caching is a responsibility that must be left to the read function.

To resolve this seemingly unresolvable conundrum, I've adopted a simple but flexible policy: every read function now has access to a private options.storage object, which is a Record<string, any> where the read function can stash any information it wants to preserve across multiple invocations of the read function. This options.storage object is unique to the current entity object and the current field name (plus any additional information specified via keyArgs). In other words, you can think of this options.storage object as storing mutable metadata about the immutable existing field value.

Here's an example of a cached read function for a WorkItem.expensiveResult field:

const cache = new InMemoryCache({
  typePolicies: {
    WorkItem: {
      fields: {
        expensiveResult: {
          keyArgs: false, // read function assumes full responsibility for args handling
          read(existing, { args, storage }) {
            const key = makeKeyFromArgs(args); // user-defined
            if (key in storage) return storage[key];
            return storage[key] = expensiveComputation(existing, args);
          },
        },
      },
    },
  },
});

To complement the options.storage object, this PR also introduces an options.invalidate() function that can be called to invalidate any cached query results that previously consumed the field value, which is especially useful when the read function uses external data sources that might change over time. Specifically, calling invalidate() will invalidate any results that were computed using the same options.storage object:

const cache = new InMemoryCache({
  typePolicies: {
    WorkItem: {
      fields: {
        expensiveResult: {
          keyArgs: false,
          read(existing, { args, storage, invalidate }) {
            const key = makeKeyFromArgs(args);
            if (key in storage) return storage[key];
            // Suppose the expensiveComputation takes a callback function that will
            // be called whenever the result of the computation may have changed:
            return storage[key] = expensiveComputation(existing, args, newResult => {
              // If a new result is not available, delete storage[key] instead.
              storage[key] = newResult;
              invalidate();
            });
          },
        },
      },
    },
  },
});

Finally, this PR renames the options.getFieldValue helper function to options.readField, and allows it to invoke custom read functions for any fields that it reads, rather than just retrieving the existing field value. See the commit message and tests included in 5219e36 to understand why this change was important.

TypeScript doesn't do a great job of enforcing parameter types (including
`this:` types) for functions called with Function.prototype.{call,apply},
which is especially frustrating because you pretty much have to use those
methods when you want to call a function with a specific `this` object.

Another reason to avoid using `this` is simply that some developers prefer
arrow functions, and arrow functions ignore any `this` object provided by
.call or .apply. Instead, we can expose the Policies object as a property
in the FieldFunctionOptions parameter, so it can be used (or ignored)
without having to think about `this` at all.
We're no longer passing in a StoreObject, so the longer name is now
technically inaccurate.
If a read function needs to cache expensive computations, or do any other
sort of long-term bookkeeping, it's convenient to have a unique private
storage object that gets passed to the read function each time it is
invoked for a particular field within a particular object.

Making this work for normalized entity objects that have a string ID was
easy, but it was somewhat trickier to support non-normalized, nested
objects. The trick is to use the object itself as a key in a WeakMap (via
the KeyTrie), so the object will not be kept alive after it has been
removed from the cache. We do not need or want to continue using the same
storage object after such a change, because it is never safe to assume a
non-normalized object has the same identity as any other (!==) object in
the cache.
Custom field read functions that listen to external data sources need to
inform the cache when the external data change, so any queries that
previously consumed the field can be reevaluated.

Invalidation can also be triggered by writing a new value for the
underlying field data, but not all read functions are backed by existing
data in the cache, so options.invalidate() fills the gap for those purely
dynamic read functions.
At first I thought it would be risky to allow getFieldValue to call read
functions, because it would open the door to infinite recursion and
expensive chains of read functions.

However, after attempting to write tests that made heavy use of
getFieldValue, I realized that calling read functions is just too useful
to forbid, and, if we crippled getFieldValue in this way, developers would
probably just resort to using cache.readFragment to read data from the
cache, which passes through several more layers of abstraction, and thus
is almost certainly slower than readField.
Comment on lines +389 to +390
export type FieldValueGetter =
ReturnType<typeof makeFieldValueGetter>;
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 like this pattern of exporting just the inferred return type of a function, without exporting the function itself.

field: string,
foreignRef?: Reference,
) => any,
typename = getFieldValue("__typename") as string,
Copy link
Member Author

Choose a reason for hiding this comment

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

We could keep passing in the typename (with a default expression fallback), since we always have to call getFieldValue<string>(objectOrReference, "__typename") in executeSelectionSet, which is the primary caller of this method. That would make reading fields that don't have custom read functions a tiny bit faster.

Comment on lines +484 to +485
invalidate() {
policies.fieldDep.dirty(storage);
Copy link
Member Author

Choose a reason for hiding this comment

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

In order to be really useful, the options.invalidate function should also schedule a cache.broadcastWatches() call. I'll tackle that in a follow-up PR.

@@ -472,6 +602,473 @@ describe("type policies", function () {
expect(cache.extract(true)).toEqual(expectedExtraction);
});

it("readField helper function calls custom read functions", function () {
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 is a long test, but I think it's worth reading through it to get a sense for what it's like to implement highly dynamic custom read functions in terms of other custom read functions.

Copy link
Member

@hwillson hwillson left a comment

Choose a reason for hiding this comment

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

This looks great @benjamn - super useful! Just a quick note while we're working on the docs: the idea of using a cache (StorageType) while working with the cache (InMemoryCache) might confuse people as they ramp up with the new cache API, so we'll want to make sure this is addressed clearly. Thanks!

Comment on lines 121 to 123
// consumed this field. If you use options.storage as a cache, setting a
// new value in the cache and then calling options.invalidate() can be a
// good way to deliver asynchronous results.
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if the use invalidate to help deliver asynchronous results mention here might confuse people, without more context. These are code comments so maybe not (as people in here are grokking the source at the same time), but since we're mentioning it we might want to add another sentence or two that expands on this.

@benjamn benjamn mentioned this pull request Dec 12, 2019
31 tasks
@benjamn benjamn merged commit c2c1f08 into release-3.0 Dec 12, 2019
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 16, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants