Query icon
Log icon
If icon
SlackIncomingWebhook icon
Loop icon
Fail icon
Schedule icon

MySQL Missing Primary Key Guard

Find MySQL tables with no primary key, alert Slack, promote a unique NOT NULL key online or add an invisible id, and verify with a second audit.

Categories
DataInfrastructure

A MySQL table without a primary key works until it meets replication:

  • With row-based binary logging, each UPDATE or DELETE on the source becomes a full table scan on the replica, row by row, and replica lag grows with the table.
  • Group Replication, and managed services that run it (Azure, some HA setups), reject writes to such tables outright.
  • InnoDB still needs a clustered index, so it uses a hidden row id you cannot see or use.

Legacy schemas, ETL staging tables and "temporary" tables that stayed are the usual cases.

This blueprint finds those tables, and MyISAM or other non-InnoDB tables, in the schemas you choose. It alerts Slack, and can give each InnoDB table a primary key, then audit again to verify. It uses the least disruptive fix available:

  • unique key on NOT NULL columns: promote it to the primary key with online DDL;
  • otherwise: add the invisible my_row_id column that MySQL 8.0.30's sql_generate_invisible_primary_key would have created.

This blueprint was created by zkasuran.

Findings

Finding Severity Fix
NO_PRIMARY_KEY with a unique key on NOT NULL columns High ADD PRIMARY KEY (cols), DROP INDEX uq, ALGORITHM=INPLACE, LOCK=NONE
NO_PRIMARY_KEY with no such key, up to max_rows_for_id rows High ADD COLUMN my_row_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT INVISIBLE PRIMARY KEY FIRST, LOCK=SHARED
NO_PRIMARY_KEY above max_rows_for_id High Report only, use an online schema change tool
NON_INNODB_TABLE Medium Report only, converting the engine is a migration

A unique key qualifies only if it has no nullable column, no prefix and no expression. The smallest one wins. MySQL refuses LOCK=NONE when adding an auto-increment column, so that change blocks writes for the length of the rebuild. The flow says so, and max_rows_for_id keeps it to tables where that is acceptable.

How it works

  1. audit (io.kestra.plugin.jdbc.mysql.Query, read-only) reads information_schema.tables, table_constraints, statistics and columns for the chosen schemas. It returns each finding with its candidate key and fix. Names are quoted with backticks, with embedded backticks doubled.
  2. summary logs every finding and the fix it will get.
  3. route (io.kestra.plugin.core.flow.If):
    • clean: logs it.
    • findings: alert posts to Slack.
      • remediate_gate runs only when auto_remediate is true and dry_run is false.
      • remediate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one ALTER TABLE at a time.
      • reaudit and verify_result fail if a fixed table still has no primary key.
  4. errors alerts Slack when the audit cannot run.

What you get

  • Every table that slows replication or blocks a move to Group Replication, with its size.
  • The least disruptive primary key for each table, chosen from its existing indexes first.
  • Invisible ids that SELECT * does not return, so applications keep working, and a second audit as proof.

Who it's for

  • Teams preparing a move to MySQL Group Replication, InnoDB Cluster or a managed MySQL service.
  • DBAs chasing replica lag caused by row-based replication on keyless tables.
  • Teams evaluating the MySQL plugin who want an example of an information_schema query driving DDL.

Why orchestrate this with Kestra

The query finds the tables once. Kestra runs it every week as new tables appear, sends findings to Slack, and applies the DDL behind auto_remediate and dry_run, one table at a time. It verifies the result and keeps a history of which table got which key.

Tested end to end

On Kestra 2.0.5 OSS against MySQL 8.4, with a local HTTP sink standing in for Slack. Schema shop:

  • inventory: 8,000 rows, unique key on two NOT NULL columns;
  • order_log: 5,000 rows, unique key with a nullable column;
  • page_views: 30,000 rows, no key;
  • weird`name: no key;
  • sessions: MyISAM;
  • products: has a primary key.
Run Inputs Result
1 dry run 5 findings. inventory gets the promotion of uq_sku_wh. order_log's key is rejected because event is nullable. products not flagged
2 remediate, first version verify_result failed on 3 tables: MySQL answered LOCK=NONE is not supported ... Adding an auto-increment column requires a lock. The invisible id now uses LOCK=SHARED and a row limit
3 remediate, max_rows_for_id: 20000 order_log and weird`name get my_row_id, page_views (30,000 rows) is held back. Verified. SELECT * on order_log still returns only its 3 original columns
4 remediate, default limit page_views gets my_row_id, verified
5 dry run Only the MyISAM table left, report only
6 unreachable server errors alert sent

Prerequisites

  • MySQL 8.0.23 or later, for invisible columns, reachable from the Kestra worker.
  • For the audit, SELECT on information_schema. For remediation, ALTER on the tables.
  • Replicas apply the same DDL, so expect the rebuild time on each of them too.
  • Local testing: docker run -d --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=pw mysql:8.4, then create tables without a PRIMARY KEY.

Secrets

  • MYSQL_USERNAME / MYSQL_PASSWORD: credentials used by every query.
  • SLACK_WEBHOOK_URL: used by alert and the errors block.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

Input Default Purpose
jdbc_url jdbc:mysql://localhost:3306/mysql Server
schemas [shop] Schemas to check
add_invisible_id true Add my_row_id when no unique key fits
max_rows_for_id 1000000 Largest table that gets my_row_id
auto_remediate false Allow ALTER TABLE
dry_run true Must be false for a statement to run

Quick start

  1. Add the MYSQL_USERNAME, MYSQL_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow and set jdbc_url and schemas.
  3. Run it once and read the fix planned for each table.
  4. Run it with auto_remediate: true and dry_run: false in a maintenance window, with max_rows_for_id set to what a write block of a few seconds allows.
  5. Enable the weekly trigger, which always runs in safe mode.

How to extend

  • Hand the tables above max_rows_for_id to gh-ost or pt-online-schema-change from a task in the same flow.
  • Turn on sql_generate_invisible_primary_key so new tables get a key automatically.
  • Check the replicas' lag before and after each ALTER and pause when it grows.
  • Loop over a list of JDBC URLs to check every server.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.