One missing try-with-resources

2026-10-10

I found a connection leak in LangGraph4j's database checkpoint savers, proved it with a program that had nothing to do with my own code, and got a fix merged upstream. It shipped in 1.9.4 (langgraph4j#484). The fix itself is one try-with-resources block per method.

This note covers how I found it, the root cause, why it got past the tests and code review, and what I put in the pull request so the maintainer could accept it quickly.

Why I was looking at checkpoint savers

I'm building a practice project in financial risk. Flink rules raise alerts in real time, and an AI agent in a Spring Boot service investigates each case and writes a report for a compliance analyst to approve. The investigation is a multi-step agent graph (triage, specialist agents running in parallel, a summary, a review). It can pause to wait for a human approval, and the platform has to run as several instances. So the graph's state has to be persisted: if an instance crashes, another one should carry on from the last completed node instead of paying for the same LLM calls again.

LangGraph4j is the Java implementation of Python's LangGraph, with the same concepts (StateGraph, conditional edges, interrupt, checkpoints), and it ships checkpoint savers for several databases. Before adopting it I built a proof of concept on 1.9.3: a graph with the same structure as the real workflow, configured the way production would be, with Postgres, a HikariCP connection pool, several instances and repeated runs.

Interrupts, conditional edges and resuming from a checkpoint all worked. After a few runs the application stalled because it couldn't get a database connection.

The symptom: an exhausted pool

The proof of concept used a Hikari pool of 10 connections. Every step of the graph loads a checkpoint, one full run nearly filled the pool, and the next run timed out.

My first suspect was my own code, since the proof of concept also did its own database access. Before blaming the library I needed a reproduction that had nothing to do with my project.

A minimal reproduction

I kept the variables to a minimum:

  • Only the official PostgresSaverV2. No graph and none of my own code.
  • Only the saver's public list() method, called repeatedly.
  • Two experiments. The first uses a HikariCP pool of just 3 connections with Hikari's leak detection on: leakDetectionThreshold reports any connection held longer than the threshold, together with the stack trace of the code that borrowed it. The second uses PGSimpleDataSource, the non-pooled data source the official examples use, and counts sessions in the database itself.
  • A throwaway database, created for the test and dropped afterwards.

Environment: JDK 21, PostgreSQL 17, HikariCP 7.1.0, pgjdbc 42.7.14, and langgraph4j-postgres-saver 1.9.3 (before the fix) and 1.9.4 (after). The full program is in the appendix. The core of it:

Experiment 1, with a pool:

var hikari = new HikariConfig();
hikari.setJdbcUrl("jdbc:postgresql://localhost:5432/lg4j_leak_repro");
hikari.setUsername(USER);
hikari.setPassword(PASSWORD);
hikari.setMaximumPoolSize(3);
hikari.setConnectionTimeout(2_000);
hikari.setLeakDetectionThreshold(2_000);   // report any connection held for more than 2 s, with the stack that borrowed it

try (var pool = new HikariDataSource(hikari)) {
    BaseCheckpointSaver saver = PostgresSaverV2.builder()
            .datasource(pool)
            .stateSerializer(new ObjectStreamStateSerializer<>(AgentState::new))
            .createTables(true)
            .build();
    var mx = pool.getHikariPoolMXBean();
    var config = RunnableConfig.builder().threadId("leak-repro").build();
    for (int i = 1; i <= 4; i++) {
        try {
            saver.list(config);
            System.out.printf("list() #%d: active=%d idle=%d%n", i, mx.getActiveConnections(), mx.getIdleConnections());
        } catch (Exception e) {
            System.out.printf("list() #%d FAILED: %s%n", i, e);
        }
    }
    Thread.sleep(3_000);   // give the leak detector time to report before the pool closes
}

Output on 1.9.3. Hikari printed three identical reports; I kept the first and removed the JDK-internal frames:

after build: active=0 idle=1
list() #1: active=1 idle=0
list() #2: active=2 idle=0
list() #3: active=3 idle=0
[HikariPool-1:housekeeper] WARN com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered
    for org.postgresql.jdbc.PgConnection@49e202ad on thread main, stack trace follows
java.lang.Exception: Apparent connection leak detected
	at org.bsc.langgraph4j.checkpoint.AbstractPostgresSaver.exec(AbstractPostgresSaver.java:327)
	at org.bsc.langgraph4j.checkpoint.AbstractPostgresSaver.loadCheckpoints(AbstractPostgresSaver.java:216)
	at org.bsc.langgraph4j.checkpoint.AbstractCheckpointSaver.loadOrInitCheckpoints(AbstractCheckpointSaver.java:33)
	at org.bsc.langgraph4j.checkpoint.AbstractCheckpointSaver.list(AbstractCheckpointSaver.java:49)
	at repro.LeakRepro.pooled(LeakRepro.java:58)
	at repro.LeakRepro.main(LeakRepro.java:30)
(two more identical reports omitted)
list() #4 FAILED: java.lang.RuntimeException: java.sql.SQLTransientConnectionException: HikariPool-1 -
    Connection is not available, request timed out after 2004ms (total=3, active=3, idle=0, waiting=0)

Each list() call holds one more connection, and the fourth times out. Hikari's leak detector names the culprit: line 327 of AbstractPostgresSaver.exec, called from line 216 of loadCheckpoints. Three connections, three reports.

Experiment 2 has no pool and counts sessions in the database. The data source gets an applicationName, and a separate monitoring connection queries pg_stat_activity, so the count only includes sessions the saver opened:

var ds = new PGSimpleDataSource();
ds.setURL("jdbc:postgresql://localhost:5432/lg4j_leak_repro");
ds.setUser(USER);
ds.setPassword(PASSWORD);
ds.setApplicationName("lg4j-repro");      // so pg_stat_activity can tell the saver's sessions apart

var saver = /* same as above, built with ds */;
System.out.println("sessions before: " + sessions());
for (int i = 0; i < 20; i++) {
    saver.list(config);
}
System.out.println("sessions after 20 x list(): " + sessions());
System.gc();
Thread.sleep(3_000);
System.out.println("sessions after System.gc(): " + sessions());

// sessions() runs on a separate connection:
// SELECT count(*) FROM pg_stat_activity WHERE datname = 'lg4j_leak_repro' AND application_name = 'lg4j-repro'

Output on 1.9.3:

sessions before: 0
sessions after 20 x list(): 11
sessions after System.gc(): 0

After 20 calls the database still had 11 open sessions, and one manual GC closed all of them. I ran it twice and got 17 the first time and 11 the second, depending on how much the JVM happened to collect during the loop. The PostgreSQL JDBC driver closes a connection when the connection object is garbage collected. That is how the bug stayed hidden: without a pool, the symptom is a session count that rises and falls with GC timing and always comes back to zero, and nobody looks at that.

The same program on 1.9.4:

list() #1: active=0 idle=1
list() #2: active=0 idle=1
list() #3: active=0 idle=1
list() #4: active=0 idle=1

sessions before: 0
sessions after 20 x list(): 0
sessions after System.gc(): 0

Every connection goes back. No leak reports, and no sessions left behind.

The root cause

The helper in 1.9.3's AbstractPostgresSaver:

protected final <R> R exec(TryFunction<Connection, R, Exception> execStatement) throws Exception {
    final var connection = datasource.getConnection();

    connection.setAutoCommit(true);

    return execStatement.tryApply(connection);
}

exec() borrows a connection, hands it to the callback and returns. Nothing calls connection.close(). For a pooled DataSource, close() is what returns the connection to the pool, so without it the connection stays borrowed for good.

The other savers had the same problem, in more places than I expected:

Saverexec()execTransaction()
Postgresleakscorrect (already try-with-resources)
MySQLleaksleaks
Oracleleaksleaks
SQLiteleaksleaks
CockroachDBcorrectcorrect

