Building Bidirectional Sync: Conflict Resolution in Practice
What happens when a sales rep updates a customer in Pipedrive while an operations person updates the same customer in Ostendo?
Building Bidirectional Sync: Conflict Resolution in Practice
What happens when a sales rep updates a customer in Pipedrive while an operations person updates the same customer in Ostendo?
This is the central challenge of bidirectional synchronization: two systems, two sources of truth, and inevitable conflicts. Here's how we solved it in TurfDrive.
The Problem
TurfDrive syncs data between Pipedrive (CRM) and Ostendo (ERP):
- Sales team works in Pipedrive
- Operations team works in Ostendo
- Both systems can create and update the same records
Without careful conflict resolution, you get:
- Lost updates (last write accidentally overwrites valid changes)
- Data inconsistencies (different values in each system)
- User confusion ("I updated this yesterday, why did it revert?")
- Eroded trust in the sync system
The Strategy: Last-Write-Wins with Detection
We implemented a last-write-wins strategy with conflict detection and alerting:
- Track timestamps: Every record stores
last_synced_atand compares against system modification times - Detect conflicts: If both systems modified a record since the last sync, flag it
- Apply hierarchy: For conflicts, use a source-of-truth hierarchy (Pipedrive for deals, Ostendo for jobs)
- Alert humans: Surface conflicts in the dashboard for manual review
This isn't perfect—there's no algorithm that can perfectly resolve all conflicts—but it balances automation with human oversight.
Implementation: Sync Direction and Ownership
Deals: Pipedrive → Ostendo
Rule: Pipedrive owns deal status and metadata.
When a deal updates in Pipedrive:
1. Check if corresponding Ostendo job exists
2. If yes, update job status, value, stage
3. If no, create new job in Ostendo
4. Store last_synced_at timestamp
Code structure:
```ruby
class SyncPipedriveDealStatusJob
def perform(deal_id)
deal = PipedriveDeal.find(deal_id)
job = Job.find_by(pipedrive_deal_id: deal.id)
# Conflict detection
if job && job.updated_at > job.last_synced_at
flag_conflict(job, deal)
return unless resolve_conflict?(job, deal)
end
# Apply update
job.update!(
status: map_status(deal.status),
value: deal.value,
stage: deal.stage,
last_synced_at: Time.current
)
end
end
```
Jobs: Ostendo → Pipedrive
Rule: Ostendo owns job creation and core details (customer, type, dates).
When a job is created in Ostendo:
1. Check if deal already exists in Pipedrive
2. If no, create new deal
3. If yes, sync core details (customer, value) but respect Pipedrive's status
Why the asymmetry? Sales reps move deals through stages in Pipedrive, but the operations team doesn't update job stages in Ostendo the same way. Ostendo is the source of truth for "what work needs to be done," while Pipedrive tracks "where we are in the sales process."
Customers: Bidirectional with Merge Logic
Customers can be created in either system, so we need true bidirectional logic:
- Create: New customer in either system creates a corresponding record in the other
- Update: Updates flow both ways, but with conflict detection
- Merge: If both systems updated contact info, apply field-level merging:
- Phone/Email: Prefer non-empty values
- Address: Prefer most recently updated
- Notes: Concatenate (don't overwrite)
Code structure:
```ruby
class SyncCustomer
def sync_bidirectional(ostendo_customer, pipedrive_org)
# Detect which system has newer data
ostendo_updated_at = ostendo_customer.modified_date
pipedrive_updated_at = pipedrive_org.update_time
if ostendo_updated_at > last_synced_at &&
pipedrive_updated_at > last_synced_at
# Conflict: both updated since last sync
handle_customer_conflict(ostendo_customer, pipedrive_org)
elsif ostendo_updated_at > last_synced_at
# Ostendo is newer: push to Pipedrive
push_to_pipedrive(ostendo_customer)
elsif pipedrive_updated_at > last_synced_at
# Pipedrive is newer: pull to Ostendo
pull_from_pipedrive(pipedrive_org)
end
update_last_synced_timestamp
end
def handle_customer_conflict(ostendo, pipedrive)
# Field-level merge logic
merged = {
name: pipedrive.name, # Prefer Pipedrive (user-friendly)
phone: ostendo.phone.presence || pipedrive.phone,
email: pipedrive.email.presence || ostendo.email,
address: most_recent_address(ostendo, pipedrive),
notes: [ostendo.notes, pipedrive.notes].compact.join("\n\n")
}
# Apply to both systems
update_ostendo(ostendo, merged)
update_pipedrive(pipedrive, merged)
# Log conflict for review
SyncLog.create!(
syncable: ostendo,
status: "conflict_resolved",
details: "Merged fields from both systems"
)
end
end
```
Conflict Detection: When to Alert
Not all conflicts are worth alerting on. We categorize conflicts:
Low-Risk (Auto-Resolve)
- Minor field differences (formatting, whitespace)
- Empty vs populated (always take populated)
- Notes/comments (concatenate, don't overwrite)
Medium-Risk (Resolve + Log)
- Status mismatches (Open in one system, Won in another)
- Value differences (price changed in both systems)
- Date changes (delivery date updated on both sides)
→ Apply hierarchy rule (Pipedrive for deals, Ostendo for jobs) and log for review
High-Risk (Block + Alert)
- Duplicate record creation (same customer created in both systems)
- Deletion conflicts (one system deleted, other updated)
- Critical field mismatches (customer assignment, project type)
→ Don't auto-resolve. Surface in dashboard. Require human decision.
The Conflict Dashboard
We built a simple dashboard showing:
- Recent conflicts (last 7 days)
- Status: Auto-resolved, Pending review, Manually resolved
- Side-by-side comparison of values in each system
- Action buttons: "Keep Pipedrive," "Keep Ostendo," "Merge"
Non-technical users can review and resolve conflicts without touching code or databases.
Key insight: Most conflicts aren't critical. A phone number formatted differently isn't worth stopping sync. But a won deal marked as lost in the ERP? That needs human eyes.
Edge Cases We Handle
1. Race Conditions
Scenario: A deal updates in Pipedrive while our sync job is pulling data from Ostendo.
Solution: Use database transactions and row-level locking:
ruby
Job.transaction do
job = Job.lock.find(id)
# Check timestamp before updating
if job.updated_at > cutoff
raise Conflict, "Record modified during sync"
end
job.update!(...)
end
2. Network Failures Mid-Sync
Scenario: We update Pipedrive, but the Ostendo API call fails.
Solution: Store sync state and rollback on failure:
```ruby
def sync_with_rollback(job, deal)
original_state = job.attributes
begin
update_ostendo(job)
update_pipedrive(deal)
mark_synced(job)
rescue => e
job.update(original_state) # Rollback
retry_later(job)
end
end
```
3. Deleted Records
Scenario: A customer is deleted in Ostendo but has active deals in Pipedrive.
Solution: Soft deletes + retention policy:
- Mark as archived instead of hard delete
- Sync the archived status
- Alert if archived record has active deals
- Purge after 90 days if no references
4. Bulk Updates
Scenario: Operations imports 100 customers into Ostendo at once.
Solution: Batch sync with rate limiting:
- Detect bulk changes via count thresholds
- Process in batches of 50
- Add delays between batches to respect API rate limits
- Surface progress in dashboard
Timestamp-Based Sync: The Core Pattern
Every record tracks three timestamps:
class Job < ApplicationRecord
# created_at - Rails standard
# updated_at - Rails standard
timestamps :last_synced_at # Last successful sync
timestamps :last_sync_attempt_at # Last attempt (may have failed)
timestamps :ostendo_modified_at # From Ostendo API
timestamps :pipedrive_modified_at # From Pipedrive API
end
Sync logic:
```ruby
def needs_sync?
ostendo_modified_at > last_synced_at ||
pipedrive_modified_at > last_synced_at
end
def has_conflict?
ostendo_modified_at > last_synced_at &&
pipedrive_modified_at > last_synced_at
end
```
This gives us:
- Selective sync: Only process records that changed
- Conflict detection: Know when both systems updated
- Audit trail: Track sync history per record
Lessons Learned
1. Perfect Consistency Is Impossible
Accept that conflicts will happen. Design for eventual consistency and conflict visibility, not perfect real-time sync.
2. Hierarchy Simplifies Decisions
Establish clear ownership rules:
- Who owns what data?
- Which system is authoritative for each field?
- When in doubt, which system wins?
Document these rules and encode them in code.
3. Idempotency Is Critical
Running a sync twice should produce the same result as running it once. This makes retries safe and debugging easier.
Every sync operation:
- Checks current state before updating
- Uses upsert logic (create or update, not blindly insert)
- Stores unique identifiers from both systems
4. Logs Are Your Friend
We log every sync operation with structured data:
json
{
"job_id": 123,
"action": "update",
"source": "pipedrive",
"target": "ostendo",
"fields_changed": ["status", "value"],
"conflict_detected": false,
"duration_ms": 234
}
When something goes wrong, we can trace exactly what happened.
5. Users Need Visibility
The biggest complaint about sync systems: "I don't know if it worked."
We show:
- Last sync timestamp on every record
- Sync status indicator (green = synced, yellow = pending, red = error)
- Link to detailed sync log
- Recent activity feed
Transparency builds trust.
The Result
One year in:
- 99.8% auto-resolution rate (most conflicts resolve automatically)
- ~5 conflicts/week requiring human review
- Zero data loss incidents
- High user confidence ("It just works")
Takeaways
- Last-write-wins works when combined with conflict detection and alerting
- Establish clear ownership for each piece of data
- Merge field-by-field for complex objects (don't choose one system wholesale)
- Build observability so users trust the system
- Idempotency is non-negotiable for reliable syncing
Bidirectional sync is hard, but it's solvable. Start with simple rules, add complexity only when needed, and always give humans a way to override the automation.
Next in this series: Error handling and retry strategies that actually work in production.
Contact us if you're building data sync systems and need architecture guidance.