Skip to content

CUBRID Trigger — ECA Active Rules, Statement vs Row Granularity, and the Locator-Driven Firing Path

Contents:

A trigger lets the schema react to the data. Widom and Ceri’s Active Database Systems (1996) crystallised this as the ECA model: an active rule is a triple of an Event (INSERT, UPDATE, DELETE, COMMIT, …), a Condition (boolean expression over row or transaction state), and an Action (SQL statements, a procedural call, or a metasymbol such as REJECT). Database System Concepts (Silberschatz et al., 7th ed., Ch. 5) frames the same triple in SQL-99 syntax: CREATE TRIGGER … BEFORE/AFTER … FOR EACH ROW WHEN … BEGIN … END.

Five textbook ingredients shape every implementation:

  1. Granularity — row vs. statement. A row-level trigger fires once per affected row with OLD/NEW bindings; a statement-level trigger fires once per DML statement regardless of row count. SQL-99 picks FOR EACH ROW / FOR EACH STATEMENT. MySQL until 8.0 supports row-level only; Oracle, SQL Server, and CUBRID distinguish both in the firing loop.

  2. Time — BEFORE, AFTER, INSTEAD OF, DEFERRED. Each timing changes what the action is allowed to do: a BEFORE action can REJECT the DML, an AFTER action sees the mutated row, INSTEAD OF replaces the DML (typically for views), DEFERRED runs at COMMIT.

  3. Recursion and the Mutating-Table problem. A trigger’s action may fire other triggers, possibly back into the same table. The classical guard against runaway recursion is a depth limit (16 in DB2, up to 50 in Postgres). The subtler Mutating-Table problem (Oracle’s name) is a row-level trigger whose action queries its own table mid-statement; Oracle raises ORA-04091, Postgres uses deferred constraints, and CUBRID lets the action read the workspace’s half-mutated state but guards STATEMENT-level recursion with an OID stack.

  4. Immediate vs. deferred firing. Deferred actions queue per transaction and drain at COMMIT in dependency order, discarded at ROLLBACK. Essential when the action must see the final state of multiple related rows.

  5. Catalog-resident vs. compiled. Trigger metadata sits in a catalog row (pg_trigger, mysql.triggers, _db_trigger); the action body must be parsed and planned for every firing, so every engine caches a compiled form. CUBRID hangs a TR_SCHEMA_CACHE off SM_CLASS::triggers and lazily compiles each action’s PT_NODE parse tree on first fire.

Two engineering invariants emerge: triggers must be cheap to not fire (CUBRID’s sm_active_triggers short-circuits the no-trigger case), and N triggers on one event must be ordered deterministically (CUBRID exposes a numeric priority and keeps per-event lists sorted).

The SQL-99 model gives the syntax; this section names the engineering patterns nearly every implementation adopts. CUBRID’s specific choices should be read as one set of dials within the shared design space.

PostgreSQL keeps the action body outside the trigger: a CREATE FUNCTION returns a trigger-typed PL/pgSQL function, and CREATE TRIGGER binds (event, time, granularity, function) to a table. The WHEN clause is evaluated by the executor before calling the function. Statement-level triggers see NEW/OLD as transition tables (since 10.x). Storage: pg_trigger row + function body in pg_proc. No global recursion ceiling — runs until stack overflow.

Triggers are syntactically inline (CREATE TRIGGER … FOR EACH ROW BEGIN … END). No per-statement granularity until 8.0. OLD/NEW are pseudo-rows by column name. Only one trigger per (event, time) pair on a table — simplifies ordering at the cost of flexibility.

CREATE OR REPLACE TRIGGER allows BEFORE STATEMENT, BEFORE EACH ROW, AFTER EACH ROW, AFTER STATEMENT in one compound trigger — four bodies sharing package state. This is the cleanest answer to the mutating-table problem. Oracle additionally raises ORA-04091 when a row-level trigger’s action queries its own table.

INSTEAD OF triggers replace the DML rather than running before or after — the mechanism behind updatable views. CUBRID does not support this; tr_create_trigger rejects virtual classes (db_is_vclass returns ER_TR_NO_VCLASSES).

CUBRID’s lineage is the OODB UniSQL/X. That means:

  • Triggers fire from the client-side workspace, not the server. Every trigger entry point lives in src/object/, not src/transaction/locator_sr.c. Firing happens before the dirty object is packed into the LC_COPYAREA. See cubrid-locator.md for the same observation from the other side.
  • The “current” object is an in-memory MOP, not a row identifier. OLD/NEW bind to DB_OBJECT * handles; the action is rewritten at compile time to reference correlation names (“obj”, “new”, “old”) that the parser resolves to the temp template.
  • Statement-level triggers (TR_EVENT_STATEMENT_*) are recursion-controlled by an OID stack (tr_check_recursivity) that silently skips a recursive firing rather than erroring.
  • DEFERRED is a real timing, with a per-transaction TR_DEFERRED_CONTEXT list drained at COMMIT.
  • Triggers can reject the DML. TR_ACT_REJECT cancels the DML; TR_ACT_INVALIDATE poisons the whole transaction.
