├── .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/.*/' -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/.*/' -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 |
--------------------------------------------------------------------------------