Skip to content

Commit e85f2bb

Browse files
committed
added refresh materialized view cloud function
1 parent 453f002 commit e85f2bb

File tree

8 files changed

+263
-1
lines changed

8 files changed

+263
-1
lines changed

functions-python/refresh_materialized_view/.coveragerc

Whitespace-only changes.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Refresh Materialized View Cloud Function
2+
3+
This Google Cloud Function refreshes a materialized view using the `CONCURRENTLY` command to avoid table locks.
4+
5+
## Purpose
6+
7+
The function allows you to refresh PostgreSQL materialized views without blocking other database operations. It uses the `REFRESH MATERIALIZED VIEW CONCURRENTLY` command, which requires the materialized view to have a unique index.
8+
9+
## Usage
10+
11+
### HTTP Request
12+
13+
The function accepts both GET and POST requests:
14+
15+
#### GET Request
16+
17+
```
18+
GET /refresh-materialized-view?view_name=your_view_name
19+
```
20+
21+
#### POST Request
22+
23+
```
24+
POST /refresh-materialized-view
25+
Content-Type: application/json
26+
27+
{
28+
"view_name": "your_view_name"
29+
}
30+
```
31+
32+
### Parameters
33+
34+
- `view_name` (required): The name of the materialized view to refresh. Can include schema prefix (e.g., `schema_name.view_name`).
35+
36+
### Response
37+
38+
#### Success Response
39+
40+
```json
41+
{
42+
"message": "Successfully refreshed materialized view: your_view_name"
43+
}
44+
```
45+
46+
#### Error Response
47+
48+
```json
49+
{
50+
"error": "Error refreshing materialized view"
51+
}
52+
```
53+
54+
## Security
55+
56+
The function validates the view name to prevent SQL injection attacks. Only alphanumeric characters, underscores, and dots are allowed in view names.
57+
58+
## Requirements
59+
60+
- The materialized view must have a unique index for the `CONCURRENTLY` option to work
61+
- The database user must have appropriate permissions to refresh materialized views
62+
63+
## Environment Variables
64+
65+
- `FEEDS_DATABASE_URL`: PostgreSQL database connection string
66+
67+
## Error Handling
68+
69+
The function handles various error scenarios:
70+
71+
- Missing view name parameter
72+
- Invalid view name format
73+
- Database connection issues
74+
- View refresh failures
75+
76+
All errors are logged and returned with appropriate HTTP status codes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "refresh-materialized-view",
3+
"description": "Refreshes a materialized view using the CONCURRENTLY command to avoid table locks",
4+
"entry_point": "refresh_materialized_view_function",
5+
"timeout": 1800,
6+
"memory": "512Mi",
7+
"trigger_http": true,
8+
"include_folders": ["helpers"],
9+
"include_api_folders": ["database_gen", "database", "common"],
10+
"secret_environment_variables": [
11+
{
12+
"key": "FEEDS_DATABASE_URL"
13+
}
14+
],
15+
"ingress_settings": "ALLOW_INTERNAL_AND_GCLB",
16+
"max_instance_request_concurrency": 1,
17+
"max_instance_count": 3,
18+
"min_instance_count": 0,
19+
"available_cpu": 1
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Common packages
2+
functions-framework==3.*
3+
google-cloud-logging
4+
psycopg2-binary==2.9.6
5+
aiohttp~=3.10.5
6+
asyncio~=3.4.3
7+
urllib3~=2.2.2
8+
requests~=2.32.3
9+
attrs~=23.1.0
10+
pluggy~=1.3.0
11+
certifi~=2024.7.4
12+
13+
# SQL Alchemy and Geo Alchemy
14+
SQLAlchemy==2.0.23
15+
geoalchemy2==0.14.7
16+
17+
# Flask for HTTP request handling
18+
Flask==3.0.0
19+
20+
# Configuration
21+
python-dotenv~=1.0.0
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Development packages
2+
pytest~=7.4.0
3+
pytest-cov~=4.1.0
4+
coverage~=7.2.0
5+
black~=23.0.0
6+
flake8~=6.0.0
7+
pre-commit~=3.3.0
8+
requests-mock~=1.11.0
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import logging
2+
import functions_framework
3+
from flask import Request
4+
from shared.helpers.logger import init_logger
5+
from shared.database.database import with_db_session, refresh_materialized_view
6+
7+
init_logger()
8+
9+
10+
@with_db_session
11+
@functions_framework.http
12+
def refresh_materialized_view_function(request: Request, db_session):
13+
"""
14+
Refreshes a materialized view using the CONCURRENTLY command to avoid
15+
table locks.
16+
17+
Returns:
18+
tuple: (response_message, status_code)
19+
"""
20+
try:
21+
logging.info("Starting materialized view refresh function.")
22+
23+
view_name = "my_materialized_view"
24+
logging.info(f"Refreshing materialized view: {view_name}")
25+
26+
# Call the refresh function
27+
success = refresh_materialized_view(db_session, view_name)
28+
29+
if success:
30+
success_msg = f"Successfully refreshed materialized view: {view_name}"
31+
logging.info(success_msg)
32+
return {"message": success_msg}, 200
33+
else:
34+
error_msg = f"Failed to refresh materialized view: {view_name}"
35+
logging.error(error_msg)
36+
return {"error": error_msg}, 500
37+
38+
except Exception as error:
39+
error_msg = f"Error refreshing materialized view: {error}"
40+
logging.error(error_msg)
41+
return {"error": error_msg}, 500
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
from unittest.mock import Mock, patch
3+
import sys
4+
import os
5+
6+
# Add the src directory to the path
7+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
8+
9+
from main import refresh_materialized_view_function # noqa: E402
10+
11+
12+
class TestRefreshMaterializedView:
13+
"""Test class for the refresh materialized view function."""
14+
15+
@patch("main.refresh_materialized_view")
16+
def test_refresh_view_success(self, mock_refresh):
17+
"""Test successful view refresh."""
18+
mock_refresh.return_value = True
19+
20+
# Mock request object
21+
request = Mock()
22+
request.method = "GET"
23+
24+
# Mock database session
25+
db_session = Mock()
26+
27+
# Call the function
28+
response, status_code = refresh_materialized_view_function(request, db_session)
29+
30+
# Assertions
31+
assert "Successfully refreshed materialized view" in response["message"]
32+
mock_refresh.assert_called_once_with(db_session, "my_materialized_view")
33+
34+
@patch("main.refresh_materialized_view")
35+
def test_refresh_view_failure(self, mock_refresh):
36+
"""Test failure to refresh the view."""
37+
mock_refresh.return_value = False
38+
39+
# Mock request object
40+
request = Mock()
41+
request.method = "GET"
42+
43+
# Mock database session
44+
db_session = Mock()
45+
46+
# Call the function
47+
response, status_code = refresh_materialized_view_function(request, db_session)
48+
49+
# Assertions
50+
assert status_code == 500
51+
assert "Failed to refresh materialized view" in response["error"]
52+
mock_refresh.assert_called_once_with(db_session, "my_materialized_view")
53+
54+
@patch("main.refresh_materialized_view")
55+
def test_refresh_view_exception(self, mock_refresh):
56+
"""Test exception during view refresh."""
57+
mock_refresh.side_effect = Exception("Database connection failed")
58+
59+
# Mock request object
60+
request = Mock()
61+
request.method = "GET"
62+
63+
# Mock database session
64+
db_session = Mock()
65+
66+
# Call the function
67+
response, status_code = refresh_materialized_view_function(request, db_session)
68+
69+
# Assertions
70+
assert status_code == 500
71+
assert "Error refreshing materialized view" in response["error"]
72+
assert "Database connection failed" in response["error"]
73+
mock_refresh.assert_called_once_with(db_session, "my_materialized_view")
74+
75+
76+
if __name__ == "__main__":
77+
pytest.main([__file__])

infra/functions-python/main.tf

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ locals {
6868

6969
function_tasks_executor_config = jsondecode(file("${path.module}/../../functions-python/tasks_executor/function_config.json"))
7070
function_tasks_executor_zip = "${path.module}/../../functions-python/tasks_executor/.dist/tasks_executor.zip"
71+
72+
function_refresh_materialized_view_config = jsondecode(file("${path.module}/../../functions-python/refresh_materialized_view/function_config.json"))
73+
function_refresh_materialized_view_zip = "${path.module}/../../functions-python/refresh_materialized_view/.dist/refresh_materialized_view.zip"
7174
}
7275

7376
locals {
@@ -81,7 +84,8 @@ locals {
8184
[for x in local.function_backfill_dataset_service_date_range_config.secret_environment_variables : x.key],
8285
[for x in local.function_update_feed_status_config.secret_environment_variables : x.key],
8386
[for x in local.function_export_csv_config.secret_environment_variables : x.key],
84-
[for x in local.function_tasks_executor_config.secret_environment_variables : x.key]
87+
[for x in local.function_tasks_executor_config.secret_environment_variables : x.key],
88+
[for x in local.function_refresh_materialized_view_config.secret_environment_variables : x.key]
8589
)
8690

8791
# Convert the list to a set to ensure uniqueness
@@ -227,6 +231,13 @@ resource "google_storage_bucket_object" "tasks_executor_zip" {
227231
source = local.function_tasks_executor_zip
228232
}
229233

234+
# 15. Refresh Materialized View
235+
resource "google_storage_bucket_object" "refresh_materialized_view_zip" {
236+
bucket = google_storage_bucket.functions_bucket.name
237+
name = "refresh-materialized-view-${substr(filebase64sha256(local.function_refresh_materialized_view_zip), 0, 10)}.zip"
238+
source = local.function_refresh_materialized_view_zip
239+
}
240+
230241
# Secrets access
231242
resource "google_secret_manager_secret_iam_member" "secret_iam_member" {
232243
for_each = local.unique_secret_keys
@@ -1469,6 +1480,14 @@ resource "google_cloudfunctions2_function_iam_member" "update_feed_status_invoke
14691480
member = "serviceAccount:${google_service_account.functions_service_account.email}"
14701481
}
14711482

1483+
resource "google_cloudfunctions2_function_iam_member" "refresh_materialized_view_invoker" {
1484+
project = var.project_id
1485+
location = var.gcp_region
1486+
cloud_function = google_cloudfunctions2_function.refresh_materialized_view.name
1487+
role = "roles/cloudfunctions.invoker"
1488+
member = "serviceAccount:${google_service_account.functions_service_account.email}"
1489+
}
1490+
14721491
# Grant permissions to the service account to create bigquery jobs
14731492
resource "google_project_iam_member" "bigquery_job_user" {
14741493
project = var.project_id

0 commit comments

Comments
 (0)