New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FIX JENKINS-34855] Create a FileChannelWriter and use it for AtomicFileWriter "enforced" flushing #3170
[FIX JENKINS-34855] Create a FileChannelWriter and use it for AtomicFileWriter "enforced" flushing #3170
Changes from 4 commits
22efbad
9fb07a2
1191bb0
f555cb6
3a5189e
bd2cb36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
package hudson.util; | ||
|
||
import org.kohsuke.accmod.Restricted; | ||
import org.kohsuke.accmod.restrictions.NoExternalUse; | ||
|
||
import java.io.IOException; | ||
import java.io.Writer; | ||
import java.nio.ByteBuffer; | ||
import java.nio.CharBuffer; | ||
import java.nio.channels.FileChannel; | ||
import java.nio.charset.Charset; | ||
import java.nio.file.OpenOption; | ||
import java.nio.file.Path; | ||
import java.util.logging.Logger; | ||
|
||
/** | ||
* This class has been created to help make {@link AtomicFileWriter} hopefully more reliable in some corner cases. | ||
* We created this wrapper to be able to access {@link FileChannel#force(boolean)} which seems to be one of the rare | ||
* ways to actually have a guarantee that data be flushed to the physical device (only guaranteed for local, not for | ||
* remote obviously though). | ||
* | ||
* <p>The goal using this is to reduce as much as we can the likeliness to see zero-length files be created in place | ||
* of the original ones.</p> | ||
* | ||
* @see <a href="https://issues.jenkins-ci.org/browse/JENKINS-34855">JENKINS-34855</a> | ||
* @see <a href="https://github.com/jenkinsci/jenkins/pull/2548">PR-2548</a> | ||
*/ | ||
@Restricted(NoExternalUse.class) | ||
public class FileChannelWriter extends Writer { | ||
|
||
private static final Logger LOGGER = Logger.getLogger(FileChannelWriter.class.getName()); | ||
|
||
private final Charset charset; | ||
private final FileChannel channel; | ||
|
||
/** | ||
* {@link FileChannel#force(boolean)} is a <strong>very</strong> costly operation. This flag has been introduced mostly to | ||
* accommodate Jenkins' previous behaviour, when using a simple {@link java.io.BufferedWriter}. | ||
* | ||
* <p>Basically, {@link BufferedWriter#flush()} does nothing, so when existing code was rewired to use | ||
* {@link FileChannelWriter#flush()} behind {@link AtomicFileWriter} and that method actually ends up calling | ||
* {@link FileChannel#force(boolean)}, many things started timing out. The main reason is probably because XStream's | ||
* {@link com.thoughtworks.xstream.core.util.QuickWriter} uses <code>flush()</code> a lot. | ||
* So we introduced this field to be able to still get a better integrity for the use case of {@link AtomicFileWriter}. | ||
* Because from there, we make sure to call {@link #close()} from {@link AtomicFileWriter#commit()} anyway. | ||
*/ | ||
private boolean forceOnFlush; | ||
|
||
/** | ||
* @param filePath the path of the file to write to. | ||
* @param charset the charset to use when writing characters. | ||
* @param forceOnFlush set to true if you want {@link FileChannel#force(boolean)} to be called on {@link #flush()}. | ||
* @param options the options for opening the file. | ||
* @throws IOException if something went wrong. | ||
*/ | ||
FileChannelWriter(Path filePath, Charset charset, boolean forceOnFlush, OpenOption... options) throws IOException { | ||
this.charset = charset; | ||
this.forceOnFlush = forceOnFlush; | ||
channel = FileChannel.open(filePath, options); | ||
} | ||
|
||
@Override | ||
public void write(char cbuf[], int off, int len) throws IOException { | ||
final CharBuffer charBuffer = CharBuffer.wrap(cbuf, off, len); | ||
ByteBuffer byteBuffer = charset.encode(charBuffer); | ||
channel.write(byteBuffer); | ||
} | ||
|
||
@Override | ||
public void flush() throws IOException { | ||
if (forceOnFlush) { | ||
LOGGER.finest("Flush is forced"); | ||
channel.force(true); | ||
} else { | ||
LOGGER.finest("Force disabled on flush(), no-op"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we not do some flush here? Otherwise turning the flag off would be result in behaviour more broken that it was before. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @olivergondza good question. TL;DR:
So, flushing does not make as much sense here, or at least not the same meaning as when one calls
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see. So, if I understand correctly, the data should be send to disk more eagerly than before (avoiding Java level caching) even when turned off. Not that I would object against the change, I am merely pointing out there is no way for users to switch to the old behaviour we know was working fine for ages in case this would not work as expected. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
By default from |
||
} | ||
} | ||
|
||
@Override | ||
public void close() throws IOException { | ||
if(channel.isOpen()) { | ||
channel.force(true); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for the case where the file is being thrown away (abort is called rather than commit) we could get away with not calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will do as a followup. |
||
channel.close(); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
package hudson.util; | ||
|
||
import org.apache.commons.io.FileUtils; | ||
import org.junit.Before; | ||
import org.junit.Rule; | ||
import org.junit.Test; | ||
import org.junit.rules.TemporaryFolder; | ||
|
||
import java.io.File; | ||
import java.io.IOException; | ||
import java.nio.channels.ClosedChannelException; | ||
import java.nio.charset.StandardCharsets; | ||
import java.nio.file.StandardOpenOption; | ||
|
||
import static org.hamcrest.CoreMatchers.not; | ||
import static org.hamcrest.core.IsEqual.equalTo; | ||
import static org.junit.Assert.assertThat; | ||
import static org.junit.Assert.fail; | ||
|
||
public class FileChannelWriterTest { | ||
@Rule | ||
public TemporaryFolder temporaryFolder = new TemporaryFolder(); | ||
|
||
File file; | ||
FileChannelWriter writer; | ||
|
||
@Before | ||
public void setUp() throws Exception { | ||
file = temporaryFolder.newFile(); | ||
writer = new FileChannelWriter(file.toPath(), StandardCharsets.UTF_8, true, StandardOpenOption.WRITE); | ||
} | ||
|
||
@Test | ||
public void write() throws Exception { | ||
writer.write("helloooo"); | ||
writer.close(); | ||
|
||
assertContent("helloooo"); | ||
} | ||
|
||
|
||
@Test | ||
public void flush() throws Exception { | ||
writer.write("hello é è à".toCharArray()); | ||
|
||
writer.flush(); | ||
assertContent("hello é è à"); | ||
} | ||
|
||
@Test(expected = ClosedChannelException.class) | ||
public void close() throws Exception { | ||
writer.write("helloooo"); | ||
writer.close(); | ||
|
||
writer.write("helloooo"); | ||
fail("Should have failed the line above"); | ||
} | ||
|
||
|
||
private void assertContent(String string) throws IOException { | ||
assertThat(FileUtils.readFileToString(file, StandardCharsets.UTF_8), equalTo(string)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it helpful to just write to an in-memory buffer? These files are typically smallish, after all, so we do not care about heap usage much, and then we do not care at all who is
flush
ing when. If and whencommit
is called, write the entire buffer to the temp file (if you are lucky this will be a single block), make sure it is synched, then do the atomic rename.I also think we should add a flag to
XmlFile
allowing callers to opt out of expensive consistency guarantees, with the default being strict mode.Run.getDataFile
andSimpleXStreamFlowNodeStorage.getNodeFile
could then use the lax mode, accepting the risk of corrupted data in exchange for better performance. You can even guide callers to make this decision explicitly by (say) deprecating existingXmlFile
constructors in favor of ones taking aboolean
flag.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sounds a little like an over eager optimisation to me.
When writing bytes to a file (as we do here without the
SYNC
/DSYNC
flag) the OS will generally buffer them (almost all OSes have a disk cache buffer) , and they are not written directly to disk.The only sync call is the one in commit in the case that
false
is passed (which it is) for AtomicFW you would always want false (as until commit is called it can be thrown away), for any direct uses of the channel writer you may want to know writes have been flushed to disk before returning.