In a micro-services architecture paradigm, we have an exchange of messages between two services, each one writing the results into their respective database.
In a more descriptive manner, the communication is as follows:
Service A stores a message in its PostgreSQL database, communicates via gRPC with Service B, which in turn performs a number of operations, stores the result in its own PostgreSQL database, and responds back to Service A with the result. Upon receiving the result Service A updates its database with said result and uses that result to proceed with its remaining work. Once this work is done, we report the response back to the user.
The queries to PostgreSQL are being done by using JOOQ (this will be important later). The entire process is also supported by Spring Transactions. Each database operation starts its own transaction (keep that in mind). Additionally, Service A stores an array in one of the columns in the database (also keep this in mind).
So when during a routine check we've identified some data missing while the system reported processes to be completed successfully, we were understandably baffled and quite concerned.
Glossary
- PostgreSQL: a free open-source object relational database management system that stores and secures complex data.
- JOOQ: a Generative object oriented query mapping software library.
- Spring: a framework that provides a comprehensive programming and configuration model for java applications.
- HikariCP: a fast, lightweight, and reliable JDBC connection pooling library for Java applications.
- Database transactions: A single logical unit of work made up of one or more database operations.
- PostgreSQL state code 25P02: An error code from PostgreSQL indicating that the current database transaction is no longer active due to an error.
- gRPC: is an open-source, high-performance remote procedure call framework developed by Google.
- COMMIT/ROLLBACK: Database operations indicating the changes from a transaction are being stored in the database.
The issue
After some digging through the logs, we've realised, the following scenario had happened:
- Service A had the initial version of the message in its database.
- Service B had performed the calculation and had sent the response back to service A.
- Service A had tried to store the result in its database, but (for some reason unknown at the time) it failed.
- Service A acquired a response from the failed store operation.
- Service A continued the process and returned a response to the user.
The questions
At the time of the issue we had three "unknowns" to identify:
- Why did the actual store operation fail?
- Why, while the store failed, spring didn't roll back, or propagated the exception?
- How did the process in service A manage to find a response to continue the operations?
Question 2 was easy to answer.
Short answer to question 2: There was no exception after the failure of the store operation, so spring did not propagate anything.
However, when we checked the logs we realised that the insert had actually worked fine on the server side. So, while the store was successful, something caused it to roll back.
Finding out what happened
To answer questions 1 and 3, we had to combine a number of different events.
Our monitoring tool captured a query timeout, on a select operation, that was a good starting point, but we had to identify what caused this timeout, which at the time we assumed it was the cause of the issue.
The AHA! moment
To understand the issue you need to know that JOOQ provides a convenient clause called returning(). This clause will return a full Record corresponding to the table that the query is performed on.
So, if you have the following query type:
ctx
.insertInto(TABLE)
.set(records)
.onConflict(PRIMARY_KEY)
.doNothing()
.returning()
.fetchSingle()This will return a valid record in the fetchSingle() operation. However, the array initialisation during the returning() clause can also "swallow" exceptions via the use of its default bindings, when materialising the Record.
Simplified the internal pattern of the default bindings for the array is as follows:
// Simplified look at the internal pattern in DefaultBinding.java
try {
return pgFromString(ctx, string);
}
catch (Exception e) {
log.error("Cannot parse array : " + string, e);
return (T[]) Array.newInstance(type.getComponentType(), 0); // Returns an empty default array
}Which means that even in case of an exception during the array initialisation, jooq will return a valid Record to the application layer (this won't be the correct result of the query though). Knowing this, and combining it with the fact that:
- A new transaction in spring means that the fresh connection boundary is missing data type information.
- The
ROLLBACKoperation is a success no-op in PostgreSQL.
We could piece together the scenario that triggered this edge case.
What actually happened
The main trigger of this rare edge case was the new transaction. In a new spring transaction, Spring borrows a connection from the connection pool (usually HikariCP), and creates a fresh transaction boundary. So the chain of events is as follows:
- The
INSERT....RETURNINGquery was executed successfully. - During the
RETURNINGmaterialisation, JOOQ hits the array column and attempts apg_typemetadata query. - The above
pg_typequery hits astatement_timeout(or fails), and PostgreSQL immediately puts the connection into state 25P02 (current transaction is aborted, commands ignored until end of transaction block). - JOOQ suppresses/swallows the error from the
pg_typelookup (often setting the array field to null) and returns the rest of the instantiated model to Java. - Because jOOQ didn't throw an exception, Spring attempts to
COMMITthe transaction. PostgreSQL ignoresCOMMITon aborted transactions and silently converts it into aROLLBACK. - The function picks up the object that was returned and continues the process.
How to fix this
There are a number of ways that can be applied to avoid the issue described above.
- Pre-warm/Cache Metadata on Startup: Warm up array type metadata across your connection pool during application boot so
pg_typequeries never execute inside active business transactions. - Validate Arrays in Mappers: Assert that array fields are non-null in your domain record mappers (e.g., requireNotNull(record.array)). If jOOQ silently returns null due to a failed
pg_typefetch, the mapping step will throw an exception, forcing Spring to perform an explicit rollback instead of a silent commit. - Check Transaction Health: Validate whether the connection or transaction was marked rollback-only/aborted before returning the mapped record out of your repository.
- Configure JDBC Driver & Connection Pool Settings: Configure the PostgreSQL JDBC driver to minimise or eliminate runtime type metadata round-trips:
- Set prepareThreshold Tuning: Setting
prepareThreshold=-1forces the PostgreSQL JDBC driver to immediately use server-side binary transfer for supported data types, eliminating the default fallback and metadata polling that happens when statements transition from text to binary mode on cold connections. - Configure stringtype=unspecified: If using custom enums, setting
stringtype=unspecifiedon the JDBC connection URL forces PostgreSQL to infer string types implicitly rather than requiring synchronous client-sidepg_typeOID mapping during binding execution.
- Set prepareThreshold Tuning: Setting
Conclusion
This incident highlights a subtle and dangerous edge case at the intersection of application frameworks, ORM defaults, and database connection handling.
Key Takeaways:
- Silent Fallbacks Hide Fatal Errors: jOOQ's default array binding swallows serialisation failures to maintain convenience, but in a database transaction, returning partial data is far worse than crashing early.
- New Transactions Start Cold: Isolated transaction boundaries (
REQUIRES_NEW) on freshly acquired connection pool instances lack cached metadata, exposing queries to background schema lookups. - Database Aborts Ignore Commits: Once PostgreSQL enters state 25P02, a successful Java return translates into a silent
ROLLBACKonCOMMIT.
By implementing schema metadata pre-warming during boot and enforcing strict non-null checks in our repository mappers, we eliminate the silent failure mode ensuring that if an operation fails at the database level, it halts execution immediately and surfaces a true rollback to the application.