Saltar a contenido

Variable Scoping

Zeebe implementa variable scoping per-element-instance con shadowing automático. La clase VariableBehavior provee tres operaciones: mergeLocalDocument (set sin propagación), mergeDocument (set con shadowing bottom-to-top), y setLocalVariable (single variable). Mutaciones emiten VARIABLE.CREATED o VARIABLE.UPDATED events, optimizando: si el valor no cambia, no se emite event.

Modelo de scoping

Cada element instance en Zeebe tiene su propio scope para variables. La jerarquía:

flowchart TD
    PI[ProcessInstance scope — root]
    PI --> SP[SubProcess scope]
    SP --> MI[MultiInstance body scope<br/>si hay]
    MI --> EI[Element instance scope<br/>per iteration]
    PI --> CA[CallActivity scope<br/>puente a child process]

Cada scope: - Tiene un scopeKey único (= el element instance key) - Tiene un parentScopeKey (= el element instance del padre, o -1 si es root) - Almacena sus propias variables locales

Storage en RocksDB / PostgreSQL

CREATE TABLE variable_instances (
    variable_key BIGINT PRIMARY KEY,
    scope_key BIGINT NOT NULL,                -- element instance key
    process_instance_key BIGINT NOT NULL,
    name TEXT NOT NULL,
    value JSONB NOT NULL,                     -- o BYTEA para MessagePack
    UNIQUE (scope_key, name)                  -- una variable por nombre por scope
);

Key insight: la unicidad es (scope, name), no global. La misma variable name puede existir en múltiples scopes con valores distintos.

VariableBehavior — el único punto de entrada para mutaciones

Del código fuente (ver sources/variable-behavior-source):

"A behavior which allows processors to mutate the variable state. Use this anywhere where you would want to set a variable during processing."

Solo 3 métodos públicos:

1. mergeLocalDocument(scopeKey, ..., document)

Setea TODAS las variables del documento en el scopeKey exacto. Sin propagación.

Casos de uso: - Output mapping al completar un elemento (variables locales al elemento) - Input mapping al activar un elemento

2. mergeDocument(scopeKey, ..., document)

Setea variables con propagación bottom-to-top con shadowing.

Algoritmo:

currentScope = scopeKey
remaining = todas las variables del document

while parentScope = parent(currentScope) > 0:
    for cada variable en remaining:
        if existe en currentScope:
            UPDATE en currentScope, remover de remaining
        # si no existe, se queda en remaining
    currentScope = parentScope

# variables que sobraron → CREATE en currentScope (root)
for cada variable en remaining:
    CREATE en currentScope

Casos de uso: - User actualiza variables del proceso desde la API - Worker completa un job con result variables - Initial variables al crear una process instance

Resultado: las variables se setean donde ya existen (shadowing), o en el root si no existen en ningún scope.

3. setLocalVariable(scopeKey, ..., name, value)

Variant para una sola variable. Equivalente a mergeLocalDocument con un documento de 1 entry.

Shadowing — el comportamiento clave

Considera este BPMN:

Process
  └── SubProcess1 (variables locales: x=10)
        └── ServiceTask (variables locales: x=20)

Cuando el ServiceTask completa con output {"x": 30}: - Con mergeLocalDocument: se setea x=30 en el ServiceTask scope. Al terminar el task, su scope se borra. Las variables x=10 (subprocess) y la del proceso (si la hubiera) quedan intactas. - Con mergeDocument: empieza buscando x en el ServiceTask scope (x=20 existe → UPDATE a 30 ahí). El subprocess (x=10) y el process scope quedan intactos.

Si el output mapping fuera {"y": 5} (nuevo nombre): - Con mergeLocalDocument: crea y=5 en ServiceTask scope (se pierde al completar el task). - Con mergeDocument: busca y en cada scope hacia arriba. Si no existe en ninguno → crea en root process scope.

Optimización: skip no-op events

Del código fuente:

if (variableInstance == null) {
    // CREATE
} else if (!variableInstance.getValue().equals(record.getValueBuffer())) {
    // UPDATE
}
// Si existe con MISMO valor → NO event

Si una variable se setea con el mismo valor que ya tiene, no se emite event. Esto evita: - Ruido en el log de events - Trabajo de exporters - Disparos espurios de conditional events

Object pooling

private final IndexedDocument indexedDocument = new IndexedDocument();
private final VariableRecord variableRecord = new VariableRecord();

