Class: SeoBatchJob

Inherits:
ApplicationRecord show all
Defined in:
app/models/seo_batch_job.rb

Overview

Tracks a single batch submission to the Gemini or Anthropic Batch API.

Lifecycle:
collecting -> submitting -> processing -> completed
-> failed

The nightly pipeline creates one SeoBatchJob per run. It progresses through:

  1. SeoBatchCollectorWorker builds prompts for each page (status: collecting)
  2. SeoBatchSubmitWorker submits to Batch API (status: submitting -> processing)
  3. SeoBatchPollWorker monitors until done
  4. SeoBatchResultsWorker saves results (status: completed)

Provider routing:

  • Gemini models -> Gemini Batch API (50% discount)
  • Claude models -> Anthropic Message Batches API (50% discount)

== Schema Information

Table name: seo_batch_jobs
Database name: primary

id :bigint not null, primary key
completed_at :datetime
errored_count :integer default(0)
expired_count :integer default(0)
metadata :jsonb
model :string default("claude-sonnet-4-5"), not null
status :string default("collecting"), not null
submitted_at :datetime
succeeded_count :integer default(0)
total_pages :integer default(0)
created_at :datetime not null
updated_at :datetime not null
ai_batch_id :string

Indexes

index_seo_batch_jobs_on_ai_batch_id (ai_batch_id) UNIQUE
index_seo_batch_jobs_on_status (status)

Constant Summary collapse

STATUSES =

Statuses.

%w[collecting submitting processing completed failed].freeze
STUCK_LOCAL_AFTER =

Self-healing TTLs for the single-flight guard (see .fail_stuck_jobs!).
Local phases (collecting/submitting) finish in minutes on a healthy run,
so 24h means abandoned. Provider-side processing can legitimately take up
to the 24h batch expiry — SeoBatchPollWorker already fails jobs at its own
24h cap, so 48h here is strictly the backstop for a dead poll chain.

24.hours
STUCK_PROCESSING_AFTER =
48.hours

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#modelObject (readonly)



57
# File 'app/models/seo_batch_job.rb', line 57

validates :model, presence: true

#statusObject (readonly)



56
# File 'app/models/seo_batch_job.rb', line 56

validates :status, inclusion: { in: STATUSES }

Class Method Details

.activeActiveRecord::Relation<SeoBatchJob>

A relation of SeoBatchJobs that are active. Active Record Scope

Returns:

See Also:



59
# File 'app/models/seo_batch_job.rb', line 59

scope :active, -> { where(status: %w[collecting submitting processing]) }

.completedActiveRecord::Relation<SeoBatchJob>

A relation of SeoBatchJobs that are completed. Active Record Scope

Returns:

See Also:



60
# File 'app/models/seo_batch_job.rb', line 60

scope :completed, -> { where(status: 'completed') }

.fail_stuck_jobs!Object

Auto-recovery for the single-flight guard: fail every stuck job so the
next collection starts fresh. Called by SeoBatchCollectorWorker before it
checks .active. Returns the number of jobs actually failed.



74
75
76
# File 'app/models/seo_batch_job.rb', line 74

def self.fail_stuck_jobs!
  stuck.find_each.count { |job| fail_if_still_stuck(job) }
end

.stuckActiveRecord::Relation<SeoBatchJob>

A relation of SeoBatchJobs that are stuck. Active Record Scope

Returns:

See Also:



66
67
68
69
# File 'app/models/seo_batch_job.rb', line 66

scope :stuck, -> {
  where(status: %w[collecting submitting], updated_at: ..STUCK_LOCAL_AFTER.ago)
    .or(where(status: 'processing', updated_at: ..STUCK_PROCESSING_AFTER.ago))
}

Instance Method Details

#anthropic?Boolean

Returns:

  • (Boolean)


98
# File 'app/models/seo_batch_job.rb', line 98

def anthropic?   = model.to_s.start_with?('claude-')

#collecting?Boolean

Returns:

  • (Boolean)


91
# File 'app/models/seo_batch_job.rb', line 91

def collecting?  = status == 'collecting'

#completed?Boolean

Returns:

  • (Boolean)


94
# File 'app/models/seo_batch_job.rb', line 94

def completed?   = status == 'completed'

#failed?Boolean

Returns:

  • (Boolean)


95
# File 'app/models/seo_batch_job.rb', line 95

def failed?      = status == 'failed'

#gemini?Boolean

Returns:

  • (Boolean)


97
# File 'app/models/seo_batch_job.rb', line 97

def gemini?      = model.to_s.start_with?('gemini-')

#itemsActiveRecord::Relation<SeoBatchItem>

Returns:

See Also:



54
# File 'app/models/seo_batch_job.rb', line 54

has_many :items, class_name: 'SeoBatchItem', dependent: :destroy

#mark_completed!(succeeded: 0, errored: 0, expired: 0) ⇒ Object



118
119
120
121
122
123
124
125
126
# File 'app/models/seo_batch_job.rb', line 118

def mark_completed!(succeeded: 0, errored: 0, expired: 0)
  update!(
    status: 'completed',
    succeeded_count: succeeded,
    errored_count: errored,
    expired_count: expired,
    completed_at: Time.current
  )
end

#mark_failed!(error_message) ⇒ Object



128
129
130
131
132
133
134
# File 'app/models/seo_batch_job.rb', line 128

def mark_failed!(error_message)
  update!(
    status: 'failed',
    completed_at: Time.current,
    metadata: .merge('error' => error_message)
  )
end

#mark_submitted!(provider_batch_id:, provider: nil, extra_metadata: {}) ⇒ Object



104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'app/models/seo_batch_job.rb', line 104

def (provider_batch_id:, provider: nil, extra_metadata: {})
  merged = 
           .merge('provider' => provider || inferred_provider)
           .merge()

  update!(
    status: 'processing',
    ai_batch_id: provider_batch_id,
    submitted_at: Time.current,
    total_pages: items.count,
    metadata: merged
  )
end

#processing?Boolean

Returns:

  • (Boolean)


93
# File 'app/models/seo_batch_job.rb', line 93

def processing?  = status == 'processing'

#provider_batch_idObject



100
101
102
# File 'app/models/seo_batch_job.rb', line 100

def provider_batch_id
  ai_batch_id
end

#submitting?Boolean

Returns:

  • (Boolean)


92
# File 'app/models/seo_batch_job.rb', line 92

def submitting?  = status == 'submitting'