Files
dify/api/services/dify_builder/native_testing/lifecycle.py
T

1636 lines
81 KiB
Python

"""Durable native wait/resume/cancel owner; no competing workflow executor.
SQL request locks serialize shared generations. Queue delivery may repeat; a
claimed dispatch never restarts on a Redis timeout or a missing queue ack.
Qualified native adapters must supply actual execution/termination evidence.
"""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from re import fullmatch
from typing import TYPE_CHECKING, Literal, Protocol, Self, cast
from pydantic import StrictBool, model_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.dify_builder.models import Actor
from models.dify_builder import DifyBuilderExecutionRequest
from models.dify_builder_native_tests import (
NativeContinuation,
NativeDispatch,
NativeEffectGrant,
NativeEffectOperation,
NativeInvocationState,
NativePayloadObject,
NativePendingAction,
NativeTestInvocation,
NativeTestScope,
)
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest
from runtime_contracts.builder.native.contracts import (
ID,
CancellationReceipt,
CheckpointRef,
FrozenDTO,
NativeTestContextRef,
Outcome,
ResumeReceipt,
WaitingReceipt,
WaitReason,
)
from runtime_contracts.builder.native.runtime_contracts import (
NativeCheckpointPayload,
NativeWorkflowOutcome,
ResolvedNativeTestContext,
)
from services.dify_builder.native_testing.private_reads import validate_invocation_workflow, validate_private_read
from services.dify_builder.native_testing.repository import (
OwnedNativeTest,
OwnedNativeTestLoader,
admitted_workflow_revision,
native_workflow_revision,
utc_now,
)
from services.dify_builder.native_testing.state_store import NativeExecutionStateStore, invocation_state, owner_query
from services.dify_builder.native_testing.transitions import (
dispatch_readiness,
has_unknown_descendant,
invocation_subtree,
pending_reason,
quarantine_unknown,
recoverable_invocation_ids,
require_ancestor_authority,
validate_pending_fence,
validate_pending_history,
)
if TYPE_CHECKING:
from services.dify_builder.native_testing.host_capacity import NativeHostCapacity
class NativePendingResolution(FrozenDTO):
"""Facts from the actual configuration/form owner, never client completion flags."""
category: Literal["configuration", "interaction"]
invocation_id: ID
pending_action_id: ID
owner_ref: ID
reference: ID
resolved: StrictBool
account_ref: ID | None
answer_result_ref: ID | None
@model_validator(mode="after")
def validate_completion(self) -> Self:
if self.category == "configuration":
if self.answer_result_ref is not None or (self.resolved and self.account_ref is None):
raise ValueError("configuration requires resolved account identity")
elif self.account_ref is not None or (self.resolved and self.answer_result_ref is None):
raise ValueError("interaction requires authentic form answer")
if not self.resolved and (self.account_ref is not None or self.answer_result_ref is not None):
raise ValueError("unresolved owner has no completion evidence")
return self
class NativePendingResolutionPort(Protocol):
def resolve(
self, *, session: Session, owned: OwnedNativeTest, pending: NativePendingAction
) -> NativePendingResolution:
"""Read actual native owner facts in SQL only; no OAuth/decryption/network."""
...
class NativeResumeSettlementPort(Protocol):
def validate(
self, *, session: Session, owned: OwnedNativeTest, state: NativeInvocationState, checkpoint: CheckpointRef
) -> None:
"""SQL-only proof of the completed source acquisition, inside the resume writer."""
...
class NativeRuntimePort(Protocol):
def run_initial(self, *, context: ResolvedNativeTestContext) -> NativeWorkflowOutcome: ...
def resume(
self,
*,
context: ResolvedNativeTestContext,
checkpoint: NativeCheckpointPayload,
resolutions: tuple[NativePendingResolution, ...],
) -> NativeWorkflowOutcome: ...
def cancel(self, *, refs: tuple[NativeTestContextRef, ...]) -> tuple[str, ...]:
"""Legacy generic C03 receipt; production uses exact capacity SQL ACKs instead."""
...
def recovery_evidence(
self, *, context: ResolvedNativeTestContext, dispatch_id: str
) -> Literal["not_started", "stopped", "unknown"]:
"""Fence the complete native subtree, never infer proof from a lease timeout.
'stopped' proves neither the old root nor any child execution can continue.
"""
...
@dataclass(frozen=True)
class _ResumeCheckpoint:
"""Private acknowledged selection read outside the writer, rechecked inside."""
ref: NativeTestContextRef
checkpoint: CheckpointRef
payload: NativeCheckpointPayload
pending_ids: tuple[str, ...]
observed_generation: int
@dataclass(frozen=True)
class NativeDispatchRef:
ref: NativeTestContextRef
generation: int
dispatch_id: str
@dataclass(frozen=True)
class PreparedNativeDispatchClaim:
ref: NativeTestContextRef
generation: int
dispatch_id: str
context: ResolvedNativeTestContext
context_object_id: str | None
checkpoint: CheckpointRef | None
payload: NativeCheckpointPayload | None
selected: dict[str, _ResumeCheckpoint]
class NativeCancellationSettlementPort(Protocol):
def finish_unacquired(self, *, ref: NativeTestContextRef) -> None:
"""SQL-only genuine initial-outbox cancellation; no physical ACK by ID."""
...
class NativeTestLifecycleService:
def __init__(
self,
loader: OwnedNativeTestLoader,
*,
state_store: NativeExecutionStateStore,
resolution: NativePendingResolutionPort | None = None,
runtime: NativeRuntimePort | None = None,
resume_settlement: NativeResumeSettlementPort | None = None,
cancellation_settlement: NativeCancellationSettlementPort | None = None,
clock: Callable[[], datetime] = utc_now,
):
self.loader, self.store, self.resolution, self.runtime, self.clock = (
loader,
state_store,
resolution,
runtime,
clock,
)
self._dispatch_ref: NativeDispatchRef | None = None
self.resume_settlement = resume_settlement
self.cancellation_settlement = cancellation_settlement
self._capacity: NativeHostCapacity | None = None
@staticmethod
def _request_row(session: Session, request_id: str) -> DifyBuilderExecutionRequest:
request = session.get(DifyBuilderExecutionRequest, request_id)
if request is None:
raise BuilderExecutionPolicyError("native_owner_mismatch")
return request
def _root_ref(self, actor: Actor, request_id: str) -> NativeTestContextRef:
if actor != self.loader.actor:
raise BuilderExecutionPolicyError("native_owner_mismatch")
with self.loader.session_factory() as session:
request = session.scalar(
select(DifyBuilderExecutionRequest).where(
DifyBuilderExecutionRequest.id == request_id,
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
DifyBuilderExecutionRequest.actor_id == actor.account_id,
)
)
state = session.scalar(
select(NativeInvocationState).where(
NativeInvocationState.request_id == request_id,
NativeInvocationState.tenant_id == actor.tenant_id,
NativeInvocationState.parent_invocation_id.is_(None),
)
)
if request is None or state is None:
raise BuilderExecutionPolicyError("native_owner_mismatch")
return NativeTestContextRef(
request_id=request_id, invocation_id=state.invocation_id, context_digest=request.context_digest
)
def fence_pending(
self, *, ref: NativeTestContextRef, pending_operation_ids: tuple[str, ...], expected_generation: int
) -> None:
"""Commit pause intent only; success acknowledges a fence, never a wait.
Permission RPC stays outside the writer. A lost acknowledgement does not
prove physical closure; already committed claims retain their owners.
"""
authority = validate_private_read(self.loader, ref)
with self.loader.locked(ref=ref) as (session, owned, _):
state = invocation_state(session, ref, owned.tenant_id)
if (
type(expected_generation) is not int
or (state.generation, state.app_id, state.workflow_id, state.context_object_id)
!= (expected_generation, authority.app_id, authority.workflow_id, authority.context_object_id)
or (state.generation != authority.generation)
):
raise BuilderExecutionPolicyError("native_generation_conflict")
dispatch_readiness(session, ref=ref, tenant_id=owned.tenant_id, now=self.clock(), coalesce_self=True)
workflow = validate_invocation_workflow(
session, tenant_id=owned.tenant_id, app_id=authority.app_id, workflow_id=authority.workflow_id
)
revision_context = authority.revision_context
revision = (
admitted_workflow_revision(workflow, revision_context)
if revision_context
else native_workflow_revision(workflow)
)
if revision != authority.graph_revision:
raise BuilderExecutionPolicyError("native_revision_drift")
if (
not isinstance(pending_operation_ids, tuple)
or not pending_operation_ids
or any(type(identity) is not str or not identity for identity in pending_operation_ids)
or len(set(pending_operation_ids)) != len(pending_operation_ids)
):
raise BuilderExecutionPolicyError("native_pending_mismatch")
base = state.pending_ids if state.wait_reason is not None else []
combined = tuple(dict.fromkeys((*base, *pending_operation_ids)))
rows = session.scalars(
owner_query(NativePendingAction, ref, owned.tenant_id).where(NativePendingAction.id.in_(combined))
).all()
# Validate the incoming tuple separately so duplicates or an empty
# request cannot disappear into the ordered union.
validate_pending_fence(
[row for row in rows if row.id in pending_operation_ids],
state=state,
ref=ref,
tenant_id=owned.tenant_id,
pending_ids=pending_operation_ids,
)
validated = validate_pending_fence(
rows, state=state, ref=ref, tenant_id=owned.tenant_id, pending_ids=combined
)
state.pending_ids, state.wait_reason = list(combined), pending_reason(validated)
# The writer context has committed before returning plain completion.
def seal_stop(
self, *, ref: NativeTestContextRef, expected_generation: int, dispatch_id: str, reason_code: str
) -> None:
"""Permanently close this request's authority, not its physical evidence.
Called by the exact currently owned native control sink. Settlement
ownership permits safe shutdown after permission revocation or expiry;
this SQL-only operation grants no I/O, checkpoint, cleanup or terminal
Run receipt. Acquisition evidence stays intact for actual settlement.
A repeated or ambiguously acknowledged seal cannot confer acceptance.
"""
if (
type(expected_generation) is not int
or type(dispatch_id) is not str
or not dispatch_id
or type(reason_code) is not str
or fullmatch(r"[a-z][a-z0-9_]{0,127}", reason_code) is None
):
raise BuilderExecutionPolicyError("native_stop_fence_invalid")
with self.loader.locked(ref=ref, settlement=True) as (session, owned, scope):
request = session.get(DifyBuilderExecutionRequest, ref.request_id)
states = session.scalars(
select(NativeInvocationState).where(NativeInvocationState.request_id == ref.request_id)
).all()
invocations = session.scalars(
select(NativeTestInvocation).where(NativeTestInvocation.request_id == ref.request_id)
).all()
dispatches = session.scalars(
select(NativeDispatch).where(NativeDispatch.request_id == ref.request_id)
).all()
owner = (ref.request_id, owned.tenant_id)
if any((row.request_id, row.tenant_id) != owner for row in (*states, *invocations, *dispatches)):
raise BuilderExecutionPolicyError("native_owner_mismatch")
by_id = {row.invocation_id: row for row in states}
active = {row.invocation_id: row for row in invocations}
roots = [row for row in states if row.parent_invocation_id is None]
current = next((row for row in dispatches if row.id == dispatch_id), None)
state = by_id.get(ref.invocation_id)
if (
request is None
or request.state not in {"running", "waiting"}
or scope.cancelled
or len(roots) != 1
or len(by_id) != len(states)
or len(active) != len(invocations)
or set(by_id) != set(active)
or state is None
or not active[ref.invocation_id].active
or state.state not in {"running", "waiting"}
or state.generation != expected_generation
or current is None
or (current.invocation_id, current.generation, current.state)
!= (ref.invocation_id, expected_generation, "claimed")
):
raise BuilderExecutionPolicyError("native_stop_fence_conflict")
root = roots[0]
if (root.app_id, root.workflow_id) != (owned.app_id, owned.workflow_id):
raise BuilderExecutionPolicyError("native_owner_mismatch")
connected = {root.invocation_id}
while True:
expanded = connected | {row.invocation_id for row in states if row.parent_invocation_id in connected}
if expanded == connected:
break
connected = expanded
if connected != set(by_id) or any(row.invocation_id not in connected for row in dispatches):
raise BuilderExecutionPolicyError("native_owner_mismatch")
request.state = "sealed"
for invocation in invocations:
invocation.active = False
for dispatch in dispatches:
if dispatch.state in {"queued", "published"}:
dispatch.state = "cancelled"
# None is returned only after the actual request writer acknowledges commit.
def pause(
self,
*,
ref: NativeTestContextRef,
checkpoint: CheckpointRef,
pending_operation_ids: tuple[str, ...],
expected_generation: int,
) -> WaitingReceipt:
payload = self.store.read_checkpoint(ref=ref, checkpoint=checkpoint)
if (
not isinstance(pending_operation_ids, tuple)
or not pending_operation_ids
or any(type(identity) is not str or not identity for identity in pending_operation_ids)
or len(set(pending_operation_ids)) != len(pending_operation_ids)
):
raise BuilderExecutionPolicyError("native_pending_mismatch")
with self.loader.locked(ref=ref) as (session, owned, _):
state = invocation_state(session, ref, owned.tenant_id)
if (
type(expected_generation) is not int
or state.generation != expected_generation
or payload.generation != expected_generation
):
raise BuilderExecutionPolicyError("native_generation_conflict")
if owned.expires_at <= self.clock():
raise BuilderExecutionPolicyError("native_expired")
if state.state not in {"running", "waiting"}:
raise BuilderExecutionPolicyError("native_state_conflict")
if type(state.pending_ids) is not list or state.wait_reason not in {
None,
"configuration",
"interaction",
"approval",
}:
raise BuilderExecutionPolicyError("native_pending_mismatch")
if state.state == "running" and state.wait_reason is None and state.pending_ids:
validate_pending_history(session, ref=ref, tenant_id=owned.tenant_id, state=state)
if state.wait_reason is not None and not set(state.pending_ids) <= set(pending_operation_ids):
raise BuilderExecutionPolicyError("native_pending_mismatch")
if (
state.wait_reason is not None
and pending_reason(self._pending_rows(session, owned, state)) != state.wait_reason
):
raise BuilderExecutionPolicyError("native_pending_mismatch")
child_pending = set()
for child_ref in payload.child_refs:
if (child_ref.request_id, child_ref.context_digest) != (ref.request_id, ref.context_digest):
raise BuilderExecutionPolicyError("native_child_owner_mismatch")
child_state = invocation_state(session, child_ref, owned.tenant_id)
if child_state.parent_invocation_id != ref.invocation_id:
raise BuilderExecutionPolicyError("native_child_owner_mismatch")
if child_state.state == "waiting":
if child_state.checkpoint_id is None:
raise BuilderExecutionPolicyError("native_child_wait_uncommitted")
child_continuation = session.scalar(
owner_query(NativeContinuation, child_ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == child_state.checkpoint_id,
NativeContinuation.generation == child_state.generation,
)
)
if child_continuation is None:
raise BuilderExecutionPolicyError("native_child_wait_uncommitted")
self._pending_rows(session, owned, child_state)
child_pending.update(child_state.pending_ids)
rows = session.scalars(
select(NativePendingAction).where(
NativePendingAction.tenant_id == owned.tenant_id,
NativePendingAction.request_id == ref.request_id,
NativePendingAction.id.in_(pending_operation_ids),
)
).all()
if len(rows) != len(pending_operation_ids) or any(
row.category not in {"approval", "configuration", "interaction"}
or row.id not in child_pending
and (row.invocation_id != ref.invocation_id or row.generation != expected_generation)
for row in rows
):
raise BuilderExecutionPolicyError("native_pending_mismatch")
continuation = session.scalar(
owner_query(NativeContinuation, ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == checkpoint.checkpoint_id,
NativeContinuation.generation == expected_generation,
NativeContinuation.payload_digest == checkpoint.payload_digest,
)
)
if continuation is None:
raise BuilderExecutionPolicyError("checkpoint_unavailable")
if state.state == "waiting" and (
state.checkpoint_id != checkpoint.checkpoint_id or set(state.pending_ids) != set(pending_operation_ids)
):
raise BuilderExecutionPolicyError("native_wait_conflict")
reasons = {row.category for row in rows}
reason = cast(
WaitReason, next(reason for reason in ("configuration", "interaction", "approval") if reason in reasons)
)
state.state, state.checkpoint_id = "waiting", checkpoint.checkpoint_id
state.pending_ids, state.wait_reason = list(pending_operation_ids), reason
self._request_row(session, ref.request_id).state = "waiting"
result = WaitingReceipt(
request_id=ref.request_id,
invocation_id=ref.invocation_id,
generation=expected_generation,
checkpoint=checkpoint,
pending_operation_ids=pending_operation_ids,
wait_reason=reason,
)
# Caller may emit this receipt only after the transaction has committed.
return result
def _pending_rows(self, session, owned, state, *, generations=None):
"""SQL provenance for own and precisely represented descendant waits."""
ids = state.pending_ids
if (
type(ids) is not list
or not ids
or any(type(identity) is not str or not identity for identity in ids)
or len(set(ids)) != len(ids)
):
raise BuilderExecutionPolicyError("native_pending_mismatch")
ref = owned.ref.model_copy(update={"invocation_id": state.invocation_id})
states = {row.invocation_id: row for row in self._subtree(session, ref, owned.tenant_id)}
rows = session.scalars(
select(NativePendingAction).where(
NativePendingAction.tenant_id == owned.tenant_id,
NativePendingAction.request_id == owned.ref.request_id,
NativePendingAction.id.in_(ids),
)
).all()
by_id = {row.id: row for row in rows}
if set(by_id) != set(ids) or len(rows) != len(ids):
raise BuilderExecutionPolicyError("native_pending_mismatch")
for row in rows:
owner = states.get(row.invocation_id)
expected = (
generations.get(row.invocation_id) if generations is not None else owner.generation if owner else None
)
if (
owner is None
or (row.tenant_id, row.request_id) != (owned.tenant_id, owned.ref.request_id)
or row.generation != expected
or row.id not in owner.pending_ids
or row.category not in {"approval", "configuration", "interaction"}
):
raise BuilderExecutionPolicyError("native_pending_mismatch")
return tuple(by_id[identity] for identity in ids)
def _resolve_pending(self, session, owned, state, *, generations=None) -> list[NativePendingResolution]:
resolutions = []
for pending in self._pending_rows(session, owned, state, generations=generations):
pending_ref = owned.ref.model_copy(update={"invocation_id": pending.invocation_id})
if pending.category == "approval":
operation = session.scalar(
owner_query(NativeEffectOperation, pending_ref, owned.tenant_id).where(
NativeEffectOperation.id == pending.operation_id
)
)
if operation is None:
raise BuilderExecutionPolicyError("native_pending_mismatch")
if operation.state == "completed":
continue
self.loader._load(session, ref=pending_ref, lock=False)
grant = session.scalar(
select(NativeEffectGrant).where(
NativeEffectGrant.tenant_id == owned.tenant_id,
NativeEffectGrant.request_id == owned.ref.request_id,
NativeEffectGrant.scope_digest == operation.scope_digest,
)
)
if (
operation.state != "pending"
or grant is None
or grant.decision != "approved"
or grant.expires_at <= self.clock()
or grant.spent_calls >= grant.max_calls
or grant.spent_dispatches >= grant.max_dispatches
):
raise BuilderExecutionPolicyError("native_pending_unresolved")
else:
if self.resolution is None:
raise BuilderExecutionPolicyError("native_pending_resolver_required")
pending_owned, _ = self.loader._load(session, ref=pending_ref, lock=False)
resolution = NativePendingResolution.model_validate(
self.resolution.resolve(session=session, owned=pending_owned, pending=pending).model_dump(
mode="json"
)
)
if (
resolution.category,
resolution.invocation_id,
resolution.pending_action_id,
resolution.owner_ref,
resolution.reference,
) != (
pending.category,
pending.invocation_id,
pending.id,
pending.owner_ref,
pending.reference,
):
raise BuilderExecutionPolicyError("native_pending_owner_mismatch")
if not resolution.resolved:
raise BuilderExecutionPolicyError("native_pending_unresolved")
resolutions.append(resolution)
return resolutions
def _validate_resolution_results(self, session, owned, resolutions):
"""Current protected answer provenance; no payload read inside a writer."""
from models.dify_builder_native_tests import NativePayloadObject
for resolution in resolutions:
if (
resolution.answer_result_ref is not None
and session.scalar(
owner_query(
NativePayloadObject,
owned.ref.model_copy(update={"invocation_id": resolution.invocation_id}),
owned.tenant_id,
).where(
NativePayloadObject.id == resolution.answer_result_ref,
NativePayloadObject.kind == "result",
)
)
is None
):
raise BuilderExecutionPolicyError("native_result_owner_mismatch")
def _checkpoint_tree(self, ref, checkpoint, payload):
"""Read only the acknowledged represented tree, with all payload I/O outside SQL writers."""
selected, stack = {}, [(ref, checkpoint, payload)]
while stack:
current_ref, current_checkpoint, current_payload = stack.pop()
if current_ref.invocation_id in selected:
raise BuilderExecutionPolicyError("native_child_owner_mismatch")
with self.loader.session_factory() as session:
owned, _ = self.loader._load(session, ref=current_ref, lock=False, settlement=True)
state = invocation_state(session, current_ref, owned.tenant_id)
if state.checkpoint_id != current_checkpoint.checkpoint_id:
raise BuilderExecutionPolicyError("checkpoint_unavailable")
selected[current_ref.invocation_id] = _ResumeCheckpoint(
current_ref, current_checkpoint, current_payload, tuple(state.pending_ids), state.generation
)
children = []
for child_ref in current_payload.child_refs:
if (child_ref.request_id, child_ref.context_digest) != (ref.request_id, ref.context_digest):
raise BuilderExecutionPolicyError("native_child_owner_mismatch")
child = invocation_state(session, child_ref, owned.tenant_id)
if child.parent_invocation_id != current_ref.invocation_id:
raise BuilderExecutionPolicyError("native_child_owner_mismatch")
if child.state not in {"waiting", "queued", "completed", "stopped"} or not child.pending_ids:
continue
saved = session.scalar(
owner_query(NativeContinuation, child_ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == child.checkpoint_id
)
)
if saved is None:
raise BuilderExecutionPolicyError("checkpoint_unavailable")
children.append(
(
child_ref,
CheckpointRef(checkpoint_id=saved.checkpoint_id, payload_digest=saved.payload_digest),
)
)
for child_ref, child_checkpoint in children:
child_payload = self.store.read_checkpoint(ref=child_ref, checkpoint=child_checkpoint)
stack.append((child_ref, child_checkpoint, child_payload))
return selected
def _validate_checkpoints(self, session, owned, selected):
states = {}
for identity, proof in selected.items():
state = invocation_state(session, proof.ref, owned.tenant_id)
continuation = session.scalar(
owner_query(NativeContinuation, proof.ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == proof.checkpoint.checkpoint_id,
NativeContinuation.payload_digest == proof.checkpoint.payload_digest,
NativeContinuation.generation == proof.payload.generation,
)
)
if (
continuation is None
or state.checkpoint_id != proof.checkpoint.checkpoint_id
or tuple(state.pending_ids) != proof.pending_ids
or state.generation != proof.observed_generation
or continuation.generation != proof.payload.generation
or state.state == "waiting"
and state.generation != proof.payload.generation
):
raise BuilderExecutionPolicyError("native_pending_mismatch")
states[identity] = state
return states
def _resume_selection(self, *, actor: Actor, request_id: str):
ref = self._root_ref(actor, request_id)
context = self.store.load_context(ref=ref) # current RBAC, revision and immutable input snapshot
with self.loader.session_factory() as session:
observed = invocation_state(session, ref, context.tenant_id)
saved = session.scalar(
owner_query(NativeContinuation, ref, context.tenant_id).where(
NativeContinuation.checkpoint_id == observed.checkpoint_id
)
)
if saved is None:
raise BuilderExecutionPolicyError("checkpoint_unavailable")
checkpoint = CheckpointRef(checkpoint_id=saved.checkpoint_id, payload_digest=saved.payload_digest)
try:
recovered = self.store.read_checkpoint(ref=ref, checkpoint=checkpoint)
if recovered.native_run_id != context.native_run_id:
raise BuilderExecutionPolicyError("native_run_mismatch")
except Exception:
raise BuilderExecutionPolicyError("checkpoint_unavailable") from None
selected = self._checkpoint_tree(ref, checkpoint, recovered)
return ref, context, checkpoint, recovered, selected
def _validate_resume(self, *, session, owned, scope, checkpoint, recovered, selected, expected_generation):
"""Shared SQL-only resume checks; no generation, queue, or grant writes."""
if owned.expires_at <= self.clock():
raise BuilderExecutionPolicyError("native_expired")
ref = owned.ref
state = invocation_state(session, ref, owned.tenant_id)
if type(expected_generation) is not int or expected_generation != state.generation:
raise BuilderExecutionPolicyError("native_generation_conflict")
if state.state != "waiting" or state.checkpoint_id != checkpoint.checkpoint_id:
raise BuilderExecutionPolicyError("native_state_conflict")
if has_unknown_descendant(session, ref, owned.tenant_id):
raise BuilderExecutionPolicyError("native_subtree_unknown")
represented = self._validate_checkpoints(session, owned, selected)
generations = {identity: proof.payload.generation for identity, proof in selected.items()}
if recovered.generation != state.generation:
raise BuilderExecutionPolicyError("native_generation_conflict")
if self.resume_settlement is not None:
self.resume_settlement.validate(session=session, owned=owned, state=state, checkpoint=checkpoint)
resolutions = self._resolve_pending(session, owned, state, generations=generations)
self._validate_resolution_results(session, owned, resolutions)
return state, represented, resolutions
def check_resume(self, *, actor: Actor, request_id: str, expected_generation: int) -> None:
"""Full advisory proof; the actual command repeats every check under lock.
Existing owned storage reads and permission checks are allowed. This
path invokes no business operation, OAuth refresh, LLM, claim or queue
publication and writes no SQL/object-storage state.
"""
ref, _context, checkpoint, recovered, selected = self._resume_selection(actor=actor, request_id=request_id)
with self.loader.session_factory() as session:
owned, scope = self.loader._load(session, ref=ref, lock=False)
self._validate_resume(
session=session,
owned=owned,
scope=scope,
checkpoint=checkpoint,
recovered=recovered,
selected=selected,
expected_generation=expected_generation,
)
def resume(self, *, actor: Actor, request_id: str, expected_generation: int, idempotency_key: str) -> ResumeReceipt:
ref, _context, checkpoint, recovered, selected = self._resume_selection(actor=actor, request_id=request_id)
digest = self._idempotency("resume", idempotency_key)
with self.loader.locked(ref=ref) as (session, owned, scope):
if owned.expires_at <= self.clock():
raise BuilderExecutionPolicyError("native_expired")
previous = session.scalar(
select(NativeDispatch).where(
NativeDispatch.request_id == request_id,
NativeDispatch.tenant_id == owned.tenant_id,
NativeDispatch.idempotency_digest == digest,
)
)
if previous is not None:
prior_checkpoint = session.scalar(
owner_query(NativeContinuation, ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == previous.checkpoint_id,
NativeContinuation.generation == expected_generation,
)
)
if type(expected_generation) is not int or prior_checkpoint is None:
raise BuilderExecutionPolicyError("native_generation_conflict")
return self._receipt(previous)
state, represented, resolutions = self._validate_resume(
session=session,
owned=owned,
scope=scope,
checkpoint=checkpoint,
recovered=recovered,
selected=selected,
expected_generation=expected_generation,
)
scope.generation += 1
# Advance precisely the waiting descendants represented by this
# parent's committed wait. Other branches retain their own cursors.
resolved_ids = set(state.pending_ids)
for child in self._subtree(session, ref, owned.tenant_id):
if (
child.invocation_id != ref.invocation_id
and child.invocation_id in represented
and child.state == "waiting"
and child.pending_ids
and set(child.pending_ids) <= resolved_ids
):
child.generation, child.state = scope.generation, "queued"
state.generation, state.state = scope.generation, "queued"
state.resolution = [resolution.model_dump(mode="json") for resolution in resolutions]
dispatch = NativeDispatch(
tenant_id=owned.tenant_id,
request_id=request_id,
invocation_id=ref.invocation_id,
generation=scope.generation,
idempotency_digest=digest,
state="queued",
checkpoint_id=state.checkpoint_id,
claim_expires_at=None,
)
session.add(dispatch)
session.flush()
result = self._receipt(dispatch)
return result
@staticmethod
def _idempotency(kind: str, key: str) -> str:
if not isinstance(key, str) or not key or len(key) > 128:
raise BuilderExecutionPolicyError("invalid_idempotency_key")
return canonical_digest({"kind": kind, "key": key})
@staticmethod
def _receipt(dispatch: NativeDispatch) -> ResumeReceipt:
status = (
"running"
if dispatch.state == "claimed"
else "queued"
if dispatch.state in {"queued", "published"}
else "terminal"
)
return ResumeReceipt(
request_id=dispatch.request_id, generation=dispatch.generation, dispatch_id=dispatch.id, status=status
)
def publish_pending(self, *, publish: Callable[..., None], limit: int = 100, request_id: str | None = None) -> int:
"""Every unclaimed outbox remains retryable, including lost broker acknowledgements."""
with self.loader.session_factory() as session:
query = (
select(NativeDispatch, DifyBuilderExecutionRequest.context_digest)
.join(DifyBuilderExecutionRequest, DifyBuilderExecutionRequest.id == NativeDispatch.request_id)
.where(
NativeDispatch.tenant_id == self.loader.actor.tenant_id,
DifyBuilderExecutionRequest.actor_id == self.loader.actor.account_id,
NativeDispatch.state.in_(("queued", "published")),
)
.order_by(NativeDispatch.id)
)
if request_id is not None:
query = query.where(NativeDispatch.request_id == request_id)
rows = session.execute(query.limit(limit)).all()
published = 0
for dispatch, digest in rows:
ref = NativeTestContextRef(
request_id=dispatch.request_id, invocation_id=dispatch.invocation_id, context_digest=digest
)
try:
self.loader.load(ref=ref)
publish(
ref=ref.model_dump(mode="json"),
generation=dispatch.generation,
dispatch_id=dispatch.id,
actor={"account_id": self.loader.actor.account_id, "tenant_id": self.loader.actor.tenant_id},
)
except Exception:
continue # durable row remains available to the next sweep
with self.loader.locked(ref=ref) as (session, _, _):
row = session.get(NativeDispatch, dispatch.id)
if row is None:
raise BuilderExecutionPolicyError("native_dispatch_conflict")
if row.state == "queued":
row.state = "published"
published += 1
return published
def _validate_initial(self, session, owned, scope, context, state, *, states):
from models.dify_builder import DifyBuilderTestInput
from models.dify_builder_native_tests import (
NativeEffectAttempt,
NativeEffectDispatchClaim,
NativeEffectOutcome,
NativePayloadObject,
)
if (
type(state.generation) is not int
or state.generation != 0
or scope.generation != 0
or context.generation != 0
or context.ref != owned.ref
or context.parent_invocation_id is not None
or state.parent_invocation_id is not None
or context.execution_mode != "native"
or not context.native_run_id
or context.native_run_id != context.root_native_run_id
or state.state not in states
or state.checkpoint_id is not None
or state.pending_ids != []
or state.wait_reason is not None
or state.resolution != []
or state.outcome is not None
or state.cancellation_key is not None
or state.cleanup_complete is not False
or scope.cancelled
or owned.expires_at <= self.clock()
or (state.app_id, state.workflow_id) != (context.app_id, context.workflow_id)
or (context.tenant_id, context.actor_id, context.app_id, context.workflow_id)
!= (owned.tenant_id, owned.actor_id, owned.app_id, owned.workflow_id)
or any(value != 0 for value in (scope.spent_calls, scope.spent_dispatches, scope.spent_cost_units))
or scope.budget_spent != {}
):
raise BuilderExecutionPolicyError("native_initial_conflict")
row = session.scalar(
owner_query(NativePayloadObject, owned.ref, owned.tenant_id).where(
NativePayloadObject.id == state.context_object_id,
NativePayloadObject.kind == "context",
NativePayloadObject.value_digest == canonical_digest(context.model_dump(mode="json")),
)
)
if row is None:
raise BuilderExecutionPolicyError("native_state_unavailable")
workflow = validate_invocation_workflow(
session, tenant_id=owned.tenant_id, app_id=context.app_id, workflow_id=context.workflow_id
)
inputs = session.get(DifyBuilderTestInput, owned.test_input_id)
revision_context = owned.revision_context
if (
(
admitted_workflow_revision(workflow, revision_context)
if revision_context
else native_workflow_revision(workflow)
)
!= context.graph_revision
or inputs is None
or canonical_digest(inputs.inputs) != context.input_revision
or canonical_digest(context.inputs) != context.input_revision
):
raise BuilderExecutionPolicyError("native_revision_drift")
for model in (
NativeContinuation,
NativePendingAction,
NativeEffectOperation,
NativeEffectAttempt,
NativeEffectDispatchClaim,
NativeEffectOutcome,
):
if (
session.scalar(
select(model.id).where(model.request_id == owned.ref.request_id, model.tenant_id == owned.tenant_id)
)
is not None
):
raise BuilderExecutionPolicyError("native_initial_conflict")
lineage = session.scalars(
select(NativeInvocationState).where(
NativeInvocationState.request_id == owned.ref.request_id,
NativeInvocationState.tenant_id == owned.tenant_id,
)
).all()
if len(lineage) != 1 or lineage[0].invocation_id != owned.ref.invocation_id:
raise BuilderExecutionPolicyError("native_initial_conflict")
def enqueue_initial_dispatch(self, *, ref: NativeTestContextRef) -> NativeDispatchRef:
context = self.store.load_context(ref=ref)
digest = canonical_digest({"initial": ref.model_dump(mode="json")})
with self.loader.locked(ref=ref) as (session, owned, scope):
state = invocation_state(session, ref, owned.tenant_id)
existing = session.scalars(owner_query(NativeDispatch, ref, owned.tenant_id)).all()
if existing:
if (
len(existing) != 1
or existing[0].generation != 0
or existing[0].checkpoint_id is not None
or existing[0].idempotency_digest != digest
):
raise BuilderExecutionPolicyError("native_initial_conflict")
return NativeDispatchRef(ref, 0, existing[0].id)
self._validate_initial(session, owned, scope, context, state, states={"running"})
dispatch_readiness(session, ref=ref, tenant_id=owned.tenant_id, now=self.clock())
dispatch = NativeDispatch(
tenant_id=owned.tenant_id,
request_id=ref.request_id,
invocation_id=ref.invocation_id,
generation=0,
idempotency_digest=digest,
state="queued",
checkpoint_id=None,
claim_expires_at=None,
)
session.add(dispatch)
state.state = "queued"
session.flush()
result = NativeDispatchRef(ref, 0, dispatch.id)
return result
def claim_dispatch(self, *, ref: NativeTestContextRef, generation: int, dispatch_id: str) -> bool:
if self._capacity is not None:
return self._capacity.claim_dispatch(ref=ref, generation=generation, dispatch_id=dispatch_id)
prepared = self.prepare_dispatch_claim(ref=ref, generation=generation, dispatch_id=dispatch_id)
if prepared is None:
return False
with self.loader.locked(ref=ref) as (session, owned, scope):
return self.consume_dispatch_claim(session=session, owned=owned, scope=scope, prepared=prepared)
def claimed_resume_checkpoint(
self, *, ref: NativeTestContextRef, generation: int, dispatch_id: str
) -> CheckpointRef:
"""Metadata handoff for an already claimed resume, never new execution authority.
Permission checks precede the bounded SQL read; no writer or private I/O.
Runtime must still reread this C03 object and verify its actual native run.
"""
from models.dify_builder_native_tests import NativePayloadObject
if type(generation) is not int or generation <= 0 or type(dispatch_id) is not str or not dispatch_id:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
authority = validate_private_read(self.loader, ref)
if not authority.root_revision_matches:
raise BuilderExecutionPolicyError("native_revision_drift")
if authority.cancelled or not authority.active or authority.request_state not in {"running", "waiting"}:
raise BuilderExecutionPolicyError("native_invocation_mismatch")
with self.loader.session_factory() as session:
owned, _ = self.loader._load(session, ref=ref, lock=False)
state = invocation_state(session, ref, owned.tenant_id)
if (
owned.ref != ref
or (authority.tenant_id, authority.actor_id) != (owned.tenant_id, owned.actor_id)
or (state.generation, authority.generation) != (generation, generation)
or (state.app_id, state.workflow_id, state.context_object_id, state.parent_invocation_id)
!= (
authority.app_id,
authority.workflow_id,
authority.context_object_id,
authority.parent_invocation_id,
)
):
raise BuilderExecutionPolicyError("native_generation_conflict")
dispatch_readiness(session, ref=ref, tenant_id=owned.tenant_id, now=self.clock())
dispatch = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
if (
dispatch is None
or dispatch.state != "claimed"
or dispatch.generation != generation
or dispatch.claim_expires_at is None
or dispatch.claim_expires_at <= self.clock()
or not dispatch.checkpoint_id
or state.checkpoint_id != dispatch.checkpoint_id
):
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
saved = session.scalar(
owner_query(NativeContinuation, ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == dispatch.checkpoint_id
)
)
row = session.scalar(
owner_query(NativePayloadObject, ref, owned.tenant_id).where(
NativePayloadObject.id == dispatch.checkpoint_id, NativePayloadObject.kind == "checkpoint"
)
)
if (
saved is None
or row is None
or type(saved.generation) is not int
or not 0 <= saved.generation < generation
or row.logical_key != str(saved.generation)
or row.payload_digest != saved.payload_digest
or not saved.payload_ref
or row.storage_key != saved.payload_ref
):
raise BuilderExecutionPolicyError("checkpoint_unavailable")
try:
return CheckpointRef(checkpoint_id=saved.checkpoint_id, payload_digest=saved.payload_digest)
except ValueError:
raise BuilderExecutionPolicyError("checkpoint_unavailable") from None
def prepare_dispatch_claim(
self, *, ref: NativeTestContextRef, generation: int, dispatch_id: str
) -> PreparedNativeDispatchClaim | None:
# Already-claimed/unknown deliveries can be rejected even after the
# invocation has been quarantined. They acquire no execution authority.
with self.loader.session_factory() as session:
owned, _ = self.loader._load(session, ref=ref, lock=False, settlement=True)
prior = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
if (
prior is not None
and prior.generation == generation
and prior.state in {"claimed", "completed", "unknown"}
):
return None
require_ancestor_authority(session, ref, owned.tenant_id)
self.loader.load(ref=ref)
context = self.store.load_context(ref=ref)
with self.loader.session_factory() as session:
owned, _ = self.loader._load(session, ref=ref, lock=False)
observed = invocation_state(session, ref, owned.tenant_id)
dispatch = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
if dispatch is None or observed.checkpoint_id != dispatch.checkpoint_id:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
if generation == 0 and dispatch.generation == 0 and dispatch.checkpoint_id is None:
if dispatch.idempotency_digest != canonical_digest({"initial": ref.model_dump(mode="json")}):
raise BuilderExecutionPolicyError("native_initial_conflict")
self._validate_initial(
session,
owned,
self.loader._load(session, ref=ref, lock=False)[1],
context,
observed,
states={"queued"},
)
return PreparedNativeDispatchClaim(
ref, generation, dispatch_id, context, observed.context_object_id, None, None, {}
)
saved = session.scalar(
owner_query(NativeContinuation, ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == dispatch.checkpoint_id
)
)
if saved is None:
raise BuilderExecutionPolicyError("checkpoint_unavailable")
checkpoint = CheckpointRef(checkpoint_id=saved.checkpoint_id, payload_digest=saved.payload_digest)
payload = self.store.read_checkpoint(ref=ref, checkpoint=checkpoint)
if payload.native_run_id != context.native_run_id:
raise BuilderExecutionPolicyError("native_run_mismatch")
selected = self._checkpoint_tree(ref, checkpoint, payload)
return PreparedNativeDispatchClaim(ref, generation, dispatch_id, context, None, checkpoint, payload, selected)
def consume_dispatch_claim(
self, *, session: Session, owned: OwnedNativeTest, scope: NativeTestScope, prepared: PreparedNativeDispatchClaim
) -> bool:
"""SQL only; caller holds the request lock, and App first for physical admission."""
ref, generation, dispatch_id = prepared.ref, prepared.generation, prepared.dispatch_id
if owned.ref != ref:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
dispatch = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
if dispatch is None or type(generation) is not int or generation != dispatch.generation:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
if dispatch.state not in {"queued", "published"}:
return False
# Recheck the full local chain in this transaction, without RPC or a new session.
owned, scope = self.loader._load(session, ref=ref, lock=False)
state = invocation_state(session, ref, owned.tenant_id)
if generation == 0 and dispatch.checkpoint_id is None:
if dispatch.idempotency_digest != canonical_digest({"initial": ref.model_dump(mode="json")}):
raise BuilderExecutionPolicyError("native_initial_conflict")
if state.context_object_id != prepared.context_object_id:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
if prepared.checkpoint is not None or prepared.payload is not None or prepared.selected:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
self._validate_initial(session, owned, scope, prepared.context, state, states={"queued"})
state.state = "running"
dispatch_readiness(session, ref=ref, tenant_id=owned.tenant_id, now=self.clock())
dispatch.state, dispatch.claim_expires_at = "claimed", self.clock() + timedelta(minutes=10)
state.cleanup_complete = False
request = session.get(DifyBuilderExecutionRequest, ref.request_id)
if request is None:
raise BuilderExecutionPolicyError("native_owner_mismatch")
request.state = "running"
return True
checkpoint, payload, selected = prepared.checkpoint, prepared.payload, prepared.selected
if checkpoint is None or payload is None:
raise BuilderExecutionPolicyError("checkpoint_unavailable")
require_ancestor_authority(session, ref, owned.tenant_id)
state = invocation_state(session, ref, owned.tenant_id)
dispatch = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
if dispatch is None or type(generation) is not int or generation != dispatch.generation:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
if dispatch.state not in {"queued", "published"}:
return False
if state.generation != generation or state.state != "queued" or owned.expires_at <= self.clock():
raise BuilderExecutionPolicyError("native_generation_conflict")
if has_unknown_descendant(session, ref, owned.tenant_id):
raise BuilderExecutionPolicyError("native_subtree_unknown")
if dispatch.checkpoint_id != checkpoint.checkpoint_id or payload.generation >= generation:
raise BuilderExecutionPolicyError("native_dispatch_mismatch")
represented = self._validate_checkpoints(session, owned, selected)
generations = {identity: proof.payload.generation for identity, proof in selected.items()}
resolutions = self._resolve_pending(session, owned, state, generations=generations)
if [resolution.model_dump(mode="json") for resolution in resolutions] != state.resolution:
raise BuilderExecutionPolicyError("native_pending_unresolved")
self._validate_resolution_results(session, owned, resolutions)
resolved_ids = set(state.pending_ids)
reopened = [state]
for child in self._subtree(session, ref, owned.tenant_id):
if (
child.invocation_id != ref.invocation_id
and child.invocation_id in represented
and child.generation == generation
and child.state == "queued"
and child.pending_ids
and set(child.pending_ids) <= resolved_ids
):
reopened.append(child)
dispatch.state, dispatch.claim_expires_at = "claimed", self.clock() + timedelta(minutes=10)
for current in reopened:
current.state, current.wait_reason = "running", None
for current in reopened:
dispatch_readiness(
session,
ref=ref.model_copy(update={"invocation_id": current.invocation_id}),
tenant_id=owned.tenant_id,
now=self.clock(),
)
state.cleanup_complete = False
self._request_row(session, ref.request_id).state = "running"
return True
def execute_dispatch(
self, *, ref: NativeTestContextRef, generation: int, dispatch_id: str
) -> NativeWorkflowOutcome | None:
if self.runtime is None:
raise BuilderExecutionPolicyError("native_runtime_unavailable")
if self._dispatch_ref is not None and self._dispatch_ref != NativeDispatchRef(ref, generation, dispatch_id):
raise BuilderExecutionPolicyError("native_dispatch_conflict")
if self._capacity is not None and self._capacity._used:
return None # Exact already-used acquisition, including a terminal duplicate.
# Validate/read recovery material before claiming. No fake completion or
# ordinary-runtime fallback if a qualified collaborator is absent.
context = self.store.load_context(ref=ref)
checkpoint = None
resolutions = ()
if generation != 0:
with self.loader.session_factory() as session:
state = invocation_state(session, ref, context.tenant_id)
row = session.scalar(
owner_query(NativeContinuation, ref, context.tenant_id).where(
NativeContinuation.checkpoint_id == state.checkpoint_id
)
)
if row is None:
raise BuilderExecutionPolicyError("checkpoint_unavailable")
checkpoint_ref = CheckpointRef(checkpoint_id=row.checkpoint_id, payload_digest=row.payload_digest)
resolutions = tuple(NativePendingResolution.model_validate(raw) for raw in state.resolution)
checkpoint = self.store.read_checkpoint(ref=ref, checkpoint=checkpoint_ref)
if not self.claim_dispatch(ref=ref, generation=generation, dispatch_id=dispatch_id):
return None
try:
if generation == 0:
outcome = self.runtime.run_initial(context=context)
else:
assert checkpoint is not None
outcome = self.runtime.resume(context=context, checkpoint=checkpoint, resolutions=resolutions)
outcome = NativeWorkflowOutcome.model_validate(outcome.model_dump(mode="json"))
if outcome.ref != ref or outcome.native_run_id != context.native_run_id:
raise BuilderExecutionPolicyError("native_outcome_mismatch")
if outcome.status == "waiting":
if outcome.checkpoint_ref is None:
raise BuilderExecutionPolicyError("native_outcome_mismatch")
self.pause(
ref=ref,
checkpoint=outcome.checkpoint_ref,
pending_operation_ids=outcome.pending_operation_ids,
expected_generation=generation,
)
elif outcome.output_result_ref is not None:
self.store.load_result(ref=ref, result_ref=outcome.output_result_ref)
with self.loader.locked(ref=ref, settlement=True) as (session, owned, scope):
self.finalize_dispatch_locked(
session=session,
owned=owned,
scope=scope,
dispatch_id=dispatch_id,
generation=generation,
outcome=outcome,
)
return outcome
except Exception:
self._mark_unknown(ref=ref, dispatch_id=dispatch_id)
raise
def finalize_dispatch_locked(
self,
*,
session: Session,
owned: OwnedNativeTest,
scope: NativeTestScope,
dispatch_id: str,
generation: int,
outcome: NativeWorkflowOutcome,
terminal_failure: bool = False,
) -> None:
"""SQL projection only, after caller's real outcome/checkpoint/result preparation."""
ref = owned.ref
if outcome.ref != ref:
raise BuilderExecutionPolicyError("native_outcome_mismatch")
if owned.state == "sealed" and outcome.status not in {"stopped", "unknown"}:
raise BuilderExecutionPolicyError("native_request_sealed")
state = invocation_state(session, ref, owned.tenant_id)
dispatch = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
# Only the confirmed terminal owner supplies this after exact source,
# terminal child and unconsumed effect proof under this same root lock.
# It closes a quarantine; it never restores execution authority.
close_quarantine = terminal_failure and outcome.status == "completed" and outcome.native_status == "failed"
if (
dispatch is None
or type(generation) is not int
or dispatch.generation != generation
or dispatch.state not in ({"claimed", "unknown"} if close_quarantine else {"claimed"})
or state.generation != generation
or scope.cancelled
):
raise BuilderExecutionPolicyError("native_dispatch_conflict")
if outcome.status == "waiting":
checkpoint = outcome.checkpoint_ref
if (
checkpoint is None
or state.state != "waiting"
or state.checkpoint_id != checkpoint.checkpoint_id
or tuple(state.pending_ids) != outcome.pending_operation_ids
or state.wait_reason != outcome.wait_reason
or session.scalar(
owner_query(NativeContinuation, ref, owned.tenant_id).where(
NativeContinuation.checkpoint_id == checkpoint.checkpoint_id,
NativeContinuation.payload_digest == checkpoint.payload_digest,
NativeContinuation.generation == generation,
)
)
is None
):
raise BuilderExecutionPolicyError("checkpoint_unavailable")
self._pending_rows(session, owned, state)
if outcome.output_result_ref is not None:
from models.dify_builder_native_tests import NativePayloadObject
if (
session.scalar(
owner_query(NativePayloadObject, ref, owned.tenant_id).where(
NativePayloadObject.id == outcome.output_result_ref,
NativePayloadObject.kind == "result",
)
)
is None
):
raise BuilderExecutionPolicyError("native_result_owner_mismatch")
if outcome.status == "completed" and any(
child.state == "unknown" and not (close_quarantine and child.invocation_id == ref.invocation_id)
for child in self._subtree(session, ref, owned.tenant_id)
):
raise BuilderExecutionPolicyError("native_subtree_unknown")
if outcome.status == "unknown":
quarantine_unknown(session, ref=ref, tenant_id=owned.tenant_id, outcome=outcome.model_dump(mode="json"))
else:
dispatch.state = "completed"
if outcome.status not in {"waiting", "unknown"}:
state.state, state.outcome = outcome.status, outcome.model_dump(mode="json")
invocation = session.scalar(owner_query(NativeTestInvocation, ref, owned.tenant_id))
invocation.active = False
if state.parent_invocation_id is None and outcome.status != "unknown":
remaining = session.scalar(
select(NativeTestInvocation.id).where(
NativeTestInvocation.request_id == ref.request_id,
NativeTestInvocation.tenant_id == owned.tenant_id,
NativeTestInvocation.invocation_id != ref.invocation_id,
NativeTestInvocation.active.is_(True),
)
)
if remaining is not None:
raise BuilderExecutionPolicyError("native_children_running")
self._request_row(session, ref.request_id).state = "sealed"
def _mark_unknown(self, *, ref: NativeTestContextRef, dispatch_id: str) -> None:
with self.loader.locked(ref=ref, settlement=True) as (session, owned, _):
dispatch = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
if dispatch is not None and dispatch.state == "claimed":
dispatch.state = "unknown"
state = invocation_state(session, ref, owned.tenant_id)
if state.generation == dispatch.generation and state.state != "cancelled":
quarantine_unknown(session, ref=ref, tenant_id=owned.tenant_id)
_subtree = staticmethod(invocation_subtree)
def reconcile(self, *, limit: int = 100, request_id: str | None = None) -> int:
with self.loader.session_factory() as session:
query = (
select(NativeDispatch, DifyBuilderExecutionRequest.context_digest)
.join(DifyBuilderExecutionRequest, DifyBuilderExecutionRequest.id == NativeDispatch.request_id)
.where(
NativeDispatch.tenant_id == self.loader.actor.tenant_id,
DifyBuilderExecutionRequest.actor_id == self.loader.actor.account_id,
NativeDispatch.state == "claimed",
NativeDispatch.claim_expires_at <= self.clock(),
)
)
if request_id is not None:
query = query.where(NativeDispatch.request_id == request_id)
rows = session.execute(query.limit(limit)).all()
for row, digest in rows:
self._mark_unknown(
ref=NativeTestContextRef(
request_id=row.request_id, invocation_id=row.invocation_id, context_digest=digest
),
dispatch_id=row.id,
)
with self.loader.session_factory() as session:
cancellation_query = (
select(NativeInvocationState.request_id)
.join(DifyBuilderExecutionRequest, DifyBuilderExecutionRequest.id == NativeInvocationState.request_id)
.where(
NativeInvocationState.tenant_id == self.loader.actor.tenant_id,
DifyBuilderExecutionRequest.actor_id == self.loader.actor.account_id,
NativeInvocationState.cancellation_key.is_not(None),
NativeInvocationState.cleanup_complete.is_(False),
)
.distinct()
)
if request_id is not None:
cancellation_query = cancellation_query.where(NativeInvocationState.request_id == request_id)
cancelled = session.scalars(cancellation_query.limit(limit)).all()
for request_id in cancelled:
self.cancel(actor=self.loader.actor, request_id=request_id, idempotency_key="cleanup-reconcile")
return len(rows) + len(cancelled)
def recover_dispatch(self, *, ref: NativeTestContextRef, dispatch_id: str) -> None:
"""Require native fencing proof and current authority before restoring execution.
Physical dispatch uncertainty cannot reopen a terminal business outcome.
Recheck authority under the root lock after collecting external proof.
"""
if self.runtime is None:
raise BuilderExecutionPolicyError("native_runtime_unavailable")
context = self.store.load_recovery_context(ref=ref)
with self.loader.session_factory() as session:
owned, scope = self.loader._load(session, ref=ref, lock=False, settlement=True)
self._check_recovery_authority(session, ref, owned, scope)
row = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
state = invocation_state(session, ref, owned.tenant_id)
self._check_recovery_source(session, ref, owned, row, state)
proof = self.runtime.recovery_evidence(context=context, dispatch_id=dispatch_id)
if proof not in {"not_started", "stopped"}:
raise BuilderExecutionPolicyError("native_recovery_unproven")
recovery_lock = self._capacity.locked if self._capacity is not None else self.loader.locked
with recovery_lock(ref=ref, settlement=True) as (session, owned, scope):
row = session.scalar(
owner_query(NativeDispatch, ref, owned.tenant_id).where(NativeDispatch.id == dispatch_id)
)
state = invocation_state(session, ref, owned.tenant_id)
self._check_recovery_authority(session, ref, owned, scope)
if row is None or row.state != "unknown" or state.generation != row.generation:
raise BuilderExecutionPolicyError("native_dispatch_conflict")
self._check_recovery_source(session, ref, owned, row, state)
row.state, row.claim_expires_at = "queued", None
state.state = "queued"
# Proof applies to the complete native execution subtree. Preserve
# completed/unknown child outcomes and the prior waiting cursors.
recoverable = recoverable_invocation_ids(self._subtree(session, ref, owned.tenant_id), ref.invocation_id)
for invocation in session.scalars(
select(NativeTestInvocation).where(
NativeTestInvocation.request_id == ref.request_id,
NativeTestInvocation.tenant_id == owned.tenant_id,
NativeTestInvocation.invocation_id.in_(recoverable),
)
):
invocation.active = True
@staticmethod
def _check_recovery_source(session, ref, owned, row, state):
if (
row is None
or row.state != "unknown"
or row.generation != state.generation
or row.checkpoint_id != state.checkpoint_id
):
raise BuilderExecutionPolicyError("native_recovery_unavailable")
# Retained exact IDs survive the first claim. A later active fence or
# checkpoint may replace that source; never guess it back into existence.
validate_pending_history(session, ref=ref, tenant_id=owned.tenant_id, state=state, dispatch_states=("unknown",))
def _check_recovery_authority(self, session, ref, owned, scope):
if scope.cancelled or owned.expires_at <= self.clock() or owned.state not in {"running", "waiting"}:
raise BuilderExecutionPolicyError("native_recovery_unavailable")
if self._capacity is not None and not invocation_state(session, ref, owned.tenant_id).cleanup_complete:
raise BuilderExecutionPolicyError("native_cleanup_unproven")
require_ancestor_authority(session, ref, owned.tenant_id)
state = invocation_state(session, ref, owned.tenant_id)
if state.state in {"completed", "stopped", "cancelled"}:
raise BuilderExecutionPolicyError("native_recovery_unavailable")
def cancellation_receipt(
self, *, session: Session, owned: OwnedNativeTest, scope: NativeTestScope
) -> CancellationReceipt:
"""Read current cancellation facts; physical ACK never settles a source.
The existing request owner validates actor/session/app/workflow/context.
Inspect all request history so foreign or missing rows cannot prove clean
cancellation, and retain older unresolved local acquisitions.
"""
ref = owned.ref
# GET may already retain a pre-cancel version of these ORM identities.
# Reselecting alone does not replace their cached attributes.
states = session.scalars(
select(NativeInvocationState)
.where(NativeInvocationState.request_id == ref.request_id)
.execution_options(populate_existing=True)
).all()
invocations = session.scalars(
select(NativeTestInvocation).where(NativeTestInvocation.request_id == ref.request_id)
).all()
sources = session.scalars(select(NativeDispatch).where(NativeDispatch.request_id == ref.request_id)).all()
by_id = {row.invocation_id: row for row in states}
complete = (
bool(states)
and len(by_id) == len(states) == len(invocations)
and set(by_id) == {row.invocation_id for row in invocations}
and all(
(row.request_id, row.tenant_id) == (ref.request_id, owned.tenant_id) for row in (*states, *invocations)
)
and {row.invocation_id for row in states if row.parent_invocation_id is None} == {ref.invocation_id}
and len(self._subtree(session, ref, owned.tenant_id)) == len(states)
)
for state in states:
local_ref = ref.model_copy(update={"invocation_id": state.invocation_id})
if (state.request_id, state.tenant_id) != (ref.request_id, owned.tenant_id):
complete = False
continue
self.loader._load(session, ref=local_ref, lock=False, settlement=True)
if not state.app_id or not state.workflow_id:
complete = False
continue
validate_invocation_workflow(
session, tenant_id=owned.tenant_id, app_id=state.app_id, workflow_id=state.workflow_id
)
context = session.scalar(
owner_query(NativePayloadObject, local_ref, owned.tenant_id).where(
NativePayloadObject.id == state.context_object_id, NativePayloadObject.kind == "context"
)
)
if (
context is None
or type(state.generation) is not int
or not 0 <= state.generation <= scope.generation
or (
state.parent_invocation_id is None
and (state.app_id, state.workflow_id) != (owned.app_id, owned.workflow_id)
)
):
complete = False
source_states = []
current_root = False
for source in sources:
state = by_id.get(source.invocation_id)
if (
(source.request_id, source.tenant_id) != (ref.request_id, owned.tenant_id)
or state is None
or (state.request_id, state.tenant_id) != (ref.request_id, owned.tenant_id)
or type(source.generation) is not int
or type(state.generation) is not int
or not 0 <= source.generation <= state.generation
):
complete = False
continue
source_states.append(source.state)
if source.invocation_id == ref.invocation_id and source.generation == state.generation:
current_root = True
if source.state == "cancelled" and (
source.generation != 0
or source.checkpoint_id is not None
or source.claim_expires_at is not None
or source.idempotency_digest
!= canonical_digest(
{"initial": ref.model_copy(update={"invocation_id": source.invocation_id}).model_dump(mode="json")}
)
):
complete = False
complete = complete and current_root
operation_states: dict[str, Outcome] = {}
for operation in session.scalars(
select(NativeEffectOperation).where(
NativeEffectOperation.request_id == ref.request_id, NativeEffectOperation.tenant_id == owned.tenant_id
)
):
if operation.invocation_id not in by_id:
raise BuilderExecutionPolicyError("native_owner_mismatch")
operation_states[operation.id] = cast(
Outcome,
"unknown"
if operation.state in {"claimed", "unknown"}
else "not_dispatched"
if operation.state in {"pending", "denied", "cancelled"}
else operation.state,
)
status = (
"unknown"
if "unknown" in operation_states.values() or "unknown" in source_states
else "cancelled"
if complete
and all(row.cleanup_complete for row in states)
and all(value in {"completed", "cancelled"} for value in source_states)
else "cancelling"
)
return CancellationReceipt(
request_id=ref.request_id, generation=scope.generation, status=status, operation_states=operation_states
)
def cancel(self, *, actor: Actor, request_id: str, idempotency_key: str) -> CancellationReceipt:
ref = self._root_ref(actor, request_id)
digest = self._idempotency("cancel", idempotency_key)
with self.loader.locked(ref=ref, settlement=True) as (session, owned, scope):
scope.cancelled = True
states = session.scalars(
select(NativeInvocationState).where(
NativeInvocationState.request_id == request_id, NativeInvocationState.tenant_id == owned.tenant_id
)
).all()
refs = tuple(
ref.model_copy(update={"invocation_id": state.invocation_id})
for state in states
if not state.cleanup_complete
)
for state in states:
state.state, state.cancellation_key = "cancelled", state.cancellation_key or digest
for invocation in session.scalars(
select(NativeTestInvocation).where(
NativeTestInvocation.request_id == request_id, NativeTestInvocation.tenant_id == owned.tenant_id
)
):
invocation.active = False
for dispatch in session.scalars(
select(NativeDispatch).where(
NativeDispatch.request_id == request_id,
NativeDispatch.tenant_id == owned.tenant_id,
NativeDispatch.state.in_(("queued", "published")),
)
):
dispatch.state = "cancelled"
for operation in session.scalars(
select(NativeEffectOperation).where(
NativeEffectOperation.request_id == request_id, NativeEffectOperation.tenant_id == owned.tenant_id
)
):
if operation.state in {"pending", "denied", "cancelled"}:
operation.state = "not_dispatched"
stopped = set()
if self.runtime is not None:
try:
stopped = set(self.runtime.cancel(refs=refs))
except Exception:
stopped = set() # cancellation remains durable; cleanup can be retried
if self._capacity is not None:
try:
self._capacity.record_cancelled_cleanup(ref=ref)
except Exception:
stopped = set() # No verified cleanup acknowledgement; keep its slot quarantined.
if self.cancellation_settlement is not None:
try:
self.cancellation_settlement.finish_unacquired(ref=ref)
except Exception:
stopped = set() # Missing/ambiguous history grants no physical cleanup ACK.
cleanup_lock = self._capacity.locked if self._capacity is not None else self.loader.locked
with cleanup_lock(ref=ref, settlement=True) as (session, owned, scope):
states = session.scalars(
select(NativeInvocationState).where(
NativeInvocationState.request_id == request_id, NativeInvocationState.tenant_id == owned.tenant_id
)
).all()
for state in states:
if self._capacity is None and self.cancellation_settlement is None and state.invocation_id in stopped:
state.cleanup_complete = True
receipt = self.cancellation_receipt(session=session, owned=owned, scope=scope)
if receipt.status == "cancelled":
self._request_row(session, request_id).state = "sealed"
return receipt