dongjoon-hyun commented on code in PR #55518:
URL: https://github.com/apache/spark/pull/55518#discussion_r4316536111
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupBasedRowLevelOperationScanPlanning.scala:
##########
@@ -92,6 +95,38 @@ object GroupBasedRowLevelOperationScanPlanning extends
Rule[LogicalPlan] with Pr
}
}
+ /**
+ * Returns the columns of `relation` that the projections and filters
directly above each read
+ * of the table use, as the rest of the query sees only their output. Only
whole columns are
+ * pruned, as the query is not rewritten for a pruned nested schema.
Review Comment:
**22.** Minor: copy-on-write narrow scans are pruned only at the top level,
which this Scaladoc states but the PR description and the public Javadoc do
not. `readAttrs` returns bare relation attributes, so
`PushDownUtils.pruneColumns` (L71) keeps every field of a struct that the
UPDATE references only through a nested field, while delta-based scans prune
nested fields through `V2ScanRelationPushDown`
(`V2ScanRelationPushDown.scala:1003-1016`). For `pk INT NOT NULL, salary INT, s
STRUCT<c1: INT, c2: INT>, dep STRING` and `UPDATE t SET salary = s.c1 WHERE pk
= 1`, the copy-on-write scan is pruned to `s STRUCT<c1: INT, c2: INT>` and the
delta scan to `s STRUCT<c1: INT>`. The nested `CHECK` test
(`GroupBasedColumnUpdateTableSuite.scala:639-646`) only checks that `s` is
read. This is not a regression, since the base reads every column, but on a
wide struct it is a large part of the I/O this feature is meant to avoid. Could
the PR description and the scan-builder paragraph of `SupportsC
olumnUpdates` (`SupportsColumnUpdates.java:41-44`) state that copy-on-write
scans are pruned only at the top level, and leave nested pruning to a separate
JIRA?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteRowLevelCommand.scala:
##########
@@ -129,6 +184,24 @@ trait RewriteRowLevelCommand extends Rule[LogicalPlan] {
V2ExpressionUtils.resolveRef[AttributeReference](FieldReference(name),
plan)
}
+ protected def isIdentityAssignment(key: Attribute, value: Expression):
Boolean = {
Review Comment:
**20. An explicit `SET pk = pk` on an undeclared row-ID column makes the
distribution check reject a row-ID column, which the Javadoc allows.** This
method strips one `Alias` before `semanticEquals`, so it treats `SET pk = pk`,
which assignment alignment turns into `Alias(pk, "pk")`
(`TableOutputResolver.scala:331`), as an identity: `pk` is not reported in
`updatedColumns()` and does not have to be declared. `buildOriginalRowIdValues`
(L416-429) still uses `!key.semanticEquals(value)`, so for the same assignment
it adds `__original_row_id_pk` (`RewriteUpdateTable.scala:220`).
`newLazyRowIdProjection` (L390-399) then reads that column instead of `pk`, no
projection reads `pk`, `orderReadColumnsFirst` moves it after the last read
ordinal, and `validateColumnUpdateWrite` (`V2Writes.scala:157-166`) rejects a
write that clusters by `pk`.
With the setup of "distribution by an undeclared row ID column is allowed"
(`DeltaBasedColumnUpdateTableSuite.scala:346`: `column-update-req-attrs` =
`salary,dep` and `column-update-cluster-by` = `pk`), `UPDATE t SET salary = -1
WHERE pk = 1` succeeds, but `UPDATE t SET salary = -1, pk = pk WHERE pk = 1`
fails with `COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS` and `[pk]`,
although `SupportsColumnUpdates.java:81-84` exempts row-ID columns for delta
operations. On the wide path the extra column is harmless, because the row
projection still reads `pk`.
Could `buildOriginalRowIdValues` use `isIdentityAssignment` as well, and
could that test get a `pk = pk` variant? Its other caller,
`RewriteMergeIntoTable.scala:351`, gets the same result, since an identity
assignment does not change the row ID.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -258,6 +354,407 @@ class InMemoryRowLevelOperationTable private (
}
}
+ // A delta-based operation that supports column-level updates: Spark sends
only the declared
+ // columns in the row projection instead of the full row schema. It declares
`pk` (the
+ // row-lookup key) and `dep` (an unconditionally declared base column) plus
whatever columns
+ // Spark reports as being assigned via
`RowLevelOperationInfo#updatedColumns()`.
+ class DeltaBasedColumnUpdateOperation(
+ command: Command,
+ updatedCols: Seq[NamedReference] = Nil,
+ options: CaseInsensitiveStringMap = CaseInsensitiveStringMap.empty())
+ extends DeltaBasedOperation(command, options)
+ with SupportsColumnUpdates {
+ override def representUpdateAsDeleteAndInsert(): Boolean = false
+ override def requiredDataAttributes(): Array[NamedReference] = {
+ val base = Seq(FieldReference("pk"), FieldReference("dep"))
+ val baseNames = base.map(_.describe()).toSet
+ (base ++ updatedCols.filterNot(r =>
baseNames.contains(r.describe()))).toArray
+ }
+
+ private val clusterRef: NamedReference =
Option(properties.get(COLUMN_UPDATE_CLUSTER_BY))
+ .map(FieldReference(_))
+ .getOrElse(PARTITION_COLUMN_REF)
+
+ override def newWriteBuilder(info: LogicalWriteInfo): DeltaWriteBuilder = {
+ lastWriteInfo = info
+ // Capture info into a local val so nested writer/commit closures see a
stable schema
+ // even if a subsequent newWriteBuilder call mutates lastWriteInfo.
+ val capturedInfo = info
+ val capturedWriteSchema = if
(capturedInfo.columnUpdateSchema().isPresent) {
+ capturedInfo.columnUpdateSchema().get()
+ } else {
+ capturedInfo.schema()
+ }
+ new DeltaWriteBuilder {
+ override def build(): DeltaWrite =
+ new DeltaWrite with RequiresDistributionAndOrdering {
Review Comment:
**25.** Minor: the delta column-update fixtures ignore `no-metadata`, so no
test sends narrow rows through `DeltaWritingSparkTask`. With `no-metadata`, the
inherited `requiredMetadataAttributes()` is empty (L307-313), but this write
still requires clustering and ordering by `clusterRef`, which defaults to
`_partition` (L374-376), and the split write hard-codes `_partition` (item 18).
Every narrow UPDATE then fails with
`COLUMN_UPDATE_UNDECLARED_WRITE_REQUIREMENT_COLUMNS` and `[_partition]`,
whereas the parent returns a plain `DeltaWrite` (L326-329) and the
copy-on-write operation clusters by `dep` (L587-588). `WriteDeltaExec` uses
`DeltaWritingSparkTask` exactly when there is no metadata projection
(`WriteToDataSourceV2Exec.scala:435-440`), so that task never receives narrow
rows in a test. The round-4 comment
([r4074793050](https://github.com/apache/spark/pull/55518#discussion_r4074793050))
raised the same gap for copy-on-write, which now has no-metadata tests
(`GroupBasedColumnU
pdateTableSuite.scala:145`, `:181`). Similarly, L367 overrides
`representUpdateAsDeleteAndInsert()` to `false`, so `split-updates` is silently
ignored on these tables. Could the delta builders honor `no-metadata` like the
parent, and could a no-metadata variant of `DeltaBasedColumnUpdateTableSuite`
run, as `DeltaBasedNoMetadataUpdateTableSuite` does for full rows?
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/InMemoryRowLevelOperationTable.scala:
##########
@@ -258,6 +354,407 @@ class InMemoryRowLevelOperationTable private (
}
}
+ // A delta-based operation that supports column-level updates: Spark sends
only the declared
+ // columns in the row projection instead of the full row schema. It declares
`pk` (the
+ // row-lookup key) and `dep` (an unconditionally declared base column) plus
whatever columns
+ // Spark reports as being assigned via
`RowLevelOperationInfo#updatedColumns()`.
+ class DeltaBasedColumnUpdateOperation(
+ command: Command,
+ updatedCols: Seq[NamedReference] = Nil,
+ options: CaseInsensitiveStringMap = CaseInsensitiveStringMap.empty())
+ extends DeltaBasedOperation(command, options)
+ with SupportsColumnUpdates {
+ override def representUpdateAsDeleteAndInsert(): Boolean = false
+ override def requiredDataAttributes(): Array[NamedReference] = {
+ val base = Seq(FieldReference("pk"), FieldReference("dep"))
+ val baseNames = base.map(_.describe()).toSet
+ (base ++ updatedCols.filterNot(r =>
baseNames.contains(r.describe()))).toArray
+ }
+
+ private val clusterRef: NamedReference =
Option(properties.get(COLUMN_UPDATE_CLUSTER_BY))
+ .map(FieldReference(_))
+ .getOrElse(PARTITION_COLUMN_REF)
+
+ override def newWriteBuilder(info: LogicalWriteInfo): DeltaWriteBuilder = {
+ lastWriteInfo = info
+ // Capture info into a local val so nested writer/commit closures see a
stable schema
+ // even if a subsequent newWriteBuilder call mutates lastWriteInfo.
+ val capturedInfo = info
+ val capturedWriteSchema = if
(capturedInfo.columnUpdateSchema().isPresent) {
+ capturedInfo.columnUpdateSchema().get()
+ } else {
+ capturedInfo.schema()
+ }
+ new DeltaWriteBuilder {
+ override def build(): DeltaWrite =
+ new DeltaWrite with RequiresDistributionAndOrdering {
+
+ override def requiredDistribution(): Distribution = {
+ Distributions.clustered(Array(clusterRef))
+ }
+
+ override def requiredOrdering(): Array[SortOrder] = {
+ Array[SortOrder](
+ LogicalExpressions.sort(
+ clusterRef,
+ SortDirection.ASCENDING,
+ SortDirection.ASCENDING.defaultNullOrdering())
+ )
+ }
+
+ override def toBatch: DeltaBatchWrite =
+ new TestBatchWrite with DeltaBatchWrite {
+ override def createBatchWriterFactory(
+ info: PhysicalWriteInfo): DeltaWriterFactory = {
+ new DeltaBufferedRowsWriterFactory(capturedWriteSchema)
+ }
+
+ // For column-update writes, updated rows contain only the
declared columns
+ // (columnUpdateSchema from LogicalWriteInfo). We expand each
row to the full
+ // table schema by overlaying them on the base row found by pk.
+ override protected def doCommit(messages:
Array[WriterCommitMessage]): Unit =
+ dataMap.synchronized {
+ val newData = messages.map(_.asInstanceOf[BufferedRows])
+ val writeSchema = capturedWriteSchema
+ val writeFieldIdx =
writeSchema.fieldNames.zipWithIndex.toMap
+
+ val mergedData = newData.map { buf =>
+ val merged = new BufferedRows(buf.key, schema)
+ val updateOpName = UTF8String.fromString(Update.toString)
+ val insertOpName = UTF8String.fromString(Insert.toString)
+ buf.log.foreach { logRow =>
+ val opName = logRow.getUTF8String(0)
+ if (opName == updateOpName) {
+ val pk = logRow.getInt(1)
+ val narrowRow = logRow.get(3,
writeSchema).asInstanceOf[InternalRow]
+ val baseRow = currentRowByPk(pk)
+ val fullRow = overlayNarrowRow(baseRow, narrowRow,
writeFieldIdx)
+ merged.rows.append(fullRow)
+ } else if (opName == insertOpName) {
+ // INSERT rows arrive with the full table schema via
writer.insert()
+ val insertRow = logRow.get(3,
schema).asInstanceOf[InternalRow]
+ merged.rows.append(insertRow.copy())
+ }
+ }
+ merged
+ }
+
+ withDeletes(newData)
+ withData(mergedData)
+ lastWriteLog = newData.flatMap(buffer =>
buffer.log).toIndexedSeq
+ }
+
+ override def abort(messages: Array[WriterCommitMessage]): Unit
= {}
+ }
+ }
+ }
+ }
+ }
+
+ class DeltaBasedColumnUpdateOperationWithReqAttrs(
+ command: Command,
+ reqCols: Array[String],
+ options: CaseInsensitiveStringMap = CaseInsensitiveStringMap.empty())
+ extends DeltaBasedColumnUpdateOperation(command, options = options) {
+ override def requiredDataAttributes(): Array[NamedReference] =
reqCols.map(FieldReference(_))
+ }
+
+ class DeltaBasedColumnUpdateSplitOperation(
+ command: Command,
+ updatedCols: Seq[NamedReference] = Nil,
+ options: CaseInsensitiveStringMap = CaseInsensitiveStringMap.empty())
+ extends DeltaBasedColumnUpdateOperation(command, updatedCols, options) {
+ override def representUpdateAsDeleteAndInsert(): Boolean = true
+
+ override def newWriteBuilder(info: LogicalWriteInfo): DeltaWriteBuilder = {
+ lastWriteInfo = info
+ // Capture info into a local val so nested writer/commit closures see a
stable schema
+ // even if a subsequent newWriteBuilder call mutates lastWriteInfo.
+ val capturedInfo = info
+ val capturedWriteSchema = if
(capturedInfo.columnUpdateSchema().isPresent) {
+ capturedInfo.columnUpdateSchema().get()
+ } else {
+ capturedInfo.schema()
+ }
+ new DeltaWriteBuilder {
+ override def build(): DeltaWrite =
+ new DeltaWrite with RequiresDistributionAndOrdering {
+ override def requiredDistribution(): Distribution =
+ Distributions.clustered(Array(PARTITION_COLUMN_REF))
+ override def requiredOrdering(): Array[SortOrder] =
Array[SortOrder](
+ LogicalExpressions.sort(
+ PARTITION_COLUMN_REF,
+ SortDirection.ASCENDING,
+ SortDirection.ASCENDING.defaultNullOrdering()))
+ override def toBatch: DeltaBatchWrite =
+ new TestBatchWrite with DeltaBatchWrite {
+ override def createBatchWriterFactory(
+ info: PhysicalWriteInfo): DeltaWriterFactory = {
+ new DeltaBufferedRowsWriterFactory(capturedWriteSchema)
+ }
+
+ // For delete+reinsert with narrow writes, the REINSERT row
has only the
+ // connector-declared columns (requiredDataAttributes order).
The base row is
+ // looked up by `pk`, so `pk` must be declared for the write
to commit.
+ // Reconstruct the full row by overlaying the narrow row onto
the original.
+ override protected def doCommit(messages:
Array[WriterCommitMessage]): Unit =
+ dataMap.synchronized {
+ val newData = messages.map(_.asInstanceOf[BufferedRows])
+ val writeSchema = capturedWriteSchema
+ val writeFieldIdx =
writeSchema.fieldNames.zipWithIndex.toMap
+ val reinsertOpName =
UTF8String.fromString(Reinsert.toString)
+ val insertOpName = UTF8String.fromString(Insert.toString)
+ val pkIdx = writeFieldIdx("pk")
Review Comment:
**23. Every row-level DELETE on a split column-update table fails at commit,
so the DELETE fallback is not tested on that operation.** Unlike the parent,
which takes `pk` from the row ID inside its `Update` branch (L427-428), this
lookup runs before the log loop. A delta DELETE has no row projection, so
`schema()` is empty and `columnUpdateSchema()` is absent
(`V2Writes.scala:240-241`), `capturedWriteSchema` (L474-478) is empty, and this
line throws `NoSuchElementException: key not found: pk`. Any DELETE that is not
metadata-only, e.g. `DELETE FROM t WHERE pk < 2`, fails this way on
`column-update-split`, `column-update-split-req-attrs` and
`column-update-split-row-id` tables. This is the same line as item 14, but a
different case: item 14 is a split UPDATE that cannot commit.
The round-4 comment
([r4074793068](https://github.com/apache/spark/pull/55518#discussion_r4074793068))
asked for the DELETE and MERGE fallback to be tested on both connector types,
and the PR description lists "DELETE and MERGE falling back to full-width rows"
among what the suites cover. The split operation got "column-update split:
MERGE falls back to full-width rows"
(`DeltaBasedColumnUpdateTableSuite.scala:225`), but no DELETE test. Could the
`pk` lookup move into the `Reinsert` branch, and could a "column-update split:
row-level DELETE falls back to full-width rows" test be added?
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/GroupBasedColumnUpdateTableSuite.scala:
##########
@@ -0,0 +1,754 @@
+/*
+ * 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.sql.connector
+
+import org.apache.spark.{SparkRuntimeException,
SparkUnsupportedOperationException}
+import org.apache.spark.sql.{AnalysisException, Row}
+import org.apache.spark.sql.catalyst.optimizer.ColumnPruning
+import org.apache.spark.sql.catalyst.plans.logical.Union
+import org.apache.spark.sql.connector.catalog.{InMemoryBaseTable,
InMemoryRowLevelOperationTable}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
+
+class GroupBasedColumnUpdateTableSuite extends UpdateTableSuiteBase {
Review Comment:
**24. Three inherited UPDATE tests do not run against the narrow fixture.**
Since finding 25
([r3741376793](https://github.com/apache/spark/pull/55518#discussion_r3741376793)),
this suite and `DeltaBasedColumnUpdateTableSuite` extend the existing UPDATE
bases, and the PR description says "so the existing UPDATE tests also run
against narrow writes". But `extraTableProps` reaches a table only through the
`createTable` helper (`RowLevelOperationSuiteBase.scala:136`), and three
inherited tests create their table another way, so both suites run them on a
plain table:
- "update table with expression-based default values" (`catalog.createTable`
with a `TableInfo` without properties, `UpdateTableSuiteBase.scala:100-101`)
- "update with current_timestamp default value using DEFAULT keyword"
(`CREATE TABLE`, `UpdateTableSuiteBase.scala:849-850`)
- "update table with recursive CTE" (`CREATE TABLE`,
`UpdateTableSuiteBase.scala:916-917`)
The first one, `SET value = DEFAULT` on a column with an existence default,
is worth running narrow. The fixtures also declare `pk` and `dep`
unconditionally (`InMemoryRowLevelOperationTable.scala:369`, `:574`), so the
CTE table `(val INT)` would fail with
`COLUMN_UPDATE_UNKNOWN_REQUIRED_DATA_ATTRIBUTE` if the properties did reach it.
Could these tables pick up `extraTableProps`, with the fixtures declaring only
existing columns, or could the description say which inherited tests stay on
the wide path?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTable.scala:
##########
@@ -228,4 +240,114 @@ object RewriteUpdateTable extends RewriteRowLevelCommand {
val expandOutput = generateExpandOutput(attrs, outputs)
Expand(outputs, expandOutput, matchedRowsPlan)
}
+
+ /**
+ * Returns the output of the write relation, which is the layout of the data
rows the connector
+ * receives. If the operation opts into column updates, this is the
connector's
+ * `requiredDataAttributes()`, validated against the assignments. Otherwise
it is the full
+ * relation output.
+ */
+ private def resolveWriteAttrs(
+ relation: DataSourceV2Relation,
+ operation: RowLevelOperation,
+ assignments: Seq[Assignment]): Seq[AttributeReference] = operation match
{
+ case op: SupportsColumnUpdates with SupportsDelta if
op.representUpdateAsDeleteAndInsert =>
+ val attrs = resolveDeclaredAttrs(relation, op, assignments)
+ val rowIdAttrs = resolveRowIdAttrs(relation, op)
+ validateNoRowIdReassignment(op, relation, attrs, assignments, rowIdAttrs)
+ validateRowIdDeclared(op, attrs, rowIdAttrs,
resolveRequiredMetadataAttrs(relation, op))
+ attrs
+ case op: SupportsColumnUpdates =>
+ resolveDeclaredAttrs(relation, op, assignments)
+ case _ =>
+ relation.output
+ }
+
+ /**
+ * Resolves the connector's `requiredDataAttributes()` and checks that it
covers every assigned
+ * column.
+ */
+ private def resolveDeclaredAttrs(
+ relation: DataSourceV2Relation,
+ operation: SupportsColumnUpdates,
+ assignments: Seq[Assignment]): Seq[AttributeReference] = {
+ val attrs = resolveRequiredDataAttrs(relation, operation)
+ validateUpdatedColumnsSubset(operation, assignments, attrs)
+ attrs
+ }
+
+ /**
+ * Enforces that every column being assigned (non-identity) is present in
the connector-declared
+ * `requiredDataAttributes()`. Comparison is at root-column granularity.
+ */
+ private def validateUpdatedColumnsSubset(
+ operation: RowLevelOperation,
+ assignments: Seq[Assignment],
+ connectorDataAttrs: Seq[AttributeReference]): Unit = {
+ val declaredIds = connectorDataAttrs.map(_.exprId).toSet
+ val missing = assignments.collect {
+ case Assignment(key: AttributeReference, value)
+ if !isIdentityAssignment(key, value) &&
!declaredIds.contains(key.exprId) =>
+ key.name
+ }.distinct
+ if (missing.nonEmpty) {
+ throw
QueryCompilationErrors.requiredDataAttributesMissingUpdatedColumnsError(
+ operation.getClass.getName, missing)
+ }
+ }
+
+ /**
+ * For connectors that opt into narrow column updates AND represent UPDATE
as delete + insert,
+ * reject reassignment of any row-ID column, unless
`requiredDataAttributes()` covers every
+ * column in the relation. In that case the REINSERT payload already is the
full row with the
+ * new row-ID value, and the DELETE half still carries the original row-ID
via
+ * `newLazyRowIdProjection`, so reassignment is safe. Otherwise the REINSERT
path has no
+ * row-ID channel to reconstruct columns outside `requiredDataAttributes()`.
+ */
+ private def validateNoRowIdReassignment(
+ operation: RowLevelOperation,
+ relation: DataSourceV2Relation,
+ connectorDataAttrs: Seq[AttributeReference],
+ assignments: Seq[Assignment],
+ rowIdAttrs: Seq[Attribute]): Unit = {
+ val declaredIds = connectorDataAttrs.map(_.exprId).toSet
+ if (relation.output.forall(a => declaredIds.contains(a.exprId))) {
Review Comment:
**21. This exemption never applies when the target relation carries metadata
columns.** `relation.output` is not always the data columns. When an UPDATE is
not resolved in the first iteration of the Resolution batch, for example
because a comparison needs type coercion, `AddMetadataColumns`
(`Analyzer.scala:691`, `:1145`) runs on the resolved command in the next
iteration and calls `withMetadataColumns()` on the target relation
(`Analyzer.scala:1194`), which appends every metadata column to its output
(`DataSourceV2Relation.scala:137-141`). Metadata columns cannot be declared
(`RewriteRowLevelCommand.scala:157`), so this `forall` is then false even when
every data column is declared.
With the setup of "row-ID reassignment is allowed when
requiredDataAttributes covers every table column"
(`DeltaBasedColumnUpdateTableSuite.scala:659`), `UPDATE t SET pk = pk + 10,
salary = -1 WHERE dep = 'hr' AND _partition = 'hr' AND salary > 0.5` fails with
`COLUMN_UPDATE_SPLIT_ROW_ID_REASSIGNMENT`, while the same statement with
`salary > 0`, or without `_partition = 'hr'`, is exempted. This is the
exemption added for finding 22
([r3741376785](https://github.com/apache/spark/pull/55518#discussion_r3741376785)).
Could this compare only the data columns, e.g. `relation.output.filterNot(a
=> MetadataAttribute.isValid(a.metadata))`?
--
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]