diff --git a/Writerside/doc.tree b/Writerside/doc.tree index f8bf4845..d659899d 100644 --- a/Writerside/doc.tree +++ b/Writerside/doc.tree @@ -10,4 +10,5 @@ + diff --git a/Writerside/topics/expiration.md b/Writerside/topics/expiration.md new file mode 100644 index 00000000..1b1512bf --- /dev/null +++ b/Writerside/topics/expiration.md @@ -0,0 +1,128 @@ +# Expiration + +## Server-controlled + +When receiving a response from the server, the [`Cache-Control` HTTP header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) can be used to determine the **expiration date** of the fields in the response. + +> Apollo Server can be configured to include the `Cache-Control` header in responses. See the [caching documentation](https://www.apollographql.com/docs/apollo-server/performance/caching/) for more information. + +The cache can be configured to store the **expiration date** of the received fields in the corresponding records. To do so, call [`.storeExpirationDate(true)`](https://apollographql.github.io/apollo-kotlin-normalized-cache-incubating/kdoc/normalized-cache-incubating/com.apollographql.cache.normalized/store-expiration-date.html?query=fun%20%3CT%3E%20MutableExecutionOptions%3CT%3E.storeExpirationDate(storeExpirationDate:%20Boolean):%20T), and set your client's cache resolver to [`ExpirationCacheResolver`](https://apollographql.github.io/apollo-kotlin-normalized-cache-incubating/kdoc/normalized-cache-incubating/com.apollographql.cache.normalized.api/-expiration-cache-resolver/index.html): + +```kotlin +val apolloClient = ApolloClient.builder() + .serverUrl("https://example.com/graphql") + .storeExpirationDate(true) + .normalizedCache( + normalizedCacheFactory = /*...*/, + cacheResolver = ExpirationCacheResolver(), + ) + .build() +``` + +**Expiration dates** will be stored and when a field is resolved, the cache resolver will check if the field is expired. If so, it will throw a `CacheMissException`. + +## Client-controlled + +When storing fields, the cache can also store their **received date**. This date can then be compared to the current date when resolving a field to determine if its age is above its **maximum age**. + +To store the **received date** of fields, call [`.storeReceivedDate(true)`](https://apollographql.github.io/apollo-kotlin-normalized-cache-incubating/kdoc/normalized-cache-incubating/com.apollographql.cache.normalized/store-receive-date.html?query=fun%20%3CT%3E%20MutableExecutionOptions%3CT%3E.storeReceiveDate(storeReceiveDate:%20Boolean):%20T), and set your client's cache resolver to [`ExpirationCacheResolver`](https://apollographql.github.io/apollo-kotlin-normalized-cache-incubating/kdoc/normalized-cache-incubating/com.apollographql.cache.normalized.api/-expiration-cache-resolver/index.html): + +```kotlin +val apolloClient = ApolloClient.builder() + .serverUrl("https://example.com/graphql") + .storeReceivedDate(true) + .normalizedCache( + normalizedCacheFactory = /*...*/, + cacheResolver = ExpirationCacheResolver(maxAgeProvider), + ) + .build() +``` + +> Expiration dates and received dates can be both stored to combine server-controlled and client-controlled expiration strategies. + +The **maximum age** of fields can be configured either programmatically, or declaratively in the schema. This is done by passing a [`MaxAgeProvider`](https://apollographql.github.io/apollo-kotlin-normalized-cache-incubating/kdoc/normalized-cache-incubating/com.apollographql.cache.normalized.api/-max-age-provider/index.html?query=interface%20MaxAgeProvider) to the `ExpirationCacheResolver`. + +### Global max age + +To set a global maximum age for all fields, pass a [`GlobalMaxAgeProvider`](https://apollographql.github.io/apollo-kotlin-normalized-cache-incubating/kdoc/normalized-cache-incubating/com.apollographql.cache.normalized.api/-global-max-age-provider/index.html?query=class%20GlobalMaxAgeProvider(maxAge:%20Duration)%20:%20MaxAgeProvider) to the `ExpirationCacheResolver`: + +```kotlin + cacheResolver = ExpirationCacheResolver(GlobalMaxAgeProvider(1.hours)), +``` + +### Max age per type and field + +#### Programmatically + +Use a [`SchemaCoordinatesMaxAgeProvider`](https://apollographql.github.io/apollo-kotlin-normalized-cache-incubating/kdoc/normalized-cache-incubating/com.apollographql.cache.normalized.api/-schema-coordinates-max-age-provider/index.html?query=class%20SchemaCoordinatesMaxAgeProvider(maxAges:%20Map%3CString,%20MaxAge%3E,%20defaultMaxAge:%20Duration)%20:%20MaxAgeProvider) to specify a max age per type and/or field: + +```kotlin + cacheResolver = ExpirationCacheResolver(SchemaCoordinatesMaxAgeProvider( + maxAges = mapOf( + "Query.cachedBook" to MaxAge.Duration(60.seconds), + "Query.reader" to MaxAge.Duration(40.seconds), + "Post" to MaxAge.Duration(4.minutes), + "Book.cachedTitle" to MaxAge.Duration(30.seconds), + "Reader.book" to MaxAge.Inherit, + ), + defaultMaxAge = 1.hours, + )), +``` +Note that this provider replicates the behavior of Apollo Server's [`@cacheControl` directive](https://www.apollographql.com/docs/apollo-server/performance/caching/#default-maxage) when it comes to defaults and the meaning of `Inherit`. + +#### Declaratively + +To declare the maximum age of types and fields in the schema, use the `@cacheControl` and `@cacheControlField` directive: + +``` +# First import the directives +extend schema @link( + url: "https://specs.apollo.dev/cache/v0.1", + import: ["@cacheControl", "@cacheControlField"] +) + +# Then extend your types +extend type Query @cacheControl(maxAge: 60) + @cacheControlField(name: "cachedBook", maxAge: 60) + @cacheControlField(name: "reader", maxAge: 40) + +extend type Post @cacheControl(maxAge: 240) + +extend type Book @cacheControlField(name: "cachedTitle", maxAge: 30) + +extend type Reader @cacheControlField(name: "book", inheritMaxAge: true) +``` + +Then configure the Cache compiler plugin in your `build.gradle.kts`: + +```kotlin +apollo { + service("service") { + packageName.set(/*...*/) + + plugin("com.apollographql.cache:normalized-cache-apollo-compiler-plugin:%latest_version%") { + argument("packageName", packageName.get()) + } + } +} +``` + +This will generate a map in `yourpackage.cache.Cache.maxAges`, that you can pass to the `SchemaCoordinatesMaxAgeProvider`: + +```kotlin + cacheResolver = ExpirationCacheResolver(SchemaCoordinatesMaxAgeProvider( + maxAges = Cache.maxAges, + defaultMaxAge = 1.hours, + )), +``` + +## Maximum staleness + +If expired fields are acceptable up to a certain value, you can set a maximum staleness duration. This duration is the maximum time that an expired field will be resolved without resulting in a cache miss. To set this duration, call `.maxStale(Duration)` either globally on your client, or per operation: + +```kotlin +client.query(MyQuery()) + .fetchPolicy(FetchPolicy.CacheOnly) + .maxStale(1.hours) + .execute() +``` diff --git a/Writerside/topics/pagination.md b/Writerside/topics/pagination.md index 7a213ce4..5f12d5c5 100644 --- a/Writerside/topics/pagination.md +++ b/Writerside/topics/pagination.md @@ -1,4 +1,4 @@ -# Pagination with the normalized cache +# Pagination When using the normalized cache, objects are stored in records keyed by the object's id: diff --git a/normalized-cache-incubating/api/normalized-cache-incubating.api b/normalized-cache-incubating/api/normalized-cache-incubating.api index 0a3c5116..7b703e69 100644 --- a/normalized-cache-incubating/api/normalized-cache-incubating.api +++ b/normalized-cache-incubating/api/normalized-cache-incubating.api @@ -272,6 +272,7 @@ public final class com/apollographql/cache/normalized/api/EmptyMetadataGenerator } public final class com/apollographql/cache/normalized/api/ExpirationCacheResolver : com/apollographql/cache/normalized/api/CacheResolver { + public fun ()V public fun (Lcom/apollographql/cache/normalized/api/MaxAgeProvider;)V public fun resolveField (Lcom/apollographql/cache/normalized/api/ResolverContext;)Ljava/lang/Object; } diff --git a/normalized-cache-incubating/api/normalized-cache-incubating.klib.api b/normalized-cache-incubating/api/normalized-cache-incubating.klib.api index 503b9bc3..727db72b 100644 --- a/normalized-cache-incubating/api/normalized-cache-incubating.klib.api +++ b/normalized-cache-incubating/api/normalized-cache-incubating.klib.api @@ -177,6 +177,7 @@ final class com.apollographql.cache.normalized.api/EmbeddedFieldsContext { // co final fun (): com.apollographql.apollo.api/CompiledNamedType // com.apollographql.cache.normalized.api/EmbeddedFieldsContext.parentType.|(){}[0] } final class com.apollographql.cache.normalized.api/ExpirationCacheResolver : com.apollographql.cache.normalized.api/CacheResolver { // com.apollographql.cache.normalized.api/ExpirationCacheResolver|null[0] + constructor () // com.apollographql.cache.normalized.api/ExpirationCacheResolver.|(){}[0] constructor (com.apollographql.cache.normalized.api/MaxAgeProvider) // com.apollographql.cache.normalized.api/ExpirationCacheResolver.|(com.apollographql.cache.normalized.api.MaxAgeProvider){}[0] final fun resolveField(com.apollographql.cache.normalized.api/ResolverContext): kotlin/Any? // com.apollographql.cache.normalized.api/ExpirationCacheResolver.resolveField|resolveField(com.apollographql.cache.normalized.api.ResolverContext){}[0] } diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt index a7c7a6db..07e0e851 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/api/CacheResolver.kt @@ -9,6 +9,7 @@ import com.apollographql.cache.normalized.maxStale import com.apollographql.cache.normalized.storeExpirationDate import com.apollographql.cache.normalized.storeReceiveDate import kotlin.jvm.JvmSuppressWildcards +import kotlin.time.Duration /** * Controls how fields are resolved from the cache. @@ -147,6 +148,11 @@ object DefaultCacheResolver : CacheResolver { class ExpirationCacheResolver( private val maxAgeProvider: MaxAgeProvider, ) : CacheResolver { + /** + * Creates a new [ExpirationCacheResolver] with no max ages. Use this constructor if you want to consider only the expiration dates. + */ + constructor() : this(maxAgeProvider = GlobalMaxAgeProvider(Duration.INFINITE)) + override fun resolveField(context: ResolverContext): Any? { val resolvedField = FieldPolicyCacheResolver.resolveField(context) if (context.parent is Record) { diff --git a/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt b/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt index 01a664cc..a6129c70 100644 --- a/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt +++ b/tests/expiration/src/commonTest/kotlin/SchemaCoordinatesMaxAgeProviderTest.kt @@ -103,7 +103,7 @@ class SchemaCoordinatesMaxAgeProviderTest { @Test fun fallbackValue() { val provider1 = SchemaCoordinatesMaxAgeProvider( - maxAges = mapOf(), + maxAges = mapOf(), defaultMaxAge = 12.seconds, ) var maxAge = provider1.getMaxAge( diff --git a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt index cd5f1a13..eece2227 100644 --- a/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt +++ b/tests/expiration/src/commonTest/kotlin/ServerSideExpirationTest.kt @@ -6,8 +6,6 @@ import com.apollographql.apollo.exception.CacheMissException import com.apollographql.apollo.testing.internal.runTest import com.apollographql.cache.normalized.FetchPolicy import com.apollographql.cache.normalized.api.ExpirationCacheResolver -import com.apollographql.cache.normalized.api.MaxAgeContext -import com.apollographql.cache.normalized.api.MaxAgeProvider import com.apollographql.cache.normalized.api.MemoryCacheFactory import com.apollographql.cache.normalized.api.NormalizedCacheFactory import com.apollographql.cache.normalized.apolloStore @@ -45,12 +43,7 @@ class ServerSideExpirationTest { val client = ApolloClient.Builder() .normalizedCache( normalizedCacheFactory = normalizedCacheFactory, - cacheResolver = ExpirationCacheResolver( - // Can be any value since we don't store the receive date - object : MaxAgeProvider { - override fun getMaxAge(maxAgeContext: MaxAgeContext) = 0.seconds - } - ) + cacheResolver = ExpirationCacheResolver(), ) .storeExpirationDate(true) .serverUrl(mockServer.url())