Skip to content

Commit

Permalink
Implement expiring cache based on scalacache (#299)
Browse files Browse the repository at this point in the history
* implement expiring cache based on scalacache

* improve caching docs
  • Loading branch information
majk-p authored Oct 3, 2022
1 parent 6848cac commit d97bc84
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 3 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
- run: sbt ++${{ matrix.scala }} test docs/mdoc mimaReportBinaryIssues

- name: Compress target directories
run: tar cf targets.tar oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target target mdoc/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target
run: tar cf targets.tar oauth2/js/target oauth2-cache/js/target oauth2-cache-ce2/target target oauth2-cache-scalacache/target mdoc/target oauth2-cache-cats/target oauth2-cache-future/jvm/target oauth2-cache/jvm/target oauth2-cache-future/js/target oauth2/jvm/target project/target

- name: Upload target directories
uses: actions/upload-artifact@v2
Expand Down
24 changes: 22 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ val Versions = new {
val scalaTest = "3.2.13"
val sttp = "3.3.18"
val refined = "0.10.1"
val scalaCache = "1.0.0-M6"
}

def compilerPlugins =
Expand Down Expand Up @@ -130,6 +131,24 @@ lazy val `oauth2-cache` = crossProject(JSPlatform, JVMPlatform)
.jsSettings(jsSettings)
.dependsOn(oauth2)

// oauth2-cache-scalacache doesn't have JS support because scalacache doesn't compile for js https://github.com/cb372/scalacache/issues/354#issuecomment-913024231
lazy val `oauth2-cache-scalacache` = project
.settings(
name := "sttp-oauth2-cache-scalacache",
libraryDependencies ++= Seq(
"com.github.cb372" %%% "scalacache-core" % Versions.scalaCache,
"com.github.cb372" %% "scalacache-caffeine" % Versions.scalaCache % Test,
"org.typelevel" %%% "cats-effect-kernel" % Versions.catsEffect,
"org.typelevel" %%% "cats-effect-std" % Versions.catsEffect,
"org.typelevel" %%% "cats-effect" % Versions.catsEffect % Test,
"org.typelevel" %%% "cats-effect-testkit" % Versions.catsEffect % Test,
"org.scalatest" %%% "scalatest" % Versions.scalaTest % Test
),
mimaPreviousArtifacts := Set.empty,
compilerPlugins
)
.dependsOn(`oauth2-cache`.jvm)

// oauth2-cache-cats doesn't have JS support because cats effect does not provide realTimeInstant on JS
lazy val `oauth2-cache-cats` = project
.settings(
Expand All @@ -141,7 +160,7 @@ lazy val `oauth2-cache-cats` = project
"org.typelevel" %%% "cats-effect-testkit" % Versions.catsEffect % Test,
"org.scalatest" %%% "scalatest" % Versions.scalaTest % Test
),
mimaPreviousArtifacts := Set.empty,
mimaSettings,
compilerPlugins
)
.dependsOn(`oauth2-cache`.jvm)
Expand Down Expand Up @@ -189,5 +208,6 @@ val root = project
`oauth2-cache-cats`,
`oauth2-cache-ce2`,
`oauth2-cache-future`.jvm,
`oauth2-cache-future`.js
`oauth2-cache-future`.js,
`oauth2-cache-scalacache`
)
28 changes: 28 additions & 0 deletions docs/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
sidebar_position: 6
description: Caching
---

# Caching

The sttp-oauth2 library comes with `CachingAccessTokenProvider` and `CachingTokenIntrospection` - interfaces that allow caching the responses provided by the OAuth2 provider. Both of those require an implementation of the `ExpiringCache` algebra, defined as follows:

```scala
trait ExpiringCache[F[_], K, V] {
def get(key: K): F[Option[V]]

def put(key: K, value: V, expirationTime: Instant): F[Unit]

def remove(key: K): F[Unit]
}
```

As the user of the library you can either choose to implement your own cache mechanism, or go for one of the provided:

| Class |Description | Import module |
|---------------------------|-------------------------------------------------------------|-------------------|
| `CatsRefExpiringCache` | Simple Cats Effect 3 Ref based implementation. Good enough for `CachingAccessTokenProvider`, but for `CachingTokenIntrospection` it's recommended to use an instance which better handles memory (this instance does not periodically remove expired entries) | `"com.ocadotechnology" %% "sttp-oauth2-cache-cats" % "@VERSION@"` |
| `CatsRefExpiringCache` | Simple Cats Effect 2 Ref based implementation. Good enough for `CachingAccessTokenProvider`, but for `CachingTokenIntrospection` it's recommended to use an instance which better handles memory (this instance does not periodically remove expired entries) | `"com.ocadotechnology" %% "sttp-oauth2-cache-ce2" % "@VERSION@"` |
| `ScalacacheExpiringCache` | Implementation based on https://github.com/cb372/scalacache | `"com.ocadotechnology" %% "sttp-oauth2-cache-scalacache" % "@VERSION@"` |
| `MonixFutureCache` | Future based implementation powered by [Monix](https://monix.io/) | `"com.ocadotechnology" %% "sttp-oauth2-cache-future" % "@VERSION@"` |

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.ocadotechnology.sttp.oauth2.cache.scalacache

import cats.effect.Async
import cats.effect.kernel.Clock
import cats.implicits._
import com.ocadotechnology.sttp.oauth2.cache.ExpiringCache
import scalacache._

import java.time.Instant
import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration

final class ScalacacheExpiringCache[F[_]: Async, K, V](cache: Cache[F, K, V]) extends ExpiringCache[F, K, V] {

override def get(key: K): F[Option[V]] =
cache.get(key)

override def put(key: K, value: V, expirationTime: Instant): F[Unit] =
for {
now <- Clock[F].realTimeInstant
ttl = calculateTTL(expirationTime, now)
_ <- cache.put(key)(value, Some(ttl)).void
} yield ()

override def remove(key: K): F[Unit] =
cache.remove(key).void

private def calculateTTL(expirationTime: Instant, now: Instant): FiniteDuration =
if (expirationTime isAfter now)
FiniteDuration(expirationTime.toEpochMilli() - now.toEpochMilli(), TimeUnit.MILLISECONDS)
else FiniteDuration(0, TimeUnit.MILLISECONDS)

}

object ScalacacheExpiringCache {

def apply[F[_]: Async, K, V](cache: Cache[F, K, V]): ExpiringCache[F, K, V] =
new ScalacacheExpiringCache[F, K, V](cache)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.ocadotechnology.sttp.oauth2.cache.scalacache

import cats.effect.IO
import cats.effect.kernel.Outcome.Succeeded
import cats.effect.testkit.TestContext
import cats.effect.testkit.TestInstances
import org.scalatest.Assertion
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec

import scalacache.caffeine._

import scala.concurrent.duration._

class ScalacacheExpiringCacheSpec extends AnyWordSpec with Matchers with TestInstances {
private implicit val ticker: Ticker = Ticker(TestContext())

private val someKey = "key"
private val someValue = 1

def runTest(test: IO[Assertion]): Assertion =
unsafeRun(test) match {
case Succeeded(Some(assertion)) => assertion
case wrongResult => fail(s"Test should finish successfully. Instead ended with $wrongResult")
}

"Cache" should {
"return nothing on empty cache" in unsafeRun {
for {
cacheBackend <- CaffeineCache[IO, String, Int]
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
value <- cache.get(someKey)
} yield value
}.shouldBe(Succeeded(Some(None)))

"store and retrieve value immediately" in unsafeRun {
for {
cacheBackend <- CaffeineCache[IO, String, Int]
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
now <- IO.realTimeInstant
_ <- cache.put(someKey, someValue, now.plusSeconds(60))
value <- cache.get(someKey)
} yield value
}.shouldBe(Succeeded(Some(Some(someValue))))

"return value right before expiration boundary" in unsafeRun {
for {
cacheBackend <- CaffeineCache[IO, String, Int]
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
now <- IO.realTimeInstant
_ <- cache.put(someKey, someValue, now.plusSeconds(60))
_ <- IO.sleep(60.seconds - 1.nano)
value <- cache.get(someKey)
} yield value
}.shouldBe(Succeeded(Some(Some(someValue))))

"not return value if expired" in unsafeRun {
for {
cacheBackend <- CaffeineCache[IO, String, Int]
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
now <- IO.realTimeInstant
_ <- cache.put(someKey, someValue, now.plusSeconds(60))
_ <- IO.sleep(60.seconds)
value <- cache.get(someKey)
} yield value
}.shouldBe(Succeeded(Some(None)))

"remove value when expired" in unsafeRun {
for {
cacheBackend <- CaffeineCache[IO, String, Int]
cache = ScalacacheExpiringCache[IO, String, Int](cacheBackend)
now <- IO.realTimeInstant
_ <- cache.put(someKey, someValue, now.plusSeconds(1))
_ <- IO.sleep(3.seconds)
value <- cache.get(someKey)
} yield value
}.shouldBe(Succeeded(Some(None)))

}
}

0 comments on commit d97bc84

Please sign in to comment.