Skip to content

Zero-loss message persistence is in-memory only: queued messages are lost on restart #14

Description

@ameeribro4-sudo

Labels / Complexity: bug · High — 700 points

Problem

ZeroLossMessageService (src/queue/zero-loss-message.service.ts) claims to guarantee that no messages are lost through "persistence, replication, and acknowledgment", but all of its state lives in process memory:

// src/queue/zero-loss-message.service.ts
private persistedMessages: Map<string, PersistedMessage> = new Map();
private pendingAcknowledgments: Map<string, NodeJS.Timeout> = new Map();
private replicationNodes: Set<string> = new Set();

persistMessage() stores the message in persistedMessages, and registerReplicationNode() adds a node ID to an in-memory Set. The configuration even declares a replicated persistence level that the implementation does not provide:

// src/queue/horizontal-scaling.config.ts
zeroLoss: {
  enabled: true,
  persistenceLevel: 'replicated',   // ← claims replication
  replicationFactor: 3,             // ← claims 3 replicas
  acknowledgmentTimeoutMs: 5000,
  maxRetryAttempts: 5,
},

The consequence is silent data loss. On any process restart, crash, or horizontal scale-out, every "persisted" message, every replication node, and every pending acknowledgment timeout vanishes. getReplicationTargets() returns nodes from the in-memory Set, so replicationNodes.length is always 0 on a fresh instance, and verifyMessageIntegrity() will report Insufficient replication: 0/3 for every message. A message marked processing by a worker that then dies is never re-queued, because the timeout that would requeue it only exists in the dead process's memory.

This matters because the whole HorizontalScalingModule (src/queue/horizontal-scaling.module.ts) advertises zero-loss and multi-instance behavior, yet the state is per-process and non-durable.

Root cause

// src/queue/zero-loss-message.service.ts
this.persistedMessages.set(messageId, message);   // ← in-memory only, lost on restart

Why this is architecturally hard

  1. The natural shortcut fails. A bigger in-memory Map (or moving it to a module-level singleton) does not survive a restart and does not share state across instances, which is the entire point of a zero-loss layer on a horizontally-scaled queue.
  2. A real backend must be chosen. The durable store has to be Redis (already used by Bull via src/queue/queue.module.ts and by RedisPoolService under src/common/cache/) or Postgres (via TypeORM). Each has different semantics for TTLs, atomicity, and the acknowledgment timeout.
  3. Recovery of orphaned processing messages is required. A boot-time or periodic sweep must find messages stuck in processing past the acknowledgment timeout and requeue them, without double-processing messages whose worker is actually still alive. This needs a lock/lease, not a timestamp check.
  4. The design must not duplicate Bull. Bull already persists jobs in Redis with its own retry and stalled-job handling (stalledInterval is configured in queue.module.ts). The contributor must decide whether ZeroLossMessageService should back onto Bull's own durability, or maintain a parallel store, and justify that choice.

Proposed design

Replace the in-memory Map/Set with a durable store keyed by messageId. A reasonable target signature keeps the public API stable:

persistMessage(messageId, queueName, data, maxAttempts?): Promise<PersistedMessage>
markProcessing(messageId): Promise<boolean>
acknowledgeMessage(messageId): Promise<boolean>

Use Redis hashes or strings with a TTL for the acknowledgment window, or a PersistedMessage table with a status + updatedAt index for the recovery sweep. Replication nodes should be persisted (a set key) so getReplicationTargets() returns the same targets across instances. Add a recovery path that atomically transitions processing → pending only when the lease has expired.

Acceptance criteria

Service

  • After a simulated crash and restart, a message persisted before the crash is still present and can be re-queued.
  • A message stuck in processing past the acknowledgment timeout is transitioned to pending exactly once (no double-processing).
  • replicationNodes survive a restart and are shared across instances of the same deployment.

Tests

  • A test persists a message, simulates process restart by reinstantiating the service against the same store, and asserts the message is recoverable.
  • A test verifies the processing → pending requeue fires once for an orphaned message.

Documentation

  • The chosen durability backend, its failure modes, and the recovery sweep interval are documented in the queue module or README.

Out of scope

Do not change deduplication (src/queue/message-deduplication.service.ts) or ordering (src/queue/message-ordering.service.ts) in this issue; those are adjacent but separate in-memory-state problems.

Getting started

Files in scope: src/queue/zero-loss-message.service.ts, src/queue/horizontal-scaling.config.ts, src/queue/horizontal-scaling.module.ts, and the Redis utilities under src/common/cache/.

Build and test with:

npm run build
npm run test

Good first files to read: src/queue/dead-letter-queue.service.ts (a sibling service with the same in-memory problem), src/common/cache/redis-pool.service.ts (the existing Redis accessor).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignbugSomething isn't workingdrips-waveFunded contribution program (Drips Wave)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions