Sync Conflicts - Technical Manual

Version: 1.0
Last Updated: 2026-07-16

This guide is intended for Developers, System Integrators, and DevOps personnel.


Feature Overview

The Sync Conflicts feature provides conflict detection and resolution for MQUAD's offline-first survey architecture. When enumerators collect data offline and later sync, the system detects when server data has changed since the last sync and requires supervised resolution.

The architecture follows these principles:

  • Offline Tolerance: Conflicts are expected and handled gracefully
  • Data Preservation: Both versions are retained until resolved
  • Tenant Isolation: Conflicts are scoped to individual tenants
  • Audit Trail: All conflicts and resolutions are logged

Architecture

┌─────────────────┐     ┌──────────────┐     ┌─────────────────┐
│   Mobile App    │     │ Sync Service │     │  Data Service   │
│                 │     │              │     │                 │
│ SyncQueue       │────▶│ SyncPush     │────▶│ SurveyResponse  │
│ ConflictDao     │◀───▶│ ConflictDetector│◀─▶│ ResponseAnswer  │
│ ResponsesDao    │     │ ConflictResolver│   │                 │
└─────────────────┘     └──────────────┘     └─────────────────┘
                              ▲
                              │
                              ▼
                    ┌─────────────────┐
                    │ Form Service    │
                    │ (Interview Mgmt)│
                    └─────────────────┘

Services Involved

Sync Service (Port 8003)

The sync-service owns the conflict lifecycle:

  • ConflictDetectorService: Detects conflicts during push operations
  • ConflictResolverService: Executes resolution strategies
  • SyncPushRecord: Tracks push operation status
  • SyncConflict: Persists conflict records

Data Service (Port 8002)

The data-service provides response storage and conflict triggers:

  • SurveyResponse: Primary entity being synchronized
  • ResponseAnswer: Answer records linked to responses
  • HTTP 409 Responses: Trigger conflict detection

Form Service (Port 8010)

The form-service manages the interview lifecycle:

  • InterviewExecution: Represents an active survey session
  • Links to SurveyResponse via interview_id

Data Flow

Diagram; its source is available below
Diagram source
sequenceDiagram
    participant Mobile as Mobile App
    participant Sync as Sync Service
    participant Data as Data Service

    Mobile->>Sync: POST /sync/push (idempotency_key, answers)
    Sync->>Sync: Create SyncPushRecord (pending)
    Sync->>Data: POST /responses/ (forward payload)
    alt HTTP 409 Conflict
        Data-->>Sync: 409 Conflict
        Sync->>Sync: ConflictDetector.detect_conflict()
        Sync->>Data: GET /responses/{id} (fetch server version)
        Sync->>Sync: Create SyncConflict record
        Sync->>Sync: Update SyncPushRecord (status=conflict)
        Sync-->>Mobile: Response(status=conflict)
    else Success
        Data-->>Sync: 201 Created
        Sync->>Sync: Update SyncPushRecord (completed)
        Sync-->>Mobile: Response(status=completed)
    end

Lifecycle

Diagram; its source is available below
Diagram source
stateDiagram-v2
    [*] --> Pending: Push initiated
    Pending --> Processing: Forward to data-service
    Processing --> Completed: Success (201)
    Processing --> Conflict: HTTP 409
    Processing --> Failed: Other error
    Conflict --> Unresolved: Conflict detected
    Unresolved --> Resolved_Server: Supervisor chooses server_wins
    Unresolved --> Resolved_Client: Supervisor chooses client_wins
    Resolved_Server --> Completed: Push marked completed
    Resolved_Client --> Completed: Data updated on server
    Failed --> Pending: Retry (background)
    Failed --> Permanent_Failure: Max retries exceeded

Entity Relationships

