All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.com.apollographql.cache.normalized.GarbageCollection.kt Maven / Gradle / Ivy

package com.apollographql.cache.normalized

import com.apollographql.apollo.annotations.ApolloInternal
import com.apollographql.apollo.mpp.currentTimeMillis
import com.apollographql.cache.normalized.api.CacheHeaders
import com.apollographql.cache.normalized.api.CacheKey
import com.apollographql.cache.normalized.api.DefaultRecordMerger
import com.apollographql.cache.normalized.api.MaxAgeContext
import com.apollographql.cache.normalized.api.MaxAgeProvider
import com.apollographql.cache.normalized.api.NormalizedCache
import com.apollographql.cache.normalized.api.Record
import com.apollographql.cache.normalized.api.RecordValue
import com.apollographql.cache.normalized.api.expirationDate
import com.apollographql.cache.normalized.api.receivedDate
import kotlin.time.Duration

@ApolloInternal
fun Map.getReachableCacheKeys(): Set {
  fun Map.getReachableCacheKeys(roots: List, reachableCacheKeys: MutableSet) {
    val records = roots.mapNotNull { this[it.key] }
    val cacheKeysToCheck = mutableListOf()
    for (record in records) {
      reachableCacheKeys.add(CacheKey(record.key))
      cacheKeysToCheck.addAll(record.referencedFields() - reachableCacheKeys)
    }
    if (cacheKeysToCheck.isNotEmpty()) {
      getReachableCacheKeys(cacheKeysToCheck, reachableCacheKeys)
    }
  }

  return mutableSetOf().also { reachableCacheKeys ->
    getReachableCacheKeys(listOf(CacheKey.rootKey()), reachableCacheKeys)
  }
}

@ApolloInternal
fun NormalizedCache.allRecords(): Map {
  return dump().values.fold(emptyMap()) { acc, map -> acc + map }
}

/**
 * Remove all unreachable records in the cache.
 * A record is unreachable if there exists no chain of references from the root record to it.
 *
 * @return the cache keys that were removed.
 */
fun NormalizedCache.removeUnreachableRecords(): Set {
  val allRecords = allRecords()
  return removeUnreachableRecords(allRecords)
}

private fun NormalizedCache.removeUnreachableRecords(allRecords: Map): Set {
  val unreachableCacheKeys = allRecords.keys.map { CacheKey(it) } - allRecords.getReachableCacheKeys()
  remove(unreachableCacheKeys, cascade = false)
  return unreachableCacheKeys.toSet()
}

/**
 * Remove all unreachable records in the store.
 * @see removeUnreachableRecords
 */
fun ApolloStore.removeUnreachableRecords(): Set {
  return accessCache { cache ->
    cache.removeUnreachableRecords()
  }
}

/**
 * Remove all stale fields in the cache.
 * A field is stale if its received date is older than its max age (configurable via [maxAgeProvider]) or if its expiration date has
 * passed. A maximum staleness can be passed.
 *
 * Received dates are stored by calling `storeReceiveDate(true)` on your `ApolloClient`.
 *
 * Expiration dates are stored by calling `storeExpirationDate(true)` on your `ApolloClient`.
 *
 * When all fields of a record are stale, the record itself is removed.
 *
 * This operation can result in unreachable records, and dangling references.
 *
 * @return the fields and records that were removed.
 */
fun NormalizedCache.removeStaleFields(
    maxAgeProvider: MaxAgeProvider,
    maxStale: Duration = Duration.ZERO,
): RemovedFieldsAndRecords {
  val allRecords = allRecords().toMutableMap()
  return removeStaleFields(allRecords, maxAgeProvider, maxStale)
}

private fun NormalizedCache.removeStaleFields(
    allRecords: MutableMap,
    maxAgeProvider: MaxAgeProvider,
    maxStale: Duration,
): RemovedFieldsAndRecords {
  val recordsToUpdate = mutableMapOf()
  val removedFields = mutableSetOf()
  for (record in allRecords.values.toList()) {
    var recordCopy = record
    for (field in record.fields) {
      // Consider the client controlled max age
      val receivedDate = record.receivedDate(field.key)
      if (receivedDate != null) {
        val currentDate = currentTimeMillis() / 1000
        val age = currentDate - receivedDate
        val maxAge = maxAgeProvider.getMaxAge(
            MaxAgeContext(
                listOf(
                    MaxAgeContext.Field(name = "", type = record["__typename"] as? String ?: "", isTypeComposite = true),
                    MaxAgeContext.Field(name = field.key, type = field.value.guessType(allRecords), isTypeComposite = field.value is CacheKey),
                )
            )
        ).inWholeSeconds
        val staleDuration = age - maxAge
        if (staleDuration >= maxStale.inWholeSeconds) {
          recordCopy -= field.key
          recordsToUpdate[record.key] = recordCopy
          removedFields.add(record.key + "." + field.key)
          if (recordCopy.isEmptyRecord()) {
            allRecords.remove(record.key)
          } else {
            allRecords[record.key] = recordCopy
          }
          continue
        }
      }

      // Consider the server controlled max age
      val expirationDate = record.expirationDate(field.key)
      if (expirationDate != null) {
        val currentDate = currentTimeMillis() / 1000
        val staleDuration = currentDate - expirationDate
        if (staleDuration >= maxStale.inWholeSeconds) {
          recordCopy -= field.key
          recordsToUpdate[record.key] = recordCopy
          removedFields.add(record.key + "." + field.key)
          if (recordCopy.isEmptyRecord()) {
            allRecords.remove(record.key)
          } else {
            allRecords[record.key] = recordCopy
          }
        }
      }
    }
  }
  if (recordsToUpdate.isNotEmpty()) {
    remove(recordsToUpdate.keys.map { CacheKey(it) }, cascade = false)
    val emptyRecords = recordsToUpdate.values.filter { it.isEmptyRecord() }.toSet()
    val nonEmptyRecords = recordsToUpdate.values - emptyRecords
    if (nonEmptyRecords.isNotEmpty()) {
      merge(nonEmptyRecords, CacheHeaders.NONE, DefaultRecordMerger)
    }
    return RemovedFieldsAndRecords(
        removedFields = removedFields,
        removedRecords = emptyRecords.map { CacheKey(it.key) }.toSet()
    )
  }
  return RemovedFieldsAndRecords(removedFields = emptySet(), removedRecords = emptySet())
}

/**
 * Remove all stale fields in the store.
 * @see removeStaleFields
 */
fun ApolloStore.removeStaleFields(
    maxAgeProvider: MaxAgeProvider,
    maxStale: Duration = Duration.ZERO,
): RemovedFieldsAndRecords {
  return accessCache { cache ->
    cache.removeStaleFields(maxAgeProvider, maxStale)
  }
}

/**
 * Remove all dangling references in the cache.
 * A field is a dangling reference if its value (or, for lists, any of its values) is a reference to a record that does not exist.
 *
 * When all fields of a record are dangling references, the record itself is removed.
 *
 * This operation can result in unreachable records.
 *
 * @return the fields and records that were removed.
 */
fun NormalizedCache.removeDanglingReferences(): RemovedFieldsAndRecords {
  val allRecords: MutableMap = allRecords().toMutableMap()
  return removeDanglingReferences(allRecords)
}

private fun NormalizedCache.removeDanglingReferences(allRecords: MutableMap): RemovedFieldsAndRecords {
  val recordsToUpdate = mutableMapOf()
  val allRemovedFields = mutableSetOf()
  do {
    val removedFields = mutableSetOf()
    for (record in allRecords.values.toList()) {
      var recordCopy = record
      for (field in record.fields) {
        if (field.value.isDanglingReference(allRecords)) {
          recordCopy -= field.key
          recordsToUpdate[record.key] = recordCopy
          removedFields.add(record.key + "." + field.key)
          if (recordCopy.isEmptyRecord()) {
            allRecords.remove(record.key)
          } else {
            allRecords[record.key] = recordCopy
          }
        }
      }
    }
    allRemovedFields.addAll(removedFields)
  } while (removedFields.isNotEmpty())
  if (recordsToUpdate.isNotEmpty()) {
    remove(recordsToUpdate.keys.map { CacheKey(it) }, cascade = false)
    val emptyRecords = recordsToUpdate.values.filter { it.isEmptyRecord() }.toSet()
    val nonEmptyRecords = recordsToUpdate.values - emptyRecords
    if (nonEmptyRecords.isNotEmpty()) {
      merge(nonEmptyRecords, CacheHeaders.NONE, DefaultRecordMerger)
    }
    return RemovedFieldsAndRecords(
        removedFields = allRemovedFields,
        removedRecords = emptyRecords.map { CacheKey(it.key) }.toSet()
    )
  }
  return RemovedFieldsAndRecords(removedFields = emptySet(), removedRecords = emptySet())
}

/**
 * Remove all dangling references in the store.
 * @see removeDanglingReferences
 */
fun ApolloStore.removeDanglingReferences(): RemovedFieldsAndRecords {
  return accessCache { cache ->
    cache.removeDanglingReferences()
  }
}

private fun RecordValue.isDanglingReference(allRecords: Map): Boolean {
  return when (this) {
    is CacheKey -> allRecords[this.key] == null
    is List<*> -> any { it.isDanglingReference(allRecords) }
    is Map<*, *> -> values.any { it.isDanglingReference(allRecords) }
    else -> false
  }
}

private fun Record.isEmptyRecord() = fields.isEmpty() || fields.size == 1 && fields.keys.first() == "__typename"

private fun RecordValue.guessType(allRecords: Map): String {
  return when (this) {
    is List<*> -> {
      val first = firstOrNull() ?: return ""
      first.guessType(allRecords)
    }

    is CacheKey -> {
      allRecords[key]?.get("__typename") as? String ?: ""
    }

    else -> {
      ""
    }
  }
}

private operator fun Record.minus(key: String): Record {
  return Record(
      key = this.key,
      fields = this.fields - key,
      metadata = this.metadata - key,
  )
}

/**
 * Perform garbage collection on the cache.
 *
 * This is a convenience method that calls [removeStaleFields], [removeDanglingReferences], and [removeUnreachableRecords].
 *
 * @param maxAgeProvider the max age provider to use for [removeStaleFields]
 * @param maxStale the maximum staleness to use for [removeStaleFields]
 */
fun NormalizedCache.garbageCollect(
    maxAgeProvider: MaxAgeProvider,
    maxStale: Duration = Duration.ZERO,
): GarbageCollectResult {
  val allRecords = allRecords().toMutableMap()
  return GarbageCollectResult(
      removedStaleFields = removeStaleFields(allRecords, maxAgeProvider, maxStale),
      removedDanglingReferences = removeDanglingReferences(allRecords),
      removedUnreachableRecords = removeUnreachableRecords(allRecords)
  )
}

/**
 * Perform garbage collection on the store.
 * @see garbageCollect
 */
fun ApolloStore.garbageCollect(
    maxAgeProvider: MaxAgeProvider,
    maxStale: Duration = Duration.ZERO,
): GarbageCollectResult {
  return accessCache { cache ->
    cache.garbageCollect(maxAgeProvider, maxStale)
  }
}

class RemovedFieldsAndRecords(
    val removedFields: Set,
    val removedRecords: Set,
)

class GarbageCollectResult(
    val removedStaleFields: RemovedFieldsAndRecords,
    val removedDanglingReferences: RemovedFieldsAndRecords,
    val removedUnreachableRecords: Set,
)




© 2015 - 2025 Weber Informatics LLC | Privacy Policy