Migrating topic → CustomerIssue assignment to API2 BullMQ
Why
Post-embedding CustomerIssue assignment used to bounce between Rails and API2:
Rails CompleteConversationSyncJob
→ API2 conversation-topic-embedding
→ POST /internal/conversations/assign_customer_issue_from_topic (Rails)
→ Rails GoodJob AssignConversationToCustomerIssueFromTopicJob
→ POST /internal/customer_issue_embeddings/match (API2)
→ Rails Conversation#assign + PatchConversationTopicTaggingJob → API2Every untagged synced conversation crossed the Rails↔API2 boundary four times. During the Kuda (org 25) backfill this accumulated ~78k unfinished GoodJob rows for ~67k conversations (~13.6% duplicate conversation ids).
After
Assignment runs entirely inside API2 on its own BullMQ queue:
API2 conversation-topic-embedding (indexes topic doc)
→ enqueue topic-customer-issue-assignment (BullMQ, deterministic job id)
→ runTopicCustomerIssueAssignmentJob:
field-mapping precedence → Voyage ANN nearest-match
race-safe UPDATE ... WHERE customer_issue_id IS NULL
patch Turbopuffer conversation topic docKey properties (unchanged from Rails semantics):
- Only existing CustomerIssue rows are used. No issue is ever created, no LLM is called.
- Field-mapped issues take precedence over the embedding nearest-match. Finding a configured field value stops the fallback even if the issue row is missing only when the value does not map; a present-but-already-assigned conversation is left untouched.
- The write is tenant-scoped and race-safe:
customer_issue_idis set only while it is stillNULLfor that(id, organization_id), so a concurrent or newer Rails write is never clobbered. Turbopuffer is patched only when this job wins. - BullMQ job id is
topic_customer_issue_assignment_<org>_<conversation>_<sourceVersion>, wheresourceVersionis the embedding source version (direct path) or the conversation’slast_synced_at/updated_at(Rails bridge). Repeated syncs and duplicate backlog rows for the same version collapse onto one job, but a later sync with changed topic text produces a new job id and is not suppressed by a retained terminal (completed/no_match/failed) job. - Retries are exponential (
attempts: 5, 30s base backoff). Concurrency is bounded at two levels: per-processconcurrency(TOPIC_CUSTOMER_ISSUE_ASSIGNMENT_WORKER_CONCURRENCY, default 4) and a regional/global cap via BullMQsetGlobalConcurrency(TOPIC_CUSTOMER_ISSUE_ASSIGNMENT_GLOBAL_CONCURRENCY, default 4) so the total across all worker replicas stays bounded and does not hammer Turbopuffer. Sentry queue instrumentation is inherited fromSentryQueue/bindWorkerEvents. - Turbopuffer patching is retry-safe: if the DB assignment commits but the patch
throws, BullMQ retries and the already-tagged branch idempotently re-patches the
topic document with the current
customer_issue_idinstead of leaving it stale.
Rails producers enqueue API2 directly (ENG-2252)
No Rails producer routes through a GoodJob worker anymore. The incident showed
that even a thin HTTP bridge job consumed tens of thousands of shared
latency_30s slots, so every producer now calls
Conversation#enqueue_customer_issue_assignment_from_topic!, which POSTs
straight to
POST /internal/conversation_topic_customer_issue_assignments/enqueue and
raises Conversation::Api2CustomerIssueAssignmentEnqueueError on delivery
failure. Producers:
Internal::ConversationTopicCustomerIssueAssignmentsController#create— the API2 callback path. Enqueues inline; a delivery failure returns502so the caller can retry.Maintenance::AssignUntaggedConversationsToCustomerIssuesFromTopicTaskandscripts/enqueue_untagged_topic_customer_issue_assigns.rb— backfills. Enqueue inline per conversation; a per-record delivery failure is logged and skipped so one blip does not abort the run. The deterministic API2 job id makes re-runs idempotent.
The deterministic API2 job id dedupes the ~13.6% duplicate conversation ids for free, exactly as the old bridge did.
Legacy GoodJob backlog drain shim
AssignConversationToCustomerIssueFromTopicJob is kept — but nothing
enqueues it. It is now a minimal compatibility drain shim whose perform just
calls Conversation#enqueue_customer_issue_assignment_from_topic!, so the
existing ~78k GoodJob rows queued before ENG-2252 (queue latency_30s) still
deserialize and drain by handing off to API2. Delivery failures are retried by
GoodJob (retry_on, attempts: 10).
Deletion condition: remove the shim once no
AssignConversationToCustomerIssueFromTopicJob rows remain in the production
GoodJob tables (the latency_30s backlog is empty). Because nothing enqueues
it, an empty backlog is a sufficient signal that it is safe to delete.
Internal::ConversationTopicCustomerIssueAssignmentsController and the Rails
Conversation#assign_customer_issue_from_topic_embedding model methods are left
in place for backward compatibility / rollback during rollout.
Rollout sequence
- Deploy API2 (new queue, worker, enqueue endpoint) and Rails (bridge job) together. The new embedding completions immediately enqueue API2 assignment; the Rails endpoint keeps working for any in-flight callers.
- Confirm the
topic-customer-issue-assignmentworker is processing and Sentry shows healthy completion/fail rates. - Let the existing GoodJob backlog drain: as each
AssignConversationToCustomerIssueFromTopicJobruns it enqueues (idempotently) into API2. Do not delete production GoodJob rows; let them complete or age out normally. - Monitor
conversations.customer_issue_idfill rate and Turbopufferis_untaggedcounts for org 25 to confirm parity with the old path.
Follow-up removal (after rollout is confirmed)
Once no traffic depends on the legacy path:
- Remove
POST /internal/conversations/assign_customer_issue_from_topic(Internal::ConversationTopicCustomerIssueAssignmentsController). - Delete the
AssignConversationToCustomerIssueFromTopicJobdrain shim once thelatency_30sGoodJob backlog for it is empty (nothing enqueues it anymore). - Remove the now-unused Rails
Conversationassignment methods (assign_customer_issue_from_topic_embedding,apply_existing_field_mapped_customer_issue,assign_from_nearest_customer_issues_for_topic_text, and thePOST /internal/customer_issue_embeddings/matchcaller) plus thePatchConversationTopicTaggingJobif it has no other callers.