Skip to content

Commit a344715

Browse files
media-model-proxy: fail closed when every replica drops scores
collectToTry + filterException swallowed every NonFatal xx-nsfw error, including greyscale decode failures that were already mapped to GreyScaleImageException. The controller then returned a successful empty annotation map, which Media Analysis persists as unlabeled / SFW. Keep partial-replica failover; fail the request when every attempted replica errors. Co-authored-by: Jon Bailey <Pitchfork-and-Torch@users.noreply.github.com>
1 parent 902a06f commit a344715

2 files changed

Lines changed: 43 additions & 8 deletions

File tree

media-model-proxy/src/main/scala/com/twitter/media_understanding/model_proxy/services/ModelsService.scala

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,22 @@ class ModelsService(
8484

8585
Future
8686
.collectToTry(result)
87-
.map { responses: Seq[Try[PredictionResponse]] =>
88-
responses
87+
.flatMap { responses: Seq[Try[PredictionResponse]] =>
88+
val successes = responses
8989
.filter { response => filterException(response, model) }
9090
.map { predictionResponseTry => predictionResponseTry.get().prediction }
91+
92+
// Attempted replicas that all fail must not become a successful empty
93+
// score list: callers persist that as unlabeled / SFW.
94+
if (result.nonEmpty && successes.isEmpty) {
95+
val cause = responses
96+
.collectFirst { case Throw(ex) => ex }
97+
.getOrElse(new IllegalStateException(s"${model.name} produced no scores"))
98+
stats.scope(model.name).counter("fail_closed").incr()
99+
Future.exception(cause)
100+
} else {
101+
Future.value(successes)
102+
}
91103
}
92104
}
93105
}

media-model-proxy/src/test/scala/com/twitter/media_understanding/model_proxy/services/ModelsServiceSpec.scala

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.twitter.cortex_media_annotator.thriftscala.MediaModel
44
import com.twitter.finagle.NoBrokersAvailableException
55
import com.twitter.finagle.stats.NullStatsReceiver
66
import com.twitter.media_understanding.model_proxy.clients.ModelClient
7+
import com.twitter.media_understanding.model_proxy.exception.GreyScaleImageException
78
import com.twitter.media_understanding.model_proxy.model_descriptors.ModelDescriptor
89
import com.twitter.media_understanding.model_proxy.model_descriptors.ModelDescriptorRegistry
910
import com.twitter.mediaservices.commons.thriftscala.MediaCategory
@@ -88,7 +89,7 @@ class ModelsServiceSpec extends FunSuite with MockitoSugar with BeforeAndAfterEa
8889
}
8990

9091
test(
91-
"getPrediction returns empty list of response if all models in cluster throw PredictionException"
92+
"getPrediction fails closed if all models in cluster throw PredictionException"
9293
) {
9394
when(client_1.isAvailable)
9495
.thenReturn(true)
@@ -100,12 +101,13 @@ class ModelsServiceSpec extends FunSuite with MockitoSugar with BeforeAndAfterEa
100101
when(client_2.predictFromModel(mockAny[PredictionRequest]))
101102
.thenReturn(Future.exception(new PredictionServiceException("")))
102103

103-
val result = Await.result(deepBirdService.getPrediction(Some(mediaCategory), model, media))
104-
assert(result.isEmpty)
104+
intercept[PredictionServiceException] {
105+
Await.result(deepBirdService.getPrediction(Some(mediaCategory), model, media))
106+
}
105107
}
106108

107109
test(
108-
"getPrediction returns empty list of response if all models in cluster throw non fatal exceptions"
110+
"getPrediction fails closed if all models in cluster throw non fatal exceptions"
109111
) {
110112
when(client_1.isAvailable)
111113
.thenReturn(true)
@@ -117,8 +119,27 @@ class ModelsServiceSpec extends FunSuite with MockitoSugar with BeforeAndAfterEa
117119
when(client_2.predictFromModel(mockAny[PredictionRequest]))
118120
.thenReturn(Future.exception(new IllegalArgumentException()))
119121

120-
val result = Await.result(deepBirdService.getPrediction(Some(mediaCategory), model, media))
121-
assert(result.isEmpty)
122+
intercept[IllegalArgumentException] {
123+
Await.result(deepBirdService.getPrediction(Some(mediaCategory), model, media))
124+
}
125+
}
126+
127+
test(
128+
"getPrediction fails closed with GreyScaleImageException when every replica hits a greyscale decode error"
129+
) {
130+
val grey = new PredictionServiceException(deepBirdService.greyScaleErrorString)
131+
when(client_1.isAvailable)
132+
.thenReturn(true)
133+
when(client_2.isAvailable)
134+
.thenReturn(true)
135+
when(client_1.predictFromModel(mockAny[PredictionRequest]))
136+
.thenReturn(Future.exception(grey))
137+
when(client_2.predictFromModel(mockAny[PredictionRequest]))
138+
.thenReturn(Future.exception(grey))
139+
140+
intercept[GreyScaleImageException] {
141+
Await.result(deepBirdService.getPrediction(Some(mediaCategory), model, media))
142+
}
122143
}
123144

124145
test(
@@ -155,6 +176,8 @@ class ModelsServiceSpec extends FunSuite with MockitoSugar with BeforeAndAfterEa
155176
when(client_2.isAvailable)
156177
.thenReturn(false)
157178

179+
// Kill-switched / filtered-out replicas were never attempted, so empty is
180+
// not a swallowed error.
158181
val result2 = Await.result(deepBirdService.getPrediction(Some(mediaCategory), model, media))
159182
assert(result2.isEmpty)
160183
}

0 commit comments

Comments
 (0)