diff --git a/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala index d29ce1c2f1..0b17c78b0e 100644 --- a/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala +++ b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala @@ -3,8 +3,10 @@ package code.api.util import org.json4s._ import code.api.util.APIUtil.MessageDoc import com.openbankproject.commons.util.ReflectUtils +import com.tesobe.CacheKeyFromArguments import org.json4s.JsonDSL._ +import scala.concurrent.duration._ import scala.reflect.runtime.universe._ /** @@ -15,9 +17,34 @@ import scala.reflect.runtime.universe._ object JsonSchemaGenerator { /** - * Convert a list of MessageDoc to a complete JSON Schema document + * Convert a list of MessageDoc to a complete JSON Schema document. + * + * Memoized in-process, keyed by connectorName: for a given connector the message docs + * (and therefore the schema) are static for the lifetime of the JVM, but building it + * walks every message type's full field tree via Scala runtime reflection (`<:<`/`=:=` + * subtype checks), which is expensive and -- unlike a plain field lookup -- leaves behind + * long-lived reflection bookkeeping objects (TypeConstraint/UndoPair/Symbol) that don't + * get reclaimed promptly. Recomputing this on every request under sustained polling is + * what drove a production old-gen heap to exhaustion. The caller (Http4s600) also has a + * Redis-backed cache in front of this, but that one silently falls through to a full + * recompute if Redis is unreachable or slow -- this in-memory layer doesn't depend on + * Redis at all, so it stays a working safety net even when Redis is the one struggling. */ def messageDocsToJsonSchema(messageDocs: List[MessageDoc], connectorName: String): JObject = { + // This 3-tuple of random UUIDs is a placeholder only -- CacheKeyFromArguments is a macro + // that replaces it at compile time with a real key derived from this method's owner, + // name and arguments (regardless of this method's own arity; the convention throughout + // this codebase is always a 3-tuple here). See: + // https://github.com/OpenBankProject/scala-macros/blob/master/macros/src/main/scala/com/tesobe/CacheKeyFromArgumentsMacro.scala#L49 + var cacheKey = (java.util.UUID.randomUUID().toString, java.util.UUID.randomUUID().toString, java.util.UUID.randomUUID().toString) + CacheKeyFromArguments.buildCacheKey { + code.api.cache.Caching.memoizeSyncWithImMemory(Some(cacheKey.toString()))(100000.days) { + messageDocsToJsonSchemaUncached(messageDocs, connectorName) + } + } + } + + private def messageDocsToJsonSchemaUncached(messageDocs: List[MessageDoc], connectorName: String): JObject = { val allDefinitions = scala.collection.mutable.Map[String, JObject]() val messages = messageDocs.map { messageDoc => diff --git a/obp-api/src/test/scala/code/api/util/JsonSchemaGeneratorCacheTest.scala b/obp-api/src/test/scala/code/api/util/JsonSchemaGeneratorCacheTest.scala new file mode 100644 index 0000000000..eef09f6d1e --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/JsonSchemaGeneratorCacheTest.scala @@ -0,0 +1,116 @@ +package code.api.util + +import code.api.util.APIUtil.MessageDoc +import org.scalatest.{FlatSpec, Matchers} + +/** + * Guards the in-process cache added to JsonSchemaGenerator.messageDocsToJsonSchema. + * + * Building the schema walks the full field tree of every message type via Scala runtime + * reflection (recursive <: "v")) + SchemaCacheTestPayload("id-1", BigDecimal(1), List("a", "b"), address, batch) + } + + private def sampleMessageDocs(connectorName: String): List[MessageDoc] = List( + MessageDoc( + process = s"obp.$connectorName.getThing", + messageFormat = "JSON", + description = "test message doc for cache verification", + exampleOutboundMessage = samplePayload(), + exampleInboundMessage = samplePayload() + ) + ) + + "JsonSchemaGenerator.messageDocsToJsonSchema" should "return identical output on a cache hit as on the initial cold call" in { + val connectorName = freshConnectorName("identical-output") + val docs = sampleMessageDocs(connectorName) + + val first = JsonSchemaGenerator.messageDocsToJsonSchema(docs, connectorName) + val second = JsonSchemaGenerator.messageDocsToJsonSchema(docs, connectorName) + + second should equal(first) + } + + it should "be dramatically faster on a cache hit than on the initial cold call" in { + val connectorName = freshConnectorName("timing") + val docs = sampleMessageDocs(connectorName) + + val coldStart = System.nanoTime() + JsonSchemaGenerator.messageDocsToJsonSchema(docs, connectorName) + val coldNanos = System.nanoTime() - coldStart + + // A handful of warm calls, not just one: a single fast call could be a lucky JIT/scheduling + // blip rather than an actual cache hit. Comparing the median (not every sample, and not the + // fastest) against the cold call keeps this robust to a single GC pause or JIT hiccup landing + // on one warm sample while still requiring a real, consistent improvement. + val warmNanosSamples = (1 to 7).map { _ => + val start = System.nanoTime() + JsonSchemaGenerator.messageDocsToJsonSchema(docs, connectorName) + System.nanoTime() - start + } + val medianWarmNanos = warmNanosSamples.sorted.apply(warmNanosSamples.size / 2) + + withClue(s"cold=${coldNanos}ns warm=${warmNanosSamples.sorted.mkString(",")}ns median=${medianWarmNanos}ns: ") { + medianWarmNanos should be < (coldNanos / 2) + } + } + + it should "isolate different connector names as independent cache entries" in { + val connectorA = freshConnectorName("connector-a") + val connectorB = freshConnectorName("connector-b") + + val schemaA = JsonSchemaGenerator.messageDocsToJsonSchema(sampleMessageDocs(connectorA), connectorA) + val schemaB = JsonSchemaGenerator.messageDocsToJsonSchema(sampleMessageDocs(connectorB), connectorB) + + (schemaA \ "title") should equal(org.json4s.JString(s"$connectorA Message Schemas")) + (schemaB \ "title") should equal(org.json4s.JString(s"$connectorB Message Schemas")) + } +}