Theoretical conceptCUBRID name
Active rule (catalog row)_db_trigger instance (CT_TRIGGER_NAME)
In-memory rule descriptorTR_TRIGGER (trigger_manager.h)
Per-class rule cacheTR_SCHEMA_CACHE hung off SM_CLASS::triggers
Per-event list inside the cacheTR_TRIGLIST *triggers[1] indexed by DB_TRIGGER_EVENT
ECA EventDB_TRIGGER_EVENT (TR_EVENT_INSERT, TR_EVENT_UPDATE, …)
ECA ConditionTR_TRIGGER::condition of type TR_ACTIVITY
ECA ActionTR_TRIGGER::action of type TR_ACTIVITY
Granularity (row vs statement)Encoded into the event constant: TR_EVENT_STATEMENT_*
Activity time (BEFORE/AFTER/DEFERRED)DB_TRIGGER_TIME on the TR_ACTIVITY
Action categoryDB_TRIGGER_ACTION (TR_ACT_EXPRESSION, TR_ACT_REJECT, …)
Per-firing transient stateTR_STATE carrying the live TR_TRIGLIST
Deferred queuetr_Deferred_activities chain of TR_DEFERRED_CONTEXT
Recursion depth countertr_Current_depth vs tr_Maximum_depth
Recursion stack (statement triggers)tr_Stack[TR_MAX_RECURSION_LEVEL]
OLD/NEW correlation namesOBJ_REFERENCE_NAME, NEW_REFERENCE_NAME, OLD_REFERENCE_NAME
Compile cache for action’s parse treeTR_ACTIVITY::parser + TR_ACTIVITY::statement
User-trigger vs class-trigger splitIS_USER_EVENT vs IS_CLASS_EVENT macros
Trigger object map (MOP → struct)tr_object_map MHT_TABLE
Global enable/disabletr_Execution_enabled (toggled by loader)

The trigger subsystem lives in trigger_manager.h (401 lines, public API) and trigger_manager.c (7547 lines). Most of .c is catalog↔in-memory plumbing; the conceptual core — tr_prepare_class, tr_before_object, tr_after_object, execute_activity, eval_condition, eval_action, tr_check_recursivity — is a few hundred lines. This section walks each piece in turn: catalog model, in-memory shape, firing pipeline, recursion, failure semantics, COMMIT/ROLLBACK.

A trigger is a row in the system class _db_trigger. The class is installed by schema_system_catalog_install.cpp; columns match the TR_ATT_* constants declared in trigger_manager.c:

// trigger_class_columns — schema_system_catalog_install.cpp (excerpt)
{TR_ATT_UNIQUE_NAME, "string"}, {TR_ATT_OWNER, AU_USER_CLASS_NAME},
{TR_ATT_NAME, "string"}, {TR_ATT_STATUS, "integer", ...TR_STATUS_ACTIVE},
{TR_ATT_PRIORITY, "double", ...TR_LOWEST_PRIORITY},
{TR_ATT_EVENT, "integer", ...TR_EVENT_NULL},
{TR_ATT_CLASS, "object"}, {TR_ATT_ATTRIBUTE, "string"},
{TR_ATT_CONDITION_TYPE, "integer"}, {TR_ATT_CONDITION, "string"},
{TR_ATT_CONDITION_TIME, "integer"},
{TR_ATT_ACTION_TYPE, "integer"}, {TR_ATT_ACTION, "string"},
{TR_ATT_ACTION_TIME, "integer"},
{TR_ATT_COMMENT, format_varchar (1024)},
{TR_ATT_CREATED_TIME, "datetime"}, {TR_ATT_UPDATED_TIME, "datetime"}

Triggers are reached by the standard catalog path: db_find_class("_db_trigger") → list iteration → obj_get. The row stores everything needed to recreate the trigger from cold: target class, attribute, event, time, action source as a string. The precompiled action tree is reconstructed lazily on first firing — parse trees contain MOP pointers and cannot be persisted. The view db_trigger (without leading underscore) is the user-visible projection.

In-memory shape — TR_TRIGGER, TR_ACTIVITY, TR_TRIGLIST

Section titled “In-memory shape — TR_TRIGGER, TR_ACTIVITY, TR_TRIGLIST”

object_to_trigger builds an in-memory TR_TRIGGER from a fetched catalog row. Header is plain C — server mode is forbidden by an #error guard.

// TR_TRIGGER — trigger_manager.h
typedef struct tr_trigger {
DB_OBJECT *owner; // user MOP
DB_OBJECT *object; // back-pointer to catalog instance
char *name;
double priority;
DB_TRIGGER_STATUS status; // ACTIVE / INACTIVE / INVALID
DB_TRIGGER_EVENT event;
DB_OBJECT *class_mop; // target class
char *attribute; // optional column binding
struct tr_activity *condition;
struct tr_activity *action;
char *current_refname; // "obj" by default
char *temp_refname; // "new" / "old" by default
int chn; // cache-coherency number → revalidate
// ... comment, timestamps ...
} TR_TRIGGER;
// TR_ACTIVITY — body (condition or action)
struct tr_activity {
DB_TRIGGER_ACTION type; // EXPRESSION / REJECT / INVALIDATE / PRINT
DB_TRIGGER_TIME time; // BEFORE / AFTER / DEFERRED
char *source; // raw SQL text
void *parser; // lazy PARSER_CONTEXT*
void *statement; // compiled PT_NODE*
int exec_cnt; // periodic parser-reset counter
};

