-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ZIO Module #48
Comments
This MR #47 looks good @guizmaii . My thought is that we can merge this (once you add a simple test or two), and then iterate until it's ready for release. Here's an idea I have, and would like your feedback. One thing I like about Magnum is the typesafe transaction management. For example if you have def makePayment(sum: Long)(using DbTx): Unit =
val newBalance = readAndUpdateSomeTables(sum)
sendInvoiceOverBlockingHttp(newBalance) Then It doesn't seem possible to get the same behavior with the ZIO def transact[A](transactor: Transactor)(q: DbTx ?=> A)(implicit
trace: Trace
): ZIO[Any, Throwable, A] = ??? The example would look like: def readAndUpdateSomeTables(sum: Long)(using DbTx): Long = ???
def sendInvoiceOverZIOHttp: RIO[Http, Unit] = ???
def makePayment(sum: Long): RIO[Http, Unit] =
for
newBalance <- transact(ds):
readAndUpdateSomeTables(sum)
_ <- sendInvoiceOverBlockingHttp(newBalance)
yield () And since So here's the idea: what if we put the def transact[A](transactor: Transactor)(zio: => RIO[DbTx, A])(using Trace): Task[A] = ??? And we could then implement ZIO-versions of the key classes like Frag, Query, etc. For example like open class ZIORepo[EC, E, ID](using defaults: RepoDefaults[EC, E, ID]) extends ZIOImmutableRepo[E, ID]:
def insert(entityCreator: EC): RIO[DbCon, Unit] = ??? // wrap defaults.insert in ZIO
... Users would import from |
Hum, I'm sorry, I'm not sure to understand the problem @AugustNagro 🤔 The current Here's how we organise our code at work (nothing innovative, just typical hexagonal architecture AFAIK):
// In file: modules/core/src/main/scala/my/app/core/domain/UserEntity.scala
package my.app.core.domain
final case class UserEntity(id: UUID, username: String)
// In file: modules/core/src/main/scala/my/app/core/services/MyService.scala
package my.app.core.services
import my.app.core.repositories.UserRepo
trait MyService {
def createNewUser(username: String): Task[UserEntity]
}
object MyService {
def live: ZLayer[UserRepo & NotificationService, Nothing, MyService] = ZLayer.derived[MyServiceLive]
}
final class MyServiceLive(userRepo: UserRepo, notificationService: NotificationService) extends MyService {
override def createNewUser(username: String): Task[UserEntity] =
for {
user <- userRepo.insert(username)
_ <- notificationService.notifyNewUser(user) // hypothetical notification service
} yield user
}
// In file: modules/core/src/main/scala/my/app/core/repositories/UserRepo.scala
package my.app.core.repositories
trait UserRepo {
def insert(username: String)
}
// In file: modules/db/src/main/scala/my/app/db/UserRepoLive.scala
package my.app.db
import my.app.core.repositories.UserRepo
import my.app.db.magnum.MagnumUserRepo
final class UserRepoLive(ds: DataSource, magnumUserRepo: MagnumUserRepo) extends UserRepo {
override def insert(username: String): Task[UserEntity] =
transact(ds) { tx ?=>
magnumUserRepo.insertReturning(UserCreator(username))
}.map(UserDB.toCore)
}
object UserRepoLive {
def layer: ZLayer[DataSource & MagnumUserRepo, Nothing, UserRepo] = ...
}
// In file: modules/db/src/main/scala/my/app/db/magnum/MagnumUserRepo.scala
package my.app.db.magnum
import com.augustnagro.magnum.Repo
final class MagnumUserRepo extends Repo[UserCreator, UserDB, UUID]
object MagnumUserRepo {
def live: ULayer[MagnumUserRepo] = ZLayer.succeed(new MagnumUserRepo)
} So that we don't leak DB details outside of the repositories implementation. I think I don't understand what you mean by:
Do you mean that people use a DB transaction to wrap something that sends an invoice? 🤔
I think it's equivalent to what I suggest but more complex and less performant. import scala.util.control.ControlThrowable
object AlreadyExistsException extends ControlThrowable
type AlreadyExistsException = AlreadyExistsException.type
// hypothetical complex process to insert a new user
// The code is dumb, it's just to try to give a complex process example
def inserUser(username: String): Task[UserEntity] =
transact(ds) { tx ?=>
sql"""SELECT pg_advisory_xact_lock(100)""".query[Any].run(): @nowarn("msg=unused value")
val exists = magnumUserRepo.existsByUsername(username)
if (exists) throw AlreadyExistsException
magnumUserRepo.insertReturning(UserCreator(username))
} It feels like if we introduce a // same code but with `ZIORepo`
import scala.util.control.ControlThrowable
object AlreadyExistsException extends ControlThrowable
type AlreadyExistsException = AlreadyExistsException.type
// hypothetical complex process to insert a new user
// The code is dumb, it's just to try to give a complex process example
def inserUser(username: String): Task[UserEntity] =
transact(ds) {
for {
_ <- ZIO.attempt(sql"""SELECT pg_advisory_xact_lock(100)""".query[Any].run(): @nowarn("msg=unused value"))
exists <- zioMagnumUserRepo.existsByUsername(username)
_ <- if (exists) ZIO.fail(AlreadyExistsException) else ZIO.unit
user <- zioMagnumUserRepo.insertReturning(UserCreator(username))
yield user
} Note that the |
I am not a Magnum user. But only here because of @guizmaii posting on twitter 😁 I am quite sure, that
Is considered an anti pattern. Since an open connection is required for an open transaction and therefore blocks a connection from the connection pool for a long time. Exception might be you do a one DB per user SQLite setup. So it should be
And in that case, you can aggregate all writes into one transact and you don't need to leak the DB types. |
I wouldn't get hung up on the example having IO. It's a question of where the transactional boundaries of an application lie. If you're creating a database transaction for every instance like class UserRepoLive(ds: DataSource) extends UserRepo:
override def updateBalance(user: Long, change: Long): Task[Unit] =
transact(ds) { tx ?=>
sql"UPDATE user SET balance = balance + $change".update.run()
} That's too fine grained and WILL cause bugs. For example, if you have an endpoint that calls In Spring the common pattern is that your REST controller calls a service method entrypoint which is annotated with
What do you do when retries are exhausted, or your service goes down while retrying? It's a lot of work to handle these cases correctly. A good solution is to design your services like @987Nabil says, saving your database updates until the very end. My proposal does not prevent this design; it's orthoganal. Unfortunately, this pattern is often inconvenient or impossible, and the large pre-existing ZIO projects I've worked on did not follow it. It requires discipline and architectural foresight. I don't want to neglect the majority usecase and tell people to just refactor their application 😆
I'm confident the performance impact of wrapping the calls in a ZIO will be 0, since database IO dominates. In terms of complexity, the example would look like this (from a User-code perspective): import com.augustnagro.magnum.zio.{ZioRepo, sql}
class UserRepo extends ZioRepo[UserCreator, UserEntity, Long]:
def existsByUserName(userName: String): RIO[DbCon, Boolean] =
sql"SELECT 1 FROM user WHERE user_name = $userName".query[Int].run().map(_.nonEmpty)
class UserService(userRepo: UserRepo):
def insertUser(username: String): RIO[DbTx, UserEntity] =
for
_ <- sql"""SELECT pg_advisory_xact_lock(100)""".query[Any].run()
exists <- userRepo.existsByUsername(username)
_ <- if (exists) ZIO.fail(AlreadyExistsException) else ZIO.unit
user <- userRepo.insertReturning(UserCreator(username))
yield user
class UserController(ds: DataSource, userService: UserService):
def postUser(userName: String): Task[UserEntity] =
transact(ds)(userService.insertUser(userName)) One final thing to consider is that ZIO docs validate this approach of putting database transactions in the ZIO Environment: "In a database application, a service may be defined only to operate in the context of a larger database transaction. In such a case, the transaction could be stored in the environment" |
While I understand your reasoning @AugustNagro I am not sure about the conclusion.
My counter proposal:
def transact[A](transactor: Transactor)(q: DbTx ?=> A)(implicit
trace: Trace
): ZIO[Any, Throwable, A] = ???
def transactZIO[R,E <: Throwable, A](transactor: Transactor)(q: DbTx ?=> ZIO[R,E,A])(implicit
trace: Trace
): ZIO[R, E, A] = ??? Then transactZIO can be used for the "bad code" style, but it is easier to encourage, for example in docs, the good non leaky style @guizmaii is aiming for. btw, this
is only true, if the db is accessed via network. There is a recent push for SQLite in prod, for example in the rails community, which is then disk and not network IO. So it might become significant. |
I think my earlier example having network IO has really muddied the waters, and I'm sorry about that. Still, I don't think it's wrong to let users define their own transactional boundaries. That's what the current Magnum API lets you do already. Your However, I don't discount the suggestion and I definitely see your side to the argument. I think the best thing to do will be try out both styles and see which one feels better.
The performance argument here still doesn't sway me.. a few allocations or env lookups per tx is not going have an impact even if you're using in-memory H2. I mean, just look at Doobie.. people are not complaining that every method ends up returning ConnectionIO. |
FYI: I filed zio/zio#9276 |
Issue to track work done on the ZIO module. For example by @guizmaii in #47
The text was updated successfully, but these errors were encountered: