This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 6a2cad3f49 TIKA-4848: FileSystemEmitter writes to a tmp file and 
renames atomica… (#3085)
6a2cad3f49 is described below

commit 6a2cad3f4958add3db4b0f52136e8c6400f51bbf
Author: Tim Allison <[email protected]>
AuthorDate: Thu Aug 27 17:19:12 2026 -0400

    TIKA-4848: FileSystemEmitter writes to a tmp file and renames atomica… 
(#3085)
---
 CHANGES.txt                                        |   5 +
 .../ROOT/pages/pipes/plugins/filesystem.adoc       |   4 +
 .../tika/pipes/emitter/fs/FileSystemEmitter.java   |  96 ++++++++++++---
 .../pipes/emitter/fs/FileSystemEmitterConfig.java  |   8 +-
 .../pipes/emitter/fs/FileSystemEmitterTest.java    | 134 ++++++++++++++++++++-
 5 files changed, 228 insertions(+), 19 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 42be53d9c8..19ee2c9cc9 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,10 @@
 Release 4.1.0 - unreleased
 
+   * FileSystemEmitter writes to a sibling ".tmp" file and renames it into
+     place, so readers of the output directory never see a partially written
+     file. Set "atomicWrites": false on the emitter to restore in-place
+     writes (TIKA-4848).
+
    * tika-eval: Profile/Compare accept the batch run's jsonl crash ledger
      (--pipesReport, -pa/-pb) and a run-info json (--runInfo, -ra/-rb), and
      read both from <extracts>/.run-info/ by default (refusing an ambiguous
diff --git a/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc
index 266186421f..f25773c311 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/filesystem.adoc
@@ -143,6 +143,10 @@ Writes parsed results as files under `basePath`. The 
relative output path is der
 |`prettyPrint`
 |`false`
 |Pretty-print JSON output. Has no effect in `CONTENT_ONLY` mode (raw bytes are 
written).
+
+|`atomicWrites`
+|`true`
+|Write each output to a sibling `<name>.<uuid>.tmp` and rename it into place, 
so a reader of the output directory never sees a partial file. Set to `false` 
to write in place (one fewer rename per file; needed on filesystems where 
rename is slow or not atomic). Init-time only.
 |===
 
 [#file-system-iterator]
diff --git 
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
 
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
index 3e0f8d1a9b..5e12739827 100644
--- 
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
+++ 
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitter.java
@@ -27,6 +27,7 @@ import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
 import java.nio.file.StandardOpenOption;
 import java.util.List;
+import java.util.UUID;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -53,6 +54,9 @@ import org.apache.tika.utils.StringUtils;
  */
 public class FileSystemEmitter extends AbstractStreamEmitter {
 
+    // in-progress writes; crawlers of the output dir should ignore these
+    static final String TMP_SUFFIX = ".tmp";
+
     private static final Logger LOG = 
LoggerFactory.getLogger(FileSystemEmitter.class);
 
     public static FileSystemEmitter build(ExtensionConfig pluginConfig) throws 
TikaConfigException, IOException {
@@ -127,9 +131,28 @@ public class FileSystemEmitter extends 
AbstractStreamEmitter {
             }
         }
 
+        if (!config.atomicWrites()) {
+            writeInPlace(metadataList, output, config);
+            return;
+        }
+        Path tmp = tmpFor(output);
+        try {
+            try (Writer writer = Files.newBufferedWriter(tmp, 
StandardCharsets.UTF_8,
+                    StandardOpenOption.CREATE_NEW)) {
+                JsonMetadataList.toJson(metadataList, writer, 
config.prettyPrint());
+            }
+            publish(tmp, output, config.onExists());
+        } finally {
+            Files.deleteIfExists(tmp);
+        }
+    }
+
+    // atomicWrites=false: the pre-TIKA-4848 behavior; readers can observe a 
partial file
+    private static void writeInPlace(List<Metadata> metadataList, Path output,
+                                     FileSystemEmitterConfig config) throws 
IOException {
         if (config.onExists() == FileSystemEmitterConfig.ON_EXISTS.EXCEPTION) {
             try (Writer writer = Files.newBufferedWriter(output, 
StandardCharsets.UTF_8,
-                    StandardOpenOption.CREATE_NEW)) { //CREATE_NEW forces an 
IOException if the file already exists
+                    StandardOpenOption.CREATE_NEW)) {
                 JsonMetadataList.toJson(metadataList, writer, 
config.prettyPrint());
             } catch (FileAlreadyExistsException e) {
                 throw alreadyExistsException(output);
@@ -141,6 +164,35 @@ public class FileSystemEmitter extends 
AbstractStreamEmitter {
         }
     }
 
+    private static Path tmpFor(Path output) {
+        // sibling so the rename stays on one filesystem (and therefore atomic)
+        return output.resolveSibling(output.getFileName() + "." + 
UUID.randomUUID() + TMP_SUFFIX);
+    }
+
+    /**
+     * Moves the fully written {@code tmp} onto {@code output} with a single 
rename, so a
+     * concurrent reader never sees a partial file. Ownership of {@code tmp} 
passes to this
+     * method: it is gone on return, whether moved or discarded.
+     */
+    private static void publish(Path tmp, Path output, 
FileSystemEmitterConfig.ON_EXISTS onExists)
+            throws IOException {
+        if (onExists == FileSystemEmitterConfig.ON_EXISTS.REPLACE) {
+            Files.move(tmp, output, StandardCopyOption.REPLACE_EXISTING,
+                    StandardCopyOption.ATOMIC_MOVE);
+            return;
+        }
+        // no REPLACE_EXISTING: Files.move refuses an existing target rather 
than clobbering it
+        try {
+            Files.move(tmp, output);
+        } catch (FileAlreadyExistsException e) {
+            Files.deleteIfExists(tmp);
+            if (onExists == FileSystemEmitterConfig.ON_EXISTS.EXCEPTION) {
+                throw alreadyExistsException(output);
+            }
+            LOG.debug("Skipping existing file: {}", output);
+        }
+    }
+
     @Override
     public void emit(String emitKey, InputStream inputStream, Metadata 
userMetadata, ParseContext parseContext) throws IOException {
 
@@ -174,22 +226,35 @@ public class FileSystemEmitter extends 
AbstractStreamEmitter {
         if (!Files.isDirectory(output.getParent())) {
             Files.createDirectories(output.getParent());
         }
-        if (config.onExists() == FileSystemEmitterConfig.ON_EXISTS.REPLACE) {
+        if (config.onExists() == FileSystemEmitterConfig.ON_EXISTS.SKIP && 
Files.exists(output)) {
+            LOG.debug("Skipping existing file: {}", output);
+            return;
+        }
+        if (!config.atomicWrites()) {
+            copyInPlace(inputStream, output, config.onExists());
+            return;
+        }
+        Path tmp = tmpFor(output);
+        try {
+            Files.copy(inputStream, tmp);
+            publish(tmp, output, config.onExists());
+        } finally {
+            Files.deleteIfExists(tmp);
+        }
+    }
+
+    private static void copyInPlace(InputStream inputStream, Path output,
+                                    FileSystemEmitterConfig.ON_EXISTS 
onExists) throws IOException {
+        if (onExists == FileSystemEmitterConfig.ON_EXISTS.REPLACE) {
             Files.copy(inputStream, output, 
StandardCopyOption.REPLACE_EXISTING);
-        } else if (config.onExists() == 
FileSystemEmitterConfig.ON_EXISTS.EXCEPTION) {
-            try {
-                Files.copy(inputStream, output);
-            } catch (FileAlreadyExistsException e) {
+            return;
+        }
+        try {
+            Files.copy(inputStream, output);
+        } catch (FileAlreadyExistsException e) {
+            if (onExists == FileSystemEmitterConfig.ON_EXISTS.EXCEPTION) {
                 throw alreadyExistsException(output);
             }
-        } else if (config.onExists() == 
FileSystemEmitterConfig.ON_EXISTS.SKIP) {
-            if (!Files.isRegularFile(output)) {
-                try {
-                    Files.copy(inputStream, output);
-                } catch (FileAlreadyExistsException e) {
-                    //swallow
-                }
-            }
         }
     }
 
@@ -220,7 +285,8 @@ public class FileSystemEmitter extends 
AbstractStreamEmitter {
                 // Merge runtime config into default config while preserving 
basePath and the
                 // init-time allowAbsolutePaths -- neither may be changed at 
runtime.
                 config = new 
FileSystemEmitterConfig(fileSystemEmitterConfig.basePath(), 
runtimeConfig.getFileExtension(), runtimeConfig.getOnExists(),
-                        runtimeConfig.isPrettyPrint(), 
fileSystemEmitterConfig.allowAbsolutePaths());
+                        runtimeConfig.isPrettyPrint(), 
fileSystemEmitterConfig.allowAbsolutePaths(),
+                        fileSystemEmitterConfig.atomicWrites());
                 checkConfig(config);
             }
         }
diff --git 
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterConfig.java
 
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterConfig.java
index 7fc17e2d84..5365c0e62c 100644
--- 
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterConfig.java
+++ 
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/main/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterConfig.java
@@ -19,17 +19,21 @@ package org.apache.tika.pipes.emitter.fs;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.plugins.PluginJson;
 
-public record FileSystemEmitterConfig(String basePath, String fileExtension, 
ON_EXISTS onExists, boolean prettyPrint, boolean allowAbsolutePaths) {
+public record FileSystemEmitterConfig(String basePath, String fileExtension, 
ON_EXISTS onExists, boolean prettyPrint, boolean allowAbsolutePaths,
+        Boolean atomicWrites) {
 
     enum ON_EXISTS {
         SKIP, EXCEPTION, REPLACE
     }
 
-    /** onExists is optional; absent means EXCEPTION, the documented default. 
*/
+    /** onExists absent means EXCEPTION, atomicWrites absent means true -- the 
documented defaults. */
     public FileSystemEmitterConfig {
         if (onExists == null) {
             onExists = ON_EXISTS.EXCEPTION;
         }
+        if (atomicWrites == null) {
+            atomicWrites = Boolean.TRUE;
+        }
     }
 
     public static FileSystemEmitterConfig load(final String json)
diff --git 
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterTest.java
 
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterTest.java
index 7695a193a9..21500b40ce 100644
--- 
a/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterTest.java
+++ 
b/tika-pipes/tika-pipes-plugins/tika-pipes-file-system/src/test/java/org/apache/tika/pipes/emitter/fs/FileSystemEmitterTest.java
@@ -16,13 +16,18 @@
  */
 package org.apache.tika.pipes.emitter.fs;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.stream.Stream;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -33,6 +38,7 @@ import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.pipes.api.emitter.Emitter;
+import org.apache.tika.pipes.api.emitter.StreamEmitter;
 import org.apache.tika.plugins.ExtensionConfig;
 
 public class FileSystemEmitterTest {
@@ -44,6 +50,16 @@ public class FileSystemEmitterTest {
 
     private Emitter createEmitter(Path basePath, Boolean allowAbsolutePaths)
             throws TikaConfigException, IOException {
+        return createEmitter(basePath, allowAbsolutePaths, "REPLACE");
+    }
+
+    private StreamEmitter createEmitter(Path basePath, Boolean 
allowAbsolutePaths, String onExists)
+            throws TikaConfigException, IOException {
+        return createEmitter(basePath, allowAbsolutePaths, onExists, null);
+    }
+
+    private StreamEmitter createEmitter(Path basePath, Boolean 
allowAbsolutePaths, String onExists,
+                                        Boolean atomicWrites) throws 
TikaConfigException, IOException {
         ObjectNode config = MAPPER.createObjectNode();
         if (basePath != null) {
             config.put("basePath", basePath.toAbsolutePath().toString());
@@ -51,9 +67,12 @@ public class FileSystemEmitterTest {
         if (allowAbsolutePaths != null) {
             config.put("allowAbsolutePaths", allowAbsolutePaths);
         }
-        config.put("onExists", "REPLACE");
+        config.put("onExists", onExists);
+        if (atomicWrites != null) {
+            config.put("atomicWrites", atomicWrites);
+        }
         ExtensionConfig pluginConfig = new ExtensionConfig("test", "test", 
config.toString());
-        return new FileSystemEmitterFactory().buildExtension(pluginConfig);
+        return (StreamEmitter) new 
FileSystemEmitterFactory().buildExtension(pluginConfig);
     }
 
     @Test
@@ -82,4 +101,115 @@ public class FileSystemEmitterTest {
         assertThrows(IOException.class, () -> emitter.emit(
                 "../escaped.json", List.of(new Metadata()), new 
ParseContext()));
     }
+
+    private Path seed(Path basePath, String name, String content) throws 
IOException {
+        Files.createDirectories(basePath);
+        Path existing = basePath.resolve(name);
+        Files.writeString(existing, content);
+        return existing;
+    }
+
+    private static long tmpFiles(Path dir) throws IOException {
+        try (Stream<Path> s = Files.list(dir)) {
+            return s.filter(p -> 
p.getFileName().toString().endsWith(FileSystemEmitter.TMP_SUFFIX))
+                    .count();
+        }
+    }
+
+    @Test
+    public void testOnExistsExceptionLeavesOriginalIntact() throws Exception {
+        Path basePath = tempDir.resolve("base");
+        Path existing = seed(basePath, "a.json", "original");
+        StreamEmitter emitter = createEmitter(basePath, null, "EXCEPTION");
+        assertThrows(IOException.class, () ->
+                emitter.emit("a.json", List.of(new Metadata()), new 
ParseContext()));
+        assertThrows(IOException.class, () -> emitter.emit("a.json",
+                new 
ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8)), new Metadata(),
+                new ParseContext()));
+        assertEquals("original", Files.readString(existing));
+        assertEquals(0, tmpFiles(basePath), "tmp file leaked");
+    }
+
+    @Test
+    public void testOnExistsSkipLeavesOriginalIntact() throws Exception {
+        Path basePath = tempDir.resolve("base");
+        Path existing = seed(basePath, "a.json", "original");
+        StreamEmitter emitter = createEmitter(basePath, null, "SKIP");
+        emitter.emit("a.json", List.of(new Metadata()), new ParseContext());
+        emitter.emit("a.json", new 
ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8)),
+                new Metadata(), new ParseContext());
+        assertEquals("original", Files.readString(existing));
+        assertEquals(0, tmpFiles(basePath), "tmp file leaked");
+    }
+
+    @Test
+    public void testOnExistsReplaceOverwrites() throws Exception {
+        Path basePath = tempDir.resolve("base");
+        Path existing = seed(basePath, "a.json", "original");
+        StreamEmitter emitter = createEmitter(basePath, null, "REPLACE");
+        emitter.emit("a.json", List.of(new Metadata()), new ParseContext());
+        assertFalse(Files.readString(existing).equals("original"));
+        emitter.emit("a.json", new 
ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8)),
+                new Metadata(), new ParseContext());
+        assertEquals("x", Files.readString(existing));
+        assertEquals(0, tmpFiles(basePath), "tmp file leaked");
+    }
+
+    @Test
+    public void testReaderNeverSeesPartialFile() throws Exception {
+        // Regression for the AsyncResourceTest flake: a poller that reads as 
soon as the
+        // output exists must get the whole file, never an empty one mid-write.
+        Path basePath = tempDir.resolve("base");
+        Files.createDirectories(basePath);
+        StreamEmitter emitter = createEmitter(basePath, null, "REPLACE");
+        Path out = basePath.resolve("big.json");
+        Metadata m = new Metadata();
+        m.set("x", "y".repeat(1 << 20));
+        Thread writer = new Thread(() -> {
+            try {
+                for (int i = 0; i < 20; i++) {
+                    emitter.emit("big.json", List.of(m), new ParseContext());
+                    Files.delete(out);
+                }
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        });
+        writer.start();
+        long minSeen = Long.MAX_VALUE;
+        while (writer.isAlive()) {
+            try {
+                minSeen = Math.min(minSeen, Files.size(out));
+            } catch (IOException e) {
+                //between delete and next publish
+            }
+        }
+        writer.join();
+        assertTrue(minSeen == Long.MAX_VALUE || minSeen > 1 << 20,
+                "observed partial file of size " + minSeen);
+    }
+
+    @Test
+    public void testAtomicWritesOff() throws Exception {
+        Path basePath = tempDir.resolve("base");
+        Path existing = seed(basePath, "a.json", "original");
+        StreamEmitter exc = createEmitter(basePath, null, "EXCEPTION", false);
+        assertThrows(IOException.class, () ->
+                exc.emit("a.json", List.of(new Metadata()), new 
ParseContext()));
+        assertThrows(IOException.class, () -> exc.emit("a.json",
+                new 
ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8)), new Metadata(),
+                new ParseContext()));
+        StreamEmitter skip = createEmitter(basePath, null, "SKIP", false);
+        skip.emit("a.json", List.of(new Metadata()), new ParseContext());
+        skip.emit("a.json", new 
ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8)),
+                new Metadata(), new ParseContext());
+        assertEquals("original", Files.readString(existing));
+        StreamEmitter replace = createEmitter(basePath, null, "REPLACE", 
false);
+        replace.emit("a.json", new 
ByteArrayInputStream("x".getBytes(StandardCharsets.UTF_8)),
+                new Metadata(), new ParseContext());
+        assertEquals("x", Files.readString(existing));
+        replace.emit("b.json", List.of(new Metadata()), new ParseContext());
+        assertTrue(Files.isRegularFile(basePath.resolve("b.json")));
+        assertEquals(0, tmpFiles(basePath));
+    }
 }

Reply via email to