Model.save() returns normally and writes nothing after the instance's INSERT was rolled back
メンテナーはふだん 2 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 40/100
調査の方向性
The bug is that Model.save() leaves pk and _saved_in_db = True set even when the surrounding transaction (or savepoint) rolls back, so a retry issues a silent UPDATE. Start by reading the save path in tortoise/models.py and how in_transaction() in tortoise/transactions.py propagates rollback to instances saved inside it, then check whether the transaction context can notify or reset the saved state. The reporter's tortoise_save_after_rollback.py script is a ready-made reproduction; run its retry and savepoint arms before and after a fix, and add regression tests covering both, plus that force_update already raises. Done means the second save() either inserts or raises instead of committing zero rows.
索引モデルが issue の本文から書いたものです。
説明
Describe the bug
When save() inserts a new instance inside in_transaction() and the block then raises, the transaction rolls back, but the instance keeps its pk and _saved_in_db = True. If the application saves the same instance again, for example on a retry, Tortoise sends an UPDATE for the old pk (UPDATE "item" SET "arm"=$1,"value"=$2 WHERE "id"=$3 in the script below). That matches no row, save() returns normally, the transaction commits, and nothing is written. Changing a field before the retry makes no difference.
The same happens with a savepoint: a nested in_transaction() saves the instance and raises, and the outer block catches the error, saves the instance again and commits without the row.
To Reproduce
The script below runs these arms, each with a new instance, and reads the outcome from a separate connection:
| Arm | Second call | Transaction | Rows |
|---|---|---|---|
retry (the first case above) |
returned normally | committed | 0 |
retry-changed (a field changed before the retry) |
returned normally | committed | 0 |
savepoint (the second case above) |
returned normally | committed | 0 |
savepoint-changed (a field changed before the retry) |
returned normally | committed | 0 |
control (a new instance, saved once) |
returned normally | committed | 1 |
force_update (as retry, with save(force_update=True)) |
raised IntegrityError |
rolled back | 0 |
Run it against a disposable server. DATABASE_URL must be able to create databases: the program first drops any database named tortoise_save_after_rollback, creates it, and drops it at the end. Install tortoise-orm[asyncpg,psycopg]==1.1.9: both commands also use asyncpg, for the setup and witness connections.
DATABASE_URL=postgresql://[email protected]:5432/postgres python tortoise_save_after_rollback.py asyncpg
DATABASE_URL=postgresql://[email protected]:5432/postgres python tortoise_save_after_rollback.py psycopg
tortoise_save_after_rollback.py
"""Tortoise ORM: save() after a rolled-back transaction returns normally and writes nothing.
DATABASE_URL=postgresql://[email protected]:5432/postgres \
python tortoise_save_after_rollback.py [asyncpg|psycopg]
DATABASE_URL must be able to create a database. The program first drops any database
named tortoise_save_after_rollback, creates it, and drops it at the end. Tortoise uses
the backend named on the command line. A separate asyncpg connection, in autocommit,
reads what PostgreSQL did.
In each arm, save() runs inside in_transaction(), or inside a nested in_transaction()
(a savepoint), and the block then raises, so the INSERT is rolled back. The same
instance is then saved again. The witness reads pg_xact_status() of the transaction IDs
captured inside the transactions, and counts the arm's rows.
"""
import asyncio
import os
import platform
import sys
from urllib.parse import urlsplit, urlunsplit
import asyncpg
import tortoise
from tortoise import Tortoise, fields
from tortoise.exceptions import IntegrityError
from tortoise.models import Model
from tortoise.transactions import in_transaction
URL = os.environ["DATABASE_URL"]
NAME = "tortoise_save_after_rollback"
class Item(Model):
id = fields.IntField(primary_key=True)
arm = fields.TextField()
value = fields.IntField()
class Meta:
table = "item"
class AppError(Exception):
pass
async def current_xid(conn):
rows = await conn.execute_query_dict("select pg_current_xact_id()::text as xid")
return rows[0]["xid"]
async def fate(witness, xid):
status = await witness.fetchval("select pg_xact_status($1::text::xid8)", xid)
return f"pg_xact_status({xid}) {status}"
async def rows(witness, arm):
count = await witness.fetchval("select count(*) from item where arm = $1", arm)
return f"rows with arm={arm!r}: {count}"
async def failed_attempt(witness, obj):
try:
async with in_transaction() as conn:
await obj.save()
xid = await current_xid(conn)
raise AppError("the application fails after save()")
except AppError:
pass
print(f" first save() returned, pk={obj.pk}; the block raised AppError")
print(f" first transaction: {await fate(witness, xid)}")
print(f" after the rollback: pk={obj.pk}, _saved_in_db={obj._saved_in_db}, {await rows(witness, obj.arm)}")
async def retry(witness, change=False):
arm = "retry-changed" if change else "retry"
print(f"{arm}: save() in in_transaction(), the block raises; then"
+ (" value = 2 and" if change else "") + " save() again in a new in_transaction()")
obj = Item(arm=arm, value=1)
await failed_attempt(witness, obj)
if change:
obj.value = 2
async with in_transaction() as conn:
await obj.save()
xid = await current_xid(conn)
print(" second save() returned normally")
print(f" second transaction: {await fate(witness, xid)}")
print(f" {await rows(witness, arm)}")
async def savepoint(witness, change=False):
arm = "savepoint-changed" if change else "savepoint"
print(f"{arm}: in an outer in_transaction(), save() in a nested in_transaction() that raises;"
" the error is caught; then" + (" value = 2 and" if change else "") + " save() again in the outer block")
obj = Item(arm=arm, value=1)
async with in_transaction() as conn:
try:
async with in_transaction():
await obj.save()
raise AppError("the application fails after save()")
except AppError:
pass
print(f" first save() returned, pk={obj.pk}; the nested block raised AppError")
print(f" after the savepoint rollback: pk={obj.pk}, _saved_in_db={obj._saved_in_db}")
if change:
obj.value = 2
await obj.save()
xid = await current_xid(conn)
print(" second save() returned normally")
print(" the outer block exited normally")
print(f" outer transaction: {await fate(witness, xid)}")
print(f" {await rows(witness, arm)}")
async def control(witness):
print("control: save() of a new instance in in_transaction()")
obj = Item(arm="control", value=1)
async with in_transaction() as conn:
await obj.save()
xid = await current_xid(conn)
print(f" save() returned, pk={obj.pk}")
print(f" transaction: {await fate(witness, xid)}")
print(f" {await rows(witness, 'control')}")
async def force_update(witness):
print("force_update: as retry, but the second call is save(force_update=True)")
obj = Item(arm="force_update", value=1)
await failed_attempt(witness, obj)
try:
async with in_transaction() as conn:
xid = await current_xid(conn)
await obj.save(force_update=True)
print(" second save(force_update=True) returned normally")
except IntegrityError as exc:
print(f" second save(force_update=True) raised IntegrityError: {exc}")
print(f" second transaction: {await fate(witness, xid)}")
print(f" {await rows(witness, 'force_update')}")
async def run(backend):
if backend == "psycopg":
import psycopg
import psycopg_pool
driver = f"psycopg {psycopg.__version__}, psycopg_pool {psycopg_pool.__version__}"
else:
driver = f"asyncpg {asyncpg.__version__}"
print(
f"python {platform.python_version()}, tortoise-orm {tortoise.__version__},"
f" backend {backend} ({driver}), {platform.system()} {platform.machine()}"
)
admin = await asyncpg.connect(URL, server_settings={"application_name": "repro-admin"})
print(f"server_version {await admin.fetchval('show server_version')}")
await admin.execute(f"drop database if exists {NAME} with (force)")
await admin.execute(f"create database {NAME}")
url = urlsplit(URL)
scheme = "postgres" if backend == "asyncpg" else "psycopg"
db_url = urlunsplit((scheme, url.netloc, f"/{NAME}", "application_name=repro-tortoise", ""))
await Tortoise.init(db_url=db_url, modules={"models": ["__main__"]})
await Tortoise.generate_schemas()
witness = await asyncpg.connect(
URL, database=NAME, server_settings={"application_name": "repro-witness"}
)
await retry(witness)
await retry(witness, change=True)
await savepoint(witness)
await savepoint(witness, change=True)
await control(witness)
await force_update(witness)
await witness.close()
await Tortoise.close_connections()
await admin.execute(f"drop database {NAME} with (force)")
await admin.close()
def main():
backend = sys.argv[1] if len(sys.argv) > 1 else "asyncpg"
if backend not in ("asyncpg", "psycopg"):
sys.exit("usage: tortoise_save_after_rollback.py [asyncpg|psycopg]")
asyncio.run(run(backend))
if __name__ == "__main__":
main()
Output with the asyncpg backend (the psycopg runs differ only in the version line and the transaction IDs)
python 3.13.11, tortoise-orm 1.1.9, backend asyncpg (asyncpg 0.32.0), Darwin arm64
server_version 18.6 (Debian 18.6-1.pgdg13+2)
retry: save() in in_transaction(), the block raises; then save() again in a new in_transaction()
first save() returned, pk=1; the block raised AppError
first transaction: pg_xact_status(755) aborted
after the rollback: pk=1, _saved_in_db=True, rows with arm='retry': 0
second save() returned normally
second transaction: pg_xact_status(756) committed
rows with arm='retry': 0
retry-changed: save() in in_transaction(), the block raises; then value = 2 and save() again in a new in_transaction()
first save() returned, pk=2; the block raised AppError
first transaction: pg_xact_status(757) aborted
after the rollback: pk=2, _saved_in_db=True, rows with arm='retry-changed': 0
second save() returned normally
second transaction: pg_xact_status(758) committed
rows with arm='retry-changed': 0
savepoint: in an outer in_transaction(), save() in a nested in_transaction() that raises; the error is caught; then save() again in the outer block
first save() returned, pk=3; the nested block raised AppError
after the savepoint rollback: pk=3, _saved_in_db=True
second save() returned normally
the outer block exited normally
outer transaction: pg_xact_status(759) committed
rows with arm='savepoint': 0
savepoint-changed: in an outer in_transaction(), save() in a nested in_transaction() that raises; the error is caught; then value = 2 and save() again in the outer block
first save() returned, pk=4; the nested block raised AppError
after the savepoint rollback: pk=4, _saved_in_db=True
second save() returned normally
the outer block exited normally
outer transaction: pg_xact_status(761) committed
rows with arm='savepoint-changed': 0
control: save() of a new instance in in_transaction()
save() returned, pk=5
transaction: pg_xact_status(763) committed
rows with arm='control': 1
force_update: as retry, but the second call is save(force_update=True)
first save() returned, pk=6; the block raised AppError
first transaction: pg_xact_status(764) aborted
after the rollback: pk=6, _saved_in_db=True, rows with arm='force_update': 0
second save(force_update=True) raised IntegrityError: Can't update object that doesn't exist. PK: 6
second transaction: pg_xact_status(765) aborted
rows with arm='force_update': 0
Server log for the retry arm (log_statement=all, and auto_explain with log_analyze)
2026-10-25 01:20:48.245 UTC [107] app=repro-tortoise xid=0 LOG: statement: BEGIN;
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=0 LOG: execute __asyncpg_stmt_2__: INSERT INTO "item" ("arm","value") VALUES ($1,$2) RETURNING "id"
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=0 DETAIL: Parameters: $1 = 'retry', $2 = '1'
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=755 LOG: duration: 0.100 ms plan:
Query Text: INSERT INTO "item" ("arm","value") VALUES ($1,$2) RETURNING "id"
Query Parameters: $1 = 'retry', $2 = '1'
Insert on item (cost=0.00..0.01 rows=1 width=40) (actual rows=1.00 loops=1)
-> Result (cost=0.00..0.01 rows=1 width=40) (actual rows=1.00 loops=1)
2026-10-25 01:20:48.247 UTC [107] app=repro-tortoise xid=755 LOG: execute __asyncpg_stmt_3__: select pg_current_xact_id()::text as xid
2026-10-25 01:20:48.248 UTC [107] app=repro-tortoise xid=755 LOG: statement: ROLLBACK;
…
2026-10-25 01:20:48.250 UTC [107] app=repro-tortoise xid=0 LOG: statement: BEGIN;
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 LOG: execute __asyncpg_stmt_6__: UPDATE "item" SET "arm"=$1,"value"=$2 WHERE "id"=$3
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 DETAIL: Parameters: $1 = 'retry', $2 = '1', $3 = '1'
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 LOG: duration: 0.003 ms plan:
Query Text: UPDATE "item" SET "arm"=$1,"value"=$2 WHERE "id"=$3
Query Parameters: $1 = 'retry', $2 = '1', $3 = '1'
Update on item (cost=0.15..8.17 rows=0 width=0) (actual rows=0.00 loops=1)
-> Index Scan using item_pkey on item (cost=0.15..8.17 rows=1 width=42) (actual rows=0.00 loops=1)
Index Cond: (id = 1)
Index Searches: 1
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=0 LOG: execute __asyncpg_stmt_3__: select pg_current_xact_id()::text as xid
2026-10-25 01:20:48.251 UTC [107] app=repro-tortoise xid=756 LOG: statement: COMMIT;
Expected behavior
The second save() should either write the row or raise. It should not return normally after writing nothing. save(force_update=True) already raises in this situation (the force_update arm).
Additional context
- Tortoise ORM 1.1.9 with asyncpg 0.32.0, and with psycopg 3.3.6 (psycopg_pool 3.3.3), on PostgreSQL 18.6; Python 3.13.11 on macOS arm64. Three runs per backend gave the same result. I ran only the two PostgreSQL backends. I didn't run
develop, butModel.save()is unchanged there. - From the source:
save()takes the update path because_saved_in_dbis set, callsexecute_update(), and ignores the count it returns (models.py#L1186-L1192). It sets_saved_in_db = Trueafter the statement (models.py#L1198), and I found nothing in Tortoise that resets it when the transaction or savepoint rolls back. Withforce_update=Truethe same count raises (models.py#L1180-L1183). In the runs,_saved_in_dbwas stillTrueafter every rollback; withexecute_update()wrapped, it returned 0 for every retry, andauto_explainshowed each retry's UPDATE matching 0 rows. - Django 6.1.2 persisted all four retries on the same server. So did SQLAlchemy 2.1.5, where the rollback makes the object transient and the retry adds it to a session again.
Which direction would you prefer? Or is saving an instance again after a rollback not meant to be supported?
- Reset
_saved_in_dbfor the instances inserted in a transaction or savepoint when that scope rolls back. The savepoint arms show that this has to work per savepoint level, since the outer transaction still commits. Open PR #2293 proposes the same kind of per-scope tracking foron_commit()callbacks. - When the UPDATE in the default path matches no row, raise as
force_update=Truedoes, or fall back to an INSERT as Django does. Two cautions:execute_update()also returns 0 without sending anything when no field needs updating (executor.py#L489-L490), and #1212 (2022, on 0.18.1 and 0.19.2) reported a count of 0 on MySQL for a row that exists, so the count may not be reliable on every backend.
If one of these works for you, I'm happy to prepare a PR with regression tests for the plain and savepoint cases.
- 主要言語
- Python
- スター
- 5.6k
- フォーク
- 540
- 平均マージ
- 4日 10時間
- マージ済み PR(30日)
- 31
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
tortoise/tortoise-orm のほかの issue
-
URLValidator ignores an explicit empty scheme allowlist対応中かも @IlyaKhalafi が 2 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
tortoise/tortoise-orm#2393 ·
メンテナーはふだん 2 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
tortoise/tortoise-orm#668 ·
メンテナーはふだん 2 日以内に返信
-
in_transaction() leaks a pool connection when begin() is cancelled対応中かも @inchang-ing が 2 日前に担当しました。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 45/100
tortoise/tortoise-orm#2391 ·
メンテナーはふだん 2 日以内に返信
-
Prefetch with to_attr leaves the attribute missing when all forward relations are null対応中かも @IlyaKhalafi が 10 日前に担当しました。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 35/100
tortoise/tortoise-orm#2374 ·
メンテナーはふだん 2 日以内に返信
-
Adding a custom Prefetch to a cloned queryset mutates the original query対応中かも @IlyaKhalafi が 10 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 35/100
tortoise/tortoise-orm#2372 ·
メンテナーはふだん 2 日以内に返信
tortoise/tortoise-orm の issue をすべて見る
似ている issue
-
DBFS append mode silently overwrites existing files対応中かも @IlyaKhalafi が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 77/100
fsspec/filesystem_spec#2429 ·
メンテナーはふだん 1 日以内に返信
-
[Bug]: Retrieval rerank fallback statistics stay zero after provider failure対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープンbug
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
volcengine/OpenViking#7014 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
fedora-copr/copr#4600 ·
メンテナーはふだん 2 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
ansys/pydpf-core#3620 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信