Skip to content

Spring Boot JDBC / JdbcClient with Oracle fails in native image with ORA-00604 and ORA-01756 during connection acquisition #48695

@santannaf

Description

@santannaf

Description

I am facing a persistent Oracle JDBC issue when running a Spring Boot 4.0.1 Kotlin application compiled as a native image (GraalVM 25 / Java 25).

When the application tries to obtain a JDBC connection and executes even the simplest query like:

select 1 from dual

the call fails with:

java.sql.SQLException: ORA-00604: Error occurred at recursive SQL level 1. Check subsequent errors.
ORA-01756: quoted string not properly terminated

This happens before any of my queries actually run, meaning the failure occurs during Oracle session logon / recursive SQL execution.

The problem occurs:

  • using JdbcClient
  • with Oracle UCP (PoolDataSourceImpl) and also with HikariCP
  • with a totally clean Oracle Free 23c database
  • with a simple schema
  • without any triggers or policies in the application schema

The exact same code works fine when running the JVM JAR normally.
The error only occurs in native image.

Minimal Kotlin Sample

PostDemoSpringApplication.kt

package santannaf.demo

import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.core.ParameterizedTypeReference
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.jdbc.core.simple.JdbcClient
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestClient

@SpringBootApplication
class PostDemoSpringApplication

fun main(args: Array<String>) {
    runApplication<PostDemoSpringApplication>(*args)
}

@RestController
@RequestMapping(path = ["/posts"])
class PostsController(
    private val service: PostsService
) {
    private val log = LoggerFactory.getLogger(javaClass)

    @GetMapping
    fun getPosts(): List<Post> {
        log.info("get all posts")
        val posts = service.getPosts()
        log.info("fetch posts successfully: ${posts.size}")
        return posts
    }

    @GetMapping(path = ["/user/{userId}"])
    fun getPostsByUserId(@PathVariable userId: Long): Any {

        log.info("get all posts by user id: $userId")
        return service.ping()

//        val posts = service.getPostsByUserId(userId)
//        log.info("fetch posts successfully: ${posts.size} - for user id: $userId")
//        return posts
    }
}

interface PostsService {
    fun getPosts(): List<Post>
    fun getPostsByUserId(userId: Long): List<Post>
    fun ping(): Int
}

@Service
class PostsServiceImpl(
    restClient: RestClient.Builder,
    val jdbcClient: JdbcClient,
    private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate
) : PostsService {
    private val customHttp = restClient.baseUrl("https://jsonplaceholder.typicode.com").build()

    private val insertSql = """
        INSERT INTO POSTS (ID, TITLE, USER_ID, BODY)
        VALUES (:id, :title, :userId, :body)
    """.trimIndent()

    private fun insert(post: Post): Int {
        return jdbcClient.sql(insertSql)
            .param("id", post.id)
            .param("title", post.title)
            .param("userId", post.userId)
            .param("body", post.body)
            .update()
    }

    fun insertOneByOne(posts: List<Post>) {
        posts.forEach { post ->
            jdbcClient.sql(insertSql)
                .param("id", post.id)
                .param("title", post.title)
                .param("userId", post.userId)
                .param("body", post.body)
                .update()
        }
    }

    private fun insertBatch(posts: List<Post>): IntArray {
        if (posts.isEmpty()) return intArrayOf()

        val batchValues: Array<Map<String, Any>> = posts.map { post ->
            mapOf(
                "id" to post.id,
                "title" to post.title,
                "userId" to post.userId,
                "body" to post.body
            )
        }.toTypedArray()

        return namedParameterJdbcTemplate.batchUpdate(insertSql, batchValues)
    }

    override fun getPosts(): List<Post> {
        val posts = customHttp.get()
            .uri("/posts")
            .retrieve()
            .body(object : ParameterizedTypeReference<List<Post>>() {}) ?: emptyList()

        insertBatch(posts)

        return posts
    }

    override fun ping(): Int {

        return jdbcClient.sql("select 1 from dual")
            .query(Int::class.java)
            .single()
    }

    override fun getPostsByUserId(userId: Long): List<Post> {
        val query = """
            select id, title, user_id as userId, body from posts where user_id = :1
        """.trimIndent()

        return jdbcClient.sql(query)
            .param(1, userId)
            .query(Post::class.java)
            .list()
            .filterNotNull()
    }
}

data class Post(val id: Long, val title: String, val userId: Long, val body: String)

application.properties

### Spring Config
spring.application.name=post-demo-spring
spring.threads.virtual.enabled=true

### Server Config
server.port=30001

### Management Config
management.endpoint.health.probes.enabled=true
management.endpoint.health.group.liveness.include=ping
management.endpoint.health.group.readiness.include=ping
management.endpoint.health.show-details=always
management.endpoints.web.exposure.include=metrics,info,health,prometheus
management.health.livenessstate.enabled=true
management.health.readinessstate.enabled=true
management.metrics.distribution.percentiles-histogram.http.server.requests=true
management.metrics.tags.instance.name=${INSTANCE_NAME:my-instance-name}
management.metrics.tags.instance.ip=${INSTANCE_IP:my-instance-ip}
management.metrics.tags.env=${ENV:local}
management.metrics.tags.server=${SERVER:onPremise}
management.metrics.tags.team=my_team
management.metrics.tags.service.name=${spring.application.name}
management.metrics.distribution.slo.http.server.requests=125ms,500ms,2s,8s
management.metrics.distribution.percentiles.http.server.requests=0.5,0.95,0.99

management.opentelemetry.resource-attributes.service.name=${spring.application.name}
management.opentelemetry.resource-attributes.cluster=local
management.opentelemetry.resource-attributes.deployment.environment=${ENV:local}

management.otlp.metrics.export.url=http://localhost:4318/v1/metrics
management.otlp.metrics.export.aggregation-temporality=cumulative
management.otlp.metrics.export.step=15s
management.otlp.metrics.export.base-time-unit=milliseconds

management.opentelemetry.tracing.export.otlp.transport=http
management.opentelemetry.tracing.export.otlp.endpoint=http://localhost:4318/v1/traces

management.tracing.sampling.probability=1.0

### Database Config
spring.datasource.url=jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=FREEPDB1)))
spring.datasource.username=${DB_USER:TEMPLATEBANKING_ADM}
spring.datasource.password=${DB_PASSWORD:adm}
spring.datasource.oracleucp.user=templatebanking_adm
spring.datasource.oracleucp.max-pool-size=10
spring.data.jdbc.dialect=oracle
spring.datasource.type=oracle.ucp.jdbc.PoolDataSourceImpl
spring.datasource.oracleucp.connection-factory-class-name=oracle.jdbc.pool.OracleDataSource
spring.datasource.oracleucp.connection-pool-name=oracle_pool

logging.level.com.zaxxer.hikari=INFO
logging.level.org.springframework.jdbc.core=DEBUG
logging.level.oracle.jdbc=DEBUG

build.gradle.kts

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    kotlin("jvm") version "2.3.0"
    kotlin("plugin.spring") version "2.3.0"
    id("org.springframework.boot") version "4.0.1"
    id("io.spring.dependency-management") version "1.1.7"
    id("org.graalvm.buildtools.native") version "0.11.3"
}

group = "santannaf.demo.brc.rinha.backend"
version = "0.0.1"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        mavenBom("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.23.0")
    }
}

dependencies {
    // Spring Boot
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-restclient")

    implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
    runtimeOnly("com.oracle.database.jdbc:ojdbc17")
    runtimeOnly("com.oracle.database.jdbc:ucp17")

    // Traces and Metrics
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-opentelemetry")
    implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")

    // Kotlin
    implementation("tools.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    // Unit Tests
    testImplementation("org.springframework.boot:spring-boot-starter-actuator-test")
    testImplementation("org.springframework.boot:spring-boot-starter-opentelemetry-test")
    testImplementation("org.springframework.boot:spring-boot-starter-restclient-test")
    testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
    testImplementation("org.springframework.boot:spring-boot-starter-data-jdbc-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
        jvmTarget.set(JvmTarget.JVM_25)
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

tasks.bootJar {
    archiveFileName.set("app.jar")
}

// I added this custom task to see if generating the hints would change anything, but // I'm having the same problem...
tasks.register<Exec>("runCustomJar") {
    group = "application"
    description = "Run custom jar"
    dependsOn("bootJar")
    val appName = "app.jar"
    val addressesBuild = "./build/libs/$appName"
    commandLine(
        "java",
        "-agentlib:native-image-agent=config-merge-dir=./src/main/resources/META-INF/native-image/",
        "-jar",
        addressesBuild
    )
}

// idem above
graalvmNative {
    binaries {
        named("main") {
            imageName.set("app")
            verbose.set(true)
            debug.set(true)
            configurationFileDirectories.from(file("src/main/resources/META-INF/native-image"))
            buildArgs("--color=always", "-H:+AddAllCharsets")
        }
    }
}

Error Log

org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection

Caused by: java.sql.SQLException:
ORA-00604: Error occurred at recursive SQL level 1. Check subsequent errors.
ORA-01756: quoted string not properly terminated

Spring debug log shows the failure during connection acquisition:

Executing prepared SQL statement [select 1 from dual]
HikariPool-1 - Starting...

Here's a screenshot of the native binary execution and the error...

Image

Request

Could this be related to Spring Boot JDBC / JdbcClient interaction with Oracle under native image?

Or perhaps to how JDBC metadata / session initialization is handled?

Any guidance on how to debug or mitigate would be greatly appreciated

If you want, I can also add:

  • docker-compose.yaml
  • start.sh that provisions user + schema
  • the exact native image config

…but I kept the issue focused and minimal here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: supersededAn issue that has been superseded by another

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions