Architecture Decision Record
ADR-108: Cross-Replica Mutual Exclusion via IDistributedLock
Status
Accepted (2026-09-03). Revised 2026-09-19 (the second consumer moved: ADC's AI scoring pass now runs
as the internal command ScoreEventSessionsInternalCommandHandler under
ADR-114, taking the lock as a constructor dependency
rather than resolving it from a service scope; and adoption is now three call sites rather than two,
the third being ADC's question submit path). Revised 2026-09-25 (point 12 now grounds the
absence of a lock in MMCA.Store and MMCA.Helpdesk on the rule in points 2 and 11 rather than on
current state alone; the outbox and scheduler claim-lease anchors and the question-submit key
anchor were re-pinned). Revised 2026-10-01 (adoption is four call sites, the fourth being the
framework's password-reset token redemption, and the key namespace now defaults to the host's
application namespace; see Revision below). Revised 2026-10-06: ADC's service ceiling is now
2 replicas by default and 4 in conference mode, and the Redis lock suite has seven cases, not six.
Revised 2026-10-07: adoption is five call sites, the fifth being the framework's OAuth exchange-code
redemption in OAuthControllerBase, which ADC's OAuth controller wires with the lock.
Revised 2026-10-19: adoption is six call sites, the sixth being Store's Stripe webhook registration, whose check-then-act on Stripe's endpoint list now runs under the lock; point 12 no longer says Store takes none.
Context
Both deployed apps run more than one replica of every service. ADC's Conference container app
(MMCA.ADC/infra/main.bicep:1922) scales with minReplicas: 1 and
maxReplicas: conferenceScaledMaxReplicas (scale at :2067), which is 2 by default and 4 in
conference mode (:192); Identity and Engagement scale the same way (:1914, :2202) and
Notification at a fixed maxReplicas: 2 (:2388). Anything in the framework that
relies on "only one of these runs at a time" therefore runs once per replica unless the exclusion
lives somewhere all the replicas can see.
The in-process tools do not reach that far. A SemaphoreSlim, or the striped KeyedSemaphoreStripe
(MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22), serializes
callers inside one process only. The framework already had a second, stronger answer for durable
queue work: a database claim-lease. OutboxProcessor stamps LockedUntil and a LockToken in a
conditional ExecuteUpdateAsync so exactly one replica wins a row
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:330,
claim at :357-361), and ScheduledJobRunner does the same on ScheduledJobEntry
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobRunner.cs:410, claim at
:437-445). That pattern needs a row to claim.
Some critical sections have no such row. The API idempotency filter's window between executing an
action and storing its response is guarded by a cache entry, not a database row (ADR-017, ADR-026):
two duplicates landing on different replicas both miss the cache, both execute, and the second
overwrites the first's stored response. ADC's AI scoring pass reaches the same place from the other
side: it runs as a durable internal command (ADR-114), so each trigger does own a claimable row, but
two rows for the same event can be claimed by different replicas at the same time, and each pass
issues one paid Anthropic call per session
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsInternalCommandHandler.cs:15-21).
The exclusion the pass needs is per event, not per row.
ADR-017 records how the idempotency filter uses a distributed lock. Nothing records the primitive itself: what it does and deliberately does not promise, what it degrades to, and when to reach for it rather than for a claim-lease row. This record does.
Decision
The framework ships one cross-replica mutual-exclusion primitive, IDistributedLock: a
non-reentrant, TTL-bounded, explicitly best-effort lock with an owner-scoped idempotent release,
backed by single-instance Redis where a connection exists and by a warn-once process-local fallback
where it does not. It collapses duplicate work; it never carries a correctness invariant that
persistence can enforce.
One contract, one method.
IDistributedLock(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IDistributedLock.cs:30) exposes onlyTryAcquireAsync(string key, TimeSpan ttl, TimeSpan wait, CancellationToken)returningTask<IAsyncDisposable?>(:59-63). The interface is public and frozen in the API baseline (Application/PublicAPI.Shipped.txt:321-322); both implementations areinternal sealedin Infrastructure, so a consumer binds to the contract and never to a backend.Best-effort, stated on the type. The contract documents itself as "a best-effort lock, not a consensus protocol": a holder paused past its time-to-live loses the lock without knowing it, so the guarded section must stay correct (if slower or duplicated) when exclusion is lost, and the lock is to be used "to collapse duplicate work, never as the only guard on a correctness invariant that persistence can enforce" (
IDistributedLock.cs:24-27).Non-reentrant, by contract. A caller that already holds
keyand asks again waits for itself and then fails to acquire (:20-22). There is no re-entry counter and no owner affinity.The TTL is the crash guard; the wait bounds the caller.
ttlis how long the lock survives without an explicit release, sized comfortably above the section's expected duration because work that outlives it is no longer protected (:37-42).waitis how long to block for a current holder, withTimeSpan.Zeromaking the call a single non-blocking attempt (:43-46). Anullreturn means the lock was still held elsewhere when the wait elapsed (:48-53).Release is owner-scoped and idempotent. A handle releases only the acquisition it represents; disposing a handle whose TTL already expired is a no-op rather than a release of the new holder's lock, and disposal is idempotent (
:55-57). Callers dispose inside anawait using, so release happens even when the guarded work throws.Redis implementation: conditional SET plus a compare-and-delete script.
RedisDistributedLock(Infrastructure/Concurrency/RedisDistributedLock.cs:24) acquires with a singleStringSetAsync(..., ttl, keepTtl: false, When.NotExists, ...)(:66-68) carrying a per-acquisition random token (:59). Release evaluates a Lua script that deletes the key only when its stored value still equals that token (:36-37, run at:113-115), which is what makes the release owner-scoped. A result of 0 means the holder's TTL had already lapsed, and it is logged as a warning that the section was not exclusive for all of it (:84,:117-122). A fault during release (a Redis exception or timeout) is caught and logged as a warning rather than thrown (:87-88,:124-129): the release runs after the guarded work has already committed, and the key expires on its own TTL, so a failed release never turns completed work into a failure (:106-110). Keys carry alock:prefix so locks cannot collide with cache entries in a shared instance (:30), qualified by the same cache key namespace the cache uses (:55,CacheKeyNamespace.QualifyatInfrastructure/Caching/CacheKeyPrefix.cs:91). Waiting polls every 50ms (:40). Single-instance semantics are deliberate: "this is the one-Redis lock, not Redlock" (:19-23).The fallback is process-local and says so once. With no Redis client registered,
InProcessDistributedLock(Infrastructure/Concurrency/InProcessDistributedLock.cs:31) serializes through aConcurrentDictionarykeyed on the exact key (:36,:61), polling every 25ms (:34).ttlis accepted and ignored: the holder is a task in this process, and if the process dies the table dies with it (:26-29). The first acquisition logs one warning naming the degradation and the fix (:52-55, message at:75), latched throughInterlockedso a steady state warns once rather than per request (:38-39). It keys on the exact key rather than on hashed stripes because a bounded wait would turn stripe false-sharing into a spurious "held elsewhere" for a key nobody holds (:18-25).Registration rides with the cache, and is unconditional.
AddCaching(Infrastructure/DependencyInjection.Caching.cs:26, reached fromAddInfrastructureatInfrastructure/DependencyInjection.cs:140)TryAddSingletons anIDistributedLock(DependencyInjection.Caching.cs:86):RedisDistributedLockwhen anIConnectionMultiplexerresolves (DependencyInjection.Caching.cs:88-95),InProcessDistributedLockotherwise (DependencyInjection.Caching.cs:97-99).TryAddmeans a host that registered its own implementation first keeps it. Both branches are asserted (Infrastructure.Tests/DependencyInjectionTests.cs:81-83,:94-96). In production the multiplexer comes fromAddRedisCaching(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:57, client at:65), which is itself a no-op when no connection string is configured (:59-62).First consumer: the idempotency filter's execute-then-store window.
IdempotencyFilterresolves the lock withGetServiceon the slow path (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:149) and runs the guarded section under it (:239) with a 30s TTL (:98) and a 5s wait (:105). A duplicate that cannot acquire within the wait re-checks the cache; finding nothing, it gets 409 Conflict rather than a second execution (:262-271, result at:302-311). A lock backend that faults is treated differently from a lock that is held: the action runs unguarded and the degradation is counted, so a Redis blip does not become a write outage (:254-260). With no lock registered the filter falls back to its striped semaphore (:200, stripe at:91).Second consumer: ADC's AI scoring pass.
ScoreEventSessionsInternalCommandHandlertakes the lock as a primary-constructor dependency rather than resolving it from a service scope (ScoreEventSessionsInternalCommandHandler.cs:49) and claims the event withTryAcquireAsync(ClaimKey(eventId), ClaimTimeToLive, ClaimWait, ...)(:83-85), where the key isscoring:inflight:{eventId}(:123-124), the TTL is 15 minutes (:61) and the wait isTimeSpan.Zero(:68), so a duplicate trigger logs and reports success rather than queueing behind the pass already covering the same work (:87-91). Theawait usingon the handle releases on success, on failure, and by TTL when the replica is killed mid-pass.The choose-between rule. Work that already owns a durable row uses the claim-lease: the outbox and the scheduler both stamp
LockedUntilplus aLockTokenin a conditional update whose predicate is the exclusion (OutboxProcessor.cs:357-361,ScheduledJobRunner.cs:437-445), which survives a Redis outage and needs no extra dependency.IDistributedLockis for a section whose state is not a row it can conditionally update: a cache entry, an external paid API call, a pass over rows it does not own. Inventing a row purely to hold a lease is not the answer for those, and neither is holding a database transaction open across the work.Adoption is exactly these six call sites. The third is ADC's question submit cap:
SubmitQuestionHandlertakes the lock as a constructor dependency (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:34) and serializes the per-(session, user) open-question cap, whose count-then-insert is not one atomic statement, under a claim keyedsession-question:submit:{sessionId}:{userId}(:108, taken at:148) with a 30 second TTL (:49) and a 2 second wait (:57); a submit that cannot get in within the wait is answered with the same cap failure the count itself would have returned, so contention never surfaces as a fault. The fourth is the framework's password-reset token redemption:PasswordResetTokenServicetakes the lock as a constructor dependency (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:36) and runs the read, compare and remove of one token underlock:{tokenKey}(:106-117) with a 10 second TTL (:41) and a 5 second wait (:43), so two concurrent redemptions of one token do not both succeed while the lock holds (best-effort per point 2: a holder paused past the TTL, or a host on the process-local fallback of point 7, is not excluded across replicas); a redemption that cannot take the lock is answered as an invalid token (:109-112). The fifth is the framework's OAuth exchange-code redemption:OAuthControllerBasetakes an optional lock as its fourth constructor argument (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:54) and, when one is supplied, reads and burns the single-use code underlock:{cacheKey}(:253-264) with a 10 second TTL (:80) and a 5 second wait (:81); a redeem that cannot take the lock is answered as an invalid code (:256-259), and with no lock the read-then-burn runs unlocked, single-use per replica only (:245-248). ADC'sOAuthControllerpasses the lock through (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:25). The sixth is Store's Stripe webhook registration:StripeWebhookRegistrationServiceruns as a background service on every Sales replica, and its list-then-delete-or-create reconcile is check-then-act on Stripe's endpoint list, not on a row it could conditionally update, so two replicas booting together could each mint an endpoint and hold different signing secrets. It takes the lock as a constructor dependency (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Payments/Stripe/StripeWebhookRegistrationService.cs:52) and runs the reconcile understripe:webhook-registration:{expectedUrl}(:248, taken at:143-145) with a 2 minute TTL (:69) and a 30 second wait (:76); it re-reads the shared secret once the lock is held (:160), so a replica that waited adopts the first one's mint instead of minting again, and a replica that cannot take the lock reads the shared secret once more and leaves Stripe alone (:147-155). Two tests pin both branches (MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.Infrastructure.Tests/Services/StripeWebhookRegistrationServiceTests.cs:221,:260). No other MMCA.Common component, nothing else in MMCA.Store, and nothing in MMCA.Helpdesk takes a lock today. The primitive is shipped and registered in every host that callsAddInfrastructure, and used in six places. That an application takes no lock of its own is the rule applied, not a gap to close: a lock is reached for only when there is duplicate work to collapse that persistence cannot guard (point 2), and work that owns a durable row takes the claim-lease instead (point 11). Store's other multi-replica work is guarded by persistence, which is why the Stripe registration is its one lock: it is the one section whose state lives outside a row. An adoption difference between the two apps is therefore sanctioned by points 2 and 11, and this point only records where the rule currently lands.
Rationale
- The degraded mode had to be visible, not silent. Registering nothing when Redis is absent would
push a null check onto every caller, and registering a no-op lock would make a multi-replica host
quietly non-exclusive. The warn-once fallback keeps the DI resolution total and puts one line in the
log naming both the condition and the fix (
InProcessDistributedLock.cs:75). - A handle makes release structural. Returning
IAsyncDisposable?rather than a boolean plus aReleaseAsync(key)means the release cannot be skipped on a throw path and cannot be aimed at the wrong acquisition. A counter taken on entry and released in afinallygets both of those wrong, and a process killed between the two leaves the guard stuck until an operator clears it. - The owner token is the whole of release correctness. Deleting without the comparison would let a
caller whose lock already expired free the next holder's lock, which is the double execution the
lock exists to prevent (
RedisDistributedLock.cs:32-37). - Registering it beside the cache keeps one Redis decision. The lock and the cache read the same
IConnectionMultiplexerand the same key namespace, so a host has one thing to configure and the two degrade together rather than in different directions. - Single-instance Redis is the honest ceiling. Redlock would add a quorum of independent instances to run, monitor and pay for, for a primitive whose contract already says the guarded section must survive losing exclusion.
Trade-offs
- Failover can hand the same key to two holders. The lock inherits Redis's failover behavior
(
RedisDistributedLock.cs:19-23), so a primary loss between acquire and replicate is a window in which two replicas both believe they hold the key. That is why point 2 is a contract term and not a caveat. - The fallback is correct only at one replica. A multi-replica host with no Redis connection gets
per-replica exclusion from
InProcessDistributedLock, and after the first warning nothing repeats it. ADC's scoring handler names the condition that defeats it: Conference runs more than one replica and the framework's processor polls on each of them (ScoreEventSessionsInternalCommandHandler.cs:15-17). - No renewal. Nothing extends a TTL mid-section. A section that outlives its TTL silently loses
exclusion; Redis notices only after the fact, when the release script returns 0 and logs
(
RedisDistributedLock.cs:84), and the in-process fallback cannot notice at all because it ignoresttl. - Waiting is polling, not notification. Both implementations sleep and retry (50ms and 25ms,
RedisDistributedLock.cs:40,InProcessDistributedLock.cs:34), so wake-up is granular and a long wait costs round trips: a 5s idempotency wait is up to about 100 conditional SET attempts. - ADC deliberately shares one key namespace across its services.
lock:separates locks from cache entries, and the cache key namespace separates hosts: an explicitCache:KeyPrefixwins, otherwise the prefix defaults toApplication:Namespaceor, when that is unset, the host'sIHostEnvironment.ApplicationName(CacheKeyPrefix.cs:46,:73-88;Infrastructure/Configuration/ApplicationNamespace.cs:53-68), so by default each extracted service host gets its own prefix. ADC overrides that: all four ADC services setApplication:Namespacetoadcand the sameCache:KeyPrefixofadc:(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/appsettings.json:38,:45, and the same in the Identity, Engagement and Notification service hosts) and share one Redis instance (MMCA.ADC/infra/main.bicep:1434, injected per app asConnectionStrings__redisat:1759,:1999,:2136,:2290). Two ADC services choosing the same logical key would collide, and today only the callers' key shapes prevent it. - 409 is a real cost to a caller. The idempotency filter answers a duplicate whose original is
still in flight with a conflict rather than a longer wait (
IdempotencyFilter.cs:262-271), so the client has to retry. That is deliberate, but it is behavior the TTL and wait pairing tunes rather than removes. - Six consumers is a thin evidence base. The contract's edges (TTL loss, wait expiry, idempotent
disposal) are exercised by unit tests against a mocked
IDatabase(Infrastructure.Tests/Concurrency/RedisDistributedLockTests.cs:60-202, seven cases) and by the in-process tests, not against a live Redis under failover. The behavior most likely to matter in production is the behavior least covered.
Revision (2026-10-01)
Two statements no longer matched the code. First, adoption grew to four call sites: the framework's
own PasswordResetTokenService takes IDistributedLock as a constructor dependency
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:36) and
serializes each token redemption under it (:106-117), so point 12 and the Trade-offs consumer count
now name four sites, and "no other MMCA.Common component takes a lock" now means beyond the
idempotency filter and password reset. MMCA.Store and MMCA.Helpdesk still take none. Second, the cache
key namespace is no longer empty by default: an unset Cache:KeyPrefix now falls back to
Application:Namespace or, when that is unset, the host's IHostEnvironment.ApplicationName (Infrastructure/Caching/CacheKeyPrefix.cs:73-88,
Infrastructure/Configuration/ApplicationNamespace.cs:53-68), and the four ADC services set an
explicit shared adc: prefix (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/appsettings.json:45),
so the Trade-offs bullet now describes per-host separation by default that ADC deliberately
overrides with one shared keyspace, rather than absent separation; the
service-to-service collision risk inside ADC is unchanged. The primitive, its contract, both
implementations and the choose-between rule are unchanged. Anchors were re-pinned for AddCaching
(now in DependencyInjection.Caching.cs), the idempotency filter, the scoring handler, the
scheduler claim, CacheKeyNamespace.Qualify, the IDistributedLock remarks, the public API
baseline and the ADC Bicep replica and Redis declarations.
Revision (2026-10-06)
- Context: ADC's Conference, Identity and Engagement container apps no longer declare a literal
maxReplicas: 2; they scale toconferenceScaledMaxReplicas(MMCA.ADC/infra/main.bicep:191), 2 by default and 4 in conference mode, and only Notification keeps a fixed 2 (:2341). The multi-replica premise is unchanged, and the Trade-offs fallback bullet now says "more than one replica" rather than "two replicas". - Trade-offs:
RedisDistributedLockTestsnow holds seven cases (:60to:202), not six; the old:22-40range was fixture setup. - Anchors were re-verified against current source and re-pinned for the public API baseline,
AddInfrastructure, the DI tests, the Redis release script, the scoring handler, the outbox claim, and the ADC Bicep Redis resource and injections.
Revision (2026-10-07)
Re-verified against current source. The primitive, its contract, the in-process implementation, the registration and the choose-between rule are unchanged; adoption grew by one framework call site, and the Redis release now absorbs its own faults.
- Point 12 and the Trade-offs consumer count now name five call sites, not four. The fifth is
OAuthControllerBase, which takes an optionalIDistributedLock(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:54) and, when given one, reads and burns the single-use exchange code underlock:{cacheKey}(:253-264, 10 second TTL at:80, 5 second wait at:81); ADC'sOAuthControllersupplies it (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:25). MMCA.Store and MMCA.Helpdesk still take no lock and declare noOAuthControllerBasesubclass. - Anchors re-verified against current source: the public API baseline
(
Application/PublicAPI.Shipped.txt:321-322), the outbox claim (OutboxProcessor.cs:330, conditional update at:357-361), and the ADC Bicep Conference app (main.bicep:1922), its scale (:2067),conferenceScaledMaxReplicas(:192), the Identity and Engagement scale lines (:1914,:2202), Notification's fixed ceiling (:2388), the Redis resource (:1434) and its per-appConnectionStrings__redisenvironment injections (:1759,:1999,:2136,:2290). - Point 6 now records that a fault during the Redis release is caught and logged as a warning
instead of propagating (
RedisDistributedLock.cs:87-88,:124-129), so a failed release never fails work that already committed; the key still expires on its TTL.
Revision (2026-10-19)
The primitive, its contract, the registration and the choose-between rule are unchanged. One premise of
point 12 no longer held: it recorded that nothing in MMCA.Store takes a lock and called that the rule
applied, but Stores StripeWebhookRegistrationService is exactly the kind of section point 11 assigns
to the lock (check-then-act on external Stripe state across every Sales replica, not a row). It now
takes IDistributedLock
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Payments/Stripe/StripeWebhookRegistrationService.cs:52),
wraps the reconcile in TryAcquireAsync on stripe:webhook-registration:{expectedUrl} (:143-145,
:248; 2 minute TTL at :69, 30 second wait at :76), re-reads the shared secret after acquiring
(:160) and, without the lock, adopts whatever a sibling published and makes no Stripe call
(:147-155; tests at
MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.Infrastructure.Tests/Services/StripeWebhookRegistrationServiceTests.cs:221,
:260). Point 12 and the Trade-offs consumer count now name six call sites; MMCA.Helpdesk still takes
none.
Related
ADR-017 (the HTTP idempotency filter, the first consumer, whose
409-on-lock-miss this record explains), ADR-026 (the cache registration
this lock is registered beside, and the source of the IConnectionMultiplexer and key namespace it
shares), ADR-003 (the outbox claim-lease: the row-based alternative for
durable queue work), ADR-074 (the scheduler, which applies that same
claim-lease to cron rows), ADR-052 (in-process background work,
which is per-replica by design and names a distributed lock as the point at which it should become a
real job), ADR-006 (database-per-service, which is why a shared database
is not itself the exclusion boundary).