mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Mechanically re-derived: bin/debrand runs over a fresh import of posthog/posthog@fa1a9a60d4 (2026-08-06). The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md and the squashed initial migration are restored from the shipped tree, same as the previous import. The prune's import-closure guard kept 21 products the new core now imports; dropping them is a refactor now, recorded rather than forced.
1056 lines
44 KiB
Python
1056 lines
44 KiB
Python
"""Endpoint run-path orchestration.
|
|
|
|
``EndpointExecutionService`` owns everything that happens after the viewset has
|
|
authenticated the caller and parsed the request: run-request validation, choosing
|
|
between materialized / inline / DuckLake execution, query-service invocation,
|
|
response post-processing, metrics, and failure signals. Kind-specific behavior
|
|
(variables, WHERE building, response re-shaping) is delegated to the
|
|
``EndpointQueryStrategy`` classes.
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from typing import Literal, Union, cast
|
|
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
|
|
import structlog
|
|
import hanzo_insights
|
|
from asgiref.sync import async_to_sync
|
|
from dateutil.parser import isoparse
|
|
from pydantic import BaseModel
|
|
from rest_framework import status
|
|
from rest_framework.authentication import SessionAuthentication
|
|
from rest_framework.exceptions import Throttled, ValidationError
|
|
from rest_framework.request import Request
|
|
from rest_framework.response import Response
|
|
|
|
from insights.schema import (
|
|
EndpointRefreshMode,
|
|
EndpointRunRequest,
|
|
InsightsQLQuery,
|
|
InsightsQLQueryModifiers,
|
|
InsightsQLVariable,
|
|
QueryRequest,
|
|
RefreshType,
|
|
)
|
|
|
|
from insights.insightsql.errors import ExposedInsightsQLError, ResolutionError
|
|
|
|
from insights.api.mixins import PydanticModelMixin
|
|
from insights.api.query import _process_query_request
|
|
from insights.api.services.query import process_query_model
|
|
from insights.datastore.client.connection import Workload
|
|
from insights.datastore.client.limit import ConcurrencyLimitExceeded
|
|
from insights.datastore.query_tagging import (
|
|
Feature,
|
|
Product,
|
|
get_query_tag_value,
|
|
is_api_key_access_method,
|
|
tag_queries,
|
|
)
|
|
from insights.errors import ExposedCHQueryError
|
|
from insights.event_usage import get_request_analytics_properties, report_user_action
|
|
from insights.exceptions import (
|
|
DatastoreAtCapacity,
|
|
DatastoreEstimatedQueryExecutionTimeTooLong,
|
|
DatastoreQueryMemoryLimitExceeded,
|
|
DatastoreQuerySizeExceeded,
|
|
DatastoreQueryTimeOut,
|
|
)
|
|
from insights.exceptions_capture import capture_exception
|
|
from insights.models import Team, User
|
|
from insights.permissions import is_authenticated_via_project_secret_api_key
|
|
from insights.schema_migrations.upgrade import upgrade
|
|
from insights.synthetic_user import SyntheticUser
|
|
|
|
from products.data_modeling.backend.facade.api import saved_query_materialized_at
|
|
from products.data_modeling.backend.facade.models import DataWarehouseSavedQuery
|
|
from products.data_warehouse.backend.facade.api import trigger_saved_query_schedule
|
|
from products.endpoints.backend.exceptions import EndpointAtCapacity, EndpointQueryTooExpensive
|
|
from products.endpoints.backend.insight_transformers import MaterializedSeriesMismatchError
|
|
from products.endpoints.backend.logic.pagination import EndpointPagination
|
|
from products.endpoints.backend.logic.strategies import EndpointQueryStrategy, strategy_for
|
|
from products.endpoints.backend.logs import build_execution_message, log_endpoint_execution
|
|
from products.endpoints.backend.metrics import (
|
|
ENDPOINT_CACHE_RESULT_TOTAL,
|
|
ENDPOINT_CONCURRENCY_REJECTED_TOTAL,
|
|
ENDPOINT_EXECUTION_DURATION_SECONDS,
|
|
ENDPOINT_EXECUTION_TOTAL,
|
|
ENDPOINT_INSIGHTSQL_RESULT_ROWS,
|
|
ENDPOINT_MATERIALIZED_FRESHNESS_RATIO,
|
|
ENDPOINT_VALIDATION_ERROR_TOTAL,
|
|
query_kind_label,
|
|
)
|
|
from products.endpoints.backend.models import Endpoint, EndpointVersion
|
|
from products.endpoints.backend.tasks import shadow_compare_ducklake_execution
|
|
from products.managed_warehouse.backend.facade.api import is_dev_mode
|
|
|
|
from common.scriptvm.python.utils import ScriptVMException
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
LAST_EXECUTED_THROTTLE = timedelta(minutes=30)
|
|
|
|
ExecutionType = Literal["materialized", "materialized_fallback", "inline"]
|
|
|
|
# Deterministic, customer-caused Datastore cost guardrails → stable client `code` + remediation.
|
|
# Kept out of status="error" so they don't page on-call; surfaced as an actionable 400.
|
|
_QUERY_PERFORMANCE_ERRORS: dict[type[Exception], tuple[str, str]] = {
|
|
DatastoreQueryTimeOut: (
|
|
"query_timeout",
|
|
"This query exceeded the execution time limit. Narrow its scope (e.g. a smaller date range) "
|
|
"or materialize the endpoint for dedicated compute.",
|
|
),
|
|
DatastoreQueryMemoryLimitExceeded: (
|
|
"query_memory_limit",
|
|
"This query exceeded the memory limit. Narrow its scope (e.g. a smaller date range) "
|
|
"or materialize the endpoint.",
|
|
),
|
|
DatastoreQuerySizeExceeded: (
|
|
"query_too_large",
|
|
# No materialize hint: it re-parses the same SQL, so it can't shrink an oversized query.
|
|
"The query text is too large to execute. Simplify the query.",
|
|
),
|
|
DatastoreEstimatedQueryExecutionTimeTooLong: (
|
|
"query_estimated_too_slow",
|
|
"This query is estimated to run too long. Reduce its scope by narrowing the time range "
|
|
"or materialize the endpoint.",
|
|
),
|
|
}
|
|
|
|
# Cost guardrails + transient capacity: customer problems, not faults. Skipped from capture and
|
|
# re-raised past the materialized/ducklake inline fallback.
|
|
_QUERY_GUARDRAIL_ERRORS: tuple[type[Exception], ...] = (*_QUERY_PERFORMANCE_ERRORS, DatastoreAtCapacity)
|
|
|
|
|
|
def _is_query_guardrail_error(error: BaseException) -> bool:
|
|
return isinstance(error, _QUERY_GUARDRAIL_ERRORS)
|
|
|
|
|
|
def _query_performance_code_and_detail(error: BaseException) -> tuple[str, str]:
|
|
# isinstance, not dict[type(e)], so a future subclass can't KeyError into a 500.
|
|
for exc_type, code_and_detail in _QUERY_PERFORMANCE_ERRORS.items():
|
|
if isinstance(error, exc_type):
|
|
return code_and_detail
|
|
raise error
|
|
|
|
|
|
def _emit_endpoint_failure_signal(
|
|
team,
|
|
endpoint: Endpoint,
|
|
exc: BaseException,
|
|
*,
|
|
materialized: bool,
|
|
version: int | None = None,
|
|
saved_query_id: Union[str, uuid.UUID, None] = None,
|
|
query_kind: str | None = None,
|
|
executed_sql: str | None = None,
|
|
saved_query_status: str | None = None,
|
|
saved_query_last_run_at: str | None = None,
|
|
saved_query_columns: dict | None = None,
|
|
endpoint_columns: list | None = None,
|
|
) -> None:
|
|
"""Fire a Signal when an endpoint execution fails, so the AI can reason about it later.
|
|
|
|
Fails silently — signal emission must never mask the underlying error.
|
|
"""
|
|
from products.signals.backend.facade.api import emit_signal
|
|
|
|
try:
|
|
error_class = type(exc).__name__
|
|
error_msg = str(exc)
|
|
version_str = f" v{version}" if version else ""
|
|
|
|
if materialized:
|
|
execution_mode = "materialized"
|
|
context = (
|
|
f"The materialized table (saved_query_id={saved_query_id}) may be stale, missing, or have a schema mismatch. "
|
|
f"Check whether the materialization refresh completed successfully and whether the underlying query still produces valid columns."
|
|
)
|
|
if saved_query_status:
|
|
context += f"\nSaved query status: {saved_query_status}"
|
|
if saved_query_last_run_at:
|
|
context += f", last materialized at: {saved_query_last_run_at}"
|
|
if saved_query_columns:
|
|
context += f"\nMaterialized table columns: {saved_query_columns}"
|
|
if endpoint_columns:
|
|
context += f"\nEndpoint version columns: {endpoint_columns}"
|
|
else:
|
|
execution_mode = "inline"
|
|
context = (
|
|
f"The query is executed on-demand against live data. "
|
|
f"Common causes: invalid InsightsQL syntax, missing or renamed properties, query timeout, or incompatible variable overrides."
|
|
)
|
|
|
|
parts = [
|
|
f"Endpoint '{endpoint.name}'{version_str} failed during {execution_mode} execution.",
|
|
f"Error: {error_class}: {error_msg}",
|
|
]
|
|
if query_kind:
|
|
parts.append(f"Query kind: {query_kind}")
|
|
if executed_sql:
|
|
parts.append(f"Executed InsightsQL: {executed_sql}")
|
|
parts.append(context)
|
|
parts.append(f"Endpoint path: {endpoint.endpoint_path}")
|
|
description = "\n".join(parts)
|
|
|
|
async_to_sync(emit_signal)(
|
|
team=team,
|
|
source_product="endpoints",
|
|
source_type="endpoint_execution_failed",
|
|
source_id=f"{team.id}:{endpoint.name}",
|
|
description=description,
|
|
weight=0.5,
|
|
extra={
|
|
"endpoint_name": endpoint.name,
|
|
"endpoint_version": version,
|
|
"materialized": materialized,
|
|
"saved_query_id": str(saved_query_id) if saved_query_id else None,
|
|
"error_class": error_class,
|
|
"error_message": error_msg,
|
|
},
|
|
)
|
|
except Exception as signal_exc:
|
|
logger.exception(
|
|
"Failed to emit endpoint failure signal",
|
|
endpoint_name=endpoint.name,
|
|
team_id=team.id,
|
|
signal_error_class=type(signal_exc).__name__,
|
|
signal_error=str(signal_exc),
|
|
)
|
|
capture_exception(
|
|
signal_exc,
|
|
{
|
|
"product": Product.ENDPOINTS,
|
|
"team_id": team.id,
|
|
"endpoint_name": endpoint.name,
|
|
"signal_emission": True,
|
|
},
|
|
)
|
|
|
|
|
|
def _endpoint_refresh_mode_to_refresh_type(
|
|
mode: EndpointRefreshMode | None,
|
|
) -> RefreshType:
|
|
"""
|
|
Map EndpointRefreshMode to RefreshType.
|
|
|
|
- cache -> blocking
|
|
- force/direct -> force_blocking (materialization bypass handled in should_use_materialized_table)
|
|
"""
|
|
if mode is None or mode == EndpointRefreshMode.CACHE:
|
|
return RefreshType.BLOCKING
|
|
return RefreshType.FORCE_BLOCKING
|
|
|
|
|
|
class EndpointExecutionService(PydanticModelMixin):
|
|
"""Executes an endpoint version, choosing the best execution path."""
|
|
|
|
def __init__(self, team: Team, request: Request):
|
|
self.team = team
|
|
self.request = request
|
|
|
|
# ------------------------------------------------------------------
|
|
# Validation
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def format_validation_detail(detail: object) -> str:
|
|
"""Flatten a DRF ValidationError detail into one line for logs."""
|
|
if isinstance(detail, dict):
|
|
return "; ".join(
|
|
f"{field}: {EndpointExecutionService.format_validation_detail(messages)}"
|
|
for field, messages in detail.items()
|
|
)
|
|
if isinstance(detail, list):
|
|
return "; ".join(EndpointExecutionService.format_validation_detail(item) for item in detail)
|
|
return str(detail)
|
|
|
|
def log_rejected_run(self, endpoint: Endpoint, reason: str) -> None:
|
|
log_endpoint_execution(
|
|
team_id=self.team.pk,
|
|
endpoint_id=str(endpoint.id),
|
|
instance_id=str(uuid.uuid4()),
|
|
level="ERROR",
|
|
message=f"Endpoint execution failed · invalid request · {reason}",
|
|
)
|
|
|
|
def validate_run_request(
|
|
self,
|
|
data: EndpointRunRequest,
|
|
endpoint: Endpoint,
|
|
version: EndpointVersion,
|
|
offset: int | None = None,
|
|
) -> None:
|
|
strategy = strategy_for(endpoint, version, self.team)
|
|
|
|
is_materialized = bool(version.is_materialized and version.saved_query)
|
|
|
|
if not version.is_active:
|
|
ENDPOINT_VALIDATION_ERROR_TOTAL.labels(reason="inactive_version").inc()
|
|
raise ValidationError(f"Version {version.version} is inactive and cannot be executed.")
|
|
|
|
# Reject query_override (always)
|
|
if hasattr(data, "query_override") and data.query_override is not None:
|
|
raise ValidationError({"query_override": "Not allowed. Use variables instead."})
|
|
|
|
# Allow filters_override for insight endpoints (deprecated but backwards compatible)
|
|
# Reject for InsightsQL endpoints
|
|
if data.filters_override is not None:
|
|
if strategy.query_kind == "InsightsQLQuery":
|
|
raise ValidationError({"filters_override": "Not allowed for InsightsQL endpoints. Use variables instead."})
|
|
|
|
if offset is not None and not strategy.supports_pagination:
|
|
raise ValidationError({"offset": "offset is only supported for InsightsQL endpoints"})
|
|
|
|
# Validate refresh mode
|
|
if data.refresh == EndpointRefreshMode.DIRECT and not is_materialized:
|
|
ENDPOINT_VALIDATION_ERROR_TOTAL.labels(reason="direct_refresh_not_materialized").inc()
|
|
raise ValidationError(
|
|
{
|
|
"refresh": "'direct' refresh mode is only valid for materialized endpoints. "
|
|
"Use 'cache' or 'force' instead, or enable materialization on this endpoint."
|
|
}
|
|
)
|
|
|
|
# Validate variables
|
|
if data.variables:
|
|
allowed_vars = strategy.allowed_variables(is_materialized)
|
|
unknown_vars = set(data.variables.keys()) - allowed_vars
|
|
if unknown_vars:
|
|
ENDPOINT_VALIDATION_ERROR_TOTAL.labels(reason="unknown_variable").inc()
|
|
raise ValidationError({"variables": f"Unknown variable(s): {', '.join(sorted(unknown_vars))}"})
|
|
|
|
# SECURITY: For materialized endpoints with required variables, ALL must be provided.
|
|
# Without this check, omitting variables would return ALL data instead of filtered data.
|
|
# filters_override (deprecated) only counts when it actually applies the breakdown filter —
|
|
# a property with no usable value adds no WHERE clause and must not bypass this check.
|
|
if is_materialized and not strategy.materialized_filters_override_satisfies_required(data):
|
|
required_vars = strategy.required_materialized_variables()
|
|
if required_vars:
|
|
provided = {key for key, value in (data.variables or {}).items() if value is not None}
|
|
missing = sorted(required_vars - provided)
|
|
if missing:
|
|
ENDPOINT_VALIDATION_ERROR_TOTAL.labels(reason="missing_required_variable").inc()
|
|
raise ValidationError(
|
|
{"variables": f"Required variable(s) {', '.join(repr(v) for v in missing)} not provided"}
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Execution path selection
|
|
# ------------------------------------------------------------------
|
|
|
|
def should_use_materialized_table(
|
|
self,
|
|
endpoint: Endpoint,
|
|
data: EndpointRunRequest,
|
|
version: EndpointVersion,
|
|
materialized_at: datetime | None,
|
|
) -> bool:
|
|
"""
|
|
Decide whether to use materialized table or inline execution.
|
|
|
|
Reads materialization state from the DB — the authoritative source. (The redis
|
|
"materialization ready" cache in rate_limit.py only classifies requests for
|
|
throttling and is intentionally not consulted here.)
|
|
|
|
Returns False if:
|
|
- Not materialized
|
|
- Materialization incomplete/failed
|
|
- Materialized data is stale (older than the version's data freshness target)
|
|
- User overrides present (variables, query)
|
|
- 'direct' mode requested (explicitly bypass materialization)
|
|
"""
|
|
if not version.is_materialized or not version.saved_query:
|
|
return False
|
|
|
|
saved_query = version.saved_query
|
|
if saved_query.status != DataWarehouseSavedQuery.Status.COMPLETED:
|
|
return False
|
|
|
|
if not saved_query.table:
|
|
return False
|
|
|
|
# Check if materialized data is stale. Keyed on the version's freshness target, not
|
|
# saved_query.sync_frequency_interval — the v2 schedule migration nulls that field.
|
|
if materialized_at and version.data_freshness_seconds:
|
|
next_refresh_due = materialized_at + timedelta(seconds=version.data_freshness_seconds)
|
|
if timezone.now() >= next_refresh_due:
|
|
return False
|
|
|
|
# 'direct' mode explicitly bypasses materialization to run the original query
|
|
if data.refresh == EndpointRefreshMode.DIRECT:
|
|
return False
|
|
|
|
# Check if variables are valid for materialized execution
|
|
if data.variables:
|
|
strategy = strategy_for(endpoint, version, self.team)
|
|
if not strategy.can_serve_variables_from_materialized(set(data.variables.keys())):
|
|
return False
|
|
|
|
return True
|
|
|
|
def _should_shadow_ducklake(self, endpoint: Endpoint, version: EndpointVersion | None) -> bool:
|
|
# Flag is scoped to orgs with a duckgres server; the worker re-checks before querying.
|
|
if version is None or version.query.get("kind") != "InsightsQLQuery":
|
|
return False
|
|
|
|
# is_dev_mode() is also true under the test suite (USE_LOCAL_SETUP = TEST or ...); never shadow
|
|
# there — it builds a userless InsightsQL database and runs eagerly under Celery, breaking unrelated runs.
|
|
if is_dev_mode() and not settings.TEST:
|
|
return True
|
|
|
|
return bool(
|
|
hanzo_insights.feature_enabled(
|
|
"endpoints-ducklake-shadow-execution",
|
|
str(self.team.uuid),
|
|
groups={
|
|
"organization": str(self.team.organization_id),
|
|
"project": str(self.team.id),
|
|
},
|
|
group_properties={
|
|
"organization": {"id": str(self.team.organization_id)},
|
|
"project": {"id": str(self.team.id)},
|
|
},
|
|
only_evaluate_locally=True,
|
|
send_feature_flag_events=False,
|
|
)
|
|
)
|
|
|
|
def _maybe_shadow_ducklake(
|
|
self,
|
|
endpoint: Endpoint,
|
|
data: EndpointRunRequest,
|
|
version: EndpointVersion,
|
|
*,
|
|
execution_type: ExecutionType,
|
|
datastore_ms: float,
|
|
datastore_cached: bool,
|
|
datastore_row_count: int | None,
|
|
limit: int | None,
|
|
offset: int | None,
|
|
) -> None:
|
|
"""Fire-and-forget DuckLake shadow for timing comparison; never affects the response."""
|
|
# Only shadow what Datastore actually executed: an inline, non-cached run. Cache hits
|
|
# and materialized reads have no comparable DuckLake query and would amplify cost.
|
|
if execution_type != "inline" or datastore_cached:
|
|
return
|
|
if not self._should_shadow_ducklake(endpoint, version):
|
|
return
|
|
try:
|
|
shadow_compare_ducklake_execution.delay(
|
|
team_id=self.team.pk,
|
|
endpoint_id=str(endpoint.id),
|
|
version_id=str(version.id),
|
|
variables=data.variables,
|
|
execution_type=execution_type,
|
|
datastore_cached=datastore_cached,
|
|
datastore_ms=datastore_ms,
|
|
datastore_row_count=datastore_row_count,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
except Exception:
|
|
logger.warning("Failed to dispatch DuckLake shadow comparison", endpoint_name=endpoint.name)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Top-level entry point
|
|
# ------------------------------------------------------------------
|
|
|
|
def execute(
|
|
self,
|
|
endpoint: Endpoint,
|
|
data: EndpointRunRequest,
|
|
version_obj: EndpointVersion | None,
|
|
limit: int | None = None,
|
|
offset: int | None = None,
|
|
) -> Response:
|
|
"""Run an endpoint version and return the HTTP response.
|
|
|
|
Assumes the caller has resolved the version (or established that none was
|
|
requested explicitly) and parsed limit/offset.
|
|
"""
|
|
if version_obj is None:
|
|
return Response(
|
|
{"error": "No version found for this endpoint"},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
try:
|
|
self.validate_run_request(data, endpoint, version_obj, offset=offset)
|
|
except ValidationError as exc:
|
|
self.log_rejected_run(endpoint, self.format_validation_detail(exc.detail))
|
|
raise
|
|
|
|
# Computed once per request: the gate and the materialized path both need it.
|
|
# v2 never writes saved_query.last_run_at; the helper derives from DataModelingJob.
|
|
materialized_at = (
|
|
saved_query_materialized_at(version_obj.saved_query)
|
|
if version_obj.is_materialized and version_obj.saved_query
|
|
else None
|
|
)
|
|
use_materialized = self.should_use_materialized_table(endpoint, data, version_obj, materialized_at)
|
|
|
|
report_user_action(
|
|
cast("User | SyntheticUser", self.request.user),
|
|
"endpoint executed",
|
|
{
|
|
"endpoint_id": str(endpoint.id),
|
|
"endpoint_name": endpoint.name,
|
|
"is_materialized": use_materialized,
|
|
"has_filters_override": bool(data.filters_override),
|
|
"has_variables": bool(data.variables),
|
|
"has_limit": data.limit is not None,
|
|
"has_offset": data.offset is not None,
|
|
"refresh_mode": data.refresh.value if data.refresh else None,
|
|
"auth_method": "project_secret_api_key"
|
|
if is_authenticated_via_project_secret_api_key(self.request)
|
|
else "user",
|
|
},
|
|
team=self.team,
|
|
analytics_props=get_request_analytics_properties(self.request),
|
|
)
|
|
|
|
debug = data.debug or False
|
|
execution_type: ExecutionType = "materialized" if use_materialized else "inline"
|
|
query_kind_metric = query_kind_label(version_obj.query)
|
|
execution_status: str | None = None
|
|
execution_id = str(uuid.uuid4())
|
|
error_label: str | None = None
|
|
_start_time = time.monotonic()
|
|
_duration = 0.0
|
|
_ch_query_ms = 0.0
|
|
|
|
_ch_query_start = time.monotonic()
|
|
try:
|
|
result: Response | None = None
|
|
materialized_failed = False
|
|
if use_materialized:
|
|
try:
|
|
result = self._execute_materialized_endpoint(
|
|
endpoint,
|
|
data,
|
|
version=version_obj,
|
|
materialized_at=materialized_at,
|
|
debug=debug,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
except ConcurrencyLimitExceeded:
|
|
raise
|
|
except Exception:
|
|
# Already logged/captured/signaled inside the materialized path. Re-run
|
|
# inline: only stamp materialized_fallback once inline succeeds, because
|
|
# only an inline success proves the materialized table was the sole thing
|
|
# broken. If inline also fails the request was never recoverable (a bad
|
|
# query fails on both paths) — that's an inline failure, not a fallback.
|
|
materialized_failed = True
|
|
execution_type = "inline"
|
|
result = None
|
|
|
|
if result is None:
|
|
result = self._execute_inline_endpoint(
|
|
endpoint,
|
|
data,
|
|
version_obj.query.copy(),
|
|
version=version_obj,
|
|
debug=debug,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
if materialized_failed:
|
|
execution_type = "materialized_fallback"
|
|
# Query-only wall-clock, to compare fairly with the DuckLake shadow.
|
|
_ch_query_ms = (time.monotonic() - _ch_query_start) * 1000
|
|
execution_status = "success"
|
|
except (ExposedInsightsQLError, ExposedCHQueryError) as e:
|
|
execution_status = "user_error"
|
|
error_label = getattr(e, "code_name", None) or type(e).__name__
|
|
logger.exception(
|
|
"Endpoint execution failed",
|
|
endpoint_name=endpoint.name,
|
|
code_name=getattr(e, "code_name", None),
|
|
)
|
|
raise ValidationError(str(e), getattr(e, "code_name", None))
|
|
except ScriptVMException:
|
|
execution_status = "user_error"
|
|
error_label = "ScriptVMException"
|
|
logger.exception(
|
|
"Endpoint execution failed (ScriptVM)",
|
|
endpoint_name=endpoint.name,
|
|
)
|
|
raise ValidationError("Query execution failed: InsightsQL virtual machine error")
|
|
except ResolutionError:
|
|
execution_status = "user_error"
|
|
error_label = "ResolutionError"
|
|
logger.exception(
|
|
"Endpoint resolution failed",
|
|
endpoint_name=endpoint.name,
|
|
)
|
|
raise ValidationError("Query resolution failed: unable to resolve table or field references.")
|
|
except ConcurrencyLimitExceeded:
|
|
ENDPOINT_CONCURRENCY_REJECTED_TOTAL.labels(team_id=str(self.team.pk)).inc()
|
|
raise Throttled(detail="Too many concurrent requests. Please try again later.")
|
|
except tuple(_QUERY_PERFORMANCE_ERRORS) as e:
|
|
execution_status = "query_performance"
|
|
error_label = type(e).__name__
|
|
code, detail = _query_performance_code_and_detail(e)
|
|
logger.warning("Endpoint query hit a performance limit", endpoint_name=endpoint.name, reason=error_label)
|
|
raise EndpointQueryTooExpensive(detail, code=code)
|
|
except DatastoreAtCapacity:
|
|
execution_status = "capacity"
|
|
error_label = "DatastoreAtCapacity"
|
|
logger.warning("Endpoint query hit shared Datastore capacity", endpoint_name=endpoint.name)
|
|
raise EndpointAtCapacity()
|
|
except Exception as e:
|
|
execution_status = "error"
|
|
error_label = type(e).__name__
|
|
raise
|
|
finally:
|
|
if execution_status is not None:
|
|
_duration = time.monotonic() - _start_time
|
|
ENDPOINT_EXECUTION_DURATION_SECONDS.labels(
|
|
execution_type=execution_type, query_kind=query_kind_metric
|
|
).observe(_duration)
|
|
ENDPOINT_EXECUTION_TOTAL.labels(
|
|
execution_type=execution_type, query_kind=query_kind_metric, status=execution_status
|
|
).inc()
|
|
if execution_status in ("error", "user_error", "query_performance", "capacity"):
|
|
log_endpoint_execution(
|
|
team_id=self.team.pk,
|
|
endpoint_id=str(endpoint.id),
|
|
instance_id=execution_id,
|
|
level="ERROR",
|
|
message=build_execution_message(
|
|
succeeded=False,
|
|
execution_type=execution_type,
|
|
version=version_obj.version,
|
|
error=error_label,
|
|
),
|
|
)
|
|
|
|
cache_outcome, result_row_count = self._record_result_metrics(result, execution_type, query_kind_metric)
|
|
log_endpoint_execution(
|
|
team_id=self.team.pk,
|
|
endpoint_id=str(endpoint.id),
|
|
instance_id=execution_id,
|
|
level="INFO",
|
|
message=build_execution_message(
|
|
succeeded=True,
|
|
execution_type=execution_type,
|
|
cache_outcome=cache_outcome,
|
|
duration_ms=round(_duration * 1000),
|
|
rows=result_row_count,
|
|
version=version_obj.version,
|
|
),
|
|
)
|
|
self._track_last_executed(endpoint, version_obj)
|
|
|
|
self._maybe_shadow_ducklake(
|
|
endpoint,
|
|
data,
|
|
version_obj,
|
|
execution_type=execution_type,
|
|
datastore_ms=_ch_query_ms,
|
|
datastore_cached=cache_outcome == "hit",
|
|
datastore_row_count=result_row_count,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
if isinstance(result.data, dict):
|
|
result.data["name"] = endpoint.name
|
|
result.data["execution_id"] = execution_id
|
|
result.data["endpoint_version"] = version_obj.version
|
|
result.data["endpoint_version_created_at"] = version_obj.created_at.isoformat()
|
|
|
|
return result
|
|
|
|
def _record_result_metrics(
|
|
self, result: Response, execution_type: str, query_kind_metric: str
|
|
) -> tuple[str | None, int | None]:
|
|
cache_outcome: str | None = None
|
|
result_row_count: int | None = None
|
|
try:
|
|
if isinstance(result.data, dict):
|
|
cache_outcome = "hit" if bool(result.data.get("is_cached")) else "miss"
|
|
ENDPOINT_CACHE_RESULT_TOTAL.labels(
|
|
execution_type=execution_type, query_kind=query_kind_metric, outcome=cache_outcome
|
|
).inc()
|
|
|
|
results_value = result.data.get("results")
|
|
if isinstance(results_value, list):
|
|
result_row_count = len(results_value)
|
|
if query_kind_metric == "insightsql":
|
|
ENDPOINT_INSIGHTSQL_RESULT_ROWS.labels(execution_type=execution_type).observe(result_row_count)
|
|
except Exception:
|
|
logger.debug("Failed to record endpoint result metrics", exc_info=True)
|
|
return cache_outcome, result_row_count
|
|
|
|
def _track_last_executed(self, endpoint: Endpoint, version_obj: EndpointVersion) -> None:
|
|
"""Record last execution time (30-minute granularity, API key calls only)."""
|
|
if not is_api_key_access_method(get_query_tag_value("access_method")):
|
|
return
|
|
now = timezone.now()
|
|
if endpoint.last_executed_at is None or (now - endpoint.last_executed_at > LAST_EXECUTED_THROTTLE):
|
|
endpoint.last_executed_at = now
|
|
endpoint.save(update_fields=["last_executed_at"])
|
|
if version_obj.last_executed_at is None or (now - version_obj.last_executed_at > LAST_EXECUTED_THROTTLE):
|
|
version_obj.last_executed_at = now
|
|
version_obj.save(update_fields=["last_executed_at"])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Materialized execution
|
|
# ------------------------------------------------------------------
|
|
|
|
def _execute_materialized_endpoint(
|
|
self,
|
|
endpoint: Endpoint,
|
|
data: EndpointRunRequest,
|
|
version: EndpointVersion,
|
|
materialized_at: datetime | None,
|
|
debug: bool = False,
|
|
limit: int | None = None,
|
|
offset: int | None = None,
|
|
) -> Response:
|
|
"""Execute against a materialized table in S3."""
|
|
materialized_insightsql_query = None
|
|
query_kind = None
|
|
saved_query = version.saved_query
|
|
try:
|
|
if not saved_query:
|
|
raise ValidationError("No materialized query found for this endpoint")
|
|
|
|
strategy = strategy_for(endpoint, version, self.team)
|
|
query_kind = strategy.query_kind
|
|
|
|
select_query, original_limit = strategy.build_materialized_select(table_name=saved_query.name)
|
|
|
|
pagination: EndpointPagination | None = None
|
|
|
|
# Only paginate flat-row InsightsQL results. Insight types get transformed
|
|
# into nested structures where flat-row LIMIT/OFFSET is meaningless.
|
|
if limit is not None and strategy.supports_pagination:
|
|
pagination = EndpointPagination(limit=limit, offset=offset or 0, ceiling=original_limit)
|
|
pagination.apply_to(select_query)
|
|
|
|
deprecation_headers = strategy.apply_materialized_filters(select_query, data)
|
|
|
|
materialized_insightsql_query = InsightsQLQuery(
|
|
query=select_query.to_insightsql(),
|
|
modifiers=InsightsQLQueryModifiers(useMaterializedViews=True),
|
|
)
|
|
|
|
refresh_type = _endpoint_refresh_mode_to_refresh_type(data.refresh)
|
|
|
|
query_request_data = {
|
|
"client_query_id": data.client_query_id,
|
|
"name": f"{endpoint.name}_materialized",
|
|
"refresh": refresh_type,
|
|
"query": materialized_insightsql_query.model_dump(),
|
|
}
|
|
|
|
extra_fields = {
|
|
"endpoint_materialized": True,
|
|
"endpoint_materialized_at": materialized_at.isoformat() if materialized_at else None,
|
|
}
|
|
tag_queries(
|
|
workload=Workload.ENDPOINTS,
|
|
warehouse_query=True,
|
|
endpoint_version=version.version,
|
|
)
|
|
|
|
# Compute dynamic cache TTL: time remaining until data_freshness window expires
|
|
cache_ttl = None
|
|
if materialized_at and version.data_freshness_seconds:
|
|
remaining = (
|
|
materialized_at + timedelta(seconds=version.data_freshness_seconds) - timezone.now()
|
|
).total_seconds()
|
|
if remaining <= 0:
|
|
logger.warning(
|
|
"endpoint_materialization_behind_sla",
|
|
endpoint_name=endpoint.name,
|
|
team_id=self.team.pk,
|
|
data_freshness_seconds=version.data_freshness_seconds,
|
|
last_run_at=materialized_at.isoformat(),
|
|
remaining_seconds=remaining,
|
|
)
|
|
tag_queries(endpoint_materialization_behind=True)
|
|
cache_ttl = max(1, int(remaining)) # at least 1 second to enable caching
|
|
|
|
result = self._execute_query_and_respond(
|
|
query_request_data,
|
|
data.client_query_id,
|
|
cache_age_seconds=cache_ttl,
|
|
extra_result_fields=extra_fields,
|
|
debug=debug,
|
|
headers=deprecation_headers,
|
|
pagination=pagination,
|
|
)
|
|
|
|
if self._is_cache_stale(result, materialized_at):
|
|
query_request_data["refresh"] = RefreshType.FORCE_BLOCKING
|
|
result = self._execute_query_and_respond(
|
|
query_request_data,
|
|
data.client_query_id,
|
|
extra_result_fields=extra_fields,
|
|
debug=debug,
|
|
headers=deprecation_headers,
|
|
pagination=pagination,
|
|
)
|
|
|
|
if isinstance(result.data, dict):
|
|
strategy.clean_response_sentinels(result.data)
|
|
|
|
try:
|
|
strategy.transform_materialized_response(result.data, saved_query)
|
|
except MaterializedSeriesMismatchError:
|
|
# Series drift: query was likely edited after materialization. Trigger a refresh
|
|
# so future materialized reads succeed; the caller serves this request inline.
|
|
logger.warning(
|
|
"Materialized endpoint series mismatch, triggering re-materialization",
|
|
endpoint_name=endpoint.name,
|
|
saved_query_id=saved_query.id,
|
|
)
|
|
trigger_saved_query_schedule(saved_query)
|
|
raise
|
|
|
|
# Freshness relative to the configured target: >1.0 means behind SLA.
|
|
# Absolute age is meaningless across endpoints with different frequencies.
|
|
if materialized_at and version.data_freshness_seconds:
|
|
age_seconds = max((timezone.now() - materialized_at).total_seconds(), 0.0)
|
|
ENDPOINT_MATERIALIZED_FRESHNESS_RATIO.observe(age_seconds / version.data_freshness_seconds)
|
|
|
|
return result
|
|
except Exception as e:
|
|
# Guardrail errors are customer-caused, not faults: skip capture and let execute() classify them.
|
|
if _is_query_guardrail_error(e):
|
|
raise
|
|
logger.exception(
|
|
"Materialized endpoint execution failed",
|
|
endpoint_name=endpoint.name,
|
|
saved_query_id=saved_query.id if saved_query else None,
|
|
saved_query_status=saved_query.status if saved_query else None,
|
|
)
|
|
capture_exception(
|
|
e,
|
|
{
|
|
"product": Product.ENDPOINTS,
|
|
"team_id": self.team.pk,
|
|
"endpoint_name": endpoint.name,
|
|
"materialized": True,
|
|
"saved_query_id": saved_query.id if saved_query else None,
|
|
},
|
|
)
|
|
_emit_endpoint_failure_signal(
|
|
self.team,
|
|
endpoint,
|
|
e,
|
|
materialized=True,
|
|
version=version.version,
|
|
saved_query_id=saved_query.id if saved_query else None,
|
|
query_kind=query_kind,
|
|
executed_sql=materialized_insightsql_query.query if materialized_insightsql_query else None,
|
|
saved_query_status=saved_query.status if saved_query else None,
|
|
saved_query_last_run_at=(materialized_at.isoformat() if materialized_at else None),
|
|
saved_query_columns=saved_query.columns if saved_query else None,
|
|
endpoint_columns=version.columns,
|
|
)
|
|
raise
|
|
|
|
def _is_cache_stale(self, result: Response, materialized_at: datetime | None) -> bool:
|
|
"""Check if cached result is older than the materialization."""
|
|
if not isinstance(result.data, dict) or not result.data.get("is_cached"):
|
|
return False
|
|
|
|
last_refresh = result.data.get("last_refresh")
|
|
if not last_refresh or not materialized_at:
|
|
return False
|
|
|
|
if isinstance(last_refresh, str):
|
|
last_refresh = isoparse(last_refresh)
|
|
|
|
return last_refresh < materialized_at
|
|
|
|
# ------------------------------------------------------------------
|
|
# Inline + DuckLake execution
|
|
# ------------------------------------------------------------------
|
|
|
|
def _execute_inline_endpoint(
|
|
self,
|
|
endpoint: Endpoint,
|
|
data: EndpointRunRequest,
|
|
query: dict,
|
|
version: EndpointVersion,
|
|
debug: bool = False,
|
|
limit: int | None = None,
|
|
offset: int | None = None,
|
|
) -> Response:
|
|
"""Execute query directly against Datastore."""
|
|
strategy: EndpointQueryStrategy | None = None
|
|
try:
|
|
strategy = strategy_for(endpoint, version, self.team)
|
|
|
|
# Stored snapshots may predate the current query schema
|
|
query = upgrade(query)
|
|
query = strategy.prepare_inline_query(query)
|
|
|
|
pagination: EndpointPagination | None = None
|
|
if limit is not None:
|
|
query, pagination = strategy.apply_pagination(query, limit, offset or 0)
|
|
|
|
refresh_type = _endpoint_refresh_mode_to_refresh_type(data.refresh)
|
|
|
|
plan = strategy.build_inline_plan(query, data)
|
|
|
|
query_request_data = {
|
|
"client_query_id": data.client_query_id,
|
|
"filters_override": plan.filters_override.model_dump() if plan.filters_override else None,
|
|
"name": endpoint.name,
|
|
"refresh": refresh_type,
|
|
"query": query,
|
|
}
|
|
|
|
cache_age = version.data_freshness_seconds
|
|
tag_queries(endpoint_version=version.version)
|
|
|
|
result = self._execute_query_and_respond(
|
|
query_request_data,
|
|
data.client_query_id,
|
|
variables_override=plan.variables_override,
|
|
cache_age_seconds=cache_age,
|
|
debug=debug,
|
|
headers=plan.deprecation_headers,
|
|
pagination=pagination,
|
|
)
|
|
|
|
if isinstance(result.data, dict):
|
|
strategy.clean_response_sentinels(result.data)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
self.handle_column_ch_error(e)
|
|
# Guardrail errors are customer-caused, not faults: skip capture and let execute() classify them.
|
|
if _is_query_guardrail_error(e):
|
|
raise
|
|
logger.exception(
|
|
"Inline endpoint execution failed",
|
|
endpoint_name=endpoint.name,
|
|
)
|
|
capture_exception(
|
|
e,
|
|
{
|
|
"product": Product.ENDPOINTS,
|
|
"team_id": self.team.pk,
|
|
"materialized": False,
|
|
"endpoint_name": endpoint.name,
|
|
},
|
|
)
|
|
query_kind = strategy.query_kind if strategy else query.get("kind")
|
|
_emit_endpoint_failure_signal(
|
|
self.team,
|
|
endpoint,
|
|
e,
|
|
materialized=False,
|
|
version=version.version,
|
|
query_kind=query_kind,
|
|
executed_sql=query.get("query") if query_kind == "InsightsQLQuery" else None,
|
|
endpoint_columns=version.columns,
|
|
)
|
|
raise
|
|
|
|
# ------------------------------------------------------------------
|
|
# Query service plumbing
|
|
# ------------------------------------------------------------------
|
|
|
|
def _is_interactive_session(self) -> bool:
|
|
"""Whether this request came from the Insights UI (session auth) rather than a
|
|
programmatic credential (personal API key, OAuth, project secret API key, ...).
|
|
|
|
Same idiom as get_event_source (insights/event_usage.py) minus its session-cookie
|
|
fallback, which could misclassify API-key requests sent from a logged-in browser.
|
|
"""
|
|
return isinstance(getattr(self.request, "successful_authenticator", None), SessionAuthentication)
|
|
|
|
def _execute_query_and_respond(
|
|
self,
|
|
query_request_data: dict,
|
|
client_query_id: str | None,
|
|
variables_override: list[InsightsQLVariable] | None = None,
|
|
cache_age_seconds: int | None = None,
|
|
extra_result_fields: dict | None = None,
|
|
debug: bool = False,
|
|
headers: dict[str, str] | None = None,
|
|
pagination: EndpointPagination | None = None,
|
|
) -> Response:
|
|
"""Shared query execution logic."""
|
|
merged_data = self.get_model(query_request_data, QueryRequest)
|
|
|
|
query, client_query_id, execution_mode = _process_query_request(
|
|
merged_data, self.team, client_query_id, self.request.user
|
|
)
|
|
self._tag_client_query_id(client_query_id)
|
|
endpoint_feature = Feature.ENDPOINT_PLAYGROUND if self._is_interactive_session() else Feature.ENDPOINT_EXECUTION
|
|
tag_queries(product=Product.ENDPOINTS, feature=endpoint_feature)
|
|
|
|
result = process_query_model(
|
|
self.team,
|
|
query,
|
|
variables_override=variables_override,
|
|
execution_mode=execution_mode,
|
|
query_id=client_query_id,
|
|
user=cast(User, self.request.user),
|
|
is_query_service=is_api_key_access_method(get_query_tag_value("access_method")),
|
|
cache_age_seconds=cache_age_seconds,
|
|
analytics_props=get_request_analytics_properties(self.request),
|
|
)
|
|
|
|
if isinstance(result, BaseModel):
|
|
result = result.model_dump(by_alias=True)
|
|
|
|
if isinstance(result, dict) and extra_result_fields:
|
|
result.update(extra_result_fields)
|
|
|
|
if not debug:
|
|
debug_fields_to_remove = [
|
|
"calculation_trigger",
|
|
"cache_key",
|
|
"explain",
|
|
"modifiers",
|
|
"resolved_date_range",
|
|
"timings",
|
|
"insightsql",
|
|
]
|
|
|
|
for field in debug_fields_to_remove:
|
|
result.pop(field, None)
|
|
|
|
if pagination and "results" in result:
|
|
pagination.process_results(result)
|
|
elif "results" in result:
|
|
result["hasMore"] = False
|
|
|
|
if "results" in result:
|
|
result = {"results": result.pop("results"), **result}
|
|
|
|
return Response(result, status=status.HTTP_200_OK, headers=headers)
|
|
|
|
def handle_column_ch_error(self, error) -> None:
|
|
if getattr(error, "message", None):
|
|
match = re.search(r"There's no column.*in table", error.message)
|
|
if match:
|
|
# TODO: remove once we support all column types
|
|
raise ValidationError(match.group(0) + ". Not all column types are fully supported yet.")
|
|
return
|
|
|
|
def _tag_client_query_id(self, query_id: str | None) -> None:
|
|
if query_id is None:
|
|
return
|
|
|
|
tag_queries(client_query_id=query_id)
|