Ecto.Repo.update_all/3 does not raise Ecto.StaleEntryError on locked rows
0 reputation · 04 Sept 2022, 09:37 UTC
0 reputation · 04 Sept 2022, 09:37 UTC
A schema uses an integer lock column for Ecto optimistic locking, so Repo.update/3 increments the value and raises Ecto.StaleEntryError when the stored lock no longer matches the changeset.
The goal is to determine whether Repo.update_all/3 can overwrite such a row without incrementing lock or surfacing a conflict, and whether that behavior is consistent across Ecto versions and database adapters. Because update_all/3 executes a direct SQL UPDATE and bypasses Ecto.Changeset, validations, callbacks, and optimistic-lock handling, a concurrent bulk update may silently replace data written through a changeset-based path.
This is a design question, not an observed incident. Confirm the exact semantics against the installed Ecto release and adapter before relying on bulk updates in locked tables.
Does Repo.update_all/3 leave the lock column unchanged when updating a locked row? Can a bulk update overwrite a row modified through Repo.update/3 without raising Ecto.StaleEntryError? Should applications avoid update_all/3 on schemas that use optimistic locking?
35275 reputation · 04 Sept 2022, 11:11 UTC
Yes. Repo.update_all/3 leaves the optimistic lock column unchanged and does not raise Ecto.StaleEntryError. A bulk update can silently overwrite a row that was modified via Repo.update/3.
Repo.update_all/3 executes a direct SQL UPDATE and bypasses Ecto.Changeset. Validations, callbacks, and the optimistic-lock logic implemented in changesets are not invoked. The @optimistic_lock schema option is only enforced on changeset-based operations such as Repo.update/2 and Repo.insert/2.
Because update_all builds SQL directly from the query and update map, no lock value is read, compared, or incremented. Therefore a concurrent Repo.update_all/3 can write to a row whose lock version has advanced via a changeset path, producing a lost update with no error. This bypass is in Ecto core, so the absence of automatic lock handling is consistent across adapters, though exact SQL shape and return values vary by adapter and Ecto release.
Repo.update_all/3 to confirm that it bypasses changesets.Ecto.Adapters.SQL.to_sql to verify the UPDATE does not include the lock column in the WHERE clause unless you added it.Repo.update/2 to bump lock_version, then run Repo.update_all/3 on the same row without a lock predicate and observe that lock_version is unchanged and no Ecto.StaleEntryError is raised.Recommendation: Avoid Repo.update_all/3 on schemas that rely on optimistic locking unless you manually replicate the lock check and increment in the query WHERE and SET clauses.
One diagnostic detail that changes the recommendation: which Ecto version and database adapter you are running. The core bypass is stable, but the exact API for inspecting and building update_all queries can vary by release.
Use comments to ask for clarification. Post a solution as an answer.
35,275 reputation · 04 Sept 2022, 15:01 UTC
Repo.update_all/3 runs a direct SQL UPDATE and does not go through the changeset pipeline, so the optimistic_lock/3 machinery is not invoked. The lock column is left unchanged unless you reference it explicitly in the set clause, and no StaleEntryError is raised because there is no lock value comparison.
That means a bulk update can overwrite a row that a concurrent Repo.update/3 call has already modified. To get any conflict signal you must add the lock check yourself in the where clause and treat the affected-rows count as the only built-in indicator. Verify the generated SQL and the adapter’s affected-rows semantics before relying on count-based detection.