ruby 92 lines · 3 tabs

Defensive Deserialization for ActiveJob

Shared by codesnips Jan 2026
3 tabs
module DefensiveDeserialization
  extend ActiveSupport::Concern

  MissingRecord = Struct.new(:gid) do
    def missing?
      true
    end
  end

  def deserialize_arguments_if_needed
    super
  rescue ActiveJob::DeserializationError
    self.arguments = rebuild_arguments(serialized_arguments)
  end

  private

  def rebuild_arguments(raw)
    ActiveJob::Arguments.deserialize(raw).map { |arg| coerce(arg) }
  rescue ActiveJob::DeserializationError => e
    # A raw arg is a bare GID hash we can salvage individually.
    raw.map { |arg| coerce_raw(arg) }
  end

  def coerce(arg)
    arg.respond_to?(:missing?) ? arg : arg
  end

  def coerce_raw(arg)
    return arg unless arg.is_a?(Hash) && arg.key?(GlobalID::Locator::GLOBALID_KEY)

    gid = arg[GlobalID::Locator::GLOBALID_KEY]
    record = GlobalID::Locator.locate(gid)
    return record if record

    logger.warn("[#{self.class.name}] missing record for #{gid}, substituting sentinel")
    MissingRecord.new(gid)
  rescue StandardError
    MissingRecord.new(arg)
  end
end
3 files · ruby Explain with highlit

When an ActiveJob is enqueued, Rails serializes its arguments through ActiveJob::Arguments, and Active Record objects are stored as GlobalIDs rather than as full records. The problem this creates is temporal: a job may sit in the queue for seconds or hours, and by the time a worker picks it up, a referenced record can be gone, a symbol-based enum value can have been renamed, or a stale payload from an old deploy can carry a shape the current code no longer understands. The default behavior is to raise ActiveJob::DeserializationError (wrapping ActiveRecord::RecordNotFound), which turns a benign missing record into a retry storm.

The DefensiveDeserialization concern in the first tab overrides deserialize_arguments_if_needed, the internal hook ActiveJob calls just before perform. It attempts the normal deserialization, and on ActiveJob::DeserializationError it walks the raw serialized_arguments and rebuilds them leniently: GlobalIDs that no longer resolve are replaced with a MissingRecord sentinel via GlobalID::Locator.locate, and anything else falls through untouched. This keeps the failure local to the specific argument instead of aborting the whole job.

MissingRecord is a tiny value object that records the original gid and answers missing? truthfully, so downstream code can branch on it rather than crash on a nil. The pattern favors an explicit sentinel over nil because nil is ambiguous — it cannot distinguish "argument was genuinely nil" from "record was deleted".

The ReindexProductJob shows the concern in use. It includes DefensiveDeserialization, then guards perform with return if product.missing? so a deleted product is treated as a no-op success, not an error. discard_on for ActiveJob::DeserializationError covers the residual case where even lenient rebuilding fails, ensuring such jobs are dropped instead of retried forever.

The trade-off is deliberate: silently absorbing missing records hides referential problems, so the concern logs each substitution through logger.warn for observability. This approach is worth reaching for on high-volume queues where records churn faster than jobs drain, and where a missing dependency should mean "skip", not "page someone at 3am".


Related snips

Share this code

Here's the card — post it anywhere.

Defensive Deserialization for ActiveJob — share card
Link copied