Skip to content

Fix false ConcurrencyException from non-RETURNING event ops in batched SaveChanges (#4778) - #4784

Merged
jeremydmiller merged 2 commits into
masterfrom
repro/4778-overwrite-event-false-occ
Jun 24, 2026
Merged

jeremydmiller merged 2 commits into
masterfrom
repro/4778-overwrite-event-false-occ

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4778.

Problem

Mixing an OverwriteEvent (a plain UPDATE … mt_events, no RETURNING) with a RETURNING upsert (e.g. an optimistic-concurrency projection document) in a single SaveChangesAsync throws a false ConcurrencyException, even though nothing actually conflicted. Regressed on 9.x when the closed-shape upsert (#4659) switched to inline INSERT … ON CONFLICT … RETURNING.

Root cause

OperationPage.ApplyCallbacksAsync walks one result-set per operation that is not NoDataReturnedCall (Postprocess → NextResultAsync). Under NpgsqlBatch, only row-producing statements (… RETURNING, SELECT) yield result-sets — plain UPDATE/DELETE/INSERT produce none. OverwriteEventOperation issues such a non-RETURNING UPDATE, has a no-op PostprocessAsync, and was not marked NoDataReturnedCall, so its spurious NextResultAsync() consumed the next operation's result-set. By the time the RETURNING upsert ran, the reader was exhausted → ReadAsync() returned false → false ConcurrencyException. (Deletion was already marked and correctly skipped — the fix already existed for a sibling.)

Order matters: the overwrites must land before the RETURNING op. They do — OverwriteEvent queues its op immediately while Store builds its upsert during SaveChanges, and with no doc-type FK relationship the batch stays in insertion order.

Fix

Marked every event-family operation that produces no result-set and has a no-op PostprocessAsync as NoDataReturnedCall, so the walk skips them (audited the whole family, not just the reported op):

  • OverwriteEventOperation, ReplaceEventOperation (masking)
  • AssignTagWhereOperation, AssignTagWhereHstoreOperation, SetEventTagsHstoreOperation, SetEventTagsHstoreByEventIdOperation, InsertEventTagOperation, InsertEventTagByEventIdOperation (tagging — all INSERT … [SELECT] … ON CONFLICT, no RETURNING)
  • IncrementStreamVersionById, IncrementStreamVersionByKey
  • DeleteProjectionProgress, DeleteEventsOperation (stream compaction)

Deliberately not marked: DcbTagVersionAssertion (it reads the reader), and the RETURNING upserts/asserts (correctly walked).

Tests

  • Bug_4778_overwrite_event_with_returning_upsert — the regression test (committed first as a failing repro; throws JasperFx.ConcurrencyException before the fix, passes after).
  • Full suites green after the fix: EventSourcingTests 1387 passed / 7 skipped, DocumentDbTests 999 passed / 1 skipped (net10.0).

Note

The deeper alternative is to make ApplyCallbacksAsync result-set-driven rather than operation-driven (advance only for ops that actually return data), which would immunize all operation families permanently. This PR takes the targeted marker fix for the event family per the issue; happy to follow up with the reader-driven walk if preferred.

OperationPage.ApplyCallbacksAsync walks one result-set per operation that is not
NoDataReturnedCall. Operations that issue non-RETURNING DML (plain UPDATE/DELETE/
INSERT) produce no result-set under NpgsqlBatch, so when such an op is not marked
its spurious NextResultAsync() consumes a result-set belonging to a later RETURNING
operation, exhausting that operation's reader and raising a false ConcurrencyException.

OverwriteEventOperation was the reported trigger (mixed with an optimistic-concurrency
projection upsert in one SaveChanges). Audited the whole event-operation family and
marked every op that produces no result-set and has a no-op PostprocessAsync:

- OverwriteEventOperation, ReplaceEventOperation (masking)
- AssignTagWhereOperation, AssignTagWhereHstoreOperation,
  SetEventTagsHstoreOperation, SetEventTagsHstoreByEventIdOperation,
  InsertEventTagOperation, InsertEventTagByEventIdOperation (tagging)
- IncrementStreamVersionById, IncrementStreamVersionByKey
- DeleteProjectionProgress, DeleteEventsOperation (stream compaction)

Deliberately not marked: DcbTagVersionAssertion (reads the reader), and the RETURNING
upserts/asserts (correctly walked).

Closes #4778
@jeremydmiller
jeremydmiller merged commit 1839bf0 into master Jun 24, 2026
9 checks passed
@jeremydmiller
jeremydmiller deleted the repro/4778-overwrite-event-false-occ branch June 24, 2026 22:12
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.

Incorrect result-set handling with NpgsqlBatch causes false ConcurrencyException

1 participant