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)
}