C3 AI Documentation Home

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.

Text
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.

StepDescription
1Classify input objects into creates, updates, and removes.
2Call beforeCreate(objs) — can modify objects before insertion.
3Call beforeUpdate(objs) — can modify objects before update.
4Call beforeRemove(objs) — can inspect objects before deletion.
5Populate default values, enforce constraints, authorize the operation.

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).

StepDescription
6conn.startTransaction()SQL transaction begins.
7Write invalidation entries for removed objects (initial state, ignored by queue processors).
8Execute SQL INSERT statements for created objects.
9Write invalidation entries for created objects (initial state).
10Write invalidation entries for updated objects based on their current (old) state, so that references being removed are properly notified (initial state).
11Execute SQL statements for updated objects. Individual updates may include INSERT and DELETE statements for modifying the contents of non-foreign-key collections.
12Write invalidation entries for updated objects based on their new state, so that newly added references are properly notified (initial state).
13Execute SQL DELETE statements for removed objects.
14Write any custom invalidation requests.
15conn.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:

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

StepDescription
16invalidatorUtil.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.

StepDescription
17afterCreate(objs) — entity data is committed; writes here use a separate transaction.
18afterUpdate(objs) — entity data is committed; writes here use a separate transaction.
19afterRemove(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.

StepDescription
20created(txn) — fully asynchronous, eventual.
21updated(txn) — fully asynchronous, eventual.
22removed(txn) — fully asynchronous, eventual.

Transaction boundary summary

BoundaryWhenSignificance
Before callbacksSteps 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 beginAfter before* callbacks (step 6)Entity data modifications (steps 8, 11, 13) are atomic. Invalidation entries are written in their own independent transactions.
SQL transaction commitStep 15Written data is visible in the database.
Queue commitStep 16Invalidation entries become processable by queue workers.
After callbacksSteps 17–19Run as part of the write operation after both SQL and queue commits. Writes use their own transactions.
Async callbacksSteps 20–22Dispatched 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:

  1. Phase A (during the write pipeline) — Invalidation entries are committed in their own transactions in initial state. Queue processors ignore entries in this state.
  2. Phase B (after SQL commit) — commitInvalidatedQueues() promotes all entries to pending state, 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.

Where to submit custom invalidation actions

Use ActionQueue#submitAction to schedule asynchronous work in response to entity writes. There is no InvalidationQueue.submit() method.

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:

JavaScript
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:

JavaScript
function afterCreate(objs) {
  OtherType.upsertBatch(objs.map(function(o) {
    return OtherType.make({ id: o.id, derivedField: compute(o) });
  }));
  return [];
}

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:

  1. All inner upserts share a single SQL transaction and a single InvalidatorUtil.
  2. Individual upserts do not start or commit their own SQL transactions.
  3. All invalidation entries remain in initial state until the outer transaction commits.
  4. after* callbacks from inner upserts fire only after the outer transaction commits.
JavaScript
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 point

Restrictions when inside Db.Transaction:

  • Nested transactions are not supported.
  • Only the sql datastore 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

TypePurpose
PersistableWritableDefines all lifecycle callbacks (before*, after*, created, updated, removed).
Db.TransactionWraps multiple writes in a single SQL transaction with deferred invalidation.
InvalidationQueueBase type for all invalidation queues.
ActionQueueGeneral-purpose queue for async actions; dispatches after* callbacks.
CalcFieldsQueueRefreshes stored calc fields when dependencies change.
ChangeLogQueueTracks data changes.

See also

Was this page helpful?