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

aglinxinyuan pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/release/v1.2 by this push:
     new 7bb46a7085 fix(workflow-core, v1.2): run Iceberg local storage on 
Windows without winutils (#6528)
7bb46a7085 is described below

commit 7bb46a70854f128c99b78e71fb5ead252ba26181
Author: Xinyuan Lin <[email protected]>
AuthorDate: Tue Jul 21 17:20:19 2026 -0700

    fix(workflow-core, v1.2): run Iceberg local storage on Windows without 
winutils (#6528)
    
    ### What changes were proposed in this PR?
    
    Backport of apache/texera#6488 to `release/v1.2`.
    
    On a Windows dev machine without a native Hadoop installation, every
    workflow execution fails at execution start when the `postgres` or
    `hadoop` Iceberg catalog type is configured. Hadoop's local file system
    applies POSIX permissions on **every** file/directory creation by
    shelling out to `%HADOOP_HOME%\bin\winutils.exe`, with no configuration
    flag to disable it — and Iceberg's `HadoopFileIO` (the local warehouse
    used by the `postgres` and `hadoop` catalog types) writes through
    exactly that path.
    
    `release/v1.2` carries the same regression: it is on hadoop-common
    **3.4.3**, and 3.4+ applies POSIX permissions on every local
    file/directory creation, which pulls winutils in:
    
    ```
    ExecutionStatsService                    (create runtime-stats Iceberg 
table)
     └─ IcebergUtil.createTable
         └─ JdbcTableOperations.doCommit → HadoopFileIO
             └─ RawLocalFileSystem.mkdirs / create
                 └─ RawLocalFileSystem.setPermission     ← on every file/dir 
creation
                     └─ Shell.getSetPermissionCommand    ← requires 
winutils.exe chmod
                         └─ FileNotFoundException: Hadoop bin directory does 
not exist
    ```
    
    POSIX permission bits carry no meaning on NTFS, so this skips them there
    instead of failing:
    
    | File | Change |
    |---|---|
    | `WinutilsFreeLocalFileSystem.scala` (new) | A `LocalFileSystem` whose
    `setPermission`/`setOwner` skip the winutils shell-out. Self-gating:
    when winutils **is** installed, or on non-Windows hosts, it delegates to
    Hadoop's default behavior. |
    | `IcebergUtil.scala` | New `newLocalHadoopConf()` used by
    `createHadoopCatalog`/`createPostgresCatalog`. Only when `Shell.WINDOWS
    && !Shell.hasWinutilsPath()` does it select the new file system via
    `fs.file.impl` (and disable the scheme-keyed `FileSystem` cache so an
    earlier cached instance cannot bypass the override). On all other hosts
    it returns the default `Configuration` unchanged. |
    
    Clean cherry-pick of the squash commit (`aefae7b`) — no conflicts. The
    change is confined to `common/workflow-core` Scala and adds no new
    dependencies, so no `LICENSE-binary`/`NOTICE-binary` regeneration is
    needed.
    
    ### Any related issues, documentation, discussions?
    
    Backport of #6488 (Closes #6487).
    
    ### How was this PR tested?
    
    - Both specs run green on `release/v1.2` with Java 17: `IcebergUtilSpec`
    (local-warehouse create/load regression test + both branches of the
    injectable winutils gate) and `WinutilsFreeLocalFileSystemSpec`
    (selection via `fs.file.impl`, file round-trip,
    `setPermission`/`setOwner` no-throw).
    - `WorkflowCore/scalafmtCheckAll` and `WorkflowCore/scalafixAll --check`
    pass.
    
    No `release/*` label is added — this PR *is* the backport, so the
    automated backport check correctly skips.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
---
 .../org/apache/texera/amber/util/IcebergUtil.scala | 36 ++++++++-
 .../amber/util/WinutilsFreeLocalFileSystem.scala   | 51 +++++++++++++
 .../apache/texera/amber/util/IcebergUtilSpec.scala | 48 ++++++++++++
 .../util/WinutilsFreeLocalFileSystemSpec.scala     | 89 ++++++++++++++++++++++
 4 files changed, 221 insertions(+), 3 deletions(-)

diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/util/IcebergUtil.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/util/IcebergUtil.scala
index 0b45b9eec3..53b5079d5f 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/util/IcebergUtil.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/util/IcebergUtil.scala
@@ -22,6 +22,7 @@ package org.apache.texera.amber.util
 import org.apache.texera.amber.config.StorageConfig
 import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, 
LargeBinary, Schema, Tuple}
 import org.apache.hadoop.conf.Configuration
