feat(datasources): publish durable artifact outputs
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-21 17:36:19 +02:00
parent f03497bdaf
commit 97ca670bfe
10 changed files with 639 additions and 38 deletions
+8 -4
View File
@@ -22,8 +22,11 @@ explicit frozen states, previews, retirement, and atomic producer publication.
Origin discovery retains each provider's source mode, structured health, and Origin discovery retains each provider's source mode, structured health, and
declared pushdown support. Live previews preserve the provider's effective row, declared pushdown support. Live previews preserve the provider's effective row,
serialized-byte, and elapsed-time limits and its redacted diagnostics. serialized-byte, and elapsed-time limits and its redacted diagnostics.
Producer modules can append a bounded tabular result or create a new static Producer modules can append a bounded inline tabular result or pin a larger
datasource through an idempotent capability. The publication ledger retains the durable artifact through the same idempotent capability. Artifact references
declare a backend, locator, SHA-256 checksum, schema, fingerprint, row and byte
counts; the installed provider verifies integrity and serves bounded reads.
The publication ledger retains the
producer run, output materialization, provenance, and replay identity. On producer run, output materialization, provenance, and replay identity. On
PostgreSQL, a transaction-scoped advisory lock serializes each tenant, producer, PostgreSQL, a transaction-scoped advisory lock serializes each tenant, producer,
and idempotency identity before any output side effect, so retries from multiple and idempotency identity before any output side effect, so retries from multiple
@@ -37,8 +40,9 @@ materialization provenance. The supported contract is documented in
The contracts already model database, HTTP/REST, directory, file, feed, The contracts already model database, HTTP/REST, directory, file, feed,
document, binary, directory, and stream sources so providers can be added document, binary, directory, and stream sources so providers can be added
without changing consumers. Larger durable artifact-backed publications remain without changing consumers. Storage modules contribute artifact backends
a later storage-provider slice. through the provider-neutral `datasources.artifactBackends` capability; the
Datasources module never imports their internals.
See [docs/CONCEPT.md](docs/CONCEPT.md) for ownership and lifecycle details. See [docs/CONCEPT.md](docs/CONCEPT.md) for ownership and lifecycle details.
+20
View File
@@ -96,6 +96,26 @@ This makes concurrent retries from separate API or worker nodes converge on the
same publication and materialization rather than relying on a late uniqueness same publication and materialization rather than relying on a late uniqueness
failure after output rows have already been persisted. failure after output rows have already been persisted.
## Durable artifact publications
Outputs larger than the inline row and byte limits use an immutable artifact
reference. The reference pins its backend and locator together with SHA-256
checksum, schema, datasource fingerprint, row count, byte count, media type,
and optional resume checkpoint. Datasources persists that reference as the
materialization payload and asks the installed Core-contract artifact backend
to verify it before creating catalogue state. Reads remain bounded and are
re-authorized by Datasources before reaching the backend.
Schema rules are evaluated by Datasources. Content-level rules such as
uniqueness or range require producer evidence bound to the exact payload
checksum and current quality-policy hash, including all evaluated rule IDs.
Missing or explicitly deferred evidence produces a `review_required`
publication and an immutable, addressable materialization, but it does not
replace the Datasource's current state. Valid warnings produce
`published_with_warnings`; failed evidence blocks the publication without a
catalogue side effect. These terminal states are preserved for Dataflow and
Workflow handoffs instead of being collapsed into generic success.
Approval authority, approval expiry, and retention/deletion execution remain Approval authority, approval expiry, and retention/deletion execution remain
separate work under `govoplan-datasources#2`. Until those contracts are added, separate work under `govoplan-datasources#2`. Until those contracts are added,
no JSON flag is treated as an approval and no stage is deleted automatically. no JSON flag is treated as an approval and no stage is deleted automatically.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-datasources" name = "govoplan-datasources"
version = "0.1.18" version = "0.1.19"
description = "Governed datasource catalogue, staging, and materialization lifecycle for GovOPlaN." description = "Governed datasource catalogue, staging, and materialization lifecycle for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Datasources module.""" """GovOPlaN Datasources module."""
__version__ = "0.1.18" __version__ = "0.1.19"
+32 -6
View File
@@ -7,10 +7,12 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
) )
from govoplan_core.core.datasources import ( from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS,
CAPABILITY_DATASOURCE_CATALOGUE, CAPABILITY_DATASOURCE_CATALOGUE,
CAPABILITY_DATASOURCE_LIFECYCLE, CAPABILITY_DATASOURCE_LIFECYCLE,
CAPABILITY_DATASOURCE_ORIGINS, CAPABILITY_DATASOURCE_ORIGINS,
CAPABILITY_DATASOURCE_PUBLICATION, CAPABILITY_DATASOURCE_PUBLICATION,
datasource_artifact_backend_provider,
) )
from govoplan_core.core.module_guards import ( from govoplan_core.core.module_guards import (
drop_table_retirement_provider, drop_table_retirement_provider,
@@ -55,12 +57,13 @@ from govoplan_datasources.backend.service import (
STAGE_WRITE_SCOPE, STAGE_WRITE_SCOPE,
SqlDatasourceProvider, SqlDatasourceProvider,
) )
from govoplan_datasources.backend.payloads import ExternalArtifactPayloadBackend
MODULE_ID = "datasources" MODULE_ID = "datasources"
MODULE_NAME = "Datasources" MODULE_NAME = "Datasources"
MODULE_VERSION = "0.1.18" MODULE_VERSION = "0.1.19"
DATASOURCE_INTERFACE_VERSION = "0.1.0" DATASOURCE_INTERFACE_VERSION = "0.2.0"
ARCHITECTURE = ModuleArchitectureDeclaration( ARCHITECTURE = ModuleArchitectureDeclaration(
layer="data_reporting_integration", layer="data_reporting_integration",
@@ -85,7 +88,7 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
), ),
known_limits=( known_limits=(
"Governance references are stable provider-neutral refs; dedicated selectors depend on the owning optional modules.", "Governance references are stable provider-neutral refs; dedicated selectors depend on the owning optional modules.",
"Quality and freshness policies are stored and snapshotted but enforcement remains provider-specific.", "External artifact content validation depends on an installed payload backend and checksum-bound producer evidence.",
), ),
supported_authority_modes=( supported_authority_modes=(
"native_authoritative", "native_authoritative",
@@ -182,7 +185,19 @@ def _router(context: ModuleContext):
def _provider(context: ModuleContext) -> SqlDatasourceProvider: def _provider(context: ModuleContext) -> SqlDatasourceProvider:
return SqlDatasourceProvider(registry=context.registry) provider = datasource_artifact_backend_provider(context.registry)
backends = (
tuple(
ExternalArtifactPayloadBackend(backend)
for backend in provider.artifact_backends()
)
if provider is not None
else ()
)
return SqlDatasourceProvider(
registry=context.registry,
payload_backends=backends,
)
def _dsar_provider(context: ModuleContext) -> DatasourcesDsarProvider: def _dsar_provider(context: ModuleContext) -> DatasourcesDsarProvider:
@@ -240,6 +255,7 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_DATASOURCE_ORIGINS, CAPABILITY_DATASOURCE_ORIGINS,
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS,
), ),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider( ModuleInterfaceProvider(
@@ -500,7 +516,10 @@ manifest = ModuleManifest(
"Authority mode states whether GovOPlaN, an external system, a synchronized projection, an overlay, or a linked reference " "Authority mode states whether GovOPlaN, an external system, a synchronized projection, an overlay, or a linked reference "
"controls the data. The authoritative source, owner, steward, responsible organization/function, schema owner, privacy " "controls the data. The authoritative source, owner, steward, responsible organization/function, schema owner, privacy "
"profile, retention policy, transfer agreement, legal basis, holds, correction procedure, purposes, official keys, and " "profile, retention policy, transfer agreement, legal basis, holds, correction procedure, purposes, official keys, and "
"known limits provide discoverable institutional context. Freshness and quality policies are typed JSON contracts retained " "known limits provide discoverable institutional context. A retention-policy reference identifies the owning Policy rule; it "
"does not itself delete materializations, override legal holds, or prove that a scheduler is active. A transfer-agreement "
"reference records the governed agreement for external exchange; it does not grant connector credentials, recipient access, "
"or authority to export classified rows. Freshness and quality policies are typed JSON contracts retained "
"with materialization evidence. Datasources enforces the declared bounded tabular stage rules and schema policy; origin-specific " "with materialization evidence. Datasources enforces the declared bounded tabular stage rules and schema policy; origin-specific "
"or consumer-specific controls still remain with the provider or consuming control that declares support. Metadata " "or consumer-specific controls still remain with the provider or consuming control that declares support. Metadata "
"visibility never grants row access." "visibility never grants row access."
@@ -527,6 +546,8 @@ manifest = ModuleManifest(
"datasources.field.publication-state", "datasources.field.publication-state",
"datasources.field.freshness-policy", "datasources.field.freshness-policy",
"datasources.field.quality-policy", "datasources.field.quality-policy",
"datasources.field.retention-policy",
"datasources.field.transfer-agreement",
], ],
}, },
), ),
@@ -540,7 +561,11 @@ manifest = ModuleManifest(
"promotion. Updates compare the detected schema with the current target and classify each change as compatible, warning, or " "promotion. Updates compare the detected schema with the current target and classify each change as compatible, warning, or "
"breaking. Diagnostics expose counts and bounded row numbers, never field values. The policy version and hash, diagnostics, and " "breaking. Diagnostics expose counts and bounded row numbers, never field values. The policy version and hash, diagnostics, and "
"schema diff are copied into immutable materialization provenance when promotion succeeds. Producer publication applies the same " "schema diff are copied into immutable materialization provenance when promotion succeeds. Producer publication applies the same "
"gate before any catalogue effect, retains validation evidence on the immutable output revision, serializes a publication identity across PostgreSQL worker nodes before replay lookup, and emits a transactional terminal event. Approval and retention execution " "gate before any catalogue effect, retains validation evidence on the immutable output revision, serializes a publication identity across PostgreSQL worker nodes before replay lookup, and emits a transactional terminal event. Large outputs may instead supply a "
"durable provider-neutral artifact reference with a pinned locator, SHA-256 checksum, declared schema, fingerprint, and size. "
"The configured payload backend verifies the artifact and provides bounded reads. Content-level rules require checksum- and "
"policy-bound producer evidence; absent evidence creates an immutable review-required materialization without changing the "
"Datasource's current state. Warning and review-required outcomes are preserved for Workflow handoffs. Approval and retention execution "
"are not inferred from arbitrary JSON flags and remain separate governed lifecycle work." "are not inferred from arbitrary JSON flags and remain separate governed lifecycle work."
), ),
layer="available", layer="available",
@@ -572,6 +597,7 @@ manifest = ModuleManifest(
"limitations": [ "limitations": [
"Referential rules currently use a bounded embedded value set rather than reading another protected Datasource.", "Referential rules currently use a bounded embedded value set rather than reading another protected Datasource.",
"Approval authority and automatic retention execution are not part of the current stage contract.", "Approval authority and automatic retention execution are not part of the current stage contract.",
"Artifact bytes remain owned by their payload backend; Datasources stores an immutable reference and integrity evidence.",
], ],
}, },
), ),
@@ -9,6 +9,9 @@ from sqlalchemy import delete, func, insert, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.datasources import ( from govoplan_core.core.datasources import (
DatasourceArtifactBackend,
DatasourceArtifactReference,
DatasourceField,
DatasourceUnavailableError, DatasourceUnavailableError,
DatasourceValidationError, DatasourceValidationError,
) )
@@ -116,6 +119,91 @@ class DatabaseRowsPayloadBackend:
) )
class ExternalArtifactPayloadBackend:
"""Adapts a Core artifact backend to persisted Datasources payload rows."""
def __init__(self, backend: DatasourceArtifactBackend) -> None:
self._backend = backend
self.backend = backend.backend
def read_rows(
self,
session: Session,
payload: DatasourcePayloadRecord,
*,
offset: int,
limit: int,
) -> Sequence[Mapping[str, object]]:
return self._backend.read_rows(
session,
tenant_id=payload.tenant_id,
artifact=_artifact_from_payload(payload),
offset=offset,
limit=limit,
)
def verify(
self,
session: Session,
payload: DatasourcePayloadRecord,
) -> None:
self._backend.verify(
session,
tenant_id=payload.tenant_id,
artifact=_artifact_from_payload(payload),
)
def delete(
self,
session: Session,
payload: DatasourcePayloadRecord,
) -> None:
self._backend.delete(
session,
tenant_id=payload.tenant_id,
artifact=_artifact_from_payload(payload),
)
def _artifact_from_payload(
payload: DatasourcePayloadRecord,
) -> DatasourceArtifactReference:
metadata = dict(payload.metadata_)
raw_schema = metadata.get("artifact_schema")
schema = tuple(
DatasourceField(
name=str(item.get("name") or ""),
data_type=str(item.get("data_type") or "unknown"),
nullable=bool(item.get("nullable", True)),
)
for item in raw_schema
if isinstance(item, Mapping)
) if isinstance(raw_schema, Sequence) and not isinstance(
raw_schema, (str, bytes)
) else ()
return DatasourceArtifactReference(
backend=payload.backend,
locator=str(payload.locator or ""),
checksum=payload.checksum,
row_count=payload.row_count,
byte_count=payload.byte_count,
schema=schema,
fingerprint=str(metadata.get("publication_fingerprint") or ""),
media_type=payload.media_type,
checkpoint=dict(payload.checkpoint_),
metadata={
str(key): value
for key, value in metadata.items()
if key not in {"artifact_schema", "artifact_validation"}
},
validation=(
dict(metadata.get("artifact_validation"))
if isinstance(metadata.get("artifact_validation"), Mapping)
else {}
),
)
class PayloadBackendRegistry: class PayloadBackendRegistry:
def __init__( def __init__(
self, self,
@@ -363,6 +451,7 @@ __all__ = [
"DATABASE_ROWS_BACKEND", "DATABASE_ROWS_BACKEND",
"DatabaseRowsPayloadBackend", "DatabaseRowsPayloadBackend",
"DatasourcePayloadBackend", "DatasourcePayloadBackend",
"ExternalArtifactPayloadBackend",
"PayloadBackendRegistry", "PayloadBackendRegistry",
"create_database_rows_payload", "create_database_rows_payload",
"create_external_payload_reference", "create_external_payload_reference",
+288 -21
View File
@@ -14,6 +14,7 @@ from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, has_scope from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.datasources import ( from govoplan_core.core.datasources import (
DatasourceAccessError, DatasourceAccessError,
DatasourceArtifactReference,
DatasourceDescriptor, DatasourceDescriptor,
DatasourceError, DatasourceError,
DatasourceField, DatasourceField,
@@ -25,6 +26,7 @@ from govoplan_core.core.datasources import (
DatasourceOriginReadRequest, DatasourceOriginReadRequest,
DatasourcePublicationRequest, DatasourcePublicationRequest,
DatasourcePublicationResult, DatasourcePublicationResult,
DatasourcePublicationStatus,
DatasourceReadRequest, DatasourceReadRequest,
DatasourceReadResult, DatasourceReadResult,
DatasourceStage, DatasourceStage,
@@ -46,6 +48,7 @@ from govoplan_datasources.backend.payloads import (
DatasourcePayloadBackend, DatasourcePayloadBackend,
PayloadBackendRegistry, PayloadBackendRegistry,
create_database_rows_payload, create_database_rows_payload,
create_external_payload_reference,
payload_for_materialization, payload_for_materialization,
validate_payload_size, validate_payload_size,
) )
@@ -72,9 +75,11 @@ class _PreparedPublication:
producer_module: str producer_module: str
producer_run_ref: str producer_run_ref: str
idempotency_key: str idempotency_key: str
rows: tuple[dict[str, Any], ...] rows: tuple[dict[str, Any], ...] | None
artifact: DatasourceArtifactReference | None
schema: tuple[DatasourceField, ...] schema: tuple[DatasourceField, ...]
fingerprint: str fingerprint: str
row_count: int
byte_count: int byte_count: int
request_hash: str request_hash: str
@@ -120,9 +125,8 @@ class SqlDatasourceProvider:
tenant_id=api_principal.tenant_id, tenant_id=api_principal.tenant_id,
request=request, request=request,
) )
validation = validate_stage( validation, publication_status = _validate_publication(
rows=prepared.rows, prepared=prepared,
schema=prepared.schema,
quality_policy=governance.quality_policy, quality_policy=governance.quality_policy,
baseline_schema=baseline_schema, baseline_schema=baseline_schema,
) )
@@ -138,6 +142,12 @@ class SqlDatasourceProvider:
"Published output failed governed quality or schema validation" "Published output failed governed quality or schema validation"
f"{suffix}." f"{suffix}."
) )
reusable_payload = self._publication_payload(
db,
tenant_id=api_principal.tenant_id,
actor_id=actor_id,
prepared=prepared,
)
datasource = _publication_target( datasource = _publication_target(
db, db,
tenant_id=api_principal.tenant_id, tenant_id=api_principal.tenant_id,
@@ -146,11 +156,14 @@ class SqlDatasourceProvider:
prepared=prepared, prepared=prepared,
target=target, target=target,
governance=governance, governance=governance,
publish_as_current=(
request.set_current and publication_status != "review_required"
),
) )
materialization = _append_materialization( materialization = _append_materialization(
db, db,
datasource=datasource, datasource=datasource,
rows=prepared.rows, rows=prepared.rows or (),
schema=[field_payload(field) for field in prepared.schema], schema=[field_payload(field) for field in prepared.schema],
fingerprint=prepared.fingerprint, fingerprint=prepared.fingerprint,
byte_count=prepared.byte_count, byte_count=prepared.byte_count,
@@ -163,7 +176,15 @@ class SqlDatasourceProvider:
"publication_validation": validation, "publication_validation": validation,
}, },
metadata=dict(request.metadata), metadata=dict(request.metadata),
set_current=request.set_current, set_current=(
request.set_current and publication_status != "review_required"
),
reusable_payload=reusable_payload,
state=(
"review_required"
if publication_status == "review_required"
else "published"
),
) )
publication = _create_publication_record( publication = _create_publication_record(
db, db,
@@ -173,6 +194,7 @@ class SqlDatasourceProvider:
materialization=materialization, materialization=materialization,
request=request, request=request,
prepared=prepared, prepared=prepared,
status=publication_status,
) )
audit_event( audit_event(
db, db,
@@ -180,7 +202,7 @@ class SqlDatasourceProvider:
user_id=getattr(api_principal.user, "id", None) user_id=getattr(api_principal.user, "id", None)
or api_principal.account_id, or api_principal.account_id,
api_key_id=api_principal.api_key_id, api_key_id=api_principal.api_key_id,
action="datasource.publication.published", action=f"datasource.publication.{publication_status}",
object_type="datasource_publication", object_type="datasource_publication",
object_id=publication.id, object_id=publication.id,
details={ details={
@@ -189,7 +211,12 @@ class SqlDatasourceProvider:
"datasource_ref": _datasource_ref(datasource.id), "datasource_ref": _datasource_ref(datasource.id),
"materialization_ref": _materialization_ref(materialization.id), "materialization_ref": _materialization_ref(materialization.id),
"fingerprint": prepared.fingerprint, "fingerprint": prepared.fingerprint,
"row_count": len(prepared.rows), "row_count": prepared.row_count,
"payload_backend": (
prepared.artifact.backend
if prepared.artifact is not None
else "database_rows"
),
"policy_hash": validation["policy_hash"], "policy_hash": validation["policy_hash"],
"schema_classification": validation["schema_change"][ "schema_classification": validation["schema_change"][
"classification" "classification"
@@ -198,12 +225,52 @@ class SqlDatasourceProvider:
) )
return DatasourcePublicationResult( return DatasourcePublicationResult(
ref=_publication_ref(publication.id), ref=_publication_ref(publication.id),
status=publication.status, status=cast(DatasourcePublicationStatus, publication.status),
datasource=_datasource_dto(datasource), datasource=_datasource_dto(datasource),
materialization=_materialization_dto(materialization), materialization=_materialization_dto(materialization),
replayed=False, replayed=False,
) )
def _publication_payload(
self,
session: Session,
*,
tenant_id: str,
actor_id: str | None,
prepared: _PreparedPublication,
) -> DatasourcePayloadRecord | None:
artifact = prepared.artifact
if artifact is None:
return None
backend = self._payload_backends.require(artifact.backend)
payload = create_external_payload_reference(
session,
tenant_id=tenant_id,
backend=artifact.backend,
locator=artifact.locator,
checksum=artifact.checksum,
row_count=artifact.row_count,
byte_count=artifact.byte_count,
actor_id=actor_id,
media_type=artifact.media_type,
checkpoint=artifact.checkpoint,
metadata={
**dict(artifact.metadata),
"publication_fingerprint": artifact.fingerprint,
"artifact_schema": [
field_payload(field) for field in artifact.schema
],
"artifact_validation": dict(artifact.validation),
},
)
try:
backend.verify(session, payload)
except Exception:
session.delete(payload)
session.flush()
raise
return payload
def list_datasources( def list_datasources(
self, self,
session: object, session: object,
@@ -1056,6 +1123,7 @@ def _append_materialization(
metadata: Mapping[str, object] | None = None, metadata: Mapping[str, object] | None = None,
set_current: bool, set_current: bool,
reusable_payload: DatasourcePayloadRecord | None = None, reusable_payload: DatasourcePayloadRecord | None = None,
state: str = "published",
) -> DatasourceMaterializationRecord: ) -> DatasourceMaterializationRecord:
datasource = _lock_datasource_for_materialization(session, datasource) datasource = _lock_datasource_for_materialization(session, datasource)
revision = _allocate_materialization_revision(session, datasource) revision = _allocate_materialization_revision(session, datasource)
@@ -1082,7 +1150,7 @@ def _append_materialization(
tenant_id=datasource.tenant_id, tenant_id=datasource.tenant_id,
datasource_id=datasource.id, datasource_id=datasource.id,
revision=revision, revision=revision,
state="published", state=state,
schema_version=max(1, int(schema_version or 1)), schema_version=max(1, int(schema_version or 1)),
schema_=schema_payload, schema_=schema_payload,
payload_id=payload.id, payload_id=payload.id,
@@ -1618,17 +1686,47 @@ def _prepare_publication(
producer_run_ref=producer_run_ref, producer_run_ref=producer_run_ref,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
) )
normalized = normalize_rows(request.rows) if (request.rows is None) == (request.artifact is None):
raise DatasourceValidationError(
"A publication requires exactly one inline row payload or durable "
"artifact reference."
)
artifact = request.artifact
if artifact is None:
normalized: tuple[dict[str, Any], ...] | None = normalize_rows(
request.rows or ()
)
schema = infer_schema(normalized) schema = infer_schema(normalized)
fingerprint = fingerprint_rows(normalized, schema) fingerprint = fingerprint_rows(normalized, schema)
row_count = len(normalized)
byte_count = encoded_size(normalized)
else:
normalized = None
schema = _validated_artifact_schema(artifact)
fingerprint = _validated_sha256(
artifact.fingerprint,
label="Artifact publication fingerprints",
)
_validated_sha256(
artifact.checksum,
label="Artifact publication checksums",
)
if artifact.row_count < 0 or artifact.byte_count < 0:
raise DatasourceValidationError(
"Artifact publication sizes cannot be negative."
)
row_count = artifact.row_count
byte_count = artifact.byte_count
return _PreparedPublication( return _PreparedPublication(
producer_module=producer_module, producer_module=producer_module,
producer_run_ref=producer_run_ref, producer_run_ref=producer_run_ref,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
rows=normalized, rows=normalized,
artifact=artifact,
schema=schema, schema=schema,
fingerprint=fingerprint, fingerprint=fingerprint,
byte_count=encoded_size(normalized), row_count=row_count,
byte_count=byte_count,
request_hash=_publication_request_hash( request_hash=_publication_request_hash(
request, request,
normalized=normalized, normalized=normalized,
@@ -1637,6 +1735,147 @@ def _prepare_publication(
) )
def _validated_artifact_schema(
artifact: DatasourceArtifactReference,
) -> tuple[DatasourceField, ...]:
schema = tuple(artifact.schema)
names = [field.name.strip() for field in schema]
if not schema or any(not name for name in names):
raise DatasourceValidationError(
"Artifact publications require a non-empty schema."
)
if len(names) != len(set(names)):
raise DatasourceValidationError(
"Artifact publication schema field names must be unique."
)
return schema
def _validated_sha256(value: str, *, label: str) -> str:
cleaned = value.strip().casefold()
try:
valid = len(cleaned) == 64 and int(cleaned, 16) >= 0
except ValueError:
valid = False
if not valid:
raise DatasourceValidationError(f"{label} must be SHA-256 values.")
return cleaned
def _validate_publication(
*,
prepared: _PreparedPublication,
quality_policy: Mapping[str, object],
baseline_schema: Sequence[DatasourceField] | None,
) -> tuple[dict[str, object], DatasourcePublicationStatus]:
if prepared.artifact is None:
validation = validate_stage(
rows=prepared.rows or (),
schema=prepared.schema,
quality_policy=quality_policy,
baseline_schema=baseline_schema,
)
status: DatasourcePublicationStatus = (
"published_with_warnings"
if validation.get("warnings")
else "published"
)
return validation, status
raw_rules = quality_policy.get("rules", [])
rules = (
[item for item in raw_rules if isinstance(item, Mapping)]
if isinstance(raw_rules, Sequence)
and not isinstance(raw_rules, (str, bytes))
else []
)
metadata_rule_types = {"required_fields", "field"}
metadata_policy = {
**dict(quality_policy),
"rules": [
dict(item)
for item in rules
if str(item.get("type") or "") in metadata_rule_types
],
}
validation = validate_stage(
rows=(),
schema=prepared.schema,
quality_policy=metadata_policy,
baseline_schema=baseline_schema,
)
if validation["valid"] is not True:
return validation, "review_required"
evidence = dict(prepared.artifact.validation)
content_rule_ids = {
str(item.get("id") or f"rule-{index + 1}")
for index, item in enumerate(rules)
if str(item.get("type") or "") not in metadata_rule_types
}
policy_probe = validate_stage(
rows=(),
schema=prepared.schema,
quality_policy=quality_policy,
baseline_schema=baseline_schema,
)
evidence_rule_ids = {
str(item)
for item in evidence.get("rules_evaluated", [])
if str(item).strip()
} if isinstance(evidence.get("rules_evaluated"), Sequence) and not isinstance(
evidence.get("rules_evaluated"), (str, bytes)
) else set()
evidence_verified = (
not content_rule_ids
or (
evidence.get("valid") is True
and str(evidence.get("policy_hash") or "")
== str(policy_probe["policy_hash"])
and str(evidence.get("payload_checksum") or "").casefold()
== prepared.artifact.checksum.casefold()
and content_rule_ids.issubset(evidence_rule_ids)
)
)
requested_status = str(evidence.get("status") or "verified")
if evidence.get("valid") is False or requested_status == "failed":
errors = evidence.get("errors")
validation["valid"] = False
validation["errors"] = (
list(errors)
if isinstance(errors, Sequence)
and not isinstance(errors, (str, bytes))
else [
{
"severity": "error",
"code": "quality.artifact_validation",
"message": "Artifact validation evidence reports failure.",
}
]
)
return validation, "review_required"
evidence_warnings = evidence.get("warnings")
if isinstance(evidence_warnings, Sequence) and not isinstance(
evidence_warnings, (str, bytes)
):
validation["warnings"] = [
*list(validation.get("warnings", [])),
*list(evidence_warnings),
]
validation["artifact_evidence"] = {
"verified": evidence_verified,
"rules_required": sorted(content_rule_ids),
"rules_evaluated": sorted(evidence_rule_ids),
"payload_checksum": prepared.artifact.checksum.casefold(),
}
if not evidence_verified or requested_status == "review_required":
return validation, "review_required"
if validation.get("warnings") or requested_status == "warning":
return validation, "published_with_warnings"
return validation, "published"
def _validate_publication_identity( def _validate_publication_identity(
*, *,
producer_module: str, producer_module: str,
@@ -1730,7 +1969,7 @@ def _existing_publication_result(
) )
return DatasourcePublicationResult( return DatasourcePublicationResult(
ref=_publication_ref(publication.id), ref=_publication_ref(publication.id),
status=publication.status, status=cast(DatasourcePublicationStatus, publication.status),
datasource=_datasource_dto(datasource), datasource=_datasource_dto(datasource),
materialization=_materialization_dto(materialization), materialization=_materialization_dto(materialization),
replayed=True, replayed=True,
@@ -1746,6 +1985,7 @@ def _publication_target(
prepared: _PreparedPublication, prepared: _PreparedPublication,
target: DatasourceRecord | None, target: DatasourceRecord | None,
governance: DatasourceGovernance, governance: DatasourceGovernance,
publish_as_current: bool,
) -> DatasourceRecord: ) -> DatasourceRecord:
if target is not None: if target is not None:
datasource = target datasource = target
@@ -1782,9 +2022,9 @@ def _publication_target(
provider_ref=prepared.producer_run_ref, provider_ref=prepared.producer_run_ref,
schema_version=1, schema_version=1,
schema_=[field_payload(field) for field in prepared.schema], schema_=[field_payload(field) for field in prepared.schema],
fingerprint=prepared.fingerprint, fingerprint=prepared.fingerprint if publish_as_current else "",
row_count=len(prepared.rows), row_count=prepared.row_count if publish_as_current else None,
byte_count=prepared.byte_count, byte_count=prepared.byte_count if publish_as_current else None,
provenance_={ provenance_={
**dict(request.provenance), **dict(request.provenance),
"producer_module": prepared.producer_module, "producer_module": prepared.producer_module,
@@ -1859,6 +2099,7 @@ def _create_publication_record(
materialization: DatasourceMaterializationRecord, materialization: DatasourceMaterializationRecord,
request: DatasourcePublicationRequest, request: DatasourcePublicationRequest,
prepared: _PreparedPublication, prepared: _PreparedPublication,
status: DatasourcePublicationStatus,
) -> DatasourcePublicationRecord: ) -> DatasourcePublicationRecord:
publication = DatasourcePublicationRecord( publication = DatasourcePublicationRecord(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -1868,12 +2109,19 @@ def _create_publication_record(
request_hash=prepared.request_hash, request_hash=prepared.request_hash,
datasource_id=datasource.id, datasource_id=datasource.id,
materialization_id=materialization.id, materialization_id=materialization.id,
status="published", status=status,
details_={ details_={
"fingerprint": prepared.fingerprint, "fingerprint": prepared.fingerprint,
"row_count": len(prepared.rows), "row_count": prepared.row_count,
"set_current": request.set_current, "set_current": (
request.set_current and status != "review_required"
),
"frozen": request.freeze, "frozen": request.freeze,
"payload_backend": (
prepared.artifact.backend
if prepared.artifact is not None
else "database_rows"
),
"validation": materialization.provenance_.get( "validation": materialization.provenance_.get(
"publication_validation", "publication_validation",
{}, {},
@@ -1889,7 +2137,7 @@ def _create_publication_record(
def _publication_request_hash( def _publication_request_hash(
request: DatasourcePublicationRequest, request: DatasourcePublicationRequest,
*, *,
normalized: Sequence[Mapping[str, object]], normalized: Sequence[Mapping[str, object]] | None,
fingerprint: str, fingerprint: str,
) -> str: ) -> str:
payload = { payload = {
@@ -1899,7 +2147,26 @@ def _publication_request_hash(
"name": request.name, "name": request.name,
"source_name": request.source_name, "source_name": request.source_name,
"description": request.description, "description": request.description,
"rows": [dict(row) for row in normalized], "rows": [dict(row) for row in (normalized or ())],
"artifact": (
{
"backend": request.artifact.backend,
"locator": request.artifact.locator,
"checksum": request.artifact.checksum.casefold(),
"row_count": request.artifact.row_count,
"byte_count": request.artifact.byte_count,
"schema": [
field_payload(field) for field in request.artifact.schema
],
"fingerprint": request.artifact.fingerprint.casefold(),
"media_type": request.artifact.media_type,
"checkpoint": dict(request.artifact.checkpoint),
"metadata": dict(request.artifact.metadata),
"validation": dict(request.artifact.validation),
}
if request.artifact is not None
else None
),
"fingerprint": fingerprint, "fingerprint": fingerprint,
"freeze": request.freeze, "freeze": request.freeze,
"frozen_label": request.frozen_label, "frozen_label": request.frozen_label,
+195
View File
@@ -11,6 +11,7 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.datasources import ( from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_ORIGINS, CAPABILITY_DATASOURCE_ORIGINS,
DatasourceAccessError, DatasourceAccessError,
DatasourceArtifactReference,
DatasourceField, DatasourceField,
DatasourceGovernance, DatasourceGovernance,
DatasourceOrigin, DatasourceOrigin,
@@ -44,6 +45,7 @@ from govoplan_datasources.backend.service import (
SqlDatasourceProvider, SqlDatasourceProvider,
) )
from govoplan_datasources.backend.payloads import ( from govoplan_datasources.backend.payloads import (
ExternalArtifactPayloadBackend,
create_database_rows_payload, create_database_rows_payload,
finalize_payload_deletion, finalize_payload_deletion,
mark_unreferenced_payload_for_deletion, mark_unreferenced_payload_for_deletion,
@@ -166,6 +168,56 @@ class FakeRegistry:
return self.origin_provider return self.origin_provider
class FakeArtifactBackend:
backend = "test_artifact"
def __init__(self) -> None:
self.verified: list[str] = []
self.deleted: list[str] = []
def read_rows(
self,
_session,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
offset: int,
limit: int,
):
self.assert_tenant(tenant_id)
stop = min(artifact.row_count, offset + limit)
return tuple(
{"id": index, "result": "match"}
for index in range(offset, stop)
)
def verify(
self,
_session,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None:
self.assert_tenant(tenant_id)
if not artifact.locator.startswith("artifact:"):
raise DatasourceUnavailableError("Unknown test artifact.")
self.verified.append(artifact.locator)
def delete(
self,
_session,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None:
self.assert_tenant(tenant_id)
self.deleted.append(artifact.locator)
def assert_tenant(self, tenant_id: str) -> None:
if tenant_id != "tenant-1":
raise AssertionError("Artifact backend crossed a tenant boundary.")
class DatasourceLifecycleTests(unittest.TestCase): class DatasourceLifecycleTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:") self.engine = create_engine("sqlite:///:memory:")
@@ -185,8 +237,10 @@ class DatasourceLifecycleTests(unittest.TestCase):
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.session = self.Session() self.session = self.Session()
self.origins = FakeOriginProvider() self.origins = FakeOriginProvider()
self.artifacts = FakeArtifactBackend()
self.provider = SqlDatasourceProvider( self.provider = SqlDatasourceProvider(
registry=FakeRegistry(self.origins), registry=FakeRegistry(self.origins),
payload_backends=(ExternalArtifactPayloadBackend(self.artifacts),),
) )
def tearDown(self) -> None: def tearDown(self) -> None:
@@ -667,6 +721,147 @@ class DatasourceLifecycleTests(unittest.TestCase):
self.session.query(DatasourcePublicationRecord).count(), self.session.query(DatasourcePublicationRecord).count(),
) )
def test_artifact_publication_pins_large_payload_and_supports_bounded_reads(
self,
) -> None:
artifact = DatasourceArtifactReference(
backend="test_artifact",
locator="artifact:monthly-output",
checksum="a" * 64,
row_count=25_000,
byte_count=12_000_000,
schema=(
DatasourceField("id", "integer", nullable=False),
DatasourceField("result", "string", nullable=False),
),
fingerprint="b" * 64,
)
published = self.provider.publish_rows(
self.session,
principal(scopes=(SOURCE_WRITE_SCOPE, CATALOGUE_READ_SCOPE)),
request=DatasourcePublicationRequest(
producer_module="dataflow",
producer_run_ref="dataflow-run:large-output",
idempotency_key="large-output",
name="Large output",
source_name="large_output",
artifact=artifact,
),
)
preview = self.provider.read_datasource(
self.session,
principal(scopes=(CATALOGUE_READ_SCOPE,)),
request=DatasourceReadRequest(
datasource_ref=published.datasource.ref,
offset=10,
limit=3,
),
)
self.assertEqual("published", published.status)
self.assertEqual(25_000, published.materialization.row_count)
self.assertEqual(
[{"id": 10, "result": "match"},
{"id": 11, "result": "match"},
{"id": 12, "result": "match"}],
list(preview.rows),
)
self.assertEqual(
["artifact:monthly-output", "artifact:monthly-output"],
self.artifacts.verified,
)
def test_unattested_artifact_quality_rules_require_review_without_becoming_current(
self,
) -> None:
published = self.provider.publish_rows(
self.session,
principal(scopes=(SOURCE_WRITE_SCOPE,)),
request=DatasourcePublicationRequest(
producer_module="reporting",
producer_run_ref="report-run:review",
idempotency_key="review-output",
name="Review output",
source_name="review_output",
artifact=DatasourceArtifactReference(
backend="test_artifact",
locator="artifact:review-output",
checksum="c" * 64,
row_count=2,
byte_count=128,
schema=(DatasourceField("id", "integer", False),),
fingerprint="d" * 64,
),
governance=DatasourceGovernance(
quality_policy={
"version": "unique-id-v1",
"rules": [
{
"id": "unique-id",
"type": "unique",
"fields": ["id"],
}
],
}
),
),
)
record = self.session.get(
DatasourceRecord,
published.datasource.ref.removeprefix("datasource:"),
)
self.assertEqual("review_required", published.status)
self.assertEqual("review_required", published.materialization.state)
self.assertIsNotNone(record)
assert record is not None
self.assertIsNone(record.current_materialization_id)
def test_artifact_warning_is_a_notification_ready_terminal_state(self) -> None:
published = self.provider.publish_rows(
self.session,
principal(scopes=(SOURCE_WRITE_SCOPE,)),
request=DatasourcePublicationRequest(
producer_module="dataflow",
producer_run_ref="dataflow-run:warning",
idempotency_key="warning-output",
name="Warning output",
source_name="warning_output",
artifact=DatasourceArtifactReference(
backend="test_artifact",
locator="artifact:warning-output",
checksum="e" * 64,
row_count=1,
byte_count=64,
schema=(DatasourceField("id", "integer", False),),
fingerprint="f" * 64,
validation={
"status": "warning",
"warnings": [
{
"severity": "warning",
"code": "producer.partial_match",
"message": "One source used a fallback match.",
}
],
},
),
),
)
record = self.session.get(
DatasourcePublicationRecord,
published.ref.removeprefix("publication:"),
)
self.assertEqual("published_with_warnings", published.status)
self.assertIsNotNone(record)
assert record is not None
self.assertEqual(
"published_with_warnings",
record.status,
)
def test_publication_idempotency_key_rejects_different_output(self) -> None: def test_publication_idempotency_key_rejects_different_output(self) -> None:
producer = principal(scopes=(SOURCE_WRITE_SCOPE,)) producer = principal(scopes=(SOURCE_WRITE_SCOPE,))
base = DatasourcePublicationRequest( base = DatasourcePublicationRequest(
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/datasources-webui", "name": "@govoplan/datasources-webui",
"version": "0.1.18", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -1017,10 +1017,10 @@ function GovernanceDialog({
<FormField label="Privacy profile"> <FormField label="Privacy profile">
<input value={draft.privacy_profile_ref ?? ""} onChange={(event) => setValue("privacy_profile_ref", event.target.value || null)} /> <input value={draft.privacy_profile_ref ?? ""} onChange={(event) => setValue("privacy_profile_ref", event.target.value || null)} />
</FormField> </FormField>
<FormField label="Retention policy"> <FormField label="Retention policy" interfaceId="datasources.field.retention-policy" helpContextId="datasources.field.retention-policy" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<input value={draft.retention_policy_ref ?? ""} onChange={(event) => setValue("retention_policy_ref", event.target.value || null)} /> <input value={draft.retention_policy_ref ?? ""} onChange={(event) => setValue("retention_policy_ref", event.target.value || null)} />
</FormField> </FormField>
<FormField label="Transfer agreement"> <FormField label="Transfer agreement" interfaceId="datasources.field.transfer-agreement" helpContextId="datasources.field.transfer-agreement" helpModuleId="datasources" documentation={DATASOURCE_GOVERNANCE_DOCUMENTATION}>
<input value={draft.transfer_agreement_ref ?? ""} onChange={(event) => setValue("transfer_agreement_ref", event.target.value || null)} /> <input value={draft.transfer_agreement_ref ?? ""} onChange={(event) => setValue("transfer_agreement_ref", event.target.value || null)} />
</FormField> </FormField>
<FormField label="Correction procedure"> <FormField label="Correction procedure">