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
- 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.
- 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.
- 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.
- 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
- Refetch the product immediately before saving rich content in shared editors.
- Send the version/external ids from the read you based edits on, so the guard can detect staleness.
- Surface a merge/reload prompt to users instead of auto-overwriting on conflict.
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
- File(s) #{missing_ids.join(', ')} no longer exist; they may
- Your profile was changed somewhere else. Please reload the p
- Cannot update product-level rich content while in per-varian
- Cannot switch to shared content: both product-level and vari
- Cannot switch to shared content: multiple variants have dist
AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21).
Data as JSON: /api/errors/99269c68eb4822e5.
Report an issue: GitHub.