Summary
JdbcStepExecutionDao.getLastStepExecution issues a nested query from inside an open, row-limited ResultSet. On PostgreSQL this is fine. On CockroachDB it is rejected outright, so any step that already has a prior StepExecution under the same JobInstance fails, and the connection is discarded as broken. In practice that means restarts.
This is a portability defect rather than a performance one, and it is not caught by the existing test suite because it only manifests on a database that refuses multiple simultaneously-open portals.
Affected versions
Present in 6.0.4 (current release) and on main as of today.
The code
spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/jdbc/JdbcStepExecutionDao.java, in getLastStepExecution:
return getJdbcTemplate().query(getQuery(GET_LAST_STEP_EXECUTION), (PreparedStatementCallback<StepExecution>) statement -> {
statement.setMaxRows(1); // <-- opens a row-limited portal
...
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
Long jobExecutionId = rs.getLong(19);
JobExecution jobExecution = new JobExecution(jobExecutionId, jobInstance,
jobExecutionDao.getJobParameters(jobExecutionId)); // <-- nested query, cursor still open
...
JdbcJobExecutionDao.getJobParameters prepares and executes a second statement on the same connection while the first cursor is still open.
Why setMaxRows(1) matters
The nesting alone is not sufficient. The distinction that matters is suspended and still executable versus completed, not simply open versus closed.
setMaxRows(1) makes pgjdbc send Execute with a protocol row count of 1. Per the protocol, if a non-zero row count stops execution before the portal completes, the backend replies PortalSuspended rather than CommandComplete. pgjdbc has already sent Sync, but because the connection is inside an explicit transaction that Sync yields ReadyForQuery(T) without ending the transaction or disposing the suspended portal. The nested query then attempts its protocol preparation while that portal is still actively suspended, and CockroachDB rejects it.
By contrast LIMIT 1 in the SQL, with no JDBC max rows, produces Execute(portal, 0), which fetches to completion and yields CommandComplete. That is why the two are not interchangeable here.
Relevant detail: on this setMaxRows / fetchSize == 0 path pgjdbc uses the unnamed portal and returns no named ResultCursor, so its explicit portal-cleanup machinery does not apply. Closing the Java ResultSet therefore does not necessarily emit a wire-level Close for the suspended unnamed portal.
Verified empirically in an equivalent implementation: LIMIT 1 works, switching to statement.setMaxRows(1) reintroduces the failure, removing it resolves it.
Qualifications, so this is not overstated:
setMaxRows(1) does not guarantee PortalSuspended. A zero-row result reaches CommandComplete.
- A positive
fetchSize can create the same condition via a named fetch portal, so setMaxRows is not the only trigger.
- The explanation does not apply in simple-query mode.
- Prepared-statement caching and
prepareThreshold can affect reproduction, since a subsequent unnamed Bind normally replaces the previous unnamed portal.
- The wire state above is inferred from behaviour, not captured. A pgjdbc
FINEST trace or pgwire capture showing Execute(portal=null, limit=1) then PortalSuspended then Sync then ReadyForQuery(T) then Parse then ErrorResponse would confirm it. I have not captured one.
Observed failure
org.postgresql.util.PSQLException: ERROR: unimplemented: multiple active portals is in preview,
please set session variable multiple_active_portals_enabled to true to enable them
Detail: cannot perform operation sql.PrepareStmt while a different portal is open
Hint: You have attempted to use a feature that is not yet implemented.
See: https://go.crdb.dev/issue-v/40195/v26.2
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2904)
...
at org.springframework.batch.core.repository.dao.jdbc.JdbcJobExecutionDao.getJobParameters(JdbcJobExecutionDao.java:450)
at org.springframework.batch.core.repository.dao.jdbc.JdbcStepExecutionDao.lambda$getLastStepExecution$0(JdbcStepExecutionDao.java:341)
at org.springframework.batch.core.repository.dao.jdbc.JdbcStepExecutionDao.getLastStepExecution(JdbcStepExecutionDao.java:332)
at org.springframework.batch.core.repository.explore.support.SimpleJobExplorer.getLastStepExecution(SimpleJobExplorer.java:267)
at org.springframework.batch.core.job.SimpleStepHandler.handleStep(SimpleStepHandler.java:95)
HikariCP then marks the connection broken (SQLSTATE 0A000), which surfaces downstream as Connection is closed and JDBC rollback failed, so the original cause is easy to misdiagnose.
Impact and why it looks intermittent
To be precise about the trigger, since "restart" is not quite the right framing: SimpleStepHandler.handleStep always runs with a JobInstance present and calls getLastStepExecution on ordinary first attempts too, simply getting null back.
The dangerous condition is narrower: a prior StepExecution exists for the same JobInstance and the same step name, so rs.next() is true and the nested hydration query actually runs. A first execution with a fresh step name is safe because the outer query returns zero rows, the Execute completes rather than suspending, and rs.next() is false.
In our case this presented as one service failing every attempt while others were unaffected, which sent us looking at the service rather than the framework. SimpleStepExecutionSplitter calls getLastStepExecution once per partition, so partitioned steps multiply the exposure, but partitioning is a multiplier rather than the cause.
Reproduction
Against cockroachdb/cockroach:v26.2.3 (Testcontainers), with a JDBC JobRepository and no CockroachDB session flags set:
- Run any job to completion or failure so a
JobInstance exists.
- Relaunch the same job instance so Spring Batch takes the restart path.
getLastStepExecution throws the error above.
#5133 refactors this method to reuse JdbcJobExecutionDao::getJobExecution, and at first glance looks like it would resolve this. It does not: the replacement call is still made from inside the open ResultSet.
+ return new StepExecutionRowMapper(
+ jobExecutionDao.getJobExecution(rs.getLong("JOB_EXECUTION_ID")))
+ .mapRow(rs, 0);
Since getJobExecution issues more statements than the getJobParameters call it replaces, the nesting would arguably widen. Worth noting in case that PR is considered a fix for this.
Suggested direction
Hoist the dependent read out of the cursor rather than changing which call is nested: read the row (or just its id) first, let that statement reach CommandComplete, then resolve the JobExecution. getStepExecution(long) already performs the sequential equivalent and could be delegated to.
Important subtlety: closing the Java ResultSet is not sufficient on its own. The first query must actually complete, which means limiting via SQL LIMIT 1 rather than JDBC setMaxRows(1). Otherwise the id query leaves its own suspended portal and the problem simply moves.
Happy to open a PR if the maintainers would like it in that shape.
Workaround for anyone hitting this
Either enable multiple_active_portals_enabled on the CockroachDB connection the JobRepository uses (it is a preview feature), or override JdbcStepExecutionDao.getLastStepExecution via a JdbcJobRepositoryFactoryBean subclass so it reads the id first and then delegates to getStepExecution(long). We took the latter route to avoid the preview flag.
Wire-protocol account in this report was reviewed and corrected after an independent technical review; an earlier revision attributed the behaviour to a deferred Close at the next Sync, which is wrong on two counts (pgjdbc sends Sync immediately, including inside explicit transactions, and Sync is not a deferred Close). The observable behaviour and the fix are unchanged.
Summary
JdbcStepExecutionDao.getLastStepExecutionissues a nested query from inside an open, row-limitedResultSet. On PostgreSQL this is fine. On CockroachDB it is rejected outright, so any step that already has a priorStepExecutionunder the sameJobInstancefails, and the connection is discarded as broken. In practice that means restarts.This is a portability defect rather than a performance one, and it is not caught by the existing test suite because it only manifests on a database that refuses multiple simultaneously-open portals.
Affected versions
Present in 6.0.4 (current release) and on
mainas of today.The code
spring-batch-core/src/main/java/org/springframework/batch/core/repository/dao/jdbc/JdbcStepExecutionDao.java, ingetLastStepExecution:JdbcJobExecutionDao.getJobParametersprepares and executes a second statement on the same connection while the first cursor is still open.Why
setMaxRows(1)mattersThe nesting alone is not sufficient. The distinction that matters is suspended and still executable versus completed, not simply open versus closed.
setMaxRows(1)makes pgjdbc sendExecutewith a protocol row count of 1. Per the protocol, if a non-zero row count stops execution before the portal completes, the backend repliesPortalSuspendedrather thanCommandComplete. pgjdbc has already sentSync, but because the connection is inside an explicit transaction thatSyncyieldsReadyForQuery(T)without ending the transaction or disposing the suspended portal. The nested query then attempts its protocol preparation while that portal is still actively suspended, and CockroachDB rejects it.By contrast
LIMIT 1in the SQL, with no JDBC max rows, producesExecute(portal, 0), which fetches to completion and yieldsCommandComplete. That is why the two are not interchangeable here.Relevant detail: on this
setMaxRows/fetchSize == 0path pgjdbc uses the unnamed portal and returns no namedResultCursor, so its explicit portal-cleanup machinery does not apply. Closing the JavaResultSettherefore does not necessarily emit a wire-levelClosefor the suspended unnamed portal.Verified empirically in an equivalent implementation:
LIMIT 1works, switching tostatement.setMaxRows(1)reintroduces the failure, removing it resolves it.Qualifications, so this is not overstated:
setMaxRows(1)does not guaranteePortalSuspended. A zero-row result reachesCommandComplete.fetchSizecan create the same condition via a named fetch portal, sosetMaxRowsis not the only trigger.prepareThresholdcan affect reproduction, since a subsequent unnamedBindnormally replaces the previous unnamed portal.FINESTtrace or pgwire capture showingExecute(portal=null, limit=1)thenPortalSuspendedthenSyncthenReadyForQuery(T)thenParsethenErrorResponsewould confirm it. I have not captured one.Observed failure
HikariCP then marks the connection broken (
SQLSTATE 0A000), which surfaces downstream asConnection is closedandJDBC rollback failed, so the original cause is easy to misdiagnose.Impact and why it looks intermittent
To be precise about the trigger, since "restart" is not quite the right framing:
SimpleStepHandler.handleStepalways runs with aJobInstancepresent and callsgetLastStepExecutionon ordinary first attempts too, simply gettingnullback.The dangerous condition is narrower: a prior
StepExecutionexists for the sameJobInstanceand the same step name, sors.next()is true and the nested hydration query actually runs. A first execution with a fresh step name is safe because the outer query returns zero rows, theExecutecompletes rather than suspending, andrs.next()is false.In our case this presented as one service failing every attempt while others were unaffected, which sent us looking at the service rather than the framework.
SimpleStepExecutionSplittercallsgetLastStepExecutiononce per partition, so partitioned steps multiply the exposure, but partitioning is a multiplier rather than the cause.Reproduction
Against
cockroachdb/cockroach:v26.2.3(Testcontainers), with a JDBCJobRepositoryand no CockroachDB session flags set:JobInstanceexists.getLastStepExecutionthrows the error above.On PR #5133
#5133 refactors this method to reuse
JdbcJobExecutionDao::getJobExecution, and at first glance looks like it would resolve this. It does not: the replacement call is still made from inside the openResultSet.Since
getJobExecutionissues more statements than thegetJobParameterscall it replaces, the nesting would arguably widen. Worth noting in case that PR is considered a fix for this.Suggested direction
Hoist the dependent read out of the cursor rather than changing which call is nested: read the row (or just its id) first, let that statement reach
CommandComplete, then resolve theJobExecution.getStepExecution(long)already performs the sequential equivalent and could be delegated to.Important subtlety: closing the Java
ResultSetis not sufficient on its own. The first query must actually complete, which means limiting via SQLLIMIT 1rather than JDBCsetMaxRows(1). Otherwise the id query leaves its own suspended portal and the problem simply moves.Happy to open a PR if the maintainers would like it in that shape.
Workaround for anyone hitting this
Either enable
multiple_active_portals_enabledon the CockroachDB connection theJobRepositoryuses (it is a preview feature), or overrideJdbcStepExecutionDao.getLastStepExecutionvia aJdbcJobRepositoryFactoryBeansubclass so it reads the id first and then delegates togetStepExecution(long). We took the latter route to avoid the preview flag.Wire-protocol account in this report was reviewed and corrected after an independent technical review; an earlier revision attributed the behaviour to a deferred
Closeat the nextSync, which is wrong on two counts (pgjdbc sendsSyncimmediately, including inside explicit transactions, andSyncis not a deferredClose). The observable behaviour and the fix are unchanged.