Transitions

v3.2.0

A Transition declares the states one column may start in, the moves it may make and the states a row may be deleted from, and the engine refuses every write that leaves that graph.

In this article

A Transition is a rule about how one column’s value may change over a row’s life, such as “an order starts as NEW, and becomes FINALIZED only after CHECKED”. You declare the states a row may be inserted in, the old => new moves an update may make, and optionally the states a row may be deleted from. KDBS checks every insert, every update that assigns the column, and every delete, and refuses a write that leaves the declared graph.

How it works

Declaring a Transition

CREATE TRANSITION order_status ON orders(status)
  ON INSERT ('NEW')
  ON UPDATE ('NEW' => 'CHECKED', 'CHECKED' => 'FINALIZED',
             'NEW' => 'CANCELLED', 'CHECKED' => 'CANCELLED')
  ON DELETE FROM ('NEW', 'CANCELLED');
 (illustration)
 NEW ──▶ CHECKED ──▶ FINALIZED
  │         │
  └────┬────┘
       ▼
   CANCELLED
TermMeaning
State columnThe one column the Transition governs (status)
Initial setThe states an INSERT may write (ON INSERT)
EdgeAn allowed old => new move (ON UPDATE)
Deletable setThe states a row may be deleted from (ON DELETE FROM). Left out, every delete is allowed.
State setEvery value the declaration names. It is closed: any other value is refused.
Terminal stateA state with no outgoing edge (FINALIZED, CANCELLED). A row there keeps its state.

What each write is checked against

WriteChecked againstRefused when
INSERTThe initial setThe new value is not an initial state
UPDATE that assigns the columnThe edgesold => new is not a declared edge
UPDATE that assigns the current valueNothingNever: it is not a move
UPDATE that does not assign the columnNothingNever
DELETEThe deletable setThe row’s state is not deletable

A check reads only the row being written and the declaration. Each check runs before anything of its row is written. A refused INSERT uses up no key.

A rule about history, with no history stored

“FINALIZED only after CHECKED” is a property of the path a row has taken. A Transition never stores that path, and never checks the property on its own. It follows by induction. Every insert writes an initial state and every update follows an edge, so every committed value a row has held lies on a walk of the graph from an initial state. The only edge into FINALIZED leaves CHECKED. That is why ON INSERT and ON UPDATE are both required.

The induction has one way out: deleting a row and inserting its key again in an initial state, which would restart its history under the same identity. Three facts close it:

  • An issued key is never issued again.
  • A deleted key stays bound until a PURGE frees it (see Row identity and primary keys).
  • PURGE is refused on a table that carries a Transition.

A key on a governed table is therefore bound for life.

Rollback is not a transition

An aborted UPDATE or DELETE, whether rolled back by the client or by recovery after a crash, returns the row to a committed value. The undone version never joined the row’s committed history, so nothing checks it.

Examples

The examples use this table and the declaration above:

CREATE TABLE orders (id int64, customer varchar(32), status varchar(16));

A row must start in an initial state, and a value outside the state set is refused on any write:

kdbs> INSERT INTO orders VALUES ('kim', 'FINALIZED')
ERR TRANSITION_VIOLATION retryable=0 transition 'order_status' on column 'status': a row may not be inserted in state 'FINALIZED' (byte 34)
kdbs> INSERT INTO orders VALUES ('lee', 'SHIPPED')
ERR TRANSITION_VIOLATION retryable=0 transition 'order_status' on column 'status': a row may not be inserted in state 'SHIPPED' (byte 34)
kdbs> INSERT INTO orders VALUES ('kim', 'NEW')
INSERTED oid=4000 id=1 page=131 slot=0

An update must follow an edge. Skipping CHECKED is refused; taking the two steps is not:

kdbs> UPDATE orders SET status = 'FINALIZED' WHERE id = 1
ERR TRANSITION_VIOLATION retryable=0 transition 'order_status' on column 'status': 'NEW' => 'FINALIZED' is not a declared edge, row id 1 (byte 27)
kdbs> UPDATE orders SET status = 'CHECKED' WHERE id = 1
UPDATED 1
kdbs> UPDATE orders SET status = 'FINALIZED' WHERE id = 1
UPDATED 1

