Fork Safety & Runtime Recipes
Zero-config fork survival and per-runtime wiring (Puma, Unicorn, Passenger, Sidekiq, Lambda, CLI)
Fork safety is the Convert Ruby SDK's flagship guarantee. Build the client once, let your server fork workers, and events are delivered from every forked worker — with zero fork-handling code in your app. No postfork, no on_worker_boot hook required for the common runtimes.
This page covers the fork-safety model, then the copy-pasteable wiring recipe for each runtime, the fork/daemon matrix, and TRACE-logging guidance.
How fork safety works
- The only global mutation. At
require "convert_sdk"the SDK installs a singleProcess._forkhook. It is cheap and starts no threads (a no-op on JRuby by construction). - No threads until first use. The SDK starts no background threads until the first
create_contextcall. A client built in a preloading master (Pumapreload_app!) therefore carries no thread state across the fork — there is nothing to lose at fork time. - Automatic re-arm. On the first decision in a forked worker, the
_forkdetection plus PID-guarded flush boundaries automatically re-arm the client: timers re-start lazily, the queue's process ownership resets — so the worker decides and delivers on its own. - PID-guarded
at_exit. The client registers one PID-guardedat_exitflush at construction. It flushes only in the process that registered it, so a forked child never double-delivers the parent's queue. (Best-effort: it does not run underSIGKILL— that path relies on the size/interval triggers.)
When you need postfork
postforkClient#postfork is the explicit escape hatch. You need it only for setups that bypass Process._fork entirely (or daemonize via Process.daemon), or if you simply prefer an explicit re-arm (LaunchDarkly-style). It delegates to the same re-arm path as automatic detection: marks the timers dead (they re-start lazily on next use), clears queue ownership in this process, and resets the owning PID. It is idempotent and never raises.
Recipe: Rails (Puma cluster)
The standard production shape — Puma in cluster mode (workers N + preload_app!). Fork-safe with zero configuration.
1. Build one client at boot. Under preload_app! the initializer runs once in the preloading master; every forked worker inherits the already-built client.
# config/initializers/convert_sdk.rb
require "convert_sdk"
CONVERT_SDK = ConvertSdk.create(
sdk_key: ENV.fetch("CONVERT_SDK_KEY"),
sdk_key_secret: ENV["CONVERT_SDK_KEY_SECRET"]
)2. One context per request. A context is cheap (no network, no thread). The background flush timer drains events automatically in a long-running server, so you do not need to call flush per request.
# app/controllers/pricing_controller.rb
class PricingController < ApplicationController
def show
context = CONVERT_SDK.create_context(convert_visitor_id, { "country" => "US" })
variation = context.run_experience("pricing-test")
case variation&.key
when nil then render :pricing_control # business miss
when "annual" then render :pricing_annual
else render :pricing_control
end
context.track_conversion("view-pricing")
end
end3. Puma cluster config — zero fork code needed. Automatic Process._fork detection re-arms each worker on first use. For belt-and-braces (or to be explicit), the optional re-arm is a single line:
# config/puma.rb — automatic fork detection needs NOTHING;
# the optional belt-and-braces re-arm is one line.
preload_app!
on_worker_boot { CONVERT_SDK.postfork } # OPTIONAL — the SDK detects the fork automaticallyRecipe: Unicorn / Passenger
Unicorn and Passenger fork the same way Puma does, and automatic detection covers them too. If you prefer an explicit re-arm, both expose a post-fork hook — and the hook body is identical (CONVERT_SDK.postfork):
# config/unicorn.rb
preload_app true
after_fork { |_server, _worker| CONVERT_SDK.postfork } # OPTIONAL belt-and-bracesFor Passenger, place the same call inside the worker-start hook:
PhusionPassenger.on_event(:starting_worker_process) { |_| CONVERT_SDK.postfork }Recipe: Sidekiq
Sidekiq (OSS) is threaded and single-process — no fork. Build one client at boot, reuse it across all job threads, and flush on shutdown so queued events are delivered before the process exits.
# config/initializers/convert_sdk.rb
require "convert_sdk"
CONVERT_SDK = ConvertSdk.create(sdk_key: ENV.fetch("CONVERT_SDK_KEY"))class ConversionJob
include Sidekiq::Job
def perform(visitor_id, attributes = {})
context = CONVERT_SDK.create_context(visitor_id, attributes)
context.run_experience("homepage-test")
context.track_conversion("signup")
end
end# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.on(:shutdown) { CONVERT_SDK.flush }
endCONVERT_SDK.flush drains the queue synchronously, so in-flight events from every job thread are delivered before the worker terminates.
Running Sidekiq Enterprise with forking (multi-process)? Then it behaves like a forking server — automatic detection still applies, with
CONVERT_SDK.postforkavailable as the explicit belt-and-braces re-arm.
Recipe: AWS Lambda
Lambda freezes the execution environment between invocations, so background threads are useless (they may never run) and harmful (they can hold undelivered events). The recipe is timer-off mode plus a synchronous flush before the handler returns.
Disable both timers with data_refresh_interval: nil and flush_interval: nil. In timer-off mode the SDK starts zero background threads; config freshness is checked on-demand at decision time, and delivery happens only on an explicit flush.
# handler.rb — timers OFF; flush synchronously before the handler returns.
require "convert_sdk"
# Build OUTSIDE the handler (module load) to reuse across warm invocations.
CONVERT_SDK = ConvertSdk.create(
sdk_key: ENV["CONVERT_SDK_KEY"],
data_refresh_interval: nil,
flush_interval: nil
)
def handler(event:, context:)
ctx = CONVERT_SDK.create_context(event["visitorId"])
variation = ctx.run_experience("homepage-test")
CONVERT_SDK.flush # MUST be synchronous — the env freezes after return
{ variation: variation.key }
endThe PID-guarded at_exit flush is best-effort and does not run when the environment is frozen or SIGKILLed — which is exactly what Lambda does. A synchronous CONVERT_SDK.flush before the handler returns is the only reliable delivery point.
Recipe: plain CLI / scripts
A plain script (a rake task, cron job, one-off CLI) builds a client, decides, and exits. The PID-guarded at_exit flush fires automatically on normal exit — so a short script needs no explicit flush:
# script.rb — the PID-guarded at_exit flush fires on normal exit.
require "convert_sdk"
CONVERT_SDK = ConvertSdk.create(sdk_key: ENV["CONVERT_SDK_KEY"])
ctx = CONVERT_SDK.create_context("cli-visitor")
ctx.run_experience("homepage-test")
# falls off the end -> at_exit flush delivers (NOT under SIGKILL)Flush explicitly when the script is long-running (deliver at checkpoints rather than only at exit), or when the process may be terminated by SIGKILL / exit! (which skip at_exit):
CONVERT_SDK.create_context("cli-visitor").track_conversion("job-complete")
CONVERT_SDK.flush # deliver now, don't wait for at_exitDaemonized scripts (Process.daemon)
Process.daemon)Process.daemon forks and exits the parent, so a client built before Process.daemon lives on in the forked daemon. Call postfork after daemonizing (or build the client after Process.daemon) so the daemon re-arms in its own process:
Process.daemon(true)
CONVERT_SDK.postfork # re-arm in the daemonized processFork/daemon matrix
| Runtime | Forks? | Automatic re-arm? | Explicit postfork needed? | Wiring |
|---|---|---|---|---|
Puma cluster (preload_app!) | Yes | ✅ Yes | No (belt-and-braces optional) | Rails recipe |
| Unicorn | Yes | ✅ Yes | No (after_fork belt-and-braces) | Unicorn/Passenger recipe |
| Passenger | Yes | ✅ Yes | No (starting_worker_process belt-and-braces) | Unicorn/Passenger recipe |
| Sidekiq (OSS, threaded) | No | n/a (no fork) | No — add a shutdown flush | Sidekiq recipe |
| AWS Lambda | No | n/a (env freezes) | No — timers off + sync flush | Lambda recipe |
| Plain CLI | No | n/a | No — at_exit flush is automatic | CLI recipe |
Process.daemon | Yes | ⚠️ Not guaranteed | Yes — call postfork after daemonizing | Daemonized scripts |
The Process.daemon edge case is the one runtime that requires explicit wiring: it forks and detaches, and the daemonized child may never reach a flush boundary that triggers automatic detection in time.
TRACE logging
The SDK is built to never crash your host — every public method degrades to a return value and a log line instead of raising. Failures are therefore silent by design, so debugging is mostly about turning on logging and checking the fork/daemon wiring.
Set the threshold with log_level: and attach a sink at create time with sink: (any object responding to debug/info/warn/error):
require "convert_sdk"
require "logger"
CONVERT_SDK = ConvertSdk.create(
sdk_key: ENV.fetch("CONVERT_SDK_KEY"),
log_level: ConvertSdk::LogLevel::TRACE, # finest-grained
sink: Logger.new($stdout)
)Passing sink: at create time (not afterward) is what makes the construction-time lines observable — including the initial config fetch.
What to look for
| Line fragment | What it tells you |
|---|---|
installed direct data config / installed fetched config | Config loaded successfully (the SDK is ready). |
config fetch failed (status …); continuing without config | The fetch failed; the client is running config-less and decisions will miss. |
run_at_exit_flush: registering process exiting, flushing | The PID-guarded at_exit flush fired on normal exit. |
run_at_exit_flush: suppressed in forked child (pid mismatch) | A forked child correctly did not double-flush the parent's queue. |
tracking disabled, event suppressed / tracking suppressed for call | Delivery was suppressed by the global or per-call tracking switch. |
queue full, dropping oldest event | The 1000-event queue cap was hit (you are enqueuing faster than you flush). |
TRACEandDEBUGboth dispatch to the sink's#debugmethod (the stdlibLoggerhas notrace); the numeric level, not the sink method, decides whether a line emits. Secrets (sdk_key/sdk_key_secret) are redacted from every line before any sink sees it.
Next steps
- Code Examples —
flush,postfork, and every method - Configuration Options —
data_refresh_interval/flush_intervaltimer-off mode - Troubleshooting — the missing-events decision tree