antiwork/gumroad · warning · Product::StaleContentWriteGuard::StaleContentConflict

stale_content_conflict

stale_content_conflict

Error message

This product was updated after this page was loaded, so saving now would overwrite those newer changes. Please reload the page to get the latest content, then make your edits again.

What it means

Raised as StaleContentConflict from Product::StaleContentWriteGuard when a product save payload references page/variant versions that have since changed on the server — an optimistic-concurrency guard. The guard compares stale_page_external_ids / stale_variant_external_ids from the incoming write against current records; when blocking is enabled (a Flipper flag on the owner, read fail-open inside the row lock) the save is aborted with this conflict instead of silently overwriting newer content. It only fires when enforcement is on and the timestamp/version evidence says the client edited a stale copy.

Source

Thrown at app/services/product/stale_content_write_guard.rb:137

    # accepted deliberately for now — the observe-only signal is the input to
    # the payload-contract work (gumroad-private#1360 / #1361) and under-reporting
    # it would hide the bug we are trying to characterise. If the volume becomes
    # a problem the lever is a Sentry-side sample rate on OBSERVED_MESSAGE, not
    # dropping events here. Tracked on gumroad-private#1295.
    ErrorNotifier.notify(
      blocking ? BLOCKED_MESSAGE : OBSERVED_MESSAGE,
      product_id: product.id,
      stale_page_external_ids: stale_records.select { _1[:type] == "page" }.map { _1[:id] },
      stale_variant_external_ids: stale_records.select { _1[:type] == "variant" }.map { _1[:id] },
      **diagnostics
    )
    # Enforcement off: the save proceeds exactly as it did before this guard
    # shipped. The deletion guards later in the save are untouched and still
    # block payloads that would remove content.
    return unless blocking

    product.errors.add(:base, MESSAGE)
    raise StaleContentConflict.new(MESSAGE, stale_records:)
  end

  # Whether the seller-visible rejection is switched on for this product's
  # owner.
  #
  # Reading a Flipper flag is a Redis round trip, and this runs inside the
  # save's transaction while it holds the `SELECT ... FOR UPDATE` lock on the
  # product row. So a feature-store outage must not turn a save this guard was
  # about to ALLOW into a 500 — that would recreate, for a different reason, the
  # exact failure this gate exists to stop. A failed lookup therefore reads as
  # "not enforcing": the same fail-open direction as a missing or unparseable
  # timestamp, and the only direction consistent with why the flag exists.
  #
  # The actor is the product's OWNER rather than whoever is doing the editing,
  # so a canary cohort is a stable set of sellers and a collaborator editing
  # someone else's product gets the same behaviour that seller does.
  def self.enforcement_enabled?(product)
    Feature.active?(BLOCK_FEATURE_NAME, product.user)

View on GitHub (pinned to afeacbd394)

Solutions

  1. Reload the product content in the editor, re-apply the intended edits on the fresh version, and save again — the conflict is a version race, not corruption.
  2. If you are building an API client, refetch the product (GET) before rewriting rich content, and send the version/external ids from that fresh read.
  3. Operators: confirm the Flipper flag state — enforcement off means the save proceeds (OBSERVED_MESSAGE path only notifies); flipping it on mid-session is what makes previously-working saves conflict.
  4. Only suppress deliberately: the guard exists to stop silent overwrites, so do not disable it to 'fix' the error; fix the concurrent-edit workflow instead.

Example fix

# before: single-shot save that loses to a concurrent editor
product.update!(rich_content_params)
# after: reload-and-retry on conflict
begin
  product.update!(rich_content_params)
rescue StaleContentConflict => e
  product.reload
  editor_prompt = { stale_pages: e.stale_records.select { _1[:type] == "page" }.map { _1[:id] } }
  reapply_edits_on_fresh_content(editor_prompt)
end
Defensive patterns

Strategy: retry

Validate before calling

# client-side, before saving: compare version/timestamp evidence with a fresh read
fresh = ProductApi.get(product.id)
return conflict if fresh.content_version > editor.loaded_content_version

Type guard

def stale_write?(product, payload_page_ids, payload_variant_ids)
  current_pages = product.alive_rich_contents.map(&:external_id)
  current_variants = product.alive_variants.flat_map { |v| v.alive_rich_contents.map(&:external_id) }
  (payload_page_ids - current_pages).any? || (payload_variant_ids - current_variants).any?
end

Try / catch

begin
  product.save!
rescue StaleContentConflict => e
  product.reload
  reapply_edits_from(e.stale_records) # re-base the user's edits on fresh content
  product.save!
end

Prevention

When it happens

Trigger: Two editors (or two tabs/agents of one seller) update the same product: the loser's request carries rich-content page/variant external ids whose state no longer matches the DB (older than the winner's write), and the Flipper flag enables blocking for that owner, so product.errors.add(:base) plus raise StaleContentConflict fires inside the save.

Common situations: Seller edits in two browser tabs; a long-lived edit session while an integration (API client or another collaborator) updated the product; rich-content editor loaded before a concurrent save; flag rollout mid-edit changing behavior between load and save.

Related errors


AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21). Data as JSON: /api/errors/99269c68eb4822e5. Report an issue: GitHub.