commit ab8ee3b24d751ebc276d15b163ed6a8a2aa0cded Author: Pat Garrity Date: Sun Sep 8 11:25:27 2024 -0500 Baseline implementation for `gs-timing` Includes: - Complete implementation - Most code documentation Does not include: - Tests - Complete documentation diff --git a/.forgejo/workflows/pull_request.yaml b/.forgejo/workflows/pull_request.yaml new file mode 100644 index 0000000..a98df61 --- /dev/null +++ b/.forgejo/workflows/pull_request.yaml @@ -0,0 +1,68 @@ +on: + pull_request: + types: [opened, synchronize, reopened] + +defaults: + run: + shell: bash + +jobs: + library_snapshot: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Test Library Snapshot' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message ($GS_RELEASE_TYPE) (SNAPSHOT)" + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + sbtn -Dsnapshot=true -Drelease="patch" semVerInfo + else + sbtn -Dsnapshot=true -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + - name: 'Publish Snapshot' + run: | + echo "Testing env var propagation = ${{ env.GS_RELEASE_TYPE }}" + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish due to GS_RELEASE_TYPE=norelease" + else + sbtn coverageOff + sbtn clean + sbtn compile + sbtn publish + fi diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml new file mode 100644 index 0000000..72fac7d --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -0,0 +1,84 @@ +on: + push: + branches: + - main + +defaults: + run: + shell: bash + +jobs: + library_release: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Release Library' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message" + echo "Selected Release Type: '$GS_RELEASE_TYPE'" + + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + echo "Skipping all versioning for 'norelease' commit." + else + sbtn -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping build/test for 'norelease' commit." + else + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + fi + - name: 'Publish Release' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish for 'norelease' commit." + else + sbtn coverageOff + sbtn clean + sbtn semVerWriteVersionToFile + sbtn publish + fi + - name: 'Create Git Tag' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping Git tag for 'norelease' commit." + else + selected_version="$(cat .version)" + git tag "$selected_version" + git push origin "$selected_version" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89fee26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +project/target/ +project/project/ +modules/core/target/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fbcf79c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: fix-byte-order-marker + - id: mixed-line-ending + args: ['--fix=lf'] + description: Enforces using only 'LF' line endings. + - id: trailing-whitespace + - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala + rev: v1.0.1 + hooks: + - id: scalafmt diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..9c7929b --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,72 @@ +// See: https://github.com/scalameta/scalafmt/tags for the latest tags. +version = 3.8.1 +runner.dialect = scala3 +maxColumn = 80 + +rewrite { + rules = [RedundantBraces, RedundantParens, Imports, SortModifiers] + imports.expand = true + imports.sort = scalastyle + redundantBraces.ifElseExpressions = true + redundantBraces.stringInterpolation = true +} + +indent { + main = 2 + callSite = 2 + defnSite = 2 + extendSite = 4 + withSiteRelativeToExtends = 2 + commaSiteRelativeToExtends = 2 +} + +align { + preset = more + openParenCallSite = false + openParenDefnSite = false +} + +newlines { + implicitParamListModifierForce = [before,after] + topLevelStatementBlankLines = [ + { + blanks = 1 + } + ] + afterCurlyLambdaParams = squash +} + +danglingParentheses { + defnSite = true + callSite = true + ctrlSite = true + exclude = [] +} + +verticalMultiline { + atDefnSite = true + arityThreshold = 2 + newlineAfterOpenParen = true +} + +comments { + wrap = standalone +} + +docstrings { + style = "SpaceAsterisk" + oneline = unfold + wrap = yes + forceBlankLineBefore = true +} + +project { + excludePaths = [ + "glob:**target/**", + "glob:**.metals/**", + "glob:**.bloop/**", + "glob:**.bsp/**", + "glob:**metals.sbt", + "glob:**.git/**" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2b2735 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright Patrick Garrity + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..362953d --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# gs-timing + +[GS Open Source](https://garrity.co/oss.html) | +[License (MIT)](./LICENSE) + +Timing library for Cats Effect and Scala 3. + +- [Usage](#usage) +- [Donate](#donate) + +## Usage + +### Dependency + +This artifact is available in the Garrity Software Maven repository. + +```scala +externalResolvers += + "Garrity Software Releases" at "https://maven.garrity.co/gs" + +val Gstiming: ModuleID = + "gs" %% "gs-timing-v0" % "$VERSION" +``` + +## Donate + +Enjoy this project or want to help me achieve my [goals](https://garrity.co)? +Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm). diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..2fd5eaf --- /dev/null +++ b/build.sbt @@ -0,0 +1,32 @@ +val scala3: String = "3.5.0" + +externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +ThisBuild / scalaVersion := scala3 +ThisBuild / versionScheme := Some("semver-spec") +ThisBuild / gsProjectName := "gs-timing" + +lazy val sharedSettings = Seq( + scalaVersion := scala3, + version := semVerSelected.value +) + +lazy val testSettings = Seq( + libraryDependencies ++= Seq( + "org.scalameta" %% "munit" % "1.0.1" % Test + ) +) + +lazy val `gs-timing` = project + .in(file(".")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "cats-effect" % "3.5.4" + ) + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..ee4c672 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.1 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..eb382dc --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,33 @@ +def selectCredentials(): Credentials = + if ((Path.userHome / ".sbt" / ".credentials").exists()) + Credentials(Path.userHome / ".sbt" / ".credentials") + else + Credentials.apply( + realm = "Reposilite", + host = "maven.garrity.co", + userName = sys.env + .get("GS_MAVEN_USER") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable." + ) + ), + passwd = sys.env + .get("GS_MAVEN_TOKEN") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable." + ) + ) + ) + +credentials += selectCredentials() + +externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.1.0") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.4.0") +addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0") diff --git a/src/main/scala/gs/timing/v0/ElapsedTime.scala b/src/main/scala/gs/timing/v0/ElapsedTime.scala new file mode 100644 index 0000000..aca3b39 --- /dev/null +++ b/src/main/scala/gs/timing/v0/ElapsedTime.scala @@ -0,0 +1,32 @@ +package gs.timing.v0 + +import scala.concurrent.duration.FiniteDuration + +/** Represents elapsed, monotonic time, expressed as nanoseconds. + * + * @param start + * The start time of this duration (an absolute monotonic value). + * @param end + * The end time of this duration (an absolute monotonic value). + * @param duration + * The duration that elapsed, expressed as nanoseconds. + */ +case class ElapsedTime( + start: Long, + end: Long, + duration: FiniteDuration +): + /** @return + * The elapsed duration, expressed as nanoseconds. + */ + def toNanoseconds(): Long = duration.toNanos + + /** @return + * The elapsed duration, expressed as milliseconds. + */ + def toMilliseconds(): Long = duration.toMillis + + /** @return + * The elapsed duration, expressed as seconds. + */ + def toSeconds(): Long = duration.toSeconds diff --git a/src/main/scala/gs/timing/v0/MonotonicProvider.scala b/src/main/scala/gs/timing/v0/MonotonicProvider.scala new file mode 100644 index 0000000..2ab8cfd --- /dev/null +++ b/src/main/scala/gs/timing/v0/MonotonicProvider.scala @@ -0,0 +1,96 @@ +package gs.timing.v0 + +import cats.effect.Ref +import cats.effect.Sync +import cats.syntax.all.* + +/** Provider of monotonic time - a monotonic clock is a clock that will never + * adjust or jump forwards or backwards, and represents the amount of time + * elapsed since some arbitrary point in time in the past. + * + * This is useful for calculating _elapsed time_ -- the exact duration elapsed + * between two relative events. This is _not useful_ for calculating anything + * related to real dates and times. + * + * ## System Provider + * + * For most real use cases, the _system_ provider is appropriate. This + * delegates to `System.nanoTime()` under the covers, which leverages the + * monotonic clock of the system where the application is running. + * + * {{{ + * import cats.effect.IO + * import gs.timing.v0.MonotonicProvider + * + * val provider = MonotonicProvider.system[IO] + * + * val time: IO[Long] = provider.monotonic() + * }}} + * + * ## Manual Provider + * + * For testing purposes, a manual provider is supported. This provider allows + * the user to manually control a tick count and assume the role of the clock. + * This allows for deterministic clock values. + * + * {{{ + * import cats.effect.IO + * import gs.timing.v0.MonotonicProvider + * + * for + * provider <- MonotonicProvider.manual[IO] + * t1 <- provider.monotonic() // 0 + * _ <- provider.tick() + * t2 <- provider.monotonic() // 1 + * _ <- provider.set(10) + * t3 <- provider.monotonic() // 10 + * _ <- provider.reset() + * t4 <- provider.monotonic() // 0 + * yield + * () + * }}} + */ +trait MonotonicProvider[F[_]]: + /** @return + * The current value of the underlying monotonic clock. + */ + def monotonic(): F[Long] + +object MonotonicProvider: + + /** @return + * A new provider based on the system's underlying monotonic clock. This + * implementation delegates to `System.nanoTime()`. + */ + def system[F[_]: Sync]: SystemProvider[F] = + new SystemProvider[F] + + /** @return + * A new provider, always initialized to 0, that is manually controlled by + * the user. + */ + def manual[F[_]: Sync]: F[ManualTickProvider[F]] = + ManualTickProvider.initialize[F] + + final class SystemProvider[F[_]: Sync] extends MonotonicProvider[F]: + override def monotonic(): F[Long] = Sync[F].delay(System.nanoTime()) + + final class ManualTickProvider[F[_]: Sync] private ( + ticks: Ref[F, Long] + ) extends MonotonicProvider[F]: + override def monotonic(): F[Long] = ticks.get + + def tick(): F[Unit] = ticks.update(_ + 1) + + def set(newTickCount: Long): F[Unit] = ticks.set(newTickCount) + + def reset(): F[Unit] = ticks.set(0) + + object ManualTickProvider: + + def initialize[F[_]: Sync]: F[ManualTickProvider[F]] = + Ref.of[F, Long](0).map(ticks => new ManualTickProvider[F](ticks)) + + end ManualTickProvider + +end MonotonicProvider diff --git a/src/main/scala/gs/timing/v0/MonotonicTimer.scala b/src/main/scala/gs/timing/v0/MonotonicTimer.scala new file mode 100644 index 0000000..a474293 --- /dev/null +++ b/src/main/scala/gs/timing/v0/MonotonicTimer.scala @@ -0,0 +1,54 @@ +package gs.timing.v0 + +import cats.Functor +import cats.syntax.all.* +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.FiniteDuration + +/** Timer based on monotonic time expressed as nanoseconds. Each timer is based + * on a fixed start point, and calculates [[ElapsedTime]] based on that point. + * + * This class should be instantiated by [[Timing]], which will inject an + * appropriate function for calculating elapsed time. + * + * ## Usage + * + * {{{ + * import gs.timing.v0.Timing + * import cats.effect.IO + * + * val timing = Timing.system[IO] + * + * val program: IO[List[ElapsedTime]] = + * for + * timer <- timing.start() + * elapsed1 <- timing.checkpoint() + * elapsed2 <- timing.checkpoint() + * elapsed3 <- timing.checkpoint() + * yield + * List(elapsed1, elapsed2, elapsed3) + * }}} + * + * @param start + * The fixed start point, expressed as monotonic time (nanoseconds, tick + * count). + * @param checkpointFunction + * The checkpoint function used to evaluate the current time. + */ +final class MonotonicTimer[F[_]: Functor]( + val start: Long, + private val checkpointFunction: () => F[Long] +): + + /** @return + * The [[ElapsedTime]] (in nanoseconds) based on the fixed start point and + * checking the current monotonic time. + */ + def checkpoint(): F[ElapsedTime] = + checkpointFunction().map(end => + ElapsedTime( + start = start, + end = end, + duration = new FiniteDuration(end - start, TimeUnit.NANOSECONDS) + ) + ) diff --git a/src/main/scala/gs/timing/v0/Timing.scala b/src/main/scala/gs/timing/v0/Timing.scala new file mode 100644 index 0000000..632a90c --- /dev/null +++ b/src/main/scala/gs/timing/v0/Timing.scala @@ -0,0 +1,59 @@ +package gs.timing.v0 + +import cats.effect.Sync +import cats.syntax.all.* + +/** Primary entrypoint for the `gs-timing` library. This class is an interface + * for working with monotonic time: + * + * - Getting the current value of some monotonic clock. + * - Calculating the elapsed time between two events. + * + * This class/library is not useful for anything related to human date/time + * operations, and is focused on precise machine-level timing. + * + * ## Sharing Instances + * + * System instances of this class can be shared - one instance can be used + * across an application. + * + * Manual instances of this class are user-controlled and should be shared with + * caution, and will not necessarily give consistent results across concurrent + * calls (depending on what each call does). Since these are typically reserved + * for testing purposes, creating an instance per test is advised. + * + * @param monotonicProvider + * The [[MonotonicProvider]], which determines how nanoseconds are + * calculated. + */ +final class Timing[F[_]: Sync]( + monotonicProvider: MonotonicProvider[F] +): + /** @return + * The current monotonic time (expressed as nanoseconds). + */ + def monotonic(): F[Long] = monotonicProvider.monotonic() + + /** @return + * A new [[MonotonicTimer]] based on the current monotonic time. + */ + def start(): F[MonotonicTimer[F]] = + monotonic().map(start => new MonotonicTimer[F](start, monotonic)) + +object Timing: + + /** @return + * A new [[Timing]] instance based on the system's underlying monotonic + * clock. This implementation delegates to `System.nanoTime()`. + */ + def system[F[_]: Sync]: Timing[F] = + new Timing[F](MonotonicProvider.system[F]) + + /** @return + * A new [[Timing]] instance, always initialized to 0, that is manually + * controlled by the user. + */ + def manual[F[_]: Sync]: F[Timing[F]] = + MonotonicProvider.manual[F].map(new Timing[F](_)) + +end Timing