A TR_SCHEMA_CACHE is a header plus a variable-length tail of one TR_TRIGLIST * per event:

// TR_SCHEMA_CACHE — trigger_manager.h
typedef struct tr_schema_cache {
struct tr_schema_cache *next; // global cache list
DB_OBJLIST *objects; // flat MOP list
short compiled; // 1 once tr_validate_schema_cache ran
unsigned short array_length; // 8 (class) or 2 (attribute)
TR_TRIGLIST *triggers[1]; // variable-length tail, indexed by event
} TR_SCHEMA_CACHE;

TR_CACHE_TYPE selects between class caches (8 slots, all class-level events) and attribute caches (2 slots, only UPDATE and STATEMENT_UPDATE — the events that bind to a column). Each slot is a TR_TRIGLIST sorted by descending trigger->priority:

// insert_trigger_list — trigger_manager.c (condensed)
for (t = *list; t && t->trigger->priority > trigger->priority; prev = t, t = t->next)
;
new_tr = (TR_TRIGLIST *) db_ws_alloc (sizeof (TR_TRIGLIST));
// link new_tr between prev and t

db_ws_alloc (workspace) rather than malloc: trigger lists are reclaimed when the class is decached, coupling trigger-cache lifetime to class lifetime.

The link from a class to its triggers is a single pointer: SM_CLASS::triggers (class-level) and SM_ATTRIBUTE::triggers (attribute-level), both tr_schema_cache *. When the class is loaded from the catalog, catcls_* builds the SM_CLASS graph and tr_make_schema_cache allocates the cache; trigger objects are stored in cache->objects as a flat MOP list. Per-event arrays are filled lazily on first tr_validate_schema_cache:

// tr_validate_schema_cache — trigger_manager.c (condensed)
if (cache == NULL || cache->compiled) return NO_ERROR;
for (object_list = cache->objects; object_list != NULL; object_list = next) {
next = object_list->next;
trigger = tr_map_trigger (object_list->op, 1);
if (trigger != NULL && trigger->event < cache->array_length) {
if (insert_trigger_list (&(cache->triggers[trigger->event]), trigger))
return error;
}
// else: deleted trigger — silently drop from objects list
}
cache->compiled = 1;

After this, cache->triggers[event] is the priority-ordered list for that event. The compiled flag ensures we walk the trigger list at most once per class load.

Firing path — locator-adjacent, not server-side

Section titled “Firing path — locator-adjacent, not server-side”

Triggers fire on the client side, not the server. The server’s locator_*_force knows nothing about triggers; firing happens before the dirty object reaches the LC_COPYAREA. Call sites: object_template.c (INSERT/UPDATE) and object_accessor.c (DELETE). Both follow the same three-step pattern:

flowchart LR
  DML["DML in obj_template / obj_delete"] --> Q["sm_active_triggers?"]
  Q -- "no" --> APPLY["heap mutation only"]
  Q -- "yes" --> PREP["tr_prepare_class → TR_STATE"]
  PREP --> BEF["tr_before_object (BEFORE)"]
  BEF --> APPLY2["heap mutation"]
  APPLY2 --> AFT["tr_after_object (AFTER)"]
  AFT --> DEFER["enqueue DEFERRED → tr_Deferred_activities"]

The fast path is gated by sm_active_triggers, which returns 0 when the class has no active triggers of the requested event type — the common case — short-circuiting before any list traversal:

// obt_apply_assignments — object_template.c (condensed)
trigstate = sm_active_triggers (..., class_, TR_EVENT_ALL);
event = (template_ptr->object == NULL) ? TR_EVENT_INSERT : TR_EVENT_UPDATE;
if (event != TR_EVENT_NULL)
tr_prepare_class (&trstate, class_->triggers,
OBT_BASE_CLASSOBJ (template_ptr), event);
// attribute-level triggers too, for UPDATE

If there are triggers, tr_prepare_class builds a TR_STATE and calls start_state (which performs the depth check) and merge_trigger_list:

// tr_prepare_class — trigger_manager.c (condensed)
if (!TR_EXECUTION_ENABLED) { *state_p = NULL; return NO_ERROR; }
if (cache == NULL) return NO_ERROR;
AU_DISABLE (save); // owner identity will be swapped
// in by execute_activity
if (tr_validate_schema_cache (cache, class_mop) == NO_ERROR
&& event < cache->array_length) {
triggers = cache->triggers[event];
state = start_state (state_p, triggers ? triggers->trigger->name : NULL);
if (state != NULL)
merge_trigger_list (&state->triggers, triggers, 0);
}
AU_ENABLE (save);

