Document verification in German mortgage expat brokerage workflows is inherently time-intensive, requiring multiple automated and manual sanity checks (e.g., verifying payslip validity, tax return completeness, and ID legibility). To ensure that the client's upload experience is instantaneous and uninterrupted, LeadFlow uses an asynchronous background processing model powered by BullMQ and Redis.
The document upload endpoint (POST /api/documents/upload) immediately stores the uploaded file in ImageKit, creates the Document record with status PENDING, enqueues a background job to BullMQ, and returns HTTP 201 to the client. The document lifecycle is subsequently managed asynchronously by dedicated workers.
[ HTTP Upload Request ]
│
▼
ImageKit Upload
│
▼
MongoDB Persist (status: 'PENDING')
│
▼
[ Enqueue Job ] ────► Redis: 'document-processing' Queue
│
▼
[ BullMQ Document Worker ]
│
┌────────────┴────────────┐
▼ ▼
Tenant Validation Atomic State Claim
(withBrokerageScope) (PENDING → PROCESSING)
│ │
└────────────┬────────────┘
│
▼
Simulated Realistic Check (delay)
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
[Success] [Rejection] [Transient Failure]
(status: VERIFIED) (status: REJECTED) (retry with backoff)
│ │ │
└─────────────────┴─────────────────┘
│
▼
MongoDB Committed State Change
│
▼
Realtime Socket.IO Broadcast
(`document:status_changed`)
(Advisors in brokerage, Client in personal room)
-
Producer (
document.queue.ts):- Queue Name:
document-processing. - Job Name:
verify-document. - Deterministic Job Deduplication:
jobId = doc-verify-${documentId}. - Retry Strategy: 3 attempts with exponential backoff (1s, 2s, 4s).
- Redis Failure Isolation: If Redis is down during upload, the error is caught and logged; the document remains in MongoDB as
PENDING, avoiding API request failure.
- Queue Name:
-
Consumer (
document.worker.ts):- Subscribes to the
document-processingqueue with configurable concurrency (DOCUMENT_PROCESSING_CONCURRENCY, default 5). - Safe for execution within the primary application server or as a standalone worker process (
npm run dev:worker). - Graceful lifecycle management:
startDocumentWorker()andcloseDocumentWorker().
- Subscribes to the
-
Standalone Worker Entry (
worker/src/worker.ts):- Connects independently to MongoDB and Redis.
- Handles
SIGINTandSIGTERMsignals for zero-downtime, graceful worker termination.
┌──────────────────────────┐
│ PENDING │
└─────────────┬────────────┘
│
│ (Worker claims job atomically)
▼
┌──────────────────────────┐
│ PROCESSING │
└───────┬───────────┬──────┘
│ │
(Verification │ │ (Verification failed or
passed) │ │ retries exhausted)
▼ ▼
┌────────────────┐ ┌────────────────┐
│ VERIFIED │ │ REJECTED │
└────────────────┘ └────────────────┘
- PENDING: Initial state upon successful multipart upload and MongoDB creation.
- PROCESSING: Atomically claimed by a worker. Guaranteed to be set before any verification work begins.
- VERIFIED: Terminal state.
verifiedAttimestamp recorded; verification notes added. - REJECTED: Terminal state. Notes explain why the document was rejected (e.g. illegible scan or exhausted retry attempts).
- Persisted Boundary Verification:
- The worker never trusts
brokerageIdfrom the job payload without verifying it against the database. - Queries use
withBrokerageScope(payload.brokerageId, { _id: payload.documentId }).
- The worker never trusts
- Tampering Detection:
- If a job payload specifies Brokerage B for a document that actually belongs to Brokerage A, the worker detects the discrepancy, logs a security warning, and throws a terminal
UnrecoverableError. - Cross-brokerage documents are never modified or inspected.
- If a job payload specifies Brokerage B for a document that actually belongs to Brokerage A, the worker detects the discrepancy, logs a security warning, and throws a terminal
- Atomic State Claims:
- The transition from
PENDINGtoPROCESSINGuses MongoDB's atomicfindOneAndUpdatewithstatus: 'PENDING'. - If two workers attempt to process the same document simultaneously, only one succeeds; the second worker's update returns
null, and it exits safely without performing duplicate work.
- The transition from
- Terminal State Idempotency:
- If a duplicate job is submitted for a document that is already
VERIFIEDorREJECTED, the worker checks the status and returns immediately without re-processing or updating timestamps.
- If a duplicate job is submitted for a document that is already
- Queue Deduplication:
- Enqueueing uses deterministic job IDs (
doc-verify-${documentId}). Re-submitting the same document while a job is waiting in the queue is deduplicated by BullMQ.
- Enqueueing uses deterministic job IDs (
- Transient Failures:
- Network timeouts or third-party service glitches trigger BullMQ retries with exponential backoff (up to 3 attempts).
- Permanent Rejections:
- Domain-level rejection (e.g. unreadable scan or corrupt image) transitions the document to
REJECTEDand completes the job successfully without queue retry.
- Domain-level rejection (e.g. unreadable scan or corrupt image) transitions the document to
- Exhausted Retries Defense:
- When all retry attempts are exhausted, the worker's failure handler (
handleExhaustedJobFailure) transitions the document fromPROCESSINGtoREJECTEDwith an explanatory note (Verification failed after 3 attempts: ...). - Documents are never left permanently stuck in
PROCESSING.
- When all retry attempts are exhausted, the worker's failure handler (
Realtime events are emitted strictly after database updates are committed:
- Event Name:
document:status_changed. - Rooms Targeted:
brokerage:<brokerageId>: Received by advisors and brokerage admins managing the case.platform:admins: Received by platform administrators.client:<userId>: Received by the expat client who uploaded the document, updating their portal view.
- Cross-Process Synchronization:
- Emitted directly to Socket.IO when in-process.
- Published to Redis channel
leadflow:events:document_statusfor multi-process environments where workers run separately from the API server.
- Privacy & Security:
- Event payloads contain minimal domain metadata (
documentId,brokerageId,status,title,verificationNotes). - Storage credentials, private keys, and internal tokens are never included.
- Event payloads contain minimal domain metadata (
In a distributed microservice/modular monolith environment, writing to MongoDB and enqueueing to Redis BullMQ cannot be wrapped in a single ACID transaction without expensive two-phase commit (2PC) protocols that degrade HTTP latency and introduce distributed deadlocks.
LeadFlow employs an eventual consistency pattern:
- Durable Intent in MongoDB: Document metadata and binary upload references are committed first to MongoDB in
PENDINGstatus. - Enqueue Isolation: If Redis is unreachable or times out during HTTP upload, the error is isolated: the API client receives HTTP 201 with their document in
PENDINGstate rather than experiencing a 500 failure. - Reconciliation Sweeper (
document-recovery.service.ts):- Stale PENDING Recovery (
reconcilePendingDocuments): Scans for documents created inPENDINGstatus older thanPENDING_DOCUMENT_RECOVERY_THRESHOLD_MS(default: 5 minutes) lacking active BullMQ jobs. Enqueues missing jobs into BullMQ. - Stalled PROCESSING Recovery (
reconcileStalledDocuments): Scans for documents inPROCESSINGstatus older thanSTALLED_DOCUMENT_RECOVERY_THRESHOLD_MS(default: 10 minutes) whose worker crashed or stalled. Atomically resets them toPENDINGwith audit notes and re-enqueues for processing. - Execution Cadence: Runs as a non-blocking sweep upon worker startup (
worker.tsanddocument.worker.ts) and on a recurring interval (RECONCILIATION_INTERVAL_MS, default: 1 minute).
- Stale PENDING Recovery (
| Scenario | Immediate System Reaction | Eventual State & Recovery |
|---|---|---|
| Redis Down on Upload | HTTP 201 returned; document saved in MongoDB as PENDING; enqueue error logged with correlation context. |
Background reconciliation sweeper discovers unenqueued PENDING document once Redis is restored and safely enqueues it. |
| Worker Process Crash | In-flight job loses heartbeat; BullMQ lock expires after lockDuration (30s); stalled job emitted. |
BullMQ stalls handler retries or recovery service atomically resets document from PROCESSING to PENDING and re-queues. |
| Transient Service Glitch | Worker throws retryable error; BullMQ initiates exponential backoff (1s, 2s, 4s). | Job re-runs on next attempt; resumes from PROCESSING status without duplicate client events. |
| Exhausted Retries (3 failures) | BullMQ marks job as failed; handleExhaustedJobFailure triggers. |
Document atomically transitions to REJECTED with notes (Verification failed after 3 attempts); realtime event broadcast. |
| Domain Rejection (Illegible/Corrupt) | Worker flags compliance failure; transitions document directly to REJECTED. |
Job completes successfully without BullMQ retries; client notified in realtime. |
| Duplicate Job Delivery | Worker verifies current document state via atomic query. | If already VERIFIED or REJECTED, job exits idempotently (SKIPPED_TERMINAL) with zero side effects. |
| Cross-Brokerage Payload Tampering | Worker checks withBrokerageScope(payload.brokerageId); detects mismatch with persisted document. |
Worker throws UnrecoverableError; job fails immediately without retrying; foreign document remains untouched. |
| Concurrent Workers | Atomic findOneAndUpdate conditional on status: 'PENDING'. |
Exactly one worker claims the document; rival worker aborts cleanly without double-processing. |