Skip to content
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

Merged
merged 6 commits into from Jan 12, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion core/src/main/java/hudson/util/AtomicFileWriter.java
Expand Up @@ -35,6 +35,7 @@
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.logging.Level;
import java.util.logging.Logger;

Expand Down Expand Up @@ -116,7 +117,7 @@ public AtomicFileWriter(@Nonnull Path destinationPath, @Nonnull Charset charset)
throw new IOException("Failed to create a temporary file in "+ dir,e);
}

core = Files.newBufferedWriter(tmpPath, charset);
core = new FileChannelWriter(tmpPath, charset, false, StandardOpenOption.WRITE);
Copy link
Member

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 flushing when. If and when commit 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 and SimpleXStreamFlowNodeStorage.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 existing XmlFile constructors in favor of ones taking a boolean flag.

Copy link
Member

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 flushing when. If and when commit 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.

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.

}

@Override
Expand Down
86 changes: 86 additions & 0 deletions core/src/main/java/hudson/util/FileChannelWriter.java
@@ -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");
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@olivergondza good question.

TL;DR: FileChannel.write() already achieves the equivalent of BufferedWriter.write() + BufferedWriter.flush().

FileChannel, contrary to the previous Files.newBufferedWriter has no Java-level buffering. So FileChannel.write() writes to disk, well actually sends data to the OS/disk cache depending on the system config.

So, flushing does not make as much sense here, or at least not the same meaning as when one calls BufferedWriter.flush(). And BTW, even BufferedWriter.flush() does not actually force a full flush to disk, it's more like a cache from JVM cache/RAM to the next level, no guaranty things end up on the disk. That is the whole point of this change.

FileChannel.force() actually guarantees everything is flushed to the disk. BufferedWriter.write() + BufferedWriter.flush() does not.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

@jtnord jtnord Jan 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switch to the old behaviour we know was working fine for ages in case this would not work as expected.

forceOnFlush defaults to false from AtomicFileWriter - so we are not pushing to disk on a flush thus it should behave very similar (the data has gone from Java to the OS) - although possibly via different underlying native calls.

By default from AtomicFileWriter we are only making sure the data is on disk when the Writer is close()d and if you want to simulate the old broken behaviour you can: see here and here

}
}

@Override
public void close() throws IOException {
if(channel.isOpen()) {
channel.force(true);
Copy link
Member

Choose a reason for hiding this comment

The 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 force. (that is the ATF.commit could call a force method which does a flush, force, and then subsequently call close)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do as a followup.

channel.close();
}
}
}
63 changes: 63 additions & 0 deletions core/src/test/java/hudson/util/FileChannelWriterTest.java
@@ -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));
}
}
Expand Up @@ -24,7 +24,7 @@ public class AtomicFileWriterPerfTest {
* <strong>really</strong> bad performance regressions.
*/
@Issue("JENKINS-34855")
@Test(timeout = 30 * 1000L)
@Test(timeout = 50 * 1000L)
public void poorManPerformanceTestBed() throws Exception {
int count = 1000;
while (count-- > 0) {
Expand Down