GH-11277: Fix local directory creation race in remote file GET - #11278
GH-11277: Fix local directory creation race in remote file GET#11278dlwldn30 wants to merge 2 commits into
Conversation
cppwfs
left a comment
There was a problem hiding this comment.
Thank you for your contribution!
When building the full project we see that there are test failures in FtpServerOutboundTests and SftpServerOutboundTests.
|
Reproduced both failures locally - Both assert the root cause. Attaching the Fixed by leaving the exception untouched and making the if (!localDir.exists()) {
Assert.isTrue(localDir.mkdirs() || localDir.exists(),
() -> "Failed to make local directory: " + localDir);
}This is the idiom already used by Verification:
One point for you to weigh rather than me: The PR description is updated accordingly. |
|
I think moving to the |
|
Thanks - switched to One thing worth flagging, since it changed what the adjustment had to look like. The The old assertion held only because .hasStackTraceContaining("java.lang.IllegalArgumentException: Failed to make local directory");These pass against the unfixed gateway too, so they are not written around this change. I checked by restoring only the production file to the base commit and re-running both classes - green. Verification now covers every module that builds on this class, which is what I should have done the first time:
The PR description is updated accordingly. |
cppwfs
left a comment
There was a problem hiding this comment.
Thank you again for the contribution!
|
@cppwfs friendly ping on this one. Both tests now assert The build is still waiting on workflow approval, so for reference, locally:
Happy to adjust anything else. |
|
|
||
| @Test | ||
| public void testGetConcurrentCreateSameLocalDirectory(@TempDir File localRoot) throws Exception { | ||
| SessionFactory sessionFactory = mock(SessionFactory.class); |
There was a problem hiding this comment.
Use mock() without arguments.
| try { | ||
| gw.handleRequestMessage(message); | ||
| } | ||
| catch (Throwable ex) { // NOSONAR - the race surfaces as an unchecked exception |
There was a problem hiding this comment.
We don't work with SonarQube any more.
Therefore, we don't need all of these extra noisy comment.
|
Again thank you for the contribution. Also rebase your PR and that will resolve the build failure. |
…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>
Signed-off-by: Jiwoo Lee <dlwldn30@naver.com>
artembilan
left a comment
There was a problem hiding this comment.
Just one question.
Thank you!
| ExecutorService executorService = Executors.newFixedThreadPool(concurrency); | ||
| AtomicReference<Throwable> failure = new AtomicReference<>(); | ||
| try { | ||
| for (int round = 0; round < 50; round++) { |
There was a problem hiding this comment.
How slow is this test?
I mean you made the point with this verification that atomic Java native Files.createDirectories() feature works well.
But is it really our project responsibility to test that method behavior?
I feel like we don't need it at all since it is out of scope.
Fixes: #11277
The defect
AbstractRemoteFileOutboundGateway.generateLocalDirectory()creates the local directory with a non-atomicexists()/mkdirs()sequence, per message, on the calling thread:The eager creation in
afterPropertiesSet()does not cover this, because it is restricted to aValueExpression:So with a
local-directory-expressionthe directory is never pre-created and creation relies entirely on the per-message path. Concurrent messages resolving to the same, not-yet-existing directory all pass!localDir.exists(), only onemkdirs()wins, and the losing threads getfalseand fail:The directory exists at that point and the transfer could have proceeded.
The code is identical in
v6.0.0,v6.3.0,v7.0.0andmain.The change
Use
Files.createDirectories(), which is idempotent and does not report an already-existing directory as a failure, and propagate a genuine creation failure as the cause:This mirrors
FileWritingMessageHandler, which has used the same call for the same class of problem since #11254.The two adjusted tests
Attaching the
IOExceptionas cause moves the root cause of the reportedIllegalArgumentExceptionfrom itself to aFileSystemException:FtpServerOutboundTests.testInt2866InvalidLocalDirectoryExpressionand itsSftpServerOutboundTestscounterpart pinned that position withhasRootCauseInstanceOf(IllegalArgumentException.class). That held only becauseAssert.isTrue()left the chain empty, so the exception happened to be its own root - it was not an intentional assertion about the root.They now assert the same type and message where they occur in the chain rather than at its root:
hasCauseInstanceOf()would not work either - theIllegalArgumentExceptionsits two levels down, under theMessagingExceptionfrom the session template.These assertions pass against the unfixed gateway as well, so they assert the contract rather than encoding this change. Verified by restoring only the production file to the base commit and re-running both classes - green.
Tests
RemoteFileOutboundGatewayTests.testGetConcurrentCreateSameLocalDirectory- 8 threads, 50 rounds, a fresh local directory per round, one distinct remote file per thread so only the destination directory is shared. It takes an isolated@TempDirparameter rather than the shared static one, so it cannot perturbtestMputRecursive.The previous revision was verified only against
:spring-integration-file:check, which is how the-ftpand-sftpfailures were missed. All three modules that build on this class are now in scope::spring-integration-file:check:spring-integration-ftp:test:spring-integration-sftp:testcheckstyleMainandcheckstyleTestpass for all three.Revert check: restoring only the production file with
git checkout <base> -- <file>(notgit stash, which would have been a no-op after the commit) makes exactly the new test fail - 36 tests completed, 1 failed.Scope
setupLocalDirectory()has the same shape (exists()thenmkdirs(), at line 612) but runs once fromafterPropertiesSet()on a single thread, so it cannot race. I left it alone to keep the diff minimal - happy to align it for consistency if you prefer.AbstractInboundFileSynchronizingMessageSourceandFileReadingMessageSourcecarry the same pattern. The former I have not traced to a concurrent caller; the latter is guarded byrunning.getAndSet(true)instart(). Both look safe today, but they are the same class of code and might be worth a sweep separately.No open PR touches
AbstractRemoteFileOutboundGateway.java.Contributed on behalf of Goatshave.