Authorization is disabled during list construction because the trigger may have been defined through a view by a different user; the action will later run with the owner’s identity, set via AU_SET_USER in execute_activity.

The state drives the BEFORE/AFTER pair:

// tr_before_object / tr_after_object — trigger_manager.c (condensed)
int tr_before_object (TR_STATE *state, DB_OBJECT *current, DB_OBJECT *temp) {
if (state) {
error = tr_execute_activities (state, TR_TIME_BEFORE, current, temp);
if (error) tr_abort (state);
}
}
int tr_after_object (TR_STATE *state, DB_OBJECT *current, DB_OBJECT *temp) {
if (state) {
error = tr_execute_activities (state, TR_TIME_AFTER, current, temp);
if (error) tr_abort (state);
else {
// anything still on state->triggers is DEFERRED — flush to global queue
if (state->triggers != NULL) {
add_deferred_activities (state->triggers, current);
state->triggers = NULL;
}
tr_finish (state);
}
}
}

state->triggers is consumed as it fires: tr_execute_activities removes each entry whose time matched and whose action ran successfully. Whatever remains after the AFTER pass is by construction the DEFERRED set, moved to the global queue. The (current, temp) pair carries the live MOP and the template-stage shadow: INSERT BEFORE has current=NULL; DELETE BEFORE has temp=NULL; UPDATE has both.

OLD/NEW row binding — correlation names rewritten at compile time

Section titled “OLD/NEW row binding — correlation names rewritten at compile time”

The action expression is written with correlation names like obj.name, new.salary, old.salary. CUBRID does not expose OLD and NEW as runtime objects directly; the parser is told which name maps to which slot at compile time and rewrites PT_NAME references accordingly:

// get_reference_names — trigger_manager.c (default mode, condensed)
case TR_EVENT_INSERT:
if (time == BEFORE) tempname = "new";
else /* AFTER / DEFERRED */ curname = "obj";
break;
case TR_EVENT_UPDATE:
if (time == BEFORE) { curname = "obj"; tempname = "new"; }
else if (time == AFTER) { curname = "obj"; tempname = "old"; }
else /* DEFERRED */ curname = "obj";
break;
case TR_EVENT_DELETE:
if (time == BEFORE) curname = "obj";
break;

Note the symmetry: AFTER UPDATE binds obj → updated row and old → saved before-image; BEFORE UPDATE binds obj → existing row and new → pending template. The before-image is captured by obt_apply_assignments before it overwrites the slot, into a pr_make_ext_value-allocated DB_VALUE on the template assignment:

// obt_apply_assignments — object_template.c (excerpt)
if (trstate != NULL && trstate->triggers != NULL && event == TR_EVENT_UPDATE)
{
a->old_value = pr_make_ext_value ();
error = obj_get_value (object, a->att, mem, NULL, a->old_value);
}

MYSQL_TRIGGER_CORRELATION_NAMES is a system parameter that switches the binding mode: MySQL-compat mode exposes the inserted row as new instead of obj (AFTER INSERT) and the live row as old instead of obj (BEFORE UPDATE). Behaviour is otherwise identical.

Trigger compilation — lazy + cached on the activity

Section titled “Trigger compilation — lazy + cached on the activity”

compile_trigger_activity is the single entry point for parsing a trigger body. It runs at three points: at tr_create_trigger (twice — condition and action — to surface syntax errors immediately); when validate_trigger notices a changed catalog row; and lazily on first fire if the parser was previously freed.

// compile_trigger_activity — trigger_manager.c (condensed)
if (with_evaluate) {
text = malloc (length);
strcpy (text, EVAL_PREFIX); // "EVALUATE ( "
strcat (text, activity->source);
strcat (text, EVAL_SUFFIX); // " ) "
} else
text = activity->source;
activity->parser = parser_create_parser ();
get_reference_names (trigger, activity, &curname, &tempname);
class_mop = ((curname == NULL && tempname == NULL) ? NULL : trigger->class_mop);
activity->statement = pt_compile_trigger_stmt (activity->parser, text,
class_mop, curname, tempname, &activity->source, with_evaluate);

The condition is wrapped in EVALUATE ( ... ) so the parser sees a top-level statement; the action is parsed directly. The parser is per-activity, not per-firing — the PT_NODE is reused until the trigger is updated or PRM_ID_RESET_TR_PARSER exec count is hit.

tr_check_correlation enforces one extra rule on BEFORE-INSERT triggers: bare new (without a column) is rejected with ER_TR_CORRELATION_ERROR, because it would resolve to a not-yet- allocated OID.

Recursion control — depth counter + OID stack

Section titled “Recursion control — depth counter + OID stack”

CUBRID layers two recursion guards.

The depth countertr_Current_depth vs tr_Maximum_depth, default TR_MAX_RECURSION_LEVEL = 32 — is incremented in start_state and decremented in tr_finish. Overflow raises ER_TR_EXCEEDS_MAX_REC_LEVEL. This catches infinite row-level recursion.

The OID stacktr_Stack[TR_MAX_RECURSION_LEVEL + 1] — is checked only for STATEMENT triggers. eval_action stamps the trigger’s OID at tr_Stack[tr_Current_depth - 1] and walks the stack via tr_check_recursivity before firing:

// tr_check_recursivity — trigger_manager.c
if (!is_statement)
return TR_DECISION_CONTINUE; // row triggers: depth counter handles it
for (i = 0; i < MIN (stack_size, TR_MAX_RECURSION_LEVEL); i++) {
if (OID_EQ (&oid, &stack[i]))
return TR_DECISION_DO_NOT_CONTINUE; // silently skip — no error
}
return TR_DECISION_CONTINUE;

The two-tier scheme is deliberate. Row-level triggers may legitimately recurse in nested DML; we let them, capped at 32. Statement-level triggers must fire once per statement; if a STATEMENT trigger’s action re-issues the same statement type, CUBRID silently skips rather than erroring, letting the inner statement behave normally.

stateDiagram-v2
  [*] --> Idle
  Idle --> Ready: tr_prepare_class → start_state (depth++)
  Idle --> Idle: depth > MAX → ER_TR_EXCEEDS_MAX_REC_LEVEL
  Ready --> Firing: tr_before_object / tr_after_object
  Firing --> Firing: per trigger in priority order; statement-trigger OID-stack check
  Firing --> Deferring: AFTER pass leftover (DEFERRED) triggers
  Firing --> Aborted: action error → tr_abort
  Deferring --> Idle: add_deferred_activities + tr_finish (depth--)
  Aborted --> Idle: tr_finish (depth--)

Failure semantics — REJECT, INVALIDATE, and propagating errors

Section titled “Failure semantics — REJECT, INVALIDATE, and propagating errors”

Trigger actions fail in three categorical ways:

  1. REJECT (TR_ACT_REJECT) — eval_action sets *reject = true, and tr_execute_activities raises ER_TR_REJECTED. The DML rolls back to its statement boundary, AFTER triggers never run, the transaction continues. check_semantics forbids REJECT on AFTER and DEFERRED times (ER_TR_REJECT_AFTER_EVENT) — it only makes sense BEFORE.

  2. INVALIDATE TRANSACTION (TR_ACT_INVALIDATE) — sets the global tr_Invalid_transaction flag and saves the trigger name. The current statement completes, but tr_check_commit_triggers raises ER_TR_TRANSACTION_INVALIDATED and forces COMMIT to abort. Sticky — no later statement clears it.

  3. Evaluation erroreval_action’s pt_exec_trigger_stmt returns negative status; signal_evaluation_error wraps the underlying error with the trigger name into ER_TR_ACTION_EVAL. tr_before_object/tr_after_object call tr_abort(state) and propagate. The recursion-guard er_errid () != error in signal_evaluation_error prevents the trigger name from being stacked repeatedly across recursive firings; ER_LK_UNILATERALLY_ABORTED and ER_MVCC_SERIALIZABLE_CONFLICT pass through unchanged so the abort signal isn’t lost.

Deferred activities — a per-transaction BFS queue

Section titled “Deferred activities — a per-transaction BFS queue”

Triggers with time == TR_TIME_DEFERRED accumulate on a global queue (tr_Deferred_activities) of TR_DEFERRED_CONTEXT structs, each binding a savepoint id to a sub-list of TR_TRIGLIST entries. add_deferred_activities (called from tr_after_object) stamps each entry with the target MOP so the deferred firing later knows which OID it belongs to.

tr_check_commit_triggers drains the queue at COMMIT:

// tr_check_commit_triggers — trigger_manager.c (condensed)
if (run_user_triggers (TR_EVENT_COMMIT, time)) return error;
if (time == TR_TIME_BEFORE) {
if (tr_execute_deferred_activities (NULL, NULL)) return error;
if (tr_Invalid_transaction) {
error = ER_TR_TRANSACTION_INVALIDATED;
er_set (..., tr_Invalid_transaction_trigger);
} else if (tr_Uncommitted_triggers != NULL) {
tr_free_trigger_list (tr_Uncommitted_triggers);
tr_Uncommitted_triggers = NULL;
}
}

The implementation comment in tr_execute_deferred_activities is illuminating: BEFORE/AFTER triggers form a DFS (executing immediately and recursively within the statement); DEFERRED triggers form a BFS (collected into a flat queue, drained in one pass at COMMIT). The drain may itself fire nested triggers (depth counter resets and re-increments per deferred activity) but queue order is preserved.

Events split into three groups via the IS_USER_EVENT / IS_CLASS_EVENT macros:

  • Class events (DML): TR_EVENT_INSERT, TR_EVENT_STATEMENT_INSERT, TR_EVENT_UPDATE, TR_EVENT_STATEMENT_UPDATE, TR_EVENT_DELETE, TR_EVENT_STATEMENT_DELETE. (ALTER/DROP reserved but unused.)
  • User events (transaction-scoped): TR_EVENT_COMMIT, TR_EVENT_ROLLBACK. (ABORT/TIMEOUT reserved but unused.)
  • Sentinels: TR_EVENT_NULL, TR_EVENT_ALL.

