Defer irreversible cleanup until the removal commits - #1572
Open
GeiserX wants to merge 3 commits into
Open
Conversation
This was referenced Aug 13, 2026
Author
|
Context for this one: #1585 explains why this PR and the others in the series exist — they came out of a single pass over credential handling, asking for each credential where it ends up, how long it stays, and who can read it once it's there. This PR stands alone and doesn't depend on any of the others. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Deleting a client, or a plugin cleaning up after a removed connection, can reach outside the database — the credential provider, the OAuth provider's API, a remote object. None of that enlists in the transaction doing the removal, and none of it rolls back with it.
So when the transaction aborts, the database is restored and the outside world is already changed.
oauth.removeClienthad exactly that shape: the client row came back and its secret stayed destroyed — a client that looks configured and can never authenticate again.This defers such work until the removal is durable, and gives plugins the same tool, because they were exposed to the identical trap with no way out of it.
The defect
removeClientdeletes theoauth_clientrow, then deletes the client secret from the credential provider. It opens no transaction of its own, but a caller can wrap it in one. If that transaction aborts afterwards, the row deletion is undone and the secret deletion is not.Orphaning a secret is recoverable — it is an unreferenced item. Deleting one that is still referenced is not. The deletion now waits until the removal is durable and is discarded if the removal rolls back. With no transaction active it runs immediately, exactly as today.
The same trap, reachable by plugins
removeConnectionandremoveIntegrationrun inside core's removal transaction. That is deliberate and right for database work — a plugin's own rows should die atomically with the connection. It makes them exactly the wrong place for anything else, andremoveConnectionis precisely where a plugin would revoke the token at the provider's API.Two things made this a trap rather than a choice:
PluginCtxexposedtransactionbut nothing to defer past it. An author who saw the problem had no construct to fix it.removeConnection's entire contract was "Plugin-side cleanup when a connection is removed."PluginCtxnow hasafterCommit: runs the effect once the outermost transaction commits, discards it if that transaction rolls back, runs it immediately when none is active. Both hooks now document that they run inside core's transaction and that outside-world work belongs inafterCommit.The part that is easy to get wrong
Sequencing work after your own
transaction(...)call is not the same thing, and the documentation says so explicitly.transactionnests by pass-through — inside an active transaction the inner call just runs its effect — so "after the inner transaction" is still before any commit.I mention it because I wrote that version of the fix first and it passed a typecheck, a lint and the existing suite. Only a test that drove the nested case caught it.
Tests
Both directions of the contract, deliberately:
Each is mutation-checked, and each mutation kills exactly one of them. Running
afterCommitinline kills the rollback test and correctly leaves the durable-path test passing; a mutation that killed both would have meant the tests were not distinguishing what they claim.Scope
Independent of #1564. It shares a theme with #1568 — which fixes the same class for
connections.remove— but touches different call sites, so the two can merge in either order. Both do modifyexecutor.ts, in different functions, so expect a textual conflict rather than a logical one.