Skip to content

Commit a5bd877

Browse files
terryyylimTerence
andauthored
Add API for listing feature sets using labels (#785)
* Update CoreService proto * Update SpecService and add unit test * Update Python sdk and cli * Fix python lint error * Add more tests * Black formatting changes * Revert "Black formatting changes" This reverts commit d82200f. * Refactor FeatureSet labels filter logic * Set default labels to empty dict * Refactor feature_set_list cli * Refactor brittle test * Implement helper function and not convert to proto * Update cli * Add docstrings and improve variable naming * Use getter method instead * Fix java lint * Add description for labels filter proto Co-authored-by: Terence <terence.limxp@go-jek.com>
1 parent 6137102 commit a5bd877

8 files changed

Lines changed: 248 additions & 9 deletions

File tree

core/src/main/java/feast/core/model/FeatureSet.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,23 @@ private String getProjectName() {
128128
}
129129
}
130130

131+
/**
132+
* Return a boolean to facilitate streaming elements on the basis of given predicate.
133+
*
134+
* @param labelsFilter labels contain key-value mapping for labels attached to the FeatureSet
135+
* @return boolean True if FeatureSet contains all labels in the labelsFilter
136+
*/
137+
public boolean hasAllLabels(Map<String, String> labelsFilter) {
138+
Map<String, String> featureSetLabelsMap = this.getLabelsMap();
139+
for (String key : labelsFilter.keySet()) {
140+
if (!featureSetLabelsMap.containsKey(key)
141+
|| !featureSetLabelsMap.get(key).equals(labelsFilter.get(key))) {
142+
return false;
143+
}
144+
}
145+
return true;
146+
}
147+
131148
public void setProject(Project project) {
132149
this.project = project;
133150
}
@@ -293,6 +310,10 @@ public FeatureSetProto.FeatureSet toProto() throws InvalidProtocolBufferExceptio
293310
return FeatureSetProto.FeatureSet.newBuilder().setMeta(meta).setSpec(spec).build();
294311
}
295312

313+
public Map<String, String> getLabelsMap() {
314+
return TypeConversion.convertJsonStringToMap(this.getLabels());
315+
}
316+
296317
@Override
297318
public int hashCode() {
298319
HashCodeBuilder hcb = new HashCodeBuilder();

core/src/main/java/feast/core/service/SpecService.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
import feast.proto.core.StoreProto.Store.Subscription;
4747
import java.util.ArrayList;
4848
import java.util.List;
49+
import java.util.Map;
4950
import java.util.concurrent.TimeUnit;
51+
import java.util.stream.Collectors;
5052
import lombok.extern.slf4j.Slf4j;
5153
import org.apache.kafka.clients.consumer.ConsumerRecord;
5254
import org.springframework.beans.factory.annotation.Autowired;
@@ -123,9 +125,9 @@ public GetFeatureSetResponse getFeatureSet(GetFeatureSetRequest request)
123125
}
124126