User triggers don’t attach to a class — they sit on Au_user’s triggers set and fire from tr_check_commit_triggers / tr_check_rollback_triggers. They live in tr_User_triggers, rebuilt by tr_update_user_cache.

Critical nuance: row vs. statement is encoded in the event constant itself, not in a separate granularity flag. The only firing-time distinction is eval_action’s is_statement check that drives tr_check_recursivity’s OID-stack walk for statement triggers.

stateDiagram-v2
  [*] --> Compiled: tr_create_trigger → check_semantics + compile_trigger_activity
  Compiled --> Active: trigger_to_object + sm_add_trigger; status = TR_STATUS_ACTIVE
  Active --> Inactive: tr_set_status (INACTIVE)
  Inactive --> Active: tr_set_status (ACTIVE)
  Active --> Firing: locator-driven prepare/before/after
  Firing --> Active: success or error
  Active --> Invalidated: target class dropped → status = TR_STATUS_INVALID
  Active --> Dropped: tr_drop_trigger or tr_delete_triggers_for_class
  Dropped --> [*]
  Invalidated --> [*]

The INVALID status means the target class was dropped: the _db_trigger row still exists, but firing silently skips it; an explicit DROP TRIGGER finally removes the row.

tr_init initialises the global state and creates tr_object_map, the MOP→TR_TRIGGER memoisation hash that owns TR_TRIGGER allocations across firings. tr_final walks the map and frees every entry. TR_SCHEMA_CACHE instances are not freed by tr_final — they live in the workspace and are reclaimed with the workspace.

The bulk loader needs to disable trigger firing during a load. tr_set_execution_state(false) toggles tr_Execution_enabled; every public entry point (tr_prepare_class, tr_before_object, tr_after_object, tr_check_commit_triggers, etc.) checks this flag first and returns NO_ERROR immediately when off. The flag is process-global, not transaction-local.

Putting it together — concrete UPDATE walkthrough

Section titled “Putting it together — concrete UPDATE walkthrough”

For CREATE TRIGGER t1 BEFORE UPDATE ON emp WHEN new.salary < 0 EXECUTE REJECT plus CREATE TRIGGER t2 AFTER UPDATE ON emp WHEN new.salary > 1000000 EXECUTE INSERT INTO audit VALUES (obj.id, 'big'), an UPDATE emp SET salary = X WHERE id = 7 flows:

sequenceDiagram
  participant ObjTpl as obt_apply_assignments
  participant TR as trigger_manager
  participant Workspace

  ObjTpl->>TR: sm_active_triggers(emp, TR_EVENT_ALL) → 1
  ObjTpl->>TR: tr_prepare_class(&trstate, emp.triggers, emp_mop, TR_EVENT_UPDATE)
  TR->>TR: tr_validate_schema_cache + start_state (depth 0→1)
  ObjTpl->>ObjTpl: snapshot old salary → a->old_value
  ObjTpl->>TR: tr_before_object(trstate, row_7, temp)
  TR->>TR: execute_activity(t1, BEFORE) → eval_condition (EVALUATE …)
  alt salary < 0
    TR-->>ObjTpl: ER_TR_REJECTED
  else
    ObjTpl->>Workspace: write new salary; ws_dirty(row_7)
    ObjTpl->>TR: tr_after_object(trstate, row_7, temp)
    TR->>TR: execute_activity(t2, AFTER) → INSERT INTO audit
    Note over TR: nested obt_apply_assignments on audit class<br/>raises depth to 2 then back to 1
    TR->>TR: tr_finish (depth 1→0)
  end

Salient points: the locator only ships the post-trigger dirty rows; both WHEN clauses compiled at create time and parse trees cached for reuse; tr_Current_depth went 0→1→2→1→0; for the AFTER firing, temp carried the saved before-image so old.salary resolved to the pre-update value and obj.salary to the post-update value.