The CockroachDB saver gets both right, and so does Postgres's own execTransaction(). The correct pattern was already in the same project; the new helper just didn't use it.

How it got in

I assumed it was an old bug. The commit history said otherwise:

DateEvent
2026-08-12The SQLite saver gets a V2, with a new exec() helper that doesn't close its connection
2026-09-12A refactor bringing the other savers in line with V2 (for example 8f6d0993) copies the same helper into Postgres, MySQL and Oracle
2026-09-171.9.0 is released with the leak
2026-10-011.9.3 is released, still with the leak

Before the refactor, Postgres's loadCheckpoints was correct:

try (Connection conn = getConnection()) {
    try (PreparedStatement ps = conn.prepareStatement(sqlCheckThread)) {
        ...
    }
    ...
}
return checkpoints;

The refactor changed it to:

return exec(conn -> {
    try (var ps = conn.prepareStatement(sqlCheckThread)) {
        ...
    }
    ...
    return checkpoints;
});

On its own that diff looks clean: the repeated "get a connection, run something" code moves into a shared exec(). The one thing that disappears is the outer try (Connection conn = ...), and the new exec() doesn't take it over.

Why nobody noticed

A bug that drains a connection pool in a few steps sat in a project with over 2,000 stars for three weeks without a single issue being filed. I see four reasons, and each one is worth turning into a habit.

  1. The default setup hides it. The official examples and tests use a non-pooled data source, so every call opens a new physical connection, the leaked object is left unreferenced, and the driver closes it during GC. Experiment 2 shows what that looks like: a session count that wanders (11, 17) and drops to zero after a GC. SQLite, a local file database, behaves much the same. It only goes wrong quickly when three things line up: a connection pool, one of the affected versions, and a process that keeps running.
  2. The tests can't see it. They use the non-pooled data source, each test calls the saver only a few times, and the containers restart, so the leak never has time to build up. More fundamentally, no test checked whether a borrowed connection was ever returned. Functional tests check results, and the results were always correct.
  3. Review is bad at spotting what's missing. Reviewers, human or tool-assisted, are good at checking whether new code is right and much worse at noticing a constraint that used to be there and isn't any more. This refactor gathered several try-with-resources blocks into one shared method and left the try out of that method.
  4. The window was short and few people were exposed. 1.9.0 to 1.9.3 came out within two weeks, so most users hadn't upgraded. Many of those who had were on the in-memory MemorySaver, and of those on a database saver, only people running a connection pool in production would hit it.

I didn't find it by reading the code carefully. I found it because I ran the library the way production would run it: with a pool, several runs, and leak detection on.

The fix

All seven places become try-with-resources, matching the CockroachDB saver:

protected final <R> R exec(TryFunction<Connection, R, Exception> execStatement) throws Exception {
    try (Connection connection = datasource.getConnection()) {
        connection.setAutoCommit(true);
        return execStatement.tryApply(connection);
    }
}

execTransaction() gets the same treatment. The whole method body goes inside try (Connection connection = ...), and the commit and rollback logic inside it is unchanged. The production code change touches four files, and each change adds one try block.

I left everything else alone. Reading the MySQL, Oracle and SQLite execTransaction methods, I noticed that the finally block calls commit() again after the catch block has already rolled back. It looks harmless, but it's a separate issue from the leak, so I mentioned it in the PR description and left the decision to the maintainer. A pull request that does one thing is easier to merge with confidence.

A test that counts connections

Saying "it uses try-with-resources now" isn't proof. I wanted a test that fails before the fix, passes after it, and would catch the same mistake if someone made it again.

The test wraps the real DataSource in a proxy that counts connections that have been borrowed and not yet closed:

final class TrackingDataSource implements DataSource {

    private final DataSource delegate;
    private final AtomicInteger open = new AtomicInteger();

    int openConnections() {
        return open.get();
    }

    @Override
    public Connection getConnection() throws SQLException {
        return track(delegate.getConnection());
    }

