Variable Scoping
Zeebe implementa variable scoping per-element-instance con shadowing automático. La clase
VariableBehaviorprovee tres operaciones:mergeLocalDocument(set sin propagación),mergeDocument(set con shadowing bottom-to-top), ysetLocalVariable(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:
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:
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:
- Length del nombre: default
EngineConfiguration.DEFAULT_MAX_NAME_FIELD_LENGTH(probable 255) - 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:
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 | Sí | Sí |
| 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¶
- Skip no-op events: comparar values antes de UPDATE
- Object pooling: en el hot path, reusar VariableRecord
- Validación temprana: rechazar antes de empezar transacción
- Batch evaluation de conditionals: pasar todos los events en una llamada