UNPKG

next

Version:

The React Framework

339 lines (337 loc) • 14.3 kB
import { lruPut, updateLruSize, deleteFromLru } from './lru'; /** * A specialized data type for storing multi-key cache entries. * * The basic structure is a map whose keys are tuples, called the keypath. * When querying the cache, keypaths are compared per-element. * * Example: * set(map, ['https://localhost', 'foo/bar/baz'], 'yay'); * get(map, ['https://localhost', 'foo/bar/baz']) -> 'yay' * * NOTE: Array syntax is used in these examples for illustration purposes, but * in reality the paths are lists. * * The parts of the keypath represent the different inputs that contribute * to the entry value. To illustrate, if you were to use this data type to store * HTTP responses, the keypath would include the URL and everything listed by * the Vary header. * * See vary-path.ts for more details. * * The order of elements in a keypath must be consistent between lookups to * be considered the same, but besides that, the order of the keys is not * semantically meaningful. * * Keypaths may include a special kind of key called Fallback. When an entry is * stored with Fallback as part of its keypath, it means that the entry does not * vary by that key. When querying the cache, if an exact match is not found for * a keypath, the cache will check for a Fallback match instead. Each element of * the keypath may have a Fallback, so retrieval is an O(n ^ 2) operation, but * it's expected that keypaths are relatively short. * * Example: * set(cacheMap, ['store', 'product', 1], PRODUCT_PAGE_1); * set(cacheMap, ['store', 'product', Fallback], GENERIC_PRODUCT_PAGE); * * // Exact match * get(cacheMap, ['store', 'product', 1]) -> PRODUCT_PAGE_1 * * // Fallback match * get(cacheMap, ['store', 'product', 2]) -> GENERIC_PRODUCT_PAGE * * Because we have the Fallback mechanism, we can impose a constraint that * regular JS maps do not have: a value cannot be stored at multiple keypaths * simultaneously. These cases should be expressed with Fallback keys instead. * * Additionally, because values only exist at a single keypath at a time, we * can optimize successive lookups by caching the internal map entry on the * value itself, using the `ref` field. This is especially useful because it * lets us skip the O(n ^ 2) lookup that occurs when Fallback entries * are present. * * How to decide if stuff belongs in here, or in cache.ts? * ------------------------------------------------------- * * Anything to do with retrival, lifetimes, or eviction needs to go in this * module because it affects the fallback algorithm. For example, when * performing a lookup, if an entry is stale, it needs to be treated as * semantically equivalent to if the entry was not present at all. * * If there's logic that's not related to the fallback algorithm, though, we * should prefer to put it in cache.ts. */ /** * Tracks the status of a cache entry as it progresses from no data (Empty), * waiting for server data (Pending), and finished (either Fulfilled or * Rejected depending on the response from the server. * * This is defined here, rather than in cache.ts, because cache-map.ts needs to * inspect the status when performing a `onlyMatchFulfilled` lookup. */ export var EntryStatus = /*#__PURE__*/ function(EntryStatus) { EntryStatus[EntryStatus["Empty"] = 0] = "Empty"; EntryStatus[EntryStatus["Pending"] = 1] = "Pending"; EntryStatus[EntryStatus["Fulfilled"] = 2] = "Fulfilled"; EntryStatus[EntryStatus["Rejected"] = 3] = "Rejected"; return EntryStatus; }({}); export const Fallback = {}; // This is a special internal key that is used for "revalidation" entries. It's // an implementation detail that shouldn't leak outside of this module. const Revalidation = {}; export function createCacheMap() { const cacheMap = { parent: null, key: null, value: null, map: null, // LRU-related fields prev: null, next: null, size: 0 }; return cacheMap; } function getOrInitialize(cacheMap, keys, isRevalidation) { // Go through each level of keys until we find the entry that matches, or // create a new entry if one doesn't exist. // // This function will only return entries that match the keypath _exactly_. // Unlike getWithFallback, it will not access fallback entries unless it's // explicitly part of the keypath. let entry = cacheMap; let remainingKeys = keys; let key = null; while(true){ const previousKey = key; if (remainingKeys !== null) { key = remainingKeys.value; remainingKeys = remainingKeys.parent; } else if (isRevalidation && previousKey !== Revalidation) { // During a revalidation, we append an internal "Revalidation" key to // the end of the keypath. The "normal" entry is its parent. // However, if the parent entry is currently empty, we don't need to store // this as a revalidation entry. Just insert the revalidation into the // normal slot. if (entry.value === null) { return entry; } // Otheriwse, create a child entry. key = Revalidation; } else { break; } let map = entry.map; if (map !== null) { const existingEntry = map.get(key); if (existingEntry !== undefined) { // Found a match. Keep going. entry = existingEntry; continue; } } else { map = new Map(); entry.map = map; } // No entry exists yet at this level. Create a new one. const newEntry = { parent: entry, key, value: null, map: null, // LRU-related fields prev: null, next: null, size: 0 }; map.set(key, newEntry); entry = newEntry; } return entry; } export function getFromCacheMap(now, currentCacheVersion, rootEntry, keys, isRevalidation, // When true, terminal entries whose status is not Fulfilled are skipped, so // the lookup falls through to a less-specific Fallback entry. Use this // during a navigation to prefer a Fulfilled shell entry over a more-specific // Pending entry that may still be in-flight. Callers that want to also // accept non-Fulfilled entries should perform a second lookup with this set // to false. onlyMatchFulfilled) { const entry = getEntryWithFallbackImpl(now, currentCacheVersion, rootEntry, keys, isRevalidation, 0, onlyMatchFulfilled); if (entry === null || entry.value === null) { return null; } // This is an LRU access. Move the entry to the front of the list. lruPut(entry); return entry.value; } export function isValueExpired(now, currentCacheVersion, value) { return value.staleAt <= now || value.version < currentCacheVersion; } function lazilyEvictIfNeeded(now, currentCacheVersion, entry, onlyMatchFulfilled) { // We have a matching entry, but before we can return it, we need to check if // it's still fresh. Otherwise it should be treated the same as a cache miss. if (entry.value === null) { // This entry has no value, so there's nothing to evict. return entry; } const value = entry.value; if (isValueExpired(now, currentCacheVersion, value)) { // The value expired. Lazily evict it from the cache, and return null. This // is conceptually the same as a cache miss. deleteMapEntry(entry); return null; } if (onlyMatchFulfilled && value.status !== 2) { // The entry is fresh but is not Fulfilled. Treat as a non-match so the // recursion can continue and try a Fallback entry. return null; } // The matched entry has not expired. Return it. return entry; } function getEntryWithFallbackImpl(now, currentCacheVersion, entry, keys, isRevalidation, previousKey, onlyMatchFulfilled) { // This is similar to getExactEntry, but if an exact match is not found for // a key, it will return the fallback entry instead. This is recursive at // every level, e.g. an entry with keypath [a, Fallback, c, Fallback] is // valid match for [a, b, c, d]. // // It will return the most specific match available. // // When `onlyMatchFulfilled` is true, terminal entries that aren't Fulfilled // are treated as non-matches, so the recursion will continue searching for // a Fallback match. See getFromCacheMap for the rationale. let key; let remainingKeys; if (keys !== null) { key = keys.value; remainingKeys = keys.parent; } else if (isRevalidation && previousKey !== Revalidation) { // During a revalidation, we append an internal "Revalidation" key to // the end of the keypath. key = Revalidation; remainingKeys = null; } else { // There are no more keys. This is the terminal entry. return lazilyEvictIfNeeded(now, currentCacheVersion, entry, onlyMatchFulfilled); } const map = entry.map; if (map !== null) { const existingEntry = map.get(key); if (existingEntry !== undefined) { // Found an exact match for this key. Keep searching. const result = getEntryWithFallbackImpl(now, currentCacheVersion, existingEntry, remainingKeys, isRevalidation, key, onlyMatchFulfilled); if (result !== null) { return result; } } // No match found for this key. Check if there's a fallback. const fallbackEntry = map.get(Fallback); if (fallbackEntry !== undefined) { // Found a fallback for this key. Keep searching. return getEntryWithFallbackImpl(now, currentCacheVersion, fallbackEntry, remainingKeys, isRevalidation, key, onlyMatchFulfilled); } } return null; } export function setInCacheMap(cacheMap, keys, value, isRevalidation) { // Add a value to the map at the given keypath. If the value is already // part of the map, it's removed from its previous keypath. (NOTE: This is // unlike a regular JS map, but the behavior is intentional.) const entry = getOrInitialize(cacheMap, keys, isRevalidation); setMapEntryValue(entry, value); // This is an LRU access. Move the entry to the front of the list. lruPut(entry); updateLruSize(entry, value.size); } function setMapEntryValue(entry, value) { if (entry.value !== null) { // There's already a value at the given keypath. Disconnect the old value // from the map. We're not calling `deleteMapEntry` here because the // entry itself is still in the map. We just want to overwrite its value. dropRef(entry.value); entry.value = null; } // This value may already be in the map at a different keypath. // Grab a reference before we overwrite it. const oldEntry = value.ref; entry.value = value; value.ref = entry; updateLruSize(entry, value.size); if (oldEntry !== null && oldEntry !== entry && oldEntry.value === value) { // This value is already in the map at a different keypath in the map. // Values only exist at a single keypath at a time. Remove it from the // previous keypath. // // Note that only the internal map entry is garbage collected; we don't // call `dropRef` here because it's still in the map, just // at a new keypath (the one we just set, above). deleteMapEntry(oldEntry); } } export function deleteFromCacheMap(value) { const entry = value.ref; if (entry === null) { // This value is not a member of any map. return; } dropRef(value); deleteMapEntry(entry); } function dropRef(value) { // Drop the value from the map by setting its `ref` backpointer to // null. This is a separate operation from `deleteMapEntry` because when // re-keying a value we need to be able to delete the old, internal map // entry without garbage collecting the value itself. value.ref = null; } export function deleteMapEntry(entry) { // Delete the entry from the cache. entry.value = null; deleteFromLru(entry); // Check if we can garbage collect the entry. const map = entry.map; if (map === null) { // Since this entry has no value, and also no child entries, we can // garbage collect it. Remove it from its parent, and keep garbage // collecting the parents until we reach a non-empty entry. let parent = entry.parent; let key = entry.key; while(parent !== null){ const parentMap = parent.map; if (parentMap !== null) { parentMap.delete(key); if (parentMap.size === 0) { // We just removed the last entry in the parent map. parent.map = null; if (parent.value === null) { // The parent node has no child entries, nor does it have a value // on itself. It can be garbage collected. Keep going. key = parent.key; parent = parent.parent; continue; } } } break; } } else { // Check if there's a revalidating entry. If so, promote it to a // "normal" entry, since the normal one was just deleted. const revalidatingEntry = map.get(Revalidation); if (revalidatingEntry !== undefined && revalidatingEntry.value !== null) { setMapEntryValue(entry, revalidatingEntry.value); } } } export function setSizeInCacheMap(value, size) { const entry = value.ref; if (entry === null) { // This value is not a member of any map. return; } // Except during initialization (when the size is set to 0), this is the only // place the `size` field should be updated, to ensure it's in sync with the // the LRU. value.size = size; updateLruSize(entry, size); } //# sourceMappingURL=cache-map.js.map