ADR-016: Messages in Their Own Table
Status: Accepted
Date: 2026-09-23
Context
A conversation's transcript was one JSON value in the messages column
(mediumtext) of tx_nrmcpagent_conversation (ADR-005). Every write of
the conversation wrote the whole value again, a message could not be read or
removed on its own, and the column grew without a natural bound — an image
reference, a long tool answer and forty turns all end up in one field that is
read and rewritten on every turn. Editing a sent message (NEXT-172) is a
truncation of that list. NEXT-172 asked for messages in their own table, with
an upgrade path that keeps existing conversations readable.
Two constraints come from the rest of the design:
- The claim is atomic. Sending a message, recording a decision, retrying
and editing all claim the conversation with a compare-and-swap on its
status (
updateIf), and a worker dequeues what is claimed. With the transcript in the same row, the new message and the claim were oneUPDATE. Split across two tables they must still be one unit: a claim that loses must leave no message behind, and a worker must never dequeue a conversation whose new message is not there yet. - The model does not change.
Conversationhands the transcript to the service, the controller and the worker as one list, and several places depend on that (the turn, file counting, edit, export). A parallel change (NEXT-167) was editing the model and the service at the same time.
Decision
- New table
tx_nrmcpagent_message:conversation,sorting,role,payload(the message array as JSON — tool calls, attachment references and timestamps travel unchanged),crdate; unique on(conversation, sorting). ConversationRepositoryis the only place that knows. Loading one conversation fills the model's list from the rows; saving writes the conversation row and replaces the rows in one transaction; inupdateIfthe transaction wraps the compare-and-swap and rolls back when it loses. Themessagescolumn is written empty on every save.- Replace, not append: an edit truncates, and a transcript is dozens of rows, not thousands. Appending only the new rows is an optimisation for later; it needs the repository to know what is stored, which replace-all does not.
- Rows written before the table existed stay readable: a conversation
without message rows is read from the column. The first save moves its
transcript; the upgrade wizard
nrMcpAgent_migrateMessagesToTablemoves the rest, one conversation per transaction, and keeps existing rows over a stale column value. - The list endpoints and the poll never read either — they use the
denormalised
message_count, as before. - The hard delete in
ai-chat:cleanupremoves message rows whose conversation no longer exists.
Consequences
- No code outside the repository, the cleanup command and the wizard changed; the model, the service and the controller see the same list.
- Each save costs a delete and one insert per message inside a transaction,
instead of one
UPDATEof a growing value. For the sizes a chat has this is the same order of work; for very long conversations append-only writes are the follow-up. - Replacing the rows deadlocks under REPEATABLE READ (the MySQL/MariaDB
default) when two new conversations are written at once: the delete of
an empty range takes a gap lock, and both inserts wait on the other's.
Reproduced on MariaDB 11.4 with two sessions. Every transaction of the
repository is therefore restarted up to three times on a
DeadlockException, which is the remedy the database itself names; the work is a function of the conversation and safe to repeat. A lock wait timeout is not retried (it has already waitedinnodb_lock_wait_timeout), and nothing is retried inside a caller's transaction, where the deadlock has rolled back more than the repository's part. - A legacy value that is not a JSON list — such a conversation could not
be opened before either — is not destroyed by the wizard: it stays in
the column behind the prefix
!undecodable:, which the wizard skips and no later save clears. The conversation is marked failed and archived and opens with an empty transcript instead of erroring. - The wizard selects uids only and moves each transcript in its own
transaction. Its claim is a conditional write (
WHERE messages =the value it read), taken before anything else, so a chat save that happened after the read is never overwritten by the older value. - Both tables must live on the same database connection. The repository
writes both through the conversation table's connection, and the
orphan sweep and the migration query them together; mapping
tx_nrmcpagent_messageto another connection in$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping']breaks reads and writes of the transcript, not only their atomicity. It is not supported. - The
messagescolumn stays in the schema, empty after the wizard; it can be dropped in a later release once no installation needs the fallback. - Queries per message (search, a per-message export, a size limit) are now possible without decoding every transcript.