From c977fd14789d1adc2b9f7375270807eb8b86bc8f Mon Sep 17 00:00:00 2001 From: Victor Smirnov <53015676+vityaman@users.noreply.github.com> Date: Sat, 15 Jun 2024 19:44:16 +0300 Subject: [PATCH] #142 Publish workspace event to telegram (#148) --- ...t => WorkspaceEventDraftMappingToModel.kt} | 0 .../message/WorkspaceEventMappingToModel.kt | 59 ++++++ .../{KafkaConfig.kt => KafkaTopicConfig.kt} | 27 ++- .../spring/event/SpringPublicationTasks.kt | 22 +++ .../app/spring/event/SpringTelegramActors.kt | 20 +++ .../app/spring/event/SpringTelegramConfig.kt | 27 +++ .../event/SpringWorkspaceEventConfig.kt | 168 ++++++++++++++++++ .../event/homework/SpringEventSource.kt | 6 +- .../event/homework/SpringKafkaConsumer.kt | 4 +- .../event/homework/SpringPublicationTask.kt | 2 +- .../event/homework/SpringTelegramActor.kt | 27 +-- .../spring/logic/SpringWorkspaceService.kt | 18 +- .../core/event/EventPublicationTask.kt | 6 +- .../logic/notify/NotifyWorkspaceService.kt | 24 +++ .../lms/botalka/core/model/Workspace.kt | 4 + .../storage/jooq/entity/WorkspaceMapping.kt | 2 + .../storage/jooq/event/WorkspaceEvents.kt | 73 ++++++++ botalka/src/main/resources/application.yml | 9 +- .../src/main/resources/database/changelog.sql | 7 + .../api/http/endpoint/WorkspaceApiTest.kt | 5 +- .../app/spring/logic/PublicationTest.kt | 6 + .../app/spring/storage/BrokerContainer.kt | 18 +- infra/fuzzing/custom/homeworks.sh | 2 +- infra/kafka/setup.sh | 3 +- 24 files changed, 477 insertions(+), 62 deletions(-) rename botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/{WorkspaceMappingToModel.kt => WorkspaceEventDraftMappingToModel.kt} (100%) create mode 100644 botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceEventMappingToModel.kt rename botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/{KafkaConfig.kt => KafkaTopicConfig.kt} (59%) create mode 100644 botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringPublicationTasks.kt create mode 100644 botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramActors.kt create mode 100644 botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramConfig.kt create mode 100644 botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringWorkspaceEventConfig.kt create mode 100644 botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/logic/notify/NotifyWorkspaceService.kt create mode 100644 botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/event/WorkspaceEvents.kt diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceMappingToModel.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceEventDraftMappingToModel.kt similarity index 100% rename from botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceMappingToModel.kt rename to botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceEventDraftMappingToModel.kt diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceEventMappingToModel.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceEventMappingToModel.kt new file mode 100644 index 0000000..e792a4d --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/message/WorkspaceEventMappingToModel.kt @@ -0,0 +1,59 @@ +package ru.vityaman.lms.botalka.app.spring.api.http.message + +import ru.vityaman.lms.botalka.app.spring.api.http.server.WorkspaceCommentMessage +import ru.vityaman.lms.botalka.app.spring.api.http.server.WorkspaceEventMessage +import ru.vityaman.lms.botalka.app.spring.api.http.server.WorkspaceFeedbackMessage +import ru.vityaman.lms.botalka.app.spring.api.http.server.WorkspaceSubmissionMessage +import ru.vityaman.lms.botalka.core.model.Homework +import ru.vityaman.lms.botalka.core.model.Teacher +import ru.vityaman.lms.botalka.core.model.User +import ru.vityaman.lms.botalka.core.model.Workspace + +fun WorkspaceEventMessage.toModel() = + when (this) { + is WorkspaceCommentMessage -> { + this.toModel() + } + + is WorkspaceSubmissionMessage -> { + this.toModel() + } + + is WorkspaceFeedbackMessage -> { + this.toModel() + } + + else -> { + throw NotImplementedError( + buildString { + append("WorkspaceEvent type '${this@toModel.kind}' ") + append("is not yet supported") + }, + ) + } + } + +fun WorkspaceCommentMessage.toModel() = + Workspace.Comment( + id = Workspace.Event.Id(this.id), + producer = User.Id(this.producerId), + text = this.text, + creationMoment = this.creationMoment, + ) + +fun WorkspaceSubmissionMessage.toModel() = + Workspace.Submission( + id = Workspace.Event.Id(this.id), + producer = User.Id(this.producerId), + note = this.note, + creationMoment = this.creationMoment, + ) + +fun WorkspaceFeedbackMessage.toModel() = + Workspace.Feedback( + id = Workspace.Event.Id(this.id), + teacher = Teacher(User.Id(this.producerId)), + comment = this.comment, + score = this.score?.let { Homework.Score(it.toShort()) }, + creationMoment = this.creationMoment, + ) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/KafkaConfig.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/KafkaTopicConfig.kt similarity index 59% rename from botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/KafkaConfig.kt rename to botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/KafkaTopicConfig.kt index 3f46a97..9124752 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/KafkaConfig.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/KafkaTopicConfig.kt @@ -12,23 +12,22 @@ import ru.vityaman.lms.botalka.core.model.Homework import ru.vityaman.lms.botalka.storage.kafka.KafkaTopic @Configuration -class KafkaConfig(private val jackson: ObjectMapper) { +class KafkaTopicConfig(private val jackson: ObjectMapper) { @Qualifier(BeanName.HOMEWORK_TOPIC) @Bean fun publicationTopic( - @Value("\${broker.topic.publication}") topic: String, - ): KafkaTopic = - KafkaTopic( - name = topic, - keyEncode = { it.number.toString() }, - keyDecode = { Homework.Id(it.toInt()) }, - valueEncode = { jackson.writeValueAsString(it.toMessage()) }, - valueDecode = { - jackson - .readValue(it, HomeworkMessage::class.java) - .toModel() - }, - ) + @Value("\${broker.topic.homework}") topic: String, + ) = KafkaTopic( + name = topic, + keyEncode = { it.number.toString() }, + keyDecode = { Homework.Id(it.toInt()) }, + valueEncode = { jackson.writeValueAsString(it.toMessage()) }, + valueDecode = { + jackson + .readValue(it, HomeworkMessage::class.java) + .toModel() + }, + ) object BeanName { const val HOMEWORK_TOPIC = "homeworkTopic" diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringPublicationTasks.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringPublicationTasks.kt new file mode 100644 index 0000000..cdb1d36 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringPublicationTasks.kt @@ -0,0 +1,22 @@ +package ru.vityaman.lms.botalka.app.spring.event + +import kotlinx.coroutines.runBlocking +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import ru.vityaman.lms.botalka.commons.Runnable +import java.util.concurrent.TimeUnit + +@Component +class SpringPublicationTasks( + @Qualifier(SpringWorkspaceEventConfig.BeanName.PUBLICATION_TASK) + private val workspacePublication: Runnable, +) { + @Scheduled( + fixedRateString = "\${task.scheduled.publication.precision-seconds}", + initialDelayString = "\${task.scheduled.publication.precision-seconds}", + timeUnit = TimeUnit.SECONDS, + ) + fun doWorkspacePublication(): Unit = + runBlocking { workspacePublication.run() } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramActors.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramActors.kt new file mode 100644 index 0000000..5c2dfd7 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramActors.kt @@ -0,0 +1,20 @@ +package ru.vityaman.lms.botalka.app.spring.event + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import ru.vityaman.lms.botalka.commons.Runnable + +@Component +data class SpringTelegramActors( + @Qualifier(SpringWorkspaceEventConfig.BeanName.EVENT_CONSUMING_ACTOR) + private val workspaceActor: Runnable, +) { + private val scope = CoroutineScope(Dispatchers.Default) + + init { + scope.launch { workspaceActor.run() } + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramConfig.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramConfig.kt new file mode 100644 index 0000000..1e937e4 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringTelegramConfig.kt @@ -0,0 +1,27 @@ +package ru.vityaman.lms.botalka.app.spring.event + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import ru.vityaman.lms.botalka.core.event.aspect.SlownessConfig + +@Component +data class SpringTelegramConfig( + @Value("\${external.service.telegram.duration-seconds.retry.min}") + val retryDurationSecondsMin: Int, + + @Value("\${external.service.telegram.duration-seconds.retry.max}") + val retryDurationSecondsMax: Int, + + @Value("\${external.service.telegram.duration-seconds.relax.min}") + val relaxDurationSecondsMin: Int, + + @Value("\${external.service.telegram.duration-seconds.relax.max}") + val relaxDurationSecondsMax: Int, +) { + fun toSlowness() = SlownessConfig( + retryDurationSecondsMin = retryDurationSecondsMin, + retryDurationSecondsMax = retryDurationSecondsMax, + relaxDurationSecondsMin = relaxDurationSecondsMin, + relaxDurationSecondsMax = relaxDurationSecondsMax, + ) +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringWorkspaceEventConfig.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringWorkspaceEventConfig.kt new file mode 100644 index 0000000..a08bf0e --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/SpringWorkspaceEventConfig.kt @@ -0,0 +1,168 @@ +package ru.vityaman.lms.botalka.app.spring.event + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import ru.vityaman.lms.botalka.app.spring.api.http.message.toMessage +import ru.vityaman.lms.botalka.app.spring.api.http.message.toModel +import ru.vityaman.lms.botalka.app.spring.api.http.server.WorkspaceEventMessage +import ru.vityaman.lms.botalka.app.spring.storage.MainR2dbcConfig +import ru.vityaman.lms.botalka.commons.Consumer +import ru.vityaman.lms.botalka.commons.Runnable +import ru.vityaman.lms.botalka.core.event.EventConsumingActor +import ru.vityaman.lms.botalka.core.event.EventPublicationTask +import ru.vityaman.lms.botalka.core.event.EventSource +import ru.vityaman.lms.botalka.core.event.aspect.sequenced +import ru.vityaman.lms.botalka.core.event.aspect.slowed +import ru.vityaman.lms.botalka.core.event.loggingEventCallbacks +import ru.vityaman.lms.botalka.core.external.telegram.TelegramBot +import ru.vityaman.lms.botalka.core.external.telegram.TelegramChat +import ru.vityaman.lms.botalka.core.external.telegram.TelegramConsumer +import ru.vityaman.lms.botalka.core.logging.Slf4jLog +import ru.vityaman.lms.botalka.core.model.Workspace +import ru.vityaman.lms.botalka.core.tx.TxEnv +import ru.vityaman.lms.botalka.storage.jooq.JooqDatabase +import ru.vityaman.lms.botalka.storage.jooq.event.WorkspaceEvents +import ru.vityaman.lms.botalka.storage.kafka.BasicKafkaConsumer +import ru.vityaman.lms.botalka.storage.kafka.BasicKafkaProducer +import ru.vityaman.lms.botalka.storage.kafka.KafkaTopic + +@Configuration +class SpringWorkspaceEventConfig { + @Bean + @Qualifier(BeanName.WORKSPACE_TOPIC) + fun workspaceTopic( + @Value("\${broker.topic.workspace}") topic: String, + jackson: ObjectMapper, + ) = KafkaTopic( + name = topic, + keyEncode = { it.number.toString() }, + keyDecode = { Workspace.Event.Id(it.toInt()) }, + valueEncode = { jackson.writeValueAsString(it.toMessage()) }, + valueDecode = { + jackson + .readValue(it, WorkspaceEventMessage::class.java) + .toModel() + }, + ) + + @Bean + @Qualifier(BeanName.KAFKA_EVENT_SOURCE) + fun eventSource( + @Value("\${broker.bootstrap-servers}") + bootstrapServers: String, + @Value("\${broker.consumer.workspace.group}") + groupId: String, + @Qualifier(BeanName.WORKSPACE_TOPIC) + topic: KafkaTopic, + ) = BasicKafkaConsumer( + BasicKafkaConsumer.Config( + bootstrapServers = bootstrapServers, + groupId = groupId, + ), + topic, + ) + + @Bean + @Qualifier(BeanName.KAFKA_CONSUMER) + fun consumer( + @Value("\${broker.bootstrap-servers}") + bootstrapServers: String, + @Qualifier(BeanName.WORKSPACE_TOPIC) + topic: KafkaTopic, + ) = BasicKafkaProducer(BasicKafkaProducer.Config(bootstrapServers), topic) + .asConsumerWithKey(Workspace.Event::id) + + @Bean + @Qualifier(BeanName.EVENTS) + fun events( + @Qualifier(MainR2dbcConfig.BeanName.TX_ENV) + mainTx: TxEnv, + @Qualifier(MainR2dbcConfig.BeanName.DATABASE) + database: JooqDatabase, + ) = WorkspaceEvents(database, mainTx) + + @Bean + @Qualifier(BeanName.PUBLICATION_TASK) + fun publicationTask( + @Qualifier(BeanName.EVENTS) + events: WorkspaceEvents, + @Qualifier(BeanName.KAFKA_CONSUMER) + consumer: Consumer, + ) = EventPublicationTask( + consumer = consumer, + events = events, + callbacks = loggingEventCallbacks( + Slf4jLog("WorkspaceNotifyPushTask"), + ), + ) + + @Bean + @Qualifier(BeanName.TELEGRAM_CONSUMER) + fun telegramConsumer( + telegram: TelegramBot, + @Value("\${external.service.telegram.admin-chat-id}") + adminChatId: Long, + ) = TelegramConsumer( + telegram, + TelegramChat(adminChatId), + ) { + buildString { + append("New workspace event!\n") + append("From user with id ${it.producer}\n") + append("EventId: ${it.id}\n") + append("Kind: ") + append( + when (it) { + is Workspace.Feedback -> "Feedback" + is Workspace.Submission -> "Submission" + is Workspace.Comment -> "Comment" + }, + ) + append("\n") + append( + when (it) { + is Workspace.Feedback -> it.comment + is Workspace.Submission -> it.note + is Workspace.Comment -> it.text + }, + ) + } + } + + @Bean + @Qualifier(BeanName.EVENT_CONSUMING_ACTOR) + fun eventConsumingActor( + @Qualifier(BeanName.KAFKA_EVENT_SOURCE) + mailbox: EventSource, + @Qualifier(BeanName.TELEGRAM_CONSUMER) + consumer: Consumer, + config: SpringTelegramConfig, + ): Runnable { + val log = Slf4jLog("TelegramWorkspaceEventActor") + return EventConsumingActor( + mailbox = mailbox, + consumer = consumer, + callbacks = sequenced( + EventConsumingActor.Callbacks( + onStart = { log.info("Starting...") }, + onSuccess = { log.info("Sent event with id ${it.id}") }, + onError = { log.info("Failed: ${it.message}") }, + ), + slowed(config.toSlowness()), + ), + ) + } + + object BeanName { + const val WORKSPACE_TOPIC = "workspaceTopic" + const val KAFKA_EVENT_SOURCE = "kafkaWorkspaceEventSource" + const val KAFKA_CONSUMER = "kafkaWorkspaceConsumer" + const val EVENTS = "workspaceEvents" + const val PUBLICATION_TASK = "workspacePublicationTask" + const val TELEGRAM_CONSUMER = "telegramWorkspaceConsumer" + const val EVENT_CONSUMING_ACTOR = "workspaceEventConsumingActor" + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringEventSource.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringEventSource.kt index 750e9e3..a7de8fc 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringEventSource.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringEventSource.kt @@ -3,7 +3,7 @@ package ru.vityaman.lms.botalka.app.spring.event.homework import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component -import ru.vityaman.lms.botalka.app.spring.event.KafkaConfig +import ru.vityaman.lms.botalka.app.spring.event.KafkaTopicConfig import ru.vityaman.lms.botalka.core.event.EventSource import ru.vityaman.lms.botalka.core.model.Homework import ru.vityaman.lms.botalka.storage.kafka.BasicKafkaConsumer @@ -15,10 +15,10 @@ class SpringEventSource( @Value("\${broker.bootstrap-servers}") bootstrapServers: String, - @Value("\${broker.consumer.publication.group}") + @Value("\${broker.consumer.homework.group}") groupId: String, - @Qualifier(KafkaConfig.BeanName.HOMEWORK_TOPIC) + @Qualifier(KafkaTopicConfig.BeanName.HOMEWORK_TOPIC) topic: KafkaTopic, ) : EventSource by BasicKafkaConsumer( diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringKafkaConsumer.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringKafkaConsumer.kt index ecca7a1..992edd7 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringKafkaConsumer.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringKafkaConsumer.kt @@ -3,7 +3,7 @@ package ru.vityaman.lms.botalka.app.spring.event.homework import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component -import ru.vityaman.lms.botalka.app.spring.event.KafkaConfig +import ru.vityaman.lms.botalka.app.spring.event.KafkaTopicConfig import ru.vityaman.lms.botalka.commons.Consumer import ru.vityaman.lms.botalka.core.model.Homework import ru.vityaman.lms.botalka.storage.kafka.BasicKafkaProducer @@ -15,7 +15,7 @@ class SpringKafkaConsumer( @Value("\${broker.bootstrap-servers}") bootstrapServers: String, - @Qualifier(KafkaConfig.BeanName.HOMEWORK_TOPIC) + @Qualifier(KafkaTopicConfig.BeanName.HOMEWORK_TOPIC) topic: KafkaTopic, ) : Consumer by BasicKafkaProducer(BasicKafkaProducer.Config(bootstrapServers), topic) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringPublicationTask.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringPublicationTask.kt index a329eb0..db5a416 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringPublicationTask.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringPublicationTask.kt @@ -30,7 +30,7 @@ class SpringPublicationTask( ) { private val logic = EventPublicationTask( consumer = consumer, - notifications = HomeworkEvents(homeworks, mainTx, clock), + events = HomeworkEvents(homeworks, mainTx, clock), callbacks = loggingEventCallbacks( Slf4jLog("HomeworkNotifyPushTask"), ), diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringTelegramActor.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringTelegramActor.kt index a30ca75..fb176eb 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringTelegramActor.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/event/homework/SpringTelegramActor.kt @@ -4,32 +4,16 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +import ru.vityaman.lms.botalka.app.spring.event.SpringTelegramConfig import ru.vityaman.lms.botalka.commons.Consumer import ru.vityaman.lms.botalka.core.event.EventConsumingActor import ru.vityaman.lms.botalka.core.event.EventSource -import ru.vityaman.lms.botalka.core.event.aspect.SlownessConfig import ru.vityaman.lms.botalka.core.event.aspect.sequenced import ru.vityaman.lms.botalka.core.event.aspect.slowed import ru.vityaman.lms.botalka.core.logging.Slf4jLog import ru.vityaman.lms.botalka.core.model.Homework -@Component -data class SpringTelegramConfig( - @Value("\${external.service.telegram.duration-seconds.retry.min}") - val retryDurationSecondsMin: Int, - - @Value("\${external.service.telegram.duration-seconds.retry.max}") - val retryDurationSecondsMax: Int, - - @Value("\${external.service.telegram.duration-seconds.relax.min}") - val relaxDurationSecondsMin: Int, - - @Value("\${external.service.telegram.duration-seconds.relax.max}") - val relaxDurationSecondsMax: Int, -) - @Component class SpringTelegramActor( @Qualifier(SpringConfig.BeanName.KAFKA_EVENT_SOURCE) @@ -51,14 +35,7 @@ class SpringTelegramActor( onSuccess = { log.info("Sent homework with id ${it.id}") }, onError = { log.info("Failed: ${it.message}") }, ), - slowed( - SlownessConfig( - retryDurationSecondsMin = config.retryDurationSecondsMin, - retryDurationSecondsMax = config.retryDurationSecondsMax, - relaxDurationSecondsMin = config.relaxDurationSecondsMin, - relaxDurationSecondsMax = config.relaxDurationSecondsMax, - ), - ), + slowed(config.toSlowness()), ), ) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/logic/SpringWorkspaceService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/logic/SpringWorkspaceService.kt index a55a5c7..c621b84 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/logic/SpringWorkspaceService.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/logic/SpringWorkspaceService.kt @@ -1,12 +1,18 @@ package ru.vityaman.lms.botalka.app.spring.logic +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.app.spring.event.SpringWorkspaceEventConfig.BeanName +import ru.vityaman.lms.botalka.commons.Consumer import ru.vityaman.lms.botalka.core.logging.Slf4jLog import ru.vityaman.lms.botalka.core.logic.HomeworkService import ru.vityaman.lms.botalka.core.logic.WorkspaceService import ru.vityaman.lms.botalka.core.logic.basic.BasicWorkspaceService import ru.vityaman.lms.botalka.core.logic.logging.LoggingWorkspaceService +import ru.vityaman.lms.botalka.core.logic.notify.NotifyWorkspaceService +import ru.vityaman.lms.botalka.core.model.Workspace import ru.vityaman.lms.botalka.core.storage.WorkspaceStorage +import ru.vityaman.lms.botalka.storage.jooq.event.WorkspaceEvents import java.time.Clock @Service @@ -14,8 +20,18 @@ class SpringWorkspaceService( storage: WorkspaceStorage, homeworks: HomeworkService, clock: Clock, + + @Qualifier(BeanName.KAFKA_CONSUMER) + consumer: Consumer, + + @Qualifier(BeanName.EVENTS) + events: WorkspaceEvents, ) : WorkspaceService by LoggingWorkspaceService( Slf4jLog("WorkspaceService"), - BasicWorkspaceService(storage, homeworks, clock), + NotifyWorkspaceService( + BasicWorkspaceService(storage, homeworks, clock), + events, + consumer, + ), ) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/event/EventPublicationTask.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/event/EventPublicationTask.kt index 68b0c52..7f616c9 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/event/EventPublicationTask.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/event/EventPublicationTask.kt @@ -5,19 +5,19 @@ import ru.vityaman.lms.botalka.commons.Consumer import ru.vityaman.lms.botalka.commons.Runnable class EventPublicationTask( - private val notifications: Events, + private val events: Events, private val consumer: Consumer, private val callbacks: Callbacks = Callbacks(), ) : Runnable { override suspend fun run() { callbacks.onStart() - notifications.publishable() + events.publishable() .toCollection(mutableListOf()) .shuffled() .also { callbacks.onReceived(it) } .onEach { callbacks.onNext(it) } .forEach { id -> - runCatching { notifications.atLeastOnce(id, consumer::accept) } + runCatching { events.atLeastOnce(id, consumer::accept) } .onSuccess { callbacks.onAccepted(id) } .onFailure { callbacks.onRejected(id) } .getOrThrow() diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/logic/notify/NotifyWorkspaceService.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/logic/notify/NotifyWorkspaceService.kt new file mode 100644 index 0000000..22071b3 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/logic/notify/NotifyWorkspaceService.kt @@ -0,0 +1,24 @@ +package ru.vityaman.lms.botalka.core.logic.notify + +import ru.vityaman.lms.botalka.commons.Consumer +import ru.vityaman.lms.botalka.core.logic.WorkspaceService +import ru.vityaman.lms.botalka.core.model.Workspace +import ru.vityaman.lms.botalka.storage.jooq.event.WorkspaceEvents + +class NotifyWorkspaceService( + private val origin: WorkspaceService, + private val events: WorkspaceEvents, + private val consumer: Consumer, +) : WorkspaceService by origin { + override suspend fun produce( + id: Workspace.Id, + event: Workspace.Event.Draft, + ): Workspace.Event = + runCatching { origin.produce(id, event) } + .onSuccess { + runCatching { + events.atLeastOnce(it.id, consumer::accept) + }.getOrNull() + } + .getOrThrow() +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/model/Workspace.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/model/Workspace.kt index dfba1f6..57093ef 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/model/Workspace.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/model/Workspace.kt @@ -15,6 +15,7 @@ data class Workspace( abstract val id: Id abstract val producer: User.Id abstract val creationMoment: OffsetDateTime + abstract val isPublished: Boolean @JvmInline value class Id(val number: Int) { @@ -33,6 +34,7 @@ data class Workspace( override val producer: User.Id, val text: String, override val creationMoment: OffsetDateTime, + override val isPublished: Boolean = false, ) : Event() { data class Draft( val producer: User.Id, @@ -45,6 +47,7 @@ data class Workspace( override val producer: User.Id, val note: String, override val creationMoment: OffsetDateTime, + override val isPublished: Boolean = false, ) : Event() { data class Draft( val producer: User.Id, @@ -58,6 +61,7 @@ data class Workspace( val comment: String, val score: Homework.Score?, override val creationMoment: OffsetDateTime, + override val isPublished: Boolean = false, ) : Event() { data class Draft( val teacher: Teacher, diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/WorkspaceMapping.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/WorkspaceMapping.kt index 26f3af8..afb807b 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/WorkspaceMapping.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/entity/WorkspaceMapping.kt @@ -13,6 +13,7 @@ fun HomeworkSubmissionRecord.toModel() = producer = User.Id(this.studentId), note = this.comment, creationMoment = this.creationMoment!!, + isPublished = this.isPublished ?: false, ) fun HomeworkFeedbackRecord.toModel() = @@ -22,4 +23,5 @@ fun HomeworkFeedbackRecord.toModel() = comment = this.comment, score = this.score?.let { Homework.Score(it) }, creationMoment = this.creationMoment!!, + isPublished = this.isPublished ?: false, ) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/event/WorkspaceEvents.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/event/WorkspaceEvents.kt new file mode 100644 index 0000000..e53e043 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/jooq/event/WorkspaceEvents.kt @@ -0,0 +1,73 @@ +package ru.vityaman.lms.botalka.storage.jooq.event + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import ru.vityaman.lms.botalka.core.event.Events +import ru.vityaman.lms.botalka.core.exception.orNotFound +import ru.vityaman.lms.botalka.core.model.Workspace +import ru.vityaman.lms.botalka.core.tx.TxEnv +import ru.vityaman.lms.botalka.storage.jooq.JooqDatabase +import ru.vityaman.lms.botalka.storage.jooq.entity.toModel +import ru.vityaman.lms.botalka.storage.jooq.tables.references.HOMEWORK_FEEDBACK +import ru.vityaman.lms.botalka.storage.jooq.tables.references.HOMEWORK_SUBMISSION + +class WorkspaceEvents( + private val database: JooqDatabase, + override val tx: TxEnv, +) : Events { + override fun publishable(): Flow = + database.flow { + arrayOf( + select(HOMEWORK_SUBMISSION.ID) + .from(HOMEWORK_SUBMISSION) + .where(HOMEWORK_SUBMISSION.IS_PUBLISHED.notEqual(true)), + select(HOMEWORK_FEEDBACK.ID) + .from(HOMEWORK_FEEDBACK) + .where(HOMEWORK_FEEDBACK.IS_PUBLISHED.notEqual(true)), + ).let { it[0].union(it[1]) } + }.map { Workspace.Event.Id(it["id"]!! as Int) } + + override suspend fun markPublished(event: Workspace.Event) = + when (event) { + is Workspace.Feedback -> { + database.only { + update(HOMEWORK_FEEDBACK) + .set(HOMEWORK_FEEDBACK.IS_PUBLISHED, true) + .where(HOMEWORK_FEEDBACK.ID.eq(event.id.number)) + }.let { } + } + + is Workspace.Submission -> { + database.only { + update(HOMEWORK_SUBMISSION) + .set(HOMEWORK_SUBMISSION.IS_PUBLISHED, true) + .where(HOMEWORK_SUBMISSION.ID.eq(event.id.number)) + }.let { } + } + + is Workspace.Comment -> { + TODO("Not yet supported") + } + } + + override suspend fun isPublished(event: Workspace.Event): Boolean = + event.isPublished + + override suspend fun acquireById(id: Workspace.Event.Id): Workspace.Event = + acquireFeedbackById(id) ?: acquireSubmissionById(id) + .orNotFound("Event with id $id") + + private suspend fun acquireFeedbackById(id: Workspace.Event.Id) = + database.maybe { + selectFrom(HOMEWORK_FEEDBACK) + .where(HOMEWORK_FEEDBACK.ID.eq(id.number)) + .forUpdate() + }?.toModel() + + private suspend fun acquireSubmissionById(id: Workspace.Event.Id) = + database.maybe { + selectFrom(HOMEWORK_SUBMISSION) + .where(HOMEWORK_SUBMISSION.ID.eq(id.number)) + .forUpdate() + }?.toModel() +} diff --git a/botalka/src/main/resources/application.yml b/botalka/src/main/resources/application.yml index 9f4c023..254f535 100644 --- a/botalka/src/main/resources/application.yml +++ b/botalka/src/main/resources/application.yml @@ -21,10 +21,13 @@ database: broker: bootstrap-servers: ${LMS_KAFKA_HOST}:9092 topic: - publication: publication + homework: homework + workspace: workspace consumer: - publication: - group: botalka-publication + homework: + group: botalka-homework + workspace: + group: botalka-workspace springdoc: api-docs: path: /openapi diff --git a/botalka/src/main/resources/database/changelog.sql b/botalka/src/main/resources/database/changelog.sql index 69cc390..56cf7a9 100644 --- a/botalka/src/main/resources/database/changelog.sql +++ b/botalka/src/main/resources/database/changelog.sql @@ -116,3 +116,10 @@ CREATE TABLE lms.is_initialized ( ); INSERT INTO lms.is_initialized (value) VALUES (FALSE); + +--changeset vityaman:workspace_event_publication +ALTER TABLE lms.homework_submission +ADD is_published boolean NOT NULL DEFAULT FALSE; + +ALTER TABLE lms.homework_feedback +ADD is_published boolean NOT NULL DEFAULT FALSE; diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/WorkspaceApiTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/WorkspaceApiTest.kt index 85a6d2e..2f74f79 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/WorkspaceApiTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/WorkspaceApiTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.reactor.awaitSingle import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired import org.springframework.web.reactive.function.client.WebClientResponseException @@ -25,6 +26,7 @@ import ru.vityaman.lms.botalka.app.spring.api.http.client.requestRole import ru.vityaman.lms.botalka.app.spring.env.FakeClock import java.time.Instant import java.time.OffsetDateTime +import java.util.concurrent.TimeUnit import kotlin.random.Random class WorkspaceApiTest( @@ -66,6 +68,7 @@ class WorkspaceApiTest( } @Test + @Timeout(10, unit = TimeUnit.SECONDS) fun smoking(): Unit = runBlocking { history() shouldHaveSize 0 submit("Note") @@ -90,7 +93,7 @@ class WorkspaceApiTest( @Test fun sortedByCreationMoment(): Unit = runBlocking { - val count = 434 + val count = 128 postRandomEvents(count) history() .map { it.creationMoment } diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/logic/PublicationTest.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/logic/PublicationTest.kt index cfbebfc..320e7c2 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/logic/PublicationTest.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/logic/PublicationTest.kt @@ -6,6 +6,7 @@ import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import org.springframework.beans.factory.annotation.Autowired @@ -41,6 +42,11 @@ class PublicationTest( ) }.toList() + @BeforeEach + fun clearMessages() { + telegram.messages.clear() + } + @Test @Timeout(32, unit = TimeUnit.SECONDS) fun basic(): Unit = runBlocking { diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/storage/BrokerContainer.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/storage/BrokerContainer.kt index f193130..3ad8f02 100644 --- a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/storage/BrokerContainer.kt +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/storage/BrokerContainer.kt @@ -14,6 +14,16 @@ class BrokerContainer : AutoCloseable { init { kafka.start() + createTopic("homework") + createTopic("workspace") + } + + override fun close() { + kafka.stop() + kafka.close() + } + + private fun createTopic(name: String) = kafka.execInContainer( "/bin/sh", "-c", @@ -21,15 +31,9 @@ class BrokerContainer : AutoCloseable { append("/usr/bin/kafka-topics ") append("--create --zookeeper localhost:2181 ") append("--replication-factor 1 --partitions 1 ") - append("--topic publication") + append("--topic $name") }, ) - } - - override fun close() { - kafka.stop() - kafka.close() - } companion object { val main by lazy { BrokerContainer() } diff --git a/infra/fuzzing/custom/homeworks.sh b/infra/fuzzing/custom/homeworks.sh index cd8c9d1..4872203 100755 --- a/infra/fuzzing/custom/homeworks.sh +++ b/infra/fuzzing/custom/homeworks.sh @@ -8,7 +8,7 @@ MOMENT="$2" echo "[fuzz] Got access token: '$TOKEN'" echo "[fuzz] Got moment: '$MOMENT'" -for _ in $(seq 1 64); do +for _ in $(seq 1 16); do curl -X 'POST' \ 'http://localhost:8080/api/v1/homework' \ -H 'accept: application/json' \ diff --git a/infra/kafka/setup.sh b/infra/kafka/setup.sh index e92b2b0..326a523 100755 --- a/infra/kafka/setup.sh +++ b/infra/kafka/setup.sh @@ -24,6 +24,7 @@ topic() { --replication-factor 1 --partitions 2 } -topic "publication" +topic "homework" +topic "workspace" echo "Kafka $HOST:$PORT is ready."