Skip to content

ForkSafety

Ahmed Abbas edited this page Jun 8, 2026 · 1 revision

Fork Safety & Runtime Recipes

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

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).

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

Clone this wiki locally