Saltar a contenido

Subprocess y Call Activity

Dos formas de componer procesos: embedded subprocess (mismo proceso, scope nested) y call activity (proceso separado, independiente). Diferencias en lifecycle, variables, ejecución, y trade-offs prácticos.

Comparación a 30 segundos

Aspecto Embedded Subprocess Call Activity
Compilación Dentro del proceso padre Proceso independiente
Versionado Junto con el padre Versionado propio (puede cambiar sin redeploy parent)
Variables Comparten scope con padre Aisladas; mapping explícito
Lifecycle Termina con el padre Puede sobrevivir al padre (rare)
Reutilización NO entre procesos SÍ entre procesos
Granularidad Bloque lógico interno Servicio compartido
Ejecución Misma instance Crea nueva instance
Operate UI Drill-down dentro del padre Link a otra instance

Embedded Subprocess

Modelo BPMN

<bpmn:subProcess id="paymentBlock" name="Payment block">
  <bpmn:startEvent id="paymentStart"/>
  <bpmn:serviceTask id="charge" name="Charge">
    <bpmn:extensionElements>
      <zeebe:taskDefinition type="charge-payment"/>
    </bpmn:extensionElements>
  </bpmn:serviceTask>
  <bpmn:endEvent id="paymentEnd"/>
  <!-- flows entre start, charge, end -->
</bpmn:subProcess>

Lifecycle

flowchart LR
    A[Subprocess ACTIVATE] --> B[Ejecuta start event]
    B --> C[Tokens fluyen]
    C --> D[Todos los end events alcanzados]
    D --> E[COMPLETE]

Variables — scope rules

flowchart TD
    subgraph Root[Process variables - root scope]
        V1[orderId, totalAmount]
    end
    subgraph Sub[Subprocess local scope]
        V2[chargedAt<br/>local, invisible fuera]
    end
    Root -->|visibles dentro del subprocess| Sub
    Sub -.->|al COMPLETE del subproc, las variables<br/>locales pueden propagarse vía outputMapping| Root
  • Lectura: subprocess ve variables del padre (transparente).
  • Escritura: si haces set variable sin scope explícito, escribe en el scope local del subprocess.
  • Propagación a padre: variable se escribe en el padre si:
  • El subprocess hace set variable local=false (raro), o
  • El subprocess tiene un outputMapping que las mapea al padre.
  • Al terminar: variables locales se descartan; sólo las mapeadas al padre persisten.

Cuándo usar embedded

✓ Agrupar lógica relacionada para legibilidad. ✓ Aplicar boundary events al grupo (timer, error) en lugar de a una task. ✓ Compensation scope. ✓ Multi-instance que envuelve varias tasks.

✗ Lógica reutilizable en múltiples procesos → usar Call Activity.

Casos típicos

1. Boundary timer sobre múltiples tasks

flowchart LR
    subgraph SP["Subprocess 'approval-block'"]
        MR[Manager review] --> DR[Director review]
    end
    SP -.->|timer 24h| E[Escalate]

Sin subprocess, tendrías que aplicar el timer a cada task individualmente.

2. Compensation scope

flowchart TD
    subgraph SP["Subprocess 'booking'"]
        BF["Book flight (con comp)"]
        BH["Book hotel (con comp)"]
        P[Pay]
        P -.->|error| TC[Throw compensate]
        TC --> LIFO[Comps LIFO solo del subprocess]
    end

3. Event subprocess

flowchart TD
    subgraph MF["Subprocess 'main flow'"]
        LN[... lógica normal ...]
    end
    subgraph ES["Event subprocess<br/>(start = message 'cancel-order')"]
        NC[Notify customer] --> E[End]
    end

Cancelable desde un mensaje externo sin diseñar paths explícitos.

Call Activity

Modelo BPMN

