The Keycloak Upgrade Ledger

Every upgrade we have rehearsed, with the environment stated and the clock running.

2026-09-01 · run 2026-09-01-seed-sql-mysql-mariadb-port

seed-sql.sh ported to MySQL and MariaDB (2M-class seeder)

Condition
The last engine-tooling gap: the 2M-class direct-SQL seeder was Postgres-only

What was done

bin/seed-sql.sh is now engine-aware for postgres (unchanged, generate_series) and mysql/mariadb (new, recursive CTE). Oracle is still rejected with a loud pointer (see Remaining).

Three engine differences had to be handled, each of which would otherwise fail silently-late or corrupt the fixture:

  1. Identifier case. Keycloak creates UPPERCASE identifiers (REALM, USER_ENTITY, CREDENTIAL, USER_ATTRIBUTE, ID, REALM_ID, USERNAME, …). Postgres folds unquoted identifiers to lowercase; MySQL 8 / MariaDB on Linux (lower_case_table_names=0, the lab's setting) preserve them. The first MySQL attempt failed at once with Table 'keycloak.realm' doesn't exist. The shared lookups (realm id, template id, row count) and the INSERT column lists are now written per engine.
  2. Recursion-depth knob. MySQL 8 caps a recursive CTE with cte_max_recursion_depth; MariaDB calls the same thing max_recursive_iterations. Both default to 1000, so a 250k batch fails without a SET SESSION — and the name is engine-specific.
  3. Credential JSON. The argon2 secret_data contains $argon2id$…, so it cannot be carried across the shell boundary (an unquoted heredoc would expand the $). On MySQL/MariaDB the JSON is copied in-database with a scalar subquery against the template row, so it never enters the shell at all.

Result (verified at 1000 users on both engines)

Engineuserscredentialsattributescredential hash copied from template
MySQL 8.41000 (+1 template)10002000☑ (matched)
MariaDB 11.41000 (+1 template)10002000☑ (matched)

Also exercised on MariaDB: --start extension (append users), --batch (multi-batch loop), and --no-analyze. The Postgres path was re-verified unchanged (100 users).

Observation worth its own note

--no-analyze on MariaDB reports the planner's row estimate from information_schema.tables.table_rows, and that value is inherently fuzzier than Postgres's pg_class.reltuples: after seeding 1101 rows InnoDB reported 1303. Postgres's reltuples is exact after ANALYZE; InnoDB's table_rows is a statistical estimate even when freshly analyzed. So the "stale planner" model the lab uses to reproduce the index-skip threshold (Run A) will not map one-to-one onto MySQL/MariaDB — the pre-condition that Keycloak reads there is a number that is never exact. Worth pinning before claiming the threshold finding generalises.

Remaining

Verification

ClaimEvidence
MySQL seeds 1000 with correct shape1000 users / 1000 creds / 2000 attrs, hash copied ☑
MariaDB seeds 1000 with correct shapesame counts, hash copied ☑
Extension + multi-batch + no-analyze work--start/--batch/--no-analyze runs ☑
Postgres path unchanged100 users seeded, 101 rows ☑

← Back to the Ledger