Browse Source

[Scala/otavia] Add new framework otavia: Your shiny new IO & Actor programming model! (#9158)

Yan Kun 1 year ago
parent
commit
64fd93c084

+ 1 - 0
frameworks/Scala/otavia/.mill-version

@@ -0,0 +1 @@
+0.11.8

+ 16 - 0
frameworks/Scala/otavia/.scalafmt.conf

@@ -0,0 +1,16 @@
+version = "3.5.3"
+
+runner.dialect = scala3
+maxColumn = 120
+docstrings.blankFirstLine = no
+docstrings.style = AsteriskSpace
+docstrings.removeEmpty = true
+docstrings.oneline = fold
+docstrings.wrap = yes
+docstrings.wrapMaxColumn = 120
+docstrings.forceBlankLineBefore = true
+align.preset = more
+
+indent.main = 4
+
+newlines.topLevelBodyIfMinStatements = [before,after]

+ 13 - 0
frameworks/Scala/otavia/README.MD

@@ -0,0 +1,13 @@
+## Introduction
+
+[GitHub - otavia-projects/otavia : Your shiny new IO & Actor programming model!](https://github.com/otavia-projects/otavia)
+
+`otavia` is an IO and Actor programming model power by Scala 3, it provides a toolkit to make writing high-performance
+concurrent programs more easily.
+
+You can get a quick overview of the basic usage and core design of `otavia` in the following documentation:
+
+- [Quick Start](https://github.com/otavia-projects/otavia/blob/main/docs/_docs/quick_start.md)
+- [Core Concepts and Design](https://github.com/otavia-projects/otavia/blob/main/docs/_docs/core_concept.md)
+
+More document can be found at [website](https://otavia.cc/home.html)

+ 106 - 0
frameworks/Scala/otavia/benchmark/src/app/controller/DBController.scala

@@ -0,0 +1,106 @@
+package app.controller
+
+import app.controller.DBController.*
+import app.model.World
+import cc.otavia.core.actor.{MessageOf, StateActor}
+import cc.otavia.core.address.Address
+import cc.otavia.core.message.{Ask, Reply}
+import cc.otavia.core.stack.helper.{FutureState, FuturesState, StartState}
+import cc.otavia.core.stack.{AskStack, StackState, StackYield}
+import cc.otavia.http.server.{HttpRequest, HttpResponse}
+import cc.otavia.sql.Connection
+import cc.otavia.sql.Statement.{ModifyRows, PrepareQuery}
+
+import java.util.SplittableRandom
+
+class DBController extends StateActor[REQ] {
+
+    private var connection: Address[MessageOf[Connection]] = _
+
+    private val random = new SplittableRandom()
+
+    override protected def afterMount(): Unit = connection = autowire[Connection]()
+
+    override protected def resumeAsk(stack: AskStack[REQ & Ask[? <: Reply]]): StackYield =
+        stack match
+            case stack: AskStack[SingleQueryRequest] if stack.ask.isInstanceOf[SingleQueryRequest] =>
+                handleSingleQuery(stack)
+            case stack: AskStack[MultipleQueryRequest] if stack.ask.isInstanceOf[MultipleQueryRequest] =>
+                handleMultipleQuery(stack)
+            case stack: AskStack[UpdateRequest] if stack.ask.isInstanceOf[UpdateRequest] =>
+                handleUpdateQuery(stack)
+
+    // Test 2: Single database query
+    private def handleSingleQuery(stack: AskStack[SingleQueryRequest]): StackYield = {
+        stack.state match
+            case _: StartState =>
+                val state = FutureState[World]()
+                connection.ask(PrepareQuery.fetchOne[World](SELECT_WORLD, Tuple1(randomWorld())), state.future)
+                stack.suspend(state)
+            case state: FutureState[World] =>
+                stack.`return`(state.future.getNow)
+    }
+
+    // Test 3: Multiple database queries
+    private def handleMultipleQuery(stack: AskStack[MultipleQueryRequest]): StackYield = {
+        stack.state match
+            case _: StartState =>
+                stack.suspend(selectWorlds(normalizeQueries(stack.ask.params)))
+            case state: FuturesState[World] =>
+                val response = HttpResponse.builder.setContent(state.futures.map(_.getNow)).build()
+                stack.`return`(response)
+    }
+
+    // Test 5: Database updates
+    private def handleUpdateQuery(stack: AskStack[UpdateRequest]): StackYield = {
+        stack.state match
+            case _: StartState =>
+                stack.suspend(selectWorlds(normalizeQueries(stack.ask.params)))
+            case state: FuturesState[World] =>
+                val worlds = state.futures.map(_.getNow)
+                stack.attach(worlds)
+                val newState  = FutureState[ModifyRows]()
+                val newWorlds = worlds.sortBy(_.id).map(_.copy(randomNumber = randomWorld()))
+                connection.ask(PrepareQuery.update(UPDATE_WORLD, newWorlds), newState.future)
+                stack.suspend(newState)
+            case state: FutureState[ModifyRows] =>
+                if (state.future.isFailed) state.future.causeUnsafe.printStackTrace()
+                val response = HttpResponse.builder.setContent(stack.attach[Seq[World]]).build()
+                stack.`return`(response)
+    }
+
+    private def selectWorlds(queries: Int): StackState = {
+        val state = FuturesState[World](queries)
+        for (future <- state.futures)
+            connection.ask(PrepareQuery.fetchOne[World](SELECT_WORLD, Tuple1(randomWorld())), future)
+        state
+    }
+
+    private def randomWorld(): Int = 1 + random.nextInt(10000)
+
+    private def normalizeQueries(params: Map[String, String]): Int = {
+        params.get("queries") match
+            case Some(value) =>
+                try {
+                    val queries = value.toInt
+                    if (queries < 1) 1 else if (queries > 500) 500 else queries
+                } catch {
+                    case e: Throwable => 1
+                }
+            case None => 1
+    }
+
+}
+
+object DBController {
+
+    type REQ = SingleQueryRequest | MultipleQueryRequest | UpdateRequest
+
+    class SingleQueryRequest   extends HttpRequest[Nothing, World]
+    class MultipleQueryRequest extends HttpRequest[Nothing, HttpResponse[Seq[World]]]
+    class UpdateRequest        extends HttpRequest[Nothing, HttpResponse[Seq[World]]]
+
+    private val SELECT_WORLD = "SELECT id, randomnumber from WORLD where id=$1"
+    private val UPDATE_WORLD = "update world set randomnumber=$2 where id=$1"
+
+}

+ 41 - 0
frameworks/Scala/otavia/benchmark/src/app/controller/FortuneController.scala

@@ -0,0 +1,41 @@
+package app.controller
+
+import app.controller.FortuneController.*
+import app.model.Fortune
+import cc.otavia.core.actor.{MessageOf, StateActor}
+import cc.otavia.core.address.Address
+import cc.otavia.core.stack.helper.{FutureState, StartState}
+import cc.otavia.core.stack.{AskStack, StackState, StackYield}
+import cc.otavia.http.server.{HttpRequest, HttpResponse}
+import cc.otavia.sql.Statement.PrepareQuery
+import cc.otavia.sql.{Connection, RowSet}
+
+class FortuneController extends StateActor[FortuneRequest] {
+
+    private var connection: Address[MessageOf[Connection]] = _
+
+    override protected def afterMount(): Unit = connection = autowire[Connection]()
+
+    //  Test 4: Fortunes
+    override protected def resumeAsk(stack: AskStack[FortuneRequest]): StackYield = {
+        stack.state match
+            case _: StartState =>
+                val state = FutureState[RowSet[Fortune]]()
+                connection.ask(PrepareQuery.fetchAll[Fortune](SELECT_FORTUNE), state.future)
+                stack.suspend(state)
+            case state: FutureState[RowSet[Fortune]] =>
+                val fortunes = (state.future.getNow.rows :+ Fortune(0, "Additional fortune added at request time."))
+                    .sortBy(_.message)
+                val response = HttpResponse.builder.setContent(fortunes).build()
+                stack.`return`(response)
+    }
+
+}
+
+object FortuneController {
+
+    class FortuneRequest extends HttpRequest[Nothing, HttpResponse[Seq[Fortune]]]
+
+    private val SELECT_FORTUNE = "SELECT id, message from FORTUNE"
+
+}

+ 7 - 0
frameworks/Scala/otavia/benchmark/src/app/model/Fortune.scala

@@ -0,0 +1,7 @@
+package app.model
+
+import cc.otavia.json.JsonSerde
+import cc.otavia.sql.{Row, RowDecoder}
+
+/** The model for the "fortune" database table. */
+case class Fortune(id: Int, message: String) extends Row derives RowDecoder, JsonSerde

+ 5 - 0
frameworks/Scala/otavia/benchmark/src/app/model/Message.scala

@@ -0,0 +1,5 @@
+package app.model
+
+import cc.otavia.json.JsonSerde
+
+case class Message(message: String) derives JsonSerde

+ 8 - 0
frameworks/Scala/otavia/benchmark/src/app/model/World.scala

@@ -0,0 +1,8 @@
+package app.model
+
+import cc.otavia.json.JsonSerde
+import cc.otavia.serde.annotation.rename
+import cc.otavia.sql.{Row, RowDecoder}
+
+/** The model for the "world" database table. */
+case class World(id: Int, @rename("randomnumber") randomNumber: Int) extends Row derives RowDecoder, JsonSerde

+ 70 - 0
frameworks/Scala/otavia/benchmark/src/app/startup.scala

@@ -0,0 +1,70 @@
+package app
+
+import app.controller.DBController.*
+import app.controller.FortuneController.*
+import app.controller.{DBController, FortuneController}
+import app.model.*
+import app.util.FortunesRender
+import cc.otavia.core.actor.ChannelsActor.{Bind, ChannelEstablished}
+import cc.otavia.core.actor.MainActor
+import cc.otavia.core.slf4a.LoggerFactory
+import cc.otavia.core.stack.helper.{FutureState, StartState}
+import cc.otavia.core.stack.{NoticeStack, StackYield}
+import cc.otavia.core.system.ActorSystem
+import cc.otavia.http.HttpMethod.*
+import cc.otavia.http.MediaType
+import cc.otavia.http.MediaType.*
+import cc.otavia.http.server.*
+import cc.otavia.http.server.Router.*
+import cc.otavia.json.JsonSerde
+import cc.otavia.serde.helper.BytesSerde
+import cc.otavia.sql.Connection
+
+import java.io.File
+import java.nio.charset.StandardCharsets.UTF_8
+import java.nio.file.Path
+
+private class ServerMain(val port: Int = 8080) extends MainActor(Array.empty) {
+
+    override def main0(stack: NoticeStack[MainActor.Args]): StackYield = stack.state match
+        case _: StartState =>
+            val worldResponseSerde    = HttpResponseSerde.json(summon[JsonSerde[World]])
+            val worldsResponseSerde   = HttpResponseSerde.json(JsonSerde.derived[Seq[World]])
+            val fortunesResponseSerde = HttpResponseSerde(new FortunesRender(), MediaType.TEXT_HTML_UTF8)
+
+            val dbController      = autowire[DBController]()
+            val fortuneController = autowire[FortuneController]()
+
+            val routers = Seq(
+              // Test 6: plaintext
+              constant[Array[Byte]](GET, "/plaintext", "Hello, World!".getBytes(UTF_8), BytesSerde, TEXT_PLAIN_UTF8),
+              // Test 1: JSON serialization
+              constant[Message](GET, "/json", Message("Hello, World!"), summon[JsonSerde[Message]], APP_JSON),
+              // Test 2: Single database query.
+              get("/db", dbController, () => new SingleQueryRequest(), worldResponseSerde),
+              // Test 3: Multiple database queries
+              get("/queries", dbController, () => new MultipleQueryRequest(), worldsResponseSerde),
+              // Test 5: Database updates
+              get("/updates", dbController, () => new UpdateRequest(), worldsResponseSerde),
+              //  Test 4: Fortunes
+              get("/fortunes", fortuneController, () => new FortuneRequest(), fortunesResponseSerde)
+            )
+            val server = system.buildActor(() => new HttpServer(system.actorWorkerSize, routers))
+            val state  = FutureState[ChannelEstablished]()
+            server.ask(Bind(port), state.future)
+            stack.suspend(state)
+        case state: FutureState[ChannelEstablished] =>
+            if (state.future.isFailed) state.future.causeUnsafe.printStackTrace()
+            logger.info(s"http server bind port $port success")
+            stack.`return`()
+
+}
+
+@main def startup(url: String, user: String, password: String, poolSize: Int): Unit =
+    val system = ActorSystem()
+    val logger = LoggerFactory.getLogger("startup", system)
+    logger.info("starting http server")
+    system.buildActor(() => new Connection(url, user, password), global = true, num = poolSize)
+    system.buildActor(() => new DBController(), global = true, num = system.actorWorkerSize)
+    system.buildActor(() => new FortuneController(), global = true, num = system.actorWorkerSize)
+    system.buildActor(() => new ServerMain())

+ 64 - 0
frameworks/Scala/otavia/benchmark/src/app/util/FortunesRender.scala

@@ -0,0 +1,64 @@
+package app.util
+
+import app.model.Fortune
+import cc.otavia.buffer.{Buffer, BufferUtils}
+import cc.otavia.serde.Serde
+
+import java.nio.charset.StandardCharsets
+import scala.annotation.switch
+
+class FortunesRender extends Serde[Seq[Fortune]] {
+
+    private val text1 =
+        "<!DOCTYPE html><html><head><title>Fortunes</title></head><body><table><tr><th>id</th><th>message</th></tr>"
+            .getBytes(StandardCharsets.UTF_8)
+
+    private val text2 = "<tr><td>".getBytes(StandardCharsets.UTF_8)
+
+    private val text3 = "</td><td>".getBytes(StandardCharsets.UTF_8)
+
+    private val text4 = "</td></tr>".getBytes(StandardCharsets.UTF_8)
+
+    private val text5 = "</table></body></html>".getBytes(StandardCharsets.UTF_8)
+
+    private val lt    = "&lt;".getBytes()
+    private val gt    = "&gt;".getBytes()
+    private val quot  = "&quot;".getBytes()
+    private val squot = "&#39;".getBytes()
+    private val amp   = "&amp;".getBytes()
+
+    override def serialize(fortunes: Seq[Fortune], out: Buffer): Unit = {
+        out.writeBytes(text1)
+        for (fortune <- fortunes) {
+            out.writeBytes(text2)
+            BufferUtils.writeIntAsString(out, fortune.id)
+            out.writeBytes(text3)
+            writeEscapeMessage(out, fortune.message)
+            out.writeBytes(text4)
+        }
+        out.writeBytes(text5)
+    }
+
+    override def deserialize(in: Buffer): Seq[Fortune] = throw new UnsupportedOperationException()
+
+    private def writeEscapeMessage(buffer: Buffer, message: String): Unit = {
+        var i = 0
+        while (i < message.length) {
+            val ch = message.charAt(i)
+            writeChar(buffer, ch)
+            i += 1
+        }
+    }
+
+    private def writeChar(buffer: Buffer, ch: Char): Unit = (ch: @switch) match
+        case '<'  => buffer.writeBytes(lt)
+        case '>'  => buffer.writeBytes(gt)
+        case '"'  => buffer.writeBytes(quot)
+        case '\'' => buffer.writeBytes(squot)
+        case '&'  => buffer.writeBytes(amp)
+        case _ =>
+            if (ch < 0x80) buffer.writeByte(ch.toByte)
+            else if (ch < 0x800) buffer.writeShortLE((ch >> 6 | (ch << 8 & 0x3f00) | 0x80c0).toShort)
+            else buffer.writeMediumLE(ch >> 12 | (ch << 2 & 0x3f00) | (ch << 16 & 0x3f0000) | 0x8080e0)
+
+}

+ 53 - 0
frameworks/Scala/otavia/benchmark_config.json

@@ -0,0 +1,53 @@
+{
+  "framework": "otavia",
+  "tests": [
+    {
+      "default": {
+        "json_url": "/json",
+        "plaintext_url": "/plaintext",
+        "db_url": "/db",
+        "query_url": "/queries?queries=",
+        "fortune_url": "/fortunes",
+        "update_url": "/updates?queries=",
+        "port": 8080,
+        "approach": "Realistic",
+        "classification": "Micro",
+        "database": "Postgres",
+        "framework": "otavia",
+        "language": "Scala",
+        "flavor": "None",
+        "orm": "Micro",
+        "platform": "Otavia",
+        "webserver": "None",
+        "os": "Linux",
+        "database_os": "Linux",
+        "display_name": "otavia",
+        "notes": "",
+        "versus": "Otavia"
+      },
+      "reserve": {
+        "json_url": "/json",
+        "plaintext_url": "/plaintext",
+        "db_url": "/db",
+        "query_url": "/queries?queries=",
+        "fortune_url": "/fortunes",
+        "update_url": "/updates?queries=",
+        "port": 8080,
+        "approach": "Realistic",
+        "classification": "Micro",
+        "database": "Postgres",
+        "framework": "otavia",
+        "language": "Scala",
+        "flavor": "None",
+        "orm": "Micro",
+        "platform": "Otavia",
+        "webserver": "None",
+        "os": "Linux",
+        "database_os": "Linux",
+        "display_name": "otavia",
+        "notes": "",
+        "versus": "Otavia"
+      }
+    }
+  ]
+}

+ 15 - 0
frameworks/Scala/otavia/build.sc

@@ -0,0 +1,15 @@
+import mill._
+import mill.scalalib._
+
+def otaviaVersion = "0.4.0"
+
+object benchmark extends ScalaModule {
+
+    override def scalaVersion = "3.3.1"
+
+    override def ivyDeps = Agg(
+      ivy"cc.otavia::otavia-codec-http:$otaviaVersion",
+      ivy"cc.otavia::otavia-postgres-driver:$otaviaVersion"
+    )
+
+}

+ 36 - 0
frameworks/Scala/otavia/config.toml

@@ -0,0 +1,36 @@
+[framework]
+name = "otavia"
+
+[main]
+urls.plaintext = "/plaintext"
+urls.json = "/json"
+urls.db = "/db"
+urls.query = "/queries?queries="
+urls.update = "/updates?queries="
+urls.fortune = "/fortunes"
+approach = "Realistic"
+classification = "Micro"
+database = "Postgres"
+database_os = "Linux"
+os = "Linux"
+orm = "Micro"
+platform = "Otavia"
+webserver = "None"
+versus = "Otavia"
+
+[reserve]
+urls.plaintext = "/plaintext"
+urls.json = "/json"
+urls.db = "/db"
+urls.query = "/queries?queries="
+urls.update = "/updates?queries="
+urls.fortune = "/fortunes"
+approach = "Realistic"
+classification = "Micro"
+database = "Postgres"
+database_os = "Linux"
+os = "Linux"
+orm = "Micro"
+platform = "Otavia"
+webserver = "None"
+versus = "Otavia"

+ 194 - 0
frameworks/Scala/otavia/millw

@@ -0,0 +1,194 @@
+#!/usr/bin/env sh
+
+# This is a wrapper script, that automatically download mill from GitHub release pages
+# You can give the required mill version with --mill-version parameter
+# If no version is given, it falls back to the value of DEFAULT_MILL_VERSION
+#
+# Project page: https://github.com/lefou/millw
+# Script Version: 0.4.6
+#
+# If you want to improve this script, please also contribute your changes back!
+#
+# Licensed under the Apache License, Version 2.0
+
+set -e
+
+if [ -z "${DEFAULT_MILL_VERSION}" ] ; then
+  DEFAULT_MILL_VERSION="0.10.10"
+fi
+
+
+if [ -z "${GITHUB_RELEASE_CDN}" ] ; then
+  GITHUB_RELEASE_CDN=""
+fi
+
+
+MILL_REPO_URL="https://github.com/com-lihaoyi/mill"
+
+if [ -z "${CURL_CMD}" ] ; then
+  CURL_CMD=curl
+fi
+
+# Explicit commandline argument takes precedence over all other methods
+if [ "$1" = "--mill-version" ] ; then
+  shift
+  if [ "x$1" != "x" ] ; then
+    MILL_VERSION="$1"
+    shift
+  else
+    echo "You specified --mill-version without a version." 1>&2
+    echo "Please provide a version that matches one provided on" 1>&2
+    echo "${MILL_REPO_URL}/releases" 1>&2
+    false
+  fi
+fi
+
+# Please note, that if a MILL_VERSION is already set in the environment,
+# We reuse it's value and skip searching for a value.
+
+# If not already set, read .mill-version file
+if [ -z "${MILL_VERSION}" ] ; then
+  if [ -f ".mill-version" ] ; then
+    MILL_VERSION="$(head -n 1 .mill-version 2> /dev/null)"
+  elif [ -f ".config/mill-version" ] ; then
+    MILL_VERSION="$(head -n 1 .config/mill-version 2> /dev/null)"
+  fi
+fi
+
+if [ -n "${XDG_CACHE_HOME}" ] ; then
+  MILL_DOWNLOAD_PATH="${XDG_CACHE_HOME}/mill/download"
+else
+  MILL_DOWNLOAD_PATH="${HOME}/.cache/mill/download"
+fi
+
+# If not already set, try to fetch newest from Github
+if [ -z "${MILL_VERSION}" ] ; then
+  # TODO: try to load latest version from release page
+  echo "No mill version specified." 1>&2
+  echo "You should provide a version via '.mill-version' file or --mill-version option." 1>&2
+
+  mkdir -p "${MILL_DOWNLOAD_PATH}"
+  LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" 2>/dev/null || (
+    # we might be on OSX or BSD which don't have -d option for touch
+    # but probably a -A [-][[hh]mm]SS
+    touch "${MILL_DOWNLOAD_PATH}/.expire_latest"; touch -A -010000 "${MILL_DOWNLOAD_PATH}/.expire_latest"
+  ) || (
+    # in case we still failed, we retry the first touch command with the intention
+    # to show the (previously suppressed) error message
+    LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest"
+  )
+
+  # POSIX shell variant of bash's -nt operator, see https://unix.stackexchange.com/a/449744/6993
+  # if [ "${MILL_DOWNLOAD_PATH}/.latest" -nt "${MILL_DOWNLOAD_PATH}/.expire_latest" ] ; then
+  if [ -n "$(find -L "${MILL_DOWNLOAD_PATH}/.latest" -prune -newer "${MILL_DOWNLOAD_PATH}/.expire_latest")" ]; then
+    # we know a current latest version
+    MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null)
+  fi
+
+  if [ -z "${MILL_VERSION}" ] ; then
+    # we don't know a current latest version
+    echo "Retrieving latest mill version ..." 1>&2
+    LANG=C ${CURL_CMD} -s -i -f -I ${MILL_REPO_URL}/releases/latest 2> /dev/null  | grep --ignore-case Location: | sed s'/^.*tag\///' | tr -d '\r\n' > "${MILL_DOWNLOAD_PATH}/.latest"
+    MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null)
+  fi
+
+  if [ -z "${MILL_VERSION}" ] ; then
+    # Last resort
+    MILL_VERSION="${DEFAULT_MILL_VERSION}"
+    echo "Falling back to hardcoded mill version ${MILL_VERSION}" 1>&2
+  else
+    echo "Using mill version ${MILL_VERSION}" 1>&2
+  fi
+fi
+
+MILL="${MILL_DOWNLOAD_PATH}/${MILL_VERSION}"
+
+try_to_use_system_mill() {
+  MILL_IN_PATH="$(command -v mill || true)"
+
+  if [ -z "${MILL_IN_PATH}" ]; then
+    return
+  fi
+
+  UNIVERSAL_SCRIPT_MAGIC="@ 2>/dev/null # 2>nul & echo off & goto BOF"
+
+  if ! head -c 128 "${MILL_IN_PATH}" | grep -qF "${UNIVERSAL_SCRIPT_MAGIC}"; then
+    if [ -n "${MILLW_VERBOSE}" ]; then
+      echo "Could not determine mill version of ${MILL_IN_PATH}, as it does not start with the universal script magic2" 1>&2
+    fi
+    return
+  fi
+
+  # Roughly the size of the universal script.
+  MILL_VERSION_SEARCH_RANGE="2403"
+  MILL_IN_PATH_VERSION=$(head -c "${MILL_VERSION_SEARCH_RANGE}" "${MILL_IN_PATH}" |\
+                         sed -n 's/^.*-DMILL_VERSION=\([^\s]*\) .*$/\1/p' |\
+                         head -n 1)
+
+  if [ -z "${MILL_IN_PATH_VERSION}" ]; then
+    echo "Could not determine mill version, even though ${MILL_IN_PATH} has the universal script magic" 1>&2
+    return
+  fi
+
+  if [ "${MILL_IN_PATH_VERSION}" = "${MILL_VERSION}" ]; then
+    MILL="${MILL_IN_PATH}"
+  fi
+}
+try_to_use_system_mill
+
+# If not already downloaded, download it
+if [ ! -s "${MILL}" ] ; then
+
+  # support old non-XDG download dir
+  MILL_OLD_DOWNLOAD_PATH="${HOME}/.mill/download"
+  OLD_MILL="${MILL_OLD_DOWNLOAD_PATH}/${MILL_VERSION}"
+  if [ -x "${OLD_MILL}" ] ; then
+    MILL="${OLD_MILL}"
+  else
+    VERSION_PREFIX="$(echo $MILL_VERSION | cut -b -4)"
+    case $VERSION_PREFIX in
+      0.0. | 0.1. | 0.2. | 0.3. | 0.4. )
+        DOWNLOAD_SUFFIX=""
+        ;;
+      *)
+        DOWNLOAD_SUFFIX="-assembly"
+        ;;
+    esac
+    unset VERSION_PREFIX
+
+    DOWNLOAD_FILE=$(mktemp mill.XXXXXX)
+    MILL_VERSION_TAG=$(echo $MILL_VERSION | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/')
+    DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${DOWNLOAD_SUFFIX}"
+    # TODO: handle command not found
+    echo "Downloading mill ${MILL_VERSION} from ${DOWNLOAD_URL} ..." 1>&2
+    ${CURL_CMD} -f -L -o "${DOWNLOAD_FILE}" ${DOWNLOAD_URL}
+    chmod +x "${DOWNLOAD_FILE}"
+    mkdir -p "${MILL_DOWNLOAD_PATH}"
+    mv "${DOWNLOAD_FILE}" "${MILL}"
+
+    unset DOWNLOAD_FILE
+    unset DOWNLOAD_SUFFIX
+  fi
+fi
+
+if [ -z "$MILL_MAIN_CLI" ] ; then
+  MILL_MAIN_CLI="${0}"
+fi
+
+MILL_FIRST_ARG=""
+if [ "$1" = "--bsp" ] || [ "$1" = "-i" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then
+  # Need to preserve the first position of those listed options
+  MILL_FIRST_ARG=$1
+  shift
+fi
+
+unset MILL_DOWNLOAD_PATH
+unset MILL_OLD_DOWNLOAD_PATH
+unset OLD_MILL
+unset MILL_VERSION
+unset MILL_VERSION_TAG
+unset MILL_REPO_URL
+
+# We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes
+# shellcheck disable=SC2086
+exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@"

+ 173 - 0
frameworks/Scala/otavia/millw.bat

@@ -0,0 +1,173 @@
+@echo off
+
+rem This is a wrapper script, that automatically download mill from GitHub release pages
+rem You can give the required mill version with --mill-version parameter
+rem If no version is given, it falls back to the value of DEFAULT_MILL_VERSION
+rem
+rem Project page: https://github.com/lefou/millw
+rem Script Version: 0.4.6
+rem
+rem If you want to improve this script, please also contribute your changes back!
+rem
+rem Licensed under the Apache License, Version 2.0
+
+rem setlocal seems to be unavailable on Windows 95/98/ME
+rem but I don't think we need to support them in 2019
+setlocal enabledelayedexpansion
+
+if [!DEFAULT_MILL_VERSION!]==[] (
+    set "DEFAULT_MILL_VERSION=0.10.10"
+)
+
+if [!GITHUB_RELEASE_CDN!]==[] (
+    set "GITHUB_RELEASE_CDN="
+)
+
+set "MILL_REPO_URL=https://github.com/com-lihaoyi/mill"
+
+rem %~1% removes surrounding quotes
+if [%~1%]==[--mill-version] (
+  if not [%~2%]==[] (
+    set MILL_VERSION=%~2%
+    rem shift command doesn't work within parentheses
+    set "STRIP_VERSION_PARAMS=true"
+  ) else (
+    echo You specified --mill-version without a version. 1>&2
+    echo Please provide a version that matches one provided on 1>&2
+    echo %MILL_REPO_URL%/releases 1>&2
+    exit /b 1
+  )
+)
+
+if not defined STRIP_VERSION_PARAMS GOTO AfterStripVersionParams
+rem strip the: --mill-version {version}
+shift
+shift
+:AfterStripVersionParams
+
+if [!MILL_VERSION!]==[] (
+  if exist .mill-version (
+      set /p MILL_VERSION=<.mill-version
+  ) else (
+    if exist .config\mill-version (
+      set /p MILL_VERSION=<.config\mill-version
+    )
+  )
+)
+
+if [!MILL_VERSION!]==[] (
+    set MILL_VERSION=%DEFAULT_MILL_VERSION%
+)
+
+set MILL_DOWNLOAD_PATH=%USERPROFILE%\.mill\download
+
+rem without bat file extension, cmd doesn't seem to be able to run it
+set MILL=%MILL_DOWNLOAD_PATH%\!MILL_VERSION!.bat
+
+if not exist "%MILL%" (
+    set VERSION_PREFIX=%MILL_VERSION:~0,4%
+    set DOWNLOAD_SUFFIX=-assembly
+    if [!VERSION_PREFIX!]==[0.0.] set DOWNLOAD_SUFFIX=
+    if [!VERSION_PREFIX!]==[0.1.] set DOWNLOAD_SUFFIX=
+    if [!VERSION_PREFIX!]==[0.2.] set DOWNLOAD_SUFFIX=
+    if [!VERSION_PREFIX!]==[0.3.] set DOWNLOAD_SUFFIX=
+    if [!VERSION_PREFIX!]==[0.4.] set DOWNLOAD_SUFFIX=
+    set VERSION_PREFIX=
+
+    for /F "delims=- tokens=1" %%A in ("!MILL_VERSION!") do set MILL_VERSION_BASE=%%A
+    for /F "delims=- tokens=2" %%A in ("!MILL_VERSION!") do set MILL_VERSION_MILESTONE=%%A
+	set VERSION_MILESTONE_START=!MILL_VERSION_MILESTONE:~0,1!
+    if [!VERSION_MILESTONE_START!]==[M] (
+        set MILL_VERSION_TAG="!MILL_VERSION_BASE!-!MILL_VERSION_MILESTONE!"
+    ) else (
+        set MILL_VERSION_TAG=!MILL_VERSION_BASE!
+    )
+
+    rem there seems to be no way to generate a unique temporary file path (on native Windows)
+    set DOWNLOAD_FILE=%MILL%.tmp
+
+    set DOWNLOAD_URL=!GITHUB_RELEASE_CDN!%MILL_REPO_URL%/releases/download/!MILL_VERSION_TAG!/!MILL_VERSION!!DOWNLOAD_SUFFIX!
+
+    echo Downloading mill %MILL_VERSION% from !DOWNLOAD_URL! ... 1>&2
+
+    if not exist "%MILL_DOWNLOAD_PATH%" mkdir "%MILL_DOWNLOAD_PATH%"
+    rem curl is bundled with recent Windows 10
+    rem but I don't think we can expect all the users to have it in 2019
+    where /Q curl
+    if %ERRORLEVEL% EQU 0 (
+        curl -f -L "!DOWNLOAD_URL!" -o "!DOWNLOAD_FILE!"
+    ) else (
+        rem bitsadmin seems to be available on Windows 7
+        rem without /dynamic, github returns 403
+        rem bitsadmin is sometimes needlessly slow but it looks better with /priority foreground
+        bitsadmin /transfer millDownloadJob /dynamic /priority foreground "!DOWNLOAD_URL!" "!DOWNLOAD_FILE!"
+    )
+    if not exist "!DOWNLOAD_FILE!" (
+        echo Could not download mill %MILL_VERSION% 1>&2
+        exit /b 1
+    )
+
+    move /y "!DOWNLOAD_FILE!" "%MILL%"
+
+    set DOWNLOAD_FILE=
+    set DOWNLOAD_SUFFIX=
+)
+
+set MILL_DOWNLOAD_PATH=
+set MILL_VERSION=
+set MILL_REPO_URL=
+
+if [!MILL_MAIN_CLI!]==[] (
+    set "MILL_MAIN_CLI=%0"
+)
+
+rem Need to preserve the first position of those listed options
+set MILL_FIRST_ARG=
+if [%~1%]==[--bsp] (
+  set MILL_FIRST_ARG=%1%
+) else (
+  if [%~1%]==[-i] (
+    set MILL_FIRST_ARG=%1%
+  ) else (
+    if [%~1%]==[--interactive] (
+      set MILL_FIRST_ARG=%1%
+    ) else (
+      if [%~1%]==[--no-server] (
+        set MILL_FIRST_ARG=%1%
+      ) else (
+        if [%~1%]==[--repl] (
+          set MILL_FIRST_ARG=%1%
+        ) else (
+          if [%~1%]==[--help] (
+            set MILL_FIRST_ARG=%1%
+          )
+        )
+      )
+    )
+  )
+)
+
+set "MILL_PARAMS=%*%"
+
+if not [!MILL_FIRST_ARG!]==[] (
+  if defined STRIP_VERSION_PARAMS (
+    for /f "tokens=1-3*" %%a in ("%*") do (
+        set "MILL_PARAMS=%%d"
+    )
+  ) else (
+    for /f "tokens=1*" %%a in ("%*") do (
+      set "MILL_PARAMS=%%b"
+    )
+  )
+) else (
+  if defined STRIP_VERSION_PARAMS (
+    for /f "tokens=1-2*" %%a in ("%*") do (
+        rem strip %%a - It's the "--mill-version" option.
+        rem strip %%b - it's the version number that comes after the option.
+        rem keep  %%c - It's the remaining options.
+        set "MILL_PARAMS=%%c"
+    )
+  )
+)
+
+"%MILL%" %MILL_FIRST_ARG% -D "mill.main.cli=%MILL_MAIN_CLI%" %MILL_PARAMS%

+ 15 - 0
frameworks/Scala/otavia/otavia-reserve.dockerfile

@@ -0,0 +1,15 @@
+FROM nightscape/scala-mill:eclipse-temurin-17.0.8.1_1-jdk-focal_0.11.6_3.3.0
+WORKDIR /otavia
+COPY benchmark benchmark
+COPY build.sc build.sc
+ENV COURSIER_REPOSITORIES=ivy2Local|central
+RUN mill benchmark.assembly
+
+EXPOSE 8080
+
+CMD java -server \
+    -Dcc.otavia.actor.worker.size=18 -Dcc.otavia.nio.worker.size=36 \
+    -jar \
+    out/benchmark/assembly.dest/out.jar \
+    jdbc:postgresql://tfb-database:5432/hello_world \
+    benchmarkdbuser benchmarkdbpass 54

+ 15 - 0
frameworks/Scala/otavia/otavia.dockerfile

@@ -0,0 +1,15 @@
+FROM nightscape/scala-mill:eclipse-temurin-17.0.8.1_1-jdk-focal_0.11.6_3.3.0
+WORKDIR /otavia
+COPY benchmark benchmark
+COPY build.sc build.sc
+ENV COURSIER_REPOSITORIES=ivy2Local|central
+RUN mill benchmark.assembly
+
+EXPOSE 8080
+
+CMD java -server \
+    -Dcc.otavia.actor.worker.size=24 -Dcc.otavia.nio.worker.size=48 \
+    -jar \
+    out/benchmark/assembly.dest/out.jar \
+    jdbc:postgresql://tfb-database:5432/hello_world \
+    benchmarkdbuser benchmarkdbpass 72