KAFKA-7223: add tests in preparation for suppression#5687
Conversation
|
When you have a chance please take a look at this PR. As the description says, it contains only the unit tests that we will use for buffered suppressions. I'm hoping that this progression is more reviewable than one large chunk, and also that it makes sense in its resemblence to TDD. I'd like to think of this as writing/reviewing the tests first, so we agree on the expected behavior. With that agreement in place, we'll be able to concentrate on the implementation in a follow-on PR. WDYT? |
|
|
||
| import static java.util.Objects.requireNonNull; | ||
|
|
||
| public class FullChangeSerde<T> implements Serde<Change<T>> { |
There was a problem hiding this comment.
Unlike the source/sink nodes, the suppress processor needs to be able to handle Changes in which both new and old are non-null.
| public KTable<K, V> suppress(final Suppressed<K> suppressed) { | ||
| final String name = builder.newProcessorName(SUPPRESS_NAME); | ||
|
|
||
| // TODO: follow-up pr to forward the k/v serdes |
There was a problem hiding this comment.
This will be fixed in part 3
| } | ||
|
|
||
| static class NotImplementedException extends RuntimeException { | ||
| public static class NotImplementedException extends RuntimeException { |
There was a problem hiding this comment.
Just so we can assert that it happens in the scenario tests (which are in a different package)
| private static final int SCALE_FACTOR = COMMIT_INTERVAL * 2; | ||
| private static final long TIMEOUT_MS = 30_000L; | ||
|
|
||
| @Ignore |
There was a problem hiding this comment.
Note these tests are ignored because they can't run until Part 3.
| } | ||
| } | ||
|
|
||
| @Test(expected = ProcessorStateException.class) |
There was a problem hiding this comment.
Note that we're expecting exceptions for now. This will be removed in Part 3.
| drainProducerRecords(driver, "output-suppressed", STRING_DESERIALIZER, LONG_DESERIALIZER), | ||
| asList( | ||
| // TODO: it's not strictly necessary to emit these in final mode, but it's also not harmful... maybe? | ||
| new KeyValueTimestamp<>("[k1@0/0]", null, 1L), |
There was a problem hiding this comment.
The todo refers to emitting this null result, which is a result of session windows merging.
I suppose the suppression operator could drop results with null values if (and only if) it's in "final results" mode. But on the other hand, I worry that it's too much magic.
WDYT?
There was a problem hiding this comment.
Here's my thinking: for session windows, when we merge the window we will send negating old values downstream, but with final output we should not send any data yet before merging and sending the final result, so there is no point sending null, and hence in this case we should remove it in the suppressed result as well?
There was a problem hiding this comment.
Ok, it's a little more complex to maintain as a special case, but it does seem better not to burden our users with mysteries for the sake of convenience.
There was a problem hiding this comment.
I agree with dropping null values
| if (newBytes != null) { | ||
| buffer.put(newBytes); | ||
| } | ||
| return buffer.array(); |
There was a problem hiding this comment.
I think we cannot always return buffer.array() since it just returns the backing byte array of this ByteBuffer, and since multiple ByteBuffer may potentially sits on the same byte array, this array() may actually be much larger than the ByteBuffer's capacity. To be safer we need to do sth. in the ByteBufferSerializer:
if (data.hasArray()) {
byte[] arr = data.array();
if (data.arrayOffset() == 0 && arr.length == data.remaining()) {
return arr;
}
}
byte[] ret = new byte[data.remaining()];
data.get(ret, 0, ret.length);
data.rewind();
return ret;
There was a problem hiding this comment.
Ok, thanks. I'm (clearly) not too familiar with the API. What you say makes sense.
| final Serde<K> keySerde, | ||
| final Serde<Change<V>> valueSerde) { | ||
| this.suppress = requireNonNull(suppress); | ||
| this.keySerde = keySerde; |
There was a problem hiding this comment.
Where are these two serdes used in this PR?
There was a problem hiding this comment.
They aren't. They will be in part 3, so the tests need to call this constructor. I added this constructor and the dummy fields just so the (ignored) tests can compile.
It's a bit awkward, I know.
| private static final LongDeserializer LONG_DESERIALIZER = new LongDeserializer(); | ||
| private static final int COMMIT_INTERVAL = 100; | ||
| private static final int SCALE_FACTOR = COMMIT_INTERVAL * 2; | ||
| private static final long TIMEOUT_MS = 30_000L; |
There was a problem hiding this comment.
Why SCALE_FACTOR is COMMIT_INTERVAL * 2, is it just to make sure at least one commit is called during this interval of time?
There was a problem hiding this comment.
Yes. It doesn't actually matter whether or not a commit in called in between the various times used in tests (I've tested it both ways). But it seemed like a reasonable concern that the commit might mess up the suppression in some way, so I went ahead and made sure the tests included commits.
| verifyOutput( | ||
| outputSuppressed, | ||
| asList( | ||
| new KeyValueTimestamp<>("v2", 1L, scaledTime(1L)), |
There was a problem hiding this comment.
Why the supressed result will not have tick without filtering?
There was a problem hiding this comment.
Only because it isn't time to emit it yet. The tick is the newest event, at time 5, so it's still stream-time 5. Based on the suppression config, we won't emit it until after time 7.
The only reason I filtered it out of the "raw" output is that I felt it made the tests more confusing to read. Do you think I should also filter it from the suppressed output (even though it doesn't matter), just to avoid distracting readers with the question you raised?
There was a problem hiding this comment.
The reasoning makes sense.
I think not explicitly filtering tick and expect in the output raw is actually fine, and to me it is better exposing this behavior to make sure the raw data did include the tick value while the suppressed one does not.
There was a problem hiding this comment.
Ok, it's easy enough to put it back in.
There was a problem hiding this comment.
I had the same question about tick I also think it makes sense to have it in the raw data.
| if (newBytes != null) { | ||
| buffer.put(newBytes); | ||
| } | ||
| return byteBufferSerializer.serialize(null, buffer); |
There was a problem hiding this comment.
@guozhangwang , rather than copy/paste the non-trivial transformation logic, what do you think about just delegating to the actual ByteBufferSerializer?
The deserializer is trivial, but I went ahead and used it in my deserializer, too, for symmetry.
I'm thinking that as long as the ByteBufferSerializer is stable, and it doesn't do anything weird in the future, this should be fine. And that should be the case, since future versions of the code are going to have to be compatible with data written by older versions.
guozhangwang
left a comment
There was a problem hiding this comment.
The tests LGTM, most of the comments are minor.
One meta comment though: from the unit tests in seems we are going to register one scheduled punctuator for each key in time-based intermediate suppression, and one punctuator for each window in final result suppression in window store, is that right? I'm a bit concerned about the punctuator overhead and how we can maintain it during failover, but I understand it is for part 3 not this PR.
| cleanStateBeforeTest(input, outputRaw, outputSuppressed); | ||
|
|
||
| final StreamsBuilder builder = new StreamsBuilder(); | ||
| final KTable<String, Long> valueCounts = builder |
There was a problem hiding this comment.
Can we share the logic of creating the valueCounts as well?
| new KeyValueTimestamp<>("k1", "v1", scaledTime(0L)), | ||
| new KeyValueTimestamp<>("k1", "v2", scaledTime(1L)), | ||
| new KeyValueTimestamp<>("k2", "v1", scaledTime(2L)), | ||
| new KeyValueTimestamp<>("tick", "tick", scaledTime(5L)) |
There was a problem hiding this comment.
I'd suggest 1) adding another record at 7L and 2) add a filter before line 111 to filter this record out, so that effectively this record will not be processed at all but only moves the stream time. And we can then verify that the final result does not have any outputs from this input, but the tick record did emit because of the stream time advances.
There was a problem hiding this comment.
I think I might have misunderstood you. As I read it, the role proposed for this extra record is exactly what tick is there for. It exists only to advance stream time, and it gets filtered from the output.
There was a problem hiding this comment.
fwiw, I think your earlier suggestion to not filter it is fine. I'm also adding a couple of comments explaining what's happening with it.
| new KeyValueTimestamp<>("k1", "v1", scaledTime(2L)), | ||
| new KeyValueTimestamp<>("k1", "v1", scaledTime(1L)), | ||
| new KeyValueTimestamp<>("k1", "v1", scaledTime(0L)), | ||
| new KeyValueTimestamp<>("k1", "v1", scaledTime(4L)) |
There was a problem hiding this comment.
Add another late record after this to be falling out of the grace period, and verify that it will not be reflected in the final result and be skipped?
| driver.pipeInput(recordFactory.create("input", "k1", "v1", 2L)); | ||
| driver.pipeInput(recordFactory.create("input", "k1", "v1", 1L)); | ||
| driver.pipeInput(recordFactory.create("input", "k1", "v1", 0L)); | ||
| driver.pipeInput(recordFactory.create("input", "k1", "v1", 5L)); |
There was a problem hiding this comment.
Ditto as in the integration test.
| drainProducerRecords(driver, "output-suppressed", STRING_DESERIALIZER, LONG_DESERIALIZER), | ||
| asList( | ||
| // TODO: it's not strictly necessary to emit these in final mode, but it's also not harmful... maybe? | ||
| new KeyValueTimestamp<>("[k1@0/0]", null, 1L), |
There was a problem hiding this comment.
Here's my thinking: for session windows, when we merge the window we will send negating old values downstream, but with final output we should not send any data yet before merging and sending the final result, so there is no point sending null, and hence in this case we should remove it in the suppressed result as well?
|
|
||
| @SuppressWarnings("PointlessArithmeticExpression") | ||
| public class KTableSuppressProcessorTest { | ||
| /** |
There was a problem hiding this comment.
Why remove the comments here?
There was a problem hiding this comment.
They just seemed unnecessary, as the variable names should make it obvious.
| final KTableSuppressProcessor<Windowed<String>, Long> processor = | ||
| new KTableSuppressProcessor<>(finalResults(ofMillis(1))); | ||
| @Test(expected = KTableSuppressProcessor.NotImplementedException.class) | ||
| public void finalResultsSuppressionShouldBufferAndEmitLater() { |
There was a problem hiding this comment.
What's the difference between this test and the finalResultsWith0GraceBeforeWindowEndShouldBufferAndEmitLater below? Besides grace they seems exactly the same.
In that case, could we just merge these two, to randomly choose one as the grace period, and with a significant amount of likelihood that the random number is 0?
There was a problem hiding this comment.
They are similar, but targeting different behaviors:
finalResultsSuppressionShouldBufferAndEmitLateris the "main" unit test: verifying that the buffer saves up all the data and emits at the grace period expiration.finalResultsWith0GraceBeforeWindowEndShouldBufferAndEmitLateris somewhat of a "regression" test. I added it during implementation because I initially failed to account for this behavior: even when the grace period is 0, the final results buffer should wait until the end of the window before emitting (instead of immediately emitting). I think I can name the method better and add a comment to make this clear.
As far as randomized testing goes, I'd like to experiment with a fuzzing framework in the future, rather than hacking out a half-baked one right now. There are some important features, such as a mechanism to reproduce failing tests, that are necessary to make it a "randomized test", rather than a "flaky test".
For now, I'd rather just stick with hand-written scenarios.
|
@guozhangwang , thanks for the review. I think I've addressed all your comments. About the punctuation overhead, I share your concern, and I think we can optimize it, but I agree we should table this discussion until part 3. |
bbejeck
left a comment
There was a problem hiding this comment.
Thanks @vvcephei, I made a pass and looks good overall, just a few minor comments.
One additional comment should we add unit tests to KTableSuppressProcessorTest with records falling inside the grace period as well as outside of it?
| public class FullChangeSerde<T> implements Serde<Change<T>> { | ||
| private final Serde<T> inner; | ||
|
|
||
| public FullChangeSerde(final Serde<T> inner) { |
There was a problem hiding this comment.
Can we get a unit test for FullChangeSerde?
There was a problem hiding this comment.
Thanks for this. I added one, and it turns out I caught a couple of bugs!
| verifyOutput( | ||
| outputSuppressed, | ||
| asList( | ||
| new KeyValueTimestamp<>("v2", 1L, scaledTime(1L)), |
There was a problem hiding this comment.
I had the same question about tick I also think it makes sense to have it in the raw data.
| final KTableSuppressProcessor<Windowed<String>, Long> processor = | ||
| new KTableSuppressProcessor<>(finalResults(ofMillis(0))); | ||
| @Test(expected = KTableSuppressProcessor.NotImplementedException.class) | ||
| public void finalResultsWith0GraceBeforeWindowEndShouldBufferAndEmitLater() { |
There was a problem hiding this comment.
nit: I realize this was here in a previous commit, but in this test and the next spell out Zero vs using the number, it's hard to read.
| new KeyValueTimestamp<>("v1", 1L, 2L) | ||
| ) | ||
| ); | ||
| driver.pipeInput(recordFactory.create("input", "tick", "tick", 5L)); |
There was a problem hiding this comment.
same here as with the integration test, maybe don't filter tick from raw to show that suppression is keeping it from being emitted.
There was a problem hiding this comment.
Ok, I've removed the filter, and added expectations as well as explanatory comments for "tick"
| drainProducerRecords(driver, "output-suppressed", STRING_DESERIALIZER, LONG_DESERIALIZER), | ||
| asList( | ||
| // TODO: it's not strictly necessary to emit these in final mode, but it's also not harmful... maybe? | ||
| new KeyValueTimestamp<>("[k1@0/0]", null, 1L), |
There was a problem hiding this comment.
I agree with dropping null values
| import static org.hamcrest.MatcherAssert.assertThat; | ||
| import static org.hamcrest.core.Is.is; | ||
|
|
||
| public class FullChangeSerdeTest { |
|
Ok @bbejeck, I believe I've addressed all your and @guozhangwang 's comments. |
|
Once again, my sincere thanks for the reviews, @guozhangwang and @bbejeck |
This is Part 4 of suppression (durability) Part 1 was #5567 (the API) Part 2 was #5687 (the tests) Part 3 was #5693 (in-memory buffering) Implement a changelog for the suppression buffer so that the buffer state may be recovered on restart or recovery. As of this PR, suppression is suitable for general usage. Reviewers: Bill Bejeck <bill@confluent.io>, Guozhang Wang <guozhang@confluent.io>, Matthias J. Sax <matthias@confluent.io>
This is Part 2 of suppression. Part 1 was apache#5567 In an effort to control the scope of the review, this PR is just the tests for buffered suppression. Reviewers: Bill Bejeck <bill@confluent.io>, Guozhang Wang <wangguoz@gmail.com>
This is Part 4 of suppression (durability) Part 1 was apache#5567 (the API) Part 2 was apache#5687 (the tests) Part 3 was apache#5693 (in-memory buffering) Implement a changelog for the suppression buffer so that the buffer state may be recovered on restart or recovery. As of this PR, suppression is suitable for general usage. Reviewers: Bill Bejeck <bill@confluent.io>, Guozhang Wang <guozhang@confluent.io>, Matthias J. Sax <matthias@confluent.io>
This is Part 2 of suppression.
Part 1 was #5567
In an effort to control the scope of the review, this PR is just the tests for buffered suppression.
Committer Checklist (excluded from commit message)