@@ -4,15 +4,15 @@ import app.cash.backfila.service.listener.SlackHelper
4
4
import app.cash.backfila.service.persistence.BackfilaDb
5
5
import app.cash.backfila.service.persistence.BackfillRunQuery
6
6
import app.cash.backfila.service.persistence.BackfillState
7
- import app.cash.backfila.service.persistence.DbBackfillRun
8
- import app.cash.backfila.service.persistence.DbEventLog
7
+ import app.cash.backfila.service.persistence.DbDeprecationReminder
9
8
import app.cash.backfila.service.persistence.DbRegisteredBackfill
10
- import app.cash.backfila.service.persistence.EventLogQuery
9
+ import app.cash.backfila.service.persistence.DeprecationReminderQuery
11
10
import app.cash.backfila.service.persistence.RegisteredBackfillQuery
12
11
import java.time.Clock
12
+ import java.time.DayOfWeek
13
13
import java.time.Duration
14
14
import java.time.Instant
15
- import java.time.temporal.ChronoUnit
15
+ import java.time.ZoneOffset
16
16
import javax.inject.Inject
17
17
import javax.inject.Singleton
18
18
import misk.hibernate.Query
@@ -27,21 +27,7 @@ class DeprecationNotificationHelper @Inject constructor(
27
27
private val notificationProvider : DeprecationNotificationProvider ,
28
28
private val clock : Clock ,
29
29
) {
30
- // Data class to hold all the info needed for message generation
31
- data class NotificationContext (
32
- val backfill : DbRegisteredBackfill ,
33
- val latestRun : DbBackfillRun ? ,
34
- val effectiveDeleteBy : Instant ,
35
- val deleteByReason : DeleteByReason ,
36
- )
37
-
38
- // Enum to clearly indicate why a date was chosen
39
- enum class DeleteByReason {
40
- EXPLICIT_DELETE_BY ,
41
- SUCCESSFUL_RUN ,
42
- PAUSED_RUN ,
43
- DEFAULT_CREATION ,
44
- }
30
+ private val daysInMonth = 30L
45
31
46
32
fun getRegisteredBackfillsForNotification (): List <DbRegisteredBackfill > {
47
33
return transacter.transaction { session ->
@@ -51,7 +37,7 @@ class DeprecationNotificationHelper @Inject constructor(
51
37
}
52
38
}
53
39
54
- fun evaluateRegisteredBackfill (registeredBackfill : DbRegisteredBackfill ): NotificationDecision {
40
+ fun notifyRegisteredBackfill (registeredBackfill : DbRegisteredBackfill ): NotificationDecision ? {
55
41
return transacter.transaction { session ->
56
42
val now = clock.instant()
57
43
val config = notificationProvider.getNotificationConfig()
@@ -60,12 +46,26 @@ class DeprecationNotificationHelper @Inject constructor(
60
46
val service = registeredBackfill.service // This should be loaded due to JPA relationship
61
47
val lastRegisteredAt = service.last_registered_at
62
48
if (lastRegisteredAt != null &&
63
- Duration .between(lastRegisteredAt, now) > config.registerRetention
49
+ Duration .between(lastRegisteredAt, now) > Duration .ofDays(daysInMonth * 3 )
64
50
) {
65
- return @transaction NotificationDecision .NONE
51
+ return @transaction null
52
+ }
53
+
54
+ if (! isBusinessHours(registeredBackfill)) {
55
+ return @transaction null
66
56
}
67
57
68
- val defaultDeleteBy = registeredBackfill.created_at.plus(config.defaultDeleteByDuration)
58
+ val deleteByDates = mutableListOf<Pair <NotificationDecision , Instant >>()
59
+
60
+ // Explicit delete-by date
61
+ registeredBackfill.delete_by?.let { deleteBy ->
62
+ deleteByDates.add(NotificationDecision .EXPLICIT_DELETE_BY to deleteBy)
63
+ }
64
+
65
+ // Default creation-based delete-by
66
+ val defaultDeleteBy = registeredBackfill.created_at
67
+ .plus(config.defaultDelayDays[NotificationDecision .DEFAULT_CREATION ]!! )
68
+ deleteByDates.add(NotificationDecision .DEFAULT_CREATION to defaultDeleteBy)
69
69
70
70
// Get the latest run and its status
71
71
val latestRun = queryFactory.newQuery<BackfillRunQuery >()
@@ -77,166 +77,134 @@ class DeprecationNotificationHelper @Inject constructor(
77
77
.list(session)
78
78
.firstOrNull()
79
79
80
- // Calculate delete_by date based on latest run
81
- val runBasedDeleteBy = latestRun?.let {
80
+ // Add run- based delete_by dates to deleteByDates
81
+ latestRun?.let {
82
82
val runDate = it.created_at
83
83
when (it.state) {
84
- BackfillState .COMPLETE -> runDate.plus(config.completeRunRetention)
85
- BackfillState .PAUSED -> runDate.plus(config.pausedRunRetention)
84
+ BackfillState .COMPLETE -> deleteByDates.add(
85
+ NotificationDecision .COMPLETE_RUN to runDate.plus(config.defaultDelayDays[NotificationDecision .COMPLETE_RUN ]!! ),
86
+ )
87
+ BackfillState .PAUSED -> deleteByDates.add(
88
+ NotificationDecision .PAUSED_RUN to runDate.plus(config.defaultDelayDays[NotificationDecision .PAUSED_RUN ]!! ),
89
+ )
86
90
else -> null
87
91
}
88
92
}
89
93
90
- // Determine the maximum date to use and why
91
- val (effectiveDeleteBy, deleteByReason) = when {
92
- registeredBackfill.delete_by != null &&
93
- (registeredBackfill.delete_by == listOfNotNull(registeredBackfill.delete_by, defaultDeleteBy, runBasedDeleteBy).maxOrNull()) ->
94
- registeredBackfill.delete_by!! to DeleteByReason .EXPLICIT_DELETE_BY
95
-
96
- runBasedDeleteBy != null &&
97
- (runBasedDeleteBy == listOfNotNull(registeredBackfill.delete_by, defaultDeleteBy, runBasedDeleteBy).maxOrNull()) ->
98
- if (latestRun.state == BackfillState .COMPLETE ) {
99
- runBasedDeleteBy to DeleteByReason .SUCCESSFUL_RUN
100
- } else {
101
- runBasedDeleteBy to DeleteByReason .PAUSED_RUN
102
- }
103
-
104
- else -> defaultDeleteBy to DeleteByReason .DEFAULT_CREATION
105
- }
94
+ // Find the latest delete-by date and its associated decision
95
+ val (effectiveDecision, effectiveDeleteBy) = deleteByDates.maxByOrNull { it.second }
96
+ ? : return @transaction null
106
97
107
98
// Don't send notifications before the delete_by date
108
99
val timeUntilDeletion = Duration .between(now, effectiveDeleteBy)
109
100
if (! timeUntilDeletion.isNegative) {
110
- return @transaction NotificationDecision . NONE
101
+ return @transaction null
111
102
}
112
103
113
104
// We're past the delete_by date, determine notification frequency
114
105
val timeSinceDeletion = Duration .between(effectiveDeleteBy, now)
106
+ val notifications = config.notifications[effectiveDecision] ? : emptyList()
107
+
108
+ // Find the appropriate notification based on timeSinceDeletion
109
+ val appropriateNotification = notifications
110
+ .sortedBy { it.delay } // Sort by delay to get earliest first
111
+ .findLast { notification ->
112
+ // Find the last notification whose delay is less than or equal to timeSinceDeletion
113
+ timeSinceDeletion >= notification.delay
114
+ } ? : return @transaction null
115
115
116
116
// Get the last notification sent
117
- // First get all backfill run IDs for this registered backfill
118
- val backfillRunIds = queryFactory.newQuery<BackfillRunQuery >()
117
+ val lastReminder = queryFactory.newQuery<DeprecationReminderQuery >()
119
118
.registeredBackfillId(registeredBackfill.id)
119
+ .orderByCreatedAtDesc()
120
+ .apply { maxRows = 1 }
120
121
.list(session)
121
- .map { it.id }
122
-
123
- // Then if we have any runs, query event logs for notifications across all these runs
124
- val lastNotification = if (backfillRunIds.isNotEmpty()) {
125
- queryFactory.newQuery<EventLogQuery >()
126
- .backfillRunIdIn(backfillRunIds) // We'd need to add this method to EventLogQuery
127
- .type(DbEventLog .Type .NOTIFICATION )
128
- .orderByUpdatedAtDesc()
129
- .apply {
130
- maxRows = 1
131
- }
132
- .list(session)
133
- .firstOrNull()
134
- } else {
135
- null
136
- }
137
-
138
- // First 3 months: monthly reminders
139
- if (timeSinceDeletion <= config.monthlyRemindersPhase) {
140
- val shouldSendMonthly = lastNotification?.let {
141
- Duration .between(it.created_at, now) >= Duration .ofDays(30 )
142
- } ? : true
143
-
144
- if (shouldSendMonthly) {
145
- val context = NotificationContext (
146
- backfill = registeredBackfill,
147
- latestRun = latestRun,
148
- effectiveDeleteBy = effectiveDeleteBy,
149
- deleteByReason = deleteByReason,
150
- )
122
+ .firstOrNull()
151
123
152
- currentNotificationContext = context
153
- return @transaction NotificationDecision .NOTIFY_EXPIRED
124
+ // Check if we should send this notification
125
+ val shouldSendNotification = when {
126
+ lastReminder == null -> {
127
+ // No previous reminder, should send
128
+ true
129
+ }
130
+ appropriateNotification.repeated -> {
131
+ // For repeated notifications, check if enough time has passed since last reminder
132
+ Duration .between(lastReminder.created_at, now) >= appropriateNotification.delay
133
+ }
134
+ else -> {
135
+ // For non-repeated notifications, check if this specific type hasn't been sent
136
+ ! (lastReminder.message_last_user == appropriateNotification.messageLastUser && ! lastReminder.repeated)
154
137
}
155
138
}
156
- // After 3 months: weekly reminders
157
- else {
158
- val shouldSendWeekly = lastNotification?.let {
159
- Duration .between(it.created_at, now) >= Duration .ofDays(7 )
160
- } ? : true
161
-
162
- if (shouldSendWeekly) {
163
- val context = NotificationContext (
164
- backfill = registeredBackfill,
165
- latestRun = latestRun,
166
- effectiveDeleteBy = effectiveDeleteBy,
167
- deleteByReason = deleteByReason,
168
- )
169
139
170
- currentNotificationContext = context
171
- return @transaction NotificationDecision .NOTIFY_EXPIRED
172
- }
140
+ if (shouldSendNotification) {
141
+ val message = generateNotificationMessage(appropriateNotification, registeredBackfill)
142
+ sendNotification(message, registeredBackfill.service.slack_channel)
143
+ // Log to deprecation reminder table
144
+ val reminder = DbDeprecationReminder (
145
+ registeredBackfill.id,
146
+ appropriateNotification.messageLastUser, appropriateNotification.repeated,
147
+ now,
148
+ )
149
+ session.save(reminder)
150
+ return @transaction effectiveDecision
173
151
}
174
152
175
- NotificationDecision . NONE
153
+ return @transaction null
176
154
}
177
155
}
178
156
179
157
// Separate message generation function that doesn't need DB access
180
158
private fun generateNotificationMessage (
181
- decision : NotificationDecision ,
182
- context : NotificationContext ,
159
+ notification : DeprecationMessage ,
160
+ registeredBackfill : DbRegisteredBackfill ,
183
161
): String {
184
- val now = clock.instant()
185
- val daysSinceDeletion = ChronoUnit .DAYS .between(context.effectiveDeleteBy, now)
186
-
187
- val deleteByReasonMessage = when (context.deleteByReason) {
188
- DeleteByReason .EXPLICIT_DELETE_BY -> " explicitly set delete_by date"
189
- DeleteByReason .SUCCESSFUL_RUN -> " 30 days after last successful run"
190
- DeleteByReason .PAUSED_RUN -> " 90 days after last failed run"
191
- DeleteByReason .DEFAULT_CREATION -> " 6 months after creation"
162
+ // Use the configured message directly
163
+ val baseMessage = notification.message
164
+
165
+ // Add metadata about the backfill
166
+ val metadata = buildString {
167
+ appendLine()
168
+ appendLine(" *Additional Information:*" )
169
+ appendLine(" • Backfill: `${registeredBackfill.name} `" )
170
+
171
+ // Add action items
172
+ appendLine()
173
+ appendLine(" *Actions Required:*" )
174
+ appendLine(" 1. Review if this backfill is still needed" )
175
+ appendLine(" 2. Update the `@DeleteBy` annotation with a new date" )
176
+ appendLine(" 3. Or run the backfill again to extend its lifetime" )
192
177
}
193
178
194
- return """
195
- |${decision.emoji} *Backfill Deletion Reminder*
196
- |Backfill `${context.backfill.name} ` was due for deletion on ${context.effectiveDeleteBy} .
197
- |
198
- |• Days since deletion date: $daysSinceDeletion
199
- |• Deletion date determined by: $deleteByReasonMessage
200
- |${context.latestRun?.let { " • Last run status: ${it.state} on ${it.created_at} " } ? : " • No runs found" }
201
- |
202
- |To keep this backfill:
203
- |1. Review if this backfill is still needed
204
- |2. Update the `@DeleteBy` annotation with a new date
205
- |3. Or run the backfill again to extend its lifetime
206
- |
207
- |_This reminder will be sent monthly for 3 months, then weekly until action is taken._
208
- """ .trimMargin()
179
+ return baseMessage + metadata
209
180
}
210
181
211
182
// Update send notification to use the new message generation
212
183
fun sendNotification (
213
- decision : NotificationDecision ,
214
- channel : String ,
184
+ message : String ,
185
+ channel : String? ,
215
186
) {
216
- val message = generateNotificationMessage(decision, currentNotificationContext!! )
217
-
187
+ if (channel == null ) {
188
+ // logger.warn { "No Slack channel specified for notification. Skipping." }
189
+ return
190
+ }
218
191
// Send to Slack
219
192
slackHelper.sendDeletionNotification(message, channel)
193
+ }
220
194
221
- // Record notification in event_logs for the latest run if it exists
222
- currentNotificationContext?.latestRun?.let { latestRun ->
223
- transacter.transaction { session ->
224
- session.save(
225
- DbEventLog (
226
- backfill_run_id = latestRun.id,
227
- partition_id = null ,
228
- type = DbEventLog .Type .NOTIFICATION ,
229
- message = " Deletion notification sent to $channel " ,
230
- extra_data = message,
231
- ),
232
- )
233
- }
195
+ private fun isBusinessHours (registeredBackfill : DbRegisteredBackfill ): Boolean {
196
+ val creationTime = registeredBackfill.created_at
197
+ val currentTime = clock.instant()
198
+
199
+ // Avoid weekends in UTC
200
+ if (currentTime.atZone(ZoneOffset .UTC ).dayOfWeek !in listOf (DayOfWeek .SATURDAY , DayOfWeek .SUNDAY )) {
201
+ return false
234
202
}
235
203
236
- // Clear the context after use
237
- currentNotificationContext = null
238
- }
204
+ // Keep the same hour of day as when the backfill was created
205
+ val creationHour = creationTime.atZone( ZoneOffset . UTC ).hour
206
+ val currentHour = currentTime.atZone( ZoneOffset . UTC ).hour
239
207
240
- // Add property to store context between evaluate and send
241
- private var currentNotificationContext : NotificationContext ? = null
208
+ return creationHour == currentHour
209
+ }
242
210
}
0 commit comments