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
class ReindexProductJob < ApplicationJob
include DefensiveDeserialization
queue_as :indexing
discard_on ActiveJob::DeserializationError
retry_on Search::TransientError, wait: :polynomially_longer, attempts: 5
def perform(product, locale: I18n.default_locale)
return if product.respond_to?(:missing?) && product.missing?
document = ProductDocument.build(product, locale: locale)
Search::Client.index(
index: "products_#{locale}",
id: product.id,
body: document.as_json,
refresh: false
)
rescue Search::ConflictError
logger.info("skipping stale version for product #{product.id}")
end
end
class ProductsController < ApplicationController
before_action :set_product, only: %i[update destroy]
def update
if @product.update(product_params)
ReindexProductJob.perform_later(@product, locale: I18n.locale)
render json: @product, status: :ok
else
render json: { errors: @product.errors }, status: :unprocessable_entity
end
end
def destroy
@product.destroy!
# Job is already queued; deletion is handled defensively on the worker side.
ReindexProductJob.perform_later(@product)
head :no_content
end
private
def set_product
@product = Product.find(params[:id])
end
def product_params
params.require(:product).permit(:name, :price_cents, :status, :description)
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.