SemyonSinchenko commented on code in PR #58302: URL: https://github.com/apache/spark/pull/58302#discussion_r4272377360
########## graphframes/src/main/scala/org/apache/spark/graphframes/embeddings/Hash2Vec.scala: ########## @@ -0,0 +1,603 @@ +/* + * 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.spark.graphframes.embeddings + +import scala.reflect.ClassTag +import scala.util.hashing.MurmurHash3 + +import dev.ludovic.netlib.blas.BLAS + +import org.apache.spark.graphframes.GraphFramesUnsupportedVertexTypeException +import org.apache.spark.graphframes.rw.RandomWalkBase +import org.apache.spark.ml.linalg +import org.apache.spark.ml.linalg.SQLDataTypes.VectorType +import org.apache.spark.ml.linalg.Vectors +import org.apache.spark.ml.stat.Summarizer +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.Column +import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.Row +import org.apache.spark.sql.functions.col +import org.apache.spark.sql.functions.hash +import org.apache.spark.sql.functions.lit +import org.apache.spark.sql.functions.pmod +import org.apache.spark.sql.functions.transform +import org.apache.spark.sql.functions.udf +import org.apache.spark.sql.types.ArrayType +import org.apache.spark.sql.types.ByteType +import org.apache.spark.sql.types.IntegerType +import org.apache.spark.sql.types.LongType +import org.apache.spark.sql.types.ShortType +import org.apache.spark.sql.types.StringType +import org.apache.spark.sql.types.StructField +import org.apache.spark.sql.types.StructType +import org.apache.spark.unsafe.hash.Murmur3_x86_32._ + +/** + * Implementation of Hash2Vec, an efficient word embedding technique using feature hashing. Based + * on: Argerich, Luis, Joaquin Torre Zaffaroni, and Matias J. Cano. "Hash2vec, feature hashing for + * word embeddings." arXiv preprint arXiv:1608.08940 (2016). + * + * Produces embeddings for elements in sequences using a hash-based approach to avoid storing a + * vocabulary. Uses MurmurHash3 for hashing elements to embedding indices and signs. + * + * Output DataFrame has columns "id" (element identifier, same type as sequence elements) and + * "vector" (dense vector of doubles, summed across all occurrences). + * + * Tradeoffs: Higher numPartitions reduces local state and memory per partition but increases + * aggregation and merging overhead across partitions. Larger embeddingsDim provides richer + * representations but consumes more memory. Seeds control hashing for reproducibility. + */ +class Hash2Vec extends Serializable { Review Comment: What do you think about moving this to the ML? I mean it is an implementation of the (almost) generic seq2vec model that can be scaled beyond the limits if the ML's `word2vec` (hard cap ~20M of words in vocab with default dim=100). I battle-tested it on the corpus with more than 50M of words in vocab and it works. ########## graphframes/src/main/scala/org/apache/spark/graphframes/Logging.scala: ########## @@ -0,0 +1,47 @@ +/* + * 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.spark.graphframes + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +// This needs to be accessible to org.apache.spark.graphx.lib.backport +private[org] trait Logging { Review Comment: Do we really need it there? From my understanding of the old GF' codebase this was more like a workaround. But from inside of the Apache Spark we can use it's own logging logic instead of duplicating it. ########## graphframes/src/main/scala/org/apache/spark/graphframes/examples/BeliefPropagation.scala: ########## Review Comment: What do you think about moving examples out of the core codebase? We did not do this in GF due to the back-compat policy, but now it is a perfect chance: otherwise we may stuck with this "examples" ########## graphframes/src/test/scala/org/apache/spark/graphframes/TestUtils.scala: ########## @@ -0,0 +1,128 @@ +/* + * 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.spark.graphframes + +import org.apache.spark.graphframes.GraphFrame._ +import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.types.DataType +import org.apache.spark.sql.types.StructType + +object TestUtils { + + private[this] val majorMinorRegex = """^(\d+)\.(\d+)(\..*)?$""".r + + /** Extract major/minor version integer pairs from a version string */ + def majorMinorVersion(sparkVersion: String): (Int, Int) = { + majorMinorRegex.findFirstMatchIn(sparkVersion) match { + case Some(m) => + (m.group(1).toInt, m.group(2).toInt) + case None => + throw new IllegalArgumentException( + s"Spark tried to parse '$sparkVersion' as a Spark" + + " version string, but it could not find the major and minor version numbers.") + } + } + + /** Return true if the major and minor versions are greater or eq to constraints */ + def requireSparkVersionGE(major: Int, minor: Int, sparkVersion: String): Boolean = { Review Comment: This was a part of version-matrix test, I don't think we really need it. ########## graphframes/src/main/scala/org/apache/spark/graphframes/embeddings/Hash2Vec.scala: ########## @@ -0,0 +1,603 @@ +/* + * 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.spark.graphframes.embeddings + +import scala.reflect.ClassTag +import scala.util.hashing.MurmurHash3 + +import dev.ludovic.netlib.blas.BLAS + +import org.apache.spark.graphframes.GraphFramesUnsupportedVertexTypeException +import org.apache.spark.graphframes.rw.RandomWalkBase +import org.apache.spark.ml.linalg +import org.apache.spark.ml.linalg.SQLDataTypes.VectorType +import org.apache.spark.ml.linalg.Vectors +import org.apache.spark.ml.stat.Summarizer +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.Column +import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.Row +import org.apache.spark.sql.functions.col +import org.apache.spark.sql.functions.hash +import org.apache.spark.sql.functions.lit +import org.apache.spark.sql.functions.pmod +import org.apache.spark.sql.functions.transform +import org.apache.spark.sql.functions.udf +import org.apache.spark.sql.types.ArrayType +import org.apache.spark.sql.types.ByteType +import org.apache.spark.sql.types.IntegerType +import org.apache.spark.sql.types.LongType +import org.apache.spark.sql.types.ShortType +import org.apache.spark.sql.types.StringType +import org.apache.spark.sql.types.StructField +import org.apache.spark.sql.types.StructType +import org.apache.spark.unsafe.hash.Murmur3_x86_32._ + +/** + * Implementation of Hash2Vec, an efficient word embedding technique using feature hashing. Based + * on: Argerich, Luis, Joaquin Torre Zaffaroni, and Matias J. Cano. "Hash2vec, feature hashing for + * word embeddings." arXiv preprint arXiv:1608.08940 (2016). + * + * Produces embeddings for elements in sequences using a hash-based approach to avoid storing a + * vocabulary. Uses MurmurHash3 for hashing elements to embedding indices and signs. + * + * Output DataFrame has columns "id" (element identifier, same type as sequence elements) and + * "vector" (dense vector of doubles, summed across all occurrences). + * + * Tradeoffs: Higher numPartitions reduces local state and memory per partition but increases + * aggregation and merging overhead across partitions. Larger embeddingsDim provides richer + * representations but consumes more memory. Seeds control hashing for reproducibility. + */ +class Hash2Vec extends Serializable { + private def decayGaussian(d: Int, sigma: Double): Double = { + math.exp(-(d * d) / (sigma * sigma)) + } + private val possibleDecayFunctions: Seq[String] = Seq("gaussian", "constant") + + private var contextSize: Int = 5 + private var numPartitions: Int = 5 + private var embeddingsDim: Int = 512 + private var sequenceCol: String = RandomWalkBase.rwColName + private var decayFunction: String = "gaussian" + private var gaussianSigma: Double = 1.0 + private var hashingSeed: Int = 42 + private var signHashingSeed: Int = 18 + private var doL2Norm: Boolean = true + private var safeL2NormAsChannel: Boolean = true + private var maxVectorsPerPartition: Int = 100000 + + /** + * Sets whether final vectors are L2-normalized after aggregation across partitions. When + * normalization is enabled, each vector is scaled to unit length (L2 norm = 1). + * + * When `safeNorm` is true (default), the method adds an extra channel to the vector equal to + * `log(L2-norm + 1) / sqrt(dim)`. This preserves some information about the original magnitude + * while still making vectors comparable via cosine similarity. + * + * When `safeNorm` is false, normalizes without adding an extra channel, discarding magnitude + * entirely. + * + * This setting applies globally to all output vectors. + * + * @param doNorm + * If true, output vectors are normalized. + * @param safeNorm + * If true (and doNorm is true), retains magnitude information in an extra dimension. If + * false, performs standard L2-normalization. + * @return + * This Hash2Vec instance for method chaining. + */ + def setDoNormalization(doNorm: Boolean, safeNorm: Boolean): this.type = { + doL2Norm = doNorm + safeL2NormAsChannel = safeNorm + this + } + + /** + * Limits the maximum number of distinct element vectors that can be processed inside a single + * partition before flushing intermediate results to the iterator. + * + * Partition processing uses a paged matrix to store vectors. When the number of allocated + * vectors reaches this limit within a partition, the current batch of vectors is returned (as + * part of the iterator) and a new empty batch is started for the remaining elements. + * + * This prevents a single partition from consuming unbounded memory while processing very large + * vocabularies, at the cost of producing multiple iterator batches per partition. + * + * The default value is 100000. + * + * @param value + * Upper bound on distinct vectors processed per batch inside a partition. + * @return + * This Hash2Vec instance for method chaining. + */ + def setMaxVectorsPerPartition(value: Int): this.type = { + maxVectorsPerPartition = value + this + } + + /** + * Convenience overload for `setDoNormalization(doNorm, safeNorm)` that uses safe-mode (extra + * channel) by default. Equivalent to `setDoNormalization(value, true)`. + * + * @param value + * If true, output vectors are L2-normalized with safe (extra-channel) semantics. + * @return + * This Hash2Vec instance for method chaining. + */ + def setDoNormalization(value: Boolean): this.type = { + setDoNormalization(value, true) + this + } + + /** + * Sets the context window size around each element to consider during training. Larger values + * incorporate more distant elements but increase computation time. Default: 5. + */ + def setContextSize(value: Int): this.type = { + contextSize = value + this + } + + /** + * Sets the number of partitions for RDDs to parallelize computation. More partitions distribute + * workload and reduce memory per partition but complicate merging across partitions. Default: + * 5. + */ + def setNumPartitions(value: Int): this.type = { + numPartitions = value + this + } + + /** + * Sets the dimensionality of the dense embedding vectors. Larger dimensions allow richer + * representations but require more memory. Corresponds to the hash table size. Default: 512. + */ + def setEmbeddingsDim(value: Int): this.type = { + embeddingsDim = value + this + } + + /** + * Sets the column name containing sequences of elements (as arrays). Default: "random_walk". + */ + def setSequenceCol(value: String): this.type = { + sequenceCol = value + this + } + + /** + * Sets the decay function used to weight context elements by distance. Supported values: + * "gaussian", "constant". Default: "gaussian". + */ + def setDecayFunction(value: String): this.type = { + val sep = ", " + require( + possibleDecayFunctions.contains(value), + s"supported functions: ${possibleDecayFunctions.mkString(sep)}") + decayFunction = value + this + } + + /** + * Sets the sigma parameter for Gaussian decay weighting. Smaller values decay weights faster + * with distance. Default: 1.0. + */ + def setGaussianSigma(value: Double): this.type = { + gaussianSigma = value + this + } + + /** + * Sets the seed for hashing elements to embedding indices. Used for reproducibility of + * embeddings. Default: 42. + */ + def setHashingSeed(value: Int): this.type = { + hashingSeed = value + this + } + + /** + * Sets the seed for hashing elements to determine the sign of contributions. Used for + * reproducibility of embeddings. Default: 18. + */ + def setSignHashSeed(value: Int): this.type = { + signHashingSeed = value + this + } + + private def nonNegativeMod(x: Int, mod: Int): Int = { + val rawMod = x % mod + rawMod + (if (rawMod < 0) mod else 0) + } + + private var weightFunction: (Int) => Double = _ + + private def seededStringHashFunc(s: String, seed: Int, dim: Int): Int = { + var h = seed + var i = 0 + val len = s.length + while (i < len) { + h = MurmurHash3.mix(h, s.charAt(i).toInt) + i += 1 + } + nonNegativeMod(MurmurHash3.finalizeHash(h, len), dim) + } + + private def seededLongHashFunc(l: Long, seed: Int, dim: Int): Int = + nonNegativeMod(hashLong(l, seed), dim) + + private def normalize(vector: linalg.Vector, addChannel: Boolean): linalg.Vector = { + val blas = BLAS.getInstance() + val arr = vector.toArray + val norm = blas.dnrm2(arr.size, arr, 0, 1) + blas.dscal(arr.size, 1 / (norm + 1e-6), arr, 1) + if (addChannel) { + val scaledL2 = math.log(norm + 1) + val newChannel = scaledL2 / math.sqrt(vector.size.toDouble) + new linalg.DenseVector(arr :+ newChannel) + } else { + new linalg.DenseVector(arr) + } + } + + /** + * Runs the Hash2Vec algorithm on the input DataFrame containing sequences. The specified + * sequenceCol must contain arrays of elements (string or numeric). Produces a DataFrame with + * "id" (element ID, same type as elements) and "vector" (embedding vector, VectorType). + * Embeddings are summed across all partitions and occurrences. + */ + def run(rawData: DataFrame): DataFrame = { + val spark = rawData.sparkSession + require( + rawData.schema(sequenceCol).dataType.isInstanceOf[ArrayType], + "sequence should be array") + val elDataType = rawData.schema(sequenceCol).dataType.asInstanceOf[ArrayType].elementType + + val data = + if (elDataType.isInstanceOf[ByteType] || elDataType.isInstanceOf[ShortType] || elDataType + .isInstanceOf[IntegerType]) { + rawData.withColumn( + sequenceCol, + transform(col(sequenceCol), (f: Column) => f.cast(LongType))) + } else { + rawData + } + + weightFunction = decayFunction match { + case "gaussian" => (d: Int) => decayGaussian(d, gaussianSigma) + case "constant" => (_: Int) => 1.0 + case _ => throw new RuntimeException(s"unsupported decay functions $decayFunction") + } + + val (rowRDD, schema) = elDataType match { + case _: StringType => + ( + runTyped[String](data).map(f => Row(f._1, Vectors.dense(f._2))), + StructType(Seq(StructField("id", StringType), StructField("vector", VectorType)))) + case _: LongType => + ( + runTyped[Long](data).map(f => Row(f._1, Vectors.dense(f._2))), + StructType(Seq(StructField("id", LongType), StructField("vector", VectorType)))) + case _ => + throw new GraphFramesUnsupportedVertexTypeException( + "Hash2vec supports only string or numeric types of elements but got " + + elDataType.toString) + } + + val embeddings = spark + .createDataFrame(rowRDD, schema) + .groupBy("id") + .agg(Summarizer.sum(col("vector")).alias("vector")) + + val normalizer = (x: linalg.Vector) => normalize(x, safeL2NormAsChannel) + + if (doL2Norm) { + embeddings.withColumn("vector", udf(normalizer).apply(col("vector"))) + } else { + embeddings + } + } + + private def runTyped[T: ClassTag](data: DataFrame): RDD[(T, Array[Double])] = { + // we should put sequences starts from the same vertex + // to the same partition when possible + data + .withColumn("hash_id", pmod(hash(col(sequenceCol).getItem(0)), lit(numPartitions))) + .repartition(numPartitions, col("hash_id")) + .sortWithinPartitions(col("hash_id")) + .select(col(sequenceCol)) + .rdd + .map(_.getSeq[T](0)) + .mapPartitions { iter => + val elemType = implicitly[ClassTag[T]].runtimeClass + elemType match { Review Comment: This is the only real part that needs to be updated to make it generic and fix is mechanical. I can do so. The only reason why it is not extending ML classes was "it was just easier to do" but with migration to the Apache Spark we can make it a nice addition to the ML seq2vec models family for cases that word2vec cannot handle. hash2vec is the easiest model of the class "extreme scale embeddings", perfect case for anyone who needs the "good enough baseline" for graph problems, item-product problems and other similar where the vocab exceed the word2vec limits. ########## graphframes/src/main/scala/org/apache/spark/graphframes/propertygraph/PropertyGraphFrame.scala: ########## Review Comment: This one needs to be changed a little to implement the queries. See https://github.com/graphframes/graphframes/pull/849 for details and another issue is https://github.com/graphframes/graphframes/issues/721 The Spark' policy about breaking changes is even more strict than policy in GF, so either we need to include 829 or does not include this one at all into the initial migration. ########## graphframes/src/test/scala/org/apache/spark/graphframes/ldbc/TestLDBCCases.scala: ########## @@ -0,0 +1,258 @@ +/* + * 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.spark.graphframes.ldbc + +import java.io.File +import java.nio.file._ +import java.util.Properties + +import org.apache.spark.graphframes.GraphFrame +import org.apache.spark.graphframes.GraphFrameTestSparkContext +import org.apache.spark.graphframes.SparkFunSuite +import org.apache.spark.graphframes.examples.LDBCUtils +import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.functions.abs +import org.apache.spark.sql.functions.col +import org.apache.spark.sql.functions.lit +import org.apache.spark.sql.functions.sum +import org.apache.spark.sql.types.DoubleType +import org.apache.spark.sql.types.IntegerType +import org.apache.spark.sql.types.LongType +import org.apache.spark.sql.types.StructField +import org.apache.spark.sql.types.StructType + +class TestLDBCCases extends SparkFunSuite with GraphFrameTestSparkContext { Review Comment: LDBC files are very small and distributed under the Apache 2.0... What do you think about adding them to "resources" instead of downloading on the fly (using `downloadLDBCIfNotExists`)? They are really [small](https://ldbcouncil.org/benchmarks/graphalytics/datasets/), like 1-2 KiB. Should not add a lot to the repo size <img width="1043" height="815" alt="image" src="https://github.com/user-attachments/assets/309c8b76-d25e-4bb2-bf60-b3a0bc6bcee8" /> ########## graphframes/src/main/scala/org/apache/spark/graphframes/lib/RandomizedContraction.scala: ########## @@ -0,0 +1,289 @@ +/* + * 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.spark.graphframes.lib + +import java.io.IOException +import java.util.UUID + +import scala.collection.mutable.Stack +import scala.util.Random + +import org.apache.hadoop.fs.Path + +import org.apache.spark.graphframes.GraphFrame +import org.apache.spark.graphframes.GraphFrame.{DST, ID, LONG_DST, LONG_ID, LONG_SRC, SRC} +import org.apache.spark.graphframes.Logging +import org.apache.spark.sql.Column +import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.catalyst.FunctionIdentifier +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.functions._ +import org.apache.spark.sql.graphframes.expressions.FiniteAXPlusB +import org.apache.spark.storage.StorageLevel + +/** + * Implementation of parallel connected components algorithm using randomized contraction, based + * on Boegeholz, Harald, Michael Brand, and Radu-Alexandru Todor. "In-database connected component + * analysis." 2020 IEEE 36th International Conference on Data Engineering (ICDE). IEEE, 2020. + * + * The algorithm contracts the graph iteratively using random linear functions, until no edges + * remain, then reconstructs the component identifiers. + */ +private[graphframes] object RandomizedContraction extends Logging with Serializable { + private val CHECKPOINT_NAME_PREFIX = "randomized-contraction" + + private def prepare(graph: GraphFrame): GraphFrame = { + val vertices = graph.indexedVertices + .select(col(LONG_ID).as(ID)) + + val edges = graph.indexedEdges + .select(col(LONG_SRC).as(SRC), col(LONG_DST).as(DST)) + val symmetricEdges = edges + .union(edges.select(col(DST).alias(SRC), col(SRC).alias(DST))) + .distinct() + GraphFrame(vertices, symmetricEdges) + + } + + def run( + inputGraph: GraphFrame, + useLabelsAsComponents: Boolean, + intermediateStorageLevel: StorageLevel, + useLocalCheckpoints: Boolean, + checkpointInterval: Int, + isGraphPrepared: Boolean): DataFrame = { + val spark = inputGraph.vertices.sparkSession + val sc = spark.sparkContext + val runId = UUID.randomUUID().toString.takeRight(8) + val logPrefix = s"[CC $runId]" + + val checkpointDir = sc.getCheckpointDir + .map { d => + new Path(d, s"$CHECKPOINT_NAME_PREFIX-$runId").toString + } + .getOrElse { + // Spark-Connect workaround + spark.conf.getOption("spark.checkpoint.dir") match { Review Comment: It was my decision, but right now I'm not 100% it was a good idea to write parquet-files directly to the `spark.checkpoint.dir`. I'm not sure we need to keep as is. Maybe a separate argument, like a `workDir`? WDYT? -- 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] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
