Files
sure/app/models/import_session.rb
Guillem Arias Fauste 9d4b4bc27c feat(jobs): reap imports and exports stuck by lost background jobs (#2681)
* feat(jobs): reap imports and exports stuck by lost background jobs

A hard-killed worker (OOM, SIGKILL during deploy) loses its in-flight
Sidekiq job permanently. Sync is the only model with a stale sweep;
everything else wedges in a non-terminal status forever:

- Import stuck "importing"/"reverting" (e.g. #2274)
- ImportSession stuck "importing" — unrecoverable, publish_later
  refuses to re-publish while importing
- FamilyExport stuck "pending"/"processing" — exports index polls
  every 3s indefinitely
- PdfImport's AI-processing claim held forever (ProcessPdfJob's
  reclaim only runs if the job is redelivered)
- Provider activities_fetch_pending flags stranded when a
  self-rescheduling fetch chain loses a link

SyncCleanerJob (existing hourly cron) now sweeps all of them, each
isolated so one failure doesn't block the rest, and records every
reaped record as a DebugLogEntry with the family attached.

Idempotency guards so a stray or redelivered job cannot corrupt a
record the reaper (or a user retry) has since moved on:

- Import#publish skips complete/reverting/revert_failed imports —
  a redelivered ImportJob after completion double-applies data for
  import types without row dedup. Failed imports deliberately stay
  publishable: their transaction rolled back, so a re-run is a retry.
- FamilyDataExportJob refuses terminal exports but still allows
  processing ones through, since graceful-shutdown redelivery is what
  completes them.

Reaper thresholds (6h imports, 2h exports) key off updated_at and
dwarf legitimate runtimes, so live jobs are not swept in practice.

* fix(jobs): make the reapers race-safe and commit-aware

Review feedback on #2681 (jjmata, Codex, CodeRabbit):

- Every sweep now mutates under record.with_lock with a staleness
  re-check, mirroring the guard Sync#perform gained in #2680 — a job
  finishing between the sweep query and the write can no longer be
  clobbered mid-flight
- Import.clean distinguishes which side of import!'s single transaction
  the worker died on: rows attached means the data committed and only
  the status write was lost, so the record is finalized as complete
  (marking it failed invited a re-publish that double-imports types
  without row dedup, e.g. TradeImport); no rows means a clean rollback
  and the failed/try-again path stays
- PdfImport.clean applies the same split: no rows → the AI-extraction
  claim died, reclaim to pending; rows → the publish died post-commit,
  finalize complete instead of letting the same extracted rows be
  published twice. Stuck PdfImport reverts (previously unswept by
  either clean) now go to revert_failed like every other import
- ImportSession.clean reconciles chunks whose import! committed but
  never got the complete-status write before failing the session, so
  re-publish skips them instead of duplicating their rows via
  SureImport's split path
- Import#publish redelivery skip is captured via DebugLogEntry instead
  of Rails.logger so support can see it in /settings/debug
- The interrupted-error copy is i18n-backed (imports.errors.interrupted)

* fix(jobs): round-2 review feedback on the reapers

- Import.clean excludes session-owned chunks (import_session_id: nil) —
  SyncCleanerJob runs it before ImportSession.clean, so it could
  finalize or fail a SureImport chunk outside the session flow that
  owns its lifecycle. Regression test added (CodeRabbit major)
- family.sync_later moved outside the row-lock transaction in both
  reap paths — Rails doesn't defer enqueues to after-commit by default,
  so Sidekiq could pick the job up before the status write was visible
- Per-record rescue in Import.clean / PdfImport.clean so one bad record
  doesn't abort the rest of the hourly sweep
- ImportSession.clean uses the importing? enum predicate;
  reconcile_committed_chunks! iterates with each (default-ordered
  association made find_each warn, and chunk counts are tiny)

* fix(jobs): address round-3 reaper review (isolation + commit signal)

Per-record rescue isolation for the three sweeps that lacked it —
FamilyExport.clean, ImportSession.clean, and SyncCleanerJob's
activity-flag loop — mirroring Import.clean/PdfImport.clean. One bad
record (validation error, DB blip) no longer aborts the rest of that
sweep for the hour; the activity-flag guard is per-record so a failure
also stops skipping the models that follow.

data_committed? now covers Category/Rule/Merchant imports, whose
records hang off the family rather than the import (no entries or
accounts), via the new Import#committed_by_named_records? helper. A
committed one is reaped to complete instead of a retryable failed;
nameless RuleImport rows carry no stable key, so a nameless-only file
has no commit signal and stays retryable.

* fix(schema): drop duplicate enable_banking_accounts columns from merge

The merge of main into this branch re-appended product, credit_limit,
and identification_hashes after updated_at, so schema.rb declared each
twice and db:schema:load raised "you can't define an already defined
column 'product'". Removed the duplicate declarations (kept the new
treat_balance_as_available_credit column); the test database loads
again.
2026-07-25 05:04:30 +02:00

