mattcasters commented on code in PR #8819:
URL: https://github.com/apache/hop/pull/8819#discussion_r4233900653


##########
plugins/tech/lakehouse/src/main/java/org/apache/hop/lakehouse/transforms/LakeTableOutput.java:
##########
@@ -40,7 +68,268 @@ public LakeTableOutput(
 
   @Override
   public boolean processRow() throws HopException {
-    setOutputDone();
+    try (PluginClassLoader ignored = PluginClassLoader.activate()) {
+      return writeRow();
+    }
+  }
+
+  private boolean writeRow() throws HopException {
+    Object[] row = getRow();
+    if (first) {
+      first = false;
+      IRowMeta rowMeta =
+          row == null
+              ? getPipelineMeta().getPrevTransformFields(this, 
getTransformMeta())
+              : getInputRowMeta();
+      data.coordinator = coordinator(rowMeta).orElse(null);
+      if (data.coordinator != null) {
+        data.writer =
+            new IcebergRowWriter(
+                data.coordinator.table(),
+                rowMeta,
+                getCopy(),
+                System.currentTimeMillis(),
+                IcebergRowWriter.DEFAULT_TARGET_FILE_SIZE);
+      }
+    }
+
+    if (row == null) {
+      if (data.writer != null) {
+        data.coordinator.addFiles(data.writer.complete());
+        data.writer = null;
+      }
+      setOutputDone();
+      return false;
+    }
+
+    if (data.writer != null) {
+      data.writer.write(row);
+    }
+    putRow(getInputRowMeta(), row);
+    return true;
+  }
+
+  /**
+   * The commit coordinator shared by all copies of this transform in this 
run, created by the first
+   * copy that gets here. Empty when the save mode says to leave an existing 
table alone.
+   */
+  @SuppressWarnings("unchecked")
+  private Optional<CommitCoordinator> coordinator(IRowMeta rowMeta) throws 
HopException {
+    Map<String, Object> shared = getPipeline().getExtensionDataMap();
+    String key = COORDINATOR_KEY + getTransformName();
+    synchronized (shared) {
+      if (!shared.containsKey(key)) {
+        Optional<CommitCoordinator> coordinator = 
Optional.ofNullable(newCoordinator(rowMeta));
+        shared.put(key, coordinator);
+        coordinator.ifPresent(this::commitWhenPipelineFinishes);
+      }
+      return (Optional<CommitCoordinator>) shared.get(key);
+    }
+  }
+
+  /** Coalesce and extra options configure a Spark write; this engine doesn't 
use them. */
+  private void warnAboutSparkOnlyOptions() {
+    for (String[] option :
+        new String[][] {
+          {"Coalesce partitions", meta.getCoalescePartitions()},
+          {"Extra options", meta.getExtraOptions()}
+        }) {
+      if (StringUtils.isNotBlank(resolve(option[1]))) {
+        logError(
+            "Warning: '"
+                + option[0]
+                + "' only applies to the Spark engine and is ignored when 
writing on this engine");
+      }
+    }
+  }
+
+  private CommitCoordinator newCoordinator(IRowMeta rowMeta) throws 
HopException {
+    warnAboutSparkOnlyOptions();
+    String format = resolve(meta.getFormat());
+    if (!LakeFormats.FORMAT_ICEBERG.equalsIgnoreCase(format)) {
+      throw new HopException(
+          "Table format '"
+              + format
+              + "' isn't supported on this engine yet. The local engine writes 
Apache Iceberg"
+              + " tables; Delta Lake tables can be written with the Spark 
engine.");
+    }
+
+    IcebergTableTarget target = target();
+    String saveMode =
+        StringUtils.defaultIfBlank(resolve(meta.getSaveMode()), 
LakeTableOutputMeta.MODE_ERROR);
+    Map<String, String> summary = new HashMap<>();
+    summary.put(CommitCoordinator.SUMMARY_RUN_ID, 
getPipeline().getLogChannelId());
+    summary.put("hop.pipeline", Const.NVL(getPipelineMeta().getName(), ""));
+
+    if (target.exists()) {
+      if (LakeTableOutputMeta.MODE_ERROR.equalsIgnoreCase(saveMode)) {
+        throw new HopException(
+            "Table "
+                + target.name()
+                + " already exists. Choose the Append or Overwrite save mode 
to write to it.");
+      }
+      if (LakeTableOutputMeta.MODE_IGNORE.equalsIgnoreCase(saveMode)) {
+        logBasic("Table " + target.name() + " already exists, rows are not 
written (Ignore)");
+        return null;
+      }
+      WriteMode mode;
+      if (LakeTableOutputMeta.MODE_OVERWRITE.equalsIgnoreCase(saveMode)) {
+        mode = WriteMode.OVERWRITE_TABLE;
+      } else if (LakeTableOutputMeta.MODE_APPEND.equalsIgnoreCase(saveMode)) {
+        mode = WriteMode.APPEND;
+      } else {
+        throw unsupportedSaveMode(saveMode);
+      }
+      return new CommitCoordinator(target.load(), mode, summary);
+    }
+
+    if (!isSupportedSaveMode(saveMode)) {
+      throw unsupportedSaveMode(saveMode);
+    }
+    Schema schema = IcebergTypeMapper.toIcebergSchema(rowMeta);
+    PartitionSpec spec = partitionSpec(schema, 
resolve(meta.getPartitionByColumns()));
+    logBasic(
+        "Creating table "
+            + target.name()
+            + (spec.isUnpartitioned() ? "" : ", partitioned by " + 
meta.getPartitionByColumns()));
+    return new CommitCoordinator(target.create(schema, spec, Map.of()), 
summary);
+  }
+
+  private static boolean isSupportedSaveMode(String saveMode) {
+    for (String mode :
+        new String[] {
+          LakeTableOutputMeta.MODE_APPEND,
+          LakeTableOutputMeta.MODE_OVERWRITE,
+          LakeTableOutputMeta.MODE_ERROR,
+          LakeTableOutputMeta.MODE_IGNORE
+        }) {
+      if (mode.equalsIgnoreCase(saveMode)) {
+        return true;
+      }
+    }
     return false;
   }
+
+  private static HopException unsupportedSaveMode(String saveMode) {
+    return new HopException(
+        "Save mode '"
+            + saveMode
+            + "' isn't supported. Use Append, Overwrite, ErrorIfExists or 
Ignore.");
+  }
+
+  private IcebergTableTarget target() throws HopException {
+    if 
(LakeTableInputMeta.MODE_TABLE.equalsIgnoreCase(meta.getIdentifierMode())) {
+      String catalogName = resolve(meta.getCatalogMetadataName());
+      if (StringUtils.isBlank(catalogName)) {
+        throw new HopException("Table mode needs a catalog: please select 
one");
+      }
+      LakeCatalog catalog = 
metadataProvider.getSerializer(LakeCatalog.class).load(catalogName);
+      if (catalog == null) {
+        throw new HopException("Catalog '" + catalogName + "' couldn't be 
found");
+      }
+      return IcebergTables.targetInCatalog(catalog, meta.getTableIdentifier(), 
this);
+    }
+    String path = resolve(meta.getTablePath());
+    if (StringUtils.isBlank(path)) {
+      throw new HopException("Please specify the location of the table");
+    }
+    return IcebergTableTarget.atPath(path);
+  }
+
+  /** Identity partitions on the listed columns, used when the table is 
created. */
+  static PartitionSpec partitionSpec(Schema schema, String columns) throws 
HopException {
+    if (StringUtils.isBlank(columns)) {
+      return PartitionSpec.unpartitioned();
+    }
+    PartitionSpec.Builder builder = PartitionSpec.builderFor(schema);
+    List<String> missing = new ArrayList<>();
+    for (String column : columns.split(",")) {
+      String name = column.trim();
+      if (name.isEmpty()) {
+        continue;
+      }
+      if (schema.findField(name) == null) {
+        missing.add(name);
+      } else {
+        builder.identity(name);
+      }
+    }
+    if (!missing.isEmpty()) {
+      throw new HopException("Partition column(s) not found in the input: " + 
missing);
+    }
+    return builder.build();
+  }
+
+  private void commitWhenPipelineFinishes(CommitCoordinator coordinator) {
+    getPipeline()
+        .addExecutionFinishedListener(

Review Comment:
   **[bug]** `addExecutionFinishedListener` appends, and 
`Pipeline.startThreads` has already inserted its own listener at index 0. That 
listener calls `setRunning(false)` and then `cleanupRowSets()` and 
`HopVfs.freeUnusedResources` before this one runs. `Pipeline.getStatus()` is 
therefore `Finished` with zero errors for that whole stretch. 
`HopServerPipelineStatus.isFinished()` and `RemotePipelineEngine` key off that 
description: the remote engine stores `getResult()` as soon as it sees 
`Finished`, and its `waitUntilFinished()` returns. `setRunning(true)` inside 
`finishRun` is too late to cover the gap, so a parent workflow on the remote 
engine (which this change enables) can start the next action before the 
snapshot exists, and a commit that fails afterwards is missing from the result 
it already stored. The local `waitUntilFinished` path is fine: 
`pipelineCompleted()` runs only after every listener returns, and `setErrors` 
happens before that.
   
   **Suggestion:** On the local `Pipeline`, insert this listener at index 0 
after `startThreads` (the list from `getExecutionFinishedListeners()` is live) 
so the commit runs while `getStatus()` is still `Running`, before the built-in 
listener flips the status and frees VFS. Keep recording the transform error 
before returning.



##########
plugins/tech/lakehouse/src/main/java/org/apache/hop/lakehouse/iceberg/PathTableOperations.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.hop.lakehouse.iceberg;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileSystemException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.provider.local.LocalFile;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.lakehouse.iceberg.io.HopVfsFileIO;
+import org.apache.iceberg.LocationProviders;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.TableOperations;
+import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.CommitStateUnknownException;
+import org.apache.iceberg.exceptions.RuntimeIOException;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.LocationProvider;
+
+/**
+ * Table operations for an Iceberg table that lives in a folder without a 
catalog, in the layout of
+ * Hadoop catalogs and Spark path tables: {@code metadata/v<N>.metadata.json} 
plus a {@code
+ * version-hint.text} that names the current version.
+ *
+ * <p>A commit writes the new metadata to a temporary file and then publishes 
it under the next
+ * version's name with an operation that fails if that name is already taken. 
On a local file system
+ * that is a hard link, which the operating system creates atomically, so two 
writers can't both
+ * commit the same version, whether they run in one Hop instance or in several 
processes. Commits in
+ * one JVM are also serialized per table location. Other file systems publish 
with a check followed
+ * by a write, which isn't atomic across processes, so a path table there 
(including HDFS, object
+ * stores and local file systems without hard links) should have one writer at 
a time. Tables
+ * managed by a catalog should be written through the catalog instead. Nothing 
in a commit ever
+ * deletes or replaces an existing version file.
+ *
+ * <p>The version hint is best effort. Once the metadata file is in place the 
commit has happened,
+ * so a failure to update the hint is only logged, and readers walk forward 
from the hint to the
+ * newest version (see {@link IcebergTables#findCurrentMetadataFile(String)}).
+ */
+public class PathTableOperations implements TableOperations {
+
+  /** Serializes commits to the same table location within this JVM. */
+  private static final Map<String, ReentrantLock> LOCKS = new 
ConcurrentHashMap<>();
+
+  private final String location;
+  private final FileIO io = new HopVfsFileIO();
+  private TableMetadata current;
+  private int version = -1;
+
+  public PathTableOperations(String location) {
+    this.location =
+        location.endsWith("/") ? location.substring(0, location.length() - 1) 
: location;
+  }
+
+  @Override
+  public TableMetadata current() {
+    if (current == null && version < 0) {
+      refresh();
+    }
+    return current;
+  }
+
+  @Override
+  public TableMetadata refresh() {
+    String metadataFile;
+    try {
+      metadataFile = IcebergTables.findCurrentMetadataFile(location);
+    } catch (Exception e) {
+      throw new RuntimeIOException(
+          new java.io.IOException("Unable to read Iceberg table at " + 
location, e));
+    }
+    if (metadataFile == null) {
+      // No table yet: it is created by the first commit.
+      version = 0;
+      current = null;
+    } else {
+      version =
+          (int)
+              IcebergTables.metadataVersion(
+                  metadataFile.substring(metadataFile.lastIndexOf('/') + 1));
+      current = TableMetadataParser.read(io, metadataFile);
+    }
+    return current;
+  }
+
+  @Override
+  public void commit(TableMetadata base, TableMetadata metadata) {
+    ReentrantLock lock = LOCKS.computeIfAbsent(location, ignored -> new 
ReentrantLock());
+    lock.lock();
+    try {
+      commitLocked(base, metadata);
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  private void commitLocked(TableMetadata base, TableMetadata metadata) {
+    TableMetadata latest = refresh();
+    if (base != latest
+        && (base == null
+            || latest == null
+            || 
!base.metadataFileLocation().equals(latest.metadataFileLocation()))) {
+      throw new CommitFailedException(
+          "Table %s was changed by another writer since it was read", 
location);
+    }
+
+    int next = version + 1;
+    String target = metadataFileLocation("v" + next + ".metadata.json");
+    String temp = metadataFileLocation(UUID.randomUUID() + 
".metadata.json.tmp");
+    TableMetadataParser.write(metadata, io.newOutputFile(temp));
+    try {
+      publish(temp, target, next);
+    } finally {
+      deleteQuietly(temp);
+    }
+
+    // The commit has happened. Nothing after this point may fail it.
+    try {
+      writeVersionHint(next);
+    } catch (Exception e) {
+      LogChannel.GENERAL.logBasic(
+          "Committed version "
+              + next
+              + " of Iceberg table "
+              + location
+              + ", but couldn't update version-hint.text; readers find the new 
version anyway: "
+              + e.getMessage());
+    }
+    // Read the new metadata lazily, on the next call to current() or 
refresh().
+    current = null;
+    version = -1;
+  }
+
+  /**
+   * Publishes {@code temp} as {@code target}, failing with a {@link 
CommitFailedException} if
+   * another writer committed that version first. Nothing here ever deletes or 
replaces an existing
+   * version file.
+   *
+   * <p>On a local file system with hard links, the link is atomic, so this 
also holds between
+   * processes. Everywhere else (HDFS, object stores, local file systems 
without hard links) the
+   * check and the write are separate steps, which is why path tables there 
support one writer at a
+   * time. A failure while writing the version file there is reported as a 
{@link
+   * CommitStateUnknownException}, since the file may already be visible.
+   */
+  void publish(String temp, String target, int next) {
+    FileObject targetFile;
+    try {
+      FileObject tempFile = HopVfs.getFileObject(temp);
+      targetFile = HopVfs.getFileObject(target);
+      if (tempFile instanceof LocalFile && targetFile instanceof LocalFile) {
+        try {
+          // link(2) fails with EEXIST if the name is taken, atomically.
+          Files.createLink(Paths.get(targetFile.getURI()), 
Paths.get(tempFile.getURI()));

Review Comment:
   **[bug]** `Files.createLink` is given `Paths.get(fileObject.getURI())`. 
`FileObject.getURI()` is a URI (`file:///...`), and `Paths.get(String)` treats 
that as a relative path (`file:/...`), not a URI. `createLink` throws 
`NoSuchFileException`, which is a `FileSystemException` and is swallowed here, 
so the atomic hard-link publish never runs on a local disk. Every commit falls 
through to the `exists` check plus `HopVfs.getOutputStream(target, false)`. 
That stream deletes the destination when it already exists, so two processes 
that both pass the check can replace each other's `v<N>.metadata.json`. The 
in-JVM lock does not cover that. A failed `transferTo` also leaves a partial 
version file, and `findCurrentMetadataFile` will then treat it as the current 
metadata.
   
   **Suggestion:** Build the link paths with `Path.of(URI.create(uri))` or 
`HopVfs.getFilename` for a `LocalFile`, and fall through only on 
`UnsupportedOperationException` or a real "no hard links" error, not 
`NoSuchFileException`. On the copy path, fail the commit instead of opening the 
destination with `getOutputStream` (it deletes an existing file), and delete a 
partial `v<N>.metadata.json` when the write fails before completion.



##########
plugins/tech/lakehouse/src/main/java/org/apache/hop/lakehouse/iceberg/CommitCoordinator.java:
##########
@@ -0,0 +1,197 @@
+/*
+ * 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.hop.lakehouse.iceberg;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.iceberg.AppendFiles;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.OverwriteFiles;
+import org.apache.iceberg.ReplacePartitions;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.SnapshotUpdate;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.Transaction;
+import org.apache.iceberg.exceptions.CommitStateUnknownException;
+import org.apache.iceberg.expressions.Expressions;
+
+/**
+ * Collects the data files written by every copy of an output transform and 
turns them into a single
+ * snapshot, or into nothing at all.
+ *
+ * <p>Copies call {@link #addFiles(List)} as they finish. Once the whole 
pipeline is done, exactly
+ * one of {@link #commit()} or {@link #abort()} is called: commit when the run 
ended without errors,
+ * abort otherwise. This keeps a run atomic. Readers either see all of its 
rows or none of them,
+ * even when a transform further down the pipeline fails after this one has 
finished writing.
+ */
+public class CommitCoordinator {
+
+  public enum WriteMode {
+    APPEND,
+    OVERWRITE_TABLE,
+    OVERWRITE_PARTITIONS
+  }
+
+  public static final String SUMMARY_RUN_ID = "hop.run-id";
+
+  private final Table table;
+  private final Transaction createTransaction;
+  private final WriteMode writeMode;
+  private final Map<String, String> summary;
+  private final Long startSnapshotId;
+  private boolean published;
+  private boolean outcomeUnknown;
+  private final List<DataFile> files = new ArrayList<>();
+  private boolean done;
+
+  /**
+   * Commits to an existing table.
+   *
+   * @param summary extra properties for the snapshot summary, such as the 
pipeline name and run id
+   */
+  public CommitCoordinator(Table table, WriteMode writeMode, Map<String, 
String> summary) {
+    this(table, null, writeMode, summary);
+  }
+
+  /**
+   * Commits to a table that is created by this run. The table only comes into 
existence together
+   * with the run's snapshot, so a failed run leaves no empty table behind.
+   */
+  public CommitCoordinator(Transaction createTransaction, Map<String, String> 
summary) {
+    this(createTransaction.table(), createTransaction, WriteMode.APPEND, 
summary);
+  }
+
+  private CommitCoordinator(
+      Table table,
+      Transaction createTransaction,
+      WriteMode writeMode,
+      Map<String, String> summary) {
+    this.table = table;
+    this.createTransaction = createTransaction;
+    this.writeMode = writeMode;
+    this.summary = Map.copyOf(summary);
+    Snapshot current = createTransaction == null ? table.currentSnapshot() : 
null;
+    this.startSnapshotId = current == null ? null : current.snapshotId();
+  }
+
+  /** The table to write data files for. */
+  public Table table() {
+    return table;
+  }
+
+  public synchronized void addFiles(List<DataFile> dataFiles) {
+    if (done) {
+      throw new IllegalStateException("The commit for this run has already 
been decided");
+    }
+    files.addAll(dataFiles);
+  }
+
+  /**
+   * Commits all collected files as one snapshot.
+   *
+   * @return the id of the new snapshot, or null if the commit happened but 
the table couldn't be
+   *     read back afterwards
+   * @throws CommitStateUnknownException if the catalog couldn't say whether 
the commit happened;
+   *     the data files must then be kept, see {@link #isPublished()}
+   */
+  public synchronized Long commit() {
+    done = true;
+    SnapshotUpdate<?> update =
+        switch (writeMode) {
+          case APPEND -> {
+            AppendFiles append = table.newAppend();
+            files.forEach(append::appendFile);
+            yield append;
+          }
+          case OVERWRITE_TABLE -> {
+            OverwriteFiles overwrite =
+                
table.newOverwrite().overwriteByRowFilter(Expressions.alwaysTrue());
+            files.forEach(overwrite::addFile);
+            if (startSnapshotId != null) {

Review Comment:
   **[bug]** Overwrite conflict checks are skipped when the table has no 
snapshot yet (`startSnapshotId == null`). `SnapshotProducer.commit` retries a 
`CommitFailedException` by refreshing and reapplying the overwrite. A 
concurrent append that creates the table's first snapshot is then removed by 
`overwriteByRowFilter(Expressions.alwaysTrue())` instead of failing the commit. 
Tables that already have a snapshot are protected; an existing empty table is 
not.
   
   **Suggestion:** Always call `validateNoConflictingData()` for an overwrite. 
With a null starting snapshot, Iceberg treats every current file as added since 
the beginning (`ancestorsBetween` with a null oldest id), so a snapshot that 
appeared during the run fails validation, while a table that is still empty 
does not. `ValidationException` is not retried, so the run aborts and the other 
writer's files stay.



##########
plugins/tech/lakehouse/src/main/java/org/apache/hop/lakehouse/iceberg/IcebergTables.java:
##########
@@ -221,18 +259,24 @@ static String currentMetadataFile(String location) throws 
HopException {
           newest = name;
         }
       }
-      if (newest == null) {
-        throw new HopException(
-            "No Iceberg table found at '" + root + "': the metadata folder has 
no metadata files");
-      }
-      return metadataFolder + "/" + newest;
-    } catch (HopException e) {
-      throw e;
+      return newest == null ? null : metadataFolder + "/" + newest;
     } catch (Exception e) {
       throw new HopException("Unable to find the Iceberg metadata of table '" 
+ root + "'", e);
     }
   }
 
+  /** The metadata file of {@code version} in a path-table layout, or null if 
it doesn't exist. */
+  private static String versionFile(String metadataFolder, long version) 
throws Exception {
+    for (String name :
+        new String[] {"v" + version + ".metadata.json", "v" + version + 
".gz.metadata.json"}) {

Review Comment:
   **[suggestion]** The forward walk only looks for `v<N>.metadata.json` and 
`v<N>.gz.metadata.json`. Iceberg's `HadoopTableOperations.getMetadataFile` also 
accepts the legacy gzip name `v<N>.metadata.json.gz` 
(`TableMetadataParser.getOldFileExtension`). A stale hint in front of one of 
those files stops the walk early, and the next commit can publish a new 
`v<N>.metadata.json` that hides the gzip version. `metadataVersion` does not 
match that name either, so the folder-listing fallback will not correct it.
   
   **Suggestion:** Also probe `v<N>.metadata.json.gz` in `versionFile`, and let 
`metadataVersion` recognize that name.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to