{"record":{"id":"b2d15e28b9ec8940","repo":"antiwork/gumroad","slug":"file-s-missing-ids-join-no-longer-exist","errorCode":null,"errorMessage":"File(s) #{missing_ids.join(', ')} no longer exist; they may have been deleted by a concurrent request. Retry with the current file list.","messagePattern":"File\\(s\\) #(.+?) no longer exist; they may have been deleted by a concurrent request\\. Retry with the current file list\\.","errorType":"exception","errorClass":"Link::LinkInvalid","httpStatus":200,"severity":"error","filePath":"app/controllers/api/v2/links_controller.rb","lineNumber":487,"sourceCode":"            @product.custom_html = nil\n            sanitization_report = Ai::PageSanitizer.empty_report\n          else\n            result = Ai::PageSanitizer.sanitize_with_report(params[:custom_html])\n            @product.custom_html = result.html.presence\n            sanitization_report = result.report\n          end\n        end\n\n        flag_changed = @product.has_same_rich_content_for_all_variants? != rich_content_flag_was\n\n        unless @normalized_files.nil?\n          # Unknown client ids (cli-upload-*) are creates. Only ids that matched\n          # an alive file before this request can be a concurrent delete.\n          referenced_existing_ids = @normalized_files.filter_map { |f| f[:id] if f[:id].present? && @known_existing_file_ids&.include?(f[:id]) }\n          if referenced_existing_ids.any?\n            locked_alive_ids = @product.product_files.alive.lock.map(&:external_id)\n            missing_ids = referenced_existing_ids - locked_alive_ids\n            raise Link::LinkInvalid, \"File(s) #{missing_ids.join(', ')} no longer exist; they may have been deleted by a concurrent request. Retry with the current file list.\" if missing_ids.any?\n          end\n\n          validate_file_embed_conflicts!(skip_variant_embeds: flag_changed && @product.has_same_rich_content_for_all_variants? && !@normalized_rich_content.nil?)\n\n          rich_content_params = build_rich_content_params\n          file_id_mappings = SaveFilesService.perform(@product, { files: @normalized_files }, rich_content_params) || {}\n        end\n\n        @product.save!\n\n        @product.save_tags!(params[:tags]) if params.key?(:tags)\n\n        if params.key?(:cover_ids)\n          cover_ids = normalize_params_recursively(params[:cover_ids])\n          @product.reorder_previews(cover_ids.map.with_index.to_h)\n        end\n\n        if !@normalized_rich_content.nil? && !@product.has_same_rich_content_for_all_variants? && @product.alive_variants.exists?","sourceCodeStart":469,"sourceCodeEnd":505,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/controllers/api/v2/links_controller.rb#L469-L505","documentation":"Link::LinkInvalid raised in Api::V2::LinksController#update during the file-sync step: the request's files array references existing file ids (ones that matched alive files before the request — client ids like cli-upload-* are creates), but after taking a lock on the product's alive files, some referenced ids are gone. That means a concurrent request deleted those files between this request's read and its write, so applying the payload would silently resurrect or 404; the API refuses and tells the client to refetch.","triggerScenarios":"PUT/PATCH /api/v2/links/:id with files containing ids of files that a concurrent request deleted after this request loaded @known_existing_file_ids — the locked alive scan (product_files.alive.lock) no longer finds them, producing missing_ids.","commonSituations":"Two editor sessions or an API integration deleting files while another update is in flight; a stale admin tab submitting an old file list after files were removed elsewhere; automation pipelines doing read-modify-write on the files array without refetching.","solutions":["Refetch the product's current file list (GET the link), merge your intended changes onto that fresh list, and resubmit.","In integrations, serialize file mutations per product (one writer at a time) or refetch immediately before each write.","If the deletion was unexpected, audit who deleted the files (concurrent request, another team's script) before retrying.","Don't blindly strip the missing ids from the payload without refetching — other fields may also be stale."],"exampleFix":"# before: read-modify-write across a long gap\nfiles = LinkApi.get(link_id).files\n# ... minutes later ...\nLinkApi.update(link_id, files: files)\n# after: refetch right before the write\nfresh = LinkApi.get(link_id).files\nmerged = merge_changes(fresh, intended_changes)\nLinkApi.update(link_id, files: merged)","handlingStrategy":"retry","validationCode":"# refetch and diff immediately before the write\nalive_ids = LinkApi.get(link_id).files.map { |f| f[:id] }\nrejected = payload_files.map { |f| f[:id] }.compact - alive_ids - new_client_ids\nrefresh_list_then_merge if rejected.any?","typeGuard":"def referenced_files_alive?(payload_files, current_alive_ids)\n  referenced = payload_files.filter_map { |f| f[:id] }\n  referenced.empty? || (referenced - current_alive_ids).empty?\nend","tryCatchPattern":"begin\n  LinkApi.update(link_id, files: payload)\nrescue Link::LinkInvalid => e\n  if e.message.include?(\"no longer exist\")\n    payload = merge_onto(LinkApi.get(link_id).files, changes)\n    retry\n  else\n    raise\n  end\nend","preventionTips":["Do read-modify-write on the files array in one tight sequence; refetch right before update.","Single-writer per product for file mutations in integrations.","Treat this error as a signal to refresh all product state, not just the file ids."],"tags":["api","files","concurrency","conflict","products"],"backgroundTag":"concurrent-modification-conflict","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}