497 lines
16 KiB
Ruby

require "digest"
class ImportSession < ApplicationRecord
ConflictError = Class.new(StandardError)
EnqueueError = Class.new(StandardError)
IMPORT_TYPES = %w[SureImport].freeze
STATUSES = %w[pending importing complete failed].freeze
belongs_to :family
has_many :imports, -> { order(:sequence, :created_at) }, dependent: :destroy
has_many :source_mappings,
class_name: "ImportSourceMapping",
dependent: :destroy
enum :status, {
pending: "pending",
importing: "importing",
complete: "complete",
failed: "failed"
}, validate: true, default: "pending"
validates :import_type, inclusion: { in: IMPORT_TYPES }
validates :client_session_id, uniqueness: { scope: :family_id }, allow_blank: true
validates :client_session_id, length: { maximum: 255 }, allow_blank: true
normalizes :client_session_id, with: ->(value) { value.strip.presence }
validates :expected_chunks,
numericality: { only_integer: true, greater_than: 0 },
allow_nil: true
validate :payloads_are_json_objects
# See Import::STUCK_AFTER — same dead-worker failure mode, same sweep.
# A session wedged in importing is otherwise unrecoverable: publish_later
# refuses to re-publish while importing. Before failing it, chunks whose
# import! committed but never got their complete-status write are
# reconciled — otherwise the re-publish would run those chunks again
# (process_chunk! only skips complete ones) and double-import their rows.
# Failing the session then re-enables re-publish, which resumes safely at
# the first genuinely incomplete chunk.
def self.clean
where(status: :importing)
.where("updated_at < ?", Import::STUCK_AFTER.ago)
.includes(:family)
.find_each do |session|
# Read before the lock — see Import.reap_stuck!.
family = session.family
# Row-lock + staleness re-check before mutating, as Sync#perform
# does since #2680 — the owning job may have finished in between.
session.with_lock do
next unless session.importing? && session.updated_at < Import::STUCK_AFTER.ago
session.reconcile_committed_chunks!
session.update!(
status: :failed,
error_details: {
"code" => "import_interrupted",
"message" => Import.interrupted_error_message
}
)
DebugLogEntry.capture(
category: "background_jobs",
level: "warn",
message: "Reaped ImportSession stuck in importing for over #{Import::STUCK_AFTER.inspect}",
source: name,
family: family,
metadata: { record_type: name, record_id: session.id, previous_status: "importing", new_status: "failed" }
)
end
rescue => e
# One bad record must not abort the sweep for the rest.
Rails.logger.error("ImportSession.clean failed for #{session.id}: #{e.class}: #{e.message}")
Sentry.capture_exception(e) { |scope| scope.set_tags(record_type: name, record_id: session.id) } if defined?(Sentry)
end
end
# Chunks caught by a worker kill between import!'s commit and the
# complete-status write have their rows attached but still read importing.
# Mark them complete so a re-publish skips them instead of importing the
# same rows twice (SureImport's split path would duplicate child entries).
# The chunk's summary was lost with the job; aggregate_chunk_summaries
# tolerates the empty hash.
def reconcile_committed_chunks!
# Plain each: the association carries a default order (find_each would
# log a scoped-order warning) and sessions cap out at a handful of chunks.
imports.where(status: :importing).each do |chunk|
next unless chunk.data_committed?
chunk.update!(status: :complete, error: nil, error_details: {})
DebugLogEntry.capture(
category: "background_jobs",
level: "warn",
message: "Reconciled ImportSession chunk whose data committed before the worker died",
source: self.class.name,
family: family,
metadata: { record_type: chunk.type, record_id: chunk.id, import_session_id: id }
)
end
end
def self.create_or_find_for!(family:, import_type:, client_session_id:, expected_chunks:)
import_type = import_type.presence || "SureImport"
expected_chunks = normalize_positive_integer(expected_chunks)
unless IMPORT_TYPES.include?(import_type)
session = new(import_type: import_type)
session.errors.add(:import_type, "must be SureImport")
raise ActiveRecord::RecordInvalid.new(session)
end
if client_session_id.present?
session = family.import_sessions.find_or_initialize_by(client_session_id: client_session_id)
if session.persisted? &&
expected_chunks.present? &&
session.expected_chunks.present? &&
session.expected_chunks != expected_chunks
raise ConflictError, "client_session_id already exists with a different expected_chunks value"
end
else
session = family.import_sessions.build
end
session.import_type = import_type
session.expected_chunks ||= expected_chunks
session.save!
session
rescue ActiveRecord::RecordNotUnique
raise unless client_session_id.present?
existing = family.import_sessions.find_by(client_session_id: client_session_id)
raise unless existing
if expected_chunks.present? &&
existing.expected_chunks.present? &&
existing.expected_chunks != expected_chunks
raise ConflictError, "client_session_id already exists with a different expected_chunks value"
end
if expected_chunks.present? && existing.expected_chunks.nil?
existing.update!(expected_chunks: expected_chunks)
end
existing
end
def self.normalize_positive_integer(value)
return if value.blank?
Integer(value, exception: false) || 0
end
private_class_method :normalize_positive_integer
def attach_chunk!(sequence:, content:, filename:, content_type:, client_chunk_id: nil)
sequence = self.class.send(:normalize_positive_integer, sequence)
raise ConflictError, "sequence must be a positive integer" unless sequence.positive?
raise ConflictError, "sequence exceeds expected_chunks" if expected_chunks.present? && sequence > expected_chunks
checksum = Digest::SHA256.hexdigest(content)
normalized_client_chunk_id = client_chunk_id.presence
chunk_needs_finalization = false
chunk = with_lock do
raise ConflictError, "cannot add chunks after publishing starts" unless pending? || failed?
existing = existing_chunk_for!(
sequence: sequence,
client_chunk_id: normalized_client_chunk_id,
checksum: checksum
)
if existing
chunk_needs_finalization = prepare_existing_chunk_for_retry!(
existing,
checksum: checksum,
content: content,
filename: filename,
content_type: content_type
)
existing
else
chunk_needs_finalization = true
chunk = create_chunk!(
sequence: sequence,
client_chunk_id: normalized_client_chunk_id,
checksum: checksum,
content: content,
filename: filename,
content_type: content_type
)
end
end
finalize_chunk_for_retry!(chunk, checksum) if chunk_needs_finalization
chunk
rescue ActiveRecord::RecordNotUnique
imports.reset
existing = existing_chunk_for!(
sequence: sequence,
client_chunk_id: normalized_client_chunk_id,
checksum: checksum
)
return prepare_and_finalize_existing_chunk!(
existing,
checksum: checksum,
content: content,
filename: filename,
content_type: content_type
) if existing
raise ConflictError, "chunk already exists with different content"
end
def create_chunk!(sequence:, client_chunk_id:, checksum:, content:, filename:, content_type:)
imports.create!(
family: family,
type: "SureImport",
sequence: sequence,
client_chunk_id: client_chunk_id,
checksum: checksum
).tap do |import|
import.ndjson_file.attach(
io: StringIO.new(content),
filename: filename,
content_type: content_type
)
end
end
private :create_chunk!
def publish_later
previous_status = nil
should_enqueue = false
sync_chunk_row_counts!
with_lock do
return if complete? || importing?
validate_publishable_chunks!
previous_status = status
update!(status: :importing, error_details: {})
should_enqueue = true
end
return unless should_enqueue
begin
ImportSessionJob.perform_later(self)
rescue => error
with_lock do
reload
if importing?
update!(status: previous_status, error_details: enqueue_error_details)
end
end
Rails.logger.error("ImportSession enqueue failed import_session_id=#{id} exception=#{error.class}")
raise EnqueueError, "Import session could not be queued."
end
end
def publish
return unless prepare_for_publish!
Rails.logger.info("ImportSession publish started import_session_id=#{id}")
imports.ordered_by_sequence.each do |import|
process_chunk!(import)
end
update!(status: :complete, summary: aggregate_chunk_summaries, error_details: {})
enqueue_family_sync
Rails.logger.info("ImportSession publish completed import_session_id=#{id}")
rescue => error
update!(
status: :failed,
error_details: error_details_for(error),
summary: aggregate_chunk_summaries
)
Rails.logger.error("ImportSession publish failed import_session_id=#{id} exception=#{error.class}")
end
def aggregate_chunk_summaries
imports.reload.each_with_object({}) do |import, totals|
merge_summary!(totals, import.summary || {})
end
end
private
def prepare_for_publish!
sync_chunk_row_counts!
with_lock do
return false if complete?
validate_publishable_chunks!
update!(status: :importing, error_details: {}) unless importing?
true
end
end
def enqueue_family_sync
family.sync_later
rescue => error
update!(error_details: sync_enqueue_error_details)
Rails.logger.error(
"ImportSession family sync enqueue failed import_session_id=#{id} exception=#{error.class}"
)
end
def existing_chunk_for!(sequence:, client_chunk_id:, checksum:)
sequence_match = imports.find_by(sequence: sequence)
client_chunk_match = imports.find_by(client_chunk_id: client_chunk_id) if client_chunk_id.present?
if sequence_match && client_chunk_match && sequence_match.id != client_chunk_match.id
raise ConflictError, "sequence and client_chunk_id refer to different chunks"
end
existing = sequence_match || client_chunk_match
return unless existing
if existing.sequence != sequence
raise ConflictError, "client_chunk_id already exists with a different sequence"
end
if client_chunk_id.present? && existing.client_chunk_id.present? && existing.client_chunk_id != client_chunk_id
raise ConflictError, "sequence already exists with a different client_chunk_id"
end
raise ConflictError, "chunk already exists with different content" unless existing.checksum == checksum
existing
end
def prepare_and_finalize_existing_chunk!(chunk, checksum:, content:, filename:, content_type:)
needs_finalization = with_lock do
prepare_existing_chunk_for_retry!(
chunk.reload,
checksum: checksum,
content: content,
filename: filename,
content_type: content_type
)
end
finalize_chunk_for_retry!(chunk, checksum) if needs_finalization
chunk
end
def prepare_existing_chunk_for_retry!(chunk, checksum:, content:, filename:, content_type:)
return false if chunk_ready_for_retry?(chunk, checksum)
return true if chunk.ndjson_file.attached? && chunk_content_checksum(chunk) == checksum
chunk.ndjson_file.attach(
io: StringIO.new(content),
filename: filename,
content_type: content_type
)
true
end
def finalize_chunk_for_retry!(chunk, checksum)
chunk.sync_ndjson_rows_count!
chunk.reload
return chunk if chunk_ready_for_retry?(chunk, checksum)
raise ConflictError, "chunk already exists but is incomplete"
rescue ActiveStorage::FileNotFoundError
raise ConflictError, "chunk already exists but is incomplete"
end
def chunk_ready_for_retry?(chunk, checksum)
chunk.ndjson_file.attached? &&
chunk.rows_count.to_i.positive? &&
chunk_content_checksum(chunk) == checksum
end
def chunk_content_checksum(chunk)
Digest::SHA256.hexdigest(chunk.ndjson_file.download)
rescue ActiveStorage::FileNotFoundError
nil
end
def process_chunk!(import)
return if import.complete?
import.update!(status: :importing, error: nil, error_details: {})
result = import.import!(import_session: self)
import.update!(status: :complete, summary: result.fetch(:summary, {}), error_details: {})
rescue => error
import.update!(
status: :failed,
error: public_error_message_for(error),
error_details: error_details_for(error),
summary: failed_summary_for(error)
)
raise
end
def row_count_exceeded?
imports.sum(:rows_count) > SureImport.max_row_count
end
def validate_publishable_chunks!
raise ConflictError, "import session has no chunks" unless imports.exists?
raise Import::MaxRowCountExceededError if row_count_exceeded?
validate_expected_chunk_sequences!
end
def sync_chunk_row_counts!
raise ConflictError, "import session has no chunks" unless imports.exists?
imports.reload.each(&:sync_ndjson_rows_count!)
rescue ActiveStorage::FileNotFoundError
raise ConflictError, "import session chunks are incomplete"
end
def validate_expected_chunk_sequences!
return if expected_chunks.blank?
expected_sequences = (1..expected_chunks).to_a
actual_sequences = imports.pluck(:sequence).sort
return if actual_sequences == expected_sequences
missing_sequences = expected_sequences - actual_sequences
unexpected_sequences = actual_sequences - expected_sequences
details = []
details << "missing sequences: #{missing_sequences.join(', ')}" if missing_sequences.any?
details << "unexpected sequences: #{unexpected_sequences.join(', ')}" if unexpected_sequences.any?
raise ConflictError, "import session chunks do not match expected sequences (#{details.join('; ')})"
end
def error_details_for(error)
details = {
"code" => error.respond_to?(:code) ? error.code : "import_failed",
"message" => public_error_message_for(error)
}
if error.respond_to?(:details)
details.merge!(error.details.stringify_keys)
end
details
end
def public_error_message_for(error)
return error.message if error.respond_to?(:code)
"Import session failed."
end
def enqueue_error_details
{
"code" => "import_enqueue_failed",
"message" => "Import session could not be queued."
}
end
def sync_enqueue_error_details
{
"code" => "family_sync_enqueue_failed",
"message" => "Family sync could not be queued after import completion."
}
end
def merge_summary!(totals, summary)
summary.each do |entity_type, counts|
next unless counts.respond_to?(:each)
totals[entity_type] ||= {}
counts.each do |status, count|
totals[entity_type][status] = totals[entity_type].fetch(status, 0) + count.to_i
end
end
end
def failed_summary_for(error)
record_type = error_details_for(error)["record_type"]
return {} if record_type.blank?
{
record_type.to_s.underscore.pluralize => {
"created" => 0,
"updated" => 0,
"skipped" => 0,
"failed" => 1
}
}
end
def payloads_are_json_objects
errors.add(:summary, "must be an object") unless summary.is_a?(Hash)
errors.add(:error_details, "must be an object") unless error_details.is_a?(Hash)
end
end