Estos objetos se reusan entre invocaciones del behavior. Coherente con la lección de los microbenchmarks: reusar reduce allocations 25x (ver concepts/microbenchmark-methodology).

Disparo de conditional events

Después de cada mutación:

conditionalBehavior.evaluateConditionals(processInstanceKey, variableEvents);

Cambios en variables pueden activar: - Conditional intermediate catch events - Conditional start events de event subprocesses

Es el mecanismo de "reactividad" a cambios de estado del proceso.

Validación

VariableNameLengthValidator valida antes de mutar:

  1. Length del nombre: default EngineConfiguration.DEFAULT_MAX_NAME_FIELD_LENGTH (probable 255)
  2. MessagePack válido: el buffer debe poder deserializarse

Errores → INVALID_ARGUMENT rejection.

Input/Output Mappings

Configurables en el BPMN XML:

<bpmn:serviceTask id="task">
  <bpmn:extensionElements>
    <zeebe:ioMapping>
      <zeebe:input source="=customer.email" target="email" />
      <zeebe:output source="=response.id" target="orderId" />
    </zeebe:ioMapping>
  </bpmn:extensionElements>
</bpmn:serviceTask>
  • Input mappings: al ACTIVATE_ELEMENT, evalúan source en el parent scope y crean variables locales con name=target en el element instance scope (vía mergeLocalDocument).
  • Output mappings: al COMPLETE_ELEMENT, evalúan source en el element scope y propagan al parent scope con name=target (vía mergeDocument).

Esto implementa una semántica de "interfaz" del elemento: el elemento solo ve sus variables locales (input mappings) y solo expone resultados controlados (output mappings).

VariableSourceRecord — audit

Optional tracking del origen de cada cambio:

public VariableBehavior withVariableSource(final VariableSourceRecord source)

Permite saber QUÉ causó cada mutación: deployment, job completion, message correlation, user task update, API call directo, etc. Útil para auditoría.

Diferencias notables con Camunda 7

Aspecto Camunda 7 Zeebe (Camunda 8)
Serialización Java objects (serialization-friendly) MessagePack (binary)
Tipo de datos Tipos Java (Integer, Date, etc.) JSON-compatible (primitives + maps + arrays)
Scope nesting
Propagación automática Configurable Default (mergeDocument) o explícita (mergeLocalDocument)
Validación de nombres Limited Length + MessagePack validity

Camunda 8 simplifica drásticamente al usar JSON como modelo de datos.

Implicaciones para el MVP

API que debería exponer

POST /v2/process-instances/{key}/variables
  ?merge=local       # mergeLocalDocument equivalente
  ?merge=propagate   # mergeDocument equivalente (default)
{
  "variables": { "key1": value1, "key2": value2 }
}

POST /v2/element-instances/{key}/variables
  ?merge=local|propagate
{
  "variables": { ... }
}

Tablas SQL del MVP

CREATE TABLE element_instances (
    element_instance_key BIGINT PRIMARY KEY,
    parent_scope_key BIGINT,           -- NULL si es process instance root
    process_instance_key BIGINT NOT NULL,
    element_id TEXT NOT NULL,
    state TEXT NOT NULL,
    ...
);

CREATE INDEX idx_element_parent ON element_instances(parent_scope_key);

CREATE TABLE variable_instances (
    variable_key BIGINT PRIMARY KEY,
    scope_key BIGINT REFERENCES element_instances(element_instance_key),
    process_instance_key BIGINT NOT NULL,
    name TEXT NOT NULL,
    value JSONB NOT NULL,
    UNIQUE (scope_key, name)
);

Implementación de mergeDocument

Walking up el scope tree:

WITH RECURSIVE scope_chain AS (
    -- empieza en el scope dado
    SELECT element_instance_key, parent_scope_key, 0 as depth
    FROM element_instances
    WHERE element_instance_key = $scopeKey

    UNION ALL

    -- sube al parent
    SELECT ei.element_instance_key, ei.parent_scope_key, sc.depth + 1
    FROM element_instances ei
    JOIN scope_chain sc ON ei.element_instance_key = sc.parent_scope_key
)
SELECT * FROM scope_chain ORDER BY depth;

Luego para cada variable, buscar en orden de scope_chain hasta encontrar o llegar al root.

Optimizaciones a replicar

  1. Skip no-op events: comparar values antes de UPDATE
  2. Object pooling: en el hot path, reusar VariableRecord
  3. Validación temprana: rechazar antes de empezar transacción
  4. Batch evaluation de conditionals: pasar todos los events en una llamada