Skip to content

ClosedChannelException and FileChannel leak when switching resources multiple times within the same transaction #5176

@banseok1216

Description

@banseok1216

Bug description

When StaxEventItemWriter is used within the same transaction (TransactionTemplate) in the following pattern, problems occur:

  • Using the same StaxEventItemWriter instance
  • setResource(r1) -> open -> write -> close
  • setResource(r2) -> open -> write -> close
  • setResource(r3) -> open -> write -> close

Observed problems (depending on the environment, one or both may occur):

  1. java.nio.channels.ClosedChannelException at transaction commit (or at the end of transaction synchronization)
  2. Some FileChannels opened for r1/r2/r3 remain open after the transaction ends (resource leak)

Related issue:

Environment

  • Spring Batch version: 6.0.x, 5.2.x

Steps to reproduce

  1. Add the following two tests to Spring Batch codebase in org.springframework.batch.infrastructure.item.xml.TransactionalStaxEventItemWriterTests.
  2. Run tests. You will observe either:
    • ClosedChannelException in shouldWriteThreeSeparateFilesWhenMultipleOpenCloseAndResourceSwitchInSingleTransaction, or
    • a failure in shouldCloseAllFileChannelsAfterTransaction because some channels remain isOpen() == true after transaction completion.

Expected behavior

Even when switching resources and opening/closing the writer multiple times within the same transaction:

  1. No ClosedChannelException should be thrown at transaction completion.
  2. After the transaction ends (commit/rollback), all FileChannels opened during that transaction must be closed.

Minimal Complete Reproducible example

The following tests validate two aspects:

  1. Exception reproduction: shouldWriteThreeSeparateFilesWhenMultipleOpenCloseAndResourceSwitchInSingleTransaction
  2. Leak reproduction: shouldCloseAllFileChannelsAfterTransaction
    • It uses reflection to extract the underlying FileChannel and checks isOpen() after the transaction.
@Test
void shouldWriteThreeSeparateFilesWhenMultipleOpenCloseAndResourceSwitchInSingleTransaction() throws Exception {
    WritableResource r1 = new FileSystemResource(File.createTempFile("stax-tx-rot-1", ".xml"));
    WritableResource r2 = new FileSystemResource(File.createTempFile("stax-tx-rot-2", ".xml"));
    WritableResource r3 = new FileSystemResource(File.createTempFile("stax-tx-rot-3", ".xml"));

    assertDoesNotThrow(() ->
        new TransactionTemplate(transactionManager).execute((TransactionCallback<Void>) status -> {
            try {
                writer.setResource(r1);
                writer.open(new ExecutionContext());
                writer.write(items);
                writer.close();

                writer.setResource(r2);
                writer.open(new ExecutionContext());
                writer.write(items);
                writer.close();

                writer.setResource(r3);
                writer.open(new ExecutionContext());
                writer.write(items);
                writer.close();

                return null;
            }
            catch (Exception e) {
                throw new RuntimeException(e);
            }
        })
    );
}

@Test
void shouldCloseAllFileChannelsAfterTransaction() throws Exception {
    WritableResource r1 = new FileSystemResource(File.createTempFile("stax-tx-leak-1", ".xml"));
    WritableResource r2 = new FileSystemResource(File.createTempFile("stax-tx-leak-2", ".xml"));
    WritableResource r3 = new FileSystemResource(File.createTempFile("stax-tx-leak-3", ".xml"));

    List<FileChannel> opened = new ArrayList<>();

    try {
        new TransactionTemplate(transactionManager).execute((TransactionCallback<Void>) status -> {
            try {
                writer.setResource(r1);
                writer.open(new ExecutionContext());
                FileChannel ch1 = extractChannelFromStaxWriter(writer);
                assertNotNull(ch1);
                opened.add(ch1);
                writer.write(items);
                writer.close();

                writer.setResource(r2);
                writer.open(new ExecutionContext());
                FileChannel ch2 = extractChannelFromStaxWriter(writer);
                assertNotNull(ch2);
                opened.add(ch2);
                writer.write(items);
                writer.close();

                writer.setResource(r3);
                writer.open(new ExecutionContext());
                FileChannel ch3 = extractChannelFromStaxWriter(writer);
                assertNotNull(ch3);
                opened.add(ch3);
                writer.write(items);
                writer.close();

                return null;
            }
            catch (Exception ignored) {
            }
        });
    }
    catch (Exception ignored) {
        // Continue to check leaks even if an exception happens
    }

    assertEquals(3, opened.size(), "Expected 3 opened channels");
    for (FileChannel ch : opened) {
        assertFalse(ch.isOpen(), "FileChannel should be closed after transaction");
    }
}

private static FileChannel extractChannelFromStaxWriter(StaxEventItemWriter<?> w) throws Exception {
    // legacy version
    Field field = StaxEventItemWriter.class.getDeclaredField("channel");
    field.setAccessible(true);
    return (FileChannel) field.get(w);
    
    // enhance version
    Spring Batch 6.x layout: StaxEventItemWriter.state.channel
    Field stateField = StaxEventItemWriter.class.getDeclaredField("state");
    stateField.setAccessible(true);
    Object state = stateField.get(w);
    Field channelField = state.getClass().getDeclaredField("channel");
    channelField.setAccessible(true);
    return (FileChannel) channelField.get(state);
}

Observed stacktrace example

org.springframework.batch.infrastructure.support.transaction.FlushFailedException: Could not write to output buffer
Caused by: java.nio.channels.ClosedChannelException

Why this happens

The key is that TransactionAwareBufferedWriter performs flush/close at transaction synchronization time.

Problematic structure:

  • It registers a close callback like TransactionAwareBufferedWriter(fileChannel, this::closeStream)
  • But closeStream() closes the writer instance’s mutable field (e.g. channel) rather than closing the specific fileChannel that was used when the callback was registered
  • Within the same transaction, repeated open() calls overwrite the channel field as resources are switched
  • At transaction completion, the callback may:
    • close only the last channel (leaving earlier channels open), and/or
    • attempt to flush/write using a channel that is already closed, causing ClosedChannelException

Suggested fix direction

To make this safe, the resources created by a single open() (e.g. FileOutputStream/FileChannel/Writer/XMLEventWriter) should be encapsulated in a state object, and the TransactionAwareBufferedWriter close callback should be bound to that specific state instance.

In short:

  • Introduce an OutputState in StaxEventItemWriter to own those resources
  • Register the transactional close callback as TransactionAwareBufferedWriter(fileChannel, state::closeStream)
  • In close(), call state.close(...) and then set state = null

Reference / similar design in codebase

AbstractFileItemWriter uses an OutputState to encapsulate stream/channel lifecycle and binds the transactional writer close callback to that state, avoiding the same class of problems.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions