3

I have this service method:

@Transactional
override suspend fun deleteByCarId(carId: Long) {
  routeRepository.deleteByCarId(carId)
  routePlanRepository.deleteByCarId(carId)
  carRepository.deleteById(carId)
}

The route plan (middle) and the the car (last line) are deleted, but the deletes on the routeRepository are not executed.

interface RouteRepository : CoroutineCrudRepository<Route, Long> {
  fun deleteByCarId(carId: Long)
  // ...
}
interface RoutePlanRepository : CoroutineCrudRepository<RoutePlan, Long> {
  suspend fun deleteByCarId(carId: Long)
  // ...
}

So, I figured out that this probably is because the RouteRepository misses a suspend on the delete method, but can someone please explain, why this is important?


EDIT 1

By further thinking about the more general case, I think non-suspending repository function that create a Flow seem to not necessarily be marked as suspend. But I don't understand why. All other methods seem to need suspend - but why? Normally I can run a non-suspending function from a coroutine and it is executed (of course we should not because it might block the thread - right?).

My assumption is that creating a Flow does not need to be suspendable because the action of creating it is fast and only the subscription on it will eventually executed the query.

In the above example there is no subscription to a Flow because the method returns Unit - which is why the delete is not executed?

However, I still do not understand, why marking the repository method with suspend changes the behavior. Now the creation of the query is an async operation in itself and thereby it (the creation of the delete query) becomes part of the overall request handling chain?

But I would expect that creating a Query within a suspending function does not automatically subscribes? Can someone explain?

EDIT 2

I created this issue because I think it should be better documented what is possible and what not: https://github.com/spring-projects/spring-data-commons/issues/2503

The doc currently states that the delete requires a suspend but it is not explicitly mentioned:

For return values, the translation from Reactive to Coroutines APIs is the following:

fun handler(): Mono<Void> becomes suspend fun handler()

fun handler(): Mono<T> becomes suspend fun handler(): T or suspend fun handler(): T? depending on if the Mono can be empty or not (with the advantage of being more statically typed)

fun handler(): Flux<T> becomes fun handler(): Flow<T>

https://docs.spring.io/spring-data/r2dbc/docs/current/reference/html/#kotlin.coroutines.reactive

Stuck
  • 11,225
  • 11
  • 59
  • 104
  • Do you mean that all these `deleteByCarId()` functions actually return flows? Because from your example they return `Unit`, not flow. – broot Nov 26 '21 at 13:01
  • In the example they return `Unit` and I don't understand why a suspended one is executed but without the suspend not? By further thinking about it I found that it might be helpful to understand it for flows as well. I also don't understand how a suspending function with `Unit` return behaves in r2dbc. Does it create a flow under the hood (without returning it)? – Stuck Nov 27 '21 at 15:46

3 Answers3

2

I find it weird that there is no warning or even compilation error when you define non-suspending functions in an interface extending CoroutineCrudRepository.

As you might know, suspending functions are at the core concept of coroutines. The suspend keyword means that this function can be blocking and that it can be paused and resumed at a later time. This is great when you need to execute a long-running operation but you don't want your thread to be blocked waiting for it to finish. A Database call is just an example of these long-running operations and that is why all CoroutineCrudRepository methods are defined as suspending functions. You can check this in the source code at https://github.com/konrad-kaminski/spring-kotlin-coroutine/blob/master/spring-data-mongodb-kotlin-coroutine/src/main/kotlin/org/springframework/data/repository/coroutine/CoroutineCrudRepository.kt.

I still find it weird that there is no warning or even compilation error when you do the following, but maybe there is no way for the compiler to know for sure that only suspending functions should be added to such an interface:

interface RouteRepository : CoroutineCrudRepository<Route, Long> {
  fun deleteByCarId(carId: Long)
  // ...
}
João Dias
  • 16,277
  • 6
  • 33
  • 45
  • Note that not all repositories need to be be suspending. Those that return a `Flow` don't. see https://github.com/spring-projects/spring-data-commons/blob/main/src/main/kotlin/org/springframework/data/repository/kotlin/CoroutineCrudRepository.kt The reason is, that they should only suspend if they actually do blocking operations. Otherwise the method just builds a flow (that by itself can be paused/blocking) Your answer does not answer my question of why the service does not hook the non-suspending call into the flow and thereby does not execute it. – Stuck Nov 05 '21 at 13:51
  • Good point on the `Flow`, I forgot about that. Thanks for the hint ;) But other than that all other methods need to be suspending. – João Dias Nov 05 '21 at 13:54