+import org.apache.hadoop.util.Shell
 import org.apache.iceberg.catalog.{Catalog, SupportsNamespaces, 
TableIdentifier}
 import org.apache.iceberg.data.parquet.GenericParquetReaders
 import org.apache.iceberg.data.{GenericRecord, Record}
@@ -58,10 +59,37 @@ object IcebergUtil {
   // Unique suffix for LARGE_BINARY field encoding
   private val LARGE_BINARY_FIELD_SUFFIX = "__texera_large_binary_ptr"
 
+  /**
+    * Creates the Hadoop `Configuration` used by catalogs that access the 
local file
+    * system through `HadoopFileIO`.
+    *
+    * On Windows hosts without a native Hadoop installation (winutils.exe), 
Hadoop's
+    * default local file system fails on every write because it shells out to 
winutils
+    * for chmod. In that case, swap in [[WinutilsFreeLocalFileSystem]], which 
skips
+    * permission operations, so local development works without installing 
winutils.
+    * On all other hosts the default configuration is returned unchanged.
+    *
+    * @param useWinutilsFreeLocalFs whether to swap in the winutils-free file 
system;
+    *                               defaults to the host's actual winutils 
availability
+    *                               and is only overridden in tests.
+    */
+  private[util] def newLocalHadoopConf(
+      useWinutilsFreeLocalFs: Boolean = Shell.WINDOWS && 
!Shell.hasWinutilsPath()
+  ): Configuration = {
+    val conf = new Configuration()
+    if (useWinutilsFreeLocalFs) {
+      conf.set("fs.file.impl", classOf[WinutilsFreeLocalFileSystem].getName)
+      // The FileSystem cache is keyed by scheme (not by impl class), so a 
plain
+      // LocalFileSystem cached earlier by other code would bypass this 
override.
+      conf.setBoolean("fs.file.impl.disable.cache", true)
+    }
+    conf
+  }
+
   /**
     * Creates and initializes a HadoopCatalog with the given parameters.
-    * - Uses an empty Hadoop `Configuration`, meaning the local file system 
(or `file:/`) will be used by default
-    * instead of HDFS.
+    * - Uses the Hadoop `Configuration` from [[newLocalHadoopConf]], meaning 
the local
+    * file system (or `file:/`) will be used by default instead of HDFS.
     * - The `warehouse` parameter specifies the root directory for storing 
table data.
     * - Sets the file I/O implementation to `HadoopFileIO`.
     *
@@ -74,7 +102,7 @@ object IcebergUtil {
       warehouse: Path
   ): HadoopCatalog = {
     val catalog = new HadoopCatalog()
-    catalog.setConf(new Configuration) // Empty configuration, defaults to 
`file:/`
+    catalog.setConf(newLocalHadoopConf()) // Defaults to `file:/`, no HDFS
     catalog.initialize(
       catalogName,
       Map(
@@ -128,6 +156,8 @@ object IcebergUtil {
     // Explicitly load the JDBC driver to avoid flaky CI failures.
     Class.forName("org.postgresql.Driver")
     val catalog = new JdbcCatalog()
+    // Must be set before initialize() so HadoopFileIO picks up this 
configuration.
+    catalog.setConf(newLocalHadoopConf())
     catalog.initialize(
       catalogName,
       Map(
diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/util/WinutilsFreeLocalFileSystem.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/util/WinutilsFreeLocalFileSystem.scala
new file mode 100644
index 0000000000..04b1834529
--- /dev/null
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/util/WinutilsFreeLocalFileSystem.scala
@@ -0,0 +1,51 @@
+/*
+ * 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.texera.amber.util
+
+import org.apache.hadoop.fs.permission.FsPermission
+import org.apache.hadoop.fs.{LocalFileSystem, Path, RawLocalFileSystem}
+import org.apache.hadoop.util.Shell
+
+/**
+  * A Hadoop local file system that works without a `winutils.exe` 
installation.
+  *
+  * On Windows, Hadoop's default local file system implements chmod/chown by 
invoking
+  * `%HADOOP_HOME%\bin\winutils.exe`, and every file or directory creation 
applies POSIX
+  * permissions through that path. Without a native Hadoop installation this 
fails with
+  * "Hadoop bin directory does not exist". POSIX permission bits carry no 
meaning on NTFS,
+  * so on Windows hosts without winutils this file system skips permission 
operations
+  * instead of failing.
+  *
+  * Selected via `fs.file.impl` by [[IcebergUtil]] on Windows hosts where 
winutils is
+  * unavailable. Everywhere else — winutils installed, or a non-Windows host — 
it behaves
+  * exactly like Hadoop's default local file system, including its 
winutils/chmod use.
+  */
+class WinutilsFreeLocalFileSystem extends LocalFileSystem(new 
WinutilsFreeRawLocalFileSystem)
+
+class WinutilsFreeRawLocalFileSystem extends RawLocalFileSystem {
+
+  private def skipPermissionOps: Boolean = Shell.WINDOWS && 
!Shell.hasWinutilsPath()
+
+  override def setPermission(p: Path, permission: FsPermission): Unit =
+    if (!skipPermissionOps) super.setPermission(p, permission)
+
+  override def setOwner(p: Path, username: String, groupname: String): Unit =
+    if (!skipPermissionOps) super.setOwner(p, username, groupname)
+}
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/util/IcebergUtilSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/util/IcebergUtilSpec.scala
index 75a997cbb5..a10f459073 100644
--- 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/util/IcebergUtilSpec.scala
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/util/IcebergUtilSpec.scala
@@ -21,13 +21,16 @@ package org.apache.texera.amber.util
 
 import org.apache.texera.amber.core.tuple.{AttributeType, LargeBinary, Schema, 
Tuple}
 import org.apache.texera.amber.util.IcebergUtil.toIcebergSchema
+import org.apache.hadoop.fs.FileSystem
 import org.apache.iceberg.data.GenericRecord
 import org.apache.iceberg.exceptions.RESTException
 import org.apache.iceberg.types.Types
 import org.apache.iceberg.{Schema => IcebergSchema}
 import org.scalatest.flatspec.AnyFlatSpec
 
+import java.net.URI
 import java.nio.ByteBuffer
+import java.nio.file.Files
 import java.sql.Timestamp
 import java.time.{LocalDateTime, ZoneId}
 import scala.jdk.CollectionConverters._
@@ -300,6 +303,51 @@ class IcebergUtilSpec extends AnyFlatSpec {
     assert(IcebergUtil.fromRecord(record, schema) == tuple)
   }
 
+  it should "select WinutilsFreeLocalFileSystem when winutils is unavailable 
on Windows" in {
+    val conf = IcebergUtil.newLocalHadoopConf(useWinutilsFreeLocalFs = true)
+    assert(conf.get("fs.file.impl") == 
classOf[WinutilsFreeLocalFileSystem].getName)
+    assert(conf.getBoolean("fs.file.impl.disable.cache", false))
+    // Caching is disabled, so this is a fresh instance that must be closed to 
avoid leaking handles.
+    val fs = FileSystem.get(URI.create("file:///"), conf)
+    try assert(fs.isInstanceOf[WinutilsFreeLocalFileSystem])
+    finally fs.close()
+  }
+
+  it should "leave the Hadoop configuration untouched when winutils is not 
needed" in {
+    val conf = IcebergUtil.newLocalHadoopConf(useWinutilsFreeLocalFs = false)
+    assert(conf.get("fs.file.impl") == null)
+    assert(!conf.getBoolean("fs.file.impl.disable.cache", false))
+  }
+
+  it should "create and load tables via createHadoopCatalog in a local 
warehouse on every platform" in {
+    val warehouse = Files.createTempDirectory("iceberg-local-warehouse")
+    val catalog = IcebergUtil.createHadoopCatalog("local_test", warehouse)
+    try {
+      // On Windows hosts without a winutils.exe installation, this used to 
fail with
+      // "Hadoop bin directory does not exist": Hadoop's default local file 
system
+      // shells out to winutils for chmod on every file/directory creation.
+      IcebergUtil.createTable(
+        catalog,
+        "test_namespace",
+        "test_table",
+        IcebergUtil.toIcebergSchema(Schema().add("id", AttributeType.INTEGER)),
+        overrideIfExists = true
+      )
+
+      assert(
+        
Files.exists(warehouse.resolve("test_namespace").resolve("test_table").resolve("metadata")),
+        "table metadata must be written through the local file system"
+      )
+      assert(IcebergUtil.loadTableMetadata(catalog, "test_namespace", 
"test_table").nonEmpty)
+    } finally {
+      // Close the catalog (and its underlying Hadoop FileSystem) to avoid 
leaking handles across the suite.
+      catalog match {
+        case closeable: AutoCloseable => closeable.close()
+        case _                        =>
+      }
+    }
+  }
+
   it should "surface RESTException when createRestCatalog cannot reach the 
REST endpoint" in {
     // Property Map is built before any network call. With or without
     // Lakekeeper reachable, .initialize surfaces a RESTException — the
diff --git 
a/common/workflow-core/src/test/scala/org/apache/texera/amber/util/WinutilsFreeLocalFileSystemSpec.scala
 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/util/WinutilsFreeLocalFileSystemSpec.scala
new file mode 100644
index 0000000000..e8d21f826b
--- /dev/null
+++ 
b/common/workflow-core/src/test/scala/org/apache/texera/amber/util/WinutilsFreeLocalFileSystemSpec.scala
@@ -0,0 +1,89 @@
+/*
+ * 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.texera.amber.util
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.hadoop.fs.permission.FsPermission
+import org.apache.hadoop.fs.{FileSystem, Path}
+import org.scalatest.flatspec.AnyFlatSpec
+
+import java.net.URI
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+
+class WinutilsFreeLocalFileSystemSpec extends AnyFlatSpec {
+
+  private def newFs(): FileSystem = {
+    val conf = new Configuration()
+    // Select the file system the same way production code does: through 
`fs.file.impl`.
+    conf.set("fs.file.impl", classOf[WinutilsFreeLocalFileSystem].getName)
+    conf.setBoolean("fs.file.impl.disable.cache", true)
+    FileSystem.get(URI.create("file:///"), conf)
+  }
+
+  // Caching is disabled, so every newFs() is a fresh instance that must be 
closed to
+  // avoid leaking file handles across tests (especially on Windows). 
Loan-pattern helper.
+  private def withFs(test: FileSystem => Unit): Unit = {
+    val fs = newFs()
+    try test(fs)
+    finally fs.close()
+  }
+
+  "WinutilsFreeLocalFileSystem" should "be selected through fs.file.impl" in 
withFs { fs =>
+    assert(fs.isInstanceOf[WinutilsFreeLocalFileSystem])
+  }
+
+  it should "create directories and read/write files without winutils" in 
withFs { fs =>
+    val tmp = Files.createTempDirectory("winutils-free-fs")
+    val dir = new Path(tmp.toUri.toString, "a/b/c")
+    assert(fs.mkdirs(dir))
+
+    val file = new Path(dir, "data.txt")
+    val out = fs.create(file, true)
+    out.write("hello".getBytes(StandardCharsets.UTF_8))
+    out.close()
+
+    val in = fs.open(file)
+    val buf = new Array[Byte](5)
+    in.readFully(buf)
+    in.close()
+    assert(new String(buf, StandardCharsets.UTF_8) == "hello")
+
+    assert(fs.getFileStatus(file).getLen == 5)
+    assert(fs.delete(file, false))
+  }
+
+  it should "not fail on setPermission" in withFs { fs =>
+    val tmp = Files.createTempDirectory("winutils-free-fs-perm")
+    val file = new Path(tmp.toUri.toString, "perm.txt")
+    fs.create(file, true).close()
+    // No-op on Windows hosts without winutils; delegates to Hadoop's default 
elsewhere.
+    fs.setPermission(file, new FsPermission("755"))
+  }
+
+  it should "not fail on setOwner" in withFs { fs =>
+    val tmp = Files.createTempDirectory("winutils-free-fs-owner")
+    val file = new Path(tmp.toUri.toString, "owner.txt")
+    fs.create(file, true).close()
+    // Chown-to-self is permitted everywhere; no-op on Windows hosts without 
winutils.
+    // (Not FileStatus.getOwner: reading the owner itself requires winutils on 
Windows.)
+    fs.setOwner(file, System.getProperty("user.name"), null)
+  }
+}

Reply via email to