2025-02-08 23:34:50 +02:00
|
|
|
package data
|
|
|
|
|
|
|
|
|
|
import cats.effect.IO
|
2025-02-24 23:56:21 +02:00
|
|
|
import cats.effect.implicits.concurrentParTraverseOps
|
|
|
|
|
import cats.effect.kernel.Resource
|
|
|
|
|
import cats.effect.std.Semaphore
|
|
|
|
|
import cats.effect.unsafe.implicits.global
|
|
|
|
|
import cats.implicits._
|
2025-03-05 23:25:42 +02:00
|
|
|
import data.DataService.DeletionResult
|
2025-02-08 23:34:50 +02:00
|
|
|
import fs2.io.file.{Files, Path}
|
2025-03-05 23:25:42 +02:00
|
|
|
import io.circe.Encoder
|
|
|
|
|
import io.circe.generic.semiauto.deriveEncoder
|
2025-02-13 16:11:50 +02:00
|
|
|
import org.typelevel.log4cats.Logger
|
|
|
|
|
import org.typelevel.log4cats.slf4j.Slf4jLogger
|
2025-02-14 14:46:31 +02:00
|
|
|
import parse.grib.{Grib, GribParser}
|
2025-02-08 23:34:50 +02:00
|
|
|
|
2025-02-24 23:56:21 +02:00
|
|
|
import java.io.RandomAccessFile
|
2025-02-10 23:43:58 +02:00
|
|
|
import java.time.{ZoneId, ZoneOffset, ZonedDateTime}
|
2025-02-08 23:34:50 +02:00
|
|
|
import java.time.format.DateTimeFormatter
|
2025-02-24 23:56:21 +02:00
|
|
|
import java.util.concurrent.Executors
|
|
|
|
|
import scala.concurrent.ExecutionContext
|
2025-02-08 23:34:50 +02:00
|
|
|
import scala.util.Try
|
|
|
|
|
|
|
|
|
|
object DataService {
|
2025-03-05 23:25:42 +02:00
|
|
|
case class DeletionResult(fileName: String, success: Boolean, errorMessage: Option[String])
|
|
|
|
|
|
|
|
|
|
implicit val deletionResultEncoder: Encoder[DeletionResult] = deriveEncoder[DeletionResult]
|
|
|
|
|
|
2025-02-13 16:11:50 +02:00
|
|
|
def of: IO[DataService] = {
|
|
|
|
|
for {
|
|
|
|
|
logger <- Slf4jLogger.create[IO]
|
|
|
|
|
service = new DataService(logger)
|
|
|
|
|
_ <- service.init
|
|
|
|
|
} yield service
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DataService(log: Logger[IO]) {
|
|
|
|
|
val BASE_FOLDER = "data"
|
|
|
|
|
val GRIB_FOLDER = s"$BASE_FOLDER/grib"
|
2025-02-23 20:21:11 +02:00
|
|
|
val TMP_FOLDER = s"$BASE_FOLDER/tmp"
|
2025-02-13 16:11:50 +02:00
|
|
|
|
|
|
|
|
def init: IO[Unit] = {
|
|
|
|
|
for {
|
|
|
|
|
_ <- fs2.io.file.Files[IO].createDirectories(fs2.io.file.Path(BASE_FOLDER))
|
|
|
|
|
_ <- fs2.io.file.Files[IO].createDirectories(fs2.io.file.Path(GRIB_FOLDER))
|
2025-02-23 20:21:11 +02:00
|
|
|
_ <- fs2.io.file.Files[IO].createDirectories(fs2.io.file.Path(TMP_FOLDER))
|
|
|
|
|
_ <- log.info(s"Created directories: $BASE_FOLDER, $GRIB_FOLDER, ${TMP_FOLDER}")
|
2025-02-13 16:11:50 +02:00
|
|
|
} yield ()
|
|
|
|
|
}
|
2025-02-08 23:34:50 +02:00
|
|
|
|
|
|
|
|
def getFileList(): IO[List[String]] =
|
|
|
|
|
Files[IO]
|
2025-02-13 16:11:50 +02:00
|
|
|
.list(Path(GRIB_FOLDER))
|
2025-02-08 23:34:50 +02:00
|
|
|
.map(_.toString)
|
|
|
|
|
.filter(_.endsWith(".grib"))
|
2025-02-13 16:11:50 +02:00
|
|
|
.map(_.replace(s"$GRIB_FOLDER/", ""))
|
2025-02-08 23:34:50 +02:00
|
|
|
.compile
|
|
|
|
|
.toList
|
|
|
|
|
|
|
|
|
|
def getGribStucture(fileName: String): IO[List[Grib]] = {
|
2025-02-13 16:11:50 +02:00
|
|
|
val filePath = Path(s"$GRIB_FOLDER/$fileName")
|
2025-02-08 23:34:50 +02:00
|
|
|
|
|
|
|
|
Files[IO].exists(filePath).flatMap {
|
|
|
|
|
case true => GribParser.parseFile(filePath)
|
|
|
|
|
case false => IO.raiseError(new Exception(s"File not found: $fileName"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-14 20:26:57 +02:00
|
|
|
def getAllFileStructure(): IO[List[Grib]] = {
|
|
|
|
|
for {
|
|
|
|
|
fileList <- getFileList()
|
2025-02-25 18:19:42 +02:00
|
|
|
allStructure <- fileList.parTraverseN(4)(getGribStucture) // limit concurrency
|
2025-02-14 20:26:57 +02:00
|
|
|
} yield allStructure.flatten
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-25 18:19:42 +02:00
|
|
|
private val semaphore = Semaphore[IO](4).unsafeRunSync()
|
|
|
|
|
|
|
|
|
|
private val blockingEC = ExecutionContext.fromExecutorService(
|
2025-07-11 11:12:06 +03:00
|
|
|
Executors.newFixedThreadPool(8) // limit concurrency
|
2025-02-25 18:19:42 +02:00
|
|
|
)
|
2025-02-24 23:56:21 +02:00
|
|
|
|
2026-08-24 11:50:53 +03:00
|
|
|
private val MAX_BINARY_CHUNK_BYTES = 64 * 1024 * 1024
|
|
|
|
|
|
2025-02-08 23:34:50 +02:00
|
|
|
def getBinaryChunk(offset: Int, length: Int, fileName: String): IO[Array[Byte]] = {
|
2026-08-24 11:50:53 +03:00
|
|
|
if (length > MAX_BINARY_CHUNK_BYTES || length < 0)
|
|
|
|
|
IO.raiseError(new IllegalArgumentException(s"Requested length $length is invalid (max $MAX_BINARY_CHUNK_BYTES bytes)"))
|
|
|
|
|
else
|
|
|
|
|
getBinaryChunkUnchecked(offset, length, fileName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private def getBinaryChunkUnchecked(offset: Int, length: Int, fileName: String): IO[Array[Byte]] = {
|
2025-02-24 23:56:21 +02:00
|
|
|
val fileResource = Resource.make(
|
|
|
|
|
IO.blocking(new RandomAccessFile(s"$GRIB_FOLDER/$fileName", "r"))
|
|
|
|
|
)(file => IO.blocking(file.close()))
|
|
|
|
|
|
2025-02-25 18:19:42 +02:00
|
|
|
val logMemory = IO {
|
|
|
|
|
val runtime = Runtime.getRuntime
|
|
|
|
|
val usedMemoryMB = (runtime.totalMemory - runtime.freeMemory) / 1024 / 1024
|
|
|
|
|
val maxMemoryMB = runtime.maxMemory / 1024 / 1024
|
|
|
|
|
println(s"Memory usage before reading $fileName: $usedMemoryMB MB / $maxMemoryMB MB max")
|
2025-02-08 23:34:50 +02:00
|
|
|
}
|
2025-02-25 18:19:42 +02:00
|
|
|
|
|
|
|
|
for {
|
2025-02-26 13:32:07 +02:00
|
|
|
// _ <- logMemory
|
2025-02-25 18:19:42 +02:00
|
|
|
result <- fileResource.use { file =>
|
|
|
|
|
IO.blocking {
|
|
|
|
|
val buffer = new Array[Byte](length)
|
|
|
|
|
file.seek(offset)
|
|
|
|
|
file.readFully(buffer)
|
|
|
|
|
buffer
|
|
|
|
|
}.evalOn(blockingEC)
|
|
|
|
|
}
|
|
|
|
|
_ <- IO { System.gc() }
|
|
|
|
|
} yield result
|
2025-02-08 23:34:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def getForecasts(): IO[List[(ZonedDateTime, ZonedDateTime)]] = {
|
|
|
|
|
getFileList()
|
|
|
|
|
.map { fileNameList =>
|
|
|
|
|
fileNameList.flatMap(getTimeFromName)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-05 23:25:42 +02:00
|
|
|
def deleteOldForecasts(maxHours: Int = 9): IO[List[DeletionResult]] = {
|
2025-02-11 11:33:27 +02:00
|
|
|
val nowUTC = ZonedDateTime.now(ZoneOffset.UTC)
|
2025-02-14 14:59:07 +02:00
|
|
|
val ageThreshold = nowUTC.minusHours(maxHours)
|
2025-02-11 11:33:27 +02:00
|
|
|
|
|
|
|
|
for {
|
2025-02-13 16:11:50 +02:00
|
|
|
_ <- log.info("start cleanup")
|
2025-03-16 15:38:11 +02:00
|
|
|
// _ <- IO.raiseError[Unit](new RuntimeException("Test error in deleteOldForecasts"))
|
2025-02-11 11:33:27 +02:00
|
|
|
fileList <- getFileList()
|
|
|
|
|
fileDateList = fileList.flatMap(fileName =>
|
|
|
|
|
getTimeFromName(fileName).map(extracted => (fileName, extracted._1))
|
|
|
|
|
)
|
2025-02-14 14:59:07 +02:00
|
|
|
keepList = fileDateList.filter(_._2.isAfter(ageThreshold)).map(_._1)
|
|
|
|
|
deleteList = fileList.filter(!keepList.contains(_))
|
2025-03-05 23:25:42 +02:00
|
|
|
results <- deleteList.traverse { name =>
|
|
|
|
|
val path = Path(s"$GRIB_FOLDER/${name}")
|
|
|
|
|
Files[IO].delete(path).attempt.flatMap {
|
|
|
|
|
case Right(_) => log.info(s"delete: $name").as(DeletionResult(name, true, None))
|
|
|
|
|
case Left(error) => log.error(s"Failed to delete $name: ${error.getMessage}")
|
|
|
|
|
.as(DeletionResult(name, false, Some(error.getMessage)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} yield results
|
2025-02-11 11:33:27 +02:00
|
|
|
}
|
|
|
|
|
|
2025-03-16 15:38:11 +02:00
|
|
|
def deleteTmp(): IO[List[DeletionResult]] = {
|
|
|
|
|
for {
|
|
|
|
|
fileList <- Files[IO]
|
|
|
|
|
.list(Path(TMP_FOLDER))
|
|
|
|
|
.map(_.toString)
|
|
|
|
|
.map(_.replace(s"$TMP_FOLDER/", ""))
|
|
|
|
|
.compile.toList
|
|
|
|
|
|
|
|
|
|
results <- fileList.traverse { name =>
|
|
|
|
|
val path = Path(s"$TMP_FOLDER/${name}")
|
|
|
|
|
Files[IO].delete(path).attempt.flatMap {
|
|
|
|
|
case Right(_) => log.info(s"delete: $name").as(DeletionResult(name, true, None))
|
|
|
|
|
case Left(error) => log.error(s"Failed to delete $name: ${error.getMessage}")
|
|
|
|
|
.as(DeletionResult(name, false, Some(error.getMessage)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} yield results
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-08 23:34:50 +02:00
|
|
|
private def getTimeFromName(filename: String): Option[(ZonedDateTime, ZonedDateTime)] = {
|
|
|
|
|
Try {
|
|
|
|
|
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HHmm'Z'").withZone(ZoneId.of("UTC"))
|
|
|
|
|
val start = filename.slice(9, 25) // gets "2025-02-05T1500Z"
|
|
|
|
|
val end = filename.slice(26, 42) // gets "2025-02-05T1800Z"
|
|
|
|
|
(
|
2025-02-10 23:43:58 +02:00
|
|
|
ZonedDateTime.parse(start, formatter).toInstant.atZone(ZoneOffset.UTC),
|
|
|
|
|
ZonedDateTime.parse(end, formatter).toInstant.atZone(ZoneOffset.UTC)
|
2025-02-08 23:34:50 +02:00
|
|
|
)
|
|
|
|
|
}.toOption
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|