Integrations Domain - SyncContext (Thread-Local Loop Prevention)
Overview
SyncContext is the primary loop prevention mechanism for bidirectional Django ↔ Sanity synchronization. It uses Python's thread-local storage to track sync operations within the same thread, solving the critical problem of infinite webhook loops.
Key Capabilities:
- Thread-local storage for sync source tracking
- Context managers for safe scope management
- Webhook processing detection
- Django-originated operation detection
- Automatic context cleanup
- Debugging utilities
Source Code Location
/home/user/reactsby_nextango/api/nextango/apps/integrations/sync/sync_context.py
The Problem SyncContext Solves
Without SyncContext (Infinite Loop):
# 1. Django Admin updates Product
product.name = "New Name"
product.save()
# 2. Signal triggers sync to Sanity
sync_product_to_sanity(product) # Sends update to Sanity
# 3. Sanity webhook fires back to Django
handle_product_webhook(webhook_data) # Receives webhook from Sanity
# 4. Django processes webhook, updates Product
product.name = "New Name" # Same data
product.save() # Save triggers signal again
# 5. Signal triggers sync to Sanity AGAIN
sync_product_to_sanity(product) # INFINITE LOOP
# 6. Sanity webhook fires back to Django AGAIN
# ... loop continues forever
With SyncContext (Loop Prevented):
# 1. Webhook arrives
with sync_context('sanity_webhook'): # Set thread-local context
# 2. Process webhook
product.name = "New Name"
product.save() # Triggers signal
# 3. Signal checks SyncContext
if should_skip_sync_to_sanity(): # Returns True (we're in webhook)
return # SKIP sync back to Sanity
# 4. Context cleared automatically
# 5. Normal Django operations can now sync to Sanity
Thread-Local Storage
Source: sync_context.py:10-11 Implementation:
import threading
_thread_local = threading.local()
How It Works:
- Each thread has its own isolated storage
- Data stored in one thread is NOT visible to other threads
- Survives across Django ORM operations (save, refresh_from_db)
- Automatically cleaned up when thread terminates
Why This Matters:
Instance attributes like product._sync_source are lost when Django refreshes the instance from the database. Thread-local storage persists across these operations within the same request thread.
SyncContext Class
Source: sync_context.py:14-93 Purpose: Static utility class for managing thread-local sync context Pattern: Stateless utility with static methods only
Core Methods
get_sync_source()
Source: sync_context.py:23-25 Purpose: Retrieve current sync source from thread-local storage Returns: Optional[str] - sync source or None
Method Signature:
@staticmethod
def get_sync_source() -> Optional[str]:
return getattr(_thread_local, 'sync_source', None)
Usage Example:
from nextango.apps.integrations.sync.sync_context import SyncContext
# Check current sync source
source = SyncContext.get_sync_source()
if source == 'sanity_webhook':
print("Currently processing a webhook")
elif source is None:
print("Normal Django operation")
set_sync_source()
Source: sync_context.py:27-31 Purpose: Set sync source in thread-local storage Side Effect: Logs debug message
Method Signature:
@staticmethod
def set_sync_source(source: str) -> None:
_thread_local.sync_source = source
logger.debug(f"Set thread-local sync_source: {source}")
Common Values:
'sanity_webhook'- Processing Sanity webhook (inbound)'django'- Django-originated operation (outbound)'user_domain_sync_from_sanity'- User domain inbound sync'product_domain_sync_from_sanity'- Product domain inbound sync'skip_sync'- Explicitly disable sync
Usage Example:
# In webhook handler
SyncContext.set_sync_source('sanity_webhook')
# All subsequent signal handlers will see this source
product.save() # Signal sees sync_source='sanity_webhook', skips outbound sync
clear_sync_source()
Source: sync_context.py:33-39 Purpose: Remove sync source from thread-local storage Side Effect: Logs debug message with old value
Method Signature:
@staticmethod
def clear_sync_source() -> None:
if hasattr(_thread_local, 'sync_source'):
old_source = _thread_local.sync_source
delattr(_thread_local, 'sync_source')
logger.debug(f"Cleared thread-local sync_source: {old_source}")
Usage Example:
# After webhook processing complete
SyncContext.clear_sync_source()
# Now normal Django operations can sync again
product.save() # Signal will sync to Sanity (no longer in webhook context)
get_context_data()
Source: sync_context.py:41-48 Purpose: Get all context data for debugging Returns: Dict[str, Any]
Method Signature:
@staticmethod
def get_context_data() -> Dict[str, Any]:
return {
'sync_source': getattr(_thread_local, 'sync_source', None),
'webhook_signature': getattr(_thread_local, 'webhook_signature', None),
'transaction_id': getattr(_thread_local, 'transaction_id', None),
}
Usage Example:
# Debug logging in signal handler
context = SyncContext.get_context_data()
logger.info(f"Processing in context: {context}")
# Output: {'sync_source': 'sanity_webhook', 'webhook_signature': 'abc123', 'transaction_id': 'txn-456'}
set_context_data()
Source: sync_context.py:50-56 Purpose: Set multiple context values at once Pattern: Kwargs-based batch update
Method Signature:
@staticmethod
def set_context_data(**kwargs) -> None:
for key, value in kwargs.items():
if value is not None:
setattr(_thread_local, key, value)
logger.debug(f"Set thread-local {key}: {value}")
Usage Example:
# Set multiple context values
SyncContext.set_context_data(
sync_source='sanity_webhook',
webhook_signature='sha256_abc123',
transaction_id='txn-789'
)
# All values now available in thread-local storage
clear_context()
Source: sync_context.py:58-64 Purpose: Clear all context data (complete reset)
Method Signature:
@staticmethod
def clear_context() -> None:
for attr in ['sync_source', 'webhook_signature', 'transaction_id']:
if hasattr(_thread_local, attr):
delattr(_thread_local, attr)
logger.debug("Cleared all thread-local context")
Usage Example:
# Complete cleanup after webhook processing
SyncContext.clear_context()
# All context values now None
assert SyncContext.get_sync_source() is None
assert SyncContext.get_context_data() == {'sync_source': None, 'webhook_signature': None, 'transaction_id': None}
is_webhook_processing()
Source: sync_context.py:66-87 Purpose: Detect if currently processing a webhook from Sanity Returns: Boolean Pattern: Multi-indicator detection
Method Signature:
@staticmethod
def is_webhook_processing() -> bool:
Detection Logic (sync_context.py:74-85):
webhook_indicators = [
'sanity_webhook',
'webhook_processing',
'user_domain_sync_from_sanity',
'product_domain_sync_from_sanity',
'store_domain_sync_from_sanity',
'transaction_domain_sync_from_sanity',
'promotional_campaign_domain_sync_from_sanity',
'domain_sync_from_sanity', # Generic domain sync from Sanity
'hardware_sync_sanity', # Hardware sync operations from Sanity
'from_sanity' # Any sync operation coming FROM Sanity
]
return any(indicator in sync_source for indicator in webhook_indicators)
Usage Example:
from nextango.apps.integrations.sync.sync_context import SyncContext
# In signal handler
if SyncContext.is_webhook_processing():
logger.info("Webhook processing detected, skipping outbound sync")
return # Don't sync back to Sanity
# Normal Django operation, proceed with sync
sync_to_sanity(instance)
Why Partial Matching:
Uses in operator (not exact equality) to catch all webhook-related contexts:
'user_domain_sync_from_sanity'contains'from_sanity'→ detected'webhook_processing_user'contains'webhook_processing'→ detected
is_django_originated()
Source: sync_context.py:89-92 Purpose: Check if operation originated from Django (not a webhook) Returns: Boolean
Method Signature:
@staticmethod
def is_django_originated() -> bool:
return SyncContext.get_sync_source() is None or SyncContext.get_sync_source() == 'django'
Logic:
None= No context set = normal Django operation = originated from Django'django'= Explicitly marked as Django operation
Usage Example:
if SyncContext.is_django_originated():
# This change came from Django admin, API, or internal code
# Safe to sync to Sanity
sync_to_sanity(instance)
else:
# This change came from webhook or other external source
# Do NOT sync back
logger.info("External source detected, skipping sync")
Context Managers
sync_context()
Source: sync_context.py:95-127 Purpose: General-purpose context manager for setting sync source and context data Pattern: Context manager with automatic cleanup and restoration
Function Signature:
@contextmanager
def sync_context(source: str, **context_data):
Parameters:
source(str): Sync source identifier**context_data: Additional context key-value pairs
Behavior (sync_context.py:109-126):
# Store previous context
previous_context = SyncContext.get_context_data()
try:
# Set new context
SyncContext.set_sync_source(source)
SyncContext.set_context_data(**context_data)
logger.info(f"Entered sync context: source={source}, context={context_data}")
yield
finally:
# Restore previous context
SyncContext.clear_context()
if previous_context['sync_source'] is not None:
SyncContext.set_context_data(**previous_context)
logger.info(f"Exited sync context: source={source}")
Key Feature: Nested context support - restores previous context on exit
Usage Example:
from nextango.apps.integrations.sync.sync_context import sync_context
# Basic usage
with sync_context('sanity_webhook'):
# Process webhook
user.save() # Signals see sync_source='sanity_webhook'
# Context automatically cleared
# With additional context data
with sync_context('sanity_webhook', webhook_signature='abc123', transaction_id='txn-456'):
process_webhook_payload(data)
# Nested contexts (advanced)
with sync_context('django'): # Outer context
product.save()
with sync_context('sanity_webhook'): # Inner context overrides
user.save() # Sees 'sanity_webhook'
# Outer context restored here
store.save() # Sees 'django'
webhook_processing_context()
Source: sync_context.py:129-144
Purpose: Specialized context manager for webhook processing
Pattern: Wrapper around sync_context() with preset source
Function Signature:
@contextmanager
def webhook_processing_context(webhook_signature: str = None, transaction_id: str = None):
Parameters:
webhook_signature(str, optional): Webhook signature for verificationtransaction_id(str, optional): Transaction ID for ping-pong prevention
Implementation (sync_context.py:143):
with sync_context('sanity_webhook', webhook_signature=webhook_signature, transaction_id=transaction_id):
yield
Usage Example:
from nextango.apps.integrations.sync.sync_context import webhook_processing_context
# In webhook endpoint view
def handle_product_webhook(request):
webhook_signature = request.headers.get('X-Sanity-Signature')
payload = json.loads(request.body)
with webhook_processing_context(
webhook_signature=webhook_signature,
transaction_id=payload.get('transactionId')
):
# All signal handlers will see sync_source='sanity_webhook'
# and can access webhook_signature and transaction_id from context
process_product_webhook(payload)
return JsonResponse({'status': 'success'})
django_sync_context()
Source: sync_context.py:147-158
Purpose: Context manager for Django-originated sync operations
Pattern: Wrapper around sync_context() with 'django' source
Function Signature:
@contextmanager
def django_sync_context():
Implementation (sync_context.py:157):
with sync_context('django'):
yield
Usage Example:
from nextango.apps.integrations.sync.sync_context import django_sync_context
# Manual sync operation from management command
def sync_all_products_to_sanity():
with django_sync_context():
for product in Product.objects.all():
product.save() # Triggers signal, syncs to Sanity
# Signals see sync_source='django', proceed with outbound sync
Utility Functions
should_skip_sync_to_sanity()
Source: sync_context.py:161-189 Purpose: Primary decision function for skipping Django→Sanity sync Returns: Boolean Pattern: Multi-layer detection logic
Function Signature:
def should_skip_sync_to_sanity() -> bool:
Detection Logic (sync_context.py:168-188):
sync_source = SyncContext.get_sync_source()
if not sync_source:
return False # No context = normal operation = don't skip
# Skip if we're processing a webhook (any webhook-related sync source)
webhook_indicators = [
'sanity_webhook',
'webhook_processing',
'from_sanity' # Any sync operation coming FROM Sanity should not sync back
]
if any(indicator in sync_source for indicator in webhook_indicators):
logger.debug(f"Skipping Django→Sanity sync: webhook processing context ({sync_source})")
return True
# Skip if explicitly marked to skip
if sync_source == 'skip_sync':
logger.debug("Skipping Django→Sanity sync: explicitly disabled")
return True
return False
Usage Example:
from nextango.apps.integrations.sync.sync_context import should_skip_sync_to_sanity
# In Django signal handler (users/signals.py, products/signals.py, etc.)
@receiver(post_save, sender=Product)
def sync_product_to_sanity(sender, instance, created, **kwargs):
# Primary loop prevention check
if should_skip_sync_to_sanity():
logger.info(f"Skipping Product→Sanity sync for {instance.pk}")
return # Skip outbound sync during webhook processing
# Safe to sync to Sanity
sanity_client.mutate_document(instance.to_sanity_format())
Integration Pattern: This function is called in EVERY signal handler across all domains:
- users/signals.py:
sync_user_to_sanity() - products/signals.py:
sync_product_to_sanity() - stores/signals.py:
sync_store_to_sanity() - transactions/signals.py:
sync_transaction_to_sanity()
should_skip_django_processing()
Source: sync_context.py:192-206 Purpose: Check if Django processing should be skipped (future use) Returns: Boolean Status: Placeholder for future functionality
Function Signature:
def should_skip_django_processing() -> bool:
Current Implementation (sync_context.py:199-204):
sync_source = SyncContext.get_sync_source()
# Skip if we're in a Django sync operation
if sync_source == 'django':
logger.debug("Skipping Django processing: Django sync context")
return True
return False
Intended Use Case: When syncing FROM Sanity TO Django, might want to skip certain Django-side processing (validations, triggers, etc.) that would normally occur.
Current Status: Not widely used yet, reserved for future enhancements.
get_sync_context_info()
Source: sync_context.py:209-222 Purpose: Get comprehensive context information for debugging Returns: Dict[str, Any]
Function Signature:
def get_sync_context_info() -> Dict[str, Any]:
Response Structure (sync_context.py:216-221):
context = SyncContext.get_context_data()
context['thread_id'] = threading.current_thread().ident
context['is_webhook_processing'] = SyncContext.is_webhook_processing()
context['is_django_originated'] = SyncContext.is_django_originated()
context['should_skip_sync_to_sanity'] = should_skip_sync_to_sanity()
return context
Usage Example:
from nextango.apps.integrations.sync.sync_context import get_sync_context_info
# Debugging in signal handler
def sync_product_to_sanity(sender, instance, created, **kwargs):
context_info = get_sync_context_info()
logger.info(f"Sync context: {context_info}")
# Output: {
# 'sync_source': 'sanity_webhook',
# 'webhook_signature': 'abc123',
# 'transaction_id': 'txn-456',
# 'thread_id': 140234567890,
# 'is_webhook_processing': True,
# 'is_django_originated': False,
# 'should_skip_sync_to_sanity': True
# }
if context_info['should_skip_sync_to_sanity']:
return
Legacy Support Functions
set_webhook_context()
Source: sync_context.py:226-232 Purpose: Legacy function for setting webhook context Pattern: Convenience wrapper for backward compatibility
Function Signature:
def set_webhook_context(webhook_signature: str = None, transaction_id: str = None):
SyncContext.set_context_data(
sync_source='sanity_webhook',
webhook_signature=webhook_signature,
transaction_id=transaction_id
)
Usage: Prefer webhook_processing_context() context manager over this function.
clear_webhook_context()
Source: sync_context.py:235-237
Purpose: Legacy function for clearing webhook context
Pattern: Wrapper around SyncContext.clear_context()
Function Signature:
def clear_webhook_context():
SyncContext.clear_context()
Usage: Prefer context managers (automatic cleanup) over manual clearing.
Complete Integration Examples
Webhook Processing (Inbound Sync)
Full Flow:
from nextango.apps.integrations.sync.sync_context import webhook_processing_context
from nextango.apps.integrations.models import WebhookEvent, OutboundMutationLog
def handle_product_webhook(request):
payload = json.loads(request.body)
webhook_signature = request.headers.get('X-Sanity-Signature')
# 1. Create webhook event
webhook_event = WebhookEvent.objects.create(
sanity_id=payload['_id'],
document_type=payload['_type'],
revision=payload['_rev'],
payload=payload,
status='received'
)
# 2. Check ping-pong prevention (database layer)
if OutboundMutationLog.is_django_mutation(
sanity_id=payload['_id'],
transaction_id=payload.get('transactionId')
):
webhook_event.transition_to_status('completed',
metadata={'reason': 'ping-pong_prevented_db'})
return JsonResponse({'status': 'skipped', 'reason': 'recent_django_mutation'})
# 3. Process in webhook context (thread-local layer)
with webhook_processing_context(
webhook_signature=webhook_signature,
transaction_id=payload.get('transactionId')
):
# Mark processing started
webhook_event.transition_to_status('processing')
try:
# 4. Sync from Sanity to Django
product, created = Product.objects.update_or_create(
sanity_id=payload['_id'],
defaults={
'name': payload.get('name'),
'price': payload.get('price'),
# ... other fields
}
)
# 5. post_save signal fires
# Signal handler checks: should_skip_sync_to_sanity()
# Returns True (we're in webhook_processing_context)
# SKIPS sync back to Sanity (loop prevented)
# 6. Mark complete
webhook_event.transition_to_status('completed')
action = 'created' if created else 'updated'
return JsonResponse({
'status': 'success',
'action': action,
'product_id': str(product.id)
})
except Exception as e:
webhook_event.increment_retry(error_message=str(e))
return JsonResponse({'status': 'error', 'message': str(e)}, status=500)
# Context automatically cleared here
Django-Originated Sync (Outbound)
Full Flow:
from nextango.apps.integrations.sync.sync_context import should_skip_sync_to_sanity
from nextango.apps.integrations.models import OutboundMutationLog
@receiver(post_save, sender=Product)
def sync_product_to_sanity(sender, instance, created, **kwargs):
"""Signal handler for Product changes"""
# 1. Check thread-local context (primary loop prevention)
if should_skip_sync_to_sanity():
logger.info(f"Skipping Product→Sanity sync: webhook processing context")
return # In webhook context, skip outbound sync
# 2. Check if model has skip flag (instance-level override)
if hasattr(instance, '_skip_sanity_sync') and instance._skip_sanity_sync:
logger.info(f"Skipping Product→Sanity sync: instance flag set")
return
# 3. Safe to proceed with outbound sync
try:
# Prepare data for Sanity
sanity_data = {
'_id': instance.sanity_id,
'_type': 'product',
'name': instance.name,
'price': float(instance.price),
# ... other fields
}
# 4. Send to Sanity
sanity_client = SanityAPIClient()
result = sanity_client.mutate_document(sanity_data)
# 5. Record mutation (database-layer ping-pong prevention)
OutboundMutationLog.objects.create(
sanity_id=instance.sanity_id,
transaction_id=result.get('transactionId'),
mutation_type='update' if not created else 'create'
)
logger.info(f"Successfully synced Product {instance.sanity_id} to Sanity")
except Exception as e:
logger.error(f"Failed to sync Product {instance.sanity_id} to Sanity: {e}")
# Don't raise - let the save succeed even if sync fails
Management Command with Explicit Context
Full Flow:
from django.core.management.base import BaseCommand
from nextango.apps.integrations.sync.sync_context import django_sync_context
class Command(BaseCommand):
help = 'Manually sync all products to Sanity'
def handle(self, *args, **options):
synced_count = 0
failed_count = 0
# Use django_sync_context to mark as Django-originated
with django_sync_context():
for product in Product.objects.all():
try:
# Save triggers signal
# Signal sees sync_source='django'
# should_skip_sync_to_sanity() returns False
# Proceeds with outbound sync
product.save()
synced_count += 1
if synced_count % 100 == 0:
self.stdout.write(f"Synced {synced_count} products...")
except Exception as e:
self.stderr.write(f"Failed to sync {product.sanity_id}: {e}")
failed_count += 1
# Context cleared automatically here
self.stdout.write(self.style.SUCCESS(
f"Sync complete: {synced_count} succeeded, {failed_count} failed"
))
Cross-Domain Usage
SyncContext is used in ALL domain signal handlers:
Users Domain
Source: users/signals.py:145-159
@receiver(post_save, sender=User)
def sync_user_to_sanity(sender, instance, created, **kwargs):
from nextango.apps.integrations.sync.sync_context import should_skip_sync_to_sanity
if should_skip_sync_to_sanity():
sync_source = SyncContext.get_sync_source()
logger.info(f"Skipping User→Sanity sync: {sync_source}")
return
# Proceed with sync...
Products Domain
Source: products/signals.py
@receiver(post_save, sender=Product)
def sync_product_to_sanity(sender, instance, created, **kwargs):
from nextango.apps.integrations.sync.sync_context import should_skip_sync_to_sanity
if should_skip_sync_to_sanity():
return
# Proceed with sync...
Stores Domain
Source: stores/signals.py
@receiver(post_save, sender=Store)
def sync_store_to_sanity(sender, instance, created, **kwargs):
from nextango.apps.integrations.sync.sync_context import should_skip_sync_to_sanity
if should_skip_sync_to_sanity():
return
# Proceed with sync...
Pattern: Every domain follows the same pattern for consistent loop prevention.
Debugging and Troubleshooting
Enable Debug Logging
# settings.py
LOGGING = {
'loggers': {
'nextango.apps.integrations.sync.sync_context': {
'level': 'DEBUG',
'handlers': ['console'],
},
},
}
Debug Output Example:
DEBUG Set thread-local sync_source: sanity_webhook
INFO Entered sync context: source=sanity_webhook, context={'webhook_signature': 'abc123'}
DEBUG Skipping Django→Sanity sync: webhook processing context (sanity_webhook)
INFO Exited sync context: source=sanity_webhook
DEBUG Cleared thread-local sync_source: sanity_webhook
Common Issues
Issue: Sync loop still occurring
Diagnosis:
# Add to signal handler
from nextango.apps.integrations.sync.sync_context import get_sync_context_info
context_info = get_sync_context_info()
logger.error(f"LOOP DETECTED - Context: {context_info}")
Check:
- Is
should_skip_sync_to_sanity()being called? - Is webhook handler setting context properly?
- Is context being cleared prematurely?
Issue: Legitimate syncs being skipped
Diagnosis:
if should_skip_sync_to_sanity():
context = SyncContext.get_sync_source()
logger.warning(f"Sync skipped unexpectedly - source: {context}")
return
Check:
- Is there a stale context from previous operation?
- Is context manager missing
finallyblock for cleanup? - Is thread being reused with leftover context?
Issue: Context not working across Django ORM operations
Solution: This is WHY we use thread-local storage!
# WRONG: Instance attribute lost on refresh
product._sync_source = 'sanity_webhook'
product.save()
product.refresh_from_db() # _sync_source is GONE
# CORRECT: Thread-local survives refresh
SyncContext.set_sync_source('sanity_webhook')
product.save()
product.refresh_from_db() # Context still available
assert SyncContext.get_sync_source() == 'sanity_webhook'
Best Practices
1. Always Use Context Managers
# GOOD: Automatic cleanup
with webhook_processing_context(webhook_signature=sig):
process_webhook(data)
# Context cleared automatically
# BAD: Manual management (error-prone)
SyncContext.set_sync_source('sanity_webhook')
process_webhook(data)
SyncContext.clear_sync_source() # What if exception occurs?
2. Check Context in ALL Signal Handlers
# GOOD: First thing in signal handler
@receiver(post_save, sender=Product)
def sync_product_to_sanity(sender, instance, created, **kwargs):
if should_skip_sync_to_sanity():
return
# ... rest of handler
# BAD: No context check
@receiver(post_save, sender=Product)
def sync_product_to_sanity(sender, instance, created, **kwargs):
sync_to_sanity(instance) # Will cause loops!
3. Use Specific Context Names
# GOOD: Descriptive source names
with sync_context('user_domain_sync_from_sanity'):
process_user_webhook(data)
# BAD: Generic names
with sync_context('sync'):
process_user_webhook(data)
4. Log Context for Debugging
# GOOD: Log context when making decisions
if should_skip_sync_to_sanity():
context = SyncContext.get_sync_source()
logger.info(f"Skipping sync - context: {context}")
return
# BAD: Silent skip (hard to debug)
if should_skip_sync_to_sanity():
return
Related Documentation
- models.md: OutboundMutationLog (database-layer ping-pong prevention)
- signals.md: Integration with domain signal handlers
- services.md: Universal sync coordinator using SyncContext
- views.md: Webhook endpoints setting context
Cross-Domain References:
- users/signals.md: Uses
should_skip_sync_to_sanity()in all sync signals - products/signals.md: Uses
should_skip_sync_to_sanity()in all sync signals - stores/signals.md: Uses
should_skip_sync_to_sanity()in all sync signals - transactions/signals.md: Uses
should_skip_sync_to_sanity()in all sync signals