Please wait. This can take some minutes ...
Many resources are needed to download a project. Please understand that we have to compensate our server costs. Thank you in advance.
Project price only 1 $
You can buy this project and download/modify it how often you want.
com.hazelcast.jet.sql.impl.processors.StreamToStreamJoinP Maven / Gradle / Ivy
/*
* Copyright 2024 Hazelcast Inc.
*
* Licensed under the Hazelcast Community License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://hazelcast.com/hazelcast-community-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hazelcast.jet.sql.impl.processors;
import com.hazelcast.function.ToLongFunctionEx;
import com.hazelcast.internal.serialization.SerializationService;
import com.hazelcast.internal.serialization.impl.SerializationUtil;
import com.hazelcast.internal.util.MutableInteger;
import com.hazelcast.internal.util.collection.Object2LongHashMap;
import com.hazelcast.jet.JetException;
import com.hazelcast.jet.Traverser;
import com.hazelcast.jet.config.ProcessingGuarantee;
import com.hazelcast.jet.core.AbstractProcessor;
import com.hazelcast.jet.core.BroadcastKey;
import com.hazelcast.jet.core.Processor;
import com.hazelcast.jet.core.ProcessorSupplier;
import com.hazelcast.jet.core.Watermark;
import com.hazelcast.jet.datamodel.Tuple2;
import com.hazelcast.jet.impl.JetServiceBackend;
import com.hazelcast.jet.impl.memory.AccumulationLimitExceededException;
import com.hazelcast.jet.sql.impl.ExpressionUtil;
import com.hazelcast.jet.sql.impl.JetJoinInfo;
import com.hazelcast.jet.sql.impl.ObjectArrayKey;
import com.hazelcast.nio.ObjectDataInput;
import com.hazelcast.nio.ObjectDataOutput;
import com.hazelcast.nio.serialization.DataSerializable;
import com.hazelcast.sql.impl.expression.ConstantExpression;
import com.hazelcast.sql.impl.expression.ExpressionEvalContext;
import com.hazelcast.sql.impl.row.JetSqlRow;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Queue;
import java.util.Set;
import java.util.stream.Stream;
import static com.hazelcast.shaded.com.google.common.collect.Streams.mapWithIndex;
import static com.hazelcast.jet.Traversers.traverseStream;
import static com.hazelcast.jet.Util.entry;
import static com.hazelcast.jet.core.BroadcastKey.broadcastKey;
import static com.hazelcast.jet.impl.util.Util.getNodeEngine;
import static com.hazelcast.jet.impl.util.Util.logLateEvent;
import static com.hazelcast.jet.sql.impl.processors.StreamToStreamJoinP.StreamToStreamJoinBroadcastKeys.LAST_RECEIVED_WM_KEY;
import static com.hazelcast.jet.sql.impl.processors.StreamToStreamJoinP.StreamToStreamJoinBroadcastKeys.WM_STATE_KEY;
import static java.util.function.Function.identity;
/**
* See {@code docs/design/sql/15-stream-to-stream-join.md}.
*/
public class StreamToStreamJoinP extends AbstractProcessor {
private static final long OBJECT_2_LONG_MAP_MIN_VALUE = Long.MIN_VALUE + 1;
// package-visible for tests
// tracks the current minimum event time for each watermark
final Object2LongHashMap wmState = new Object2LongHashMap<>(Long.MIN_VALUE);
final Object2LongHashMap lastReceivedWm = new Object2LongHashMap<>(Long.MIN_VALUE);
final Object2LongHashMap lastEmittedWm = new Object2LongHashMap<>(Long.MIN_VALUE);
// package-visible for tests
final StreamToStreamJoinBuffer[] buffer;
private int[] processorPartitionKeys;
private final JetJoinInfo joinInfo;
private final int outerJoinSide;
private final List>> leftTimeExtractors;
private final List>> rightTimeExtractors;
private final Map> postponeTimeMap;
private final Tuple2 columnCounts;
private long maxProcessorAccumulatedRecords;
private ExpressionEvalContext evalContext;
private ProcessingGuarantee processingGuarantee;
private int processorIndex;
private Iterator iterator;
private JetSqlRow currItem;
private final Set unusedEventsTracker = Collections.newSetFromMap(new IdentityHashMap<>());
private final Queue pendingOutput = new ArrayDeque<>();
private JetSqlRow emptyLeftRow;
private JetSqlRow emptyRightRow;
private Traverser> snapshotTraverser;
@SuppressWarnings("checkstyle:ExecutableStatementCount")
public StreamToStreamJoinP(
final JetJoinInfo joinInfo,
final Map> leftTimeExtractors,
final Map> rightTimeExtractors,
final Map> postponeTimeMap,
final Tuple2 columnCounts
) {
this.joinInfo = joinInfo;
this.leftTimeExtractors = new ArrayList<>(leftTimeExtractors.entrySet());
this.rightTimeExtractors = new ArrayList<>(rightTimeExtractors.entrySet());
this.postponeTimeMap = postponeTimeMap;
this.columnCounts = columnCounts;
switch (joinInfo.getJoinType()) {
case INNER:
outerJoinSide = -1;
break;
case LEFT:
outerJoinSide = 0;
break;
case RIGHT:
outerJoinSide = 1;
break;
default:
throw new IllegalArgumentException("Unsupported join type: " + joinInfo.getJoinType());
}
for (Byte wmKey : postponeTimeMap.keySet()) {
// using MIN_VALUE + 1 because Object2LongHashMap uses MIN_VALUE as a missing value, and it cannot be used as a value
wmState.put(wmKey, OBJECT_2_LONG_MAP_MIN_VALUE);
lastEmittedWm.put(wmKey, OBJECT_2_LONG_MAP_MIN_VALUE);
lastReceivedWm.put(wmKey, OBJECT_2_LONG_MAP_MIN_VALUE);
}
// no key must be on both sides
if (!Collections.disjoint(leftTimeExtractors.keySet(), rightTimeExtractors.keySet())) {
throw new IllegalArgumentException("Some watermark key is found on both inputs. Left="
+ leftTimeExtractors.keySet() + ", right=" + rightTimeExtractors.keySet());
}
// postponeTimeMap must contain at least one bound for a key on left, involving a key on right, and vice versa
boolean[] found = new boolean[2];
for (Entry> outerEntry : postponeTimeMap.entrySet()) {
for (Byte innerKey : outerEntry.getValue().keySet()) {
int innerOrdinal = leftTimeExtractors.containsKey(innerKey) ? 0 : 1;
int outerOrdinal = leftTimeExtractors.containsKey(outerEntry.getKey()) ? 0 : 1;
// innerOrdinal == outerOrdinal if the time bound is between timestamps on the same input, we ignore those
if (innerOrdinal != outerOrdinal) {
found[innerOrdinal] = true;
}
}
}
if (!found[0] || !found[1]) {
throw new IllegalArgumentException("Not enough time bounds in postponeTimeMap");
}
this.buffer = createBuffers();
}
@Override
protected void init(@Nonnull Context context) throws Exception {
evalContext = ExpressionEvalContext.from(context);
SerializationService ss = evalContext.getSerializationService();
emptyLeftRow = new JetSqlRow(ss, new Object[columnCounts.f0()]);
emptyRightRow = new JetSqlRow(ss, new Object[columnCounts.f1()]);
maxProcessorAccumulatedRecords = context.maxProcessorAccumulatedRecords();
processingGuarantee = context.processingGuarantee();
processorIndex = context.globalProcessorIndex();
if (!joinInfo.isEquiJoin()) {
JetServiceBackend jsb = getNodeEngine(context.hazelcastInstance()).getService(JetServiceBackend.SERVICE_NAME);
int[] processorPartitionIds = context.processorPartitions();
int[] partitionKeys = jsb.getSharedPartitionKeys();
processorPartitionKeys = new int[processorPartitionIds.length];
for (int i = 0; i < processorPartitionKeys.length; i++) {
processorPartitionKeys[i] = partitionKeys[processorPartitionIds[i]];
}
}
}
@SuppressWarnings("checkstyle:NestedIfDepth")
@Override
public boolean tryProcess(int ordinal, @Nonnull Object item) {
assert ordinal == 0 || ordinal == 1; // bad DAG
if (!processPendingOutput()) {
return false;
}
if (buffer[0].size() + buffer[1].size() >= maxProcessorAccumulatedRecords) {
throw new AccumulationLimitExceededException();
}
// region item placement in buffer
boolean avoidBuffer = false;
if (currItem == null) {
// drop the event, if it's late according to any watermarked value it contains
List>> extractors = timeExtractors(ordinal);
long[] times = new long[extractors.size()];
for (int i = 0; i < extractors.size(); i++) {
long wmValue = lastReceivedWm.getValue(extractors.get(i).getKey());
times[i] = extractors.get(i).getValue().applyAsLong((JetSqlRow) item);
if (times[i] < wmValue) {
logLateEvent(getLogger(), extractors.get(i).getKey(), wmValue, item);
return true;
}
}
// if the item is not late, but would already be removed from the buffer, don't add it to the buffer
for (int i = 0; i < extractors.size(); i++) {
long joinTimeLimit = wmState.get(extractors.get(i).getKey());
long time = times[i];
avoidBuffer |= time < joinTimeLimit;
}
currItem = (JetSqlRow) item;
if (!avoidBuffer) {
buffer[ordinal].add(currItem);
}
// we'll emit joined rows from currItem and the buffered rows from the opposite side
iterator = buffer[1 - ordinal].iterator();
if (ordinal == outerJoinSide) {
unusedEventsTracker.add(currItem);
}
}
// endregion
// region join procedure
while (iterator.hasNext()) {
JetSqlRow oppositeBufferItem = iterator.next();
JetSqlRow preparedOutput = ExpressionUtil.join(
ordinal == 0 ? currItem : oppositeBufferItem,
ordinal == 0 ? oppositeBufferItem : currItem,
joinInfo.condition(),
evalContext);
if (preparedOutput == null) {
continue;
}
if (ordinal == outerJoinSide) {
// mark current item as used
unusedEventsTracker.remove(currItem);
} else if (ordinal == 1 - outerJoinSide) {
// mark opposite-side item as used
unusedEventsTracker.remove(oppositeBufferItem);
}
if (!tryEmit(preparedOutput)) {
pendingOutput.add(preparedOutput);
return false;
}
}
if (avoidBuffer && !joinInfo.isInner() && unusedEventsTracker.remove(currItem)) {
JetSqlRow joinedRow = composeRowWithNulls(currItem, ordinal);
if (joinedRow != null && !tryEmit(joinedRow)) {
pendingOutput.add(joinedRow);
return false;
}
}
// endregion
iterator = null;
currItem = null;
return true;
}
@Override
public boolean tryProcessWatermark(int ordinal, @Nonnull Watermark watermark) {
if (!pendingOutput.isEmpty()) {
return processPendingOutput();
}
Byte receivedWmKey = watermark.key();
assert wmState.containsKey(receivedWmKey) : "unexpected watermark key: " + receivedWmKey;
assert processingGuarantee != ProcessingGuarantee.EXACTLY_ONCE
|| lastReceivedWm.get(receivedWmKey) < watermark.timestamp() : "non-monotonic watermark: "
+ watermark.timestamp() + " when state is " + lastReceivedWm.get(receivedWmKey);
lastReceivedWm.put(receivedWmKey, watermark.timestamp());
// 5.1: update wm state
boolean modified = applyToWmState(watermark);
if (modified) {
// TODO don't need to clean up particular edge, if nothing was changed for that edge
clearExpiredItemsInBuffer(0);
clearExpiredItemsInBuffer(1);
}
// Note: We can't immediately emit current WM, as it could render items in buffers late.
for (Byte wmKey : wmState.keySet()) {
long lastReceivedWatermark = lastReceivedWm.getValue(wmKey);
long newWm = Math.min(wmState.get(wmKey), lastReceivedWatermark);
if (newWm > lastEmittedWm.getValue(wmKey)) {
pendingOutput.add(new Watermark(newWm, wmKey));
lastEmittedWm.put(wmKey, newWm);
}
}
return processPendingOutput();
}
@Override
public boolean tryProcessWatermark(@Nonnull Watermark watermark) {
return true;
}
@SuppressWarnings("checkstyle:NestedIfDepth")
@Override
public boolean saveToSnapshot() {
if (snapshotTraverser == null) {
List> snapshotList = new ArrayList<>();
Stream> leftBufferStream;
Stream> rightBufferStream;
for (Entry e : wmState.entrySet()) {
Long timestamp = e.getValue();
if (timestamp != OBJECT_2_LONG_MAP_MIN_VALUE) {
snapshotList.add(
entry(
broadcastKey(WM_STATE_KEY),
new WatermarkStateValue(e.getKey(), timestamp)
)
);
}
}
for (Entry, Long> e : lastReceivedWm.entrySet()) {
Long timestamp = e.getValue();
if (timestamp != OBJECT_2_LONG_MAP_MIN_VALUE) {
snapshotList.add(
entry(
broadcastKey(LAST_RECEIVED_WM_KEY),
new WatermarkStateValue((Byte) e.getKey(), timestamp)
)
);
}
}
if (joinInfo.isEquiJoin()) {
leftBufferStream = buffer[0].content()
.stream()
.map(row -> entry(
ObjectArrayKey.project(row, joinInfo.leftEquiJoinIndices()),
new BufferSnapshotValue(row, unusedEventsTracker.contains(row), 0)
));
rightBufferStream = buffer[1].content()
.stream()
.map(row -> entry(
ObjectArrayKey.project(row, joinInfo.rightEquiJoinIndices()),
new BufferSnapshotValue(row, unusedEventsTracker.contains(row), 1)
));
} else {
MutableInteger keyIndex = new MutableInteger();
// Note: details are described in TDD: docs/design/sql/15-stream-to-stream-join.md
if (joinInfo.isRightOuter()) {
if (processorIndex == 0) {
leftBufferStream = mapWithIndex(
buffer[0].content().stream(),
(row, index) -> entry(
broadcastKey(index),
new BufferSnapshotValue(row, unusedEventsTracker.contains(row), 0)
));
} else {
leftBufferStream = Stream.empty();
}
rightBufferStream = buffer[1].content()
.stream()
.map(row -> entry(
processorPartitionKeys[cycle(keyIndex, processorPartitionKeys.length)],
new BufferSnapshotValue(row, unusedEventsTracker.contains(row), 1)));
} else {
if (processorIndex == 0) {
rightBufferStream = mapWithIndex(
buffer[1].content().stream(),
(row, index) -> entry(
broadcastKey(index),
new BufferSnapshotValue(row, unusedEventsTracker.contains(row), 1)
));
} else {
rightBufferStream = Stream.empty();
}
leftBufferStream = buffer[0].content()
.stream()
.map(row -> entry(
processorPartitionKeys[cycle(keyIndex, processorPartitionKeys.length)],
new BufferSnapshotValue(row, unusedEventsTracker.contains(row), 0)));
}
}
snapshotTraverser =
traverseStream(Stream.of(snapshotList.stream(), leftBufferStream, rightBufferStream)
.flatMap(identity()))
.onFirstNull(() -> snapshotTraverser = null);
}
return emitFromTraverserToSnapshot(snapshotTraverser);
}
@Override
public boolean isCooperative() {
return joinInfo.isCooperative();
}
/**
* Increment the `value`. If equal to `max`, set to 0. Returns the new value.
*/
private static int cycle(MutableInteger val, int max) {
val.value++;
if (val.value == max) {
val.value = 0;
}
return val.value;
}
@Override
protected void restoreFromSnapshot(@Nonnull Object key, @Nonnull Object value) {
if (value instanceof BufferSnapshotValue) {
BufferSnapshotValue bsv = (BufferSnapshotValue) value;
buffer[bsv.bufferOrdinal()].add(bsv.row());
if (bsv.unused()) {
unusedEventsTracker.add(bsv.row());
}
return;
}
if (key instanceof BroadcastKey) {
BroadcastKey> broadcastKey = (BroadcastKey>) key;
WatermarkStateValue wmValue = (WatermarkStateValue) value;
// We pick minimal available watermark (both emitted/received) among all processors
if (WM_STATE_KEY.equals(broadcastKey.key())) {
Long stateWm = wmState.get(wmValue.key());
boolean shouldUpdateReceived = stateWm <= OBJECT_2_LONG_MAP_MIN_VALUE || stateWm > wmValue.timestamp();
if (shouldUpdateReceived) {
wmState.put(wmValue.key(), wmValue.timestamp());
}
} else if (LAST_RECEIVED_WM_KEY.equals(broadcastKey.key())) {
Long stateWm = lastReceivedWm.get(wmValue.key());
boolean shouldUpdateReceived = stateWm <= OBJECT_2_LONG_MAP_MIN_VALUE || stateWm > wmValue.timestamp();
if (shouldUpdateReceived) {
lastReceivedWm.put(wmValue.key(), wmValue.timestamp());
}
} else {
throw new JetException("Unexpected broadcast key: " + broadcastKey.key());
}
}
}
@Override
public boolean closeIsCooperative() {
return true;
}
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
private boolean processPendingOutput() {
while (!pendingOutput.isEmpty()) {
if (!tryEmit(pendingOutput.peek())) {
return false;
} else {
pendingOutput.remove();
}
}
return true;
}
/**
* @return Returns true, if the state was modified
*/
private boolean applyToWmState(Watermark watermark) {
boolean modified = false;
Byte inputWmKey = watermark.key();
Map wmKeyMapping = postponeTimeMap.get(inputWmKey);
for (Map.Entry entry : wmKeyMapping.entrySet()) {
Long newLimit = watermark.timestamp() - entry.getValue();
Long oldLimit = wmState.get(entry.getKey());
if (newLimit > oldLimit) {
wmState.put(entry.getKey(), newLimit);
modified = true;
}
}
return modified;
}
private void clearExpiredItemsInBuffer(int ordinal) {
List>> extractors = timeExtractors(ordinal);
long[] limits = new long[extractors.size()];
// when removing from the buffer, we'll also recalculate the minimum buffer times
for (int i = 0; i < extractors.size(); i++) {
// TODO ignore time fields not in WM state
limits[i] = wmState.getOrDefault(extractors.get(i).getKey(), Long.MIN_VALUE);
}
buffer[ordinal].clearExpiredItems(limits, row -> {
if (outerJoinSide == ordinal && unusedEventsTracker.remove(row)) {
// 5.4: If doing an outer join, emit events removed from the buffer,
// with `null`s for the other side, if the event was never joined.
JetSqlRow joinedRow = composeRowWithNulls(row, ordinal);
if (joinedRow != null) {
pendingOutput.add(joinedRow);
}
}
});
}
private List>> timeExtractors(int ordinal) {
return ordinal == 0 ? leftTimeExtractors : rightTimeExtractors;
}
// If current join type is LEFT/RIGHT and a row don't have a matching row on the other side
// we should to produce input row with null-filled opposite side.
private JetSqlRow composeRowWithNulls(JetSqlRow row, int ordinal) {
JetSqlRow joinedRow = null;
if (ordinal == 1 && joinInfo.isRightOuter()) {
// fill LEFT side with nulls
joinedRow = ExpressionUtil.join(
emptyLeftRow,
row,
ConstantExpression.TRUE,
evalContext
);
} else if (ordinal == 0 && joinInfo.isLeftOuter()) {
// fill RIGHT side with nulls
joinedRow = ExpressionUtil.join(
row,
emptyRightRow,
ConstantExpression.TRUE,
evalContext
);
}
return joinedRow;
}
private StreamToStreamJoinBuffer[] createBuffers() {
return new StreamToStreamJoinBuffer[]{
leftTimeExtractors.size() == 1
? new StreamToStreamJoinHeapBuffer(leftTimeExtractors)
: new StreamToStreamJoinListBuffer(leftTimeExtractors),
rightTimeExtractors.size() == 1
? new StreamToStreamJoinHeapBuffer(rightTimeExtractors)
: new StreamToStreamJoinListBuffer(rightTimeExtractors)
};
}
enum StreamToStreamJoinBroadcastKeys {
WM_STATE_KEY,
LAST_RECEIVED_WM_KEY
}
public static final class StreamToStreamJoinProcessorSupplier implements ProcessorSupplier, DataSerializable {
private JetJoinInfo joinInfo;
private Map> leftTimeExtractors;
private Map> rightTimeExtractors;
private Map> postponeTimeMap;
private int leftInputColumnCount;
private int rightInputColumnCount;
@SuppressWarnings("unused") // for deserialization
private StreamToStreamJoinProcessorSupplier() {
}
public StreamToStreamJoinProcessorSupplier(
final JetJoinInfo joinInfo,
final Map> leftTimeExtractors,
final Map> rightTimeExtractors,
final Map> postponeTimeMap,
final int leftInputColumnCount,
final int rightInputColumnCount
) {
this.joinInfo = joinInfo;
this.leftTimeExtractors = leftTimeExtractors;
this.rightTimeExtractors = rightTimeExtractors;
this.postponeTimeMap = postponeTimeMap;
this.leftInputColumnCount = leftInputColumnCount;
this.rightInputColumnCount = rightInputColumnCount;
}
@Nonnull
@Override
public Collection extends Processor> get(int count) {
List processors = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
processors.add(
new StreamToStreamJoinP(
joinInfo,
leftTimeExtractors,
rightTimeExtractors,
postponeTimeMap,
Tuple2.tuple2(leftInputColumnCount, rightInputColumnCount)));
}
return processors;
}
@Override
public void writeData(ObjectDataOutput out) throws IOException {
out.writeObject(joinInfo);
SerializationUtil.writeMap(leftTimeExtractors, out);
SerializationUtil.writeMap(rightTimeExtractors, out);
SerializationUtil.writeMap(postponeTimeMap, out);
out.writeInt(leftInputColumnCount);
out.writeInt(rightInputColumnCount);
}
@Override
public void readData(ObjectDataInput in) throws IOException {
joinInfo = in.readObject();
leftTimeExtractors = SerializationUtil.readMap(in);
rightTimeExtractors = SerializationUtil.readMap(in);
postponeTimeMap = SerializationUtil.readMap(in);
leftInputColumnCount = in.readInt();
rightInputColumnCount = in.readInt();
}
}
private static final class BufferSnapshotValue implements DataSerializable {
private JetSqlRow row;
private boolean unused;
private int bufferOrdinal;
@SuppressWarnings("unused")
BufferSnapshotValue() {
}
private BufferSnapshotValue(JetSqlRow row, boolean unused, int bufferOrdinal) {
this.row = row;
this.unused = unused;
this.bufferOrdinal = bufferOrdinal;
}
public JetSqlRow row() {
return row;
}
public int bufferOrdinal() {
return bufferOrdinal;
}
public boolean unused() {
return unused;
}
@Override
public void writeData(ObjectDataOutput out) throws IOException {
out.writeInt(bufferOrdinal);
out.writeBoolean(unused);
out.writeObject(row);
}
@Override
public void readData(ObjectDataInput in) throws IOException {
bufferOrdinal = in.readInt();
unused = in.readBoolean();
row = in.readObject(JetSqlRow.class);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
BufferSnapshotValue that = (BufferSnapshotValue) o;
return unused == that.unused && bufferOrdinal == that.bufferOrdinal && row.equals(that.row);
}
@Override
public int hashCode() {
return Objects.hash(row, unused, bufferOrdinal);
}
@Override
public String toString() {
return "BufferSnapshotValue{" +
"row=" + row.get(0) +
", unused=" + unused +
", isLeftBuffer=" + bufferOrdinal +
'}';
}
}
private static final class WatermarkStateValue implements DataSerializable {
private Byte key;
private Long timestamp;
WatermarkStateValue() {
}
private WatermarkStateValue(Byte key, Long timestamp) {
this.key = key;
this.timestamp = timestamp;
}
public Byte key() {
return key;
}
public Long timestamp() {
return timestamp;
}
@Override
public void writeData(ObjectDataOutput out) throws IOException {
out.writeByte(key);
out.writeLong(timestamp);
}
@Override
public void readData(ObjectDataInput in) throws IOException {
key = in.readByte();
timestamp = in.readLong();
}
@Override
public String toString() {
return "WatermarkValue{" +
"key=" + key +
", timestamp=" + timestamp +
'}';
}
}
}