Skip to content

server: a write that fails partway in a transaction it joined refuses that one's commit, and AddMany records each statement's rows as it holds - #14

Merged
lesomnus merged 2 commits into
mainfrom
joined-partial
Oct 21, 2026
Merged

lesomnus merged 2 commits into
mainfrom
joined-partial

Conversation

@lesomnus

@lesomnus lesomnus commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

From the review of lesomnus/payday#84's work, item T5-4.

Builds on protobuf-orm/ent#16, which is merged: internal/apptest pins its merge commit, b333b8f.

What was wrong

A generated write inside a transaction somebody else began joins it, and its Close does nothing there. There are no savepoints, and ending that transaction is not the write's job. So a write that failed after it had begun writing left what it wrote in the transaction:

  • the row of an Add whose record was refused;
  • the UPDATE of an Apply whose read after it failed;
  • the first few hundred rows of an AddMany whose next INSERT was refused.

PostgreSQL takes the transaction with a refused statement. SQLite undoes the statement alone, and a failure that is not a statement's undoes nothing on either engine. A caller that went on and committed committed part of a write it had been told had failed.

AddMany also wrote every statement first and recorded after. The rows of a statement that held sat in the transaction with nothing recorded of them until the last statement held; when a later statement was refused, that never happened. payday's trail is such a recorder, so those rows were written past the trail.

What changes

  • Every write says tx.Writing() before its first statement that writes (ent.Joined.Writing, dialect: a transaction's end is its owner's -- BeginTx refuses a foreign one, and joined work that failed partway refuses the commit ent#16):

    • Add, before its INSERT;
    • AddMany, before its first INSERT;
    • Apply, before its UPDATE, when the document writes;
    • Erase, before its DELETE or its stamp.

    A Close with no Commit after that refuses the commit of the transaction the write joined (ent.ErrPartial), when that transaction was begun with dialect.BeginTx.

  • What does not refuse:

    • A write that fails before it writes refuses nothing.
    • A write 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.
  • Writing() goes before the statement, not after it. A statement refused on its own is then answered the same way on SQLite as PostgreSQL answers it.

  • AddMany records the rows of each statement as soon as it holds, before the next statement is sent. Nothing is in the transaction that is not recorded, and what a failure leaves behind is refused its commit.

  • Docs: the README and the AddMany doc say so.

Tests

internal/apptest/server/bare/x-partial_test.go runs each case in a transaction begun with BeginTx (SQLite, where a refused statement does not end the transaction):

  • An AddMany of 501 whose second statement is refused has recorded the 500 rows of the first, its commit is refused, and nothing holds.

  • The commit is also refused after:

    • an Add whose record is refused;
    • an Add the database refuses (a duplicate alias);
    • an Apply whose record is refused;
    • an Erase whose record is refused.

    Nothing of any of them holds.

  • A write refused before it wrote (an AddMany item that cannot be a row) refuses nothing.

  • A write that held refuses nothing either.

Mutations, each made in the generated user.g.go and each caught:

  • Writing taken out of AddMany, of Add (both Add cases fail), of apply, and of Erase;
  • the record moved back to after every statement rather than after each (the "500 recorded" check fails).

Locally: buf generate twice and go generate ./... in internal/apptest, with nothing moved; go test ./... in the root, runtime and internal/apptest, all passing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk

… that one's commit, and AddMany records each statement's rows as it holds

A generated write inside a transaction somebody else began joins it, and
its Close does nothing there: there are no savepoints, and ending that
transaction is not the write's to do. So a write that failed after it had
begun writing left what it wrote in the transaction -- the row of an Add
whose record was refused, the UPDATE of an Apply whose read after it
failed, the first few hundred rows of an AddMany whose next INSERT was
refused. PostgreSQL takes the transaction with a refused statement, but
SQLite undoes the statement alone, and a failure that is not a
statement's undoes nothing on either. A caller that went on and committed
committed part of a write it had been told had failed.

And AddMany wrote every statement first and recorded after, so the rows
of a statement that held sat in the transaction with nothing recorded of
them until the last statement did -- which, when a later one was refused,
was never.

Every write now says `tx.Writing()` before its first statement that
writes (protobuf-orm/ent#16): Add before its INSERT, AddMany before its
first, Apply before its UPDATE when the document writes, Erase before its
DELETE or its stamp. A Close with no Commit after that refuses the commit
of the transaction it joined (`ent.ErrPartial`), when that one was begun
with `dialect.BeginTx`. Said before the statement rather than after, so
that a statement refused on its own is answered on SQLite as PostgreSQL
answers it. A write that fails before it writes refuses nothing, and one
that began its own transaction is taken back by its Close as before.

AddMany records the rows of each statement as soon as it holds, before
the next is sent: nothing is in the transaction that is not recorded, and
what a failure leaves is refused its commit.

`internal/apptest` pins protobuf-orm/ent at #16's head.

Tested in `x-partial_test.go`, in a transaction begun with BeginTx: an
AddMany whose second statement is refused has recorded the 500 rows of
the first, and its commit is refused; so is the commit after an Add whose
record is refused, an Add the database refuses, an Apply and an Erase
whose record is refused -- and nothing of any of them holds. A write
refused before it wrote, and one that held, refuse nothing. Each fails
with its line taken out of the generated server: Writing in AddMany, Add,
apply and Erase, and the record after every statement rather than after
each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
The pin was #16's head, d22eff2; this is the merge commit, the same tree.
Generated code does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
@lesomnus
lesomnus merged commit f86fc4a into main Oct 21, 2026
2 checks passed
@lesomnus
lesomnus deleted the joined-partial branch October 21, 2026 20:55
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
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