Skip to content

Commit cc92d56

Browse files
authored
#5 green status notification on recovery of application service (#9)
1 parent 4fb484c commit cc92d56

File tree

5 files changed

+141
-82
lines changed

5 files changed

+141
-82
lines changed

src/main/scala/za/co/absa/statusboard/notification/deciders/DurationBasedNotificationDeciderImpl.scala

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,35 @@
1515
*/
1616

1717
package za.co.absa.statusboard.notification.deciders
18-
import za.co.absa.statusboard.model.RawStatus.Green
19-
import za.co.absa.statusboard.model.{NotificationCondition, RawStatus, RefinedStatus}
18+
import za.co.absa.statusboard.model.{NotificationCondition, RefinedStatus}
19+
import za.co.absa.statusboard.repository.StatusRepository
2020
import zio.{UIO, ZIO, ZLayer}
2121

2222
import java.time.Duration
2323

24-
object DurationBasedNotificationDeciderImpl extends DurationBasedNotificationDecider {
24+
class DurationBasedNotificationDeciderImpl(statusRepository: StatusRepository) extends DurationBasedNotificationDecider {
2525
override def shouldNotify(condition: NotificationCondition.DurationBased, status: RefinedStatus): UIO[Boolean] = {
2626
ZIO.succeed {
2727
!status.notificationSent &&
28-
isNotGreen(status.status) &&
2928
(!status.status.intermittent || condition.secondsInState < Duration.between(status.firstSeen, status.lastSeen).toSeconds)
29+
}.flatMap { baseShouldNotify =>
30+
if (!baseShouldNotify) ZIO.succeed(false)
31+
else lastNotificationHasSameColor(status).map(!_)
3032
}
3133
}
3234

33-
private def isNotGreen(status: RawStatus): Boolean = status match {
34-
case Green(_) => false
35-
case _ => true
35+
private def lastNotificationHasSameColor(status: RefinedStatus): UIO[Boolean] = {
36+
statusRepository.getLatestNotifiedStatus(status.env, status.serviceName).foldZIO(
37+
_ => ZIO.succeed(false),
38+
lastNotifiedStatus => ZIO.succeed(lastNotifiedStatus.status.color == status.status.color)
39+
)
3640
}
41+
}
3742

38-
val layer: ZLayer[Any, Throwable, DurationBasedNotificationDecider] = ZLayer.succeed(DurationBasedNotificationDeciderImpl)
43+
object DurationBasedNotificationDeciderImpl {
44+
val layer: ZLayer[StatusRepository, Throwable, DurationBasedNotificationDecider] = ZLayer {
45+
for {
46+
statusRepository <- ZIO.service[StatusRepository]
47+
} yield new DurationBasedNotificationDeciderImpl(statusRepository)
48+
}
3949
}

src/main/scala/za/co/absa/statusboard/repository/DynamoDbStatusRepository.scala

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ class DynamoDbStatusRepository(dynamodbClient: DynamoDbClient, tableName: String
4444
ZIO.logError(s"$msg: ${error.getMessage}") *> ZIO.fail(GeneralDatabaseError(s"$msg: ${error.getMessage}"))
4545
}
4646

47-
override def getLatestStatus(environment: String, serviceName: String): IO[DatabaseError, RefinedStatus] = getLatestStatus(fullServiceName(environment, serviceName))
47+
override def getLatestStatus(environment: String, serviceName: String): IO[DatabaseError, RefinedStatus] = getLatestStatus(fullServiceName(environment, serviceName), onlyNotified = false)
48+
49+
override def getLatestNotifiedStatus(environment: String, serviceName: String): IO[DatabaseError, RefinedStatus] = getLatestStatus(fullServiceName(environment, serviceName), onlyNotified = true)
4850

4951
override def getAllStatuses(environment: String, serviceName: String): IO[DatabaseError, Seq[RefinedStatus]] = {
5052
for {
@@ -71,7 +73,7 @@ class DynamoDbStatusRepository(dynamodbClient: DynamoDbClient, tableName: String
7173
scanResponse <- ZIO.attempt(dynamodbClient.scan(scanRequest))
7274
scanResponseItems <- ZIO.attempt(scanResponse.items().asScala.toSeq)
7375
fullServiceNames <- ZIO.foreach(scanResponseItems)(item => ZIO.succeed(sAttributeValueToString(item, FullServiceName)))
74-
result <- ZIO.foreach(fullServiceNames.toSet)(getLatestStatus)
76+
result <- ZIO.foreach(fullServiceNames.toSet)(fullServiceName => getLatestStatus(fullServiceName, onlyNotified = false))
7577
} yield result.filter(_.status match {
7678
case RawStatus.Black() => false
7779
case _ => true
@@ -112,15 +114,31 @@ class DynamoDbStatusRepository(dynamodbClient: DynamoDbClient, tableName: String
112114
ZIO.logError(s"$msg: ${error.getMessage}") *> ZIO.fail(GeneralDatabaseError(s"$msg: ${error.getMessage}"))
113115
}
114116

115-
private def getLatestStatus(fullServiceName: String): IO[DatabaseError, RefinedStatus] = {
117+
private def getLatestStatus(fullServiceName: String, onlyNotified: Boolean): IO[DatabaseError, RefinedStatus] = {
116118
for {
117119
request <- ZIO.attempt {
118-
QueryRequest
120+
val expressionValues = if (onlyNotified)
121+
Map(
122+
s":$FullServiceName" -> sAttributeValueFromString(fullServiceName),
123+
s":$NotificationSent" -> boolAttributeValueFromBoolean(true)
124+
)
125+
else
126+
Map(
127+
s":$FullServiceName" -> sAttributeValueFromString(fullServiceName)
128+
)
129+
130+
val baseBuilder = QueryRequest
119131
.builder()
120132
.tableName(tableName)
121133
.keyConditionExpression(s"$FullServiceName = :$FullServiceName")
122-
.expressionAttributeValues(Map(s":$FullServiceName" -> sAttributeValueFromString(fullServiceName)).asJava)
123-
.scanIndexForward(false)
134+
.expressionAttributeValues(expressionValues.asJava)
135+
136+
val maybeFilteredBuilder = if (onlyNotified)
137+
baseBuilder.filterExpression(s"$NotificationSent = :$NotificationSent")
138+
else
139+
baseBuilder
140+
141+
maybeFilteredBuilder.scanIndexForward(false)
124142
.limit(1)
125143
.build()
126144
}

src/main/scala/za/co/absa/statusboard/repository/StatusRepository.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ trait StatusRepository {
4545
*/
4646
def getLatestStatus(environment: String, serviceName: String): IO[DatabaseError, RefinedStatus]
4747

48+
/**
49+
* Retrieves the status of latest notification of a given service.
50+
*
51+
* @param environment Environment
52+
* @param serviceName Service name
53+
* @return A [[zio.IO]] that will produce the latest [[za.co.absa.statusboard.model.RefinedStatus]] for the service
54+
* or a [[za.co.absa.statusboard.model.AppError.DatabaseError]] if an error occurs.
55+
*/
56+
def getLatestNotifiedStatus(environment: String, serviceName: String): IO[DatabaseError, RefinedStatus]
57+
4858
/**
4959
* Retrieves full status history for a given service.
5060
*

src/test/scala/za/co/absa/statusboard/notification/deciders/DurationBasedNotificationDeciderImplUnitTests.scala

Lines changed: 74 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -16,98 +16,104 @@
1616

1717
package za.co.absa.statusboard.notification.deciders
1818

19-
import za.co.absa.statusboard.model.NotificationCondition
19+
import org.mockito.Mockito.{mock, reset, when}
20+
import za.co.absa.statusboard.model.AppError.DatabaseError.RecordNotFoundDatabaseError
21+
import za.co.absa.statusboard.model.{NotificationCondition, RefinedStatus}
2022
import za.co.absa.statusboard.model.RawStatus.{Green, Red}
23+
import za.co.absa.statusboard.repository.StatusRepository
2124
import za.co.absa.statusboard.testUtils.{ConfigProviderSpec, TestData}
2225
import zio.test.Assertion.equalTo
23-
import zio.test.{Spec, TestEnvironment, assertZIO}
24-
import zio.Scope
26+
import zio.test.{Spec, TestAspect, TestEnvironment, assert}
27+
import zio.{Scope, Task, ZIO, ZLayer}
2528

2629
import java.time.Duration
2730

2831
object DurationBasedNotificationDeciderImplUnitTests extends ConfigProviderSpec {
29-
private val statusNotSent10MinutesGreen = TestData.refinedStatus.copy(
30-
status = Green("BAD"),
31-
notificationSent = false,
32-
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(10))
33-
)
32+
private def statusGreen(sent: Boolean, seen: Int) = TestData.refinedStatus.copy(
33+
status = Green("INFO"),
34+
notificationSent = sent,
35+
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(seen))
36+
)
3437

35-
private val statusNotSent10Minutes = TestData.refinedStatus.copy(
36-
status = Red("BAD", intermittent = false),
37-
notificationSent = false,
38-
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(10))
38+
private def statusRed(sent: Boolean, seen: Int) = TestData.refinedStatus.copy(
39+
status = Red("INFO", intermittent = false),
40+
notificationSent = sent,
41+
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(seen))
3942
)
4043

41-
private val statusNotSentIntermittent10Minutes = TestData.refinedStatus.copy(
42-
status = Red("BAD", intermittent = true),
43-
notificationSent = false,
44-
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(10))
44+
private def statusRedInt(sent: Boolean, seen: Int) = TestData.refinedStatus.copy(
45+
status = Red("INFO", intermittent = true),
46+
notificationSent = sent,
47+
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(seen))
4548
)
4649

47-
private val statusYesSent10Minutes = TestData.refinedStatus.copy(
48-
status = Red("BAD", intermittent = false),
49-
notificationSent = true,
50-
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(10))
51-
)
50+
private def condition(seen: Int) = NotificationCondition.DurationBased(Duration.ofMinutes(seen).toSeconds.toInt)
5251

53-
private val statusYesSentIntermittent10Minutes = TestData.refinedStatus.copy(
54-
status = Red("BAD", intermittent = true),
55-
notificationSent = true,
56-
lastSeen = TestData.refinedStatus.firstSeen.plus(Duration.ofMinutes(10))
57-
)
52+
private val repositoryMock = mock(classOf[StatusRepository])
5853

59-
private val conditionDuration5Minutes = NotificationCondition.DurationBased(Duration.ofMinutes(5).toSeconds.toInt)
54+
private def setupRepoMock(lastNotifiedStatus: RefinedStatus): Task[Unit] = ZIO.attempt {
55+
reset(repositoryMock)
56+
when(repositoryMock.getLatestNotifiedStatus(TestData.refinedStatus.env, TestData.refinedStatus.serviceName))
57+
.thenReturn(ZIO.succeed(lastNotifiedStatus))
58+
}
6059

61-
private val conditionDuration30Minutes = NotificationCondition.DurationBased(Duration.ofMinutes(30).toSeconds.toInt)
60+
private def setupRepoMockEmpty(): Task[Unit] = ZIO.attempt {
61+
reset(repositoryMock)
62+
when(repositoryMock.getLatestNotifiedStatus(TestData.refinedStatus.env, TestData.refinedStatus.serviceName))
63+
.thenReturn(ZIO.fail(RecordNotFoundDatabaseError("BAKA")))
64+
}
65+
66+
private def resetRepoMock(): Task[Unit] = ZIO.attempt {
67+
reset(repositoryMock)
68+
}
6269

6370
override def spec: Spec[TestEnvironment with Scope, Any] = {
6471
suite("DurationBasedNotificationDeciderImplSuite")(
65-
test("Should not request notification, when already sent regardless of being over time") {
66-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration5Minutes, statusYesSent10Minutes))(
67-
equalTo(false)
68-
)
69-
},
70-
test("Should not request notification, when already sent (intermittent) regardless of being over time") {
71-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration5Minutes, statusYesSentIntermittent10Minutes))(
72-
equalTo(false)
73-
)
72+
test("Should yes request notification, when being stable (regardless of no overtime) and previously notified for different color") {
73+
for {
74+
_ <- setupRepoMock(statusGreen(sent = true, seen = 10))
75+
response <- DurationBasedNotificationDecider.shouldNotify(condition(seen = 10), statusRed(sent = false, seen = 5))
76+
} yield assert(response)(equalTo(true))
7477
},
75-
test("Should not request notification, when already sent and not being over time") {
76-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration30Minutes, statusYesSent10Minutes))(
77-
equalTo(false)
78-
)
78+
test("Should yes request notification, when being stable (regardless of no overtime) and previously no notification at all") {
79+
for {
80+
_ <- setupRepoMockEmpty()
81+
response <- DurationBasedNotificationDecider.shouldNotify(condition(seen = 10), statusRed(sent = false, seen = 5))
82+
} yield assert(response)(equalTo(true))
7983
},
80-
test("Should not request notification, when already sent (intermittnent) and not being over time") {
81-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration30Minutes, statusYesSentIntermittent10Minutes))(
82-
equalTo(false)
83-
)
84+
test("Should yes request notification, when being intermittent, with overtime and previously notified for different color") {
85+
for {
86+
_ <- setupRepoMock(statusGreen(sent = true, seen = 10))
87+
response <- DurationBasedNotificationDecider.shouldNotify(condition(seen = 5), statusRedInt(sent = false, seen = 10))
88+
} yield assert(response)(equalTo(true))
8489
},
85-
test("Should not request notification, when being over time but green #160") {
86-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration5Minutes, statusNotSent10MinutesGreen))(
87-
equalTo(false)
88-
)
90+
test("Should yes request notification, when being green (regardless of no overtime) and previously notified for different color (#160 negated in #5)") {
91+
for {
92+
_ <- setupRepoMock(statusRed(sent = true, seen = 10))
93+
response <- DurationBasedNotificationDecider.shouldNotify(condition(seen = 10), statusGreen(sent = false, seen = 5))
94+
} yield assert(response)(equalTo(true))
8995
},
90-
test("Should yes request notification, when being over time") {
91-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration5Minutes, statusNotSent10Minutes))(
92-
equalTo(true)
93-
)
96+
test("Should not request notification, when already notified - also should not touch repo") {
97+
for {
98+
_ <- resetRepoMock()
99+
response <- DurationBasedNotificationDecider.shouldNotify(condition(seen = 10), statusRed(sent = true, seen = 5))
100+
} yield assert(response)(equalTo(false))
94101
},
95-
test("Should yes request notification, when being over time (intermittent)") {
96-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration5Minutes, statusNotSentIntermittent10Minutes))(
97-
equalTo(true)
98-
)
102+
test("Should not request notification, when being intermittent with no overtime - also should not touch repo") {
103+
for {
104+
_ <- resetRepoMock()
105+
response <- DurationBasedNotificationDecider.shouldNotify(condition(seen = 10), statusRedInt(sent = false, seen = 5))
106+
} yield assert(response)(equalTo(false))
99107
},
100-
test("Should yes request notification, when not being over time and NOT intermittent") {
101-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration30Minutes, statusNotSent10Minutes))(
102-
equalTo(true)
103-
)
108+
test("Should not request notification, when previously notified for same color") {
109+
for {
110+
_ <- setupRepoMock(statusRed(sent = true, seen = 10))
111+
response <- DurationBasedNotificationDecider.shouldNotify(condition(seen = 10), statusRed(sent = false, seen = 5))
112+
} yield assert(response)(equalTo(false))
104113
},
105-
test("Should not request notification, when not being over time (intermittent)") {
106-
assertZIO(DurationBasedNotificationDecider.shouldNotify(conditionDuration30Minutes, statusNotSentIntermittent10Minutes))(
107-
equalTo(false)
108-
)
109-
}
110-
)
111-
}.provide(DurationBasedNotificationDeciderImpl.layer
114+
) @@ TestAspect.sequential
115+
}.provide(
116+
DurationBasedNotificationDeciderImpl.layer,
117+
ZLayer.succeed(repositoryMock)
112118
)
113119
}

src/test/scala/za/co/absa/statusboard/repository/DynamoDbStatusRepositoryDynamoDBIntegrationTests.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ object DynamoDbStatusRepositoryDynamoDBIntegrationTests extends ConfigProviderSp
6060
failsWithA[RecordNotFoundDatabaseError]
6161
)
6262
},
63+
test("getLatestNotifiedStatus should retrieve nothing when no state exist") {
64+
assertZIO(StatusRepository.getLatestNotifiedStatus("TestEnv", "NonExistentService").exit)(
65+
failsWithA[RecordNotFoundDatabaseError]
66+
)
67+
},
6368
test("getAllStatuses should retrieve nothing when no state exist") {
6469
assertZIO(StatusRepository.getAllStatuses("TestEnv", "NonExistentService"))(
6570
equalTo(List.empty[RefinedStatus])
@@ -91,6 +96,11 @@ object DynamoDbStatusRepositoryDynamoDBIntegrationTests extends ConfigProviderSp
9196
equalTo(refinedStatus)
9297
)
9398
},
99+
test("getLatestNotifiedStatus should retrieve nothing when the only status is not notified") {
100+
assertZIO(StatusRepository.getLatestNotifiedStatus(refinedStatus.env, refinedStatus.serviceName).exit)(
101+
failsWithA[RecordNotFoundDatabaseError]
102+
)
103+
},
94104
test("getAllStatuses should retrieve last status") {
95105
assertZIO(StatusRepository.getAllStatuses(refinedStatus.env, refinedStatus.serviceName))(
96106
equalTo(List(refinedStatus))
@@ -109,6 +119,11 @@ object DynamoDbStatusRepositoryDynamoDBIntegrationTests extends ConfigProviderSp
109119
equalTo(refinedStatusUpdated)
110120
)
111121
},
122+
test("getLatestNotifiedStatus should retrieve last notified status") {
123+
assertZIO(StatusRepository.getLatestNotifiedStatus(refinedStatusUpdated.env, refinedStatusUpdated.serviceName))(
124+
equalTo(refinedStatusUpdated)
125+
)
126+
},
112127
test("getAllStatuses should retrieve single last status") {
113128
assertZIO(StatusRepository.getAllStatuses(refinedStatusUpdated.env, refinedStatusUpdated.serviceName))(
114129
equalTo(List(refinedStatusUpdated))

0 commit comments

Comments
 (0)