diff --git a/sdks/java/io/redis/src/main/java/org/apache/beam/sdk/io/redis/RedisIO.java b/sdks/java/io/redis/src/main/java/org/apache/beam/sdk/io/redis/RedisIO.java index 62109a6a6660..6b5cd99c69e4 100644 --- a/sdks/java/io/redis/src/main/java/org/apache/beam/sdk/io/redis/RedisIO.java +++ b/sdks/java/io/redis/src/main/java/org/apache/beam/sdk/io/redis/RedisIO.java @@ -20,19 +20,23 @@ import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; import com.google.auto.value.AutoValue; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import org.apache.beam.sdk.coders.ByteArrayCoder; import org.apache.beam.sdk.coders.KvCoder; -import org.apache.beam.sdk.coders.StringUtf8Coder; import org.apache.beam.sdk.io.range.ByteKey; import org.apache.beam.sdk.io.range.ByteKeyRange; import org.apache.beam.sdk.transforms.Create; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.Filter; import org.apache.beam.sdk.transforms.Latest; +import org.apache.beam.sdk.transforms.MapElements; import org.apache.beam.sdk.transforms.PTransform; import org.apache.beam.sdk.transforms.ParDo; import org.apache.beam.sdk.transforms.SerializableFunctions; +import org.apache.beam.sdk.transforms.SimpleFunction; import org.apache.beam.sdk.transforms.View; import org.apache.beam.sdk.transforms.display.DisplayData; import org.apache.beam.sdk.transforms.splittabledofn.RestrictionTracker; @@ -41,6 +45,7 @@ import org.apache.beam.sdk.values.PCollection; import org.apache.beam.sdk.values.PCollectionView; import org.apache.beam.sdk.values.PDone; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.primitives.Longs; import org.checkerframework.checker.nullness.qual.Nullable; import redis.clients.jedis.Jedis; import redis.clients.jedis.StreamEntryID; @@ -106,6 +111,16 @@ * * } * + *

