Gather documentation from our old tools, and update this issue with relevant documentation information before adding them to official developers documentation.
Goal : improve performance for custom loaders (ex: fetch a new endpoint from magento)
Notes/Draft:
Dataloaders and cache invalidation
Why?
One of the main responsibility of GraphQL modules in Front-Commerce is fetching data from remote sources in resolvers.
A naive approach may reach a few limits in a real application. For instance, lets say one has a resolver on a Product.qty
field that fetch the current quantity in stock from https://inventory.example.com/stock/PRODUCT_SKU and a query like below:
{
category("pants") {
products({ limit: 10 }) {
sku
name
qty
}
}
}
The problem is that this query would lead to many HTTP requests from the server to the remote datasource:
- 1 request to fetch category and its products, with sku and name (in the best case)
- 10 additional requests to fetch products
qty
field
The qty
requests will be started only after the previous category response has been received, leading to waterfalls.
This problem is also known as the N+1 problem and Dataloaders are a way to solve this using batching and caching.
What are Dataloaders?
Dataloader is a pattern promoted by Facebook, from their internal implementations, to solve problems with data fetching. We use this name because it is the name of the reference implementation in Javascript: facebook/dataloader.
A dataloader is instantiated with a batching function, that will allow to fetch data in a grouped way. It also has a caching strategy that prevents fetching the same data twice in the same request (by default) or as soon as it is available in the cache (if using a Redis cache for instance).
In our previous example, if the Product.qty
resolver was implemented using a dataloader the query could have been resolved using only 2 remote API requests:
We encourage you to read the dataloader readme documentation to know more about how it works.
Front-Commerce only provides a small factory function to create Dataloaders from your GraphQL modules while keeping caching stategies configurable. Under the hood it is a pure dataloader instance that is made available to you.
Using Dataloaders in Front-Commerce
When building a GraphQL module, Front-Commerce will inject a makeDataloader
factory function in your module’s contextEnhancer
function.
The makeDataloader
factory allows developers to build a dataloader without worrying about the current store scope (in a multistore environment) or caching stategies.
Here is an example based on the use case above:
// GraphQL module definition file
// For this example the code is in the same file, but it is recommended to extract
// code in different ones
const { reorderForIds } = require("../common/dataloaderHelpers");
const StockLoader = (makeDataLoader, axiosInstance) => {
// our batching function that will be injected in the dataloader factory
// it is important to return results in the same order than the passed `skus`
// hence the use of `reorderForIds` (documented later in this page)
const loadStocksBatch = skus => {
return axiosInstance
.get("/batch/stock", { params: { skus })
.then(response => response.data.items)
.then(reorderForIds(skus, "sku"));
}
// The `Stock` key here must be unique across the project
// and is used in cache configuration to determine the caching strategy to use
const loader = makeDataLoader("Stock")(
skus => loadStocksBatch(skus)
);
return {
loadBySku: sku => loader.load(sku)
}
};
const typeDefs = `extend type Product {
qty: Int
}`;
const resolvers = {
Product: {
qty: ({ sku }, _, { loaders }) => {
// use the loader instance to fetch data
// batching and caching is transparent in the resolver
return loaders.Stock.loadBySku(sku);
}
}
}
module.exports = {
namespace: "Acme/Inventory",
typeDefs,
resolvers,
contextEnhancer: ({ makeDataLoader, config }) => {
const axiosInstance = axios.create({
baseURL: config.inventoryApiEndpointUrl
});
return {
// create an instance of the loader, to be made available in resolvers
Stock: StockLoader(makeDataLoader, axiosInstance)
};
}
};
Helpers available to build dataloaders
Writing batching functions and loaders could lead to reusing the same patterns. We have extracted some utility functions to help you in this task.
One can find them in the src/server/model/common/dataloaderHelpers.js
module.
reorderForIds
Batch functions must satisfy two constraints to be used in a dataloader (from the facebook/dataloader documentation):
- The Array of values must be the same length as the Array of keys.
- Each index in the Array of values must correspond to the same index in the Array of keys.
This is what the reorderForIds
is aimed at helping with.
Signature: const reorderForIds = (ids, idKey = "id") => data => sortedData;
It will sort data
by idKey
to match the order from the ids
array passed in parameters. In case no matching values is found, it will return null
and log a message so you could then understand why no result was found for a given id.
Example usage:
return Observable.fromPromise(
axiosInstance.get("/frontcommerce/price", { params })
)
.map(response => response.data)
.map(reorderForIds(skus, "sku"));
reorderForIdsCaseInsensitive
As its name implies, it is the same than reorderForIds
but ids are compared in a case insensitive way.
Example:
return axiosInstance
.get(`/products`, { params: searchCriteria })
.then(response => response.data.items.map(convertMagentoProductForFront))
.then(reorderForIdsCaseInsensitive(skus, "sku"));
reorderUrlsForIds
Example:
const removeLeadingSlash = url => url.replace(/^\//, "");
return axiosInstance
.get("/frontcommerce/urls/match", { params })
.then(response => response.data)
.then(reorderUrlsForIds(urls.map(removeLeadingSlash), "url"));
makeBatchLoaderFromSingleFetch
Until now, we created batching functions using a remote API that allowed to request several results at once.
When using 3rd party APIs or legacy systems, such APIs are not available. Using dataloaders in this case will not allow you to reduce the number of requests in the absolute however it could still allow you to prevent most of these requests (or reduce its number in practice) thanks to caching. It is very convenient when dealing with a slow service.
The makeBatchLoaderFromSingleFetch
allows you to create a batching function from a single fetching function very easily.
Pseudo signature: makeBatchLoaderFromSingleFetch = ( function singleFetch, function singleResponseMapper = ({ data }) => data ) => ids => sortedData;
Example:
// src/server/model/catalog/products/loaders/product_option.js
const {
makeBatchLoaderFromSingleFetch
} = require("../../../common/dataloaderHelpers");
// ...
const loadOptionsBatch = makeBatchLoaderFromSingleFetch(
sku =>
axiosInstance.get(
`/configurable-products/${encodeURIComponent(sku)}/options/all`
),
response => response.data.map(convertProductOptionForFront)
);
// ...
const optionsLoader = makeDataLoader("CatalogProductOption")(skus =>
loadOptionsBatch(skus).toPromise()
);
Caching dataloaders data
By default, all dataloaders are using a per-request in-memory caching strategy. It means that during the same GraphQL query, the same data will only be requested once.
Front-Commerce is also shipped with a persistent cache implementation, using a redis
stategy. One can implement new strategies to support more services (we also can help and support more strategies, contact us).
The dataloader cache must be configured in the src/config/caching.js
configuration file. If the configuration is empty (an empty object) only per-request cache will be used. Here is how one can configure persistent cache:
module.exports = {
redis: {
caches: "*", // or ["LoaderKeyA", "LoaderKeyB"]
disabled: ["CatalogPrice"],
config: {
host: "redis"
}
}
};
- configuration main keys (in this example
redis
) are the name of the strategy to enable
- each strategy has the following configuration:
caches
: keys of the loader to cache ("*"
for all). The key is the first argument passed to the makeDataLoader
factory
disabled
: a blacklist of loaders to not cache (convenient if caches
is "*"
)
config
: a strategy specific configuration (depends on the strategy implementation)
Invalidating the cache
For persistent cache, it is necessary that remote systems invalidate cache when relevant.
Front-Commerce provides several endpoints for it. They respond to GET
queries and are secured with a token to be passed in a auth-token
header.
The expected token must be configured in src/config/servicesKeys/cache.js
.
/_cache
: invalidate all data in persistent cache
/_cache/:scope
: invalidate all data for a given scope (for instance one store)
/_cache/:scope/:key
: invalidate all data of a given loader (matching :key
) for a given store
/_cache/:scope/:key/:id
: invalidate cached data for a single id of a given loader in a given store
Note: our Magento2 extension handles cache invalidation by default
Current dataloaders
Here is a list (as of v0.15) of Front-Commerce dataloader keys:
CatalogCategory
CatalogCategoryProduct
CatalogPrice
CatalogProduct
CatalogProductAttribute
CatalogProductAttributeCode
CatalogProductChildren
CatalogProductCrossells
CatalogProductCustomOption
CatalogProductMedia
CatalogProductOption
CatalogProductRelated
CatalogProductStock
CatalogProductUpsells
categoryUrl
CmsBlock
CmsPage
Countries
Navigation
Post
PostsList
productUrl
Swatch
UrlMatcher