├── .github └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── .scalafmt.conf ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── build.sbt ├── dev-flow.md ├── docs ├── .nojekyll ├── _coverpage.md ├── _sidebar.md ├── design.md ├── index.html ├── installing.md └── limiter.md ├── modules └── core │ ├── .github │ └── workflows │ │ ├── ci.yml │ │ └── clean.yml │ └── src │ ├── main │ └── scala │ │ ├── Limiter.scala │ │ ├── internal │ │ ├── Barrier.scala │ │ ├── Queue.scala │ │ ├── RateSyntax.scala │ │ ├── Task.scala │ │ └── Timer.scala │ │ └── package.scala │ └── test │ └── scala │ ├── BaseSuite.scala │ ├── LimiterSuite.scala │ └── internal │ ├── BarrierSuite.scala │ ├── QueueSuite.scala │ ├── TaskSuite.scala │ └── TimerSuite.scala └── project ├── build.properties └── plugins.sbt /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 20 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 21 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 22 | 23 | jobs: 24 | build: 25 | name: Build and Test 26 | strategy: 27 | matrix: 28 | os: [ubuntu-latest] 29 | scala: [2.13.8, 3.2.2, 2.12.14] 30 | java: [temurin@11] 31 | runs-on: ${{ matrix.os }} 32 | steps: 33 | - name: Checkout current branch (full) 34 | uses: actions/checkout@v2 35 | with: 36 | fetch-depth: 0 37 | 38 | - name: Setup Java (temurin@11) 39 | if: matrix.java == 'temurin@11' 40 | uses: actions/setup-java@v2 41 | with: 42 | distribution: temurin 43 | java-version: 11 44 | 45 | - name: Cache sbt 46 | uses: actions/cache@v2 47 | with: 48 | path: | 49 | ~/.sbt 50 | ~/.ivy2/cache 51 | ~/.coursier/cache/v1 52 | ~/.cache/coursier/v1 53 | ~/AppData/Local/Coursier/Cache/v1 54 | ~/Library/Caches/Coursier/v1 55 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 56 | 57 | - name: Check that workflows are up to date 58 | run: sbt ++${{ matrix.scala }} githubWorkflowCheck 59 | 60 | - run: sbt ++${{ matrix.scala }} ci 61 | 62 | - if: matrix.scala == '2.13.8' 63 | run: sbt ++${{ matrix.scala }} docs/mdoc 64 | 65 | - name: Compress target directories 66 | run: tar cf targets.tar target modules/core/.native/target modules/core/.js/target modules/core/.jvm/target project/target 67 | 68 | - name: Upload target directories 69 | uses: actions/upload-artifact@v2 70 | with: 71 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 72 | path: targets.tar 73 | 74 | publish: 75 | name: Publish Artifacts 76 | needs: [build] 77 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 78 | strategy: 79 | matrix: 80 | os: [ubuntu-latest] 81 | scala: [2.13.8] 82 | java: [temurin@11] 83 | runs-on: ${{ matrix.os }} 84 | steps: 85 | - name: Checkout current branch (full) 86 | uses: actions/checkout@v2 87 | with: 88 | fetch-depth: 0 89 | 90 | - name: Setup Java (temurin@11) 91 | if: matrix.java == 'temurin@11' 92 | uses: actions/setup-java@v2 93 | with: 94 | distribution: temurin 95 | java-version: 11 96 | 97 | - name: Cache sbt 98 | uses: actions/cache@v2 99 | with: 100 | path: | 101 | ~/.sbt 102 | ~/.ivy2/cache 103 | ~/.coursier/cache/v1 104 | ~/.cache/coursier/v1 105 | ~/AppData/Local/Coursier/Cache/v1 106 | ~/Library/Caches/Coursier/v1 107 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 108 | 109 | - name: Download target directories (2.13.8) 110 | uses: actions/download-artifact@v2 111 | with: 112 | name: target-${{ matrix.os }}-2.13.8-${{ matrix.java }} 113 | 114 | - name: Inflate target directories (2.13.8) 115 | run: | 116 | tar xf targets.tar 117 | rm targets.tar 118 | 119 | - name: Download target directories (3.2.2) 120 | uses: actions/download-artifact@v2 121 | with: 122 | name: target-${{ matrix.os }}-3.2.2-${{ matrix.java }} 123 | 124 | - name: Inflate target directories (3.2.2) 125 | run: | 126 | tar xf targets.tar 127 | rm targets.tar 128 | 129 | - name: Download target directories (2.12.14) 130 | uses: actions/download-artifact@v2 131 | with: 132 | name: target-${{ matrix.os }}-2.12.14-${{ matrix.java }} 133 | 134 | - name: Inflate target directories (2.12.14) 135 | run: | 136 | tar xf targets.tar 137 | rm targets.tar 138 | 139 | - name: Import signing key 140 | run: echo $PGP_SECRET | base64 -d | gpg --import 141 | 142 | - run: sbt ++${{ matrix.scala }} release 143 | 144 | docs: 145 | name: Deploy docs 146 | needs: [publish] 147 | if: always() && needs.build.result == 'success' && (needs.publish.result == 'success' || github.ref == 'refs/heads/docs-deploy') 148 | strategy: 149 | matrix: 150 | os: [ubuntu-latest] 151 | scala: [2.13.8] 152 | java: [temurin@11] 153 | runs-on: ${{ matrix.os }} 154 | steps: 155 | - name: Download target directories (2.13.8) 156 | uses: actions/download-artifact@v2 157 | with: 158 | name: target-${{ matrix.os }}-2.13.8-${{ matrix.java }} 159 | 160 | - name: Inflate target directories (2.13.8) 161 | run: | 162 | tar xf targets.tar 163 | rm targets.tar 164 | 165 | - name: Download target directories (3.2.2) 166 | uses: actions/download-artifact@v2 167 | with: 168 | name: target-${{ matrix.os }}-3.2.2-${{ matrix.java }} 169 | 170 | - name: Inflate target directories (3.2.2) 171 | run: | 172 | tar xf targets.tar 173 | rm targets.tar 174 | 175 | - name: Download target directories (2.12.14) 176 | uses: actions/download-artifact@v2 177 | with: 178 | name: target-${{ matrix.os }}-2.12.14-${{ matrix.java }} 179 | 180 | - name: Inflate target directories (2.12.14) 181 | run: | 182 | tar xf targets.tar 183 | rm targets.tar 184 | 185 | - name: Deploy docs 186 | uses: peaceiris/actions-gh-pages@v3 187 | with: 188 | publish_dir: ./target/website 189 | github_token: ${{ secrets.GITHUB_TOKEN }} 190 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .ensime 3 | .ensime_cache/ 4 | .DS_Store 5 | .idea 6 | *.iml 7 | .bsp 8 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.4.3 2 | align.preset = none 3 | runner.dialect = scala213 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other such characteristics. 4 | 5 | Everyone is expected to follow the [Scala Code of Conduct] when discussing the project on the available communication channels. If you are being harassed, please contact us immediately so that we can support you. 6 | 7 | ## Moderation 8 | 9 | Any questions, concerns, or moderation requests please contact a member of the project. 10 | 11 | - [Fabio Labella](fabio.labella2@gmail.com) 12 | 13 | [Scala Code of Conduct]: https://www.scala-lang.org/conduct/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Fabio Labella 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # upperbound 2 | 3 | **upperbound** is a purely functional, interval based rate limiter. 4 | 5 | All documentation is available on the [microsite](https://systemfw.org/upperbound/) 6 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | Global / onChangedBuildSource := ReloadOnSourceChanges 2 | 3 | ThisBuild / baseVersion := "0.4.0" 4 | ThisBuild / organization := "org.systemfw" 5 | ThisBuild / publishGithubUser := "SystemFw" 6 | ThisBuild / publishFullName := "Fabio Labella" 7 | ThisBuild / homepage := Some(url("https://github.com/SystemFw/upperbound")) 8 | ThisBuild / scmInfo := Some( 9 | ScmInfo( 10 | url("https://github.com/SystemFw/upperbound"), 11 | "git@github.com:SystemFw/upperbound.git" 12 | ) 13 | ) 14 | ThisBuild / licenses := List(("MIT", url("http://opensource.org/licenses/MIT"))) 15 | ThisBuild / startYear := Some(2017) 16 | Global / excludeLintKeys += scmInfo 17 | 18 | val Scala213 = "2.13.8" 19 | ThisBuild / spiewakMainBranches := Seq("main") 20 | 21 | ThisBuild / crossScalaVersions := Seq(Scala213, "3.2.2", "2.12.14") 22 | ThisBuild / scalaVersion := (ThisBuild / crossScalaVersions).value.head 23 | ThisBuild / initialCommands := """ 24 | |import cats._, data._, syntax.all._ 25 | |import cats.effect._, concurrent._ 26 | |import cats.effect.implicits._ 27 | |import cats.effect.unsafe.implicits.global 28 | |import fs2._ 29 | |import fs2.concurrent._ 30 | |import scala.concurrent.duration._ 31 | |import upperbound._ 32 | """.stripMargin 33 | 34 | ThisBuild / testFrameworks += new TestFramework("munit.Framework") 35 | ThisBuild / Test / parallelExecution := false 36 | 37 | def dep(org: String, prefix: String, version: String)( 38 | modules: String* 39 | )(testModules: String*) = 40 | Def.setting { 41 | modules.map(m => org %%% (prefix ++ m) % version) ++ 42 | testModules.map(m => org %%% (prefix ++ m) % version % Test) 43 | } 44 | 45 | lazy val root = project 46 | .in(file(".")) 47 | .enablePlugins(NoPublishPlugin, SonatypeCiReleasePlugin) 48 | .aggregate(core.jvm, core.js, core.native) 49 | 50 | lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) 51 | .crossType(CrossType.Pure) 52 | .in(file("modules/core")) 53 | .settings( 54 | name := "upperbound", 55 | scalafmtOnCompile := true, 56 | libraryDependencies ++= 57 | dep("org.typelevel", "cats-", "2.9.0")("core")().value ++ 58 | dep("org.typelevel", "cats-effect", "3.5.0")("")("-laws", "-testkit").value ++ 59 | dep("co.fs2", "fs2-", "3.7.0")("core")().value ++ 60 | dep("org.scalameta", "munit", "1.0.0-M7")()("", "-scalacheck").value ++ 61 | dep("org.typelevel", "", "2.0.0-M3")()("munit-cats-effect").value ++ 62 | dep("org.typelevel", "scalacheck-effect", "2.0.0-M2")()("", "-munit").value 63 | ) 64 | 65 | lazy val docs = project 66 | .in(file("mdoc")) 67 | .settings( 68 | mdocIn := file("docs"), 69 | mdocOut := file("target/website"), 70 | mdocVariables := Map( 71 | "version" -> version.value, 72 | "scalaVersions" -> crossScalaVersions.value 73 | .map(v => s"- **$v**") 74 | .mkString("\n") 75 | ), 76 | githubWorkflowArtifactUpload := false, 77 | fatalWarningsInCI := false 78 | ) 79 | .dependsOn(core.jvm) 80 | .enablePlugins(MdocPlugin, NoPublishPlugin) 81 | 82 | ThisBuild / githubWorkflowBuildPostamble ++= List( 83 | WorkflowStep.Sbt( 84 | List("docs/mdoc"), 85 | cond = Some(s"matrix.scala == '$Scala213'") 86 | ) 87 | ) 88 | 89 | ThisBuild / githubWorkflowAddedJobs += WorkflowJob( 90 | id = "docs", 91 | name = "Deploy docs", 92 | needs = List("publish"), 93 | cond = """ 94 | | always() && 95 | | needs.build.result == 'success' && 96 | | (needs.publish.result == 'success' || github.ref == 'refs/heads/docs-deploy') 97 | """.stripMargin.trim.linesIterator.mkString.some, 98 | steps = githubWorkflowGeneratedDownloadSteps.value.toList :+ 99 | WorkflowStep.Use( 100 | UseRef.Public("peaceiris", "actions-gh-pages", "v3"), 101 | name = Some(s"Deploy docs"), 102 | params = Map( 103 | "publish_dir" -> "./target/website", 104 | "github_token" -> "${{ secrets.GITHUB_TOKEN }}" 105 | ) 106 | ), 107 | scalas = List(Scala213), 108 | javas = githubWorkflowJavaVersions.value.toList 109 | ) 110 | -------------------------------------------------------------------------------- /dev-flow.md: -------------------------------------------------------------------------------- 1 | # Developer flow 2 | 3 | ## Local docs 4 | 5 | Docs are based on: 6 | 7 | - `docsify`, a _dynamic_ , markdown based generator. 8 | - `mdoc`, typechecked scala/markdown compiler 9 | 10 | The source for the docs is in `yourProject/docs`, the website in 11 | `yourProject/target/website`. The currently deployed website is in the 12 | `gh-pages` branch. 13 | 14 | To preview the site locally, you need to install: 15 | 16 | ``` 17 | npm i docsify-cli -g 18 | ``` 19 | 20 | then, start mdoc in an sbt session: 21 | 22 | ``` 23 | sbt docs/mdoc --watch 24 | ``` 25 | 26 | and docsify in a shell session: 27 | 28 | ``` 29 | cd yourProject/target/website 30 | docsify serve . 31 | ``` 32 | 33 | and you'll get an updating preview. 34 | Note that `mdoc` watches the markdown files, so if you change the code 35 | itself it will need a manual recompile. 36 | 37 | `docsify` uses 3 special files: `index.html`, `_coverpage.md`, `_sidebar.md`, 38 | the sidebar needs to have a specific format: 39 | 40 | - newlines in between headers 41 | - and no extra modifiers inside links `[good]`, `[**bad**]` (or collapse will not work) 42 | 43 | ## Release 44 | 45 | Push a `vx.y.z` tag on `main` to release. It will fail if semver isn't 46 | respected wrt bincompat. 47 | Docs are released automatically on each code release, if you need a 48 | docs-only deploy, (force) push `main` to the `docs-deploy` branch. 49 | 50 | To change/add branches to release: 51 | 52 | > ThisBuild / spiewakMainBranches := Seq("main", "develop") 53 | 54 | To relax semver: 55 | 56 | > ThisBuild / strictSemVer := false 57 | 58 | To publish snapshot on every main build: 59 | 60 | > ThisBuild / spiewakCiReleaseSnapshots := true 61 | 62 | Caveat: 63 | If you are publishing snapshots, you need to make sure that new 64 | commits are fully built before you push a proper release tag: push 65 | `main`, wait for the snapshot release to complete, and then push the 66 | tag. 67 | 68 | ## Links 69 | 70 | - https://github.com/djspiewak/sbt-spiewak 71 | - https://github.com/djspiewak/sbt-github-actions 72 | - https://docsify.js.org/#/ 73 | - https://scalameta.org/mdoc/ 74 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SystemFw/upperbound/db0722f718dc677d4901d84ed1148b634a742d28/docs/.nojekyll -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | # Upperbound 2 | 3 | > A simple, purely functional rate limiter 4 | 5 | - Interval-based design to prevent bursts 6 | - Prioritised jobs 7 | - Dynamic controls over both rate and concurrency 8 | 9 | [Getting started](installing.md) 10 | 11 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Installing](installing.md) 2 | - [Design](design.md) 3 | - [Limiter](limiter.md) 4 | -------------------------------------------------------------------------------- /docs/design.md: -------------------------------------------------------------------------------- 1 | # Design 2 | 3 | **upperbound** is an interval based rate limiter, which means that 4 | jobs submitted to it are started at a _constant rate_. This strategy 5 | prevents spikes in throughput, and makes it a very good fit for client 6 | side limiting, e.g. calling a rate limited API or mitigating load on a 7 | slow system. 8 | 9 | It's intended as a simple, minimal library, but with enough control to 10 | be broadly applicable, including: 11 | - Job execution rate 12 | - Maximum concurrency of jobs 13 | - Dynamically adjustable rate and concurrency 14 | - Prioritisation of jobs 15 | 16 | **upperbound** is a purely functional library, built on top of 17 | **cats-effect** for state management and performant, well-behaved 18 | concurrency. 19 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Upperbound 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /docs/installing.md: -------------------------------------------------------------------------------- 1 | # Installing 2 | 3 | Add to your `build.sbt` 4 | 5 | ```scala 6 | libraryDependencies += "org.systemfw" %% "upperbound" % "@version@" 7 | ``` 8 | 9 | `upperbound` is published for the following versions of Scala: 10 | 11 | @scalaVersions@ 12 | 13 | and depends on **cats-effect** and **fs2**. 14 | 15 | Versioning follows SemVer, binary compatibility is maintained between patch 16 | versions in 0.x releases, and between minor versions from 1.x releases 17 | forward. 18 | -------------------------------------------------------------------------------- /docs/limiter.md: -------------------------------------------------------------------------------- 1 | # Limiter 2 | 3 | ## Submitting jobs 4 | 5 | **upperbound** offers a very minimal api, centred around the **Limiter** type: 6 | 7 | ```scala 8 | trait Limiter[F[_]] { 9 | def submit[A](job: F[A], priority: Int = 0): F[A] 10 | } 11 | ``` 12 | 13 | The `submit` method submits a job (which can be an arbitrary task) to 14 | the limiter and waits until its execution is complete and a result 15 | is available. 16 | 17 | It is designed to be called concurrently: every call submits a job, 18 | and they are started at regular intervals up to a maximum number of 19 | concurrent jobs, based on the parameters you specify when creating the 20 | limiter. 21 | 22 | In case of failure, the returned `F[A]` will fail with the same error 23 | `job` failed with. Note that in **upperbound** no errors are thrown if a job is rate limited, it simply waits to be executed in a queue. 24 | `submit` can however fail with a `LimitReachedException` if the number 25 | of enqueued jobs is past the limit you specify when creating the 26 | limiter. 27 | 28 | **upperbound** is well behaved with respect to cancelation: if you 29 | cancel the `F[A]` returned by `submit`, the submitted job will be 30 | canceled too. Two scenarios are possible: if cancelation is triggered 31 | whilst the job is still queued up for execution, it will never be 32 | executed and the rate of the limiter won't be affected. If instead 33 | cancelation is triggered while the job is running, it will be 34 | interrupted, but that slot will be considered used and the next job 35 | will only be executed after the required time interval has elapsed. 36 | 37 | `submit` also takes a `priority` parameter, which lets you submit jobs 38 | at different priorities, so that higher priority jobs can be executed 39 | before lower priority ones. 40 | A higher number means a higher priority, with 0 being the default. 41 | 42 | Note that any blocking performed by `submit` is only semantic, no 43 | actual threads are blocked by the implementation. 44 | 45 | 46 | ## Rate limiting controls 47 | 48 | To create a `Limiter`, use the `Limiter.start` method, which creates a 49 | new limiter and starts processing jobs submitted to it. 50 | 51 | ```scala 52 | object Limiter { 53 | def start[F[_]: Temporal]( 54 | minInterval: FiniteDuration, 55 | maxConcurrent: Int = Int.MaxValue, 56 | maxQueued: Int = Int.MaxValue 57 | ): Resource[F, Limiter[F]] 58 | } 59 | ``` 60 | 61 | > **Note:** 62 | > It's recommended to use an explicit type ascription such as 63 | > `Limiter.start[IO]` or `Limiter.start[F]` when calling `start`, to 64 | > avoid type inference issues. 65 | 66 | In order to avoid bursts, jobs submitted to the limiter are 67 | started at regular intervals, as specified by the `minInterval` 68 | parameter. You can pass `minInterval` as a `FiniteDuration`, or using 69 | **upperbound**'s rate syntax (note the underscores in the imports): 70 | ```scala mdoc:compile-only 71 | import upperbound._ 72 | import upperbound.syntax.rate._ 73 | import scala.concurrent.duration._ 74 | import cats.effect._ 75 | 76 | Limiter.start[IO](minInterval = 1.second) 77 | // or 78 | Limiter.start[IO](minInterval = 60 every 1.minute) 79 | ``` 80 | 81 | If the duration of some jobs is longer than `minInterval`, multiple 82 | jobs will be started concurrently. 83 | You can limit the amount of concurrency with the `maxConcurrent` 84 | parameter: upon reaching `maxConcurrent` running jobs, the 85 | limiter will stop pulling new ones until old ones terminate. 86 | Note that this means that the specified interval between jobs is 87 | indeed a _minimum_ interval, and it could be longer if the 88 | `maxConcurrent` bound gets hit. The default is no limit. 89 | 90 | Jobs that are waiting to be executed are queued up in memory, and 91 | you can control the maximum size of this queue with the 92 | `maxQueued` parameter. 93 | Once this number is reached, submitting new jobs will immediately 94 | fail with a `LimitReachedException`, so that you can in turn signal 95 | for backpressure downstream. Submission is allowed again as soon as 96 | the number of jobs waiting goes below `maxQueued`. 97 | `maxQueued` must be **> 0**, and the default is no limit. 98 | 99 | > **Notes:** 100 | > - `Limiter` accepts jobs at different priorities, with jobs at a 101 | higher priority being executed before lower priority ones. 102 | > - Jobs that fail or are interrupted do not affect processing of 103 | > other jobs. 104 | 105 | 106 | ## Program construction 107 | 108 | `Limiter.start` returns a `cats.effect.Resource` so that processing 109 | can be stopped gracefully when the limiter's lifetime is over. When 110 | the `Resource` is finalised, all pending and running jobs are 111 | canceled. All outstanding calls to `submit` are also canceled. 112 | 113 | To assemble your program, make sure that all the places that need 114 | limiting at the same rate take `Limiter` as an argument, and create 115 | one at the end of a region of sharing (typically `main`) via a single 116 | call to `Limiter.start(...).use`. 117 | 118 | In particular, note that the following code creates two different 119 | limiters: 120 | 121 | ```scala mdoc:compile-only 122 | import cats.syntax.all._ 123 | import upperbound._ 124 | import cats.effect._ 125 | import scala.concurrent.duration._ 126 | 127 | val limiter = Limiter.start[IO](1.second) 128 | 129 | // example modules, generally classes in real code 130 | def apiCall: IO[Unit] = 131 | limiter.use { limiter => 132 | val call: IO[Unit] = ??? 133 | limiter.submit(call) 134 | } 135 | 136 | def otherApiCall: IO[Unit] = ??? 137 | limiter.use { limiter => 138 | val otherCall: IO[Unit] = ??? 139 | limiter.submit(otherCall) 140 | } 141 | 142 | // example logic 143 | (apiCall, otherApiCall).parTupled 144 | ``` 145 | 146 | Instead, you want to ensure the same limiter is passed to both: 147 | 148 | ```scala mdoc:compile-only 149 | import cats.syntax.all._ 150 | import upperbound._ 151 | import cats.effect._ 152 | import scala.concurrent.duration._ 153 | 154 | val limiter = Limiter.start[IO](1.second) 155 | 156 | // example modules, generally classes in real code 157 | def apiCall(limiter: Limiter[IO]): IO[Unit] = { 158 | val call: IO[Unit] = ??? 159 | limiter.submit(call) 160 | } 161 | 162 | def otherApiCall(limiter: Limiter[IO]): IO[Unit] = { 163 | val otherCall: IO[Unit] = ??? 164 | limiter.submit(otherCall) 165 | } 166 | 167 | // example logic 168 | limiter.use { limiter => 169 | ( 170 | apiCall(limiter), 171 | otherApiCall(limiter) 172 | ).parTupled 173 | } 174 | ``` 175 | 176 | If you struggled to make sense of the examples in this section, it's 177 | recommended to watch [this talk](https://systemfw.org/talks.html#scala-italy-2018). 178 | 179 | ## Adjusting rate and concurrency 180 | 181 | **upperbound** lets you control both the rate of submission and the 182 | maximum concurrency dynamically, through the following methods on 183 | `Limiter`: 184 | 185 | ```scala 186 | def minInterval: F[FiniteDuration] 187 | def setMinInterval(newMinInterval: FiniteDuration): F[Unit] 188 | def updateMinInterval(update: FiniteDuration => FiniteDuration): F[Unit] 189 | 190 | def maxConcurrent: F[Int] 191 | def setMaxConcurrent(newMaxConcurrent: Int): F[Unit] 192 | def updateMaxConcurrent(update: Int => Int): F[Unit] 193 | ``` 194 | 195 | The `*minInterval` methods let you change the rate of submission by 196 | varying the minimum time interval between two tasks. If the interval 197 | changes while the limiter is sleeping between tasks, the duration of 198 | the sleep is adjusted on the fly, taking into account any elapsed 199 | time. This might mean waking up instantly if the entire new interval 200 | has already elapsed. 201 | 202 | The `*maxConcurrent` methods let you change the maximum number of 203 | concurrent tasks that can be executing at any given time. If the 204 | concurrency limit gets changed while the limiter is already blocked 205 | waiting for some tasks to finish, the limiter will then be unblocked 206 | as soon as the number of running tasks goes below the new concurrency 207 | limit. Note however that if the limit shrinks the limiter will not try to 208 | interrupt tasks that are already running, so for some time it might be 209 | that `runningTasks > maxConcurrent`. 210 | 211 | 212 | ## Test limiter 213 | 214 | **upperbound** also provides `Limiter.noOp` for testing purposes, which is 215 | a stub `Limiter` with no actual rate limiting and a synchronous 216 | `submit` method. 217 | 218 | 219 | 220 | -------------------------------------------------------------------------------- /modules/core/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 20 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 21 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 22 | 23 | jobs: 24 | build: 25 | name: Build and Test 26 | strategy: 27 | matrix: 28 | os: [ubuntu-latest] 29 | scala: [2.13.6, 3.0.0, 2.12.14] 30 | java: [adopt@1.11.0-11] 31 | runs-on: ${{ matrix.os }} 32 | steps: 33 | - name: Checkout current branch (full) 34 | uses: actions/checkout@v2 35 | with: 36 | fetch-depth: 0 37 | 38 | - name: Setup Java and Scala 39 | uses: olafurpg/setup-scala@v13 40 | with: 41 | java-version: ${{ matrix.java }} 42 | 43 | - name: Cache sbt 44 | uses: actions/cache@v2 45 | with: 46 | path: | 47 | ~/.sbt 48 | ~/.ivy2/cache 49 | ~/.coursier/cache/v1 50 | ~/.cache/coursier/v1 51 | ~/AppData/Local/Coursier/Cache/v1 52 | ~/Library/Caches/Coursier/v1 53 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 54 | 55 | - name: Check that workflows are up to date 56 | run: sbt ++${{ matrix.scala }} githubWorkflowCheck 57 | 58 | - run: sbt ++${{ matrix.scala }} ci 59 | 60 | - if: matrix.scala == '2.13.6' 61 | run: sbt ++${{ matrix.scala }} docs/mdoc 62 | 63 | - name: Compress target directories 64 | run: tar cf targets.tar target modules/core/target project/target 65 | 66 | - name: Upload target directories 67 | uses: actions/upload-artifact@v2 68 | with: 69 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 70 | path: targets.tar 71 | 72 | publish: 73 | name: Publish Artifacts 74 | needs: [build] 75 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 76 | strategy: 77 | matrix: 78 | os: [ubuntu-latest] 79 | scala: [2.13.6] 80 | java: [adopt@1.11.0-11] 81 | runs-on: ${{ matrix.os }} 82 | steps: 83 | - name: Checkout current branch (full) 84 | uses: actions/checkout@v2 85 | with: 86 | fetch-depth: 0 87 | 88 | - name: Setup Java and Scala 89 | uses: olafurpg/setup-scala@v13 90 | with: 91 | java-version: ${{ matrix.java }} 92 | 93 | - name: Cache sbt 94 | uses: actions/cache@v2 95 | with: 96 | path: | 97 | ~/.sbt 98 | ~/.ivy2/cache 99 | ~/.coursier/cache/v1 100 | ~/.cache/coursier/v1 101 | ~/AppData/Local/Coursier/Cache/v1 102 | ~/Library/Caches/Coursier/v1 103 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 104 | 105 | - name: Download target directories (2.13.6) 106 | uses: actions/download-artifact@v2 107 | with: 108 | name: target-${{ matrix.os }}-2.13.6-${{ matrix.java }} 109 | 110 | - name: Inflate target directories (2.13.6) 111 | run: | 112 | tar xf targets.tar 113 | rm targets.tar 114 | 115 | - name: Download target directories (3.0.0) 116 | uses: actions/download-artifact@v2 117 | with: 118 | name: target-${{ matrix.os }}-3.0.0-${{ matrix.java }} 119 | 120 | - name: Inflate target directories (3.0.0) 121 | run: | 122 | tar xf targets.tar 123 | rm targets.tar 124 | 125 | - name: Download target directories (2.12.14) 126 | uses: actions/download-artifact@v2 127 | with: 128 | name: target-${{ matrix.os }}-2.12.14-${{ matrix.java }} 129 | 130 | - name: Inflate target directories (2.12.14) 131 | run: | 132 | tar xf targets.tar 133 | rm targets.tar 134 | 135 | - name: Import signing key 136 | run: echo $PGP_SECRET | base64 -d | gpg --import 137 | 138 | - run: sbt ++${{ matrix.scala }} release 139 | 140 | docs: 141 | name: Deploy docs 142 | needs: [publish] 143 | if: always() && needs.build.result == 'success' && (needs.publish.result == 'success' || github.ref == 'refs/heads/docs-deploy') 144 | strategy: 145 | matrix: 146 | os: [ubuntu-latest] 147 | scala: [2.13.6] 148 | java: [adopt@1.11.0-11] 149 | runs-on: ${{ matrix.os }} 150 | steps: 151 | - name: Download target directories (2.13.6) 152 | uses: actions/download-artifact@v2 153 | with: 154 | name: target-${{ matrix.os }}-2.13.6-${{ matrix.java }} 155 | 156 | - name: Inflate target directories (2.13.6) 157 | run: | 158 | tar xf targets.tar 159 | rm targets.tar 160 | 161 | - name: Download target directories (3.0.0) 162 | uses: actions/download-artifact@v2 163 | with: 164 | name: target-${{ matrix.os }}-3.0.0-${{ matrix.java }} 165 | 166 | - name: Inflate target directories (3.0.0) 167 | run: | 168 | tar xf targets.tar 169 | rm targets.tar 170 | 171 | - name: Download target directories (2.12.14) 172 | uses: actions/download-artifact@v2 173 | with: 174 | name: target-${{ matrix.os }}-2.12.14-${{ matrix.java }} 175 | 176 | - name: Inflate target directories (2.12.14) 177 | run: | 178 | tar xf targets.tar 179 | rm targets.tar 180 | 181 | - name: Deploy docs 182 | uses: peaceiris/actions-gh-pages@v3 183 | with: 184 | publish_dir: ./target/website 185 | github_token: ${{ secrets.GITHUB_TOKEN }} 186 | -------------------------------------------------------------------------------- /modules/core/.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/Limiter.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | 24 | import cats._ 25 | import cats.syntax.all._ 26 | import cats.effect._ 27 | import cats.effect.implicits._ 28 | import cats.effect.std.Supervisor 29 | import scala.concurrent.duration._ 30 | 31 | import upperbound.internal.{Queue, Task, Barrier, Timer} 32 | 33 | /** A purely functional, interval based rate limiter. 34 | */ 35 | trait Limiter[F[_]] { 36 | 37 | /** Submits `job` to the [[Limiter]] and waits until a result is available. 38 | * 39 | * `submit` is designed to be called concurrently: every call submits a job, 40 | * and they are started at regular intervals up to a maximum number of 41 | * concurrent jobs, based on the parameters you specify when creating the 42 | * [[Limiter]]. 43 | * 44 | * In case of failure, the returned `F[A]` will fail with the same error 45 | * `job` failed with. Note that in **upperbound** no errors are thrown if a 46 | * job is rate limited, it simply waits to be executed in a queue. `submit` 47 | * can however fail with a [[LimitReachedException]] if the number of 48 | * enqueued jobs is past the limit you specify when creating the [[Limiter]]. 49 | * 50 | * Cancelation semantics are respected, and cancelling the returned `F[A]` 51 | * will also cancel the execution of `job`. Two scenarios are possible: if 52 | * cancelation is triggered whilst `job` is still queued up for execution, 53 | * `job` will never be executed and the rate of the [[Limiter]] won't be 54 | * affected. If instead cancelation is triggered while `job` is running, 55 | * `job` will be interrupted, but that slot will be considered used and the 56 | * next job will only be executed after the required time interval has 57 | * elapsed. 58 | * 59 | * The `priority` parameter allows you to submit jobs at different 60 | * priorities, so that higher priority jobs can be executed before lower 61 | * priority ones. A higher number means a higher priority. The default is 0. 62 | * 63 | * Note that any blocking performed by this method is only semantic, no 64 | * actual threads are blocked by the implementation. 65 | */ 66 | def submit[A]( 67 | job: F[A], 68 | priority: Int = 0 69 | ): F[A] 70 | 71 | /** Obtains a snapshot of the current number of jobs waiting to be executed. 72 | * May be out of date the instant after it is retrieved. 73 | */ 74 | def pending: F[Int] 75 | 76 | /** Obtains a snapshot of the current interval. 77 | * 78 | * May be out of date the instant after it is retrieved if a call to 79 | * `setMinInterval`. or `updateMinInterval` happens. 80 | */ 81 | def minInterval: F[FiniteDuration] 82 | 83 | /** Resets the current interval. 84 | * 85 | * If the interval changes while the Limiter is sleeping between tasks, the 86 | * duration of the sleep is adjusted on the fly, taking into account any 87 | * elapsed time. This might mean waking up instantly if the entire new 88 | * interval has already elapsed. 89 | */ 90 | def setMinInterval(newMinInterval: FiniteDuration): F[Unit] 91 | 92 | /** Updates the current interval. 93 | * 94 | * If the interval changes while the Limiter is sleeping between tasks, the 95 | * duration of the sleep is adjusted on the fly, taking into account any 96 | * elapsed time. This might mean waking up instantly if the entire new 97 | * interval has already elapsed. 98 | */ 99 | def updateMinInterval(update: FiniteDuration => FiniteDuration): F[Unit] 100 | 101 | /** Obtains a snapshot of the current concurrency limit. 102 | * 103 | * May be out of date the instant after it is retrieved if a call to 104 | * `setMaxConcurrent` or `updateMaxConcurrent` happens. 105 | */ 106 | def maxConcurrent: F[Int] 107 | 108 | /** Resets the task concurrency limit. 109 | * 110 | * If `maxConcurrent` gets changed while the Limiter is already blocked 111 | * waiting for some tasks to finish, the Limiter will then be unblocked as 112 | * soon as the number of running tasks goes below `newMaxConcurrent`. 113 | * 114 | * Note however that if the concurrency limit shrinks the Limiter will not 115 | * try to interrupt tasks that are already running, so for some time it might 116 | * be that `runningTasks > maxConcurrent`. 117 | */ 118 | def setMaxConcurrent(newMaxConcurrent: Int): F[Unit] 119 | 120 | /** Updates the task concurrency limit. 121 | * 122 | * If `maxConcurrent` gets changed while the Limiter is already blocked 123 | * waiting for some tasks to finish, the Limiter will then be unblocked as 124 | * soon as the number of running tasks goes below `newMaxConcurrent`. 125 | * 126 | * Note however that if the concurrency limit shrinks the Limiter will not 127 | * try to interrupt tasks that are already running, so for some time it might 128 | * be that `runningTasks > maxConcurrent`. 129 | */ 130 | def updateMaxConcurrent(update: Int => Int): F[Unit] 131 | } 132 | 133 | object Limiter { 134 | 135 | /** Signals that the number of jobs waiting to be executed has reached the 136 | * maximum allowed number. See [[Limiter.start]] 137 | */ 138 | case class LimitReachedException() extends Exception 139 | 140 | /** Summoner */ 141 | def apply[F[_]](implicit l: Limiter[F]): Limiter[F] = l 142 | 143 | /** Creates a new [[Limiter]] and starts processing submitted jobs at a 144 | * regular rate, in priority order. 145 | * 146 | * It's recommended to use an explicit type ascription such as 147 | * `Limiter.start[IO]` or `Limiter.start[F]` when calling `start`, to avoid 148 | * type inference issues. 149 | * 150 | * In order to avoid bursts, jobs submitted to the [[Limiter]] are started at 151 | * regular intervals, as specified by the `minInterval` parameter. You can 152 | * pass `minInterval` as a `FiniteDuration`, or using **upperbound**'s rate 153 | * syntax (note the underscore in the `rate` import): 154 | * {{{ 155 | * import upperbound._ 156 | * import upperbound.syntax.rate._ 157 | * import scala.concurrent.duration._ 158 | * import cats.effect._ 159 | * 160 | * Limiter.start[IO](minInterval = 1.second) 161 | * 162 | * // or 163 | * 164 | * Limiter.start[IO](minInterval = 60 every 1.minute) 165 | * }}} 166 | * 167 | * If the duration of some jobs is longer than `minInterval`, multiple jobs 168 | * will be started concurrently. You can limit the amount of concurrency with 169 | * the `maxConcurrent` parameter: upon reaching `maxConcurrent` running jobs, 170 | * the [[Limiter]] will stop pulling new ones until old ones terminate. Note 171 | * that this means that the specified interval between jobs is indeed a 172 | * _minimum_ interval, and it could be longer if the `maxConcurrent` bound 173 | * gets hit. The default is no limit. 174 | * 175 | * Jobs that are waiting to be executed are queued up in memory, and you can 176 | * control the maximum size of this queue with the `maxQueued` parameter. 177 | * Once this number is reached, submitting new jobs will immediately fail 178 | * with a [[LimitReachedException]], so that you can in turn signal for 179 | * backpressure downstream. Submission is allowed again as soon as the number 180 | * of jobs waiting goes below `maxQueued`. `maxQueued` must be > 0. The 181 | * default is no limit. 182 | * 183 | * [[Limiter]] accepts jobs at different priorities, with jobs at a higher 184 | * priority being executed before lower priority ones. 185 | * 186 | * Jobs that fail or are interrupted do not affect processing. 187 | * 188 | * The lifetime of a [[Limiter]] is bound by the `Resource` returned by this 189 | * method: make sure all the places that need limiting at the same rate share 190 | * the same limiter by calling `use` on the returned `Resource` once, and 191 | * passing the resulting [[Limiter]] as an argument whenever needed. When the 192 | * `Resource` is finalised, all pending and running jobs are canceled. All 193 | * outstanding calls to `submit` are also canceled. 194 | */ 195 | def start[F[_]: Temporal]( 196 | minInterval: FiniteDuration, 197 | maxConcurrent: Int = Int.MaxValue, 198 | maxQueued: Int = Int.MaxValue 199 | ): Resource[F, Limiter[F]] = { 200 | assert(maxQueued > 0, s"maxQueued must be > 0, was $maxQueued") 201 | assert(maxConcurrent > 0, s"maxConcurrent must be > 0, was $maxConcurrent") 202 | 203 | val F = Temporal[F] 204 | 205 | val resources = 206 | ( 207 | Resource.eval(Queue[F, F[Unit]](maxQueued)), 208 | Resource.eval(Barrier[F](maxConcurrent)), 209 | Resource.eval(Timer[F](minInterval)), 210 | Supervisor[F] 211 | ).tupled 212 | 213 | resources.flatMap { case (queue, barrier, timer, supervisor) => 214 | val limiter = new Limiter[F] { 215 | def submit[A]( 216 | job: F[A], 217 | priority: Int = 0 218 | ): F[A] = F.uncancelable { poll => 219 | Task.create(job).flatMap { task => 220 | queue 221 | .enqueue(task.executable, priority) 222 | .flatMap { id => 223 | val propagateCancelation = 224 | queue.delete(id).flatMap { deleted => 225 | // task has already been dequeued and running 226 | task.cancel.whenA(!deleted) 227 | } 228 | 229 | poll(task.awaitResult).onCancel(propagateCancelation) 230 | } 231 | } 232 | } 233 | 234 | def pending: F[Int] = queue.size 235 | 236 | def minInterval: F[FiniteDuration] = 237 | timer.interval 238 | def setMinInterval(newMinInterval: FiniteDuration): F[Unit] = 239 | timer.setInterval(newMinInterval) 240 | def updateMinInterval( 241 | update: FiniteDuration => FiniteDuration 242 | ): F[Unit] = 243 | timer.updateInterval(update) 244 | 245 | def maxConcurrent: F[Int] = 246 | barrier.limit 247 | def setMaxConcurrent(newMaxConcurrent: Int): F[Unit] = 248 | barrier.setLimit(newMaxConcurrent) 249 | def updateMaxConcurrent(update: Int => Int): F[Unit] = 250 | barrier.updateLimit(update) 251 | } 252 | 253 | /* this only gets cancelled if the limiter needs shutting down, 254 | * no interruption safety needed except canceling running 255 | * fibers, which happens automatically through supervisor 256 | */ 257 | def executor: F[Unit] = { 258 | def go(fa: F[Unit]): F[Unit] = { 259 | /* F.unit to make sure we exit the barrier even if fa is 260 | * canceled before getting executed 261 | */ 262 | val job = (F.unit >> fa).guarantee(barrier.exit) 263 | 264 | supervisor.supervise(job) >> 265 | timer.sleep >> 266 | barrier.enter >> 267 | queue.dequeue.flatMap(go) 268 | } 269 | 270 | /* execute fhe first task immediately */ 271 | barrier.enter >> queue.dequeue.flatMap(go) 272 | } 273 | 274 | executor.background.as(limiter) 275 | } 276 | } 277 | 278 | /** Creates a no-op [[Limiter]], with no rate limiting and a synchronous 279 | * `submit` method. `pending` is always zero. `interval` is set to zero and 280 | * changes to it have no effect. 281 | */ 282 | def noOp[F[_]: Applicative]: Limiter[F] = 283 | new Limiter[F] { 284 | def submit[A](job: F[A], priority: Int): F[A] = job 285 | def pending: F[Int] = 0.pure[F] 286 | def maxConcurrent: F[Int] = Int.MaxValue.pure[F] 287 | def minInterval: F[FiniteDuration] = 0.seconds.pure[F] 288 | def setMaxConcurrent(newMaxConcurrent: Int): F[Unit] = ().pure[F] 289 | def setMinInterval(newMinInterval: FiniteDuration): F[Unit] = ().pure[F] 290 | def updateMaxConcurrent(update: Int => Int): F[Unit] = ().pure[F] 291 | def updateMinInterval(update: FiniteDuration => FiniteDuration): F[Unit] = 292 | ().pure[F] 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/internal/Barrier.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import cats.syntax.all._ 26 | import cats.effect._ 27 | import cats.effect.implicits._ 28 | 29 | /** A dynamic barrier which is meant to be used in conjunction with a task 30 | * executor. As such, it assumes there is only a single fiber entering the 31 | * barrier (the executor), but multiple ones exiting it (the tasks). 32 | */ 33 | private[upperbound] trait Barrier[F[_]] { 34 | 35 | /** Obtains a snapshot of the current limit. May be out of date the instant 36 | * after it is retrieved. 37 | */ 38 | def limit: F[Int] 39 | 40 | /** Resets the current limit */ 41 | def setLimit(n: Int): F[Unit] 42 | 43 | /** Updates the current limit */ 44 | def updateLimit(f: Int => Int): F[Unit] 45 | 46 | /** Tries to enter the barrier, semantically blocking if the number of running 47 | * task is at or past the limit. The limit can change dynamically while 48 | * `enter` is blocked, in which case `enter` will be unblocked as soon as the 49 | * number of running tasks goes below the new limit. Note however that the 50 | * Barrier does not try to interrupt tasks that are already running if the 51 | * limit dynamically shrinks, so for some time it might be that runningTasks 52 | * > limit. 53 | * 54 | * Fails with a ConcurrentModificationException if two fibers block on 55 | * `enter` at the same time. 56 | */ 57 | def enter: F[Unit] 58 | 59 | /** Called by tasks when exiting the barrier, and will unblock `enter` when 60 | * the number of running tasks goes beyond the limit. Can be called 61 | * concurrently. 62 | */ 63 | def exit: F[Unit] 64 | } 65 | private[upperbound] object Barrier { 66 | def apply[F[_]: Concurrent](initialLimit: Int): F[Barrier[F]] = { 67 | val F = Concurrent[F] 68 | 69 | case class State( 70 | running: Int, 71 | limit: Int, 72 | waiting: Option[Deferred[F, Unit]] 73 | ) 74 | 75 | def singleEnterViolation = 76 | new IllegalStateException( 77 | "Only one fiber can block on the barrier at a time" 78 | ) 79 | 80 | def runningViolation = 81 | new IllegalStateException( 82 | "The number of fibers in the barrier can never go below zero" 83 | ) 84 | 85 | def limitViolation = 86 | new IllegalArgumentException( 87 | "The limit on fibers in the barrier must be > 0" 88 | ) 89 | 90 | def wakeUp(waiting: Option[Deferred[F, Unit]]) = 91 | waiting.traverse_(_.complete(())) 92 | 93 | F.raiseError(limitViolation).whenA(initialLimit <= 0) >> 94 | F.ref(State(0, initialLimit, None)).map { state => 95 | new Barrier[F] { 96 | def enter: F[Unit] = 97 | F.uncancelable { poll => 98 | F.deferred[Unit].flatMap { wait => 99 | val waitForChanges = poll(wait.get).onCancel { 100 | state.update(s => State(s.running, s.limit, None)) 101 | } 102 | 103 | state.modify { 104 | case s @ State(_, _, Some(waiting @ _)) => 105 | s -> F.raiseError[Unit](singleEnterViolation) 106 | case State(running, limit, None) => 107 | if (running < limit) 108 | State(running + 1, limit, None) -> F.unit 109 | else 110 | State( 111 | running, 112 | limit, 113 | Some(wait) 114 | ) -> (waitForChanges >> enter) 115 | }.flatten 116 | } 117 | } 118 | 119 | def exit: F[Unit] = 120 | state 121 | .modify { case s @ State(running, limit, waiting) => 122 | val runningNow = running - 1 123 | if (runningNow < 0) 124 | s -> F.raiseError[Unit](runningViolation) 125 | else if (runningNow < limit) 126 | State(runningNow, limit, None) -> wakeUp(waiting) 127 | else State(runningNow, limit, waiting) -> F.unit 128 | } 129 | .flatten 130 | .uncancelable 131 | 132 | def updateLimit(f: Int => Int): F[Unit] = 133 | state 134 | .modify { case s @ State(running, limit, waiting) => 135 | val newLimit = f(limit) 136 | if (newLimit <= 0) 137 | s -> F.raiseError[Unit](limitViolation) 138 | else if (running < newLimit) 139 | State(running, newLimit, None) -> wakeUp(waiting) 140 | else State(running, newLimit, waiting) -> F.unit 141 | } 142 | .flatten 143 | .uncancelable 144 | 145 | def limit: F[Int] = state.get.map(_.limit) 146 | def setLimit(n: Int): F[Unit] = updateLimit(_ => n) 147 | } 148 | } 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/internal/Queue.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import cats._ 26 | import cats.syntax.all._ 27 | import cats.effect._ 28 | import cats.effect.std.PQueue 29 | import fs2._ 30 | 31 | /** A concurrent priority queue with support for deletion. Reads block on empty 32 | * queue, writes fail on full queue. 33 | */ 34 | private[upperbound] trait Queue[F[_], A] { 35 | type Id 36 | 37 | /** Enqueues an element. A higher number means higher priority, with 0 as the 38 | * default. Fails if the queue is full. Returns an Id that can be used to 39 | * mark the element as deleted. 40 | */ 41 | def enqueue(a: A, priority: Int = 0): F[Id] 42 | 43 | /** Marks the element at this Id as deleted. Returns false if the element was 44 | * not in the queue. 45 | */ 46 | def delete(id: Id): F[Boolean] 47 | 48 | /** Dequeues the highest priority element. In case there are multiple elements 49 | * with the same priority, they are dequeued in FIFO order. Semantically 50 | * blocks if the queue is empty. 51 | * 52 | * Elements marked as deleted are removed and skipped, and the next element 53 | * in the queue gets returned instead, semantically blocking if there is no 54 | * next element. 55 | */ 56 | def dequeue: F[A] 57 | 58 | /** Repeatedly calls `dequeue` 59 | */ 60 | def dequeueAll: Stream[F, A] = 61 | Stream.repeatEval(dequeue) 62 | 63 | /** Obtains a snapshot of the current number of elements in the queue. May be 64 | * out of date the instant after it is retrieved. 65 | */ 66 | def size: F[Int] 67 | } 68 | 69 | private[upperbound] object Queue { 70 | def apply[F[_]: Concurrent, A]( 71 | maxSize: Int = Int.MaxValue 72 | ): F[Queue[F, A]] = 73 | (Ref[F].of(0L), PQueue.bounded[F, Rank[F, A]](maxSize)).mapN { 74 | (lastInsertedAt, q) => 75 | new Queue[F, A] { 76 | type Id = F[Boolean] 77 | 78 | def enqueue(a: A, priority: Int = 0): F[Id] = 79 | lastInsertedAt.getAndUpdate(_ + 1).flatMap { insertedAt => 80 | Rank.create(a, priority, insertedAt).flatMap { rank => 81 | q.tryOffer(rank) 82 | .flatMap { succeeded => 83 | (new LimitReachedException) 84 | .raiseError[F, Unit] 85 | .whenA(!succeeded) 86 | } 87 | .as(rank.markAsDeleted) 88 | } 89 | } 90 | 91 | def delete(id: Id): F[Boolean] = 92 | id 93 | 94 | def dequeue: F[A] = q.take.flatMap { 95 | _.extract.flatMap { 96 | case Some(a) => a.pure[F] 97 | case None => dequeue 98 | } 99 | } 100 | 101 | def size: F[Int] = q.size 102 | } 103 | } 104 | 105 | case class Rank[F[_]: Concurrent, A]( 106 | a: Ref[F, Option[A]], 107 | priority: Int, 108 | insertedAt: Long = 0 109 | ) { 110 | def extract: F[Option[A]] = 111 | a.getAndSet(None) 112 | 113 | def markAsDeleted: F[Boolean] = 114 | a.getAndSet(None).map(_.isDefined) 115 | } 116 | 117 | object Rank { 118 | def create[F[_]: Concurrent, A]( 119 | a: A, 120 | priority: Int, 121 | insertedAt: Long 122 | ): F[Rank[F, A]] = 123 | Ref[F].of(a.some).map(Rank(_, priority, insertedAt)) 124 | 125 | implicit def rankOrder[F[_], A]: Order[Rank[F, A]] = 126 | Order.whenEqual( 127 | Order.reverse(Order.by(_.priority)), 128 | Order.by(_.insertedAt) 129 | ) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/internal/RateSyntax.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import scala.concurrent.duration.FiniteDuration 26 | 27 | trait RateSyntax { 28 | implicit def rateOps(n: Int): RateOps = 29 | new RateOps(n) 30 | } 31 | 32 | final class RateOps private[internal] (private[internal] val n: Int) 33 | extends AnyVal { 34 | def every(t: FiniteDuration): FiniteDuration = t / n.toLong 35 | } 36 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/internal/Task.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import cats.syntax.all._ 26 | import cats.effect._ 27 | import cats.effect.implicits._ 28 | 29 | /** Packages `fa` to be queued for later execution, and controls propagation of 30 | * the result from executor to client, and propagation of cancelation from 31 | * client to executor. 32 | */ 33 | private[upperbound] case class Task[F[_]: Concurrent, A]( 34 | task: F[A], 35 | result: Deferred[F, Outcome[F, Throwable, A]], 36 | stopSignal: Deferred[F, Unit] 37 | ) { 38 | private val F = Concurrent[F] 39 | 40 | /** Packages `task` for later execution. Cannot fail. Propagates result to 41 | * `waitResult`, including cancelation. 42 | */ 43 | def executable: F[Unit] = 44 | F.uncancelable { poll => 45 | /* `poll(..).onCancel` handles direct cancelation of `executable`, 46 | * which happens when Limiter itself gets shutdown. 47 | * 48 | * `racePair(..).flatMap` propagates cancelation triggered by 49 | * `cancel`from the client to `executable`. 50 | */ 51 | poll(F.racePair(task, stopSignal.get)) 52 | .onCancel(result.complete(Outcome.canceled).void) 53 | .flatMap { 54 | case Left((taskResult, waitForStopSignal)) => 55 | waitForStopSignal.cancel >> result.complete(taskResult) 56 | case Right((runningTask, _)) => 57 | runningTask.cancel >> runningTask.join.flatMap(result.complete(_)) 58 | } 59 | .void 60 | } 61 | 62 | /** Cancels the running task, backpressuring on finalisers 63 | */ 64 | def cancel: F[Unit] = 65 | (stopSignal.complete(()) >> result.get.void).uncancelable 66 | 67 | /** Completes when `executable` does, canceling itself if `executable` gets 68 | * canceled. However, canceling `waitResult` does not cancel `executable` 69 | * automatically, `cancel` needs to be called manually. 70 | */ 71 | def awaitResult: F[A] = 72 | result.get 73 | .flatMap { 74 | _.embed(onCancel = F.canceled >> F.never) 75 | } 76 | } 77 | 78 | private[upperbound] object Task { 79 | def create[F[_]: Concurrent, A](fa: F[A]): F[Task[F, A]] = 80 | ( 81 | Deferred[F, Outcome[F, Throwable, A]], 82 | Deferred[F, Unit] 83 | ).mapN(Task(fa, _, _)) 84 | } 85 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/internal/Timer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import cats.syntax.all._ 26 | import cats.effect._ 27 | import fs2._ 28 | import fs2.concurrent.SignallingRef 29 | import scala.concurrent.duration._ 30 | 31 | /** Resettable timer. */ 32 | private[upperbound] trait Timer[F[_]] { 33 | 34 | /** Obtains a snapshot of the current interval. May be out of date the instant 35 | * after it is retrieved. 36 | */ 37 | def interval: F[FiniteDuration] 38 | 39 | /** Resets the current interval */ 40 | def setInterval(t: FiniteDuration): F[Unit] 41 | 42 | /** Updates the current interval */ 43 | def updateInterval(f: FiniteDuration => FiniteDuration): F[Unit] 44 | 45 | /** Sleeps for the duration of the current interval. 46 | * 47 | * If the interval changes while a sleep is happening, the duration of the 48 | * sleep is adjusted on the fly, taking into account any elapsed time. This 49 | * might mean waking up instantly if the entire new interval has already 50 | * elapsed. 51 | */ 52 | def sleep: F[Unit] 53 | } 54 | private[upperbound] object Timer { 55 | def apply[F[_]: Temporal](initialInterval: FiniteDuration) = { 56 | val F = Temporal[F] 57 | SignallingRef[F, FiniteDuration](initialInterval).map { intervalState => 58 | new Timer[F] { 59 | def interval: F[FiniteDuration] = 60 | intervalState.get 61 | 62 | def setInterval(t: FiniteDuration): F[Unit] = 63 | intervalState.set(t) 64 | 65 | def updateInterval(f: FiniteDuration => FiniteDuration): F[Unit] = 66 | intervalState.update(f) 67 | 68 | def sleep: F[Unit] = 69 | F.monotonic.flatMap { start => 70 | intervalState.discrete 71 | .switchMap { interval => 72 | val action = 73 | F.monotonic.flatMap { now => 74 | val elapsed = now - start 75 | val toSleep = interval - elapsed 76 | 77 | F.sleep(toSleep).whenA(toSleep > 0.nanos) 78 | } 79 | Stream.eval(action) 80 | } 81 | .take(1) 82 | .compile 83 | .drain 84 | } 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package object upperbound { 23 | type LimitReachedException = Limiter.LimitReachedException 24 | val LimitReachedException = Limiter.LimitReachedException 25 | 26 | object syntax { 27 | 28 | /** Syntactic sugar to create rates. 29 | * 30 | * Example (note the underscores): 31 | * {{{ 32 | * import upperbound.syntax.rate._ 33 | * import scala.concurrent.duration._ 34 | * 35 | * val r: FiniteDuration = 100 every 1.minute 36 | * }}} 37 | */ 38 | object rate extends internal.RateSyntax 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /modules/core/src/test/scala/BaseSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | 24 | import munit.{CatsEffectSuite, ScalaCheckEffectSuite} 25 | 26 | abstract class BaseSuite extends CatsEffectSuite with ScalaCheckEffectSuite { 27 | def isJS = Option(System.getProperty("java.vm.name")).contains("Scala.js") 28 | } 29 | -------------------------------------------------------------------------------- /modules/core/src/test/scala/LimiterSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | 24 | import cats.syntax.all._ 25 | import cats.effect._ 26 | import fs2._ 27 | import scala.concurrent.duration._ 28 | 29 | import cats.effect.testkit.TestControl 30 | 31 | class LimiterSuite extends BaseSuite { 32 | def simulation( 33 | desiredInterval: FiniteDuration, 34 | maxConcurrent: Int, 35 | productionInterval: FiniteDuration, 36 | producers: Int, 37 | jobsPerProducer: Int, 38 | jobCompletion: FiniteDuration, 39 | control: Limiter[IO] => IO[Unit] = _ => IO.unit 40 | ): IO[Vector[FiniteDuration]] = 41 | Limiter.start[IO](desiredInterval, maxConcurrent).use { limiter => 42 | def job = IO.monotonic <* IO.sleep(jobCompletion) 43 | 44 | def producer = 45 | Stream(job) 46 | .repeatN(jobsPerProducer.toLong) 47 | .covary[IO] 48 | .meteredStartImmediately(productionInterval) 49 | .mapAsyncUnordered(Int.MaxValue)(job => limiter.submit(job)) 50 | 51 | def runProducers = 52 | Stream(producer) 53 | .repeatN(producers.toLong) 54 | .parJoinUnbounded 55 | 56 | def results = 57 | runProducers 58 | .sliding(2) 59 | .map { sample => sample(1) - sample(0) } 60 | .compile 61 | .toVector 62 | 63 | control(limiter).background.surround(results) 64 | } 65 | 66 | test("submit semantics should return the result of the submitted job") { 67 | IO.ref(false) 68 | .flatMap { complete => 69 | Limiter.start[IO](200.millis).use { 70 | _.submit(complete.set(true).as("done")) 71 | .product(complete.get) 72 | } 73 | } 74 | .map { case (res, state) => 75 | assertEquals(res, "done") 76 | assertEquals(state, true) 77 | } 78 | } 79 | 80 | test("submit semantics should report errors of a failed task") { 81 | case class MyError() extends Exception 82 | Limiter 83 | .start[IO](200.millis) 84 | .use { 85 | _.submit(IO.raiseError[Int](new MyError)) 86 | } 87 | .intercept[MyError] 88 | } 89 | 90 | test("multiple fast producers, fast non-failing jobs") { 91 | val prog = simulation( 92 | desiredInterval = 200.millis, 93 | maxConcurrent = Int.MaxValue, 94 | productionInterval = 1.millis, 95 | producers = 4, 96 | jobsPerProducer = if (isJS) 10 else 100, 97 | jobCompletion = 0.seconds 98 | ) 99 | 100 | TestControl.executeEmbed(prog).map { r => 101 | assert(r.forall(_ == 200.millis)) 102 | } 103 | } 104 | 105 | test("slow producer, no unnecessary delays") { 106 | val prog = simulation( 107 | desiredInterval = 200.millis, 108 | maxConcurrent = Int.MaxValue, 109 | productionInterval = 300.millis, 110 | producers = 1, 111 | jobsPerProducer = if (isJS) 10 else 100, 112 | jobCompletion = 0.seconds 113 | ) 114 | 115 | TestControl.executeEmbed(prog).map { r => 116 | assert(r.forall(_ == 300.millis)) 117 | } 118 | } 119 | 120 | test("maximum concurrency") { 121 | val prog = simulation( 122 | desiredInterval = 50.millis, 123 | maxConcurrent = 3, 124 | productionInterval = 1.millis, 125 | producers = 1, 126 | jobsPerProducer = 10, 127 | jobCompletion = 300.millis 128 | ) 129 | 130 | val expected = Vector( 131 | 50, 50, 200, 50, 50, 200, 50, 50, 200 132 | ).map(_.millis) 133 | 134 | TestControl.executeEmbed(prog).assertEquals(expected) 135 | } 136 | 137 | test("interval change") { 138 | val prog = simulation( 139 | desiredInterval = 200.millis, 140 | maxConcurrent = Int.MaxValue, 141 | productionInterval = 1.millis, 142 | producers = 1, 143 | jobsPerProducer = 10, 144 | jobCompletion = 0.seconds, 145 | control = 146 | limiter => IO.sleep(1100.millis) >> limiter.setMinInterval(300.millis) 147 | ) 148 | 149 | val expected = Vector( 150 | 200, 200, 200, 200, 200, 300, 300, 300, 300 151 | ).map(_.millis) 152 | 153 | TestControl.executeEmbed(prog).assertEquals(expected) 154 | } 155 | 156 | test( 157 | "descheduling a job while blocked on the time limit should not affect the interval" 158 | ) { 159 | val prog = Limiter.start[IO](500.millis).use { limiter => 160 | val job = limiter.submit(IO.monotonic) 161 | val skew = IO.sleep(10.millis) // to ensure we queue jobs as desired 162 | 163 | ( 164 | job, 165 | skew >> job.timeoutTo(200.millis, IO.unit), 166 | skew >> skew >> job 167 | ).parMapN((t1, _, t3) => t3 - t1) 168 | } 169 | 170 | TestControl.executeEmbed(prog).assertEquals(500.millis) 171 | } 172 | 173 | test( 174 | "descheduling a job while blocked on the concurrency limit should not affect the interval" 175 | ) { 176 | val prog = Limiter.start[IO](30.millis, maxConcurrent = 1).use { limiter => 177 | val job = limiter.submit(IO.monotonic <* IO.sleep(500.millis)) 178 | val skew = IO.sleep(10.millis) // to ensure we queue jobs as desired 179 | 180 | ( 181 | job, 182 | skew >> job.timeoutTo(200.millis, IO.unit), 183 | skew >> skew >> job 184 | ).parMapN((t1, _, t3) => t3 - t1) 185 | } 186 | 187 | TestControl.executeEmbed(prog).assertEquals(500.millis) 188 | } 189 | 190 | test("cancelling job, interval slot gets taken") { 191 | val prog = Limiter.start[IO](100.millis).use { limiter => 192 | val job = limiter.submit(IO.monotonic) 193 | val canceledJob = limiter 194 | .submit(IO.monotonic <* IO.sleep(50.millis)) 195 | .as("done") 196 | .timeoutTo(125.millis, "canceled".pure[IO]) 197 | val skew = IO.sleep(10.millis) // to ensure we queue jobs as desired 198 | 199 | ( 200 | job, 201 | skew >> canceledJob, 202 | skew >> skew >> job 203 | ).parMapN((t1, outcome, t3) => (outcome, t3 - t1)) 204 | } 205 | 206 | TestControl.executeEmbed(prog).assertEquals("canceled" -> 200.millis) 207 | } 208 | 209 | test("max concurrency shrinks before interval elapses, should be respected") { 210 | val interval = 500.millis 211 | val taskDuration = 700.millis // > interval 212 | val concurrencyShrinksAt = 300.millis // < interval 213 | 214 | val prog = 215 | Limiter.start[IO](interval, maxConcurrent = 2).use { limiter => 216 | val skew = IO.sleep(10.millis) 217 | ( 218 | limiter.submit(IO.monotonic <* IO.sleep(taskDuration)), 219 | skew >> limiter.submit(IO.monotonic), 220 | IO.sleep(concurrencyShrinksAt) >> limiter.setMaxConcurrent(1) 221 | ).parMapN((t1, t2, _) => t2 - t1) 222 | } 223 | 224 | TestControl.executeEmbed(prog).assertEquals(taskDuration) 225 | } 226 | 227 | test("max concurrency shrinks after interval elapses, should be no-op") { 228 | val interval = 300.millis 229 | val taskDuration = 700.millis // > interval 230 | val concurrencyShrinksAt = 500.millis // > interval 231 | 232 | val prog = 233 | Limiter.start[IO](interval, maxConcurrent = 2).use { limiter => 234 | val skew = IO.sleep(10.millis) 235 | ( 236 | limiter.submit(IO.monotonic <* IO.sleep(taskDuration)), 237 | skew >> limiter.submit(IO.monotonic), 238 | IO.sleep(concurrencyShrinksAt) >> limiter.setMaxConcurrent(1) 239 | ).parMapN((t1, t2, _) => t2 - t1) 240 | } 241 | 242 | TestControl.executeEmbed(prog).assertEquals(interval) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /modules/core/src/test/scala/internal/BarrierSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import cats.syntax.all._ 26 | import cats.effect._ 27 | import scala.concurrent.duration._ 28 | 29 | import cats.effect.testkit.TestControl 30 | import cats.effect.testkit.TestControl.NonTerminationException 31 | 32 | class BarrierSuite extends BaseSuite { 33 | private def fillBarrier(barrier: Barrier[IO]): IO[Unit] = 34 | barrier.limit 35 | .flatMap(limit => barrier.enter.replicateA(limit)) 36 | .void 37 | 38 | def timedStart(fa: IO[_]): Resource[IO, IO[FiniteDuration]] = 39 | fa.timed.background.map { _.flatMap(_.embedNever).map(_._1) } 40 | 41 | test("enter the barrier immediately if below the limit") { 42 | val prog = Barrier[IO](10).flatMap(_.enter) 43 | 44 | TestControl.executeEmbed(prog).assert 45 | } 46 | 47 | test("enter blocks when limit is hit") { 48 | val prog = Barrier[IO](2).flatMap { barrier => 49 | fillBarrier(barrier) >> barrier.enter 50 | } 51 | 52 | TestControl.executeEmbed(prog).intercept[NonTerminationException] 53 | } 54 | 55 | test("enter is unblocked by exit") { 56 | val prog = Barrier[IO](1).flatMap { barrier => 57 | fillBarrier(barrier) >> timedStart(barrier.enter).use { getResult => 58 | IO.sleep(1.second) >> barrier.exit >> getResult 59 | } 60 | } 61 | 62 | TestControl.executeEmbed(prog).assertEquals(1.second) 63 | } 64 | 65 | test("enter is unblocked by exit the right amount of times") { 66 | val prog = Barrier[IO](3) 67 | .flatMap { barrier => 68 | fillBarrier(barrier) >> 69 | timedStart(barrier.enter >> barrier.enter).use { getResult => 70 | IO.sleep(1.second) >> 71 | barrier.exit >> 72 | IO.sleep(1.second) >> 73 | barrier.exit >> 74 | getResult 75 | } 76 | } 77 | 78 | TestControl.executeEmbed(prog).assertEquals(2.seconds) 79 | } 80 | 81 | test("Only one fiber can block on enter at the same time") { 82 | val prog = Barrier[IO](1) 83 | .flatMap { barrier => 84 | fillBarrier(barrier) >> (barrier.enter, barrier.enter).parTupled 85 | } 86 | 87 | TestControl.executeEmbed(prog).intercept[Throwable] 88 | } 89 | 90 | test("Cannot call exit without entering") { 91 | val prog = Barrier[IO](5).flatMap { barrier => 92 | barrier.exit >> barrier.enter 93 | } 94 | 95 | TestControl.executeEmbed(prog).intercept[Throwable] 96 | } 97 | 98 | test("Calls to exit cannot outnumber calls to enter") { 99 | val prog = Barrier[IO](2).flatMap { barrier => 100 | barrier.enter >> barrier.enter >> barrier.exit >> barrier.exit >> barrier.exit 101 | } 102 | 103 | TestControl.executeEmbed(prog).intercept[Throwable] 104 | } 105 | 106 | test("Cannot construct a barrier with a 0 limit") { 107 | Barrier[IO](0).intercept[Throwable] 108 | } 109 | 110 | test("Cannot change a limit to zero") { 111 | Barrier[IO](0).flatMap(_.setLimit(0)).intercept[Throwable] 112 | } 113 | 114 | test("A blocked enter is immediately unblocked if the limit is expanded") { 115 | val prog = Barrier[IO](3) 116 | .flatMap { barrier => 117 | fillBarrier(barrier) >> 118 | timedStart(barrier.enter >> barrier.enter).use { getResult => 119 | IO.sleep(1.second) >> 120 | barrier.setLimit(4) >> 121 | IO.sleep(1.second) >> 122 | barrier.setLimit(5) >> 123 | getResult 124 | } 125 | } 126 | 127 | TestControl.executeEmbed(prog).assertEquals(2.seconds) 128 | } 129 | 130 | test("A blocked enter is not unblocked prematurely if the limit is shrunk") { 131 | val prog = Barrier[IO](3) 132 | .flatMap { barrier => 133 | fillBarrier(barrier) >> 134 | timedStart(barrier.enter).use { getResult => 135 | barrier.setLimit(2) >> 136 | IO.sleep(1.second) >> 137 | barrier.exit >> 138 | getResult 139 | } 140 | } 141 | 142 | TestControl.executeEmbed(prog).intercept[NonTerminationException] 143 | } 144 | 145 | test("Sequential limit changes") { 146 | val prog = Barrier[IO](3) 147 | .flatMap { barrier => 148 | fillBarrier(barrier) >> 149 | timedStart(barrier.enter).use { _ => 150 | barrier.setLimit(5) >> 151 | barrier.enter >> 152 | barrier.setLimit(2) >> 153 | barrier.exit >> 154 | barrier.exit >> 155 | barrier.enter 156 | } 157 | } 158 | 159 | TestControl.executeEmbed(prog).intercept[NonTerminationException] 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /modules/core/src/test/scala/internal/QueueSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import cats.syntax.all._ 26 | import cats.effect._ 27 | import fs2.Stream 28 | import scala.concurrent.duration._ 29 | 30 | import org.scalacheck.effect.PropF.forAllF 31 | import cats.effect.testkit.TestControl 32 | 33 | class QueueSuite extends BaseSuite { 34 | test("dequeue the highest priority elements first") { 35 | forAllF { (elems: Vector[Int]) => 36 | Queue[IO, Int]() 37 | .map { q => 38 | Stream 39 | .emits(elems) 40 | .zipWithIndex 41 | .evalMap { case (e, p) => q.enqueue(e, p.toInt) } 42 | .drain ++ q.dequeueAll.take(elems.size.toLong) 43 | } 44 | .flatMap(_.compile.toVector) 45 | .assertEquals(elems.reverse) 46 | } 47 | } 48 | 49 | test("dequeue elements with the same priority in FIFO order") { 50 | forAllF { (elems: Vector[Int]) => 51 | Queue[IO, Int]() 52 | .map { q => 53 | Stream 54 | .emits(elems) 55 | .evalMap(q.enqueue(_)) 56 | .drain ++ q.dequeueAll 57 | .take(elems.size.toLong) 58 | } 59 | .flatMap(_.compile.toVector) 60 | .assertEquals(elems) 61 | } 62 | } 63 | 64 | test("fail an enqueue attempt if the queue is full") { 65 | Queue[IO, Int](1) 66 | .flatMap { q => 67 | q.enqueue(1) >> q.enqueue(1) 68 | } 69 | .intercept[LimitReachedException] 70 | } 71 | 72 | test("successfully enqueue after dequeueing from a full queue") { 73 | Queue[IO, Int](1) 74 | .flatMap { q => 75 | q.enqueue(1) >> 76 | q.enqueue(2).attempt >> 77 | q.dequeue >> 78 | q.enqueue(3) >> 79 | q.dequeue 80 | } 81 | .assertEquals(3) 82 | } 83 | 84 | test("block on an empty queue until an element is available") { 85 | val prog = Queue[IO, Unit]() 86 | .flatMap { q => 87 | def prod = IO.sleep(1.second) >> q.enqueue(()) 88 | def consumer = q.dequeue.timeout(3.seconds) 89 | 90 | prod.start >> consumer 91 | } 92 | 93 | TestControl.executeEmbed(prog).assert 94 | } 95 | 96 | test( 97 | "If a dequeue gets canceled before an enqueue, no elements are lost in the next dequeue" 98 | ) { 99 | val prog = Queue[IO, Unit]().flatMap { q => 100 | q.dequeue.timeout(2.second).attempt >> 101 | q.enqueue(()) >> 102 | q.dequeue.timeout(1.second) 103 | } 104 | 105 | TestControl.executeEmbed(prog).assert 106 | } 107 | 108 | test("Mark an element as deleted") { 109 | val prog = Queue[IO, Int]().flatMap { q => 110 | q.enqueue(1).flatMap { id => 111 | q.enqueue(2) >> 112 | q.delete(id) >> 113 | ( 114 | q.dequeue, 115 | q.dequeue.map(_.some).timeoutTo(1.second, None.pure[IO]) 116 | ).tupled 117 | } 118 | } 119 | 120 | TestControl.executeEmbed(prog).assertEquals(2 -> None) 121 | } 122 | 123 | // This test uses real concurrency to maximise racing 124 | test("Delete returns true <-> element is marked as deleted") { 125 | // Number of iterations to make potential races repeatable 126 | val n = 1000 127 | 128 | val prog = 129 | Queue[IO, Int]().flatMap { q => 130 | q.enqueue(1).flatMap { id => 131 | q.enqueue(2) >> 132 | ( 133 | q.delete(id), 134 | q.dequeue 135 | ).parTupled 136 | } 137 | } 138 | 139 | prog 140 | .replicateA(n) 141 | .map { results => 142 | results.forall { case (deleted, elem) => 143 | if (deleted) elem == 2 144 | else elem == 1 145 | } 146 | } 147 | .assert 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /modules/core/src/test/scala/internal/TaskSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import scala.concurrent.duration._ 26 | import cats.effect._ 27 | import cats.syntax.all._ 28 | 29 | import cats.effect.testkit.TestControl 30 | import java.util.concurrent.CancellationException 31 | 32 | class TaskSuite extends BaseSuite { 33 | 34 | class MyException extends Throwable 35 | 36 | def execute[A](task: IO[A]): IO[(IO[A], IO[Unit])] = 37 | Task.create(task).flatMap { task => 38 | task.executable.start.as(task.awaitResult -> task.cancel) 39 | } 40 | 41 | def executeAndWait[A](task: IO[A]): IO[A] = 42 | execute(task).flatMap(_._1) 43 | 44 | test("The Task executable cannot fail") { 45 | val prog = 46 | Task 47 | .create { IO.raiseError[Unit](new MyException) } 48 | .flatMap(_.executable) 49 | 50 | TestControl.executeEmbed(prog).assert 51 | } 52 | 53 | test("Task propagates results") { 54 | val prog = executeAndWait { IO.sleep(1.second).as(42) } 55 | 56 | TestControl.executeEmbed(prog).assertEquals(42) 57 | } 58 | 59 | test("Task propagates errors") { 60 | val prog = executeAndWait { IO.raiseError[Unit](new MyException) } 61 | 62 | TestControl.executeEmbed(prog).intercept[MyException] 63 | } 64 | 65 | test("Task propagates cancellation") { 66 | val prog = executeAndWait { IO.sleep(1.second) >> IO.canceled } 67 | 68 | /* TestControl reports cancelation and nontermination with different 69 | * exceptions, a deadlock in Task.awaitResult would make this test fail 70 | */ 71 | TestControl.executeEmbed(prog).intercept[CancellationException] 72 | } 73 | 74 | test("cancel cancels the Task executable") { 75 | val prog = 76 | execute { IO.never[Unit] } 77 | .flatMap { case (wait, cancel) => cancel >> wait } 78 | 79 | TestControl.executeEmbed(prog).intercept[CancellationException] 80 | } 81 | 82 | test("cancel backpressures on finalisers") { 83 | val prog = 84 | execute { IO.never[Unit].onCancel(IO.sleep(1.second)) } 85 | .flatMap { case (_, cancel) => 86 | IO.sleep(10.millis) >> cancel.timed.map(_._1) 87 | } 88 | 89 | TestControl.executeEmbed(prog).assertEquals(1.second) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /modules/core/src/test/scala/internal/TimerSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Fabio Labella 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | * this software and associated documentation files (the "Software"), to deal in 6 | * the Software without restriction, including without limitation the rights to 7 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | * the Software, and to permit persons to whom the Software is furnished to do so, 9 | * subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | */ 21 | 22 | package upperbound 23 | package internal 24 | 25 | import cats.syntax.all._ 26 | import cats.effect._ 27 | import scala.concurrent.duration._ 28 | 29 | import cats.effect.testkit.TestControl 30 | 31 | class TimerSuite extends BaseSuite { 32 | private def timedSleep(timer: Timer[IO]): Resource[IO, IO[FiniteDuration]] = 33 | timer.sleep.timed.background 34 | .map { _.flatMap(_.embedNever).map(_._1) } 35 | 36 | test("behaves like a normal clock if never reset") { 37 | val prog = Timer[IO](1.second).flatMap(timedSleep(_).use(x => x)) 38 | 39 | TestControl.executeEmbed(prog).assertEquals(1.seconds) 40 | } 41 | 42 | test("sequential resets") { 43 | val prog = Timer[IO](1.second).flatMap { timer => 44 | ( 45 | timedSleep(timer).use(x => x), 46 | timer.updateInterval(_ + 1.second), 47 | timedSleep(timer).use(x => x) 48 | ).mapN((x, _, y) => (x, y)) 49 | } 50 | 51 | TestControl.executeEmbed(prog).assertEquals((1.second, 2.seconds)) 52 | } 53 | 54 | test("reset while sleeping, interval increased") { 55 | val prog = Timer[IO](2.seconds).flatMap { timer => 56 | timedSleep(timer).use { getResult => 57 | IO.sleep(1.second) >> 58 | timer.setInterval(3.seconds) >> 59 | getResult 60 | } 61 | } 62 | 63 | TestControl.executeEmbed(prog).assertEquals(3.seconds) 64 | } 65 | 66 | test("reset while sleeping, interval decreased but still in the future") { 67 | val prog = Timer[IO](5.seconds).flatMap { timer => 68 | timedSleep(timer).use { getResult => 69 | IO.sleep(1.second) >> 70 | timer.setInterval(3.seconds) >> 71 | getResult 72 | } 73 | } 74 | 75 | TestControl.executeEmbed(prog).assertEquals(3.seconds) 76 | } 77 | 78 | test("reset while sleeping, interval decreased and has already elapsed") { 79 | val prog = Timer[IO](5.seconds).flatMap { timer => 80 | timedSleep(timer).use { getResult => 81 | IO.sleep(2.second) >> 82 | timer.setInterval(1.seconds) >> 83 | getResult 84 | } 85 | } 86 | 87 | TestControl.executeEmbed(prog).assertEquals(2.seconds) 88 | } 89 | 90 | test("multiple resets while sleeping, latest wins") { 91 | val prog = Timer[IO](10.seconds).flatMap { timer => 92 | timedSleep(timer).use { getResult => 93 | IO.sleep(1.second) >> 94 | timer.setInterval(15.seconds) >> 95 | IO.sleep(3.seconds) >> 96 | timer.setInterval(8.seconds) >> 97 | IO.sleep(2.seconds) >> 98 | timer.setInterval(4.seconds) >> 99 | getResult 100 | } 101 | } 102 | 103 | TestControl.executeEmbed(prog).assertEquals(6.seconds) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.8 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.codecommit" % "sbt-spiewak-sonatype" % "0.23.0") 2 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") 3 | addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.2.24") 4 | 5 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.1") 6 | addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.12") 7 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") 8 | addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.1") 9 | --------------------------------------------------------------------------------