Diagram; its source is available below
Diagram source
erDiagram
    SYNC_CONFLICT ||--o{ SYNC_PUSH_RECORD : "references"
    SYNC_CONFLICT }|--|| SURVEY_RESPONSE : "references"
    SURVEY_RESPONSE ||--o{ RESPONSE_ANSWER : "contains"
    INTERVIEW_EXECUTION ||--|| SURVEY_RESPONSE : "creates"
    
    SYNC_CONFLICT {
        uuid id
        uuid tenant_id
        string device_id
        string response_id
        string conflict_type
        json server_version
        json client_version
        string resolution
        datetime resolved_at
        uuid resolved_by
        datetime created_at
    }
    
    SYNC_PUSH_RECORD {
        uuid id
        uuid user_id
        uuid tenant_id
        string device_id
        string idempotency_key
        string status
        string response_id
        text request_payload
        text error_message
    }
    
    SURVEY_RESPONSE {
        uuid id
        uuid tenant_id
        uuid survey_id
        uuid version_id
        uuid interview_id
        string respondent_id
        string status
    }
    
    INTERVIEW_EXECUTION {
        uuid id
        uuid tenant_id
        uuid survey_id
        string status
        string session_token
    }

Conflict Creation Process

  1. Push Receipt: Sync-service receives POST to /sync/push with response data
  2. Record Creation: SyncPushRecord created with status=pending
  3. Forward Request: Payload forwarded to data-service /responses/
  4. Conflict Detection: HTTP 409 triggers ConflictDetectorService.detect_conflict()
  5. Server Fetch: Current server response fetched for comparison
  6. Comparison Logic:
    • If client_sync_time provided: Compare server.updated_at > client_sync_time
    • If no timestamp: Compare server.answers vs client.answers
  7. Record Creation: SyncConflict persisted with both versions
  8. Status Update: SyncPushRecord.status = conflict

Conflict Resolution Process

Server Wins Strategy

ConflictResolverService.resolve_conflict(strategy="server_wins")
    │
    ├── Set conflict.resolution = "server_wins"
    ├── Set conflict.resolved_at = now()
    ├── Set conflict.resolved_by = user_id
    └── Update SyncPushRecord.status = "failed"

No data modifications occur on the server. The client changes are discarded.

Client Wins Strategy

ConflictResolverService.resolve_conflict(strategy="client_wins")
    │
    ├── Extract client_version from conflict
    ├── PUT /responses/{response_id} to data-service
    │   └── Updates SurveyResponse and ResponseAnswer records
    ├── Set conflict.resolution = "client_wins"
    ├── Set conflict.resolved_at = now()
    ├── Set conflict.resolved_by = user_id
    └── Update SyncPushRecord.status = "completed"

The server is overwritten with the client's version.


Permission Model

All conflict endpoints require the response:read permission:

EndpointPermission
GET /conflicts/response:read
GET /conflicts/{id}response:read
POST /conflicts/{id}/resolveresponse:read

The permission is enforced via check_permissions dependency in the controller. Users must have this permission in their role's permission list for the current tenant.


Event Flow

Planned Events (not yet implemented):

  • conflict.detected - When conflict is created
  • conflict.resolved - When resolution is applied
  • conflict.rejected - When conflict is rejected

Current Events:

  • response.submitted - Published on successful response creation

Failure Scenarios

ScenarioDetectionHandling
Network timeout during conflict detectionException in detectorConflict not created; push marked failed
Data-service unavailableHTTP exceptionConflict not created; push marked failed
Invalid strategyValueError in resolverHTTP 400 returned
Conflict already resolvedCheck in resolverHTTP 400 returned
Server unreachable during client_winsHTTP exception in resolverResolution fails; conflict remains unresolved

Logging

Sync Service Logs:

  • Conflict detection: logger.info(f"Conflict detected and recorded for response {response_id}")
  • Resolution: logger.info(f"Conflict resolved using '{data.strategy}' strategy")
  • Errors: logger.error(...) for all exception cases

Log Fields:

  • tenant_id - Scope of the conflict
  • device_id - Originating device
  • response_id - Affected response
  • user_id - Resolver (when applicable)

Monitoring

Database Metrics:

  • sync_conflicts table with indexes on tenant_id, response_id, resolution
  • Query performance optimized for unresolved conflict lookup

Application Metrics:

  • Prometheus metrics available at /api/v1/metrics
  • Conflict count can be derived from database queries

Performance Considerations

  • Conflict list endpoint: Queries all unresolved conflicts; no pagination optimization for large datasets
  • Conflict resolution: Client wins triggers HTTP PUT to data-service; may be slow with large responses
  • Idempotency: Push records deduplicated by (tenant_id, device_id, idempotency_key)
  • Retry limits: Push records have max_retries=3 with exponential backoff

Sequence Diagram

Diagram; its source is available below
Diagram source
sequenceDiagram
    participant M as Mobile/Device
    participant S as Sync Service
    participant D as Data Service
    participant DB1 as sync_db
    participant DB2 as data_db

    M->>S: POST /sync/push
    Note over S: Create SyncPushRecord<br/>status=pending
    S->>DB1: INSERT sync_push_records

    S->>D: POST /responses/
    alt 409 Conflict
        D-->>S: 409 Conflict
        S->>D: GET /responses/{id}<br/>include_answers=true
        D-->>S: Server version
        S->>DB1: INSERT sync_conflicts<br/>(both versions)
        S->>DB1: UPDATE sync_push_records<br/>status=conflict
        S-->>M: 202 Accepted<br/>status=conflict
    end

    M->>S: (Later) GET /conflicts/
    S->>DB1: SELECT * FROM sync_conflicts<br/>WHERE resolution IS NULL

    M->>S: POST /conflicts/{id}/resolve<br/>{strategy: server_wins}
    S->>DB1: UPDATE sync_conflicts<br/>SET resolution='server_wins'
    S->>DB1: UPDATE sync_push_records<br/>SET status='failed'
    S-->>M: 200 OK

State Diagram

This diagram could not be drawn, so its source is shown.
stateDiagram-v2
    [*] --> Push_Pending: Idempotency check passed
    Push_Pending --> Forwarding: POST received
    Forwarding --> Completed: 201 Created
    Forwarding --> Conflict_Detected: HTTP 409
    Forwarding --> Push_Failed: Other error
    
    state Conflict_Detected {
        [*] --> Unresolved
        Unresolved --> Resolved: Supervisor action
        Resolved --> Closed: Resolution complete
    }
    
    state Push_Failed {
        [*] --> Retry_Queued: retry_count < max
        Retry_Queued --> Push_Pending: Background retry
        Push_Failed --> Permanent: retry_count >= max
    }
    
    note right of Conflict_Detected
        SyncConflict created with:
        - server_version (JSON snapshot)
        - client_version (JSON snapshot)
        - resolution = NULL
    end
    
    note right of Resolved
        outcome = "server_wins" OR "client_wins"
        SyncPushRecord updated accordingly
    end