Skip to content

dialect: a transaction's end is its owner's -- BeginTx refuses a foreign one, and joined work that failed partway refuses the commit - #16

Merged
lesomnus merged 2 commits into
masterfrom
tx-refuse
Oct 21, 2026
Merged

lesomnus merged 2 commits into
masterfrom
tx-refuse

Conversation

@lesomnus

Copy link
Copy Markdown
Member

Two holes in how a transaction begun with dialect.BeginTx is ended, found in the review of lesomnus/payday#84's work (T5-4, T5-5).

1. BeginTx inside a transaction it did not begin

BeginTx nested only when KeepOf recognised the transaction drv runs in. Inside one it could not recognise — a generated client's Client.Tx, whose driver says it is a transaction (InTx) and keeps nothing — it began a second Keep over the no-op transaction that driver hands out. That keep answered to the wrong end:

  • its commit wrote what it kept into a transaction it did not end;
  • its rollback dropped what it kept, while the writes it was about stayed for whoever ended the transaction.

What a transaction keeps is what makes the rest of it acceptable. In lesomnus/payday that is the trail, so a write committed this way is a write the record does not have. Nothing reaches this today; every stack is rebound onto a BeginTx driver.

Now:

  • BeginTx refuses with ErrForeignTx and begins nothing when the driver is in a transaction (InTx) that keeps nothing (KeepOf). It looks through a DebugDriver, as both of those do.
  • What BeginTx hands its caller is a driver.Tx: Commit and Rollback, nothing else. It was a dialect.Tx, which can also send statements — straight to the connection, past the driver that asks the keep about each one (Keep.BeforeStatement). A transaction begun inside another hands back an end that does nothing, as before.

Callers that only Commit and Rollback what they are handed compile unchanged. In payday that is the generated batch, rove and roster. One payday test helper is typed dialect.Tx and changes with payday's bump.

2. Work that failed partway in a transaction it joined

A generated write joins a transaction it finds itself in rather than nesting one; there are no savepoints. Its Close does nothing there, since ending that transaction is not its job. So when the write failed after it had begun writing, what it wrote stayed in the transaction:

  • PostgreSQL takes the whole transaction with a failed statement.
  • SQLite undoes the statement alone.
  • A failure that was not a statement's — a recorder, a read after the write — undoes nothing on either engine.

A caller that went on and committed would commit part of a write it had been told had failed. The case that showed it is an AddMany whose second INSERT of a few hundred rows is refused.

Now:

  • Keep.Refuse(err) says a transaction may not commit. Its commit answers err and rolls back, and the kept work is not done. The first refusal is the one answered.
  • ent.Joined says it for the work:
    • When JoinTx joins a transaction BeginTx began, it holds that transaction's keep.
    • The work calls Writing() before its first statement that writes.
    • A Close with no Commit after that refuses the joined transaction's commit with ErrPartial.
  • What does not refuse:
    • Work that fails before it writes has nothing to take back, and refuses nothing.
    • Work that began its own transaction is taken back by its Close, as before.
    • A transaction that keeps nothing (a generated Client.Tx) is its owner's to answer for.
  • ent.Db asks for the client's Driver(), which is where the keep is found. A generated client has always had it.

protoc-gen-orm-ent will emit tx.Writing() in each generated write, in a PR that follows this one.

UPSTREAM.md's Transactions row says all of this.

Tests

Each test fails with its line taken out (mutations listed after each group).

  • dialect, on the doubles:

    • BeginTx on a foreign transaction is refused, as it is and under a DebugDriver, and begins nothing.
    • Neither end it hands back, begun or begun inside, can send a statement.
    • A refusal is the commit's, and the kept work is not done.
    • The first refusal is the one answered, and nil refuses nothing.
    • A refusal said while the kept work runs, or said inside a transaction begun inside, is the commit's.

    Mutations: no ErrForeignTx check; the nested end being the inner transaction; finish not reading the refusal; finish reading it only before the work.

  • root (Joined), on the doubles:

    • A joined write that began writing and failed refuses the commit (ErrPartial).
    • One that failed before writing does not, and neither does one that held.
    • One that wanted no transaction is told the same.
    • One in a transaction that keeps nothing tells nobody.
    • One that began its own transaction rolls back.

    Mutations: Close not refusing; Writing not marking; Commit not marking.

  • entc/integration/keep, on every engine: a refused transaction takes its writes back.

  • entc/integration/stduuid, on a generated client:

    • A joined write that failed partway leaves nothing committed.
    • BeginTx on a Client.Tx's driver is refused.
  • Locally:

    • go test ./ ./dialect/....
    • entc/integration keep (SQLite) and stduuid.
    • go build ./... in the root, examples and entc/integration.
    • .github/lint.sh on the changed files.

    CI runs the other engines.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk

lesomnus and others added 2 commits October 22, 2026 05:09
…, and hands back only the end of one

BeginTx nested only when KeepOf found the transaction drv runs in. Inside
one it could not see as its own -- a generated client's `Client.Tx`, whose
driver says it is a transaction and keeps nothing -- it began a second
keep over the no-op transaction that driver hands out. That keep answered
to the wrong end: its commit wrote what it kept into a transaction it did
not end, and its rollback dropped what it kept while the writes it was
about stayed, for whoever did end the transaction to commit without it.
What a transaction keeps is what makes the rest of it acceptable -- a
trail, in lesomnus/payday -- so a write committed that way is a write the
record does not have.

Now it refuses with ErrForeignTx when the driver is in a transaction
(InTx) that keeps nothing (KeepOf), through a DebugDriver as both of
those look. Nothing is begun.

And what it hands its caller is the end of the transaction and nothing
else: a driver.Tx, Commit and Rollback. It was a dialect.Tx, which can
also send statements -- straight to the connection, past the driver that
asks the keep about each one first (Keep.BeforeStatement), so a statement
sent that way did not find what the transaction kept. A transaction
begun inside another hands back an end that does nothing, as before.

Tested on the doubles: refused as it is and under a DebugDriver, with
nothing begun; neither end it hands back, begun or begun inside, sends a
statement. On a generated client in entc/integration/stduuid: BeginTx on
a `Client.Tx`'s driver is refused. Each fails with its check taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
…uses that transaction's commit

A generated write joins a transaction it finds itself in rather than
nesting one -- there are no savepoints -- and its Close does nothing
there, since ending the transaction is not its to do. So when it failed
after it had begun writing, whatever it had written stayed in the
transaction. PostgreSQL took the transaction with the failed statement;
SQLite undoes the statement alone, and a failure that was not a
statement's -- a recorder, a read after the write -- undoes nothing on
either engine. A caller that went on and committed committed part of a
write it had been told had failed. An AddMany of several statements,
whose second INSERT is refused, is where lesomnus/payday found it.

`Keep.Refuse(err)` says a transaction may not commit: its commit answers
err and rolls back, with the kept work not done; the first refusal is the
one answered.

`ent.Joined` says it for the work. JoinTx, joining a transaction BeginTx
began, holds that transaction's keep; the work says `Writing()` before
its first statement that writes, and a Close with no Commit after that
refuses the joined transaction's commit with ErrPartial. Work that fails
before it writes has nothing to take back and refuses nothing, work that
began its own transaction is taken back by its Close as before, and a
transaction that keeps nothing -- a generated Client.Tx -- is its owner's
to answer for. `ent.Db` asks for the client's Driver(), which is where
the keep is found; a generated client has always had it.

Tested on the doubles: a refusal is the commit's and the kept work is not
done, the first refusal is answered, nil refuses nothing, a refusal said
while the kept work runs or inside a transaction begun inside is the
commit's; a joined write that began writing and failed refuses, one that
failed before writing or that held does not, one that began its own
transaction rolls back, one in a transaction that keeps nothing tells
nobody. On every engine in entc/integration/keep, a refused transaction
takes its writes back; on a generated client in entc/integration/stduuid,
a joined write that failed partway leaves nothing committed. Each fails
with its line taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
@lesomnus
lesomnus merged commit b333b8f into master Oct 21, 2026
8 checks passed
@lesomnus
lesomnus deleted the tx-refuse branch October 21, 2026 20:50
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 21, 2026
…write that fails partway refuses the commit of the transaction it joined (#84)

The two halves of the review's T5-4 and T5-5, merged, and what payday's
apptest is regenerated with:

- protobuf-orm/ent#16 (b333b8f): `dialect.BeginTx` refuses to begin
  inside a transaction it did not begin (`dialect.ErrForeignTx`), hands
  back the end of a transaction and nothing that sends statements (a
  `driver.Tx`), and lets work that cannot take back what it did refuse
  the transaction's commit (`Keep.Refuse`); `ent.Joined` does that for a
  write that joined a kept transaction and failed after it began
  writing (`ent.ErrPartial`).
- protobuf-orm/protoc-gen-orm-ent#14 (f86fc4a): every generated write
  says `tx.Writing()` before its first statement that writes, and an
  AddMany records each statement's rows as soon as it holds.
- protobuf-orm/protoc-gen-orm-ent#13 comes with it: an AddMany's rule
  hands the request on whole.
- The fork's master since 5460d90 comes with it too: a mutation that
  assigns a SQL expression, setting a field after clearing it, generated
  files importing what they use, views removed.

So an AddMany in an app's operation or a batch whose second INSERT is
refused leaves nothing for that transaction to commit: its commit is
refused, and neither the five hundred rows the first INSERT wrote nor
what the trail kept of them holds. On SQLite, which undoes a refused
statement alone, they were committed with the transaction.

`cmd/access_test.go` holds what BeginTx hands back as a `driver.Tx`.

Tested: `TestAnAddManyThatFailsPartwayIsNotCommitted` -- an AddMany of
501 robots in a transaction begun with BeginTx, whose last is taken --
has its commit refused, with no robot and no line of the trail of it
held; it fails with `tx.Writing()` taken out of the generated AddMany.
`pd gen --check` is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 21, 2026
…write that fails partway refuses the commit of the transaction it joined (#84)

The two halves of the review's T5-4 and T5-5, merged, and what payday's
apptest is regenerated with:

- protobuf-orm/ent#16 (b333b8f): `dialect.BeginTx` refuses to begin
  inside a transaction it did not begin (`dialect.ErrForeignTx`), hands
  back the end of a transaction and nothing that sends statements (a
  `driver.Tx`), and lets work that cannot take back what it did refuse
  the transaction's commit (`Keep.Refuse`); `ent.Joined` does that for a
  write that joined a kept transaction and failed after it began
  writing (`ent.ErrPartial`).
- protobuf-orm/protoc-gen-orm-ent#14 (f86fc4a): every generated write
  says `tx.Writing()` before its first statement that writes, and an
  AddMany records each statement's rows as soon as it holds.
- protobuf-orm/protoc-gen-orm-ent#13 comes with it: an AddMany's rule
  hands the request on whole.
- The fork's master since 5460d90 comes with it too: a mutation that
  assigns a SQL expression, setting a field after clearing it, generated
  files importing what they use, views removed.

So an AddMany in an app's operation or a batch whose second INSERT is
refused leaves nothing for that transaction to commit: its commit is
refused, and neither the five hundred rows the first INSERT wrote nor
what the trail kept of them holds. On SQLite, which undoes a refused
statement alone, they were committed with the transaction.

`cmd/access_test.go` holds what BeginTx hands back as a `driver.Tx`.

Tested: `TestAnAddManyThatFailsPartwayIsNotCommitted` -- an AddMany of
501 robots in a transaction begun with BeginTx, whose last is taken --
has its commit refused, with no robot and no line of the trail of it
held; it fails with `tx.Writing()` taken out of the generated AddMany.
`pd gen --check` is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 21, 2026
…write that fails partway refuses the commit of the transaction it joined (#84)

The two halves of the review's T5-4 and T5-5, merged, and what payday's
apptest is regenerated with:

- protobuf-orm/ent#16 (b333b8f): `dialect.BeginTx` refuses to begin
  inside a transaction it did not begin (`dialect.ErrForeignTx`), hands
  back the end of a transaction and nothing that sends statements (a
  `driver.Tx`), and lets work that cannot take back what it did refuse
  the transaction's commit (`Keep.Refuse`); `ent.Joined` does that for a
  write that joined a kept transaction and failed after it began
  writing (`ent.ErrPartial`).
- protobuf-orm/protoc-gen-orm-ent#14 (f86fc4a): every generated write
  says `tx.Writing()` before its first statement that writes, and an
  AddMany records each statement's rows as soon as it holds.
- protobuf-orm/protoc-gen-orm-ent#13 comes with it: an AddMany's rule
  hands the request on whole.
- The fork's master since 5460d90 comes with it too: a mutation that
  assigns a SQL expression, setting a field after clearing it, generated
  files importing what they use, views removed.

So an AddMany in an app's operation or a batch whose second INSERT is
refused leaves nothing for that transaction to commit: its commit is
refused, and neither the five hundred rows the first INSERT wrote nor
what the trail kept of them holds. On SQLite, which undoes a refused
statement alone, they were committed with the transaction.

`cmd/access_test.go` holds what BeginTx hands back as a `driver.Tx`.

Tested: `TestAnAddManyThatFailsPartwayIsNotCommitted` -- an AddMany of
501 robots in a transaction begun with BeginTx, whose last is taken --
has its commit refused, with no robot and no line of the trail of it
held; it fails with `tx.Writing()` taken out of the generated AddMany.
`pd gen --check` is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 21, 2026
…write that fails partway refuses the commit of the transaction it joined (#84)

The two halves of the review's T5-4 and T5-5, merged, and what payday's
apptest is regenerated with:

- protobuf-orm/ent#16 (b333b8f): `dialect.BeginTx` refuses to begin
  inside a transaction it did not begin (`dialect.ErrForeignTx`), hands
  back the end of a transaction and nothing that sends statements (a
  `driver.Tx`), and lets work that cannot take back what it did refuse
  the transaction's commit (`Keep.Refuse`); `ent.Joined` does that for a
  write that joined a kept transaction and failed after it began
  writing (`ent.ErrPartial`).
- protobuf-orm/protoc-gen-orm-ent#14 (f86fc4a): every generated write
  says `tx.Writing()` before its first statement that writes, and an
  AddMany records each statement's rows as soon as it holds.
- protobuf-orm/protoc-gen-orm-ent#13 comes with it: an AddMany's rule
  hands the request on whole.
- The fork's master since 5460d90 comes with it too: a mutation that
  assigns a SQL expression, setting a field after clearing it, generated
  files importing what they use, views removed.

So an AddMany in an app's operation or a batch whose second INSERT is
refused leaves nothing for that transaction to commit: its commit is
refused, and neither the five hundred rows the first INSERT wrote nor
what the trail kept of them holds. On SQLite, which undoes a refused
statement alone, they were committed with the transaction.

`cmd/access_test.go` holds what BeginTx hands back as a `driver.Tx`.

Tested: `TestAnAddManyThatFailsPartwayIsNotCommitted` -- an AddMany of
501 robots in a transaction begun with BeginTx, whose last is taken --
has its commit refused, with no robot and no line of the trail of it
held; it fails with `tx.Writing()` taken out of the generated AddMany.
`pd gen --check` is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 21, 2026
…write that fails partway refuses the commit of the transaction it joined (#84)

The two halves of the review's T5-4 and T5-5, merged, and what payday's
apptest is regenerated with:

- protobuf-orm/ent#16 (b333b8f): `dialect.BeginTx` refuses to begin
  inside a transaction it did not begin (`dialect.ErrForeignTx`), hands
  back the end of a transaction and nothing that sends statements (a
  `driver.Tx`), and lets work that cannot take back what it did refuse
  the transaction's commit (`Keep.Refuse`); `ent.Joined` does that for a
  write that joined a kept transaction and failed after it began
  writing (`ent.ErrPartial`).
- protobuf-orm/protoc-gen-orm-ent#14 (f86fc4a): every generated write
  says `tx.Writing()` before its first statement that writes, and an
  AddMany records each statement's rows as soon as it holds.
- protobuf-orm/protoc-gen-orm-ent#13 comes with it: an AddMany's rule
  hands the request on whole.
- The fork's master since 5460d90 comes with it too: a mutation that
  assigns a SQL expression, setting a field after clearing it, generated
  files importing what they use, views removed.

So an AddMany in an app's operation or a batch whose second INSERT is
refused leaves nothing for that transaction to commit: its commit is
refused, and neither the five hundred rows the first INSERT wrote nor
what the trail kept of them holds. On SQLite, which undoes a refused
statement alone, they were committed with the transaction.

`cmd/access_test.go` holds what BeginTx hands back as a `driver.Tx`.

Tested: `TestAnAddManyThatFailsPartwayIsNotCommitted` -- an AddMany of
501 robots in a transaction begun with BeginTx, whose last is taken --
has its commit refused, with no robot and no line of the trail of it
held; it fails with `tx.Writing()` taken out of the generated AddMany.
`pd gen --check` is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 21, 2026
go.mod: protobuf-orm/ent#16 and protoc-gen-orm-ent#14 -- a write that fails partway refuses the commit of the transaction it joined (#84)
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.

1 participant