Bound connector source previews
This commit is contained in:
@@ -7,6 +7,7 @@ from govoplan_core.core.datasources import (
|
||||
DatasourceOrigin,
|
||||
DatasourceOriginReadRequest,
|
||||
DatasourceOriginReadResult,
|
||||
DatasourceUnavailableError,
|
||||
DatasourceValidationError,
|
||||
)
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
@@ -15,6 +16,7 @@ from govoplan_core.core.tabular_sources import (
|
||||
TabularSourceAccessError,
|
||||
TabularSourceError,
|
||||
TabularSourceNotFoundError,
|
||||
TabularSourceUnavailableError,
|
||||
TabularSourceValidationError,
|
||||
)
|
||||
from govoplan_connectors.backend.tabular_sources import SqlTabularSourceProvider
|
||||
@@ -79,6 +81,8 @@ class ConnectorDatasourceOriginProvider:
|
||||
offset=request.offset,
|
||||
columns=request.columns,
|
||||
expected_fingerprint=request.expected_fingerprint,
|
||||
max_bytes=request.max_bytes,
|
||||
timeout_ms=request.timeout_ms,
|
||||
),
|
||||
)
|
||||
except TabularSourceError as exc:
|
||||
@@ -88,6 +92,12 @@ class ConnectorDatasourceOriginProvider:
|
||||
rows=result.rows,
|
||||
total_rows=result.total_rows,
|
||||
truncated=result.truncated,
|
||||
returned_bytes=result.returned_bytes,
|
||||
elapsed_ms=result.elapsed_ms,
|
||||
effective_row_limit=result.effective_row_limit,
|
||||
effective_byte_limit=result.effective_byte_limit,
|
||||
effective_timeout_ms=result.effective_timeout_ms,
|
||||
diagnostics=result.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +126,9 @@ def _origin(source: TabularSource) -> DatasourceOrigin:
|
||||
updated_at=source.updated_at,
|
||||
capabilities=source.capabilities,
|
||||
metadata=dict(source.metadata),
|
||||
source_mode=source.source_mode,
|
||||
pushdown=source.pushdown,
|
||||
health=source.health,
|
||||
)
|
||||
|
||||
|
||||
@@ -124,6 +137,8 @@ def _datasource_error(exc: TabularSourceError):
|
||||
return DatasourceAccessError(str(exc))
|
||||
if isinstance(exc, TabularSourceNotFoundError):
|
||||
return DatasourceNotFoundError(str(exc))
|
||||
if isinstance(exc, TabularSourceUnavailableError):
|
||||
return DatasourceUnavailableError(str(exc))
|
||||
if isinstance(exc, TabularSourceValidationError):
|
||||
return DatasourceValidationError(str(exc))
|
||||
return DatasourceValidationError(str(exc))
|
||||
|
||||
@@ -453,7 +453,9 @@ manifest = ModuleManifest(
|
||||
"Connectors owns endpoint discovery, authentication hand-off, transport limits, retries, and protocol health. "
|
||||
"Domain modules own field mapping, validation, reconciliation, and record mutation. The shared Core runtime "
|
||||
"contract reports redacted effects and diagnostics with source revisions, fingerprints, and immutable input hashes. "
|
||||
"A commit must reject stale, truncated, conflicting, or error-bearing previews, and credentials never appear in URLs or samples."
|
||||
"Tabular previews enforce effective row, serialized-byte, and elapsed-time ceilings and report limit truncation "
|
||||
"as structured diagnostics. A commit must reject stale, truncated, conflicting, or error-bearing previews, and "
|
||||
"credentials never appear in URLs or samples."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -468,8 +470,11 @@ manifest = ModuleManifest(
|
||||
body=(
|
||||
"Connectors owns source configuration, access checks, schema discovery, "
|
||||
"fingerprints, and bounded reads. Dataflow stores only opaque source "
|
||||
"references and expected fingerprints. The first executable provider "
|
||||
"imports immutable JSON or CSV snapshots and exposes them as Datasource "
|
||||
"references and expected fingerprints. Each source declares its live, "
|
||||
"cached, file-backed, or static mode, structured health, and supported "
|
||||
"projection, filter, aggregation, sorting, and pagination pushdown. The "
|
||||
"first executable provider imports immutable JSON or CSV snapshots, "
|
||||
"supports projection and pagination, and exposes them as Datasource "
|
||||
"origins. Database and API providers can implement the same origin "
|
||||
"contract without changing Datasources or Dataflow."
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ from govoplan_core.core.tabular_sources import (
|
||||
TabularSourceAccessError,
|
||||
TabularSourceError,
|
||||
TabularSourceNotFoundError,
|
||||
TabularSourceUnavailableError,
|
||||
)
|
||||
from govoplan_core.core.feeds import (
|
||||
FeedCapabilityError,
|
||||
@@ -38,6 +39,9 @@ from govoplan_connectors.backend.schemas import (
|
||||
SanctionsSourceResponse,
|
||||
SnapshotCreateRequest,
|
||||
TabularColumnResponse,
|
||||
TabularHealthResponse,
|
||||
TabularPreviewDiagnosticResponse,
|
||||
TabularPushdownResponse,
|
||||
TabularSourceDeleteResponse,
|
||||
TabularSourceListResponse,
|
||||
TabularSourcePreviewResponse,
|
||||
@@ -85,6 +89,11 @@ def _http_error(exc: TabularSourceError) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, TabularSourceAccessError):
|
||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
if isinstance(exc, TabularSourceUnavailableError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
)
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
|
||||
|
||||
|
||||
@@ -377,6 +386,8 @@ def api_preview_tabular_source(
|
||||
source_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
max_bytes: int = Query(default=1_000_000, ge=2, le=5_000_000),
|
||||
timeout_ms: int = Query(default=2_000, ge=1, le=10_000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> TabularSourcePreviewResponse:
|
||||
@@ -389,6 +400,8 @@ def api_preview_tabular_source(
|
||||
source_ref=f"snapshot:{source_id}",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
max_bytes=max_bytes,
|
||||
timeout_ms=timeout_ms,
|
||||
),
|
||||
)
|
||||
except TabularSourceError as exc:
|
||||
@@ -398,6 +411,20 @@ def api_preview_tabular_source(
|
||||
rows=[dict(row) for row in result.rows],
|
||||
total_rows=result.total_rows,
|
||||
truncated=result.truncated,
|
||||
returned_bytes=result.returned_bytes,
|
||||
elapsed_ms=result.elapsed_ms,
|
||||
effective_row_limit=result.effective_row_limit,
|
||||
effective_byte_limit=result.effective_byte_limit,
|
||||
effective_timeout_ms=result.effective_timeout_ms,
|
||||
diagnostics=[
|
||||
TabularPreviewDiagnosticResponse(
|
||||
severity=item.severity,
|
||||
code=item.code,
|
||||
message=item.message,
|
||||
details=dict(item.details),
|
||||
)
|
||||
for item in result.diagnostics
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -604,6 +631,25 @@ def _source_response(source: TabularSource) -> TabularSourceResponse:
|
||||
updated_at=source.updated_at.isoformat() if source.updated_at else None,
|
||||
capabilities=list(source.capabilities),
|
||||
metadata=dict(source.metadata),
|
||||
source_mode=source.source_mode,
|
||||
pushdown=TabularPushdownResponse(
|
||||
projections=source.pushdown.projections,
|
||||
pagination=source.pushdown.pagination,
|
||||
filters=list(source.pushdown.filters),
|
||||
aggregations=list(source.pushdown.aggregations),
|
||||
sorting=list(source.pushdown.sorting),
|
||||
),
|
||||
health=TabularHealthResponse(
|
||||
status=source.health.status,
|
||||
code=source.health.code,
|
||||
summary=source.health.summary,
|
||||
checked_at=(
|
||||
source.health.checked_at.isoformat()
|
||||
if source.health.checked_at
|
||||
else None
|
||||
),
|
||||
details=dict(source.health.details),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -100,6 +100,29 @@ class TabularColumnResponse(BaseModel):
|
||||
nullable: bool
|
||||
|
||||
|
||||
class TabularPushdownResponse(BaseModel):
|
||||
projections: bool
|
||||
pagination: bool
|
||||
filters: list[str]
|
||||
aggregations: list[str]
|
||||
sorting: list[str]
|
||||
|
||||
|
||||
class TabularHealthResponse(BaseModel):
|
||||
status: Literal["healthy", "warning", "error", "unknown"]
|
||||
code: str
|
||||
summary: str
|
||||
checked_at: str | None
|
||||
details: dict[str, Any]
|
||||
|
||||
|
||||
class TabularPreviewDiagnosticResponse(BaseModel):
|
||||
severity: Literal["info", "warning", "error"]
|
||||
code: str
|
||||
message: str
|
||||
details: dict[str, Any]
|
||||
|
||||
|
||||
class TabularSourceResponse(BaseModel):
|
||||
ref: str
|
||||
provider: str
|
||||
@@ -114,6 +137,9 @@ class TabularSourceResponse(BaseModel):
|
||||
updated_at: str | None
|
||||
capabilities: list[str]
|
||||
metadata: dict[str, Any]
|
||||
source_mode: Literal["live", "cached", "file_backed", "static"]
|
||||
pushdown: TabularPushdownResponse
|
||||
health: TabularHealthResponse
|
||||
|
||||
|
||||
class TabularSourceListResponse(BaseModel):
|
||||
@@ -125,6 +151,12 @@ class TabularSourcePreviewResponse(BaseModel):
|
||||
rows: list[dict[str, Any]]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
returned_bytes: int
|
||||
elapsed_ms: int
|
||||
effective_row_limit: int
|
||||
effective_byte_limit: int
|
||||
effective_timeout_ms: int
|
||||
diagnostics: list[TabularPreviewDiagnosticResponse]
|
||||
|
||||
|
||||
class TabularSourceDeleteResponse(BaseModel):
|
||||
@@ -216,6 +248,9 @@ __all__ = [
|
||||
"SanctionsSourceListResponse",
|
||||
"SanctionsSourceResponse",
|
||||
"TabularColumnResponse",
|
||||
"TabularHealthResponse",
|
||||
"TabularPreviewDiagnosticResponse",
|
||||
"TabularPushdownResponse",
|
||||
"TabularSourceDeleteResponse",
|
||||
"TabularSourceListResponse",
|
||||
"TabularSourcePreviewResponse",
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -13,12 +14,16 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
TabularColumn,
|
||||
TabularPreviewDiagnostic,
|
||||
TabularPushdown,
|
||||
TabularReadRequest,
|
||||
TabularReadResult,
|
||||
TabularSnapshotInput,
|
||||
TabularSource,
|
||||
TabularSourceAccessError,
|
||||
TabularSourceNotFoundError,
|
||||
TabularSourceHealth,
|
||||
TabularSourceUnavailableError,
|
||||
TabularSourceValidationError,
|
||||
parse_tabular_csv,
|
||||
)
|
||||
@@ -32,9 +37,14 @@ ADMIN_SCOPE = "connectors:source:admin"
|
||||
MAX_SNAPSHOT_ROWS = 10_000
|
||||
MAX_SNAPSHOT_BYTES = 5_000_000
|
||||
MAX_READ_ROWS = 500
|
||||
MAX_READ_BYTES = 1_000_000
|
||||
MAX_READ_TIMEOUT_MS = 2_000
|
||||
|
||||
|
||||
class SqlTabularSourceProvider:
|
||||
def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self._clock = clock
|
||||
|
||||
def list_sources(
|
||||
self,
|
||||
session: object,
|
||||
@@ -99,8 +109,39 @@ class SqlTabularSourceProvider:
|
||||
"The source fingerprint changed; refresh the source node before running it."
|
||||
)
|
||||
|
||||
started = self._clock()
|
||||
limit = max(1, min(int(request.limit), MAX_READ_ROWS))
|
||||
byte_limit = max(2, min(int(request.max_bytes), MAX_READ_BYTES))
|
||||
timeout_ms = max(1, min(int(request.timeout_ms), MAX_READ_TIMEOUT_MS))
|
||||
offset = max(0, int(request.offset))
|
||||
diagnostics: list[TabularPreviewDiagnostic] = []
|
||||
if limit != request.limit:
|
||||
diagnostics.append(
|
||||
_preview_diagnostic(
|
||||
"preview.row_limit_tightened",
|
||||
"The provider tightened the requested row limit.",
|
||||
requested=request.limit,
|
||||
effective=limit,
|
||||
)
|
||||
)
|
||||
if byte_limit != request.max_bytes:
|
||||
diagnostics.append(
|
||||
_preview_diagnostic(
|
||||
"preview.byte_limit_tightened",
|
||||
"The provider tightened the requested byte limit.",
|
||||
requested=request.max_bytes,
|
||||
effective=byte_limit,
|
||||
)
|
||||
)
|
||||
if timeout_ms != request.timeout_ms:
|
||||
diagnostics.append(
|
||||
_preview_diagnostic(
|
||||
"preview.timeout_tightened",
|
||||
"The provider tightened the requested time limit.",
|
||||
requested=request.timeout_ms,
|
||||
effective=timeout_ms,
|
||||
)
|
||||
)
|
||||
selected_columns = tuple(dict.fromkeys(request.columns))
|
||||
known_columns = {column["name"] for column in item.schema_}
|
||||
unknown_columns = [column for column in selected_columns if column not in known_columns]
|
||||
@@ -108,20 +149,70 @@ class SqlTabularSourceProvider:
|
||||
raise TabularSourceValidationError(
|
||||
f"Unknown source columns: {', '.join(unknown_columns)}"
|
||||
)
|
||||
window = item.rows[offset : offset + limit]
|
||||
rows = tuple(
|
||||
{
|
||||
rows: list[dict[str, object]] = []
|
||||
returned_bytes = 2
|
||||
stopped_for = ""
|
||||
for row in item.rows[offset:]:
|
||||
if len(rows) >= limit:
|
||||
stopped_for = "rows"
|
||||
break
|
||||
elapsed_ms = int(max(0.0, self._clock() - started) * 1_000)
|
||||
if elapsed_ms >= timeout_ms:
|
||||
if not rows:
|
||||
raise TabularSourceUnavailableError(
|
||||
"Tabular source preview exceeded its time budget."
|
||||
)
|
||||
stopped_for = "time"
|
||||
break
|
||||
selected = {
|
||||
key: value
|
||||
for key, value in row.items()
|
||||
if not selected_columns or key in selected_columns
|
||||
}
|
||||
for row in window
|
||||
)
|
||||
row_bytes = len(
|
||||
json.dumps(
|
||||
selected,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
)
|
||||
additional_bytes = row_bytes + (1 if rows else 0)
|
||||
if returned_bytes + additional_bytes > byte_limit:
|
||||
if not rows:
|
||||
raise TabularSourceValidationError(
|
||||
"A single source row exceeds the preview byte limit."
|
||||
)
|
||||
stopped_for = "bytes"
|
||||
break
|
||||
rows.append(selected)
|
||||
returned_bytes += additional_bytes
|
||||
elapsed_ms = int(max(0.0, self._clock() - started) * 1_000)
|
||||
if stopped_for:
|
||||
labels = {
|
||||
"rows": ("preview.row_limit_reached", "row"),
|
||||
"bytes": ("preview.byte_limit_reached", "byte"),
|
||||
"time": ("preview.timeout_reached", "time"),
|
||||
}
|
||||
code, label = labels[stopped_for]
|
||||
diagnostics.append(
|
||||
TabularPreviewDiagnostic(
|
||||
severity="warning",
|
||||
code=code,
|
||||
message=f"The preview stopped at its effective {label} limit.",
|
||||
)
|
||||
)
|
||||
return TabularReadResult(
|
||||
source=_source_dto(item),
|
||||
rows=rows,
|
||||
rows=tuple(rows),
|
||||
total_rows=item.row_count,
|
||||
truncated=offset + len(rows) < item.row_count,
|
||||
returned_bytes=returned_bytes,
|
||||
elapsed_ms=elapsed_ms,
|
||||
effective_row_limit=limit,
|
||||
effective_byte_limit=byte_limit,
|
||||
effective_timeout_ms=timeout_ms,
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
def create_snapshot(
|
||||
@@ -283,6 +374,33 @@ def _source_dto(item: ConnectorTabularSource) -> TabularSource:
|
||||
updated_at=item.updated_at,
|
||||
capabilities=("read", "preview"),
|
||||
metadata=dict(item.metadata_),
|
||||
source_mode="cached",
|
||||
pushdown=TabularPushdown(
|
||||
projections=True,
|
||||
pagination=True,
|
||||
),
|
||||
health=TabularSourceHealth(
|
||||
status="healthy",
|
||||
code="snapshot.ready",
|
||||
summary="The immutable connector snapshot is ready.",
|
||||
checked_at=item.updated_at,
|
||||
details={"immutable": True},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _preview_diagnostic(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
requested: int,
|
||||
effective: int,
|
||||
) -> TabularPreviewDiagnostic:
|
||||
return TabularPreviewDiagnostic(
|
||||
severity="info",
|
||||
code=code,
|
||||
message=message,
|
||||
details={"requested": requested, "effective": effective},
|
||||
)
|
||||
|
||||
|
||||
@@ -367,6 +485,8 @@ def _escape_like(value: str) -> str:
|
||||
__all__ = [
|
||||
"ADMIN_SCOPE",
|
||||
"MAX_READ_ROWS",
|
||||
"MAX_READ_BYTES",
|
||||
"MAX_READ_TIMEOUT_MS",
|
||||
"MAX_SNAPSHOT_BYTES",
|
||||
"MAX_SNAPSHOT_ROWS",
|
||||
"READ_SCOPE",
|
||||
|
||||
Reference in New Issue
Block a user