File size: 14,865 Bytes
b91e262 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 | import type { VaryPath } from './vary-path'
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.
*/
// The protocol that values must implement. In practice, the only two types that
// we ever actually deal with in this module are RouteCacheEntry and
// SegmentCacheEntry; this is just to keep track of the coupling so we don't
// leak concerns between the modules unnecessarily.
export interface MapValue {
ref: UnknownMapEntry | null
size: number
staleAt: number
version: number
}
/**
* Represents a node in the cache map and LRU.
* MapEntry<V> structurally satisfies this interface for any V extends MapValue.
*
* The LRU can contain entries of different value types
* (e.g., both RouteCacheEntry and SegmentCacheEntry). This interface captures
* the common structure needed for cache map and LRU operations without
* requiring knowledge of the specific value type.
*/
export interface MapEntry<V extends MapValue> {
// Cache map structure fields
parent: MapEntry<V> | null
key: unknown
map: Map<unknown, MapEntry<V>> | null
value: V | null
// LRU linked list fields
prev: MapEntry<V> | null
next: MapEntry<V> | null
size: number
}
/**
* A looser type for MapEntry
* This allows the LRU to work with entries of different
* value types while still providing type safety.
*
* The `map` field lets Map<unknown, MapEntry<V>> be assignable to this
* type since we're only reading from the map, not inserting into it.
*/
export type UnknownMapEntry = {
parent: UnknownMapEntry | null
key: unknown
map: Pick<Map<unknown, UnknownMapEntry>, 'get' | 'delete' | 'size'> | null
value: MapValue | null
prev: UnknownMapEntry | null
next: UnknownMapEntry | null
size: number
}
// The CacheMap type is just the root entry of the map.
export type CacheMap<V extends MapValue> = MapEntry<V>
export type FallbackType = { __brand: 'Fallback' }
export const Fallback = {} as FallbackType
// 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<V extends MapValue>(): CacheMap<V> {
const cacheMap: MapEntry<V> = {
parent: null,
key: null,
value: null,
map: null,
// LRU-related fields
prev: null,
next: null,
size: 0,
}
return cacheMap
}
function getOrInitialize<V extends MapValue>(
cacheMap: CacheMap<V>,
keys: VaryPath,
isRevalidation: boolean
): MapEntry<V> {
// 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: VaryPath | null = keys
let key: unknown | null = 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 {
// There are no more keys. This is the terminal entry.
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: MapEntry<V> = {
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<V extends MapValue>(
now: number,
currentCacheVersion: number,
rootEntry: CacheMap<V>,
keys: VaryPath,
isRevalidation: boolean
): V | null {
const entry = getEntryWithFallbackImpl(
now,
currentCacheVersion,
rootEntry,
keys,
isRevalidation,
0
)
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: number,
currentCacheVersion: number,
value: MapValue
): boolean {
return value.staleAt <= now || value.version < currentCacheVersion
}
function lazilyEvictIfNeeded<V extends MapValue>(
now: number,
currentCacheVersion: number,
entry: MapEntry<V>
) {
// 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
}
// The matched entry has not expired. Return it.
return entry
}
function getEntryWithFallbackImpl<V extends MapValue>(
now: number,
currentCacheVersion: number,
entry: MapEntry<V>,
keys: VaryPath | null,
isRevalidation: boolean,
previousKey: unknown | null
): MapEntry<V> | null {
// 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.
let key
let remainingKeys: VaryPath | null
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.
// TODO: When performing a lookup during a navigation, as opposed to a
// prefetch, we may want to skip entries that are Pending if there's also
// a Fulfilled fallback entry. Tricky to say, though, since if it's
// already pending, it's likely to stream in soon. Maybe we could do this
// just on slow connections and offline mode.
return lazilyEvictIfNeeded(now, currentCacheVersion, entry)
}
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
)
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
)
}
}
return null
}
export function setInCacheMap<V extends MapValue>(
cacheMap: CacheMap<V>,
keys: VaryPath,
value: V,
isRevalidation: boolean
): void {
// 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: UnknownMapEntry, value: MapValue): void {
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: MapValue): void {
const entry = value.ref
if (entry === null) {
// This value is not a member of any map.
return
}
dropRef(value)
deleteMapEntry(entry)
}
function dropRef(value: MapValue): void {
// 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: UnknownMapEntry): void {
// 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
}
}
}
// The parent is not empty. Stop garbage collecting.
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<V extends MapValue>(
value: V,
size: number
): void {
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)
}
|