Skip to content

Add MySQL support to River - #1227

Open
brandur wants to merge 9 commits into
masterfrom
brandur-river-mysql
Open

brandur wants to merge 9 commits into
masterfrom
brandur-river-mysql

Conversation

@brandur

@brandur brandur commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Here, add a new driver rivermysql that brings MySQL support to River.

Similar to SQLite, it's unfortunately not quite as good as the Postgres
driver, but it does the job. MySQL has more facilities than SQLite, but
it's still missing some major niceties like RETURNING, which for many
queries requires us to write an implementation using two operations --
one that performs the action, and then another that loads back the
result using returned IDs. It also doesn't have listen/notify.

Luckily, like SQLite, any of this nastiness stays cordoned to the
driver layer and doesn't leak into the mainline River code. We've got
good driver testing and basic tests on clients for each driver in place
so we get reasonable assurance that everything works.

In the before times, I would've been fairly concerned at the additional
maintenance burden that supporting another database would bring, but
with the rise of LLMs I think it's more plausible that we can bring
something like this in without much trouble. Incredibly, I was able to
get almost all of this implemented in just one evening whereby my SQLite
driver took me multiple months.

I don't want to bring MySQL in as a hard dependency, so I've made the
MySQL tests disabled by default. Use RIVER_MYSQL_TESTS_ENABLED=true to
activate them. Only one CI matrix case runs MySQL tests so that we don't
have to repeat them to exhaustion on every version of Go and Postgres.

See also:
#158

@brandur
brandur force-pushed the brandur-river-mysql branch 4 times, most recently from 0f7a847 to ec9be04 Compare April 24, 2026 06:05
@brandur
brandur force-pushed the brandur-river-mysql branch from ec9be04 to 739bd01 Compare May 12, 2026 02:52
@brandur
brandur force-pushed the brandur-river-mysql branch 2 times, most recently from 29d0c9e to ff70405 Compare June 11, 2026 19:56
@brandur

brandur commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Rebased this one so it has a new Listener similar to the one added for SQLite in #1275. MySQL's is a bit more difficult because unlike SQLite, out of order transactions are possible so we can't just track lastID, but Codex found a nice trick involving FOR UPDATE that we can use, and was able to verify it works via test case.

@brandur
brandur force-pushed the brandur-river-mysql branch from ff70405 to 57507b7 Compare June 11, 2026 21:46
@brandur
brandur force-pushed the brandur-river-mysql branch from 57507b7 to d2dd71a Compare July 24, 2026 21:22
Comment thread rivershared/sqlctemplate/sqlc_template.go Dismissed
@brandur
brandur force-pushed the brandur-river-mysql branch from d2dd71a to c7cad01 Compare July 24, 2026 22:08
@brandur
brandur force-pushed the brandur-river-mysql branch from c7cad01 to 1540484 Compare October 2, 2026 16:27
@brandur
brandur force-pushed the brandur-river-mysql branch 3 times, most recently from 6230b70 to 3466425 Compare October 15, 2026 22:34
Here, add a new driver `rivermysql` that brings MySQL support to River.

Similar to SQLite, it's unfortunately not quite as good as the Postgres
driver, but it does the job. MySQL has more facilities than SQLite, but
it's still missing some major niceties like `RETURNING`, which for many
queries requires us to write an implementation using two operations --
one that performs the action, and then another that loads back the
result using returned IDs. It also doesn't have listen/notify.

Luckily, _like_ SQLite, any of this nastiness stays cordoned to the
driver layer and doesn't leak into the mainline River code. We've got
good driver testing and basic tests on clients for each driver in place
so we get reasonable assurance that everything works.

In the before times, I would've been fairly concerned at the additional
maintenance burden that supporting another database would bring, but
with the rise of LLMs I think it's more plausible that we can bring
something like this in without much trouble. Incredibly, I was able to
get almost all of this implemented in just one evening whereby my SQLite
driver took me multiple months.

I don't want to bring MySQL in as a hard dependency, so I've made the
MySQL tests disabled by default. Use `RIVER_MYSQL_TESTS_ENABLED=true` to
activate them. Only one CI matrix case runs MySQL tests so that we don't
have to repeat them to exhaustion on every version of Go and Postgres.

See also:
#158
@brandur
brandur force-pushed the brandur-river-mysql branch from 3466425 to 4d384a2 Compare October 15, 2026 23:15
Sqlc is unsurprisingly ... buggy. Unfortunately though `JSON_TABLE` is
technically supported in `master` right now, there's no release, and
even having Codex try `master` it seems that it still doesn't work
properly.

