CUBRID Trigger — ECA Active Rules, Statement vs Row Granularity, and the Locator-Driven Firing Path
Contents:
- Theoretical Background
- Common DBMS Design
- CUBRID’s Approach
- Source Walkthrough
- Cross-check Notes
- Open Questions
- Sources
Theoretical Background
Section titled “Theoretical Background”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:
-
Granularity — row vs. statement. A row-level trigger fires once per affected row with
OLD/NEWbindings; a statement-level trigger fires once per DML statement regardless of row count. SQL-99 picksFOR EACH ROW/FOR EACH STATEMENT. MySQL until 8.0 supports row-level only; Oracle, SQL Server, and CUBRID distinguish both in the firing loop. -
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.
-
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.
-
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.
-
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 aTR_SCHEMA_CACHEoffSM_CLASS::triggersand lazily compiles each action’sPT_NODEparse 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).
Common DBMS Design
Section titled “Common DBMS Design”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.
Postgres
Section titled “Postgres”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.
Oracle
Section titled “Oracle”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.
SQL Server
Section titled “SQL Server”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).
Where CUBRID sits
Section titled “Where CUBRID sits”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/, notsrc/transaction/locator_sr.c. Firing happens before the dirty object is packed into the LC_COPYAREA. Seecubrid-locator.mdfor the same observation from the other side. - The “current” object is an in-memory MOP, not a row identifier.
OLD/NEWbind toDB_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_CONTEXTlist drained at COMMIT. - Triggers can reject the DML.
TR_ACT_REJECTcancels the DML;TR_ACT_INVALIDATEpoisons the whole transaction.
Theory ↔ CUBRID mapping
Section titled “Theory ↔ CUBRID mapping”| Theoretical concept | CUBRID name |
|---|---|
| Active rule (catalog row) | _db_trigger instance (CT_TRIGGER_NAME) |
| In-memory rule descriptor | TR_TRIGGER (trigger_manager.h) |
| Per-class rule cache | TR_SCHEMA_CACHE hung off SM_CLASS::triggers |
| Per-event list inside the cache | TR_TRIGLIST *triggers[1] indexed by DB_TRIGGER_EVENT |
| ECA Event | DB_TRIGGER_EVENT (TR_EVENT_INSERT, TR_EVENT_UPDATE, …) |
| ECA Condition | TR_TRIGGER::condition of type TR_ACTIVITY |
| ECA Action | TR_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 category | DB_TRIGGER_ACTION (TR_ACT_EXPRESSION, TR_ACT_REJECT, …) |
| Per-firing transient state | TR_STATE carrying the live TR_TRIGLIST |
| Deferred queue | tr_Deferred_activities chain of TR_DEFERRED_CONTEXT |
| Recursion depth counter | tr_Current_depth vs tr_Maximum_depth |
| Recursion stack (statement triggers) | tr_Stack[TR_MAX_RECURSION_LEVEL] |
| OLD/NEW correlation names | OBJ_REFERENCE_NAME, NEW_REFERENCE_NAME, OLD_REFERENCE_NAME |
| Compile cache for action’s parse tree | TR_ACTIVITY::parser + TR_ACTIVITY::statement |
| User-trigger vs class-trigger split | IS_USER_EVENT vs IS_CLASS_EVENT macros |
| Trigger object map (MOP → struct) | tr_object_map MHT_TABLE |
| Global enable/disable | tr_Execution_enabled (toggled by loader) |
CUBRID’s Approach
Section titled “CUBRID’s Approach”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.
Catalog model — _db_trigger
Section titled “Catalog model — _db_trigger”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.htypedef 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.htypedef 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 tdb_ws_alloc (workspace) rather than malloc: trigger lists are
reclaimed when the class is decached, coupling trigger-cache
lifetime to class lifetime.
Class binding — SM_CLASS::triggers
Section titled “Class binding — SM_CLASS::triggers”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 UPDATEIf 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_activityif (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 counter — tr_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 stack — tr_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.cif (!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:
-
REJECT (
TR_ACT_REJECT) —eval_actionsets*reject = true, andtr_execute_activitiesraisesER_TR_REJECTED. The DML rolls back to its statement boundary, AFTER triggers never run, the transaction continues.check_semanticsforbids REJECT on AFTER and DEFERRED times (ER_TR_REJECT_AFTER_EVENT) — it only makes sense BEFORE. -
INVALIDATE TRANSACTION (
TR_ACT_INVALIDATE) — sets the globaltr_Invalid_transactionflag and saves the trigger name. The current statement completes, buttr_check_commit_triggersraisesER_TR_TRANSACTION_INVALIDATEDand forces COMMIT to abort. Sticky — no later statement clears it. -
Evaluation error —
eval_action’spt_exec_trigger_stmtreturns negative status;signal_evaluation_errorwraps the underlying error with the trigger name intoER_TR_ACTION_EVAL.tr_before_object/tr_after_objectcalltr_abort(state)and propagate. The recursion-guarder_errid () != errorinsignal_evaluation_errorprevents the trigger name from being stacked repeatedly across recursive firings;ER_LK_UNILATERALLY_ABORTEDandER_MVCC_SERIALIZABLE_CONFLICTpass 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.
Event coverage and user vs class triggers
Section titled “Event coverage and user vs class triggers”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.
Trigger lifecycle FSM
Section titled “Trigger lifecycle FSM”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.
Module init and disabling
Section titled “Module init and disabling”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.
Source Walkthrough
Section titled “Source Walkthrough”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, theTR_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 fromdo_create_triggerinexecute_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(inschema_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_activitieshead/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_triggersfamily,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)”| Symbol | File | Line |
|---|---|---|
Header file trigger_manager.h: |
| Symbol | Line |
|---|---|
TR_TRIGGER struct | 71 |
TR_TRIGLIST struct | 102 |
TR_DEFERRED_CONTEXT struct | 111 |
TR_ACTIVITY struct | 132 |
TR_STATE struct | 151 |
TR_SCHEMA_CACHE struct | 158 |
TR_CACHE_TYPE / TR_RECURSION_DECISION enums | 178 / 193 |
TR_MAX_RECURSION_LEVEL macro | 50 |
TR_ATT_* extern decls | 205-225 |
Type enums in compat/dbtype_def.h:
| Symbol | Line |
|---|---|
DB_TRIGGER_STATUS | 354 |
DB_TRIGGER_EVENT | 366 |
DB_TRIGGER_TIME | 398 |
DB_TRIGGER_ACTION | 407 |
Implementation file trigger_manager.c:
| Symbol | Line |
|---|---|
IS_USER_EVENT / IS_CLASS_EVENT | 66-73 |
EVAL_PREFIX / EVAL_SUFFIX | 117-118 |
TR_ATT_* definitions | 120-140 |
tr_Current_depth / tr_Stack | 142 / 144 |
tr_Invalid_transaction | 146 |
tr_Deferred_activities | 151 |
tr_Schema_caches | 159 |
tr_Execution_enabled | 165 |
tr_object_map | 177 |
make_activity / free_activity | 320 / 348 |
tr_make_trigger / free_trigger | 380 / 466 |
tr_free_trigger_list | 488 |
insert_trigger_list / merge_trigger_list | 511 / 570 |
add_deferred_activities | 772 |
flush_deferred_activities | 817 |
trigger_to_object / object_to_trigger | 996 / 1175 |
get_reference_names | 1465 |
compile_trigger_activity | 1599 |
validate_trigger | 1761 |
tr_map_trigger / tr_unmap_trigger | 1835 / 1885 |
tr_make_schema_cache / tr_copy_schema_cache | 2272 / 2322 |
tr_merge_schema_cache / tr_free_schema_cache | 2396 / 2433 |
tr_add_cache_trigger / tr_drop_cache_trigger | 2491 / 2533 |
tr_validate_schema_cache | 2617 |
reorder_schema_caches / tr_active_schema_cache | 2770 / 2801 |
tr_delete_schema_cache / tr_delete_triggers_for_class | 2855 / 2929 |
check_semantics / tr_check_correlation | 3754 / 3883 |
tr_create_trigger | 3930 |
tr_drop_trigger_internal / tr_drop_trigger | 4414 / 4506 |
value_as_boolean / signal_evaluation_error | 4581 / 4658 |
eval_condition | 4696 |
tr_check_recursivity | 4801 |
eval_action | 4839 |
execute_activity | 5039 |
tr_execute_activities | 5145 |
run_user_triggers | 5201 |
start_state | 5293 |
tr_prepare_statement / tr_prepare_class | 5336 / 5521 |
tr_finish / tr_abort | 5586 / 5605 |
tr_before_object / tr_before | 5630 / 5662 |
tr_after_object / tr_after | 5677 / 5720 |
tr_has_user_trigger | 5731 |
tr_check_commit_triggers | 5784 |
tr_check_rollback_triggers | 5851 |
tr_check_abort_triggers | 5934 |
its_deleted | 5970 |
tr_execute_deferred_activities | 6026 |
tr_drop_deferred_activities | 6146 |
tr_init / tr_final | 7287 / 7330 |
tr_dump | 7364 |
tr_get_execution_state / tr_set_execution_state | 7394 / 7408 |
Call sites (other files):
| Symbol | File | Line |
|---|---|---|
obt_apply_assignments (caller) | object_template.c | 2399 |
| INSERT/UPDATE BEFORE call sites | object_template.c | 2474 / 2490 |
| INSERT/UPDATE AFTER call sites | object_template.c | 2661 / 2667 |
| DELETE BEFORE / AFTER calls | object_accessor.c | 2174 / 2242 |
sm_get_trigger_cache | schema_manager.c | 4467 |
sm_active_triggers | schema_manager.c | 4557 |
sm_add_trigger / sm_drop_trigger | schema_manager.c | 4870 / 4902 |
SM_CLASS::triggers | class_object.h | 794 |
SM_ATTRIBUTE::triggers | class_object.h | 469 |
tr_check_commit_triggers call | transaction_cl.c | 279 |
tr_check_rollback_triggers call | transaction_cl.c | 428 |
tr_check_abort_triggers call | transaction_cl.c | 551 |
do_create_trigger | query/execute_statement.c | 6661 |
tr_create_trigger invocation | query/execute_statement.c | 6749 |
_db_trigger catalog install | schema_system_catalog_install.cpp | 803 |
CT_TRIGGER_NAME macro | schema_system_catalog_constants.h | 49 |
Cross-check Notes
Section titled “Cross-check Notes”-
Locator integration is client-side. Readers of
cubrid-locator.mdwill look for trigger firing insidelocator_*_forceand not find it: server-sidelocator_sr.cdoes heap, lock, B-tree, FK, log, replication — but never triggers. Triggers fire inobt_apply_assignments(object_template.c) andobj_delete(object_accessor.c) before the dirty MOP enters the LC_COPYAREA. Thetrigger_manager.h#error Does not belong to server moduleguard makes this explicit. -
SM_CLASS::triggersis atr_schema_cache *, not a direct list of trigger MOPs.cubrid-class-object.mdlists the slot under “Trigger cache”; this document expands its structure. The cache keeps two views —objects(flat MOP list, persistence shape) andtriggers[event](per-event priority-sorted lists, firing shape) — synchronised bytr_validate_schema_cache. -
_db_triggeris a regular catalog class, not a special table.cubrid-catalog-manager.mdhandles it like any other system class. The trigger module accesses it via ordinaryobj_get/obj_set. The viewdb_triggeris 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_CONTINUEand 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.
Open Questions
Section titled “Open Questions”-
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_NAMESmigration story. The parameter switches between two correlation-name modes (obj/new/oldvs.new/oldonly). 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_conditionandeval_actionreset the parser whenexec_cntexceeds 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/_RESTOREintr_execute_activitiesmark trigger-induced changes for CDC, but whether the primary’s trigger fires again on the replica isn’t visible here — seecubrid-cdc.mdandcubrid-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.
Sources
Section titled “Sources”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.md—SM_CLASS::triggers.cubrid-catalog-manager.md—_db_triggeras a regular system class.cubrid-locator.md— server-side locator’s explicit non-role in trigger firing.cubrid-parser.md—pt_compile_trigger_stmt.cubrid-cdc.md—CDC_TRIGGER_INVOLVED_*and change-data-capture.