    private Connection track(Connection connection) {
        open.incrementAndGet();
        var closed = new AtomicBoolean();
        return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class},
                (proxy, method, args) -> {
                    if (method.getName().equals("close") && closed.compareAndSet(false, true)) {
                        open.decrementAndGet();
                    }
                    try {
                        return method.invoke(connection, args);
                    } catch (InvocationTargetException e) {
                        throw e.getCause();
                    }
                });
    }
    // the remaining DataSource methods delegate directly
}

It then checks that the count is back to zero after each of the saver's public operations:

var ds = new TrackingDataSource(dataSource());
BaseCheckpointSaver saver = PostgresSaverV2.builder()
        .datasource(ds)
        .stateSerializer(new ObjectStreamStateSerializer<>(AgentState::new))
        .createTables(true)
        .build();
assertEquals(0, ds.openConnections(), "after build");

var config = RunnableConfig.builder().threadId("connection-leak-test").build();
saver.list(config);
assertEquals(0, ds.openConnections(), "after list");
saver.get(config);
assertEquals(0, ds.openConnections(), "after get");
// the same for put, a second get, and release

This doesn't depend on a pool or on GC timing. If any connection is borrowed and not returned, the count is non-zero, the test fails at once, and the assertion message names the step.

Then I checked the test itself, by reverting the fix and keeping the test. It has to fail:

  • Postgres fails at "after list", because loadCheckpoints leaks through exec().
  • SQLite fails earlier, at "after build", because table creation goes through execTransaction().

With the fix restored, both tests pass. The existing upstream suites pass too: 21 Postgres tests including the new one, 18 SQLite and 16 MySQL, on JDK 17 (matching upstream CI) and JDK 21. Oracle needs a very large container, so I didn't run its tests locally. I confirmed it compiles and said so in the PR; its change is identical to MySQL's.

The reproduction that almost didn't reproduce

While writing this note I re-ran the reproduction to get fresh output. On the first re-run, 1.9.3 didn't leak a single connection, and the pool stayed at active=0 throughout.

That contradicted the PR. Before drawing any conclusion, I looked at the jar that was actually being loaded:

  • In my local Maven repository, the 1.9.3 sources jar still had an exec() that never closed its connection.
  • The binary jar in the same directory, decompiled with javap -c, did call Connection.close() in exec().
  • Its SHA-1 didn't match the .sha1 file on Maven Central, and _remote.repositories showed it had been installed locally, on the evening I prepared the PR.

While preparing the PR I had run mvn install on my fork, and the fork's version was still 1.9.3. So the fixed code had been installed into my local repository under the release's own coordinates, replacing the real 1.9.3 from Maven Central, in all 17 modules. From then on, every project on this machine that depended on 1.9.3 was using a version that doesn't exist anywhere else.

