shangxinli commented on code in PR #18683:
URL: https://github.com/apache/hudi/pull/18683#discussion_r3259722122
##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala:
##########
@@ -133,6 +134,19 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
}
}
+ @transient private var cachedBlobDetection: (StructType, Set[Int]) = _
Review Comment:
Should we consider thread-safe?
##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/VectorConversionUtils.java:
##########
@@ -265,6 +267,104 @@ public static void convertRowVectorColumns(InternalRow
row, GenericInternalRow r
}
}
+ //
---------------------------------------------------------------------------
+ // Blob descriptor-mode helpers (Parquet DESCRIPTOR read path)
+ //
---------------------------------------------------------------------------
+
+ /**
+ * Detects BLOB columns from Spark StructType metadata annotations.
+ *
+ * @param schema Spark StructType (may be null)
+ * @return set of field ordinals that are BLOB columns; empty set if none
found
+ */
+ public static Set<Integer> detectBlobColumnsFromMetadata(StructType schema) {
+ Set<Integer> blobColumnIndices = new LinkedHashSet<>();
+ if (schema == null) {
+ return blobColumnIndices;
+ }
+ StructField[] fields = schema.fields();
+ for (int i = 0; i < fields.length; i++) {
+ StructField field = fields[i];
+ if (field.metadata().contains(HoodieSchema.TYPE_METADATA_FIELD)) {
+ String typeStr =
field.metadata().getString(HoodieSchema.TYPE_METADATA_FIELD);
+ HoodieSchema parsed = HoodieSchema.parseTypeDescriptor(typeStr);
+ if (parsed != null && parsed.getType() == HoodieSchemaType.BLOB) {
+ blobColumnIndices.add(i);
+ }
+ }
+ }
+ return blobColumnIndices;
+ }
+
+ /**
+ * Strips the {@code data} sub-field from BLOB struct columns so the Parquet
reader
+ * skips the binary column chunk entirely (genuine I/O savings).
+ *
+ * <p>The returned schema has 2-field blob structs: {@code {type,
reference}} instead of
+ * the full {@code {type, data, reference}}. Use {@link
#buildBlobNullPadRowMapper} to
+ * re-insert null at the {@code data} position after reading.
+ *
+ * @param schema the original Spark schema
+ * @param blobColumns ordinals of blob columns (from {@link
#detectBlobColumnsFromMetadata})
+ * @return a new StructType with the {@code data} sub-field removed from
blob structs
+ */
+ public static StructType stripBlobDataField(StructType schema, Set<Integer>
blobColumns) {
+ StructField[] fields = schema.fields();
+ StructField[] newFields = new StructField[fields.length];
+ for (int i = 0; i < fields.length; i++) {
+ if (blobColumns.contains(i) && fields[i].dataType() instanceof
StructType) {
+ StructType blobStruct = (StructType) fields[i].dataType();
+ List<StructField> kept = new ArrayList<>();
+ for (StructField sub : blobStruct.fields()) {
+ if (!sub.name().equals(HoodieSchema.Blob.INLINE_DATA_FIELD)) {
+ kept.add(sub);
+ }
+ }
+ StructType strippedStruct = new StructType(kept.toArray(new
StructField[0]));
+ newFields[i] = new StructField(fields[i].name(), strippedStruct,
fields[i].nullable(), fields[i].metadata());
+ } else {
+ newFields[i] = fields[i];
+ }
+ }
+ return new StructType(newFields);
+ }
+
+ /**
+ * Returns a {@link Function} that expands 2-field blob structs {@code
{type, reference}}
+ * back to 3-field structs {@code {type, null, reference}} by inserting null
at the
+ * {@code data} position, then applies the projection callback.
+ *
+ * @param readSchema the Spark schema of incoming rows (blob structs
have 2 fields)
+ * @param blobColumns ordinals of blob columns in {@code readSchema}
+ * @param projectionCallback called with the expanded row; must copy any
data it needs to retain
+ * @return a function that converts one row and returns the projected result
+ */
+ public static Function<InternalRow, InternalRow> buildBlobNullPadRowMapper(
+ StructType readSchema,
+ Set<Integer> blobColumns,
+ Function<InternalRow, InternalRow> projectionCallback) {
+ int numFields = readSchema.fields().length;
+ GenericInternalRow buffer = new GenericInternalRow(numFields);
+ return row -> {
+ for (int i = 0; i < numFields; i++) {
+ if (row.isNullAt(i)) {
+ buffer.setNullAt(i);
+ } else if (blobColumns.contains(i)) {
+ InternalRow blobStruct = row.getStruct(i, 2);
+ // Expand {type, reference} → {type, null, reference}
+ GenericInternalRow expanded = new GenericInternalRow(3);
Review Comment:
`new GenericInternalRow(3)` allocates per blob column per row on a hot path.
For a 1M-row scan with 2 blob columns that's 2M short-lived heap allocations.
##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/blob/BatchedBlobReader.scala:
##########
@@ -208,8 +208,8 @@ class BatchedBlobReader(
// Dispatch based on storage_type (field 0)
val storageType = accessor.getString(blobStruct, 0)
if (storageType == HoodieSchema.Blob.INLINE) {
- // Case 1: Inline — bytes are in field 1
- val bytes = accessor.getBytes(blobStruct, 1)
+ // Case 1: Inline — bytes are in field 1 (may be null in
DESCRIPTOR mode)
+ val bytes = if (accessor.isNullAt(blobStruct, 1)) null else
accessor.getBytes(blobStruct, 1)
Review Comment:
Is there a case that `read_blob()` silently returns `null` to the user
instead of throwing where it should not?
##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/VectorConversionUtils.java:
##########
@@ -265,6 +267,104 @@ public static void convertRowVectorColumns(InternalRow
row, GenericInternalRow r
}
}
+ //
---------------------------------------------------------------------------
+ // Blob descriptor-mode helpers (Parquet DESCRIPTOR read path)
+ //
---------------------------------------------------------------------------
+
+ /**
+ * Detects BLOB columns from Spark StructType metadata annotations.
+ *
+ * @param schema Spark StructType (may be null)
+ * @return set of field ordinals that are BLOB columns; empty set if none
found
+ */
+ public static Set<Integer> detectBlobColumnsFromMetadata(StructType schema) {
+ Set<Integer> blobColumnIndices = new LinkedHashSet<>();
+ if (schema == null) {
+ return blobColumnIndices;
+ }
+ StructField[] fields = schema.fields();
+ for (int i = 0; i < fields.length; i++) {
+ StructField field = fields[i];
+ if (field.metadata().contains(HoodieSchema.TYPE_METADATA_FIELD)) {
+ String typeStr =
field.metadata().getString(HoodieSchema.TYPE_METADATA_FIELD);
+ HoodieSchema parsed = HoodieSchema.parseTypeDescriptor(typeStr);
+ if (parsed != null && parsed.getType() == HoodieSchemaType.BLOB) {
+ blobColumnIndices.add(i);
+ }
+ }
+ }
+ return blobColumnIndices;
+ }
+
+ /**
+ * Strips the {@code data} sub-field from BLOB struct columns so the Parquet
reader
+ * skips the binary column chunk entirely (genuine I/O savings).
+ *
+ * <p>The returned schema has 2-field blob structs: {@code {type,
reference}} instead of
+ * the full {@code {type, data, reference}}. Use {@link
#buildBlobNullPadRowMapper} to
+ * re-insert null at the {@code data} position after reading.
+ *
+ * @param schema the original Spark schema
+ * @param blobColumns ordinals of blob columns (from {@link
#detectBlobColumnsFromMetadata})
+ * @return a new StructType with the {@code data} sub-field removed from
blob structs
+ */
+ public static StructType stripBlobDataField(StructType schema, Set<Integer>
blobColumns) {
+ StructField[] fields = schema.fields();
+ StructField[] newFields = new StructField[fields.length];
+ for (int i = 0; i < fields.length; i++) {
+ if (blobColumns.contains(i) && fields[i].dataType() instanceof
StructType) {
+ StructType blobStruct = (StructType) fields[i].dataType();
+ List<StructField> kept = new ArrayList<>();
+ for (StructField sub : blobStruct.fields()) {
+ if (!sub.name().equals(HoodieSchema.Blob.INLINE_DATA_FIELD)) {
+ kept.add(sub);
+ }
+ }
+ StructType strippedStruct = new StructType(kept.toArray(new
StructField[0]));
+ newFields[i] = new StructField(fields[i].name(), strippedStruct,
fields[i].nullable(), fields[i].metadata());
+ } else {
+ newFields[i] = fields[i];
+ }
+ }
+ return new StructType(newFields);
+ }
+
+ /**
+ * Returns a {@link Function} that expands 2-field blob structs {@code
{type, reference}}
+ * back to 3-field structs {@code {type, null, reference}} by inserting null
at the
+ * {@code data} position, then applies the projection callback.
+ *
+ * @param readSchema the Spark schema of incoming rows (blob structs
have 2 fields)
+ * @param blobColumns ordinals of blob columns in {@code readSchema}
+ * @param projectionCallback called with the expanded row; must copy any
data it needs to retain
+ * @return a function that converts one row and returns the projected result
+ */
+ public static Function<InternalRow, InternalRow> buildBlobNullPadRowMapper(
+ StructType readSchema,
+ Set<Integer> blobColumns,
+ Function<InternalRow, InternalRow> projectionCallback) {
+ int numFields = readSchema.fields().length;
+ GenericInternalRow buffer = new GenericInternalRow(numFields);
+ return row -> {
+ for (int i = 0; i < numFields; i++) {
+ if (row.isNullAt(i)) {
+ buffer.setNullAt(i);
+ } else if (blobColumns.contains(i)) {
+ InternalRow blobStruct = row.getStruct(i, 2);
+ // Expand {type, reference} → {type, null, reference}
+ GenericInternalRow expanded = new GenericInternalRow(3);
Review Comment:
Also, the hardcoded `3` is fragile.
##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/blob/ReadBlobRule.scala:
##########
@@ -41,45 +45,94 @@ import scala.collection.mutable.ArrayBuffer
*/
case class ReadBlobRule(spark: SparkSession) extends Rule[LogicalPlan] {
- override def apply(plan: LogicalPlan): LogicalPlan = plan resolveOperatorsUp
{
- case Project(projectList, Filter(condition, child))
- if containsReadBlobExpression(projectList)
- && containsReadBlobInExpression(condition)
- && !child.isInstanceOf[BatchedBlobRead] =>
- val projectBlobCols = extractAllBlobColumns(projectList)
- val filterBlobCols = extractBlobColumnsFromExpression(condition)
- val blobColumns = (projectBlobCols ++ filterBlobCols)
- .foldLeft((mutable.LinkedHashSet.empty[ExprId],
ArrayBuffer.empty[AttributeReference])) {
- case ((seen, acc), a) if seen.add(a.exprId) => (seen, acc += a)
- case ((seen, acc), _) => (seen, acc)
- }._2.toSeq
- val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns, child)
- val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
- val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
- Project(newProjectList, Filter(newCondition, wrappedPlan))
-
- case Filter(condition, child)
- if containsReadBlobInExpression(condition)
- && !child.isInstanceOf[BatchedBlobRead] =>
-
- val blobColumns = extractBlobColumnsFromExpression(condition)
- val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns, child)
- val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
- Project(child.output, Filter(newCondition, wrappedPlan))
-
- case Project(projectList, child)
- if containsReadBlobExpression(projectList)
- && !child.isInstanceOf[BatchedBlobRead] =>
-
- val blobColumns = extractAllBlobColumns(projectList)
- val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns, child)
- val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
- Project(newProjectList, wrappedPlan)
-
- case node if containsReadBlobInAnyExpression(node) =>
- throw new IllegalArgumentException(
- s"read_blob() may only appear in SELECT or WHERE clauses. Found in
unsupported logical plan node: ${node.nodeName}. " +
- s"Move read_blob() to a SELECT or WHERE clause. Full plan:
${node.simpleStringWithNodeId()}")
+ override def apply(plan: LogicalPlan): LogicalPlan = {
+ val transformed = plan resolveOperatorsUp {
+ case Project(projectList, Filter(condition, child))
+ if containsReadBlobExpression(projectList)
+ && containsReadBlobInExpression(condition)
+ && !child.isInstanceOf[BatchedBlobRead] =>
+ val projectBlobCols = extractAllBlobColumns(projectList)
+ val filterBlobCols = extractBlobColumnsFromExpression(condition)
+ val blobColumns = (projectBlobCols ++ filterBlobCols)
+ .foldLeft((mutable.LinkedHashSet.empty[ExprId],
ArrayBuffer.empty[AttributeReference])) {
+ case ((seen, acc), a) if seen.add(a.exprId) => (seen, acc += a)
+ case ((seen, acc), _) => (seen, acc)
+ }._2.toSeq
+ val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns,
child)
+ val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
+ val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
+ Project(newProjectList, Filter(newCondition, wrappedPlan))
+
+ case Filter(condition, child)
+ if containsReadBlobInExpression(condition)
+ && !child.isInstanceOf[BatchedBlobRead] =>
+
+ val blobColumns = extractBlobColumnsFromExpression(condition)
+ val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns,
child)
+ val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
+ Project(child.output, Filter(newCondition, wrappedPlan))
+
+ case Project(projectList, child)
+ if containsReadBlobExpression(projectList)
+ && !child.isInstanceOf[BatchedBlobRead] =>
+
+ val blobColumns = extractAllBlobColumns(projectList)
+ val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns,
child)
+ val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
+ Project(newProjectList, wrappedPlan)
+
+ case node if containsReadBlobInAnyExpression(node) =>
+ throw new IllegalArgumentException(
+ s"read_blob() may only appear in SELECT or WHERE clauses. Found in
unsupported logical plan node: ${node.nodeName}. " +
+ s"Move read_blob() to a SELECT or WHERE clause. Full plan:
${node.simpleStringWithNodeId()}")
+ }
+ injectForceContentColumnOptions(transformed)
+ }
+
+ /**
+ * For every [[BatchedBlobRead]] in the transformed plan, find the
underlying Hudi Parquet
+ * [[LogicalRelation]] that produces its blob attribute and add an internal
option carrying the
+ * set of blob column names that must keep their `data` sub-field at read
time. This lets
+ * read_blob() materialize bytes per-column even when the relation was
constructed in
+ * `hoodie.read.blob.inline.mode=DESCRIPTOR`, without mutating any shared
FileFormat state.
+ *
+ * Lance and non-Parquet formats are skipped — Lance handles DESCRIPTOR +
read_blob() natively
+ * via its populated reference field. Relations not in DESCRIPTOR mode at
construction time are
+ * also skipped (no strip happens, so no override is needed).
+ */
+ private def injectForceContentColumnOptions(plan: LogicalPlan): LogicalPlan
= {
+ val readBlobAttrIds: Set[ExprId] = plan.collect {
+ case BatchedBlobRead(_, attr, _) => attr.exprId
+ }.toSet
+ if (readBlobAttrIds.isEmpty) {
+ plan
+ } else {
+ plan transformDown {
+ case lr @ LogicalRelation(rel: HadoopFsRelation, _, _, _) =>
+ rel.fileFormat match {
+ case ff: HoodieFileGroupReaderBasedFileFormat
+ if ff.hoodieFileFormat == HoodieFileFormat.PARQUET &&
ff.isBlobDescriptorMode =>
+ val matched: Set[String] = lr.output.collect {
+ case a: AttributeReference if
readBlobAttrIds.contains(a.exprId) => a.name
Review Comment:
Matching on `a.name` is fragile if users alias blob columns (`SELECT
read_blob(payload) AS p ...`) or if attribute names are qualified differently
between the plan and the file schema. The reader side compares against
`parquetReadStructType.fields(idx).name` (literal Parquet column name) in
`SparkFileFormatInternalRowReaderContext.scala:123` and
`HoodieFileGroupReaderBasedFileFormat.scala:510`. Any divergence silently
misses the override and `read_blob()` returns null.
##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/blob/ReadBlobRule.scala:
##########
@@ -41,45 +45,94 @@ import scala.collection.mutable.ArrayBuffer
*/
case class ReadBlobRule(spark: SparkSession) extends Rule[LogicalPlan] {
- override def apply(plan: LogicalPlan): LogicalPlan = plan resolveOperatorsUp
{
- case Project(projectList, Filter(condition, child))
- if containsReadBlobExpression(projectList)
- && containsReadBlobInExpression(condition)
- && !child.isInstanceOf[BatchedBlobRead] =>
- val projectBlobCols = extractAllBlobColumns(projectList)
- val filterBlobCols = extractBlobColumnsFromExpression(condition)
- val blobColumns = (projectBlobCols ++ filterBlobCols)
- .foldLeft((mutable.LinkedHashSet.empty[ExprId],
ArrayBuffer.empty[AttributeReference])) {
- case ((seen, acc), a) if seen.add(a.exprId) => (seen, acc += a)
- case ((seen, acc), _) => (seen, acc)
- }._2.toSeq
- val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns, child)
- val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
- val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
- Project(newProjectList, Filter(newCondition, wrappedPlan))
-
- case Filter(condition, child)
- if containsReadBlobInExpression(condition)
- && !child.isInstanceOf[BatchedBlobRead] =>
-
- val blobColumns = extractBlobColumnsFromExpression(condition)
- val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns, child)
- val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
- Project(child.output, Filter(newCondition, wrappedPlan))
-
- case Project(projectList, child)
- if containsReadBlobExpression(projectList)
- && !child.isInstanceOf[BatchedBlobRead] =>
-
- val blobColumns = extractAllBlobColumns(projectList)
- val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns, child)
- val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
- Project(newProjectList, wrappedPlan)
-
- case node if containsReadBlobInAnyExpression(node) =>
- throw new IllegalArgumentException(
- s"read_blob() may only appear in SELECT or WHERE clauses. Found in
unsupported logical plan node: ${node.nodeName}. " +
- s"Move read_blob() to a SELECT or WHERE clause. Full plan:
${node.simpleStringWithNodeId()}")
+ override def apply(plan: LogicalPlan): LogicalPlan = {
+ val transformed = plan resolveOperatorsUp {
+ case Project(projectList, Filter(condition, child))
+ if containsReadBlobExpression(projectList)
+ && containsReadBlobInExpression(condition)
+ && !child.isInstanceOf[BatchedBlobRead] =>
+ val projectBlobCols = extractAllBlobColumns(projectList)
+ val filterBlobCols = extractBlobColumnsFromExpression(condition)
+ val blobColumns = (projectBlobCols ++ filterBlobCols)
+ .foldLeft((mutable.LinkedHashSet.empty[ExprId],
ArrayBuffer.empty[AttributeReference])) {
+ case ((seen, acc), a) if seen.add(a.exprId) => (seen, acc += a)
+ case ((seen, acc), _) => (seen, acc)
+ }._2.toSeq
+ val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns,
child)
+ val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
+ val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
+ Project(newProjectList, Filter(newCondition, wrappedPlan))
+
+ case Filter(condition, child)
+ if containsReadBlobInExpression(condition)
+ && !child.isInstanceOf[BatchedBlobRead] =>
+
+ val blobColumns = extractBlobColumnsFromExpression(condition)
+ val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns,
child)
+ val newCondition = replaceReadBlobExpression(condition, blobToDataAttr)
+ Project(child.output, Filter(newCondition, wrappedPlan))
+
+ case Project(projectList, child)
+ if containsReadBlobExpression(projectList)
+ && !child.isInstanceOf[BatchedBlobRead] =>
+
+ val blobColumns = extractAllBlobColumns(projectList)
+ val (wrappedPlan, blobToDataAttr) = wrapWithBlobReads(blobColumns,
child)
+ val newProjectList = transformNamedExpressions(projectList,
blobToDataAttr)
+ Project(newProjectList, wrappedPlan)
+
+ case node if containsReadBlobInAnyExpression(node) =>
+ throw new IllegalArgumentException(
+ s"read_blob() may only appear in SELECT or WHERE clauses. Found in
unsupported logical plan node: ${node.nodeName}. " +
+ s"Move read_blob() to a SELECT or WHERE clause. Full plan:
${node.simpleStringWithNodeId()}")
+ }
+ injectForceContentColumnOptions(transformed)
+ }
+
+ /**
+ * For every [[BatchedBlobRead]] in the transformed plan, find the
underlying Hudi Parquet
+ * [[LogicalRelation]] that produces its blob attribute and add an internal
option carrying the
+ * set of blob column names that must keep their `data` sub-field at read
time. This lets
+ * read_blob() materialize bytes per-column even when the relation was
constructed in
+ * `hoodie.read.blob.inline.mode=DESCRIPTOR`, without mutating any shared
FileFormat state.
+ *
+ * Lance and non-Parquet formats are skipped — Lance handles DESCRIPTOR +
read_blob() natively
+ * via its populated reference field. Relations not in DESCRIPTOR mode at
construction time are
+ * also skipped (no strip happens, so no override is needed).
+ */
+ private def injectForceContentColumnOptions(plan: LogicalPlan): LogicalPlan
= {
+ val readBlobAttrIds: Set[ExprId] = plan.collect {
+ case BatchedBlobRead(_, attr, _) => attr.exprId
+ }.toSet
+ if (readBlobAttrIds.isEmpty) {
+ plan
+ } else {
+ plan transformDown {
+ case lr @ LogicalRelation(rel: HadoopFsRelation, _, _, _) =>
+ rel.fileFormat match {
+ case ff: HoodieFileGroupReaderBasedFileFormat
+ if ff.hoodieFileFormat == HoodieFileFormat.PARQUET &&
ff.isBlobDescriptorMode =>
+ val matched: Set[String] = lr.output.collect {
+ case a: AttributeReference if
readBlobAttrIds.contains(a.exprId) => a.name
+ }.toSet
+ if (matched.isEmpty) {
+ lr
+ } else {
+ val newOptions = rel.options +
+ (HoodieReaderConfig.BLOB_INLINE_READ_FORCE_CONTENT_COLUMNS
-> matched.mkString(","))
+ val newRel = HadoopFsRelation(
Review Comment:
`HadoopFsRelation`'s constructor signature has varied across Spark
3.3/3.4/3.5 (e.g. `userSpecifiedSchema` was added in 3.4). If Hudi still
supports the older Spark profile this will fail to compile cross-version.
Verify against `pom.xml`'s Spark profile matrix, or use `rel.copy(options =
newOptions)` if Scala's case-class copy is available on this Spark version.
--
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]