Commit d914ebd
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
21 | 30 | | |
22 | 31 | | |
23 | 32 | | |
| |||
32 | 41 | | |
33 | 42 | | |
34 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
35 | 51 | | |
36 | 52 | | |
37 | 53 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
9 | 15 | | |
10 | 16 | | |
11 | 17 | | |
| |||
53 | 59 | | |
54 | 60 | | |
55 | 61 | | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
56 | 114 | | |
57 | 115 | | |
58 | 116 | | |
| |||
0 commit comments