-
Notifications
You must be signed in to change notification settings - Fork 0
ForkSafety
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.
-
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.)
Client#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.
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 automaticallyUnicorn 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 }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.
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.
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_exitProcess.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 process| 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 | 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.
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.
| 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.
-
Code Examples —
flush,postfork, and every method -
Configuration Options —
data_refresh_interval/flush_intervaltimer-off mode - Troubleshooting — the missing-events decision tree
Copyrights © 2026 All Rights Reserved by Convert Insights, Inc.
Getting Started
Ruby SDK
- Quickstart
- Installation
- Initialization
- Configuration
- Return Types & Sentinels
- Code Examples
- Fork Safety & Runtime Recipes
- Tracking Control
Core Concepts
- Experiences & Variations
- Feature Flags
- Bucketing Algorithm
- Rule Evaluation
- Segments
- Data Management
- Event System
- API Communication
How-To Guides
- Running Experiences
- Running Features
- Tracking Conversions
- Visitor Context
- Persistent DataStore
- Troubleshooting
Contributing