perf(workflow): batch revision reads and add bounded histories
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""Authorized, bounded projections for workflow progress and retained history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session, joinedload, load_only, raiseload
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.governance import (
|
||||
definition_decision,
|
||||
require_definition_action,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.instance_service import (
|
||||
event_response,
|
||||
step_response,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
WorkflowInstanceEventPageResponse,
|
||||
WorkflowInstanceStepPageResponse,
|
||||
WorkflowInstanceSummaryListResponse,
|
||||
WorkflowInstanceSummaryResponse,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.service import WorkflowNotFoundError
|
||||
|
||||
|
||||
MAX_PAGE_SIZE = 200
|
||||
MAX_HISTORY_SEQUENCE = 2_147_483_647
|
||||
_CANDIDATE_BATCH_SIZE = 250
|
||||
|
||||
|
||||
def _require_read(principal: ApiPrincipal) -> None:
|
||||
if not any(
|
||||
has_scope(principal, scope)
|
||||
for scope in ("workflow:instance:read", "workflow:instance:admin")
|
||||
):
|
||||
raise PermissionError("Workflow instance read permission is required.")
|
||||
|
||||
|
||||
def _summary_statement():
|
||||
# Raiseload makes accidental history/payload expansion fail visibly. Only the
|
||||
# pinned revision metadata is needed, never its graph or BPMN document.
|
||||
return (
|
||||
select(WorkflowInstance)
|
||||
.options(
|
||||
load_only(
|
||||
WorkflowInstance.tenant_id,
|
||||
WorkflowInstance.definition_id,
|
||||
WorkflowInstance.definition_revision_id,
|
||||
WorkflowInstance.status,
|
||||
WorkflowInstance.start_origin,
|
||||
WorkflowInstance.current_step_id,
|
||||
WorkflowInstance.started_at,
|
||||
WorkflowInstance.finished_at,
|
||||
WorkflowInstance.created_at,
|
||||
WorkflowInstance.updated_at,
|
||||
raiseload=True,
|
||||
),
|
||||
joinedload(WorkflowInstance.definition),
|
||||
joinedload(WorkflowInstance.definition_revision).load_only(
|
||||
WorkflowDefinitionRevision.tenant_id,
|
||||
WorkflowDefinitionRevision.definition_id,
|
||||
WorkflowDefinitionRevision.revision,
|
||||
WorkflowDefinitionRevision.content_hash,
|
||||
WorkflowDefinitionRevision.execution_mode,
|
||||
raiseload=True,
|
||||
),
|
||||
raiseload("*"),
|
||||
)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
|
||||
|
||||
def _require_evidence(instance: WorkflowInstance) -> None:
|
||||
definition = instance.definition
|
||||
revision = instance.definition_revision
|
||||
if (
|
||||
definition is None
|
||||
or revision is None
|
||||
or revision.id != instance.definition_revision_id
|
||||
or definition.id != instance.definition_id
|
||||
or definition.tenant_id not in {None, instance.tenant_id}
|
||||
or revision.definition_id != definition.id
|
||||
or revision.tenant_id != definition.tenant_id
|
||||
):
|
||||
raise WorkflowNotFoundError(
|
||||
"Workflow instance definition evidence is incomplete."
|
||||
)
|
||||
|
||||
|
||||
def _summary(instance: WorkflowInstance) -> WorkflowInstanceSummaryResponse:
|
||||
_require_evidence(instance)
|
||||
return WorkflowInstanceSummaryResponse(
|
||||
id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
definition_name=instance.definition.name,
|
||||
definition_revision=instance.definition_revision.revision,
|
||||
definition_hash=instance.definition_revision.content_hash,
|
||||
execution_mode=instance.definition_revision.execution_mode,
|
||||
start_origin=instance.start_origin,
|
||||
status=instance.status,
|
||||
current_step_id=instance.current_step_id,
|
||||
started_at=instance.started_at,
|
||||
finished_at=instance.finished_at,
|
||||
created_at=instance.created_at,
|
||||
updated_at=instance.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _cursor(instance: WorkflowInstance) -> str:
|
||||
return base64.urlsafe_b64encode(
|
||||
json.dumps([instance.created_at.isoformat(), instance.id]).encode("utf-8")
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def _cursor_boundary(cursor: str) -> tuple[datetime, str]:
|
||||
try:
|
||||
if len(cursor) > 256:
|
||||
raise ValueError
|
||||
payload = json.loads(base64.b64decode(cursor, altchars=b"-_", validate=True))
|
||||
if (
|
||||
not isinstance(payload, list)
|
||||
or len(payload) != 2
|
||||
or not all(isinstance(value, str) for value in payload)
|
||||
or not 1 <= len(payload[1]) <= 36
|
||||
):
|
||||
raise ValueError
|
||||
return datetime.fromisoformat(payload[0]), payload[1]
|
||||
except (ValueError, TypeError, UnicodeError, binascii.Error) as exc:
|
||||
raise ValueError("Invalid workflow instance cursor.") from exc
|
||||
|
||||
|
||||
def list_instance_summaries(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
definition_id: str | None = None,
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> WorkflowInstanceSummaryListResponse:
|
||||
_require_read(principal)
|
||||
if not 1 <= limit <= MAX_PAGE_SIZE:
|
||||
raise ValueError("Workflow page size must be between 1 and 200.")
|
||||
boundary = _cursor_boundary(cursor) if cursor is not None else None
|
||||
statement = _summary_statement().where(
|
||||
WorkflowInstance.tenant_id == principal.tenant_id,
|
||||
)
|
||||
if definition_id is not None:
|
||||
statement = statement.where(WorkflowInstance.definition_id == definition_id)
|
||||
statement = statement.order_by(
|
||||
WorkflowInstance.created_at.desc(), WorkflowInstance.id.desc()
|
||||
)
|
||||
items: list[WorkflowInstanceSummaryResponse] = []
|
||||
last_cursor: str | None = None
|
||||
# Authorization can depend on a Core policy provider, so a raw SQL count or
|
||||
# SQL limit before authorization cannot define an authorized page. Scan in
|
||||
# bounded batches until the page and one authorized lookahead are found.
|
||||
while True:
|
||||
decisions: dict[str, bool] = {}
|
||||
batch_statement = statement
|
||||
if boundary is not None:
|
||||
created_at, instance_id = boundary
|
||||
batch_statement = batch_statement.where(
|
||||
or_(
|
||||
WorkflowInstance.created_at < created_at,
|
||||
and_(
|
||||
WorkflowInstance.created_at == created_at,
|
||||
WorkflowInstance.id < instance_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
candidates = list(session.scalars(batch_statement.limit(_CANDIDATE_BATCH_SIZE)))
|
||||
for instance in candidates:
|
||||
_require_evidence(instance)
|
||||
allowed = decisions.get(instance.definition_id)
|
||||
if allowed is None:
|
||||
allowed = definition_decision(
|
||||
instance.definition,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
action="view",
|
||||
).allowed
|
||||
decisions[instance.definition_id] = allowed
|
||||
if not allowed:
|
||||
continue
|
||||
if len(items) == limit:
|
||||
return WorkflowInstanceSummaryListResponse(
|
||||
instances=items, next_cursor=last_cursor
|
||||
)
|
||||
items.append(_summary(instance))
|
||||
last_cursor = _cursor(instance)
|
||||
if len(candidates) < _CANDIDATE_BATCH_SIZE:
|
||||
return WorkflowInstanceSummaryListResponse(instances=items)
|
||||
last = candidates[-1]
|
||||
boundary = (last.created_at, last.id)
|
||||
|
||||
|
||||
def _authorized_instance(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
instance_id: str,
|
||||
) -> WorkflowInstance:
|
||||
_require_read(principal)
|
||||
instance = session.scalar(
|
||||
_summary_statement().where(
|
||||
WorkflowInstance.tenant_id == principal.tenant_id,
|
||||
WorkflowInstance.id == instance_id,
|
||||
)
|
||||
)
|
||||
if instance is None:
|
||||
raise WorkflowNotFoundError("Workflow instance not found.")
|
||||
_require_evidence(instance)
|
||||
require_definition_action(
|
||||
instance.definition, principal=principal, registry=registry, action="view"
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
def get_instance_summary(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
instance_id: str,
|
||||
) -> WorkflowInstanceSummaryResponse:
|
||||
return _summary(
|
||||
_authorized_instance(
|
||||
session, principal=principal, registry=registry, instance_id=instance_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def instance_history_page(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
instance_id: str,
|
||||
kind: str,
|
||||
limit: int = 100,
|
||||
after_sequence: int = 0,
|
||||
through_sequence: int | None = None,
|
||||
) -> WorkflowInstanceStepPageResponse | WorkflowInstanceEventPageResponse:
|
||||
if kind not in {"steps", "events"}:
|
||||
raise ValueError("Workflow history kind must be steps or events.")
|
||||
if (
|
||||
not 1 <= limit <= MAX_PAGE_SIZE
|
||||
or not 0 <= after_sequence <= MAX_HISTORY_SEQUENCE
|
||||
or (
|
||||
through_sequence is not None
|
||||
and not 0 <= through_sequence <= MAX_HISTORY_SEQUENCE
|
||||
)
|
||||
):
|
||||
raise ValueError("Invalid workflow history page bounds.")
|
||||
_authorized_instance(
|
||||
session, principal=principal, registry=registry, instance_id=instance_id
|
||||
)
|
||||
model = WorkflowInstanceStep if kind == "steps" else WorkflowInstanceEvent
|
||||
predicates = [
|
||||
model.tenant_id == principal.tenant_id,
|
||||
model.instance_id == instance_id,
|
||||
]
|
||||
if through_sequence is not None:
|
||||
predicates.append(model.sequence <= through_sequence)
|
||||
total, last_sequence = session.execute(
|
||||
select(func.count(), func.max(model.sequence)).where(*predicates)
|
||||
).one()
|
||||
upper = int(last_sequence or 0)
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(model)
|
||||
.where(
|
||||
*predicates, model.sequence > after_sequence, model.sequence <= upper
|
||||
)
|
||||
.order_by(model.sequence.asc())
|
||||
.limit(limit + 1)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
)
|
||||
next_after = rows[limit - 1].sequence if len(rows) > limit else None
|
||||
common = {
|
||||
"total": int(total),
|
||||
"through_sequence": upper,
|
||||
"next_after_sequence": next_after,
|
||||
}
|
||||
if kind == "steps":
|
||||
return WorkflowInstanceStepPageResponse(
|
||||
steps=[step_response(row) for row in rows[:limit]], **common
|
||||
)
|
||||
return WorkflowInstanceEventPageResponse(
|
||||
events=[event_response(row) for row in rows[:limit]], **common
|
||||
)
|
||||
Reference in New Issue
Block a user