<bpmn:callActivity id="processOrder" name="Process Order">
  <bpmn:extensionElements>
    <zeebe:calledElement processId="order-fulfillment"
                        propagateAllChildVariables="false"/>
    <zeebe:ioMapping>
      <zeebe:input source="=orderId" target="orderId"/>
      <zeebe:input source="=customer.email" target="customerEmail"/>
      <zeebe:output source="=trackingNumber" target="trackingNumber"/>
    </zeebe:ioMapping>
  </bpmn:extensionElements>
</bpmn:callActivity>

Lifecycle

flowchart LR
    A[Call activity ACTIVATE] --> B[Engine crea<br/>nueva instance del<br/>proceso target]
    B --> C[Padre queda WAITING]
    C --> D[Child completa]
    D --> E[Variables mapped back]
    E --> F[Padre continúa]

Detalle: 1. El padre crea una nueva process_instance con parent_instance_key=<calling instance>. 2. La nueva instance ejecuta independientemente (mismo proceso del engine, otra entrada en la tabla). 3. El padre queda con un token en estado WAITING. 4. Cuando la child completa, dispara un evento que el padre captura. 5. El outputMapping copia variables del child al padre.

Variables — mapping explícito

flowchart TD
    P["Parent process variables<br/>orderId, customer={email, name}"]
    P -->|"inputMapping (explícito)<br/>orderId → orderId<br/>customer.email → customerEmail"| C1["Child process starts with:<br/>{orderId, customerEmail}"]
    C1 -->|child executes, accumulates| C2["{orderId, customerEmail,<br/>trackingNumber, ...}"]
    C2 -->|"outputMapping al COMPLETE<br/>trackingNumber → trackingNumber"| PR["Parent receives: trackingNumber<br/>(parent vars updated)"]

propagateAllChildVariables="false" (recomendado): solo las variables del outputMapping vuelven al padre. true (NO recomendado): TODAS las variables del child sobreescriben las del padre — fuente de bugs.

Cuándo usar Call Activity

✓ Reutilizar un proceso desde múltiples padres (e.g., "validate customer" desde "create order" y "update subscription"). ✓ Versionar independientemente la sub-lógica. ✓ Aislar variables para evitar conflictos de nombre. ✓ Separar ownership entre equipos (cada equipo dueño de su proceso). ✓ Multi-tenant: tenant-shared subproceso, instances aisladas.

✗ Bloque lógico de un solo uso → embedded. ✗ Loop muy pequeño donde el overhead de crear nueva instance pesa.

Resolución de versión del child

<zeebe:calledElement processId="order-fulfillment"
                    bindingType="latest"/>

Binding types:

Type Resolution Use case
latest (default) Última versión deployed Dev / pre-prod
deployment Misma versión deployment Coherencia entre procesos relacionados
versionTag Tag específico (v1.2.3) Producción con control de cambios

Nuestra implementación M1: solo latest y versionTag. deployment agrega complejidad por las relaciones implicit.

Persistencia child-parent

-- Tabla process_instances
parent_process_instance_key BIGINT REFERENCES process_instances(key),
parent_element_instance_key BIGINT,  -- el call activity en el padre

CREATE INDEX ON process_instances (parent_process_instance_key);

Permite: - "Cancel parent" cascada a child (opcional). - Operate: drill-down de parent a child y back. - Métricas: instance count incluye o excluye sub-instances.

Cancelación

  • wf instance cancel <parent-key> → cancela también las child instances activas (cascada).
  • wf instance cancel <child-key> → child cancelled; parent recibe un evento de "subprocess failed" que puede ser handled (boundary error event).

Diferencias semánticas críticas

Error handling

Embedded: error inside subproc → busca boundary handler en subproc → si no hay, sube al padre.

Call Activity: error inside child → si child no lo maneja, child queda en incident. El padre no ve el error automáticamente. Para reaccionar, el padre necesita un boundary error event en el call activity.

<bpmn:callActivity id="processOrder">...</bpmn:callActivity>
<bpmn:boundaryEvent attachedToRef="processOrder">
  <bpmn:errorEventDefinition errorRef="ChildFailed"/>
</bpmn:boundaryEvent>

El child debe propagar el error explícitamente vía <bpmn:endEvent> con error end definition.

Compensation

Embedded: throw compensation dentro del subproc compensa elementos del subproc. Throw fuera puede o no incluir el subproc según activityRef.

Call Activity: compensation NO cruza el boundary. Cada proceso compensa lo suyo. Si necesitás cross-process compensation, el padre debe orquestar explícitamente.

Timer / Boundary events

Embedded: timer boundary sobre el subproc afecta a TODO el contenido. Interrupting timer cancela todos los tokens dentro.

Call Activity: timer boundary sobre call activity cancela el child completo (cascading cancel).

Performance considerations

Embedded subprocess

  • Overhead: 1 element instance extra (el subproc en sí).
  • Memory: variables comparten scope, sin duplicación.
  • Replay: misma instance, mismo command log.

Call Activity

  • Overhead: nueva process_instance (insert en tabla, indexes).
  • Memory: variables duplicadas (parent + child).
  • Replay: dos command logs ligados.
  • Latencia: ~1-5ms extra para crear child + correlate al parent.

Para procesos high-throughput, preferir embedded a menos que la reutilización justifique.

Multi-tenancy

Embedded: tenant del padre.

Call Activity: child hereda tenant del padre por defecto. Permitir cross-tenant call activity es ANTI-PATTERN; bloqueado por defecto.

flowchart LR
    A[Tenant A process] --> B[Call activity]
    B -->|debe llamar| C[Proceso de tenant A]
    B -.->|si intenta llamar proceso tenant B| D[BPMN_TENANT_MISMATCH error]

Recursión

Es legal: un proceso puede call activity a sí mismo (recursión).

flowchart TD
    P["Process 'tree-traversal'"] --> FC[For each child]
    FC --> CA["Call activity 'tree-traversal'<br/>(recursive)"]
    CA -.->|recursión| P

Riesgos: - Stack overflow lógico (process instances anidadas). - Sin límite, una recursión runaway puede crear miles de instances.

Mitigación: - Configurable max_call_activity_depth: 10 (default). - Métrica de profundidad: wf_engine_call_activity_depth{process_id} histogram. - Alerta si depth > 80% del max.

Testing patterns

// Test: child completes, parent continues
func TestCallActivityHappyPath(t *testing.T) {
    parent := DeployProcess("parent.bpmn")
    child := DeployProcess("child.bpmn")

    instance := StartInstance(parent, map[string]any{"orderId": "O-1"})
    WaitForCallActivityActive(instance, "processOrder")

    childInstance := GetChildInstance(instance, "processOrder")
    CompleteJob(childInstance, "do-work", map[string]any{"result": "ok"})

    WaitForCompletion(instance)
    AssertVariable(instance, "result", "ok")
}

// Test: child fails, parent handles via boundary
func TestCallActivityErrorBoundary(t *testing.T) {
    // child raises BPMN error "validation-failed"
    // parent has boundary error event with code="validation-failed"
    // assert: parent activates error handler path
}

Operate UI considerations

  • Embedded: drill-down dentro de la misma instance. Breadcrumb: Process > Subprocess "X" > Task "Y".
  • Call Activity: link a otra instance. Breadcrumb cruza la frontera.
flowchart TD
    P[Parent instance #1234] --> E["Element 'processOrder'<br/>(CallActivity)"]
    E -->|link| C[view child instance #5678]

Decisión: cuándo cuál

Usar embedded subprocess si: - Boundary event aplicable a un grupo. - Compensation scope. - Event subprocess. - Lógica de un solo uso.

Usar call activity si: - Reutilización entre procesos. - Versionado independiente. - Equipos diferentes lo mantienen. - Aislamiento de variables fuerte.

Usar service task simple si: - Es UNA action. - No requiere modelado interno.

Referencias