Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/models/Backend.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import models.entities.Studies.*
import models.entities.Evidences.*
import models.entities.SequenceOntologyTerm.*
import models.entities.*
import models.gql.{InteractionSourceEnum, StudyTypeEnum}
import models.gql.{Fetchers, StudyTypeEnum, InteractionSourceEnum}
import models.entities.Violations.{DateFilterError, InputParameterCheckError}
import org.apache.http.impl.nio.reactor.IOReactorConfig
import play.api.cache.AsyncCacheApi
Expand Down Expand Up @@ -63,6 +63,7 @@ class Backend @Inject() (implicit
markerContext = newMarkerContext

implicit val defaultOTSettings: OTSettings = loadConfigurationObject[OTSettings]("ot", config)
Fetchers.configure(defaultOTSettings.cache)
implicit val defaultESSettings: ElasticsearchSettings = defaultOTSettings.elasticsearch
implicit val dbConfig: DatabaseConfig[ClickHouseProfile] = dbConfigProvider.get[ClickHouseProfile]

Expand Down
20 changes: 16 additions & 4 deletions app/models/entities/Configuration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,16 @@ object Configuration {
/** main Open Targets configuration object. It keeps track of meta, elasticsearch and clickhouse
* configuration.
*/
case class CacheSettings(fetcherMaxMb: Long)

case class OTSettings(
meta: Meta,
elasticsearch: ElasticsearchSettings,
clickhouse: ClickhouseSettings,
ignoreCache: Boolean,
qValidationLimitNTerms: Int,
logging: Logging
logging: Logging,
cache: CacheSettings
)

implicit val loggingJsonImp: OFormat[Logging] = Json.format[Logging]
Expand Down Expand Up @@ -217,25 +220,34 @@ object Configuration {
implicit val clickhouseSettingsJSONImp: OFormat[ClickhouseSettings] =
Json.format[ClickhouseSettings]

implicit val cacheSettingsJSONImp: Reads[CacheSettings] =
(__ \ "fetcherMaxMb")
.read[String]
.map(s => CacheSettings(s.toLong))
.orElse((__ \ "fetcherMaxMb").read[Long].map(CacheSettings.apply))

implicit val otSettingsJSONImp: Reads[OTSettings] = ((__ \ "meta").read[Meta] and
(__ \ "elasticsearch").read[ElasticsearchSettings] and
(__ \ "clickhouse").read[ClickhouseSettings] and
(__ \ "ignoreCache").read[String] and
(__ \ "qValidationLimitNTerms").read[String] and
(__ \ "logging").read[Logging])(
(__ \ "logging").read[Logging] and
(__ \ "cache").read[CacheSettings])(
(meta,
elasticsearchSettings,
clickhouseSettings,
ignoreCache,
qValidationLimitNTerms,
logging
logging,
cache
) =>
OTSettings.apply(meta,
elasticsearchSettings,
clickhouseSettings,
ignoreCache.toBooleanOption.getOrElse(false),
qValidationLimitNTerms.toInt,
logging
logging,
cache
)
)
}
126 changes: 126 additions & 0 deletions app/models/gql/Cache.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package models.gql

import sangria.execution.deferred.FetcherCache
import com.github.benmanes.caffeine.cache.{Cache, Caffeine}
import scala.jdk.CollectionConverters._

/** LRU (Least Recently Used) cache implementation for FetcherCache using Caffeine.
*
* @param maxBytes
* Maximum total memory in bytes for each cache before eviction occurs
*/
class LruFetcherCache(maxBytes: Long = 256L * 1024 * 1024) extends FetcherCache {

private def estimateBytes(key: Any, value: Any): Int = {
val size = (key.toString.length + value.toString.length) * 2
math.max(1, math.min(size, Int.MaxValue)).toInt
}

private def estimateBytesSeq(key: Any, values: Seq[Any]): Int = {
val size = key.toString.length * 2 + values.foldLeft(0)((acc, v) => acc + v.toString.length * 2)
math.max(1, math.min(size, Int.MaxValue)).toInt
}

// Primary cache for entity lookups by ID
private val cache: Cache[Any, Any] = Caffeine
.newBuilder()
.maximumWeight(maxBytes)
.weigher[Any, Any]((k, v) => estimateBytes(k, v))
// .recordStats()
.build[Any, Any]()

// Cache for relationship lookups
private val relCache: Cache[Any, Seq[Any]] = Caffeine
.newBuilder()
.maximumWeight(maxBytes)
.weigher[Any, Seq[Any]]((k, v) => estimateBytesSeq(k, v))
// .recordStats()
.build[Any, Seq[Any]]()

def cacheKey(id: Any): Any = id

def cacheKeyRel(rel: Any, relId: Any): Any = rel -> relId

def cacheable(id: Any): Boolean = true

def cacheableRel(rel: Any, relId: Any): Boolean = true

def get(id: Any): Option[Any] =
Option(cache.getIfPresent(cacheKey(id)))

def getRel(rel: Any, relId: Any): Option[Seq[Any]] =
Option(relCache.getIfPresent(cacheKeyRel(rel, relId)))

def update(id: Any, value: Any): Unit =
if (cacheable(id)) {
cache.put(cacheKey(id), value)
}

def updateRel[T](rel: Any, relId: Any, idFn: T => Any, values: Seq[T]): Unit =
if (cacheableRel(rel, relId)) {
values.foreach { v =>
update(idFn(v), v)
}
relCache.put(cacheKeyRel(rel, relId), values)
}

def clear(): Unit = {
cache.invalidateAll()
relCache.invalidateAll()
}

override def clearId(id: Any): Unit =
cache.invalidate(cacheKey(id))

override def clearRel(rel: Any): Unit = {
// Iterate through all keys and remove matching relationships
val keysToRemove = relCache.asMap().keySet().asScala.filter {
case (r, _) => r == rel
case _ => false
}
relCache.invalidateAll(keysToRemove.asJava)
}

override def clearRelId(rel: Any, relId: Any): Unit =
relCache.invalidate(cacheKeyRel(rel, relId))

/** Get current cache statistics. Note that recordStats needs to be uncommented if you want stats.
* It is commented out because there is a performance hit in recording stats.
*/
def stats(): CacheStats = {
val cacheStats = cache.stats()
val relCacheStats = relCache.stats()
CacheStats(
entityCacheSize = cache.estimatedSize(),
relCacheSize = relCache.estimatedSize(),
entityHitRate = cacheStats.hitRate(),
relHitRate = relCacheStats.hitRate(),
entityEvictionCount = cacheStats.evictionCount(),
relEvictionCount = relCacheStats.evictionCount()
)
}
}

case class CacheStats(
entityCacheSize: Long,
relCacheSize: Long,
entityHitRate: Double,
relHitRate: Double,
entityEvictionCount: Long,
relEvictionCount: Long
) {
override def toString: String =
s"entity[size=$entityCacheSize, hitRate=${f"$entityHitRate%.2f"}, evictions=$entityEvictionCount] " +
s"rel[size=$relCacheSize, hitRate=${f"$relHitRate%.2f"}, evictions=$relEvictionCount]"
}

object LruFetcherCache {

/** Create an LRU cache with default memory limit (256 MB per cache)
*/
def apply(): LruFetcherCache = new LruFetcherCache()

/** Create an LRU cache with a custom memory limit in bytes
*/
def apply(maxBytes: Long): LruFetcherCache = new LruFetcherCache(maxBytes)
}
Loading
Loading