Skip to content

Commit

Permalink
Merge pull request #606 from kevin-lee/task/602/TimeSource
Browse files Browse the repository at this point in the history
Close #602 - [`effectie-time`] Add `TimeSource`
  • Loading branch information
kevin-lee committed Jan 8, 2024
2 parents 687db09 + 7c74641 commit 0200bfb
Show file tree
Hide file tree
Showing 4 changed files with 406 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package effectie.time

import cats._
import cats.syntax.all._
import effectie.core._
import effectie.syntax.all._
import effectie.time.TimeSource.TimeSpent

import java.time.Instant
import scala.concurrent.duration.{FiniteDuration, MILLISECONDS, NANOSECONDS, SECONDS, TimeUnit}

/** @author Kevin Lee
*/
trait TimeSource[F[*]] {

implicit protected def M: Monad[F]

def name: String

def currentTime(): F[Instant]

def realTimeTo(unit: TimeUnit): F[FiniteDuration]

def monotonicTo(unit: TimeUnit): F[FiniteDuration]

def realTime: F[FiniteDuration]

def monotonic: F[FiniteDuration]

def timeSpent[A](fa: => F[A]): F[(A, TimeSpent)] =
for {
start <- monotonic
a <- fa
end <- monotonic
} yield (a, TimeSpent(end - start))

override def toString: String = s"TimeSource(name=$name)"
}

