Skip to content

Commit 933bf2e

Browse files
authored
Merge branch 'main' into ld/iam-sts-aws-upgrade
2 parents 41ef9c3 + 15d47c8 commit 933bf2e

31 files changed

Lines changed: 1394 additions & 366 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@ jobs:
1515
pull-requests: write
1616
services:
1717
elasticsearch:
18-
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29
19-
# Wait for elasticsearch to report healthy before continuing.
18+
image: docker.elastic.co/elasticsearch/elasticsearch:8.18.3
19+
# These options should mimic those set in ElasticSearchDockerBase.scala, because this is
20+
# the CI version of the TestContainers approach we use for running tests locally.
21+
# `xpack.security.enabled=false` is needed since v8 for tests to use plain
22+
# unauthenticated HTTP, because in v7 => v8 they changed the default to `true`:
23+
# https://www.elastic.co/guide/en/elasticsearch/reference/8.18/release-notes-8.0.0.html#:~:text=Set%20xpack.security.enabled%20to%20true%20for%20all%20licenses
24+
#
25+
# `--health-` settings make it wait for es to report healthy before continuing.
2026
# see https://github.com/actions/example-services/blob/master/.github/workflows/postgres-service.yml#L28
21-
options: -e "discovery.type=single-node" --expose 9200 --health-cmd "curl localhost:9200/_cluster/health" --health-interval 10s --health-timeout 5s --health-retries 10
27+
options: -e "discovery.type=single-node" -e "xpack.security.enabled=false" --expose 9200 --health-cmd "curl localhost:9200/_cluster/health" --health-interval 10s --health-timeout 5s --health-retries 10
2228
ports:
2329
- 9200:9200
2430
localstack:

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ ThisBuild / packageOptions += FixedTimestamp(Package.keepTimestamps)
2020
ThisBuild / libraryDependencySchemes +=
2121
"org.scala-lang.modules" %% "scala-java8-compat" % VersionScheme.Always
2222