Assigning the current value, or updating another column, is not a move and is not checked:

kdbs> UPDATE orders SET customer = 'park' WHERE id = 1
UPDATED 1

A terminal state has no way back, and a row is deleted only from a deletable state:

kdbs> UPDATE orders SET status = 'NEW' WHERE id = 1
ERR TRANSITION_VIOLATION retryable=0 transition 'order_status' on column 'status': 'FINALIZED' => 'NEW' is not a declared edge, row id 1 (byte 27)
kdbs> DELETE FROM orders WHERE id = 1
ERR TRANSITION_VIOLATION retryable=0 transition 'order_status' on column 'status': a row may not be deleted from state 'FINALIZED', row id 1

PURGE is refused while the Transition stands:

kdbs> PURGE FROM orders WHERE id = 1
ERR 'orders' carries transition 'order_status', whose state history a freed key would restart; PURGE frees no key of a relation that carries a Transition (byte 11)

Each statement is checked on its own, so several steps inside one transaction pass when each one is an edge:

kdbs> BEGIN
BEGIN
kdbs> INSERT INTO orders VALUES ('choi', 'NEW')
INSERTED oid=4000 id=2 page=131 slot=1
kdbs> UPDATE orders SET status = 'CHECKED' WHERE customer = 'choi'
UPDATED 1
kdbs> UPDATE orders SET status = 'FINALIZED' WHERE customer = 'choi'
UPDATED 1
kdbs> COMMIT
COMMIT

What it guarantees

  • Every committed value lies on the graph. A row in FINALIZED passed through CHECKED while the Transition stood.
  • Every write path checks. Single-row and multi-row INSERT, the KWP load stream, UPDATE by literal or by expression, and DELETE. A write path that cannot check refuses the write instead of skipping the check.
  • A violation fails the transaction, like any failed statement: inside BEGIN the client must ROLLBACK; in autocommit the statement is undone.
  • Nothing to rebuild. A Transition is one catalog row. It keeps no derived structure, writes no log record of its own and needs no recovery step.

What it costs

  • History is not verified at creation. CREATE TRANSITION refuses an existing row whose state is outside the state set, but it accepts a row already in a late state, wherever it came from. The graph speaks for values committed while it stands.
  • PURGE is refused on the table. Deleted rows keep their keys and space until the Transition is dropped.
  • A violation is not retryable. The client must change what it writes.
  • DROP TABLE inside an explicit transaction is refused for a table with a Transition, because a rollback could not give the constraint back. In autocommit the drop removes the table’s Transitions with it.

How to observe it

SHOW TRANSITIONS lists every Transition with its table, column position and declaration:

kdbs> SHOW TRANSITIONS
transition_id,name,rel_oid,rel_name,column_no,column,def
1,order_status,4000,orders,2,status,CREATE TRANSITION order_status ON orders(status) ON INSERT ('NEW') ON UPDATE ('NEW' => 'CHECKED', 'CHECKED' => 'FINALIZED', 'NEW' => 'CANCELLED', 'CHECKED' => 'CANCELLED') ON DELETE FROM ('NEW', 'CANCELLED')

A refused write is TRANSITION_VIOLATION. Its message names the Transition, the column, the state or edge, the row id where one exists, and the byte of the value where it was written in the statement.

Limitations

  • One column, one table. No state machine spanning columns or tables.
  • One Transition per column.
  • Column types: the signed integers, char(N) and varchar(N). The column must be NOT NULL (the default), and it cannot be the primary key, which is never updated.
  • No guards and no actions. An edge cannot carry a condition (WHEN ...), and a move cannot trigger another write.
  • No stored history, audit log or transition timestamps.
  • Not undone by ROLLBACK. CREATE TRANSITION and DROP TRANSITION commit at their own statement.

See also