app/models/job.rb
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class Job < ApplicationModel
include ChecksClientNotification
include ChecksConditionValidation
include ChecksHtmlSanitized
include HasTimeplan
include Job::Assets
OBJECTS_BATCH_SIZE = 2_000
store :condition
store :perform
validates :name, presence: true, uniqueness: { case_sensitive: false }
validates :object, presence: true, inclusion: { in: %w[Ticket User Organization] }
validates :perform, 'validations/verify_perform_rules': true
before_save :updated_matching, :update_next_run_at
validates :note, length: { maximum: 250 }
sanitized_html :note
=begin
verify each job if needed to run (e. g. if true and times are matching) and execute it
Job.run
=end
def self.run
start_at = Time.zone.now
Job.where(active: true).each do |job|
next if !job.executable?
job.run(false, start_at)
end
true
end
=begin
execute a single job if needed (e. g. if true and times are matching)
job = Job.find(123)
job.run
force to run job (ignore times are matching)
job.run(true)
=end
def run(force = false, start_at = Time.zone.now)
logger.debug { "Execute job #{inspect}" }
object_ids = start_job(start_at, force)
return if object_ids.nil?
object_ids.each_slice(10) do |slice|
run_slice(slice)
end
finish_job
end
def executable?(start_at = Time.zone.now)
return false if !active
# only execute jobs older than 1 min to give admin time to make last-minute changes
return false if updated_at > 1.minute.ago
# check if job got stuck
return false if running == true && last_run_at && 1.day.ago < last_run_at
# check if jobs need to be executed
# ignore if job was running within last 10 min.
return false if last_run_at && last_run_at > start_at - 10.minutes
true
end
def matching_count
object_count, _objects = object.constantize.selectors(condition, limit: 1, execution_time: true)
object_count || 0
end
private
def next_run_at_calculate(time = Time.zone.now)
return nil if !active
if last_run_at && (time - last_run_at).positive?
time += 10.minutes
end
timeplan_calculation.next_at(time)
end
def updated_matching
self.matching = matching_count
end
def update_next_run_at
self.next_run_at = next_run_at_calculate
end
def finish_job
Transaction.execute(reset_user_id: true) do
mark_as_finished
end
end
def mark_as_finished
self.running = false
self.last_run_at = Time.zone.now
save!
end
def start_job(start_at, force)
Transaction.execute(reset_user_id: true) do
next if !start_job_executable?(start_at, force)
next if !start_job_in_timeplan?(start_at, force)
object_count, objects = object.constantize.selectors(condition, limit: OBJECTS_BATCH_SIZE, execution_time: true)
logger.debug { "Job #{name} with #{object_count} object(s)" }
mark_as_started(objects&.count || 0)
objects&.pluck(:id) || []
end
end
def start_job_executable?(start_at, force)
return true if executable?(start_at) || force
if next_run_at && next_run_at <= Time.zone.now
save!
end
false
end
def start_job_in_timeplan?(start_at, force)
return true if in_timeplan?(start_at) || force
save! # trigger updating matching tickets count and next_run_at time even if not in timeplan
false
end
def mark_as_started(batch_count)
self.processed = batch_count
self.running = true
self.last_run_at = Time.zone.now
save!
end
def run_slice(slice)
Transaction.execute(disable_notification: disable_notification, reset_user_id: true) do
_, objects = object.constantize.selectors(condition, limit: OBJECTS_BATCH_SIZE, execution_time: true)
return if objects.nil?
objects
.where(id: slice)
.each do |object|
object.perform_changes(self, 'job')
end
end
end
end