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 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)
endLifecycle
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 exceededEntity Relationships
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
- Push Receipt: Sync-service receives POST to
/sync/pushwith response data - Record Creation:
SyncPushRecordcreated withstatus=pending - Forward Request: Payload forwarded to data-service
/responses/ - Conflict Detection: HTTP 409 triggers
ConflictDetectorService.detect_conflict() - Server Fetch: Current server response fetched for comparison
- Comparison Logic:
- If
client_sync_timeprovided: Compareserver.updated_at > client_sync_time - If no timestamp: Compare
server.answersvsclient.answers
- If
- Record Creation:
SyncConflictpersisted with both versions - 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:
| Endpoint | Permission |
|---|---|
| GET /conflicts/ | response:read |
| GET /conflicts/{id} | response:read |
| POST /conflicts/{id}/resolve | response: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 createdconflict.resolved- When resolution is appliedconflict.rejected- When conflict is rejected
Current Events:
response.submitted- Published on successful response creation
Failure Scenarios
| Scenario | Detection | Handling |
|---|---|---|
| Network timeout during conflict detection | Exception in detector | Conflict not created; push marked failed |
| Data-service unavailable | HTTP exception | Conflict not created; push marked failed |
| Invalid strategy | ValueError in resolver | HTTP 400 returned |
| Conflict already resolved | Check in resolver | HTTP 400 returned |
| Server unreachable during client_wins | HTTP exception in resolver | Resolution 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 conflictdevice_id- Originating deviceresponse_id- Affected responseuser_id- Resolver (when applicable)
Monitoring
Database Metrics:
sync_conflictstable with indexes ontenant_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=3with exponential backoff
Sequence Diagram
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 OKState Diagram
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