125127
/**
126-
* Return a list of feature sets matching the feature set name and project provided in the filter.
127-
* All fields are requried. Use '*' for all arguments in order to return all feature sets in all
128-
* projects.
128+
* Return a list of feature sets matching the feature set name, project and labels provided in the
129+
* filter. All fields are required. Use '*' in feature set name and project, and empty map in
130+
* labels in order to return all feature sets in all projects.
129131
*
130132
* <p>Project name can be explicitly provided, or an asterisk can be provided to match all
131133
* projects. It is not possible to provide a combination of asterisks/wildcards and text. If the
@@ -135,13 +137,17 @@ public GetFeatureSetResponse getFeatureSet(GetFeatureSetRequest request)
135137
* sets will be returned. Regex is not supported. Explicitly defining a feature set name is not
136138
* possible if a project name is not set explicitly
137139
*
140+
* <p>The labels in the filter accepts a map. All feature sets which contain every provided label
141+
* will be returned.
142+
*
138143
* @param filter filter containing the desired featureSet name
139144
* @return ListFeatureSetsResponse with list of featureSets found matching the filter
140145
*/
141146
public ListFeatureSetsResponse listFeatureSets(ListFeatureSetsRequest.Filter filter)
142147
throws InvalidProtocolBufferException {
143148
String name = filter.getFeatureSetName();
144149
String project = filter.getProject();
150+
Map<String, String> labelsFilter = filter.getLabelsMap();
145151

146152
if (name.isEmpty()) {
147153
throw new IllegalArgumentException(
@@ -197,6 +203,10 @@ public ListFeatureSetsResponse listFeatureSets(ListFeatureSetsRequest.Filter fil
197203

198204
ListFeatureSetsResponse.Builder response = ListFeatureSetsResponse.newBuilder();
199205
if (featureSets.size() > 0) {
206+
featureSets =
207+
featureSets.stream()
208+
.filter(featureSet -> featureSet.hasAllLabels(labelsFilter))
209+
.collect(Collectors.toList());
200210
for (FeatureSet featureSet : featureSets) {
201211
response.addFeatureSets(featureSet.toProto());
202212
}

core/src/test/java/feast/core/service/SpecServiceTest.java

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public class SpecServiceTest {
106106
// TODO: Updates update features in place, so if tests follow the wrong order they might break.
107107
// Refactor this maybe?
108108
@Before
109-
public void setUp() {
109+
public void setUp() throws InvalidProtocolBufferException {
110110
initMocks(this);
111111
defaultSource = TestObjectFactory.defaultSource;
112112

@@ -121,7 +121,50 @@ public void setUp() {
121121
"f3", "project1", Arrays.asList(f3e1), Arrays.asList(f3f2, f3f1));
122122

123123
FeatureSet featureSet4 = newDummyFeatureSet("f4", Project.DEFAULT_NAME);
124-
featureSets = Arrays.asList(featureSet1, featureSet2, featureSet3, featureSet4);
124+
Map<String, String> singleFeatureSetLabels =
125+
new HashMap<>() {
126+
{
127+
put("fsLabel1", "fsValue1");
128+
}
129+
};
130+
Map<String, String> duoFeatureSetLabels =
131+
new HashMap<>() {
132+
{
133+
put("fsLabel1", "fsValue1");
134+
put("fsLabel2", "fsValue2");
135+
}
136+
};
137+
FeatureSet featureSet5 = newDummyFeatureSet("f5", Project.DEFAULT_NAME);
138+
FeatureSet featureSet6 = newDummyFeatureSet("f6", Project.DEFAULT_NAME);
139+
FeatureSetSpec featureSetSpec5 = featureSet5.toProto().getSpec().toBuilder().build();
140+
FeatureSetSpec featureSetSpec6 = featureSet6.toProto().getSpec().toBuilder().build();
141+
FeatureSetProto.FeatureSet fs5 =
142+
FeatureSetProto.FeatureSet.newBuilder()
143+
.setSpec(
144+
featureSetSpec5
145+
.toBuilder()
146+
.setSource(defaultSource.toProto())
147+
.putAllLabels(singleFeatureSetLabels)
148+
.build())
149+
.build();
150+
FeatureSetProto.FeatureSet fs6 =
151+
FeatureSetProto.FeatureSet.newBuilder()
152+
.setSpec(
153+
featureSetSpec6
154+
.toBuilder()
155+
.setSource(defaultSource.toProto())
156+
.putAllLabels(duoFeatureSetLabels)
157+
.build())
158+
.build();
159+
160+
featureSets =
161+
Arrays.asList(
162+
featureSet1,
163+
featureSet2,
164+
featureSet3,
165+
featureSet4,
166+
FeatureSet.fromProto(fs5),
167+
FeatureSet.fromProto(fs6));
125168

126169
when(featureSetRepository.findAll()).thenReturn(featureSets);
127170
when(featureSetRepository.findAllByOrderByNameAsc()).thenReturn(featureSets);
@@ -713,6 +756,35 @@ public void applyFeatureSetShouldAcceptFeatureSetLabels() throws InvalidProtocol
713756
assertEquals(featureSetLabels, appliedLabels);
714757
}
715758

759+
@Test
760+
public void shouldFilterByFeatureSetLabels() throws InvalidProtocolBufferException {
761+
List<FeatureSetProto.FeatureSet> list = new ArrayList<>();
762+
ListFeatureSetsResponse actual1 =
763+
specService.listFeatureSets(
764+
Filter.newBuilder()
765+
.setFeatureSetName("*")
766+
.setProject("*")
767+
.putLabels("fsLabel2", "fsValue2")
768+
.build());
769+
list.add(featureSets.get(5).toProto());
770+
ListFeatureSetsResponse expected1 =
771+
ListFeatureSetsResponse.newBuilder().addAllFeatureSets(list).build();
772+
773+
ListFeatureSetsResponse actual2 =
774+
specService.listFeatureSets(
775+
Filter.newBuilder()
776+
.setFeatureSetName("*")
777+
.setProject("*")
778+
.putLabels("fsLabel1", "fsValue1")
779+
.build());
780+
list.add(0, featureSets.get(4).toProto());
781+
ListFeatureSetsResponse expected2 =
782+
ListFeatureSetsResponse.newBuilder().addAllFeatureSets(list).build();
783+
784+
assertThat(actual1, equalTo(expected1));
785+
assertThat(actual2, equalTo(expected2));
786+
}
787+
716788
@Test
717789
public void shouldUpdateStoreIfConfigChanges() throws InvalidProtocolBufferException {
718790
when(storeRepository.findById("SERVING")).thenReturn(Optional.of(stores.get(0)));

protos/feast/core/CoreService.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ message ListFeatureSetsRequest {
140140
// - my-feature-set* can be used to match all features prefixed by "my-feature-set"
141141
// - my-feature-set-6 can be used to select a single feature set
142142
string feature_set_name = 1;
143+
144+
// User defined metadata for feature set.
145+
// Feature sets with all matching labels will be returned.
146+
map<string,string> labels = 4;
143147
}
144148
}
145149

sdk/python/feast/cli.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,55 @@ def feature_set():
120120
pass
121121

122122

123+
def _get_labels_dict(label_str: str):
124+
"""
125+
Converts CLI input labels string to dictionary format if provided string is valid.
126+
"""
127+
labels_dict = {}
128+
labels_kv = label_str.split(",")
129+
if label_str == "":
130+
return labels_dict
131+
if len(labels_kv) % 2 == 1:
132+
raise ValueError("Uneven key-value label pairs were entered")
133+
for k, v in zip(labels_kv[0::2], labels_kv[1::2]):
134+
labels_dict[k] = v
135+
return labels_dict
136+
137+
123138
@feature_set.command(name="list")
124-
def feature_set_list():
139+
@click.option(
140+
"--project",
141+
"-p",
142+
help="Project that feature set belongs to",
143+
type=click.STRING,
144+
default="*",
145+
)
146+
@click.option(
147+
"--name",
148+
"-n",
149+
help="Filters feature sets by name. Wildcards (*) may be included to match multiple feature sets",
150+
type=click.STRING,
151+
default="*",
152+
)
153+
@click.option(
154+
"--labels",
155+
"-l",
156+
help="Labels to filter for feature sets",
157+
type=click.STRING,
158+
default="",
159+
)
160+
def feature_set_list(project: str, name: str, labels: str):
125161
"""
126162
List all feature sets
127163
"""
128164
feast_client = Client() # type: Client
129165

166+
labels_dict = _get_labels_dict(labels)
167+
130168
table = []
131-
for fs in feast_client.list_feature_sets(project="*", name="*"):
169+
for fs in feast_client.list_feature_sets(
170+
project=project, name=name, labels=labels_dict
171+
):
132172
table.append([fs.name, repr(fs)])
133173

134174
from tabulate import tabulate

sdk/python/feast/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ def _apply_feature_set(self, feature_set: FeatureSet):
425425
feature_set._update_from_feature_set(applied_fs)
426426

427427
def list_feature_sets(
428-
self, project: str = None, name: str = None,
428+
self, project: str = None, name: str = None, labels: Dict[str, str] = dict()
429429
) -> List[FeatureSet]:
430430
"""
431431
Retrieve a list of feature sets from Feast Core
@@ -448,7 +448,9 @@ def list_feature_sets(
448448
if name is None:
449449
name = "*"
450450

451-
filter = ListFeatureSetsRequest.Filter(project=project, feature_set_name=name)
451+
filter = ListFeatureSetsRequest.Filter(
452+
project=project, feature_set_name=name, labels=labels
453+
)
452454

453455
# Get latest feature sets from Feast Core
454456
feature_set_protos = self._core_service_stub.ListFeatureSets(

sdk/python/tests/test_client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from feast.core.CoreService_pb2 import (
3535
GetFeastCoreVersionResponse,
3636
GetFeatureSetResponse,
37+
ListFeatureSetsResponse,
3738
ListIngestionJobsResponse,
3839
)
3940
from feast.core.FeatureSet_pb2 import EntitySpec as EntitySpecProto
@@ -321,6 +322,67 @@ def test_get_feature_set(self, mocked_client, mocker):
321322
and len(feature_set.entities) == 1
322323
)
323324

325+
@pytest.mark.parametrize(
326+
"mocked_client",
327+
[pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")],
328+
)
329+
def test_list_feature_sets(self, mocked_client, mocker):
330+
mocker.patch.object(
331+
mocked_client,
332+
"_core_service_stub",
333+
return_value=Core.CoreServiceStub(grpc.insecure_channel("")),
334+
)
335+
336+
feature_set_1_proto = FeatureSetProto(
337+
spec=FeatureSetSpecProto(
338+
project="test",
339+
name="driver_car",
340+
max_age=Duration(seconds=3600),
341+
labels={"key1": "val1", "key2": "val2"},
342+
features=[
343+
FeatureSpecProto(
344+
name="feature_1", value_type=ValueProto.ValueType.FLOAT
345+
)
346+
],
347+
)
348+
)
349+
feature_set_2_proto = FeatureSetProto(
350+
spec=FeatureSetSpecProto(
351+
project="test",
352+
name="driver_ride",
353+
max_age=Duration(seconds=3600),
354+
labels={"key1": "val1"},
355+
features=[
356+
FeatureSpecProto(
357+
name="feature_1", value_type=ValueProto.ValueType.FLOAT
358+
)
359+
],
360+
)
361+
)
362+
363+
mocker.patch.object(
364+
mocked_client._core_service_stub,
365+
"ListFeatureSets",
366+
return_value=ListFeatureSetsResponse(
367+
feature_sets=[feature_set_1_proto, feature_set_2_proto]
368+
),
369+
)
370+
371+
feature_sets = mocked_client.list_feature_sets(labels={"key1": "val1"})
372+
assert len(feature_sets) == 2
373+
374+
feature_set = feature_sets[0]
375+
assert (
376+
feature_set.name == "driver_car"
377+
and "key1" in feature_set.labels
378+
and feature_set.labels["key1"] == "val1"
379+
and "key2" in feature_set.labels
380+
and feature_set.labels["key2"] == "val2"
381+
and feature_set.fields["feature_1"].name == "feature_1"
382+
and feature_set.fields["feature_1"].dtype == ValueType.FLOAT
383+
and len(feature_set.features) == 1
384+
)
385+
324386
@pytest.mark.parametrize(
325387
"mocked_client",
326388
[pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")],

tests/e2e/redis/basic-ingest-redis-serving.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,34 @@ def test_basic_register_feature_set_success(client):
126126
project=PROJECT_NAME)
127127
assert cust_trans_fs_actual == cust_trans_fs_expected
128128

129+
# Register feature set with labels
130+
driver_unlabelled_fs = FeatureSet(
131+
"driver_unlabelled",
132+
features=[
133+
Feature("rating", ValueType.FLOAT),
134+
Feature("cost", ValueType.FLOAT)
135+
],
136+
entities=[Entity("entity_id", ValueType.INT64)],
137+
max_age=Duration(seconds=100)
138+
)
139+
driver_labeled_fs_expected = FeatureSet(
140+
"driver_labeled",
141+
features=[
142+
Feature("rating", ValueType.FLOAT),
143+
Feature("cost", ValueType.FLOAT)
144+
],
145+
entities=[Entity("entity_id", ValueType.INT64)],
146+
max_age=Duration(seconds=100),
147+
labels={"key1":"val1"}
148+
)
149+
client.set_project(PROJECT_NAME)
150+
client.apply(driver_unlabelled_fs)
151+
client.apply(driver_labeled_fs_expected)
152+
driver_fs_actual = client.list_feature_sets(
153+
project=PROJECT_NAME, labels={"key1": "val1"}
154+
)[0]
155+
assert driver_fs_actual == driver_labeled_fs_expected
156+
129157
# reset client's project for other tests
130158
client.set_project()
131159

0 commit comments

Comments
 (0)