1

I don't fully understand what's happening here, but I believe you misinterpret this case. There is no builtin magic in suspend functions or flows which for example makes flows automatically hot if they were created in a suspend function. But there is a magic provided by Spring Data and related to CoroutineCrudRepository.

Recently, many database/web service libraries follow the pattern where the user creates an interface and the library provides an implementation of this interface. That means the library reads the interface and tries to guess what is the expected outcome by the user.

The problem is: non-suspend delete function does not make too much sense. What is your expected behavior? Should it block the thread? It is highly discouraged when using coroutines. Should it be asynchronous, so start the delete operation in the background and return immediately? But how to access this asynchronous task if the function doesn't return Job, Deferred, Flow or similar?

My guess is that the library is confused with your function definition, it doesn't know what do you expect and therefore it provides some "weird" or incomplete implementation. Maybe it schedules the delete operation asynchronously. Or maybe it just does nothing. I agree with @João Dias that it should probably throw an error or at least generate a warning.

Anyway, I think the proper solution to your problem is to make this function suspend. It really should be suspend from the beginning.

Update

After some discussion in the comments it seems the author of the question is confused what does it mean that the function is suspend or that it returns e.g. Flow and how they are connected to each other.

This is all related to how we would like to wait for some long-running operation. We can do it either synchronously, meaning that the function returns when the operation finishes or asynchronously, so the function returns immediately and the operation is executed in the background. In Kotlin there are two options for synchronous: blocking (classic) and suspend (coroutines). First one blocks the thread and we would like to avoid this by using coroutines and suspend functions.

There are much more options for going asynchronous and they are divided into two subcategories: receive data once (callback, CompletableFuture, Deferred, Mono) or receive a stream of values (callback, Flow, Flux). In the case of databases a stream of values could be interpreted either as a stream of entities while they are being searched (I believe Spring Data works like this) or as a stream of changes to the data in the db (Android Room?).

Ultimately, this is your decision which execution strategy you want to use and you choose it by changing the definition of your function, for example:

  • suspend fun getData(): Data - receive the data once and synchronously by suspending.
  • fun getData(): Deferred<Data> - receive the data once and asynchronously (we can also use a future, Mono, etc.).
  • fun getData(): Flow<Data> - receive a stream of data (alternatively: Flux, etc.).
  • fun getData(): Data - receive the data once and synchronously by blocking the thread - this should be avoided.
  • suspend fun getData(): Flow<Data> - unclear what is the expected behavior, because it seems to be synchronous (suspend) and asynchronous (Flow) at the same time - should be avoided (same for suspend function returning futures, Deferred, Mono, Flux, etc.).

Delete operation is a little different, because it doesn't return any data, but the idea is the same. We can either execute it synchronously (suspend function) or asynchronously (non-suspend returning Deferred<Void>, Mono<Void>, etc.).

One last note: I don't say all above function definitions work with Spring Data. I speak mostly abstract here, but as I said, Spring Data tries to guess what is the expected behavior by looking at your function definition and I believe it should work more or less how I described it.

