|
| 1 | +package feast.ingestion; |
| 2 | + |
| 3 | +import com.google.common.io.Files; |
| 4 | +import com.google.protobuf.InvalidProtocolBufferException; |
| 5 | +import com.google.protobuf.util.JsonFormat; |
| 6 | +import feast.core.FeatureSetProto.EntitySpec; |
| 7 | +import feast.core.FeatureSetProto.FeatureSetSpec; |
| 8 | +import feast.core.FeatureSetProto.FeatureSpec; |
| 9 | +import feast.core.SourceProto.KafkaSourceConfig; |
| 10 | +import feast.core.SourceProto.Source; |
| 11 | +import feast.core.SourceProto.SourceType; |
| 12 | +import feast.core.StoreProto.Store; |
| 13 | +import feast.core.StoreProto.Store.RedisConfig; |
| 14 | +import feast.core.StoreProto.Store.StoreType; |
| 15 | +import feast.core.StoreProto.Store.Subscription; |
| 16 | +import feast.ingestion.options.ImportOptions; |
| 17 | +import feast.storage.RedisProto.RedisKey; |
| 18 | +import feast.test.TestUtil; |
| 19 | +import feast.test.TestUtil.LocalKafka; |
| 20 | +import feast.test.TestUtil.LocalRedis; |
| 21 | +import feast.types.FeatureRowProto.FeatureRow; |
| 22 | +import feast.types.ValueProto.ValueType.Enum; |
| 23 | +import java.io.IOException; |
| 24 | +import java.nio.charset.StandardCharsets; |
| 25 | +import java.time.Duration; |
| 26 | +import java.util.ArrayList; |
| 27 | +import java.util.Collections; |
| 28 | +import java.util.HashMap; |
| 29 | +import java.util.List; |
| 30 | +import java.util.Map; |
| 31 | +import java.util.stream.IntStream; |
| 32 | +import org.apache.beam.sdk.PipelineResult; |
| 33 | +import org.apache.beam.sdk.PipelineResult.State; |
| 34 | +import org.apache.beam.sdk.options.PipelineOptionsFactory; |
| 35 | +import org.apache.kafka.common.serialization.ByteArraySerializer; |
| 36 | +import org.junit.AfterClass; |
| 37 | +import org.junit.Assert; |
| 38 | +import org.junit.BeforeClass; |
| 39 | +import org.junit.Test; |
| 40 | +import org.slf4j.Logger; |
| 41 | +import org.slf4j.LoggerFactory; |
| 42 | +import redis.clients.jedis.Jedis; |
| 43 | + |
| 44 | +public class ImportJobTest { |
| 45 | + |
| 46 | + private static final Logger LOGGER = LoggerFactory.getLogger(ImportJobTest.class.getName()); |
| 47 | + |
| 48 | + private static final String KAFKA_HOST = "localhost"; |
| 49 | + private static final int KAFKA_PORT = 9093; |
| 50 | + private static final String KAFKA_BOOTSTRAP_SERVERS = KAFKA_HOST + ":" + KAFKA_PORT; |
| 51 | + private static final short KAFKA_REPLICATION_FACTOR = 1; |
| 52 | + private static final String KAFKA_TOPIC = "topic_1"; |
| 53 | + private static final long KAFKA_PUBLISH_TIMEOUT_SEC = 10; |
| 54 | + |
| 55 | + @SuppressWarnings("UnstableApiUsage") |
| 56 | + private static final String ZOOKEEPER_DATA_DIR = Files.createTempDir().getAbsolutePath(); |
| 57 | + private static final String ZOOKEEPER_HOST = "localhost"; |
| 58 | + private static final int ZOOKEEPER_PORT = 2182; |
| 59 | + |
| 60 | + private static final String REDIS_HOST = "localhost"; |
| 61 | + private static final int REDIS_PORT = 6380; |
| 62 | + |
| 63 | + // Expected time taken for the import job to be ready to receive Feature Row input |
| 64 | + private static final int IMPORT_JOB_READY_DURATION_SEC = 5; |
| 65 | + // Expected time taken for the import job to finish writing to Store |
| 66 | + private static final int IMPORT_JOB_RUN_DURATION_SEC = 10; |
| 67 | + |
| 68 | + @BeforeClass |
| 69 | + public static void setup() throws IOException, InterruptedException { |
| 70 | + LocalKafka.start(KAFKA_HOST, KAFKA_PORT, KAFKA_REPLICATION_FACTOR, true, ZOOKEEPER_HOST, |
| 71 | + ZOOKEEPER_PORT, ZOOKEEPER_DATA_DIR); |
| 72 | + LocalRedis.start(REDIS_PORT); |
| 73 | + } |
| 74 | + |
| 75 | + @AfterClass |
| 76 | + public static void tearDown() { |
| 77 | + LocalRedis.stop(); |
| 78 | + LocalKafka.stop(); |
| 79 | + } |
| 80 | + |
| 81 | + @Test |
| 82 | + public void runPipeline_ShouldWriteToRedisCorrectlyGivenValidSpecAndFeatureRow() |
| 83 | + throws IOException, InterruptedException { |
| 84 | + FeatureSetSpec spec = |
| 85 | + FeatureSetSpec.newBuilder().setName("feature_set").setVersion(3) |
| 86 | + .addEntities(EntitySpec.newBuilder() |
| 87 | + .setName("entity_id_primary").setValueType(Enum.INT32).build()) |
| 88 | + .addEntities(EntitySpec.newBuilder() |
| 89 | + .setName("entity_id_secondary").setValueType(Enum.STRING).build()) |
| 90 | + .addFeatures(FeatureSpec.newBuilder() |
| 91 | + .setName("feature_1").setValueType(Enum.STRING_LIST).build()) |
| 92 | + .addFeatures(FeatureSpec.newBuilder() |
| 93 | + .setName("feature_2").setValueType(Enum.STRING).build()) |
| 94 | + .addFeatures(FeatureSpec.newBuilder() |
| 95 | + .setName("feature_3").setValueType(Enum.INT64).build()) |
| 96 | + .setSource(Source.newBuilder() |
| 97 | + .setType(SourceType.KAFKA).setKafkaSourceConfig( |
| 98 | + KafkaSourceConfig.newBuilder() |
| 99 | + .setBootstrapServers(KAFKA_HOST + ":" + KAFKA_PORT) |
| 100 | + .setTopic(KAFKA_TOPIC).build()) |
| 101 | + .build()) |
| 102 | + .build(); |
| 103 | + |
| 104 | + Store redis = |
| 105 | + Store.newBuilder().setName(StoreType.REDIS.toString()).setType(StoreType.REDIS) |
| 106 | + .setRedisConfig(RedisConfig.newBuilder() |
| 107 | + .setHost(REDIS_HOST).setPort(REDIS_PORT).build()) |
| 108 | + .addSubscriptions(Subscription.newBuilder() |
| 109 | + .setName(spec.getName()).setVersion(String.valueOf(spec.getVersion())).build()) |
| 110 | + .build(); |
| 111 | + |
| 112 | + ImportOptions options = PipelineOptionsFactory.create().as(ImportOptions.class); |
| 113 | + options.setFeatureSetSpecJson( |
| 114 | + Collections.singletonList( |
| 115 | + JsonFormat.printer().omittingInsignificantWhitespace().print(spec))); |
| 116 | + options.setStoreJson( |
| 117 | + Collections.singletonList( |
| 118 | + JsonFormat.printer().omittingInsignificantWhitespace().print(redis))); |
| 119 | + options.setBlockOnRun(false); |
| 120 | + |
| 121 | + int inputSize = 4096; |
| 122 | + List<FeatureRow> input = new ArrayList<>(); |
| 123 | + Map<RedisKey, FeatureRow> expected = new HashMap<>(); |
| 124 | + |
| 125 | + LOGGER.info("Generating test data ..."); |
| 126 | + IntStream.range(0, inputSize).forEach(i -> { |
| 127 | + FeatureRow randomRow = TestUtil.createRandomFeatureRow(spec); |
| 128 | + RedisKey redisKey = TestUtil.createRedisKey(spec, randomRow); |
| 129 | + input.add(randomRow); |
| 130 | + expected.put(redisKey, randomRow); |
| 131 | + }); |
| 132 | + |
| 133 | + LOGGER.info("Starting Import Job with the following options: {}", options.toString()); |
| 134 | + PipelineResult pipelineResult = ImportJob.runPipeline(options); |
| 135 | + Thread.sleep(Duration.ofSeconds(IMPORT_JOB_READY_DURATION_SEC).toMillis()); |
| 136 | + Assert.assertEquals(pipelineResult.getState(), State.RUNNING); |
| 137 | + |
| 138 | + LOGGER.info("Publishing {} Feature Row messages to Kafka ...", input.size()); |
| 139 | + TestUtil.publishFeatureRowsToKafka(KAFKA_BOOTSTRAP_SERVERS, KAFKA_TOPIC, input, |
| 140 | + ByteArraySerializer.class, KAFKA_PUBLISH_TIMEOUT_SEC); |
| 141 | + Thread.sleep(Duration.ofSeconds(IMPORT_JOB_RUN_DURATION_SEC).toMillis()); |
| 142 | + |
| 143 | + LOGGER.info("Validating the actual values written to Redis ..."); |
| 144 | + Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT); |
| 145 | + expected.forEach((key, expectedValue) -> { |
| 146 | + byte[] actualByteValue = jedis.get(key.toByteArray()); |
| 147 | + Assert.assertNotNull("Key not found in Redis: " + key, actualByteValue); |
| 148 | + FeatureRow actualValue = null; |
| 149 | + try { |
| 150 | + actualValue = FeatureRow.parseFrom(actualByteValue); |
| 151 | + } catch (InvalidProtocolBufferException e) { |
| 152 | + Assert.fail(String |
| 153 | + .format("Actual Redis value cannot be parsed as FeatureRow, key: %s, value :%s", |
| 154 | + key, new String(actualByteValue, StandardCharsets.UTF_8))); |
| 155 | + } |
| 156 | + Assert.assertEquals(expectedValue, actualValue); |
| 157 | + }); |
| 158 | + } |
| 159 | +} |
0 commit comments