Baseline implementation for gs-timing
Some checks failed
/ Build and Release Library (push) Failing after 31s

Includes:
- Complete implementation
- Most code documentation

Does not include:
- Tests
- Complete documentation
This commit is contained in:
Pat Garrity 2024-09-08 11:25:27 -05:00
commit ab8ee3b24d
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
14 changed files with 588 additions and 0 deletions

View file

@ -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

View file

@ -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

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
project/target/
project/project/
modules/core/target/

16
.pre-commit-config.yaml Normal file
View file

@ -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

72
.scalafmt.conf Normal file
View file

@ -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/**"
]
}

9
LICENSE Normal file
View file

@ -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.

28
README.md Normal file
View file

@ -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).

32
build.sbt Normal file
View file

@ -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"
)
)

1
project/build.properties Normal file
View file

@ -0,0 +1 @@
sbt.version=1.10.1

33
project/plugins.sbt Normal file
View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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)
)
)

View file

@ -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