Routine GoodJob workload families
Summary dispatch and expired-evaluation sweep
ENG-3000 removes three additional normal-path GoodJob classes. Every
Conversation#summarize_later call now uses
Api2ConversationSummaryEnqueuer.enqueue_or_later!: successful API2 acceptance
creates no GoodJob row, while a proven delivery error logs and reports a
warning before enqueuing the retained SummarizeConversationJob. The job class
also drains work queued by older revisions.
API2 registers mark-expired-evaluations-failed-hourly at :05 UTC on the
maintenance queue. Its job calls
POST /internal/expired_evaluations/process, where Rails executes the same
snapshot and QA-request timeout transitions used by the retained jobs and
returns candidate/failure counts. BullMQ retries the authenticated request
three times with exponential backoff. The two GoodJob cron entries are disabled
by default.
For expiry rollback, set Flipper pause_api2_expired_evaluation_cleanup, then
enable mark_expired_evaluations_as_failed and
mark_expired_qa_evaluation_requests_as_failed in GoodJob. Re-drive once after
the last missed hour; both operations are idempotent over already terminal
records. Remove the pause switch, endpoint and retained jobs only after both
regions show successful BullMQ runs and 24 hours of zero new normal-path rows.
ENG-2920 removes normal-path
Rails/GoodJob ownership of the four remaining routine workload families in the
September 8 queue snapshot: SyncJobs::SyncUnit21Job,
ConversationUploads::CheckNewUploadsJob and the downstream
ProcessDirectUploadJob path, SendDailyAgentEvaluationReportJob plus
SendDailyAgentEvaluationReportForOrganizationJob, and
FixCounterCachesJob. API2 owns the schedulers, durable execution, retries,
and business behavior; Rails keeps only narrow authenticated /internal/*
endpoints that execute model-bound work synchronously.
Contract
- Register one BullMQ job scheduler per family on the exact existing cadences:
Unit21 every ten minutes, upload polling every thirty minutes, counter-cache
repair every three hours, and daily agent evaluation reports at 04:00 UTC
(
yesterday) and 20:00 UTC (today). - Enqueue per-unit work with deterministic job IDs
(
unit21-sync-source-<id>,conversation-upload-check-<id>,conversation-upload-process-<id>,daily-agent-eval-report-<org>-<date>-<email|no-email>-<bf|no-bf>,counter-cache-fix) so BullMQ dedupe preserves single-run concurrency and idempotent outbound delivery. - Keep the Rails cron entries in
config/initializers/good_job.rbbut setenabled_by_default: false, with each entry naming its Flipperpause_api2_*kill switch; a code revert re-enables Rails cron without racing the API2 schedulers. - Gate every API2 tick and worker behind the matching
pause_api2_*check so the kill switch is effective even while schedulers remain registered.
Unit21 sync
- Select the same sources as
SyncJobs::SyncUnit21Job: non-discardedOrganizationDataSourcerecords whose data source is Unit21. - Sync one source per BullMQ job through
POST /internal/organization_data_sources/:id/sync_unit21, which runs the existingsync_unit21!path and returns the sync payload or the Rails error classification. Per-source failures are isolated in the tick and reach Sentry with the organization data source ID.
Conversation uploads
- Poll
conversation_uploadsrows that arependinginside the recent window, plusdirectuploads that arefailedor stuck inprocessingbeyond the 30-minute threshold — matchingCheckNewUploadsJoband the periodic-retry scan. - Route pending rows by
upload_type:presignrows go to thecheckjob (legacyblob_keylookup, 30-minute expiry).directrows are never sent throughcheck_status—ConversationUpload#check_statusnow returns early for them as a belt-and-braces guard. Adirectrow whoseactive_storage_attachmentsfile is still present and that is older than the five-minute controller grace window is queued as a deterministicconversation-upload-process-<id>job (which collapses onto an already queued controller hand-off); adirectrow with no retained file is leftpendingfor the durable-delivery reconciler rather than expired. - Check uploads through
POST /internal/conversation_uploads/:id/check; process direct uploads through.../process_directand record transient retries through.../retry_direct; mark terminal failures through.../mark_failed. - Preserve the Rails retry classification: retryable errors rethrow inside the
five-attempt budget and use the
polynomially_longerequivalent backoff (attemptsMade^4 * 1000 + 2000); non-retryable errors and exhausted attempts mark the upload failed via the same model method and capture the Sentry exception with the Rails fingerprint. ConversationUploadsController#createand any other direct-upload producer enqueue the API2 process job viaApi2ConversationUploadEnqueuerinstead ofProcessDirectUploadJob.perform_later; the upload path never depends on GoodJob analysis jobs.- XCALLY completion (ENG-2948): once
Xcally#processhas durably committed the recording attachment, it hands conversation completion to API2 throughPOST /internal/provider_conversation_sync_completions/enqueue(Api2ProviderConversationSyncCompletionEnqueuer, PostHog flagapi2_conversation_upload_sync_completion). The BullMQprovider-conversation-sync-completionjob is pinned bysync_run_id = xcally-upload-<conversation_upload.id>and the route keeps the completed job for a 7-day retention window so the pinned id still dedupes a retry that arrives after the first run finished. - Single-execution identity: dispatch is claimed durably in
public.outbound_delivery_claimsunderconversation-upload/sync-completion/<upload_id>before the enqueue call. The API2 hand-off and the GoodJob fallback share that claim — whichever dispatch lands marks it delivered, and a retried upload processor pass dispatches nothing.ActiveStorage::AnalyzeJobstill runs in Rails but is no longer coupled to completion — the retired batch’s wait on it was implicit capture, not a required ordering for transcription discovery. - Failure contract follows the claim rules: a proven rejection (API2
answers non-2xx) takes the GoodJob batch fallback and marks the claim, with
a warning log and a Sentry warning. An ambiguous failure (timeout or
transport error that may follow an accepted BullMQ job) must not fall back —
the claim is left
unresolvedand surfaced to Sentry for human reconciliation per### Resolving uncertain delivery claimsbelow. Resolve an uncertain key by checking whether aprovider_conversation_sync_completion_xcally-upload-<id>BullMQ job exists or ran; if not,release!the claim and re-drive the upload. Flag off (api2_conversation_upload_sync_completion) restores the legacy batch completion exactly as before; there is no normal-pathCompleteConversationSyncJobenqueue once the flag is on.
Native direct-upload processing (ENG-3248, default off)
PostHog flag api2_conversation_upload_native_processing (owner: platform;
created 2026-09-18; review/remove by 2026-12-17; objective: zero Rails
process_direct/retry_direct calls). With it off the
conversation-upload-process job keeps calling Rails exactly as above. With
it on for an organization, direct uploads whose source is xcally or
outreach_kaia are processed by
src/lib/conversation-uploads/native-processing/:
run.tsmirrorsConversationUpload#process_direct_upload/#retry_direct_upload!: periodic-retry eligibility (MAX_PERIODIC_RETRIES, minimum interval, stuck-processing and stored-source-file checks), theprocessing→completedstatus transitions, and clearing theperiodic_retry_count/last_processing_error*metadata on success. Failures raiseConversationUploadProcessingError(retryable, errorClass)and flow into the existing worker classification (mark_failedparity for terminal errors, five-attempt backoff for retryable ones).xcally.ts/outreach-kaia.tsreproduce the Rails processors: the same organization-data-source, employee (email → integration profile → name), client (phone / email / name), conversation (organization_data_source_idexternal_id,external_type IS NULL), part and call-detail identities, membership linking, agent-replacement reconciliation with QA-eligibility and conversation-team refresh,prefix_idand thread creation. Metadata, media and ZIP validation errors are terminal; storage, ffmpeg spawn and enqueue failures are retryable.
- Media (GSM 8 kHz mono → WAV, MP4 → WAV,
ffprobeduration →duration_msandhandling_time) runs on the API2 image, which now shipsffmpeg. Downloads, conversion and object uploads happen outside any transaction; the attachment is inserted under a shortFOR UPDATEon the exact call-detail row, re-checked for an existing recording, and an orphaned WAV object is deleted if that insert fails. Non-GSM recordings share the upload’s Active Storage blob, as in Rails. - Completion reuses the native upload-driven sync completion (ENG-3242): the
sync_completiondelivery row is inserted in the persistence transaction withsync_run_id = xcally-upload-<upload_id>(reopened whenfailed), the BullMQ job is enqueued after commit and after the recording attachment, and the row moves toaccepted; the completion worker marks itcompleted. An enqueue failure marks the rowuncertainand retries the job.
Rollback is the flag: turning it off returns the organization to the Rails processors with no deploy. Replays are safe in either direction because both sides use the same external-id identities and the same delivery keys.
Durable Rails→API2 delivery ledger (ENG-2948, default off)
PostHog flag api2_conversation_upload_durable_delivery (owner: platform;
created 2026-09-13; review/remove by 2026-12-12; objective: every direct
upload hand-off and XCALLY completion hand-off is recoverable after a
commit-then-timeout). With it off nothing below runs: the controller
calls Api2ConversationUploadEnqueuer synchronously and the XCALLY processor
uses the outbound_delivery_claims path above. With it on:
ConversationUploadsController#createcomputesconversation_uploads.idempotency_key=direct:sha256(source | canonical metadata | file bytes)and reuses the existing tenant row (unique indexindex_conversation_uploads_on_org_id_and_idempotency_key) on a client retry, so a retried POST after a lost response creates no second upload, attachment, or hand-off.- One
conversation_upload_deliveriesrow per(conversation_upload_id, kind)is inserted in the same transaction as the upload (kindprocess,delivery_key = conversation-upload/process/<upload_id>) or as the created conversation (kindsync_completion,delivery_key = conversation-upload/sync-completion/<upload_id>,sync_run_id = xcally-upload-<upload_id>). The HTTP call happens after commit, so a Redis/HTTP failure leaves a discoverable row. - States:
pending(Rails-owned; a proven API2 rejection parks the row untilnext_attempt_at, after which the API2 reconciler picks it up; five Rails rejections are terminal) →sent(Rails saw a 2xx) /uncertain(timeout or transport error; never blindly resent by Rails) →accepted(API2 wrote the ledger after the BullMQ add succeeded, so a lost response still proves acceptance) →completed(process worker saw Rails reportcompleted; completion worker finishedcomplete_conversation_sync) orfailed(terminal: Rails rejected five times, the Active Storage file is gone, the target conversation was deleted, or the replay budget of three is exhausted).ownerrecords which side is expected to move the row. - Reconciliation runs inside the 30-minute conversation-upload tick
(
reconcileConversationUploadDeliveries) over rows insent,uncertain, oracceptedthat have not moved for ten minutes. Order per row: (1) business outcome from the durable Rails tables —processrows withconversation_uploads.status = 'completed'andsync_completionrows whose conversation haslast_synced_atset are markedcompletedwithout touching Redis; (2) a live deterministic BullMQ job (conversation-upload-process-<id>/provider_conversation_sync_completion_xcally-upload-<id>) in waiting/delayed/active/prioritized means wait; (3) only when the job is missing or finished-without-outcome and the file/conversation still exists does the tick take an optimistic claim (UPDATE ... WHERE state = $old AND replay_count = $n) and re-add the job, replacing a finished job of the same id. Async_completionrow is only replayed once its upload iscompleted(Rails completes the upload after the recording is attached and the hand-off dispatched); while the upload ispending/processingthe Rails processor still owns it, and afailed/expiredupload marks the rowfaileduntil the periodic retry reprocesses it andConversationUploadDelivery.record!reopens the row; (4) missing file / missing conversation /replay_count >= 3→failedwithlast_error. - API2 ledger marks that follow an already-durable effect (job added,
upload completed, completion run finished) are best-effort: a failed mark
is logged to Sentry and left for the reconciler, never surfaced as a 5xx
or a failed job, so Rails cannot read a queued job as a rejection and a
completed upload cannot be flipped to
failed. Rails-side dispatch holds a two-minute lease (last_attempted_at) so a client retry racing an in-flight send does not double-post.
Read-only validation queries (both regions):
-- Pending direct uploads that still hold a file: must be picked up by the
-- scanner, never expired. Anything older than ~45 minutes here is a bug.
SELECT u.id, u.organization_id, u.status, u.created_at
FROM conversation_uploads u
WHERE u.upload_type = 'direct'
AND u.status = 'pending'
AND EXISTS (SELECT 1 FROM active_storage_attachments a
WHERE a.record_type = 'ConversationUpload'
AND a.name = 'file' AND a.record_id = u.id)
ORDER BY u.created_at;
-- Direct uploads expired by the presign path after the fix ships: must be 0.
SELECT COUNT(*) FROM conversation_uploads
WHERE upload_type = 'direct' AND status = 'expired'
AND updated_at > '<deploy timestamp>';
-- Ledger rows still in flight past the reconcile grace window.
SELECT d.id, d.kind, d.state, d.owner, d.attempts, d.replay_count,
d.conversation_upload_id, d.conversation_id, d.sync_run_id,
d.last_error, d.updated_at
FROM conversation_upload_deliveries d
WHERE d.state IN ('pending', 'sent', 'uncertain', 'accepted')
AND d.updated_at < now() - interval '10 minutes'
ORDER BY d.updated_at;
-- Terminal failures to triage by hand (no automatic replay).
SELECT id, kind, conversation_upload_id, last_error, failed_at
FROM conversation_upload_deliveries
WHERE state = 'failed'
ORDER BY failed_at DESC;
-- Completion delivered but transcription/sync never landed: must be 0.
SELECT d.id, d.conversation_id
FROM conversation_upload_deliveries d
JOIN conversations c ON c.id = d.conversation_id
WHERE d.kind = 'sync_completion' AND d.state = 'completed'
AND c.last_synced_at IS NULL;Deployment, canary, acceptance, rollback:
- Server first. Deploy the Rails migrations (ledger table and
idempotency_key— additive, transactional, no backfill) and API2 with the flag off. Readiness: the scanner’s next tick logsskippedDirect/reconciledcounts, the first validation query returns only rows younger than the grace window, and the ledger table is empty. - Canary. Enable
api2_conversation_upload_durable_deliveryfor one organization that already hasapi2_conversation_upload_sync_completionon. Gates over 24 hours: every new ledger row reachescompleted; zerofailed; zerouncertainrows older than the grace window; thelast_synced_at IS NULLquery is 0; no duplicateconversation_uploadsfor oneidempotency_key;CompleteConversationSyncJobGoodJob rows for that org stay at 0. - Acceptance. Repeat the gates region-wide, then widen the flag. The
legacy owners (synchronous controller enqueue,
outbound_delivery_claimsand the GoodJob completion batch) stay in code until acceptance; only then is the flag baked in and the claim path retired in a follow-up. - Rollback. Turn the durable flag off: new uploads take the legacy path
immediately. Rows already in the ledger keep reconciling — the reconciler
only acts on durable ledger/business state and is safe to leave running;
pause_api2_conversation_upload_checksstops the whole tick (scanner and reconciler) if needed, andapi2_conversation_upload_sync_completionoff restores GoodJob completion as documented above. No production rows are repaired or replayed as part of shipping this change; historicaloutbound_delivery_claimsare resolved by the manual procedure below.
Zero-new-GoodJob bake for XCALLY completion
After the flag is enabled for an organization in a region, new
CompleteConversationSyncJob rows attributable to XCALLY uploads must stay at
zero (retained rows inside the 12-hour GoodJob cleanup window are historical):
SELECT COUNT(*)
FROM good_jobs
WHERE job_class = 'CompleteConversationSyncJob'
AND queue_name = 'default'
AND created_at > now() - interval '12 hours';Attribute any surviving rows to conversations.organization_data_source_id → data_sources.key = 'xcally' via the serialized conversation GlobalID on the
job arguments; the converse assertion is that BullMQ
provider-conversation-sync-completion accepted each hand-off. A warning log
line XCALLY upload completion API2 hand-off failed marks a proven-rejection
fallback; ... hand-off outcome is uncertain marks an unresolved claim that
needs reconciliation. The upload-driven completion job now claims the same
native completion steps as provider syncs and skips the Rails complete call
when all completion keys are native; the rollback is the shorter
PROVIDER_SYNC_NATIVE_EFFECTS list. Both warning outcomes also surface in
Sentry.
Daily agent evaluation reports
- Resolve
yesterday/todayin each organization’s own time zone, select the same reportable organizations (non-demo orgs with a fixed-per-period ticket rule or no ticket rules), and enqueue one per-org BullMQ job withsendEmail: falseandqueueBackfill: truefrom the cron ticks. - Row collection is org-gated: with PostHog
api2_daily_agent_evaluation_report_collecton for an organization, the tick’s pre-collection and the per-org job run the portedQAEvaluationReport::RowCollectornatively (one transaction,statement_timeout = 5min, no Rails call, noCollectRowsJobrow). With the flag off — or PostHog unreachable — the per-org job collects rows throughPOST /internal/daily_agent_evaluation_reports/collectas before, and that endpoint plus the collector and job class stay until bake. A native failure is never retried through Rails inside an attempt; BullMQ retries replay the same engine so a flag flip mid-incident cannot double-collect. - The per-org job then queues the backfill
preview through
.../backfill_preview(which returns the existingQAEvaluationBackfillRunwhen one is active, preserving backfill-preview-before-notification ordering and active-run recovery), posts the Slack report, and only then sends email. Each completed step is persisted in the BullMQ job data so a retry re-runs only the remaining steps — the duplicate-delivery protection GoodJob could not offer. - The tick job resolves and persists every org’s coverage date
(
resolvedDates) before the first external call, so a BullMQ retry across a tenant’s local midnight cannot shifttoday/yesterdaycoverage. - Every outbound send is claimed durably in
public.outbound_delivery_claimsbefore the irreversible call: email deliveries carry adelivery_keythe Rails endpoints honor, and the Slack post claims the key API2-side. A proven non-delivery — the provider rejected the request with a response (e.g. a Slack non-2xx) — voids the claim (OutboundDeliveryClaim#release!on Rails,releaseOutboundClaimin API2) so the retry re-claims and re-sends instead of stalling on a permanentlyuncertainkey. Ambiguous outcomes — adeliver_nowraise or a dropped fetch response can both follow a landed delivery — keep the claim unresolved:uncertainis never resent automatically and is surfaced to Sentry for manual resolution instead. - Preserve the per-org Slack payload section limit and the combined email: the cron tick collects rows for every org and sends one email with CSV attachments for the orgs that produced rows.
- Admin-triggered and post-backfill report sends go through
Api2DailyAgentEvaluationReportEnqueuerwithsource: 'admin' | 'backfill'instead of the legacy job classes.
Counter caches
- Run all seven repair steps (
assignment_queue_memberships,group_employees,employees,credit_usages,conversations,sheet_rows,evaluation_sampling_rule_counters) throughPOST /internal/counter_caches/fix, which executes the existingFixCounterCachesJobstep implementations. - Hold a session-level advisory lock
(
pg_try_advisory_lock(hashtextextended('fix_counter_caches', 0))) on a dedicated connection so no idle transaction is held across the seven Rails HTTP repair calls and the same lock is visible to the RailsFixCounterCachesJobrollback path. A contended API2 run re-parks itself (DelayedError, five minutes) instead of being consumed; a contended Rails run raisesLockContentionErrorand retries viaretry_onrather than reporting success. - Persist
completedStepsin the job data so a retry resumes after the last completed step instead of re-running bounded repairs. Employee#merge_into!enqueues the repair throughApi2CounterCacheFixEnqueuer; an enqueue failure is logged and reported to Sentry without failing the merge.
Counter-cache regional rollout
Create the PostHog flag api2_counter_cache_native_repairs with a release
condition for one deployment region (region = eu-central-1 or
region = us-east-1) at 100%, with no percentage cohort. Wait for one
three-hour tick; verify counter cache fix completed with zero
Internal::CounterCachesController#fix calls in that region and unchanged
counter parity. Then add the second region and repeat the same verification.
Rollback by disabling the PostHog flag (the next run returns to Rails) or by
enabling the Flipper pause_api2_counter_cache_repairs switch.
Rollback and rollout assertion
The kill switches pause_api2_unit21_sync,
pause_api2_conversation_upload_checks,
pause_api2_daily_agent_evaluation_reports, and
pause_api2_counter_cache_repairs stop the API2 side immediately; reverting
the enabled_by_default: false lines restores the Rails cron entries. For
XCALLY completion, api2_conversation_upload_sync_completion off is the
non-racing revert: uploads processed while the flag is off complete through
the GoodJob batch exactly as before. Every
worker re-checks its flag at execution time, so queued BullMQ jobs report
skipped/paused and complete rather than racing the reverted Rails path.
After rollout, assert that the six
named classes receive zero new normal-path GoodJob rows — retained rows inside
the 12-hour cleanup window are historical, not new traffic.
Re-driving work skipped while paused
Skipped BullMQ jobs complete as skipped — nothing resumes them
automatically after rollback. Re-drive each family explicitly once Rails is
re-enabled:
- Daily reports — for a same-day rollback, enqueue the aggregate job
with the sentinel that was skipped so per-org dates still resolve in each
tenant’s timezone:
SendDailyAgentEvaluationReportJob.perform_later('yesterday')(04:00 UTC tick) orperform_later('today')(20:00 UTC tick). For a rollback after the local day has moved on, pass literal dates instead — either the aggregateperform_later('YYYY-MM-DD')(one date for all orgs) or, for exact per-tenant coverage, oneSendDailyAgentEvaluationReportForOrganizationJob.perform_later(org, 'YYYY-MM-DD', false)per reportable org. The API2-side equivalent is onePOST /internal/daily_agent_evaluation_reports/enqueueper org with an explicitdate. - Counter caches — run
FixCounterCachesJob.perform_later; a contended run retries on the shared lock rather than reporting a false success. - Unit21 sync and conversation uploads — the next cron tick covers them; both are idempotent sweeps, so no manual catch-up is needed beyond re-enabling the Rails cron entries.
Resolving uncertain delivery claims
A claim row with no delivered_at means one send attempt’s outcome could
not be confirmed. Find them with
OutboundDeliveryClaim.unresolved (Rails) or
SELECT * FROM outbound_delivery_claims WHERE delivered_at IS NULL, and
resolve each by hand rather than re-sending blindly:
- Check whether the delivery actually happened — the monitoring Slack
channel for
slackkeys, the mail provider’s log foremail/combinedkeys (the key encodes channel, job id, and enqueue timestamp). - If it landed:
OutboundDeliveryClaim.find_by(delivery_key:).mark_delivered!. The next retry seesalready_sentand skips. - If it did not:
OutboundDeliveryClaim.find_by(delivery_key:).release!(or delete the row), then re-drive the caller — the BullMQ job’s retry re-claims and re-sends.
Because senders release their claim only on proven non-delivery (a provider
rejection response), uncertain appears for genuinely ambiguous sends —
raised sends, lost responses, unwritable checkpoints — and stays rare; each
occurrence surfaces in Sentry as a ... delivery outcome uncertain
warning.
Verification
Focused API2 unit tests cover tick fan-out, failure isolation, job-ID dedupe,
report date resolution and step ordering; PGlite tests cover the real
candidate-selection SQL, reportable-organization eligibility, and the
counter-cache advisory-lock run. Rails request specs cover every new
/internal/* endpoint (auth, not-found, success, retry, and failure marking)
and every rewired producer’s API2 enqueue. API2 typecheck and scoped RuboCop
are required before push.