Background Jobs Configuration
This document covers how background jobs actually run in the Kulcare API backend:
Sidekiq (via systemd, managed by Capistrano), scheduled jobs (via whenever
crontab, not Sidekiq-cron), job code layout (domains/*/jobs/), and failure
alerting (Slack — there is no Sentry in this app).
Companion reading
typesense-search-sync.md— the most detailed worked example of a job pipeline (§7 failure handling, §8 observability, "Deploying & operating").row-level-security.md— why jobs must run under the right tenant GUC (§5.5, cross-tenant fan-out).
The model in one paragraph
Jobs are Active Job classes (queue_adapter = :sidekiq, set per environment in
config/environments/{development,staging,production}.rb) that live under
domains/<domain>/jobs/ and inherit from Shared::ApplicationJob. One Sidekiq
process per server works every queue listed in config/sidekiq.yml — it's a
systemd unit (sidekiq) managed by capistrano-sidekiq, so cap deploy restarts
it automatically. Recurring work is cron, generated by the whenever gem
from config/schedule.rb and applied automatically on every deploy (via
Capfile → whenever/capistrano). Failures alert to Slack in production via a
global Sidekiq error handler; everything else is visible in the Sidekiq Web UI
at /sidekiq.
| Concern | What we use | Where |
|---|---|---|
| Worker | Sidekiq, systemd unit sidekiq (capistrano-sidekiq) | backend/config/sidekiq.yml, backend/Capfile |
| Queues | Bare names listed in sidekiq.yml (no prefix) | backend/config/sidekiq.yml |
| Job classes | domains/*/jobs/… < Shared::ApplicationJob | backend/domains/, backend/domains/shared/jobs/application_job.rb |
| Scheduling | whenever → crontab, applied on cap deploy | backend/config/schedule.rb, backend/Capfile |
| Redis | Rails credentials (redis_url, redis_password) | backend/config/initializers/sidekiq.rb |
| Tenant context in jobs | Sidekiq client/server middleware (RLS) | backend/config/initializers/sidekiq_tenant_middleware.rb |
| Failure alerting | Slack webhook (production only) | backend/parts/core/services/slack_sidekiq_failures.rb |
| Web UI | /sidekiq, basic-auth from credentials | backend/parts/core/routes.rb, initializer |
| Sentry | None — no gem; sentry.rb initializer is commented out | — |
Sidekiq configuration
config/sidekiq.yml (the real file)
---
:verbose: false
:concurrency: 3
:pidfile: tmp/pids/sidekiq.pid
staging:
:concurrency: 5
production:
:concurrency: 5
:queues:
- patient_history
- knowledge_base
# … ~55 more domain queues …
- [mailers, 2] # weighted 2x
- reindexing
- default
- notifications
# … etc …Notes:
- Redis is not configured here. The connection comes from the initializer
(below) via Rails credentials. Do not add a
:redis:block to this file. - Queues are bare names (
reindexing,notifications,mailers, …). There is noqueue_name_prefix— the prefix line inconfig/environments/production.rbis commented out. - Adding a new queue? It must be listed here. Sidekiq only polls queues
named in this file; a
queue_as :clinicaljob with no entry below enqueues and then never runs (the #1855 QA bug). Whenever a domain adds a queue, add the entry in the same change.
config/initializers/sidekiq.rb (the real file, abridged)
# Redis via Rails credentials (staging/production; dev/test fall back to
# Sidekiq's default localhost Redis):
unless Rails.env.development? || Rails.env.test?
Sidekiq.configure_server do |config|
config.redis = {
url: Rails.application.credentials.redis_url,
password: Rails.application.credentials.redis_password
}
end
Sidekiq.configure_client do |config|
config.redis = {
url: Rails.application.credentials.redis_url,
password: Rails.application.credentials.redis_password
}
end
end
# Global defaults: 3 retries, no backtrace (memory)
Sidekiq.default_job_options = { backtrace: false, retry: 3 }
Sidekiq.configure_server do |config|
config.on(:startup) { Rails.logger.info 'Sidekiq is starting...' }
config.on(:quiet) { Rails.logger.warn 'Got USR1, stopping further job processing...' }
config.on(:shutdown){ Rails.logger.warn 'Got TERM, shutting down process...' }
# Failure alerting: Slack, production only
config.error_handlers << proc { |ex| SlackSidekiqFailures.perform(ex) if Rails.env.production? }
end
# Sidekiq Web UI basic auth (credentials-stored)
Sidekiq::Web.use(Rack::Auth::Basic) do |username, password|
username == Rails.application.credentials.sidekiq_username &&
password == Rails.application.credentials.sidekiq_password
endTenant context in jobs (RLS)
Many domain tables are RLS-forced, so jobs must execute under the right tenant
GUC or they silently query nothing. This is handled by middleware —
config/initializers/sidekiq_tenant_middleware.rb:
- Client middleware captures
Shared::Current.tenant/.userinto the job payload at enqueue time. - Server middleware restores them (and the Postgres RLS GUC) on the
worker, and clears everything in an
ensureblock to prevent context leakage between jobs.
Jobs therefore "just work" when enqueued from a tenant-scoped web request. For
cron-enqueued or console-enqueued jobs (no ambient tenant), the job itself must
establish context — see Identity::Typesense::BaseSyncJob#with_tenant_context
for the canonical pattern and
row-level-security.md §5.5 for the
cross-tenant fan-out.
Job classes and organization
Jobs live under the domain that owns them — not app/jobs/:
backend/domains/
├── identity/jobs/ # e.g. device_prune_job.rb, typesense/*, geocode_facility_location_job.rb
├── billing/jobs/ # e.g. stale_pending_cleanup_job.rb, care_package_orders/*, marketplace/*
├── clinical/jobs/
├── growth/jobs/
├── notifications/jobs/
├── connect/jobs/
└── shared/jobs/application_job.rbThe base class (domains/shared/jobs/application_job.rb):
module Shared
class ApplicationJob < ActiveJob::Base
include Bullet::ActiveJob if defined?(Bullet::ActiveJob) && Rails.env.development?
end
endConventions:
- Inherit from
Shared::ApplicationJob(a legacy bareApplicationJobexists inparts/core/jobs/for older code; don't use it for new jobs). - Declare the queue explicitly:
queue_as :billing— and make sure that queue is inconfig/sidekiq.yml(see above). - Retries are global (
retry: 3,backtrace: false); a job that keeps failing exhausts retries and lands in the dead set, visible in the Web UI. - Make cron-invoked jobs idempotent — cron and Sidekiq retries can double-
fire (every entry in
config/schedule.rbis written with this assumption).
Scheduled jobs (whenever → crontab)
There is no sidekiq-cron, no config/schedule.yml, and no :scheduler:
block in sidekiq.yml. Recurring work is plain cron, generated from
backend/config/schedule.rb by the whenever gem. The Capfile wires it into
Capistrano:
require 'whenever/capistrano'
set :whenever_identifier, ->{ "#{fetch(:application)}_#{fetch(:stage)}" }so cap deploy regenerates the crontab automatically — there is no manual
crontab step. The current schedule (all off-peak, all idempotent):
| When | Entry | Purpose |
|---|---|---|
| daily 3:00am | rake typesense:reindex | Full Typesense rebuild — self-healing backstop for the per-edit sync jobs (#1592) |
| daily 3:30am | runner "Billing::StalePendingCleanupJob.perform_later" | Expire subscriptions abandoned in pending (#1641) |
| daily 3:45am | runner "Billing::CarePackageOrders::SweepAbandonedJob.perform_later" | Reap care-package purchases abandoned at Cashfree checkout (#1086) |
| Sunday 4:00am | runner "Identity::DevicePruneScheduler.perform_later" | Fleet-wide stale-device prune for users who never log in (#1688) |
Cron output goes to /var/log/cron_log.log (set :output in schedule.rb).
Adding a scheduled job: add the entry to config/schedule.rb with a comment
explaining why it's safe to re-run, then deploy — Capistrano applies it.
Locally you can preview/apply manually:
bundle exec whenever # preview generated crontab
bundle exec whenever --update-crontab # apply (deploy does this for you)
crontab -l # verify what's installedDeployment & the systemd unit
capistrano-sidekiq (see Gemfile) provides a user-level systemd unit
named sidekiq (install_plugin Capistrano::Sidekiq::Systemd in the
Capfile), reading config/sidekiq.yml. Consequences:
cap deployruns DB migrations and then restarts thesidekiqunit — no manual service management during a normal deploy.- The unit must exist on a new server before the first deploy
(
cap sidekiq:install/ ops provisioning). - One process works all queues from
config/sidekiq.yml. If you ever split Sidekiq into per-queue processes, ensure every queue — especially:reindexing— is still covered by some worker, or that pipeline silently stops (the failure class #1592 was originally about).
Manual operations on a server (user systemd):
systemctl --user status sidekiq
systemctl --user restart sidekiq
journalctl --user -u sidekiq -fMonitoring, failure handling & alerting
How a failing job flows through the system:
- The job raises → Sidekiq retries (global
retry: 3). - Each failure fires the global error handler →
SlackSidekiqFailures.perform(ex)— production only — which posts the error to theslack_be_issueswebhook channel (parts/core/services/slack_sidekiq_failures.rb). - After retries are exhausted the job lands in the dead set, visible in the Sidekiq Web UI.
Observability surfaces:
- Sidekiq Web UI — mounted at
/sidekiq(parts/core/routes.rb), basic- auth'd via credentials (initializer). Shows queue depths, retries, and the dead set in every environment. Thesidekiq-failuresgem adds a failures view. - Slack alerts — production job failures (above).
rake typesense:status— reindexing queue depth, retry/dead-set counts, collection document counts; usable during an incident.- There is no Sentry. No
sentry-*gem is installed;config/initializers/sentry.rbis fully commented out. (A queue namedsentryappears insidekiq.yml— it's a legacy queue name, not Sentry integration.) Don't addSentry.*calls expecting them to fire; ops alerting is Slack.
Local development
config/environments/development.rbalso setsconfig.active_job.queue_adapter = :sidekiq, so enqueueing in dev enqueues for real. Run a worker withbundle exec sidekiq -C config/sidekiq.yml(defaults to localhost Redis — no credentials needed), or useperform_now/inline adapters in tests.- Dev/test skip the credentials-based Redis config (see the
unlessguard in the initializer). after_commitcallbacks don't fire under transactional tests — see how the Typesense specs invoke callbacks directly (docs/guides/developer/typesense-search-sync.md§11).
Troubleshooting
Job enqueued but never runs — the #1 cause: its queue is missing from
config/sidekiq.yml (#1855). Add the queue, deploy (restarts the unit).
"Search is stale" / sync silently stopped — see
typesense-search-sync.md §10:
rake typesense:status → check the dead set → rake typesense:reindex.
A cron job didn't run — crontab -l (was the last deploy's whenever
update applied?); check /var/log/cron_log.log; confirm the entry is in
config/schedule.rb.
Job queries return nothing under RLS — the tenant GUC wasn't set: the job
was enqueued without ambient context (cron/console). See the middleware notes
above and row-level-security.md §6.1.
Sidekiq won't start — check Redis reachability with the credential URL,
then journalctl --user -u sidekiq.