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

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:

  1. Track timestamps: Every record stores last_synced_at and compares against system modification times
  2. Detect conflicts: If both systems modified a record since the last sync, flag it
  3. Apply hierarchy: For conflicts, use a source-of-truth hierarchy (Pipedrive for deals, Ostendo for jobs)
  4. 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:

  1. Create: New customer in either system creates a corresponding record in the other
  2. Update: Updates flow both ways, but with conflict detection
  3. 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

  1. Last-write-wins works when combined with conflict detection and alerting
  2. Establish clear ownership for each piece of data
  3. Merge field-by-field for complex objects (don't choose one system wholesale)
  4. Build observability so users trust the system
  5. 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.