Follow clean code more closely

This commit is contained in:
2023-10-26 09:26:58 +02:00
parent 06ad9eaa31
commit 0ea147fa8e
47 changed files with 508 additions and 251 deletions
@@ -1,6 +1,6 @@
package de.twomartens.timetable package de.twomartens.timetable
import de.twomartens.timetable.service.ScheduledTaskService import de.twomartens.timetable.bahnApi.service.ScheduledTaskService
import mu.KotlinLogging import mu.KotlinLogging
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.boot.context.event.ApplicationReadyEvent
@@ -1,4 +1,4 @@
package de.twomartens.timetable.configuration package de.twomartens.timetable.bahnApi.configuration
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@@ -8,6 +8,7 @@ private const val CORE_POOL_SIZE = 1
@Configuration @Configuration
open class ThreadPoolTaskExecutorConfig { open class ThreadPoolTaskExecutorConfig {
@Bean @Bean
open fun threadPoolTaskExecutor(): ThreadPoolTaskExecutor { open fun threadPoolTaskExecutor(): ThreadPoolTaskExecutor {
val executor = ThreadPoolTaskExecutor() val executor = ThreadPoolTaskExecutor()
@@ -1,11 +1,10 @@
package de.twomartens.timetable.configuration package de.twomartens.timetable.bahnApi.configuration
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
private const val POOL_SIZE = 1 private const val POOL_SIZE = 1
private const val THREAD_NAME_PREFIX = "ThreadPoolTaskScheduler" private const val THREAD_NAME_PREFIX = "ThreadPoolTaskScheduler"
@Configuration @Configuration
@@ -3,17 +3,18 @@ package de.twomartens.timetable.bahnApi.jaxb
import jakarta.xml.bind.annotation.adapters.XmlAdapter import jakarta.xml.bind.annotation.adapters.XmlAdapter
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.* import java.util.*
class LocalDateTimeAdapter : XmlAdapter<String?, LocalDateTime>() { class LocalDateTimeAdapter : XmlAdapter<String?, LocalDateTime>() {
@Throws(Exception::class) @Throws(DateTimeParseException::class)
override fun unmarshal(xmlValue: String?): LocalDateTime { override fun unmarshal(xmlValue: String?): LocalDateTime {
return LocalDateTime.parse(xmlValue, dateFormatter.get()) return LocalDateTime.parse(xmlValue, dateFormatter.get())
} }
@Throws(Exception::class) @Throws(DateTimeParseException::class)
override fun marshal(kotlinValue: LocalDateTime): String? { override fun marshal(kotlinValue: LocalDateTime): String? {
return dateFormatter.get().format(kotlinValue) return dateFormatter.get().format(kotlinValue)
} }
@@ -1,6 +1,7 @@
package de.twomartens.timetable.bahnApi.mapper package de.twomartens.timetable.bahnApi.mapper
import de.twomartens.timetable.bahnApi.model.db.BahnTimetable import de.twomartens.timetable.bahnApi.model.db.BahnTimetable
import de.twomartens.timetable.model.base.NonEmptyString
import org.mapstruct.* import org.mapstruct.*
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -16,9 +17,9 @@ interface BahnTimetableMapper {
fun mapToDB(dto: de.twomartens.timetable.bahnApi.model.dto.BahnTimetable, fun mapToDB(dto: de.twomartens.timetable.bahnApi.model.dto.BahnTimetable,
dateTime: LocalDateTime): BahnTimetable { dateTime: LocalDateTime): BahnTimetable {
return BahnTimetable( return BahnTimetable(
dto.eva!!, dto.eva,
dateTime, dateTime,
dto.station, NonEmptyString(dto.station),
dto.stops dto.stops
) )
} }
@@ -0,0 +1,24 @@
package de.twomartens.timetable.bahnApi.model
import de.twomartens.timetable.model.db.StationId
@JvmInline
value class Eva(val value: Int) {
init {
if (isKnown()) {
require(value in 8000000..8999999) {
"Eva number must start with an 8 and have 7 digits"
}
}
}
private fun isKnown() = value != -1
companion object {
val UNKNOWN = Eva(-1)
fun of(stationId: StationId): Eva {
return Eva(stationId.stationIdWithinCountry.name.toInt())
}
}
}
@@ -0,0 +1,5 @@
package de.twomartens.timetable.bahnApi.model
import java.time.LocalDate
data class FetchDates(val previousDay: LocalDate, val fetchDate: LocalDate, val nextDate: LocalDate)
@@ -0,0 +1,58 @@
package de.twomartens.timetable.bahnApi.model
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.model.base.HourAtDay
import org.springframework.stereotype.Component
import java.time.LocalDateTime
private const val SECONDS_BETWEEN_TASKS = 2
private const val ZERO = 0
@Component
class TaskFactory {
private val _tasksPerHour: MutableMap<HourAtDay, Int> = mutableMapOf()
fun initializeWithExistingTasks(existingTasks: List<ScheduledFetchTask>) {
existingTasks.forEach { countTask(it) }
}
fun createTaskAndUpdateCounter(eva: Eva, hourAtDay: HourAtDay): ScheduledFetchTask {
val fetchDateTime = calculateNextAvailableFetchDateTime(hourAtDay)
incrementTaskCountForHourAtDay(hourAtDay)
return ScheduledFetchTask(eva, hourAtDay, fetchDateTime)
}
private fun calculateNextAvailableFetchDateTime(hourAtDay: HourAtDay): LocalDateTime {
val secondInHourToExecute = calculateSecondInHourToCalculate(hourAtDay)
return calculateFetchDateTime(hourAtDay, secondInHourToExecute)
}
private fun calculateSecondInHourToCalculate(hourAtDay: HourAtDay): Int {
val numberOfTasksInHour = getNumberOfTasksInHour(hourAtDay)
return numberOfTasksInHour * SECONDS_BETWEEN_TASKS
}
private fun calculateFetchDateTime(
hourAtDay: HourAtDay,
secondInHourToExecute: Int
): LocalDateTime {
return hourAtDay.dateTime.plusSeconds(secondInHourToExecute.toLong())
}
private fun countTask(scheduledFetchTask: ScheduledFetchTask) {
val scheduledTime = scheduledFetchTask.scheduledExecutionDateTime
val hourAtDay = HourAtDay.of(scheduledTime)
incrementTaskCountForHourAtDay(hourAtDay)
}
private fun incrementTaskCountForHourAtDay(hourAtDay: HourAtDay) {
val numberOfTasksInHour = getNumberOfTasksInHour(hourAtDay)
_tasksPerHour[hourAtDay] = numberOfTasksInHour + 1
}
private fun getNumberOfTasksInHour(hourAtDay: HourAtDay) =
_tasksPerHour.computeIfAbsent(hourAtDay) { ZERO }
}
@@ -1,7 +1,7 @@
package de.twomartens.timetable.bahnApi.model.db package de.twomartens.timetable.bahnApi.model.db
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.model.NonEmptyString import de.twomartens.timetable.model.base.NonEmptyString
import org.bson.types.ObjectId import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
@@ -1,8 +1,8 @@
package de.twomartens.timetable.bahnApi.model.db package de.twomartens.timetable.bahnApi.model.db
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.dto.BahnStationStop import de.twomartens.timetable.bahnApi.model.dto.BahnStationStop
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.model.base.NonEmptyString
import de.twomartens.timetable.model.NonEmptyString
import org.bson.types.ObjectId import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
@@ -1,7 +1,7 @@
package de.twomartens.timetable.bahnApi.model.db package de.twomartens.timetable.bahnApi.model.db
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.model.Hour import de.twomartens.timetable.model.base.HourAtDay
import org.bson.types.ObjectId import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
@@ -9,15 +9,13 @@ import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.CompoundIndex import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@Document @Document
@CompoundIndex(def = "{'eva': 1, 'fetchedDate': 1, 'fetchedTime': 1", unique = true) @CompoundIndex(def = "{'eva': 1, 'fetchedDateTime': 1", unique = true)
data class ScheduledFetchTask( data class ScheduledFetchTask(
var eva: Eva, var eva: Eva,
var fetchedDate: LocalDate, var fetchedDateTime: HourAtDay,
var fetchedTime: Hour,
var scheduledExecutionDateTime: LocalDateTime var scheduledExecutionDateTime: LocalDateTime
) { ) {
@Id @Id
@@ -1,20 +1,16 @@
package de.twomartens.timetable.bahnApi.model.dto package de.twomartens.timetable.bahnApi.model.dto
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.bahnApi.model.Eva
import jakarta.xml.bind.annotation.XmlAccessType import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlAttribute import jakarta.xml.bind.annotation.XmlAttribute
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
data class BahnStation( data class BahnStation(
@field:XmlAttribute @field:XmlAttribute var name: String,
var name: String, @field:XmlAttribute var eva: Eva,
@field:XmlAttribute @field:XmlAttribute var ds100: String,
var eva: Eva?, @field:XmlAttribute var db: Boolean
@field:XmlAttribute
var ds100: String,
@field:XmlAttribute
var db: Boolean
) { ) {
constructor(): this("", null, "", true) constructor() : this("", Eva.UNKNOWN, "", true)
} }
@@ -1,6 +1,6 @@
package de.twomartens.timetable.bahnApi.model.dto package de.twomartens.timetable.bahnApi.model.dto
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.bahnApi.model.Eva
import jakarta.xml.bind.annotation.XmlAccessType import jakarta.xml.bind.annotation.XmlAccessType
import jakarta.xml.bind.annotation.XmlAccessorType import jakarta.xml.bind.annotation.XmlAccessorType
import jakarta.xml.bind.annotation.XmlAttribute import jakarta.xml.bind.annotation.XmlAttribute
@@ -8,11 +8,11 @@ import jakarta.xml.bind.annotation.XmlElement
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
data class BahnStationStop( data class BahnStationStop(
@field:XmlAttribute var eva: Eva?, @field:XmlAttribute var eva: Eva,
@field:XmlAttribute var id: String, @field:XmlAttribute var id: String,
@field:XmlElement(name = "tl") var tripLabel: BahnTripLabel?, @field:XmlElement(name = "tl") var tripLabel: BahnTripLabel,
@field:XmlElement(name = "ar") var arrival: BahnStopEvent?, @field:XmlElement(name = "ar") var arrival: BahnStopEvent?,
@field:XmlElement(name = "dp") var departure: BahnStopEvent? @field:XmlElement(name = "dp") var departure: BahnStopEvent?
) { ) {
constructor(): this(null, "", null, null, null) constructor() : this(Eva.UNKNOWN, "", BahnTripLabel(), null, null)
} }
@@ -10,5 +10,5 @@ import jakarta.xml.bind.annotation.XmlRootElement
data class BahnStations( data class BahnStations(
@field:XmlElement(name = "station") var stations: List<BahnStation> @field:XmlElement(name = "station") var stations: List<BahnStation>
) { ) {
constructor(): this(mutableListOf()) constructor() : this(listOf())
} }
@@ -1,14 +1,14 @@
package de.twomartens.timetable.bahnApi.model.dto package de.twomartens.timetable.bahnApi.model.dto
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.bahnApi.model.Eva
import jakarta.xml.bind.annotation.* import jakarta.xml.bind.annotation.*
@XmlRootElement(name = "timetable") @XmlRootElement(name = "timetable")
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
data class BahnTimetable( data class BahnTimetable(
@field:XmlAttribute var eva: Eva?, @field:XmlAttribute var eva: Eva,
@field:XmlAttribute var station: String, @field:XmlAttribute var station: String,
@field:XmlElement(name = "s") var stops: List<BahnStationStop> @field:XmlElement(name = "s") var stops: List<BahnStationStop>
) { ) {
constructor(): this(null, "", mutableListOf()) constructor() : this(Eva.UNKNOWN, "", listOf())
} }
@@ -11,8 +11,7 @@ data class BahnTripLabel(
@field:XmlAttribute(name = "f") var filterFlags: String, @field:XmlAttribute(name = "f") var filterFlags: String,
@field:XmlAttribute(name = "n") var trainNumber: String, @field:XmlAttribute(name = "n") var trainNumber: String,
@field:XmlAttribute(name = "o") var owner: String, @field:XmlAttribute(name = "o") var owner: String,
@field:XmlAttribute(name = "t") var tripType: BahnTripType? @field:XmlAttribute(name = "t") var tripType: BahnTripType
) { ) {
constructor(): this("", "", "", "", constructor() : this("", "", "", "", BahnTripType.p)
null)
} }
@@ -1,18 +1,17 @@
package de.twomartens.timetable.bahnApi.service package de.twomartens.timetable.bahnApi.service
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.dto.BahnStation import de.twomartens.timetable.bahnApi.model.dto.BahnStation
import de.twomartens.timetable.bahnApi.model.dto.BahnStations import de.twomartens.timetable.bahnApi.model.dto.BahnStations
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.property.BahnApiProperties import de.twomartens.timetable.bahnApi.property.BahnApiProperties
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.model.base.HourAtDay
import de.twomartens.timetable.model.Hour
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -33,14 +32,14 @@ class BahnApiService(
return body?.stations ?: listOf() return body?.stations ?: listOf()
} }
fun fetchTimetable(eva: Eva, date: LocalDate, hour: Hour): BahnTimetable { fun fetchTimetable(eva: Eva, hourAtDay: HourAtDay): BahnTimetable {
val requestEntity = buildRequestEntity<BahnTimetable>() val requestEntity = buildRequestEntity<BahnTimetable>()
val dateFormatter = DateTimeFormatter.ofPattern("yyMMdd") val dateFormatter = DateTimeFormatter.ofPattern("yyMMdd")
val timeFormatter = DateTimeFormatter.ofPattern("HH") val timeFormatter = DateTimeFormatter.ofPattern("HH")
val time = LocalTime.of(hour.value, 0) val time = LocalTime.of(hourAtDay.hour.value, 0)
val response = restTemplate.exchange( val response = restTemplate.exchange(
"https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/plan/" + "https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/plan/" +
"${eva}/${date.format(dateFormatter)}/${time.format(timeFormatter)}", "${eva}/${hourAtDay.date.format(dateFormatter)}/${time.format(timeFormatter)}",
HttpMethod.GET, HttpMethod.GET,
requestEntity, requestEntity,
BahnTimetable::class.java BahnTimetable::class.java
@@ -6,9 +6,9 @@ import de.twomartens.timetable.bahnApi.model.dto.BahnStation
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.repository.BahnStationRepository import de.twomartens.timetable.bahnApi.repository.BahnStationRepository
import de.twomartens.timetable.bahnApi.repository.BahnTimetableRepository import de.twomartens.timetable.bahnApi.repository.BahnTimetableRepository
import de.twomartens.timetable.model.base.HourAtDay
import org.mapstruct.factory.Mappers import org.mapstruct.factory.Mappers
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime
@Service @Service
class BahnDatabaseService( class BahnDatabaseService(
@@ -23,7 +23,7 @@ class BahnDatabaseService(
.map { bahnStationMapper.mapToDB(it) }) .map { bahnStationMapper.mapToDB(it) })
} }
fun storeTimetable(timetable: BahnTimetable, dateTime: LocalDateTime) { fun storeTimetable(timetable: BahnTimetable, hourAtDay: HourAtDay) {
bahnTimetableRepository.save(bahnTimetableMapper.mapToDB(timetable, dateTime)) bahnTimetableRepository.save(bahnTimetableMapper.mapToDB(timetable, hourAtDay.dateTime))
} }
} }
@@ -0,0 +1,77 @@
package de.twomartens.timetable.bahnApi.service
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.model.FetchDates
import de.twomartens.timetable.bahnApi.model.TaskFactory
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.bahnApi.repository.ScheduledFetchTaskRepository
import de.twomartens.timetable.model.base.Hour
import de.twomartens.timetable.model.base.HourAtDay
import de.twomartens.timetable.model.db.TswRoute
import mu.KotlinLogging
import org.springframework.stereotype.Service
import java.time.LocalDate
@Service
class ScheduledTaskService(
private val scheduledFetchTaskRepository: ScheduledFetchTaskRepository,
private val taskFactory: TaskFactory,
private val taskScheduler: TaskScheduler
) {
fun initializeScheduledTasks() {
log.info { "Initialize persisted scheduled fetch tasks" }
val scheduledTasks = scheduledFetchTaskRepository.findAll()
taskFactory.initializeWithExistingTasks(scheduledTasks)
taskScheduler.scheduleFetchTasks(scheduledTasks)
}
fun scheduleTimetableFetch(tswRoute: TswRoute, fetchedDate: LocalDate) {
log.info {
"Schedule timetable fetch: [route ${tswRoute.name}]"
}
val fetchDates = calculateDatesToFetch(fetchedDate)
val newTasks = buildScheduledTasks(tswRoute, fetchDates)
storeTasksInDatabase(newTasks)
taskScheduler.scheduleFetchTasks(newTasks)
}
private fun calculateDatesToFetch(fetchedDate: LocalDate): FetchDates {
val previousDay = fetchedDate.minusDays(1)
val nextDay = fetchedDate.plusDays(1)
return FetchDates(previousDay, fetchedDate, nextDay)
}
private fun buildScheduledTasks(
tswRoute: TswRoute,
fetchDates: FetchDates
): List<ScheduledFetchTask> {
val newTasks = mutableListOf<ScheduledFetchTask>()
tswRoute.stationIds.forEach {
val stationId = it
val eva = Eva.of(stationId)
var hourAtDay = HourAtDay.of(Hour.of(23), fetchDates.previousDay)
var newTask = taskFactory.createTaskAndUpdateCounter(eva, hourAtDay)
newTasks.add(newTask)
for (hour in 0..23) {
hourAtDay = HourAtDay.of(Hour.of(hour), fetchDates.fetchDate)
newTask = taskFactory.createTaskAndUpdateCounter(eva, hourAtDay)
newTasks.add(newTask)
}
for (hour in 0..3) {
hourAtDay = HourAtDay.of(Hour.of(hour), fetchDates.nextDate)
newTask = taskFactory.createTaskAndUpdateCounter(eva, hourAtDay)
newTasks.add(newTask)
}
}
return newTasks
}
private fun storeTasksInDatabase(newTasks: List<ScheduledFetchTask>) {
scheduledFetchTaskRepository.saveAll(newTasks)
}
companion object {
private val log = KotlinLogging.logger {}
}
}
@@ -0,0 +1,56 @@
package de.twomartens.timetable.bahnApi.service
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.repository.ScheduledFetchTaskRepository
import de.twomartens.timetable.bahnApi.tasks.DeleteScheduledTask
import de.twomartens.timetable.bahnApi.tasks.FetchTimetableTask
import de.twomartens.timetable.bahnApi.tasks.StoreTimetableTask
import de.twomartens.timetable.model.base.HourAtDay
import de.twomartens.timetable.property.TimeProperties
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import org.springframework.stereotype.Service
import java.time.ZonedDateTime
@Service
class TaskScheduler(
private val timeProperties: TimeProperties,
private val threadPoolTaskScheduler: ThreadPoolTaskScheduler,
private val threadPoolTaskExecutor: ThreadPoolTaskExecutor,
private val bahnApiService: BahnApiService,
private val bahnDatabaseService: BahnDatabaseService,
private val scheduledFetchTaskRepository: ScheduledFetchTaskRepository
) {
fun scheduleFetchTasks(scheduledTasks: List<ScheduledFetchTask>) {
scheduledTasks.forEach {
val zonedExecutionTime = it.scheduledExecutionDateTime
.atZone(timeProperties.defaultTimeZone)
scheduleDeleteTask(it, zonedExecutionTime)
scheduleFetchTimetableTask(it, zonedExecutionTime)
}
}
fun scheduleStoreTask(timetable: BahnTimetable, hourAtDay: HourAtDay) {
val storeTask = StoreTimetableTask(timetable, hourAtDay, bahnDatabaseService)
threadPoolTaskExecutor.execute(storeTask)
}
private fun scheduleDeleteTask(
scheduledFetchTask: ScheduledFetchTask,
zonedExecutionTime: ZonedDateTime
) {
val deleteTask = DeleteScheduledTask(scheduledFetchTaskRepository, scheduledFetchTask)
threadPoolTaskScheduler.schedule(deleteTask, zonedExecutionTime.toInstant())
}
private fun scheduleFetchTimetableTask(
scheduledFetchTask: ScheduledFetchTask,
zonedExecutionTime: ZonedDateTime
) {
val timetableTask = FetchTimetableTask(scheduledFetchTask.eva,
scheduledFetchTask.fetchedDateTime,
bahnApiService, this)
threadPoolTaskScheduler.schedule(timetableTask, zonedExecutionTime.toInstant())
}
}
@@ -14,8 +14,8 @@ open class DeleteScheduledTask(
log.info { log.info {
"Delete scheduled fetch task after the fetch occurred: " + "Delete scheduled fetch task after the fetch occurred: " +
"[eva: ${scheduledFetchTask.eva}], " + "[eva: ${scheduledFetchTask.eva}], " +
"[date: ${scheduledFetchTask.fetchedDate}], " + "[date: ${scheduledFetchTask.fetchedDateTime.date}], " +
"[time: ${scheduledFetchTask.fetchedTime}]" "[time: ${scheduledFetchTask.fetchedDateTime.hour}]"
} }
scheduledFetchTaskRepository.delete(scheduledFetchTask) scheduledFetchTaskRepository.delete(scheduledFetchTask)
} }
@@ -1,30 +1,24 @@
package de.twomartens.timetable.bahnApi.tasks package de.twomartens.timetable.bahnApi.tasks
import de.twomartens.timetable.bahnApi.model.Eva
import de.twomartens.timetable.bahnApi.service.BahnApiService import de.twomartens.timetable.bahnApi.service.BahnApiService
import de.twomartens.timetable.bahnApi.service.BahnDatabaseService import de.twomartens.timetable.bahnApi.service.TaskScheduler
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.model.base.HourAtDay
import de.twomartens.timetable.model.Hour
import mu.KotlinLogging import mu.KotlinLogging
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.time.LocalDate
@Async @Async
open class FetchTimetableTask( open class FetchTimetableTask(
private val eva: Eva, private val eva: Eva,
private val date: LocalDate, private val hourAtDay: HourAtDay,
private val hour: Hour,
private val bahnApiService: BahnApiService, private val bahnApiService: BahnApiService,
private val bahnDatabaseService: BahnDatabaseService, private val scheduler: TaskScheduler) : Runnable {
private val executor: ThreadPoolTaskExecutor) : Runnable {
override fun run() { override fun run() {
log.info { log.info {
"Fetch timetable: [eva: $eva], [date: $date], [hour: $hour]" "Fetch timetable: [eva: $eva], [date: ${hourAtDay.date}], [hour: ${hourAtDay.hour}]"
} }
val timetable = bahnApiService.fetchTimetable(eva, date, hour) val timetable = bahnApiService.fetchTimetable(eva, hourAtDay)
val storeTask = StoreTimetableTask(timetable, date.atTime(hour.value, 0), scheduler.scheduleStoreTask(timetable, hourAtDay)
bahnDatabaseService)
executor.execute(storeTask)
} }
companion object { companion object {
@@ -2,19 +2,19 @@ package de.twomartens.timetable.bahnApi.tasks
import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable import de.twomartens.timetable.bahnApi.model.dto.BahnTimetable
import de.twomartens.timetable.bahnApi.service.BahnDatabaseService import de.twomartens.timetable.bahnApi.service.BahnDatabaseService
import de.twomartens.timetable.model.base.HourAtDay
import mu.KotlinLogging import mu.KotlinLogging
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
import java.time.LocalDateTime
@Async @Async
open class StoreTimetableTask( open class StoreTimetableTask(
private val timetable: BahnTimetable, private val timetable: BahnTimetable,
private val dateTime: LocalDateTime, private val hourAtDay: HourAtDay,
private val bahnDatabaseService: BahnDatabaseService private val bahnDatabaseService: BahnDatabaseService
) : Runnable { ) : Runnable {
override fun run() { override fun run() {
log.info { "Store timetable: [eva: ${timetable.eva}], station: ${timetable.station}]" } log.info { "Store timetable: [eva: ${timetable.eva}], station: ${timetable.station}]" }
bahnDatabaseService.storeTimetable(timetable, dateTime) bahnDatabaseService.storeTimetable(timetable, hourAtDay)
} }
companion object { companion object {
@@ -1,8 +1,9 @@
package de.twomartens.timetable.controller package de.twomartens.timetable.controller
import de.twomartens.timetable.model.NonEmptyString import de.twomartens.timetable.bahnApi.service.ScheduledTaskService
import de.twomartens.timetable.model.base.NonEmptyString
import de.twomartens.timetable.model.db.UserId
import de.twomartens.timetable.repository.TswRouteRepository import de.twomartens.timetable.repository.TswRouteRepository
import de.twomartens.timetable.service.ScheduledTaskService
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
@@ -34,8 +35,11 @@ class TimetableController(
description = "Request does not follow specification" description = "Request does not follow specification"
)] )]
) )
@PostMapping("/{routeName}/fetchTimetable/{date}") @PostMapping("/{userId}/{routeName}/fetchTimetable/{date}")
fun scheduleFetch( fun scheduleFetch(
@PathVariable @Parameter(description = "The id of the user",
example = "1",
required = true) userId: UserId,
@PathVariable @Parameter(description = "The name of the TSW route.", @PathVariable @Parameter(description = "The name of the TSW route.",
example = "CologneAachen", example = "CologneAachen",
required = true) routeName: NonEmptyString, required = true) routeName: NonEmptyString,
@@ -47,7 +51,7 @@ class TimetableController(
if (!date.isAfter(LocalDate.now(clock))) { if (!date.isAfter(LocalDate.now(clock))) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Date must be in the future") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Date must be in the future")
} }
val route = routeRepository.findByName(routeName) val route = routeRepository.findByUserIdAndName(userId, routeName)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST,
"Route name must belong to existing route") "Route name must belong to existing route")
scheduledTaskService.scheduleTimetableFetch(route, date) scheduledTaskService.scheduleTimetableFetch(route, date)
@@ -1,10 +0,0 @@
package de.twomartens.timetable.model
@JvmInline
value class Eva(val value: Int) {
init {
require(value in 8000000..8999999) {
"Eva number must start with an 8 and have 7 digits"
}
}
}
@@ -1,10 +0,0 @@
package de.twomartens.timetable.model
@JvmInline
value class Hour(val value: Int) {
init {
require(value in 0..23) {
"Hour must be between 0 and 23"
}
}
}
@@ -1,12 +0,0 @@
package de.twomartens.timetable.model
import java.time.LocalDateTime
@JvmInline
value class HourAtDay(val value: LocalDateTime) {
init {
require(value.minute == 0 && value.second == 0 && value.nano == 0) {
"DateTime must only include an hour and the date."
}
}
}
@@ -1,42 +0,0 @@
package de.twomartens.timetable.model
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import java.time.LocalDate
import java.time.temporal.ChronoUnit
private const val SECONDS_BETWEEN_TASKS = 2
class Tasks {
private val _tasks: MutableMap<HourAtDay, MutableList<ScheduledFetchTask>> = mutableMapOf()
var tasks: List<ScheduledFetchTask> = listOf()
get() {
return _tasks.flatMap {
it.value
}
}
set(newTasks) {
_tasks.clear()
newTasks.forEach { addTask(it) }
field = newTasks
}
fun createAndAddTask(eva: Eva, date: LocalDate, hour: Hour): ScheduledFetchTask {
val hourAtDay = HourAtDay(date.atTime(hour.value, 0))
val tasksForHourAtDay = _tasks.computeIfAbsent(hourAtDay) { mutableListOf() }
val secondInHourToExecute = tasksForHourAtDay.size * SECONDS_BETWEEN_TASKS
val fetchDateTime = hourAtDay.value.plusSeconds(secondInHourToExecute.toLong())
val task = ScheduledFetchTask(eva, date, hour, fetchDateTime)
tasksForHourAtDay.add(task)
return task
}
private fun addTask(scheduledFetchTask: ScheduledFetchTask) {
val scheduledTime = scheduledFetchTask.scheduledExecutionDateTime
val hourAtDay = HourAtDay(scheduledTime.truncatedTo(ChronoUnit.HOURS))
val tasksForHourAtDay = _tasks.computeIfAbsent(hourAtDay) { mutableListOf() }
tasksForHourAtDay.add(scheduledFetchTask)
}
}
@@ -0,0 +1,16 @@
package de.twomartens.timetable.model.base
@JvmInline
value class Hour private constructor(val value: Int) {
init {
require(value in 0..23) {
"Hour must be between 0 and 23"
}
}
companion object {
fun of(hour: Int): Hour {
return Hour(hour)
}
}
}
@@ -0,0 +1,39 @@
package de.twomartens.timetable.model.base
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
@JvmInline
value class HourAtDay private constructor(private val value: LocalDateTime) {
init {
require(value.minute == 0 && value.second == 0 && value.nano == 0) {
"DateTime must only include an hour and the date."
}
}
val date: LocalDate
get() {
return value.toLocalDate()
}
val hour: Hour
get() {
return Hour.of(value.hour)
}
val dateTime: LocalDateTime
get() {
return value
}
companion object {
fun of(dateTime: LocalDateTime): HourAtDay {
return HourAtDay(dateTime.truncatedTo(ChronoUnit.HOURS))
}
fun of(hour: Hour, date: LocalDate): HourAtDay {
return HourAtDay(date.atTime(hour.value, 0))
}
}
}
@@ -1,4 +1,4 @@
package de.twomartens.timetable.model package de.twomartens.timetable.model.base
@JvmInline @JvmInline
value class NonEmptyString(val name: String) { value class NonEmptyString(val name: String) {
@@ -0,0 +1,10 @@
package de.twomartens.timetable.model.base
@JvmInline
value class PositiveInteger(val value: Int) {
init {
require(value > 0) {
"Value must be positive integer"
}
}
}
@@ -0,0 +1,6 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.PositiveInteger
@JvmInline
value class CoachCapacity(val capacity: PositiveInteger)
@@ -0,0 +1,12 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.NonEmptyString
@JvmInline
value class CountryCode(val countryCode: NonEmptyString) {
init {
require(countryCode.name.length == 2) {
"CountryCode must be only two characters long to conform to ISO 3166-1 alpha-2"
}
}
}
@@ -0,0 +1,11 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.NonEmptyString
import kotlin.time.Duration
data class Depot(
var name: NonEmptyString,
var nearestStationId: StationId,
var tracks: Map<TrackId, CoachCapacity>,
var formationTimes: Map<FormationId, Duration>
)
@@ -0,0 +1,27 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
@CompoundIndex(def = "{'userId': 1, 'formationId': 1}", unique = true)
data class Formation(
var userId: UserId,
var positiveInteger: FormationId,
var name: NonEmptyString
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -0,0 +1,6 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.PositiveInteger
@JvmInline
value class FormationId(val id: PositiveInteger)
@@ -0,0 +1,10 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.NonEmptyString
import kotlin.time.Duration
data class Portal(
var name: NonEmptyString,
var nearestStationId: StationId,
var formationTimes: Map<FormationId, Duration>
)
@@ -1,5 +1,6 @@
package de.twomartens.timetable.model.db package de.twomartens.timetable.model.db
data class Section( import de.twomartens.timetable.model.base.NonEmptyString
var name: String
) @JvmInline
value class Section(val name: NonEmptyString)
@@ -1,6 +1,5 @@
package de.twomartens.timetable.model.db package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.Eva
import org.bson.types.ObjectId import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
@@ -11,7 +10,7 @@ import java.time.Instant
@Document @Document
data class Station( data class Station(
@Indexed(unique = true) var eva: Eva, @Indexed(unique = true) var stationId: StationId,
var name: String, var name: String,
var platforms: List<Platform> var platforms: List<Platform>
) { ) {
@@ -0,0 +1,29 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.NonEmptyString
@JvmInline
value class StationId(val id: NonEmptyString) {
init {
require(idPattern.matches(id.name)) {
"StationId must start with two character country code, followed by a dash, " +
"and at least one word character"
}
}
companion object {
private val idPattern = Regex("^\\w{2}-(?<countryStationId>\\w.*)")
}
val stationIdWithinCountry: NonEmptyString
get() {
val matchResult = idPattern.matchEntire(id.name)
val groupMatch = matchResult?.groups?.get("countryStationId")?.value
return when {
matchResult == null || groupMatch == null ->
throw IllegalArgumentException("id wasn't matched by pattern")
else -> NonEmptyString(groupMatch)
}
}
}
@@ -0,0 +1,6 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.PositiveInteger
@JvmInline
value class TrackId(val id: PositiveInteger)
@@ -1,30 +1,23 @@
package de.twomartens.timetable.model.db package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.Eva import de.twomartens.timetable.model.base.NonEmptyString
import de.twomartens.timetable.model.NonEmptyString
import org.bson.types.ObjectId import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.Indexed import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant import java.time.Instant
@Document @Document
@CompoundIndex(def = "{'userId': 1, 'name': 1}", unique = true)
data class TswRoute( data class TswRoute(
@Indexed(unique = true) var name: NonEmptyString, var userId: UserId,
/** var name: NonEmptyString,
* Ordered list of stations that represent the order of stations on the route. var countryCode: CountryCode,
* var stationIds: List<StationId>,
* Not every stop is necessarily called at by each train. There may be multiple var portals: List<Portal>,
* parallel paths through a route (like S-Bahn track and long-distance track). var depots: List<Depot>
* In such cases, the S-Bahn stops are listed as part of the route as they appear
* geographically.
*
* Only the station ids are used here as the duplication would be heavy and
* consistency hard to ensure when potentially repeated across various routes.
*/
var stations: List<Eva>
) { ) {
@Id @Id
var id: ObjectId = ObjectId() var id: ObjectId = ObjectId()
@@ -0,0 +1,24 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.NonEmptyString
import org.bson.types.ObjectId
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.mapping.Document
import java.time.Instant
@Document
data class User(
var userId: UserId,
var name: NonEmptyString
) {
@Id
var id: ObjectId = ObjectId()
@CreatedDate
lateinit var created: Instant
@LastModifiedDate
lateinit var lastModified: Instant
}
@@ -0,0 +1,6 @@
package de.twomartens.timetable.model.db
import de.twomartens.timetable.model.base.NonEmptyString
@JvmInline
value class UserId(val id: NonEmptyString)
@@ -1,10 +1,11 @@
package de.twomartens.timetable.repository package de.twomartens.timetable.repository
import de.twomartens.timetable.model.NonEmptyString import de.twomartens.timetable.model.base.NonEmptyString
import de.twomartens.timetable.model.db.TswRoute import de.twomartens.timetable.model.db.TswRoute
import de.twomartens.timetable.model.db.UserId
import org.bson.types.ObjectId import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository import org.springframework.data.mongodb.repository.MongoRepository
interface TswRouteRepository : MongoRepository<TswRoute, ObjectId> { interface TswRouteRepository : MongoRepository<TswRoute, ObjectId> {
fun findByName(name: NonEmptyString): TswRoute? fun findByUserIdAndName(userId: UserId, name: NonEmptyString): TswRoute?
} }
@@ -1,77 +0,0 @@
package de.twomartens.timetable.service
import de.twomartens.timetable.bahnApi.model.db.ScheduledFetchTask
import de.twomartens.timetable.bahnApi.repository.ScheduledFetchTaskRepository
import de.twomartens.timetable.bahnApi.service.BahnApiService
import de.twomartens.timetable.bahnApi.service.BahnDatabaseService
import de.twomartens.timetable.bahnApi.tasks.DeleteScheduledTask
import de.twomartens.timetable.bahnApi.tasks.FetchTimetableTask
import de.twomartens.timetable.model.Hour
import de.twomartens.timetable.model.Tasks
import de.twomartens.timetable.model.db.TswRoute
import de.twomartens.timetable.property.TimeProperties
import mu.KotlinLogging
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import org.springframework.stereotype.Service
import java.time.LocalDate
@Service
class ScheduledTaskService(
private val timeProperties: TimeProperties,
private val scheduledFetchTaskRepository: ScheduledFetchTaskRepository,
private val threadPoolTaskScheduler: ThreadPoolTaskScheduler,
private val bahnApiService: BahnApiService,
private val bahnDatabaseService: BahnDatabaseService,
private val threadPoolTaskExecutor: ThreadPoolTaskExecutor
) {
private val tasks: Tasks = Tasks()
fun initializeScheduledTasks() {
log.info { "Initialize persisted scheduled fetch tasks" }
val scheduledTasks = scheduledFetchTaskRepository.findAll()
tasks.tasks = scheduledTasks
scheduleTasks(scheduledTasks)
}
fun scheduleTimetableFetch(tswRoute: TswRoute, fetchedDate: LocalDate) {
log.info {
"Schedule timetable fetch: [route ${tswRoute.name}]"
}
val previousDay = fetchedDate.minusDays(1)
val nextDay = fetchedDate.plusDays(1)
val newTasks = mutableListOf<ScheduledFetchTask>()
tswRoute.stations.forEach {
val eva = it
var newTask = tasks.createAndAddTask(eva, date = previousDay, hour = Hour(23))
newTasks.add(newTask)
for (hour in 0..23) {
newTask = tasks.createAndAddTask(eva, date = fetchedDate, hour = Hour(hour))
newTasks.add(newTask)
}
for (hour in 0..3) {
newTask = tasks.createAndAddTask(eva, date = nextDay, hour = Hour(hour))
newTasks.add(newTask)
}
}
scheduledFetchTaskRepository.saveAll(newTasks)
scheduleTasks(newTasks)
}
private fun scheduleTasks(scheduledTasks: List<ScheduledFetchTask>) {
scheduledTasks.forEach {
val zonedExecutionTime = it.scheduledExecutionDateTime
.atZone(timeProperties.defaultTimeZone)
val deleteTask = DeleteScheduledTask(scheduledFetchTaskRepository, it)
val timetableTask = FetchTimetableTask(it.eva, it.fetchedDate, it.fetchedTime,
bahnApiService, bahnDatabaseService, threadPoolTaskExecutor)
threadPoolTaskScheduler.schedule(timetableTask, zonedExecutionTime.toInstant())
threadPoolTaskScheduler.schedule(deleteTask, zonedExecutionTime.toInstant())
}
}
companion object {
private val log = KotlinLogging.logger {}
}
}