GH-11253: Fix directory-creation race in FileWritingMessageHandler - #11254
Conversation
artembilan
left a comment
There was a problem hiding this comment.
Please, find my review in lines.
Thank you!
| CyclicBarrier barrier = new CyclicBarrier(concurrency); | ||
| List<Future<?>> results = new ArrayList<>(); | ||
| for (int i = 0; i < concurrency; i++) { | ||
| Message<String> message = MessageBuilder.withPayload("foo") |
There was a problem hiding this comment.
No foo/bar language, please.
We slowly migrate existing code, but definitely don't want more of them in a new one.
There was a problem hiding this comment.
Sorry, I was not aware of that.
Renamed the payload to "test data".
| .setHeader(FileHeaders.FILENAME, "file-" + i + ".txt") | ||
| .build(); | ||
| results.add(executorService.submit(() -> { | ||
| barrier.await(); |
There was a problem hiding this comment.
I'm not sure that we need a barrier...
Please, elaborate.
There was a problem hiding this comment.
The barrier was there to line up all threads right before handleMessage() to maximize the chance of hitting the exists()/mkdirs() window.
However, I re-verified without it -> the test still failed consistently against the unpatched code in 10 out of 10 runs. So you are right, it is not needed; removed.
| })); | ||
| } | ||
| for (Future<?> result : results) { | ||
| result.get(10, TimeUnit.SECONDS); |
There was a problem hiding this comment.
I think we don't need such a complexity.
We just need one CountDownLatch to countDown in the Runnable after that handler.handleMessage().
There was a problem hiding this comment.
Simplified as suggested: plain executorService.execute() with a single CountDownLatch counted down after handleMessage(), no futures involved.
| for (Future<?> result : results) { | ||
| result.get(10, TimeUnit.SECONDS); | ||
| } | ||
| assertThat(new File(directory, "file-0.txt")).exists(); |
There was a problem hiding this comment.
This doesn't look finished.
I would expect here a loop to check that all requested files are created.
There was a problem hiding this comment.
Added the assertion loop verifying that all requested files exist.
| @Test | ||
| public void concurrentWritesToSameNewDestinationDirectory() throws Exception { | ||
| FileWritingMessageHandler handler = new FileWritingMessageHandler( | ||
| new SpelExpressionParser().parseExpression("headers['directory']")); |
There was a problem hiding this comment.
We don't need SpEL parsing for such an expression style.
The FunctionExpression will do the trick for us:
new FunctionExpression<Message<?>>(message -> message.getHeaders().get("directory"))
There was a problem hiding this comment.
Your are right. I wasn't aware of that. Thanks for the hint.
Done. Replaced by FunctionExpression.
There was a problem hiding this comment.
Thank you!
You don't need to comment for every single my review line that you are agreed with me.
There is enough just one common: I'll see that we are on the same page with your next change set.
However you are free to discuss further if you think I'm wrong in my review.
You also don't need to squash all the commits after PR is opened.
I saw that question from my AI, so I believe yours also is trying to follow our CONTRIBUTION.md.
I guess that one has to be fixed respectively 😄
| FileWritingMessageHandler handler = new FileWritingMessageHandler( | ||
| new SpelExpressionParser().parseExpression("headers['directory']")); | ||
| handler.setBeanFactory(TEST_INTEGRATION_CONTEXT); | ||
| handler.setApplicationContext(new GenericApplicationContext()); |
There was a problem hiding this comment.
The TEST_INTEGRATION_CONTEXT is already an ApplicationContext, so we don't need this new instance.
More over, please, double check if we need it at all.
I just removed same line of code in the @BeforeEach method of this class - and all tests have passed.
There was a problem hiding this comment.
You are right - removed the setApplicationContext(new GenericApplicationContext()) line and double-checked that the test still behaves as intended...
…ssageHandler `validateDestinationDirectory()` used a non-atomic `exists()`/`mkdirs()` sequence. With a `directory-expression` this runs per message on the calling thread, so concurrent messages resolving to the same, not-yet-existing destination directory raced on `File.mkdirs()`: the losing threads received `false` and failed with `IllegalArgumentException: Destination directory [...] could not be created.` although the directory existed at that point. Use the idempotent and concurrency-safe `Files.createDirectories()` instead, retaining the exception type and message and propagating a genuine creation failure as cause. Fixes: spring-projects#11253 Signed-off-by: Thomas Knall <thomas.knall@prime-sign.com>
38841c2 to
35e958d
Compare
artembilan
left a comment
There was a problem hiding this comment.
[ant:checkstyle] [ERROR] /home/runner/work/spring-integration/spring-integration/spring-integration-file/src/test/java/org/springframework/integration/file/outbound/FileWritingMessageHandlerTests.java:54:1: Wrong lexicographical order for 'org.springframework.integration.channel.QueueChannel' import. Should be before 'org.springframework.integration.expression.FunctionExpression'. [ImportOrder]
Please, make sure you run ./gradlew check (or just for that specific module) before pushing changes upstream to the PR.
Thank you!
Signed-off-by: Thomas Knall <thomas.knall@prime-sign.com>
Head branch was pushed to by a user without write access
…file GET Fixes: spring-projects#11277 `generateLocalDirectory()` used a non-atomic `exists()`/`mkdirs()` sequence. With a `local-directory-expression` the eager `setupLocalDirectory()` in `afterPropertiesSet()` is skipped, since it only runs for a `ValueExpression`, so the directory is created per message on the calling thread. Concurrent messages resolving to the same, not-yet-existing local directory raced on `File.mkdirs()`: the losing threads received `false` and failed with `IllegalArgumentException: Failed to make local directory: [...]` although the directory existed at that point. * Treat `false` from `mkdirs()` as a failure only when the directory is still absent - the concurrency-tolerant idiom already used by `PropertiesPersistingMetadataStore` `Files.createDirectories()`, as used by `FileWritingMessageHandler` since spring-projectsGH-11254, is race-tolerant as well, but it makes an `IOException` the root cause of the reported `IllegalArgumentException`. `FtpServerOutboundTests` and `SftpServerOutboundTests` assert that root cause, so the thrown exception is kept unchanged in type, message and cause instead. Signed-off-by: Jiwoo Lee <dlwldn30@naver.com>
…file GET Fixes: spring-projects#11277 `generateLocalDirectory()` used a non-atomic `exists()`/`mkdirs()` sequence. With a `local-directory-expression` the eager `setupLocalDirectory()` in `afterPropertiesSet()` is skipped, since it only runs for a `ValueExpression`, so the directory is created per message on the calling thread. Concurrent messages resolving to the same, not-yet-existing local directory raced on `File.mkdirs()`: the losing threads received `false` and failed with `IllegalArgumentException: Failed to make local directory: [...]` although the directory existed at that point. * Use the idempotent and concurrency-safe `Files.createDirectories()` instead, retaining the exception type and message and propagating a genuine creation failure as cause This mirrors the fix applied to `FileWritingMessageHandler` in spring-projectsGH-11254. Attaching the `IOException` as cause moves the root cause of the reported `IllegalArgumentException` from itself to a `FileSystemException`. `FtpServerOutboundTests` and `SftpServerOutboundTests` pinned that incidental position via `hasRootCauseInstanceOf()`, which held only because the previous `Assert.isTrue()` left the chain empty. * Assert the exception type and message where they occur in the chain instead of at its root, so the tests no longer depend on nothing being wrapped The adjusted assertions pass against the unfixed gateway as well, so they do not encode this change. Signed-off-by: Jiwoo Lee <dlwldn30@naver.com>
…file GET Fixes: spring-projects#11277 `generateLocalDirectory()` used a non-atomic `exists()`/`mkdirs()` sequence. With a `local-directory-expression` the eager `setupLocalDirectory()` in `afterPropertiesSet()` is skipped, since it only runs for a `ValueExpression`, so the directory is created per message on the calling thread. Concurrent messages resolving to the same, not-yet-existing local directory raced on `File.mkdirs()`: the losing threads received `false` and failed with `IllegalArgumentException: Failed to make local directory: [...]` although the directory existed at that point. * Use the idempotent and concurrency-safe `Files.createDirectories()` instead, retaining the exception type and message and propagating a genuine creation failure as cause This mirrors the fix applied to `FileWritingMessageHandler` in spring-projectsGH-11254. Attaching the `IOException` as cause moves the root cause of the reported `IllegalArgumentException` from itself to the `FileSystemException` raised by the failed creation. `FtpServerOutboundTests` and `SftpServerOutboundTests` pinned the old position via `hasRootCauseInstanceOf(IllegalArgumentException)`, which held only because the previous `Assert.isTrue()` left the chain empty. * Assert the `IllegalArgumentException` and its message where they now occur in the chain, and keep a root cause type assertion for the `FileSystemException` that the failed creation contributes Signed-off-by: Jiwoo Lee <dlwldn30@naver.com>
…file GET Fixes: spring-projects#11277 `generateLocalDirectory()` used a non-atomic `exists()`/`mkdirs()` sequence. With a `local-directory-expression` the eager `setupLocalDirectory()` in `afterPropertiesSet()` is skipped, since it only runs for a `ValueExpression`, so the directory is created per message on the calling thread. Concurrent messages resolving to the same, not-yet-existing local directory raced on `File.mkdirs()`: the losing threads received `false` and failed with `IllegalArgumentException: Failed to make local directory: [...]` although the directory existed at that point. * Use the idempotent and concurrency-safe `Files.createDirectories()` instead, retaining the exception type and message and propagating a genuine creation failure as cause This mirrors the fix applied to `FileWritingMessageHandler` in spring-projectsGH-11254. Attaching the `IOException` as cause moves the root cause of the reported `IllegalArgumentException` from itself to the `FileSystemException` raised by the failed creation. `FtpServerOutboundTests` and `SftpServerOutboundTests` pinned the old position via `hasRootCauseInstanceOf(IllegalArgumentException)`, which held only because the previous `Assert.isTrue()` left the chain empty. * Assert the `IllegalArgumentException` and its message where they now occur in the chain, and keep a root cause type assertion for the `FileSystemException` that the failed creation contributes Signed-off-by: Jiwoo Lee <dlwldn30@naver.com>
…file GET Fixes: spring-projects#11277 `generateLocalDirectory()` used a non-atomic `exists()`/`mkdirs()` sequence. With a `local-directory-expression` the eager `setupLocalDirectory()` in `afterPropertiesSet()` is skipped, since it only runs for a `ValueExpression`, so the directory is created per message on the calling thread. Concurrent messages resolving to the same, not-yet-existing local directory raced on `File.mkdirs()`: the losing threads received `false` and failed with `IllegalArgumentException: Failed to make local directory: [...]` although the directory existed at that point. * Use the idempotent and concurrency-safe `Files.createDirectories()` instead, retaining the exception type and message and propagating a genuine creation failure as cause This mirrors the fix applied to `FileWritingMessageHandler` in spring-projectsGH-11254. Attaching the `IOException` as cause moves the root cause of the reported `IllegalArgumentException` from itself to the `FileSystemException` raised by the failed creation. `FtpServerOutboundTests` and `SftpServerOutboundTests` pinned the old position via `hasRootCauseInstanceOf(IllegalArgumentException)`, which held only because the previous `Assert.isTrue()` left the chain empty. * Assert the `IllegalArgumentException` and its message where they now occur in the chain, and keep a root cause type assertion for the `FileSystemException` that the failed creation contributes Signed-off-by: Jiwoo Lee <dlwldn30@naver.com>
Fixes GH-11253
FileWritingMessageHandler.validateDestinationDirectory()used a non-atomicexists()/mkdirs()sequence. With adirectory-expression, this code runs permessage on the calling thread, so concurrent messages resolving to the same,
not-yet-existing destination directory raced on
File.mkdirs(): the losing threadsreceived
false(the directory had been created concurrently in the meantime) andfailed with
IllegalArgumentException: Destination directory [...] could not be created.although the directory existed at that point.
Use the idempotent and concurrency-safe
Files.createDirectories()instead. Theexception type and message are retained for backward compatibility; a genuine creation
failure now additionally carries the underlying
IOExceptionas cause.Verified with the reproducer from GH-11253: fails within the first rounds before the
change, passes 3 x 500 rounds x 8 threads after it.