Add support for writing log compaction files - #4334
Conversation
scottsand-db
left a comment
There was a problem hiding this comment.
Looks great! Left some minor comments.
| return this; | ||
| } | ||
|
|
||
| @Override |
There was a problem hiding this comment.
This feels like a really weird config to be on the transaction builder since it doesn't apply to every transaction. Is there really no table property for this?
There was a problem hiding this comment.
Maybe this is another scenario where it would make sense for us to have sort of session config.. (and we're kind of bypassing it by putting it here)
There was a problem hiding this comment.
Also slightly confusing semantics about when we compact since it's writer based (and not required), but there's probably nothing we can do about that. But it's kind of confusing, do we compact when it's been logCompactionInterval commits since the last compaction? Since the last time this writer compacted based on this writer's number of commits? Based on mod (how it's implemented)?
Not sure if it's worth clarifying this to the user
There was a problem hiding this comment.
It probably should/could be an actual config on the table, but that would be a bigger change. Do we have any notion of a "session config"? (afaict, we don't).
Unless there's a current place it fits better I'd advocate for having it here and then we can move it if we introduce something better?
There was a problem hiding this comment.
I think we've just been putting off adding something like a session config for a while. I guess this is another example of where it would make sense to have one
There was a problem hiding this comment.
Makes sense. Are you okay with that being a follow-up and having this here?
There was a problem hiding this comment.
My 2c is: yes that should be a followup. That's quite a large change (designing a configuration API and then plumbing it through everywhere). All of these APIs are maked as @Evolving -- so, IMO, it's fine to add this for now and refactor later 👍 Also curious what Alli things here, too.
There was a problem hiding this comment.
+1 I am ok following up later on this. It is a fairly large task to design configuration.
There was a problem hiding this comment.
I'm okay with this being a follow up task -- just wanted to note this is another scenario where it seems like we should have a configuration (and curious if others agree)
| } | ||
|
|
||
| /** Utility to convert an Iterator<FilteredColumnarBatch> into an Iterator<Row> */ | ||
| private static class FilteredBatchToRowIter implements CloseableIterator<Row> { |
There was a problem hiding this comment.
I think this could just be distilled to a flatMap right? @vkorukanti do you know if we ever added this utility somewhere I think we've discussed it a few times
There was a problem hiding this comment.
It totally can! But we don't have flatMap available afaict? Java has it for Stream but our iterators don't implement that.
There was a problem hiding this comment.
We would implement our own version of it on CloseableIterator (our own interface anyways) or in a utilities class but I thought maybe we had done this? If not maybe we can add this as a util here? I know we've wanted this before
There was a problem hiding this comment.
Okay. I'm checking with @vkorukanti if we already have this, otherwise I can add it.
There was a problem hiding this comment.
We don't have one. Planning to add one.
However in my opinion we should change or add our JsonHandler to also take the ColumnarBatch and create json rows out of the batches. Delta sharing found that instead of getting rows if they used column vectors in batch directly, it was significant (~40% better) perf improvement.
There was a problem hiding this comment.
Thanks Venki!
Sounds like this is still a discussion point. @allisonport-db thoughts on just having this utility here and then moving it to a more general spot if/when we settle on exactly what we want to do?
There was a problem hiding this comment.
If it's not too much work to just add it as a util I think I'd prefer that so we can re-use it in the future the next time we need it. Otherwise each time we just keep re-implementing the same general functionality and the duplication grows linearly... But if you don't want to do it in this PR that's okay with me
There was a problem hiding this comment.
Plus maybe better since I feel like there's a small risk we make little mistakes each time we rewrite our nested iterators (& maybe don't test all the edge cases)
There was a problem hiding this comment.
Sounds good, move to Utils
vkorukanti
left a comment
There was a problem hiding this comment.
LGTM, thanks for adding this feature to Kernel.
| return this; | ||
| } | ||
|
|
||
| @Override |
There was a problem hiding this comment.
+1 I am ok following up later on this. It is a fairly large task to design configuration.
| Optional.of(endVersion), | ||
| false /* mustBeRecreatable */) | ||
| .toInMemoryList(); | ||
|
|
There was a problem hiding this comment.
assert to deltas.size == (endVersion - startVersion)?
There was a problem hiding this comment.
Isn't it that we could race with a checkpoint, in which case we might get a smaller set?
There was a problem hiding this comment.
Actually, I just added this because I think it makes sense to validate and get an error if something unexpected happens.
|
|
||
| LogSegment segment = | ||
| new LogSegment(dataPath, endVersion, deltas, emptyList(), lastCommitTimestamp); | ||
| CreateCheckpointIterator checkpointIterator = |
There was a problem hiding this comment.
one nit: should we rename CreateCheckpointIterator to LogCompactionIterator to be generic?
There was a problem hiding this comment.
I think this is okay. Log compaction is a "minor checkpoint" so it's not too confusing
| val batch = actions.next().getColumnarBatch() | ||
| val rows = batch.getRows() | ||
| while (rows.hasNext()) { |
There was a problem hiding this comment.
there are some implicit methods added on CloseableIterator in test (scala code - TestUtils). You can just say actions.toSeq.map etc.
| dataPath, | ||
| System.currentTimeMillis() - startTimeMillis); | ||
|
|
||
| if (deltas.isEmpty()) { |
There was a problem hiding this comment.
For log segments we do some validation on the deltas found - do you think we should do this here? We can probably reuse the same code
I think it just checks that endVersion and startVersion are present and the deltas are contiguous
There was a problem hiding this comment.
Oh it seems like we do this in the LogSegment constructor :) awesome! nevermind here
There was a problem hiding this comment.
I think +1 on @vkorukanti comment however, it seems like we check that the endVersion is present and that they are contiguous, but not the first version
There was a problem hiding this comment.
Also this can be a follow-up but maybe we should consider the desired behavior in this scenario when files are missing or something -- should we throw an error? and if so what kind? (currently will throw illegal argument exceptions but in some cases it seems like it should be InvalidTableException)
There was a problem hiding this comment.
As above, i think a checkpoint racing could cause an issue here. Maybe we want an error in that case though?
| // add | ||
| val addRow = row.getStruct(1) | ||
| val path = addRow.getString(0) | ||
| if (!removed.contains(path)) { |
There was a problem hiding this comment.
Is this an issue if it doesn't include the key for DVs?
There was a problem hiding this comment.
In general yes, but not here as we know what's happening
| var seenMetadata = false | ||
| var seenProtocol = false | ||
| val resBuilder = Seq.newBuilder[TestRow] | ||
| while (actions.hasNext()) { |
There was a problem hiding this comment.
I feel like essentially rewriting log replay here in order to test this is kind of weird. Wondering if instead we can just do a super simple example and actually check for the adds/removes/metadatas that we know we committed and should be there? open to thoughts
i.e.
version 0 - metadata1
version 1 - add1, add2
version 2 - remove1, add3, dm1
version 3 - dm1_2, metadata2
// etc
expect metadata2, dm1_2, add3, remove1, add2
There was a problem hiding this comment.
I felt the same. I think self-contained tests where the expected output is hardcoded will make it easier to understand the tests.
| } else if (!rowIsNull(row)) { | ||
| resBuilder += TestRow(row) |
There was a problem hiding this comment.
what do we do with domain metadatas here? don't those also have to be resolved with themselves? (only include the latest one)
There was a problem hiding this comment.
Great catch, we need to resolve those too. I've added that into the test now
| val testMsgUpdate = if (includeRemoves) " and removes" else "" | ||
| test(s"Read table with adds$testMsgUpdate") { | ||
| withTempDirAndEngine { (tablePath, engine) => | ||
| addData(tablePath, alternateBetweenAddsAndRemoves = includeRemoves, numberIter = 10) |
There was a problem hiding this comment.
are we sure we haven't done any compaction by default here? should we disable it explicitly?
There was a problem hiding this comment.
oss spark doesn't support writing minor compactions
There was a problem hiding this comment.
Ah did not realize this is done using Spark. Anyway to make sure if that is added to Spark this test doesn't start writing them?
There was a problem hiding this comment.
Yeah, I mean if it's added to spark we'd want to disable compaction, but we can't preemptively do that since there's no config to set currently.
| val seenDomains = scala.collection.mutable.HashSet.empty[String] | ||
| var seenMetadata = false | ||
| var seenProtocol = false | ||
| actions.toSeq.flatMap { wrapper => |
There was a problem hiding this comment.
resurrecting #4334 (comment)
Talked a bit with venki. There's two main issues to just statically declaring expected results:
- The file names in the add/remove actions are different each time. So we'd have to somehow discover those
- Constructing a
TestRowwith a wide schema with nested structs is pretty messy. You need to do something likeTestRow(null, null, addFile(....), null, null, null, null), and I'm still not quite clear whataddFilewould have to return to make it work.
Really we just want log replay here, but it would be obviously better for us to not re-write it for the test.
I couldn't find an easy way to expose kernel log replay to get just the reconciled actions (other than the checkpoint iterator which is actually what we're trying to test here). Perhaps there's a way to get oss-spark to do that, but I haven't found that yet either.
@allisonport-db would you be okay with taking that as a follow-up since I think this is testing that the feature works, we could just probably clean it up and remove a bunch of code if we figure out the above.
There was a problem hiding this comment.
Fine for a follow up. I think we're picturing different tests though I was thinking
(1) manually commit add/removes so we know the paths
(2) we don't need to check the exact contents necessarily in this test, but assert for example add(path=/something/file1) exists and maybe add(path=/something/file2) does not exist etc
Or maybe a different way to do it using the actual adds we commit to compare
There was a problem hiding this comment.
Future ideas for follow up too is we can use the spark API to correctly check the DM as well I think, also would be good to have a test where theres a metadata + protocol update in the version range (make sure we keep the latest one only)
There was a problem hiding this comment.
We do have that test btw. The domain metadata changed enables the feature so it writes new p and m
| hook.threadSafeInvoke(engine) | ||
|
|
||
| spark.conf.set(DeltaSQLConf.DELTALOG_MINOR_COMPACTION_USE_FOR_READS.key, "true") | ||
| val withCompactionData = readUsingSpark(tablePath) |
There was a problem hiding this comment.
Only an idea but if you include domain metadata you can check those with spark as well by loading the snapshot and accessing snapshot.domainMetadata
There was a problem hiding this comment.
DeltaLog.forTable(spark, tablePath).getSnapshotAt(version) to get the Spark snapshot in Kernel btw
allisonport-db
left a comment
There was a problem hiding this comment.
LGTM but let's revisit the testing as a followup (I feel like there's a few different options we could consider)
| } | ||
|
|
||
| // Utility class to support `intoRows` below | ||
| private static class FilteredBatchToRowIter implements CloseableIterator<Row> { |
There was a problem hiding this comment.
Sorry I think maybe I wasn't clear; I meant we should add a flatMap util to the CloseableIterator interface and we can generally use that for everything else. Fine to do this in a follow-up though don't want to block on this
|
@allisonport-db I've added #4416 and #4417 as follow-ups. I think that should make us good to merge. LMK if you need anything else. Thanks! |
Which Delta project/connector is this regarding?
Description
This adds support for writing log compaction files as a post-commit hook.
This introduces a new method on
TransactionBuilder:withLogCompactionInverval, which allows saying how many commits there should be in between log compactions. This is set to 10 by default, and can be set to 0 to disable compaction.In this PR we count commits just by the commit number. This means checkpoints could cause a compaction to happen more frequently that exactly
Xjson commit files, but this won't harm anything, it's just a slightly more frequent compaction that strictly necessary.How was this patch tested?
Added unit tests that the actions are as expected in the compacted file, as well as a test that delta-spark can read both with and without the compacted file and get the same result.
Does this PR introduce any user-facing changes?
Yes, this means your log will compact by default, and there is a new method to call on
TransactionBuilderif you want to configure it.