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

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 6f60984f4e [ZEPPELIN-6394] Make the interpreter setting write safe 
under concurrent saves
6f60984f4e is described below

commit 6f60984f4e74344de200bf36715cc533b56c4f7f
Author: 김동환 <[email protected]>
AuthorDate: Sat Sep 26 00:53:17 2026 +0900

    [ZEPPELIN-6394] Make the interpreter setting write safe under concurrent 
saves
    
    ### What is this PR for?
    
    When several interpreters finish downloading their dependencies at the same 
time, each of them saves the interpreter settings from its own thread, so 
`conf/interpreter.json` is written concurrently. The reporter's log shows those 
writes failing:
    
    ```
    ERROR ({Thread-16}) InterpreterSetting.java[run]:1003 - Fail to save 
interpreter.json
    java.nio.file.NoSuchFileException: 
/data/soft/zeppelin-0.11.2/conf/interpreter.json
        at java.nio.file.Files.move(Files.java:1395)
        at 
org.apache.zeppelin.util.FileUtils.atomicWriteToFile(FileUtils.java:60)
        at 
org.apache.zeppelin.storage.LocalConfigStorage.save(LocalConfigStorage.java:54)
        at 
org.apache.zeppelin.interpreter.InterpreterSettingManager.saveToFile(InterpreterSettingManager.java:350)
        at 
org.apache.zeppelin.interpreter.InterpreterSetting$1.run(InterpreterSetting.java:1001)
    ```
    
    There are two problems behind it.
    
    **1. The write itself is not atomic.** `FileUtils.atomicWriteToFile` writes 
a temp file and then moves it over the destination with `REPLACE_EXISTING` 
alone. That replace is not a single operation, so when two writers move their 
own temp file onto the same destination, one of them can fail with 
`NoSuchFileException` and leave its temp file behind in the config directory. 
The failure is swallowed as an `ERROR` log, so the settings silently do not 
reach disk.
    
    Measured on the current code with 16 threads writing the same file 300 
times each:
    
    | move options | failed writes (of 4800) | leftover temp files |
    | --- | --- | --- |
    | `REPLACE_EXISTING` | 9 – 24, reproduced on every run | same count |
    | `REPLACE_EXISTING, ATOMIC_MOVE` | 0 | 0 |
    
    This PR requests `ATOMIC_MOVE` as well. The temp file is created in the 
destination directory (`Files.createTempFile(destinationDirectory, ...)`), so 
the move never crosses file systems and the atomic move is always supported. 
The option was present as a trailing comment from the first version of this 
method in ZEPPELIN-4907 and was never enabled; there is no recorded reason for 
leaving it off.
    
    **2. The snapshot and the write are two steps.** 
`InterpreterSettingManager.saveToFile` copies the settings and then writes 
them, without holding anything in between, so an older snapshot can be written 
after a newer one. When that happens, a setting that was added in between is 
dropped from the file while it survives in memory, and the loss only shows up 
after the next restart. This is the serialization the Jira asks for; it is done 
with a private lock rather than `synchronized` on t [...]
    
    Note for the Jira description: on the default `LocalConfigStorage` the 
concurrent writes do not corrupt the file, because every write goes to its own 
temp file. What they do is fail, and lose an update.
    
    **Relation to #5144.** The `saveToFile()` serialization in this PR overlaps 
#5144, which was opened by the reporter and already carries an LGTM that a 
later rebase dismissed. That PR does not cover the move itself, which is what 
produces the `NoSuchFileException` in the report. If it is preferred to land 
#5144 for the serialization, I am happy to drop the `InterpreterSettingManager` 
change from here and leave only the `FileUtils` fix.
    
    ### What type of PR is it?
    Bug Fix
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6394
    
    ### How should this be tested?
    Two new tests, both verified to fail without the corresponding change:
    
    * `FileUtilsTest.testConcurrentWritesToTheSameFileSucceed` — 16 threads 
write the same file 300 times each and no write may fail or leave a temp file 
behind. Fails on master on every run with the `NoSuchFileException` above.
    * 
`InterpreterSettingManagerSaveRaceTest.testSaveDuringAnotherSaveIsNotOverwritten`
 — a setting is added while another save is in flight, and the last write has 
to carry it. Fails on master with `the last write dropped added_during_save`.
    
    Existing suites run: `InterpreterSettingManagerTest`, 
`InterpreterSettingTest`, `InterpreterFactoryTest`, 
`InterpreterInfoSavingTest`, `LocalConfigStorageTest`, `CredentialsTest`, 
`LocalRecoveryStorageTest`.
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5426 from dev-donghwan/ZEPPELIN-6394.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .../interpreter/InterpreterSettingManager.java     |  19 ++-
 .../java/org/apache/zeppelin/util/FileUtils.java   |   6 +-
 .../InterpreterSettingManagerSaveRaceTest.java     | 181 +++++++++++++++++++++
 .../org/apache/zeppelin/util/FileUtilsTest.java    |  83 ++++++++++
 4 files changed, 284 insertions(+), 5 deletions(-)

diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/interpreter/InterpreterSettingManager.java
 
b/zeppelin-server/src/main/java/org/apache/zeppelin/interpreter/InterpreterSettingManager.java
index d0cff37f2b..1a85f82d5d 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/interpreter/InterpreterSettingManager.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/interpreter/InterpreterSettingManager.java
@@ -131,6 +131,9 @@ public class InterpreterSettingManager implements 
NoteEventListener {
     new ConcurrentHashMap<>());
   private final Map<String, List<Meter>> interpreterSettingsMeters = new 
ConcurrentHashMap<>();
 
+  /** Guards {@link #saveToFile()}, the only writer of the interpreter setting 
file. */
+  private final Object saveLock = new Object();
+
   private final List<Repository> interpreterRepositories;
   private InterpreterOption defaultOption;
   private String defaultInterpreterGroup;
@@ -369,11 +372,19 @@ public class InterpreterSettingManager implements 
NoteEventListener {
     }
   }
 
+  /**
+   * Snapshotting the settings and writing them out has to be one step. 
Dependency downloads
+   * save from a thread per interpreter setting, so without this an older 
snapshot can be
+   * written after a newer one and drop a setting that was added in between 
from the file,
+   * while it survives in memory until the next restart.
+   */
   public void saveToFile() throws IOException {
-    InterpreterInfoSaving info = new InterpreterInfoSaving();
-    info.interpreterSettings = new HashMap<>(interpreterSettings);
-    info.interpreterRepositories = interpreterRepositories;
-    configStorage.save(info);
+    synchronized (saveLock) {
+      InterpreterInfoSaving info = new InterpreterInfoSaving();
+      info.interpreterSettings = new HashMap<>(interpreterSettings);
+      info.interpreterRepositories = interpreterRepositories;
+      configStorage.save(info);
+    }
   }
 
   private void initMetrics() {
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/util/FileUtils.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/util/FileUtils.java
index 2698ffcd06..fdf9ac6898 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/util/FileUtils.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/util/FileUtils.java
@@ -57,8 +57,12 @@ public class FileUtils {
     }
     try {
       file.getParentFile().mkdirs();
+      // A plain replace is not atomic, so two writers moving their own temp 
file onto the
+      // same destination can make one of them fail with NoSuchFileException 
and leave its
+      // temp file behind. The temp file is created in the destination 
directory, so this
+      // move never crosses file systems and the atomic move is always 
supported.
       Files.move(tempFile.toPath(), destinationFilePath,
-              StandardCopyOption.REPLACE_EXISTING); 
//StandardCopyOption.ATOMIC_MOVE);
+              StandardCopyOption.REPLACE_EXISTING, 
StandardCopyOption.ATOMIC_MOVE);
     } catch (IOException iox) {
       if (!tempFile.delete()) {
         tempFile.deleteOnExit();
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/interpreter/InterpreterSettingManagerSaveRaceTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/interpreter/InterpreterSettingManagerSaveRaceTest.java
new file mode 100644
index 0000000000..270c9b488f
--- /dev/null
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/interpreter/InterpreterSettingManagerSaveRaceTest.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.zeppelin.interpreter;
+
+import org.apache.zeppelin.conf.ZeppelinConfiguration;
+import org.apache.zeppelin.dep.Dependency;
+import org.apache.zeppelin.display.AngularObjectRegistryListener;
+import org.apache.zeppelin.helium.ApplicationEventListener;
+import org.apache.zeppelin.interpreter.remote.RemoteInterpreterProcessListener;
+import org.apache.zeppelin.notebook.NotebookAuthorizationInfoSaving;
+import org.apache.zeppelin.storage.ConfigStorage;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Dependency downloads write the interpreter settings from a thread per 
interpreter setting,
+ * so {@link InterpreterSettingManager#saveToFile()} runs concurrently. Its 
snapshot and its
+ * write have to stay one step, otherwise a snapshot taken before a setting 
was added can be
+ * written after the save that carried it, and the file loses that setting 
while it survives
+ * in memory until the next restart.
+ */
+class InterpreterSettingManagerSaveRaceTest extends AbstractInterpreterTest {
+
+  private static final String NEW_SETTING_NAME = "added_during_save";
+  /** How long the first writer waits for a second one before it gives up and 
writes. */
+  private static final long HANDOVER_TIMEOUT_MS = 3000;
+
+  @Test
+  void testSaveDuringAnotherSaveIsNotOverwritten() throws Exception {
+    RecordingConfigStorage storage = new RecordingConfigStorage(zConf);
+    InterpreterSettingManager manager = new InterpreterSettingManager(zConf,
+        mock(AngularObjectRegistryListener.class), 
mock(RemoteInterpreterProcessListener.class),
+        mock(ApplicationEventListener.class), storage, pluginManager);
+    try {
+      storage.observe();
+
+      // The first writer holds its write until a second write has gone 
through, which is the
+      // window an unserialized saveToFile leaves open between its snapshot 
and its write.
+      List<Throwable> errors = new CopyOnWriteArrayList<>();
+      CountDownLatch firstWriterDone = new CountDownLatch(1);
+      Thread firstWriter = new Thread(() -> {
+        try {
+          manager.saveToFile();
+        } catch (Exception e) {
+          errors.add(e);
+        } finally {
+          firstWriterDone.countDown();
+        }
+      }, "saveToFile-holder");
+      firstWriter.setDaemon(true);
+      firstWriter.start();
+
+      assertTrue(storage.firstWriteEntered.await(10, TimeUnit.SECONDS),
+          "the first writer never reached the storage");
+
+      // Add a setting while the first writer is inside its write. 
createNewSetting() mutates
+      // the settings and saves them, so its content has to survive.
+      InterpreterOption option = new InterpreterOption();
+      option.setPerNote("scoped");
+      option.setPerUser("scoped");
+      Map<String, InterpreterProperty> properties = new HashMap<>();
+      properties.put("property_4", new InterpreterProperty("property_4", 
"value_4"));
+      manager.createNewSetting(NEW_SETTING_NAME, "test", new 
ArrayList<Dependency>(),
+          option, properties);
+
+      assertTrue(firstWriterDone.await(30, TimeUnit.SECONDS),
+          "the first writer did not finish within the timeout");
+      assertTrue(errors.isEmpty(), () -> "A writer threw: " + errors);
+
+      List<Set<String>> writes = storage.writtenSettingNames;
+      assertFalse(writes.isEmpty(), "nothing was written");
+      assertTrue(writes.get(writes.size() - 1).contains(NEW_SETTING_NAME),
+          () -> "the last write dropped " + NEW_SETTING_NAME + ", writes were 
" + writes);
+    } finally {
+      manager.close();
+    }
+  }
+
+  /**
+   * Records the interpreter names of every write. The first write is held 
back until a second
+   * one has been recorded, so that a save which is not serialized can 
overtake it.
+   */
+  private static class RecordingConfigStorage extends ConfigStorage {
+    private final List<Set<String>> writtenSettingNames = new 
CopyOnWriteArrayList<>();
+    private final CountDownLatch firstWriteEntered = new CountDownLatch(1);
+    private volatile boolean observing;
+    private volatile boolean firstWrite = true;
+
+    RecordingConfigStorage(ZeppelinConfiguration zConf) {
+      super(zConf);
+    }
+
+    /** Start recording, so that the writes of the constructor are left out. */
+    void observe() {
+      observing = true;
+    }
+
+    @Override
+    public void save(InterpreterInfoSaving settingInfos) throws IOException {
+      if (!observing) {
+        return;
+      }
+      Set<String> names = new HashSet<>();
+      for (InterpreterSetting setting : 
settingInfos.interpreterSettings.values()) {
+        names.add(setting.getName());
+      }
+      boolean holdForSecondWrite = firstWrite;
+      firstWrite = false;
+      if (holdForSecondWrite) {
+        firstWriteEntered.countDown();
+        // Serialized saves make this wait time out, which is the point: the 
second write
+        // cannot start before this one is done, so it lands last and keeps 
its content.
+        long deadline = System.nanoTime() + 
TimeUnit.MILLISECONDS.toNanos(HANDOVER_TIMEOUT_MS);
+        while (writtenSettingNames.isEmpty() && System.nanoTime() < deadline) {
+          try {
+            Thread.sleep(20);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            break;
+          }
+        }
+      }
+      writtenSettingNames.add(Collections.unmodifiableSet(names));
+    }
+
+    @Override
+    public InterpreterInfoSaving loadInterpreterSettings() throws IOException {
+      return null;
+    }
+
+    @Override
+    public void save(NotebookAuthorizationInfoSaving authorizationInfoSaving) 
throws IOException {
+      // not used by this test
+    }
+
+    @Override
+    public NotebookAuthorizationInfoSaving loadNotebookAuthorization() throws 
IOException {
+      return null;
+    }
+
+    @Override
+    public String loadCredentials() throws IOException {
+      return null;
+    }
+
+    @Override
+    public void saveCredentials(String credentials) throws IOException {
+      // not used by this test
+    }
+  }
+}
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/util/FileUtilsTest.java 
b/zeppelin-server/src/test/java/org/apache/zeppelin/util/FileUtilsTest.java
new file mode 100644
index 0000000000..a43d3dc63e
--- /dev/null
+++ b/zeppelin-server/src/test/java/org/apache/zeppelin/util/FileUtilsTest.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.zeppelin.util;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FileUtilsTest {
+
+  private static final int WRITERS = 16;
+  private static final int WRITES_PER_WRITER = 300;
+
+  /**
+   * Several threads write the same file, the way the interpreter settings are 
written when
+   * more than one interpreter finishes downloading its dependencies. A 
replace that is not
+   * atomic lets one of the moves fail with NoSuchFileException and leaves its 
temp file in
+   * the destination directory.
+   */
+  @Test
+  void testConcurrentWritesToTheSameFileSucceed(@TempDir Path tempDir) throws 
Exception {
+    File target = tempDir.resolve("interpreter.json").toFile();
+    ExecutorService pool = Executors.newFixedThreadPool(WRITERS);
+    CountDownLatch start = new CountDownLatch(1);
+    List<Throwable> failures = new CopyOnWriteArrayList<>();
+    try {
+      for (int writer = 0; writer < WRITERS; writer++) {
+        final int writerId = writer;
+        pool.submit(() -> {
+          try {
+            start.await();
+            for (int i = 0; i < WRITES_PER_WRITER; i++) {
+              FileUtils.atomicWriteToFile("{\"writer\":" + writerId + 
",\"write\":" + i + "}",
+                  target);
+            }
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+          } catch (Exception e) {
+            failures.add(e);
+          }
+        });
+      }
+      start.countDown();
+      pool.shutdown();
+      assertTrue(pool.awaitTermination(120, TimeUnit.SECONDS), "the writers 
did not finish");
+    } finally {
+      pool.shutdownNow();
+    }
+
+    assertTrue(failures.isEmpty(), () -> "writing the file failed: " + 
failures);
+    File[] leftovers = tempDir.toFile().listFiles((dir, name) -> 
name.endsWith(".tmp"));
+    assertEquals(0, leftovers == null ? 0 : leftovers.length,
+        "a failed move left its temp file behind");
+    assertTrue(FileUtils.readFromFile(target).startsWith("{\"writer\":"),
+        "the file does not hold a complete write");
+  }
+}

Reply via email to