perf(workflow): batch revision reads and add bounded histories

This commit is contained in:
2026-09-08 07:47:18 +02:00
parent 4c447671b0
commit a2fc5639db
10 changed files with 1189 additions and 46 deletions
@@ -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
)