Skip to main content

Notification Concurrency Control

Summary

No concurrency control of any kind is declared. There is no row version, no concurrency token, no timestamp column marked for concurrency, no pessimistic locking and no optimistic-concurrency exception handling anywhere in the reviewed source.

Audience

Engineers, security reviewers, architects, QA and DevOps engineers.

Verified absence

  • No IsRowVersion and no IsConcurrencyToken call in the model.
  • No byte[] version property or xmin mapping on any entity.
  • No DbUpdateConcurrencyException handling in any file.
  • No DbUpdateException handling in any file, so a unique-index violation is not caught either.
  • No locking hint, no advisory lock, no row-level read-for-update construct and no serialisable isolation request.
  • No retry-on-conflict logic on any save.

Last write wins on every mutable row.

Race windows

Duplicate event processing

The most significant window. The idempotency check is a no-tracking existence read at the start of processing; the ledger row is written at the end. Two concurrent deliveries of the same event can both pass the check and both process fully.

The unique index on the event identifier rejects the second ledger insert — but only after both have already created messages and attempted delivery. Because that violation is not caught, it surfaces as an unhandled exception on the losing consumer, which negatively acknowledges and requeues, potentially repeating the cycle.

The window is widened by the fact that processing includes provider sends and bounded retry delays, so it can be seconds wide rather than milliseconds.

Recipient upsert

The lookup and the insert are separate operations with no transaction. Two concurrent events for the same unseen recipient can both find nothing and both insert. The unique index on tenant, type and external reference rejects the loser, and again nothing catches it.

Template version allocation

Template create reads the current maximum version and inserts at that value plus one. Two concurrent creates for the same tenant, key and channel read the same maximum and compute the same next version. The unique index on tenant, key, channel and version rejects the loser — uncaught.

Concurrent message mutation

Nothing prevents the retry endpoint and an in-flight dispatcher loop mutating the same message row simultaneously. Both write the status, attempt counter and error with no version check, so the final state is whichever save landed last and may not reflect the true delivery outcome.

Read-state

Marking a message read checks the current status before writing, making the operation idempotent for repeated identical calls, but the check and the write are not atomic. Two concurrent calls can both observe the unread state; the outcome is the same status either way, but two audit rows are appended for one transition.

What the unique indexes do provide

The four unique indexes are the module's only concurrency safety net. They guarantee that duplicate rows cannot be stored for templates, recipients, preferences and the idempotency ledger. They do not prevent the duplicate work that precedes the rejected insert, and because no violation is caught, they convert a data-integrity save into an unhandled failure.

Classification

Foundation — structural uniqueness exists; concurrency control does not.

Requires confirmation

Whether concurrency tokens, an early idempotency claim and unique-violation handling are planned requires confirmation.

See Also

Keywords

  • Notification database
  • Concurrency
  • Draft database documentation

Source References

  • microservices/src/notification-service/Infrastructure/NotificationDbContext.cs
  • microservices/src/notification-service/Domain/NotificationEntities.cs
  • microservices/src/notification-service/Application/NotificationDispatcher.cs
  • microservices/src/notification-service/Api/NotificationEndpoints.cs

Revision Information

  • Status: Draft
  • Last reviewed: 2026-07-21
  • Review cycle: Quarterly