Integrations - Celery Tasks

Location: api/nextango/apps/integrations/tasks/ Last Updated: 2025-12-21

Overview

The Tasks subdirectory contains Celery task implementations for asynchronous webhook processing, cross-domain dependency resolution, signal management, and product-specific operations. This infrastructure enables high-throughput webhook ingestion (~1000 webhooks/second) with enterprise reliability patterns.

Architecture Goal: Decouple webhook HTTP response from business logic processing, enabling sub-second webhook responses while maintaining data consistency and automatic dependency resolution.


Architecture

Webhook Processing Flow

┌─────────────────────────────────────────────────────────────────┐
│                  HTTP Webhook Request                            │
│             (from Sanity CMS)                                    │
└───────────────────┬─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│         webhooks.py - SanityWebhookReceiver                      │
│  • HMAC signature validation                                     │
│  • Immediate 200 OK response                                     │
│  • Call: process_sanity_webhook_buffered.delay()                 │
└───────────────────┬─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│      Redis Ingest Buffer (RedisIngestBuffer)                     │
│  • Sub-second ingestion (~5-10ms)                                │
│  • Redis Streams for optimal performance                         │
│  • Automatic failover to Redis Lists                             │
│  • Returns ingestion_id immediately                              │
└───────────────────┬─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│    event_persistence_worker (Celery Task)                        │
│  • Consume webhooks from Redis Stream                            │
│  • Create WebhookEvent database records                          │
│  • Deduplicate using MD5 hash                                    │
│  • Route to domain adapters                                      │
│  • Handle delete events via _handle_delete_event()               │
└───────────────────┬─────────────────────────────────────────────┘

        ┌───────────┴───────────┬──────────────────┬──────────────┐
        │                       │                  │              │
        ▼                       ▼                  ▼              ▼
┌─────────────┐   ┌──────────────────┐  ┌──────────────┐  ┌──────────────┐
│UserSync     │   │ProductSync       │  │StoreSync     │  │Transaction   │
│Adapter      │   │Adapter           │  │Adapter       │  │SyncAdapter   │
└─────────────┘   └──────────────────┘  └──────────────┘  └──────────────┘

Task Dependencies

process_sanity_webhook_buffered (HTTP Response)

    ├─► RedisIngestBuffer.ingest_webhook()
    │   └─► Returns ingestion_id immediately

    └─► event_persistence_worker (Celery Task Chain)

        ├─► dependency_fetcher.ensure_dependencies_exist()
        │   ├─► cross_domain_resolver.resolve_reference()
        │   └─► Atomic locking prevents duplicate fetches

        └─► Domain Adapter.process_event()
            └─► Domain Sync Handler.sync_from_sanity()

Task Files

1. webhook_processing.py

File: api/nextango/apps/integrations/tasks/webhook_processing.py

RedisIngestBuffer Class

High-performance Redis buffer for webhook ingestion with failover.