Symbols grouped by subsystem. Line numbers go in the position-hints table; anchors here are symbol names — grep for those.

  • Catalog bridge. _db_trigger (system class), CT_TRIGGER_NAME, the TR_ATT_* column-name constants, trigger_to_object / object_to_trigger, tr_set_trigger_timestamps.
  • In-memory structs. TR_TRIGGER, TR_ACTIVITY, TR_TRIGLIST, TR_SCHEMA_CACHE, TR_STATE, TR_DEFERRED_CONTEXT, TR_RECURSION_DECISION. Allocation/free: tr_make_trigger, tr_clear_trigger, free_trigger, make_activity, free_activity.
  • Lifecycle / DDL. tr_create_trigger (called from do_create_trigger in execute_statement.c), tr_drop_trigger / tr_drop_trigger_internal, tr_rename_trigger, tr_set_status / tr_set_priority / tr_set_comment, check_semantics, check_target, validate_trigger, compile_trigger_activity, tr_check_correlation, tr_map_trigger / tr_unmap_trigger.
  • Schema cache. tr_make_schema_cache, tr_copy_schema_cache, tr_merge_schema_cache, tr_validate_schema_cache, tr_active_schema_cache, tr_add_cache_trigger / tr_drop_cache_trigger, tr_delete_schema_cache, tr_delete_triggers_for_class, tr_get_cache_objects, reorder_schema_caches.
  • Firing path. sm_active_triggers (in schema_manager.c), tr_prepare_class, tr_prepare_statement, start_state, tr_before_object / tr_before, tr_after_object / tr_after, tr_finish, tr_abort, tr_execute_activities, execute_activity, eval_condition, eval_action, signal_evaluation_error, value_as_boolean, get_reference_names.
  • Recursion. tr_Current_depth, tr_Maximum_depth, TR_MAX_RECURSION_LEVEL, tr_Stack, tr_check_recursivity, compare_recursion_levels, tr_get_depth / tr_set_depth.
  • Deferred queue. tr_Deferred_activities head/tail, add_deferred_activity_context, add_deferred_activities, flush_deferred_activities, remove_deferred_activity, remove_deferred_context, tr_execute_deferred_activities, tr_drop_deferred_activities, its_deleted.
  • Transaction integration. tr_check_commit_triggers, tr_check_rollback_triggers, tr_check_abort_triggers, tr_has_user_trigger, tr_Uncommitted_triggers, tr_Invalid_transaction / tr_Invalid_transaction_trigger.
  • User triggers. IS_USER_EVENT / IS_CLASS_EVENT, tr_User_triggers family, register_user_trigger / unregister_user_trigger, tr_update_user_cache / tr_invalidate_user_cache, run_user_triggers, get_user_trigger_objects.
  • Module control. tr_init, tr_final, tr_dump, tr_get_execution_state / tr_set_execution_state, tr_object_map, tr_Schema_caches, tr_get_class_name.

Position hints (as of 2026-05-01, file src/object/trigger_manager.c unless noted)

Section titled “Position hints (as of 2026-05-01, file src/object/trigger_manager.c unless noted)”
SymbolFileLine
Header file trigger_manager.h:
SymbolLine
TR_TRIGGER struct71
TR_TRIGLIST struct102
TR_DEFERRED_CONTEXT struct111
TR_ACTIVITY struct132
TR_STATE struct151
TR_SCHEMA_CACHE struct158
TR_CACHE_TYPE / TR_RECURSION_DECISION enums178 / 193
TR_MAX_RECURSION_LEVEL macro50
TR_ATT_* extern decls205-225

Type enums in compat/dbtype_def.h:

SymbolLine
DB_TRIGGER_STATUS354
DB_TRIGGER_EVENT366
DB_TRIGGER_TIME398
DB_TRIGGER_ACTION407

Implementation file trigger_manager.c:

SymbolLine
IS_USER_EVENT / IS_CLASS_EVENT66-73
EVAL_PREFIX / EVAL_SUFFIX117-118
TR_ATT_* definitions120-140
tr_Current_depth / tr_Stack142 / 144
tr_Invalid_transaction146
tr_Deferred_activities151
tr_Schema_caches159
tr_Execution_enabled165
tr_object_map177
make_activity / free_activity320 / 348
tr_make_trigger / free_trigger380 / 466
tr_free_trigger_list488
insert_trigger_list / merge_trigger_list511 / 570
add_deferred_activities772
flush_deferred_activities817
trigger_to_object / object_to_trigger996 / 1175
get_reference_names1465
compile_trigger_activity1599
validate_trigger1761
tr_map_trigger / tr_unmap_trigger1835 / 1885
tr_make_schema_cache / tr_copy_schema_cache2272 / 2322
tr_merge_schema_cache / tr_free_schema_cache2396 / 2433
tr_add_cache_trigger / tr_drop_cache_trigger2491 / 2533
tr_validate_schema_cache2617
reorder_schema_caches / tr_active_schema_cache2770 / 2801
tr_delete_schema_cache / tr_delete_triggers_for_class2855 / 2929
check_semantics / tr_check_correlation3754 / 3883
tr_create_trigger3930
tr_drop_trigger_internal / tr_drop_trigger4414 / 4506
value_as_boolean / signal_evaluation_error4581 / 4658
eval_condition4696
tr_check_recursivity4801
eval_action4839
execute_activity5039
tr_execute_activities5145
run_user_triggers5201
start_state5293
tr_prepare_statement / tr_prepare_class5336 / 5521
tr_finish / tr_abort5586 / 5605
tr_before_object / tr_before5630 / 5662
tr_after_object / tr_after5677 / 5720
tr_has_user_trigger5731
tr_check_commit_triggers5784
tr_check_rollback_triggers5851
tr_check_abort_triggers5934
its_deleted5970
tr_execute_deferred_activities6026
tr_drop_deferred_activities6146
tr_init / tr_final7287 / 7330
tr_dump7364
tr_get_execution_state / tr_set_execution_state7394 / 7408

Call sites (other files):

SymbolFileLine
obt_apply_assignments (caller)object_template.c2399
INSERT/UPDATE BEFORE call sitesobject_template.c2474 / 2490
INSERT/UPDATE AFTER call sitesobject_template.c2661 / 2667
DELETE BEFORE / AFTER callsobject_accessor.c2174 / 2242
sm_get_trigger_cacheschema_manager.c4467
sm_active_triggersschema_manager.c4557
sm_add_trigger / sm_drop_triggerschema_manager.c4870 / 4902
SM_CLASS::triggersclass_object.h794
SM_ATTRIBUTE::triggersclass_object.h469
tr_check_commit_triggers calltransaction_cl.c279
tr_check_rollback_triggers calltransaction_cl.c428
tr_check_abort_triggers calltransaction_cl.c551
do_create_triggerquery/execute_statement.c6661
tr_create_trigger invocationquery/execute_statement.c6749
_db_trigger catalog installschema_system_catalog_install.cpp803
CT_TRIGGER_NAME macroschema_system_catalog_constants.h49
  • Locator integration is client-side. Readers of cubrid-locator.md will look for trigger firing inside locator_*_force and not find it: server-side locator_sr.c does heap, lock, B-tree, FK, log, replication — but never triggers. Triggers fire in obt_apply_assignments (object_template.c) and obj_delete (object_accessor.c) before the dirty MOP enters the LC_COPYAREA. The trigger_manager.h #error Does not belong to server module guard makes this explicit.

  • SM_CLASS::triggers is a tr_schema_cache *, not a direct list of trigger MOPs. cubrid-class-object.md lists the slot under “Trigger cache”; this document expands its structure. The cache keeps two views — objects (flat MOP list, persistence shape) and triggers[event] (per-event priority-sorted lists, firing shape) — synchronised by tr_validate_schema_cache.

  • _db_trigger is a regular catalog class, not a special table. cubrid-catalog-manager.md handles it like any other system class. The trigger module accesses it via ordinary obj_get / obj_set. The view db_trigger is the user-visible projection.

  • Server-side MVCC sees no triggers. A trigger’s action runs as ordinary SQL through the same parser/executor/locator pipeline and inherits the transaction’s snapshot. A BEFORE INSERT trigger querying the target class will not see its own pending row (the row isn’t inserted yet); an AFTER UPDATE trigger will see the updated row (the workspace applied the assignment). This sidesteps Oracle’s mutating-table problem with looser semantics: CUBRID lets the action read the half-updated workspace state.

  • Statement-level recursion is silently skipped. Where Postgres and Oracle either error or allow, CUBRID returns TR_DECISION_DO_NOT_CONTINUE and lets the inner statement proceed without the recursive trigger firing. The OID stack (tr_Stack) is the mechanism; the depth counter is a separate guard that bounds row-level recursion at 32.

  • Rationale for silent-skip on STATEMENT recursion. The implementation comment says “we should not go further with the action, but we should allow the call to succeed.” Why this rather than an error? SQL-99 doesn’t prescribe; Oracle errors; Postgres allows. The choice simplifies chained DML but masks some user errors.

  • MYSQL_TRIGGER_CORRELATION_NAMES migration story. The parameter switches between two correlation-name modes (obj / new / old vs. new / old only). Which is the default per release isn’t visible from the source; triggers created under one setting may not parse under the other.

  • Overhead of PRM_ID_RESET_TR_PARSER. eval_condition and eval_action reset the parser when exec_cnt exceeds the threshold. Comments hint this is a workaround (“until we figure out how to reuse the same parser”) rather than a deliberate refresh. Performance impact at high firing rates is unclear.

  • HA replication semantics for triggered DML. Trigger actions generate ordinary log records on the server, so each triggered DML replicates as an independent record. CDC_TRIGGER_INVOLVED_BACKUP / _RESTORE in tr_execute_activities mark trigger-induced changes for CDC, but whether the primary’s trigger fires again on the replica isn’t visible here — see cubrid-cdc.md and cubrid-ha-replication.md.

  • Commit-trigger / deferred-activity ordering. Source comment: “Do we run the deferred activities before the commit triggers? If not, the commit trigger can schedule deferred activities as well.” The current order runs commit triggers first then drains the deferred queue, so deferred actions can see effects of commit triggers but not vice versa. Whether this is by design isn’t recorded.

Code excerpts come from the CUBRID source tree on 2026-05-01. Frontmatter references: lists files consulted; the position-hint table pins specific symbols.

Theoretical:

  • Silberschatz/Korth/Sudarshan, Database System Concepts, 7th ed., Ch. 5 §“Triggers”.
  • Widom and Ceri, Active Database Systems, Morgan Kaufmann, 1996 — the canonical ECA formulation.
  • SQL:1999 (ISO/IEC 9075-2:1999), §“CREATE TRIGGER”.
  • Postgres docs, “Triggers” chapter; Oracle PL/SQL Language Reference, “Trigger” chapter.

Companion docs in this knowledge base:

  • cubrid-class-object.mdSM_CLASS::triggers.
  • cubrid-catalog-manager.md_db_trigger as a regular system class.
  • cubrid-locator.md — server-side locator’s explicit non-role in trigger firing.
  • cubrid-parser.mdpt_compile_trigger_stmt.
  • cubrid-cdc.mdCDC_TRIGGER_INVOLVED_* and change-data-capture.