Skip to content

Commit e52274b

Browse files
authored
Merge pull request #590 from 47degrees/fix-traverse
Replace implicit traverse behavior with explicit batching
2 parents f23548b + 5e98932 commit e52274b

File tree

14 files changed

+301
-173
lines changed

14 files changed

+301
-173
lines changed

README.md

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@ A library for Simple & Efficient data access in Scala and Scala.js
2121

2222
Add the following dependency to your project's build file.
2323

24-
For Scala 2.11.x and 2.12.x:
24+
For Scala 2.12.x through 3.x:
2525

2626
```scala
27-
"com.47deg" %% "fetch" % "2.1.1"
27+
"com.47deg" %% "fetch" % "3.0.0"
2828
```
2929

30-
Or, if using Scala.js (0.6.x):
30+
Or, if using Scala.js (1.8.x):
3131

3232
```scala
33-
"com.47deg" %%% "fetch" % "2.1.1"
33+
"com.47deg" %%% "fetch" % "3.0.0"
3434
```
3535

3636

@@ -105,20 +105,15 @@ def fetchString[F[_] : Async](n: Int): Fetch[F, String] =
105105

106106
## Creating a runtime
107107

108-
Since `Fetch` relies on `Concurrent` from the `cats-effect` library, we'll need a runtime for executing our effects. We'll be using `IO` from `cats-effect` to run fetches, but you can use any type that has a `Concurrent` instance.
108+
Since we'll use `IO` from the `cats-effect` library to execute our fetches, we'll need an `IORuntime` for executing our `IO` instances.
109109

110-
For executing `IO`, we need a `ContextShift[IO]` used for running `IO` instances and a `Timer[IO]` that is used for scheduling. Let's go ahead and create them. We'll use a `java.util.concurrent.ScheduledThreadPoolExecutor` with a couple of threads to run our fetches.
111-
112-
```scala
113-
import java.util.concurrent._
114-
import scala.concurrent.ExecutionContext
115-
116-
val executor = new ScheduledThreadPoolExecutor(4)
117-
val executionContext: ExecutionContext = ExecutionContext.fromExecutor(executor)
118-
119-
import cats.effect.unsafe.implicits.global
110+
```scala mdoc:silent
111+
import cats.effect.unsafe.implicits.global //Gives us an IORuntime in places it is normally not provided
120112
```
121113

114+
Normally, in your applications, this is provided by `IOApp`, and you should not need to import this except in limited scenarios such as test environments that do not have Cats Effect integration.
115+
For more information, and particularly on why you would usually not want to make one of these yourself, [see this post by Daniel Spiewak](https://github.com/typelevel/cats-effect/discussions/1562#discussioncomment-254838)
116+
122117
## Creating and running a fetch
123118

124119
Now that we can convert `Int` values to `Fetch[F, String]`, let's try creating a fetch.

docs/README.md

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ A library for Simple & Efficient data access in Scala and Scala.js
1313

1414
Add the following dependency to your project's build file.
1515

16-
For Scala 2.11.x and 2.12.x:
16+
For Scala 2.12.x through 3.x:
1717

1818
```scala
1919
"com.47deg" %% "fetch" % "@VERSION@"
2020
```
2121

22-
Or, if using Scala.js (0.6.x):
22+
Or, if using Scala.js (1.8.x):
2323

2424
```scala
2525
"com.47deg" %%% "fetch" % "@VERSION@"
@@ -106,20 +106,15 @@ def fetchString[F[_] : Async](n: Int): Fetch[F, String] =
106106

107107
## Creating a runtime
108108

109-
Since `Fetch` relies on `Concurrent` from the `cats-effect` library, we'll need a runtime for executing our effects. We'll be using `IO` from `cats-effect` to run fetches, but you can use any type that has a `Concurrent` instance.
110-
111-
For executing `IO`, we need a `ContextShift[IO]` used for running `IO` instances and a `Timer[IO]` that is used for scheduling. Let's go ahead and create them. We'll use a `java.util.concurrent.ScheduledThreadPoolExecutor` with a couple of threads to run our fetches.
109+
Since we'll use `IO` from the `cats-effect` library to execute our fetches, we'll need an `IORuntime` for executing our `IO` instances.
112110

113111
```scala mdoc:silent
114-
import java.util.concurrent._
115-
import scala.concurrent.ExecutionContext
116-
117-
val executor = new ScheduledThreadPoolExecutor(4)
118-
val executionContext: ExecutionContext = ExecutionContext.fromExecutor(executor)
119-
120-
import cats.effect.unsafe.implicits.global
112+
import cats.effect.unsafe.implicits.global //Gives us an IORuntime in places it is normally not provided
121113
```
122114

115+
Normally, in your applications, this is provided by `IOApp`, and you should not need to import this except in limited scenarios such as test environments that do not have Cats Effect integration.
116+
For more information, and particularly on why you would usually not want to make one of these yourself, [see this post by Daniel Spiewak](https://github.com/typelevel/cats-effect/discussions/1562#discussioncomment-254838)
117+
123118
## Creating and running a fetch
124119

125120
Now that we can convert `Int` values to `Fetch[F, String]`, let's try creating a fetch.
@@ -282,10 +277,6 @@ runFetchFourTimesSharedCache.unsafeRunTimed(5.seconds)
282277

283278
As you can see above, the cache will now work between calls and can be used to deduplicate requests over a period of time.
284279
Note that this does not support any kind of automatic cache invalidation, so you will need to keep track of which values you want to re-fetch if you plan on sharing the cache.
285-
286-
```scala mdoc:invisible
287-
executor.shutdownNow()
288-
```
289280
---
290281

291282
For more in-depth information, take a look at our [documentation](https://47degrees.github.io/fetch/docs.html).

fetch-examples/src/test/scala/DoobieExample.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import scala.concurrent.ExecutionContext
3131
import java.util.concurrent.Executors
3232

3333
import fetch._
34+
import fetch.syntax._
3435

3536
object DatabaseExample {
3637
case class AuthorId(id: Int)
@@ -150,7 +151,7 @@ class DoobieExample extends AnyWordSpec with Matchers with BeforeAndAfterAll {
150151

151152
"We can fetch multiple authors from the DB in parallel" in {
152153
def fetch[F[_]: Async]: Fetch[F, List[Author]] =
153-
List(1, 2).traverse(Authors.fetchAuthor[F])
154+
List(1, 2).map(Authors.fetchAuthor[F]).batchAll
154155

155156
val io: IO[(Log, List[Author])] = Fetch.runLog[IO](fetch)
156157

fetch-examples/src/test/scala/GithubExample.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import cats.effect._
1818
import cats.syntax.all._
1919
import fetch.{Data, DataSource, Fetch}
20+
import fetch.syntax._
2021
import io.circe._
2122
import io.circe.generic.semiauto._
2223
import org.http4s._
@@ -204,7 +205,7 @@ class GithubExample extends AnyWordSpec with Matchers {
204205
def fetchOrg[F[_]: Async](org: String) =
205206
for {
206207
repos <- orgRepos(org)
207-
projects <- repos.traverse(fetchProject[F])
208+
projects <- repos.batchAllWith(fetchProject[F])
208209
} yield projects
209210

210211
def fetchOrgStars[F[_]: Async](org: String): Fetch[F, Int] =

fetch-examples/src/test/scala/GraphQLExample.scala

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import atto._, Atto._
2323
import cats.syntax.all._
2424
import cats.data.NonEmptyList
2525
import cats.effect._
26+
import fetch.syntax._
2627
import scala.concurrent.ExecutionContext
2728
import scala.concurrent.duration._
2829

@@ -199,14 +200,15 @@ class GraphQLExample extends AnyWordSpec with Matchers {
199200
case RepositoriesQuery(n, name, Some(_), Some(_)) =>
200201
for {
201202
repos <- Repos.fetch(org)
202-
projects <-
203-
repos
204-
.take(n)
205-
.traverse(repo =>
206-
(Languages.fetch(repo), Collaborators.fetch(repo)).mapN { case (ls, cs) =>
207-
Project(name >> Some(repo.name), ls, cs)
208-
}
209-
)
203+
projects <- {
204+
val nRepos = repos.take(n)
205+
val fetches = nRepos.map { repo =>
206+
(Languages.fetch(repo), Collaborators.fetch(repo)).mapN { case (ls, cs) =>
207+
Project(name >> Some(repo.name), ls, cs)
208+
}
209+
}
210+
fetches.batchAll
211+
}
210212
} yield projects
211213

212214
case RepositoriesQuery(n, name, None, None) =>
@@ -215,16 +217,22 @@ class GraphQLExample extends AnyWordSpec with Matchers {
215217
case RepositoriesQuery(n, name, Some(_), None) =>
216218
for {
217219
repos <- Repos.fetch(org)
218-
projects <- repos.traverse { r =>
219-
Languages.fetch(r).map(ls => Project(name >> Some(r.name), ls, List()))
220+
projects <- {
221+
val fetches = repos.map { r =>
222+
Languages.fetch(r).map(ls => Project(name >> Some(r.name), ls, List()))
223+
}
224+
fetches.batchAll
220225
}
221226
} yield projects
222227

223228
case RepositoriesQuery(n, name, None, Some(_)) =>
224229
for {
225230
repos <- Repos.fetch(org)
226-
projects <- repos.traverse { r =>
227-
Collaborators.fetch(r).map(cs => Project(name >> Some(r.name), List(), cs))
231+
projects <- {
232+
val fetches = repos.map { r =>
233+
Collaborators.fetch(r).map(cs => Project(name >> Some(r.name), List(), cs))
234+
}
235+
fetches.batchAll
228236
}
229237
} yield projects
230238
}

fetch-examples/src/test/scala/Http4sExample.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import org.scalatest.wordspec.AnyWordSpec
3434
import java.util.concurrent._
3535

3636
import fetch._
37+
import fetch.syntax._
3738

3839
object HttpExample {
3940
case class UserId(id: Int)
@@ -114,7 +115,7 @@ object HttpExample {
114115
fetchUserById(UserId(id))
115116

116117
def fetchManyUsers[F[_]: Async](ids: List[Int]): Fetch[F, List[User]] =
117-
ids.traverse(i => fetchUserById(UserId(i)))
118+
ids.map(i => fetchUserById(UserId(i))).batchAll
118119

119120
def fetchPosts[F[_]: Async](user: User): Fetch[F, (User, List[Post])] =
120121
fetchPostsForUser(user.id).map(posts => (user, posts))
@@ -149,7 +150,7 @@ class Http4sExample extends AnyWordSpec with Matchers {
149150
def fetch[F[_]: Async]: Fetch[F, List[(User, List[Post])]] =
150151
for {
151152
users <- fetchManyUsers(List(1, 2))
152-
usersWithPosts <- users.traverse(fetchPosts[F])
153+
usersWithPosts <- users.map(fetchPosts[F]).batchAll
153154
} yield usersWithPosts
154155

155156
val io = Fetch.runLog[IO](fetch)

fetch/src/main/scala/fetch.scala

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ object `package` {
303303
}
304304
} yield result)
305305

306-
override def product[A, B](fa: Fetch[F, A], fb: Fetch[F, B]): Fetch[F, (A, B)] =
306+
override def product[A, B](fa: Fetch[F, A], fb: Fetch[F, B]): Fetch[F, (A, B)] = {
307307
Unfetch[F, (A, B)](for {
308308
fab <- (fa.run, fb.run).tupled
309309
result = fab match {
@@ -321,6 +321,7 @@ object `package` {
321321
Throw[F, (A, B)](e)
322322
}
323323
} yield result)
324+
}
324325

325326
override def productR[A, B](fa: Fetch[F, A])(fb: Fetch[F, B]): Fetch[F, B] =
326327
Unfetch[F, B](for {
@@ -360,6 +361,26 @@ object `package` {
360361
def pure[F[_]: Applicative, A](a: A): Fetch[F, A] =
361362
Unfetch(Applicative[F].pure(Done(a)))
362363

364+
/**
365+
* Given a number of fetches, returns all of the results in a `List`. In the event that multiple
366+
* fetches are made to the same data source, this will attempt to batch them together.
367+
*
368+
* This should be used in code that previously relied on the auto-batching behavior of calling
369+
* `traverse` on lists of `Fetch` values.
370+
*/
371+
def batchAll[F[_]: Monad, A](fetches: Fetch[F, A]*): Fetch[F, List[A]] = {
372+
fetches.toList.toNel
373+
.map { nes =>
374+
nes
375+
.map(_.map(Chain.one(_)))
376+
.reduceLeft { (fa, fb) =>
377+
fetchM[F].map2(fa, fb)((a, b) => a ++ b)
378+
}
379+
.map(_.toList)
380+
}
381+
.getOrElse(Fetch.pure[F, List[A]](List.empty))
382+
}
383+
363384
def exception[F[_]: Applicative, A](e: Log => FetchException): Fetch[F, A] =
364385
Unfetch(Applicative[F].pure(Throw[F, A](e)))
365386

fetch/src/main/scala/syntax.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package fetch
1818

1919
import cats._
2020
import cats.effect._
21+
import fetch.Fetch
2122

2223
object syntax {
2324

@@ -38,4 +39,14 @@ object syntax {
3839
def fetch[F[_]: Concurrent]: Fetch[F, B] =
3940
Fetch.error[F, B](a)
4041
}
42+
43+
implicit class FetchSeqBatchSyntax[F[_]: Monad, A](fetches: Seq[Fetch[F, A]]) {
44+
45+
def batchAll: Fetch[F, List[A]] = Fetch.batchAll(fetches: _*)
46+
}
47+
48+
implicit class SeqSyntax[A](val as: Seq[A]) extends AnyVal {
49+
50+
def batchAllWith[F[_]: Monad, B](f: A => Fetch[F, B]) = Fetch.batchAll(as.map(f): _*)
51+
}
4152
}

fetch/src/test/scala/FetchBatchingTests.scala

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import cats.instances.list._
2222
import cats.syntax.all._
2323
import cats.effect._
2424
import fetch._
25+
import fetch.syntax._
2526

2627
import java.util.concurrent.atomic.AtomicInteger
2728
import scala.concurrent.duration.{DurationInt, FiniteDuration}
@@ -152,7 +153,7 @@ class FetchBatchingTests extends FetchSpec {
152153

153154
"A large fetch to a datasource with a maximum batch size is split and executed in sequence" in {
154155
def fetch[F[_]: Concurrent]: Fetch[F, List[Int]] =
155-
List.range(1, 6).traverse(fetchBatchedDataSeq[F])
156+
List.range(1, 6).map(fetchBatchedDataSeq[F]).batchAll
156157

157158
val io = Fetch.runLog[IO](fetch)
158159

@@ -166,7 +167,7 @@ class FetchBatchingTests extends FetchSpec {
166167

167168
"A large fetch to a datasource with a maximum batch size is split and executed in parallel" in {
168169
def fetch[F[_]: Concurrent]: Fetch[F, List[Int]] =
169-
List.range(1, 6).traverse(fetchBatchedDataPar[F])
170+
List.range(1, 6).map(fetchBatchedDataPar[F]).batchAll
170171

171172
val io = Fetch.runLog[IO](fetch)
172173

@@ -180,8 +181,8 @@ class FetchBatchingTests extends FetchSpec {
180181

181182
"Fetches to datasources with a maximum batch size should be split and executed in parallel and sequentially when using productR" in {
182183
def fetch[F[_]: Concurrent]: Fetch[F, List[Int]] =
183-
List.range(1, 6).traverse(fetchBatchedDataPar[F]) *>
184-
List.range(1, 6).traverse(fetchBatchedDataSeq[F])
184+
List.range(1, 6).map(fetchBatchedDataPar[F]).batchAll *>
185+
List.range(1, 6).map(fetchBatchedDataSeq[F]).batchAll
185186

186187
val io = Fetch.runLog[IO](fetch)
187188

@@ -195,8 +196,8 @@ class FetchBatchingTests extends FetchSpec {
195196

196197
"Fetches to datasources with a maximum batch size should be split and executed in parallel and sequentially when using productL" in {
197198
def fetch[F[_]: Concurrent]: Fetch[F, List[Int]] =
198-
List.range(1, 6).traverse(fetchBatchedDataPar[F]) <*
199-
List.range(1, 6).traverse(fetchBatchedDataSeq[F])
199+
List.range(1, 6).map(fetchBatchedDataPar[F]).batchAll <*
200+
List.range(1, 6).map(fetchBatchedDataSeq[F]).batchAll
200201

201202
val io = Fetch.runLog[IO](fetch)
202203

@@ -210,7 +211,7 @@ class FetchBatchingTests extends FetchSpec {
210211

211212
"A large (many) fetch to a datasource with a maximum batch size is split and executed in sequence" in {
212213
def fetch[F[_]: Concurrent]: Fetch[F, List[Int]] =
213-
List(fetchBatchedDataSeq[F](1), fetchBatchedDataSeq[F](2), fetchBatchedDataSeq[F](3)).sequence
214+
List(1, 2, 3).map(fetchBatchedDataSeq[F]).batchAll
214215

215216
val io = Fetch.runLog[IO](fetch)
216217

@@ -224,7 +225,7 @@ class FetchBatchingTests extends FetchSpec {
224225

225226
"A large (many) fetch to a datasource with a maximum batch size is split and executed in parallel" in {
226227
def fetch[F[_]: Concurrent]: Fetch[F, List[Int]] =
227-
List(fetchBatchedDataPar[F](1), fetchBatchedDataPar[F](2), fetchBatchedDataPar[F](3)).sequence
228+
List(1, 2, 3).map(fetchBatchedDataPar[F]).batchAll
228229

229230
val io = Fetch.runLog[IO](fetch)
230231

@@ -247,7 +248,7 @@ class FetchBatchingTests extends FetchSpec {
247248
)
248249

249250
val io = Fetch.runLog[IO](
250-
ids.toList.traverse(fetchBatchedDataBigId[IO])
251+
ids.toList.map(fetchBatchedDataBigId[IO]).batchAll
251252
)
252253

253254
io.map { case (log, result) =>

fetch/src/test/scala/FetchReportingTests.scala

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,25 @@ class FetchReportingTests extends FetchSpec {
8484
}.unsafeToFuture()
8585
}
8686

87-
"Single fetches combined with traverse are run in one round" in {
87+
"Single fetches combined with traverse are NOT run in one round" in {
8888
def fetch[F[_]: Concurrent] =
8989
for {
9090
manies <- many(3) // round 1
91-
ones <- manies.traverse(one[F]) // round 2
91+
ones <- manies.traverse(one[F]) // rounds 2, 3, 4
92+
} yield ones
93+
94+
val io = Fetch.runLog[IO](fetch)
95+
96+
io.map { case (log, result) =>
97+
log.rounds.size shouldEqual 4
98+
}.unsafeToFuture()
99+
}
100+
101+
"Single fetches combined with Fetch.batchAll are run in one round" in {
102+
def fetch[F[_]: Concurrent] =
103+
for {
104+
manies <- many(3) // round 1
105+
ones <- Fetch.batchAll(manies.map(one[F]): _*) // round 2
92106
} yield ones
93107

94108
val io = Fetch.runLog[IO](fetch)

0 commit comments

Comments
 (0)