Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spark: Use snapshot schema when reading snapshot #3722

Merged
merged 4 commits into from
Dec 17, 2021

Conversation

wypoon
Copy link
Contributor

@wypoon wypoon commented Dec 12, 2021

This has been implemented for Spark 2 in #1508. For Spark 3, Ryan Blue proposed a syntax for adding the snapshot id or timestamp to the table identifier in #3269. Here we implement the Spark 3 support for using the snapshot schema by using the proposed table identifier syntax. This is until a new version of Spark 3 is released with support for AS OF in Spark SQL.
Note: The table identifier syntax is for internal use only (as in this implementation) and not meant to be exposed as a publicly supported syntax in SQL. However, for testing, we do test its use from SQL.

@wypoon
Copy link
Contributor Author

wypoon commented Dec 13, 2021

@rdblue @jackye1995 please take a look. This incorporates #3269 (updated). It would be nice if this could make it into 0.13, as using the snapshot schema is already implemented in the Spark 2.4 support.

@jackye1995
Copy link
Contributor

Yes agree, I think we need to include this for 0.13 consistent experience in 3.x and 2.4. Please let me know if anyone is against it.

@jackye1995 jackye1995 added this to the Iceberg 0.13.0 Release milestone Dec 13, 2021
@jackye1995 jackye1995 requested a review from rdblue December 13, 2021 18:56
@jackye1995
Copy link
Contributor

Skimmed through this, mostly look good to me as most of the content was reviewed once in the original PR, I will take another deeper look in the afternoon.

And FYI, I am also adding the time travel support in Trino (trinodb/trino#10258), I will add another PR to match this behavior.

@wypoon
Copy link
Contributor Author

wypoon commented Dec 13, 2021

@aokolnychyi can you take a look too, in case it conflicts with any changes you're making?

Comment on lines -96 to -100

if (requestedSchema != null) {
// convert the requested schema to throw an exception if any requested fields are unknown
SparkSchemaUtil.convert(icebergTable.schema(), requestedSchema);
}
Copy link
Contributor Author

@wypoon wypoon Dec 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pointed this out in #1508 and I'll point it out again here:

I removed requestedSchema from SparkTable because with #1783, the Spark 3 IcebergSource changed to be a SupportsCatalogOptions, not just a TableProvider. Since DataFrameReader does not support specifying a schema when reading from an IcebergSource:

    DataSource.lookupDataSourceV2(source, sparkSession.sessionState.conf).map { provider =>
      ...
      val (table, catalog, ident) = provider match {
        case _: SupportsCatalogOptions if userSpecifiedSchema.nonEmpty =>
          throw new IllegalArgumentException(
            s"$source does not support user specified schema. Please don't specify the schema.")

(see https://github.com/apache/spark/blob/v3.2.0/sql/core/src/main/scala/org/apache/spark/sql/DataFrameReader.scala#L220-L223)
there is no reason to have a requestedSchema field as we cannot make use of it.

This has been implemented for Spark 2. For Spark 3, Ryan Blue proposed
a syntax for adding the snapshot id or timestamp to the table identifier
in apache#3269. Here we implement the Spark 3 support for using the snapshot
schema by using the proposed table identifier syntax. This is until a
new Spark 3 is released with support for AS OF in Spark SQL.
Note: The table identifier syntax is for internal use only (as in this
implementation) and not meant to be exposed as a publicly supported
syntax in SQL. However, for testing, we do test its use from SQL.
Add a Schema parameter to the SparkScanBuilder constructor, so that
we can pass the snapshot schema in when constructing it.
In SparkTable#newScanBuilder, construct SparkScanBuilder with the
snapshot schema.
@wypoon wypoon force-pushed the schema-for-snapshot-spark3 branch from ba8c820 to 7ba1eab Compare December 14, 2021 03:34
@wypoon wypoon requested a review from rdblue December 14, 2021 17:11
Copy link
Contributor

@jackye1995 jackye1995 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall looks good to me, I don't have any further comments around the time travel logic. I think we are missing a few failure test cases, could you add those?

String value = options.get(property);
if (value != null) {
return Long.parseLong(value);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: newline after if

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add a blank line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a blank line.
What is the rationale for always adding a blank line after an if?
I fail to see how this makes the code more readable.
I can understand breaking a large block of code up with blank lines in general, but this is a very short method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes agree. I think it's mostly just general codestyle rules the community follows, maybe we should just put these into checkstyle instead of being human linters

@@ -120,4 +120,42 @@ public void testMetadataTables() {
ImmutableList.of(row(ANY, ANY, null, "append", ANY, ANY)),
sql("SELECT * FROM %s.snapshots", tableName));
}

@Test
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are missing a few failure test cases:

  1. Cannot specify both snapshot-id and as-of-timestamp
  2. Cannot write from table at a specific snapshot
  3. Cannot delete from table at a specific snapshot

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack. I agree that it'd be good to have such test cases. I'd point out though that none of the above should be supported even before this change, so if the test cases don't exist, they are existing holes.

Copy link
Contributor Author

@wypoon wypoon Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test cases for reading with both snapshot-id and as-of-timestamp, writing to a table at a specific snapshot, and deleting from a table at a specific snapshot.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These look good to me. Thanks for adding them!

// or timestamp selector, then SparkTable will be constructed with a non-null snapshotId, but
// SparkTable#newScanBuilder will be called without the "snapshot-id" or "as-of-timestamp" option.
// We therefore add a "snapshot-id" option here in this latter case.
CaseInsensitiveStringMap scanOptions =
Copy link
Contributor

@rdblue rdblue Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is worth the complexity. Why not just always add the snapshot ID if snapshotId is set? We know that if it is set, the option snapshot-id or as-of-timestamp should correspond to it. We should just make sure that the given snapshot ID is set in the options and remove as-of-timestamp if it is set. That makes this whole block simpler:

CaseInsensitiveStringMap scanOptions = snapshotId != null ? addSnapshotId(options, snapshotId) : options;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the update to addSnapshotId below, this worked fine with tests:

CaseInsensitiveStringMap scanOptions = addSnapshotId(options, snapshotId);

It didn't need the null check because that's done inside addSnapshotId.

Copy link
Contributor Author

@wypoon wypoon Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me try it out.
I had run into a problem with the original addSnapshotId function and always calling it. After I analysed what was happening, I wrote that comment to remind myself. I therefore called addSnapshotId only when strictly necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see that you changed

      Preconditions.checkArgument(snapshotIdFromOptions == null,
          "Cannot override snapshot ID more than once: %s", snapshotIdFromOptions);

to

      Preconditions.checkArgument(snapshotIdFromOptions == null || snapshotId.toString().equals(snapshotIdFromOptions),
          "Cannot override snapshot ID more than once: %s", snapshotIdFromOptions);

in addSnapshotId.
With the old version, you should only call addSnapshotId if the options did not already have snapshot-id or as-of-timestamp.


Map<String, String> scanOptions = Maps.newHashMap();
scanOptions.putAll(options.asCaseSensitiveMap());
scanOptions.put(SparkReadOptions.SNAPSHOT_ID, String.valueOf(snapshotId));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also remove as-of-timestamp since snapshot-id is being set. I think I missed that in my PR.

Copy link
Contributor

@rdblue rdblue Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated this to the following and tests work fine:

  private static CaseInsensitiveStringMap addSnapshotId(CaseInsensitiveStringMap options, Long snapshotId) {
    if (snapshotId != null) {
      String snapshotIdFromOptions = options.get(SparkReadOptions.SNAPSHOT_ID);
      Preconditions.checkArgument(snapshotIdFromOptions == null || snapshotId.toString().equals(snapshotIdFromOptions),
          "Cannot override snapshot ID more than once: %s", snapshotIdFromOptions);

      Map<String, String> scanOptions = Maps.newHashMap();
      scanOptions.putAll(options.asCaseSensitiveMap());
      scanOptions.put(SparkReadOptions.SNAPSHOT_ID, String.valueOf(snapshotId));
      scanOptions.remove(SparkReadOptions.AS_OF_TIMESTAMP);

      return new CaseInsensitiveStringMap(scanOptions);
    }

    return options;
  }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that makes sense.

Assert.assertEquals("Records should match", originalRecords,
resultDf.orderBy("id").collectAsList());

Snapshot snapshot1 = table.currentSnapshot();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A better name would be beforeAddColumn. In general, I think adding numbers to a generic name is not a good practice for readable tests.

"spark_catalog".equals(catalogName));

// get a timestamp just after the last write and get the current row set as expected
long timestamp = validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis() + 2;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use waitUntilAfter defined in SparkTestBase to avoid flaky tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a waitUntilAfter.

Copy link
Contributor

@rdblue rdblue left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, this is ready to go in. My only real concern is over timestamps in testing without using waitUntilAfter. I think we can also simplify handling in newScanBuilder, but that's minor and I think that the current logic is correct.

@rdblue
Copy link
Contributor

rdblue commented Dec 16, 2021

Thanks, @wypoon! This looks great I think we can get it in with a couple minor changes.

Minor tweaks to tests.
@wypoon
Copy link
Contributor Author

wypoon commented Dec 16, 2021

@rdblue thanks for all the reviews. I adopted your suggestion around SparkTable#newScanBuilder and SparkTable.addSnapshotId, and made the other tweaks you suggested.
@jackye1995 thanks for your reviews and suggestions too.

@wypoon
Copy link
Contributor Author

wypoon commented Dec 17, 2021

@rdblue @jackye1995 if this can be merged, I'll prepare PRs for Spark 3.1 and 3.0 for porting it. I'll be on vacation for the next two weeks.

@rdblue rdblue merged commit 4718fd3 into apache:master Dec 17, 2021
@rdblue
Copy link
Contributor

rdblue commented Dec 17, 2021

Thanks, @wypoon! Nice work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants