Skip to content

Multi-instance safety: Redis cache invalidation resilience & exactly-once scheduled tasks#731

Open
KrzysztofPajak wants to merge 2 commits into
developfrom
feature/multi-instance-cache-and-tasks
Open

Multi-instance safety: Redis cache invalidation resilience & exactly-once scheduled tasks#731
KrzysztofPajak wants to merge 2 commits into
developfrom
feature/multi-instance-cache-and-tasks

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Problem

When running multiple instances (Kubernetes pods, replicas):

  1. Cache invalidation via RedisMessageBus was best-effort only. Redis pub/sub has no replay - invalidations published while an instance was disconnected were silently lost, and the instance kept serving stale data until cache TTL. Publish/subscribe failures were swallowed with Debug.WriteLine, invisible in production.
  2. Scheduled tasks ran on every instance. The only guard was a non-atomic read of LastStartUtc, so two instances racing at the same interval both executed the task - e.g. queued e-mails were sent twice. LeasedByMachineName did not help (manual pin, empty by default, pod names rotate).

Changes

RedisMessageBus / cache

  • Reconnect handling: on ConnectionRestored (subscription connection) the local cache is cleared with Clear(false) - invalidations missed while disconnected make it untrustworthy; ConnectionFailed is logged as a warning
  • Subscription starts with retry + exponential backoff (1s → 30s) instead of a discarded fire-and-forget task
  • ILogger instead of Debug.WriteLine; failed publish logs an error, successful publish/receive log at Debug level (publish logs the subscriber count returned by Redis - handy for verifying instances actually listen)
  • RedisMessageCacheManager awaits publication; duplicated removal logic delegated to MemoryCacheBase
  • Startup: AbortOnConnectFail=false (no crash-loop when Redis is briefly unavailable at pod start), IConnectionMultiplexer registered for connection events, duplicated ICacheBase registration removed

Scheduled tasks - exactly-once per interval

  • New IScheduleTaskService.TryClaimTaskRun: atomic compare-and-set on LastStartUtc (conditional UpdateOneAsync) + read-back verification via new ScheduleTask.LeasedByInstance field; exactly one instance wins each run, others skip and re-check next interval (natural failover when the winning pod dies)
  • BackgroundServiceTask executes only after winning the claim; instance identity is a per-process GUID
  • Fixed: the task entity is persisted only by the instance that ran it - previously the unconditional UpdateTask let a losing instance overwrite the winner's results with stale data
  • Manual LeasedByMachineName pin behaviour unchanged; note: LiteDB's UpdateOneAsync is find-then-update (non-atomic), but LiteDB is a single-instance database anyway

Aspire (local multi-instance testing)

  • Two Grand.Web instances (ports 80 / 8080) + Redis container with pub/sub enabled, Debug logging for the cache message bus, plugin shadow copy disabled (instances share one content root)

Tests

  • RedisMessageBusTests (8): publish payload/channel, error swallowing, subscription on startup, dispatch of received messages with publisher=false, cache clear on restored subscription connection (and not on interactive)
  • RedisMessageCacheManagerTests (6): publish per operation type, no publish with publisher=false
  • ScheduleTaskServiceTests (4, MongoDB integration): first claim wins, stale claim fails and keeps winner, concurrent claims - exactly one winner, missing task
  • BackgroundServiceTaskTests (4): claim won → execute + persist; claim lost → no execute, no stale persist; not due → no claim; machine pin respected

All affected suites green: Grand.Infrastructure.Tests 83/83, Grand.Modules.Tests 18/18, Grand.Web.Common.Tests 15/15.

🤖 Generated with Claude Code

…eployments

RedisMessageBus (cache sync between instances):
- clear local cache when the Redis subscription connection is restored -
  pub/sub has no replay, so invalidations published while disconnected are
  lost and the local cache can no longer be trusted
- subscribe with retry (exponential backoff) instead of a discarded task
- replace Debug.WriteLine with ILogger; log publish delivery (receiver count)
  and received invalidations at Debug level
- await message publication in RedisMessageCacheManager
- connect with AbortOnConnectFail=false and register IConnectionMultiplexer;
  remove duplicated ICacheBase registration

Scheduled tasks (no duplicated runs across instances):
- add ScheduleTaskService.TryClaimTaskRun - atomic compare-and-set on
  LastStartUtc, so exactly one instance wins a run (fixes e.g. duplicated
  queued e-mails); losing instance skips and retries next interval
- persist the task entity only when the instance actually ran it, so a
  losing instance no longer overwrites the winner's results with stale data
- identify instances by a per-process GUID (pod/machine names are not stable)

Aspire: run two Grand.Web instances with Redis pub/sub enabled to exercise
the multi-instance scenario locally; disable plugin shadow copy (instances
share one content root).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 6, 2026 19:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread src/Web/Grand.Web.Common/Infrastructure/BackgroundServiceTask.cs Fixed
Comment thread src/Core/Grand.Infrastructure/Caching/Redis/RedisMessageBus.cs Dismissed
Comment thread src/Core/Grand.Infrastructure/Caching/Redis/RedisMessageBus.cs Dismissed
Comment thread src/Core/Grand.Infrastructure/Caching/Redis/RedisMessageBus.cs Dismissed
Comment thread src/Web/Grand.Web.Common/Infrastructure/BackgroundServiceTask.cs Dismissed
…t, rethrow cancellation on shutdown

- ScheduleTaskService.TryClaimTaskRun: explicit task null check instead of null-conditional chain
- BackgroundServiceTask: remove unread runTask assignment; rethrow OperationCanceledException during shutdown so an interrupted run is not recorded as a task failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@KrzysztofPajak KrzysztofPajak requested a review from Copilot July 6, 2026 19:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
60.2% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

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.

2 participants