Following on from #46, which addressed the main visibility concern for queries outside explicit transactions — original PG cause now reaches both the exception and Log.error for the documented call shape (verified on 3.13.8 against PG 18). The cascade "current transaction is aborted, commands ignored until end of transaction block" no longer surfaces when calling Model.where(...) or Database.execute(...) outside start_transaction().
Three paths in the same defect class still reproduce on 3.13.8.
Gap 1 — fetch() inside db.start_transaction()
db.start_transaction()
GiftCard.where("created_by_email = ? AND is_deleted = 0", [email])
Surfaces InFailedSqlTransaction: current transaction is aborted, commands ignored until end of transaction block. The original UndefinedFunction: operator does not exist: boolean = integer is lost.
_heal_aborted_txn() (postgres.py:79) and _on_query_error()'s auto-rollback (postgres.py:162) both defer when self._in_transaction is True. Stated reasoning at postgres.py:127-130 — "Inside an explicit transaction we do NOT auto-rollback — the user owns that transaction." — preserves the SAVEPOINT/retry contract, but the visibility half goes with it.
Gap 2 — execute() inside db.start_transaction() (silent failure)
db.start_transaction()
db.execute("UPDATE gift_cards SET is_deleted = 0 WHERE id = 1") # returns False, last_error = original cause
db.execute("UPDATE gift_cards SET created_by_email = 'x' WHERE id = 1") # returns False, last_error = cascade
Database.execute() (connection.py:412-414) catches every exception and returns False. Inside a transaction, the second call on a poisoned connection also returns False — caller has no exception to catch and no way to distinguish a real failure from a cascade without parsing db.last_error, which by the second call has been overwritten by the cascade message.
Gap 3 — db.last_error asymmetric between fetch() and execute()
| Surface |
After fetch() failure |
After execute() failure |
db.get_error() |
None |
original cause |
adapter.last_error |
original cause |
original cause |
Database.fetch() (connection.py:438-439) routes straight to adapter.fetch() with no last_error capture. Database.execute() (connection.py:412-414) does capture. The adapter's _on_query_error (postgres.py:154) writes self.last_error = str(exc) either way — the Database wrapper just doesn't read it on the fetch() path.
Potential fix shapes
- Gaps 1 + 2 —
Log.warning from _on_query_error even when inside an explicit transaction, so the original cause is at least in the log when auto-rollback is skipped. No behavioural change for callers running SAVEPOINT/retry patterns; closes the visibility half.
- Gap 3 — one-line capture in
Database.fetch() mirroring Database.execute(). fetch() still re-raises; only db.get_error() populates.
Following on from #46, which addressed the main visibility concern for queries outside explicit transactions — original PG cause now reaches both the exception and
Log.errorfor the documented call shape (verified on 3.13.8 against PG 18). The cascade"current transaction is aborted, commands ignored until end of transaction block"no longer surfaces when callingModel.where(...)orDatabase.execute(...)outsidestart_transaction().Three paths in the same defect class still reproduce on 3.13.8.
Gap 1 —
fetch()insidedb.start_transaction()Surfaces
InFailedSqlTransaction: current transaction is aborted, commands ignored until end of transaction block. The originalUndefinedFunction: operator does not exist: boolean = integeris lost._heal_aborted_txn()(postgres.py:79) and_on_query_error()'s auto-rollback (postgres.py:162) both defer whenself._in_transactionis True. Stated reasoning atpostgres.py:127-130— "Inside an explicit transaction we do NOT auto-rollback — the user owns that transaction." — preserves the SAVEPOINT/retry contract, but the visibility half goes with it.Gap 2 —
execute()insidedb.start_transaction()(silent failure)Database.execute()(connection.py:412-414) catches every exception and returnsFalse. Inside a transaction, the second call on a poisoned connection also returnsFalse— caller has no exception to catch and no way to distinguish a real failure from a cascade without parsingdb.last_error, which by the second call has been overwritten by the cascade message.Gap 3 —
db.last_errorasymmetric betweenfetch()andexecute()fetch()failureexecute()failuredb.get_error()Noneadapter.last_errorDatabase.fetch()(connection.py:438-439) routes straight toadapter.fetch()with nolast_errorcapture.Database.execute()(connection.py:412-414) does capture. The adapter's_on_query_error(postgres.py:154) writesself.last_error = str(exc)either way — the Database wrapper just doesn't read it on thefetch()path.Potential fix shapes
Log.warningfrom_on_query_erroreven when inside an explicit transaction, so the original cause is at least in the log when auto-rollback is skipped. No behavioural change for callers running SAVEPOINT/retry patterns; closes the visibility half.Database.fetch()mirroringDatabase.execute().fetch()still re-raises; onlydb.get_error()populates.