Repository: oom-ai/oomstore
Pull request #973 (2022-01-14 05:10:53)

WIP Renaming refactor on short feature name and full feature name
`FeatureFullName` -> `FeatureName` (used in most public apis)
`FeatureName` -> `ShortFeatureName` (used in private apis)


Patch:
diff --git a/internal/database/metadata/informer/feature.go b/internal/database/metadata/informer/feature.go
index 9cb4a05e..20c5f15a 100644
--- a/internal/database/metadata/informer/feature.go
+++ b/internal/database/metadata/informer/feature.go
@@ -1,7 +1,6 @@
 package informer
 
 import (
-	"github.com/oom-ai/oomstore/internal/database/metadata"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
@@ -17,28 +16,15 @@ func (c *FeatureCache) Enrich(groupCache *GroupCache) {
 	}
 }
 
-func (c *FeatureCache) List(opt metadata.ListFeatureOpt) types.FeatureList {
+func (c *FeatureCache) List(fullNames *[]string) types.FeatureList {
 	features := c.FeatureList
 
-	// filter ids
-	if opt.FeatureIDs != nil {
-		var tmp types.FeatureList
-		for _, id := range *opt.FeatureIDs {
-			if f := c.Find(func(f *types.Feature) bool {
-				return f.ID == id
-			}); f != nil {
-				tmp = append(tmp, f)
-			}
-		}
-		features = tmp
-	}
-
 	// filter names
-	if opt.FeatureFullNames != nil {
+	if fullNames != nil {
 		var tmp types.FeatureList
-		for _, fullName := range *opt.FeatureFullNames {
+		for _, fullName := range *fullNames {
 			if f := features.Find(func(f *types.Feature) bool {
-				return f.FullName == fullName
+				return f.FullName() == fullName
 			}); f != nil {
 				tmp = append(tmp, f)
 			}
@@ -46,18 +32,5 @@ func (c *FeatureCache) List(opt metadata.ListFeatureOpt) types.FeatureList {
 		features = tmp
 	}
 
-	// filter entity
-	if opt.EntityID != nil {
-		features = features.Filter(func(f *types.Feature) bool {
-			return f.Entity().ID == *opt.EntityID
-		})
-	}
-
-	// filter group
-	if opt.GroupID != nil {
-		features = features.Filter(func(f *types.Feature) bool {
-			return f.Group.ID == *opt.GroupID
-		})
-	}
 	return features
 }
diff --git a/internal/database/metadata/informer/informer.go b/internal/database/metadata/informer/informer.go
index 60f57461..fdbb38b5 100644
--- a/internal/database/metadata/informer/informer.go
+++ b/internal/database/metadata/informer/informer.go
@@ -88,6 +88,6 @@ func (f *Informer) Cache() *Cache {
 	return f.cache.Load().(*Cache)
 }
 
-func (f *Informer) CacheListFeature(ctx context.Context, opt metadata.ListFeatureOpt) types.FeatureList {
-	return f.Cache().Features.List(opt).Copy()
+func (f *Informer) ListCachedFeature(ctx context.Context, fullNames *[]string) types.FeatureList {
+	return f.Cache().Features.List(fullNames).Copy()
 }
diff --git a/internal/database/metadata/mock_metadata/store.go b/internal/database/metadata/mock_metadata/store.go
index 2ac445dd..4179e367 100644
--- a/internal/database/metadata/mock_metadata/store.go
+++ b/internal/database/metadata/mock_metadata/store.go
@@ -37,20 +37,6 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder {
 	return m.recorder
 }
 
-// CacheListFeature mocks base method.
-func (m *MockStore) CacheListFeature(ctx context.Context, opt metadata.ListFeatureOpt) types.FeatureList {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "CacheListFeature", ctx, opt)
-	ret0, _ := ret[0].(types.FeatureList)
-	return ret0
-}
-
-// CacheListFeature indicates an expected call of CacheListFeature.
-func (mr *MockStoreMockRecorder) CacheListFeature(ctx, opt interface{}) *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CacheListFeature", reflect.TypeOf((*MockStore)(nil).CacheListFeature), ctx, opt)
-}
-
 // Close mocks base method.
 func (m *MockStore) Close() error {
 	m.ctrl.T.Helper()
@@ -172,18 +158,18 @@ func (mr *MockStoreMockRecorder) GetFeature(ctx, id interface{}) *gomock.Call {
 }
 
 // GetFeatureByName mocks base method.
-func (m *MockStore) GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error) {
+func (m *MockStore) GetFeatureByName(ctx context.Context, groupName, featureName string) (*types.Feature, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, fullName)
+	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, groupName, featureName)
 	ret0, _ := ret[0].(*types.Feature)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
 // GetFeatureByName indicates an expected call of GetFeatureByName.
-func (mr *MockStoreMockRecorder) GetFeatureByName(ctx, fullName interface{}) *gomock.Call {
+func (mr *MockStoreMockRecorder) GetFeatureByName(ctx, groupName, featureName interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockStore)(nil).GetFeatureByName), ctx, fullName)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockStore)(nil).GetFeatureByName), ctx, groupName, featureName)
 }
 
 // GetGroup mocks base method.
@@ -246,6 +232,20 @@ func (mr *MockStoreMockRecorder) GetRevisionBy(ctx, groupID, revision interface{
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRevisionBy", reflect.TypeOf((*MockStore)(nil).GetRevisionBy), ctx, groupID, revision)
 }
 
+// ListCachedFeature mocks base method.
+func (m *MockStore) ListCachedFeature(ctx context.Context, fullNames *[]string) types.FeatureList {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListCachedFeature", ctx, fullNames)
+	ret0, _ := ret[0].(types.FeatureList)
+	return ret0
+}
+
+// ListCachedFeature indicates an expected call of ListCachedFeature.
+func (mr *MockStoreMockRecorder) ListCachedFeature(ctx, fullNames interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCachedFeature", reflect.TypeOf((*MockStore)(nil).ListCachedFeature), ctx, fullNames)
+}
+
 // ListEntity mocks base method.
 func (m *MockStore) ListEntity(ctx context.Context, entityIDs *[]int) (types.EntityList, error) {
 	m.ctrl.T.Helper()
@@ -534,18 +534,18 @@ func (mr *MockDBStoreMockRecorder) GetFeature(ctx, id interface{}) *gomock.Call
 }
 
 // GetFeatureByName mocks base method.
-func (m *MockDBStore) GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error) {
+func (m *MockDBStore) GetFeatureByName(ctx context.Context, groupName, featureName string) (*types.Feature, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, fullName)
+	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, groupName, featureName)
 	ret0, _ := ret[0].(*types.Feature)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
 // GetFeatureByName indicates an expected call of GetFeatureByName.
-func (mr *MockDBStoreMockRecorder) GetFeatureByName(ctx, fullName interface{}) *gomock.Call {
+func (mr *MockDBStoreMockRecorder) GetFeatureByName(ctx, groupName, featureName interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockDBStore)(nil).GetFeatureByName), ctx, fullName)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockDBStore)(nil).GetFeatureByName), ctx, groupName, featureName)
 }
 
 // GetGroup mocks base method.
@@ -761,18 +761,18 @@ func (m *MockCacheStore) EXPECT() *MockCacheStoreMockRecorder {
 	return m.recorder
 }
 
-// CacheListFeature mocks base method.
-func (m *MockCacheStore) CacheListFeature(ctx context.Context, opt metadata.ListFeatureOpt) types.FeatureList {
+// ListCachedFeature mocks base method.
+func (m *MockCacheStore) ListCachedFeature(ctx context.Context, fullNames *[]string) types.FeatureList {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "CacheListFeature", ctx, opt)
+	ret := m.ctrl.Call(m, "ListCachedFeature", ctx, fullNames)
 	ret0, _ := ret[0].(types.FeatureList)
 	return ret0
 }
 
-// CacheListFeature indicates an expected call of CacheListFeature.
-func (mr *MockCacheStoreMockRecorder) CacheListFeature(ctx, opt interface{}) *gomock.Call {
+// ListCachedFeature indicates an expected call of ListCachedFeature.
+func (mr *MockCacheStoreMockRecorder) ListCachedFeature(ctx, fullNames interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CacheListFeature", reflect.TypeOf((*MockCacheStore)(nil).CacheListFeature), ctx, opt)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCachedFeature", reflect.TypeOf((*MockCacheStore)(nil).ListCachedFeature), ctx, fullNames)
 }
 
 // Refresh mocks base method.
diff --git a/internal/database/metadata/mysql/db.go b/internal/database/metadata/mysql/db.go
index c92043e3..7bffa8d6 100644
--- a/internal/database/metadata/mysql/db.go
+++ b/internal/database/metadata/mysql/db.go
@@ -76,8 +76,8 @@ func (db *DB) GetFeature(ctx context.Context, id int) (*types.Feature, error) {
 	return sqlutil.GetFeature(ctx, db, id)
 }
 
-func (db *DB) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
-	return sqlutil.GetFeatureByName(ctx, db, name)
+func (db *DB) GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error) {
+	return sqlutil.GetFeatureByName(ctx, db, groupName, featureName)
 }
 
 func (db *DB) ListFeature(ctx context.Context, opt metadata.ListFeatureOpt) (types.FeatureList, error) {
diff --git a/internal/database/metadata/mysql/feature.go b/internal/database/metadata/mysql/feature.go
index 61a38525..bcc965ac 100644
--- a/internal/database/metadata/mysql/feature.go
+++ b/internal/database/metadata/mysql/feature.go
@@ -13,8 +13,8 @@ func createFeature(ctx context.Context, sqlxCtx metadata.SqlxContext, opt metada
 	if err := opt.ValueType.Validate(); err != nil {
 		return 0, errdefs.WithStack(err)
 	}
-	query := "INSERT INTO feature(name, full_name, group_id, value_type, description) VALUES (?, ?, ?, ?, ?)"
-	res, err := sqlxCtx.ExecContext(ctx, sqlxCtx.Rebind(query), opt.FeatureName, opt.FullName, opt.GroupID, opt.ValueType, opt.Description)
+	query := "INSERT INTO feature(name, group_id, value_type, description) VALUES (?, ?, ?, ?)"
+	res, err := sqlxCtx.ExecContext(ctx, sqlxCtx.Rebind(query), opt.FeatureName, opt.GroupID, opt.ValueType, opt.Description)
 	if err != nil {
 		if er, ok := err.(*mysql.MySQLError); ok {
 			if er.Number == ER_DUP_ENTRY {
diff --git a/internal/database/metadata/mysql/schema.go b/internal/database/metadata/mysql/schema.go
index b50f2a92..e2d05b5f 100644
--- a/internal/database/metadata/mysql/schema.go
+++ b/internal/database/metadata/mysql/schema.go
@@ -5,14 +5,12 @@ var META_TABLE_SCHEMAS = map[string]string{
 		CREATE TABLE feature (
 			id				INT 			NOT NULL AUTO_INCREMENT PRIMARY KEY,
 			name          	VARCHAR(32) 	NOT	NULL,
-			full_name   	VARCHAR(65)		NOT NULL COMMENT "<group_name>:<feature_name>",
 			group_id      	INT         	NOT	NULL,
 			value_type    	INT         	NOT	NULL COMMENT "data type of feature value",
 			description   	VARCHAR(128)	DEFAULT '',
 			create_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP,
 			modify_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-			UNIQUE (group_id, name),
-			UNIQUE (full_name)
+			UNIQUE (group_id, name)
 		);
 		`,
 	"feature_group": `
diff --git a/internal/database/metadata/mysql/tx.go b/internal/database/metadata/mysql/tx.go
index 03a3c1de..81b0a0bb 100644
--- a/internal/database/metadata/mysql/tx.go
+++ b/internal/database/metadata/mysql/tx.go
@@ -68,8 +68,8 @@ func (tx *Tx) ListFeature(ctx context.Context, opt metadata.ListFeatureOpt) (typ
 	return sqlutil.ListFeature(ctx, tx, opt)
 }
 
-func (tx *Tx) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
-	return sqlutil.GetFeatureByName(ctx, tx, name)
+func (tx *Tx) GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error) {
+	return sqlutil.GetFeatureByName(ctx, tx, groupName, featureName)
 }
 
 func (tx *Tx) CreateRevision(ctx context.Context, opt metadata.CreateRevisionOpt) (int, string, error) {
diff --git a/internal/database/metadata/postgres/db.go b/internal/database/metadata/postgres/db.go
index 307ff2e1..9c5179b0 100644
--- a/internal/database/metadata/postgres/db.go
+++ b/internal/database/metadata/postgres/db.go
@@ -89,8 +89,8 @@ func (db *DB) ListFeature(ctx context.Context, opt metadata.ListFeatureOpt) (typ
 	return sqlutil.ListFeature(ctx, db, opt)
 }
 
-func (db *DB) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
-	return sqlutil.GetFeatureByName(ctx, db, name)
+func (db *DB) GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error) {
+	return sqlutil.GetFeatureByName(ctx, db, groupName, featureName)
 }
 
 func (db *DB) CreateRevision(ctx context.Context, opt metadata.CreateRevisionOpt) (int, string, error) {
diff --git a/internal/database/metadata/postgres/feature.go b/internal/database/metadata/postgres/feature.go
index 425bbe72..6114d447 100644
--- a/internal/database/metadata/postgres/feature.go
+++ b/internal/database/metadata/postgres/feature.go
@@ -14,8 +14,8 @@ func createFeature(ctx context.Context, sqlxCtx metadata.SqlxContext, opt metada
 		return 0, err
 	}
 	var featureID int
-	query := "INSERT INTO feature(name, full_name, group_id, value_type, description) VALUES ($1, $2, $3, $4, $5) RETURNING id"
-	err := sqlxCtx.GetContext(ctx, &featureID, query, opt.FeatureName, opt.FullName, opt.GroupID, opt.ValueType, opt.Description)
+	query := "INSERT INTO feature(name, group_id, value_type, description) VALUES ($1, $2, $3, $4) RETURNING id"
+	err := sqlxCtx.GetContext(ctx, &featureID, query, opt.FeatureName, opt.GroupID, opt.ValueType, opt.Description)
 	if err != nil {
 		if e2, ok := err.(*pq.Error); ok {
 			if e2.Code == pgerrcode.UniqueViolation {
diff --git a/internal/database/metadata/postgres/schema.go b/internal/database/metadata/postgres/schema.go
index 3ff4a79a..9e494c02 100644
--- a/internal/database/metadata/postgres/schema.go
+++ b/internal/database/metadata/postgres/schema.go
@@ -23,17 +23,14 @@ var META_TABLE_SCHEMAS = map[string]string{
 		CREATE TABLE feature (
 			id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
 			name        	VARCHAR(32)  	NOT NULL,
-			full_name   	VARCHAR(65)		NOT NULL,
 			group_id    	INT          	NOT NULL,
 			value_type  	INT  		   	NOT NULL,
 			description 	VARCHAR(128) 	DEFAULT '',
 			create_time   	TIMESTAMP    	NOT NULL DEFAULT CURRENT_TIMESTAMP,
 			modify_time   	TIMESTAMP    	NOT NULL DEFAULT CURRENT_TIMESTAMP,
-			UNIQUE (group_id, name),
-			UNIQUE (full_name)
+			UNIQUE (group_id, name)
 		);
-		COMMENT ON COLUMN feature.value_type    IS 'data type of feature value';
-		COMMENT ON COLUMN feature.full_name		IS '<group_name>:<feature_name>';
+		COMMENT ON COLUMN feature.value_type IS 'data type of feature value';
 		`,
 	"feature_group": `
 		CREATE TABLE feature_group (
diff --git a/internal/database/metadata/postgres/tx.go b/internal/database/metadata/postgres/tx.go
index 6af374cc..fb911e38 100644
--- a/internal/database/metadata/postgres/tx.go
+++ b/internal/database/metadata/postgres/tx.go
@@ -68,8 +68,8 @@ func (tx *Tx) ListFeature(ctx context.Context, opt metadata.ListFeatureOpt) (typ
 	return sqlutil.ListFeature(ctx, tx, opt)
 }
 
-func (tx *Tx) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
-	return sqlutil.GetFeatureByName(ctx, tx, name)
+func (tx *Tx) GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error) {
+	return sqlutil.GetFeatureByName(ctx, tx, groupName, featureName)
 }
 
 func (tx *Tx) CreateRevision(ctx context.Context, opt metadata.CreateRevisionOpt) (int, string, error) {
diff --git a/internal/database/metadata/sqlite/db.go b/internal/database/metadata/sqlite/db.go
index 24fcc9fa..ebb6a680 100644
--- a/internal/database/metadata/sqlite/db.go
+++ b/internal/database/metadata/sqlite/db.go
@@ -66,8 +66,8 @@ func (db *DB) GetFeature(ctx context.Context, id int) (*types.Feature, error) {
 	return sqlutil.GetFeature(ctx, db, id)
 }
 
-func (db *DB) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
-	return sqlutil.GetFeatureByName(ctx, db, name)
+func (db *DB) GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error) {
+	return sqlutil.GetFeatureByName(ctx, db, groupName, featureName)
 }
 
 func (db *DB) ListFeature(ctx context.Context, opt metadata.ListFeatureOpt) (types.FeatureList, error) {
diff --git a/internal/database/metadata/sqlite/feature.go b/internal/database/metadata/sqlite/feature.go
index 6e29dc6f..04b5d7f8 100644
--- a/internal/database/metadata/sqlite/feature.go
+++ b/internal/database/metadata/sqlite/feature.go
@@ -14,8 +14,8 @@ func createFeature(ctx context.Context, sqlxCtx metadata.SqlxContext, opt metada
 	if err := opt.ValueType.Validate(); err != nil {
 		return 0, err
 	}
-	query := "INSERT INTO feature(name, full_name, group_id, value_type, description) VALUES (?, ?, ?, ?, ?)"
-	res, err := sqlxCtx.ExecContext(ctx, sqlxCtx.Rebind(query), opt.FeatureName, opt.FullName, opt.GroupID, opt.ValueType, opt.Description)
+	query := "INSERT INTO feature(name, group_id, value_type, description) VALUES (?, ?, ?, ?)"
+	res, err := sqlxCtx.ExecContext(ctx, sqlxCtx.Rebind(query), opt.FeatureName, opt.GroupID, opt.ValueType, opt.Description)
 	if err != nil {
 		if sqliteErr, ok := err.(*sqlite.Error); ok {
 			if sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
diff --git a/internal/database/metadata/sqlite/schema.go b/internal/database/metadata/sqlite/schema.go
index 29f969bb..4b12bcfc 100644
--- a/internal/database/metadata/sqlite/schema.go
+++ b/internal/database/metadata/sqlite/schema.go
@@ -13,14 +13,12 @@ var META_TABLE_SCHEMAS = map[string]string{
 		CREATE TABLE feature (
 			id				INTEGER 		NOT NULL PRIMARY KEY AUTOINCREMENT,
 			name          	VARCHAR(32) 	NOT	NULL,
-			full_name   	VARCHAR(65)		NOT NULL,
 			group_id      	INT         	NOT	NULL,
 			value_type    	INT          	NOT	NULL,
 			description   	VARCHAR(128)	DEFAULT '',
 			create_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP,
 			modify_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP,
 			UNIQUE (group_id, name),
-			UNIQUE (full_name),
 			FOREIGN KEY (group_id) REFERENCES feature_group(id)
 		);
 		`,
diff --git a/internal/database/metadata/sqlite/tx.go b/internal/database/metadata/sqlite/tx.go
index 01cb0750..5d21cb35 100644
--- a/internal/database/metadata/sqlite/tx.go
+++ b/internal/database/metadata/sqlite/tx.go
@@ -68,8 +68,9 @@ func (tx *Tx) ListFeature(ctx context.Context, opt metadata.ListFeatureOpt) (typ
 	return sqlutil.ListFeature(ctx, tx, opt)
 }
 
-func (tx *Tx) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
-	return sqlutil.GetFeatureByName(ctx, tx, name)
+func (tx *Tx) GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error) {
+	return sqlutil.GetFeatureByName(ctx, tx, groupName, featureName)
+
 }
 
 func (tx *Tx) CreateRevision(ctx context.Context, opt metadata.CreateRevisionOpt) (int, string, error) {
diff --git a/internal/database/metadata/sqlutil/feature.go b/internal/database/metadata/sqlutil/feature.go
index c40edae3..da9e9a1a 100644
--- a/internal/database/metadata/sqlutil/feature.go
+++ b/internal/database/metadata/sqlutil/feature.go
@@ -55,25 +55,25 @@ func GetFeature(ctx context.Context, sqlxCtx metadata.SqlxContext, id int) (*typ
 	return &feature, nil
 }
 
-func GetFeatureByName(ctx context.Context, sqlxCtx metadata.SqlxContext, fullName string) (*types.Feature, error) {
+func GetFeatureByName(ctx context.Context, sqlxCtx metadata.SqlxContext, groupName string, featureName string) (*types.Feature, error) {
 	var (
 		feature types.Feature
 		group   *types.Group
 		err     error
 	)
 
-	query := `SELECT * FROM feature WHERE full_name = ?`
-	if err := sqlxCtx.GetContext(ctx, &feature, sqlxCtx.Rebind(query), fullName); err != nil {
-		if err == sql.ErrNoRows {
-			return nil, errdefs.NotFound(errdefs.Errorf("feature %s not found", fullName))
-		}
+	if group, err = GetGroupByName(ctx, sqlxCtx, groupName); err != nil {
 		return nil, errdefs.WithStack(err)
 	}
+	feature.Group = group
 
-	if group, err = GetGroup(ctx, sqlxCtx, feature.GroupID); err != nil {
+	query := `SELECT * FROM feature WHERE name = ? AND group_id = ?`
+	if err := sqlxCtx.GetContext(ctx, &feature, sqlxCtx.Rebind(query), featureName, group.ID); err != nil {
+		if err == sql.ErrNoRows {
+			return nil, errdefs.NotFound(errdefs.Errorf("feature %s not found in group %s", featureName, groupName))
+		}
 		return nil, errdefs.WithStack(err)
 	}
-	feature.Group = group
 
 	return &feature, nil
 }
@@ -135,11 +135,5 @@ func buildListFeatureCond(opt metadata.ListFeatureOpt) ([]string, []interface{},
 		in["id"] = *opt.FeatureIDs
 	}
 
-	if opt.FeatureFullNames != nil {
-		if len(*opt.FeatureFullNames) == 0 {
-			return []string{"false"}, nil, nil
-		}
-		in["full_name"] = *opt.FeatureFullNames
-	}
 	return dbutil.BuildConditions(and, in)
 }
diff --git a/internal/database/metadata/store.go b/internal/database/metadata/store.go
index 8fdec7ba..b22badb8 100644
--- a/internal/database/metadata/store.go
+++ b/internal/database/metadata/store.go
@@ -29,7 +29,7 @@ type DBStore interface {
 	CreateFeature(ctx context.Context, opt CreateFeatureOpt) (int, error)
 	UpdateFeature(ctx context.Context, opt UpdateFeatureOpt) error
 	GetFeature(ctx context.Context, id int) (*types.Feature, error)
-	GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error)
+	GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error)
 	ListFeature(ctx context.Context, opt ListFeatureOpt) (types.FeatureList, error)
 
 	// feature group
@@ -52,7 +52,7 @@ type DBStore interface {
 
 // The interface defines methods that a memory backend store must implement.
 type CacheStore interface {
-	CacheListFeature(ctx context.Context, opt ListFeatureOpt) types.FeatureList
+	ListCachedFeature(ctx context.Context, fullNames *[]string) types.FeatureList
 
 	// Pull data from database and update cache.
 	Refresh() error
diff --git a/internal/database/metadata/types.go b/internal/database/metadata/types.go
index b5a88195..10f52751 100644
--- a/internal/database/metadata/types.go
+++ b/internal/database/metadata/types.go
@@ -9,7 +9,6 @@ type CreateEntityOpt struct {
 
 type CreateFeatureOpt struct {
 	FeatureName string
-	FullName    string
 	GroupID     int
 	Description string
 	ValueType   types.ValueType
@@ -56,8 +55,7 @@ type UpdateRevisionOpt struct {
 }
 
 type ListFeatureOpt struct {
-	EntityID         *int
-	GroupID          *int
-	FeatureIDs       *[]int
-	FeatureFullNames *[]string
+	EntityID   *int
+	GroupID    *int
+	FeatureIDs *[]int
 }
diff --git a/internal/database/offline/bigquery/export.go b/internal/database/offline/bigquery/export.go
index e8545cff..f10fa480 100644
--- a/internal/database/offline/bigquery/export.go
+++ b/internal/database/offline/bigquery/export.go
@@ -2,12 +2,72 @@ package bigquery
 
 import (
 	"context"
+	"strings"
 
+	"github.com/spf13/cast"
+
+	"github.com/oom-ai/oomstore/internal/database/offline/sqlutil"
+
+	"cloud.google.com/go/bigquery"
+	"github.com/oom-ai/oomstore/pkg/errdefs"
+	"google.golang.org/api/iterator"
+
+	"github.com/oom-ai/oomstore/internal/database/dbutil"
 	"github.com/oom-ai/oomstore/internal/database/offline"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
-func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (*types.ExportResult, error) {
+func (db *DB) ExportOneGroup(ctx context.Context, opt offline.ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	dbOpt := dbutil.DBOpt{
+		Backend:    types.BackendBigQuery,
+		BigQueryDB: db.Client,
+		DatasetID:  &db.datasetID,
+	}
+	doExportOpt := sqlutil.DoExportOneGroupOpt{
+		ExportOneGroupOpt: opt,
+		QueryResults:      bigqueryQueryExportResults,
+	}
+	return sqlutil.DoExportOneGroup(ctx, dbOpt, doExportOpt)
+}
+
+func bigqueryQueryExportResults(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.ExportOneGroupOpt, query string, args []interface{}) (<-chan types.ExportRecord, <-chan error) {
+	stream := make(chan types.ExportRecord)
+	errs := make(chan error, 1) // at most 1 error
+	for _, arg := range args {
+		query = strings.Replace(query, "?", cast.ToString(arg), 1)
+	}
+
+	go func() {
+		defer close(stream)
+		defer close(errs)
+		rows, err := dbOpt.BigQueryDB.Query(query).Read(ctx)
+		if err != nil {
+			errs <- err
+			return
+		}
+		for {
+			recordMap := make(map[string]bigquery.Value)
+			err = rows.Next(&recordMap)
+			if err == iterator.Done {
+				break
+			}
+			if err != nil {
+				errs <- errdefs.Errorf("failed at rows.Next, err=%v", err)
+				return
+			}
+			record := make([]interface{}, 0, len(recordMap))
+			record = append(record, recordMap[opt.EntityName])
+			for _, feature := range opt.Features {
+				record = append(record, recordMap[feature.Name])
+			}
+			stream <- record
+		}
+	}()
+
+	return stream, errs
+}
+
+func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	//TODO implement me
 	panic("implement me")
 }
diff --git a/internal/database/offline/mock_offline/store.go b/internal/database/offline/mock_offline/store.go
index e1d6438d..580d286d 100644
--- a/internal/database/offline/mock_offline/store.go
+++ b/internal/database/offline/mock_offline/store.go
@@ -65,11 +65,11 @@ func (mr *MockStoreMockRecorder) CreateTable(ctx, opt interface{}) *gomock.Call
 }
 
 // Export mocks base method.
-func (m *MockStore) Export(ctx context.Context, opt offline.ExportOpt) (*types.ExportResult, error) {
+func (m *MockStore) Export(ctx context.Context, opt offline.ExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "Export", ctx, opt)
-	ret0, _ := ret[0].(*types.ExportResult)
-	ret1, _ := ret[1].(error)
+	ret0, _ := ret[0].(<-chan types.ExportRecord)
+	ret1, _ := ret[1].(<-chan error)
 	return ret0, ret1
 }
 
@@ -79,6 +79,21 @@ func (mr *MockStoreMockRecorder) Export(ctx, opt interface{}) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Export", reflect.TypeOf((*MockStore)(nil).Export), ctx, opt)
 }
 
+// ExportOneGroup mocks base method.
+func (m *MockStore) ExportOneGroup(ctx context.Context, opt offline.ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ExportOneGroup", ctx, opt)
+	ret0, _ := ret[0].(<-chan types.ExportRecord)
+	ret1, _ := ret[1].(<-chan error)
+	return ret0, ret1
+}
+
+// ExportOneGroup indicates an expected call of ExportOneGroup.
+func (mr *MockStoreMockRecorder) ExportOneGroup(ctx, opt interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExportOneGroup", reflect.TypeOf((*MockStore)(nil).ExportOneGroup), ctx, opt)
+}
+
 // Import mocks base method.
 func (m *MockStore) Import(ctx context.Context, opt offline.ImportOpt) (int64, error) {
 	m.ctrl.T.Helper()
diff --git a/internal/database/offline/mysql/store.go b/internal/database/offline/mysql/store.go
index bc659d1f..ffb69792 100644
--- a/internal/database/offline/mysql/store.go
+++ b/internal/database/offline/mysql/store.go
@@ -37,7 +37,11 @@ func (db *DB) Import(ctx context.Context, opt offline.ImportOpt) (int64, error)
 	return sqlutil.Import(ctx, db.DB, opt, dbutil.LoadDataFromSource(Backend, MySQLBatchSize), Backend)
 }
 
-func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (*types.ExportResult, error) {
+func (db *DB) ExportOneGroup(ctx context.Context, opt offline.ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	return sqlutil.ExportOneGroup(ctx, db.DB, opt, Backend)
+}
+
+func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	return sqlutil.Export(ctx, db.DB, opt, Backend)
 }
 
diff --git a/internal/database/offline/postgres/store.go b/internal/database/offline/postgres/store.go
index 3b5e49c4..6ae9ffb9 100644
--- a/internal/database/offline/postgres/store.go
+++ b/internal/database/offline/postgres/store.go
@@ -34,7 +34,11 @@ func (db *DB) Import(ctx context.Context, opt offline.ImportOpt) (int64, error)
 	return sqlutil.Import(ctx, db.DB, opt, loadDataFromSource, Backend)
 }
 
-func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (*types.ExportResult, error) {
+func (db *DB) ExportOneGroup(ctx context.Context, opt offline.ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	return sqlutil.ExportOneGroup(ctx, db.DB, opt, Backend)
+}
+
+func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	return sqlutil.Export(ctx, db.DB, opt, Backend)
 
 }
diff --git a/internal/database/offline/redshift/store.go b/internal/database/offline/redshift/store.go
index cf20c5d6..66f3f3b6 100644
--- a/internal/database/offline/redshift/store.go
+++ b/internal/database/offline/redshift/store.go
@@ -37,7 +37,11 @@ func (db *DB) Import(ctx context.Context, opt offline.ImportOpt) (int64, error)
 	return sqlutil.Import(ctx, db.DB, opt, dbutil.LoadDataFromSource(Backend, RedshiftBatchSize), Backend)
 }
 
-func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (*types.ExportResult, error) {
+func (db *DB) ExportOneGroup(ctx context.Context, opt offline.ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	return sqlutil.ExportOneGroup(ctx, db.DB, opt, Backend)
+}
+
+func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	return sqlutil.Export(ctx, db.DB, opt, Backend)
 }
 
diff --git a/internal/database/offline/snowflake/store.go b/internal/database/offline/snowflake/store.go
index 2b01da38..b89f054c 100644
--- a/internal/database/offline/snowflake/store.go
+++ b/internal/database/offline/snowflake/store.go
@@ -51,7 +51,11 @@ func (db *DB) Import(ctx context.Context, opt offline.ImportOpt) (int64, error)
 	return sqlutil.Import(ctx, db.DB, opt, dbutil.LoadDataFromSource(Backend, SnowflakeBatchSize), Backend)
 }
 
-func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (*types.ExportResult, error) {
+func (db *DB) ExportOneGroup(ctx context.Context, opt offline.ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	return sqlutil.ExportOneGroup(ctx, db.DB, opt, Backend)
+}
+
+func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	return sqlutil.Export(ctx, db.DB, opt, Backend)
 }
 
diff --git a/internal/database/offline/sqlite/store.go b/internal/database/offline/sqlite/store.go
index c5e38019..6d55a7fa 100644
--- a/internal/database/offline/sqlite/store.go
+++ b/internal/database/offline/sqlite/store.go
@@ -37,7 +37,11 @@ func (db *DB) Import(ctx context.Context, opt offline.ImportOpt) (int64, error)
 	return sqlutil.Import(ctx, db.DB, opt, dbutil.LoadDataFromSource(Backend, SQLiteBatchSize), Backend)
 }
 
-func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (*types.ExportResult, error) {
+func (db *DB) ExportOneGroup(ctx context.Context, opt offline.ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	return sqlutil.ExportOneGroup(ctx, db.DB, opt, Backend)
+}
+
+func (db *DB) Export(ctx context.Context, opt offline.ExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	return sqlutil.Export(ctx, db.DB, opt, Backend)
 }
 
diff --git a/internal/database/offline/sqlutil/export.go b/internal/database/offline/sqlutil/export.go
index 49c4db93..42b6b6ca 100644
--- a/internal/database/offline/sqlutil/export.go
+++ b/internal/database/offline/sqlutil/export.go
@@ -14,26 +14,60 @@ import (
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
-type QueryExportResults func(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.ExportOpt, query string, args []interface{}, features types.FeatureList) (*types.ExportResult, error)
+type QueryExportResults func(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.ExportOneGroupOpt, query string, args []interface{}) (<-chan types.ExportRecord, <-chan error)
+
+type DoExportOneGroupOpt struct {
+	offline.ExportOneGroupOpt
+	QueryResults QueryExportResults
+}
 
 type DoExportOpt struct {
 	offline.ExportOpt
 	QueryResults QueryExportResults
 }
 
-func Export(ctx context.Context, db *sqlx.DB, opt offline.ExportOpt, backend types.BackendType) (*types.ExportResult, error) {
+func ExportOneGroup(ctx context.Context, db *sqlx.DB, opt offline.ExportOneGroupOpt, backend types.BackendType) (<-chan types.ExportRecord, <-chan error) {
+	dbOpt := dbutil.DBOpt{
+		Backend: backend,
+		SqlxDB:  db,
+	}
+	doJoinOpt := DoExportOneGroupOpt{
+		ExportOneGroupOpt: opt,
+		QueryResults:      sqlxQueryExportResults,
+	}
+	return DoExportOneGroup(ctx, dbOpt, doJoinOpt)
+}
+
+func DoExportOneGroup(ctx context.Context, dbOpt dbutil.DBOpt, opt DoExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error) {
+	var (
+		emptyStream = make(chan types.ExportRecord)
+		errs        = make(chan error, 1) // at most 1 error
+	)
+	defer close(emptyStream)
+	defer close(errs)
+
+	query, args, err := buildExportOneGroupQuery(dbOpt, opt.ExportOneGroupOpt)
+	if err != nil {
+		errs <- errdefs.WithStack(err)
+		return emptyStream, errs
+	}
+
+	return opt.QueryResults(ctx, dbOpt, opt.ExportOneGroupOpt, query, args)
+}
+
+func Export(ctx context.Context, db *sqlx.DB, opt offline.ExportOpt, backend types.BackendType) (<-chan types.ExportRecord, <-chan error) {
 	dbOpt := dbutil.DBOpt{
 		Backend: backend,
 		SqlxDB:  db,
 	}
 	doJoinOpt := DoExportOpt{
 		ExportOpt:    opt,
-		QueryResults: queryExportResults,
+		QueryResults: sqlxQueryExportResults,
 	}
 	return DoExport(ctx, dbOpt, doJoinOpt)
 }
 
-func DoExport(ctx context.Context, dbOpt dbutil.DBOpt, opt DoExportOpt) (*types.ExportResult, error) {
+func DoExport(ctx context.Context, dbOpt dbutil.DBOpt, opt DoExportOpt) (<-chan types.ExportRecord, <-chan error) {
 	var (
 		emptyStream = make(chan types.ExportRecord)
 		errs        = make(chan error, 1) // at most 1 error
@@ -63,7 +97,8 @@ func DoExport(ctx context.Context, dbOpt dbutil.DBOpt, opt DoExportOpt) (*types.
 	// Step 1: prepare export_entity table, which contains all entity keys from source tables
 	tableName, err := prepareEntityTable(ctx, dbOpt, opt.ExportOpt, snapshotTables, cdcTables)
 	if err != nil {
-		return nil, err
+		errs <- errdefs.WithStack(err)
+		return emptyStream, errs
 	}
 
 	// Step 2: join export_entity table, snapshot tables and cdc tables
@@ -76,14 +111,14 @@ func DoExport(ctx context.Context, dbOpt dbutil.DBOpt, opt DoExportOpt) (*types.
 		features := opt.Features[groupID]
 		if features[0].Group.Category == types.CategoryBatch {
 			for _, f := range features {
-				fields = append(fields, fmt.Sprintf("%s.%s AS %s", qt(opt.SnapshotTables[groupID]), qt(f.Name), qt(f.FullName)))
+				fields = append(fields, fmt.Sprintf("%s.%s AS %s", qt(opt.SnapshotTables[groupID]), qt(f.Name), qt(f.FullName())))
 				featureList = append(featureList, f)
 			}
 		} else {
 			for _, f := range features {
 				cdc := fmt.Sprintf("%s.%s", opt.CdcTables[groupID]+"_0", qt(f.Name))
 				snapshot := fmt.Sprintf("%s.%s", qt(opt.SnapshotTables[groupID]), qt(f.Name))
-				fields = append(fields, fmt.Sprintf("(CASE WHEN %s IS NULL THEN %s ELSE %s END) AS %s", cdc, snapshot, cdc, qt(f.FullName)))
+				fields = append(fields, fmt.Sprintf("(CASE WHEN %s IS NULL THEN %s ELSE %s END) AS %s", cdc, snapshot, cdc, qt(f.FullName())))
 				featureList = append(featureList, f)
 			}
 		}
@@ -98,16 +133,61 @@ func DoExport(ctx context.Context, dbOpt dbutil.DBOpt, opt DoExportOpt) (*types.
 		Backend:         dbOpt.Backend,
 	})
 	if err != nil {
-		return nil, err
+		errs <- errdefs.WithStack(err)
+		return emptyStream, errs
 	}
 	args := make([]interface{}, 0, len(opt.CdcTables)*2)
 	for i := 0; i < len(opt.CdcTables)*2; i++ {
 		args = append(args, opt.UnixMilli)
 	}
-	return queryExportResults(ctx, dbOpt, opt.ExportOpt, query, args, featureList)
+	return queryExportResults(ctx, dbOpt, query, args, featureList)
+}
+
+func queryExportResults(ctx context.Context, dbOpt dbutil.DBOpt, query string, args []interface{}, featureList types.FeatureList) (<-chan types.ExportRecord, <-chan error) {
+	stream := make(chan types.ExportRecord)
+	errs := make(chan error, 1) // at most 1 error
+
+	go func() {
+		defer close(stream)
+		defer close(errs)
+		stmt, err := dbOpt.SqlxDB.Preparex(dbOpt.SqlxDB.Rebind(query))
+		if err != nil {
+			errs <- errdefs.WithStack(err)
+			return
+		}
+		defer stmt.Close()
+		rows, err := stmt.Queryx(args...)
+		if err != nil {
+			errs <- errdefs.WithStack(err)
+			return
+		}
+		defer rows.Close()
+		for rows.Next() {
+			record, err := rows.SliceScan()
+			if err != nil {
+				errs <- errdefs.Errorf("failed at rows.SliceScan, err=%v", err)
+				return
+			}
+			record[0] = cast.ToString(record[0])
+			for i, f := range featureList {
+				if record[i+1] == nil {
+					continue
+				}
+				deserializedValue, err := dbutil.DeserializeByValueType(record[i+1], f.ValueType, dbOpt.Backend)
+				if err != nil {
+					errs <- err
+					return
+				}
+				record[i+1] = deserializedValue
+			}
+			stream <- record
+		}
+	}()
+
+	return stream, errs
 }
 
-func queryExportResults(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.ExportOpt, query string, args []interface{}, features types.FeatureList) (*types.ExportResult, error) {
+func sqlxQueryExportResults(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.ExportOneGroupOpt, query string, args []interface{}) (<-chan types.ExportRecord, <-chan error) {
 	stream := make(chan types.ExportRecord)
 	errs := make(chan error, 1) // at most 1 error
 
@@ -133,7 +213,7 @@ func queryExportResults(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.Exp
 				return
 			}
 			record[0] = cast.ToString(record[0])
-			for i, f := range features {
+			for i, f := range opt.Features {
 				if record[i+1] == nil {
 					continue
 				}
@@ -147,6 +227,50 @@ func queryExportResults(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.Exp
 			stream <- record
 		}
 	}()
-	header := append([]string{opt.EntityName}, features.FullNames()...)
-	return types.NewExportResult(header, stream, errs), nil
+
+	return stream, errs
+}
+
+func buildExportOneGroupQuery(dbOpt dbutil.DBOpt, opt offline.ExportOneGroupOpt) (string, []interface{}, error) {
+	if opt.CdcTable == nil && opt.UnixMilli == nil {
+		return buildExportBatchQuery(dbOpt, opt), nil, nil
+	}
+	if opt.CdcTable != nil && opt.UnixMilli != nil {
+		return buildExportStreamQuery(dbOpt, opt)
+	}
+	return "", nil, fmt.Errorf("invalid option %+v", opt)
+}
+
+func buildExportBatchQuery(dbOpt dbutil.DBOpt, opt offline.ExportOneGroupOpt) string {
+	fields := append([]string{opt.EntityName}, opt.Features.Names()...)
+	qt := dbutil.QuoteFn(dbOpt.Backend)
+
+	tableName := qt(opt.SnapshotTable)
+	if dbOpt.Backend == types.BackendBigQuery {
+		tableName = fmt.Sprintf("%s.%s", *dbOpt.DatasetID, tableName)
+	}
+	query := fmt.Sprintf("SELECT %s FROM %s", qt(fields...), tableName)
+	if opt.Limit != nil {
+		query += fmt.Sprintf(" LIMIT %d", *opt.Limit)
+	}
+	return query
+}
+
+func buildExportStreamQuery(dbOpt dbutil.DBOpt, opt offline.ExportOneGroupOpt) (string, []interface{}, error) {
+	query, err := buildAggregateQuery(aggregateQueryParams{
+		EntityName:            opt.EntityName,
+		UnixMilli:             "unix_milli",
+		FeatureNames:          opt.Features.Names(),
+		PrevSnapshotTableName: opt.SnapshotTable,
+		CurrCdcTableName:      *opt.CdcTable,
+		Backend:               dbOpt.Backend,
+		DatasetID:             dbOpt.DatasetID,
+	})
+	if err != nil {
+		return "", nil, errdefs.WithStack(err)
+	}
+	if opt.Limit != nil {
+		query += fmt.Sprintf(" LIMIT %d", *opt.Limit)
+	}
+	return query, []interface{}{*opt.UnixMilli, *opt.UnixMilli, *opt.UnixMilli, *opt.UnixMilli}, nil
 }
diff --git a/internal/database/offline/sqlutil/export_helper.go b/internal/database/offline/sqlutil/export_helper.go
index 646f1952..22b51866 100644
--- a/internal/database/offline/sqlutil/export_helper.go
+++ b/internal/database/offline/sqlutil/export_helper.go
@@ -53,6 +53,91 @@ ON e.{{ qt $.EntityName }} = {{ $table }}_0.{{ qt $.EntityName }}
 
 `
 
+const AGGREGATE_QUERY = `
+SELECT
+	l.{{ qt .EntityName }},
+	{{ featureValue .FeatureNames }}
+FROM {{ qt .PrevSnapshotTableName }} AS l
+LEFT JOIN
+(
+	SELECT
+		t1.*
+	FROM
+		{{ qt .CurrCdcTableName }} AS t1
+	JOIN
+		(SELECT
+			{{ qt .EntityName }},
+			MAX({{ qt .UnixMilli }}) AS {{ qt .UnixMilli }}
+		FROM {{ qt .CurrCdcTableName }}
+		WHERE {{ qt .UnixMilli }} <= ?
+		GROUP BY {{ qt .EntityName }}) AS t2
+	ON t1.{{ qt .EntityName }} = t2.{{ qt .EntityName }} AND t1.{{ qt .UnixMilli }} = t2.{{ qt .UnixMilli }}
+	WHERE t1.{{ qt .UnixMilli }} <= ?
+) AS r
+ON l.{{ qt .EntityName }} = r.{{ qt .EntityName }}
+{{ .Union }}
+SELECT
+	r.{{ qt .EntityName }},
+	{{ featureValue .FeatureNames }}
+FROM
+(
+	SELECT
+		t1.*
+	FROM
+		{{ qt .CurrCdcTableName }} AS t1
+	JOIN
+		(SELECT
+			{{ qt .EntityName }},
+			MAX({{ qt .UnixMilli }}) AS {{ qt .UnixMilli }}
+		FROM {{ qt .CurrCdcTableName }}
+		WHERE {{ qt .UnixMilli }} <= ?
+		GROUP BY {{ qt .EntityName }}) AS t2
+	ON t1.{{ qt .EntityName }} = t2.{{ qt .EntityName }} AND t1.{{ qt .UnixMilli }} = t2.{{ qt .UnixMilli }}
+	WHERE t1.{{ qt .UnixMilli }} <= ?
+) AS r
+LEFT JOIN
+{{ qt .PrevSnapshotTableName }} AS l
+ON l.{{ qt .EntityName }} = r.{{ qt .EntityName }}
+`
+
+type aggregateQueryParams struct {
+	EntityName            string
+	UnixMilli             string
+	FeatureNames          []string
+	PrevSnapshotTableName string
+	CurrCdcTableName      string
+	Union                 string
+	Backend               types.BackendType
+	DatasetID             *string
+}
+
+func buildAggregateQuery(params aggregateQueryParams) (string, error) {
+	if params.Backend == types.BackendBigQuery {
+		params.Union = "UNION DISTINCT"
+		params.PrevSnapshotTableName = fmt.Sprintf("%s.%s", *params.DatasetID, params.PrevSnapshotTableName)
+		params.CurrCdcTableName = fmt.Sprintf("%s.%s", *params.DatasetID, params.CurrCdcTableName)
+	} else {
+		params.Union = "UNION"
+	}
+	qt := dbutil.QuoteFn(params.Backend)
+	t := template.Must(template.New("snapshot").Funcs(template.FuncMap{
+		"qt": dbutil.QuoteFn(params.Backend),
+		"featureValue": func(features []string) string {
+			values := make([]string, 0, len(features))
+			for _, f := range features {
+				values = append(values, fmt.Sprintf("(CASE WHEN r.%s IS NULL THEN l.%s ELSE r.%s END) AS %s", qt(f), qt(f), qt(f), f))
+			}
+			return strings.Join(values, ",")
+		},
+	}).Parse(AGGREGATE_QUERY))
+
+	buf := bytes.NewBuffer(nil)
+	if err := t.Execute(buf, params); err != nil {
+		return "", errors.WithStack(err)
+	}
+	return buf.String(), nil
+}
+
 type unionEntityQueryParams struct {
 	TableName      string
 	EntityName     string
@@ -157,10 +242,9 @@ func prepareEntityTable(ctx context.Context, dbOpt dbutil.DBOpt, opt offline.Exp
 
 	// Step 3: create index on table entity_rows
 	if supportIndex(dbOpt.Backend) {
-		qt := dbutil.QuoteFn(dbOpt.Backend)
-		index := fmt.Sprintf(`CREATE UNIQUE INDEX idx_%s ON %s (%s)`, tableName, tableName, qt(opt.EntityName))
+		index := fmt.Sprintf(`CREATE UNIQUE INDEX idx_%s ON %s (%s)`, tableName, tableName, opt.EntityName)
 		if err = dbOpt.ExecContext(ctx, index, nil); err != nil {
-			return "", errdefs.WithStack(err)
+			return "", err
 		}
 	}
 	return tableName, nil
diff --git a/internal/database/offline/sqlutil/join.go b/internal/database/offline/sqlutil/join.go
index c3d7be45..8d3fc981 100644
--- a/internal/database/offline/sqlutil/join.go
+++ b/internal/database/offline/sqlutil/join.go
@@ -224,7 +224,7 @@ func ReadJoinedTable(ctx context.Context, dbOpt dbutil.DBOpt, opt ReadJoinedTabl
 		for _, f := range opt.FeatureMap[name] {
 			fields = append(fields, fmt.Sprintf("%s.%s", qt(name), qt(f.Name)))
 			header = append(header, dbutil.Column{
-				Name:      f.FullName,
+				Name:      f.FullName(),
 				ValueType: f.ValueType,
 			})
 		}
diff --git a/internal/database/offline/store.go b/internal/database/offline/store.go
index 13a1e573..2a376541 100644
--- a/internal/database/offline/store.go
+++ b/internal/database/offline/store.go
@@ -9,7 +9,8 @@ import (
 
 type Store interface {
 	Join(ctx context.Context, opt JoinOpt) (*types.JoinResult, error)
-	Export(ctx context.Context, opt ExportOpt) (*types.ExportResult, error)
+	ExportOneGroup(ctx context.Context, opt ExportOneGroupOpt) (<-chan types.ExportRecord, <-chan error)
+	Export(ctx context.Context, opt ExportOpt) (<-chan types.ExportRecord, <-chan error)
 	Import(ctx context.Context, opt ImportOpt) (int64, error)
 	Push(ctx context.Context, opt PushOpt) error
 
diff --git a/internal/database/offline/types.go b/internal/database/offline/types.go
index d97883cd..cf166174 100644
--- a/internal/database/offline/types.go
+++ b/internal/database/offline/types.go
@@ -6,6 +6,15 @@ import (
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
+type ExportOneGroupOpt struct {
+	SnapshotTable string
+	CdcTable      *string
+	UnixMilli     *int64
+	EntityName    string
+	Features      types.FeatureList
+	Limit         *uint64
+}
+
 type ExportOpt struct {
 	SnapshotTables map[int]string
 	CdcTables      map[int]string
diff --git a/internal/database/online/cassandra/get.go b/internal/database/online/cassandra/get.go
index 0f1e5186..3ff2af86 100644
--- a/internal/database/online/cassandra/get.go
+++ b/internal/database/online/cassandra/get.go
@@ -39,7 +39,7 @@ func (db *DB) Get(ctx context.Context, opt online.GetOpt) (dbutil.RowMap, error)
 	rs := make(map[string]interface{}, len(scan))
 	for _, feature := range opt.Features {
 		deserializedValue, _ := dbutil.DeserializeByValueType(scan[feature.Name], feature.ValueType, types.BackendCassandra)
-		rs[feature.FullName] = deserializedValue
+		rs[feature.FullName()] = deserializedValue
 	}
 	return rs, nil
 }
@@ -89,7 +89,7 @@ func deserializeIntoRowMap(values map[string]interface{}, entityName string, fea
 	rs := make(dbutil.RowMap)
 	for _, feature := range features {
 		deserializedValue, _ := dbutil.DeserializeByValueType(values[feature.Name], feature.ValueType, types.BackendCassandra)
-		rs[feature.FullName] = deserializedValue
+		rs[feature.FullName()] = deserializedValue
 	}
 	return entityKey, rs
 }
diff --git a/internal/database/online/dynamodb/get.go b/internal/database/online/dynamodb/get.go
index e52f2b38..15ebc97e 100644
--- a/internal/database/online/dynamodb/get.go
+++ b/internal/database/online/dynamodb/get.go
@@ -129,7 +129,7 @@ func deserializeFeatureValues(features oomTypes.FeatureList, item map[string]typ
 		if err != nil {
 			return nil, err
 		}
-		rowMap[feature.FullName] = deserializedValue
+		rowMap[feature.FullName()] = deserializedValue
 	}
 	return rowMap, nil
 }
diff --git a/internal/database/online/redis/get.go b/internal/database/online/redis/get.go
index 04b9cbae..c6fc357f 100644
--- a/internal/database/online/redis/get.go
+++ b/internal/database/online/redis/get.go
@@ -48,7 +48,7 @@ func (db *DB) Get(ctx context.Context, opt online.GetOpt) (dbutil.RowMap, error)
 		if err != nil {
 			return nil, err
 		}
-		rowMap[opt.Features[i].FullName] = deserializedValue
+		rowMap[opt.Features[i].FullName()] = deserializedValue
 	}
 	return rowMap, nil
 }
diff --git a/internal/database/online/sqlutil/get.go b/internal/database/online/sqlutil/get.go
index 4a3978ba..da9357fb 100644
--- a/internal/database/online/sqlutil/get.go
+++ b/internal/database/online/sqlutil/get.go
@@ -94,7 +94,7 @@ func deserializeIntoRowMap(values []interface{}, features types.FeatureList, bac
 		if err != nil {
 			return nil, err
 		}
-		rs[features[i].FullName] = deserializedValue
+		rs[features[i].FullName()] = deserializedValue
 	}
 	return rs, nil
 }
diff --git a/internal/database/online/tikv/get.go b/internal/database/online/tikv/get.go
index ca2b2aa7..81dab74c 100644
--- a/internal/database/online/tikv/get.go
+++ b/internal/database/online/tikv/get.go
@@ -109,7 +109,7 @@ func (db *DB) MultiGet(ctx context.Context, opt online.MultiGetOpt) (map[string]
 		if _, ok := res[entityKey]; !ok {
 			res[entityKey] = make(dbutil.RowMap)
 		}
-		res[entityKey][inputs[i].feature.FullName] = deserializedValue
+		res[entityKey][inputs[i].feature.FullName()] = deserializedValue
 	}
 	return res, nil
 }
diff --git a/oomagent/codegen/oomagent.pb.go b/oomagent/codegen/oomagent.pb.go
index 2d170632..a4f33dd4 100644
--- a/oomagent/codegen/oomagent.pb.go
+++ b/oomagent/codegen/oomagent.pb.go
@@ -834,91 +834,6 @@ func (x *PushRequest) GetFeatureValues() []*Value {
 	return nil
 }
 
-type SnapshotResponse struct {
-	state         protoimpl.MessageState
-	sizeCache     protoimpl.SizeCache
-	unknownFields protoimpl.UnknownFields
-}
-
-func (x *SnapshotResponse) Reset() {
-	*x = SnapshotResponse{}
-	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[13]
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		ms.StoreMessageInfo(mi)
-	}
-}
-
-func (x *SnapshotResponse) String() string {
-	return protoimpl.X.MessageStringOf(x)
-}
-
-func (*SnapshotResponse) ProtoMessage() {}
-
-func (x *SnapshotResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[13]
-	if protoimpl.UnsafeEnabled && x != nil {
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		if ms.LoadMessageInfo() == nil {
-			ms.StoreMessageInfo(mi)
-		}
-		return ms
-	}
-	return mi.MessageOf(x)
-}
-
-// Deprecated: Use SnapshotResponse.ProtoReflect.Descriptor instead.
-func (*SnapshotResponse) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{13}
-}
-
-type SnapshotRequest struct {
-	state         protoimpl.MessageState
-	sizeCache     protoimpl.SizeCache
-	unknownFields protoimpl.UnknownFields
-
-	GroupName string `protobuf:"bytes,1,opt,name=group_name,json=groupName,proto3" json:"group_name,omitempty"`
-}
-
-func (x *SnapshotRequest) Reset() {
-	*x = SnapshotRequest{}
-	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[14]
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		ms.StoreMessageInfo(mi)
-	}
-}
-
-func (x *SnapshotRequest) String() string {
-	return protoimpl.X.MessageStringOf(x)
-}
-
-func (*SnapshotRequest) ProtoMessage() {}
-
-func (x *SnapshotRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[14]
-	if protoimpl.UnsafeEnabled && x != nil {
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		if ms.LoadMessageInfo() == nil {
-			ms.StoreMessageInfo(mi)
-		}
-		return ms
-	}
-	return mi.MessageOf(x)
-}
-
-// Deprecated: Use SnapshotRequest.ProtoReflect.Descriptor instead.
-func (*SnapshotRequest) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{14}
-}
-
-func (x *SnapshotRequest) GetGroupName() string {
-	if x != nil {
-		return x.GroupName
-	}
-	return ""
-}
-
 type EntityRow struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -932,7 +847,7 @@ type EntityRow struct {
 func (x *EntityRow) Reset() {
 	*x = EntityRow{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[15]
+		mi := &file_oomagent_proto_msgTypes[13]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -945,7 +860,7 @@ func (x *EntityRow) String() string {
 func (*EntityRow) ProtoMessage() {}
 
 func (x *EntityRow) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[15]
+	mi := &file_oomagent_proto_msgTypes[13]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -958,7 +873,7 @@ func (x *EntityRow) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityRow.ProtoReflect.Descriptor instead.
 func (*EntityRow) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{15}
+	return file_oomagent_proto_rawDescGZIP(), []int{13}
 }
 
 func (x *EntityRow) GetEntityKey() string {
@@ -995,7 +910,7 @@ type ChannelJoinRequest struct {
 func (x *ChannelJoinRequest) Reset() {
 	*x = ChannelJoinRequest{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[16]
+		mi := &file_oomagent_proto_msgTypes[14]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1008,7 +923,7 @@ func (x *ChannelJoinRequest) String() string {
 func (*ChannelJoinRequest) ProtoMessage() {}
 
 func (x *ChannelJoinRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[16]
+	mi := &file_oomagent_proto_msgTypes[14]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1021,7 +936,7 @@ func (x *ChannelJoinRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ChannelJoinRequest.ProtoReflect.Descriptor instead.
 func (*ChannelJoinRequest) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{16}
+	return file_oomagent_proto_rawDescGZIP(), []int{14}
 }
 
 func (x *ChannelJoinRequest) GetFeatureFullNames() []string {
@@ -1057,7 +972,7 @@ type ChannelJoinResponse struct {
 func (x *ChannelJoinResponse) Reset() {
 	*x = ChannelJoinResponse{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[17]
+		mi := &file_oomagent_proto_msgTypes[15]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1070,7 +985,7 @@ func (x *ChannelJoinResponse) String() string {
 func (*ChannelJoinResponse) ProtoMessage() {}
 
 func (x *ChannelJoinResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[17]
+	mi := &file_oomagent_proto_msgTypes[15]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1083,7 +998,7 @@ func (x *ChannelJoinResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ChannelJoinResponse.ProtoReflect.Descriptor instead.
 func (*ChannelJoinResponse) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{17}
+	return file_oomagent_proto_rawDescGZIP(), []int{15}
 }
 
 func (x *ChannelJoinResponse) GetHeader() []string {
@@ -1113,7 +1028,7 @@ type JoinRequest struct {
 func (x *JoinRequest) Reset() {
 	*x = JoinRequest{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[18]
+		mi := &file_oomagent_proto_msgTypes[16]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1126,7 +1041,7 @@ func (x *JoinRequest) String() string {
 func (*JoinRequest) ProtoMessage() {}
 
 func (x *JoinRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[18]
+	mi := &file_oomagent_proto_msgTypes[16]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1139,7 +1054,7 @@ func (x *JoinRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use JoinRequest.ProtoReflect.Descriptor instead.
 func (*JoinRequest) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{18}
+	return file_oomagent_proto_rawDescGZIP(), []int{16}
 }
 
 func (x *JoinRequest) GetFeatureFullNames() []string {
@@ -1172,7 +1087,7 @@ type JoinResponse struct {
 func (x *JoinResponse) Reset() {
 	*x = JoinResponse{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[19]
+		mi := &file_oomagent_proto_msgTypes[17]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1185,7 +1100,7 @@ func (x *JoinResponse) String() string {
 func (*JoinResponse) ProtoMessage() {}
 
 func (x *JoinResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[19]
+	mi := &file_oomagent_proto_msgTypes[17]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1198,7 +1113,7 @@ func (x *JoinResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use JoinResponse.ProtoReflect.Descriptor instead.
 func (*JoinResponse) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{19}
+	return file_oomagent_proto_rawDescGZIP(), []int{17}
 }
 
 type ChannelExportRequest struct {
@@ -1207,14 +1122,14 @@ type ChannelExportRequest struct {
 	unknownFields protoimpl.UnknownFields
 
 	FeatureNames []string `protobuf:"bytes,1,rep,name=feature_names,json=featureNames,proto3" json:"feature_names,omitempty"`
-	UnixMilli    int64    `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"`
+	RevisionId   int32    `protobuf:"varint,2,opt,name=revision_id,json=revisionId,proto3" json:"revision_id,omitempty"`
 	Limit        *uint64  `protobuf:"varint,3,opt,name=limit,proto3,oneof" json:"limit,omitempty"`
 }
 
 func (x *ChannelExportRequest) Reset() {
 	*x = ChannelExportRequest{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[20]
+		mi := &file_oomagent_proto_msgTypes[18]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1227,7 +1142,7 @@ func (x *ChannelExportRequest) String() string {
 func (*ChannelExportRequest) ProtoMessage() {}
 
 func (x *ChannelExportRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[20]
+	mi := &file_oomagent_proto_msgTypes[18]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1240,7 +1155,7 @@ func (x *ChannelExportRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ChannelExportRequest.ProtoReflect.Descriptor instead.
 func (*ChannelExportRequest) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{20}
+	return file_oomagent_proto_rawDescGZIP(), []int{18}
 }
 
 func (x *ChannelExportRequest) GetFeatureNames() []string {
@@ -1250,9 +1165,9 @@ func (x *ChannelExportRequest) GetFeatureNames() []string {
 	return nil
 }
 
-func (x *ChannelExportRequest) GetUnixMilli() int64 {
+func (x *ChannelExportRequest) GetRevisionId() int32 {
 	if x != nil {
-		return x.UnixMilli
+		return x.RevisionId
 	}
 	return 0
 }
@@ -1276,7 +1191,7 @@ type ChannelExportResponse struct {
 func (x *ChannelExportResponse) Reset() {
 	*x = ChannelExportResponse{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[21]
+		mi := &file_oomagent_proto_msgTypes[19]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1289,7 +1204,7 @@ func (x *ChannelExportResponse) String() string {
 func (*ChannelExportResponse) ProtoMessage() {}
 
 func (x *ChannelExportResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[21]
+	mi := &file_oomagent_proto_msgTypes[19]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1302,7 +1217,7 @@ func (x *ChannelExportResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ChannelExportResponse.ProtoReflect.Descriptor instead.
 func (*ChannelExportResponse) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{21}
+	return file_oomagent_proto_rawDescGZIP(), []int{19}
 }
 
 func (x *ChannelExportResponse) GetHeader() []string {
@@ -1325,7 +1240,7 @@ type ExportRequest struct {
 	unknownFields protoimpl.UnknownFields
 
 	FeatureNames   []string `protobuf:"bytes,1,rep,name=feature_names,json=featureNames,proto3" json:"feature_names,omitempty"`
-	UnixMilli      int64    `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"`
+	RevisionId     int32    `protobuf:"varint,2,opt,name=revision_id,json=revisionId,proto3" json:"revision_id,omitempty"`
 	Limit          *uint64  `protobuf:"varint,3,opt,name=limit,proto3,oneof" json:"limit,omitempty"`
 	OutputFilePath string   `protobuf:"bytes,4,opt,name=output_file_path,json=outputFilePath,proto3" json:"output_file_path,omitempty"`
 }
@@ -1333,7 +1248,7 @@ type ExportRequest struct {
 func (x *ExportRequest) Reset() {
 	*x = ExportRequest{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[22]
+		mi := &file_oomagent_proto_msgTypes[20]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1346,7 +1261,7 @@ func (x *ExportRequest) String() string {
 func (*ExportRequest) ProtoMessage() {}
 
 func (x *ExportRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[22]
+	mi := &file_oomagent_proto_msgTypes[20]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1359,7 +1274,7 @@ func (x *ExportRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ExportRequest.ProtoReflect.Descriptor instead.
 func (*ExportRequest) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{22}
+	return file_oomagent_proto_rawDescGZIP(), []int{20}
 }
 
 func (x *ExportRequest) GetFeatureNames() []string {
@@ -1369,9 +1284,9 @@ func (x *ExportRequest) GetFeatureNames() []string {
 	return nil
 }
 
-func (x *ExportRequest) GetUnixMilli() int64 {
+func (x *ExportRequest) GetRevisionId() int32 {
 	if x != nil {
-		return x.UnixMilli
+		return x.RevisionId
 	}
 	return 0
 }
@@ -1399,7 +1314,7 @@ type ExportResponse struct {
 func (x *ExportResponse) Reset() {
 	*x = ExportResponse{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_oomagent_proto_msgTypes[23]
+		mi := &file_oomagent_proto_msgTypes[21]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1412,7 +1327,7 @@ func (x *ExportResponse) String() string {
 func (*ExportResponse) ProtoMessage() {}
 
 func (x *ExportResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_oomagent_proto_msgTypes[23]
+	mi := &file_oomagent_proto_msgTypes[21]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1425,7 +1340,7 @@ func (x *ExportResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ExportResponse.ProtoReflect.Descriptor instead.
 func (*ExportResponse) Descriptor() ([]byte, []int) {
-	return file_oomagent_proto_rawDescGZIP(), []int{23}
+	return file_oomagent_proto_rawDescGZIP(), []int{21}
 }
 
 var File_oomagent_proto protoreflect.FileDescriptor
@@ -1540,123 +1455,114 @@ var file_oomagent_proto_rawDesc = []byte{
 	0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x0e, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65,
 	0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e,
 	0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0d,
-	0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x12, 0x0a,
-	0x10, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
-	0x65, 0x22, 0x30, 0x0a, 0x0f, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71,
-	0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61,
-	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x4e,
-	0x61, 0x6d, 0x65, 0x22, 0x61, 0x0a, 0x09, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77,
-	0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4b, 0x65, 0x79, 0x12,
-	0x1d, 0x0a, 0x0a, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x69, 0x6c, 0x6c, 0x69, 0x18, 0x02, 0x20,
-	0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x6e, 0x69, 0x78, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x12, 0x16,
-	0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06,
-	0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x97, 0x01, 0x0a, 0x12, 0x43, 0x68, 0x61, 0x6e, 0x6e,
-	0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a,
-	0x12, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x6e, 0x61,
-	0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x66, 0x65, 0x61, 0x74, 0x75,
-	0x72, 0x65, 0x46, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x0a, 0x65,
-	0x6e, 0x74, 0x69, 0x74, 0x79, 0x5f, 0x72, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
-	0x13, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74,
-	0x79, 0x52, 0x6f, 0x77, 0x52, 0x09, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77, 0x12,
-	0x1f, 0x0a, 0x0b, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x03,
-	0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73,
-	0x22, 0x5d, 0x0a, 0x13, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e, 0x52,
-	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65,
-	0x72, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12,
-	0x2e, 0x0a, 0x0a, 0x6a, 0x6f, 0x69, 0x6e, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x77, 0x18, 0x02, 0x20,
-	0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x56,
-	0x61, 0x6c, 0x75, 0x65, 0x52, 0x09, 0x6a, 0x6f, 0x69, 0x6e, 0x65, 0x64, 0x52, 0x6f, 0x77, 0x22,
-	0x8d, 0x01, 0x0a, 0x0b, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
-	0x2c, 0x0a, 0x12, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x5f,
-	0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x66, 0x65, 0x61,
-	0x74, 0x75, 0x72, 0x65, 0x46, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x26, 0x0a,
-	0x0f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68,
-	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x46, 0x69, 0x6c,
-	0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f,
-	0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0e, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x46, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x22,
-	0x0e, 0x0a, 0x0c, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
-	0x7f, 0x0a, 0x14, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74,
-	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75,
-	0x72, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c,
-	0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a,
-	0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x69, 0x6c, 0x6c, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
-	0x52, 0x09, 0x75, 0x6e, 0x69, 0x78, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x12, 0x19, 0x0a, 0x05, 0x6c,
-	0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x69,
-	0x6d, 0x69, 0x74, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74,
-	0x22, 0x52, 0x0a, 0x15, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72,
-	0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61,
-	0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65,
-	0x72, 0x12, 0x21, 0x0a, 0x03, 0x72, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f,
-	0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52,
-	0x03, 0x72, 0x6f, 0x77, 0x22, 0xa2, 0x01, 0x0a, 0x0d, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52,
-	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72,
-	0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x66,
-	0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x75,
-	0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x69, 0x6c, 0x6c, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52,
-	0x09, 0x75, 0x6e, 0x69, 0x78, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x69,
-	0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x69, 0x6d,
-	0x69, 0x74, 0x88, 0x01, 0x01, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f,
-	0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0e, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x46, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x42,
-	0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x10, 0x0a, 0x0e, 0x45, 0x78, 0x70,
-	0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xcf, 0x06, 0x0a, 0x08,
-	0x4f, 0x6f, 0x6d, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x46, 0x0a, 0x09, 0x4f, 0x6e, 0x6c, 0x69,
-	0x6e, 0x65, 0x47, 0x65, 0x74, 0x12, 0x1a, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74,
-	0x2e, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
-	0x74, 0x1a, 0x1b, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4f, 0x6e, 0x6c,
-	0x69, 0x6e, 0x65, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
-	0x12, 0x55, 0x0a, 0x0e, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x47,
-	0x65, 0x74, 0x12, 0x1f, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4f, 0x6e,
-	0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75,
-	0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4f,
-	0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73,
-	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12,
-	0x15, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52,
-	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e,
-	0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
-	0x12, 0x4d, 0x0a, 0x0d, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6d, 0x70, 0x6f, 0x72,
-	0x74, 0x12, 0x1e, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x61,
-	0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x61, 0x0a,
+	0x09, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x6e,
+	0x74, 0x69, 0x74, 0x79, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+	0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x4b, 0x65, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x6e, 0x69,
+	0x78, 0x5f, 0x6d, 0x69, 0x6c, 0x6c, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75,
+	0x6e, 0x69, 0x78, 0x4d, 0x69, 0x6c, 0x6c, 0x69, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75,
+	0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73,
+	0x22, 0x97, 0x01, 0x0a, 0x12, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x66, 0x65, 0x61, 0x74, 0x75,
+	0x72, 0x65, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20,
+	0x03, 0x28, 0x09, 0x52, 0x10, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x46, 0x75, 0x6c, 0x6c,
+	0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x0a, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x5f,
+	0x72, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6f, 0x6f, 0x6d, 0x61,
+	0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77, 0x52, 0x09,
+	0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x6f, 0x77, 0x12, 0x1f, 0x0a, 0x0b, 0x76, 0x61, 0x6c,
+	0x75, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a,
+	0x76, 0x61, 0x6c, 0x75, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x5d, 0x0a, 0x13, 0x43, 0x68,
+	0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x03, 0x28,
+	0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x2e, 0x0a, 0x0a, 0x6a, 0x6f, 0x69,
+	0x6e, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e,
+	0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x09,
+	0x6a, 0x6f, 0x69, 0x6e, 0x65, 0x64, 0x52, 0x6f, 0x77, 0x22, 0x8d, 0x01, 0x0a, 0x0b, 0x4a, 0x6f,
+	0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x66, 0x65, 0x61,
+	0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18,
+	0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x46, 0x75,
+	0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x6e, 0x70, 0x75, 0x74,
+	0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x0d, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x46, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x12,
+	0x28, 0x0a, 0x10, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x70,
+	0x61, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x75, 0x74, 0x70, 0x75,
+	0x74, 0x46, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x22, 0x0e, 0x0a, 0x0c, 0x4a, 0x6f, 0x69,
+	0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x81, 0x01, 0x0a, 0x14, 0x43, 0x68,
+	0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6e, 0x61,
+	0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x66, 0x65, 0x61, 0x74, 0x75,
+	0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x76, 0x69, 0x73,
+	0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x72, 0x65,
+	0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69,
+	0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74,
+	0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x52, 0x0a,
+	0x15, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72,
+	0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x21,
+	0x0a, 0x03, 0x72, 0x6f, 0x77, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6f, 0x6f,
+	0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x03, 0x72, 0x6f,
+	0x77, 0x22, 0xa4, 0x01, 0x0a, 0x0d, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6e,
+	0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x66, 0x65, 0x61, 0x74,
+	0x75, 0x72, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x76, 0x69,
+	0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x72,
+	0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x69, 0x6d,
+	0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69,
+	0x74, 0x88, 0x01, 0x01, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x66,
+	0x69, 0x6c, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
+	0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x46, 0x69, 0x6c, 0x65, 0x50, 0x61, 0x74, 0x68, 0x42, 0x08,
+	0x0a, 0x06, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x10, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f,
+	0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x8a, 0x06, 0x0a, 0x08, 0x4f,
+	0x6f, 0x6d, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x46, 0x0a, 0x09, 0x4f, 0x6e, 0x6c, 0x69, 0x6e,
+	0x65, 0x47, 0x65, 0x74, 0x12, 0x1a, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
+	0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x1b, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4f, 0x6e, 0x6c, 0x69,
+	0x6e, 0x65, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
+	0x55, 0x0a, 0x0e, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x47, 0x65,
+	0x74, 0x12, 0x1f, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4f, 0x6e, 0x6c,
+	0x69, 0x6e, 0x65, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4f, 0x6e,
+	0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x15,
+	0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74,
+	0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
+	0x4d, 0x0a, 0x0d, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74,
+	0x12, 0x1e, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x61, 0x6e,
+	0x6e, 0x65, 0x6c, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x18, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x49, 0x6d, 0x70, 0x6f,
+	0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x28, 0x01, 0x12, 0x3d,
+	0x0a, 0x06, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67,
+	0x65, 0x6e, 0x74, 0x2e, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
 	0x74, 0x1a, 0x18, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x49, 0x6d, 0x70,
-	0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x28, 0x01, 0x12,
-	0x3d, 0x0a, 0x06, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x2e, 0x6f, 0x6f, 0x6d, 0x61,
-	0x67, 0x65, 0x6e, 0x74, 0x2e, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
-	0x73, 0x74, 0x1a, 0x18, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x49, 0x6d,
-	0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x37,
-	0x0a, 0x04, 0x50, 0x75, 0x73, 0x68, 0x12, 0x15, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e,
-	0x74, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e,
-	0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73,
-	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x43, 0x0a, 0x08, 0x53, 0x6e, 0x61, 0x70, 0x73,
-	0x68, 0x6f, 0x74, 0x12, 0x19, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x53,
-	0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a,
-	0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68,
-	0x6f, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x50, 0x0a, 0x0b,
-	0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6f, 0x6f,
-	0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f,
-	0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x6f, 0x6f, 0x6d, 0x61,
-	0x67, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e,
-	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x37,
-	0x0a, 0x04, 0x4a, 0x6f, 0x69, 0x6e, 0x12, 0x15, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e,
-	0x74, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e,
-	0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x73,
-	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x0d, 0x43, 0x68, 0x61, 0x6e, 0x6e,
-	0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1e, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67,
-	0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72,
-	0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67,
-	0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72,
-	0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x3d, 0x0a,
-	0x06, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x17, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65,
-	0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
-	0x1a, 0x18, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f,
-	0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x3f, 0x0a, 0x0b,
-	0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x16, 0x2e, 0x67, 0x6f,
-	0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d,
-	0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
-	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x0a, 0x5a,
-	0x08, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x67, 0x65, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
-	0x33,
+	0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x37, 0x0a,
+	0x04, 0x50, 0x75, 0x73, 0x68, 0x12, 0x15, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74,
+	0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x6f,
+	0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x50, 0x0a, 0x0b, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
+	0x6c, 0x4a, 0x6f, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74,
+	0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x43,
+	0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x37, 0x0a, 0x04, 0x4a, 0x6f, 0x69, 0x6e,
+	0x12, 0x15, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x6f, 0x69, 0x6e,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65,
+	0x6e, 0x74, 0x2e, 0x4a, 0x6f, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
+	0x00, 0x12, 0x54, 0x0a, 0x0d, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f,
+	0x72, 0x74, 0x12, 0x1e, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68,
+	0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68,
+	0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x3d, 0x0a, 0x06, 0x45, 0x78, 0x70, 0x6f, 0x72,
+	0x74, 0x12, 0x17, 0x2e, 0x6f, 0x6f, 0x6d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70,
+	0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x6f, 0x6f, 0x6d,
+	0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x3f, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68,
+	0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+	0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x0a, 0x5a, 0x08, 0x2f, 0x63, 0x6f, 0x64, 0x65,
+	0x67, 0x65, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -1671,7 +1577,7 @@ func file_oomagent_proto_rawDescGZIP() []byte {
 	return file_oomagent_proto_rawDescData
 }
 
-var file_oomagent_proto_msgTypes = make([]protoimpl.MessageInfo, 26)
+var file_oomagent_proto_msgTypes = make([]protoimpl.MessageInfo, 24)
 var file_oomagent_proto_goTypes = []interface{}{
 	(*Value)(nil),                  // 0: oomagent.Value
 	(*OnlineGetRequest)(nil),       // 1: oomagent.OnlineGetRequest
@@ -1686,29 +1592,27 @@ var file_oomagent_proto_goTypes = []interface{}{
 	(*ImportRequest)(nil),          // 10: oomagent.ImportRequest
 	(*PushResponse)(nil),           // 11: oomagent.PushResponse
 	(*PushRequest)(nil),            // 12: oomagent.PushRequest
-	(*SnapshotResponse)(nil),       // 13: oomagent.SnapshotResponse
-	(*SnapshotRequest)(nil),        // 14: oomagent.SnapshotRequest
-	(*EntityRow)(nil),              // 15: oomagent.EntityRow
-	(*ChannelJoinRequest)(nil),     // 16: oomagent.ChannelJoinRequest
-	(*ChannelJoinResponse)(nil),    // 17: oomagent.ChannelJoinResponse
-	(*JoinRequest)(nil),            // 18: oomagent.JoinRequest
-	(*JoinResponse)(nil),           // 19: oomagent.JoinResponse
-	(*ChannelExportRequest)(nil),   // 20: oomagent.ChannelExportRequest
-	(*ChannelExportResponse)(nil),  // 21: oomagent.ChannelExportResponse
-	(*ExportRequest)(nil),          // 22: oomagent.ExportRequest
-	(*ExportResponse)(nil),         // 23: oomagent.ExportResponse
-	nil,                            // 24: oomagent.FeatureValueMap.MapEntry
-	nil,                            // 25: oomagent.OnlineMultiGetResponse.ResultEntry
-	(structpb.NullValue)(0),        // 26: google.protobuf.NullValue
-	(*emptypb.Empty)(nil),          // 27: google.protobuf.Empty
+	(*EntityRow)(nil),              // 13: oomagent.EntityRow
+	(*ChannelJoinRequest)(nil),     // 14: oomagent.ChannelJoinRequest
+	(*ChannelJoinResponse)(nil),    // 15: oomagent.ChannelJoinResponse
+	(*JoinRequest)(nil),            // 16: oomagent.JoinRequest
+	(*JoinResponse)(nil),           // 17: oomagent.JoinResponse
+	(*ChannelExportRequest)(nil),   // 18: oomagent.ChannelExportRequest
+	(*ChannelExportResponse)(nil),  // 19: oomagent.ChannelExportResponse
+	(*ExportRequest)(nil),          // 20: oomagent.ExportRequest
+	(*ExportResponse)(nil),         // 21: oomagent.ExportResponse
+	nil,                            // 22: oomagent.FeatureValueMap.MapEntry
+	nil,                            // 23: oomagent.OnlineMultiGetResponse.ResultEntry
+	(structpb.NullValue)(0),        // 24: google.protobuf.NullValue
+	(*emptypb.Empty)(nil),          // 25: google.protobuf.Empty
 }
 var file_oomagent_proto_depIdxs = []int32{
-	26, // 0: oomagent.Value.null_value:type_name -> google.protobuf.NullValue
-	24, // 1: oomagent.FeatureValueMap.map:type_name -> oomagent.FeatureValueMap.MapEntry
+	24, // 0: oomagent.Value.null_value:type_name -> google.protobuf.NullValue
+	22, // 1: oomagent.FeatureValueMap.map:type_name -> oomagent.FeatureValueMap.MapEntry
 	2,  // 2: oomagent.OnlineGetResponse.result:type_name -> oomagent.FeatureValueMap
-	25, // 3: oomagent.OnlineMultiGetResponse.result:type_name -> oomagent.OnlineMultiGetResponse.ResultEntry
+	23, // 3: oomagent.OnlineMultiGetResponse.result:type_name -> oomagent.OnlineMultiGetResponse.ResultEntry
 	0,  // 4: oomagent.PushRequest.feature_values:type_name -> oomagent.Value
-	15, // 5: oomagent.ChannelJoinRequest.entity_row:type_name -> oomagent.EntityRow
+	13, // 5: oomagent.ChannelJoinRequest.entity_row:type_name -> oomagent.EntityRow
 	0,  // 6: oomagent.ChannelJoinResponse.joined_row:type_name -> oomagent.Value
 	0,  // 7: oomagent.ChannelExportResponse.row:type_name -> oomagent.Value
 	0,  // 8: oomagent.FeatureValueMap.MapEntry.value:type_name -> oomagent.Value
@@ -1719,26 +1623,24 @@ var file_oomagent_proto_depIdxs = []int32{
 	8,  // 13: oomagent.OomAgent.ChannelImport:input_type -> oomagent.ChannelImportRequest
 	10, // 14: oomagent.OomAgent.Import:input_type -> oomagent.ImportRequest
 	12, // 15: oomagent.OomAgent.Push:input_type -> oomagent.PushRequest
-	14, // 16: oomagent.OomAgent.Snapshot:input_type -> oomagent.SnapshotRequest
-	16, // 17: oomagent.OomAgent.ChannelJoin:input_type -> oomagent.ChannelJoinRequest
-	18, // 18: oomagent.OomAgent.Join:input_type -> oomagent.JoinRequest
-	20, // 19: oomagent.OomAgent.ChannelExport:input_type -> oomagent.ChannelExportRequest
-	22, // 20: oomagent.OomAgent.Export:input_type -> oomagent.ExportRequest
-	27, // 21: oomagent.OomAgent.HealthCheck:input_type -> google.protobuf.Empty
-	3,  // 22: oomagent.OomAgent.OnlineGet:output_type -> oomagent.OnlineGetResponse
-	5,  // 23: oomagent.OomAgent.OnlineMultiGet:output_type -> oomagent.OnlineMultiGetResponse
-	7,  // 24: oomagent.OomAgent.Sync:output_type -> oomagent.SyncResponse
-	9,  // 25: oomagent.OomAgent.ChannelImport:output_type -> oomagent.ImportResponse
-	9,  // 26: oomagent.OomAgent.Import:output_type -> oomagent.ImportResponse
-	11, // 27: oomagent.OomAgent.Push:output_type -> oomagent.PushResponse
-	13, // 28: oomagent.OomAgent.Snapshot:output_type -> oomagent.SnapshotResponse
-	17, // 29: oomagent.OomAgent.ChannelJoin:output_type -> oomagent.ChannelJoinResponse
-	19, // 30: oomagent.OomAgent.Join:output_type -> oomagent.JoinResponse
-	21, // 31: oomagent.OomAgent.ChannelExport:output_type -> oomagent.ChannelExportResponse
-	23, // 32: oomagent.OomAgent.Export:output_type -> oomagent.ExportResponse
-	27, // 33: oomagent.OomAgent.HealthCheck:output_type -> google.protobuf.Empty
-	22, // [22:34] is the sub-list for method output_type
-	10, // [10:22] is the sub-list for method input_type
+	14, // 16: oomagent.OomAgent.ChannelJoin:input_type -> oomagent.ChannelJoinRequest
+	16, // 17: oomagent.OomAgent.Join:input_type -> oomagent.JoinRequest
+	18, // 18: oomagent.OomAgent.ChannelExport:input_type -> oomagent.ChannelExportRequest
+	20, // 19: oomagent.OomAgent.Export:input_type -> oomagent.ExportRequest
+	25, // 20: oomagent.OomAgent.HealthCheck:input_type -> google.protobuf.Empty
+	3,  // 21: oomagent.OomAgent.OnlineGet:output_type -> oomagent.OnlineGetResponse
+	5,  // 22: oomagent.OomAgent.OnlineMultiGet:output_type -> oomagent.OnlineMultiGetResponse
+	7,  // 23: oomagent.OomAgent.Sync:output_type -> oomagent.SyncResponse
+	9,  // 24: oomagent.OomAgent.ChannelImport:output_type -> oomagent.ImportResponse
+	9,  // 25: oomagent.OomAgent.Import:output_type -> oomagent.ImportResponse
+	11, // 26: oomagent.OomAgent.Push:output_type -> oomagent.PushResponse
+	15, // 27: oomagent.OomAgent.ChannelJoin:output_type -> oomagent.ChannelJoinResponse
+	17, // 28: oomagent.OomAgent.Join:output_type -> oomagent.JoinResponse
+	19, // 29: oomagent.OomAgent.ChannelExport:output_type -> oomagent.ChannelExportResponse
+	21, // 30: oomagent.OomAgent.Export:output_type -> oomagent.ExportResponse
+	25, // 31: oomagent.OomAgent.HealthCheck:output_type -> google.protobuf.Empty
+	21, // [21:32] is the sub-list for method output_type
+	10, // [10:21] is the sub-list for method input_type
 	10, // [10:10] is the sub-list for extension type_name
 	10, // [10:10] is the sub-list for extension extendee
 	0,  // [0:10] is the sub-list for field type_name
@@ -1907,30 +1809,6 @@ func file_oomagent_proto_init() {
 			}
 		}
 		file_oomagent_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*SnapshotResponse); i {
-			case 0:
-				return &v.state
-			case 1:
-				return &v.sizeCache
-			case 2:
-				return &v.unknownFields
-			default:
-				return nil
-			}
-		}
-		file_oomagent_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*SnapshotRequest); i {
-			case 0:
-				return &v.state
-			case 1:
-				return &v.sizeCache
-			case 2:
-				return &v.unknownFields
-			default:
-				return nil
-			}
-		}
-		file_oomagent_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*EntityRow); i {
 			case 0:
 				return &v.state
@@ -1942,7 +1820,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*ChannelJoinRequest); i {
 			case 0:
 				return &v.state
@@ -1954,7 +1832,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*ChannelJoinResponse); i {
 			case 0:
 				return &v.state
@@ -1966,7 +1844,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*JoinRequest); i {
 			case 0:
 				return &v.state
@@ -1978,7 +1856,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*JoinResponse); i {
 			case 0:
 				return &v.state
@@ -1990,7 +1868,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*ChannelExportRequest); i {
 			case 0:
 				return &v.state
@@ -2002,7 +1880,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*ChannelExportResponse); i {
 			case 0:
 				return &v.state
@@ -2014,7 +1892,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*ExportRequest); i {
 			case 0:
 				return &v.state
@@ -2026,7 +1904,7 @@ func file_oomagent_proto_init() {
 				return nil
 			}
 		}
-		file_oomagent_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
+		file_oomagent_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*ExportResponse); i {
 			case 0:
 				return &v.state
@@ -2050,15 +1928,15 @@ func file_oomagent_proto_init() {
 	}
 	file_oomagent_proto_msgTypes[8].OneofWrappers = []interface{}{}
 	file_oomagent_proto_msgTypes[10].OneofWrappers = []interface{}{}
+	file_oomagent_proto_msgTypes[18].OneofWrappers = []interface{}{}
 	file_oomagent_proto_msgTypes[20].OneofWrappers = []interface{}{}
-	file_oomagent_proto_msgTypes[22].OneofWrappers = []interface{}{}
 	type x struct{}
 	out := protoimpl.TypeBuilder{
 		File: protoimpl.DescBuilder{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_oomagent_proto_rawDesc,
 			NumEnums:      0,
-			NumMessages:   26,
+			NumMessages:   24,
 			NumExtensions: 0,
 			NumServices:   1,
 		},
diff --git a/oomagent/codegen/oomagent_grpc.pb.go b/oomagent/codegen/oomagent_grpc.pb.go
index e1e0847c..a7a01b7a 100644
--- a/oomagent/codegen/oomagent_grpc.pb.go
+++ b/oomagent/codegen/oomagent_grpc.pb.go
@@ -25,7 +25,6 @@ type OomAgentClient interface {
 	ChannelImport(ctx context.Context, opts ...grpc.CallOption) (OomAgent_ChannelImportClient, error)
 	Import(ctx context.Context, in *ImportRequest, opts ...grpc.CallOption) (*ImportResponse, error)
 	Push(ctx context.Context, in *PushRequest, opts ...grpc.CallOption) (*PushResponse, error)
-	Snapshot(ctx context.Context, in *SnapshotRequest, opts ...grpc.CallOption) (*SnapshotResponse, error)
 	ChannelJoin(ctx context.Context, opts ...grpc.CallOption) (OomAgent_ChannelJoinClient, error)
 	Join(ctx context.Context, in *JoinRequest, opts ...grpc.CallOption) (*JoinResponse, error)
 	ChannelExport(ctx context.Context, in *ChannelExportRequest, opts ...grpc.CallOption) (OomAgent_ChannelExportClient, error)
@@ -120,15 +119,6 @@ func (c *oomAgentClient) Push(ctx context.Context, in *PushRequest, opts ...grpc
 	return out, nil
 }
 
-func (c *oomAgentClient) Snapshot(ctx context.Context, in *SnapshotRequest, opts ...grpc.CallOption) (*SnapshotResponse, error) {
-	out := new(SnapshotResponse)
-	err := c.cc.Invoke(ctx, "/oomagent.OomAgent/Snapshot", in, out, opts...)
-	if err != nil {
-		return nil, err
-	}
-	return out, nil
-}
-
 func (c *oomAgentClient) ChannelJoin(ctx context.Context, opts ...grpc.CallOption) (OomAgent_ChannelJoinClient, error) {
 	stream, err := c.cc.NewStream(ctx, &OomAgent_ServiceDesc.Streams[1], "/oomagent.OomAgent/ChannelJoin", opts...)
 	if err != nil {
@@ -229,7 +219,6 @@ type OomAgentServer interface {
 	ChannelImport(OomAgent_ChannelImportServer) error
 	Import(context.Context, *ImportRequest) (*ImportResponse, error)
 	Push(context.Context, *PushRequest) (*PushResponse, error)
-	Snapshot(context.Context, *SnapshotRequest) (*SnapshotResponse, error)
 	ChannelJoin(OomAgent_ChannelJoinServer) error
 	Join(context.Context, *JoinRequest) (*JoinResponse, error)
 	ChannelExport(*ChannelExportRequest, OomAgent_ChannelExportServer) error
@@ -260,9 +249,6 @@ func (UnimplementedOomAgentServer) Import(context.Context, *ImportRequest) (*Imp
 func (UnimplementedOomAgentServer) Push(context.Context, *PushRequest) (*PushResponse, error) {
 	return nil, status.Errorf(codes.Unimplemented, "method Push not implemented")
 }
-func (UnimplementedOomAgentServer) Snapshot(context.Context, *SnapshotRequest) (*SnapshotResponse, error) {
-	return nil, status.Errorf(codes.Unimplemented, "method Snapshot not implemented")
-}
 func (UnimplementedOomAgentServer) ChannelJoin(OomAgent_ChannelJoinServer) error {
 	return status.Errorf(codes.Unimplemented, "method ChannelJoin not implemented")
 }
@@ -407,24 +393,6 @@ func _OomAgent_Push_Handler(srv interface{}, ctx context.Context, dec func(inter
 	return interceptor(ctx, in, info, handler)
 }
 
-func _OomAgent_Snapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
-	in := new(SnapshotRequest)
-	if err := dec(in); err != nil {
-		return nil, err
-	}
-	if interceptor == nil {
-		return srv.(OomAgentServer).Snapshot(ctx, in)
-	}
-	info := &grpc.UnaryServerInfo{
-		Server:     srv,
-		FullMethod: "/oomagent.OomAgent/Snapshot",
-	}
-	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
-		return srv.(OomAgentServer).Snapshot(ctx, req.(*SnapshotRequest))
-	}
-	return interceptor(ctx, in, info, handler)
-}
-
 func _OomAgent_ChannelJoin_Handler(srv interface{}, stream grpc.ServerStream) error {
 	return srv.(OomAgentServer).ChannelJoin(&oomAgentChannelJoinServer{stream})
 }
@@ -553,10 +521,6 @@ var OomAgent_ServiceDesc = grpc.ServiceDesc{
 			MethodName: "Push",
 			Handler:    _OomAgent_Push_Handler,
 		},
-		{
-			MethodName: "Snapshot",
-			Handler:    _OomAgent_Snapshot_Handler,
-		},
 		{
 			MethodName: "Join",
 			Handler:    _OomAgent_Join_Handler,
diff --git a/oomagent/server.go b/oomagent/server.go
index cc3ed4da..a820ea38 100644
--- a/oomagent/server.go
+++ b/oomagent/server.go
@@ -154,13 +154,6 @@ func (s *server) Push(ctx context.Context, req *codegen.PushRequest) (*codegen.P
 	return &codegen.PushResponse{}, nil
 }
 
-func (s *server) Snapshot(ctx context.Context, re *codegen.SnapshotRequest) (*codegen.SnapshotResponse, error) {
-	if err := s.oomstore.Snapshot(ctx, re.GroupName); err != nil {
-		return nil, status.Errorf(codes.Internal, err.Error())
-	}
-	return &codegen.SnapshotResponse{}, nil
-}
-
 func (s *server) Import(ctx context.Context, req *codegen.ImportRequest) (*codegen.ImportResponse, error) {
 	var description string
 	if req.Description == nil {
@@ -285,10 +278,10 @@ func (s *server) Join(ctx context.Context, req *codegen.JoinRequest) (*codegen.J
 
 func (s *server) ChannelExport(req *codegen.ChannelExportRequest, stream codegen.OomAgent_ChannelExportServer) error {
 	ctx := context.Background()
-	exportResult, err := s.oomstore.ChannelExport(ctx, types.ChannelExportOpt{
-		FeatureFullNames: req.FeatureNames,
-		UnixMilli:        req.UnixMilli,
-		Limit:            req.Limit,
+	exportResult, err := s.oomstore.ChannelExportBatch(ctx, types.ChannelExportBatchOpt{
+		FeatureNames: req.FeatureNames,
+		RevisionID:   int(req.RevisionId),
+		Limit:        req.Limit,
 	})
 	if err != nil {
 		return internalError(err.Error())
@@ -316,11 +309,11 @@ func (s *server) ChannelExport(req *codegen.ChannelExportRequest, stream codegen
 }
 
 func (s *server) Export(ctx context.Context, req *codegen.ExportRequest) (*codegen.ExportResponse, error) {
-	err := s.oomstore.Export(ctx, types.ExportOpt{
-		FeatureFullNames: req.FeatureNames,
-		UnixMilli:        req.UnixMilli,
-		Limit:            req.Limit,
-		OutputFilePath:   req.OutputFilePath,
+	err := s.oomstore.ExportBatch(ctx, types.ExportBatchOpt{
+		FeatureNames:   req.FeatureNames,
+		RevisionID:     int(req.RevisionId),
+		Limit:          req.Limit,
+		OutputFilePath: req.OutputFilePath,
 	})
 	if err != nil {
 		return nil, internalError(err.Error())
diff --git a/oomcli/cmd/export.go b/oomcli/cmd/export.go
index 0f75cefe..b1904c59 100644
--- a/oomcli/cmd/export.go
+++ b/oomcli/cmd/export.go
@@ -8,7 +8,7 @@ import (
 )
 
 var exportOutput *string
-var exportOpt types.ChannelExportOpt
+var exportOpt types.ChannelExportBatchOpt
 
 var exportCmd = &cobra.Command{
 	Use:   "export",
@@ -35,9 +35,9 @@ func init() {
 	flags := exportCmd.Flags()
 	exportOutput = flags.StringP("output", "o", ASCIITable, "output format [csv,ascii_table]")
 
-	flags.StringSliceVar(&exportOpt.FeatureFullNames, "feature", nil, "select feature names")
+	flags.StringSliceVar(&exportOpt.FeatureNames, "feature", nil, "select feature names")
 
-	flags.Int64VarP(&exportOpt.UnixMilli, "unix-milli", "u", 0, "max unix timestamp")
+	flags.IntVarP(&exportOpt.RevisionID, "revision-id", "r", 0, "group revision id")
 	_ = exportCmd.MarkFlagRequired("revision-id")
 
 	exportOpt.Limit = flags.Uint64P("limit", "l", 0, "max records to export")
diff --git a/oomcli/cmd/export_helper.go b/oomcli/cmd/export_helper.go
index 31552c2c..b3fdf9ca 100644
--- a/oomcli/cmd/export_helper.go
+++ b/oomcli/cmd/export_helper.go
@@ -14,8 +14,8 @@ import (
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
-func export(ctx context.Context, store *oomstore.OomStore, opt types.ChannelExportOpt, output string) error {
-	exportResult, err := store.ChannelExport(ctx, opt)
+func export(ctx context.Context, store *oomstore.OomStore, opt types.ChannelExportBatchOpt, output string) error {
+	exportResult, err := store.ChannelExportBatch(ctx, opt)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/oomstore/apply.go b/pkg/oomstore/apply.go
index 0aa1392d..6024949d 100644
--- a/pkg/oomstore/apply.go
+++ b/pkg/oomstore/apply.go
@@ -2,7 +2,6 @@ package oomstore
 
 import (
 	"context"
-	"fmt"
 	"io"
 
 	"github.com/mitchellh/mapstructure"
@@ -150,8 +149,7 @@ func (s *OomStore) applyFeature(ctx context.Context, tx metadata.DBStore, newFea
 		return nil, err
 	}
 
-	featureFullName := fmt.Sprintf("%s.%s", newFeature.GroupName, newFeature.Name)
-	feature, err := tx.GetFeatureByName(ctx, featureFullName)
+	feature, err := tx.GetFeatureByName(ctx, newFeature.GroupName, newFeature.Name)
 	if err != nil {
 		if !errdefs.IsNotFound(err) {
 			return nil, err
@@ -162,7 +160,6 @@ func (s *OomStore) applyFeature(ctx context.Context, tx metadata.DBStore, newFea
 		}
 		id, err := tx.CreateFeature(ctx, metadata.CreateFeatureOpt{
 			FeatureName: newFeature.Name,
-			FullName:    featureFullName,
 			GroupID:     group.ID,
 			Description: newFeature.Description,
 			ValueType:   valueType,
diff --git a/pkg/oomstore/export.go b/pkg/oomstore/export.go
index 79e314a7..32939aab 100644
--- a/pkg/oomstore/export.go
+++ b/pkg/oomstore/export.go
@@ -7,13 +7,15 @@ import (
 
 	"github.com/spf13/cast"
 
+	"github.com/oom-ai/oomstore/internal/database/dbutil"
 	"github.com/oom-ai/oomstore/internal/database/offline"
 	"github.com/oom-ai/oomstore/pkg/errdefs"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
+	"github.com/oom-ai/oomstore/pkg/oomstore/util"
 )
 
 /*
-ChannelExport exports the latest feature value up to the given timestamp.
+ChannelExportBatch exports batch feature values of a particular revision.
 Usage Example:
 	exportResult, err := store.ExportBatch(ctx, opt)
 	if err != nil {
@@ -25,8 +27,123 @@ Usage Example:
 	// Attention: call CheckStreamError after consuming exportResult.Data channel
 	return exportResult.CheckStreamError()
 */
+func (s *OomStore) ChannelExportBatch(ctx context.Context, opt types.ChannelExportBatchOpt) (*types.ExportResult, error) {
+	revision, err := s.GetRevision(ctx, opt.RevisionID)
+	if err != nil {
+		return nil, err
+	}
+
+	var features types.FeatureList
+	if len(opt.FeatureNames) == 0 {
+		features, err = s.ListFeature(ctx, types.ListFeatureOpt{
+			GroupName: &revision.Group.Name,
+		})
+	} else {
+		fullNames := make([]string, 0, len(opt.FeatureNames))
+		for _, name := range opt.FeatureNames {
+			fullNames = append(fullNames, util.ComposeFullFeatureName(revision.Group.Name, name))
+		}
+		features, err = s.ListFeature(ctx, types.ListFeatureOpt{
+			FeatureFullNames: &fullNames,
+		})
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	entity := revision.Group.Entity
+	if entity == nil {
+		return nil, errdefs.Errorf("failed to get entity id=%d", revision.GroupID)
+	}
+
+	stream, exportErr := s.offline.ExportOneGroup(ctx, offline.ExportOneGroupOpt{
+		SnapshotTable: revision.SnapshotTable,
+		EntityName:    entity.Name,
+		Features:      features,
+		Limit:         opt.Limit,
+	})
+	header := append([]string{entity.Name}, features.Names()...)
+	return types.NewExportResult(header, stream, exportErr), nil
+}
+
+func (s *OomStore) ExportBatch(ctx context.Context, opt types.ExportBatchOpt) error {
+	exportResult, err := s.ChannelExportBatch(ctx, types.ChannelExportBatchOpt{
+		RevisionID:   opt.RevisionID,
+		FeatureNames: opt.FeatureNames,
+		Limit:        opt.Limit,
+	})
+	if err != nil {
+		return err
+	}
+
+	file, err := os.Create(opt.OutputFilePath)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+	w := csv.NewWriter(file)
+	defer w.Flush()
+
+	if err := w.Write(exportResult.Header); err != nil {
+		return err
+	}
+	for row := range exportResult.Data {
+		if err := w.Write(cast.ToStringSlice([]interface{}(row))); err != nil {
+			return err
+		}
+	}
+	return exportResult.CheckStreamError()
+}
+
+// ChannelExportStream exports the latest streaming feature values up to the given timestamp.
+// Currently, this API can only export features in one feature group.
+func (s *OomStore) ChannelExportStream(ctx context.Context, opt types.ChannelExportStreamOpt) (*types.ExportResult, error) {
+	if err := validateFullFeatureNames(opt.FeatureFullNames...); err != nil {
+		return nil, errdefs.WithStack(err)
+	}
+	features, err := s.ListFeature(ctx, types.ListFeatureOpt{
+		FeatureFullNames: &opt.FeatureFullNames,
+	})
+	if err != nil {
+		return nil, err
+	}
+	if len(features.GroupIDs()) != 1 {
+		return nil, errdefs.Errorf("expected 1 group, got %d groups", len(features.GroupIDs()))
+	}
+	group := features[0].Group
+	revisions, err := s.ListRevision(ctx, &group.ID)
+	if err != nil {
+		return nil, err
+	}
+	revision := revisions.Before(opt.UnixMilli)
+	if revision == nil {
+		return nil, errdefs.Errorf("no feature values up to %d, use a later timestamp", opt.UnixMilli)
+	}
+	if revision.SnapshotTable == "" {
+		if err = s.Snapshot(ctx, group.Name); err != nil {
+			return nil, errdefs.WithStack(err)
+		}
+	}
+
+	snapshotTable := dbutil.OfflineStreamSnapshotTableName(group.ID, revision.Revision)
+	cdcTable := dbutil.OfflineStreamCdcTableName(group.ID, revision.Revision)
+	stream, exportErr := s.offline.ExportOneGroup(ctx, offline.ExportOneGroupOpt{
+		SnapshotTable: snapshotTable,
+		CdcTable:      &cdcTable,
+		UnixMilli:     &opt.UnixMilli,
+		EntityName:    group.Entity.Name,
+		Features:      features,
+		Limit:         opt.Limit,
+	})
+
+	header := append([]string{group.Entity.Name}, features.Names()...)
+	return types.NewExportResult(header, stream, exportErr), nil
+}
+
+// ChannelExport exports the latest streaming feature values up to the given timestamp.
+// Currently, this API can only export features in one feature group.
 func (s *OomStore) ChannelExport(ctx context.Context, opt types.ChannelExportOpt) (*types.ExportResult, error) {
-	if err := validateFeatureFullNames(opt.FeatureFullNames); err != nil {
+	if err := validateFullFeatureNames(opt.FeatureFullNames...); err != nil {
 		return nil, err
 	}
 	features, err := s.ListFeature(ctx, types.ListFeatureOpt{
@@ -64,7 +181,7 @@ func (s *OomStore) ChannelExport(ctx context.Context, opt types.ChannelExportOpt
 		}
 	}
 
-	result, err := s.offline.Export(ctx, offline.ExportOpt{
+	stream, exportErr := s.offline.Export(ctx, offline.ExportOpt{
 		SnapshotTables: snapshotTables,
 		CdcTables:      cdcTables,
 		Features:       featureMap,
@@ -72,41 +189,9 @@ func (s *OomStore) ChannelExport(ctx context.Context, opt types.ChannelExportOpt
 		EntityName:     features[0].Group.Entity.Name,
 		Limit:          opt.Limit,
 	})
-	if err != nil {
-		return nil, err
-	}
-	return result, nil
-}
-
-// Export exports the latest feature value up to the given timestamp, it outputs
-// feature values to the given file path.
-func (s *OomStore) Export(ctx context.Context, opt types.ExportOpt) error {
-	exportResult, err := s.ChannelExport(ctx, types.ChannelExportOpt{
-		FeatureFullNames: opt.FeatureFullNames,
-		UnixMilli:        opt.UnixMilli,
-		Limit:            opt.Limit,
-	})
-	if err != nil {
-		return err
-	}
 
-	file, err := os.Create(opt.OutputFilePath)
-	if err != nil {
-		return err
-	}
-	defer file.Close()
-	w := csv.NewWriter(file)
-	defer w.Flush()
-
-	if err := w.Write(exportResult.Header); err != nil {
-		return err
-	}
-	for row := range exportResult.Data {
-		if err := w.Write(cast.ToStringSlice([]interface{}(row))); err != nil {
-			return err
-		}
-	}
-	return exportResult.CheckStreamError()
+	header := append([]string{features[0].Group.Entity.Name}, features.Names()...)
+	return types.NewExportResult(header, stream, exportErr), nil
 }
 
 // key: group_Id, value: slice of features
diff --git a/pkg/oomstore/feature.go b/pkg/oomstore/feature.go
index d936a163..7181d330 100644
--- a/pkg/oomstore/feature.go
+++ b/pkg/oomstore/feature.go
@@ -2,7 +2,6 @@ package oomstore
 
 import (
 	"context"
-	"fmt"
 	"strings"
 
 	"github.com/oom-ai/oomstore/pkg/errdefs"
@@ -10,10 +9,7 @@ import (
 	"github.com/oom-ai/oomstore/internal/database/metadata"
 	"github.com/oom-ai/oomstore/internal/database/online"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
-)
-
-const (
-	FeatureFullNameSeparator = "."
+	"github.com/oom-ai/oomstore/pkg/oomstore/util"
 )
 
 // Get metadata of a feature by ID.
@@ -22,20 +18,22 @@ func (s *OomStore) GetFeature(ctx context.Context, id int) (*types.Feature, erro
 }
 
 // Get metadata of a feature by full name.
-func (s *OomStore) GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error) {
-	return s.metadata.GetFeatureByName(ctx, fullName)
+func (s *OomStore) GetFeatureByFullName(ctx context.Context, fullName string) (*types.Feature, error) {
+	groupName, featureName, err := util.SplitFullFeatureName(fullName)
+	if err != nil {
+		return nil, err
+	}
+	return s.GetFeatureByName(ctx, groupName, featureName)
+}
+
+// Get metadata of a feature by group name and feature name.
+func (s *OomStore) GetFeatureByName(ctx context.Context, groupName string, featureName string) (*types.Feature, error) {
+	return s.metadata.GetFeatureByName(ctx, groupName, featureName)
 }
 
 // List metadata of features meeting particular criteria.
 func (s *OomStore) ListFeature(ctx context.Context, opt types.ListFeatureOpt) (types.FeatureList, error) {
-	if opt.FeatureFullNames != nil {
-		if err := validateFeatureFullNames(*opt.FeatureFullNames); err != nil {
-			return nil, err
-		}
-	}
-	metadataOpt := metadata.ListFeatureOpt{
-		FeatureFullNames: opt.FeatureFullNames,
-	}
+	metadataOpt := metadata.ListFeatureOpt{}
 	if opt.EntityName != nil {
 		entity, err := s.metadata.GetEntityByName(ctx, *opt.EntityName)
 		if err != nil {
@@ -50,15 +48,22 @@ func (s *OomStore) ListFeature(ctx context.Context, opt types.ListFeatureOpt) (t
 		}
 		metadataOpt.GroupID = &group.ID
 	}
-	return s.metadata.ListFeature(ctx, metadataOpt)
+	features, err := s.metadata.ListFeature(ctx, metadataOpt)
+	if err != nil {
+		return nil, err
+	}
+	if opt.FeatureFullNames != nil {
+		features = features.FilterFullnames(*opt.FeatureFullNames)
+	}
+	return features, nil
 }
 
 // Update metadata of a feature.
 func (s *OomStore) UpdateFeature(ctx context.Context, opt types.UpdateFeatureOpt) error {
-	if err := validateFeatureFullNames([]string{opt.FeatureFullName}); err != nil {
+	if err := validateFullFeatureNames(opt.FeatureFullName); err != nil {
 		return err
 	}
-	feature, err := s.metadata.GetFeatureByName(ctx, opt.FeatureFullName)
+	feature, err := s.GetFeatureByFullName(ctx, opt.FeatureFullName)
 	if err != nil {
 		return err
 	}
@@ -77,7 +82,6 @@ func (s *OomStore) CreateFeature(ctx context.Context, opt types.CreateFeatureOpt
 
 	id, err := s.metadata.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: opt.FeatureName,
-		FullName:    fmt.Sprintf("%s.%s", group.Name, opt.FeatureName),
 		GroupID:     group.ID,
 		ValueType:   opt.ValueType,
 		Description: opt.Description,
@@ -103,11 +107,11 @@ func (s *OomStore) CreateFeature(ctx context.Context, opt types.CreateFeatureOpt
 	return id, nil
 }
 
-func validateFeatureFullNames(names []string) error {
-	for _, name := range names {
-		nameSlice := strings.Split(name, FeatureFullNameSeparator)
+func validateFullFeatureNames(fullnames ...string) error {
+	for _, fullname := range fullnames {
+		nameSlice := strings.Split(fullname, util.SepFullFeatureName)
 		if len(nameSlice) != 2 {
-			return errdefs.Errorf("invalid feature full name %s", name)
+			return errdefs.Errorf("invalid full feature name: '%s'", fullname)
 		}
 	}
 	return nil
diff --git a/pkg/oomstore/join.go b/pkg/oomstore/join.go
index 8ec5d4e5..9a3804b0 100644
--- a/pkg/oomstore/join.go
+++ b/pkg/oomstore/join.go
@@ -11,7 +11,6 @@ import (
 
 	"github.com/spf13/cast"
 
-	"github.com/oom-ai/oomstore/internal/database/metadata"
 	"github.com/oom-ai/oomstore/internal/database/offline"
 	"github.com/oom-ai/oomstore/pkg/errdefs"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
@@ -20,7 +19,7 @@ import (
 // ChannelJoin gets point-in-time correct feature values for each entity row.
 // Currently, this API only supports batch features.
 func (s *OomStore) ChannelJoin(ctx context.Context, opt types.ChannelJoinOpt) (*types.JoinResult, error) {
-	if err := validateFeatureFullNames(opt.FeatureFullNames); err != nil {
+	if err := validateFullFeatureNames(opt.FeatureFullNames...); err != nil {
 		return nil, err
 	}
 	data := make(chan []interface{})
@@ -29,7 +28,7 @@ func (s *OomStore) ChannelJoin(ctx context.Context, opt types.ChannelJoinOpt) (*
 	emptyResult := &types.JoinResult{
 		Data: data,
 	}
-	features, err := s.metadata.ListFeature(ctx, metadata.ListFeatureOpt{
+	features, err := s.ListFeature(ctx, types.ListFeatureOpt{
 		FeatureFullNames: &opt.FeatureFullNames,
 	})
 	if err != nil {
@@ -75,7 +74,7 @@ func (s *OomStore) ChannelJoin(ctx context.Context, opt types.ChannelJoinOpt) (*
 // Input File should contain header, the first two columns of Input File should be
 // entity_key, unix_milli, then followed by other real-time feature values.
 func (s *OomStore) Join(ctx context.Context, opt types.JoinOpt) error {
-	if err := validateFeatureFullNames(opt.FeatureFullNames); err != nil {
+	if err := validateFullFeatureNames(opt.FeatureFullNames...); err != nil {
 		return err
 	}
 	entityRows, header, err := GetEntityRowsFromInputFile(opt.InputFilePath)
diff --git a/pkg/oomstore/online_query.go b/pkg/oomstore/online_query.go
index 9929703a..28b1e994 100644
--- a/pkg/oomstore/online_query.go
+++ b/pkg/oomstore/online_query.go
@@ -6,14 +6,13 @@ import (
 	"github.com/oom-ai/oomstore/pkg/errdefs"
 
 	"github.com/oom-ai/oomstore/internal/database/dbutil"
-	"github.com/oom-ai/oomstore/internal/database/metadata"
 	"github.com/oom-ai/oomstore/internal/database/online"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
 // OnlineGet gets online features of a particular entity instance.
 func (s *OomStore) OnlineGet(ctx context.Context, opt types.OnlineGetOpt) (*types.FeatureValues, error) {
-	if err := validateFeatureFullNames(opt.FeatureFullNames); err != nil {
+	if err := validateFullFeatureNames(opt.FeatureFullNames...); err != nil {
 		return nil, err
 	}
 	rs := types.FeatureValues{
@@ -21,9 +20,7 @@ func (s *OomStore) OnlineGet(ctx context.Context, opt types.OnlineGetOpt) (*type
 		FeatureFullNames: opt.FeatureFullNames,
 		FeatureValueMap:  make(map[string]interface{}),
 	}
-	features := s.metadata.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &opt.FeatureFullNames,
-	})
+	features := s.metadata.ListCachedFeature(ctx, &opt.FeatureFullNames)
 	if len(features) == 0 {
 		return &rs, nil
 	}
@@ -61,13 +58,11 @@ func (s *OomStore) OnlineGet(ctx context.Context, opt types.OnlineGetOpt) (*type
 
 // OnlineMultiGet gets online features of multiple entity instances.
 func (s *OomStore) OnlineMultiGet(ctx context.Context, opt types.OnlineMultiGetOpt) (map[string]*types.FeatureValues, error) {
-	if err := validateFeatureFullNames(opt.FeatureFullNames); err != nil {
+	if err := validateFullFeatureNames(opt.FeatureFullNames...); err != nil {
 		return nil, err
 	}
 	result := make(map[string]*types.FeatureValues)
-	features := s.metadata.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &opt.FeatureFullNames,
-	})
+	features := s.metadata.ListCachedFeature(ctx, &opt.FeatureFullNames)
 	if len(features) == 0 {
 		return result, nil
 	}
diff --git a/pkg/oomstore/sync.go b/pkg/oomstore/sync.go
index 9e7f2ade..e888d1a0 100644
--- a/pkg/oomstore/sync.go
+++ b/pkg/oomstore/sync.go
@@ -40,21 +40,18 @@ func (s *OomStore) Sync(ctx context.Context, opt types.SyncOpt) error {
 	}
 
 	// Move data from offline to online store
-	exportResult, err := s.offline.Export(ctx, offline.ExportOpt{
-		SnapshotTables: map[int]string{group.ID: revision.SnapshotTable},
-		Features:       map[int]types.FeatureList{group.ID: features},
-		EntityName:     group.Entity.Name,
+	exportStream, exportError := s.offline.ExportOneGroup(ctx, offline.ExportOneGroupOpt{
+		SnapshotTable: revision.SnapshotTable,
+		EntityName:    group.Entity.Name,
+		Features:      features,
 	})
-	if err != nil {
-		return err
-	}
 
 	if err = s.online.Import(ctx, online.ImportOpt{
 		Features:     features,
 		Revision:     revision,
 		Entity:       group.Entity,
-		ExportStream: exportResult.Data,
-		ExportError:  exportResult.GetErrorChannel(),
+		ExportStream: exportStream,
+		ExportError:  exportError,
 	}); err != nil {
 		return err
 	}
diff --git a/pkg/oomstore/types/feature.go b/pkg/oomstore/types/feature.go
index 4aff841c..599c6d29 100644
--- a/pkg/oomstore/types/feature.go
+++ b/pkg/oomstore/types/feature.go
@@ -2,12 +2,13 @@ package types
 
 import (
 	"time"
+
+	"github.com/oom-ai/oomstore/pkg/oomstore/util"
 )
 
 type Feature struct {
 	ID        int       `db:"id"`
 	Name      string    `db:"name"`
-	FullName  string    `db:"full_name"`
 	ValueType ValueType `db:"value_type"`
 
 	Description string    `db:"description"`
@@ -18,6 +19,13 @@ type Feature struct {
 	Group   *Group
 }
 
+func (f *Feature) FullName() string {
+	if f.Group == nil {
+		panic("expected group to be not nil")
+	}
+	return util.ComposeFullFeatureName(f.Group.Name, f.Name)
+}
+
 func (f *Feature) Copy() *Feature {
 	if f == nil {
 		return nil
@@ -65,7 +73,7 @@ func (l *FeatureList) Names() (names []string) {
 
 func (l *FeatureList) FullNames() (fullNames []string) {
 	for _, f := range *l {
-		fullNames = append(fullNames, f.FullName)
+		fullNames = append(fullNames, f.FullName())
 	}
 	return
 }
@@ -86,6 +94,17 @@ func (l FeatureList) Filter(filter func(*Feature) bool) (rs FeatureList) {
 	return
 }
 
+func (l FeatureList) FilterFullnames(fullnames []string) (rs FeatureList) {
+	return l.Filter(func(f *Feature) bool {
+		for _, fullname := range fullnames {
+			if f.FullName() == fullname {
+				return true
+			}
+		}
+		return false
+	})
+}
+
 func (l FeatureList) Find(find func(*Feature) bool) *Feature {
 	for _, f := range l {
 		if find(f) {
diff --git a/pkg/oomstore/types/options.go b/pkg/oomstore/types/options.go
index 160ca927..ad048882 100644
--- a/pkg/oomstore/types/options.go
+++ b/pkg/oomstore/types/options.go
@@ -36,11 +36,23 @@ type ChannelExportOpt struct {
 	Limit            *uint64
 }
 
-type ExportOpt struct {
-	FeatureFullNames []string
+type ChannelExportBatchOpt struct {
+	RevisionID   int
+	FeatureNames []string
+	Limit        *uint64
+}
+
+type ChannelExportStreamOpt struct {
 	UnixMilli        int64
+	FeatureFullNames []string
 	Limit            *uint64
-	OutputFilePath   string
+}
+
+type ExportBatchOpt struct {
+	RevisionID     int
+	FeatureNames   []string
+	Limit          *uint64
+	OutputFilePath string
 }
 
 type OnlineGetOpt struct {
diff --git a/pkg/oomstore/types/revision.go b/pkg/oomstore/types/revision.go
index 32394807..43ca574b 100644
--- a/pkg/oomstore/types/revision.go
+++ b/pkg/oomstore/types/revision.go
@@ -74,14 +74,11 @@ func (l RevisionList) Before(unixMilli int64) *Revision {
 		return nil
 	}
 	var i int
-	for i = 1; i < len(l); i++ {
+	for i = range l {
 		if l[i].Revision > unixMilli {
 			break
 		}
 	}
-	if i == 0 {
-		return l[0]
-	}
 	return l[i-1]
 }
 
diff --git a/pkg/oomstore/types/types.go b/pkg/oomstore/types/types.go
index c116caf1..57a4e946 100644
--- a/pkg/oomstore/types/types.go
+++ b/pkg/oomstore/types/types.go
@@ -54,10 +54,6 @@ func (e *ExportResult) CheckStreamError() error {
 	return nil
 }
 
-func (e *ExportResult) GetErrorChannel() <-chan error {
-	return e.error
-}
-
 type DataTableSchema struct {
 	Fields []DataTableFieldSchema
 }
diff --git a/pkg/oomstore/util/feature.go b/pkg/oomstore/util/feature.go
new file mode 100644
index 00000000..1d87bdd4
--- /dev/null
+++ b/pkg/oomstore/util/feature.go
@@ -0,0 +1,31 @@
+package util
+
+import (
+	"strings"
+
+	"github.com/oom-ai/oomstore/pkg/errdefs"
+)
+
+const SepFullFeatureName = "."
+
+func ComposeFullFeatureName(groupName string, featureName string) string {
+	return groupName + SepFullFeatureName + featureName
+}
+
+func SplitFullFeatureName(fullname string) (string, string, error) {
+	parts := strings.SplitN(fullname, SepFullFeatureName, 2)
+	if len(parts) != 2 {
+		return "", "", errdefs.Errorf("invalid full feature name: '%s'", fullname)
+	}
+	return parts[0], parts[1], nil
+}
+
+func ValidateFullFeatureNames(fullnames ...string) error {
+	for _, fullname := range fullnames {
+		nameSlice := strings.Split(fullname, SepFullFeatureName)
+		if len(nameSlice) != 2 {
+			return errdefs.Errorf("invalid full feature name: '%s'", fullname)
+		}
+	}
+	return nil
+}
diff --git a/proto/oomagent.proto b/proto/oomagent.proto
index 6def7b89..9c723aee 100644
--- a/proto/oomagent.proto
+++ b/proto/oomagent.proto
@@ -13,7 +13,6 @@ service OomAgent {
   rpc ChannelImport(stream ChannelImportRequest) returns (ImportResponse) {}
   rpc Import(ImportRequest) returns (ImportResponse) {}
   rpc Push(PushRequest) returns (PushResponse) {}
-  rpc Snapshot(SnapshotRequest) returns (SnapshotResponse) {}
 
   rpc ChannelJoin(stream ChannelJoinRequest) returns (stream ChannelJoinResponse) {}
   rpc Join(JoinRequest) returns (JoinResponse) {}
@@ -105,12 +104,6 @@ message PushRequest {
   repeated Value feature_values = 4;
 }
 
-message SnapshotResponse {}
-
-message SnapshotRequest {
-  string group_name = 1;
-}
-
 message EntityRow {
   string entity_key = 1;
   int64 unix_milli = 2;
@@ -138,7 +131,7 @@ message JoinResponse {}
 
 message ChannelExportRequest {
   repeated string feature_names = 1;
-  int64 unix_milli = 2;
+  int32 revision_id = 2;
   optional uint64 limit = 3;
 }
 
@@ -149,7 +142,7 @@ message ChannelExportResponse {
 
 message ExportRequest {
   repeated string feature_names = 1;
-  int64 unix_milli = 2;
+  int32 revision_id = 2;
   optional uint64 limit = 3;
   string output_file_path = 4;
 }
diff --git a/sdk/python/src/oomstore/codegen/oomagent_pb2.py b/sdk/python/src/oomstore/codegen/oomagent_pb2.py
index 7687628a..7442244c 100644
--- a/sdk/python/src/oomstore/codegen/oomagent_pb2.py
+++ b/sdk/python/src/oomstore/codegen/oomagent_pb2.py
@@ -21,7 +21,7 @@ DESCRIPTOR = _descriptor.FileDescriptor(
   syntax='proto3',
   serialized_options=b'Z\010/codegen',
   create_key=_descriptor._internal_create_key,
-  serialized_pb=b'\n\x0eoomagent.proto\x12\x08oomagent\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xd1\x01\n\x05Value\x12\x30\n\nnull_value\x18\x01 \x01(\x0e\x32\x1a.google.protobuf.NullValueH\x00\x12\x15\n\x0bint64_value\x18\x02 \x01(\x03H\x00\x12\x16\n\x0c\x64ouble_value\x18\x03 \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x14\n\nbool_value\x18\x05 \x01(\x08H\x00\x12\x1a\n\x10unix_milli_value\x18\x06 \x01(\x03H\x00\x12\x15\n\x0b\x62ytes_value\x18\x07 \x01(\x0cH\x00\x42\x06\n\x04kind\"B\n\x10OnlineGetRequest\x12\x12\n\nentity_key\x18\x01 \x01(\t\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x02 \x03(\t\"\x7f\n\x0f\x46\x65\x61tureValueMap\x12/\n\x03map\x18\x01 \x03(\x0b\x32\".oomagent.FeatureValueMap.MapEntry\x1a;\n\x08MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1e\n\x05value\x18\x02 \x01(\x0b\x32\x0f.oomagent.Value:\x02\x38\x01\">\n\x11OnlineGetResponse\x12)\n\x06result\x18\x01 \x01(\x0b\x32\x19.oomagent.FeatureValueMap\"H\n\x15OnlineMultiGetRequest\x12\x13\n\x0b\x65ntity_keys\x18\x01 \x03(\t\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x02 \x03(\t\"\xa0\x01\n\x16OnlineMultiGetResponse\x12<\n\x06result\x18\x01 \x03(\x0b\x32,.oomagent.OnlineMultiGetResponse.ResultEntry\x1aH\n\x0bResultEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.oomagent.FeatureValueMap:\x02\x38\x01\"7\n\x0bSyncRequest\x12\x13\n\x0brevision_id\x18\x01 \x01(\x05\x12\x13\n\x0bpurge_delay\x18\x02 \x01(\x05\"\x0e\n\x0cSyncResponse\"\x99\x01\n\x14\x43hannelImportRequest\x12\x17\n\ngroup_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08revision\x18\x03 \x01(\x03H\x02\x88\x01\x01\x12\x0b\n\x03row\x18\x04 \x01(\x0c\x42\r\n\x0b_group_nameB\x0e\n\x0c_descriptionB\x0b\n\t_revision\"%\n\x0eImportResponse\x12\x13\n\x0brevision_id\x18\x01 \x01(\x05\"\xb0\x01\n\rImportRequest\x12\x12\n\ngroup_name\x18\x01 \x01(\t\x12\x15\n\x08revision\x18\x02 \x01(\x03H\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x17\n\x0finput_file_path\x18\x04 \x01(\t\x12\x16\n\tdelimiter\x18\x05 \x01(\tH\x02\x88\x01\x01\x42\x0b\n\t_revisionB\x0e\n\x0c_descriptionB\x0c\n\n_delimiter\"\x0e\n\x0cPushResponse\"u\n\x0bPushRequest\x12\x12\n\nentity_key\x18\x01 \x01(\t\x12\x12\n\ngroup_name\x18\x02 \x01(\t\x12\x15\n\rfeature_names\x18\x03 \x03(\t\x12\'\n\x0e\x66\x65\x61ture_values\x18\x04 \x03(\x0b\x32\x0f.oomagent.Value\"\x12\n\x10SnapshotResponse\"%\n\x0fSnapshotRequest\x12\x12\n\ngroup_name\x18\x01 \x01(\t\"C\n\tEntityRow\x12\x12\n\nentity_key\x18\x01 \x01(\t\x12\x12\n\nunix_milli\x18\x02 \x01(\x03\x12\x0e\n\x06values\x18\x03 \x03(\t\"n\n\x12\x43hannelJoinRequest\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x01 \x03(\t\x12\'\n\nentity_row\x18\x02 \x01(\x0b\x32\x13.oomagent.EntityRow\x12\x13\n\x0bvalue_names\x18\x03 \x03(\t\"J\n\x13\x43hannelJoinResponse\x12\x0e\n\x06header\x18\x01 \x03(\t\x12#\n\njoined_row\x18\x02 \x03(\x0b\x32\x0f.oomagent.Value\"\\\n\x0bJoinRequest\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x01 \x03(\t\x12\x17\n\x0finput_file_path\x18\x02 \x01(\t\x12\x18\n\x10output_file_path\x18\x03 \x01(\t\"\x0e\n\x0cJoinResponse\"_\n\x14\x43hannelExportRequest\x12\x15\n\rfeature_names\x18\x01 \x03(\t\x12\x12\n\nunix_milli\x18\x02 \x01(\x03\x12\x12\n\x05limit\x18\x03 \x01(\x04H\x00\x88\x01\x01\x42\x08\n\x06_limit\"E\n\x15\x43hannelExportResponse\x12\x0e\n\x06header\x18\x01 \x03(\t\x12\x1c\n\x03row\x18\x02 \x03(\x0b\x32\x0f.oomagent.Value\"r\n\rExportRequest\x12\x15\n\rfeature_names\x18\x01 \x03(\t\x12\x12\n\nunix_milli\x18\x02 \x01(\x03\x12\x12\n\x05limit\x18\x03 \x01(\x04H\x00\x88\x01\x01\x12\x18\n\x10output_file_path\x18\x04 \x01(\tB\x08\n\x06_limit\"\x10\n\x0e\x45xportResponse2\xcf\x06\n\x08OomAgent\x12\x46\n\tOnlineGet\x12\x1a.oomagent.OnlineGetRequest\x1a\x1b.oomagent.OnlineGetResponse\"\x00\x12U\n\x0eOnlineMultiGet\x12\x1f.oomagent.OnlineMultiGetRequest\x1a .oomagent.OnlineMultiGetResponse\"\x00\x12\x37\n\x04Sync\x12\x15.oomagent.SyncRequest\x1a\x16.oomagent.SyncResponse\"\x00\x12M\n\rChannelImport\x12\x1e.oomagent.ChannelImportRequest\x1a\x18.oomagent.ImportResponse\"\x00(\x01\x12=\n\x06Import\x12\x17.oomagent.ImportRequest\x1a\x18.oomagent.ImportResponse\"\x00\x12\x37\n\x04Push\x12\x15.oomagent.PushRequest\x1a\x16.oomagent.PushResponse\"\x00\x12\x43\n\x08Snapshot\x12\x19.oomagent.SnapshotRequest\x1a\x1a.oomagent.SnapshotResponse\"\x00\x12P\n\x0b\x43hannelJoin\x12\x1c.oomagent.ChannelJoinRequest\x1a\x1d.oomagent.ChannelJoinResponse\"\x00(\x01\x30\x01\x12\x37\n\x04Join\x12\x15.oomagent.JoinRequest\x1a\x16.oomagent.JoinResponse\"\x00\x12T\n\rChannelExport\x12\x1e.oomagent.ChannelExportRequest\x1a\x1f.oomagent.ChannelExportResponse\"\x00\x30\x01\x12=\n\x06\x45xport\x12\x17.oomagent.ExportRequest\x1a\x18.oomagent.ExportResponse\"\x00\x12?\n\x0bHealthCheck\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x42\nZ\x08/codegenb\x06proto3'
+  serialized_pb=b'\n\x0eoomagent.proto\x12\x08oomagent\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xd1\x01\n\x05Value\x12\x30\n\nnull_value\x18\x01 \x01(\x0e\x32\x1a.google.protobuf.NullValueH\x00\x12\x15\n\x0bint64_value\x18\x02 \x01(\x03H\x00\x12\x16\n\x0c\x64ouble_value\x18\x03 \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x04 \x01(\tH\x00\x12\x14\n\nbool_value\x18\x05 \x01(\x08H\x00\x12\x1a\n\x10unix_milli_value\x18\x06 \x01(\x03H\x00\x12\x15\n\x0b\x62ytes_value\x18\x07 \x01(\x0cH\x00\x42\x06\n\x04kind\"B\n\x10OnlineGetRequest\x12\x12\n\nentity_key\x18\x01 \x01(\t\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x02 \x03(\t\"\x7f\n\x0f\x46\x65\x61tureValueMap\x12/\n\x03map\x18\x01 \x03(\x0b\x32\".oomagent.FeatureValueMap.MapEntry\x1a;\n\x08MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1e\n\x05value\x18\x02 \x01(\x0b\x32\x0f.oomagent.Value:\x02\x38\x01\">\n\x11OnlineGetResponse\x12)\n\x06result\x18\x01 \x01(\x0b\x32\x19.oomagent.FeatureValueMap\"H\n\x15OnlineMultiGetRequest\x12\x13\n\x0b\x65ntity_keys\x18\x01 \x03(\t\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x02 \x03(\t\"\xa0\x01\n\x16OnlineMultiGetResponse\x12<\n\x06result\x18\x01 \x03(\x0b\x32,.oomagent.OnlineMultiGetResponse.ResultEntry\x1aH\n\x0bResultEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.oomagent.FeatureValueMap:\x02\x38\x01\"7\n\x0bSyncRequest\x12\x13\n\x0brevision_id\x18\x01 \x01(\x05\x12\x13\n\x0bpurge_delay\x18\x02 \x01(\x05\"\x0e\n\x0cSyncResponse\"\x99\x01\n\x14\x43hannelImportRequest\x12\x17\n\ngroup_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08revision\x18\x03 \x01(\x03H\x02\x88\x01\x01\x12\x0b\n\x03row\x18\x04 \x01(\x0c\x42\r\n\x0b_group_nameB\x0e\n\x0c_descriptionB\x0b\n\t_revision\"%\n\x0eImportResponse\x12\x13\n\x0brevision_id\x18\x01 \x01(\x05\"\xb0\x01\n\rImportRequest\x12\x12\n\ngroup_name\x18\x01 \x01(\t\x12\x15\n\x08revision\x18\x02 \x01(\x03H\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x17\n\x0finput_file_path\x18\x04 \x01(\t\x12\x16\n\tdelimiter\x18\x05 \x01(\tH\x02\x88\x01\x01\x42\x0b\n\t_revisionB\x0e\n\x0c_descriptionB\x0c\n\n_delimiter\"\x0e\n\x0cPushResponse\"u\n\x0bPushRequest\x12\x12\n\nentity_key\x18\x01 \x01(\t\x12\x12\n\ngroup_name\x18\x02 \x01(\t\x12\x15\n\rfeature_names\x18\x03 \x03(\t\x12\'\n\x0e\x66\x65\x61ture_values\x18\x04 \x03(\x0b\x32\x0f.oomagent.Value\"C\n\tEntityRow\x12\x12\n\nentity_key\x18\x01 \x01(\t\x12\x12\n\nunix_milli\x18\x02 \x01(\x03\x12\x0e\n\x06values\x18\x03 \x03(\t\"n\n\x12\x43hannelJoinRequest\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x01 \x03(\t\x12\'\n\nentity_row\x18\x02 \x01(\x0b\x32\x13.oomagent.EntityRow\x12\x13\n\x0bvalue_names\x18\x03 \x03(\t\"J\n\x13\x43hannelJoinResponse\x12\x0e\n\x06header\x18\x01 \x03(\t\x12#\n\njoined_row\x18\x02 \x03(\x0b\x32\x0f.oomagent.Value\"\\\n\x0bJoinRequest\x12\x1a\n\x12\x66\x65\x61ture_full_names\x18\x01 \x03(\t\x12\x17\n\x0finput_file_path\x18\x02 \x01(\t\x12\x18\n\x10output_file_path\x18\x03 \x01(\t\"\x0e\n\x0cJoinResponse\"`\n\x14\x43hannelExportRequest\x12\x15\n\rfeature_names\x18\x01 \x03(\t\x12\x13\n\x0brevision_id\x18\x02 \x01(\x05\x12\x12\n\x05limit\x18\x03 \x01(\x04H\x00\x88\x01\x01\x42\x08\n\x06_limit\"E\n\x15\x43hannelExportResponse\x12\x0e\n\x06header\x18\x01 \x03(\t\x12\x1c\n\x03row\x18\x02 \x03(\x0b\x32\x0f.oomagent.Value\"s\n\rExportRequest\x12\x15\n\rfeature_names\x18\x01 \x03(\t\x12\x13\n\x0brevision_id\x18\x02 \x01(\x05\x12\x12\n\x05limit\x18\x03 \x01(\x04H\x00\x88\x01\x01\x12\x18\n\x10output_file_path\x18\x04 \x01(\tB\x08\n\x06_limit\"\x10\n\x0e\x45xportResponse2\x8a\x06\n\x08OomAgent\x12\x46\n\tOnlineGet\x12\x1a.oomagent.OnlineGetRequest\x1a\x1b.oomagent.OnlineGetResponse\"\x00\x12U\n\x0eOnlineMultiGet\x12\x1f.oomagent.OnlineMultiGetRequest\x1a .oomagent.OnlineMultiGetResponse\"\x00\x12\x37\n\x04Sync\x12\x15.oomagent.SyncRequest\x1a\x16.oomagent.SyncResponse\"\x00\x12M\n\rChannelImport\x12\x1e.oomagent.ChannelImportRequest\x1a\x18.oomagent.ImportResponse\"\x00(\x01\x12=\n\x06Import\x12\x17.oomagent.ImportRequest\x1a\x18.oomagent.ImportResponse\"\x00\x12\x37\n\x04Push\x12\x15.oomagent.PushRequest\x1a\x16.oomagent.PushResponse\"\x00\x12P\n\x0b\x43hannelJoin\x12\x1c.oomagent.ChannelJoinRequest\x1a\x1d.oomagent.ChannelJoinResponse\"\x00(\x01\x30\x01\x12\x37\n\x04Join\x12\x15.oomagent.JoinRequest\x1a\x16.oomagent.JoinResponse\"\x00\x12T\n\rChannelExport\x12\x1e.oomagent.ChannelExportRequest\x1a\x1f.oomagent.ChannelExportResponse\"\x00\x30\x01\x12=\n\x06\x45xport\x12\x17.oomagent.ExportRequest\x1a\x18.oomagent.ExportResponse\"\x00\x12?\n\x0bHealthCheck\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x42\nZ\x08/codegenb\x06proto3'
   ,
   dependencies=[google_dot_protobuf_dot_empty__pb2.DESCRIPTOR,google_dot_protobuf_dot_struct__pb2.DESCRIPTOR,])
 
@@ -674,63 +674,6 @@ _PUSHREQUEST = _descriptor.Descriptor(
 )
 
 
-_SNAPSHOTRESPONSE = _descriptor.Descriptor(
-  name='SnapshotResponse',
-  full_name='oomagent.SnapshotResponse',
-  filename=None,
-  file=DESCRIPTOR,
-  containing_type=None,
-  create_key=_descriptor._internal_create_key,
-  fields=[
-  ],
-  extensions=[
-  ],
-  nested_types=[],
-  enum_types=[
-  ],
-  serialized_options=None,
-  is_extendable=False,
-  syntax='proto3',
-  extension_ranges=[],
-  oneofs=[
-  ],
-  serialized_start=1379,
-  serialized_end=1397,
-)
-
-
-_SNAPSHOTREQUEST = _descriptor.Descriptor(
-  name='SnapshotRequest',
-  full_name='oomagent.SnapshotRequest',
-  filename=None,
-  file=DESCRIPTOR,
-  containing_type=None,
-  create_key=_descriptor._internal_create_key,
-  fields=[
-    _descriptor.FieldDescriptor(
-      name='group_name', full_name='oomagent.SnapshotRequest.group_name', index=0,
-      number=1, type=9, cpp_type=9, label=1,
-      has_default_value=False, default_value=b"".decode('utf-8'),
-      message_type=None, enum_type=None, containing_type=None,
-      is_extension=False, extension_scope=None,
-      serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
-  ],
-  extensions=[
-  ],
-  nested_types=[],
-  enum_types=[
-  ],
-  serialized_options=None,
-  is_extendable=False,
-  syntax='proto3',
-  extension_ranges=[],
-  oneofs=[
-  ],
-  serialized_start=1399,
-  serialized_end=1436,
-)
-
-
 _ENTITYROW = _descriptor.Descriptor(
   name='EntityRow',
   full_name='oomagent.EntityRow',
@@ -772,8 +715,8 @@ _ENTITYROW = _descriptor.Descriptor(
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1438,
-  serialized_end=1505,
+  serialized_start=1379,
+  serialized_end=1446,
 )
 
 
@@ -818,8 +761,8 @@ _CHANNELJOINREQUEST = _descriptor.Descriptor(
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1507,
-  serialized_end=1617,
+  serialized_start=1448,
+  serialized_end=1558,
 )
 
 
@@ -857,8 +800,8 @@ _CHANNELJOINRESPONSE = _descriptor.Descriptor(
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1619,
-  serialized_end=1693,
+  serialized_start=1560,
+  serialized_end=1634,
 )
 
 
@@ -903,8 +846,8 @@ _JOINREQUEST = _descriptor.Descriptor(
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1695,
-  serialized_end=1787,
+  serialized_start=1636,
+  serialized_end=1728,
 )
 
 
@@ -928,8 +871,8 @@ _JOINRESPONSE = _descriptor.Descriptor(
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1789,
-  serialized_end=1803,
+  serialized_start=1730,
+  serialized_end=1744,
 )
 
 
@@ -949,8 +892,8 @@ _CHANNELEXPORTREQUEST = _descriptor.Descriptor(
       is_extension=False, extension_scope=None,
       serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
     _descriptor.FieldDescriptor(
-      name='unix_milli', full_name='oomagent.ChannelExportRequest.unix_milli', index=1,
-      number=2, type=3, cpp_type=2, label=1,
+      name='revision_id', full_name='oomagent.ChannelExportRequest.revision_id', index=1,
+      number=2, type=5, cpp_type=1, label=1,
       has_default_value=False, default_value=0,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
@@ -979,8 +922,8 @@ _CHANNELEXPORTREQUEST = _descriptor.Descriptor(
       create_key=_descriptor._internal_create_key,
     fields=[]),
   ],
-  serialized_start=1805,
-  serialized_end=1900,
+  serialized_start=1746,
+  serialized_end=1842,
 )
 
 
@@ -1018,8 +961,8 @@ _CHANNELEXPORTRESPONSE = _descriptor.Descriptor(
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1902,
-  serialized_end=1971,
+  serialized_start=1844,
+  serialized_end=1913,
 )
 
 
@@ -1039,8 +982,8 @@ _EXPORTREQUEST = _descriptor.Descriptor(
       is_extension=False, extension_scope=None,
       serialized_options=None, file=DESCRIPTOR,  create_key=_descriptor._internal_create_key),
     _descriptor.FieldDescriptor(
-      name='unix_milli', full_name='oomagent.ExportRequest.unix_milli', index=1,
-      number=2, type=3, cpp_type=2, label=1,
+      name='revision_id', full_name='oomagent.ExportRequest.revision_id', index=1,
+      number=2, type=5, cpp_type=1, label=1,
       has_default_value=False, default_value=0,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
@@ -1076,8 +1019,8 @@ _EXPORTREQUEST = _descriptor.Descriptor(
       create_key=_descriptor._internal_create_key,
     fields=[]),
   ],
-  serialized_start=1973,
-  serialized_end=2087,
+  serialized_start=1915,
+  serialized_end=2030,
 )
 
 
@@ -1101,8 +1044,8 @@ _EXPORTRESPONSE = _descriptor.Descriptor(
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=2089,
-  serialized_end=2105,
+  serialized_start=2032,
+  serialized_end=2048,
 )
 
 _VALUE.fields_by_name['null_value'].enum_type = google_dot_protobuf_dot_struct__pb2._NULLVALUE
@@ -1175,8 +1118,6 @@ DESCRIPTOR.message_types_by_name['ImportResponse'] = _IMPORTRESPONSE
 DESCRIPTOR.message_types_by_name['ImportRequest'] = _IMPORTREQUEST
 DESCRIPTOR.message_types_by_name['PushResponse'] = _PUSHRESPONSE
 DESCRIPTOR.message_types_by_name['PushRequest'] = _PUSHREQUEST
-DESCRIPTOR.message_types_by_name['SnapshotResponse'] = _SNAPSHOTRESPONSE
-DESCRIPTOR.message_types_by_name['SnapshotRequest'] = _SNAPSHOTREQUEST
 DESCRIPTOR.message_types_by_name['EntityRow'] = _ENTITYROW
 DESCRIPTOR.message_types_by_name['ChannelJoinRequest'] = _CHANNELJOINREQUEST
 DESCRIPTOR.message_types_by_name['ChannelJoinResponse'] = _CHANNELJOINRESPONSE
@@ -1295,20 +1236,6 @@ PushRequest = _reflection.GeneratedProtocolMessageType('PushRequest', (_message.
   })
 _sym_db.RegisterMessage(PushRequest)
 
-SnapshotResponse = _reflection.GeneratedProtocolMessageType('SnapshotResponse', (_message.Message,), {
-  'DESCRIPTOR' : _SNAPSHOTRESPONSE,
-  '__module__' : 'oomagent_pb2'
-  # @@protoc_insertion_point(class_scope:oomagent.SnapshotResponse)
-  })
-_sym_db.RegisterMessage(SnapshotResponse)
-
-SnapshotRequest = _reflection.GeneratedProtocolMessageType('SnapshotRequest', (_message.Message,), {
-  'DESCRIPTOR' : _SNAPSHOTREQUEST,
-  '__module__' : 'oomagent_pb2'
-  # @@protoc_insertion_point(class_scope:oomagent.SnapshotRequest)
-  })
-_sym_db.RegisterMessage(SnapshotRequest)
-
 EntityRow = _reflection.GeneratedProtocolMessageType('EntityRow', (_message.Message,), {
   'DESCRIPTOR' : _ENTITYROW,
   '__module__' : 'oomagent_pb2'
@@ -1384,8 +1311,8 @@ _OOMAGENT = _descriptor.ServiceDescriptor(
   index=0,
   serialized_options=None,
   create_key=_descriptor._internal_create_key,
-  serialized_start=2108,
-  serialized_end=2955,
+  serialized_start=2051,
+  serialized_end=2829,
   methods=[
   _descriptor.MethodDescriptor(
     name='OnlineGet',
@@ -1447,20 +1374,10 @@ _OOMAGENT = _descriptor.ServiceDescriptor(
     serialized_options=None,
     create_key=_descriptor._internal_create_key,
   ),
-  _descriptor.MethodDescriptor(
-    name='Snapshot',
-    full_name='oomagent.OomAgent.Snapshot',
-    index=6,
-    containing_service=None,
-    input_type=_SNAPSHOTREQUEST,
-    output_type=_SNAPSHOTRESPONSE,
-    serialized_options=None,
-    create_key=_descriptor._internal_create_key,
-  ),
   _descriptor.MethodDescriptor(
     name='ChannelJoin',
     full_name='oomagent.OomAgent.ChannelJoin',
-    index=7,
+    index=6,
     containing_service=None,
     input_type=_CHANNELJOINREQUEST,
     output_type=_CHANNELJOINRESPONSE,
@@ -1470,7 +1387,7 @@ _OOMAGENT = _descriptor.ServiceDescriptor(
   _descriptor.MethodDescriptor(
     name='Join',
     full_name='oomagent.OomAgent.Join',
-    index=8,
+    index=7,
     containing_service=None,
     input_type=_JOINREQUEST,
     output_type=_JOINRESPONSE,
@@ -1480,7 +1397,7 @@ _OOMAGENT = _descriptor.ServiceDescriptor(
   _descriptor.MethodDescriptor(
     name='ChannelExport',
     full_name='oomagent.OomAgent.ChannelExport',
-    index=9,
+    index=8,
     containing_service=None,
     input_type=_CHANNELEXPORTREQUEST,
     output_type=_CHANNELEXPORTRESPONSE,
@@ -1490,7 +1407,7 @@ _OOMAGENT = _descriptor.ServiceDescriptor(
   _descriptor.MethodDescriptor(
     name='Export',
     full_name='oomagent.OomAgent.Export',
-    index=10,
+    index=9,
     containing_service=None,
     input_type=_EXPORTREQUEST,
     output_type=_EXPORTRESPONSE,
@@ -1500,7 +1417,7 @@ _OOMAGENT = _descriptor.ServiceDescriptor(
   _descriptor.MethodDescriptor(
     name='HealthCheck',
     full_name='oomagent.OomAgent.HealthCheck',
-    index=11,
+    index=10,
     containing_service=None,
     input_type=google_dot_protobuf_dot_empty__pb2._EMPTY,
     output_type=google_dot_protobuf_dot_empty__pb2._EMPTY,
diff --git a/sdk/python/src/oomstore/codegen/oomagent_pb2_grpc.py b/sdk/python/src/oomstore/codegen/oomagent_pb2_grpc.py
index 6ac54980..aff9f281 100644
--- a/sdk/python/src/oomstore/codegen/oomagent_pb2_grpc.py
+++ b/sdk/python/src/oomstore/codegen/oomagent_pb2_grpc.py
@@ -45,11 +45,6 @@ class OomAgentStub(object):
                 request_serializer=oomagent__pb2.PushRequest.SerializeToString,
                 response_deserializer=oomagent__pb2.PushResponse.FromString,
                 )
-        self.Snapshot = channel.unary_unary(
-                '/oomagent.OomAgent/Snapshot',
-                request_serializer=oomagent__pb2.SnapshotRequest.SerializeToString,
-                response_deserializer=oomagent__pb2.SnapshotResponse.FromString,
-                )
         self.ChannelJoin = channel.stream_stream(
                 '/oomagent.OomAgent/ChannelJoin',
                 request_serializer=oomagent__pb2.ChannelJoinRequest.SerializeToString,
@@ -116,12 +111,6 @@ class OomAgentServicer(object):
         context.set_details('Method not implemented!')
         raise NotImplementedError('Method not implemented!')
 
-    def Snapshot(self, request, context):
-        """Missing associated documentation comment in .proto file."""
-        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
-        context.set_details('Method not implemented!')
-        raise NotImplementedError('Method not implemented!')
-
     def ChannelJoin(self, request_iterator, context):
         """Missing associated documentation comment in .proto file."""
         context.set_code(grpc.StatusCode.UNIMPLEMENTED)
@@ -185,11 +174,6 @@ def add_OomAgentServicer_to_server(servicer, server):
                     request_deserializer=oomagent__pb2.PushRequest.FromString,
                     response_serializer=oomagent__pb2.PushResponse.SerializeToString,
             ),
-            'Snapshot': grpc.unary_unary_rpc_method_handler(
-                    servicer.Snapshot,
-                    request_deserializer=oomagent__pb2.SnapshotRequest.FromString,
-                    response_serializer=oomagent__pb2.SnapshotResponse.SerializeToString,
-            ),
             'ChannelJoin': grpc.stream_stream_rpc_method_handler(
                     servicer.ChannelJoin,
                     request_deserializer=oomagent__pb2.ChannelJoinRequest.FromString,
@@ -327,23 +311,6 @@ class OomAgent(object):
             options, channel_credentials,
             insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
 
-    @staticmethod
-    def Snapshot(request,
-            target,
-            options=(),
-            channel_credentials=None,
-            call_credentials=None,
-            insecure=False,
-            compression=None,
-            wait_for_ready=None,
-            timeout=None,
-            metadata=None):
-        return grpc.experimental.unary_unary(request, target, '/oomagent.OomAgent/Snapshot',
-            oomagent__pb2.SnapshotRequest.SerializeToString,
-            oomagent__pb2.SnapshotResponse.FromString,
-            options, channel_credentials,
-            insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
-
     @staticmethod
     def ChannelJoin(request_iterator,
             target,


Test patch:
diff --git a/internal/database/metadata/informer/informer_test.go b/internal/database/metadata/informer/informer_test.go
index 1dc0b638..5bf54421 100644
--- a/internal/database/metadata/informer/informer_test.go
+++ b/internal/database/metadata/informer/informer_test.go
@@ -5,9 +5,9 @@ import (
 	"testing"
 	"time"
 
-	"github.com/oom-ai/oomstore/internal/database/metadata"
 	"github.com/oom-ai/oomstore/internal/database/metadata/informer"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
+	"github.com/oom-ai/oomstore/pkg/oomstore/util"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -49,10 +49,8 @@ func TestInformer(t *testing.T) {
 	ctx, informer := prepareInformer(t)
 	defer informer.Close()
 
-	entityID := 1
-	features := informer.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		EntityID: &entityID,
-	})
+	name := util.ComposeFullFeatureName("group", "price")
+	features := informer.ListCachedFeature(ctx, &[]string{name})
 	require.Equal(t, 1, len(features))
 	feature := features[0]
 
@@ -70,17 +68,12 @@ func TestInformerDeepCopy(t *testing.T) {
 	ctx, informer := prepareInformer(t)
 	defer informer.Close()
 
-	entityID := 1
-	features := informer.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		EntityID: &entityID,
-	})
+	features := informer.ListCachedFeature(ctx, nil)
 	require.Equal(t, 1, len(features))
 	// changing this entity should not change the internal state of the informer
 	features[0].Name = "new_price"
 
-	features = informer.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		EntityID: &entityID,
-	})
+	features = informer.ListCachedFeature(ctx, nil)
 	require.Equal(t, 1, len(features))
 	assert.Equal(t, 1, features[0].ID)
 	assert.Equal(t, 100, features[0].GroupID)
diff --git a/internal/database/metadata/mysql/db_test.go b/internal/database/metadata/mysql/db_test.go
index fc0a41c2..6d19da7b 100644
--- a/internal/database/metadata/mysql/db_test.go
+++ b/internal/database/metadata/mysql/db_test.go
@@ -134,7 +134,7 @@ func TestListFeature(t *testing.T) {
 }
 
 func TestCacheListFeature(t *testing.T) {
-	test_impl.TestCacheListFeature(t, prepareStore, runtime_mysql.DestroyStore(DATABASE))
+	test_impl.TestListCachedFeature(t, prepareStore, runtime_mysql.DestroyStore(DATABASE))
 }
 
 func TestUpdateFeature(t *testing.T) {
diff --git a/internal/database/metadata/postgres/db_test.go b/internal/database/metadata/postgres/db_test.go
index 3996d9f6..a8225582 100644
--- a/internal/database/metadata/postgres/db_test.go
+++ b/internal/database/metadata/postgres/db_test.go
@@ -115,7 +115,7 @@ func TestListFeature(t *testing.T) {
 }
 
 func TestCacheListFeature(t *testing.T) {
-	test_impl.TestCacheListFeature(t, prepareStore, runtime_pg.DestroyStore(DATABASE))
+	test_impl.TestListCachedFeature(t, prepareStore, runtime_pg.DestroyStore(DATABASE))
 }
 
 func TestUpdateFeature(t *testing.T) {
diff --git a/internal/database/metadata/sqlite/db_test.go b/internal/database/metadata/sqlite/db_test.go
index 2de2b1f1..18b95765 100644
--- a/internal/database/metadata/sqlite/db_test.go
+++ b/internal/database/metadata/sqlite/db_test.go
@@ -138,7 +138,7 @@ func TestListFeature(t *testing.T) {
 }
 
 func TestCacheListFeature(t *testing.T) {
-	test_impl.TestCacheListFeature(t, prepareStore, destroyStore)
+	test_impl.TestListCachedFeature(t, prepareStore, destroyStore)
 }
 
 func TestCreateRevision(t *testing.T) {
diff --git a/internal/database/metadata/test_impl/feature.go b/internal/database/metadata/test_impl/feature.go
index 4c45f396..a9bed41e 100644
--- a/internal/database/metadata/test_impl/feature.go
+++ b/internal/database/metadata/test_impl/feature.go
@@ -144,7 +144,6 @@ func TestGetFeatureByName(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 
 	_, err := store.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: "phone",
-		FullName:    "device_info.phone",
 		GroupID:     groupID,
 		Description: "description",
 		ValueType:   types.String,
@@ -152,16 +151,15 @@ func TestGetFeatureByName(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 	require.NoError(t, err)
 
 	// case 1: wrong feature name, return error
-	_, err = store.GetFeatureByName(ctx, "p")
-	assert.EqualError(t, err, "feature p not found")
+	_, err = store.GetFeatureByName(ctx, "g", "f")
+	assert.EqualError(t, err, "feature group g not found")
 
 	// case 2: correct feature name, return feature `phone`
-	feature, err := store.GetFeatureByName(ctx, "device_info.phone")
+	feature, err := store.GetFeatureByName(ctx, "device_info", "phone")
 	assert.NoError(t, err)
 	expected := &types.Feature{
 		ID:          1,
 		Name:        "phone",
-		FullName:    "device_info.phone",
 		ValueType:   types.String,
 		Description: "description",
 		GroupID:     1,
@@ -170,20 +168,19 @@ func TestGetFeatureByName(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 	assert.Equal(t, expected, feature)
 }
 
-func TestCacheListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyStoreFn) {
+func TestListCachedFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyStoreFn) {
 	t.Cleanup(destroyStore)
 
 	ctx, store := prepareStore(t)
 	defer store.Close()
-	entityID, groupID := prepareEntityAndGroup(t, ctx, store)
+	_, groupID := prepareEntityAndGroup(t, ctx, store)
 
 	// case 1: no feature to list
-	features := store.CacheListFeature(ctx, metadata.ListFeatureOpt{})
+	features := store.ListCachedFeature(ctx, nil)
 	assert.Equal(t, 0, features.Len())
 
-	featureID, err := store.CreateFeature(ctx, metadata.CreateFeatureOpt{
+	_, err := store.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: "phone",
-		FullName:    "device_info.phone",
 		GroupID:     groupID,
 		Description: "description",
 		ValueType:   types.String,
@@ -192,47 +189,11 @@ func TestCacheListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 	require.NoError(t, store.Refresh())
 
 	// case 2: no condition, list all features
-	features = store.CacheListFeature(ctx, metadata.ListFeatureOpt{})
+	features = store.ListCachedFeature(ctx, nil)
 	assert.Equal(t, 1, features.Len())
 
-	// case 3: list features by FeatureIDs
-	features = store.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureIDs: &[]int{featureID},
-	})
-	assert.Equal(t, 1, features.Len())
-
-	// case 4: list features by EntityID and FeatureIDs
-	features = store.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		EntityID:   intPtr(entityID + 1),
-		FeatureIDs: &[]int{featureID},
-	})
-	assert.Equal(t, 0, features.Len())
-
-	// case 5: list features by GroupID and FeatureIDs
-	features = store.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		GroupID:    intPtr(groupID + 1),
-		FeatureIDs: &[]int{featureID},
-	})
-	assert.Equal(t, 0, features.Len())
-
-	// case 6: list features by EntityID and empty FeatureIDs, return no feature
-	features = store.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		EntityID:   &entityID,
-		FeatureIDs: &[]int{},
-	})
-	assert.Equal(t, 0, len(features))
-
-	// case 7: list features by EntityID
-	features = store.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		EntityID: &entityID,
-	})
-	assert.Equal(t, 1, len(features))
-
 	// case 8: list features by FeatureFullNames
-	features, err = store.ListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &[]string{"device_info.phone"},
-	})
-	assert.NoError(t, err)
+	features = store.ListCachedFeature(ctx, &[]string{"device_info.phone"})
 	assert.Equal(t, 1, len(features))
 }
 
@@ -250,7 +211,6 @@ func TestListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore Des
 
 	featureID, err := store.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: "phone",
-		FullName:    "device_info.phone",
 		GroupID:     groupID,
 		Description: "description",
 		ValueType:   types.String,
@@ -299,13 +259,6 @@ func TestListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore Des
 	})
 	assert.NoError(t, err)
 	assert.Equal(t, 1, len(features))
-
-	// case 8: list features by FeatureFullNames
-	features, err = store.ListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &[]string{"device_info.phone"},
-	})
-	assert.NoError(t, err)
-	assert.Equal(t, 1, len(features))
 }
 
 func TestUpdateFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyStoreFn) {
diff --git a/internal/database/metadata/tidb/db_test.go b/internal/database/metadata/tidb/db_test.go
index 03cd7eb1..5ccd618b 100644
--- a/internal/database/metadata/tidb/db_test.go
+++ b/internal/database/metadata/tidb/db_test.go
@@ -134,7 +134,7 @@ func TestListFeature(t *testing.T) {
 }
 
 func TestCacheListFeature(t *testing.T) {
-	test_impl.TestCacheListFeature(t, prepareStore, runtime_tidb.DestroyStore(DATABASE))
+	test_impl.TestListCachedFeature(t, prepareStore, runtime_tidb.DestroyStore(DATABASE))
 }
 
 func TestUpdateFeature(t *testing.T) {
diff --git a/internal/database/offline/bigquery/store_test.go b/internal/database/offline/bigquery/store_test.go
index 9cbcda15..e214b2bc 100644
--- a/internal/database/offline/bigquery/store_test.go
+++ b/internal/database/offline/bigquery/store_test.go
@@ -73,7 +73,6 @@ func TestPing(t *testing.T) {
 }
 
 func TestImport(t *testing.T) {
-	t.Skip()
 	test_impl.TestImport(t, prepareStore, destroyStore(DATASET_ID))
 }
 
@@ -106,6 +105,5 @@ func TestTableSchema(t *testing.T) {
 }
 
 func TestSnapshot(t *testing.T) {
-	t.Skip()
 	test_impl.TestSnapshot(t, prepareStore, destroyStore(DATASET_ID))
 }
diff --git a/internal/database/offline/test_impl/export.go b/internal/database/offline/test_impl/export.go
index e9e6c176..86bd9205 100644
--- a/internal/database/offline/test_impl/export.go
+++ b/internal/database/offline/test_impl/export.go
@@ -92,20 +92,19 @@ func TestExport(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyS
 
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
-			result, err := store.Export(ctx, tc.opt)
+			actual, errs := store.Export(ctx, tc.opt)
 			values := make([][]interface{}, 0)
-			for row := range result.Data {
+			for row := range actual {
 				values = append(values, row)
 			}
 			if tc.expectedError != nil {
-				assert.EqualError(t, err, tc.expectedError.Error())
+				assert.EqualError(t, <-errs, tc.expectedError.Error())
 			} else {
 				fmt.Println("expected: ", tc.expected)
 				fmt.Println("actual: ", values)
 
 				assert.ElementsMatch(t, tc.expected, values)
-				assert.NoError(t, err)
-				assert.NoError(t, result.CheckStreamError())
+				assert.NoError(t, <-errs)
 			}
 		})
 	}
@@ -126,13 +125,11 @@ func prepareFeaturesForExport() (batchFeatures types.FeatureList, streamFeatures
 	batchFeatures = []*types.Feature{
 		{
 			Name:      "model",
-			FullName:  "device.model",
 			ValueType: types.String,
 			Group:     batchGroup,
 		},
 		{
 			Name:      "price",
-			FullName:  "device.price",
 			ValueType: types.Int64,
 			Group:     batchGroup,
 		},
@@ -140,13 +137,11 @@ func prepareFeaturesForExport() (batchFeatures types.FeatureList, streamFeatures
 	streamFeatures = []*types.Feature{
 		{
 			Name:      "last_txn_amount",
-			FullName:  "account.last_txn_amount",
 			ValueType: types.Int64,
 			Group:     streamGroup,
 		},
 		{
 			Name:      "is_vip",
-			FullName:  "account.is_vip",
 			ValueType: types.Bool,
 			Group:     streamGroup,
 		},
diff --git a/internal/database/offline/test_impl/import.go b/internal/database/offline/test_impl/import.go
index 5dc039fa..a1ba6689 100644
--- a/internal/database/offline/test_impl/import.go
+++ b/internal/database/offline/test_impl/import.go
@@ -20,11 +20,6 @@ func TestImport(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyS
 	defer store.Close()
 
 	entity := types.Entity{Name: "device"}
-	group := &types.Group{
-		ID:       1,
-		Name:     "device",
-		Category: types.CategoryBatch,
-	}
 	snapshotTable := "offline_1_1"
 
 	opt := offline.ImportOpt{
@@ -57,21 +52,19 @@ func TestImport(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyS
 		_, err := store.Import(ctx, opt)
 		assert.NoError(t, err)
 
-		result, err := store.Export(ctx, offline.ExportOpt{
-			SnapshotTables: map[int]string{1: snapshotTable},
-			EntityName:     entity.Name,
-			Features: map[int]types.FeatureList{
-				1: []*types.Feature{
-					{Name: "model", FullName: "device.model", ValueType: types.String, Group: group},
-					{Name: "price", FullName: "device.price", ValueType: types.Int64, Group: group},
-				}},
+		stream, errch := store.ExportOneGroup(ctx, offline.ExportOneGroupOpt{
+			SnapshotTable: snapshotTable,
+			EntityName:    entity.Name,
+			Features: []*types.Feature{
+				{Name: "model", ValueType: types.String},
+				{Name: "price", ValueType: types.Int64},
+			},
 		})
 		records := make([][]interface{}, 0)
-		for row := range result.Data {
+		for row := range stream {
 			records = append(records, row)
 		}
-		assert.NoError(t, err)
-		assert.NoError(t, result.CheckStreamError())
+		assert.NoError(t, <-errch)
 		sort.Slice(records, func(i, j int) bool {
 			return cast.ToString(records[i][0]) < cast.ToString(records[j][0])
 		})
diff --git a/internal/database/offline/test_impl/join.go b/internal/database/offline/test_impl/join.go
index 7b20a373..f0a2717f 100644
--- a/internal/database/offline/test_impl/join.go
+++ b/internal/database/offline/test_impl/join.go
@@ -144,42 +144,36 @@ func TestJoin(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroySto
 }
 
 func prepareFeatures(oneGroup bool) (types.FeatureList, map[string]types.FeatureList) {
-	groupBasic := &types.Group{
-		ID:       1,
-		Name:     "device_basic",
-		Category: types.CategoryBatch,
-	}
-	groupAdvanced := &types.Group{
-		ID:       2,
-		Name:     "device_advanced",
-		Category: types.CategoryBatch,
+	group := &types.Group{
+		ID:   1,
+		Name: "device_basic",
 	}
 	price := &types.Feature{
 		Name:      "price",
-		FullName:  "device_basic.price",
 		ValueType: types.Int64,
 		GroupID:   1,
-		Group:     groupBasic,
+		Group:     group,
 	}
 	model := &types.Feature{
 		Name:      "model",
-		FullName:  "device_basic.model",
 		ValueType: types.String,
 		GroupID:   1,
-		Group:     groupBasic,
+		Group:     group,
 	}
 	isActive := &types.Feature{
 		Name:      "is_active",
-		FullName:  "device_advanced.is_active",
 		ValueType: types.Bool,
 		GroupID:   2,
-		Group:     groupAdvanced,
+		Group: &types.Group{
+			ID:   2,
+			Name: "device_advanced",
+		},
 	}
 
 	if oneGroup {
 		features := types.FeatureList{model, price}
 		featureMap := map[string]types.FeatureList{
-			"device_basic": {
+			group.Name: {
 				model, price,
 			},
 		}
@@ -187,10 +181,10 @@ func prepareFeatures(oneGroup bool) (types.FeatureList, map[string]types.Feature
 	} else {
 		features := types.FeatureList{model, price, isActive}
 		featureMap := map[string]types.FeatureList{
-			"device_basic": {
+			group.Name: {
 				model, price,
 			},
-			"device_advanced": {isActive},
+			isActive.Group.Name: {isActive},
 		}
 		return features, featureMap
 	}
diff --git a/internal/database/offline/test_impl/snapshot.go b/internal/database/offline/test_impl/snapshot.go
index 3ef7ef93..74aa5ff4 100644
--- a/internal/database/offline/test_impl/snapshot.go
+++ b/internal/database/offline/test_impl/snapshot.go
@@ -56,13 +56,13 @@ func TestSnapshot(t *testing.T, prepareStore PrepareStoreFn, destroyStore Destro
 	})
 	assert.NoError(t, err)
 
-	result, err := store.Export(ctx, offline.ExportOpt{
-		SnapshotTables: map[int]string{1: "offline_stream_snapshot_1_2"},
-		EntityName:     "device",
-		Features:       map[int]types.FeatureList{1: features},
+	actual, errs := store.ExportOneGroup(ctx, offline.ExportOneGroupOpt{
+		SnapshotTable: "offline_stream_snapshot_1_2",
+		EntityName:    "device",
+		Features:      features,
 	})
 	values := make([][]interface{}, 0)
-	for row := range result.Data {
+	for row := range actual {
 		values = append(values, row)
 	}
 	sort.Slice(values, func(i, j int) bool {
@@ -75,6 +75,6 @@ func TestSnapshot(t *testing.T, prepareStore PrepareStoreFn, destroyStore Destro
 		{"1237", "pixel", int64(200)},
 	}
 	assert.Equal(t, expected, values)
-	assert.NoError(t, result.CheckStreamError())
-	assert.NoError(t, err)
+	assert.NoError(t, <-errs)
+
 }
diff --git a/internal/database/online/test_impl/get.go b/internal/database/online/test_impl/get.go
index 32aa871d..27dc6c06 100644
--- a/internal/database/online/test_impl/get.go
+++ b/internal/database/online/test_impl/get.go
@@ -27,7 +27,7 @@ func TestGetExisted(t *testing.T, prepareStore PrepareStoreFn, destroyStore Dest
 		require.NoError(t, err)
 
 		for i, f := range s.Features {
-			compareFeatureValue(t, target.ValueAt(i), rs[f.FullName], f.ValueType)
+			compareFeatureValue(t, target.ValueAt(i), rs[f.FullName()], f.ValueType)
 		}
 	}
 }
@@ -73,7 +73,7 @@ func TestMultiGet(t *testing.T, prepareStore PrepareStoreFn, destroystore Destro
 	require.NoError(t, err)
 	for _, record := range s.Data {
 		for i, feature := range s.Features {
-			compareFeatureValue(t, record.ValueAt(i), rs[record.EntityKey()][feature.FullName], feature.ValueType)
+			compareFeatureValue(t, record.ValueAt(i), rs[record.EntityKey()][feature.FullName()], feature.ValueType)
 		}
 	}
 
diff --git a/internal/database/online/test_impl/purge.go b/internal/database/online/test_impl/purge.go
index 651bc293..8fe2ce37 100644
--- a/internal/database/online/test_impl/purge.go
+++ b/internal/database/online/test_impl/purge.go
@@ -50,7 +50,7 @@ func TestPurgeNotRemovesOtherRevisions(t *testing.T, prepareStore PrepareStoreFn
 		})
 		require.NoError(t, err)
 		for i, f := range SampleSmall.Features {
-			compareFeatureValue(t, record.ValueAt(i), rs[f.FullName], f.ValueType)
+			compareFeatureValue(t, record.ValueAt(i), rs[f.FullName()], f.ValueType)
 		}
 	}
 }
diff --git a/internal/database/online/test_impl/stream.go b/internal/database/online/test_impl/stream.go
index 4850deba..564c5a58 100644
--- a/internal/database/online/test_impl/stream.go
+++ b/internal/database/online/test_impl/stream.go
@@ -46,7 +46,6 @@ func init() {
 			&types.Feature{
 				ID:        1,
 				Name:      "last_5_click_post",
-				FullName:  "user_clicks.last_5_click_post",
 				ValueType: types.String,
 				GroupID:   1,
 				Group:     group1,
@@ -54,7 +53,6 @@ func init() {
 			&types.Feature{
 				ID:        2,
 				Name:      "last_10_click_post",
-				FullName:  "user_clicks.last_10_click_post",
 				ValueType: types.String,
 				GroupID:   1,
 				Group:     group1,
@@ -62,7 +60,6 @@ func init() {
 			&types.Feature{
 				ID:        3,
 				Name:      "recent_5_read_post",
-				FullName:  "user_reads.recent_5_read_post",
 				ValueType: types.String,
 				GroupID:   2,
 				Group:     group2,
@@ -139,7 +136,7 @@ func TestPush(t *testing.T, prepareStore PrepareStoreFn, destoryStore DestroySto
 	})
 	assert.NoError(t, err)
 	assert.Equal(t, map[string]interface{}{
-		feature1.FullName: "post1",
+		feature1.FullName(): "post1",
 	}, rs)
 
 	assert.NoError(t, store.Push(ctx, online.PushOpt{
@@ -158,7 +155,7 @@ func TestPush(t *testing.T, prepareStore PrepareStoreFn, destoryStore DestroySto
 	})
 	assert.NoError(t, err)
 	assert.Equal(t, map[string]interface{}{
-		feature1.FullName: "post2",
+		feature1.FullName(): "post2",
 	}, rs)
 
 	assert.NoError(t, store.PrepareStreamTable(ctx, online.PrepareStreamTableOpt{
@@ -183,7 +180,7 @@ func TestPush(t *testing.T, prepareStore PrepareStoreFn, destoryStore DestroySto
 	})
 	assert.NoError(t, err)
 	assert.Equal(t, map[string]interface{}{
-		feature1.FullName: "post1",
-		feature2.FullName: "post2",
+		feature1.FullName(): "post1",
+		feature2.FullName(): "post2",
 	}, rs)
 }
diff --git a/internal/database/online/test_impl/util.go b/internal/database/online/test_impl/util.go
index fb3b9840..223e1a71 100644
--- a/internal/database/online/test_impl/util.go
+++ b/internal/database/online/test_impl/util.go
@@ -37,7 +37,6 @@ func init() {
 				&types.Feature{
 					ID:        1,
 					Name:      "age",
-					FullName:  "user.age",
 					GroupID:   1,
 					Group:     &types.Group{ID: 1, Category: types.CategoryBatch},
 					ValueType: types.Int64,
@@ -45,7 +44,6 @@ func init() {
 				&types.Feature{
 					ID:        2,
 					Name:      "gender",
-					FullName:  "user.gender",
 					GroupID:   1,
 					Group:     &types.Group{ID: 1, Category: types.CategoryBatch},
 					ValueType: types.String,
@@ -53,7 +51,6 @@ func init() {
 				&types.Feature{
 					ID:        3,
 					Name:      "account",
-					FullName:  "user.account",
 					GroupID:   1,
 					Group:     &types.Group{ID: 1, Category: types.CategoryBatch},
 					ValueType: types.Float64,
@@ -61,7 +58,6 @@ func init() {
 				&types.Feature{
 					ID:        4,
 					Name:      "is_active",
-					FullName:  "user.is_active",
 					GroupID:   1,
 					Group:     &types.Group{ID: 1, Category: types.CategoryBatch},
 					ValueType: types.Bool,
@@ -69,7 +65,6 @@ func init() {
 				&types.Feature{
 					ID:        5,
 					Name:      "register_time",
-					FullName:  "user.register_time",
 					GroupID:   1,
 					Group:     &types.Group{ID: 1, Category: types.CategoryBatch},
 					ValueType: types.Time,
@@ -98,7 +93,6 @@ func init() {
 			&types.Feature{
 				ID:        2,
 				Name:      "charge",
-				FullName:  "user.charge",
 				GroupID:   2,
 				Group:     &types.Group{ID: 2, Category: types.CategoryBatch},
 				ValueType: types.Float64,
diff --git a/oomagent/test/test_channel_export.sh b/oomagent/test/test_channel_export.sh
index bd84a4ad..8ea23312 100755
--- a/oomagent/test/test_channel_export.sh
+++ b/oomagent/test/test_channel_export.sh
@@ -6,22 +6,20 @@ prepare_store
 prepare_oomagent
 
 import_sample account "./data/account_10.csv"
-unix_milli=${3:-$(perl -MTime::HiRes=time -E 'say int(time * 1000)')}
 
 case1() {
     prefix="export some features"
-    arg=$(cat <<-EOF
+    arg='
     {
-       "feature_names": ["account.state"],
-       "unix_milli": $unix_milli
+        "feature_names": ["state"],
+        "revision_id": 3
     }
-EOF
-    )
+    '
     actual=$(testgrpc ChannelExport <<<"$arg")
 
     case="$prefix - first response returns header"
     actual_header=$(jq -s '.[0].header' <<< "$actual")
-    expected_header='["user","account.state"]'
+    expected_header='["user","state"]'
     assert_json_eq "$case" "$expected_header" "$actual_header"
 
     case="$prefix - returns correct rows"
@@ -43,18 +41,12 @@ EOF
 
 case2() {
     prefix="export some features"
-    arg=$(cat <<-EOF
-    {
-       "feature_names": ["account.state","account.credit_score","account.account_age_days","account.has_2fa_installed"],
-       "unix_milli": $unix_milli
-    }
-EOF
-)
+    arg='{"revision_id":3}'
     actual=$(testgrpc ChannelExport <<<"$arg")
 
     case="$prefix - first response returns header"
     actual_header=$(jq -s '.[0].header' <<< "$actual")
-    expected_header='["user","account.state","account.credit_score","account.account_age_days","account.has_2fa_installed"]'
+    expected_header='["user","state","credit_score","account_age_days","has_2fa_installed"]'
     assert_json_eq "$case" "$expected_header" "$actual_header"
 
     case="$prefix - returns correct rows"
diff --git a/oomagent/test/test_export_to_file.sh b/oomagent/test/test_export_to_file.sh
index 0eff1ba5..823e66cf 100755
--- a/oomagent/test/test_export_to_file.sh
+++ b/oomagent/test/test_export_to_file.sh
@@ -31,17 +31,16 @@ group_test() {
 #  test group 1  #
 ##################
 group="export some features"
-unix_milli=${3:-$(perl -MTime::HiRes=time -E 'say int(time * 1000)')}
 arg=$(cat <<-EOF
 {
-    "feature_names": ["account.state"],
-    "unix_milli": $unix_milli,
-    "output_file_path": "$output"
+    "feature_names": ["state"],
+    "output_file_path": "$output",
+    "revision_id": "3"
 }
 EOF
 )
 expected='
-user,account.state
+user,state
 1,Nevada
 2,South Carolina
 3,New Jersey
@@ -61,24 +60,11 @@ group_test "$group" "$arg" "$expected"
 group="export all features"
 arg=$(cat <<-EOF
 {
-    "feature_names": ["account.state","account.credit_score","account.account_age_days","account.has_2fa_installed"],
     "output_file_path": "$output",
-    "unix_milli": $unix_milli
+    "revision_id": "3"
 }
 EOF
 )
-expected='
-user,account.state,account.credit_score,account.account_age_days,account.has_2fa_installed
-1,Nevada,530,242,true
-2,South Carolina,520,268,false
-3,New Jersey,655,84,false
-4,Ohio,677,119,true
-5,California,566,289,false
-6,North Carolina,533,155,true
-7,North Dakota,605,334,true
-8,West Virginia,664,282,false
-9,Alabama,577,150,true
-10,Idaho,693,212,true
-'
+expected=$(cat ./data/account_10.csv)
 group_test "$group" "$arg" "$expected"
 )
diff --git a/oomcli/test/test_export.sh b/oomcli/test/test_export.sh
index 1c7f6682..4ca016d1 100755
--- a/oomcli/test/test_export.sh
+++ b/oomcli/test/test_export.sh
@@ -7,7 +7,7 @@ register_features
 import_sample >> /dev/null
 
 case='get all'
-expected='device,phone.price
+expected='device,price
 1,3999
 2,5299
 3,3999
@@ -18,6 +18,5 @@ expected='device,phone.price
 8,6500
 9,4500
 '
-timestamp=${1:-$(perl -MTime::HiRes=time -E 'say int(time * 1000)')}
-actual=$(oomcli export --feature phone.price --unix-milli $timestamp -o csv)
+actual=$(oomcli export --feature price --revision-id 1 -o csv)
 assert_eq "$case" "$(sort <<< "$expected")" "$(sort <<< "$actual")"
diff --git a/oomcli/test/test_stream_join.sh b/oomcli/test/test_stream_join.sh
deleted file mode 100755
index 71c91d77..00000000
--- a/oomcli/test/test_stream_join.sh
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env bash
-
-SDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) && cd "$SDIR" || exit 1
-source ./util.sh
-
-init_store
-register_features
-
-# clean up the tmp file
-trap 'command rm -rf entity_rows.csv entity_rows_with_values.csv' EXIT INT TERM HUP
-
-case='oomcli join stream historical-feature'
-oomcli push --entity-key 1 --group user-click --features last_5_click_posts=0,1,2,3,4 --features number_of_user_starred_posts=10 >> /dev/null 2>&1
-oomcli push --entity-key 1 --group user-click --features last_5_click_posts=1,2,3,4,5 --features number_of_user_starred_posts=10 >> /dev/null 2>&1
-oomcli push --entity-key 2 --group user-click --features last_5_click_posts=2,3,4,5,6 --features number_of_user_starred_posts=11 >> /dev/null 2>&1
-oomcli push --entity-key 3 --group user-click --features last_5_click_posts=3,4,5,6,7 --features number_of_user_starred_posts=12 >> /dev/null 2>&1
-oomcli snapshot user-click
-
-t1=$(date +%s%3N)
-cat <<-EOF > entity_rows.csv
-entity_key,unix_milli
-1,$t1
-2,$t1
-3,$t1
-EOF
-
-expected="
-entity_key,unix_milli,user-click.last_5_click_posts,user-click.number_of_user_starred_posts
-1,$t1,\"1,2,3,4,5\",10
-2,$t1,\"2,3,4,5,6\",11
-3,$t1,\"3,4,5,6,7\",12
-"
-actual=$(oomcli join \
-    --feature user-click.last_5_click_posts,user-click.number_of_user_starred_posts \
-    --input-file entity_rows.csv \
-    --output csv
-)
-sorted_expected=$(echo "$expected"|sort)
-sorted_actual=$(echo "$actual"|sort)
-
-assert_eq "$case" "$sorted_expected" "$sorted_actual"
-
-case='oomcli join stream historical-feature with real-time feature values'
-cat <<-EOF > entity_rows_with_values.csv
-entity_key,unix_milli,value_1,value_2
-1,$t1,1,2
-2,$t1,3,4
-3,$t1,5,6
-EOF
-
-expected="
-entity_key,unix_milli,value_1,value_2,user-click.last_5_click_posts,user-click.number_of_user_starred_posts
-1,$t1,1,2,\"1,2,3,4,5\",10
-2,$t1,3,4,\"2,3,4,5,6\",11
-3,$t1,5,6,\"3,4,5,6,7\",12
-"
-actual=$(oomcli join \
-    --feature user-click.last_5_click_posts,user-click.number_of_user_starred_posts \
-    --input-file entity_rows_with_values.csv \
-    --output csv
-)
-sorted_expected=$(echo "$expected"|sort)
-sorted_actual=$(echo "$actual"|sort)
-
-assert_eq "$case" "$sorted_expected" "$sorted_actual"
diff --git a/pkg/oomstore/export_test.go b/pkg/oomstore/export_test.go
deleted file mode 100644
index 1123b8d8..00000000
--- a/pkg/oomstore/export_test.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package oomstore_test
-
-import (
-	"context"
-	"fmt"
-	"testing"
-
-	"github.com/golang/mock/gomock"
-	"github.com/stretchr/testify/assert"
-
-	"github.com/oom-ai/oomstore/internal/database/metadata/mock_metadata"
-	"github.com/oom-ai/oomstore/internal/database/offline"
-	"github.com/oom-ai/oomstore/internal/database/offline/mock_offline"
-	"github.com/oom-ai/oomstore/pkg/oomstore"
-	"github.com/oom-ai/oomstore/pkg/oomstore/types"
-)
-
-func TestChannelExport(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-	ctx := context.Background()
-
-	offlineStore := mock_offline.NewMockStore(ctrl)
-	metadataStore := mock_metadata.NewMockStore(ctrl)
-	store := oomstore.TEST__New(nil, offlineStore, metadataStore)
-
-	features := types.FeatureList{
-		{
-			Name:      "model",
-			ValueType: types.String,
-		},
-		{
-			Name:      "price",
-			ValueType: types.Int64,
-		},
-	}
-
-	testCases := []struct {
-		description  string
-		opt          types.ChannelExportOpt
-		features     types.FeatureList
-		exportStream <-chan types.ExportRecord
-		exportError  <-chan error
-		expected     [][]interface{}
-	}{
-		{
-			description: "provide multiple features, should return multiple feature values",
-			opt: types.ChannelExportOpt{
-				FeatureFullNames: []string{"model", "price"},
-				UnixMilli:        100,
-			},
-			features:     features,
-			exportStream: prepareTwoFeatureStream(),
-			expected:     [][]interface{}{{"1234", "xiaomi", int64(100)}, {"1235", "apple", int64(200)}, {"1236", "huawei", int64(300)}, {"1237", "oneplus", int64(240)}},
-		},
-		{
-			description: "provide no features, should return empty values",
-			opt: types.ChannelExportOpt{
-				FeatureFullNames: []string{},
-				UnixMilli:        100,
-			},
-			features:     features,
-			exportStream: prepareEmptyStream(),
-			exportError:  prepareExportError(),
-			expected:     [][]interface{}{},
-		},
-		{
-			description: "provide one feature, should return one feature values",
-			opt: types.ChannelExportOpt{
-				FeatureFullNames: []string{"price"},
-				UnixMilli:        100,
-			},
-			features:     features[1:],
-			exportStream: prepareOneFeatureStream(),
-			expected:     [][]interface{}{{"1234", int64(100)}, {"1235", int64(200)}, {"1236", int64(300)}, {"1237", int64(240)}},
-		},
-	}
-	for _, tc := range testCases {
-		t.Run(tc.description, func(t *testing.T) {
-			t.Skip()
-			metadataStore.EXPECT().Refresh().Return(nil).AnyTimes()
-
-			if len(tc.opt.FeatureFullNames) == 0 {
-				metadataStore.EXPECT().GetGroupByName(ctx, "device_info").Return(&types.Group{
-					Name: "device_info",
-					ID:   1,
-				}, nil)
-			}
-			metadataStore.EXPECT().ListFeature(gomock.Any(), gomock.Any()).Return(features, nil)
-
-			offlineStore.EXPECT().Export(gomock.Any(), offline.ExportOpt{
-				SnapshotTables: map[int]string{1: "device_info_10"},
-				Features:       map[int]types.FeatureList{1: features},
-				EntityName:     "device",
-				Limit:          tc.opt.Limit,
-			}).Return(tc.exportStream, tc.exportError)
-
-			// execute and compare results
-			actual, err := store.ChannelExport(context.Background(), tc.opt)
-			assert.NoError(t, err)
-			values := make([][]interface{}, 0)
-			for row := range actual.Data {
-				values = append(values, row)
-			}
-			if tc.exportError != nil {
-				assert.Error(t, actual.CheckStreamError())
-			} else {
-				assert.NoError(t, actual.CheckStreamError())
-			}
-			assert.Equal(t, tc.expected, values)
-		})
-	}
-}
-
-func prepareTwoFeatureStream() chan types.ExportRecord {
-	stream := make(chan types.ExportRecord)
-	go func() {
-		defer close(stream)
-		stream <- []interface{}{"1234", "xiaomi", int64(100)}
-		stream <- []interface{}{"1235", "apple", int64(200)}
-		stream <- []interface{}{"1236", "huawei", int64(300)}
-		stream <- []interface{}{"1237", "oneplus", int64(240)}
-	}()
-	return stream
-}
-
-func prepareOneFeatureStream() chan types.ExportRecord {
-	stream := make(chan types.ExportRecord)
-	go func() {
-		defer close(stream)
-		stream <- []interface{}{"1234", int64(100)}
-		stream <- []interface{}{"1235", int64(200)}
-		stream <- []interface{}{"1236", int64(300)}
-		stream <- []interface{}{"1237", int64(240)}
-	}()
-	return stream
-}
-
-func prepareEmptyStream() chan types.ExportRecord {
-	stream := make(chan types.ExportRecord)
-	go func() {
-		defer close(stream)
-	}()
-	return stream
-}
-
-func prepareExportError() <-chan error {
-	err := make(chan error, 1)
-	go func() {
-		defer close(err)
-		err <- fmt.Errorf("error")
-	}()
-	return err
-}
diff --git a/pkg/oomstore/feature_test.go b/pkg/oomstore/feature_test.go
index ec67e8bf..2b4c66f5 100644
--- a/pkg/oomstore/feature_test.go
+++ b/pkg/oomstore/feature_test.go
@@ -2,7 +2,6 @@ package oomstore_test
 
 import (
 	"context"
-	"fmt"
 	"testing"
 
 	"github.com/golang/mock/gomock"
@@ -70,7 +69,6 @@ func TestCreateFeature(t *testing.T) {
 
 			metadataOpt := metadata.CreateFeatureOpt{
 				FeatureName: tc.opt.FeatureName,
-				FullName:    fmt.Sprintf("%s.%s", tc.opt.GroupName, tc.opt.FeatureName),
 				GroupID:     tc.group.ID,
 				ValueType:   tc.valueType,
 				Description: tc.opt.Description,
diff --git a/pkg/oomstore/join_test.go b/pkg/oomstore/join_test.go
index d92e841a..1355a418 100644
--- a/pkg/oomstore/join_test.go
+++ b/pkg/oomstore/join_test.go
@@ -108,7 +108,7 @@ func TestChannelJoin(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
 			metadataStore.EXPECT().Refresh().Return(nil).AnyTimes()
-			metadataStore.EXPECT().ListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureFullNames: &tc.opt.FeatureFullNames}).Return(tc.features, nil)
+			metadataStore.EXPECT().ListFeature(gomock.Any(), metadata.ListFeatureOpt{}).Return(tc.features, nil)
 			if tc.entity != nil {
 				for _, featureList := range tc.featureMap {
 					metadataStore.EXPECT().ListRevision(gomock.Any(), &featureList[0].GroupID).Return(revisions, nil).AnyTimes()
diff --git a/pkg/oomstore/online_query_test.go b/pkg/oomstore/online_query_test.go
index 8d91da66..ece4c4ee 100644
--- a/pkg/oomstore/online_query_test.go
+++ b/pkg/oomstore/online_query_test.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/golang/mock/gomock"
 	"github.com/oom-ai/oomstore/internal/database/dbutil"
-	"github.com/oom-ai/oomstore/internal/database/metadata"
 	"github.com/oom-ai/oomstore/internal/database/metadata/mock_metadata"
 	"github.com/oom-ai/oomstore/internal/database/online/mock_online"
 	"github.com/oom-ai/oomstore/pkg/oomstore"
@@ -81,7 +80,7 @@ func TestOnlineGet(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
-			metadataStore.EXPECT().CacheListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureFullNames: &tc.opt.FeatureFullNames}).Return(tc.features)
+			metadataStore.EXPECT().ListCachedFeature(gomock.Any(), &tc.opt.FeatureFullNames).Return(tc.features)
 			if tc.entityName != nil {
 				onlineStore.EXPECT().Get(gomock.Any(), gomock.Any()).Return(dbutil.RowMap{
 					"price": int64(100),
@@ -175,7 +174,7 @@ func TestOnlineMultiGet(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
-			metadataStore.EXPECT().CacheListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureFullNames: &tc.opt.FeatureFullNames}).Return(tc.features)
+			metadataStore.EXPECT().ListCachedFeature(gomock.Any(), &tc.opt.FeatureFullNames).Return(tc.features)
 			if tc.entityName != nil {
 				onlineStore.EXPECT().MultiGet(gomock.Any(), gomock.Any()).Return(map[string]dbutil.RowMap{
 					"1234": {
@@ -219,7 +218,6 @@ func prepareFeatures(isConsistent bool, isAvailable bool) types.FeatureList {
 	features := types.FeatureList{
 		{
 			Name:      "model",
-			FullName:  "device.model",
 			ValueType: types.String,
 			GroupID:   1,
 			Group: &types.Group{
@@ -232,7 +230,6 @@ func prepareFeatures(isConsistent bool, isAvailable bool) types.FeatureList {
 		},
 		{
 			Name:      "price",
-			FullName:  "device.price",
 			ValueType: types.Int64,
 			GroupID:   2,
 			Group: &types.Group{
@@ -245,7 +242,6 @@ func prepareFeatures(isConsistent bool, isAvailable bool) types.FeatureList {
 		},
 		{
 			Name:      "age",
-			FullName:  "user.age",
 			ValueType: types.Int64,
 			GroupID:   3,
 			Group: &types.Group{
diff --git a/pkg/oomstore/sync_test.go b/pkg/oomstore/sync_test.go
index 1ed2e775..f650fb40 100644
--- a/pkg/oomstore/sync_test.go
+++ b/pkg/oomstore/sync_test.go
@@ -73,14 +73,12 @@ func TestSync(t *testing.T) {
 				}, nil)
 				metadataStore.EXPECT().ListFeature(ctx, metadata.ListFeatureOpt{GroupID: &revision.Group.ID}).Return(features, nil)
 
-				result := &types.ExportResult{
-					Data: make(chan types.ExportRecord),
-				}
-				offlineStore.EXPECT().Export(ctx, offline.ExportOpt{
-					SnapshotTables: map[int]string{1: "snapshot-table-name"},
-					Features:       map[int]types.FeatureList{1: features},
-					EntityName:     "device",
-				}).Return(result, nil)
+				stream := make(chan types.ExportRecord)
+				offlineStore.EXPECT().ExportOneGroup(ctx, offline.ExportOneGroupOpt{
+					SnapshotTable: "snapshot-table-name",
+					EntityName:    "device",
+					Features:      features,
+				}).Return(stream, nil)
 
 				onlineStore.EXPECT().Import(ctx, online.ImportOpt{
 					Features: features,
@@ -88,7 +86,7 @@ func TestSync(t *testing.T) {
 					Entity: &types.Entity{
 						Name: "device",
 					},
-					ExportStream: result.Data,
+					ExportStream: stream,
 				}).Return(nil)
 
 				metadataStore.EXPECT().WithTransaction(ctx, gomock.Any()).Return(nil)
@@ -110,14 +108,12 @@ func TestSync(t *testing.T) {
 				}, nil)
 				metadataStore.EXPECT().ListFeature(ctx, metadata.ListFeatureOpt{GroupID: &revision.Group.ID}).Return(features, nil)
 
-				result := &types.ExportResult{
-					Data: make(chan types.ExportRecord),
-				}
-				offlineStore.EXPECT().Export(ctx, offline.ExportOpt{
-					SnapshotTables: map[int]string{1: "snapshot-table-name"},
-					Features:       map[int]types.FeatureList{1: features},
-					EntityName:     "device",
-				}).Return(result, nil)
+				stream := make(chan types.ExportRecord)
+				offlineStore.EXPECT().ExportOneGroup(ctx, offline.ExportOneGroupOpt{
+					SnapshotTable: "snapshot-table-name",
+					EntityName:    "device",
+					Features:      features,
+				}).Return(stream, nil)
 
 				onlineStore.EXPECT().Import(ctx, online.ImportOpt{
 					Features: features,
@@ -125,7 +121,7 @@ func TestSync(t *testing.T) {
 					Entity: &types.Entity{
 						Name: "device",
 					},
-					ExportStream: result.Data,
+					ExportStream: stream,
 				}).Return(nil)
 
 				metadataStore.EXPECT().WithTransaction(ctx, gomock.Any()).Return(nil)