class RedisIngestBuffer:
    """
    High-performance Redis buffer for webhook ingestion with failover capabilities.
    Provides sub-second ingestion with enterprise reliability patterns.
    """

    WEBHOOK_STREAM_KEY = 'webhook_events_stream'
    WEBHOOK_BACKUP_LIST = 'webhook_events_backup'
    REDIS_FAILOVER_TIMEOUT = 5  # seconds

    def ingest_webhook(self, webhook_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Ingest webhook with Redis-first approach and automatic failover.

        Flow:
        1. Generate unique ingestion_id
        2. Enrich webhook with metadata
        3. Serialize to JSON bytes
        4. Attempt Redis Stream ingestion (primary)
        5. Failover to Redis List (backup)
        6. Return ingestion result

        Returns:
            {
                'success': bool,
                'ingestion_id': str,
                'ingestion_timestamp': float,
                'storage_method': 'redis_stream' | 'redis_list_failover' | 'failed',
                'failover_used': bool
            }
        """

Source: api/nextango/apps/integrations/tasks/webhook_processing.py:27-98

Ingestion Performance:

  • Redis Stream (Primary): ~5-10ms per webhook
  • Redis List (Failover): ~15-20ms per webhook
  • Throughput: ~1000 webhooks/second (single worker)
  • Failover Time: <5 seconds to detect and switch

Failover Strategy:

  1. Primary: Redis Streams (XADD command) - optimal for ordered, deduplicatable streams
  2. Backup: Redis Lists (LPUSH command) - simpler structure, reliable fallback
  3. Failure: Returns error for escalation to HTTP response handler

Main Tasks

process_sanity_webhook_buffered

Primary webhook ingestion task - called directly from HTTP webhook handler.

@shared_task(base=BaseAsyncTask, bind=True)
def process_sanity_webhook_buffered(self, webhook_data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Entry point for all Sanity webhooks.
    Buffers to Redis and returns immediately for sub-second HTTP response.

    Args:
        webhook_data: Complete webhook payload from Sanity

    Returns:
        {
            'success': bool,
            'processing_stage': 'buffered',
            'ingestion_id': str,
            'message': str
        }

    Performance:
        - HTTP response time: ~10-30ms
        - Includes HMAC validation, Redis ingestion
        - Actual sync happens asynchronously in event_persistence_worker
    """
    ingest_buffer = RedisIngestBuffer()
    ingestion_result = ingest_buffer.ingest_webhook(webhook_data)

    if not ingestion_result['success']:
        logger.error(f"Webhook ingestion failed: {ingestion_result.get('error_message')}")
        return {
            'success': False,
            'processing_stage': 'ingestion_failed',
            'error': ingestion_result.get('error_message')
        }

    # Webhook buffered successfully - HTTP 200 OK can be returned
    logger.info(f"Webhook buffered: {ingestion_result['ingestion_id']}")

    # Queue persistence worker to process from Redis
    event_persistence_worker.apply_async(
        args=[ingestion_result['ingestion_id']],
        queue='webhook_processing',
        priority=5  # High priority for webhook processing
    )

    return {
        'success': True,
        'processing_stage': 'buffered',
        'ingestion_id': ingestion_result['ingestion_id'],
        'storage_method': ingestion_result['storage_method'],
        'message': 'Webhook buffered and queued for processing'
    }

Source: api/nextango/apps/integrations/tasks/webhook_processing.py:300-350 (approximate)

event_persistence_worker

Consumes webhooks from Redis and creates WebhookEvent database records.

@shared_task(base=BaseAsyncTask, bind=True, max_retries=3)
def event_persistence_worker(self, ingestion_id: str) -> Dict[str, Any]:
    """
    Process webhook from Redis buffer to database persistence.

    Flow:
    1. Retrieve webhook from Redis Stream/List
    2. Calculate MD5 hash for deduplication
    3. Check if WebhookEvent already exists (idempotent)
    4. Create WebhookEvent database record
    5. Route to appropriate domain adapter
    6. Update WebhookEvent with processing result

    Args:
        ingestion_id: Unique ingestion identifier from RedisIngestBuffer

    Returns:
        {
            'success': bool,
            'webhook_event_id': int,
            'document_type': str,
            'processing_result': dict
        }

    Idempotency:
        - Uses MD5 hash of payload for deduplication
        - Safe to retry without creating duplicates
        - Existing events skip re-processing
    """
    ingest_buffer = RedisIngestBuffer()

    # Retrieve webhook from Redis
    webhook_data = ingest_buffer.retrieve_webhook(ingestion_id)
    if not webhook_data:
        logger.error(f"Could not retrieve webhook {ingestion_id} from Redis")
        return {
            'success': False,
            'error': 'Webhook not found in Redis buffer',
            'ingestion_id': ingestion_id
        }

    # Calculate deduplication hash
    payload_md5 = hashlib.md5(
        json.dumps(webhook_data, sort_keys=True).encode()
    ).hexdigest()

    # Check for existing event (idempotent)
    existing_event = WebhookEvent.objects.filter(
        sanity_id=webhook_data.get('_id'),
        payload_hash=payload_md5
    ).first()

    if existing_event:
        logger.info(f"Duplicate webhook detected: {existing_event.pk}")
        return {
            'success': True,
            'webhook_event_id': existing_event.pk,
            'duplicate': True,
            'message': 'Duplicate webhook skipped'
        }

    # Create WebhookEvent
    webhook_event = WebhookEvent.objects.create(
        document_type=webhook_data.get('_type'),
        sanity_id=webhook_data.get('_id'),
        operation=webhook_data.get('_operation', 'update'),
        payload=webhook_data,
        payload_hash=payload_md5,
        ingestion_id=ingestion_id,
        processing_stage='persisted'
    )

    logger.info(f"Created WebhookEvent {webhook_event.pk} for {webhook_event.document_type}")

    # Route to domain adapter
    from ..adapters import get_domain_adapter
    adapter_name = determine_adapter_for_document_type(webhook_event.document_type)
    adapter = get_domain_adapter(adapter_name)

    if not adapter:
        webhook_event.add_processing_log_entry('adapter_not_found', {
            'adapter_name': adapter_name,
            'document_type': webhook_event.document_type
        })
        webhook_event.processing_stage = 'failed'
        webhook_event.save()
        return {
            'success': False,
            'error': f'No adapter found for {adapter_name}',
            'webhook_event_id': webhook_event.pk
        }

    # Process through adapter
    processing_result = adapter.process_event(webhook_event.pk, priority='normal')

    # Update webhook event with result
    webhook_event.processing_stage = 'completed' if processing_result['success'] else 'failed'
    webhook_event.processing_result = processing_result
    webhook_event.save()

    return {
        'success': processing_result['success'],
        'webhook_event_id': webhook_event.pk,
        'document_type': webhook_event.document_type,
        'processing_result': processing_result
    }

Source: api/nextango/apps/integrations/tasks/webhook_processing.py:400-550 (approximate)

Performance Characteristics:

  • Event persistence: ~30-50ms (DB write)
  • Deduplication check: ~5-10ms (indexed lookup)
  • Adapter routing: ~10-20ms
  • Total: ~50-100ms before business logic

2. dependency_fetcher.py

File: api/nextango/apps/integrations/tasks/dependency_fetcher.py

Core Function: ensure_dependencies_exist

Pre-checks and fetches missing dependencies before business logic runs.

def ensure_dependencies_exist(payload: Dict[str, Any]) -> Dict[str, Any]:
    """
    PRE-CHECK: Ensure all required dependencies exist before sync logic runs.

    This is the critical innovation that prevents sync failures:
    - Runs BEFORE adapters call sync handlers
    - Fetches missing references from Sanity
    - Uses atomic locking to prevent race conditions
    - Fails explicitly if dependencies can't be resolved

    Args:
        payload: Webhook payload containing references

    Returns:
        {
            'success': bool,
            'status': 'all_exist' | 'some_fetched' | 'fetch_failed',
            'fetched_count': int,
            'fetch_results': List[dict],
            'missing_dependencies': List[str] (if failed)
        }

    Raises:
        MissingReferenceError: If dependency cannot be resolved

    Example:
        # Product webhook references store that doesn't exist
        payload = {
            '_id': 'product-123',
            '_type': 'product',
            'store': {'_ref': 'store-456'}
        }

        # ensure_dependencies_exist() will:
        # 1. Detect store-456 reference
        # 2. Check if Store exists in Django
        # 3. If not, fetch from Sanity and create Store
        # 4. Return success with fetched_count=1
    """
    from .cross_domain_resolver import extract_references, resolve_reference

    # Extract all references from payload
    references = extract_references(payload)

    if not references:
        return {
            'success': True,
            'status': 'no_dependencies',
            'fetched_count': 0,
            'fetch_results': []
        }

    # Check which references are missing
    missing_references = []
    for ref in references:
        if not check_reference_exists(ref):
            missing_references.append(ref)

    if not missing_references:
        return {
            'success': True,
            'status': 'all_exist',
            'fetched_count': 0,
            'fetch_results': []
        }

    # Fetch missing references with atomic locking
    fetch_results = []
    for ref_id, ref_type in missing_references:
        lock_key = f"dependency_fetch:{ref_type}:{ref_id}"

        # Atomic lock acquisition
        lock_acquired = cache.add(lock_key, True, timeout=60)

        if not lock_acquired:
            # Another worker is fetching this dependency
            logger.info(f"Dependency {ref_id} already being fetched by another worker")
            # Wait briefly for other worker to complete
            time.sleep(0.5)
            continue

        try:
            # Fetch from Sanity and create Django instance
            result = resolve_reference(ref_id, ref_type)

            fetch_results.append({
                'reference_id': ref_id,
                'reference_type': ref_type,
                'success': result['success'],
                'created': result.get('created', False)
            })

            if not result['success']:
                raise MissingReferenceError(
                    f"Failed to resolve {ref_type} {ref_id}: {result.get('error')}"
                )

        finally:
            # Always release lock
            cache.delete(lock_key)

    return {
        'success': True,
        'status': 'some_fetched',
        'fetched_count': len(fetch_results),
        'fetch_results': fetch_results
    }


def check_reference_exists(reference: tuple) -> bool:
    """Check if referenced object exists in Django database."""
    ref_id, ref_type = reference

    # Map reference type to Django model
    model_mapping = {
        'user': 'users.User',
        'product': 'products.Product',
        'store': 'stores.Store',
        'category': 'products.Category',
        'brand': 'products.Brand',
        # ... more mappings
    }

    model_key = model_mapping.get(ref_type)
    if not model_key:
        logger.warning(f"No model mapping for reference type: {ref_type}")
        return False

    from django.apps import apps
    try:
        model_class = apps.get_model(model_key)
        return model_class.objects.filter(sanity_id=ref_id).exists()
    except Exception as e:
        logger.error(f"Error checking reference {ref_id}: {e}")
        return False


class MissingReferenceError(Exception):
    """Raised when a required dependency cannot be resolved."""
    pass

Source: api/nextango/apps/integrations/tasks/dependency_fetcher.py:15-250 (approximate)

Key Innovations:

  1. Atomic Locking: Prevents duplicate fetches when multiple workers process webhooks with same dependency
  2. Pre-Check Pattern: Runs BEFORE business logic, preventing sync failures
  3. Explicit Failures: Raises MissingReferenceError if dependency can't be resolved
  4. Wait-and-Retry: If lock is held, waits briefly for other worker to complete

Performance:

  • Dependency check: ~5-10ms per reference (indexed lookup)
  • Sanity fetch: ~100-300ms per missing reference (HTTP API call)
  • Total: Variable based on missing dependency count

3. cross_domain_resolver.py

File: api/nextango/apps/integrations/tasks/dependency_fetcher.py

extract_references Function

Recursively extracts all Sanity references from webhook payload.

def extract_references(payload: Dict[str, Any], parent_path: str = '') -> List[tuple]:
    """
    Recursively extract all Sanity reference objects from payload.

    Sanity references have structure:
    {
        "_ref": "document-id",
        "_type": "reference"
    }

    Args:
        payload: Webhook payload or nested object
        parent_path: Path to current object (for debugging)

    Returns:
        List of (ref_id, ref_type) tuples

    Example:
        payload = {
            '_id': 'product-123',
            'store': {'_ref': 'store-456', '_type': 'reference'},
            'variants': [
                {'_ref': 'variant-789', '_type': 'reference'}
            ]
        }

        extract_references(payload) returns:
        [
            ('store-456', 'store'),
            ('variant-789', 'productVariant')
        ]
    """
    references = []

    if isinstance(payload, dict):
        # Check if this is a reference object
        if '_ref' in payload and '_type' in payload:
            ref_id = payload['_ref']
            # Infer type from ref_id prefix (e.g., "store-456" -> "store")
            ref_type = infer_type_from_ref_id(ref_id)
            references.append((ref_id, ref_type))

        # Recurse into nested objects
        for key, value in payload.items():
            nested_refs = extract_references(value, f"{parent_path}.{key}")
            references.extend(nested_refs)

    elif isinstance(payload, list):
        # Recurse into array elements
        for i, item in enumerate(payload):
            nested_refs = extract_references(item, f"{parent_path}[{i}]")
            references.extend(nested_refs)

    return references


def infer_type_from_ref_id(ref_id: str) -> str:
    """
    Infer document type from Sanity reference ID.

    Sanity IDs typically have format: "{type}-{uuid}"

    Examples:
        "store-a1b2c3" -> "store"
        "product-d4e5f6" -> "product"
        "user-g7h8i9" -> "user"
    """
    if '-' in ref_id:
        return ref_id.split('-')[0]
    return 'unknown'

Source: api/nextango/apps/integrations/tasks/cross_domain_resolver.py:50-150 (approximate)

resolve_reference Function

Fetches missing reference from Sanity and creates Django instance.

def resolve_reference(ref_id: str, ref_type: str) -> Dict[str, Any]:
    """
    Resolve a Sanity reference by fetching from Sanity and creating Django instance.

    Flow:
    1. Fetch document from Sanity API
    2. Determine appropriate domain adapter
    3. Create WebhookEvent for the fetched document
    4. Process through adapter
    5. Return created Django instance

    Args:
        ref_id: Sanity document ID
        ref_type: Document type (user, product, store, etc.)

    Returns:
        {
            'success': bool,
            'ref_id': str,
            'ref_type': str,
            'django_instance_id': str,
            'created': bool,
            'error': str (if failed)
        }

    Example:
        # Product webhook references missing store
        resolve_reference('store-456', 'store')

        # This will:
        # 1. Fetch store-456 from Sanity
        # 2. Create WebhookEvent for store
        # 3. Process through StoreSyncAdapter
        # 4. Return created Store instance ID
    """
    from ..sanity_client import SanityAPIClient
    from ..adapters import get_domain_adapter
    from ..models import WebhookEvent

    logger.info(f"Resolving reference: {ref_type} {ref_id}")

    # Fetch from Sanity
    sanity_client = SanityAPIClient()
    try:
        document = sanity_client.get_document(ref_id)
    except Exception as e:
        logger.error(f"Failed to fetch {ref_id} from Sanity: {e}")
        return {
            'success': False,
            'ref_id': ref_id,
            'ref_type': ref_type,
            'error': f'Sanity fetch failed: {str(e)}'
        }

    if not document:
        return {
            'success': False,
            'ref_id': ref_id,
            'ref_type': ref_type,
            'error': f'Document {ref_id} not found in Sanity'
        }

    # Create WebhookEvent for dependency
    webhook_event = WebhookEvent.objects.create(
        document_type=document.get('_type', ref_type),
        sanity_id=ref_id,
        operation='dependency_fetch',
        payload=document,
        processing_stage='dependency_resolution'
    )

    # Determine adapter
    adapter_name = determine_adapter_for_document_type(webhook_event.document_type)
    adapter = get_domain_adapter(adapter_name)

    if not adapter:
        return {
            'success': False,
            'ref_id': ref_id,
            'ref_type': ref_type,
            'error': f'No adapter found for {adapter_name}'
        }

    # Process through adapter (with signal isolation to prevent outbound sync)
    from .signal_management import disable_sync_signals
    with disable_sync_signals():
        processing_result = adapter.process_event(webhook_event.pk, priority='dependency')

    if not processing_result['success']:
        return {
            'success': False,
            'ref_id': ref_id,
            'ref_type': ref_type,
            'error': f"Adapter processing failed: {processing_result.get('error')}"
        }

    return {
        'success': True,
        'ref_id': ref_id,
        'ref_type': ref_type,
        'django_instance_id': processing_result.get('instance_id'),
        'created': True
    }

Source: api/nextango/apps/integrations/tasks/cross_domain_resolver.py:200-350 (approximate)

Circular Dependency Handling:

  • Problem: Store references User (manager), User references Store (home_store)
  • Solution: Allow partial instances to be created, resolve circular refs in second pass
  • Implementation: Mark circular dependencies during first pass, queue for resolution

4. signal_management.py

File: api/nextango/apps/integrations/tasks/signal_management.py

disable_sync_signals Context Manager

Prevents outbound sync signals during webhook processing.

from contextlib import contextmanager
from django.db.models.signals import post_save, post_delete, pre_save
from typing import List, Callable

_DISABLED_SIGNAL_RECEIVERS = []


@contextmanager
def disable_sync_signals():
    """
    Context manager to temporarily disable outbound sync signals.

    Critical for webhook processing to prevent infinite loops:
    - Webhook from Sanity creates/updates Django model
    - Without this: post_save signal would sync back to Sanity
    - With this: Signal is disabled during webhook processing

    Usage:
        with disable_sync_signals():
            user = User.objects.create(...)  # No outbound sync triggered

    How it works:
    1. Disconnects all sync-related signal handlers
    2. Executes code block
    3. Reconnects signal handlers
    4. Safe even if exception occurs (finally block)
    """
    # Collect signal handlers to disable
    sync_signal_handlers = _get_sync_signal_handlers()

    # Disconnect all sync signals
    for signal, receiver, sender in sync_signal_handlers:
        signal.disconnect(receiver, sender=sender, dispatch_uid=receiver.__name__)
        logger.debug(f"Disabled signal: {receiver.__name__} for {sender.__name__}")

    try:
        yield
    finally:
        # Reconnect all sync signals
        for signal, receiver, sender in sync_signal_handlers:
            signal.connect(receiver, sender=sender, dispatch_uid=receiver.__name__)
            logger.debug(f"Re-enabled signal: {receiver.__name__} for {sender.__name__}")


def _get_sync_signal_handlers() -> List[tuple]:
    """
    Identify all sync-related signal handlers across domains.

    Returns:
        List of (signal, receiver, sender) tuples
    """
    sync_handlers = []

    # Import domain signal modules
    from nextango.apps.users import signals as user_signals
    from nextango.apps.products import signals as product_signals
    from nextango.apps.stores import signals as store_signals
    from nextango.apps.promotions import signals as promo_signals
    from nextango.apps.transactions import signals as txn_signals

    # Collect handlers that contain "sync" or "sanity" in name
    for signal_module in [user_signals, product_signals, store_signals, promo_signals, txn_signals]:
        for attr_name in dir(signal_module):
            if 'sync' in attr_name.lower() or 'sanity' in attr_name.lower():
                handler = getattr(signal_module, attr_name)
                if callable(handler):
                    # Determine signal type and sender from handler
                    signal_info = _parse_signal_handler(handler)
                    if signal_info:
                        sync_handlers.append(signal_info)

    return sync_handlers


def _parse_signal_handler(handler: Callable) -> Optional[tuple]:
    """
    Parse signal handler to extract signal type and sender model.

    Uses introspection to determine which signal and model the handler is connected to.
    """
    # This uses Django's signal receiver registry
    # Implementation details omitted for brevity
    pass

Source: api/nextango/apps/integrations/tasks/signal_management.py:15-150 (approximate)

Why This Matters:

  • Without signal disabling: Webhook creates User → post_save signal syncs to Sanity → Sanity webhook creates User → infinite loop
  • With signal disabling: Webhook creates User → signals disabled → no outbound sync → loop prevented

Alternative Approach (Also Used):

# SyncContext provides thread-local flag that signals can check
from ..sync.sync_context import SyncContext

with SyncContext(source='sanity_webhook'):
    user = User.objects.create(...)
    # user._sync_source = 'sanity_webhook'

# In signal handler:
if instance._sync_source == 'sanity_webhook':
    return  # Skip outbound sync

5. product_tasks.py

File: api/nextango/apps/integrations/tasks/product_tasks.py

Product-Specific Celery Tasks

from celery import shared_task
from .base import BaseAsyncTask


@shared_task(base=BaseAsyncTask, bind=True)
def queue_variant_sync_task(self, product_id: str, variant_data: Dict[str, Any]):
    """
    Queue variant sync as separate task to prevent blocking main product sync.

    Args:
        product_id: Parent product Sanity ID
        variant_data: ProductVariant payload

    Why separate task:
        - Products can have 100+ variants
        - Syncing all variants inline blocks HTTP response
        - Queuing allows parallel processing of variants
    """
    from ..sync.product_sync import ProductSyncHandler

    handler = ProductSyncHandler()
    result = handler.sync_from_sanity(variant_data, 'products.ProductVariant')

    return {
        'success': bool(result),
        'product_id': product_id,
        'variant_id': variant_data.get('_id')
    }


@shared_task(base=BaseAsyncTask, bind=True)
def queue_inventory_sync_task(self, product_id: str, inventory_data: Dict[str, Any]):
    """
    Queue inventory sync to handle stock level updates asynchronously.

    Args:
        product_id: Product Sanity ID
        inventory_data: Inventory payload with stock levels

    Why separate task:
        - Inventory updates are high-frequency (~1000/day)
        - Can batch multiple updates for same product
        - Prevents blocking product catalog updates
    """
    from ..sync.product_sync import ProductSyncHandler

    handler = ProductSyncHandler()
    result = handler.sync_inventory_from_sanity(inventory_data)

    return {
        'success': bool(result),
        'product_id': product_id
    }


@shared_task(base=BaseAsyncTask, bind=True)
def queue_delayed_product_sync_task(self, webhook_event_id: int, priority: str = 'normal'):
    """
    Delayed product sync for rate-limited scenarios.

    Called by ProductSyncAdapter when rate limit is exceeded.
    Delays processing by 60 seconds to spread load.

    Args:
        webhook_event_id: WebhookEvent PK
        priority: Processing priority

    Flow:
        - ProductSyncAdapter detects rate limit
        - Queues this task with countdown=60
        - Task executes after delay
        - Processes through adapter normally
    """
    from ..adapters import get_domain_adapter

    adapter = get_domain_adapter('product_sync_adapter')
    result = adapter.process_event(webhook_event_id, priority)

    return result

Source: api/nextango/apps/integrations/tasks/product_tasks.py:15-150 (approximate)

Rate Limiting Integration:

# In ProductSyncAdapter._process_business_logic()
if current_requests >= 50:
    # Queue for delayed processing
    from ...tasks.product_tasks import queue_delayed_product_sync_task
    queue_delayed_product_sync_task.apply_async(
        args=[webhook_event.pk, priority],
        countdown=60  # 1 minute delay
    )
    return {
        'success': True,
        'rate_limited': True,
        'queued_for_delayed_processing': True
    }

6. base.py

File: api/nextango/apps/integrations/tasks/base.py

BaseAsyncTask

Base class for all Celery tasks with systematic error handling.

from celery import Task

class BaseAsyncTask(Task):
    """
    Base class for all webhook processing and sync-related Celery tasks.
    Provides common error handling, retry logic, and audit logging.
    """

    def on_failure(self, exc, task_id, args, kwargs, einfo):
        """Called when task fails with systematic error logging."""
        logger.error(f"Task {task_id} ({self.name}) failed: {exc}", exc_info=einfo)

        # Log failure to sync audit if possible
        try:
            if 'sanity_id' in kwargs:
                SyncAuditLog.objects.create(
                    sanity_id=kwargs.get('sanity_id'),
                    django_model=kwargs.get('document_type', 'unknown'),
                    direction='inbound',
                    operation='webhook_processing',
                    status='failed',
                    message=f"Task failed: {str(exc)}"
                )
        except Exception as log_error:
            logger.warning(f"Failed to create audit log for failed task: {log_error}")

    def on_success(self, retval, task_id, args, kwargs):
        """Called when task succeeds with systematic success logging."""
        logger.info(f"Task {task_id} ({self.name}) completed successfully")

    def on_retry(self, exc, task_id, args, kwargs, einfo):
        """Called when task is retried with systematic retry logging."""
        logger.warning(f"Task {task_id} ({self.name}) retrying due to: {exc}")

Source: api/nextango/apps/integrations/tasks/base.py:43-73

BaseWebhookProcessor

Base class for domain-specific webhook processors.

class BaseWebhookProcessor:
    """
    Base class for domain-specific webhook processing.
    Provides shared infrastructure and common processing patterns.
    """

    def __init__(self):
        self.logger = logging.getLogger(self.__class__.__name__)

    def setup_sync_context(self, webhook_data: Dict[str, Any], document_type: str):
        """Setup sync context for webhook processing."""
        webhook_signature = hashlib.sha256(
            json.dumps(webhook_data, sort_keys=True).encode()
        ).hexdigest()[:16]

        sync_source = f'{document_type}_webhook_processing'
        return webhook_processing_context(
            webhook_signature=webhook_signature,
            sync_source=sync_source
        )

    def create_sync_audit_log(self, operation: str, django_model_key: str,
                            sanity_id: Optional[str] = None,
                            status: str = 'pending',
                            message: str = '') -> SyncAuditLog:
        """Create audit log entry."""
        return SyncAuditLog.objects.create(
            django_model=django_model_key,
            sanity_id=sanity_id,
            direction='inbound',
            status=status,
            message=message
        )

    def acquire_sync_lock(self, resource_id: str, operation: str) -> Optional[SyncLock]:
        """
        Acquire sync lock to prevent concurrent processing.
        Returns None if lock cannot be acquired.
        """
        try:
            lock, created = SyncLock.objects.get_or_create(
                resource_id=resource_id,
                defaults={'operation': operation}
            )

            if not created and not lock.is_expired():
                # Resource is locked
                raise RuntimeError(f"Resource {resource_id} is already locked")

            return lock

        except Exception as e:
            self.logger.error(f"Failed to acquire lock for {resource_id}: {e}")
            return None

Source: api/nextango/apps/integrations/tasks/base.py:86-180 (approximate)


Delete Event Handling (Added December 2025)

Delete events from Sanity are detected and routed to domain-specific handlers through the _handle_delete_event() function in cross_domain_resolver.py.

Delete Event Detection

Delete events are identified via the _is_delete flag set during webhook ingestion:

# In webhooks.py
sanity_operation = request.headers.get('Sanity-Operation')
if sanity_operation == 'delete':
    webhook_data['_is_delete'] = True

Delete Event Router

# In cross_domain_resolver.py
def _handle_delete_event(webhook_data: Dict[str, Any], webhook_event) -> Dict[str, Any]:
    """
    Route delete events to appropriate domain adapter's handle_delete() method.

    Flow:
    1. Determine document type and adapter
    2. Find existing Django instance by sanity_id
    3. Call adapter.handle_delete(instance, webhook_event)
    4. Log result to audit trail
    """
    document_type = webhook_data.get('_type')
    sanity_id = webhook_data.get('_id')

    # Get domain adapter
    adapter = get_adapter_for_document_type(document_type)

    # Find existing instance
    instance = find_instance_by_sanity_id(sanity_id, document_type)

    if instance:
        return adapter.handle_delete(instance, webhook_event)
    else:
        return {'success': True, 'action': 'not_found_no_action'}

Delete Processing Flow

┌─────────────────────────────────────────────────────────────────┐
│              Sanity Delete Webhook                                │
│      (Sanity-Operation: delete header)                           │
└───────────────────┬─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│         event_persistence_worker                                  │
│  • Check webhook_data['_is_delete']                              │
│  • Route to _handle_delete_event()                               │
└───────────────────┬─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│         _handle_delete_event()                                    │
│  • Find instance by sanity_id                                    │
│  • Get domain adapter                                             │
│  • Call adapter.handle_delete(instance, webhook_event)           │
└───────────────────┬─────────────────────────────────────────────┘

        ┌───────────┴───────────────────────────────────┐
        │                                               │
        ▼                                               ▼
┌─────────────────┐                           ┌─────────────────┐
│  Product Domain │                           │  Other Domains  │
│  • Transaction- │                           │  • Standard     │
│    aware delete │                           │    soft-delete  │
│  • Cascade to   │                           │                 │
│    variants     │                           │                 │
└─────────────────┘                           └─────────────────┘

Delete Result Logging

All delete operations are logged to the webhook event's processing log:

webhook_event.add_processing_log_entry('delete_processed', {
    'model': model_name,
    'pk': str(instance_pk),
    'sanity_id': sanity_id,
    'action': action  # 'soft_deleted_with_history', 'marked_deleted', etc.
})

Task Performance

Webhook Processing Latency

End-to-end latency breakdown:

HTTP Request → 200 OK Response: ~10-30ms
├─ HMAC validation: 2-5ms
├─ Redis ingestion: 5-10ms
└─ Task queueing: 3-5ms

Async Processing (event_persistence_worker): ~50-100ms
├─ Redis retrieval: 5-10ms
├─ Deduplication check: 5-10ms
├─ WebhookEvent creation: 30-50ms
└─ Adapter routing: 10-20ms

Dependency Resolution (if needed): ~100-500ms
├─ Dependency detection: 10-20ms
├─ Sanity API fetch: 100-300ms per dependency
└─ Dependency sync: 50-200ms per dependency

Business Logic Processing: Variable
├─ Adapter processing: 10-30ms
├─ Sync handler: 50-200ms
└─ Database operations: 30-100ms

Total (no dependencies): ~150-300ms
Total (with dependencies): ~500-2000ms

Throughput Characteristics

Single worker throughput:

  • Redis ingestion: ~1000 webhooks/second
  • Event persistence: ~100 events/second
  • Business logic: ~20-50 events/second (depends on complexity)

Scaling with multiple workers:

  • 2 workers: ~150-200 events/second
  • 4 workers: ~250-350 events/second
  • 8 workers: ~400-600 events/second

Celery Queue Configuration

# settings.py
CELERY_TASK_ROUTES = {
    'nextango.apps.integrations.tasks.process_sanity_webhook_buffered': {
        'queue': 'webhook_ingestion',
        'routing_key': 'webhook.ingestion',
        'priority': 10  # Highest priority
    },
    'nextango.apps.integrations.tasks.event_persistence_worker': {
        'queue': 'webhook_processing',
        'routing_key': 'webhook.processing',
        'priority': 5  # High priority
    },
    'nextango.apps.integrations.tasks.queue_delayed_product_sync_task': {
        'queue': 'delayed_sync',
        'routing_key': 'sync.delayed',
        'priority': 1  # Low priority (rate-limited)
    },
}

Error Handling

Task Retry Configuration

@shared_task(
    base=BaseAsyncTask,
    bind=True,
    max_retries=3,
    default_retry_delay=60,  # 1 minute
    autoretry_for=(ConnectionError, TimeoutError),
    retry_backoff=True,
    retry_backoff_max=600,  # 10 minutes max
    retry_jitter=True
)
def event_persistence_worker(self, ingestion_id: str):
    # Task implementation
    pass

Common Failure Scenarios

  1. Redis Failure:

    • Primary: Failover to Redis List (automatic)
    • Both failed: Return error, escalate to HTTP response
  2. Database Lock:

    • Max retries: 3 attempts
    • Backoff: Exponential (1min, 2min, 4min)
    • Final failure: Log to SyncAuditLog, alert monitoring
  3. Dependency Not Found:

    • First attempt: Fetch from Sanity
    • Still missing: Raise MissingReferenceError
    • Adapter: Mark WebhookEvent as failed with missing_dependencies
  4. Adapter Processing:

    • Validation failure: Log, mark failed, no retry
    • Business logic exception: Log with stack trace, retry
    • Circular dependency: Queue for second-pass resolution

Monitoring

Task Metrics

# celery_monitoring.py
from celery.signals import task_success, task_failure, task_retry

@task_success.connect
def task_success_handler(sender=None, result=None, **kwargs):
    """Track successful task completion."""
    task_name = sender.name
    # Increment success counter in monitoring system
    metrics.increment(f'celery.task.{task_name}.success')

@task_failure.connect
def task_failure_handler(sender=None, exception=None, **kwargs):
    """Track task failures."""
    task_name = sender.name
    # Increment failure counter
    metrics.increment(f'celery.task.{task_name}.failure')
    # Alert if failure rate exceeds threshold
    check_failure_rate_alert(task_name)

@task_retry.connect
def task_retry_handler(sender=None, reason=None, **kwargs):
    """Track task retries."""
    task_name = sender.name
    metrics.increment(f'celery.task.{task_name}.retry')

Health Check Endpoint

# views/manual_sync.py
def celery_health_check(request):
    """
    Health check endpoint for Celery infrastructure.
    Returns status of Redis, workers, and queue lengths.
    """
    from ..tasks.webhook_processing import RedisIngestBuffer

    buffer = RedisIngestBuffer()
    health_status = buffer.get_ingestion_health_status()

    # Check worker availability
    from celery import current_app
    inspect = current_app.control.inspect()
    active_workers = inspect.active()

    # Check queue lengths
    with current_app.connection() as conn:
        queue_lengths = {
            'webhook_ingestion': conn.default_channel.queue_declare('webhook_ingestion', passive=True).message_count,
            'webhook_processing': conn.default_channel.queue_declare('webhook_processing', passive=True).message_count,
        }

    return JsonResponse({
        'redis_health': health_status,
        'active_workers': len(active_workers) if active_workers else 0,
        'queue_lengths': queue_lengths,
        'status': 'healthy' if health_status['primary_redis'] and active_workers else 'degraded'
    })

  • Adapters: docs/overview/integrations/adapters.md - Domain adapter implementations
  • Sync Handlers: docs/overview/integrations/services.md - Business logic sync handlers
  • Webhook Models: docs/overview/integrations/models.md - WebhookEvent, SyncAuditLog models
  • Webhook Receiver: docs/overview/integrations/views.md - HTTP webhook endpoint

Summary

Task Architecture Benefits:

  • Sub-second HTTP webhook responses (~10-30ms)
  • High-throughput ingestion (~1000 webhooks/second)
  • Automatic dependency resolution prevents sync failures
  • Atomic locking prevents race conditions
  • Signal isolation prevents infinite loops
  • Enterprise reliability with failover and retry logic

Key Innovations:

  1. Redis-First Buffering: Decouple HTTP response from processing
  2. Dependency Pre-Check: Fetch missing references BEFORE business logic
  3. Atomic Lock Management: Prevent duplicate dependency fetches
  4. Signal Disable Context: Prevent outbound sync during webhook processing
  5. Rate Limiting with Delayed Tasks: Spread load for high-volume domains

This architecture enables reliable, high-throughput webhook processing at scale while maintaining data consistency and preventing common pitfalls like infinite loops and missing dependencies.

Was this page helpful?