Unresolved behavior of SQLAlchemy merge during retry scenarios
0 reputation · 05 Jul 2022, 17:44 UTC
0 reputation · 05 Jul 2022, 17:44 UTC
Implement a retry mechanism for write operations that avoids duplicate inserts or lost updates when a transaction is retried after a transient failure.
Using SQLAlchemy’s merge method to perform idempotent upserts, the session may automatically issue a flush that re‑executes an INSERT if the instance is detached between retries. Concurrent sessions can modify the same row, potentially causing a duplicate key error or a lost update. The autocommit execution option and with_for_update lock mode are not uniformly supported across dialects, and their interaction with retries is not fully documented.
merge, autocommit execution options, or async async_session.begin()—provides the most reliable path to retry without duplicating writes?merge is used concurrently, especially regarding lost updates or duplicate key exceptions?autocommit and with_for_update be leveraged (or avoided) to mitigate race conditions during retries?35275 reputation · 05 Jul 2022, 19:56 UTC
The most reliable way to retry write operations without duplicating inserts or losing updates is to use a fresh transaction context—async_session.begin() (or a new synchronous Session) for each retry attempt—combined with explicit clearing of the identity map (expunge or expire) before calling merge. merge alone does not guarantee idempotent behavior across retries, and the autocommit execution option is not uniformly supported across dialects, so it should not be relied upon for retry safety.
merge() returns a persistent instance that is either loaded from the database or created and then has its state copied onto an existing instance present in the Session's identity map.merge may encounter a stale instance in the identity map, treat the object as already persistent, and skip the INSERT, which can result in no new row or an IntegrityError on commit.Session.expunge(instance) or Session.expire([instance]) (or beginning a sub‑transaction with begin_nested) removes the stale state so that merge behaves as if the object were new.When a transient failure causes a flush to fail and the transaction is rolled back, the instance may retain a temporary primary‑key value (e.g., None or a negative placeholder). If the same Session is reused and merge is called again without clearing the identity map, SQLAlchemy may see the instance as already associated with the Session and attempt to re‑apply the same INSERT, leading to a duplicate‑key error or a lost update when another session has modified the row in the meantime.
async with async_session.begin() as session:with Session() as session: (or manually call session.begin()).merge:
session.expunge_all() # or session.expunge(instance)
# or
session.expire([instance])
merge on the instance you wish to upsert.session.query(MyModel).filter_by(id=instance.id).with_for_update().one()
Note: verify that your target dialect (e.g., PostgreSQL, MySQL) supports FOR UPDATE in the desired isolation level.To decide whether to rely on with_for_update or to fall back to database‑level upsert (ON CONFLICT / MERGE), please confirm which SQL dialect you are using (e.g., PostgreSQL, MySQL, SQLite) and its version, as lock behavior and native upsert support vary.
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 05 Jul 2022, 20:57 UTC
merge() is not an atomic database UPSERT. Because it performs a SELECT followed by an INSERT or UPDATE, it remains vulnerable to race conditions where two concurrent sessions might both find a record missing and both attempt to insert it, leading to an IntegrityError.
For true idempotency during retries, consider using dialect-specific upsert constructs—such as ON CONFLICT in PostgreSQL or ON DUPLICATE KEY UPDATE in MySQL—via the insert() function. This moves the atomicity to the database engine, avoiding the check-then-act window inherent in the standard merge() workflow.