specPublisher) {
this.featureSetRepository = featureSetRepository;
this.storeRepository = storeRepository;
this.projectRepository = projectRepository;
this.defaultSource = defaultSource;
+ this.specPublisher = specPublisher;
}
/**
@@ -240,6 +249,7 @@ public ListStoresResponse listStores(ListStoresRequest.Filter filter) {
*
* @param newFeatureSet Feature set that will be created or updated.
*/
+ @Transactional
public ApplyFeatureSetResponse applyFeatureSet(FeatureSetProto.FeatureSet newFeatureSet)
throws InvalidProtocolBufferException {
// Autofill default project if not specified
@@ -299,6 +309,39 @@ public ApplyFeatureSetResponse applyFeatureSet(FeatureSetProto.FeatureSet newFea
status = Status.UPDATED;
}
+ featureSet.incVersion();
+
+ // Sending latest version of FeatureSet to all currently running IngestionJobs (there's one
+ // topic for all sets).
+ // All related jobs would apply new FeatureSet on the fly.
+ // We wait for Kafka broker to ack that the message was added to topic before actually
+ // committing this FeatureSet.
+ // In case kafka doesn't respond within SPEC_PUBLISHING_TIMEOUT_SECONDS we abort current
+ // transaction and return error to client.
+ try {
+ specPublisher
+ .sendDefault(featureSet.getReference(), featureSet.toProto().getSpec())
+ .get(SPEC_PUBLISHING_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ throw io.grpc.Status.UNAVAILABLE
+ .withDescription(
+ String.format(
+ "Unable to publish FeatureSet to Kafka. Cause: %s",
+ e.getCause() != null ? e.getCause().getMessage() : "unknown"))
+ .withCause(e)
+ .asRuntimeException();
+ }
+
+ // Updating delivery status for related jobs (that are currently using this FeatureSet).
+ // We now set status to IN_PROGRESS, so listenAckFromJobs would be able to
+ // monitor delivery progress for each new version.
+ featureSet.getJobStatuses().stream()
+ .filter(s -> s.getJob().isRunning())
+ .forEach(
+ s ->
+ s.setDeliveryStatus(
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS));
+
// Persist the FeatureSet object
featureSet.setStatus(FeatureSetStatus.STATUS_PENDING);
project.addFeatureSet(featureSet);
@@ -347,4 +390,60 @@ public UpdateStoreResponse updateStore(UpdateStoreRequest updateStoreRequest)
.setStore(updateStoreRequest.getStore())
.build();
}
+
+ /**
+ * Listener for ACK messages coming from IngestionJob when FeatureSetSpec is installed (in
+ * pipeline).
+ *
+ * Updates FeatureSetJobStatus for respected FeatureSet (selected by reference) and Job (select
+ * by Id).
+ *
+ *
When all related (running) to FeatureSet jobs are updated - FeatureSet receives READY status
+ *
+ * @param record ConsumerRecord with key: FeatureSet reference and value: Ack message
+ */
+ @KafkaListener(topics = {"${feast.stream.specsOptions.specsAckTopic}"})
+ @Transactional
+ public void listenAckFromJobs(
+ ConsumerRecord record) {
+ String setReference = record.key();
+ Pair projectAndSetName = parseReference(setReference);
+ FeatureSet featureSet =
+ featureSetRepository.findFeatureSetByNameAndProject_Name(
+ projectAndSetName.getSecond(), projectAndSetName.getFirst());
+ if (featureSet == null) {
+ log.warn(
+ String.format("ACKListener received message for unknown FeatureSet %s", setReference));
+ return;
+ }
+
+ if (featureSet.getVersion() != record.value().getFeatureSetVersion()) {
+ log.warn(
+ String.format(
+ "ACKListener received outdated ack for %s. Current %d, Received %d",
+ setReference, featureSet.getVersion(), record.value().getFeatureSetVersion()));
+ return;
+ }
+
+ featureSet.getJobStatuses().stream()
+ .filter(js -> js.getJob().getId().equals(record.value().getJobName()))
+ .findFirst()
+ .ifPresent(
+ featureSetJobStatus ->
+ featureSetJobStatus.setDeliveryStatus(
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_DELIVERED));
+
+ boolean allDelivered =
+ featureSet.getJobStatuses().stream()
+ .filter(js -> js.getJob().isRunning())
+ .allMatch(
+ js ->
+ js.getDeliveryStatus()
+ .equals(FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_DELIVERED));
+
+ if (allDelivered) {
+ featureSet.setStatus(FeatureSetStatus.STATUS_READY);
+ featureSetRepository.saveAndFlush(featureSet);
+ }
+ }
}
diff --git a/core/src/main/java/feast/core/util/KafkaSerialization.java b/core/src/main/java/feast/core/util/KafkaSerialization.java
new file mode 100644
index 00000000000..881d8eb9cf8
--- /dev/null
+++ b/core/src/main/java/feast/core/util/KafkaSerialization.java
@@ -0,0 +1,68 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright 2018-2020 The Feast Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feast.core.util;
+
+import com.google.protobuf.GeneratedMessageV3;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.Message;
+import com.google.protobuf.Parser;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import org.apache.kafka.common.serialization.Deserializer;
+import org.apache.kafka.common.serialization.Serializer;
+
+/*
+Serializer & Deserializer implementation to write & read protobuf object from/to kafka
+ */
+public class KafkaSerialization {
+ public static class ProtoSerializer implements Serializer {
+ @Override
+ public byte[] serialize(String topic, T data) {
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ try {
+ data.writeTo(stream);
+ } catch (IOException e) {
+ throw new RuntimeException(
+ String.format(
+ "Unable to serialize object of type %s. Reason: %s",
+ data.getClass().getName(), e.getCause().getMessage()));
+ }
+
+ return stream.toByteArray();
+ }
+ }
+
+ public static class ProtoDeserializer implements Deserializer {
+ private Parser parser;
+
+ public ProtoDeserializer(Parser parser) {
+ this.parser = parser;
+ }
+
+ @Override
+ public T deserialize(String topic, byte[] data) {
+ try {
+ return parser.parseFrom(data);
+ } catch (InvalidProtocolBufferException e) {
+ throw new RuntimeException(
+ String.format(
+ "Unable to deserialize object from topic %s. Reason: %s",
+ topic, e.getCause().getMessage()));
+ }
+ }
+ }
+}
diff --git a/core/src/main/resources/application-override.yaml b/core/src/main/resources/application-override.yaml
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml
index 5dc5698e889..9f02ed302db 100644
--- a/core/src/main/resources/application.yml
+++ b/core/src/main/resources/application.yml
@@ -78,6 +78,10 @@ feast:
replicationFactor: 1
partitions: 1
+ specsOptions:
+ specsTopic: feast-specs
+ specsAckTopic: feast-specs-ack
+
spring:
jpa:
properties.hibernate:
diff --git a/core/src/test/java/feast/core/job/JobUpdateTaskTest.java b/core/src/test/java/feast/core/job/JobUpdateTaskTest.java
index d1826738019..fc633ba15f8 100644
--- a/core/src/test/java/feast/core/job/JobUpdateTaskTest.java
+++ b/core/src/test/java/feast/core/job/JobUpdateTaskTest.java
@@ -24,11 +24,8 @@
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
-import feast.core.model.FeatureSet;
-import feast.core.model.Job;
-import feast.core.model.JobStatus;
-import feast.core.model.Source;
-import feast.core.model.Store;
+import feast.core.model.*;
+import feast.core.util.ModelHelpers;
import feast.proto.core.FeatureSetProto;
import feast.proto.core.FeatureSetProto.FeatureSetMeta;
import feast.proto.core.FeatureSetProto.FeatureSetSpec;
@@ -92,7 +89,14 @@ public void setUp() {
}
Job makeJob(String extId, List featureSets, JobStatus status) {
- return new Job("job", extId, RUNNER, source, store, featureSets, status);
+ return new Job(
+ "job",
+ extId,
+ RUNNER,
+ source,
+ store,
+ ModelHelpers.makeFeatureSetJobStatus(featureSets),
+ status);
}
JobUpdateTask makeTask(List featureSets, Optional currentJob) {
diff --git a/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java b/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java
index ea9caa91ff6..50812882029 100644
--- a/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java
+++ b/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java
@@ -26,20 +26,19 @@
import com.google.api.client.googleapis.testing.auth.oauth2.MockGoogleCredential;
import com.google.api.services.dataflow.Dataflow;
import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
import com.google.protobuf.Duration;
import com.google.protobuf.util.JsonFormat;
import com.google.protobuf.util.JsonFormat.Printer;
import feast.core.config.FeastProperties.MetricsProperties;
import feast.core.exception.JobExecutionException;
import feast.core.job.Runner;
-import feast.core.job.option.FeatureSetJsonByteConverter;
import feast.core.model.*;
-import feast.ingestion.options.BZip2Compressor;
import feast.ingestion.options.ImportOptions;
-import feast.ingestion.options.OptionCompressor;
import feast.proto.core.FeatureSetProto;
import feast.proto.core.FeatureSetProto.FeatureSetMeta;
import feast.proto.core.FeatureSetProto.FeatureSetSpec;
+import feast.proto.core.IngestionJobProto;
import feast.proto.core.RunnerProto.DataflowRunnerConfigOptions;
import feast.proto.core.RunnerProto.DataflowRunnerConfigOptions.Builder;
import feast.proto.core.SourceProto;
@@ -50,8 +49,6 @@
import feast.proto.core.StoreProto.Store.StoreType;
import feast.proto.core.StoreProto.Store.Subscription;
import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
import org.apache.beam.runners.dataflow.DataflowPipelineJob;
import org.apache.beam.runners.dataflow.DataflowRunner;
import org.apache.beam.sdk.PipelineResult.State;
@@ -71,6 +68,7 @@ public class DataflowJobManagerTest {
@Mock private Dataflow dataflow;
private DataflowRunnerConfigOptions defaults;
+ private IngestionJobProto.SpecsStreamingUpdateConfig specsStreamingUpdateConfig;
private DataflowJobManager dfJobManager;
@Before
@@ -94,7 +92,17 @@ public void setUp() {
e.printStackTrace();
}
- dfJobManager = new DataflowJobManager(defaults, metricsProperties, credential);
+ specsStreamingUpdateConfig =
+ IngestionJobProto.SpecsStreamingUpdateConfig.newBuilder()
+ .setSource(
+ KafkaSourceConfig.newBuilder()
+ .setTopic("specs_topic")
+ .setBootstrapServers("servers:9092")
+ .build())
+ .build();
+
+ dfJobManager =
+ new DataflowJobManager(defaults, metricsProperties, specsStreamingUpdateConfig, credential);
dfJobManager = spy(dfJobManager);
}
@@ -142,11 +150,7 @@ public void shouldStartJobWithCorrectPipelineOptions() throws IOException {
expectedPipelineOptions.setLabels(defaults.getLabelsMap());
expectedPipelineOptions.setJobName(jobName);
expectedPipelineOptions.setStoreJson(Lists.newArrayList(printer.print(store)));
-
- OptionCompressor> featureSetJsonCompressor =
- new BZip2Compressor<>(new FeatureSetJsonByteConverter());
- expectedPipelineOptions.setFeatureSetJson(
- featureSetJsonCompressor.compress(Collections.singletonList(featureSet)));
+ expectedPipelineOptions.setSourceJson(printer.print(source));
ArgumentCaptor captor = ArgumentCaptor.forClass(ImportOptions.class);
@@ -155,6 +159,10 @@ public void shouldStartJobWithCorrectPipelineOptions() throws IOException {
when(mockPipelineResult.getJobId()).thenReturn(expectedExtJobId);
doReturn(mockPipelineResult).when(dfJobManager).runPipeline(any());
+
+ FeatureSetJobStatus featureSetJobStatus = new FeatureSetJobStatus();
+ featureSetJobStatus.setFeatureSet(FeatureSet.fromProto(featureSet));
+
Job job =
new Job(
jobName,
@@ -162,7 +170,7 @@ public void shouldStartJobWithCorrectPipelineOptions() throws IOException {
Runner.DATAFLOW,
Source.fromProto(source),
Store.fromProto(store),
- Lists.newArrayList(FeatureSet.fromProto(featureSet)),
+ Sets.newHashSet(featureSetJobStatus),
JobStatus.PENDING);
Job actual = dfJobManager.startJob(job);
@@ -184,9 +192,6 @@ public void shouldStartJobWithCorrectPipelineOptions() throws IOException {
// Assume the files that are staged are correct
expectedPipelineOptions.setFilesToStage(actualPipelineOptions.getFilesToStage());
- assertThat(
- actualPipelineOptions.getFeatureSetJson(),
- equalTo(expectedPipelineOptions.getFeatureSetJson()));
assertThat(
actualPipelineOptions.getDeadLetterTableSpec(),
equalTo(expectedPipelineOptions.getDeadLetterTableSpec()));
@@ -197,6 +202,11 @@ public void shouldStartJobWithCorrectPipelineOptions() throws IOException {
equalTo(expectedPipelineOptions.getMetricsExporterType()));
assertThat(
actualPipelineOptions.getStoreJson(), equalTo(expectedPipelineOptions.getStoreJson()));
+ assertThat(
+ actualPipelineOptions.getSourceJson(), equalTo(expectedPipelineOptions.getSourceJson()));
+ assertThat(
+ actualPipelineOptions.getSpecsStreamingUpdateConfigJson(),
+ equalTo(printer.print(specsStreamingUpdateConfig)));
assertThat(actual.getExtId(), equalTo(expectedExtJobId));
}
@@ -231,6 +241,9 @@ public void shouldThrowExceptionWhenJobStateTerminal() throws IOException {
doReturn(mockPipelineResult).when(dfJobManager).runPipeline(any());
+ FeatureSetJobStatus featureSetJobStatus = new FeatureSetJobStatus();
+ featureSetJobStatus.setFeatureSet(FeatureSet.fromProto(featureSet));
+
Job job =
new Job(
"job",
@@ -238,7 +251,7 @@ public void shouldThrowExceptionWhenJobStateTerminal() throws IOException {
Runner.DATAFLOW,
Source.fromProto(source),
Store.fromProto(store),
- Lists.newArrayList(FeatureSet.fromProto(featureSet)),
+ Sets.newHashSet(featureSetJobStatus),
JobStatus.PENDING);
expectedException.expect(JobExecutionException.class);
diff --git a/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java b/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java
index 0128f5aa0b3..0e797dc64d4 100644
--- a/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java
+++ b/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java
@@ -16,6 +16,7 @@
*/
package feast.core.job.direct;
+import static feast.core.util.ModelHelpers.makeFeatureSetJobStatus;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
@@ -31,17 +32,15 @@
import com.google.protobuf.util.JsonFormat.Printer;
import feast.core.config.FeastProperties.MetricsProperties;
import feast.core.job.Runner;
-import feast.core.job.option.FeatureSetJsonByteConverter;
import feast.core.model.FeatureSet;
import feast.core.model.Job;
import feast.core.model.JobStatus;
import feast.core.model.Source;
import feast.core.model.Store;
-import feast.ingestion.options.BZip2Compressor;
import feast.ingestion.options.ImportOptions;
-import feast.ingestion.options.OptionCompressor;
import feast.proto.core.FeatureSetProto;
import feast.proto.core.FeatureSetProto.FeatureSetSpec;
+import feast.proto.core.IngestionJobProto;
import feast.proto.core.RunnerProto.DirectRunnerConfigOptions;
import feast.proto.core.SourceProto;
import feast.proto.core.SourceProto.KafkaSourceConfig;
@@ -51,8 +50,6 @@
import feast.proto.core.StoreProto.Store.StoreType;
import feast.proto.core.StoreProto.Store.Subscription;
import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
import org.apache.beam.runners.direct.DirectRunner;
import org.apache.beam.sdk.PipelineResult;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
@@ -71,6 +68,7 @@ public class DirectRunnerJobManagerTest {
private DirectRunnerJobManager drJobManager;
private DirectRunnerConfigOptions defaults;
+ private IngestionJobProto.SpecsStreamingUpdateConfig specsStreamingUpdateConfig;
@Before
public void setUp() {
@@ -79,7 +77,18 @@ public void setUp() {
MetricsProperties metricsProperties = new MetricsProperties();
metricsProperties.setEnabled(false);
- drJobManager = new DirectRunnerJobManager(defaults, directJobRegistry, metricsProperties);
+ specsStreamingUpdateConfig =
+ IngestionJobProto.SpecsStreamingUpdateConfig.newBuilder()
+ .setSource(
+ KafkaSourceConfig.newBuilder()
+ .setTopic("specs_topic")
+ .setBootstrapServers("servers:9092")
+ .build())
+ .build();
+
+ drJobManager =
+ new DirectRunnerJobManager(
+ defaults, directJobRegistry, metricsProperties, specsStreamingUpdateConfig);
drJobManager = Mockito.spy(drJobManager);
}
@@ -125,11 +134,7 @@ public void shouldStartDirectJobAndRegisterPipelineResult() throws IOException {
expectedPipelineOptions.setTargetParallelism(1);
expectedPipelineOptions.setStoreJson(Lists.newArrayList(printer.print(store)));
expectedPipelineOptions.setProject("");
-
- OptionCompressor> featureSetJsonCompressor =
- new BZip2Compressor<>(new FeatureSetJsonByteConverter());
- expectedPipelineOptions.setFeatureSetJson(
- featureSetJsonCompressor.compress(Collections.singletonList(featureSet)));
+ expectedPipelineOptions.setSourceJson(printer.print(source));
ArgumentCaptor pipelineOptionsCaptor =
ArgumentCaptor.forClass(ImportOptions.class);
@@ -145,7 +150,7 @@ public void shouldStartDirectJobAndRegisterPipelineResult() throws IOException {
Runner.DIRECT,
Source.fromProto(source),
Store.fromProto(store),
- Lists.newArrayList(FeatureSet.fromProto(featureSet)),
+ makeFeatureSetJobStatus(FeatureSet.fromProto(featureSet)),
JobStatus.PENDING);
Job actual = drJobManager.startJob(job);
verify(drJobManager, times(1)).runPipeline(pipelineOptionsCaptor.capture());
@@ -156,9 +161,6 @@ public void shouldStartDirectJobAndRegisterPipelineResult() throws IOException {
expectedPipelineOptions.setOptionsId(
actualPipelineOptions.getOptionsId()); // avoid comparing this value
- assertThat(
- actualPipelineOptions.getFeatureSetJson(),
- equalTo(expectedPipelineOptions.getFeatureSetJson()));
assertThat(
actualPipelineOptions.getDeadLetterTableSpec(),
equalTo(expectedPipelineOptions.getDeadLetterTableSpec()));
@@ -169,6 +171,11 @@ public void shouldStartDirectJobAndRegisterPipelineResult() throws IOException {
equalTo(expectedPipelineOptions.getMetricsExporterType()));
assertThat(
actualPipelineOptions.getStoreJson(), equalTo(expectedPipelineOptions.getStoreJson()));
+ assertThat(
+ actualPipelineOptions.getSourceJson(), equalTo(expectedPipelineOptions.getSourceJson()));
+ assertThat(
+ actualPipelineOptions.getSpecsStreamingUpdateConfigJson(),
+ equalTo(printer.print(specsStreamingUpdateConfig)));
assertThat(jobStarted.getPipelineResult(), equalTo(mockPipelineResult));
assertThat(jobStarted.getJobId(), equalTo(expectedJobId));
diff --git a/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java b/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java
index 8386efb28f4..caec99f2287 100644
--- a/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java
+++ b/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java
@@ -34,9 +34,8 @@
import feast.core.job.JobManager;
import feast.core.job.JobMatcher;
import feast.core.job.Runner;
-import feast.core.model.FeatureSet;
-import feast.core.model.Job;
-import feast.core.model.JobStatus;
+import feast.core.model.*;
+import feast.core.util.ModelHelpers;
import feast.proto.core.CoreServiceProto.ListFeatureSetsRequest.Filter;
import feast.proto.core.CoreServiceProto.ListFeatureSetsResponse;
import feast.proto.core.CoreServiceProto.ListStoresResponse;
@@ -50,7 +49,6 @@
import feast.proto.core.StoreProto.Store.RedisConfig;
import feast.proto.core.StoreProto.Store.StoreType;
import feast.proto.core.StoreProto.Store.Subscription;
-import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Before;
@@ -159,7 +157,7 @@ public void shouldGenerateAndSubmitJobsIfAny() throws InvalidProtocolBufferExcep
Runner.DATAFLOW,
feast.core.model.Source.fromProto(source),
feast.core.model.Store.fromProto(store),
- Arrays.asList(featureSet1, featureSet2),
+ ModelHelpers.makeFeatureSetJobStatus(featureSet1, featureSet2),
JobStatus.PENDING);
Job expected =
@@ -169,7 +167,7 @@ public void shouldGenerateAndSubmitJobsIfAny() throws InvalidProtocolBufferExcep
Runner.DATAFLOW,
feast.core.model.Source.fromProto(source),
feast.core.model.Store.fromProto(store),
- Arrays.asList(featureSet1, featureSet2),
+ ModelHelpers.makeFeatureSetJobStatus(featureSet1, featureSet2),
JobStatus.RUNNING);
when(featureSetRepository.findAllByNameLikeAndProject_NameLikeOrderByNameAsc("%", "project1"))
@@ -246,7 +244,7 @@ public void shouldGroupJobsBySource() throws InvalidProtocolBufferException {
Runner.DATAFLOW,
feast.core.model.Source.fromProto(source1),
feast.core.model.Store.fromProto(store),
- Arrays.asList(featureSet1),
+ ModelHelpers.makeFeatureSetJobStatus(featureSet1),
JobStatus.PENDING);
Job expected1 =
@@ -256,7 +254,7 @@ public void shouldGroupJobsBySource() throws InvalidProtocolBufferException {
Runner.DATAFLOW,
feast.core.model.Source.fromProto(source1),
feast.core.model.Store.fromProto(store),
- Arrays.asList(featureSet1),
+ ModelHelpers.makeFeatureSetJobStatus(featureSet1),
JobStatus.RUNNING);
Job expectedInput2 =
@@ -266,7 +264,7 @@ public void shouldGroupJobsBySource() throws InvalidProtocolBufferException {
Runner.DATAFLOW,
feast.core.model.Source.fromProto(source2),
feast.core.model.Store.fromProto(store),
- Arrays.asList(featureSet2),
+ ModelHelpers.makeFeatureSetJobStatus(featureSet2),
JobStatus.PENDING);
Job expected2 =
@@ -276,7 +274,7 @@ public void shouldGroupJobsBySource() throws InvalidProtocolBufferException {
Runner.DATAFLOW,
feast.core.model.Source.fromProto(source2),
feast.core.model.Store.fromProto(store),
- Arrays.asList(featureSet2),
+ ModelHelpers.makeFeatureSetJobStatus(featureSet2),
JobStatus.RUNNING);
ArgumentCaptor> jobArgCaptor = ArgumentCaptor.forClass(List.class);
diff --git a/core/src/test/java/feast/core/service/JobServiceTest.java b/core/src/test/java/feast/core/service/JobServiceTest.java
index ff056287f9b..27509620ff2 100644
--- a/core/src/test/java/feast/core/service/JobServiceTest.java
+++ b/core/src/test/java/feast/core/service/JobServiceTest.java
@@ -16,6 +16,7 @@
*/
package feast.core.service;
+import static feast.core.util.ModelHelpers.makeFeatureSetJobStatus;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.fail;
@@ -25,6 +26,7 @@
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
+import com.google.common.collect.Lists;
import com.google.protobuf.InvalidProtocolBufferException;
import feast.core.dao.JobRepository;
import feast.core.job.JobManager;
@@ -126,7 +128,8 @@ public void setupJobRepository() {
when(this.jobRepository.findById(this.job.getId())).thenReturn(Optional.of(this.job));
when(this.jobRepository.findByStoreName(this.dataStore.getName()))
.thenReturn(Arrays.asList(this.job));
- when(this.jobRepository.findByFeatureSetsIn(Arrays.asList(this.featureSet)))
+ when(this.jobRepository.findByFeatureSetJobStatusesIn(
+ Lists.newArrayList((this.featureSet.getJobStatuses()))))
.thenReturn(Arrays.asList(this.job));
when(this.jobRepository.findAll()).thenReturn(Arrays.asList(this.job));
}
@@ -155,7 +158,7 @@ private Job newDummyJob(String id, String extId, JobStatus status) {
Runner.DATAFLOW,
this.dataSource,
this.dataStore,
- Arrays.asList(this.featureSet),
+ makeFeatureSetJobStatus(this.featureSet),
status);
}
diff --git a/core/src/test/java/feast/core/service/SpecServiceTest.java b/core/src/test/java/feast/core/service/SpecServiceTest.java
index b5fd03fc7fc..9ec219b6c97 100644
--- a/core/src/test/java/feast/core/service/SpecServiceTest.java
+++ b/core/src/test/java/feast/core/service/SpecServiceTest.java
@@ -16,12 +16,13 @@
*/
package feast.core.service;
+import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.IsEqual.equalTo;
import static org.junit.Assert.assertEquals;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
import static org.mockito.MockitoAnnotations.initMocks;
import com.google.api.client.util.Lists;
@@ -45,16 +46,21 @@
import feast.proto.core.FeatureSetProto.EntitySpec;
import feast.proto.core.FeatureSetProto.FeatureSetSpec;
import feast.proto.core.FeatureSetProto.FeatureSpec;
+import feast.proto.core.IngestionJobProto;
import feast.proto.core.StoreProto;
import feast.proto.core.StoreProto.Store.RedisConfig;
import feast.proto.core.StoreProto.Store.StoreType;
import feast.proto.core.StoreProto.Store.Subscription;
import feast.proto.types.ValueProto.ValueType.Enum;
+import io.grpc.StatusRuntimeException;
import java.sql.Date;
import java.time.Instant;
import java.util.*;
import java.util.Map.Entry;
+import java.util.concurrent.CancellationException;
import java.util.stream.Collectors;
+import lombok.SneakyThrows;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@@ -62,6 +68,8 @@
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.scheduling.annotation.AsyncResult;
import org.tensorflow.metadata.v0.BoolDomain;
import org.tensorflow.metadata.v0.FeaturePresence;
import org.tensorflow.metadata.v0.FeaturePresenceWithinGroup;
@@ -86,6 +94,8 @@ public class SpecServiceTest {
@Mock private ProjectRepository projectRepository;
+ @Mock private KafkaTemplate kafkaTemplate;
+
@Rule public final ExpectedException expectedException = ExpectedException.none();
private SpecService specService;
@@ -143,8 +153,11 @@ public void setUp() {
when(storeRepository.findById("SERVING")).thenReturn(Optional.of(store1));
when(storeRepository.findById("NOTFOUND")).thenReturn(Optional.empty());
+ when(kafkaTemplate.sendDefault(any(), any())).thenReturn(new AsyncResult<>(null));
+
specService =
- new SpecService(featureSetRepository, storeRepository, projectRepository, defaultSource);
+ new SpecService(
+ featureSetRepository, storeRepository, projectRepository, defaultSource, kafkaTemplate);
}
@Test
@@ -284,6 +297,7 @@ public void applyFeatureSetShouldApplyFeatureSetIfNotExists()
.build();
assertThat(applyFeatureSetResponse.getStatus(), equalTo(Status.CREATED));
assertThat(applyFeatureSetResponse.getFeatureSet().getSpec(), equalTo(expected.getSpec()));
+ assertThat(applyFeatureSetResponse.getFeatureSet().getSpec().getVersion(), equalTo(1));
}
@Test
@@ -308,7 +322,12 @@ public void applyFeatureSetShouldUpdateAndSaveFeatureSetIfAlreadyExists()
.toBuilder()
.setMeta(incomingFeatureSet.getMeta().toBuilder().build())
.setSpec(
- incomingFeatureSet.getSpec().toBuilder().setSource(defaultSource.toProto()).build())
+ incomingFeatureSet
+ .getSpec()
+ .toBuilder()
+ .setVersion(2)
+ .setSource(defaultSource.toProto())
+ .build())
.build();
ApplyFeatureSetResponse applyFeatureSetResponse =
@@ -318,6 +337,74 @@ public void applyFeatureSetShouldUpdateAndSaveFeatureSetIfAlreadyExists()
assertEquals(
FeatureSet.fromProto(applyFeatureSetResponse.getFeatureSet()),
FeatureSet.fromProto(expected));
+
+ assertThat(applyFeatureSetResponse.getFeatureSet().getSpec().getVersion(), equalTo(2));
+ verify(kafkaTemplate)
+ .sendDefault(eq(featureSets.get(0).getReference()), any(FeatureSetSpec.class));
+ }
+
+ @Test
+ @SneakyThrows
+ public void applyFeatureSetShouldNotWorkWithoutKafkaAck() {
+ FeatureSet fsInTest = featureSets.get(1);
+ FeatureSetProto.FeatureSet incomingFeatureSet = fsInTest.toProto();
+ CancellationException exc = new CancellationException();
+ when(kafkaTemplate.sendDefault(eq(fsInTest.getReference()), any()).get()).thenThrow(exc);
+
+ incomingFeatureSet =
+ incomingFeatureSet
+ .toBuilder()
+ .setMeta(incomingFeatureSet.getMeta())
+ .setSpec(
+ incomingFeatureSet
+ .getSpec()
+ .toBuilder()
+ .addFeatures(
+ FeatureSpec.newBuilder().setName("feature2").setValueType(Enum.STRING))
+ .build())
+ .build();
+
+ expectedException.expect(StatusRuntimeException.class);
+ specService.applyFeatureSet(incomingFeatureSet);
+ verify(featureSetRepository, never()).saveAndFlush(ArgumentMatchers.any(FeatureSet.class));
+ }
+
+ @Test
+ @SneakyThrows
+ public void applyFeatureSetShouldUpdateDeliveryStatuses() {
+ FeatureSet fsInTest = featureSets.get(1);
+ FeatureSetJobStatus j1 =
+ newJob(
+ fsInTest,
+ JobStatus.RUNNING,
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_DELIVERED);
+ FeatureSetJobStatus j2 =
+ newJob(
+ fsInTest,
+ JobStatus.ABORTED,
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_DELIVERED);
+
+ fsInTest.getJobStatuses().addAll(Arrays.asList(j1, j2));
+
+ FeatureSetProto.FeatureSet incomingFeatureSet = fsInTest.toProto();
+ incomingFeatureSet =
+ incomingFeatureSet
+ .toBuilder()
+ .setMeta(incomingFeatureSet.getMeta())
+ .setSpec(
+ incomingFeatureSet
+ .getSpec()
+ .toBuilder()
+ .addFeatures(
+ FeatureSpec.newBuilder().setName("feature2").setValueType(Enum.STRING))
+ .build())
+ .build();
+
+ specService.applyFeatureSet(incomingFeatureSet);
+ assertThat(
+ j1.getDeliveryStatus(), is(FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS));
+ assertThat(
+ j2.getDeliveryStatus(), is(FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_DELIVERED));
}
@Test
@@ -681,6 +768,70 @@ public void getOrListFeatureSetShouldUseDefaultProjectIfProjectUnspecified()
assertThat(listResponse.getFeatureSetsList(), equalTo(Arrays.asList(expected.toProto())));
}
+ @Test
+ public void specAckListenerShouldDoNothingWhenMessageIsOutdated() {
+ FeatureSet fsInTest = featureSets.get(1);
+ FeatureSetJobStatus j1 =
+ newJob(
+ fsInTest,
+ JobStatus.RUNNING,
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS);
+ FeatureSetJobStatus j2 =
+ newJob(
+ fsInTest,
+ JobStatus.RUNNING,
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS);
+
+ fsInTest.getJobStatuses().addAll(Arrays.asList(j1, j2));
+
+ specService.listenAckFromJobs(newAckMessage("project/invalid", 0, j1.getJob().getId()));
+ specService.listenAckFromJobs(newAckMessage(fsInTest.getReference(), 0, ""));
+ specService.listenAckFromJobs(newAckMessage(fsInTest.getReference(), -1, j1.getJob().getId()));
+
+ assertThat(
+ j1.getDeliveryStatus(), is(FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS));
+ assertThat(
+ j2.getDeliveryStatus(), is(FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS));
+ }
+
+ @Test
+ public void specAckListenerShouldUpdateFeatureSetStatus() {
+ FeatureSet fsInTest = featureSets.get(1);
+ fsInTest.setStatus(FeatureSetProto.FeatureSetStatus.STATUS_PENDING);
+
+ FeatureSetJobStatus j1 =
+ newJob(
+ fsInTest,
+ JobStatus.RUNNING,
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS);
+ FeatureSetJobStatus j2 =
+ newJob(
+ fsInTest,
+ JobStatus.RUNNING,
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS);
+ FeatureSetJobStatus j3 =
+ newJob(
+ fsInTest,
+ JobStatus.ABORTED,
+ FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_IN_PROGRESS);
+
+ fsInTest.getJobStatuses().addAll(Arrays.asList(j1, j2, j3));
+
+ specService.listenAckFromJobs(
+ newAckMessage(fsInTest.getReference(), fsInTest.getVersion(), j1.getJob().getId()));
+
+ assertThat(
+ j1.getDeliveryStatus(), is(FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_DELIVERED));
+ assertThat(fsInTest.getStatus(), is(FeatureSetProto.FeatureSetStatus.STATUS_PENDING));
+
+ specService.listenAckFromJobs(
+ newAckMessage(fsInTest.getReference(), fsInTest.getVersion(), j2.getJob().getId()));
+
+ assertThat(
+ j2.getDeliveryStatus(), is(FeatureSetProto.FeatureSetJobDeliveryStatus.STATUS_DELIVERED));
+ assertThat(fsInTest.getStatus(), is(FeatureSetProto.FeatureSetStatus.STATUS_READY));
+ }
+
private FeatureSet newDummyFeatureSet(String name, String project) {
FeatureSpec f1 =
FeatureSpec.newBuilder()
@@ -698,6 +849,33 @@ private FeatureSet newDummyFeatureSet(String name, String project) {
return fs;
}
+ private FeatureSetJobStatus newJob(
+ FeatureSet fs, JobStatus status, FeatureSetProto.FeatureSetJobDeliveryStatus deliveryStatus) {
+ Job job = new Job();
+ job.setStatus(status);
+ job.setId(UUID.randomUUID().toString());
+
+ FeatureSetJobStatus featureSetJobStatus = new FeatureSetJobStatus();
+ featureSetJobStatus.setJob(job);
+ featureSetJobStatus.setFeatureSet(fs);
+ featureSetJobStatus.setDeliveryStatus(deliveryStatus);
+
+ return featureSetJobStatus;
+ }
+
+ private ConsumerRecord newAckMessage(
+ String key, int version, String jobName) {
+ return new ConsumerRecord<>(
+ "topic",
+ 0,
+ 0,
+ key,
+ IngestionJobProto.FeatureSetSpecAck.newBuilder()
+ .setFeatureSetVersion(version)
+ .setJobName(jobName)
+ .build());
+ }
+
private Store newDummyStore(String name) {
// Add type to this method when we enable filtering by type
Store store = new Store();
diff --git a/core/src/test/java/feast/core/service/TestObjectFactory.java b/core/src/test/java/feast/core/service/TestObjectFactory.java
index 40c379d3cf7..a498d6c21c2 100644
--- a/core/src/test/java/feast/core/service/TestObjectFactory.java
+++ b/core/src/test/java/feast/core/service/TestObjectFactory.java
@@ -39,15 +39,18 @@ public class TestObjectFactory {
public static FeatureSet CreateFeatureSet(
String name, String project, List entities, List features) {
- return new FeatureSet(
- name,
- project,
- 100L,
- entities,
- features,
- defaultSource,
- new HashMap<>(),
- FeatureSetProto.FeatureSetStatus.STATUS_READY);
+ FeatureSet fs =
+ new FeatureSet(
+ name,
+ project,
+ 100L,
+ entities,
+ features,
+ defaultSource,
+ new HashMap<>(),
+ FeatureSetProto.FeatureSetStatus.STATUS_READY);
+ fs.setVersion(1);
+ return fs;
}
public static Feature CreateFeature(String name, ValueProto.ValueType.Enum valueType) {
diff --git a/core/src/test/java/feast/core/util/ModelHelpers.java b/core/src/test/java/feast/core/util/ModelHelpers.java
new file mode 100644
index 00000000000..f864a5562de
--- /dev/null
+++ b/core/src/test/java/feast/core/util/ModelHelpers.java
@@ -0,0 +1,41 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright 2018-2020 The Feast Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feast.core.util;
+
+import feast.core.model.FeatureSet;
+import feast.core.model.FeatureSetJobStatus;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class ModelHelpers {
+ public static Set makeFeatureSetJobStatus(FeatureSet... featureSets) {
+ return Stream.of(featureSets)
+ .map(
+ fs -> {
+ FeatureSetJobStatus s = new FeatureSetJobStatus();
+ s.setFeatureSet(fs);
+ return s;
+ })
+ .collect(Collectors.toSet());
+ }
+
+ public static Set makeFeatureSetJobStatus(List featureSets) {
+ return makeFeatureSetJobStatus(featureSets.toArray(FeatureSet[]::new));
+ }
+}
diff --git a/infra/scripts/test-end-to-end-batch-dataflow.sh b/infra/scripts/test-end-to-end-batch-dataflow.sh
index 9ad29185570..e138a75bdea 100755
--- a/infra/scripts/test-end-to-end-batch-dataflow.sh
+++ b/infra/scripts/test-end-to-end-batch-dataflow.sh
@@ -14,6 +14,8 @@ test -z ${K8_CLUSTER_NAME} && K8_CLUSTER_NAME="feast-e2e-dataflow"
test -z ${HELM_RELEASE_NAME} && HELM_RELEASE_NAME="pr-$PULL_NUMBER"
test -z ${HELM_COMMON_NAME} && HELM_COMMON_NAME="deps"
test -z ${DATASET_NAME} && DATASET_NAME=feast_e2e_$(date +%s)
+test -z ${SPECS_TOPIC} && SPECS_TOPIC=feast-specs-$(date +%s)
+
feast_kafka_1_ip_name="feast-kafka-1"
feast_kafka_2_ip_name="feast-kafka-2"
@@ -209,8 +211,9 @@ export GCLOUD_SUBNET=$GCLOUD_SUBNET
export GCLOUD_REGION=$GCLOUD_REGION
export HELM_COMMON_NAME=$HELM_COMMON_NAME
export IMAGE_TAG=${PULL_PULL_SHA:1}
+export SPECS_TOPIC=$SPECS_TOPIC
-envsubst $'$TEMP_BUCKET $DATASET_NAME $GCLOUD_PROJECT $GCLOUD_NETWORK \
+envsubst $'$TEMP_BUCKET $DATASET_NAME $GCLOUD_PROJECT $GCLOUD_NETWORK $SPECS_TOPIC \
$GCLOUD_SUBNET $GCLOUD_REGION $IMAGE_TAG $HELM_COMMON_NAME $feast_kafka_1_ip
$feast_kafka_2_ip $feast_kafka_3_ip $feast_redis_ip $feast_statsd_ip' < $ORIGINAL_DIR/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml > $ORIGINAL_DIR/infra/charts/feast/values-end-to-end-batch-dataflow-updated.yaml
diff --git a/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml b/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml
index 345d5234184..48231face69 100644
--- a/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml
+++ b/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml
@@ -15,6 +15,9 @@ feast-core:
stream:
options:
bootstrapServers: $feast_kafka_1_ip:31090
+ specsOptions:
+ specsTopic: $SPECS_TOPIC
+ specsAckTopic: $SPECS_TOPIC-ack
jobs:
active_runner: dataflow
diff --git a/ingestion/pom.xml b/ingestion/pom.xml
index 36d5cd6269b..3eadcdca252 100644
--- a/ingestion/pom.xml
+++ b/ingestion/pom.xml
@@ -230,6 +230,13 @@
test
+
+ org.slf4j
+ slf4j-simple
+ 1.7.30
+ test
+
+
com.google.guava
guava
diff --git a/ingestion/src/main/java/feast/ingestion/ImportJob.java b/ingestion/src/main/java/feast/ingestion/ImportJob.java
index 5cef8e57449..8dee52e08a2 100644
--- a/ingestion/src/main/java/feast/ingestion/ImportJob.java
+++ b/ingestion/src/main/java/feast/ingestion/ImportJob.java
@@ -16,20 +16,20 @@
*/
package feast.ingestion;
-import static feast.ingestion.utils.SpecUtil.getFeatureSetReference;
import static feast.ingestion.utils.StoreUtil.getFeatureSink;
import com.google.protobuf.InvalidProtocolBufferException;
-import feast.ingestion.options.BZip2Decompressor;
import feast.ingestion.options.ImportOptions;
-import feast.ingestion.options.StringListStreamConverter;
+import feast.ingestion.transform.FeatureRowToStoreAllocator;
import feast.ingestion.transform.ProcessAndValidateFeatureRows;
import feast.ingestion.transform.ReadFromSource;
import feast.ingestion.transform.metrics.WriteFailureMetricsTransform;
import feast.ingestion.transform.metrics.WriteSuccessMetricsTransform;
+import feast.ingestion.transform.specs.ReadFeatureSetSpecs;
+import feast.ingestion.transform.specs.WriteFeatureSetSpecAck;
import feast.ingestion.utils.SpecUtil;
-import feast.proto.core.FeatureSetProto.FeatureSet;
import feast.proto.core.FeatureSetProto.FeatureSetSpec;
+import feast.proto.core.IngestionJobProto.SpecsStreamingUpdateConfig;
import feast.proto.core.SourceProto.Source;
import feast.proto.core.StoreProto.Store;
import feast.proto.types.FeatureRowProto.FeatureRow;
@@ -39,15 +39,16 @@
import feast.storage.api.writer.WriteResult;
import feast.storage.connectors.bigquery.writer.BigQueryDeadletterSink;
import java.io.IOException;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.PipelineResult;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
import org.apache.beam.sdk.options.PipelineOptionsValidator;
import org.apache.beam.sdk.transforms.*;
import org.apache.beam.sdk.values.*;
+import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
public class ImportJob {
@@ -73,11 +74,14 @@ public static void main(String[] args) throws IOException {
public static PipelineResult runPipeline(ImportOptions options) throws IOException {
/*
* Steps:
- * 1. Read messages from Feast Source as FeatureRow
- * 2. Validate the feature rows to ensure the schema matches what is registered to the system
- * 3. Write FeatureRow to the corresponding Store
- * 4. Write elements that failed to be processed to a dead letter queue.
- * 5. Write metrics to a metrics sink
+ * 1. Read FeatureSetSpec messages from kafka
+ * 2. Read messages from Feast Source as FeatureRow
+ * 3. Validate the feature rows to ensure the schema matches what is registered to the system
+ * 4. Distribute rows across stores by subscription
+ * 5. Write FeatureRow to the corresponding Store
+ * 6. Write elements that failed to be processed to a dead letter queue.
+ * 7. Write metrics to a metrics sink
+ * 8. Send ack on receiving FeatureSetSpec
*/
PipelineOptionsValidator.validate(ImportOptions.class, options);
@@ -85,75 +89,71 @@ public static PipelineResult runPipeline(ImportOptions options) throws IOExcepti
log.info("Starting import job with settings: \n{}", options.toString());
- BZip2Decompressor> decompressor =
- new BZip2Decompressor<>(new StringListStreamConverter());
- List featureSetJson = decompressor.decompress(options.getFeatureSetJson());
- List featureSets = SpecUtil.parseFeatureSetSpecJsonList(featureSetJson);
List stores = SpecUtil.parseStoreJsonList(options.getStoreJson());
+ Source source = SpecUtil.parseSourceJson(options.getSourceJson());
+ SpecsStreamingUpdateConfig specsStreamingUpdateConfig =
+ SpecUtil.parseSpecsStreamingUpdateConfig(options.getSpecsStreamingUpdateConfigJson());
+
+ // Step 1. Read FeatureSetSpecs from Spec source
+ PCollection> featureSetSpecs =
+ pipeline.apply(
+ "ReadFeatureSetSpecs",
+ ReadFeatureSetSpecs.newBuilder()
+ .setSource(source)
+ .setStores(stores)
+ .setSpecsStreamingUpdateConfig(specsStreamingUpdateConfig)
+ .build());
+
+ PCollectionView