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

Add duration and byte-count formatters #188

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Changes from 3 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
9 changes: 7 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ coverageFailOnMinimum := true
coverageMinimumStmtTotal := 75
coverageMinimumBranchTotal := 70

lazy val allScalacOptions = Seq(
scalacOptions ++= Seq(
"-feature",
"-Xfatal-warnings",
"-unchecked",
@@ -27,6 +27,12 @@ lazy val allScalacOptions = Seq(
"-release:17",
)

Test / scalacOptions ++= Seq(
// Allow using -Wnonunit-statement to find bugs in tests without exploding from scalatest assertions
"-Wconf:msg=unused value of type org.scalatest.Assertion:s",
"-Wconf:msg=unused value of type org.scalamock:s",
)

doc / javacOptions := Seq("-encoding", "UTF-8")

licenses += ("MIT", url("http://opensource.org/licenses/MIT"))
@@ -50,7 +56,6 @@ Test / javaOptions ++= Seq(
"--add-opens=java.base/sun.security.ssl=ALL-UNNAMED",
"--add-opens=java.base/java.lang=ALL-UNNAMED",
)
scalacOptions ++= allScalacOptions ++ Seq("-release", "17")
scalafmtOnCompile := true
credentials += Credentials(
"Artifactory Realm",
61 changes: 61 additions & 0 deletions src/main/scala/io/flow/util/ByteFormatter.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package io.flow.util

import java.math.RoundingMode
import java.text.DecimalFormat

object ByteFormatter {
def byteCountSI(bytes: Long): String = {
format(bytes, true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how you feel about flag arguments but throwing in an alternative, e.g., format(value, multiplier) where the multiplier has only two cases (SI, Binary) and the list of symbols is derived from it with pattern matching.

}

def byteCountBinary(bytes: Long): String = {
format(bytes, false)
}

private[util] val (df0, df1, df2) = {
def withDownRound(df: DecimalFormat) = {
df.setRoundingMode(RoundingMode.DOWN)
df
}
(
withDownRound(new DecimalFormat("0")),
withDownRound(new DecimalFormat("0.#")),
withDownRound(new DecimalFormat("0.##")),
)
}

private def format(bytes: Long, si: Boolean) = {
val absBytes = if (bytes == Long.MinValue) Long.MaxValue else Math.abs(bytes)
val (baseValue, unitStrings) =
if (si)
(1000, Vector("k", "M", "G", "T", "P", "E", "Z", "Y"))
else
(1024, Vector("Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi"))

def getExponent(curBytes: Long, baseValue: Int, curExponent: Int = 0): Int = {
if (curBytes < baseValue) {
curExponent
} else {
val newExponent = 1 + curExponent
getExponent(curBytes / baseValue, baseValue, newExponent)
}
}

if (absBytes < baseValue) {
s"$bytes B"
} else {
val signum = if (bytes < 0) "-" else ""
val exponent = getExponent(absBytes, baseValue)
val divisor = Math.pow(baseValue.toDouble, exponent.toDouble)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Seems both simpler and more precise to just return both exponent and divisor from getExponent. (In the recursive step, exponent becomes exponent+1 and divisor becomes divisor*baseValue.) Or just look it up like the unit perhaps.

val unitString = unitStrings(exponent - 1)

val res = absBytes / divisor
3 - res.toLong.toString.length match {
case 2 => f"$signum${df2.format(res)} ${unitString}B"
case 1 => f"$signum${df1.format(res)} ${unitString}B"
case _ => f"$signum${df0.format(res)} ${unitString}B"
}

}
}
}
27 changes: 27 additions & 0 deletions src/main/scala/io/flow/util/DurationFormatter.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.flow.util

import scala.concurrent.duration.FiniteDuration

object DurationFormatter {
import ByteFormatter.{df0, df1, df2}
private val units = Seq((1000L, "ns"), (1000L, "us"), (1000L, "ms"), (60L, "sec"), (60L, "min"), (24L, "h"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not μs and s? I'd suggest m instead of min, because while m is very common for durations, although techinically that's a meter, and d for days (also avois "days" vs "day") 🤷‍♂️ FWIW, Go (hence k8s and all infra stuff written in Go) formats durations as, e.g., 2d9h45m19s.


def format(duration: FiniteDuration): String = {
val nanos = duration.toNanos.toDouble
val (t, u) = units.foldLeft((nanos, None: Option[String])) { case ((time, result), (base, unit)) =>
result.fold {
if (time < base) {
(time, Some(unit))
} else {
(time / base, None)
}
}(_ => (time, result))
}
val unit = u.getOrElse("days")
3 - t.toLong.toString.length match {
case 2 => f"${df2.format(t)} $unit"
case 1 => f"${df1.format(t)} $unit"
case _ => f"${df0.format(t)} $unit"
}
}
}
87 changes: 87 additions & 0 deletions src/test/scala/io/flow/util/ByteFormatterSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package io.flow.util

import org.scalatest.matchers.must.Matchers
import org.scalatest.wordspec.AnyWordSpec

class ByteFormatterSpec extends AnyWordSpec with Matchers {
"SI byte count" should {
"byte values" in {
ByteFormatter.byteCountSI(0L) mustBe "0 B"
ByteFormatter.byteCountSI(1L) mustBe "1 B"
ByteFormatter.byteCountSI(10L) mustBe "10 B"
ByteFormatter.byteCountSI(100L) mustBe "100 B"
ByteFormatter.byteCountSI(999L) mustBe "999 B"
}

"KB values" in {
ByteFormatter.byteCountSI(1000L) mustBe "1 kB"
ByteFormatter.byteCountSI(1009L) mustBe "1 kB"
ByteFormatter.byteCountSI(1010L) mustBe "1.01 kB"
ByteFormatter.byteCountSI(9999L) mustBe "9.99 kB"
ByteFormatter.byteCountSI(10000L) mustBe "10 kB"
ByteFormatter.byteCountSI(10099L) mustBe "10 kB"
ByteFormatter.byteCountSI(10100L) mustBe "10.1 kB"
ByteFormatter.byteCountSI(99999L) mustBe "99.9 kB"
ByteFormatter.byteCountSI(100000L) mustBe "100 kB"
ByteFormatter.byteCountSI(100999L) mustBe "100 kB"
ByteFormatter.byteCountSI(101000L) mustBe "101 kB"
ByteFormatter.byteCountSI(999999L) mustBe "999 kB"
}

"MB values" in {
ByteFormatter.byteCountSI(1000000L) mustBe "1 MB"
ByteFormatter.byteCountSI(999999999L) mustBe "999 MB"
}

"GB values" in {
ByteFormatter.byteCountSI(1000000000L) mustBe "1 GB"
ByteFormatter.byteCountSI(999999999999L) mustBe "999 GB"
}

"TB values" in {
ByteFormatter.byteCountSI(1000000000000L) mustBe "1 TB"
ByteFormatter.byteCountSI(999999999999999L) mustBe "999 TB"
}
}

"Binary byte count" should {
"byte values" in {
ByteFormatter.byteCountBinary(0L) mustBe "0 B"
ByteFormatter.byteCountBinary(1L) mustBe "1 B"
ByteFormatter.byteCountBinary(10L) mustBe "10 B"
ByteFormatter.byteCountBinary(100L) mustBe "100 B"
ByteFormatter.byteCountBinary(999L) mustBe "999 B"
ByteFormatter.byteCountBinary(1023L) mustBe "1023 B"
}

"KiB values" in {
ByteFormatter.byteCountBinary(1024L) mustBe "1 KiB"
ByteFormatter.byteCountBinary(1034L) mustBe "1 KiB"
ByteFormatter.byteCountBinary(1035L) mustBe "1.01 KiB"
ByteFormatter.byteCountBinary(1024L * 10 - 1) mustBe "9.99 KiB"
ByteFormatter.byteCountBinary(1024L * 10) mustBe "10 KiB"
ByteFormatter.byteCountBinary(1024L * 11 - 1) mustBe "10.9 KiB"
ByteFormatter.byteCountBinary(1024L * 11) mustBe "11 KiB"
ByteFormatter.byteCountBinary(1024L * 100 - 1) mustBe "99.9 KiB"
ByteFormatter.byteCountBinary(1024L * 100) mustBe "100 KiB"
ByteFormatter.byteCountBinary(1024L * 128 - 1) mustBe "127 KiB"
ByteFormatter.byteCountBinary(1024L * 128) mustBe "128 KiB"
ByteFormatter.byteCountBinary(1024L * 1024 - 1) mustBe "1023 KiB"
}

"MiB values" in {
ByteFormatter.byteCountBinary(1024L * 1024) mustBe "1 MiB"
ByteFormatter.byteCountBinary(1024L * 1024 * 1024 - 1) mustBe "1023 MiB"
}

"GiB values" in {
ByteFormatter.byteCountBinary(1024L * 1024 * 1024) mustBe "1 GiB"
ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024 - 1) mustBe "1023 GiB"
}

"TiB values" in {
ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024) mustBe "1 TiB"
ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024 * 1024 - 1) mustBe "1023 TiB"
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about larger values; can the unit lookup fail?

}
}
73 changes: 73 additions & 0 deletions src/test/scala/io/flow/util/DurationFormatterSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package io.flow.util

import org.scalatest.matchers.must.Matchers
import org.scalatest.wordspec.AnyWordSpec
import scala.concurrent.duration._

class DurationFormatterSpec extends AnyWordSpec with Matchers {
"nanosecond values" in {
DurationFormatter.format(0.nanos) mustBe "0 ns"
DurationFormatter.format(1.nanos) mustBe "1 ns"
DurationFormatter.format(9.nanos) mustBe "9 ns"
DurationFormatter.format(10.nanos) mustBe "10 ns"
DurationFormatter.format(99.nanos) mustBe "99 ns"
DurationFormatter.format(100.nanos) mustBe "100 ns"
DurationFormatter.format(999.nanos) mustBe "999 ns"
}

"microsecond values" in {
DurationFormatter.format(1000.nanos) mustBe "1 us"
DurationFormatter.format(1009.nanos) mustBe "1 us"
DurationFormatter.format(1010.nanos) mustBe "1.01 us"
DurationFormatter.format(1099.nanos) mustBe "1.09 us"
DurationFormatter.format(1100.nanos) mustBe "1.1 us"
DurationFormatter.format(9999.nanos) mustBe "9.99 us"
DurationFormatter.format(10000.nanos) mustBe "10 us"
DurationFormatter.format(10099.nanos) mustBe "10 us"
DurationFormatter.format(10100.nanos) mustBe "10.1 us"
DurationFormatter.format(99999.nanos) mustBe "99.9 us"
DurationFormatter.format(100000.nanos) mustBe "100 us"
DurationFormatter.format(999999.nanos) mustBe "999 us"
}

"millisecond values" in {
DurationFormatter.format(1.milli) mustBe "1 ms"
DurationFormatter.format(1000000.nanos) mustBe "1 ms"
DurationFormatter.format(999.millis) mustBe "999 ms"
DurationFormatter.format(999999999.nanos) mustBe "999 ms"
}

"second values" in {
DurationFormatter.format(1.second) mustBe "1 sec"
DurationFormatter.format(1000.millis) mustBe "1 sec"
DurationFormatter.format(1000000000.nanos) mustBe "1 sec"
DurationFormatter.format(59.seconds) mustBe "59 sec"
DurationFormatter.format(59999.millis) mustBe "59.9 sec"
DurationFormatter.format(59999999999L.nanos) mustBe "59.9 sec"
}

"minute values" in {
DurationFormatter.format(1.minute) mustBe "1 min"
DurationFormatter.format(60.seconds) mustBe "1 min"
DurationFormatter.format(60000000000L.nanos) mustBe "1 min"
DurationFormatter.format(59.minutes) mustBe "59 min"
DurationFormatter.format(3599.seconds) mustBe "59.9 min"
DurationFormatter.format(3599999999999L.nanos) mustBe "59.9 min"
}

"hour values" in {
DurationFormatter.format(1.hour) mustBe "1 h"
DurationFormatter.format(60.minutes) mustBe "1 h"
DurationFormatter.format(3600000000000L.nanos) mustBe "1 h"
DurationFormatter.format(23.hours) mustBe "23 h"
DurationFormatter.format(1439.minutes) mustBe "23.9 h"
DurationFormatter.format(86399999999999L.nanos) mustBe "23.9 h"
}

"day values" in {
DurationFormatter.format(1.day) mustBe "1 days"
DurationFormatter.format(24.hours) mustBe "1 days"
DurationFormatter.format(86400000000000L.nanos) mustBe "1 days"
DurationFormatter.format(100000.days) mustBe "100000 days"
}
}