Skip to content

Commit d914ebd

Browse files
fspeirscursoragentCopilot
authored
Guard Salesforce::RoleSyncJob against missing parent records (#850)
## Problem Heroku Connect was leaving `Contact_Editor_Affiliation__c` rows permanently `FAILED` in the mirror with errors like: ``` Foreign key external ID: <school-uuid> not found for field EditorUUID__c in entity Editor__c Foreign key external ID: <user-uuid> not found for field Pi_Accounts_Unique_ID__c in entity Contact ``` This happens because `School` and `Role` `after_commit` callbacks both fire in the same transaction and enqueue independent jobs (`SchoolSyncJob` and `RoleSyncJob`) on the `salesforce_sync` queue. The jobs use **different** concurrency keys, so the role write to the Heroku Connect mirror can be pushed to Salesforce **before** the Editor record lands. When that happens the lookup-by-external-id fails, Salesforce rejects the INSERT, and Heroku Connect does **not** auto-retry foreign-key resolution failures — the mirror row stays `FAILED` forever. The same pattern happens against `Contact` when a role is created before the upstream Pi Accounts → Salesforce Contact pipeline has materialised the user. ### Evidence from production Investigating Editors in Salesforce with no `Contact_Editor_Affiliation__c`: - 1,114 Editors with no CEA, of which 1,098 are rejected schools (working as designed). - 16 active residual cases. After classifying via `_hc_err`: - 9 schools had no mirror row at all (one-off backfill recovered them). - 1 school (Gurnard, created 3 days ago) had `_hc_err = "Foreign key external ID … not found for field EditorUUID__c in entity Editor__c"`. The parent `Editor__c` had since synced (`sfid` populated, `_hc_lastop = SYNCED`), so the affiliation INSERT would now succeed — but Connect never retries. - 3 schools failed with the equivalent `Contact` error and have no `Salesforce::Contact` mirror row at all (separate upstream issue, not in scope). - A new occurrence of the Editor race appeared in the Heroku Connect log at `00:01:04 UTC` today for school `6a228ff8-…`, confirming the race is still actively breaking new affiliations. The 10 recoverable cases were re-synced manually; the SOQL count for not-rejected Editors with no CEA dropped 16 → 6 (3 missing-Contact + 3 by-design schools with only student / no roles). ## Fix Before writing to the affiliation mirror, verify the parent records exist in the local Heroku Connect mirror **with `sfid` populated** (i.e. Salesforce has acknowledged them). If either parent isn't ready, raise `SalesforceRecordNotFound`. `SalesforceSyncJob` already declares: ```ruby retry_on SalesforceRecordNotFound, wait: :polynomially_longer, attempts: 10 ``` …so this slots into the existing retry mechanism (same one `ContactSyncJob` already relies on) without any new error class, queue, or backoff config. ### Behaviour | Scenario | Before | After | |---|---|---| | Editor not yet synced (school create race) | Mirror row goes `FAILED` permanently | Job retries with polynomial backoff up to 10 times; lands once Editor is in Salesforce | | Contact not yet synced (transient upstream lag) | Mirror row goes `FAILED` permanently | Same; self-heals when Contact arrives | | Contact never created upstream (genuinely broken) | Silent `FAILED` row in mirror | Job exhausts retries with a clear error message; visible in GoodJob/Sentry | | Student role | Returns early (unchanged) | Returns early (unchanged) | | `SALESFORCE_ENABLED=false` | Discarded (unchanged) | Discarded (unchanged) | ### What this does not fix - 1,098 rejected schools without affiliations — working as designed. - 3 schools whose users (1 live, 2 discarded/invalidated) have no `Contact` in Salesforce — needs investigation upstream of editor-api. - Existing `FAILED` mirror rows already in production — those need a one-off cleanup (delete `sfid: nil` rows then re-enqueue `RoleSyncJob`); already done for the 10 recoverable cases above. ## Tests `spec/jobs/salesforce/role_sync_job_spec.rb`: - Existing happy-path / save-failure / student-role / feature-flag-off contexts retained, with parent rows now provisioned via `let!` so the new guards pass. - New contexts: - `Salesforce::School` exists with `sfid: nil` → raises `SalesforceRecordNotFound` (Editor not yet synced) - No `Salesforce::School` row at all → raises `SalesforceRecordNotFound` - `Salesforce::Contact` exists with `sfid: nil` → raises `SalesforceRecordNotFound` (Contact not yet synced) - No `Salesforce::Contact` row at all → raises `SalesforceRecordNotFound` - In all failure cases, no row is written to the affiliation mirror. ## Test plan - [x] CI rspec passes - [x] Rubocop passes (verified locally) - [ ] After deploy, monitor Heroku Connect error log for `Foreign key external ID … not found` errors on `contact_editor_affiliation__c` — should drop to ~0 for new schools - [ ] Spot-check a freshly created school: confirm `Contact_Editor_Affiliation__c` rows appear in Salesforce within retry window Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 82faeab commit d914ebd

2 files changed

Lines changed: 74 additions & 0 deletions

File tree

app/jobs/salesforce/role_sync_job.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ def perform(role_id:)
1818

1919
return if role.student?
2020

21+
# The Contact_Editor_Affiliation__c row uses Salesforce external-ID lookups to resolve
22+
# its parent Editor__c (school) and Contact (user). If either parent has not yet been
23+
# pushed to Salesforce, Heroku Connect rejects the INSERT permanently with a
24+
# "Foreign key external ID ... not found" error and the row is stuck FAILED in the
25+
# mirror. Raising SalesforceRecordNotFound here defers the affiliation write via the
26+
# SalesforceSyncJob retry_on, giving the parent records time to land in Salesforce.
27+
ensure_parent_synced!(Salesforce::School, :editoruuid__c, role.school_id, 'Editor__c')
28+
ensure_parent_synced!(Salesforce::Contact, :pi_accounts_unique_id__c, role.user_id, 'Contact')
29+
2130
sf_role = Salesforce::Role.find_or_initialize_by(affiliation_id__c: role_id)
2231
sf_role.attributes = sf_role_attributes(role:)
2332

@@ -32,6 +41,13 @@ def perform(role_id:)
3241

3342
private
3443

44+
def ensure_parent_synced!(model, external_id_field, external_id, label)
45+
return if model.where(external_id_field => external_id).where.not(sfid: nil).exists?
46+
47+
raise SalesforceRecordNotFound,
48+
"#{label} not yet synced for #{external_id_field}: #{external_id}"
49+
end
50+
3551
def sf_role_attributes(role:)
3652
mapped_attributes(role:).to_h do |sf_field, value|
3753
value = truncate_value(sf_field:, value:) if value.is_a?(String)

spec/jobs/salesforce/role_sync_job_spec.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
subject(:perform_job) { described_class.perform_now(role_id: role.id) }
77

88
let(:role) { create(:role) }
9+
let!(:sf_school) do
10+
create(:salesforce_school, editoruuid__c: role.school_id, sfid: SecureRandom.alphanumeric(18))
11+
end
12+
let!(:sf_contact) do
13+
create(:salesforce_contact, pi_accounts_unique_id__c: role.user_id, sfid: SecureRandom.alphanumeric(18))
14+
end
915

1016
around do |example|
1117
ClimateControl.modify(SALESFORCE_ENABLED: 'true') { example.run }
@@ -53,6 +59,58 @@
5359
end
5460
end
5561

62+
context 'when the parent Editor__c is not yet synced to Salesforce' do
63+
before { sf_school.update!(sfid: nil) }
64+
65+
it 'retries the job to defer the affiliation write' do
66+
expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id)
67+
end
68+
69+
it 'does not write the affiliation to the mirror' do
70+
perform_job
71+
expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil
72+
end
73+
end
74+
75+
context 'when there is no Salesforce::School row for the role school' do
76+
before { sf_school.destroy }
77+
78+
it 'retries the job' do
79+
expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id)
80+
end
81+
82+
it 'does not write the affiliation to the mirror' do
83+
perform_job
84+
expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil
85+
end
86+
end
87+
88+
context 'when the parent Contact is not yet synced to Salesforce' do
89+
before { sf_contact.update!(sfid: nil) }
90+
91+
it 'retries the job to defer the affiliation write' do
92+
expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id)
93+
end
94+
95+
it 'does not write the affiliation to the mirror' do
96+
perform_job
97+
expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil
98+
end
99+
end
100+
101+
context 'when there is no Salesforce::Contact row for the role user' do
102+
before { sf_contact.destroy }
103+
104+
it 'retries the job' do
105+
expect { perform_job }.to have_enqueued_job(described_class).with(role_id: role.id)
106+
end
107+
108+
it 'does not write the affiliation to the mirror' do
109+
perform_job
110+
expect(Salesforce::Role.find_by(affiliation_id__c: role.id)).to be_nil
111+
end
112+
end
113+
56114
context 'when SALESFORCE_ENABLED is false' do
57115
around do |example|
58116
ClimateControl.modify(SALESFORCE_ENABLED: 'false') do

0 commit comments

Comments
 (0)