spring-projects / spring-projects/spring-graphql

`IllegalArgumentException: object is not an instance of declaring class` when a reflectively-invoked `suspend` GraphQL data fetcher suspends on `Dispatchers.IO` and returns a value-class-bearing type

Open
#1,482 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
Dominant language
Java
Stars
1.6k
Forks
336
PR merge metrics
No merged PRs in 30d

Description

Summary

A Spring GraphQL data fetcher declared as a Kotlin suspend function throws

java.lang.IllegalArgumentException: object is not an instance of declaring class

at invocation time, before (or during the resumption of) the resolver body, when all the
following hold:

  1. The data fetcher is a Kotlin suspend function and is therefore invoked
    reflectively through Spring's CoroutinesUtils.invokeSuspendingFunction(...)
    → kotlin-reflect KCallables.callSuspendBy(...)ValueClassAwareCaller.
  2. The function actually suspends and resumes on a different dispatcher
    in our case its body does coroutineScope { async(Dispatchers.IO) { … } }
    and await()s the results.
  3. The value produced on resume is constructed from / contains a Kotlin
    @JvmInline value class (so kotlin-reflect's value-class-aware boxing path
    is engaged when marshalling the call).

Making the body non-suspending (running the same work synchronously, no
async(Dispatchers.IO)) eliminates the crash. No other change is
required.

Environment

Component Version
Kotlin 2.4.0 (language/api version 2.4)
kotlinx-coroutines 1.11.0
JVM target 21
Spring Boot 4.0.6
Netflix DGS 12.0.1 (graphql-dgs-spring-graphql-starter, i.e. DGS on top of Spring for GraphQL)
DGS codegen 8.5.0
Reactive stack spring-boot-starter-webflux / reactor

Observed behavior

Calling the query throws during data-fetch dispatch. Representative stack trace
(framework frames are verbatim; application package/line numbers genericized):

java.lang.IllegalArgumentException: object is not an instance of declaring class
    at kotlin.reflect.jvm.internal.calls.ValueClassAwareCaller.call(ValueClassAwareCaller.kt:137)
    at kotlin.reflect.jvm.internal.ReflectKCallableKt.callDefaultMethod(ReflectKCallable.kt:173)
    at kotlin.reflect.full.KCallables.callSuspendBy(KCallables.kt:82)
    at org.springframework.core.CoroutinesUtils.invokeSuspendingFunction(CoroutinesUtils.kt:...)
    ... reactor / DGS data-fetcher dispatch frames ...
    at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt)
    at com.example.app.api.MetricsResolver.aggregateMetrics$suspendImpl(MetricsResolver.kt:30)
    ... 

The aggregateMetrics$suspendImpl(...:30) frame (below the
_COROUTINE._BOUNDARY._ marker) is the suspension point — the body does
begin executing and reaches the async(Dispatchers.IO)/await() call. The
thrown exception originates from the reflective call machinery
(ValueClassAwareCaller) handling the suspending invocation, not from
application logic.

Minimal-ish reproduction

The crash reproduces only through the reflective suspend-invocation path that
Spring GraphQL / DGS uses (CoroutinesUtils.invokeSuspendingFunction). A direct
KFunction.callSuspendBy(...) from a hand-written harness did not reproduce
it in our testing (it resolved a plain, non-value-class-aware caller), so the
reproduction below is framed around the Spring GraphQL runtime.

Value class + return type
@JvmInline
value class Amount(val cents: Long) {
    fun toDollars(): Double = cents / 100.0
}

// Domain type returned by the service; contains a value-class field.
data class Metrics(
    val total: Amount,
    val count: Int,
)

// GraphQL DTO (plain data class generated by codegen).
data class MetricsDto(
    val total: Double,
    val count: Int,
)

fun Metrics.toDto() = MetricsDto(total = total.toDollars(), count = count)
Service — body genuinely suspends on Dispatchers.IO (TRIGGERS the crash)
@Service
class MetricsService(
    private val repository: MetricsRepository, // blocking JPA repo
) {
    suspend fun calculate(filterId: String?): Metrics? =
        coroutineScope {
            val a = async(Dispatchers.IO) { repository.aggregateA(filterId) }
            val b = async(Dispatchers.IO) { repository.aggregateB(filterId) }
            Metrics.from(a.await(), b.await()) // builds the value-class field
        }
}
Resolver — reflectively invoked by Spring GraphQL / DGS
@DgsComponent
class MetricsResolver(
    private val metricsService: MetricsService,
) {
    @DgsQuery
    suspend fun aggregateMetrics(
        @InputArgument accountId: String?,
        @InputArgument openOnly: Boolean?,
        @InputArgument filter: FilterInput,
    ): MetricsDto? =
        metricsService.calculate(filterId = accountId)?.toDto()
}

GraphQL schema:

type Query {
    aggregateMetrics(accountId: String, openOnly: Boolean, filter: FilterInput!): Metrics
}

Querying aggregateMetrics throws IllegalArgumentException: object is not an instance of declaring class.

The fix / workaround — make the body non-suspending (NO crash)

Removing the coroutineScope { async(Dispatchers.IO) { … } } and running the
two aggregate queries sequentially (so the suspend function never actually
suspends/resumes on another dispatcher) makes the resolver complete
synchronously and the crash disappears:

@Service
class MetricsService(
    private val repository: MetricsRepository,
) {
    fun calculate(filterId: String?): Metrics? {           // no longer suspend
        val a = repository.aggregateA(filterId)            // sequential, blocking
        val b = repository.aggregateB(filterId)
        return Metrics.from(a, b)
    }
}

(The resolver may remain suspend or be changed to a plain function; either way
it now completes without a real suspension point, and the value is returned
without hitting the broken resume path.)

Why we believe the trigger is the suspend-resume + value-class boxing combination

We bisected against every other suspend GraphQL resolver in our codebase.
Each of the following passes, which individually rules out the obvious
single-feature explanations:

Distinguishing feature A passing suspend resolver that has it
Is suspend many
Nullable return (Dto?) several
Nullable primitive parameter (Int? / Boolean?) yes
Takes the same complex @InputArgument input type yes
Returns a type built from value classes yes (when it does not dispatch via async(Dispatchers.IO))

The only resolver that crashes is the one whose body genuinely suspends and
resumes on Dispatchers.IO while producing a value-class-bearing return. The
crash is raised by kotlin-reflect's ValueClassAwareCaller (the value-class /
boxing-aware caller), on the reflective-suspend path
(callSuspendBycallDefaultMethodValueClassAwareCaller.call). This is
consistent with the known class of reflective-suspend-invocation issues where
the caller mishandles the resumed continuation / COROUTINE_SUSPENDED vs. the
boxed return value (cf. kotlinx.coroutines issue #3761 and related
kotlin-reflect reports). The receiver/argument array appears to be misaligned
on resume, producing object is not an instance of declaring class from the
underlying Method.invoke.

We did not confirm the exact internal cause inside kotlin-reflect; the above
is the empirically confirmed trigger and a hypothesis for the mechanism.

Questions

  • Is ValueClassAwareCaller expected to be selected for a suspend function
    whose declared parameter and return types contain no value class (the DTO
    is a plain data class)? If so, does its argument/receiver indexing account for
    the continuation parameter and the resumed return correctly?
  • Does the resumption of a reflectively-invoked suspend function on a different
    dispatcher (Dispatchers.IO) interact incorrectly with the value-class boxing
    path?

I can also submit this to the Kotlin team, as the throwing frame
is kotlin.reflect.jvm.internal.calls.ValueClassAwareCaller), with a
cross-reference to kotlinx.coroutines (#3761 family) and a note that it
surfaces through Spring Framework CoroutinesUtils.invokeSuspendingFunction
as used by Spring for GraphQL / Netflix DGS.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with Spring's CoroutinesUtils.invokeSuspendingFunction entry point and trace the reported kotlin-reflect path through KCallables.callSuspendBy and ValueClassAwareCaller.call using the provided Spring GraphQL reproduction. Confirm the suspend-resume and value-class conditions, then define a regression test covering the dispatcher switch; done means the reflective GraphQL invocation no longer throws for that case.

Written by the indexing model from the issue text.

Assessment

Tech stack
graphql, kotlin, spring
Domain
api, backend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.