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 single Process._fork hook. 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_context call. A client built in a preloading master (Puma preload_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 _fork detection 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-guarded at_exit flush 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 under SIGKILL — that path relies on the size/interval triggers.)

When you need postfork

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.

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
end

3. 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 automatically

Recipe: 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-braces

For 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 }
end

CONVERT_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.postfork available 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 }
end

The 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_exit

Daemonized scripts (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 process

Fork/daemon matrix

RuntimeForks?Automatic re-arm?Explicit postfork needed?Wiring
Puma cluster (preload_app!)Yes✅ YesNo (belt-and-braces optional)Rails recipe
UnicornYes✅ YesNo (after_fork belt-and-braces)Unicorn/Passenger recipe
PassengerYes✅ YesNo (starting_worker_process belt-and-braces)Unicorn/Passenger recipe
Sidekiq (OSS, threaded)Non/a (no fork)No — add a shutdown flushSidekiq recipe
AWS LambdaNon/a (env freezes)No — timers off + sync flushLambda recipe
Plain CLINon/aNo — at_exit flush is automaticCLI recipe
Process.daemonYes⚠️ Not guaranteedYes — call postfork after daemonizingDaemonized 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 fragmentWhat it tells you
installed direct data config / installed fetched configConfig loaded successfully (the SDK is ready).
config fetch failed (status …); continuing without configThe fetch failed; the client is running config-less and decisions will miss.
run_at_exit_flush: registering process exiting, flushingThe 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 callDelivery was suppressed by the global or per-call tracking switch.
queue full, dropping oldest eventThe 1000-event queue cap was hit (you are enqueuing faster than you flush).

TRACE and DEBUG both dispatch to the sink's #debug method (the stdlib Logger has no trace); 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