23-
lazy val jacksonVersion = "2.21.1"
23+
lazy val jacksonVersion = "2.21.4"
2424
lazy val jacksonAnnotationsVersion = "2.21"
2525
lazy val jacksonOverrides = Seq(
2626
"com.fasterxml.jackson.core" % "jackson-core",

common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import com.gu.mediaservice.model.leases.LeasesByMedia
88
import com.gu.mediaservice.model.usage.Usage
99
import com.typesafe.scalalogging.LazyLogging
1010
import play.api.http.HeaderNames
11-
import play.api.libs.json.{JsArray, JsObject, JsValue, Json, Reads}
11+
import play.api.libs.json.{JsArray, JsObject, JsTrue, JsValue, Json, Reads}
1212

1313
import scala.concurrent.duration.{Duration, DurationInt}
1414
import scala.concurrent.{ExecutionContext, Future}
@@ -38,8 +38,8 @@ object ClientResponse {
3838
case class ClientErrorMessages(errorMessage: String, downstreamErrorMessage: String)
3939

4040
object GridClient extends LazyLogging {
41-
def apply(services: Services)(implicit wsClient: WSClient): GridClient =
42-
new GridClient(services)
41+
def apply(services: Services, originUri: String)(implicit wsClient: WSClient): GridClient =
42+
new GridClient(services, originUri)
4343

4444
sealed trait Response {
4545
def status: Int
@@ -96,7 +96,7 @@ object GridClient extends LazyLogging {
9696

9797
}
9898

99-
class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLogging {
99+
class GridClient(services: Services, originDomain: String)(implicit wsClient: WSClient) extends LazyLogging {
100100

101101
/*
102102
* `requestTimeout` will set the max duration of the request before timing out. You may also want to increase the
@@ -260,6 +260,11 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo
260260
authorisedRequest.post(Json.obj("data" -> data)).map { response => validateResponse(response, url)}
261261
}
262262

263+
def putArchived(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext) = {
264+
val url = new URL(s"${services.metadataBaseUri}/metadata/$mediaId/archived")
265+
val request = authFn(wsClient.url(url.toString))
266+
request.put(Json.obj("data" -> JsTrue)).map { response => validateResponse(response, url)}
267+
}
263268
}
264269

265270
class DownstreamApiInBadStateException(message: String, downstreamMessage: String) extends IllegalStateException(message) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.gu.mediaservice.lib
2+
3+
object VectorUtils {
4+
def dotProduct(vectorOne: List[Double], vectorTwo: List[Double]): Double = {
5+
require(vectorOne.length == vectorTwo.length, "Vectors must be the same dimensionality")
6+
vectorOne.zip(vectorTwo).map(component => component._1 * component._2).sum
7+
}
8+
9+
def magnitude(vector: List[Double]): Double = {
10+
math.sqrt(vector.map(math.pow(_, 2)).sum)
11+
}
12+
13+
def cosineSimilarity(vectorOne: List[Double], vectorTwo: List[Double]): Option[Double] = {
14+
// Cosine similarity is undefined when the vectors differ in dimensionality
15+
// (the dot product is meaningless) or when either has zero magnitude (the
16+
// angle to the origin is meaningless), so we return None in those cases and
17+
// let the caller choose a domain-appropriate fallback.
18+
if (vectorOne.length != vectorTwo.length) None
19+
else {
20+
val magnitudeProduct = magnitude(vectorOne) * magnitude(vectorTwo)
21+
if (magnitudeProduct == 0.0) None
22+
else Some(dotProduct(vectorOne, vectorTwo) / magnitudeProduct)
23+
}
24+
}
25+
26+
// ===============================================================================
27+
// The below functions are primarily for constructing artificial vectors for tests
28+
// ===============================================================================
29+
private def oneHotVector(dims: Int, hotIndex: Int): List[Double] =
30+
List.tabulate(dims)(i => if (i == hotIndex) 1.0 else 0.0)
31+
32+
// The first standard basis vector e0: (1, 0, 0, ...).
33+
def firstBasisVector(dims: Int): List[Double] = oneHotVector(dims, 0)
34+
35+
private def vectorWithComponents(dims: Int, components: (Int, Double)*): List[Double] = {
36+
val arr = Array.fill(dims)(0.0)
37+
components.foreach { case (i, v) => arr(i) = v }
38+
arr.toList
39+
}
40+
41+
// Builds a vector whose cosine similarity with the first basis vector equals
42+
// the requested `similarity` (in [-1, 1], where 1 is identical and 0 is orthogonal).
43+
// This is specifically for use in tests where we need to create test fixtures with
44+
// specific semantic scores.
45+
def vectorWithCosineSimilarity(dims: Int, similarity: Double): List[Double] = {
46+
require(dims > 1, "Dimensions must be at least 2")
47+
require(similarity >= -1.0 && similarity <= 1.0, "Cosine similarity must be between -1 and 1")
48+
if (similarity == 0.0) {
49+
// A cosine similarity of 0 means orthogonal to the first basis vector.
50+
// That vector is one-hot at index 0, so any vector with a zero
51+
// first component is orthogonal to it. (0, 1, 0, 0, ...) is the simplest choice.
52+
oneHotVector(dims, 1)
53+
} else {
54+
val absVec2D = {
55+
// Let's pretend we're in just 2 dimensions and think about triangles.
56+
//
57+
// /|
58+
// / |
59+
// n / | opposite = sqrt(n^2 - 1)
60+
// / |
61+
// /θ___|
62+
// 1
63+
// (adjacent)
64+
//
65+
// Our queryEmbedding lies flat on the x axis.
66+
// cos(θ) = adjacent / hypotenuse = 1 / n
67+
// If we want cos θ to be 1/n, e.g. 1/2,
68+
// then the adjacent side must be 1, and the hypotenuse n.
69+
// We want to find the opposite side
70+
// n^2 = 1^2 + adj^2
71+
// opposite = sqrt(n^2 - 1)
72+
// The two components of our vector are therefore (1, sqrt(n^2 - 1))
73+
val n = 1 / Math.abs(similarity)
74+
(1, Math.sqrt(Math.pow(n, 2) - 1))
75+
}
76+
val vec2D = if (similarity < 0.0) (-absVec2D._1, -absVec2D._2) else absVec2D
77+
vectorWithComponents(dims, 0 -> vec2D._1, 1 -> vec2D._2)
78+
}
79+
}
80+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.gu.mediaservice.lib
2+
3+
import org.scalatest.Inspectors
4+
import org.scalatest.OptionValues
5+
import org.scalatest.funspec.AnyFunSpec
6+
import org.scalatest.matchers.should.Matchers
7+
import org.scalacheck.Gen
8+
import VectorUtils.{dotProduct, magnitude, cosineSimilarity, firstBasisVector, vectorWithCosineSimilarity}
9+
10+
11+
class VectorUtilsTest extends AnyFunSpec with Matchers with Inspectors with OptionValues {
12+
val tolerance = 1e-9
13+
14+
describe("dotProduct") {
15+
it ("should compute the dot product of two vectors") {
16+
dotProduct(List(1.0, 2.0, 3.0), List(4.0, 5.0, 6.0)) shouldBe 32.0
17+
}
18+
19+
it ("should compute a dot product of zero for orthogonal vectors") {
20+
dotProduct(List(1.0, 0.0), List(0.0, 1.0)) shouldBe 0.0
21+
}
22+
23+
it ("should return zero for the dot product of empty vectors") {
24+
dotProduct(List.empty, List.empty) shouldBe 0.0
25+
}
26+
27+
it ("should reject vectors of differing dimensionality") {
28+
an [IllegalArgumentException] should be thrownBy dotProduct(List(1.0, 2.0), List(1.0, 2.0, 3.0))
29+
}
30+
}
31+
32+
describe("magnitude") {
33+
it ("should compute the magnitude of a vector") {
34+
magnitude(List(3.0, 4.0)) shouldBe 5.0
35+
}
36+
37+
it ("should return a magnitude of zero for a zero vector") {
38+
magnitude(List(0.0, 0.0, 0.0)) shouldBe 0.0
39+
}
40+
}
41+
42+
describe("cosineSimilarity") {
43+
it ("should return a cosine similarity of 1 for identical vectors") {
44+
cosineSimilarity(List(1.0, 2.0, 3.0), List(1.0, 2.0, 3.0)).value shouldBe 1.0 +- tolerance
45+
}
46+
47+
it ("should return a cosine similarity of 1 for parallel vectors") {
48+
cosineSimilarity(List(1.0, 2.0, 3.0), List(2.0, 4.0, 6.0)).value shouldBe 1.0 +- tolerance
49+
}
50+
51+
it ("should return a cosine similarity of 0 for orthogonal vectors") {
52+
cosineSimilarity(List(1.0, 0.0), List(0.0, 1.0)).value shouldBe 0.0 +- tolerance
53+
}
54+
55+
it ("should return a cosine similarity of -1 for opposite vectors") {
56+
cosineSimilarity(List(1.0, 2.0, 3.0), List(-1.0, -2.0, -3.0)).value shouldBe -1.0 +- tolerance
57+
}
58+
59+
it ("should be undefined (None) when the first vector has zero magnitude") {
60+
cosineSimilarity(List(0.0, 0.0, 0.0), List(1.0, 2.0, 3.0)) shouldBe None
61+
}
62+
63+
it ("should be undefined (None) when the second vector has zero magnitude") {
64+
cosineSimilarity(List(1.0, 2.0, 3.0), List(0.0, 0.0, 0.0)) shouldBe None
65+
}
66+
67+
it ("should be undefined (None) when both vectors have zero magnitude") {
68+
cosineSimilarity(List(0.0, 0.0, 0.0), List(0.0, 0.0, 0.0)) shouldBe None
69+
}
70+
71+
it ("should be undefined (None) when the vectors differ in dimensionality") {
72+
cosineSimilarity(List(1.0, 2.0), List(1.0, 2.0, 3.0)) shouldBe None
73+
}
74+
}
75+
76+
// This function is specifically for use in tests,
77+
// where we need to create test fixtures with specific semantic scores.
78+
describe("vectorWithCosineSimilarity") {
79+
val dims = 256
80+
81+
it ("should create a vector at the requested cosine similarity across a fine sweep of the valid range") {
82+
val similarities = (-1000 to 1000).map(_ / 1000.0)
83+
forAll(similarities) { s =>
84+
cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, s)).value shouldBe s +- tolerance
85+
}
86+
}
87+
88+
it ("should create a vector at the requested cosine similarity for randomly generated similarities") {
89+
val similarityGen = Gen.chooseNum(-1.0, 1.0)
90+
val samples = Gen.listOfN(500, similarityGen).sample.getOrElse(Nil)
91+
samples should not be empty
92+
forAll(samples) { s =>
93+
cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, s)).value shouldBe s +- tolerance
94+
}
95+
}
96+
97+
it ("should work across a range of dimensions") {
98+
forAll(List(2, 3, 8, 16, 128, 256, 1024)) { n =>
99+
cosineSimilarity(firstBasisVector(n), vectorWithCosineSimilarity(n, 0.42)).value shouldBe 0.42 +- tolerance
100+
}
101+
}
102+
103+
it ("should return a vector identical to the basis vector for a similarity of 1") {
104+
vectorWithCosineSimilarity(dims, 1.0) shouldBe firstBasisVector(dims)
105+
cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, 1.0)).value shouldBe 1.0 +- tolerance
106+
}
107+
108+
it ("should return a vector opposite to the basis vector for a similarity of -1") {
109+
cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, -1.0)).value shouldBe -1.0 +- tolerance
110+
}
111+
112+
it ("should return a vector orthogonal to the basis vector for a similarity of 0") {
113+
cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, 0.0)).value shouldBe 0.0 +- tolerance
114+
}
115+
116+
it ("should negate the vector when the similarity is negated") {
117+
val positive = vectorWithCosineSimilarity(dims, 0.25)
118+
val negative = vectorWithCosineSimilarity(dims, -0.25)
119+
forAll(positive.zip(negative)) { case (p, n) => n shouldBe (-p) +- tolerance }
120+
}
121+
122+
it ("should produce only finite components for valid similarities") {
123+
forAll(List(-1.0, -0.5, -1e-3, 0.0, 1e-3, 0.5, 1.0)) { s =>
124+
forAll(vectorWithCosineSimilarity(dims, s)) { component =>
125+
component.isNaN shouldBe false
126+
component.isInfinite shouldBe false
127+
}
128+
}
129+
}
130+
131+
it ("should reject a similarity greater than 1") {
132+
an [IllegalArgumentException] should be thrownBy vectorWithCosineSimilarity(dims, 1.0001)
133+
}
134+
135+
it ("should reject a similarity less than -1") {
136+
an [IllegalArgumentException] should be thrownBy vectorWithCosineSimilarity(dims, -1.0001)
137+
}
138+
139+
it ("should reject fewer than 2 dimensions") {
140+
an [IllegalArgumentException] should be thrownBy vectorWithCosineSimilarity(1, 0.5)
141+
}
142+
}
143+
}

image-loader/app/ImageLoaderComponents.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class ImageLoaderComponents(context: Context) extends GridComponents(context, ne
2121
logger.info(s" $index -> ${processor.description}")
2222
}
2323

24-
private val gridClient = GridClient(config.services)(wsClient)
24+
private val gridClient = GridClient(config.services, config.services.loaderBaseUri)(wsClient)
2525

2626
val store = new ImageLoaderStore(config)
2727
val maybeIngestQueue = config.maybeIngestSqsQueueUrl.map(queueUrl => new SimpleSqsMessageConsumer(queueUrl, config))

image-loader/conf/routes

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
GET / controllers.ImageLoaderController.index
22
POST /prepare controllers.ImageLoaderController.getPreSignedUploadUrlsAndTrack
33
POST /images controllers.ImageLoaderController.loadImage(uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], filename: Option[String])
4-
+nocsrf
54
POST /enqueueDerivativeImage controllers.ImageLoaderController.enqueueDerivativeImage(derivativeOfMediaIds: String, filename: String, uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String])
65
POST /imports controllers.ImageLoaderController.importImage(uri: String, uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], filename: Option[String])
7-
+nocsrf
86
POST /images/restore controllers.ImageLoaderController.restoreFromReplica
97
GET /images/project/:imageId controllers.ImageLoaderController.projectImageBy(imageId: String)
108

0 commit comments

Comments
 (0)