diff --git a/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillRunsAction.kt b/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillRunsAction.kt index 44c27f064..d423a1023 100644 --- a/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillRunsAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillRunsAction.kt @@ -102,6 +102,7 @@ class GetBackfillRunsAction @Inject constructor( val (pausedBackfills, nextOffset) = queryFactory.newQuery() .serviceId(dbService.id) .stateNot(BackfillState.RUNNING) + .notSoftDeleted() .filterByArgs(filterArgs) .newPager( idDescPaginator(), diff --git a/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillStatusAction.kt b/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillStatusAction.kt index 1574b5bdf..4d853b353 100644 --- a/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillStatusAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/dashboard/GetBackfillStatusAction.kt @@ -65,6 +65,7 @@ data class GetBackfillStatusResponse( val backoff_schedule: String?, val partitions: List, val event_logs: List, + val deleted_at: Instant?, ) class GetBackfillStatusAction @Inject constructor( @@ -98,6 +99,7 @@ class GetBackfillStatusAction @Inject constructor( run.backoff_schedule, partitions.map { dbToUi(it) }, events(session, run, partitions), + run.deleted_at, ) } } diff --git a/service/src/main/kotlin/app/cash/backfila/dashboard/SoftDeleteBackfillAction.kt b/service/src/main/kotlin/app/cash/backfila/dashboard/SoftDeleteBackfillAction.kt new file mode 100644 index 000000000..51cc0d3f8 --- /dev/null +++ b/service/src/main/kotlin/app/cash/backfila/dashboard/SoftDeleteBackfillAction.kt @@ -0,0 +1,55 @@ +package app.cash.backfila.dashboard + +import app.cash.backfila.service.persistence.BackfilaDb +import app.cash.backfila.service.persistence.BackfillState +import app.cash.backfila.service.persistence.DbBackfillRun +import app.cash.backfila.service.persistence.DbEventLog +import javax.inject.Inject +import misk.MiskCaller +import misk.exceptions.BadRequestException +import misk.hibernate.Id +import misk.hibernate.Transacter +import misk.hibernate.load +import misk.scope.ActionScoped +import misk.security.authz.Authenticated +import misk.web.PathParam +import misk.web.Post +import misk.web.RequestContentType +import misk.web.ResponseContentType +import misk.web.actions.WebAction +import misk.web.mediatype.MediaTypes + +class SoftDeleteBackfillAction @Inject constructor( + @BackfilaDb private val transacter: Transacter, + private val caller: @JvmSuppressWildcards ActionScoped, +) : WebAction { + @Post("/backfill/delete/{id}") + @RequestContentType(MediaTypes.APPLICATION_JSON) + @ResponseContentType(MediaTypes.APPLICATION_JSON) + @Authenticated(capabilities = ["users"]) + fun softDelete( + @PathParam id: Long, + ) { + transacter.transaction { session -> + val backfillRun = session.load(Id(id)) + + // Only allow soft delete for COMPLETE or CANCELLED backfills + if (backfillRun.state != BackfillState.COMPLETE && backfillRun.state != BackfillState.CANCELLED) { + throw BadRequestException("Can only delete completed or cancelled backfills") + } + + backfillRun.deleted_at = java.time.Instant.now() + + // Log the deletion event + session.save( + DbEventLog( + backfillRun.id, + partition_id = null, + user = caller.get()?.principal ?: "", + type = DbEventLog.Type.STATE_CHANGE, + message = "backfill soft deleted", + ), + ) + } + } +} diff --git a/service/src/main/kotlin/app/cash/backfila/service/persistence/BackfillRunQuery.kt b/service/src/main/kotlin/app/cash/backfila/service/persistence/BackfillRunQuery.kt index 8ddbe8f90..8730ce627 100644 --- a/service/src/main/kotlin/app/cash/backfila/service/persistence/BackfillRunQuery.kt +++ b/service/src/main/kotlin/app/cash/backfila/service/persistence/BackfillRunQuery.kt @@ -30,4 +30,7 @@ interface BackfillRunQuery : Query { @Order("updated_at", asc = false) fun orderByUpdatedAtDesc(): BackfillRunQuery + + @Constraint("deleted_at", Operator.IS_NULL) + fun notSoftDeleted(): BackfillRunQuery } diff --git a/service/src/main/kotlin/app/cash/backfila/service/persistence/DbBackfillRun.kt b/service/src/main/kotlin/app/cash/backfila/service/persistence/DbBackfillRun.kt index 2e6d4fea8..75782e390 100644 --- a/service/src/main/kotlin/app/cash/backfila/service/persistence/DbBackfillRun.kt +++ b/service/src/main/kotlin/app/cash/backfila/service/persistence/DbBackfillRun.kt @@ -90,6 +90,9 @@ class DbBackfillRun() : DbUnsharded, DbTimestampedEntity { @Column(nullable = false) var dry_run: Boolean = false + @Column(nullable = true) + var deleted_at: Instant? = null + /** Comma separated list of delays for consecutive retries in milliseconds, e.g. 1000,2000 */ @Column var backoff_schedule: String? = null diff --git a/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt index 426734dae..c79822267 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/actions/BackfillShowButtonHandlerAction.kt @@ -1,6 +1,7 @@ package app.cash.backfila.ui.actions import app.cash.backfila.dashboard.CancelBackfillAction +import app.cash.backfila.dashboard.SoftDeleteBackfillAction import app.cash.backfila.dashboard.StartBackfillAction import app.cash.backfila.dashboard.StartBackfillRequest import app.cash.backfila.dashboard.StopBackfillAction @@ -33,6 +34,7 @@ class BackfillShowButtonHandlerAction @Inject constructor( private val stopBackfillAction: StopBackfillAction, private val updateBackfillAction: UpdateBackfillAction, private val cancelBackfillAction: CancelBackfillAction, + private val softDeleteBackfillAction: SoftDeleteBackfillAction, ) : WebAction { @Get(PATH) @ResponseContentType(MediaTypes.TEXT_HTML) @@ -56,6 +58,9 @@ class BackfillShowButtonHandlerAction @Inject constructor( BackfillState.CANCELLED.name -> { cancelBackfillAction.cancel(id.toLong()) } + "soft_delete" -> { + softDeleteBackfillAction.softDelete(id.toLong()) + } } } diff --git a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt index 6645d8d1b..0d861e803 100644 --- a/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt +++ b/service/src/main/kotlin/app/cash/backfila/ui/pages/BackfillShowAction.kt @@ -10,6 +10,7 @@ import app.cash.backfila.ui.components.DashboardPageLayout import app.cash.backfila.ui.components.PageTitle import app.cash.backfila.ui.components.ProgressBar import app.cash.backfila.ui.pages.BackfillCreateAction.BackfillCreateField.CUSTOM_PARAMETER_PREFIX +import java.time.Instant import javax.inject.Inject import javax.inject.Singleton import kotlinx.html.ButtonType @@ -260,6 +261,7 @@ class BackfillShowAction @Inject constructor( val button: Link? = null, val updateFieldId: String? = null, val cancelButton: Link? = null, + val deleteButton: Link? = null, ) private fun getStateButton(state: BackfillState): Link? { @@ -288,6 +290,19 @@ class BackfillShowAction @Inject constructor( } } + private fun getDeleteButton(state: BackfillState, deletedAt: Instant?): Link? { + if (deletedAt != null) { + return null + } + return when (state) { + BackfillState.COMPLETE, BackfillState.CANCELLED -> Link( + label = DELETE_STATE_BUTTON_LABEL, + href = "soft_delete", + ) + else -> null + } + } + private fun GetBackfillStatusResponse.toConfigurationRows(id: Long) = listOf( DescriptionListRow( label = "State", @@ -295,6 +310,7 @@ class BackfillShowAction @Inject constructor( button = getStateButton(state), updateFieldId = "state", cancelButton = getCancelButton(state), + deleteButton = getDeleteButton(state, deleted_at), ), DescriptionListRow( label = "Dry Run", @@ -533,6 +549,35 @@ class BackfillShowAction @Inject constructor( } } } + + it.deleteButton?.let { deleteButton -> + span("ml-2") { + form { + action = BackfillShowButtonHandlerAction.path(id) + + it.updateFieldId?.let { + input { + type = InputType.hidden + name = "field_id" + value = it + } + + input { + type = InputType.hidden + name = "field_value" + value = deleteButton.href + } + } + + button( + classes = "rounded-full bg-gray-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600", + ) { + type = ButtonType.submit + +deleteButton.label + } + } + } + } } } } @@ -582,6 +627,7 @@ class BackfillShowAction @Inject constructor( const val START_STATE_BUTTON_LABEL = "Start" const val PAUSE_STATE_BUTTON_LABEL = "Pause" const val CANCEL_STATE_BUTTON_LABEL = "Cancel" + const val DELETE_STATE_BUTTON_LABEL = "Delete" const val UPDATE_BUTTON_LABEL = "Update" const val VIEW_LOGS_BUTTON_LABEL = "View Logs" } diff --git a/service/src/main/resources/migrations/v019__backfila.sql b/service/src/main/resources/migrations/v019__backfila.sql new file mode 100644 index 000000000..7b6bd1ada --- /dev/null +++ b/service/src/main/resources/migrations/v019__backfila.sql @@ -0,0 +1,2 @@ +ALTER TABLE backfill_runs + ADD COLUMN `deleted_at` timestamp(3) NULL DEFAULT NULL; \ No newline at end of file