object TimeSource {

def apply[F[*]: TimeSource]: TimeSource[F] = implicitly[TimeSource[F]]

implicit def showTimeSource[F[*]]: Show[TimeSource[F]] = Show.fromToString

def withSources[F[*]: Fx: Monad](
timeSourceName: String,
realTimeSource: F[Instant],
monotonicSource: F[Long],
): TimeSource[F] =
new DefaultTimeSource[F](timeSourceName, realTimeSource, monotonicSource)

private class DefaultTimeSource[F[_]: Fx](
timeSourceName: String,
realTimeSource: F[Instant],
monotonicSource: F[Long],
)(implicit protected val M: Monad[F])
extends TimeSource[F] {

override val name: String = timeSourceName

override val toString: String = s"DefaultTimeSource(name=$name)"

override def currentTime(): F[Instant] = realTimeSource

override def realTimeTo(unit: TimeUnit): F[FiniteDuration] =
for {
now <- currentTime()
real <- effectOf(unit.convert(now.getEpochSecond, SECONDS) + unit.convert(now.getNano.toLong, NANOSECONDS))
} yield FiniteDuration(real, unit)

override def monotonicTo(unit: TimeUnit): F[FiniteDuration] =
monotonicSource.map(nano => FiniteDuration(unit.convert(nano, NANOSECONDS), unit))

override def realTime: F[FiniteDuration] = realTimeTo(MILLISECONDS)

override def monotonic: F[FiniteDuration] = monotonicTo(NANOSECONDS)
}

/** The default behaviours of TimeSource depend on Instant and System.
* For realTime, it uses Instant.now() to get the epoch seconds and nano seconds.
* For monotonic, it uses System.nanoTime() to get the nano seconds.
*
* NOTE: To get the current time (Instant.now()), you should use realTime not monotonic.
*/
def default[F[*]: Fx: Monad](timeSourceName: String): TimeSource[F] =
withSources(
timeSourceName,
effectOf(Instant.now()),
effectOf(System.nanoTime()),
)

final case class TimeSpent(timeSpent: FiniteDuration) extends AnyVal
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package effectie.time

import effectie.time.TimeSource.TimeSpent

import scala.concurrent.duration.FiniteDuration

/** @author Kevin Lee
Expand All @@ -8,6 +10,9 @@ import scala.concurrent.duration.FiniteDuration
trait syntax {
implicit def FiniteDurationExtraOps(finiteDuration: FiniteDuration): syntax.FiniteDurationExtraOps =
new syntax.FiniteDurationExtraOps(finiteDuration)

implicit def fAWithTimeOps[F[*], A](fa: F[A]): syntax.FAWithTimeOps[F[*], A] = new syntax.FAWithTimeOps[F[*], A](fa)

}
object syntax extends syntax {
final class FiniteDurationExtraOps(private val finiteDuration: FiniteDuration) extends AnyVal {
Expand All @@ -18,4 +23,8 @@ object syntax extends syntax {
finiteDuration >= approxFiniteDuration.min && finiteDuration <= approxFiniteDuration.max
}

final class FAWithTimeOps[F[*], A](private val fa: F[A]) extends AnyVal {
def withTimeSpent(implicit timeSource: TimeSource[F]): F[(A, TimeSpent)] = timeSource.timeSpent(fa)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package effectie.time

import cats.effect._
import cats.syntax.all._
import effectie.syntax.all._
import effectie.time.syntax._
import hedgehog._
import hedgehog.runner._

import java.time.Instant
import scala.concurrent.duration._

object TimeSourceSpec extends Properties {
import effectie.instances.ce2.fx.ioFx

type F[A] = IO[A]
val F = IO

implicit val timer: Timer[F] = F.timer(scala.concurrent.ExecutionContext.global)

override def tests: List[Test] = List(
example("test Show[TimeSource]", testShowTimeSource),
example("example test realTime and monotonic", testRealTimeAndMonotonicExample),
property("property test realTime and monotonic", testRealTimeAndMonotonicProperty),
example("test default TimeSource - realTime", testDefaultTimeSourceRealTime),
example("test default TimeSource - monotonic", testDefaultTimeSourceMonotonic),
property("test default TimeSource - timeSpent", testDefaultTimeSourceTimeSpent).withTests(count = 5),
)

private def runIO(test: F[Result]): Result = test.unsafeRunSync()

def testShowTimeSource: Result = {
val name = "test TimeSource"
val expected = s"DefaultTimeSource(name=$name)"
val actual = TimeSource.default[F](name).show
actual ==== expected
}

def testRealTimeAndMonotonicExample: Result = runIO {
val epochSeconds = 123456789L
val nanoSeconds = 123456789L
val fullNanos = epochSeconds * 1000000000L + nanoSeconds

val expectedMillis = fullNanos / 1000000L
val expectedNanos = fullNanos

implicit val sourcedTimeSource: TimeSource[F] = TimeSource.withSources[F](
"Test",
pureOf(Instant.ofEpochSecond(epochSeconds, nanoSeconds)),
pureOf(fullNanos),
)

val timeSource = TimeSource[F]

import scala.concurrent.duration._
List(
timeSource
.realTime
.map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTime")),
timeSource
.realTimeTo(MILLISECONDS)
.map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTimeTo(MILLISECONDS)")),
timeSource
.realTimeTo(NANOSECONDS)
.map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.realTimeTo(NANOSECONDS)")),
timeSource
.monotonic
.map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonic")),
timeSource
.monotonicTo(MILLISECONDS)
.map(time => (time ==== expectedMillis.milliseconds).log("timeSource.monotonicTo(MILLISECONDS)")),
timeSource
.monotonicTo(NANOSECONDS)
.map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonicTo(NANOSECONDS)")),
).sequence
.map(Result.all)
}

def testRealTimeAndMonotonicProperty: Property = {
for {
epochSeconds <- Gen.long(Range.linear(1L, 999999999L)).log("epochSeconds")
nanoSeconds <- Gen.long(Range.linear(1L, 999999999L)).log("nanoSeconds")
fullNanos <- Gen.constant(epochSeconds * 1000000000L + nanoSeconds).log("fullNanos")

} yield runIO {
val expectedMillis = fullNanos / 1000000L
val expectedNanos = fullNanos

val timeSource = TimeSource.withSources[F](
"Test",
pureOf(Instant.ofEpochSecond(epochSeconds, nanoSeconds)),
pureOf(fullNanos),
)

import scala.concurrent.duration._
List(
timeSource
.realTime
.map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTime")),
timeSource
.realTimeTo(MILLISECONDS)
.map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTimeTo(MILLISECONDS)")),
timeSource
.realTimeTo(NANOSECONDS)
.map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.realTimeTo(NANOSECONDS)")),
timeSource
.monotonic
.map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonic")),
timeSource
.monotonicTo(MILLISECONDS)
.map(time => (time ==== expectedMillis.milliseconds).log("timeSource.monotonicTo(MILLISECONDS)")),
timeSource
.monotonicTo(NANOSECONDS)
.map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonicTo(NANOSECONDS)")),
).sequence
.map(Result.all)
}
}

def testDefaultTimeSourceRealTime: Result = runIO {

val now = Instant.now()
val expectedNanos = now.getEpochSecond * 1000000000L + now.getNano
val expectedMillis = expectedNanos / 1000000L

val timeSource = TimeSource.default[F](
"Test"
)

List(
timeSource
.realTime
.map(time =>
Result
.diff(time, (expectedMillis.milliseconds +- 1000.milliseconds))(_.isWithIn(_))
.log("timeSource.realTime"),
),
timeSource
.realTimeTo(MILLISECONDS)
.map(time =>
Result
.diff(time, (expectedMillis.milliseconds +- 1000.milliseconds))(_.isWithIn(_))
.log("timeSource.realTimeTo(MILLISECONDS)"),
),
timeSource
.realTimeTo(NANOSECONDS)
.map(time =>
Result
.diff(time, (expectedNanos.nanoseconds +- 1000.milliseconds))(_.isWithIn(_))
.log("timeSource.realTimeTo(NANOSECONDS)"),
),
).sequence
.map(Result.all)
}

def testDefaultTimeSourceMonotonic: Result = runIO {

val expectedNanos = System.nanoTime()
val expectedMillis = expectedNanos / 1000000L

val timeSource = TimeSource.default[F](
"Test"
)

List(
timeSource
.monotonic
.map(time =>
Result
.diff(time, (expectedNanos.nanoseconds +- 1000.milliseconds))(_.isWithIn(_))
.log("timeSource.monotonic"),
),
timeSource
.monotonicTo(MILLISECONDS)
.map(time =>
Result
.diff(time, (expectedMillis.milliseconds +- 1000.milliseconds))(_.isWithIn(_))
.log("timeSource.monotonicTo(MILLISECONDS)"),
),
timeSource
.monotonicTo(NANOSECONDS)
.map(time =>
Result
.diff(time, (expectedNanos.nanoseconds +- 1000.milliseconds))(_.isWithIn(_))
.log("timeSource.monotonicTo(NANOSECONDS)"),
),
).sequence
.map(Result.all)
}

def testDefaultTimeSourceTimeSpent: Property = {
for {
waitFor <- Gen.int(Range.linear(200, 700)).map(_.milliseconds).log("waitFor")
diff <- Gen.constant(180.milliseconds).log("diff")
} yield runIO {
val timeSource = TimeSource.default[F](
"Test"
)

for {
_ <- F.sleep(500.milliseconds) // warm up
resultAndTimeSpent <- timeSource.timeSpent {
F.sleep(waitFor) *>
pureOf("Done")
}
(result, timeSpent) = resultAndTimeSpent
_ <- effectOf(
println(
s""">>> waitFor: ${waitFor.show}
|>>> timeSpent: ${timeSpent.timeSpent.toMillis.show} milliseconds
|>>> diff: ${(timeSpent.timeSpent - waitFor).toMillis.show} milliseconds
|""".stripMargin
)
)
} yield {
Result.all(
List(
result ==== "Done",
Result
.diffNamed(
s"timeSpent (${timeSpent.timeSpent.toMillis.show} milliseconds) should be " +
s"within ${(waitFor - diff).show} to ${(waitFor + diff).show}.",
timeSpent,
(waitFor +- diff),
)(_.timeSpent.isWithIn(_))
.log(
s"""--- diff test log ---
|> actual: ${timeSpent.timeSpent.toMillis.show} milliseconds
|> expected range: ${(waitFor - diff).show} to ${(waitFor + diff).show}
|> waitFor: ${waitFor.show}
|> expected diff: +- ${diff.show})
|> actual diff: ${(timeSpent.timeSpent - waitFor).toMillis.show} milliseconds
|""".stripMargin
),
)
)
}
}
}

}
Loading

0 comments on commit 0200bfb

Please sign in to comment.