Adjusted Collection

This commit is contained in:
Brage Skjønborg 2026-02-01 05:26:29 +01:00
parent 22627c387a
commit a13b949c9b
19 changed files with 229 additions and 77 deletions

View File

@ -6,17 +6,17 @@ import reactor.core.publisher.Mono
@Component @Component
class ProcesserClient( class ProcesserClient(
private val webClient: WebClient private val processerWebClient: WebClient
) { ) {
fun fetchLog(path: String): Mono<String> = fun fetchLog(path: String): Mono<String> =
webClient.get() processerWebClient.get()
.uri { it.path("/state/log").queryParam("path", path).build() } .uri { it.path("/state/log").queryParam("path", path).build() }
.retrieve() .retrieve()
.bodyToMono(String::class.java) .bodyToMono(String::class.java)
fun ping(): Mono<String> = fun ping(): Mono<String> =
webClient.get() processerWebClient.get()
.uri("/actuator/health") .uri("/actuator/health")
.retrieve() .retrieve()
.bodyToMono(String::class.java) .bodyToMono(String::class.java)

View File

@ -5,21 +5,8 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClient
@Configuration
class ProcesserWebClientConfig {
@Bean
fun processerWebClient(
builder: WebClient.Builder,
props: ProcesserClientProperties
): WebClient =
builder
.baseUrl(props.baseUrl)
.build()
}
@ConfigurationProperties(prefix = "processer") @ConfigurationProperties(prefix = "processer")
data class ProcesserClientProperties( data class ProcesserClientProperties(
val baseUrl: String val baseUrl: String
) )

View File

@ -0,0 +1,21 @@
package no.iktdev.mediaprocessing.coordinator.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.function.client.WebClient
@Configuration
class WebClients(
private val processerClientProperties: ProcesserClientProperties
) {
@Bean
fun webClient(): WebClient.Builder =
WebClient
.builder()
.codecs { it.defaultCodecs().maxInMemorySize(10 * 1024 * 1024) }
@Bean
fun processerWebClient(builder: WebClient.Builder): WebClient {
return builder.baseUrl(processerClientProperties.baseUrl).build()
}
}

View File

@ -0,0 +1,21 @@
package no.iktdev.mediaprocessing.coordinator.controller
import no.iktdev.mediaprocessing.coordinator.ProcesserClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
@RestController
@RequestMapping("/log")
class LogController(
private val processerClient: ProcesserClient
) {
@GetMapping
fun getLog(@RequestParam path: String): Mono<String> {
return processerClient.fetchLog(path)
}
}

View File

@ -26,20 +26,28 @@ class TaskController(
) { ) {
@GetMapping("/active") @GetMapping("/active")
fun getActiveTasks(): List<CoordinatorTaskTransferDto> = fun getActiveTasks(): List<CoordinatorTaskTransferDto> {
taskService.getActiveTasks().map { it.toCoordinatorTransferDto() } val tasks = taskService.getActiveTasks()
val logEvents = eventService.getTaskEventResultsWithLogs(tasks.map { it.referenceId }.toSet())
return tasks.map { it.toCoordinatorTransferDto(logEvents) }
}
@GetMapping @GetMapping
fun getPagedTasks(query: TaskQuery): Paginated<CoordinatorTaskTransferDto> { fun getPagedTasks(query: TaskQuery): Paginated<CoordinatorTaskTransferDto> {
val paginatedTasks = taskService.getPagedTasks(query) val paginatedTasks = taskService.getPagedTasks(query)
return paginatedTasks.map { it.toCoordinatorTransferDto() } val logEvents = eventService.getTaskEventResultsWithLogs(paginatedTasks.items.map { it.referenceId }.toSet())
return paginatedTasks.map { it.toCoordinatorTransferDto(logEvents) }
} }
@GetMapping("/{id}") @GetMapping("/{id}")
fun getTask(@PathVariable id: UUID): CoordinatorTaskTransferDto? = fun getTask(@PathVariable id: UUID): CoordinatorTaskTransferDto? {
taskService.getTaskById(id)?.toCoordinatorTransferDto() val tasks = taskService.getTaskById(id) ?: return null
val logEvents = eventService.getTaskEventResultsWithLogs(setOf(tasks.referenceId))
return tasks.toCoordinatorTransferDto(logEvents)
}
@GetMapping("/{taskId}/reset") @GetMapping("/{taskId}/reset")

View File

@ -0,0 +1,9 @@
package no.iktdev.mediaprocessing.coordinator.dto
import java.util.*
data class LogAssociatedIds(
val referenceId: UUID,
val ids: Set<UUID>,
val logFile: String
)

View File

@ -1,6 +1,7 @@
package no.iktdev.mediaprocessing.coordinator.dto.translate package no.iktdev.mediaprocessing.coordinator.dto.translate
import no.iktdev.eventi.models.store.PersistedTask import no.iktdev.eventi.models.store.PersistedTask
import no.iktdev.mediaprocessing.coordinator.dto.LogAssociatedIds
import no.iktdev.mediaprocessing.shared.common.rules.TaskLifecycleRules import no.iktdev.mediaprocessing.shared.common.rules.TaskLifecycleRules
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
@ -17,11 +18,16 @@ data class CoordinatorTaskTransferDto(
val consumed: Boolean, val consumed: Boolean,
val lastCheckIn: Instant?, val lastCheckIn: Instant?,
val persistedAt: Instant, val persistedAt: Instant,
val logs: List<String> = emptyList(),
val abandoned: Boolean, val abandoned: Boolean,
) { ) {
} }
fun PersistedTask.toCoordinatorTransferDto(): CoordinatorTaskTransferDto { fun PersistedTask.toCoordinatorTransferDto(logs: List<LogAssociatedIds>): CoordinatorTaskTransferDto {
val matchingLogs = logs
.filter { log -> log.ids.contains(taskId) }
.map { it.logFile }
return CoordinatorTaskTransferDto( return CoordinatorTaskTransferDto(
id = id, id = id,
referenceId = referenceId, referenceId = referenceId,
@ -34,6 +40,7 @@ fun PersistedTask.toCoordinatorTransferDto(): CoordinatorTaskTransferDto {
consumed = consumed, consumed = consumed,
lastCheckIn = lastCheckIn, lastCheckIn = lastCheckIn,
persistedAt = persistedAt, persistedAt = persistedAt,
logs = matchingLogs,
abandoned = TaskLifecycleRules.isAbandoned(consumed, persistedAt, lastCheckIn) abandoned = TaskLifecycleRules.isAbandoned(consumed, persistedAt, lastCheckIn)
) )
} }

View File

@ -8,35 +8,25 @@ import no.iktdev.mediaprocessing.shared.common.projection.CollectProjection
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@Component @Component
class CollectEventsListener: EventListener() { class CollectEventsListener : EventListener() {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
val undesiredStates = listOf(CollectProjection.TaskStatus.Failed, CollectProjection.TaskStatus.Pending) override fun onEvent(event: Event, history: List<Event>): Event? {
override fun onEvent( // Avoid double-collection
event: Event,
history: List<Event>
): Event? {
// Prevent Rouge trigger when replayed
if (event is CollectedEvent || history.any { it is CollectedEvent }) return null if (event is CollectedEvent || history.any { it is CollectedEvent }) return null
val collectProjection = CollectProjection(history) val projection = CollectProjection(history)
log.info { collectProjection.prettyPrint() }
val taskStatus = collectProjection.getTaskStatus() // Must have a StartProcessingEvent
if (taskStatus.all { it == CollectProjection.TaskStatus.NotInitiated }) { if (projection.startedWith == null) return null
// No work has been done, so we are not ready
return null // Must be allowed to store (Auto or Manual + AllowCompletion)
} if (!projection.isStorePermitted()) return null
val statusAcceptable = taskStatus.none { it in undesiredStates }
if (!statusAcceptable) { // Must have all relevant tasks completed
if (taskStatus.any { it == CollectProjection.TaskStatus.Failed }) { if (!projection.isWorkflowComplete()) return null
log.warn { "One or more tasks have failed in ${event.referenceId}" }
} else {
log.info { "One or more tasks are still pending in ${event.referenceId}" }
}
return null
}
return CollectedEvent(history.map { it.eventId }.toSet()).derivedOf(event) return CollectedEvent(history.map { it.eventId }.toSet()).derivedOf(event)
} }
} }

View File

@ -4,12 +4,9 @@ import no.iktdev.eventi.ListenerOrder
import no.iktdev.eventi.events.EventListener import no.iktdev.eventi.events.EventListener
import no.iktdev.eventi.models.Event import no.iktdev.eventi.models.Event
import no.iktdev.eventi.models.store.TaskStatus import no.iktdev.eventi.models.store.TaskStatus
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MediaParsedInfoEvent import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.*
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MetadataSearchResultEvent
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.MetadataSearchTaskCreatedEvent
import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.MetadataSearchTask import no.iktdev.mediaprocessing.shared.common.event_task_contract.tasks.MetadataSearchTask
import no.iktdev.mediaprocessing.shared.database.stores.TaskStore import no.iktdev.mediaprocessing.shared.database.stores.TaskStore
import org.jetbrains.annotations.VisibleForTesting import org.jetbrains.annotations.VisibleForTesting
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.util.* import java.util.*
@ -30,6 +27,13 @@ class MediaCreateMetadataSearchTaskListener: EventListener() {
event: Event, event: Event,
history: List<Event> history: List<Event>
): Event? { ): Event? {
val startedEvent = history.filterIsInstance<StartProcessingEvent>().firstOrNull() ?: return null
if (startedEvent.data.operation.isNotEmpty()) {
if (!startedEvent.data.operation.contains(OperationType.Metadata))
return null
}
// For replay // For replay
if (event is MetadataSearchTaskCreatedEvent) { if (event is MetadataSearchTaskCreatedEvent) {
val hasResult = history.filter { it is MetadataSearchResultEvent } val hasResult = history.filter { it is MetadataSearchResultEvent }

View File

@ -22,7 +22,8 @@ class StartedListener : EventListener() {
operation = setOf( operation = setOf(
OperationType.ExtractSubtitles, OperationType.ExtractSubtitles,
OperationType.ConvertSubtitles, OperationType.ConvertSubtitles,
OperationType.Encode OperationType.Encode,
OperationType.Metadata
) )
) )
) )

View File

@ -2,11 +2,14 @@ package no.iktdev.mediaprocessing.coordinator.services
import no.iktdev.eventi.ZDS.toEvent import no.iktdev.eventi.ZDS.toEvent
import no.iktdev.eventi.models.store.PersistedEvent import no.iktdev.eventi.models.store.PersistedEvent
import no.iktdev.mediaprocessing.coordinator.dto.LogAssociatedIds
import no.iktdev.mediaprocessing.shared.common.dto.EventQuery import no.iktdev.mediaprocessing.shared.common.dto.EventQuery
import no.iktdev.mediaprocessing.shared.common.dto.Paginated import no.iktdev.mediaprocessing.shared.common.dto.Paginated
import no.iktdev.mediaprocessing.shared.common.dto.SequenceEvent import no.iktdev.mediaprocessing.shared.common.dto.SequenceEvent
import no.iktdev.mediaprocessing.shared.common.dto.toDto import no.iktdev.mediaprocessing.shared.common.dto.toDto
import no.iktdev.mediaprocessing.shared.common.effectivePersisted import no.iktdev.mediaprocessing.shared.common.effectivePersisted
import no.iktdev.mediaprocessing.shared.common.event_task_contract.EventRegistry
import no.iktdev.mediaprocessing.shared.common.event_task_contract.TaskResultEvent
import no.iktdev.mediaprocessing.shared.database.stores.EventStore import no.iktdev.mediaprocessing.shared.database.stores.EventStore
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant import java.time.Instant
@ -77,4 +80,32 @@ class EventService {
return EventStore.getDeletedSequences(referenceIds) return EventStore.getDeletedSequences(referenceIds)
} }
val taskResultEventTypes: List<String> =
EventRegistry.getEvents()
.filter { TaskResultEvent::class.java.isAssignableFrom(it) }
.map { it.simpleName }
fun getTaskEventResultsWithLogs(referenceIds: Set<UUID>): List<LogAssociatedIds> {
// 1. Hent persisted events som matcher TaskResultEvent-typene
val persisted = EventStore.getPersistedEventsFor(referenceIds, taskResultEventTypes)
// 2. Deserialiser til domeneklasse
val domainEvents = persisted.map { it.toEvent() }
// 3. Filtrer til TaskResultEvent-instansene som har logg
return domainEvents
.filterIsInstance<TaskResultEvent>()
.filter { it.logFile != null }
.map {
LogAssociatedIds(
referenceId = it.referenceId,
ids = setOf( it.eventId, *(it.metadata.derivedFromId?.toTypedArray() ?: emptyArray())),
logFile = it.logFile!!
)
}
}
} }

