1
+ package app.cash.backfila.service.deletion
2
+
3
+ import app.cash.backfila.service.BackfilaConfig
4
+ import app.cash.backfila.service.persistence.BackfilaDb
5
+ import app.cash.backfila.service.persistence.DbRegisteredBackfill
6
+ import app.cash.backfila.service.persistence.EventLogQuery
7
+ import app.cash.backfila.service.persistence.RegisteredBackfillQuery
8
+ import app.cash.backfila.service.listener.SlackHelper
9
+ import app.cash.backfila.service.persistence.DbBackfillRun
10
+ import app.cash.backfila.service.persistence.DbEventLog
11
+ import misk.hibernate.Id
12
+ import java.time.Clock
13
+ import java.time.Duration
14
+ import java.time.temporal.ChronoUnit
15
+ import javax.inject.Inject
16
+ import javax.inject.Singleton
17
+ import misk.hibernate.Query
18
+ import misk.hibernate.Transacter
19
+ import misk.hibernate.newQuery
20
+ import wisp.logging.getLogger
21
+
22
+ @Singleton
23
+ class DeleteByNotificationHelper @Inject constructor(
24
+ @BackfilaDb private val transacter : Transacter ,
25
+ private val queryFactory : Query .Factory ,
26
+ private val slackHelper : SlackHelper ,
27
+ private val backfilaConfig : BackfilaConfig ,
28
+ private val clock : Clock
29
+ ) {
30
+ fun getBackfillsNeedingNotification (): List <DbRegisteredBackfill > {
31
+ return transacter.transaction { session ->
32
+ // Get all active backfills
33
+ val backfills = queryFactory.newQuery<RegisteredBackfillQuery >()
34
+ .active()
35
+ .list(session)
36
+
37
+ // For backfills without a delete_by date, set it based on creation time
38
+ backfills.forEach { backfill ->
39
+ if (backfill.delete_by == null ) {
40
+ backfill.delete_by = backfill.created_at.plus(
41
+ backfilaConfig.delete_by_notification.defaultDeleteByDuration
42
+ )
43
+ // Save the updated backfill
44
+ session.save(backfill)
45
+ }
46
+ }
47
+ backfills
48
+ }
49
+ }
50
+
51
+ fun evaluateBackfill (backfill : DbRegisteredBackfill ): NotificationDecision {
52
+ val deleteBy = backfill.delete_by
53
+ ? : return NotificationDecision .NONE
54
+
55
+ return transacter.transaction { session ->
56
+ // Get relevant events
57
+ val events = queryFactory.newQuery<EventLogQuery >()
58
+ .backfillRunId(backfill.id as Id <DbBackfillRun >)
59
+ .list(session)
60
+
61
+ val lastSuccessfulRun = events
62
+ .filter { it.type == DbEventLog .Type .NOTIFICATION && it.message == " COMPLETED" }
63
+ .maxByOrNull { it.created_at }
64
+
65
+ val lastNotification = events
66
+ .filter { it.type == DbEventLog .Type .NOTIFICATION }
67
+ .maxByOrNull { it.created_at }
68
+
69
+ // Calculate time until/since deletion
70
+ val now = clock.instant()
71
+ val timeUntilDeletion = Duration .between(now, deleteBy)
72
+
73
+ // If we're past the delete_by date
74
+ if (timeUntilDeletion.isNegative) {
75
+ val timeSinceDeletion = Duration .between(deleteBy, now)
76
+ return @transaction evaluatePostDeleteNotification(timeSinceDeletion, lastNotification)
77
+ }
78
+
79
+ // Find appropriate notification stage
80
+ val stage = backfilaConfig.delete_by_notification.preDeleteStages.find {
81
+ timeUntilDeletion <= it.threshold
82
+ } ? : return @transaction NotificationDecision .NONE
83
+
84
+ // Check if we should notify based on the stage frequency
85
+ val shouldNotify = lastNotification?.let {
86
+ Duration .between(it.created_at, now) >= stage.frequency
87
+ } ? : true
88
+
89
+ if (! shouldNotify) {
90
+ return @transaction NotificationDecision .NONE
91
+ }
92
+
93
+ // Determine notification urgency based on run status
94
+ when {
95
+ lastSuccessfulRun == null ||
96
+ Duration .between(lastSuccessfulRun.created_at, now) > backfilaConfig.delete_by_notification.promptAfterFailedOrNoRuns ->
97
+ NotificationDecision .NOTIFY_URGENT
98
+
99
+ Duration .between(lastSuccessfulRun.created_at, now) > backfilaConfig.delete_by_notification.promptAfterLastSuccessfulRun ->
100
+ NotificationDecision .NOTIFY_WARNING
101
+
102
+ else -> NotificationDecision .NOTIFY_INFO
103
+ }
104
+ }
105
+ }
106
+
107
+ private fun evaluatePostDeleteNotification (
108
+ timeSinceDeletion : Duration ,
109
+ lastNotification : DbEventLog ?
110
+ ): NotificationDecision {
111
+ val config = backfilaConfig.delete_by_notification.postDeleteNotifications
112
+
113
+ // Stop notifications if we're past maxAge
114
+ if (timeSinceDeletion > config.maxAge) {
115
+ return NotificationDecision .NONE
116
+ }
117
+
118
+ // If we haven't sent any notifications yet, check initial delay
119
+ if (lastNotification == null ) {
120
+ return if (timeSinceDeletion >= config.initialDelay) {
121
+ NotificationDecision .NOTIFY_EXPIRED
122
+ } else {
123
+ NotificationDecision .NONE
124
+ }
125
+ }
126
+
127
+ // Find the next notification delay that's applicable
128
+ val nextDelay = config.followUpDelays.find { delay ->
129
+ timeSinceDeletion <= delay &&
130
+ Duration .between(lastNotification.created_at, clock.instant()) >= delay
131
+ }
132
+
133
+ return if (nextDelay != null ) NotificationDecision .NOTIFY_EXPIRED else NotificationDecision .NONE
134
+ }
135
+
136
+ fun sendNotification (
137
+ backfill : DbRegisteredBackfill ,
138
+ decision : NotificationDecision ,
139
+ channel : String
140
+ ) {
141
+ val message = generateNotificationMessage(backfill, decision)
142
+
143
+ // Send to Slack
144
+ slackHelper.sendDeletionNotification(message, channel)
145
+
146
+ // Record notification in event_logs
147
+ transacter.transaction { session ->
148
+ session.save(
149
+ DbEventLog (
150
+ backfill_run_id = backfill.id as Id <DbBackfillRun >,
151
+ partition_id = null ,
152
+ type = DbEventLog .Type .NOTIFICATION ,
153
+ message = " Deletion notification sent to $channel " ,
154
+ extra_data = message
155
+ )
156
+ )
157
+ }
158
+ }
159
+
160
+ fun determineNotificationChannel (backfill : DbRegisteredBackfill ): String {
161
+ // TODO: Make this configurable or determine from backfill metadata
162
+ return " #backfila-notifications"
163
+ }
164
+
165
+ private fun generateNotificationMessage (
166
+ backfill : DbRegisteredBackfill ,
167
+ decision : NotificationDecision
168
+ ): String {
169
+ val deleteBy = backfill.delete_by!!
170
+ val daysUntilDeletion = ChronoUnit .DAYS .between(clock.instant(), deleteBy)
171
+
172
+ val lastInteraction = transacter.transaction { session ->
173
+ queryFactory.newQuery<EventLogQuery >()
174
+ .backfillRunId(backfill.id as Id <DbBackfillRun >)
175
+ .list(session)
176
+ .maxByOrNull { it.created_at }
177
+ }
178
+
179
+ return """
180
+ |${decision.emoji} *Backfill Deletion Notice*
181
+ |Backfill `${backfill.name} ` is scheduled for deletion on ${deleteBy} .
182
+ |
183
+ |• Days until deletion: $daysUntilDeletion
184
+ |• Last activity: ${lastInteraction?.created_at ? : " Never" }
185
+ |${if (lastInteraction?.user != null ) " • Last touched by: ${lastInteraction.user} " else " " }
186
+ |
187
+ |To extend this backfill's lifetime, please:
188
+ |1. Review if this backfill is still needed
189
+ |2. Update the `@DeleteBy` annotation with a new date if needed
190
+ |
191
+ |_Note: Any activity on this backfill will reset the notification schedule._
192
+ """ .trimMargin()
193
+ }
194
+
195
+ companion object {
196
+ private val logger = getLogger<DeleteByNotificationHelper >()
197
+ }
198
+ }
0 commit comments