I deleted those 1.9.3 directories from the local repository (they're only a cache) and resolved the dependencies again into a fresh local repository (-Dmaven.repo.local=...). Once the jar's SHA-1 matched Maven Central and its exec() had no close(), I got the results shown above.

Two lessons from that:

  • When you test an upstream fix locally, don't install it under the release version. Use a -SNAPSHOT or an obviously local version, run mvn verify only, or point maven.repo.local at a temporary directory.
  • When you run a "before the fix" control, confirm it is really running the code from before the fix. A contaminated control rarely fails loudly. It usually looks fine, which is worse. Checking the loaded jar's path and checksum takes a minute.

The pull request

LangGraph4j is maintained mostly by one person, whose time is limited. I tried to put everything they'd need into the PR description at once:

  1. The problem and its impact: the reproduction output with a pool, including the stack trace Hikari pointed to, and why it's hard to notice without a pool.
  2. Where the regression came from: the 2026-09-12 refactor, with a commit hash, affecting 1.9.0 to 1.9.3. That helps the maintainer decide whether a patch release is urgent.
  3. The scope of the fix: what I changed, what I deliberately didn't, and the correct pattern that already existed in the project (the CockroachDB saver).
  4. Tests: how the new test behaves before and after the fix, the results of the existing suites, and a plain statement that I didn't run the Oracle tests.
  5. The unrelated issue I noticed, listed separately and kept out of this change.

The maintainer replied the same day that they'd merge it as soon as possible. It was merged a week later, and 1.9.4 was released six minutes after the merge.

Afterwards

I didn't end up using the official saver in my project. I wrote my own: it stores state as JSON instead of Java serialization, leaves the schema to Flyway, and before writing a checkpoint it checks that this instance still holds the claim on the case, so an old instance that recovers after a timeout can't overwrite a newer instance's progress. A general-purpose saver can't do that last part, because it doesn't know about the application's claiming mechanism. Later, for long-term maintenance reasons, I moved the graph runtime to a different framework. Both were decisions about my own project's needs and have little to do with this bug. With 1.9.4, the official savers work fine behind a connection pool.

Takeaways

When you evaluate a framework, test it with your production configuration. The defaults in official examples are chosen so that things run, not so that they keep running. At minimum, use the connection pool you'd use in production, turn on its leak detection, run the same flow several times, and look at resource metrics (active connections, database sessions) as well as results.

Treat returning resources as behaviour that needs a test. Functional tests check output, and output is usually still correct while something leaks. A counting DataSource proxy is a few dozen lines and exposes this kind of problem on the first run. The same idea works for file handles, HTTP connections and threads.

For this class of bug, deterministic tools are more reliable than review. Some switches that already exist:

  • HikariCP's leakDetectionThreshold, which reports connections held too long and the stack that borrowed them.
  • The PostgreSQL JDBC driver's logUnclosedConnections=true, which logs where a connection was opened if it is garbage collected without being closed.
  • Static analysis: SpotBugs' ODR_OPEN_DATABASE_RESOURCE, and IntelliJ's inspections for resources that are opened but not safely closed, can flag a getConnection() with no matching close() at build time.

When reviewing a refactor, ask where the old constraints went. When code is pulled into a shared method, whatever it used to enforce implicitly (closing resources, releasing locks, restoring context) has to move into the new method. Checking the added code is half the review. The other half is confirming that every deleted try, finally and unlock has a new home.

When adopting a dependency, look at how it's maintained as well as how many stars it has: how many maintainers, whether large refactors get reviewed, what the tests cover. A project maintained mostly by one person is fine to use, but pin the version and run your own regression tests before upgrading. Those projects are also the ones most glad to get a PR that comes with a reproduction, a root cause and a test.

Appendix: the full reproduction program

A standalone Maven project with no application code. Pass -Dlg4j.version=1.9.4 to switch to the fixed version.

pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>
    <groupId>repro</groupId>
    <artifactId>lg4j-leak-repro</artifactId>
    <version>1</version>
    <properties>
        <maven.compiler.release>21</maven.compiler.release>
        <!-- 1.9.3 = before the fix, 1.9.4 = after -->
        <lg4j.version>1.9.3</lg4j.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.bsc.langgraph4j</groupId>
            <artifactId>langgraph4j-postgres-saver</artifactId>
            <version>${lg4j.version}</version>
        </dependency>
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>7.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <version>42.7.14</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>2.0.17</version>
        </dependency>
    </dependencies>
</project>

src/main/java/repro/LeakRepro.java:

package repro;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.checkpoint.BaseCheckpointSaver;
import org.bsc.langgraph4j.checkpoint.PostgresSaverV2;
import org.bsc.langgraph4j.serializer.std.ObjectStreamStateSerializer;
import org.bsc.langgraph4j.state.AgentState;
import org.postgresql.ds.PGSimpleDataSource;

import javax.sql.DataSource;
import java.sql.DriverManager;

/**
 * Minimal reproduction: only the official PostgresSaverV2 and its public list(), no graph, no application code.
 * Experiment 1: a HikariCP pool of 3 with leak detection. Experiment 2: the non-pooled PGSimpleDataSource,
 * counting server sessions in pg_stat_activity before and after a GC.
 */
public class LeakRepro {

    static final String DB = "lg4j_leak_repro";
    static final String URL = "jdbc:postgresql://localhost:5432/" + DB;
    static final String USER = "...";
    static final String PASSWORD = "...";

    public static void main(String[] args) throws Exception {
        System.out.println("langgraph4j-postgres-saver jar: " + PostgresSaverV2.class.getProtectionDomain().getCodeSource()
                .getLocation().getPath().replaceAll(".*/", ""));
        pooled();
        nonPooled();
    }

    static BaseCheckpointSaver saver(DataSource ds) throws Exception {
        return PostgresSaverV2.builder()
                .datasource(ds)
                .stateSerializer(new ObjectStreamStateSerializer<>(AgentState::new))
                .createTables(true)
                .build();
    }

    static void pooled() throws Exception {
        System.out.println("\n== Experiment 1: HikariCP, maximumPoolSize=3, leakDetectionThreshold=2s ==");
        var hikari = new HikariConfig();
        hikari.setJdbcUrl(URL);
        hikari.setUsername(USER);
        hikari.setPassword(PASSWORD);
        hikari.setMaximumPoolSize(3);
        hikari.setConnectionTimeout(2_000);
        hikari.setLeakDetectionThreshold(2_000);   // report a connection held for more than 2 s, with the borrower's stack
        try (var pool = new HikariDataSource(hikari)) {
            var saver = saver(pool);
            var mx = pool.getHikariPoolMXBean();
            System.out.printf("after build: active=%d idle=%d%n", mx.getActiveConnections(), mx.getIdleConnections());
            var config = RunnableConfig.builder().threadId("leak-repro").build();
            for (int i = 1; i <= 4; i++) {
                try {
                    saver.list(config);
                    System.out.printf("list() #%d: active=%d idle=%d%n", i, mx.getActiveConnections(),
                            mx.getIdleConnections());
                } catch (Exception e) {
                    System.out.printf("list() #%d FAILED: %s%n", i, e);
                }
            }
            Thread.sleep(3_000);   // give the leak detector time to report before the pool closes
        }
    }

    static void nonPooled() throws Exception {
        System.out.println("\n== Experiment 2: PGSimpleDataSource (no pool), 20 x list() ==");
        var ds = new PGSimpleDataSource();
        ds.setURL(URL);
        ds.setUser(USER);
        ds.setPassword(PASSWORD);
        ds.setApplicationName("lg4j-repro");           // so pg_stat_activity can tell the saver's sessions apart
        var saver = saver(ds);
        var config = RunnableConfig.builder().threadId("leak-repro-2").build();
        System.out.println("sessions before: " + sessions());
        for (int i = 0; i < 20; i++) {
            saver.list(config);
        }
        System.out.println("sessions after 20 x list(): " + sessions());
        System.gc();
        Thread.sleep(3_000);
        System.out.println("sessions after System.gc(): " + sessions());
    }

    /** Server sessions opened by the saver, counted on a separate monitoring connection. */
    static int sessions() throws Exception {
        try (var c = DriverManager.getConnection(URL, USER, PASSWORD);
             var rs = c.createStatement().executeQuery(
                     "SELECT count(*) FROM pg_stat_activity WHERE datname = '" + DB
                             + "' AND application_name = 'lg4j-repro'")) {
            rs.next();
            return rs.getInt(1);
        }
    }
}

To run it, create an empty database called lg4j_leak_repro, then run the program in its own JVM. Don't use mvn exec:java: it runs inside Maven's JVM, Maven takes over the logging, and with -q you won't see Hikari's warnings.

mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt compile
java -cp "target/classes:$(cat cp.txt)" repro.LeakRepro

References

  • The PR: langgraph4j/langgraph4j#484, "fix(savers): close JDBC connections in exec and execTransaction"
  • An example commit from the refactor that introduced the regression: 8f6d0993
  • The fixed release: LangGraph4j v1.9.4