Repository navigation
Avoid opening subtransactions when already in a transaction - #1420
Conversation
|
Here's an example from Codex of a potential problem that could result from the change: A concrete example is a Pro user who saves an order but treats an accompanying analytics job as optional: if err := insertOrder(ctx, tx, order); err != nil {
return err
}
if _, err := client.InsertTx(ctx, tx, AnalyticsArgs{OrderID: order.ID}, nil); err != nil {
logger.Error("Couldn't enqueue analytics", "error", err)
// Intentionally continue: analytics shouldn't block checkout.
}
return tx.Commit(ctx)Suppose the job insertion hits a PostgreSQL
|
|
Another one: A stronger example is a bulk import that intentionally accepts valid records and reports individual failures. Suppose a Pro application imports 1,000 documents, calling One document contains a NUL character in its text. Go successfully encodes that as
Here, retaining successful work is explicitly the desired behavior. There’s no order/email consistency concern: each document is independent, and one malformed input now prevents every valid document from being processed. The application would need its own per-document savepoints, or separate transactions, to restore that behavior. That’s a legitimate compatibility concern, although it’s still a hypothetical usage pattern, not an observed customer dependency. I’d still favor caller-owned savepoints for this case: the application knows it wants partial success, whereas most callers want the entire transaction rolled back when an insertion fails. |
|
I don't find either of these examples all that convincing in that it's definitely describing a real thing, but it'd be unusual to still want to commit the transaction in case of either failure, and in case you did want to commit it, an explicit savepoint would definitely be preferable to River deciding for you. Want to be careful on this one because although the obvious answer would be to put this behind a configuration flag, it seems to me that the overwhelmingly more "correct" behavior would be the opposite of what River does right now. So it'd be nice to work our way to a point where we could get back to that. |
River currently opens subtransactions when already in a transaction for most behavior. This isn't something that's useful in the majority of situations, but may be very occasionally in cases where a piece of a transaction fails, but the caller still wants to commit the whole transaction anyway. Unfortunately, subtransactions have a real performance impact in Postgres [1], so it's not free to have them. One of our users confirms to have observed this IRL. This patch modifies River's transaction approaches so as to only open a new one unless not already in one. So `Client.Insert` still opens a transaction, but `Client.InsertTx` won't open a new subtransaction on top of the `tx` that was already passed into it. Though not an API change, this is a behavioral change. I've been having Codex try to give me the worst case scenario of what might break were we to change this. So far it hasn't come up with anything convincing (in every scenario the change improves correctness of the original state), but it's still worth thinking about whether there might be other real use cases where people would be broken by this. For now I've added an env flag `RIVER_USE_LEGACY_SUBTRANSACTIONS` that can be used to revert back to the previous behavior. The idea here is that it'd give anyone broken by this a potential escape hatch, but it would still let us roll forward with no subtransactions as the preferred default. The env var would be removed in a future version. [1] https://planetscale.com/blog/the-dangers-of-postgres-subtransactions
04ade8e to
5f9acf4
Compare
bgentry
left a comment
There was a problem hiding this comment.
I was a bit confused until I realized this was mainly a sqlite-specific PR (its title doesn't say so, nor does the changelog entry). Aside from making that clearer, I think this is fine to merge. I assume you've also verified the other drivers don't need such changes?
|
Ah, it actually does apply to all databases. The key piece is in // executor preserves transaction identity when a method is invoked through an
// embedded Executor, so transaction helpers can reuse it without a savepoint.
func (e *Executor) executor() riverdriver.Executor {
if e.execTx != nil {
return e.execTx
}
return e
}SQLite's driver needed a little extra code too, but this change fixes all DBs. |
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per pass. It uses the savepoint to catch a unique key conflict, roll back that one row, and mark the row discarded. In Postgres, each savepoint that writes gets its own transaction ID. A backend caches only 64 of these IDs. Past that, the cache overflows. Until the transaction ends, other sessions must look up `pg_subtrans` to check whether recent rows are visible. This can slow queries across the server. The driver `transaction` helpers have the same problem. Inside an application transaction, the Sequel helper passes `savepoint: true` and the Active Record helper passes `requires_new: true`. Each River write can open a savepoint, and an insert opens two. Port Go's `JobSchedule` instead. On Postgres, one statement locks the due rows, finds conflicts with `jobs_with_rownum` and `unique_conflicts`, and sets each row to `available` or `discarded`. SQLite cannot update rows in a CTE, so the SQLite path checks each unique job for a conflict before it writes, as Go's SQLite driver does. No write fails, so no savepoint is needed. The Postgres query keeps `FOR UPDATE SKIP LOCKED`. The Ruby leader runs the scheduler in the loop that renews leadership, so it must not wait on an application's row lock. The pass now notifies every queue it changes, as Go does. Make both helpers join an open transaction, as Go's `WithTxV` does since #1420. Active Record ignores `ActiveRecord::Rollback` in a joined block. So the Active Record helper yields directly, and the rollback reaches the transaction's owner. A rollback raised in `Driver#transaction` now rolls back the caller's whole transaction. The SQLite batch insert uses the same helper. `RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the savepoints, like Go's temporary fallback. New specs run a scheduler pass with both kinds of conflict inside a caller's transaction. They assert the outcome that Go produces and that no savepoint opens. On Postgres, they also check that each changed row has the caller's transaction ID as its `xmin`. Specs that relied on River's savepoints now use one that the application owns.
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per pass. It uses the savepoint to catch a unique key conflict, roll back that one row, and mark the row discarded. In Postgres, each savepoint that writes gets its own transaction ID. A backend caches only 64 of these IDs. Past that, the cache overflows. Until the transaction ends, other sessions must look up `pg_subtrans` to check whether recent rows are visible. This can slow queries across the server. The driver `transaction` helpers have the same problem. Inside an application transaction, the Sequel helper passes `savepoint: true` and the Active Record helper passes `requires_new: true`. Each River write can open a savepoint, and an insert opens two. Port Go's `JobSchedule` instead. On Postgres, one statement locks the due rows with `FOR UPDATE`, finds conflicts with `jobs_with_rownum` and `unique_conflicts`, and sets each row to `available` or `discarded`. The query matches Go's, including its locking, so the scheduler now waits for a row that another transaction has locked instead of skipping it. SQLite cannot update rows in a CTE, so the SQLite path checks each unique job for a conflict before it writes, as Go's SQLite driver does. No write fails, so no savepoint is needed. The pass now notifies every queue it changes, as Go does. Make both helpers join an open transaction, as Go's `WithTxV` does since #1420. Active Record ignores `ActiveRecord::Rollback` in a joined block. So the Active Record helper yields directly, and the rollback reaches the transaction's owner. A rollback raised in `Driver#transaction` now rolls back the caller's whole transaction. The SQLite batch insert uses the same helper. `RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the savepoints, like Go's temporary fallback. New specs run a scheduler pass with both kinds of conflict inside a caller's transaction. They assert the outcome that Go produces and that no savepoint opens. On Postgres, they also check that each changed row has the caller's transaction ID as its `xmin`. Specs that relied on River's savepoints now use one that the application owns.
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per pass. It uses the savepoint to catch a unique key conflict, roll back that one row, and mark the row discarded. In Postgres, a savepoint gets its own transaction ID when it first writes or locks a row. Each backend caches at most 64 of these IDs. A released savepoint keeps its entry until the top-level transaction ends, so a pass that makes more than 64 jobs available overflows the cache. Until that transaction ends, snapshots that any session takes are marked as overflowed. Their visibility checks on rows that recent transactions wrote must then look up `pg_subtrans`. This can slow queries across the server. The driver `transaction` helpers have the same problem. The Sequel helper passes `savepoint: true` and the Active Record helper passes `requires_new: true`, so inside an application transaction each River write can open a savepoint. An insert opens two. Replace the scheduler's per-job savepoints with a port of Go's `JobSchedule`. On Postgres, one statement locks the due rows with `FOR UPDATE`, finds conflicts with `jobs_with_rownum` and `unique_conflicts`, and sets each row to `available` or `discarded`. The query is equivalent to Go's and locks rows the same way, so the scheduler now waits for a row that another transaction has locked instead of skipping it. On SQLite, the pass follows Go's SQLite driver and checks each unique job for a conflict before it writes. Neither path relies on a failed write to find a conflict, so neither needs a savepoint. The pass now also notifies a queue whose only changed job is discarded, as Go does. Make both helpers join an open transaction, as Go's `WithTxV` has done since #1420. Active Record's `transaction` swallows an `ActiveRecord::Rollback` raised in a joined block and rolls nothing back. The Active Record helper therefore yields directly, so the rollback reaches the transaction's owner. Inside an open transaction, a `rollback_exception` raised in `Driver#transaction` now reaches the caller's enclosing transaction block, as a rollback raised in the caller's own code would. The SQLite batch insert uses the same helper. `RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the savepoints, matching Go's temporary fallback of the same name. New specs run a scheduler pass with both kinds of conflict inside a caller's transaction. They assert the outcome that Go produces and that no savepoint opens. On Postgres, they also check that each changed row has the caller's transaction ID as its `xmin`. Specs that relied on River's savepoints now use one that the application owns.
The Ruby scheduler opens a savepoint for each due job, up to 1,000 per pass. It uses the savepoint to catch a unique key conflict, roll back that one row, and mark the row discarded. In Postgres, a savepoint gets its own transaction ID when it first writes or locks a row. Each backend caches at most 64 of these IDs. A released savepoint keeps its entry until the top-level transaction ends, so a pass that makes more than 64 jobs available overflows the cache. Until that transaction ends, snapshots that any session takes are marked as overflowed. Their visibility checks on rows that recent transactions wrote must then look up `pg_subtrans`. This can slow queries across the server. The driver `transaction` helpers have the same problem. The Sequel helper passes `savepoint: true` and the Active Record helper passes `requires_new: true`, so inside an application transaction each River write can open a savepoint. An insert opens two. Replace the scheduler's per-job savepoints with a port of Go's `JobSchedule`. On Postgres, one statement locks the due rows with `FOR UPDATE`, finds conflicts with `jobs_with_rownum` and `unique_conflicts`, and sets each row to `available` or `discarded`. The query is equivalent to Go's and locks rows the same way, so the scheduler now waits for a row that another transaction has locked instead of skipping it. On SQLite, the pass follows Go's SQLite driver and checks each unique job for a conflict before it writes. Neither path relies on a failed write to find a conflict, so neither needs a savepoint. The pass now also notifies a queue whose only changed job is discarded, as Go does. Make both helpers join an open transaction, as Go's `WithTxV` has done since #1420. Active Record's `transaction` swallows an `ActiveRecord::Rollback` raised in a joined block and rolls nothing back. The Active Record helper therefore yields directly, so the rollback reaches the transaction's owner. Inside an open transaction, a `rollback_exception` raised in `Driver#transaction` now reaches the caller's enclosing transaction block, as a rollback raised in the caller's own code would. The SQLite batch insert uses the same helper. `RIVER_USE_LEGACY_SUBTRANSACTIONS=1` (or `true`) restores the savepoints, matching Go's temporary fallback of the same name. New specs run a scheduler pass with both kinds of conflict inside a caller's transaction. They assert the outcome that Go produces and that no savepoint opens. On Postgres, they also check that each changed row has the caller's transaction ID as its `xmin`. Specs that relied on River's savepoints now use one that the application owns.
River currently opens subtransactions when already in a transaction for
most behavior. This isn't something that's useful in the majority of
situations, but may be very occasionally in cases where a piece of a
transaction fails, but the caller still wants to commit the whole
transaction anyway.
Unfortunately, subtransactions have a real performance impact in
Postgres [1], so it's not free to have them. One of our users confirms
to have observed this IRL.
This patch modifies River's transaction approaches so as to only open a
new one unless not already in one. So
Client.Insertstill opens atransaction, but
Client.InsertTxwon't open a new subtransaction ontop of the
txthat was already passed into it.Though not an API change, this is a behavioral change. I've been having
Codex try to give me the worst case scenario of what might break were we
to change this. So far it hasn't come up with anything convincing (in
every scenario the change improves correctness of the original state),
but it's still worth thinking about whether there might be other real
use cases where people would be broken by this.
For now I've added an env flag
RIVER_USE_LEGACY_SUBTRANSACTIONSthatcan be used to revert back to the previous behavior. The idea here is
that it'd give anyone broken by this a potential escape hatch, but it
would still let us roll forward with no subtransactions as the preferred
default. The env var would be removed in a future version.
[1] https://planetscale.com/blog/the-dangers-of-postgres-subtransactions