Visitar URL original
feat: Implemented registry refresh action feature by AdityaPatil22 · Pull Request #6657 · feast-dev/feast · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: Enforced UPDATE permission on REST registry refresh
Signed-off-by: Aditya Patil <adpatil@redhat.com>
  • Loading branch information
Aditya Patil
Aditya Patil committed Jul 31, 2026
commit a740aa233fa6366bb261bf92b8769e23d2be15dc
8 changes: 8 additions & 0 deletions sdk/python/feast/api/registry/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ def register_all_routes(app: FastAPI, grpc_handler, server=None, store=None):
@app.post("/registry/refresh")
Comment thread
ntkathole marked this conversation as resolved.
Outdated
def refresh_registry():
try:
from feast.permissions.action import AuthzedAction
from feast.permissions.security_manager import assert_permissions

project = resolved_store.registry.get_project(
name=resolved_store.project, allow_cache=True
)
assert_permissions(resource=project, actions=[AuthzedAction.UPDATE])

resolved_store.refresh_registry()
Comment thread
ntkathole marked this conversation as resolved.
Outdated
return Response(status_code=status.HTTP_200_OK)
except Exception:
Comment thread
ntkathole marked this conversation as resolved.
Outdated
Expand Down
43 changes: 43 additions & 0 deletions sdk/python/tests/unit/api/test_api_rest_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2286,3 +2286,46 @@ def test_metrics_resource_counts_nonexistent_project(fastapi_test_app):
assert data["featureServices"] == []
assert data["featureViews"] == []
assert "registryLastUpdated" in data


def test_registry_refresh_via_rest(fastapi_test_app):
"""POST /registry/refresh invalidates the registry cache and returns 200."""
response = fastapi_test_app.post("/registry/refresh")
assert response.status_code == 200


def test_registry_refresh_via_rest_error():
"""POST /registry/refresh returns 500 when refresh fails."""
from unittest.mock import patch

from feast.api.registry.rest import register_all_routes
from feast.registry_server import RegistryServer

tmp_dir = tempfile.TemporaryDirectory()
registry_path = os.path.join(tmp_dir.name, "registry.db")

config = {
"registry": registry_path,
"project": "demo_project",
"provider": "local",
"offline_store": {"type": "file"},
"online_store": {"type": "sqlite", "path": ":memory:"},
}
from fastapi import FastAPI

from feast.repo_config import RepoConfig

store = FeatureStore(config=RepoConfig.model_validate(config))
store.apply([])

app = FastAPI()
grpc_handler = RegistryServer(store.registry, store=store)
register_all_routes(app, grpc_handler, store=store)

with patch.object(store, "refresh_registry", side_effect=Exception("db error")):
client = TestClient(app)
response = client.post("/registry/refresh")
assert response.status_code == 500
assert "Registry refresh failed" in response.json()["detail"]

tmp_dir.cleanup()
31 changes: 15 additions & 16 deletions sdk/python/tests/unit/test_ui_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,11 @@ def test_catch_all_route(ui_app_with_registry):
# ---------- projects-list.json tests ----------


def _get_projects_list(app):
"""Fetch the dynamic projects-list.json endpoint."""
client = TestClient(app)
resp = client.get("/projects-list.json")
assertpy.assert_that(resp.status_code).is_equal_to(EXPECTED_SUCCESS_STATUS)
return resp.json()
def _read_projects_list(temp_dir):
"""Read the projects-list.json written by get_app via the mock (ui_dir = temp_dir)."""
projects_file = os.path.join(temp_dir, "projects-list.json")
with open(projects_file) as f:
return json.load(f)


def test_projects_list_registry_path(mock_feature_store):
Expand All @@ -166,9 +165,9 @@ def test_projects_list_registry_path(mock_feature_store):
_create_mock_ui_files(temp_dir)

with _setup_importlib_mocks(temp_dir):
app = get_app(mock_feature_store, TEST_PROJECT_NAME)
get_app(mock_feature_store, TEST_PROJECT_NAME)

data = _get_projects_list(app)
data = _read_projects_list(temp_dir)
assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to("/api/v1")


Expand All @@ -182,13 +181,13 @@ def test_projects_list_with_root_path(mock_feature_store):
_create_mock_ui_files(temp_dir)

with _setup_importlib_mocks(temp_dir):
app = get_app(
get_app(
mock_feature_store,
TEST_PROJECT_NAME,
root_path="/feast",
)

data = _get_projects_list(app)
data = _read_projects_list(temp_dir)
assertpy.assert_that(data["projects"][0]["registryPath"]).is_equal_to(
"/feast/api/v1"
)
Expand All @@ -207,9 +206,9 @@ def test_projects_list_multiple_projects(mock_feature_store):
_create_mock_ui_files(temp_dir)

with _setup_importlib_mocks(temp_dir):
app = get_app(mock_feature_store, TEST_PROJECT_NAME)
get_app(mock_feature_store, TEST_PROJECT_NAME)

data = _get_projects_list(app)
data = _read_projects_list(temp_dir)
assertpy.assert_that(len(data["projects"])).is_equal_to(3)
assertpy.assert_that(data["projects"][0]["id"]).is_equal_to("all")
assertpy.assert_that(data["projects"][1]["id"]).is_equal_to("project_alpha")
Expand All @@ -226,9 +225,9 @@ def test_projects_list_fallback_on_empty(mock_feature_store):
_create_mock_ui_files(temp_dir)

with _setup_importlib_mocks(temp_dir):
app = get_app(mock_feature_store, TEST_PROJECT_NAME)
get_app(mock_feature_store, TEST_PROJECT_NAME)

data = _get_projects_list(app)
data = _read_projects_list(temp_dir)
assertpy.assert_that(len(data["projects"])).is_equal_to(1)
assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME)

Expand All @@ -243,9 +242,9 @@ def test_projects_list_fallback_on_exception(mock_feature_store):
_create_mock_ui_files(temp_dir)

with _setup_importlib_mocks(temp_dir):
app = get_app(mock_feature_store, TEST_PROJECT_NAME)
get_app(mock_feature_store, TEST_PROJECT_NAME)

data = _get_projects_list(app)
data = _read_projects_list(temp_dir)
assertpy.assert_that(len(data["projects"])).is_equal_to(1)
assertpy.assert_that(data["projects"][0]["id"]).is_equal_to(TEST_PROJECT_NAME)

Expand Down
12 changes: 10 additions & 2 deletions ui/src/hooks/useRegistryRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ProjectListContext,
ProjectsListSchema,
} from "../contexts/ProjectListContext";
import { useDataMode } from "../contexts/DataModeContext";

interface Toast {
id: string;
Expand All @@ -18,6 +19,7 @@ const useRegistryRefresh = () => {
const queryClient = useQueryClient();
const projectListCtx = useContext(ProjectListContext);
const basename = projectListCtx?.basename || "";
const { fetchOptions } = useDataMode();

const removeToast = useCallback((removedToast: { id: string }) => {
setToasts((prev) => prev.filter((t) => t.id !== removedToast.id));
Expand All @@ -28,12 +30,18 @@ const useRegistryRefresh = () => {
try {
const refreshRes = await fetch(`${basename}/api/v1/registry/refresh`, {
Comment thread
ntkathole marked this conversation as resolved.
method: "POST",
headers: { ...fetchOptions?.headers },
credentials: fetchOptions?.credentials,
});
if (!refreshRes.ok) {
throw new Error(`Registry refresh failed (${refreshRes.status})`);
}
const res = await fetch(`${basename}/projects-list.json`, {
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...fetchOptions?.headers,
},
credentials: fetchOptions?.credentials,
});
if (!res.ok) {
throw new Error(`Failed to fetch project list (${res.status})`);
Expand Down Expand Up @@ -64,7 +72,7 @@ const useRegistryRefresh = () => {
} finally {
setRefreshing(false);
}
}, [basename, queryClient]);
}, [basename, queryClient, fetchOptions]);

return { refreshing, toasts, handleRefresh, removeToast };
};
Expand Down