21. Change events: LISTEN, NOTIFY, WAIT FOR CHANGES
Enterprise, Cluster and Developer editions. In the Express edition, these statements return error 9001.
An application that wants to know what has changed (a stock screen, a dashboard, a cache) does not need to poll the database in a loop: it subscribes to the tables or channels it cares about, then waits on a dedicated connection. Miraj hands it the changes as soon as they are committed:
-- Dedicated application connection
LISTEN TABLE articles COLUMNS (qte, prix);
WAIT FOR CHANGES TIMEOUT 30;+------------------+------------------+--------+-------+----------+------+---------+---------+------------+-------------------------+
| seq | commit_seq | event | db | name | pk | columns | payload | session_id | at |
+------------------+------------------+--------+-------+----------+------+---------+---------+------------+-------------------------+
| 1792376601136069 | 1792376601136069 | UPDATE | stock | articles | [25] | prix | NULL | 169 | 2026-10-19 03:26:06.508 |
| 1792376601136070 | 1792376601136070 | INSERT | stock | articles | [27] | NULL | NULL | 169 | 2026-10-19 03:26:06.508 |
+------------------+------------------+--------+-------+----------+------+---------+---------+------------+-------------------------+Here, another session changed the price of article 25, renamed that same article (change discarded: nom is not in COLUMNS) then added article 27.
Principles:
- No trigger to write: Miraj itself records the primary key and the modified columns of every row written to a listened table.
- Publication on commit: an event is returned only once the transaction is committed, never after a
ROLLBACK. The writes of a single transaction are merged: a row inserted then modified produces a singleINSERT, and a row inserted then deleted produces nothing. - Resuming after a disconnection: each event carries an increasing number. A client that reconnects picks up where it left off.
- A writer never waits for a subscriber: a slow or absent subscriber does not slow down writes. If it falls too far behind, it receives
RESYNCand must read everything again. - No special driver:
WAIT FOR CHANGESis an ordinary statement that returns a result set. Any SQL client can use it (Delphi, PHP, graphical tools,miraj-cli). An application that embeds the engine throughmiraj.dllalso hasmiraj_session_wait_changes(WaitChanges/wait_changes/waitChangesin the wrappers, see 27.9): it waits for at most a given delay and returns at most a given number of rows.
21.1 Subscribing#
LISTEN TABLE [database.]table [COLUMNS (c1, …)] [WHERE condition] [WITH ROW] [FROM n]
LISTEN [CHANNEL] channel [FROM n]
UNLISTEN TABLE [database.]table
UNLISTEN [CHANNEL] channel
UNLISTEN *LISTEN TABLEfollows the writes to a table: insertions, modifications, deletions, truncations and structure changes. It requires theSELECTprivilege on the table, checked at the time of theLISTEN.COLUMNS (…): only theUPDATEs that change at least one of these columns are returned. Insertions and deletions are always returned.WHERE condition: only the rows that satisfy the condition are returned. The condition applies to the columns of the table (the after image, or the before image for a deletion). It cannot contain a subquery, an aggregate or a variable, otherwise error 1235 is returned.WITH ROW: thepayloadcolumn contains the whole row, as a JSON object.
LISTEN channellistens to a named channel, fed byNOTIFY(§21.4). It requires no privilege.LISTENreturns one row with a singleseqcolumn: the session's position in the sequence of events (§21.3).
LISTEN TABLE articles WHERE qte < 20 WITH ROW;+------------------+
| seq |
+------------------+
| 1792376601136070 |
+------------------+After UPDATE articles SET qte = 5 WHERE id = 26 and UPDATE articles SET qte = 500 WHERE id = 25 (discarded by the filter), WAIT FOR CHANGES returns:
+------------------+------------------+--------+-------+----------+------+---------+----------------------------------------------------+------------+-------------------------+
| seq | commit_seq | event | db | name | pk | columns | payload | session_id | at |
+------------------+------------------+--------+-------+----------+------+---------+----------------------------------------------------+------------+-------------------------+
| 1792376601136071 | 1792376601136071 | UPDATE | stock | articles | [26] | qte | {"id": 26, "nom": "Écrou", "qte": 5, "prix": 0.80} | 172 | 2026-10-19 03:26:06.536 |
+------------------+------------------+--------+-------+----------+------+---------+----------------------------------------------------+------------+-------------------------+- A session can have up to 64 subscriptions (
change_events_max_listeners); beyond that, error 9008 is returned. A newLISTENon the same target replaces the previous one. UNLISTEN *removes all of the session's subscriptions. Disconnecting andKILLalso remove them.- A table without a primary key can be listened to, with warning 9009 (
Table 'stock.nk' has no primary key: only table-level events are delivered): its writes produce onlyBULKevents, without a key, with the reasontable without primary key. - Temporary tables are never listened to.
LISTEN, UNLISTEN and WAIT FOR CHANGES are refused in a procedure, a function, a trigger or a scheduled event: the routine is created, but running it returns error 1314 (LISTEN is not allowed in stored procedures).
21.2 Waiting for changes#
WAIT FOR CHANGES [TIMEOUT seconds] [LIMIT n]- Returns, in order, the events published since the session's position that match its subscriptions, at most
LIMITof them (1,000 by default), then advances the position: an event that has been returned is not returned again. - If there is nothing to return, the statement waits for the first event, for at most
TIMEOUTseconds (by default@@change_events_wait_timeout, 30 s). Once the delay has elapsed, it returns an empty result. TIMEOUT 0does not block: it collects whatever has arrived and returns immediately.- Waiting holds no lock: it delays neither writes nor
ALTER TABLE. KILL QUERYinterrupts it (error 1317), as doesmax_statement_time(error 1969); since that limit is 300 s by default, a longerTIMEOUTrequires raising it for the session (SET max_statement_time = 0).SHOW PROCESSLISTshows the session in theWaiting for changesstate.WAIT FOR CHANGESis refused inside an open transaction (error 9006) and without a subscription (error 9007). Withautocommit = 0, an implicit transaction that has only read is closed automatically.- An empty result keeps the columns below, with no rows.
Returned columns:
| Column | Content |
|---|---|
seq | Event number, increasing. |
commit_seq | Number shared by all the events of a single commit. It lets you group the changes of a transaction. |
event | INSERT, UPDATE, DELETE, BULK, TRUNCATE, SCHEMA, NOTIFY or RESYNC (see the table below). |
db | Database of the table; NULL for NOTIFY (a channel belongs to no database). |
name | Name of the table, or of the channel for NOTIFY. |
pk | Primary key of the row, as a JSON array ([25], ["FR", 2026]); binary values are written "0x…", dates in SQL format. NULL for a table event. |
columns | For an UPDATE: columns whose value changed, separated by commas. |
payload | Payload of a NOTIFY; row as JSON with WITH ROW; reason for a table event (1500 rows, ALTER TABLE, RENAME TABLE TO database.new_name…). |
session_id | Session that caused the write, so that an application can ignore its own modifications. NULL on a cluster secondary (§21.7). |
at | Publication date and time, to the millisecond. |
Events:
| Event | When |
|---|---|
INSERT, UPDATE, DELETE | A row added, modified or deleted. A modified primary key produces a DELETE of the old key then an INSERT of the new one; a REPLACE that replaces a row produces an UPDATE. |
BULK | More than change_events_bulk_rows rows (1,000 by default) of a table modified by a single transaction, or a table without a primary key: a single event, without a key, which prompts you to reread the table. |
TRUNCATE | TRUNCATE TABLE (reason TRUNCATE). A DELETE without a condition produces one DELETE per row, or a BULK beyond the threshold. |
SCHEMA | ALTER TABLE, RENAME TABLE, DROP TABLE or DROP DATABASE on the listened table. The subscription remains: a table recreated under the same name is followed again. |
NOTIFY | Notification from a listened channel (§21.4). |
RESYNC | Events have been lost for this session (§21.3): reread the data. |
Example: successive writes of a session on a listened table (LISTEN TABLE articles), including a transaction that inserts row 40 then modifies it and inserts row 41 then deletes it; then, outside a transaction, key 40 becomes 42, a REPLACE replaces row 42, a DELETE without a condition empties the table and an ALTER TABLE adds a column to it:
+------------------+------------------+--------+-------+----------+------+---------+-------------+------------+-------------------------+
| seq | commit_seq | event | db | name | pk | columns | payload | session_id | at |
+------------------+------------------+--------+-------+----------+------+---------+-------------+------------+-------------------------+
| 1792376601136075 | 1792376601136075 | INSERT | stock | articles | [40] | NULL | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136076 | 1792376601136076 | DELETE | stock | articles | [40] | NULL | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136077 | 1792376601136076 | INSERT | stock | articles | [42] | NULL | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136078 | 1792376601136078 | UPDATE | stock | articles | [42] | nom,qte | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136079 | 1792376601136079 | DELETE | stock | articles | [25] | NULL | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136080 | 1792376601136079 | DELETE | stock | articles | [26] | NULL | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136081 | 1792376601136079 | DELETE | stock | articles | [27] | NULL | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136082 | 1792376601136079 | DELETE | stock | articles | [42] | NULL | NULL | 178 | 2026-10-19 03:26:06.580 |
| 1792376601136083 | 1792376601136083 | SCHEMA | stock | articles | NULL | NULL | ALTER TABLE | 178 | 2026-10-19 03:26:06.580 |
+------------------+------------------+--------+-------+----------+------+---------+-------------+------------+-------------------------+TRUNCATE TABLE b, the insertion of 1,500 rows in one statement, then RENAME TABLE b TO b2 give:
+------------------+------------------+----------+-------+------+------+---------+--------------------------+------------+-------------------------+
| seq | commit_seq | event | db | name | pk | columns | payload | session_id | at |
+------------------+------------------+----------+-------+------+------+---------+--------------------------+------------+-------------------------+
| 1792376601136087 | 1792376601136087 | TRUNCATE | stock | b | NULL | NULL | TRUNCATE | 232 | 2026-10-19 03:26:46.994 |
| 1792376601136088 | 1792376601136088 | BULK | stock | b | NULL | NULL | 1500 rows | 232 | 2026-10-19 03:26:47.000 |
| 1792376601136089 | 1792376601136089 | SCHEMA | stock | b | NULL | NULL | RENAME TABLE TO stock.b2 | 232 | 2026-10-19 03:26:47.020 |
+------------------+------------------+----------+-------+------+------+---------+--------------------------+------------+-------------------------+21.3 Resuming after a disconnection#
Each session has a position: the number of the last event it received. The first LISTEN places it on the last published event, and each WAIT FOR CHANGES advances it.
To resume after a disconnection (application restart, network outage), the client keeps the last seq it processed and resubscribes with FROM:
LISTEN TABLE articles FROM 1790294400000042;
WAIT FOR CHANGES; -- returns the events that follow 1790294400000042Events are kept in memory in a queue of bounded size (100,000 events and 64 MiB by default, change_events_buffer_events and change_events_buffer_size). A table remains listened to for change_events_retention (10 minutes by default) after its last subscriber disconnects, so that its changes are found again on reconnection. An explicit UNLISTEN, however, stops tracking immediately.
The session receives a single RESYNC row when resuming is impossible:
- the expected events have left the queue (subscriber too far behind, or absent for longer than the retention);
- the server has restarted since: events are not kept on disk;
- the number comes from another server, for example from another node of the cluster (§21.7).
After RESYNC, reread the data. For a reload without gaps, first note the position:
SELECT MIRAJ_CHANGE_SEQ(); -- for example 1790294400000108
SELECT * FROM articles; -- full reload
LISTEN TABLE articles FROM 1790294400000108;Numbers are specific to each server startup: they start from the startup time, in microseconds. A number from a previous startup therefore cannot be mistaken for a number from the current startup.
21.4 Application notifications: NOTIFY#
NOTIFY channel [, 'payload']
SELECT MIRAJ_NOTIFY('channel', 'payload'); -- 1NOTIFYsends a notification to the sessions that listen to the channel. The payload is free text of at most 8,000 bytes (change_events_max_payload); beyond that, error 9010 is returned (NOTIFY payload exceeds 8000 bytes).MIRAJ_NOTIFY()returns 1.
LISTEN alertes;
NOTIFY alertes, 'stock bas';
SELECT MIRAJ_NOTIFY('alertes', 'x');
WAIT FOR CHANGES TIMEOUT 0;+------------------+------------------+--------+------+---------+------+---------+-----------+------------+-------------------------+
| seq | commit_seq | event | db | name | pk | columns | payload | session_id | at |
+------------------+------------------+--------+------+---------+------+---------+-----------+------------+-------------------------+
| 1792376601136073 | 1792376601136073 | NOTIFY | NULL | alertes | NULL | NULL | stock bas | 175 | 2026-10-19 03:26:06.559 |
| 1792376601136074 | 1792376601136074 | NOTIFY | NULL | alertes | NULL | NULL | x | 175 | 2026-10-19 03:26:06.559 |
+------------------+------------------+--------+------+---------+------+---------+-----------+------------+-------------------------+- In a transaction, the notification is published only at
COMMIT, together with the table changes and under the samecommit_seq. AROLLBACKcancels it. Two identical notifications (same channel, same payload) in a transaction count as one. NOTIFYandMIRAJ_NOTIFY()are allowed in procedures, functions and triggers. In a trigger, you write for exampleSET @n = MIRAJ_NOTIFY('low_stock', NEW.id);. A statement replayed after a write conflict publishes its notification only once.
21.5 Monitoring#
SHOW LISTENERS;
SHOW STATUS LIKE 'Miraj_change_events_%';
SELECT MIRAJ_CHANGE_SEQ();SHOW LISTENERSlists the subscriptions:Id,User,Kind,Target,Columns,Filter,With_row,Cursor,Lag(published events the session has not yet received),WaitingandTime. With thePROCESSprivilege, it shows all sessions, otherwise those of the user.SHOW STATUS:Miraj_change_events_published,_buffered,_buffer_bytes,_evicted,_resyncs,_bulk,_listeners,_waits,_last_seq.
SHOW LISTENERS;+-----+------+-------+----------------+----------+--------+----------+------------------+-----+---------+------+
| Id | User | Kind | Target | Columns | Filter | With_row | Cursor | Lag | Waiting | Time |
+-----+------+-------+----------------+----------+--------+----------+------------------+-----+---------+------+
| 169 | root | TABLE | stock.articles | qte,prix | NULL | NO | 1792376601136070 | 0 | NO | 0 |
+-----+------+-------+----------------+----------+--------+----------+------------------+-----+---------+------+SHOW STATUS LIKE 'Miraj_change_events_%';+----------------------------------+------------------+
| Variable_name | Value |
+----------------------------------+------------------+
| Miraj_change_events_buffer_bytes | 3270 |
| Miraj_change_events_buffered | 17 |
| Miraj_change_events_bulk | 1 |
| Miraj_change_events_evicted | 0 |
| Miraj_change_events_last_seq | 1792376601136085 |
| Miraj_change_events_listeners | 0 |
| Miraj_change_events_published | 17 |
| Miraj_change_events_resyncs | 0 |
| Miraj_change_events_waits | 5 |
+----------------------------------+------------------+21.6 Settings#
| Variable | Server option | Default | Purpose |
|---|---|---|---|
change_events_buffer_events | --change-events-buffer-events <n> | 100,000 | Events kept in memory for resuming. |
change_events_buffer_size | --change-events-buffer-size <bytes> | 64 MiB | Maximum size of these events. |
change_events_bulk_rows | --change-events-bulk-rows <n> | 1,000 | Beyond this, per table and per transaction, a single BULK. |
change_events_max_payload | --change-events-max-payload <bytes> | 8,000 | Maximum size of a NOTIFY payload. |
change_events_max_listeners | --change-events-max-listeners <n> | 64 | Subscriptions per session. |
change_events_wait_timeout | --change-events-wait-timeout <seconds> | 30 | Wait of WAIT FOR CHANGES without TIMEOUT (session and global variable). |
change_events_retention | --change-events-retention <seconds> | 600 | Tracking of a table after its last subscriber disconnects; 0 stops it immediately. |
The options set the value at startup; they can also be written, under the same name with underscores, in miraj_config.xml (chapter 16, §16.2). SET GLOBAL then changes them until the next restart (SHOW VARIABLES LIKE 'change_events%' shows them; both durations are displayed with six decimals, 600.000000). An invalid value prevents the server from starting. In the Express edition, these options are accepted and have no effect.
21.7 On a cluster (Cluster edition)#
A subscriber can connect to the primary or to a secondary: each node publishes the changes of its own tables.
- On the primary, events are published on commit, as on a standalone server.
- On a secondary, they are published when the secondary applies the log received from the primary. It returns the same events as the primary, in the same order, with a
commit_seqshared by each transaction; they arrive only with the replication lag (LAG_SECONDS, §17.5.2). Structure changes (SCHEMA) are returned there as well. - On a secondary,
session_idisNULL: the session that wrote is on the primary. - Filters (
COLUMNS,WHERE,WITH ROW) apply in the same way on all nodes. - Partitioned tables are listened to under their name, on all nodes, whichever partition is written. A row that changes partition changes key: it produces
DELETEthenINSERT. Thechange_events_bulk_rowsthreshold applies per partition. ATRUNCATEof the table, orALTER TABLE … TRUNCATE PARTITION, produces oneBULK(reasonTRUNCATE PARTITION) per emptied partition rather than aTRUNCATE. NOTIFYstays local to the node that executes it: it does not go through replication. Since writes, and therefore theNOTIFYs of triggers, run on the primary, an application that listens to a channel must subscribe on the primary.- Each node has its own numbering and its own
change_events_…settings. A number obtained on one node is meaningless on another. - Failover: after a promotion, a client that reconnects to another node with
LISTEN … FROM nreceivesRESYNCand rereads its data. A client already subscribed to the promoted secondary is not affected: the node keeps its numbering and continues to publish.
Recommended strategy: subscribe screens and read caches on a secondary, to offload the primary, and NOTIFY channels on the primary. On RESYNC, reread the data then resubscribe (§21.3).
Differences between nodes, rare in practice:
- With the pessimistic concurrency model (
--concurrency pessimistic), a write outside a transaction may be described in finer detail by a secondary than by the primary. The secondary returns a primary-keyUPDATEasDELETE+INSERT, cites only the columns actually changed, and details row by row someDELETEs that the primary summarizes asBULK. The multiversion model, the default, produces the same events on all nodes. - Two nodes configured with different
change_events_bulk_rowsvalues do not summarize the same transactions asBULK. - The rows of a
CREATE TABLE … SELECTare returned by a secondary if a subscription already targets that table name (for example left over after aDROP TABLE), but not by the primary.
21.8 Limits#
- Events are kept in memory only: a restart loses them, and the client receives
RESYNC. - A session has a single position for all its subscriptions: if events leave the queue before it reads them, it receives
RESYNC, even if those events concerned other tables. - After
RENAME TABLE, the subscription stays on the old name (the reason of theSCHEMAevent gives the new one): resubscribe under the new name. - Pessimistic model, write outside a transaction: a primary-key
UPDATEis returned under the new key only; a deletion that has already been compacted producesBULK. - If the server clock has gone backwards between two startups, an old number may, very rarely, fall within the range of the current startup.