View File

@ -46,7 +46,7 @@ open class TestBase {
fun defaultStartEvent(): StartProcessingEvent { fun defaultStartEvent(): StartProcessingEvent {
val start = StartProcessingEvent( val start = StartProcessingEvent(
data = StartData( data = StartData(
operation = setOf(OperationType.Encode, OperationType.ExtractSubtitles, OperationType.ConvertSubtitles), operation = setOf(OperationType.Encode, OperationType.ExtractSubtitles, OperationType.ConvertSubtitles, OperationType.Metadata),
fileUri = "file:///unit/${UUID.randomUUID()}.mkv" fileUri = "file:///unit/${UUID.randomUUID()}.mkv"
) )
) )

View File

@ -75,63 +75,89 @@ class CollectEventsListenerTest : TestBase() {
) )
fun success2() { fun success2() {
val started = defaultStartEvent().let { ev -> val started = defaultStartEvent().let { ev ->
ev.copy(data = ev.data.copy(operation = setOf(OperationType.Encode, OperationType.ExtractSubtitles))) ev.copy(
data = ev.data.copy(
operation = setOf(
OperationType.Metadata,
OperationType.Encode,
OperationType.ExtractSubtitles
)
)
)
} }
val parsed = mediaParsedEvent( val parsed = mediaParsedEvent(
collection = "MyCollection", collection = "MyCollection",
fileName = "MyCollection 1", fileName = "MyCollection 1",
mediaType = MediaType.Movie mediaType = MediaType.Movie
).derivedOf(started) ).derivedOf(started)
val metadata = metadataEvent(parsed).first()
val encode = encodeEvent("/tmp/video.mp4", parsed) val encode = encodeEvent("/tmp/video.mp4", parsed)
val history = listOf( val history = listOf(
started, started,
parsed, parsed,
metadata,
*encode.toTypedArray(), *encode.toTypedArray(),
) )
val result = listener.onEvent(history.last(), history) val result = listener.onEvent(history.last(), history)
assertThat(result).isNotNull()
assertThat { assertThat(result).isNull()
result is CollectedEvent
}
} }
@Test @Test
@DisplayName( @DisplayName(
""" """
Hvis vi har kun convert hendelse Hvis vi har kun convert hendelse
Når convert har komment inn Når convert har kommet inn
: :
Opprettes CollectEvent basert historikken Opprettes CollectEvent basert historikken
""" """
) )
fun success3() { fun success3() {
val started = defaultStartEvent().let { ev -> val started = defaultStartEvent().let { ev ->
ev.copy(data = ev.data.copy(operation = setOf(OperationType.ConvertSubtitles))) ev.copy(
data = ev.data.copy(
operation = setOf(
OperationType.Metadata,
OperationType.ConvertSubtitles
)
)
)
} }
val parsed = mediaParsedEvent( val parsed = mediaParsedEvent(
collection = "MyCollection", collection = "MyCollection",
fileName = "MyCollection 1", fileName = "MyCollection 1",
mediaType = MediaType.Movie mediaType = MediaType.Movie
).derivedOf(started) ).derivedOf(started)
val convert = encodeEvent("/tmp/fancy.srt", parsed) val metadata = metadataEvent(parsed)
val convert = convertEvent(
language = "en",
baseName = "sub1",
outputFiles = listOf("/tmp/sub1.vtt"),
derivedFrom = parsed
)
val history = listOf( val history = listOf(
started, started,
parsed, parsed,
*metadata.toTypedArray(),
*convert.toTypedArray(), *convert.toTypedArray(),
) )
val result = listener.onEvent(history.last(), history) val result = listener.onEvent(history.last(), history)
assertThat(result).isNotNull()
assertThat { assertThat(result).isInstanceOf(CollectedEvent::class.java)
result is CollectedEvent
}
} }
@Test @Test
@DisplayName( @DisplayName(
""" """
@ -200,6 +226,8 @@ class CollectEventsListenerTest : TestBase() {
assertThat(result).isNull() assertThat(result).isNull()
} }
@Test @Test
@DisplayName( @DisplayName(
""" """

View File

@ -8,5 +8,6 @@ import no.iktdev.eventi.models.store.TaskStatus
*/ */
open class TaskResultEvent( open class TaskResultEvent(
val status: TaskStatus, val status: TaskStatus,
val error: String? = null val error: String? = null,
val logFile: String? = null
) : Event() ) : Event()

View File

@ -5,10 +5,10 @@ import no.iktdev.mediaprocessing.shared.common.event_task_contract.TaskResultEve
class ProcesserEncodeResultEvent( class ProcesserEncodeResultEvent(
val data: EncodeResult? = null, val data: EncodeResult? = null,
val logFile: String? = null, logFile: String? = null,
status: TaskStatus, status: TaskStatus,
error: String? = null error: String? = null
) : TaskResultEvent(status, error) { ) : TaskResultEvent(status, error, logFile) {
data class EncodeResult( data class EncodeResult(
val cachedOutputFile: String? = null val cachedOutputFile: String? = null
) )

View File

@ -6,8 +6,9 @@ import no.iktdev.mediaprocessing.shared.common.event_task_contract.TaskResultEve
class ProcesserExtractResultEvent( class ProcesserExtractResultEvent(
val data: ExtractResult? = null, val data: ExtractResult? = null,
status: TaskStatus, status: TaskStatus,
error: String? = null error: String? = null,
) : TaskResultEvent(status, error) { logFile: String? = null,
) : TaskResultEvent(status, error, logFile) {
data class ExtractResult( data class ExtractResult(
val language: String, val language: String,
val cachedOutputFile: String val cachedOutputFile: String

View File

@ -22,5 +22,6 @@ enum class StartFlow {
enum class OperationType { enum class OperationType {
ExtractSubtitles, ExtractSubtitles,
Encode, Encode,
ConvertSubtitles ConvertSubtitles,
Metadata
} }

View File

@ -49,6 +49,37 @@ class CollectProjection(val events: List<Event>) {
coverDownloadTaskStatus coverDownloadTaskStatus
) )
fun getRelevantTaskStatuses(): List<TaskStatus> {
val required = startedWith?.tasks ?: emptySet()
val statusMap = mapOf(
OperationType.Encode to encodeTaskStatus,
OperationType.ExtractSubtitles to extreactTaskStatus,
OperationType.ConvertSubtitles to convertTaskStatus,
OperationType.Metadata to metadataTaskStatus,
)
return required.map { statusMap[it] ?: TaskStatus.NotInitiated }
}
fun isWorkflowComplete(): Boolean {
val statuses = getRelevantTaskStatuses()
if (statuses.isEmpty()) return false
val anyFailed = statuses.any { it == TaskStatus.Failed }
val anyPending = statuses.any { it == TaskStatus.Pending }
val allCompleted = statuses.all { it == TaskStatus.Completed }
if (anyFailed) return false
if (anyPending) return false
return allCompleted
}
fun isStorePermitted(): Boolean { fun isStorePermitted(): Boolean {
val start = events.filterIsInstance<StartProcessingEvent>().firstOrNull() val start = events.filterIsInstance<StartProcessingEvent>().firstOrNull()
?: return false // ingen start → ingen store ?: return false // ingen start → ingen store

View File

@ -88,6 +88,17 @@ object EventStore: EventStore {
return result.getOrDefault(emptyList()) return result.getOrDefault(emptyList())
} }
fun getPersistedEventsFor(referenceId: Set<UUID>, eventNames: List<String>): List<PersistedEvent> {
val deleted = getDeletedSequences(referenceId).map { it.toString() }
val result = withTransaction {
EventsTable
.getWhere { (EventsTable.referenceId eq referenceId.toString()) and
(EventsTable.referenceId notInList deleted.toList()) and
(EventsTable.event inList eventNames )}
}
return result.getOrDefault(emptyList())
}
override fun persist(event: Event) { override fun persist(event: Event) {
val asData = ZDS.WGson.toJson(event) val asData = ZDS.WGson.toJson(event)
val eventName = event::class.simpleName ?: run { val eventName = event::class.simpleName ?: run {