Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ class BackfilaWebActionsModule() : KAbstractModule() {
install(WebActionModule.create<GetBackfillStatusAction>())
install(WebActionModule.create<UpdateBackfillAction>())
install(WebActionModule.create<ViewLogsAction>())
install(WebActionModule.create<EditPartitionCursorAction>())
install(WebActionModule.create<EditPartitionCursorHandlerAction>())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package app.cash.backfila.dashboard

import app.cash.backfila.ui.components.DashboardPageLayout
import app.cash.backfila.ui.pages.BackfillShowAction
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.html.ButtonType
import kotlinx.html.FormMethod
import kotlinx.html.InputType
import kotlinx.html.a
import kotlinx.html.button
import kotlinx.html.div
import kotlinx.html.form
import kotlinx.html.h1
import kotlinx.html.input
import kotlinx.html.label
import kotlinx.html.p
import misk.security.authz.Authenticated
import misk.tailwind.Link
import misk.web.Get
import misk.web.PathParam
import misk.web.Response
import misk.web.ResponseBody
import misk.web.ResponseContentType
import misk.web.actions.WebAction
import misk.web.mediatype.MediaTypes

@Singleton
class EditPartitionCursorAction @Inject constructor(
private val getBackfillStatusAction: GetBackfillStatusAction,
private val dashboardPageLayout: DashboardPageLayout,
) : WebAction {

@Get(PATH)
@ResponseContentType(MediaTypes.TEXT_HTML)
@Authenticated(capabilities = ["users"])
fun get(
@PathParam id: Long,
@PathParam partitionName: String,
): Response<ResponseBody> {
val backfill = getBackfillStatusAction.status(id)

val partition = backfill.partitions.find { it.name == partitionName }
?: throw IllegalArgumentException("Partition not found")

// Take a snapshot of current cursor for validation
val cursorSnapshot = partition.pkey_cursor

return Response(
dashboardPageLayout.newBuilder()
.title("Edit Cursor - Partition $partitionName")
.breadcrumbLinks(
Link("Backfill #$id", BackfillShowAction.path(id)),
Link("Edit Cursor", path(id, partitionName)),
)
.buildHtmlResponseBody {
div("space-y-6 max-w-2xl mx-auto py-8") {
h1("text-xl font-semibold") {
+"Edit Cursor for Partition: $partitionName"
}

div("rounded-md bg-yellow-50 p-4 mb-6") {
div("flex") {
div("flex-shrink-0") {
// Warning icon
div("h-5 w-5 text-yellow-400") {
+"⚠️"
}
}
div("ml-3") {
h1("text-sm font-medium text-yellow-800") {
+"Warning: Editing cursors can be dangerous"
}
div("mt-2 text-sm text-yellow-700") {
p {
+"Make sure you understand the implications of changing the cursor position. Records between the old and new cursor positions may be skipped or processed multiple times."
}
}
}
}
}

form {
method = FormMethod.get
action = EditPartitionCursorHandlerAction.path(id, partitionName)

input {
type = InputType.hidden
name = "cursor_snapshot"
value = cursorSnapshot ?: ""
}

div("space-y-4") {
div {
label("block text-sm font-medium text-gray-700") {
htmlFor = "current_cursor"
+"Current Cursor"
}
div("mt-1") {
input(classes = "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6") {
type = InputType.text
attributes["id"] = "current_cursor"
value = cursorSnapshot ?: "Not started"
disabled = true
}
}
}

div {
label("block text-sm font-medium text-gray-700") {
htmlFor = "new_cursor"
+"New Cursor"
}
div("mt-1") {
input(classes = "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6") {
type = InputType.text
name = "new_cursor"
attributes["id"] = "new_cursor"
value = cursorSnapshot ?: ""
required = true
}
}
p("mt-2 text-sm text-gray-500") {
+"Enter the new cursor value. This must be a valid UTF-8 string."
}
}

div("flex justify-end gap-3") {
a(href = BackfillShowAction.path(id), classes = "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50") {
+"Cancel"
}
button(classes = "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600") {
type = ButtonType.submit
+"Update Cursor"
}
}
}
}
}
},
)
}

companion object {
private const val PATH = "/backfills/{id}/{partitions}/{partitionName}/edit-cursor"
fun path(id: Long, partitionName: String) = PATH
.replace("{id}", id.toString())
.replace("{partitionName}", partitionName)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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.RunPartitionQuery
import app.cash.backfila.ui.pages.BackfillShowAction
import javax.inject.Inject
import javax.inject.Singleton
import misk.exceptions.BadRequestException
import misk.hibernate.Query
import misk.hibernate.Transacter
import misk.hibernate.newQuery
import misk.scope.ActionScoped
import misk.security.authz.Authenticated
import misk.web.Get
import misk.web.HttpCall
import misk.web.PathParam
import misk.web.Response
import misk.web.ResponseBody
import misk.web.ResponseContentType
import misk.web.actions.WebAction
import misk.web.mediatype.MediaTypes
import misk.web.toResponseBody
import okhttp3.Headers
import okio.ByteString.Companion.encodeUtf8

@Singleton
class EditPartitionCursorHandlerAction @Inject constructor(
private val getBackfillStatusAction: GetBackfillStatusAction,
@BackfilaDb private val transacter: Transacter,
private val queryFactory: Query.Factory,
private val httpCall: ActionScoped<HttpCall>,
) : WebAction {

@Get(PATH)
@ResponseContentType(MediaTypes.TEXT_HTML)
@Authenticated(capabilities = ["users"])
fun get(
@PathParam id: Long,
@PathParam partitionName: String,
): Response<ResponseBody> {
val request = httpCall.get().asOkHttpRequest()
val cursorSnapshot = request.url.queryParameter("cursor_snapshot")?.takeIf { it.isNotBlank() }
val newCursor = request.url.queryParameter("new_cursor")

// Validate UTF-8
try {
newCursor?.toByteArray(Charsets.UTF_8)?.toString(Charsets.UTF_8)
} catch (e: Exception) {
throw BadRequestException("New cursor must be valid UTF-8")
}

// Verify backfill state and cursor hasn't changed
val backfill = getBackfillStatusAction.status(id)
if (backfill.state != BackfillState.PAUSED) {
throw BadRequestException("Backfill must be paused to edit cursors")
}

val partition = backfill.partitions.find { it.name == partitionName }
?: throw BadRequestException("Partition not found")

if (partition.pkey_cursor != cursorSnapshot) {
throw BadRequestException("Cursor has changed since edit form was loaded")
}

// Update the cursor
transacter.transaction { session ->
queryFactory.newQuery<RunPartitionQuery>()
.partitionId(partition.id)
.uniqueResult(session)
?.let { partitionRecord ->
partitionRecord.pkey_cursor = newCursor?.encodeUtf8()
session.save(partitionRecord)
} ?: throw BadRequestException("Partition not found")
}

// Redirect to backfill page
return Response(
body = "go to ${BackfillShowAction.path(id)}".toResponseBody(),
statusCode = 303,
headers = Headers.headersOf("Location", BackfillShowAction.path(id)),
)
}

companion object {
private const val PATH = "/backfills/{id}/{partitionName}/edit-cursor"
fun path(id: Long, partitionName: String) = PATH
.replace("{id}", id.toString())
.replace("{partitionName}", partitionName)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ interface RunPartitionQuery : Query<DbRunPartition> {

@Order("partition_name")
fun orderByName(): RunPartitionQuery

@Constraint("id", Operator.EQ)
fun partitionId(partitionId: Long): RunPartitionQuery
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.cash.backfila.ui.pages

import app.cash.backfila.dashboard.EditPartitionCursorAction
import app.cash.backfila.dashboard.GetBackfillStatusAction
import app.cash.backfila.dashboard.GetBackfillStatusResponse
import app.cash.backfila.dashboard.ViewLogsAction
Expand Down Expand Up @@ -161,6 +162,12 @@ class BackfillShowAction @Inject constructor(
scope = ThScope.col
+"""ETA"""
}
if (backfill.state == BackfillState.PAUSED) {
th(classes = "py-3 pl-8 pr-0 text-right font-semibold") {
scope = ThScope.col
+"""Actions"""
}
}
}
}
tbody {
Expand Down Expand Up @@ -202,6 +209,16 @@ class BackfillShowAction @Inject constructor(
}
}
}
if (backfill.state == BackfillState.PAUSED) {
td("py-5 pl-8 pr-0 text-right align-top") {
a(
href = EditPartitionCursorAction.path(id, partition.name),
classes = "text-indigo-600 hover:text-indigo-900",
) {
+"Edit Cursor"
}
}
}
}
}
}
Expand Down
Loading