Daily provider field-definition sync
ENG-3010
moves the 02:00 UTC SyncProviderFieldsJob sweep and its per-source
SyncProviderFieldsForOrganizationDataSourceJob fan-out from Rails/GoodJob to
the API2 provider-fields-sync BullMQ queue. The sync keeps the Rails
conversation_fields, customer_fields, employee_fields, and
company_fields rows current with each provider’s field definitions so
imported conversations, customers, employees, and companies can attach field
values.
Why
The production CloudWatch census for the 24 hours ending 2026-09-10 22:48 UTC
recorded 12 US and 6 EU SyncProviderFieldsForOrganizationDataSourceJob
attempts plus one SyncProviderFieldsJob sweep per region. The workload is
small but it is a recurring normal-path GoodJob producer, and its provider
clients, credential loaders, and Rails-mirrored field schemas already exist in
API2.
Contract
- One scheduler,
provider-fields-sync-daily, fires ascanjob at0 2 * * *UTC (the existing cadence). The scan lists non-discarded Rails organization data sources whosedata_sources.keyiszendesk,freshdesk, orintercom— without a tenant, asActsAsTenant.without_tenantdid — and enqueues onesourcejob per row carryingorganizationId,organizationDataSourceId, andprovider. An enqueue failure for one source is logged, the remaining sources are still enqueued, and the scan fails afterwards so the failure is visible. - A
sourcejob re-checks the kill switch, reloads the source scoped to its organization (a discarded or re-typed source completes asskipped), and then syncs each provider category independently:- Zendesk
ticket_fields→ conversation fields (name = description, else title);user_fields→ customer and employee fields (name = key);organization_fields→ company fields (name = key). Enum options keepcustom_field_options[].valueas values and.nameas labels. - Freshdesk
/api/v2/admin/ticket_fields→ conversation fields;/api/v2/contact_fields→ customer fields;/api/v2/company_fields→ company fields. Enum values and labels are bothchoices[].value. - Intercom
data_attributes?model=contact|company|conversation→ customer, company, and conversation fields; onlycustomand non-archivedattributes are kept, and anyoptionsforce theenumtype with identical values and labels. - Value-type tables match the Rails class helpers exactly; unsupported provider types are skipped, not persisted.
- Zendesk
- Field identity is
(organization_data_source_id, external_id). A re-run updateslabel,name,value_type,options, andlast_synced_atin place; a new row takes its id from the Rails sequence and conversation fields receive the Rails-styleconversation_field_…public id. - A category failure (credentials, provider error) is logged with the organization, source, and category ids and does not stop sibling categories; a field that cannot be mapped or persisted is logged and does not stop its siblings. The job only throws — and BullMQ retries it — when every category failed, which is the credential/provider-outage case.
- Deduplication id
provider_fields_source_<org>_<ods>keeps one live source job per data source. Source jobs have 3 attempts with exponential backoff from 60s; the scan has 1 attempt and is retried by the next daily tick. - The Rails
sync_provider_fieldscron stays ingood_job.rbwithenabled_by_default: false.SyncProviderFieldsJobandSyncProviderFieldsForOrganizationDataSourceJobare a documented recovery-only shim.
Kill switch and rollback
Flipper pause_api2_provider_fields_sync is checked at scan and source
execution time; paused jobs complete as paused without calling a provider or
touching the field tables. Rollback order:
- Enable
pause_api2_provider_fields_syncin the affected region. - Only then re-enable the
sync_provider_fieldscron in the GoodJob dashboard (or revertenabled_by_default: false). Never run both owners: both upsert the same field rows. - Leave the API2 worker and queued BullMQ jobs alone; they drain as
paused.
Work skipped while paused is covered by the next sweep because the sync is a full re-read of every provider’s current definitions.
Bake criteria
Deploy the API2 worker/scheduler and the Rails revision together (overlap is safe: the upsert is idempotent per field identity). Then observe two 02:00 UTC ticks in US and EU and require:
- every kept Zendesk/Freshdesk/Intercom source logged
provider fields sync completedwitherror: nullon each category, or an explained failure; last_synced_atadvanced on the field rows of every such source;- zero new
SyncProviderFieldsJob/SyncProviderFieldsForOrganizationDataSourceJobGoodJob rows after the deploy; - no duplicate
(organization_data_source_id, external_id)rows and an empty or fully explainedprovider-fields-syncfailed set.
Observation queries
Rails (each region):
SELECT 'conversation' AS kind, organization_data_source_id, max(last_synced_at)
FROM conversation_fields WHERE organization_data_source_id IS NOT NULL
GROUP BY 1, 2
UNION ALL
SELECT 'customer', organization_data_source_id, max(last_synced_at)
FROM customer_fields WHERE organization_data_source_id IS NOT NULL GROUP BY 1, 2
UNION ALL
SELECT 'employee', organization_data_source_id, max(last_synced_at)
FROM employee_fields WHERE organization_data_source_id IS NOT NULL GROUP BY 1, 2
UNION ALL
SELECT 'company', organization_data_source_id, max(last_synced_at)
FROM company_fields WHERE organization_data_source_id IS NOT NULL GROUP BY 1, 2
ORDER BY 3 NULLS FIRST;
SELECT job_class, count(*) AS jobs, sum(executions_count) AS executions
FROM good_jobs
WHERE job_class IN ('SyncProviderFieldsJob', 'SyncProviderFieldsForOrganizationDataSourceJob')
AND created_at > now() - interval '24 hours'
GROUP BY job_class;The second query must return no rows after the deploy. API2: open
/admin/queues, select provider-fields-sync, and read waiting, failed
counts and the scheduler’s next time. Worker logs are tagged
provider-fields-sync with provider, organizationId,
organizationDataSourceId, and per-category fetched, synced, skipped,
fieldErrors, and error.
Recover a failed source
A source job that exhausted its attempts keeps organizationId,
organizationDataSourceId, and provider in the failed set. With production
authorization: match it to its final-attempt log/Sentry event, fix the
credential or provider cause, and retry the individual job from
/admin/queues. The next daily tick also re-syncs it. Run the Rails shim
manually for one source only while pause_api2_provider_fields_sync is
enabled, then unpause.
Verification
src/lib/provider-fields-sync/mappings.test.ts covers every Zendesk,
Freshdesk, and Intercom value-type mapping, enum option shapes, the Intercom
custom/non-archived filter, and Rails presence validations.
sync.test.ts covers scan fan-out, pause at scan and source, per-category and
per-field error isolation, the all-categories-failed retry path, deduplication
and retry options, the 02:00 UTC scheduler registration, worker lifecycle, and
the paused Rails cron. sync.db.test.ts runs the scan and source sync against
PGlite copies of the Rails tables, asserting tenant/source filtering, the
Flipper kill switch, and idempotent upserts on all four field tables. Rails
GoodJob configuration specs assert the disabled cron entry.