Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Close #602 - [effectie-time] Add TimeSource #606

Merged
merged 1 commit into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading