Entity Write Transaction Model
When you create, update, upsert, merge, or remove entity instances, the C3 Agentic AI Platform executes a multi-phase pipeline that coordinates SQL transactions, lifecycle callbacks, and invalidation queue entries. This document describes the full sequence of steps and where each boundary falls.
See also Monitor and Manage Queues and Overview of Invalidation Queues and Asynchronous Processing.
Write pipeline overview
Every write operation (create, update, upsert, merge, remove) follows the same pipeline. The pipeline is implemented in the platform's PersistableUpsertLogic and is divided into five phases.
Phase 1 Pre-Transaction (synchronous, no SQL transaction)
Phase 2 SQL Transaction (atomic INSERT / UPDATE / DELETE)
Phase 3 Queue Commit (invalidation entries become processable)
Phase 4 After Callbacks (asynchronous, dispatched via ActionQueue)
Phase 5 Async Callbacks (fully asynchronous, eventual)Detailed sequence
Phase 1 — Pre-transaction
These steps execute before any SQL transaction is opened. The before* callbacks can modify objects before they are persisted.
| Step | Description |
|---|---|
| 1 | Classify input objects into creates, updates, and removes. |
| 2 | Call beforeCreate(objs) — can modify objects before insertion. |
| 3 | Call beforeUpdate(objs) — can modify objects before update. |
| 4 | Call beforeRemove(objs) — can inspect objects before deletion. |
| 5 | Populate default values, enforce constraints, authorize the operation. |
Any write operations performed inside a before* callback (e.g. creating or updating other entity instances) execute in their own independent transaction. These writes commit immediately and are not rolled back if the main write operation fails in Phase 2 (for example, due to a unique-index violation or other constraint error). Design before* callbacks accordingly: avoid side-effecting writes that must be atomic with the primary operation, or be prepared to compensate for them manually if the primary operation fails.
Phase 2 — SQL transaction
A database transaction is opened for the entity data modifications (steps 8, 11, 13). The SQL statements for entity data are atomic — they either all commit or all roll back.
Invalidation queue entries (steps 7, 9, 10, 12, 14) are written in their own independent transactions and committed immediately in initial state. Because queue processors ignore entries in initial state, these writes are effectively invisible until they are promoted to pending in Phase 3. If the entity data transaction rolls back, Phase 3 aborts the entries instead of promoting them (see Two-phase invalidation).
| Step | Description |
|---|---|
| 6 | conn.startTransaction() — SQL transaction begins. |
| 7 | Write invalidation entries for removed objects (initial state, ignored by queue processors). |
| 8 | Execute SQL INSERT statements for created objects. |
| 9 | Write invalidation entries for created objects (initial state). |
| 10 | Write invalidation entries for updated objects based on their current (old) state, so that references being removed are properly notified (initial state). |
| 11 | Execute SQL statements for updated objects. Individual updates may include INSERT and DELETE statements for modifying the contents of non-foreign-key collections. |
| 12 | Write invalidation entries for updated objects based on their new state, so that newly added references are properly notified (initial state). |
| 13 | Execute SQL DELETE statements for removed objects. |
| 14 | Write any custom invalidation requests. |
| 15 | conn.endTransaction(true) — SQL transaction commits. |
During steps 7, 9, 10, 12, and 14 the platform automatically invokes a set of invalidators that create queue entries for downstream recalculation. These include:
- CalcFieldsQueue — stored calc field refresh.
- NormalizationQueue — timeseries normalization.
- PopulateAclQueue — access control list propagation.
- HierDenormQueue — hierarchy denormalization.
- ChangeLogQueue — data change tracking.
- ActionQueue — dispatches the
created,updated, andremovedasync callbacks.
All of these entries are committed in their own transactions in initial state. They are ignored by queue processors until promoted to pending in Phase 3.
Phase 3 — Queue commit
| Step | Description |
|---|---|
| 16 | invalidatorUtil.commitInvalidatedQueues() — all queue entries move from initial to pending. |
After this step, queue processors can pick up and execute the entries. If the SQL transaction rolled back in Phase 2, abortInvalidatedQueues() deletes the initial entries instead, so no stale work is dispatched.
Phase 4 — After callbacks
The after* callbacks are invoked as part of the write operation, after the main SQL transaction has committed and queue entries have been promoted. Any write operations performed inside these callbacks execute in their own separate transactions and are not part of the main transaction.
| Step | Description |
|---|---|
| 17 | afterCreate(objs) — entity data is committed; writes here use a separate transaction. |
| 18 | afterUpdate(objs) — entity data is committed; writes here use a separate transaction. |
| 19 | afterRemove(objs) — entity data is committed; writes here use a separate transaction. |
Phase 5 — Async callbacks
These are the fully asynchronous PersistableWritable callbacks. They are dispatched independently from the after callbacks and may execute at any later time.
| Step | Description |
|---|---|
| 20 | created(txn) — fully asynchronous, eventual. |
| 21 | updated(txn) — fully asynchronous, eventual. |
| 22 | removed(txn) — fully asynchronous, eventual. |
Transaction boundary summary
| Boundary | When | Significance |
|---|---|---|
| Before callbacks | Steps 2–4 (before SQL transaction) | Writes inside these callbacks use their own transaction and are not rolled back if the primary operation fails. |
| SQL transaction begin | After before* callbacks (step 6) | Entity data modifications (steps 8, 11, 13) are atomic. Invalidation entries are written in their own independent transactions. |
| SQL transaction commit | Step 15 | Written data is visible in the database. |
| Queue commit | Step 16 | Invalidation entries become processable by queue workers. |
| After callbacks | Steps 17–19 | Run as part of the write operation after both SQL and queue commits. Writes use their own transactions. |
| Async callbacks | Steps 20–22 | Dispatched via the ActionQueue; fully asynchronous and eventual. |
Two-phase invalidation
The platform uses a two-phase invalidation pattern to coordinate queue processing with entity writes:
- Phase A (during the write pipeline) — Invalidation entries are committed in their own transactions in
initialstate. Queue processors ignore entries in this state. - Phase B (after SQL commit) —
commitInvalidatedQueues()promotes all entries topendingstate, making them available for processing.
If the SQL transaction rolls back, abortInvalidatedQueues() deletes the initial entries so that queue processors do not act on data that was never committed.
If the machine crashes while entries are still in initial state (before they can be promoted or aborted), the platform's stuck-entry recovery will eventually detect them and promote them to pending for processing. This means processing is not strictly prevented — it is only deferred. The design prioritizes preventing premature processing over preventing redundant processing, since the queue operations are expected to be idempotent and not harmful if replayed.
Where to submit custom invalidation actions
Use ActionQueue#submitAction to schedule asynchronous work in response to entity writes. There is no InvalidationQueue.submit() method.
Recommended — afterCreate / afterUpdate / afterRemove
Place ActionQueue.submitAction() calls in the after* callbacks. At this point the SQL transaction has already committed, so the data the action depends on is guaranteed to exist:
function afterCreate(objs) {
objs.each(function(obj) {
ActionQueue.submitAction(
OtherType,
"recalculateFromMyType",
ActionQueueContext.make({ arguments: { id: obj.id } }),
true // autoCommit = true, entry is immediately pending
);
});
return [];
}You can also write to other entity types directly from after* callbacks:
function afterCreate(objs) {
OtherType.upsertBatch(objs.map(function(o) {
return OtherType.make({ id: o.id, derivedField: compute(o) });
}));
return [];
}Not recommended — before callbacks
The before* callbacks run before the SQL transaction. If you submit an action with autoCommit = true here and the transaction subsequently rolls back, the queue entry references data that does not exist.
Similarly, any direct write operations (e.g. create, upsert, merge) performed inside a before* callback run in their own independent transaction and commit immediately. If the primary write operation later fails in Phase 2 — for instance due to a unique-index violation — those writes are not rolled back. This can leave orphaned or inconsistent data that must be cleaned up manually.
If you must submit from a before* callback, use autoCommit = false so the entry participates in the two-phase invalidation lifecycle. However, this is fragile and not recommended.
Behavior inside Db.Transaction
By default, each C3 write operation (create, update, upsert, merge, remove) executes in its own SQL transaction. Db.Transaction provides a mechanism to span a single SQL transaction across multiple C3 write operations, so that they commit or roll back as a unit.
When multiple write operations are wrapped in Db.Transaction#execute:
- All inner upserts share a single SQL transaction and a single
InvalidatorUtil. - Individual upserts do not start or commit their own SQL transactions.
- All invalidation entries remain in
initialstate until the outer transaction commits. after*callbacks from inner upserts fire only after the outer transaction commits.
Db.Transaction.execute(function() {
TypeA.upsertBatch(listA); // SQL writes, invalidation entries in 'initial'
TypeB.upsertBatch(listB); // same SQL transaction, same invalidation batch
});
// SQL commits here, then invalidation entries move to 'pending'
// afterCreate / afterUpdate callbacks fire asynchronously after this pointRestrictions when inside Db.Transaction:
- Nested transactions are not supported.
- Only the
sqldatastore is supported. - You cannot start batch jobs, map-reduce jobs, workflows, or other async actions inline.
- There is no internal retry; retry logic must be external.
- All invalidations are committed after the SQL commit — there is no way to wait for queue processing inside the transaction.
Key platform types
| Type | Purpose |
|---|---|
| PersistableWritable | Defines all lifecycle callbacks (before*, after*, created, updated, removed). |
| Db.Transaction | Wraps multiple writes in a single SQL transaction with deferred invalidation. |
| InvalidationQueue | Base type for all invalidation queues. |
| ActionQueue | General-purpose queue for async actions; dispatches after* callbacks. |
| CalcFieldsQueue | Refreshes stored calc fields when dependencies change. |
| ChangeLogQueue | Tracks data changes. |