← All posts

Jun 14, 2026

Inside Mastodon's PostStatusService: Idempotency, Locks, and a Long Workflow

Mastodon's PostStatusService is what posting a toot looks like at scale. Idempotency keys, distributed Redis locks, scheduled posts. A walkthrough of the patterns I'd steal.

rails oss mastodon service-objects idempotency

PostStatusService is the Rails service that runs every time anyone posts to Mastodon. It validates media, applies the user’s defaults, schedules or persists the status, and triggers the post-processing pipeline. It’s the kind of code that runs millions of times a day on a federated network — which is exactly why I wanted to read it.

I’m reading mastodon/mastodon at SHA bdad4f78f309af6ac439dac4f7705818550e7c08. Mastodon is AGPL-3.0, so excerpts are short and verbatim. Footnote at the bottom.

The shape

The service is a single class with a single public method, call(account, options). That’s already a pattern: every Mastodon service inherits from BaseService and exposes one entry point. Whether you call it call, perform, or run is bikeshedding — pick one and stick to it. Mastodon picked call.

The orchestration in call is short enough to read in one breath:

# mastodon/mastodon · app/services/post_status_service.rb · @bdad4f7
def call(account, options = {})
  @account     = account
  @options     = options
  @text        = @options[:text] || ''
  @in_reply_to = @options[:thread]
  @quoted_status = @options[:quoted_status]

  with_idempotency do
    validate_media!
    preprocess_attributes!

    if scheduled?
      schedule_status!
    else
      process_status!
    end
  end
  # ... post-processing follows
end

That’s the whole flow: assign instance variables, wrap in idempotency, validate, preprocess, then either schedule or post. The interesting code is in the helpers.

Idempotency, the Mastodon way

The first thing the orchestration does is wrap the work in with_idempotency. This is the part I’d steal:

# mastodon/mastodon · app/services/post_status_service.rb · @bdad4f7
def with_idempotency
  return yield unless idempotency_given?

  with_redis_lock("idempotency:lock:status:#{@account.id}:#{@options[:idempotency]}") do
    return idempotency_duplicate if idempotency_duplicate?

    yield

    redis.setex(idempotency_key, 3_600, @status.id)
  end
end

Three things going on:

  1. Opt-in. If the caller didn’t pass an idempotency key, the method just yields. No overhead, no Redis traffic, no behavior change. This is the right default — the cost of idempotency is always wasted on the calls that don’t need it.

  2. Distributed lock around the whole block. with_redis_lock (from the Lockable concern) takes a Redis-backed lock for the duration. This is doing two jobs: preventing concurrent attempts at the same idempotent operation from racing, and preventing the duplicate check + write from being non-atomic. Without the lock, two requests with the same key could both observe “no duplicate” and both write.

  3. Stored key has a TTL. redis.setex(idempotency_key, 3_600, @status.id) stores the result for an hour. After that the key expires and re-posting with the same idempotency key would actually post. That’s a deliberate tradeoff — Mastodon assumes idempotency keys are fresh-per-attempt, not eternal.

The pattern generalizes. Anywhere you have a “create exactly one of these even if the client retries” workflow — payment intents, shipment requests, webhooks — this is the shape. A short-lived Redis key, a lock around the check-and-write, an opt-in trigger.

Preprocess: the case statement that wasn’t

preprocess_attributes! is where defaults get applied. It’s a method I’d normally write as a case statement and immediately regret. Mastodon writes it as a sequence of guarded assignments:

# mastodon/mastodon · app/services/post_status_service.rb · @bdad4f7
def preprocess_attributes!
  @sensitive    = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
  @text         = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? && @quoted_status.blank?
  @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
  @visibility   = :unlisted if @visibility&.to_sym == :public && @account.silenced?
  @visibility   = :private if @quoted_status&.private_visibility? && %i(public unlisted).include?(@visibility&.to_sym)
  @scheduled_at = @options[:scheduled_at]&.to_datetime
  @scheduled_at = nil if scheduled_in_the_past?
rescue ArgumentError
  raise ActiveRecord::RecordInvalid
end

This is dense, and I had to read it twice. But once you see the pattern — each line is “set the default, then maybe override based on context” — it’s actually quite linear. The visibility logic alone is three lines:

  • Take the explicit visibility, fall back to the user’s default
  • If the account is silenced, downgrade public to unlisted
  • If the quoted status is private, downgrade public/unlisted to private

Reading it as three separate rules is far easier than reading it as one nested ternary. The author resisted the urge to compress, and the file is more readable for it.

The rescue ArgumentError at the bottom converts a parse failure on scheduled_at into the same exception that validation failures raise. That’s a nice consistency move — the caller catches one thing, not two.

What’s not in the service

What I find interesting about PostStatusService is what it doesn’t do. The service doesn’t:

  • Render any response (that’s the controller)
  • Send notifications (that’s postprocess_status!, which delegates further)
  • Federate the status to other servers (that’s a job, enqueued downstream)
  • Check authorization (that’s the controller, again)

The service is a workflow orchestrator. It owns the rules for what it means to create a status. Everything tangential lives elsewhere. This is why the file isn’t 2,000 lines — the work is delegated to specialized helpers and Sidekiq workers, and the service just sequences them.

If I were starting a service object today, the rules I’d take from this file:

  1. One public method. Make every service callable the same way.
  2. Set instance variables once at the top. Don’t pass them between private methods.
  3. Idempotency is opt-in via key, not always-on. The cost is real; only pay it when the caller asks.
  4. Distributed lock around the full check-and-write. Without it, the check is racy.
  5. Preprocessing is a sequence, not a state machine. Each line is one rule. Read top to bottom.

License footnote

Mastodon is licensed under AGPL-3.0. The excerpts above are short illustrative quotations of public source code at SHA bdad4f78f309af6ac439dac4f7705818550e7c08, used here for commentary and education. If you build on Mastodon’s source, the AGPL applies to your derivative work — read the license carefully.