mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-03-28 17:55:06 +00:00
Merge pull request #45 from eikek/feature/background-tasks
Feature/background tasks
This commit is contained in:
commit
a07a6ff376
@ -3,7 +3,7 @@ version = "2.4.2"
|
||||
align = more
|
||||
#align.arrowEnumeratorGenerator = true
|
||||
|
||||
maxColumn = 100
|
||||
maxColumn = 90
|
||||
|
||||
rewrite.rules = [
|
||||
AvoidInfix
|
||||
|
@ -141,6 +141,7 @@ val common = project.in(file("modules/common")).
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.circe ++
|
||||
Dependencies.loggingApi ++
|
||||
Dependencies.calevCore ++
|
||||
Dependencies.pureconfig.map(_ % "optional")
|
||||
)
|
||||
|
||||
@ -194,7 +195,8 @@ val store = project.in(file("modules/store")).
|
||||
Dependencies.databases ++
|
||||
Dependencies.flyway ++
|
||||
Dependencies.loggingApi ++
|
||||
Dependencies.emil
|
||||
Dependencies.emil ++
|
||||
Dependencies.calev
|
||||
).dependsOn(common)
|
||||
|
||||
val extract = project.in(file("modules/extract")).
|
||||
@ -260,7 +262,8 @@ val joexapi = project.in(file("modules/joexapi")).
|
||||
settings(
|
||||
name := "docspell-joexapi",
|
||||
libraryDependencies ++=
|
||||
Dependencies.circe,
|
||||
Dependencies.circe ++
|
||||
Dependencies.http4sClient,
|
||||
openapiTargetLanguage := Language.Scala,
|
||||
openapiPackage := Pkg("docspell.joexapi.model"),
|
||||
openapiSpec := (Compile/resourceDirectory).value/"joex-openapi.yml"
|
||||
@ -302,7 +305,7 @@ val backend = project.in(file("modules/backend")).
|
||||
Dependencies.bcrypt ++
|
||||
Dependencies.http4sClient ++
|
||||
Dependencies.emil
|
||||
).dependsOn(store)
|
||||
).dependsOn(store, joexapi)
|
||||
|
||||
val webapp = project.in(file("modules/webapp")).
|
||||
disablePlugins(RevolverPlugin).
|
||||
|
@ -25,6 +25,7 @@ trait BackendApp[F[_]] {
|
||||
def job: OJob[F]
|
||||
def item: OItem[F]
|
||||
def mail: OMail[F]
|
||||
def joex: OJoex[F]
|
||||
}
|
||||
|
||||
object BackendApp {
|
||||
@ -44,9 +45,10 @@ object BackendApp {
|
||||
tagImpl <- OTag[F](store)
|
||||
equipImpl <- OEquipment[F](store)
|
||||
orgImpl <- OOrganization(store)
|
||||
uploadImpl <- OUpload(store, queue, cfg, httpClientEc)
|
||||
joexImpl <- OJoex.create(httpClientEc, store)
|
||||
uploadImpl <- OUpload(store, queue, cfg, joexImpl)
|
||||
nodeImpl <- ONode(store)
|
||||
jobImpl <- OJob(store, httpClientEc)
|
||||
jobImpl <- OJob(store, joexImpl)
|
||||
itemImpl <- OItem(store)
|
||||
mailImpl <- OMail(store, JavaMailEmil(blocker))
|
||||
} yield new BackendApp[F] {
|
||||
@ -62,6 +64,7 @@ object BackendApp {
|
||||
val job = jobImpl
|
||||
val item = itemImpl
|
||||
val mail = mailImpl
|
||||
val joex = joexImpl
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect: ContextShift](
|
||||
|
@ -1,15 +1,13 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{ConcurrentEffect, Resource}
|
||||
import cats.effect._
|
||||
import docspell.backend.ops.OJob.{CollectiveQueueState, JobCancelResult}
|
||||
import docspell.common.{Ident, JobState}
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QJob
|
||||
import docspell.store.records.{RJob, RJobLog}
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait OJob[F[_]] {
|
||||
|
||||
def queueState(collective: Ident, maxResults: Int): F[CollectiveQueueState]
|
||||
@ -36,9 +34,9 @@ object OJob {
|
||||
jobs.filter(_.job.state == JobState.Running)
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect](
|
||||
def apply[F[_]: Sync](
|
||||
store: Store[F],
|
||||
clientEC: ExecutionContext
|
||||
joex: OJoex[F]
|
||||
): Resource[F, OJob[F]] =
|
||||
Resource.pure[F, OJob[F]](new OJob[F] {
|
||||
|
||||
@ -70,8 +68,7 @@ object OJob {
|
||||
}
|
||||
|
||||
def tryCancel(job: RJob, worker: Ident): F[JobCancelResult] =
|
||||
OJoex
|
||||
.cancelJob(job.id, worker, store, clientEC)
|
||||
joex.cancelJob(job.id, worker)
|
||||
.map(flag => if (flag) JobCancelResult.CancelRequested else JobCancelResult.JobNotFound)
|
||||
|
||||
for {
|
||||
|
@ -1,62 +1,40 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.ConcurrentEffect
|
||||
import cats.effect._
|
||||
import docspell.common.{Ident, NodeType}
|
||||
import docspell.joexapi.client.JoexClient
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RNode
|
||||
import org.http4s.client.blaze.BlazeClientBuilder
|
||||
import org.http4s.Method._
|
||||
import org.http4s.{Request, Uri}
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
import org.log4s._
|
||||
|
||||
trait OJoex[F[_]] {
|
||||
|
||||
def notifyAllNodes: F[Unit]
|
||||
|
||||
def cancelJob(job: Ident, worker: Ident): F[Boolean]
|
||||
|
||||
}
|
||||
|
||||
object OJoex {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
def notifyAll[F[_]: ConcurrentEffect](
|
||||
store: Store[F],
|
||||
clientExecutionContext: ExecutionContext
|
||||
): F[Unit] =
|
||||
for {
|
||||
nodes <- store.transact(RNode.findAll(NodeType.Joex))
|
||||
_ <- nodes.toList.traverse(notifyJoex[F](clientExecutionContext))
|
||||
} yield ()
|
||||
def apply[F[_]: Sync](client: JoexClient[F], store: Store[F]): Resource[F, OJoex[F]] =
|
||||
Resource.pure[F, OJoex[F]](new OJoex[F] {
|
||||
def notifyAllNodes: F[Unit] =
|
||||
for {
|
||||
nodes <- store.transact(RNode.findAll(NodeType.Joex))
|
||||
_ <- nodes.toList.traverse(n => client.notifyJoexIgnoreErrors(n.url))
|
||||
} yield ()
|
||||
|
||||
def cancelJob[F[_]: ConcurrentEffect](
|
||||
jobId: Ident,
|
||||
worker: Ident,
|
||||
store: Store[F],
|
||||
clientEc: ExecutionContext
|
||||
): F[Boolean] =
|
||||
for {
|
||||
node <- store.transact(RNode.findById(worker))
|
||||
cancel <- node.traverse(joexCancel(clientEc)(_, jobId))
|
||||
} yield cancel.getOrElse(false)
|
||||
def cancelJob(job: Ident, worker: Ident): F[Boolean] =
|
||||
for {
|
||||
node <- store.transact(RNode.findById(worker))
|
||||
cancel <- node.traverse(n => client.cancelJob(n.url, job))
|
||||
} yield cancel.isDefined
|
||||
})
|
||||
|
||||
private def joexCancel[F[_]: ConcurrentEffect](
|
||||
ec: ExecutionContext
|
||||
)(node: RNode, job: Ident): F[Boolean] = {
|
||||
val notifyUrl = node.url / "api" / "v1" / "job" / job.id / "cancel"
|
||||
BlazeClientBuilder[F](ec).resource.use { client =>
|
||||
val req = Request[F](POST, Uri.unsafeFromString(notifyUrl.asString))
|
||||
client.expect[String](req).map(_ => true)
|
||||
}
|
||||
}
|
||||
def create[F[_]: ConcurrentEffect](ec: ExecutionContext, store: Store[F]): Resource[F, OJoex[F]] =
|
||||
JoexClient.resource(ec).flatMap(client => apply(client, store))
|
||||
|
||||
private def notifyJoex[F[_]: ConcurrentEffect](ec: ExecutionContext)(node: RNode): F[Unit] = {
|
||||
val notifyUrl = node.url / "api" / "v1" / "notify"
|
||||
val execute = BlazeClientBuilder[F](ec).resource.use { client =>
|
||||
val req = Request[F](POST, Uri.unsafeFromString(notifyUrl.asString))
|
||||
client.expect[String](req).map(_ => ())
|
||||
}
|
||||
execute.attempt.map {
|
||||
case Right(_) =>
|
||||
()
|
||||
case Left(_) =>
|
||||
logger.warn(s"Notifying Joex instance '${node.id.id}/${node.url.asString}' failed.")
|
||||
()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package docspell.backend.ops
|
||||
|
||||
import bitpeace.MimetypeHint
|
||||
import cats.implicits._
|
||||
import cats.effect.{ConcurrentEffect, Effect, Resource}
|
||||
import cats.effect._
|
||||
import docspell.backend.Config
|
||||
import fs2.Stream
|
||||
import docspell.common._
|
||||
@ -12,8 +12,6 @@ import docspell.store.queue.JobQueue
|
||||
import docspell.store.records.{RCollective, RJob, RSource}
|
||||
import org.log4s._
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait OUpload[F[_]] {
|
||||
|
||||
def submit(data: OUpload.UploadData[F], account: AccountId): F[OUpload.UploadResult]
|
||||
@ -51,11 +49,11 @@ object OUpload {
|
||||
case object NoSource extends UploadResult
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect](
|
||||
def apply[F[_]: Sync](
|
||||
store: Store[F],
|
||||
queue: JobQueue[F],
|
||||
cfg: Config,
|
||||
httpClientEC: ExecutionContext
|
||||
joex: OJoex[F]
|
||||
): Resource[F, OUpload[F]] =
|
||||
Resource.pure[F, OUpload[F]](new OUpload[F] {
|
||||
|
||||
@ -92,7 +90,7 @@ object OUpload {
|
||||
for {
|
||||
_ <- logger.fdebug(s"Storing jobs: $jobs")
|
||||
_ <- queue.insertAll(jobs)
|
||||
_ <- OJoex.notifyAll(store, httpClientEC)
|
||||
_ <- joex.notifyAllNodes
|
||||
} yield UploadResult.Success
|
||||
|
||||
private def saveFile(file: File[F]): F[Option[ProcessItemArgs.File]] =
|
||||
@ -109,7 +107,7 @@ object OUpload {
|
||||
}, id => Some(ProcessItemArgs.File(file.name, id))))
|
||||
|
||||
private def checkFileList(files: Seq[ProcessItemArgs.File]): F[Either[UploadResult, Unit]] =
|
||||
Effect[F].pure(if (files.isEmpty) Left(UploadResult.NoFiles) else Right(()))
|
||||
Sync[F].pure(if (files.isEmpty) Left(UploadResult.NoFiles) else Right(()))
|
||||
|
||||
private def makeJobs(
|
||||
args: Vector[ProcessItemArgs],
|
||||
|
28
modules/common/src/main/scala/docspell/common/Hash.scala
Normal file
28
modules/common/src/main/scala/docspell/common/Hash.scala
Normal file
@ -0,0 +1,28 @@
|
||||
package docspell.common
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
final class Hash(bytes: ByteVector) {
|
||||
|
||||
private def digest(name: String): String =
|
||||
bytes.digest(name).toHex.toLowerCase
|
||||
|
||||
def sha256: String =
|
||||
digest("SHA-256")
|
||||
|
||||
def md5: String =
|
||||
digest("MD5")
|
||||
|
||||
def add(str: String): Hash =
|
||||
new Hash(bytes ++ ByteVector.view(str.getBytes(StandardCharsets.UTF_8)))
|
||||
|
||||
def add(id: Ident): Hash =
|
||||
add(id.id)
|
||||
}
|
||||
|
||||
object Hash {
|
||||
|
||||
def empty: Hash = new Hash(ByteVector.empty)
|
||||
|
||||
}
|
@ -4,6 +4,8 @@ import java.time.{Instant, LocalDate, ZoneId}
|
||||
|
||||
import cats.effect.Sync
|
||||
import io.circe.{Decoder, Encoder}
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
case class Timestamp(value: Instant) {
|
||||
|
||||
@ -14,22 +16,40 @@ case class Timestamp(value: Instant) {
|
||||
def minus(d: Duration): Timestamp =
|
||||
Timestamp(value.minusNanos(d.nanos))
|
||||
|
||||
def - (d: Duration): Timestamp =
|
||||
minus(d)
|
||||
|
||||
def minusHours(n: Long): Timestamp =
|
||||
Timestamp(value.minusSeconds(n * 60 * 60))
|
||||
|
||||
def toDate: LocalDate =
|
||||
value.atZone(ZoneId.of("UTC")).toLocalDate
|
||||
def toUtcDate: LocalDate =
|
||||
value.atZone(Timestamp.UTC).toLocalDate
|
||||
|
||||
def toUtcDateTime: LocalDateTime =
|
||||
value.atZone(Timestamp.UTC).toLocalDateTime
|
||||
|
||||
def atZone(zone: ZoneId): ZonedDateTime =
|
||||
value.atZone(zone)
|
||||
|
||||
def atUTC: ZonedDateTime = atZone(Timestamp.UTC)
|
||||
|
||||
def asString: String = value.toString
|
||||
|
||||
def < (other: Timestamp): Boolean =
|
||||
this.value.isBefore(other.value)
|
||||
}
|
||||
|
||||
object Timestamp {
|
||||
val UTC = ZoneId.of("UTC")
|
||||
|
||||
val Epoch = Timestamp(Instant.EPOCH)
|
||||
|
||||
def current[F[_]: Sync]: F[Timestamp] =
|
||||
Sync[F].delay(Timestamp(Instant.now))
|
||||
|
||||
def from(zd: ZonedDateTime): Timestamp =
|
||||
Timestamp(zd.toInstant)
|
||||
|
||||
implicit val encodeTimestamp: Encoder[Timestamp] =
|
||||
BaseJsonCodecs.encodeInstantEpoch.contramap(_.value)
|
||||
|
||||
|
@ -4,6 +4,7 @@ import docspell.common._
|
||||
import _root_.pureconfig._
|
||||
import _root_.pureconfig.error.{CannotConvert, FailureReason}
|
||||
import scodec.bits.ByteVector
|
||||
import com.github.eikek.calev.CalEvent
|
||||
|
||||
import scala.reflect.ClassTag
|
||||
|
||||
@ -31,6 +32,10 @@ object Implicits {
|
||||
else ByteVector.encodeUtf8(str).left.map(ex => s"Invalid utf8 string: ${ex.getMessage}")
|
||||
})
|
||||
|
||||
implicit val caleventReader: ConfigReader[CalEvent] =
|
||||
ConfigReader[String].emap(reason(CalEvent.parse))
|
||||
|
||||
|
||||
def reason[A: ClassTag](f: String => Either[String, A]): String => Either[FailureReason, A] =
|
||||
in =>
|
||||
f(in).left.map(str => CannotConvert(in, implicitly[ClassTag[A]].runtimeClass.toString, str))
|
||||
|
@ -64,6 +64,58 @@ docspell.joex {
|
||||
wakeup-period = "30 minutes"
|
||||
}
|
||||
|
||||
periodic-scheduler {
|
||||
|
||||
# Each scheduler needs a unique name. This defaults to the node
|
||||
# name, which must be unique, too.
|
||||
name = ${docspell.joex.app-id}
|
||||
|
||||
# A fallback to start looking for due periodic tasks regularily.
|
||||
# Usually joex instances should be notified via REST calls if
|
||||
# external processes change tasks. But these requests may get
|
||||
# lost.
|
||||
wakeup-period = "10 minutes"
|
||||
}
|
||||
|
||||
# Docspell uses periodic house keeping tasks, like cleaning expired
|
||||
# invites, that can be configured here.
|
||||
house-keeping {
|
||||
|
||||
# When the house keeping tasks execute. Default is to run every
|
||||
# week.
|
||||
schedule = "Sun *-*-* 00:00:00"
|
||||
|
||||
# This task removes invitation keys that have been created but not
|
||||
# used. The timespan here must be greater than the `invite-time'
|
||||
# setting in the rest server config file.
|
||||
cleanup-invites = {
|
||||
|
||||
# Whether this task is enabled.
|
||||
enabled = true
|
||||
|
||||
# The minimum age of invites to be deleted.
|
||||
older-than = "30 days"
|
||||
}
|
||||
|
||||
# Jobs store their log output in the database. Normally this data
|
||||
# is only interesting for some period of time. The processing logs
|
||||
# of old files can be removed eventually.
|
||||
cleanup-jobs = {
|
||||
|
||||
# Whether this task is enabled.
|
||||
enabled = true
|
||||
|
||||
# The minimum age of jobs to delete. It is matched against the
|
||||
# `finished' timestamp.
|
||||
older-than = "30 days"
|
||||
|
||||
# This defines how many jobs are deleted in one transaction.
|
||||
# Since the data to delete may get large, it can be configured
|
||||
# whether more or less memory should be used.
|
||||
delete-batch = "100"
|
||||
}
|
||||
}
|
||||
|
||||
# Configuration of text extraction
|
||||
extraction {
|
||||
# For PDF files it is first tried to read the text parts of the
|
||||
|
@ -1,10 +1,11 @@
|
||||
package docspell.joex
|
||||
|
||||
import docspell.common.{Ident, LenientUri}
|
||||
import docspell.joex.scheduler.SchedulerConfig
|
||||
import docspell.joex.scheduler.{PeriodicSchedulerConfig, SchedulerConfig}
|
||||
import docspell.store.JdbcConfig
|
||||
import docspell.convert.ConvertConfig
|
||||
import docspell.extract.ExtractConfig
|
||||
import docspell.joex.hk.HouseKeepingConfig
|
||||
|
||||
case class Config(
|
||||
appId: Ident,
|
||||
@ -12,6 +13,8 @@ case class Config(
|
||||
bind: Config.Bind,
|
||||
jdbc: JdbcConfig,
|
||||
scheduler: SchedulerConfig,
|
||||
periodicScheduler: PeriodicSchedulerConfig,
|
||||
houseKeeping: HouseKeepingConfig,
|
||||
extraction: ExtractConfig,
|
||||
convert: ConvertConfig
|
||||
)
|
||||
|
@ -1,7 +1,7 @@
|
||||
package docspell.joex
|
||||
|
||||
import docspell.common.Ident
|
||||
import docspell.joex.scheduler.Scheduler
|
||||
import docspell.joex.scheduler.{PeriodicScheduler, Scheduler}
|
||||
import docspell.store.records.RJobLog
|
||||
|
||||
trait JoexApp[F[_]] {
|
||||
@ -10,6 +10,8 @@ trait JoexApp[F[_]] {
|
||||
|
||||
def scheduler: Scheduler[F]
|
||||
|
||||
def periodicScheduler: PeriodicScheduler[F]
|
||||
|
||||
def findLogs(jobId: Ident): F[Vector[RJobLog]]
|
||||
|
||||
/** Shuts down the job executor.
|
||||
|
@ -3,9 +3,12 @@ package docspell.joex
|
||||
import cats.implicits._
|
||||
import cats.effect._
|
||||
import docspell.common.{Ident, NodeType, ProcessItemArgs}
|
||||
import docspell.joex.hk._
|
||||
import docspell.joex.process.ItemHandler
|
||||
import docspell.joex.scheduler.{JobTask, Scheduler, SchedulerBuilder}
|
||||
import docspell.joex.scheduler._
|
||||
import docspell.joexapi.client.JoexClient
|
||||
import docspell.store.Store
|
||||
import docspell.store.queue._
|
||||
import docspell.store.ops.ONode
|
||||
import docspell.store.records.RJobLog
|
||||
import fs2.concurrent.SignallingRef
|
||||
@ -16,15 +19,21 @@ final class JoexAppImpl[F[_]: ConcurrentEffect: ContextShift: Timer](
|
||||
cfg: Config,
|
||||
nodeOps: ONode[F],
|
||||
store: Store[F],
|
||||
pstore: PeriodicTaskStore[F],
|
||||
termSignal: SignallingRef[F, Boolean],
|
||||
val scheduler: Scheduler[F]
|
||||
val scheduler: Scheduler[F],
|
||||
val periodicScheduler: PeriodicScheduler[F]
|
||||
) extends JoexApp[F] {
|
||||
|
||||
def init: F[Unit] = {
|
||||
val run = scheduler.start.compile.drain
|
||||
val run = scheduler.start.compile.drain
|
||||
val prun = periodicScheduler.start.compile.drain
|
||||
for {
|
||||
_ <- scheduleBackgroundTasks
|
||||
_ <- ConcurrentEffect[F].start(run)
|
||||
_ <- ConcurrentEffect[F].start(prun)
|
||||
_ <- scheduler.periodicAwake
|
||||
_ <- periodicScheduler.periodicAwake
|
||||
_ <- nodeOps.register(cfg.appId, NodeType.Joex, cfg.baseUrl)
|
||||
} yield ()
|
||||
}
|
||||
@ -36,8 +45,10 @@ final class JoexAppImpl[F[_]: ConcurrentEffect: ContextShift: Timer](
|
||||
nodeOps.unregister(cfg.appId)
|
||||
|
||||
def initShutdown: F[Unit] =
|
||||
scheduler.shutdown(false) *> termSignal.set(true)
|
||||
periodicScheduler.shutdown *> scheduler.shutdown(false) *> termSignal.set(true)
|
||||
|
||||
private def scheduleBackgroundTasks: F[Unit] =
|
||||
HouseKeepingTask.periodicTask[F](cfg.houseKeeping.schedule).flatMap(pstore.insert)
|
||||
}
|
||||
|
||||
object JoexAppImpl {
|
||||
@ -46,12 +57,17 @@ object JoexAppImpl {
|
||||
cfg: Config,
|
||||
termSignal: SignallingRef[F, Boolean],
|
||||
connectEC: ExecutionContext,
|
||||
clientEC: ExecutionContext,
|
||||
blocker: Blocker
|
||||
): Resource[F, JoexApp[F]] =
|
||||
for {
|
||||
client <- JoexClient.resource(clientEC)
|
||||
store <- Store.create(cfg.jdbc, connectEC, blocker)
|
||||
queue <- JobQueue(store)
|
||||
pstore <- PeriodicTaskStore.create(store)
|
||||
nodeOps <- ONode(store)
|
||||
sch <- SchedulerBuilder(cfg.scheduler, blocker, store)
|
||||
.withQueue(queue)
|
||||
.withTask(
|
||||
JobTask.json(
|
||||
ProcessItemArgs.taskName,
|
||||
@ -59,8 +75,23 @@ object JoexAppImpl {
|
||||
ItemHandler.onCancel[F]
|
||||
)
|
||||
)
|
||||
.withTask(
|
||||
JobTask.json(
|
||||
HouseKeepingTask.taskName,
|
||||
HouseKeepingTask[F](cfg),
|
||||
HouseKeepingTask.onCancel[F]
|
||||
)
|
||||
)
|
||||
.resource
|
||||
app = new JoexAppImpl(cfg, nodeOps, store, termSignal, sch)
|
||||
psch <- PeriodicScheduler.create(
|
||||
cfg.periodicScheduler,
|
||||
sch,
|
||||
queue,
|
||||
pstore,
|
||||
client,
|
||||
Timer[F]
|
||||
)
|
||||
app = new JoexAppImpl(cfg, nodeOps, store, pstore, termSignal, sch, psch)
|
||||
appR <- Resource.make(app.init.map(_ => app))(_.shutdown)
|
||||
} yield appR
|
||||
}
|
||||
|
@ -24,13 +24,14 @@ object JoexServer {
|
||||
def stream[F[_]: ConcurrentEffect: ContextShift](
|
||||
cfg: Config,
|
||||
connectEC: ExecutionContext,
|
||||
clientEC: ExecutionContext,
|
||||
blocker: Blocker
|
||||
)(implicit T: Timer[F]): Stream[F, Nothing] = {
|
||||
|
||||
val app = for {
|
||||
signal <- Resource.liftF(SignallingRef[F, Boolean](false))
|
||||
exitCode <- Resource.liftF(Ref[F].of(ExitCode.Success))
|
||||
joexApp <- JoexAppImpl.create[F](cfg, signal, connectEC, blocker)
|
||||
joexApp <- JoexAppImpl.create[F](cfg, signal, connectEC, clientEC, blocker)
|
||||
|
||||
httpApp = Router(
|
||||
"/api/info" -> InfoRoutes(),
|
||||
|
@ -13,10 +13,10 @@ import org.log4s._
|
||||
object Main extends IOApp {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(
|
||||
val blockingEC: ExecutionContext = ExecutionContext.fromExecutor(
|
||||
Executors.newCachedThreadPool(ThreadFactories.ofName("docspell-joex-blocking"))
|
||||
)
|
||||
val blocker = Blocker.liftExecutionContext(blockingEc)
|
||||
val blocker = Blocker.liftExecutionContext(blockingEC)
|
||||
val connectEC: ExecutionContext = ExecutionContext.fromExecutorService(
|
||||
Executors.newFixedThreadPool(5, ThreadFactories.ofName("docspell-joex-dbconnect"))
|
||||
)
|
||||
@ -52,6 +52,6 @@ object Main extends IOApp {
|
||||
cfg.baseUrl
|
||||
)
|
||||
logger.info(s"\n${banner.render("***>")}")
|
||||
JoexServer.stream[IO](cfg, connectEC, blocker).compile.drain.as(ExitCode.Success)
|
||||
JoexServer.stream[IO](cfg, connectEC, blockingEC, blocker).compile.drain.as(ExitCode.Success)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,26 @@
|
||||
package docspell.joex.hk
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.joex.scheduler.Task
|
||||
import docspell.store.records._
|
||||
|
||||
object CleanupInvitesTask {
|
||||
|
||||
def apply[F[_]: Sync](cfg: HouseKeepingConfig.CleanupInvites): Task[F, Unit, Unit] =
|
||||
Task { ctx =>
|
||||
if (cfg.enabled) {
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
ts = now - cfg.olderThan
|
||||
_ <- ctx.logger.info(s"Cleanup invitations older than $ts")
|
||||
n <- ctx.store.transact(RInvitation.deleteOlderThan(ts))
|
||||
_ <- ctx.logger.info(s"Removed $n invitations")
|
||||
} yield ()
|
||||
} else {
|
||||
ctx.logger.info("CleanupInvites task is disabled in the configuration")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
package docspell.joex.hk
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common._
|
||||
import docspell.joex.scheduler.Task
|
||||
import docspell.store.Store
|
||||
import docspell.store.records._
|
||||
|
||||
object CleanupJobsTask {
|
||||
|
||||
def apply[F[_]: Sync](cfg: HouseKeepingConfig.CleanupJobs): Task[F, Unit, Unit] =
|
||||
Task { ctx =>
|
||||
if (cfg.enabled) {
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
ts = now - cfg.olderThan
|
||||
_ <- ctx.logger.info(s"Cleanup jobs older than $ts")
|
||||
n <- deleteDoneJobs(ctx.store, ts, cfg.deleteBatch)
|
||||
_ <- ctx.logger.info(s"Removed $n jobs")
|
||||
} yield ()
|
||||
} else {
|
||||
ctx.logger.info("CleanupJobs task is disabled in the configuration")
|
||||
}
|
||||
}
|
||||
|
||||
def deleteDoneJobs[F[_]: Sync](store: Store[F], ts: Timestamp, batch: Int): F[Int] =
|
||||
Stream
|
||||
.eval(store.transact(RJob.deleteDoneAndOlderThan(ts, batch)))
|
||||
.repeat
|
||||
.takeWhile(_ > 0)
|
||||
.compile
|
||||
.foldMonoid
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package docspell.joex.hk
|
||||
|
||||
import com.github.eikek.calev.CalEvent
|
||||
import docspell.common._
|
||||
|
||||
import HouseKeepingConfig._
|
||||
|
||||
case class HouseKeepingConfig(
|
||||
schedule: CalEvent,
|
||||
cleanupInvites: CleanupInvites,
|
||||
cleanupJobs: CleanupJobs
|
||||
)
|
||||
|
||||
object HouseKeepingConfig {
|
||||
|
||||
case class CleanupInvites(enabled: Boolean, olderThan: Duration)
|
||||
|
||||
case class CleanupJobs(enabled: Boolean, olderThan: Duration, deleteBatch: Int)
|
||||
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package docspell.joex.hk
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect._
|
||||
import com.github.eikek.calev._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.joex.Config
|
||||
import docspell.joex.scheduler.Task
|
||||
import docspell.store.records._
|
||||
|
||||
object HouseKeepingTask {
|
||||
private val periodicId = Ident.unsafe("docspell-houskeeping")
|
||||
val systemGroup: Ident = Ident.unsafe("docspell-system")
|
||||
|
||||
val taskName: Ident = Ident.unsafe("housekeeping")
|
||||
|
||||
def apply[F[_]: Sync](cfg: Config): Task[F, Unit, Unit] =
|
||||
Task.log[F](_.info(s"Running house-keeping task now"))
|
||||
.flatMap(_ => CleanupInvitesTask(cfg.houseKeeping.cleanupInvites))
|
||||
.flatMap(_ => CleanupJobsTask(cfg.houseKeeping.cleanupJobs))
|
||||
|
||||
def onCancel[F[_]: Sync]: Task[F, Unit, Unit] =
|
||||
Task.log(_.warn("Cancelling house-keeping task"))
|
||||
|
||||
def periodicTask[F[_]: Sync](ce: CalEvent): F[RPeriodicTask] =
|
||||
RPeriodicTask
|
||||
.createJson(
|
||||
true,
|
||||
taskName,
|
||||
systemGroup,
|
||||
(),
|
||||
"Docspell house-keeping",
|
||||
systemGroup,
|
||||
Priority.Low,
|
||||
ce
|
||||
)
|
||||
.map(_.copy(id = periodicId))
|
||||
}
|
@ -19,7 +19,8 @@ object JoexRoutes {
|
||||
case POST -> Root / "notify" =>
|
||||
for {
|
||||
_ <- app.scheduler.notifyChange
|
||||
resp <- Ok(BasicResult(true, "Scheduler notified."))
|
||||
_ <- app.periodicScheduler.notifyChange
|
||||
resp <- Ok(BasicResult(true, "Schedulers notified."))
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / "running" =>
|
||||
|
@ -0,0 +1,49 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import fs2._
|
||||
import fs2.concurrent.SignallingRef
|
||||
import cats.effect._
|
||||
|
||||
import docspell.joexapi.client.JoexClient
|
||||
import docspell.store.queue._
|
||||
|
||||
/** A periodic scheduler takes care to submit periodic tasks to the
|
||||
* job queue.
|
||||
*
|
||||
* It is run in the background to regularily find a periodic task to
|
||||
* execute. If the task is due, it will be submitted into the job
|
||||
* queue where it will be picked up by the scheduler from some joex
|
||||
* instance. If it is due in the future, a notification is scheduled
|
||||
* to be received at that time so the task can be looked up again.
|
||||
*/
|
||||
trait PeriodicScheduler[F[_]] {
|
||||
|
||||
def config: PeriodicSchedulerConfig
|
||||
|
||||
def start: Stream[F, Nothing]
|
||||
|
||||
def shutdown: F[Unit]
|
||||
|
||||
def periodicAwake: F[Fiber[F, Unit]]
|
||||
|
||||
def notifyChange: F[Unit]
|
||||
}
|
||||
|
||||
object PeriodicScheduler {
|
||||
|
||||
def create[F[_]: ConcurrentEffect: ContextShift](
|
||||
cfg: PeriodicSchedulerConfig,
|
||||
sch: Scheduler[F],
|
||||
queue: JobQueue[F],
|
||||
store: PeriodicTaskStore[F],
|
||||
client: JoexClient[F],
|
||||
timer: Timer[F]
|
||||
): Resource[F, PeriodicScheduler[F]] =
|
||||
for {
|
||||
waiter <- Resource.liftF(SignallingRef(true))
|
||||
state <- Resource.liftF(SignallingRef(PeriodicSchedulerImpl.emptyState[F]))
|
||||
psch = new PeriodicSchedulerImpl[F](cfg, sch, queue, store, client, waiter, state, timer)
|
||||
_ <- Resource.liftF(psch.init)
|
||||
} yield psch
|
||||
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import docspell.common._
|
||||
|
||||
case class PeriodicSchedulerConfig(
|
||||
name: Ident,
|
||||
wakeupPeriod: Duration
|
||||
)
|
@ -0,0 +1,184 @@
|
||||
package docspell.joex.scheduler
|
||||
|
||||
import fs2._
|
||||
import fs2.concurrent.SignallingRef
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import org.log4s.getLogger
|
||||
import com.github.eikek.fs2calev._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.joexapi.client.JoexClient
|
||||
import docspell.store.queue._
|
||||
import docspell.store.records.RPeriodicTask
|
||||
|
||||
import PeriodicSchedulerImpl.State
|
||||
|
||||
final class PeriodicSchedulerImpl[F[_]: ConcurrentEffect: ContextShift](
|
||||
val config: PeriodicSchedulerConfig,
|
||||
sch: Scheduler[F],
|
||||
queue: JobQueue[F],
|
||||
store: PeriodicTaskStore[F],
|
||||
client: JoexClient[F],
|
||||
waiter: SignallingRef[F, Boolean],
|
||||
state: SignallingRef[F, State[F]],
|
||||
timer: Timer[F]
|
||||
) extends PeriodicScheduler[F] {
|
||||
private[this] val logger = getLogger
|
||||
implicit private val _timer: Timer[F] = timer
|
||||
|
||||
def start: Stream[F, Nothing] =
|
||||
logger.sinfo("Starting periodic scheduler") ++
|
||||
mainLoop
|
||||
|
||||
def shutdown: F[Unit] =
|
||||
state.modify(_.requestShutdown)
|
||||
|
||||
def periodicAwake: F[Fiber[F, Unit]] =
|
||||
ConcurrentEffect[F].start(
|
||||
Stream
|
||||
.awakeEvery[F](config.wakeupPeriod.toScala)
|
||||
.evalMap(_ => logger.fdebug("Periodic awake reached") *> notifyChange)
|
||||
.compile
|
||||
.drain
|
||||
)
|
||||
|
||||
def notifyChange: F[Unit] =
|
||||
waiter.update(b => !b)
|
||||
|
||||
// internal
|
||||
|
||||
/**
|
||||
* On startup, get all periodic jobs from this scheduler and remove
|
||||
* the mark, so they get picked up again.
|
||||
*/
|
||||
def init: F[Unit] =
|
||||
logError("Error clearing marks")(store.clearMarks(config.name))
|
||||
|
||||
def mainLoop: Stream[F, Nothing] = {
|
||||
val body: F[Boolean] =
|
||||
for {
|
||||
_ <- logger.fdebug(s"Going into main loop")
|
||||
now <- Timestamp.current[F]
|
||||
_ <- logger.fdebug(s"Looking for next periodic task")
|
||||
go <- logThrow("Error getting next task")(
|
||||
store
|
||||
.takeNext(config.name, None)
|
||||
.use({
|
||||
case Marked.Found(pj) =>
|
||||
logger
|
||||
.fdebug(s"Found periodic task '${pj.subject}/${pj.timer.asString}'") *>
|
||||
(if (isTriggered(pj, now)) submitJob(pj)
|
||||
else scheduleNotify(pj).map(_ => false))
|
||||
case Marked.NotFound =>
|
||||
logger.fdebug("No periodic task found") *> false.pure[F]
|
||||
case Marked.NotMarkable =>
|
||||
logger.fdebug("Periodic job cannot be marked. Trying again.") *> true
|
||||
.pure[F]
|
||||
})
|
||||
)
|
||||
} yield go
|
||||
|
||||
Stream
|
||||
.eval(state.get.map(_.shutdownRequest))
|
||||
.evalTap(
|
||||
if (_) logger.finfo[F]("Stopping main loop due to shutdown request.")
|
||||
else ().pure[F]
|
||||
)
|
||||
.flatMap(if (_) Stream.empty else Stream.eval(cancelNotify *> body))
|
||||
.flatMap({
|
||||
case true =>
|
||||
mainLoop
|
||||
case false =>
|
||||
logger.sdebug(s"Waiting for notify") ++
|
||||
waiter.discrete.take(2).drain ++
|
||||
logger.sdebug(s"Notify signal, going into main loop") ++
|
||||
mainLoop
|
||||
})
|
||||
}
|
||||
|
||||
def isTriggered(pj: RPeriodicTask, now: Timestamp): Boolean =
|
||||
pj.nextrun < now
|
||||
|
||||
def submitJob(pj: RPeriodicTask): F[Boolean] =
|
||||
store
|
||||
.findNonFinalJob(pj.id)
|
||||
.flatMap({
|
||||
case Some(job) =>
|
||||
logger.finfo[F](
|
||||
s"There is already a job with non-final state '${job.state}' in the queue"
|
||||
) *> scheduleNotify(pj) *> false.pure[F]
|
||||
|
||||
case None =>
|
||||
logger.finfo[F](s"Submitting job for periodic task '${pj.task.id}'") *>
|
||||
pj.toJob.flatMap(queue.insert) *> notifyJoex *> true.pure[F]
|
||||
})
|
||||
|
||||
def notifyJoex: F[Unit] =
|
||||
sch.notifyChange *> store.findJoexNodes.flatMap(
|
||||
_.traverse(n => client.notifyJoexIgnoreErrors(n.url)).map(_ => ())
|
||||
)
|
||||
|
||||
def scheduleNotify(pj: RPeriodicTask): F[Unit] =
|
||||
Timestamp
|
||||
.current[F]
|
||||
.flatMap(now =>
|
||||
logger.fdebug(
|
||||
s"Scheduling next notify for timer ${pj.timer.asString} -> ${pj.timer.nextElapse(now.toUtcDateTime)}"
|
||||
)
|
||||
) *>
|
||||
ConcurrentEffect[F]
|
||||
.start(
|
||||
CalevFs2
|
||||
.sleep[F](pj.timer)
|
||||
.evalMap(_ => notifyChange)
|
||||
.compile
|
||||
.drain
|
||||
)
|
||||
.flatMap(fb => state.modify(_.setNotify(fb)))
|
||||
|
||||
def cancelNotify: F[Unit] =
|
||||
state
|
||||
.modify(_.clearNotify)
|
||||
.flatMap({
|
||||
case Some(fb) =>
|
||||
fb.cancel
|
||||
case None =>
|
||||
().pure[F]
|
||||
})
|
||||
|
||||
private def logError(msg: => String)(fa: F[Unit]): F[Unit] =
|
||||
fa.attempt.flatMap {
|
||||
case Right(_) => ().pure[F]
|
||||
case Left(ex) => logger.ferror(ex)(msg).map(_ => ())
|
||||
}
|
||||
|
||||
private def logThrow[A](msg: => String)(fa: F[A]): F[A] =
|
||||
fa.attempt
|
||||
.flatMap({
|
||||
case r @ Right(_) => (r: Either[Throwable, A]).pure[F]
|
||||
case l @ Left(ex) => logger.ferror(ex)(msg).map(_ => (l: Either[Throwable, A]))
|
||||
})
|
||||
.rethrow
|
||||
}
|
||||
|
||||
object PeriodicSchedulerImpl {
|
||||
def emptyState[F[_]]: State[F] =
|
||||
State(false, None)
|
||||
|
||||
case class State[F[_]](
|
||||
shutdownRequest: Boolean,
|
||||
scheduledNotify: Option[Fiber[F, Unit]]
|
||||
) {
|
||||
def requestShutdown: (State[F], Unit) =
|
||||
(copy(shutdownRequest = true), ())
|
||||
|
||||
def setNotify(fb: Fiber[F, Unit]): (State[F], Unit) =
|
||||
(copy(scheduledNotify = Some(fb)), ())
|
||||
|
||||
def clearNotify: (State[F], Option[Fiber[F, Unit]]) =
|
||||
(copy(scheduledNotify = None), scheduledNotify)
|
||||
|
||||
}
|
||||
}
|
@ -34,6 +34,9 @@ case class SchedulerBuilder[F[_]: ConcurrentEffect: ContextShift](
|
||||
def withLogSink(sink: LogSink[F]): SchedulerBuilder[F] =
|
||||
copy(logSink = sink)
|
||||
|
||||
def withQueue(queue: JobQueue[F]): SchedulerBuilder[F] =
|
||||
copy(queue = Resource.pure[F, JobQueue[F]](queue))
|
||||
|
||||
def serve: Resource[F, Scheduler[F]] =
|
||||
resource.evalMap(sch => ConcurrentEffect[F].start(sch.start.compile.drain).map(_ => sch))
|
||||
|
||||
|
@ -4,6 +4,7 @@ import cats.implicits._
|
||||
import cats.{Applicative, ApplicativeError, FlatMap, Functor}
|
||||
import cats.data.Kleisli
|
||||
import cats.effect.Sync
|
||||
import docspell.common.Logger
|
||||
|
||||
/**
|
||||
* The code that is executed by the scheduler
|
||||
@ -51,4 +52,7 @@ object Task {
|
||||
|
||||
def setProgress[F[_]: Sync, A, B](n: Int)(data: B): Task[F, A, B] =
|
||||
Task(_.setProgress(n).map(_ => data))
|
||||
|
||||
def log[F[_]](f: Logger[F] => F[Unit]): Task[F, Unit, Unit] =
|
||||
Task(ctx => f(ctx.logger))
|
||||
}
|
||||
|
@ -0,0 +1,59 @@
|
||||
package docspell.joexapi.client
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect._
|
||||
import docspell.common.{Ident, LenientUri}
|
||||
import docspell.common.syntax.all._
|
||||
import org.http4s.{Method, Request, Uri}
|
||||
import org.http4s.client.Client
|
||||
import org.http4s.client.blaze.BlazeClientBuilder
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import org.log4s.getLogger
|
||||
|
||||
trait JoexClient[F[_]] {
|
||||
|
||||
def notifyJoex(base: LenientUri): F[Unit]
|
||||
|
||||
def notifyJoexIgnoreErrors(base: LenientUri): F[Unit]
|
||||
|
||||
def cancelJob(base: LenientUri, job: Ident): F[Unit]
|
||||
|
||||
}
|
||||
|
||||
object JoexClient {
|
||||
|
||||
private[this] val logger = getLogger
|
||||
|
||||
def apply[F[_]: Sync](client: Client[F]): JoexClient[F] =
|
||||
new JoexClient[F] {
|
||||
def notifyJoex(base: LenientUri): F[Unit] = {
|
||||
val notifyUrl = base / "api" / "v1" / "notify"
|
||||
val req = Request[F](Method.POST, uri(notifyUrl))
|
||||
logger.fdebug(s"Notify joex at ${notifyUrl.asString}") *>
|
||||
client.expect[String](req).map(_ => ())
|
||||
}
|
||||
|
||||
def notifyJoexIgnoreErrors(base: LenientUri): F[Unit] =
|
||||
notifyJoex(base).attempt.map {
|
||||
case Right(()) => ()
|
||||
case Left(ex) =>
|
||||
logger.warn(
|
||||
s"Notifying Joex instance '${base.asString}' failed: ${ex.getMessage}"
|
||||
)
|
||||
()
|
||||
}
|
||||
|
||||
def cancelJob(base: LenientUri, job: Ident): F[Unit] = {
|
||||
val cancelUrl = base / "api" / "v1" / "job" / job.id / "cancel"
|
||||
val req = Request[F](Method.POST, uri(cancelUrl))
|
||||
client.expect[String](req).map(_ => ())
|
||||
}
|
||||
|
||||
private def uri(u: LenientUri): Uri =
|
||||
Uri.unsafeFromString(u.asString)
|
||||
}
|
||||
|
||||
def resource[F[_]: ConcurrentEffect](ec: ExecutionContext): Resource[F, JoexClient[F]] =
|
||||
BlazeClientBuilder[F](ec).resource.map(apply[F])
|
||||
}
|
@ -16,3 +16,4 @@ title: ADRs
|
||||
- [0009 Convert Office Files](adr/0009_convert_office_docs)
|
||||
- [0010 Convert Image Files](adr/0010_convert_image_files)
|
||||
- [0011 Extract Text](adr/0011_extract_text)
|
||||
- [0012 Periodic Tasks](adr/0012_periodic_tasks)
|
||||
|
102
modules/microsite/docs/dev/adr/0012_periodic_tasks.md
Normal file
102
modules/microsite/docs/dev/adr/0012_periodic_tasks.md
Normal file
@ -0,0 +1,102 @@
|
||||
---
|
||||
layout: docs
|
||||
title: Periodic Tasks
|
||||
---
|
||||
|
||||
# Periodic Tasks
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Currently there is a `Scheduler` that consumes tasks off a queue in
|
||||
the database. This allows multiple job executors running in parallel
|
||||
racing for the next job to execute. This is for executing tasks
|
||||
immediately – as long as there are enough resource.
|
||||
|
||||
What is missing, is a component that maintains periodic tasks. The
|
||||
reason for this is to have house keeping tasks that run regularily and
|
||||
clean up stale or unused data. Later, users should be able to create
|
||||
periodic tasks, for example to read e-mails from an inbox.
|
||||
|
||||
The problem is again, that it must work with multiple job executor
|
||||
instances running at the same time. This is the same pattern as with
|
||||
the `Scheduler`: it must be ensured that only one task is used at a
|
||||
time. Multiple job exectuors must not schedule a perdiodic task more
|
||||
than once. If a periodic tasks takes longer than the time between
|
||||
runs, it must wait for the next interval.
|
||||
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Adding a `timer` and `nextrun` field to the current `job` table
|
||||
2. Creating a separate table for periodic tasks
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
The 2. option.
|
||||
|
||||
For internal housekeeping tasks, it may suffice to reuse the existing
|
||||
`job` queue by adding more fields such that a job may be considered
|
||||
periodic. But this conflates with what the `Scheduler` is doing now
|
||||
(executing tasks as soon as possible while being bound to some
|
||||
resources) with a completely different subject.
|
||||
|
||||
There will be a new `PeriodicScheduler` that works on a new table in
|
||||
the database that is representing periodic tasks. This table will
|
||||
share fields with the `job` table to be able to create `RJob`
|
||||
instances. This new component is only taking care of periodically
|
||||
submitting jobs to the job queue such that the `Scheduler` will
|
||||
eventually pick it up and run it.
|
||||
|
||||
```sql
|
||||
CREATE TABLE "periodic_task" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"enabled" boolean not null,
|
||||
"task" varchar(254) not null,
|
||||
"group_" varchar(254) not null,
|
||||
"args" text not null,
|
||||
"subject" varchar(254) not null,
|
||||
"submitter" varchar(254) not null,
|
||||
"priority" int not null,
|
||||
"worker" varchar(254),
|
||||
"marked" timestamp,
|
||||
"timer" varchar(254) not null,
|
||||
"nextrun" timestamp not null,
|
||||
"created" timestamp not null
|
||||
);
|
||||
```
|
||||
|
||||
Preparing for other features, periodic tasks will be created by users.
|
||||
It should be possible to disable/enable them. The next 6 properties
|
||||
are needed to insert jobs into the `job` table. The `worker` field
|
||||
(and `marked`) are used to mark a periodic job as "being worked on by
|
||||
a job executor".
|
||||
|
||||
The `timer` is the schedule, which is a
|
||||
[systemd-like](https://man.cx/systemd.time#heading7) calendar event
|
||||
string. This is parsed by [this
|
||||
library](https://github.com/eikek/calev). The `nextrun` field will
|
||||
store the timestamp of the next time the task would need to be
|
||||
executed. This is needed to query this table for the newest task.
|
||||
|
||||
The `PeriodicScheduler` works roughly like this:
|
||||
|
||||
On startup:
|
||||
- Remove stale worker values. If the process has been killed, there
|
||||
may be marked tasks which must be cleared now.
|
||||
|
||||
Main-Loop:
|
||||
0. Cancel current scheduled notify (see 4. below)
|
||||
1. get next (= earliest & enabled) periodic job
|
||||
2. if none: stop
|
||||
3. if triggered (= `nextrun <= 'now'`):
|
||||
- Mark periodic task. On fail: goto 1.
|
||||
- Submit new job into the jobqueue:
|
||||
- Update `nextrun` field
|
||||
- Check for non-final jobs of that name. This is required to not
|
||||
run the same periodic task multiple times concurrently.
|
||||
- if exist: goto 4.
|
||||
- if not exist: submit job
|
||||
- Unmark periodic task
|
||||
4. if future
|
||||
- schedule notify: notify self to run again next time the task
|
||||
schedule triggers
|
@ -1,3 +1,8 @@
|
||||
---
|
||||
layout: docs
|
||||
title: Short Title
|
||||
---
|
||||
|
||||
# [short title of solved problem and solution]
|
||||
|
||||
* Status: [proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)] <!-- optional -->
|
||||
|
@ -118,7 +118,7 @@ trait Conversions {
|
||||
)
|
||||
|
||||
def mkItemList(v: Vector[OItem.ListItem]): ItemLightList = {
|
||||
val groups = v.groupBy(item => item.date.toDate.toString.substring(0, 7))
|
||||
val groups = v.groupBy(item => item.date.toUtcDate.toString.substring(0, 7))
|
||||
|
||||
def mkGroup(g: (String, Vector[OItem.ListItem])): ItemLightGroup =
|
||||
ItemLightGroup(g._1, g._2.map(mkItemLight).toList)
|
||||
|
@ -0,0 +1,18 @@
|
||||
CREATE TABLE `periodic_task` (
|
||||
`id` varchar(254) not null primary key,
|
||||
`enabled` boolean not null,
|
||||
`task` varchar(254) not null,
|
||||
`group_` varchar(254) not null,
|
||||
`args` text not null,
|
||||
`subject` varchar(254) not null,
|
||||
`submitter` varchar(254) not null,
|
||||
`priority` int not null,
|
||||
`worker` varchar(254),
|
||||
`marked` timestamp,
|
||||
`timer` varchar(254) not null,
|
||||
`nextrun` timestamp not null,
|
||||
`created` timestamp not null
|
||||
);
|
||||
|
||||
CREATE INDEX `periodic_task_nextrun_idx` ON `periodic_task`(`nextrun`);
|
||||
CREATE INDEX `periodic_task_worker_idx` ON `periodic_task`(`worker`);
|
@ -0,0 +1,18 @@
|
||||
CREATE TABLE "periodic_task" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"enabled" boolean not null,
|
||||
"task" varchar(254) not null,
|
||||
"group_" varchar(254) not null,
|
||||
"args" text not null,
|
||||
"subject" varchar(254) not null,
|
||||
"submitter" varchar(254) not null,
|
||||
"priority" int not null,
|
||||
"worker" varchar(254),
|
||||
"marked" timestamp,
|
||||
"timer" varchar(254) not null,
|
||||
"nextrun" timestamp not null,
|
||||
"created" timestamp not null
|
||||
);
|
||||
|
||||
CREATE INDEX "periodic_task_nextrun_idx" ON "periodic_task"("nextrun");
|
||||
CREATE INDEX "periodic_task_worker_idx" ON "periodic_task"("worker");
|
@ -30,6 +30,9 @@ case class Column(name: String, ns: String = "", alias: String = "") {
|
||||
def is(c: Column): Fragment =
|
||||
f ++ fr"=" ++ c.f
|
||||
|
||||
def isNot[A: Put](value: A): Fragment =
|
||||
f ++ fr"<> $value"
|
||||
|
||||
def isNull: Fragment =
|
||||
f ++ fr"is null"
|
||||
|
||||
|
@ -7,6 +7,7 @@ import doobie._
|
||||
import doobie.implicits.legacy.instant._
|
||||
import doobie.util.log.Success
|
||||
import emil.{MailAddress, SSLType}
|
||||
import com.github.eikek.calev.CalEvent
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
@ -98,6 +99,9 @@ trait DoobieMeta {
|
||||
Meta[String].imap(str => str.split(',').toList.map(_.trim).map(EmilUtil.unsafeReadMailAddress))(
|
||||
lma => lma.map(EmilUtil.mailAddressString).mkString(",")
|
||||
)
|
||||
|
||||
implicit val metaCalEvent: Meta[CalEvent] =
|
||||
Meta[String].timap(CalEvent.unsafe)(_.asString)
|
||||
}
|
||||
|
||||
object DoobieMeta extends DoobieMeta {
|
||||
|
@ -0,0 +1,65 @@
|
||||
package docspell.store.queries
|
||||
|
||||
import docspell.common._
|
||||
import docspell.store.impl.Implicits._
|
||||
import docspell.store.records._
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
|
||||
object QPeriodicTask {
|
||||
|
||||
def clearWorkers(name: Ident): ConnectionIO[Int] = {
|
||||
val worker = RPeriodicTask.Columns.worker
|
||||
updateRow(RPeriodicTask.table, worker.is(name), worker.setTo[Ident](None)).update.run
|
||||
}
|
||||
|
||||
def setWorker(pid: Ident, name: Ident, ts: Timestamp): ConnectionIO[Int] = {
|
||||
val id = RPeriodicTask.Columns.id
|
||||
val worker = RPeriodicTask.Columns.worker
|
||||
val marked = RPeriodicTask.Columns.marked
|
||||
updateRow(
|
||||
RPeriodicTask.table,
|
||||
and(id.is(pid), worker.isNull),
|
||||
commas(worker.setTo(name), marked.setTo(ts))
|
||||
).update.run
|
||||
}
|
||||
|
||||
def unsetWorker(
|
||||
pid: Ident,
|
||||
nextRun: Option[Timestamp]
|
||||
): ConnectionIO[Int] = {
|
||||
val id = RPeriodicTask.Columns.id
|
||||
val worker = RPeriodicTask.Columns.worker
|
||||
val next = RPeriodicTask.Columns.nextrun
|
||||
updateRow(
|
||||
RPeriodicTask.table,
|
||||
id.is(pid),
|
||||
commas(worker.setTo[Ident](None), next.setTo(nextRun))
|
||||
).update.run
|
||||
}
|
||||
|
||||
def findNext(excl: Option[Ident]): ConnectionIO[Option[RPeriodicTask]] = {
|
||||
val enabled = RPeriodicTask.Columns.enabled
|
||||
val pid = RPeriodicTask.Columns.id
|
||||
val order = orderBy(RPeriodicTask.Columns.nextrun.f) ++ fr"ASC"
|
||||
|
||||
val where = excl match {
|
||||
case Some(id) => and(pid.isNot(id), enabled.is(true))
|
||||
case None => enabled.is(true)
|
||||
}
|
||||
val sql =
|
||||
selectSimple(RPeriodicTask.Columns.all, RPeriodicTask.table, where) ++ order
|
||||
sql.query[RPeriodicTask].streamWithChunkSize(2).take(1).compile.last
|
||||
}
|
||||
|
||||
def findNonFinal(pid: Ident): ConnectionIO[Option[RJob]] =
|
||||
selectSimple(
|
||||
RJob.Columns.all,
|
||||
RJob.table,
|
||||
and(
|
||||
RJob.Columns.tracker.is(pid),
|
||||
RJob.Columns.state.isOneOf(JobState.all.diff(JobState.done).toSeq)
|
||||
)
|
||||
).query[RJob].option
|
||||
|
||||
}
|
@ -29,7 +29,7 @@ object JobQueue {
|
||||
worker: Ident,
|
||||
retryPause: Duration
|
||||
): F[Option[RJob]] =
|
||||
logger.fdebug("Select next job") *> QJob.takeNextJob(store)(prio, worker, retryPause)
|
||||
logger.ftrace("Select next job") *> QJob.takeNextJob(store)(prio, worker, retryPause)
|
||||
|
||||
def insert(job: RJob): F[Unit] =
|
||||
store
|
||||
|
@ -0,0 +1,17 @@
|
||||
package docspell.store.queue
|
||||
|
||||
sealed trait Marked[+A] {}
|
||||
|
||||
object Marked {
|
||||
|
||||
final case class Found[A](value: A) extends Marked[A]
|
||||
|
||||
final case object NotFound extends Marked[Nothing]
|
||||
|
||||
final case object NotMarkable extends Marked[Nothing]
|
||||
|
||||
|
||||
def found[A](v: A): Marked[A] = Found(v)
|
||||
def notFound[A]: Marked[A] = NotFound
|
||||
def notMarkable[A]: Marked[A] = NotMarkable
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
package docspell.store.queue
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import org.log4s.getLogger
|
||||
import com.github.eikek.fs2calev._
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records._
|
||||
import docspell.store.queries.QPeriodicTask
|
||||
|
||||
trait PeriodicTaskStore[F[_]] {
|
||||
|
||||
/** Get the free periodic task due next and reserve it to the given
|
||||
* worker.
|
||||
*
|
||||
* If found, the task is returned and resource finalization takes
|
||||
* care of unmarking the task after use and updating `nextRun` with
|
||||
* the next timestamp.
|
||||
*/
|
||||
def takeNext(
|
||||
worker: Ident,
|
||||
excludeId: Option[Ident]
|
||||
): Resource[F, Marked[RPeriodicTask]]
|
||||
|
||||
def clearMarks(name: Ident): F[Unit]
|
||||
|
||||
def findNonFinalJob(pjobId: Ident): F[Option[RJob]]
|
||||
|
||||
/** Insert a task or update if it already exists.
|
||||
*/
|
||||
def insert(task: RPeriodicTask): F[Unit]
|
||||
|
||||
/** Adds the task only if it not already exists.
|
||||
*/
|
||||
def add(task: RPeriodicTask): F[AddResult]
|
||||
|
||||
def findJoexNodes: F[Vector[RNode]]
|
||||
}
|
||||
|
||||
object PeriodicTaskStore {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
def create[F[_]: Sync](store: Store[F]): Resource[F, PeriodicTaskStore[F]] =
|
||||
Resource.pure[F, PeriodicTaskStore[F]](new PeriodicTaskStore[F] {
|
||||
|
||||
def takeNext(
|
||||
worker: Ident,
|
||||
excludeId: Option[Ident]
|
||||
): Resource[F, Marked[RPeriodicTask]] = {
|
||||
val chooseNext: F[Marked[RPeriodicTask]] =
|
||||
getNext(excludeId).flatMap {
|
||||
case Some(pj) =>
|
||||
mark(pj.id, worker).map {
|
||||
case true => Marked.found(pj.copy(worker = worker.some))
|
||||
case false => Marked.notMarkable
|
||||
}
|
||||
case None =>
|
||||
Marked.notFound.pure[F]
|
||||
}
|
||||
|
||||
Resource.make(chooseNext)({
|
||||
case Marked.Found(pj) => unmark(pj)
|
||||
case _ => ().pure[F]
|
||||
})
|
||||
}
|
||||
|
||||
def getNext(excl: Option[Ident]): F[Option[RPeriodicTask]] =
|
||||
store.transact(QPeriodicTask.findNext(excl))
|
||||
|
||||
def mark(pid: Ident, name: Ident): F[Boolean] =
|
||||
Timestamp
|
||||
.current[F]
|
||||
.flatMap(now =>
|
||||
store.transact(QPeriodicTask.setWorker(pid, name, now)).map(_ > 0)
|
||||
)
|
||||
|
||||
def unmark(job: RPeriodicTask): F[Unit] =
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
nextRun <- CalevFs2
|
||||
.nextElapses[F](now.atUTC)(job.timer)
|
||||
.take(1)
|
||||
.compile
|
||||
.last
|
||||
.map(_.map(Timestamp.from))
|
||||
_ <- store.transact(QPeriodicTask.unsetWorker(job.id, nextRun))
|
||||
} yield ()
|
||||
|
||||
def clearMarks(name: Ident): F[Unit] =
|
||||
store
|
||||
.transact(QPeriodicTask.clearWorkers(name))
|
||||
.flatMap { n =>
|
||||
if (n > 0) logger.finfo(s"Clearing $n periodic tasks from worker ${name.id}")
|
||||
else ().pure[F]
|
||||
}
|
||||
|
||||
def findNonFinalJob(pjobId: Ident): F[Option[RJob]] =
|
||||
store.transact(QPeriodicTask.findNonFinal(pjobId))
|
||||
|
||||
def insert(task: RPeriodicTask): F[Unit] = {
|
||||
val update = store.transact(RPeriodicTask.update(task))
|
||||
val insertAttempt = store.transact(RPeriodicTask.insert(task)).attempt.map {
|
||||
case Right(n) => n > 0
|
||||
case Left(_) => false
|
||||
}
|
||||
|
||||
for {
|
||||
n1 <- update
|
||||
ins <- if (n1 == 0) insertAttempt else true.pure[F]
|
||||
_ <- if (ins) 1.pure[F] else update
|
||||
} yield ()
|
||||
}
|
||||
|
||||
def add(task: RPeriodicTask): F[AddResult] = {
|
||||
val insert = RPeriodicTask.insert(task)
|
||||
val exists = RPeriodicTask.exists(task.id)
|
||||
store.add(insert, exists)
|
||||
}
|
||||
|
||||
def findJoexNodes: F[Vector[RNode]] =
|
||||
store.transact(RNode.findAll(NodeType.Joex))
|
||||
|
||||
})
|
||||
}
|
@ -46,4 +46,7 @@ object RInvitation {
|
||||
_ <- delete(invite)
|
||||
} yield inv > 0
|
||||
}
|
||||
|
||||
def deleteOlderThan(ts: Timestamp): ConnectionIO[Int] =
|
||||
deleteFrom(table, created.isLt(ts)).update.run
|
||||
}
|
||||
|
@ -1,12 +1,15 @@
|
||||
package docspell.store.records
|
||||
|
||||
import cats.effect.Sync
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
import io.circe.Encoder
|
||||
|
||||
import docspell.common._
|
||||
import docspell.store.impl.Column
|
||||
import docspell.store.impl.Implicits._
|
||||
import io.circe.Encoder
|
||||
|
||||
case class RJob(
|
||||
id: Ident,
|
||||
@ -227,7 +230,8 @@ object RJob {
|
||||
}
|
||||
|
||||
def selectGroupInState(states: Seq[JobState]): ConnectionIO[Vector[Ident]] = {
|
||||
val sql = selectDistinct(List(group), table, state.isOneOf(states)) ++ orderBy(group.f)
|
||||
val sql =
|
||||
selectDistinct(List(group), table, state.isOneOf(states)) ++ orderBy(group.f)
|
||||
sql.query[Ident].to[Vector]
|
||||
}
|
||||
|
||||
@ -236,4 +240,19 @@ object RJob {
|
||||
n0 <- RJobLog.deleteAll(jobId)
|
||||
n1 <- deleteFrom(table, id.is(jobId)).update.run
|
||||
} yield n0 + n1
|
||||
|
||||
def findIdsDoneAndOlderThan(ts: Timestamp): Stream[ConnectionIO, Ident] =
|
||||
selectSimple(
|
||||
Seq(id),
|
||||
table,
|
||||
and(state.isOneOf(JobState.done.toSeq), or(finished.isNull, finished.isLt(ts)))
|
||||
).query[Ident].stream
|
||||
|
||||
def deleteDoneAndOlderThan(ts: Timestamp, batch: Int): ConnectionIO[Int] =
|
||||
findIdsDoneAndOlderThan(ts)
|
||||
.take(batch.toLong)
|
||||
.evalMap(delete)
|
||||
.map(_ => 1)
|
||||
.compile
|
||||
.foldMonoid
|
||||
}
|
||||
|
@ -0,0 +1,176 @@
|
||||
package docspell.store.records
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
import com.github.eikek.calev.CalEvent
|
||||
import docspell.common._
|
||||
import docspell.store.impl.Column
|
||||
import docspell.store.impl.Implicits._
|
||||
import io.circe.Encoder
|
||||
|
||||
/** A periodic task is a special job description, that shares a few
|
||||
* properties of a `RJob`. It must provide all information to create
|
||||
* a `RJob` value eventually.
|
||||
*/
|
||||
case class RPeriodicTask(
|
||||
id: Ident,
|
||||
enabled: Boolean,
|
||||
task: Ident,
|
||||
group: Ident,
|
||||
args: String,
|
||||
subject: String,
|
||||
submitter: Ident,
|
||||
priority: Priority,
|
||||
worker: Option[Ident],
|
||||
marked: Option[Timestamp],
|
||||
timer: CalEvent,
|
||||
nextrun: Timestamp,
|
||||
created: Timestamp
|
||||
) {
|
||||
|
||||
def toJob[F[_]: Sync]: F[RJob] =
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
jid <- Ident.randomId[F]
|
||||
} yield RJob(
|
||||
jid,
|
||||
task,
|
||||
group,
|
||||
args,
|
||||
subject,
|
||||
now,
|
||||
submitter,
|
||||
priority,
|
||||
JobState.Waiting,
|
||||
0,
|
||||
0,
|
||||
Some(id),
|
||||
None,
|
||||
None,
|
||||
None
|
||||
)
|
||||
}
|
||||
|
||||
object RPeriodicTask {
|
||||
|
||||
def create[F[_]: Sync](
|
||||
enabled: Boolean,
|
||||
task: Ident,
|
||||
group: Ident,
|
||||
args: String,
|
||||
subject: String,
|
||||
submitter: Ident,
|
||||
priority: Priority,
|
||||
timer: CalEvent
|
||||
): F[RPeriodicTask] =
|
||||
Ident
|
||||
.randomId[F]
|
||||
.flatMap(id =>
|
||||
Timestamp
|
||||
.current[F]
|
||||
.map { now =>
|
||||
RPeriodicTask(
|
||||
id,
|
||||
enabled,
|
||||
task,
|
||||
group,
|
||||
args,
|
||||
subject,
|
||||
submitter,
|
||||
priority,
|
||||
None,
|
||||
None,
|
||||
timer,
|
||||
timer
|
||||
.nextElapse(now.atZone(Timestamp.UTC))
|
||||
.map(_.toInstant)
|
||||
.map(Timestamp.apply)
|
||||
.getOrElse(Timestamp.Epoch),
|
||||
now
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def createJson[F[_]: Sync, A](
|
||||
enabled: Boolean,
|
||||
task: Ident,
|
||||
group: Ident,
|
||||
args: A,
|
||||
subject: String,
|
||||
submitter: Ident,
|
||||
priority: Priority,
|
||||
timer: CalEvent
|
||||
)(implicit E: Encoder[A]): F[RPeriodicTask] =
|
||||
create[F](enabled, task, group, E(args).noSpaces, subject, submitter, priority, timer)
|
||||
|
||||
val table = fr"periodic_task"
|
||||
|
||||
object Columns {
|
||||
val id = Column("id")
|
||||
val enabled = Column("enabled")
|
||||
val task = Column("task")
|
||||
val group = Column("group_")
|
||||
val args = Column("args")
|
||||
val subject = Column("subject")
|
||||
val submitter = Column("submitter")
|
||||
val priority = Column("priority")
|
||||
val worker = Column("worker")
|
||||
val marked = Column("marked")
|
||||
val timer = Column("timer")
|
||||
val nextrun = Column("nextrun")
|
||||
val created = Column("created")
|
||||
val all = List(
|
||||
id,
|
||||
enabled,
|
||||
task,
|
||||
group,
|
||||
args,
|
||||
subject,
|
||||
submitter,
|
||||
priority,
|
||||
worker,
|
||||
marked,
|
||||
timer,
|
||||
nextrun,
|
||||
created
|
||||
)
|
||||
}
|
||||
|
||||
import Columns._
|
||||
|
||||
def insert(v: RPeriodicTask): ConnectionIO[Int] = {
|
||||
val sql = insertRow(
|
||||
table,
|
||||
all,
|
||||
fr"${v.id},${v.enabled},${v.task},${v.group},${v.args}," ++
|
||||
fr"${v.subject},${v.submitter},${v.priority},${v.worker}," ++
|
||||
fr"${v.marked},${v.timer},${v.nextrun},${v.created}"
|
||||
)
|
||||
sql.update.run
|
||||
}
|
||||
|
||||
def update(v: RPeriodicTask): ConnectionIO[Int] = {
|
||||
val sql = updateRow(
|
||||
table,
|
||||
id.is(v.id),
|
||||
commas(
|
||||
enabled.setTo(v.enabled),
|
||||
group.setTo(v.group),
|
||||
args.setTo(v.args),
|
||||
subject.setTo(v.subject),
|
||||
submitter.setTo(v.submitter),
|
||||
priority.setTo(v.priority),
|
||||
worker.setTo(v.worker),
|
||||
marked.setTo(v.marked),
|
||||
timer.setTo(v.timer),
|
||||
nextrun.setTo(v.nextrun)
|
||||
)
|
||||
)
|
||||
sql.update.run
|
||||
}
|
||||
|
||||
def exists(pid: Ident): ConnectionIO[Boolean] =
|
||||
selectCount(id, table, id.is(pid)).query[Int].unique.map(_ > 0)
|
||||
}
|
@ -7,6 +7,7 @@ object Dependencies {
|
||||
val BcryptVersion = "0.4"
|
||||
val BetterMonadicForVersion = "0.3.1"
|
||||
val BitpeaceVersion = "0.4.3"
|
||||
val CalevVersion = "0.1.0"
|
||||
val CirceVersion = "0.13.0"
|
||||
val DoobieVersion = "0.8.8"
|
||||
val EmilVersion = "0.2.0"
|
||||
@ -37,6 +38,14 @@ object Dependencies {
|
||||
val ViewerJSVersion = "0.5.8"
|
||||
|
||||
|
||||
val calevCore = Seq(
|
||||
"com.github.eikek" %% "calev-core" % CalevVersion,
|
||||
)
|
||||
val calevFs2 = Seq(
|
||||
"com.github.eikek" %% "calev-fs2" % CalevVersion
|
||||
)
|
||||
val calev = calevFs2 ++ calevCore
|
||||
|
||||
val jclOverSlf4j = Seq(
|
||||
"org.slf4j" % "jcl-over-slf4j" % Slf4jVersion
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user