Here, go around the use of sqlc for the couple major driver functions in
MySQL that could use it to help perform a batch operation (for most
driver operations it doesn't matter). For 1,000 row batches:

| Operation | Before | After | Speedup |
|---|---:|---:|---:|
| Insert without returned rows | 579 ms | 17 ms | **33×** |
| Complete/change state | 657 ms | 28 ms | **23×** |
| Rescue | 599 ms | 22 ms | **27×** |
| Notify | 457 ms | 6.8 ms | **68×** |

Unfortunately it doesn't help with the standard bulk insert because we
have to do something weird in MySQL to get each of the returned rows.
@brandur

brandur commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T05:10:53.062317Z 003bd8e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7930c2d085

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +388 to +390
let polled = async {
let mut connection = connection?;
let receive = async {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Poll notifications inside an explicit transaction

When concurrent notification inserts reserve IDs and commit out of order, this pooled connection remains in autocommit mode while NotificationListener executes its FOR UPDATE/FOR SHARE reads. InnoDB does not retain the intended locking-read behavior without an explicit transaction, so the poll can observe a later committed ID, advance last_id, and permanently skip the earlier row when it commits. Begin and commit a transaction around each cursor/subscription/poll scan, as the other MySQL implementations in this commit do.

Useful? React with 👍 / 👎.

@brandur

brandur commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f829882c05

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client.go Outdated
return nil, errJobListParamsMetadataNotSupportedSQLite
}
if c.driver.DatabaseName() == riverdriver.DatabaseNameMySQL && params.metadataCalled {
params = params.withMetadataPredicateSQL("JSON_CONTAINS(metadata, CAST(@metadata_fragment AS JSON))")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve structure in MySQL metadata containment

For nested arrays, JSON_CONTAINS does not preserve the containment semantics used by the other drivers: a stored fragment such as {"array":[[1]]} can satisfy a filter for {"array":[1]}, even though the nested level differs. Both JobList and JobListTx therefore return false-positive rows for valid metadata filters; use a structural predicate that traverses each object/array level, preferably through a driver/sqlc-backed query rather than embedding runtime SQL here.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

Comment thread js/driver/mysql/src/driver.ts Outdated
Comment on lines +319 to +323
if (item.uniqueKey !== null) {
const key = Buffer.from(item.uniqueKey).toString("hex");
if (keys.has(key))
throw new ConfigurationError("Batch repeats a unique key");
keys.add(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit duplicate-key checks to active unique states

When multiple batch entries share a unique key but their uniqueStates do not include their insertion state, the functional River index evaluates to NULL and both rows are valid. This unconditional set check rejects such batches before insertion—for example, two pending jobs whose uniqueness applies only while available—while the other implementations only reject repeated keys whose current state is covered. Gate the check and insertion into keys on whether item.uniqueStates includes item.state.

Useful? React with 👍 / 👎.

Comment on lines +593 to +597
if (duplicate
&& database.dialect() == Database.Dialect.MYSQL
&& (key == null
|| !key.equals(job.uniqueKey())
|| !job.uniqueStates().contains(job.state())))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the incoming state before accepting a duplicate

When MySQL's broad ON DUPLICATE KEY fires on a user-added unique constraint, this accepts the existing row whenever its key and state are active, but it never checks whether the incoming job's stateMask covers the incoming state. If the same River key is inactive for the new job, the River functional index could not have caused the conflict, so treating it as a River duplicate silently drops a valid insert. Include the incoming state-bit check before accepting the duplicate.

Useful? React with 👍 / 👎.

@brandur

brandur commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Addressed the latest findings in 6979464 and 6d5097c: structural metadata containment, active-state batch uniqueness, and incoming-state validation for MySQL duplicate inserts. Local review also found the same duplicate-state issue in TypeScript, cross-queue index scans in Java/TypeScript/Ruby, and a Java cleanup/completion deadlock; those are fixed too.

Validation: full Go tests and lint; metadata regressions on MySQL 8.0 and 8.4; Java verification on both MySQL versions; 20 repeated Java rescue-test runs; all 46 TypeScript MySQL integration tests plus lint/type checks; Ruby MySQL tests, lint, and type checks. The cleanup regression fails with the original query and passes with the fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d5097c3f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

FOR UPDATE;

-- name: JobDeleteBefore :execresult
DELETE FROM /* TEMPLATE: schema */river_job

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip locked jobs in every MySQL cleaner

When a completion transaction holds a low-ID job lock, this ordered DELETE can block while scanning that record even though the running job does not match the cleanup predicate; if the completion batch subsequently needs a row already locked by the cleaner, InnoDB can deadlock and roll back a completion. The Java MySQL cleaner in this diff replaces the same direct-delete shape with a bounded FOR UPDATE SKIP LOCKED selection, but the Go query here and the new TypeScript/Rust cleaners still use the blocking form, so apply the same skip-locked pattern across them.

Useful? React with 👍 / 👎.

-- name: JobUpdateExec :exec
UPDATE /* TEMPLATE: schema */river_job
SET
metadata = CASE WHEN CAST(sqlc.arg('metadata_do_merge') AS SIGNED) THEN JSON_MERGE_PATCH(metadata, CAST(sqlc.arg('metadata') AS JSON)) ELSE metadata END

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve null-valued metadata during MySQL merges

When a metadata update contains JSON null, such as {"checkpoint":null}, JSON_MERGE_PATCH removes that key instead of retaining it with a null value as River's shallow merge and Postgres jsonb || do. This changes persisted metadata and makes a subsequent containment filter for that null-valued key fail; use per-member JSON_SET semantics in this query and the batch completion query, and cover the null case in the shared driver suite.

AGENTS.md reference: AGENTS.md:L26-L26

Useful? React with 👍 / 👎.

Select and lock cleaner candidates with SKIP LOCKED before deleting by ID across the MySQL implementations. Keep Java cleanup working without a default database.

Preserve JSON nulls and replace nested metadata members at the top level. Shared regressions also correct SQLite merge behavior and Postgres database/sql numeric precision. Isolate cancellation tests from startup maintenance locks.
Force primary-key access for bounded deletes and expand cleanup locking regressions to full 1,000-job batches. This prevents the optimizer from switching to table scans as the ID list grows.
@brandur

brandur commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Addressed the latest review in bb7368c and 5d68530:

  • MySQL cleaners in Go, Java, TypeScript, Ruby, and Rust select and lock candidates with SKIP LOCKED, then delete only the selected IDs. Primary-key hints keep full batches from becoming table scans. Java also works without a default database.
  • Go metadata updates preserve JSON nulls and replace nested objects at the top level for single, batched, and repeated-ID updates. Shared tests also fixed equivalent SQLite behavior and Postgres database/sql numeric precision loss.
  • Local review expanded locking regressions to 1,000-job batches and isolated cancellation tests from startup maintenance. The cancellation tests pass 20 repeated runs.

Verification: full Go tests and lint; Java Maven verify on MySQL 8.0 and 8.4 without a default database; TypeScript MySQL integration tests and lint/type checks; Ruby MySQL tests, lint, and type checks; Rust MySQL tests on 8.0 and 8.4 and the full Clippy matrix. Go MySQL conformance tests also pass on both versions. The local re-review has no remaining findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 5d68530c61

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Cancelled queries can leave SQLx waiting for an unfinished statement during pool release, blocking pool shutdown. Discard interrupted River-owned connections and reuse them only after successful operations.

Preserve SQLx transaction depth for savepoints and cover cancellation, pool shutdown, and connection reuse with regression tests.
@brandur

brandur commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Integrated the previously uncommitted Rust MySQL cancellation fix with the latest PR code. I reproduced the bug on the previously reviewed head: cancelling a query blocked during statement preparation left pool.close() waiting for that connection.

River-owned MySQL operations now discard interrupted connections and return connections to the pool after successful completion. Transactions continue to use SQLx's transaction manager so savepoints work correctly. Caller-owned transactions retain their existing ownership semantics.

Verification: all 18 MySQL tests pass on 8.0 and 8.4, the Rust unit/SQLite/documentation suite and full Clippy matrix pass, and the cancellation, savepoint/reuse, notification shutdown, and worker maintenance tests pass 25 repeated runs (100 test executions). Local re-review found no remaining issues.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 003bd8e0dc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@brandur
brandur marked this pull request as ready for review October 17, 2026 05:19
@brandur
brandur requested a review from bgentry October 17, 2026 05:19
@brandur

brandur commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@bgentry Thoughts on moving forward with this? I feel a little more motivated now that I have multiple IRL testimonies of MySQL use that make sense. Rationale similar to Polyglot: probably crazy if everything's hand-maintained. Less so given how automated the process is. We'd probably have a similar strategy to SQLite: main first, Pro later.

Plus, gives me something to hack on a bit while the Pro polyglot stuff is in limbo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants