From 2c30b5ec092cfdd8c646cc54fdb78035e1e5e16b Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Tue, 14 Nov 2023 13:56:23 +0800 Subject: [PATCH] add event statistics, fix CronTask - add for EventEnv a timeStartup field - cha EventListener and EventListenerManager - add for EventListener a method executeFilter used to manager if an event should be run. This replaced the condition statement inside the EventListenerManager - add for EventListener a method atEventPost, this will run at current event listener is on complete - add for MornyConfig a reportZone field - can be set by `--report-zone` - used for controlling Morny Report daemon uses the zoned time to send report. default is system default time zone. - add for MornyReport new EventStatistics and DailyReportTask - add for MornyInformation command new subcommand `event` to manually show MornyReport.EventStatistics info. - add WatchDog and MornyCoeur.watchDog, used for checking if the machine is in sleep mode and notify the MornyCoeur.tasks to avoid timing problem - fix CronTask frequency got initialize problem - add slf4j-nop for project --- build.gradle | 13 ++- gradle.properties | 3 +- .../cc/sukazyo/cono/morny/MornyCoeur.scala | 15 ++- .../cc/sukazyo/cono/morny/MornyConfig.java | 15 +++ .../cc/sukazyo/cono/morny/ServerMain.scala | 1 + .../sukazyo/cono/morny/bot/api/EventEnv.scala | 2 + .../cono/morny/bot/api/EventListener.scala | 19 ++++ .../morny/bot/api/EventListenerManager.scala | 10 +- .../morny/bot/command/MornyInformation.scala | 14 ++- .../cono/morny/daemon/MornyDaemons.scala | 5 +- .../cono/morny/daemon/MornyReport.scala | 102 +++++++++++++++++- .../cc/sukazyo/cono/morny/util/UseMath.scala | 5 + .../cono/morny/util/schedule/CronTask.scala | 2 +- .../morny/util/schedule/RoutineTask.scala | 14 ++- .../cono/morny/util/schedule/Scheduler.scala | 79 ++++++++------ .../util/statistics/NumericStatistics.scala | 101 +++++++++++++++++ .../cono/morny/util/time/WatchDog.scala | 67 ++++++++++++ 17 files changed, 412 insertions(+), 55 deletions(-) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala diff --git a/build.gradle b/build.gradle index c3d39c3..22da9ba 100644 --- a/build.gradle +++ b/build.gradle @@ -83,20 +83,25 @@ dependencies { implementation group: 'cc.sukazyo', name: 'messiva', version: lib_messiva_v implementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v - testImplementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v implementation group: 'com.github.pengrad', name: 'java-telegram-bot-api', version: lib_javatelegramapi_v implementation group: 'com.softwaremill.sttp.client3', name: scala('core'), version: lib_sttp_v implementation group: 'com.softwaremill.sttp.client3', name: scala('okhttp-backend'), version: lib_sttp_v - implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: lib_okhttp_v + runtimeOnly group: 'com.squareup.okhttp3', name: 'okhttp', version: lib_okhttp_v implementation group: 'com.google.code.gson', name: 'gson', version: lib_gson_v implementation group: 'com.cronutils', name: 'cron-utils', version: lib_cron_utils_v + // used for disable slf4j + // due to the slf4j api have been used in the following libraries: + // - cron-utils + runtimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v + testRuntimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v + + testImplementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v testImplementation group: 'org.scalatest', name: scala('scalatest'), version: lib_scalatest_v testImplementation group: 'org.scalatest', name: scala('scalatest-freespec'), version: lib_scalatest_v testRuntimeOnly group: 'org.scala-lang.modules', name: scala('scala-xml'), version: lib_scalamodule_xml_v - - // for generating HTML report // required by gradle-scalatest plugin + // for generating HTML report: required by gradle-scalatest plugin testRuntimeOnly group: 'com.vladsch.flexmark', name: 'flexmark-all', version: '0.64.6' } diff --git a/gradle.properties b/gradle.properties index 9dcc505..70ddbdf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ MORNY_ARCHIVE_NAME = morny-coeur MORNY_CODE_STORE = https://github.com/Eyre-S/Coeur-Morny-Cono MORNY_COMMIT_PATH = https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s -VERSION = 1.3.0-dev2 +VERSION = 1.3.0-dev3 USE_DELTA = false VERSION_DELTA = @@ -19,6 +19,7 @@ lib_scalamodule_xml_v = 2.2.0 lib_messiva_v = 0.2.0 lib_resourcetools_v = 0.2.2 +lib_slf4j_v = 2.0.9 lib_javatelegramapi_v = 6.2.0 diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala index b1cc8d1..79a5dc9 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyCoeur.scala @@ -9,9 +9,11 @@ import cc.sukazyo.cono.morny.bot.event.{MornyEventListeners, MornyOnInlineQuery, import cc.sukazyo.cono.morny.bot.query.MornyQueries import cc.sukazyo.cono.morny.util.schedule.Scheduler import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis +import cc.sukazyo.cono.morny.util.time.WatchDog import com.pengrad.telegrambot.TelegramBot import com.pengrad.telegrambot.request.GetMe +import scala.annotation.unused import scala.util.boundary import scala.util.boundary.break @@ -64,10 +66,10 @@ class MornyCoeur (using val config: MornyConfig) { /** [[account]]'s telegram user id */ val userid: Long = __loginResult.userid - /** current Morny's [[MornyTrusted]] instance */ - val trusted: MornyTrusted = MornyTrusted() /** Morny's task [[Scheduler]] */ val tasks: Scheduler = Scheduler() + /** current Morny's [[MornyTrusted]] instance */ + val trusted: MornyTrusted = MornyTrusted() val daemons: MornyDaemons = MornyDaemons() //noinspection ScalaWeakerAccess @@ -80,6 +82,15 @@ class MornyCoeur (using val config: MornyConfig) { eventManager register MornyOnInlineQuery(using queries) //noinspection ScalaUnusedSymbol val events: MornyEventListeners = MornyEventListeners(using eventManager) + eventManager register daemons.reporter.EventStatistics.EventInfoCatcher + @unused + val watchDog: WatchDog = WatchDog("watch-dog", 1000, 1500, { (consumed, _) => + import cc.sukazyo.cono.morny.util.CommonFormat.formatDuration as f + logger warn + s"""Can't keep up! is the server overloaded or host machine fall asleep? + | current tick takes ${f(consumed)} to complete.""".stripMargin + tasks.notifyIt() + }) ///>>> BLOCK START instance configure & startup stage 2 diff --git a/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java b/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java index 2702254..c8ed479 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java +++ b/src/main/scala/cc/sukazyo/cono/morny/MornyConfig.java @@ -6,6 +6,7 @@ import java.lang.annotation.*; import java.time.ZoneOffset; import java.util.HashSet; import java.util.Set; +import java.util.TimeZone; public class MornyConfig { @@ -109,6 +110,18 @@ public class MornyConfig { */ public final long reportToChat; + /** + * 控制 Morny Coeur 系统的报告的基准时间. + *

+ * 仅会用于 {@link cc.sukazyo.cono.morny.daemon.MornyReport} 内的时间敏感的报告, + * 不会用于 {@code /info} 命令等位置。 + *

+ * 默认使用 {@link TimeZone#getDefault()}. + * + * @since 1.3.0 + */ + @Nonnull public final TimeZone reportZone; + /* ======================================= * * function: dinner query tool * * ======================================= */ @@ -144,6 +157,7 @@ public class MornyConfig { this.dinnerTrustedReaders = prototype.dinnerTrustedReaders; this.dinnerChatId = prototype.dinnerChatId; this.reportToChat = prototype.reportToChat; + this.reportZone = prototype.reportZone; this.medicationNotifyToChat = prototype.medicationNotifyToChat; this.medicationTimerUseTimezone = prototype.medicationTimerUseTimezone; prototype.medicationNotifyAt.forEach(i -> { if (i < 0 || i > 23) throw new CheckFailure.UnavailableTimeInMedicationNotifyAt(); }); @@ -173,6 +187,7 @@ public class MornyConfig { @Nonnull public final Set dinnerTrustedReaders = new HashSet<>(); public long dinnerChatId = -1L; public long reportToChat = -1L; + @Nonnull public TimeZone reportZone = TimeZone.getDefault(); public long medicationNotifyToChat = -1L; @Nonnull public ZoneOffset medicationTimerUseTimezone = ZoneOffset.UTC; @Nonnull public final Set medicationNotifyAt = new HashSet<>(); diff --git a/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala b/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala index e4f7870..e067cba 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/ServerMain.scala @@ -51,6 +51,7 @@ object ServerMain { case "--master" | "-mm" => i+=1 ; config.trustedMaster = args(i)toLong case "--trusted-chat" | "-trs" => i+=1 ; config.trustedChat = args(i)toLong case "--report-to" => i+=1; config.reportToChat = args(i)toLong + case "--report-zone" => i+=1; config.reportZone = TimeZone.getTimeZone(args(i)) case "--trusted-reader-dinner" | "-trsd" => i+=1 ; config.dinnerTrustedReaders add (args(i)toLong) case "--dinner-chat" | "-chd" => i+=1 ; config.dinnerChatId = args(i)toLong diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala index 6cc7dea..43a532f 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventEnv.scala @@ -1,5 +1,6 @@ package cc.sukazyo.cono.morny.bot.api +import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis import com.pengrad.telegrambot.model.Update import scala.collection.mutable @@ -12,6 +13,7 @@ class EventEnv ( private var _isOk: Int = 0 private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty + val timeStartup: EpochMillis = System.currentTimeMillis def isEventOk: Boolean = _isOk > 0 diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala index 3c2fbb9..8834060 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListener.scala @@ -2,6 +2,25 @@ package cc.sukazyo.cono.morny.bot.api trait EventListener () { + /** Determine if this event listener should be processed. + * + * Default implementation is it only be [[true]] when the event + * is not ok yet (when [[EventEnv.isEventOk]] is false). + * + * Notice that: You should not override this method to filter some + * affair level conditions (such as if this update contains a text + * message), you should write them to the listener function! This + * method is just for event low-level controls. + * + * @param env The [[EventEnv event variable]]. + * @return [[true]] if this event listener should run; [[false]] + * if it should not run. + */ + def executeFilter (using env: EventEnv): Boolean = + if env.isEventOk then false else true + + def atEventPost (using EventEnv): Unit = {} + def onMessage (using EventEnv): Unit = {} def onEditedMessage (using EventEnv): Unit = {} def onChannelPost (using EventEnv): Unit = {} diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala index de846ab..9643578 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/api/EventListenerManager.scala @@ -32,7 +32,9 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener { override def run (): Unit = { given env: EventEnv = EventEnv(update) boundary { for (i <- listeners) { - try { + + if (i.executeFilter) try { + updateThreadName("message") if update.message ne null then i.onMessage updateThreadName("edited-message") @@ -61,6 +63,10 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener { if update.chatMember ne null then i.onChatMemberUpdated updateThreadName("chat-join-request") if update.chatJoinRequest ne null then i.onChatJoinRequest + + updateThreadName("#post") + i.atEventPost + } catch case e => { val errorMessage = StringBuilder() errorMessage ++= "Event throws unexpected exception:\n" @@ -75,7 +81,7 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener { logger error errorMessage.toString coeur.daemons.reporter.exception(e, "on event running") } - if env.isEventOk then boundary.break() + }} } diff --git a/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala b/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala index b754e52..00de370 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/bot/command/MornyInformation.scala @@ -23,11 +23,12 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { val VERSION = "version" val VERSION_2 = "v" val TASKS = "tasks" + val EVENTS = "event" } override val name: String = "info" override val aliases: Array[ICommandAlias]|Null = null - override val paramRule: String = "[(version|runtime|stickers[.IDs]|tasks)]" + override val paramRule: String = "[(version|runtime|stickers[.IDs]|tasks|event)]" override val description: String = "输出当前 Morny 的各种信息" override def execute (using command: InputCommand, event: Update): Unit = { @@ -44,6 +45,7 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { case Subs.RUNTIME => echoRuntime case Subs.VERSION | Subs.VERSION_2 => echoVersion case Subs.TASKS => echoTasksStatus + case Subs.EVENTS => echoEventStatistics case _ => echo404 } @@ -159,6 +161,16 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand { ).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId) } + private def echoEventStatistics (using update: Update): Unit = { + coeur.account exec SendMessage( + update.message.chat.id, + // language=html + s"""Event Statistics : + |in today + |${coeur.daemons.reporter.EventStatistics.eventStatisticsHTML}""".stripMargin + ).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId) + } + private def echo404 (using event: Update): Unit = coeur.account exec new SendSticker( event.message.chat.id, diff --git a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala index 0a60e1a..e3256bc 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyDaemons.scala @@ -13,8 +13,8 @@ class MornyDaemons (using val coeur: MornyCoeur) { logger notice "ALL Morny Daemons starting..." - // TrackerDataManager.init(); medicationTimer.start() + reporter.start() logger notice "Morny Daemons started." @@ -24,9 +24,8 @@ class MornyDaemons (using val coeur: MornyCoeur) { logger notice "stopping All Morny Daemons..." - // TrackerDataManager.DAEMON.interrupt(); medicationTimer.stop() - // TrackerDataManager.trackingLock.lock(); + reporter.stop() logger notice "stopped ALL Morny Daemons." } diff --git a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala index d42c345..468580f 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/daemon/MornyReport.scala @@ -2,17 +2,26 @@ package cc.sukazyo.cono.morny.daemon import cc.sukazyo.cono.morny.{MornyCoeur, MornyConfig} import cc.sukazyo.cono.morny.Log.{exceptionLog, logger} +import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener} import cc.sukazyo.cono.morny.data.MornyInformation.getVersionAllFullTagHTML +import cc.sukazyo.cono.morny.util.statistics.NumericStatistics import cc.sukazyo.cono.morny.util.tgapi.event.EventRuntimeException import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.* import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec +import cc.sukazyo.cono.morny.util.EpochDateTime.DurationMillis +import cc.sukazyo.cono.morny.util.schedule.CronTask +import com.cronutils.builder.CronBuilder +import com.cronutils.model.Cron +import com.cronutils.model.definition.CronDefinitionBuilder import com.google.gson.GsonBuilder import com.pengrad.telegrambot.model.request.ParseMode import com.pengrad.telegrambot.model.User import com.pengrad.telegrambot.request.{BaseRequest, SendMessage} import com.pengrad.telegrambot.response.BaseResponse +import java.time.ZoneId + class MornyReport (using coeur: MornyCoeur) { private val enabled = coeur.config.reportToChat != -1 @@ -67,10 +76,12 @@ class MornyReport (using coeur: MornyCoeur) { // language=html s"""▌Morny Logged in |-v $getVersionAllFullTagHTML - |as user @${coeur.username} + |Logged into user: @${coeur.username} | |as config fields: - |${sectionConfigFields(coeur.config)}""" + |${sectionConfigFields(coeur.config)} + | + |Report Daemon will use TimeZone ${coeur.config.reportZone.getDisplayName} for following report.""" .stripMargin ).parseMode(ParseMode HTML)) } @@ -120,4 +131,91 @@ class MornyReport (using coeur: MornyCoeur) { ).parseMode(ParseMode HTML)) } + object EventStatistics { + + private var eventTotal = 0 + private val runningTime: NumericStatistics[DurationMillis] = NumericStatistics() + + def reset (): Unit = { + eventTotal = 0; runningTime.reset() + } + + private def runningTimeStatisticsHTML: String = + runningTime.value match + // language=html + case None => "<no-statistics>" + case Some(value) => + import cc.sukazyo.cono.morny.util.CommonFormat.formatDuration as f + s""" - average: ${f(value.total / value.count)} + | - max time: ${f(value.max)} + | - min time: ${f(value.min)} + | - total: ${f(value.total)}""".stripMargin + + def eventStatisticsHTML: String = + import cc.sukazyo.cono.morny.util.UseMath.percentageOf as p + val processed = runningTime.count + val ignored = eventTotal - processed + // language=html + s""" - total event received: $eventTotal + | - event processed: (${eventTotal p processed}%) $processed + | - event ignored: (${eventTotal p ignored}%) $ignored + | - processed time usage: + |${runningTimeStatisticsHTML.indent(3)}""".stripMargin + + object EventInfoCatcher extends EventListener { + override def executeFilter (using EventEnv): Boolean = true + //noinspection ScalaWeakerAccess + case class EventTimeUsed (it: DurationMillis) + override def atEventPost (using event: EventEnv): Unit = { + eventTotal += 1 + if event.isEventOk then { + val timeUsed = EventTimeUsed(System.currentTimeMillis - event.timeStartup) + event provide timeUsed + logger debug s"event consumed ${timeUsed.it}ms" + runningTime ++ timeUsed.it + } + } + } + + } + + private object DailyReportTask extends CronTask { + + import com.cronutils.model.field.expression.FieldExpressionFactory.* + + override val name: String = "reporter#event" + override val cron: Cron = CronBuilder.cron( + CronDefinitionBuilder.defineCron + .withHours.and + .instance + ).withHour(on(0)).instance + override val zone: ZoneId = coeur.config.reportZone.toZoneId + + //noinspection TypeAnnotation + override def main = { + + executeReport(SendMessage( + coeur.config.reportToChat, + // language=html + s"""▌Morny Daily Report + | + |Event Statistics : + |${EventStatistics.eventStatisticsHTML}""".stripMargin + ).parseMode(ParseMode.HTML)) + + // daily reset + EventStatistics.reset() + + } + + } + + def start (): Unit = { + coeur.tasks ++ DailyReportTask + } + + def stop (): Unit = { + coeur.tasks % DailyReportTask + } + } diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala b/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala index 5f11c38..fdbfd1b 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/UseMath.scala @@ -16,4 +16,9 @@ object UseMath { def ** (other: Int): Double = Math.pow(self, other) } + extension (base: Int) { + def percentageOf (another: Int): Int = + Math.round((another.toDouble/base)*100).toInt + } + } diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala index 5a8de3d..c6c9607 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/CronTask.scala @@ -9,7 +9,7 @@ import scala.jdk.OptionConverters.* trait CronTask extends RoutineTask { - private transparent inline def cronCalc = ExecutionTime.forCron(cron) + private lazy val cronCalc = ExecutionTime.forCron(cron) def cron: Cron diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala index dce86fe..d7904ab 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/RoutineTask.scala @@ -13,14 +13,20 @@ import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis */ trait RoutineTask extends Task { - private[schedule] var currentScheduledTimeMillis: EpochMillis = firstRoutineTimeMillis + private[schedule] var currentScheduledTimeMillis: Option[EpochMillis] = None /** Next running time of this task. * - * Should be auto generated from [[firstRoutineTimeMillis]] and - * [[nextRoutineTimeMillis]]. + * Should be auto generated from [[firstRoutineTimeMillis]] when this method + * is called at first time, and then from [[nextRoutineTimeMillis]] for following + * routines controlled by [[Scheduler]]. */ - override def scheduledTimeMillis: EpochMillis = currentScheduledTimeMillis + override def scheduledTimeMillis: EpochMillis = + currentScheduledTimeMillis match + case Some(time) => time + case None => + currentScheduledTimeMillis = Some(firstRoutineTimeMillis) + currentScheduledTimeMillis.get /** The task scheduled time at initial. * diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala index 411be39..4eb066e 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Scheduler.scala @@ -63,8 +63,7 @@ class Scheduler { private val taskList: mutable.TreeSet[Task] = mutable.TreeSet.empty private var exitAtNextRoutine = false private var waitForDone = false - private var currentRunning: Task|Null = _ - private var currentRunning_isScheduledCancel = false +// private var currentRunning: Task|Null = _ private var runtimeStatus = State.INIT private val runtime: Thread = new Thread { @@ -76,20 +75,19 @@ class Scheduler { if taskList.isEmpty then true else false else false - while (!willExit) { + taskList.synchronized { while (!willExit) { runtimeStatus = State.PREPARE_RUN - val nextMove: Task|EpochMillis|"None" = taskList.synchronized { + val nextMove: Task|EpochMillis|"None" = taskList.headOption match case Some(_readyToRun) if System.currentTimeMillis >= _readyToRun.scheduledTimeMillis => taskList -= _readyToRun - currentRunning = _readyToRun +// currentRunning = _readyToRun _readyToRun case Some(_notReady) => _notReady.scheduledTimeMillis - System.currentTimeMillis case None => "None" - } nextMove match case readyToRun: Task => @@ -104,31 +102,33 @@ class Scheduler { runtimeStatus = State.RUNNING_POST this setName s"${readyToRun.name}#post" - if currentRunning_isScheduledCancel then {} + // this if is used for check if post effect need to be + // run. It is useless since the wait/notify changes. + if false then {} else { - currentRunning match + readyToRun match case routine: RoutineTask => - routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis) match + routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis.get) match case next: EpochMillis => - routine.currentScheduledTimeMillis = next - if (!currentRunning_isScheduledCancel) schedule(routine) + routine.currentScheduledTimeMillis = Some(next) + schedule(routine) case _ => case _ => } - currentRunning = null +// currentRunning = null this setName runnerName case needToWaitMillis: EpochMillis => runtimeStatus = State.WAITING - try Thread.sleep(needToWaitMillis) - catch case _: InterruptedException => {} + try taskList.wait(needToWaitMillis) + catch case _: (InterruptedException|IllegalArgumentException) => {} case _: "None" => runtimeStatus = State.WAITING_EMPTY - try Thread.sleep(Long.MaxValue) + try taskList.wait() catch case _: InterruptedException => {} - } + }} runtimeStatus = State.END } @@ -154,9 +154,9 @@ class Scheduler { * @return [[true]] if the task is added. */ def schedule (task: Task): Boolean = - try taskList.synchronized: - taskList add task - finally runtime.interrupt() + taskList.synchronized: + try taskList add task + finally taskList.notifyAll() /** Remove the task from scheduler task queue. * @@ -172,23 +172,16 @@ class Scheduler { this /** Remove the task from scheduler task queue. * - * If the removal task is running, the current run will be done, but will - * not do the post effect of the task (like schedule the next routine - * of [[RoutineTask]]). + * If the removal task is running, the method will wait for the current run + * complete (and current run post effect complete), then do remove. * * @return [[true]] if the task is in task queue or is running, and have been * succeed removed from task queue. */ def cancel (task: Task): Boolean = - try { - val succeed = taskList.synchronized { taskList remove task } - if succeed then succeed - else if task == currentRunning then - currentRunning_isScheduledCancel = true - true - else false - } - finally runtime.interrupt() + taskList synchronized: + try taskList remove task + finally taskList.notifyAll() /** Count of tasks in the task queue. * @@ -205,6 +198,19 @@ class Scheduler { def runnerState: Thread.State = runtime.getState + /** Manually update the task scheduler. + * + * If the inner state of the scheduler somehow changed and cannot automatically + * update schedule states to schedule the new state, you can call this method + * to manually let the task scheduler reschedule it. + * + * You can also use it with some tick-guard like [[cc.sukazyo.cono.morny.util.time.WatchDog]] + * to make the scheduler avoid fails when machine fall asleep or some else conditions. + */ + def notifyIt(): Unit = + taskList synchronized: + taskList.notifyAll() + /** Stop the scheduler's runner, no matter how much task is not run yet. * * After call this, it will immediately give a signal to the runner for @@ -217,8 +223,9 @@ class Scheduler { * runner is stopped. If you want a sync version, see [[waitForStop]]. */ def stop (): Unit = - exitAtNextRoutine = true - runtime.interrupt() + taskList synchronized: + exitAtNextRoutine = true + taskList.notifyAll() /** Stop the scheduler's runner, no matter how much task is not run yet, * and wait for the runner stopped. @@ -251,8 +258,9 @@ class Scheduler { */ //noinspection ScalaWeakerAccess def tagStopAtAllDone (): Unit = - waitForDone = true - runtime.interrupt() + taskList synchronized: + waitForDone = true + taskList.notifyAll() /** Tag this scheduler runner stop when all of the scheduler's task in task * queue have been stopped, and wait for the runner stopped. @@ -264,6 +272,7 @@ class Scheduler { * thread. The interrupted status of the current * thread is cleared when this exception is thrown. */ + @throws[InterruptedException] def waitForStopAtAllDone(): Unit = tagStopAtAllDone() runtime.join() diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala b/src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala new file mode 100644 index 0000000..0d1df83 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/util/statistics/NumericStatistics.scala @@ -0,0 +1,101 @@ +package cc.sukazyo.cono.morny.util.statistics + +import scala.annotation.targetName + +/** Statistics for numbers. + * + * Gives a easy way to get amount of numbers min/max/sum value. + * + * Use [[++]] to collect a value to statistics, use [[value]] to + * get the statistic results. + * + * @param role The [[Numeric]] implementation of the given number type, + * required for numeric calculation. + * @tparam T The exactly number type + */ +class NumericStatistics [T] (using role: Numeric[T]) { + + /** Statistic state values. + * + * This class instance should only be used in the statistics manager. + * You need to converted it to [[State.Immutable]] version when expose + * it (use its [[readonly]] method). + * + * @param total The sum of all data collected. + * @param min The minimal value in the collected data. + * @param max The maximize value in the collected data. + * @param count total collected data count. + */ + class State ( + var min: T, + var max: T, + var total: T, + var count: Int + ) { + /** Generate the [[State.Immutable]] readonly copy for this. */ + def readonly: State.Immutable = State.Immutable(this) + } + object State: + /** The immutable (readonly) version [[State]]. */ + class Immutable (source: State): + /** @see [[State.min]] */ + val min: T = source.min + /** @see [[State.max]] */ + val max: T = source.max + /** @see [[State.total]] */ + val total: T = source.total + /** @see [[State.count]] */ + val count: Int = source.count + + private var state: Option[State] = None + + /** Collect a new data to the statistic. + * @return The [[NumericStatistics]] itself for chained call. + */ + @targetName("collect") + def ++ (newOne: T): this.type = + state match + case Some(current) => + if (role.lt(newOne, current.min)) current.min = newOne + if (role.gt(newOne, current.max)) current.max = newOne + current.total = role.plus(current.total, newOne) + current.count = current.count + 1 + case None => + state = Some(new State ( + min = newOne, + max = newOne, + total = newOne, + count = 1 + )) + this + + /** Reset the statistics to the initial state. + * + * All the collected data will be drop. + */ + def reset (): Unit = + state = None + + /** Get the statistic values. + * + * @return An [[Option]] contains one [[State.Immutable]] object + * which refers the statistic state when call this method. + * If the statistic have no data recorded, then it will + * be [[None]] + */ + def value: Option[State.Immutable] = + state match + case Some(v) => Some(v.readonly) + case None => None + + /** The number counts in the statistics. + * + * It will always returns a [[Int]] value, regardless if the + * statistic is collected some data. + */ + def count: Int = + state match + case Some(value) => value.count + case None => 0 + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala b/src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala new file mode 100644 index 0000000..ffa996e --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/util/time/WatchDog.scala @@ -0,0 +1,67 @@ +package cc.sukazyo.cono.morny.util.time + +import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis} + +trait WatchDog (val isDaemonIt: Boolean = true) extends Thread { + + val threadName: String = "watch-dog" + val tickSpeedMillis: DurationMillis = 1000 + val overloadMillis: DurationMillis = tickSpeedMillis + (tickSpeedMillis/2) + private var previousTickTimeMillis: Option[EpochMillis] = None + + this setName threadName + this setDaemon isDaemonIt + + this.start() + + override def run(): Unit = { + while (!this.isInterrupted) { + val currentMillis = System.currentTimeMillis() + previousTickTimeMillis match + case Some(_previousMillis) => + val consumedMillis = currentMillis - _previousMillis + if consumedMillis > overloadMillis then + this.overloaded(consumedMillis, consumedMillis - _previousMillis) + previousTickTimeMillis = Some(currentMillis) + case _ => + previousTickTimeMillis = Some(currentMillis) + try Thread.sleep(tickSpeedMillis) + catch case _: InterruptedException => + this.interrupt() + } + } + + def overloaded(consumed: DurationMillis, delayed: DurationMillis): Unit + +} + +object WatchDog { + + def apply ( + _threadName: String, _tickSpeedMillis: DurationMillis, _overloadMillis: DurationMillis, + overloadedCallback: (DurationMillis, DurationMillis) => Unit + ): WatchDog = + new WatchDog: + override val threadName: String = _threadName + override val tickSpeedMillis: DurationMillis = _tickSpeedMillis + override val overloadMillis: DurationMillis = _overloadMillis + override def overloaded (consumed: DurationMillis, delayed: DurationMillis): Unit = overloadedCallback(consumed, delayed) + + def apply ( + _threadName: String, _tickSpeedMillis: DurationMillis, + overloadedCallback: (DurationMillis, DurationMillis) => Unit + ): WatchDog = + new WatchDog: + override val threadName: String = _threadName + override val tickSpeedMillis: DurationMillis = _tickSpeedMillis + override def overloaded (consumed: DurationMillis, delayed: DurationMillis): Unit = overloadedCallback(consumed, delayed) + + def apply ( + _threadName: String, + overloadedCallback: (DurationMillis, DurationMillis) => Unit + ): WatchDog = + new WatchDog: + override val threadName: String = _threadName + override def overloaded (consumed: DurationMillis, delayed: DurationMillis): Unit = overloadedCallback(consumed, delayed) + +}