Databases (MySQL, PostgreSQL)

Database evidence for every incident, fixes on the database itself, and the right response when the database is the one that's down.

With a database connected, 3AM reads the database directly: whether it's up and why not, who holds the connections, which session is blocking the others, how replication is doing. It uses that evidence in two ways:

  • For the database's own incidents. It ends the session holding a lock, closes a leaking client's idle sessions, makes a primary writable again, or restarts replication that stopped cleanly. Each fix is rehearsed and approved.
  • For other incidents. When an application's log says it can't reach its database, 3AM checks the database itself instead of trusting the log line.

When the database itself is down, 3AM proves why before touching anything:

Proven causeWhat 3AM does
Hung, or its pod isn't ReadyRestart its pod (Kubernetes connector), then wait until it answers
Process downRestart its pod
Out of diskGrow its volume (Kubernetes connector), or ask a person. Never a restart
Still recoveringWait. A restart would start the recovery over
Connections exhaustedClose the leaking client's idle sessions
Corrupted, or misconfiguredHand it to a DBA with the error

MySQL and MariaDB, and PostgreSQL, are fully supported. Oracle and SQL Server support reads and approved data changes.

Before you start

  • One connection per database instance (or cluster endpoint) you want 3AM to watch.
  • Two accounts on each: a read account for evidence, and an admin account for approved fixes.
  • For databases in Kubernetes, the Kubernetes connector with the database's namespace, so 3AM can restart its pod or grow its volume.

1. Create the accounts

-- evidence: sessions, locks, replication, and the application's tables for packs that read them
CREATE USER 'threeam_read'@'%' IDENTIFIED BY '…';
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'threeam_read'@'%';
GRANT SELECT ON performance_schema.* TO 'threeam_read'@'%';
GRANT SELECT, EXECUTE ON sys.* TO 'threeam_read'@'%';
GRANT SELECT ON orders.* TO 'threeam_read'@'%';            -- the application's schema

-- approved fixes: end sessions, read-only off, start replication
CREATE USER 'threeam_admin'@'%' IDENTIFIED BY '…';
GRANT PROCESS, REPLICATION CLIENT, CONNECTION_ADMIN, SYSTEM_VARIABLES_ADMIN, REPLICATION_SLAVE_ADMIN ON *.* TO 'threeam_admin'@'%';
GRANT SELECT ON performance_schema.* TO 'threeam_admin'@'%';
GRANT SELECT, EXECUTE ON sys.* TO 'threeam_admin'@'%';
GRANT SELECT ON orders.* TO 'threeam_admin'@'%';

CONNECTION_ADMIN also lets the admin account in when every connection slot is taken. MySQL keeps one extra slot for it, which is how 3AM closes a connection leak on a full database. Add INSERT, UPDATE, DELETE on the application's schema only if you want approved data changes (sql.write).

2. Add the connector

One connector per instance. In connectors.json:

/etc/3am/connectors.json
{ "name": "ledger-db", "kind": "sql",
  "settings": { "engine": "postgres", "host": "ledger-db-0.ledger-db.bank-prod.svc", "database": "ledger",
                "username": "threeam_admin", "password": { "file": "/etc/3am/secrets/ledger-db-admin" },
                "read_username": "threeam_read", "read_password": { "file": "/etc/3am/secrets/ledger-db-read" },
                "service": "ledger", "expected_role": "primary" } }

expected_role matters. 3AM makes a read-only database writable again only if it's declared the primary. A demoted old primary also looks like "not a replica", and making it writable would give you two primaries.

When an alert names a database (its instance or host label), 3AM checks and fixes that database, and no other. If it can't tell which of several databases an alert means, it asks instead of guessing.

kind: sqlactionsalerts in
SettingWhat to enterRequiredDefault
engineDatabase engine (mysql, mariadb, postgres, oracle, mssql)yes—
hostHostyes—
portPortno—
databaseDatabase / service nameyes—
usernameAdmin account (approved fixes)yes—
passwordsecretPasswordyes—
read_usernameRead-only account (evidence and polls)no—
read_passwordsecretIts passwordno—
serviceThe service this database belongs to (as the estate names it)no—
expected_roleWhat this instance should be: primary or replica. 3AM makes a read-only database writable only if it is declared the primary (, primary, replica)no—
tlsRequire TLSyesfalse
ca_fileCA certificate (PEM)no—
max_rows_affectedRefuse writes that would change more rows than thisyes1000
max_sessions_killedMost sessions kill_idle_sessions closes at onceyes50
protected_usersUsers whose sessions 3AM never closes (replication, monitoring)no—
down_after_sSeconds failing before it's an incidentyes60
connections_ratioConnections used (of the maximum) before it's an incidentyes0.9
long_transaction_sSeconds a transaction may stay openyes600
blocked_after_sSeconds sessions may wait on a lockyes60
lag_sReplication lag before it's an incidentyes300
pollWatch this database (signals)yestrue

3. Verify

Test connection
postgres 17.11, primary, 1/30 connections

Test connection also tries the admin account, and names any view the read account can't see.

What it detects

SignalMeaning
DatabaseDownIt hasn't answered for down_after_s (60 s), with the cause: refused, hung, DNS, credentials, too many connections, starting or recovering, out of disk
DatabaseRestartedIt restarted since the last look, including PostgreSQL's in-place crash recovery
DatabaseConnectionsHighConnections used are above connections_ratio (90%) of the slots applications can use
DatabaseBlockedSessionsSessions have waited on a lock for blocked_after_s (60 s)
DatabaseLongTransactionA transaction has been open for long_transaction_s (10 min)
DatabaseIdleInTransactionSessions sit idle inside an open transaction (PostgreSQL)
DatabaseReadOnlyA primary is read-only, so writes fail
DatabaseReplicationStopped, DatabaseReplicationLagA replica stopped, or is more than lag_s (5 min) behind

Alerts from Prometheus exporters (MySQLDown, MySQLTooManyConnections, PostgresqlDown, ...) reach the same causes.

What it proves and fixes

CauseHow 3AM knowsFix
A lock held by another sessionSessions wait on a lock; the blocking session is named, with its user and last statementEnd the blocking session (its transaction rolls back)
A connection leakConnections near the limit, most held idle by one user for minutesClose that user's sessions idle for 2 minutes or more
A read-only primaryDeclared primary, read-only, upMake it writable again
Replication stopped cleanlyThe replica stopped with no error (MySQL)Start replication
Replication stopped on an errorThe error, for example a duplicate keyAdvice for a DBA: skipping it would make the replica differ
An inactive replication slotIt holds more than 1 GB of WAL (PostgreSQL)Advice: drop it once its consumer is gone for good
Transaction ID wraparound nearage(datfrozenxid) above 1.2 billion (PostgreSQL)Advice: VACUUM (FREEZE)

Actions

ActionParametersNotes
kill_sessionidRefuses 3AM's own session, the database's own processes, and protected_users
cancel_queryidCancels the running statement; the session stays
kill_idle_sessionsuser, idle_sAt most max_sessions_killed (50) at a time; idle_s at least 30
set_read_writeA declared primary only, never a replica
start_replicationMySQL, only when it stopped without an error
sql.writestatementOne statement, in a transaction, rehearsed, capped at max_rows_affected (1,000)
db_status, sessions, sql.readRead-only evidence; sql.read runs in a read-only transaction with a 15 s limit

Certification

Live on MySQL 8.4 and PostgreSQL 17, with the accounts above and 3AM inside a Kubernetes cluster: 60 of 60 checks passed (2026-10-05). The oldest supported versions, MySQL 8.0 and PostgreSQL 14, passed the same scenarios.

  • The connector. Both accounts on primaries and replicas; the read account can't write; a wrong password and a name that doesn't resolve are explained.
  • Locks. An idle transaction held a row lock on each engine. 3AM ended the blocking session, and the waiting update went through.
  • Connection leaks. A client leaked connections until none were left. On MySQL, 3AM read and fixed it through the admin account's reserved slot. On both engines it closed exactly the leaking client's idle sessions.
  • Read-only primary. Made writable again and checked with a write. The replica, read-only by design, raised nothing.
  • Replication. Stopped cleanly: started again. Broken by a duplicate key: advice, with the real error in the evidence.
  • The database down. A frozen database and a killed database process were each restarted through Kubernetes and verified. A full disk crashed PostgreSQL, which recovered with writes failing. 3AM proved it from the log and didn't restart it.

Troubleshooting

You seeDo this
it refuses 3AM's credentialsThe password was rotated or expired, or the account is locked. Update the credential file
3AM's account may not use the configured databaseGrant the account access to the application's schema (MySQL: SELECT ON <schema>.*)
can't see: other users' sessionsPostgreSQL: grant pg_read_all_stats (part of pg_monitor)
its name doesn't resolve for a database in KubernetesA headless Service stops publishing a pod that isn't Ready: the database is hung or crashed, not your DNS
A full disk, but no incident yetUntil PostgreSQL's next checkpoint (up to 5 minutes) a full database still answers reads. Only a disk-space alert from monitoring (for example KubePersistentVolumeFillingUp) shows it sooner
isn't declared the primarySet expected_role: primary on the connector of the instance that should take writes

On this page