From 5c64474af33eaf89609d81f43cf888d36ec89faf Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 23 Sep 2026 10:21:07 +0200 Subject: [PATCH 1/2] fix: memoize JsonSchemaGenerator output per connector messageDocsToJsonSchema walks every message type's full field tree via Scala runtime reflection (recursive <: From 2c45fc45db308fca2c6dc3846a5120f2a1a99e7b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 23 Sep 2026 10:50:38 +0200 Subject: [PATCH 2/2] test: verify messageDocsToJsonSchema caching actually hits MessageDocsJsonSchemaTest already covers output correctness end to end via HTTP, but a broken cache (wrong key derivation, wrong TTL handling) could still pass a correctness-only check by silently recomputing every time -- which is exactly what happened once already while writing this fix, caught only by the compiler rejecting a cache-key placeholder with the wrong tuple arity. This adds a direct, non-HTTP test of the cache itself: identical output on a hit, a measurable speedup on a hit (median of several warm calls against a cold call, sized against a case-class tree wide/deep enough that the reflection cost isn't lost in JIT/GC noise), and that different connector names get independent cache entries. --- .../util/JsonSchemaGeneratorCacheTest.scala | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/util/JsonSchemaGeneratorCacheTest.scala 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")) + } +}