title: MySQL and MariaDB description: Run Dendrux on MySQL 8.4 or MariaDB 10.11 and newer: install the driver, create the store, migrate, size payload limits against max_allowed_packet, and know the collation and isolation rules.
MySQL and MariaDB
Dendrux stores every run in your database. MySQL 8.4 and MariaDB 10.11 (and newer) are supported next to PostgreSQL and SQLite, on the InnoDB engine. Use the same agents, the same pause and resume calls and the same RunStore reads; only the connection string and a few server rules differ.
One live-update difference. The built-in live-update transport (
live_updates=True) is PostgreSQL-only, andMySQLStateStore(engine, live_updates=True)raises aValueErrorthat suggestsRedisUpdates. By defaultfollowandstream_eventspoll the database. To wake followers between polls, give the store a Redis bus (pip install "dendrux[redis]", thenMySQLStateStore(engine, live_updates=RedisUpdates(url))) or your ownLiveUpdates; see Scaling. Everything else, including recovery withsweepandreclaim, works as on PostgreSQL.
Install and connect
pip install "dendrux[mysql]"The extra installs the asyncmy driver and cryptography. MySQL 8.4 logs users in with caching_sha2_password by default, and asyncmy needs cryptography for that login on a first connection and after a server restart. Without it, a correct password can fail with a message that cryptography is required. The extra is opt-in and is not part of dendrux[all].
The URL form is mysql+asyncmy://user:password@host:3306/database. mysql://, mysql+aiomysql:// and the mariadb spellings (mariadb://, mariadb+asyncmy://) are accepted and use asyncmy. The connected server decides whether it is MySQL or MariaDB. An engine you create yourself with a mariadb+asyncmy:// URL works with MySQLStateStore(engine), store_for_engine(engine) and RunStore.from_engine(engine) as well as one with a mysql+asyncmy:// URL.
If asyncmy is missing, or cryptography is missing when the server asks for a caching_sha2_password login, building the store raises an ImportError that names dendrux[mysql].
For a database URL, Dendrux creates the engine and sets what it needs. You pass no extra options. The examples use the OpenAI provider, so they also need pip install "dendrux[openai]" and an OPENAI_API_KEY; any provider works the same way.
from dendrux import Agent
agent = Agent(
name="worker",
prompt="Help the user.",
provider="openai:gpt-4o-mini",
database_url="mysql+asyncmy://user:password@db.internal:3306/dendrux",
)Every engine Dendrux creates for a MySQL or MariaDB URL (Agent(database_url=), RunStore.from_database_url, MySQLStateStore.from_url and the CLI) uses READ COMMITTED isolation, sets the connection time zone to UTC and uses utf8mb4. Passing options that contradict this (a different isolation_level, another init_command) raises ValueError.
To own the engine yourself, as the scaling guide recommends for production, set the same two options:
from sqlalchemy.ext.asyncio import create_async_engine
from dendrux import Agent
from dendrux.store import MySQLStateStore, RunStore
engine = create_async_engine(
"mysql+asyncmy://user:password@db.internal:3306/dendrux",
isolation_level="READ COMMITTED",
connect_args={"init_command": "SET time_zone='+00:00'", "charset": "utf8mb4"},
pool_size=5,
max_overflow=0,
pool_pre_ping=True,
pool_recycle=1800,
)
state = MySQLStateStore(engine)
reads = RunStore(state)
agent = Agent(name="worker", prompt="Help the user.", provider="openai:gpt-4o-mini", state_store=state)What the store requires and checks
On first use the store reads the server's settings once per store (one query, no SET on checkout) and refuses to run, with a ValueError that names the setting but never a value, unless all of these hold. The first use is before any claim or write. A pool is assumed to be configured uniformly, so the facts are cached per store:
A driver exception that is not a SQLAlchemy error (a character-encoding failure, an error raised inside asyncmy) becomes a PersistenceError with no value text and no driver context; an AttributeError or UnicodeError raised by your own code propagates unchanged.
The time zone is not checked. Dendrux writes UTC timestamps itself, and sets SET time_zone='+00:00' on engines it creates; set it on your own engine as shown above so that server-side defaults agree.
Metadata filters (list_runs, count_runs) behave the same on PostgreSQL, SQLite, MySQL and MariaDB. A scalar filter value matches only a value of the same JSON type: true is not 1, an explicit JSON null is not an absent key, and a string does not match an array that contains it. 1 and 1.0 are equal. Strings are case- and whitespace-sensitive, including non-ASCII text. Integers up to the unsigned 64-bit maximum match exactly, including values above the signed 64-bit range. Collection-valued filters keep their earlier behaviour.
JSON integers. MySQL stores a JSON integer beyond 64 bits as an approximate decimal, so the MySQL store refuses it: the range is -9223372036854775808 through 18446744073709551615. A nested integer outside it, in input, metadata, a tool result or a patch, raises JSONIntegerOutOfRangeError (a non-retryable PersistenceError with code json_integer_out_of_range) before anything is written. The range applies to each JSON field on its own, and reads never check it. Send larger numbers as strings. MariaDB, PostgreSQL and SQLite keep large integers exactly and never raise it.
Create the schema
MySQL and MariaDB do not create tables on first use. Run the migration once, as a deployment job. A statement that finds a table missing raises SchemaNotInitializedError, and one that finds a column missing raises SchemaOutOfDateError, both with the dendrux db migrate hint and no driver text, on every store path including an engine you own. DENDRUX_SKIP_SCHEMA_CHECK skips only the startup catalog check, not this:
export DENDRUX_DATABASE_URL="mysql+asyncmy://user:password@db.internal:3306/dendrux"
dendrux db migrate
dendrux db checkdendrux db migrate builds the schema in 37 resumable steps. If a step fails or the process stops, run it again: it continues where it stopped and refuses anything it does not recognise. A fresh MySQL or MariaDB database is already at storage format 2: after migrate, dendrux db status shows storage_format=2 activate_step=done, and there is no activation step. Then dendrux db status works as described in Database CLI.
- Forward-only. MySQL cannot roll DDL back with a transaction, so there is no downgrade. Take a backup before every migration or activation.
- One at a time. Maintenance commands (
migrate,activate,dedupe) hold a named server lock (GET_LOCK) for the whole database, on a dedicated connection. A second one waits, then gives up. Individual DDL statements wait at most five seconds for a table lock. - No offline SQL. The CLI always works against the live database named by
DENDRUX_DATABASE_URL; it cannot print a migration script for you to run elsewhere.
A dedicated or a shared database
Dendrux tables can live in a database of their own, or beside your application's tables. Only the Dendrux table names and alembic_version are ever considered; nothing else is touched.
Do not share the Alembic version table. If your application keeps its own Alembic history in the same database,
alembic_versionalready exists anddendrux db migraterefuses to run. Give Dendrux a separate database (the simplest fix), or keep the two histories in different databases. A Dendrux-owned version-table name is on the backlog.
Collations
Tenant ids, run ids, idempotency keys and other identifiers must compare exactly: "t" and "t " are different tenants, and so are "T" and "t". Dendrux therefore creates these columns with a binary, no-pad collation: utf8mb4_0900_bin on MySQL and utf8mb4_nopad_bin on MariaDB. It also keeps a generated tenant_scope column so that untenanted runs are unique per key.
Do not change these collations or convert the tables. utf8mb4_bin is not acceptable: it ignores trailing spaces. dendrux db check reports drift.
Payload limits and max_allowed_packet
MySQL and MariaDB refuse a statement larger than max_allowed_packet (MySQL 8.4 defaults to 64 MiB, MariaDB 10.11 to 16 MiB). Dendrux guards against this in two ways.
An early check of your limits. Before the Agent first uses the store, it compares its configured Limits with the server's value and refuses with a ValueError that names both numbers, before any run starts. The size compared is a worst-case estimate of the largest statement the payloads that Limits bounds can produce:
The default limits need about 12.8 MiB, so they fit both servers' defaults. The estimate is deliberately pessimistic, and it cannot see payloads that Limits does not bound, such as model prompts and responses. To raise a limit, raise max_allowed_packet on the server (a global setting that applies to new connections) or lower the Dendrux limit.
The server's own limit is the final guard. If the server answers a statement with its packet-too-large error (1153, or 1301 for an oversized function result), the store raises StatementTooLargeError, a PersistenceError with code == "statement_too_large". The write is not retried and nothing is persisted. The error carries the server's limit; its size is usually None, because the server does not report it. The message never contains a value, and a store_statement_limit line is logged.
The server usually closes the connection instead of answering. In tests against MySQL 8.4 and MariaDB 10.11 with the
asyncmydriver, an oversized statement was never answered with 1153: the server closes the connection while the driver is still sending, and the driver reports a lost connection (2006 or 2013). Dendrux therefore decides on the failure path only, at no cost to a write that succeeds: when a MySQL-family connection is lost and any value bound to the failed statement is larger than the store'smax_allowed_packet, the store raisesStatementTooLargeError(code == "statement_too_large") after one attempt. It is not retried, nothing is persisted, and the next write works. A lost connection with no oversized value is an ordinary transient failure and follows the retry rules. If you see these errors on large payloads, checkmax_allowed_packetfirst.
Images are not stored in the database, so ImagePolicy sizes are not part of either check. Large payloads can also go to a blob store.
Operating notes
- Connections. Use a bounded pool per process, as in the scaling guide. Dendrux sets isolation and the time zone per connection, so a proxy must not reset them.
- Deployment. The store declares
deployment="multi_server": any number of processes and hosts can share one database. - Lost acknowledgements. If a connection dies after
COMMIT, a write with a retry identity is read back and reports its real outcome, and so iscreate_run(the run id, agent, tenant and input prove it). If Dendrux cannot prove whether a claim took effect, it raisesStoreOutcomeUnknown. Do not run the claimed work and do not retry the claim; the lease andreclaimrecover the run. A blind retry of a submit call raisesRunNotPausedErroronce the run is no longer paused; that does not prove your first attempt was the one that claimed it, so never assume ownership. See Errors. - When a write fails. A write that still fails after the retries makes
agent.runraisePersistenceError(a stream ends withrun_error); see Errors.
Test servers for contributors
The repository ships a Docker Compose file with MySQL 8.4 and MariaDB 10.11 for the test suites. They listen on 127.0.0.1:53306 and 127.0.0.1:53307 only.
Run these from packages/python in a repository checkout (the Makefile that has the targets lives there):
cd packages/python
make test-dbs-up
export DENDRUX_TEST_MYSQL_URL=mysql+asyncmy://root:dendrux_test@127.0.0.1:53306/dendrux_ci_test
export DENDRUX_TEST_MARIADB_URL=mysql+asyncmy://root:dendrux_test@127.0.0.1:53307/dendrux_ci_test
make test-mysql
make test-dbs-downThe database name must end in _test; each test makes and drops its own copy. Unset variables skip those tests. See Load testing for make load-mysql and make load-mariadb.