broot
  • 21,588
  • 3
  • 30
  • 35
  • Interesting, but I think it is documented as stated in r2dbc reference 17.5.1 `Coroutines support is enabled when kotlinx-coroutines-core, kotlinx-coroutines-reactive and kotlinx-coroutines-reactor dependencies are in the classpath` and esp 17.5.3 documents `CorutineCrudRepository` => https://docs.spring.io/spring-data/r2dbc/docs/current/reference/html/#kotlin.coroutines.repositories – Stuck Nov 28 '21 at 18:51
  • Also this is relevant but I still don't understand it `Invoking a custom implementation method propagates the Coroutines invocation to the actual implementation method if the custom method is suspend-able without requiring the implementation method to return a reactive type such as Mono or Flux.`. Delete methods return either `void` or the delete count. So I still consider it a lack of documentation and will post it in the r2dbs repo next week. – Stuck Nov 28 '21 at 18:52
  • 1
    Ahh, sorry, I was totally wrong when saying it is not official. I found wrong GitHub repository :facepalm:. Regarding your second comment: I'm definitely not an expert regarding Spring Data, but this cited fragment is about custom implementations and you use a query method here. Also, correct me if I'm wrong, but the delete operation in reactive repositories doesn't return `void`, but `Mono`. It is technically impossible to use non-suspending, synchronous function for a delete operation and do not block the thread. We can only either block, suspend or go asynchronous. – broot Nov 28 '21 at 21:33
  • Why can `find`-operations be non-`suspend`ing then? I originally understood that adding `suspend` solves the problem, but I don't understand why. I created an issue at spring-data because this should be documented in more detail https://github.com/spring-projects/spring-data-commons/issues/2503 – Stuck Nov 29 '21 at 11:58
  • Maybe because `Flow`s collect operations are suspending functions – Stuck Nov 29 '21 at 12:12
  • See updated answer. – broot Nov 29 '21 at 13:47
  • The update is not fully correct. Flow by definition has a collect operation and it gets a suspend lambda as collector because it's collection must be run in a coroutine. That connects them. Also a suspend function that creates a flow makes sense, it is a coroutine that creates the flow. Collecting the resulting flow itself is suspendable as well. Also Coroutines can be async (e.g. Using `asyn { }`). I am not confused about Coroutines but wonder why the non suspending delete is not executed. In general we can call them from suspend functions (otherwise a find would also not work but it does). – Stuck Nov 29 '21 at 15:22
  • Yes, flows are collected by coroutines. What is your point? You seem to focus on "am I a coroutine?" question, but this is not really that important here. The main point is *waiting* for the db operation. The difference between delete and find operations in your example is that we wait for them to finish differently. Delete is expected to finish at the time we return from it, so it has to be `suspend`. Find on the other hand does not wait for operation to finish, it returns immediately, so it is not `suspend`. Then we wait for results while collecting the flow, so collecting is `suspend`. – broot Nov 29 '21 at 17:06
  • Regarding non-suspend delete being ignored - as I said, I don't know why does it happen and my guess would be that this is a bug. Anyway, non-suspend delete function is a misuse, we should not do this and probably this is the reason why Spring behave strangely. – broot Nov 29 '21 at 17:11
  • Your last comment is the problem I originally tried to address. To me it feels strange that the delete is not happening (independently of *when* (before or after the coroutine is finished)). I am confused why that is coroutine related, because changing if it is `suspend`able should not change if the query is executed. It should only change when and under which conditions it is executed. So we still do not have an answer - most likely it is a bug and hopefully the spring people can help with clarifying it in said issue. Thanks anyway! – Stuck Nov 30 '21 at 11:52
0

I have no experience with spring or the dependencies you're using but I've been using android's Room ORM and I think the dependencies you are using work in a similar manner.

Essentially these ORM generate code for you and at the very root they are performing an operation of the type:

...
db.connect()
val cursor = db.execSQL("DELETE FROM $table WHERE $condition")
db.commit()
...

Am I right?

In Room, the developer might want to consume in different manners:

  • Plain old blocking way: generate the function as is without doing anything fancy
  • Same as before but mark it as suspend so we know it's a long running operation
  • Using a LiveData<T> wrapper. (It's similar to flows)
  • Using the Deferred<T> type (as if the user wanted to launch an async {} task

... or any other type of bindings; that might be CompleteableFuture, RxJava... you get the gist of it.

So the tool you're using might just wrap the db operations into a suspend function when you indicate so and the generated code ends up looking like:

suspend fun deleteById(...) {
  db.connect()
  val cursor = db.execSQL(...)
  db.commit()
  return cursor.getFirst()
}

Which would explain why marking the function as suspend just works... but what about the Flow<T> weird behavior?

Well, it depends on how you create the flow.

you might create a Flow like so: (notice it's a not blocking function!)

fun deleteById(...) = flow {
  db.connect()
  val cursor = db.execSQL(...)
  db.commit()
  emit(cursor.getFirst())
}

Here, we just used a flow builder and the action is going to take place whenever the flow is collected.

But what if the ORM tries to do it's best when you pass the suspend keyword?

I imagine it looks something like the following:

suspend fun deleteById(...) {
  db.connect()
  val cursor = db.execSQL(...)
  db.commit()
  return flowOf(cursor.getFirst())
}

So this time, the code is being executed as it's called but the result is being wrapped in a Flow so no matter what the DB operation has already been executed no matter if you subscribe to the given Flow<T>

Some random IT boy
  • 7,569
  • 2
  • 21
  • 47