{@link #writeBytes()} is similar to {@link #write()} but works with key/value pairs + * represented as byte arrays: + * + *

{@code
+ * pipeline.apply(...)
+ *   // here we a have a PCollection with key/value pairs
+ *   .apply(RedisIO.writeBytes().withEndpoint("::1", 6379))
+ *
+ * }
+ * *

Writing Redis Streams

* *

{@link #writeStreams()} appends the entries of a {@link PCollection} of key/value pairs @@ -159,12 +174,32 @@ public static Read read() { .build(); } + /** Read binary data from a Redis server. */ + public static ReadBytes readBytes() { + return new AutoValue_RedisIO_ReadBytes.Builder() + .setConnectionConfiguration(RedisConnectionConfiguration.create()) + .setKeyPattern("*") + .setBatchSize(1000) + .setOutputParallelization(true) + .build(); + } + /** * Like {@link #read()} but executes multiple instances of the Redis query substituting each * element of a {@link PCollection} as key pattern. */ public static ReadKeyPatterns readKeyPatterns() { return new AutoValue_RedisIO_ReadKeyPatterns.Builder() + .setReadKeyPatternsBytes(readKeyPatternsBytes()) + .build(); + } + + /** + * Like {@link #read()} but executes multiple instances of the Redis query substituting each + * element of a {@link PCollection} as key pattern. + */ + public static ReadKeyPatternsBytes readKeyPatternsBytes() { + return new AutoValue_RedisIO_ReadKeyPatternsBytes.Builder() .setConnectionConfiguration(RedisConnectionConfiguration.create()) .setBatchSize(1000) .setOutputParallelization(true) @@ -173,7 +208,12 @@ public static ReadKeyPatterns readKeyPatterns() { /** Write data to a Redis server. */ public static Write write() { - return new AutoValue_RedisIO_Write.Builder() + return new AutoValue_RedisIO_Write.Builder().setWriteBytes(writeBytes()).build(); + } + + /** Write data to a Redis server. */ + public static WriteBytes writeBytes() { + return new AutoValue_RedisIO_WriteBytes.Builder() .setConnectionConfiguration(RedisConnectionConfiguration.create()) .setMethod(Write.Method.APPEND) .build(); @@ -182,6 +222,12 @@ public static Write write() { /** Write stream data to a Redis server. */ public static WriteStreams writeStreams() { return new AutoValue_RedisIO_WriteStreams.Builder() + .setWriteStreamsBytes(writeStreamsBytes()) + .build(); + } + + public static WriteStreamsBytes writeStreamsBytes() { + return new AutoValue_RedisIO_WriteStreamsBytes.Builder() .setConnectionConfiguration(RedisConnectionConfiguration.create()) .setMaxLen(0L) .setApproximateTrim(true) @@ -282,33 +328,187 @@ public PCollection> expand(PBegin input) { } } - /** Implementation of {@link #readKeyPatterns()}. */ + /** Implementation of {@link #readBytes()}. */ @AutoValue - public abstract static class ReadKeyPatterns - extends PTransform, PCollection>> { + public abstract static class ReadBytes + extends PTransform>> { abstract @Nullable RedisConnectionConfiguration connectionConfiguration(); + abstract @Nullable String keyPattern(); + abstract int batchSize(); abstract boolean outputParallelization(); abstract Builder toBuilder(); + public ReadBytes withEndpoint(String host, int port) { + checkArgument(host != null, "host can not be null"); + checkArgument(0 < port && port < 65536, "port must be a positive integer less than 65536"); + return toBuilder() + .setConnectionConfiguration(connectionConfiguration().withHost(host).withPort(port)) + .build(); + } + + public ReadBytes withAuth(String auth) { + checkArgument(auth != null, "auth can not be null"); + return toBuilder() + .setConnectionConfiguration(connectionConfiguration().withAuth(auth)) + .build(); + } + + public ReadBytes withTimeout(int timeout) { + checkArgument(timeout >= 0, "timeout can not be negative"); + return toBuilder() + .setConnectionConfiguration(connectionConfiguration().withTimeout(timeout)) + .build(); + } + + public ReadBytes withKeyPattern(String keyPattern) { + checkArgument(keyPattern != null, "keyPattern can not be null"); + return toBuilder().setKeyPattern(keyPattern).build(); + } + + public ReadBytes withConnectionConfiguration(RedisConnectionConfiguration connection) { + checkArgument(connection != null, "connection can not be null"); + return toBuilder().setConnectionConfiguration(connection).build(); + } + + public ReadBytes withBatchSize(int batchSize) { + return toBuilder().setBatchSize(batchSize).build(); + } + + /** + * Whether to reshuffle the resulting PCollection so results are distributed to all workers. The + * default is to parallelize and should only be changed if this is known to be unnecessary. + */ + public ReadBytes withOutputParallelization(boolean outputParallelization) { + return toBuilder().setOutputParallelization(outputParallelization).build(); + } + + @Override + public void populateDisplayData(DisplayData.Builder builder) { + connectionConfiguration().populateDisplayData(builder); + } + + @Override + public PCollection> expand(PBegin input) { + checkArgument(connectionConfiguration() != null, "withConnectionConfiguration() is required"); + + return input + .apply(Create.of(keyPattern())) + .apply( + RedisIO.readKeyPatternsBytes() + .withConnectionConfiguration(connectionConfiguration()) + .withBatchSize(batchSize()) + .withOutputParallelization(outputParallelization())); + } + @AutoValue.Builder abstract static class Builder { abstract @Nullable Builder setConnectionConfiguration( RedisConnectionConfiguration connection); + abstract @Nullable Builder setKeyPattern(String keyPattern); + abstract Builder setBatchSize(int batchSize); abstract Builder setOutputParallelization(boolean outputParallelization); - abstract ReadKeyPatterns build(); + abstract ReadBytes build(); } + } + + /** Implementation of {@link #readKeyPatterns()}. */ + @AutoValue + public abstract static class ReadKeyPatterns + extends PTransform, PCollection>> { + + abstract ReadKeyPatternsBytes readKeyPatternsBytes(); + + abstract Builder toBuilder(); public ReadKeyPatterns withEndpoint(String host, int port) { + return toBuilder() + .setReadKeyPatternsBytes(readKeyPatternsBytes().withEndpoint(host, port)) + .build(); + } + + public ReadKeyPatterns withAuth(String auth) { + return toBuilder().setReadKeyPatternsBytes(readKeyPatternsBytes().withAuth(auth)).build(); + } + + public ReadKeyPatterns withTimeout(int timeout) { + return toBuilder() + .setReadKeyPatternsBytes(readKeyPatternsBytes().withTimeout(timeout)) + .build(); + } + + public ReadKeyPatterns withConnectionConfiguration(RedisConnectionConfiguration connection) { + return toBuilder() + .setReadKeyPatternsBytes(readKeyPatternsBytes().withConnectionConfiguration(connection)) + .build(); + } + + public ReadKeyPatterns withBatchSize(int batchSize) { + return toBuilder() + .setReadKeyPatternsBytes(readKeyPatternsBytes().withBatchSize(batchSize)) + .build(); + } + + /** + * Whether to reshuffle the resulting PCollection so results are distributed to all workers. The + * default is to parallelize and should only be changed if this is known to be unnecessary. + */ + public ReadKeyPatterns withOutputParallelization(boolean outputParallelization) { + return toBuilder() + .setReadKeyPatternsBytes( + readKeyPatternsBytes().withOutputParallelization(outputParallelization)) + .build(); + } + + @Override + public PCollection> expand(PCollection input) { + + return input + .apply(readKeyPatternsBytes()) + .apply( + MapElements.via( + new SimpleFunction, KV>() { + @Override + public KV apply(KV input) { + return KV.of( + new String(input.getKey(), StandardCharsets.UTF_8), + new String(input.getValue(), StandardCharsets.UTF_8)); + } + })); + } + + @AutoValue.Builder + abstract static class Builder { + + abstract Builder setReadKeyPatternsBytes(ReadKeyPatternsBytes readKeyPatternsBytes); + + abstract ReadKeyPatterns build(); + } + } + + /** Implementation of {@link #readKeyPatternsBytes()}. */ + @AutoValue + public abstract static class ReadKeyPatternsBytes + extends PTransform, PCollection>> { + + abstract @Nullable RedisConnectionConfiguration connectionConfiguration(); + + abstract int batchSize(); + + abstract boolean outputParallelization(); + + abstract Builder toBuilder(); + + public ReadKeyPatternsBytes withEndpoint(String host, int port) { checkArgument(host != null, "host can not be null"); checkArgument(port > 0, "port can not be negative or 0"); return toBuilder() @@ -316,26 +516,27 @@ public ReadKeyPatterns withEndpoint(String host, int port) { .build(); } - public ReadKeyPatterns withAuth(String auth) { + public ReadKeyPatternsBytes withAuth(String auth) { checkArgument(auth != null, "auth can not be null"); return toBuilder() .setConnectionConfiguration(connectionConfiguration().withAuth(auth)) .build(); } - public ReadKeyPatterns withTimeout(int timeout) { + public ReadKeyPatternsBytes withTimeout(int timeout) { checkArgument(timeout >= 0, "timeout can not be negative"); return toBuilder() .setConnectionConfiguration(connectionConfiguration().withTimeout(timeout)) .build(); } - public ReadKeyPatterns withConnectionConfiguration(RedisConnectionConfiguration connection) { + public ReadKeyPatternsBytes withConnectionConfiguration( + RedisConnectionConfiguration connection) { checkArgument(connection != null, "connection can not be null"); return toBuilder().setConnectionConfiguration(connection).build(); } - public ReadKeyPatterns withBatchSize(int batchSize) { + public ReadKeyPatternsBytes withBatchSize(int batchSize) { return toBuilder().setBatchSize(batchSize).build(); } @@ -343,26 +544,40 @@ public ReadKeyPatterns withBatchSize(int batchSize) { * Whether to reshuffle the resulting PCollection so results are distributed to all workers. The * default is to parallelize and should only be changed if this is known to be unnecessary. */ - public ReadKeyPatterns withOutputParallelization(boolean outputParallelization) { + public ReadKeyPatternsBytes withOutputParallelization(boolean outputParallelization) { return toBuilder().setOutputParallelization(outputParallelization).build(); } @Override - public PCollection> expand(PCollection input) { + public PCollection> expand(PCollection input) { checkArgument(connectionConfiguration() != null, "withConnectionConfiguration() is required"); - PCollection> output = + + PCollection> output = input .apply(ParDo.of(new ReadFn(connectionConfiguration()))) - .setCoder(KvCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of())); + .setCoder(KvCoder.of(ByteArrayCoder.of(), ByteArrayCoder.of())); if (outputParallelization()) { output = output.apply(new Reparallelize()); } return output; } + + @AutoValue.Builder + abstract static class Builder { + + abstract @Nullable Builder setConnectionConfiguration( + RedisConnectionConfiguration connection); + + abstract Builder setBatchSize(int batchSize); + + abstract Builder setOutputParallelization(boolean outputParallelization); + + abstract ReadKeyPatternsBytes build(); + } } @DoFn.BoundedPerElement - private static class ReadFn extends DoFn> { + private static class ReadFn extends DoFn> { protected final RedisConnectionConfiguration connectionConfiguration; transient Jedis jedis; @@ -394,10 +609,11 @@ public void processElement( ScanParams scanParams = new ScanParams(); scanParams.match(c.element()); while (tracker.tryClaim(cursor)) { - ScanResult scanResult = jedis.scan(redisCursor.getCursor(), scanParams); + byte[] cursorBytes = redisCursor.getCursor().getBytes(StandardCharsets.UTF_8); + ScanResult scanResult = jedis.scan(cursorBytes, scanParams); if (scanResult.getResult().size() > 0) { - String[] keys = scanResult.getResult().toArray(new String[scanResult.getResult().size()]); - List results = jedis.mget(keys); + byte[][] keys = scanResult.getResult().toArray(new byte[scanResult.getResult().size()][]); + List results = jedis.mget(keys); for (int i = 0; i < results.size(); i++) { if (results.get(i) != null) { c.output(KV.of(keys[i], results.get(i))); @@ -411,20 +627,20 @@ public void processElement( } private static class Reparallelize - extends PTransform>, PCollection>> { + extends PTransform>, PCollection>> { @Override - public PCollection> expand(PCollection> input) { + public PCollection> expand(PCollection> input) { // reparallelize mimics the same behavior as in JdbcIO, used to break fusion - PCollectionView>> empty = + PCollectionView>> empty = input .apply("Consume", Filter.by(SerializableFunctions.constant(false))) .apply(View.asIterable()); - PCollection> materialized = + PCollection> materialized = input.apply( "Identity", ParDo.of( - new DoFn, KV>() { + new DoFn, KV>() { @ProcessElement public void processElement(ProcessContext c) { c.output(c.element()); @@ -441,6 +657,66 @@ public void processElement(ProcessContext c) { @AutoValue public abstract static class Write extends PTransform>, PDone> { + abstract WriteBytes writeBytes(); + + public abstract Builder toBuilder(); + + public Write withEndpoint(String host, int port) { + return toBuilder().setWriteBytes(writeBytes().withEndpoint(host, port)).build(); + } + + public Write withAuth(String auth) { + return toBuilder().setWriteBytes(writeBytes().withAuth(auth)).build(); + } + + public Write withTimeout(int timeout) { + return toBuilder().setWriteBytes(writeBytes().withTimeout(timeout)).build(); + } + + public Write withConnectionConfiguration(RedisConnectionConfiguration connection) { + return toBuilder() + .setWriteBytes(writeBytes().withConnectionConfiguration(connection)) + .build(); + } + + public Write withMethod(Method method) { + return toBuilder().setWriteBytes(writeBytes().withMethod(method)).build(); + } + + public Write withExpireTime(Long expireTimeMillis) { + return toBuilder().setWriteBytes(writeBytes().withExpireTime(expireTimeMillis)).build(); + } + + @Override + public PDone expand(PCollection> input) { + MapElements, KV> toBytes; + Method method = writeBytes().method(); + if (Method.INCRBY == method || Method.DECRBY == method) { + toBytes = + MapElements.via( + new SimpleFunction, KV>() { + @Override + public KV apply(KV input) { + long value = Long.parseLong(input.getValue().trim()); + return KV.of( + input.getKey().getBytes(StandardCharsets.UTF_8), Longs.toByteArray(value)); + } + }); + } else { + toBytes = + MapElements.via( + new SimpleFunction, KV>() { + @Override + public KV apply(KV input) { + return KV.of( + input.getKey().getBytes(StandardCharsets.UTF_8), + input.getValue().getBytes(StandardCharsets.UTF_8)); + } + }); + } + return input.apply(toBytes).apply(writeBytes()); + } + /** Determines the method used to insert data in Redis. */ public enum Method { @@ -480,9 +756,23 @@ public enum Method { DECRBY, } + @AutoValue.Builder + abstract static class Builder { + + abstract Builder setWriteBytes(WriteBytes writeBytes); + + abstract Write build(); + } + } + + /** A {@link PTransform} to write to a Redis server. */ + @AutoValue + public abstract static class WriteBytes + extends PTransform>, PDone> { + abstract @Nullable RedisConnectionConfiguration connectionConfiguration(); - abstract @Nullable Method method(); + abstract Write.@Nullable Method method(); abstract @Nullable Long expireTime(); @@ -494,14 +784,14 @@ abstract static class Builder { abstract Builder setConnectionConfiguration( RedisConnectionConfiguration connectionConfiguration); - abstract Builder setMethod(Method method); + abstract Builder setMethod(Write.Method method); abstract Builder setExpireTime(Long expireTimeMillis); - abstract Write build(); + abstract WriteBytes build(); } - public Write withEndpoint(String host, int port) { + public WriteBytes withEndpoint(String host, int port) { checkArgument(host != null, "host can not be null"); checkArgument(port > 0, "port can not be negative or 0"); return toBuilder() @@ -509,56 +799,56 @@ public Write withEndpoint(String host, int port) { .build(); } - public Write withAuth(String auth) { + public WriteBytes withAuth(String auth) { checkArgument(auth != null, "auth can not be null"); return toBuilder() .setConnectionConfiguration(connectionConfiguration().withAuth(auth)) .build(); } - public Write withTimeout(int timeout) { + public WriteBytes withTimeout(int timeout) { checkArgument(timeout >= 0, "timeout can not be negative"); return toBuilder() .setConnectionConfiguration(connectionConfiguration().withTimeout(timeout)) .build(); } - public Write withConnectionConfiguration(RedisConnectionConfiguration connection) { + public WriteBytes withConnectionConfiguration(RedisConnectionConfiguration connection) { checkArgument(connection != null, "connection can not be null"); return toBuilder().setConnectionConfiguration(connection).build(); } - public Write withMethod(Method method) { + public WriteBytes withMethod(Write.Method method) { checkArgument(method != null, "method can not be null"); return toBuilder().setMethod(method).build(); } - public Write withExpireTime(Long expireTimeMillis) { + public WriteBytes withExpireTime(Long expireTimeMillis) { checkArgument(expireTimeMillis != null, "expireTimeMillis can not be null"); checkArgument(expireTimeMillis > 0, "expireTimeMillis can not be negative or 0"); return toBuilder().setExpireTime(expireTimeMillis).build(); } @Override - public PDone expand(PCollection> input) { + public PDone expand(PCollection> input) { checkArgument(connectionConfiguration() != null, "withConnectionConfiguration() is required"); input.apply(ParDo.of(new WriteFn(this))); return PDone.in(input.getPipeline()); } - private static class WriteFn extends DoFn, Void> { + private static class WriteFn extends DoFn, Void> { private static final int DEFAULT_BATCH_SIZE = 1000; - private final Write spec; + private final WriteBytes spec; private transient Jedis jedis; private transient @Nullable Transaction transaction; private int batchCount; - public WriteFn(Write spec) { + public WriteFn(WriteBytes spec) { this.spec = spec; } @@ -575,7 +865,7 @@ public void startBundle() { @ProcessElement public void processElement(ProcessContext c) { - KV record = c.element(); + KV record = c.element(); writeRecord(record); @@ -588,39 +878,39 @@ public void processElement(ProcessContext c) { } } - private void writeRecord(KV record) { - Method method = spec.method(); + private void writeRecord(KV record) { + Write.Method method = spec.method(); Long expireTime = spec.expireTime(); - if (Method.APPEND == method) { + if (Write.Method.APPEND == method) { writeUsingAppendCommand(record, expireTime); - } else if (Method.SET == method) { + } else if (Write.Method.SET == method) { writeUsingSetCommand(record, expireTime); - } else if (Method.LPUSH == method || Method.RPUSH == method) { + } else if (Write.Method.LPUSH == method || Write.Method.RPUSH == method) { writeUsingListCommand(record, method, expireTime); - } else if (Method.SADD == method) { + } else if (Write.Method.SADD == method) { writeUsingSaddCommand(record, expireTime); - } else if (Method.PFADD == method) { + } else if (Write.Method.PFADD == method) { writeUsingHLLCommand(record, expireTime); - } else if (Method.INCRBY == method) { + } else if (Write.Method.INCRBY == method) { writeUsingIncrBy(record, expireTime); - } else if (Method.DECRBY == method) { + } else if (Write.Method.DECRBY == method) { writeUsingDecrBy(record, expireTime); } } - private void writeUsingAppendCommand(KV record, Long expireTime) { - String key = record.getKey(); - String value = record.getValue(); + private void writeUsingAppendCommand(KV record, Long expireTime) { + byte[] key = record.getKey(); + byte[] value = record.getValue(); transaction.append(key, value); setExpireTimeWhenRequired(key, expireTime); } - private void writeUsingSetCommand(KV record, Long expireTime) { - String key = record.getKey(); - String value = record.getValue(); + private void writeUsingSetCommand(KV record, Long expireTime) { + byte[] key = record.getKey(); + byte[] value = record.getValue(); if (expireTime != null) { transaction.psetex(key, expireTime, value); @@ -630,57 +920,57 @@ private void writeUsingSetCommand(KV record, Long expireTime) { } private void writeUsingListCommand( - KV record, Method method, Long expireTime) { + KV record, Write.Method method, Long expireTime) { - String key = record.getKey(); - String value = record.getValue(); + byte[] key = record.getKey(); + byte[] value = record.getValue(); - if (Method.LPUSH == method) { + if (Write.Method.LPUSH == method) { transaction.lpush(key, value); - } else if (Method.RPUSH == method) { + } else if (Write.Method.RPUSH == method) { transaction.rpush(key, value); } setExpireTimeWhenRequired(key, expireTime); } - private void writeUsingSaddCommand(KV record, Long expireTime) { - String key = record.getKey(); - String value = record.getValue(); + private void writeUsingSaddCommand(KV record, Long expireTime) { + byte[] key = record.getKey(); + byte[] value = record.getValue(); transaction.sadd(key, value); setExpireTimeWhenRequired(key, expireTime); } - private void writeUsingHLLCommand(KV record, Long expireTime) { - String key = record.getKey(); - String value = record.getValue(); + private void writeUsingHLLCommand(KV record, Long expireTime) { + byte[] key = record.getKey(); + byte[] value = record.getValue(); transaction.pfadd(key, value); setExpireTimeWhenRequired(key, expireTime); } - private void writeUsingIncrBy(KV record, Long expireTime) { - String key = record.getKey(); - String value = record.getValue(); - long inc = Long.parseLong(value); + private void writeUsingIncrBy(KV record, Long expireTime) { + byte[] key = record.getKey(); + byte[] value = record.getValue(); + long inc = Longs.fromByteArray(value); transaction.incrBy(key, inc); setExpireTimeWhenRequired(key, expireTime); } - private void writeUsingDecrBy(KV record, Long expireTime) { - String key = record.getKey(); - String value = record.getValue(); - long decr = Long.parseLong(value); + private void writeUsingDecrBy(KV record, Long expireTime) { + byte[] key = record.getKey(); + byte[] value = record.getValue(); + long decr = Longs.fromByteArray(value); transaction.decrBy(key, decr); setExpireTimeWhenRequired(key, expireTime); } - private void setExpireTimeWhenRequired(String key, Long expireTime) { + private void setExpireTimeWhenRequired(byte[] key, Long expireTime) { if (expireTime != null) { transaction.pexpire(key, expireTime); } @@ -705,13 +995,77 @@ public void teardown() { } } + @AutoValue + public abstract static class WriteStreams + extends PTransform>>, PDone> { + + abstract WriteStreamsBytes writeStreamsBytes(); + + public abstract Builder toBuilder(); + + public WriteStreams withMaxLen(long maxLen) { + return toBuilder().setWriteStreamsBytes(writeStreamsBytes().withMaxLen(maxLen)).build(); + } + + public WriteStreams withEndpoint(String host, int port) { + return toBuilder().setWriteStreamsBytes(writeStreamsBytes().withEndpoint(host, port)).build(); + } + + public WriteStreams withConnectionConfiguration(RedisConnectionConfiguration connection) { + return toBuilder() + .setWriteStreamsBytes(writeStreamsBytes().withConnectionConfiguration(connection)) + .build(); + } + + /** + * If {@link #withMaxLen(long)} is used, set the "~" prefix to the MAXLEN value, indicating to + * the server that it should use "close enough" trimming. + */ + public WriteStreams withApproximateTrim(boolean approximateTrim) { + return toBuilder() + .setWriteStreamsBytes(writeStreamsBytes().withApproximateTrim(approximateTrim)) + .build(); + } + + @Override + public PDone expand(PCollection>> input) { + return input.apply(ParDo.of(new StringToByteFn())).apply(writeStreamsBytes()); + } + + @AutoValue.Builder + abstract static class Builder { + + abstract Builder setWriteStreamsBytes(WriteStreamsBytes writeStreamsBytes); + + abstract WriteStreams build(); + } + + private static class StringToByteFn + extends DoFn>, KV>> { + + @ProcessElement + public void processElement( + @Element KV> element, + OutputReceiver>> out) { + byte[] key = element.getKey().getBytes(StandardCharsets.UTF_8); + Map byteMap = + element.getValue().entrySet().stream() + .collect( + Collectors.toMap( + e -> e.getKey().getBytes(StandardCharsets.UTF_8), + e -> e.getValue().getBytes(StandardCharsets.UTF_8))); + out.output(KV.of(key, byteMap)); + } + } + } + /** * A {@link PTransform} to write stream key pairs (https://redis.io/topics/streams-intro) to a * Redis server. */ @AutoValue - public abstract static class WriteStreams - extends PTransform>>, PDone> { + public abstract static class WriteStreamsBytes + extends PTransform>>, PDone> { abstract RedisConnectionConfiguration connectionConfiguration(); @@ -722,7 +1076,7 @@ public abstract static class WriteStreams abstract Builder toBuilder(); /** Set the hostname and port of the Redis server to connect to. */ - public WriteStreams withEndpoint(String host, int port) { + public WriteStreamsBytes withEndpoint(String host, int port) { checkArgument(host != null, "host can not be null"); checkArgument(port > 0, "port can not be negative or 0"); return toBuilder() @@ -735,7 +1089,7 @@ public WriteStreams withEndpoint(String host, int port) { * either just a password or a username and password separated by a space. See * https://redis.io/commands/auth for details */ - public WriteStreams withAuth(String auth) { + public WriteStreamsBytes withAuth(String auth) { checkArgument(auth != null, "auth can not be null"); return toBuilder() .setConnectionConfiguration(connectionConfiguration().withAuth(auth)) @@ -743,7 +1097,7 @@ public WriteStreams withAuth(String auth) { } /** Set the connection timeout for the Redis server connection. */ - public WriteStreams withTimeout(int timeout) { + public WriteStreamsBytes withTimeout(int timeout) { checkArgument(timeout >= 0, "timeout can not be negative"); return toBuilder() .setConnectionConfiguration(connectionConfiguration().withTimeout(timeout)) @@ -751,13 +1105,13 @@ public WriteStreams withTimeout(int timeout) { } /** Predefine a {@link RedisConnectionConfiguration} and pass it to the builder. */ - public WriteStreams withConnectionConfiguration(RedisConnectionConfiguration connection) { + public WriteStreamsBytes withConnectionConfiguration(RedisConnectionConfiguration connection) { checkArgument(connection != null, "connection can not be null"); return toBuilder().setConnectionConfiguration(connection).build(); } /** When appending (XADD) to a stream, set a MAXLEN option. */ - public WriteStreams withMaxLen(long maxLen) { + public WriteStreamsBytes withMaxLen(long maxLen) { checkArgument(maxLen >= 0L, "maxLen must be positive if set"); return toBuilder().setMaxLen(maxLen).build(); } @@ -766,10 +1120,18 @@ public WriteStreams withMaxLen(long maxLen) { * If {@link #withMaxLen(long)} is used, set the "~" prefix to the MAXLEN value, indicating to * the server that it should use "close enough" trimming. */ - public WriteStreams withApproximateTrim(boolean approximateTrim) { + public WriteStreamsBytes withApproximateTrim(boolean approximateTrim) { return toBuilder().setApproximateTrim(approximateTrim).build(); } + @Override + public PDone expand(PCollection>> input) { + checkArgument(connectionConfiguration() != null, "withConnectionConfiguration() is required"); + + input.apply(ParDo.of(new WriteStreamFn(this))); + return PDone.in(input.getPipeline()); + } + @AutoValue.Builder abstract static class Builder { @@ -780,29 +1142,20 @@ abstract Builder setConnectionConfiguration( abstract Builder setApproximateTrim(boolean approximateTrim); - abstract WriteStreams build(); + abstract WriteStreamsBytes build(); } - @Override - public PDone expand(PCollection>> input) { - checkArgument(connectionConfiguration() != null, "withConnectionConfiguration() is required"); - - input.apply(ParDo.of(new WriteStreamFn(this))); - return PDone.in(input.getPipeline()); - } - - private static class WriteStreamFn extends DoFn>, Void> { + private static class WriteStreamFn extends DoFn>, Void> { private static final int DEFAULT_BATCH_SIZE = 1000; - private final WriteStreams spec; - + private final WriteStreamsBytes spec; private transient Jedis jedis; private transient @Nullable Transaction transaction; private int batchCount; - public WriteStreamFn(WriteStreams spec) { + public WriteStreamFn(WriteStreamsBytes spec) { this.spec = spec; } @@ -819,7 +1172,7 @@ public void startBundle() { @ProcessElement public void processElement(ProcessContext c) { - KV> record = c.element(); + KV> record = c.element(); writeRecord(record); @@ -832,9 +1185,7 @@ public void processElement(ProcessContext c) { } } - private void writeRecord(KV> record) { - String key = record.getKey(); - Map value = record.getValue(); + private void writeRecord(KV> record) { final XAddParams params = new XAddParams().id(StreamEntryID.NEW_ENTRY); if (spec.maxLen() > 0L) { params.maxLen(spec.maxLen()); @@ -842,7 +1193,7 @@ private void writeRecord(KV> record) { params.approximateTrimming(); } } - transaction.xadd(key, params, value); + transaction.xadd(record.getKey(), params, record.getValue()); } @FinishBundle diff --git a/sdks/java/io/redis/src/test/java/org/apache/beam/sdk/io/redis/RedisIOTest.java b/sdks/java/io/redis/src/test/java/org/apache/beam/sdk/io/redis/RedisIOTest.java index 5754de89ec3a..0037999d722c 100644 --- a/sdks/java/io/redis/src/test/java/org/apache/beam/sdk/io/redis/RedisIOTest.java +++ b/sdks/java/io/redis/src/test/java/org/apache/beam/sdk/io/redis/RedisIOTest.java @@ -21,9 +21,11 @@ import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists.transform; import static org.hamcrest.CoreMatchers.hasItems; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -31,8 +33,10 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; +import org.apache.beam.sdk.coders.ByteArrayCoder; import org.apache.beam.sdk.coders.KvCoder; import org.apache.beam.sdk.coders.MapCoder; import org.apache.beam.sdk.coders.StringUtf8Coder; @@ -46,6 +50,8 @@ import org.apache.beam.sdk.values.KV; import org.apache.beam.sdk.values.PCollection; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.primitives.Ints; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.primitives.Longs; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Rule; @@ -151,6 +157,36 @@ public void testReadWithKeyPattern() { p.run(); } + @Test + public void testReadBytes() { + for (int i = 0; i < 10; i++) { + byte[] key = ("binaryread" + i).getBytes(StandardCharsets.UTF_8); + byte[] value = new byte[] {(byte) i}; + client.set(key, value); + } + + PCollection> read = + p.apply( + "Read", + RedisIO.readBytes().withEndpoint(REDIS_HOST, port).withKeyPattern("binaryread*")); + + // Verify actual byte content + PAssert.that(read) + .satisfies( + results -> { + Set seen = new HashSet<>(); + for (KV kv : results) { + String key = new String(kv.getKey(), StandardCharsets.UTF_8); + int idx = Integer.parseInt(key.substring("binaryread".length())); + assertArrayEquals(new byte[] {(byte) idx}, kv.getValue()); + seen.add(key); + } + assertEquals(10, seen.size()); + return null; + }); + p.run(); + } + @Test public void testWriteWithMethodSet() { String key = "testWriteWithMethodSet"; @@ -181,7 +217,7 @@ public void testWriteWithMethodSetWithExpiration() { assertEquals(newValue, client.get(key)); Long expireTime = client.pttl(key); - assertTrue(expireTime.toString(), 9_000 <= expireTime && expireTime <= 10_0000); + assertTrue(expireTime.toString(), 9_000 <= expireTime && expireTime <= 10_000); client.del(key); } @@ -255,14 +291,14 @@ public void testWriteWithMethodPFAddWithExpireTime() { write.apply( RedisIO.write() .withEndpoint(REDIS_HOST, port) - .withMethod(Method.PFADD) + .withMethod(RedisIO.Write.Method.PFADD) .withExpireTime(10_000L)); p.run(); long count = client.pfcount(key); assertEquals(6, count); Long expireTime = client.pttl(key); - assertTrue(expireTime.toString(), 9_000 <= expireTime && expireTime <= 10_0000); + assertTrue(expireTime.toString(), 9_000 <= expireTime && expireTime <= 10_000); client.del(key); } @@ -297,6 +333,196 @@ public void testWriteUsingDECRBY() { assertEquals(-1, count); } + @Test + public void testWriteBytesWithMethodSet() { + byte[] key = {1}; + client.set(key, new byte[] {1}); + + byte[] newValue = {2}; + PCollection> write = p.apply(Create.of(KV.of(key, newValue))); + write.apply(RedisIO.writeBytes().withEndpoint(REDIS_HOST, port).withMethod(Method.SET)); + p.run(); + + assertArrayEquals(newValue, client.get(key)); + assertEquals(NO_EXPIRATION, Long.valueOf(client.ttl(key))); + } + + @Test + public void testWriteBytesWithMethodSetWithExpiration() { + byte[] key = {2}; + client.set(key, new byte[] {1}); + + byte[] newValue = {2}; + + PCollection> write = p.apply(Create.of(KV.of(key, newValue))); + write.apply( + RedisIO.writeBytes() + .withEndpoint(REDIS_HOST, port) + .withMethod(Method.SET) + .withExpireTime(10_000L)); + p.run(); + + assertArrayEquals(newValue, client.get(key)); + Long expireTime = client.pttl(key); + assertTrue(expireTime.toString(), 9_000 <= expireTime && expireTime <= 10_000); + client.del(key); + } + + @Test + public void testWriteBytesWithMethodLPush() { + byte[] key = {3}; + byte[] value = {1}; + client.lpush(key, value); + + byte[] newValue = {2}; + PCollection> write = p.apply(Create.of(KV.of(key, newValue))); + write.apply(RedisIO.writeBytes().withEndpoint(REDIS_HOST, port).withMethod(Method.LPUSH)); + p.run(); + + List values = client.lrange(key, 0, -1); + List expected = Arrays.asList(new byte[] {2}, new byte[] {1}); + assertEquals(expected.size(), values.size()); + for (int i = 0; i < expected.size(); i++) { + assertArrayEquals(expected.get(i), values.get(i)); + } + } + + @Test + public void testWriteBytesWithMethodRPush() { + byte[] key = {4}; + byte[] value = {1}; + client.lpush(key, value); + + byte[] newValue = {2}; + PCollection> write = p.apply(Create.of(KV.of(key, newValue))); + write.apply(RedisIO.writeBytes().withEndpoint(REDIS_HOST, port).withMethod(Method.RPUSH)); + p.run(); + + List values = client.lrange(key, 0, -1); + List expected = Arrays.asList(new byte[] {1}, new byte[] {2}); + assertEquals(expected.size(), values.size()); + for (int i = 0; i < expected.size(); i++) { + assertArrayEquals(expected.get(i), values.get(i)); + } + } + + @Test + public void testWriteBytesWithMethodSAdd() { + byte[] key = {5}; + List values = + Arrays.asList( + new byte[] {0}, + new byte[] {1}, + new byte[] {2}, + new byte[] {3}, + new byte[] {2}, + new byte[] {4}, + new byte[] {0}, + new byte[] {5}); + List> data = buildConstantKeyList(key, values); + + PCollection> write = p.apply(Create.of(data)); + write.apply(RedisIO.writeBytes().withEndpoint(REDIS_HOST, port).withMethod(Method.SADD)); + p.run(); + + Set members = client.smembers(key); + assertEquals(6, members.size()); + assertThat(members, hasItems(values.toArray(new byte[0][]))); + } + + @Test + public void testWriteBytesWithMethodPFAdd() { + byte[] key = {6}; + List values = + Arrays.asList( + new byte[] {0}, + new byte[] {1}, + new byte[] {2}, + new byte[] {3}, + new byte[] {2}, + new byte[] {4}, + new byte[] {0}, + new byte[] {5}); + List> data = buildConstantKeyList(key, values); + + PCollection> write = p.apply(Create.of(data)); + write.apply(RedisIO.writeBytes().withEndpoint(REDIS_HOST, port).withMethod(Method.PFADD)); + p.run(); + + long count = client.pfcount(key); + assertEquals(6, count); + assertEquals(NO_EXPIRATION, Long.valueOf(client.ttl(key))); + } + + @Test + public void testWriteBytesWithMethodPFAddWithExpireTime() { + byte[] key = {7}; + List values = + Arrays.asList( + new byte[] {0}, + new byte[] {1}, + new byte[] {2}, + new byte[] {3}, + new byte[] {2}, + new byte[] {4}, + new byte[] {0}, + new byte[] {5}); + List> data = buildConstantKeyList(key, values); + + PCollection> write = p.apply(Create.of(data)); + write.apply( + RedisIO.writeBytes() + .withEndpoint(REDIS_HOST, port) + .withMethod(Method.PFADD) + .withExpireTime(10_000L)); + p.run(); + + long count = client.pfcount(key); + assertEquals(6, count); + Long expireTime = client.pttl(key); + assertTrue(expireTime.toString(), 9_000 <= expireTime && expireTime <= 10_000); + client.del(key); + } + + @Test + public void testWriteBytesUsingINCRBY() { + byte[] key = "key_incr_bytes".getBytes(StandardCharsets.UTF_8); + List values = + Arrays.asList(0L, 1L, 2L, -3L, 2L, 4L, 0L, 5L).stream() + .map(Longs::toByteArray) + .collect(Collectors.toList()); + List> data = buildConstantKeyList(key, values); + + p.apply(Create.of(data)) + .apply(RedisIO.writeBytes().withEndpoint(REDIS_HOST, port).withMethod(Method.INCRBY)); + + p.run(); + + byte[] response = client.get(key); + long count = Long.parseLong(new String(response, StandardCharsets.UTF_8)); + assertEquals(11, count); + } + + @Test + public void testWriteBytesUsingDECRBY() { + byte[] key = "key_decr_bytes".getBytes(StandardCharsets.UTF_8); + + List values = + Arrays.asList(-10L, 1L, 2L, -3L, 2L, 4L, 0L, 5L).stream() + .map(Longs::toByteArray) + .collect(Collectors.toList()); + List> data = buildConstantKeyList(key, values); + + p.apply(Create.of(data)) + .apply(RedisIO.writeBytes().withEndpoint(REDIS_HOST, port).withMethod(Method.DECRBY)); + + p.run(); + + byte[] response = client.get(key); + long count = Long.parseLong(new String(response, StandardCharsets.UTF_8)); + assertEquals(-1, count); + } + @Test public void testWriteStreams() { @@ -330,6 +556,62 @@ public void testWriteStreams() { } } + @Test + public void testWriteStreamsBytes() { + + /* test data is 10 keys (stream IDs), each with two entries, each entry having one k/v pair of data */ + List redisKeys = + IntStream.range(0, 10).boxed().map(Ints::toByteArray).collect(Collectors.toList()); + + Map fooValues = + ImmutableMap.of( + "sensor-id".getBytes(StandardCharsets.UTF_8), + new byte[] {1, 2, 3, 4}, + "temperature".getBytes(StandardCharsets.UTF_8), + new byte[] {19, 8}); + Map barValues = + ImmutableMap.of( + "sensor-id".getBytes(StandardCharsets.UTF_8), + new byte[] {9, 9, 9, 9}, + "temperature".getBytes(StandardCharsets.UTF_8), + new byte[] {18, 2}); + + List>> allData = + redisKeys.stream() + .flatMap(id -> Stream.of(KV.of(id, fooValues), KV.of(id, barValues))) + .collect(toList()); + + PCollection>> write = + p.apply( + Create.of(allData) + .withCoder( + KvCoder.of( + ByteArrayCoder.of(), + MapCoder.of(ByteArrayCoder.of(), ByteArrayCoder.of())))); + write.apply(RedisIO.writeStreamsBytes().withEndpoint(REDIS_HOST, port)); + p.run(); + + for (byte[] key : redisKeys) { + String redisKey = new String(key, StandardCharsets.UTF_8); + List streamEntries = + client.xrange(redisKey, (StreamEntryID) null, (StreamEntryID) null, Integer.MAX_VALUE); + assertEquals(2, streamEntries.size()); + assertThat( + transform(streamEntries, StreamEntry::getFields), + hasItems( + ImmutableMap.of( + "sensor-id", + new String(new byte[] {9, 9, 9, 9}, StandardCharsets.UTF_8), + "temperature", + new String(new byte[] {18, 2}, StandardCharsets.UTF_8)), + ImmutableMap.of( + "sensor-id", + new String(new byte[] {1, 2, 3, 4}, StandardCharsets.UTF_8), + "temperature", + new String(new byte[] {19, 8}, StandardCharsets.UTF_8)))); + } + } + @Test public void testWriteStreamsWithTruncation() { /* test data is 10 keys (stream IDs), each with two entries, each entry having one k/v pair of data */ @@ -400,9 +682,9 @@ public void redisByteKeyToRedisCursor() { assertEquals("1885267", redisCursor.getCursor()); } - private static List> buildConstantKeyList(String key, List values) { - List> data = new ArrayList<>(); - for (String value : values) { + private static List> buildConstantKeyList(K key, List values) { + List> data = new ArrayList<>(); + for (V value : values) { data.add(KV.of(key, value)); } return data;