3859: Implement Job lock method on api server. This takes a queued job and
[arvados.git] / services / api / app / models / job.rb
index 12f82bee6ffc7cf6ea41396eca7a1e0682f9d757..81744c7643bdf23a246f83d2adf7ae899ab24334 100644 (file)
@@ -9,13 +9,15 @@ class Job < ArvadosModel
   before_create :ensure_unique_submit_id
   after_commit :trigger_crunch_dispatch_if_cancelled, :on => :update
   before_validation :set_priority
+  before_validation :update_timestamps_when_state_changes
+  before_validation :update_state_from_old_state_attrs
   validate :ensure_script_version_is_commit
   validate :find_docker_image_locator
-  before_validation :verify_status
-  before_create :set_state_before_save
-  before_save :set_state_before_save
+  validate :validate_status
+  validate :validate_state_change
 
   has_many :commit_ancestors, :foreign_key => :descendant, :primary_key => :script_version
+  has_many(:nodes, foreign_key: :job_uuid, primary_key: :uuid)
 
   class SubmitIdReused < StandardError
   end
@@ -45,6 +47,7 @@ class Job < ArvadosModel
     t.add :supplied_script_version
     t.add :docker_image_locator
     t.add :queue_position
+    t.add :node_uuids
     t.add :description
   end
 
@@ -63,6 +66,10 @@ class Job < ArvadosModel
                       running: false)
   end
 
+  def node_uuids
+    nodes.map(&:uuid)
+  end
+
   def self.queue
     self.where('started_at is ? and is_locked_by_uuid is ? and cancelled_at is ? and success is ?',
                nil, nil, nil, nil).
@@ -84,6 +91,18 @@ class Job < ArvadosModel
       order('priority desc, created_at')
   end
 
+  def lock locked_by_uuid
+    transaction do
+      self.reload
+      unless self.state == Queued and self.is_locked_by_uuid.nil?
+        raise ConflictError.new
+      end
+      self.state = Running
+      self.is_locked_by_uuid = locked_by_uuid
+      self.save!
+    end
+  end
+
   protected
 
   def foreign_key_attributes
@@ -106,7 +125,7 @@ class Job < ArvadosModel
   end
 
   def ensure_script_version_is_commit
-    if self.is_locked_by_uuid and self.started_at
+    if self.state == Running
       # Apparently client has already decided to go for it. This is
       # needed to run a local job using a local working directory
       # instead of a commit-ish.
@@ -193,7 +212,8 @@ class Job < ArvadosModel
           success_changed? or
           output_changed? or
           log_changed? or
-          tasks_summary_changed?
+          tasks_summary_changed? or
+          state_changed?
         logger.warn "User #{current_user.uuid if current_user} tried to change protected job attributes on locked #{self.class.to_s} #{uuid_was}"
         return false
       end
@@ -242,99 +262,87 @@ class Job < ArvadosModel
     end
   end
 
-  def verify_status
-    changed_attributes = self.changed
+  def update_timestamps_when_state_changes
+    return if not (state_changed? or new_record?)
+    case state
+    when Running
+      self.started_at ||= Time.now
+    when Failed, Complete
+      self.finished_at ||= Time.now
+    when Cancelled
+      self.cancelled_at ||= Time.now
+    end
 
-    if new_record?
-      self.state = Queued
-    elsif 'state'.in? changed_attributes
-      case self.state
-      when Queued
-        self.running = false
-        self.success = nil
-      when Running
-        if !self.is_locked_by_uuid
-          return false
-        end
-        if !self.started_at
-          self.started_at = Time.now
-        end
-        self.running = true
-        self.success = nil
-      when Cancelled
-        if !self.cancelled_at
-          self.cancelled_at = Time.now
-        end
-        self.running = false
-        self.success = false
-      when Failed
-        if !self.finished_at
-          self.finished_at = Time.now
-        end
-        self.running = false
-        self.success = false
-      when Complete
-        if !self.finished_at
-          self.finished_at = Time.now
-        end
-        self.running = false
-        self.success = true
-      end
-    elsif 'cancelled_at'.in? changed_attributes
-      self.state = Cancelled
+    # TODO: Remove the following case block when old "success" and
+    # "running" attrs go away. Until then, this ensures we still
+    # expose correct success/running flags to older clients, even if
+    # some new clients are writing only the new state attribute.
+    case state
+    when Queued
+      self.running = false
+      self.success = nil
+    when Running
+      self.running = true
+      self.success = nil
+    when Cancelled, Failed
       self.running = false
       self.success = false
-    elsif 'success'.in? changed_attributes
-      if self.cancelled_at
-        self.state = Cancelled
-        self.running = false
-        self.success = false
-      else
-        if self.success
-          self.state = Complete
-        else
-          self.state = Failed
-        end
-        if !self.finished_at
-          self.finished_at = Time.now
-        end
-        self.running = false
-      end
-    elsif 'running'.in? changed_attributes
-      if self.running
-        self.state = Running
-        if !self.started_at
-          self.started_at = Time.now
-        end
-      else
-        self.state = nil # let set_state_before_save determine what the state should be
-        self.started_at = nil
-      end
+    when Complete
+      self.running = false
+      self.success = true
     end
+    self.running ||= false # Default to false instead of nil.
+
     true
   end
 
-  def set_state_before_save
-    if !self.state
-      if self.cancelled_at
+  def update_state_from_old_state_attrs
+    # If a client has touched the legacy state attrs, update the
+    # "state" attr to agree with the updated values of the legacy
+    # attrs.
+    #
+    # TODO: Remove this method when old "success" and "running" attrs
+    # go away.
+    if cancelled_at_changed? or
+        success_changed? or
+        running_changed? or
+        state.nil?
+      if cancelled_at
         self.state = Cancelled
-      elsif self.success
-        self.state = Complete
-      elsif (!self.success.nil? && !self.success)
+      elsif success == false
         self.state = Failed
-      elsif (self.running && self.success.nil? && !self.cancelled_at)
+      elsif success == true
+        self.state = Complete
+      elsif running == true
         self.state = Running
-      elsif !self.started_at && !self.cancelled_at && !self.is_locked_by_uuid && self.success.nil?
+      else
         self.state = Queued
       end
     end
+    true
+  end
+
+  def validate_status
     if self.state.in?(States)
       true
     else
-      errors.add :state, "'#{state.inspect} must be one of: [#{States.join ', '}]"
+      errors.add :state, "#{state.inspect} must be one of: #{States.inspect}"
       false
     end
   end
 
+  def validate_state_change
+    if self.state_changed?
+      if self.state_was.in? [Complete, Failed, Cancelled]
+        # Once in a finished state, don't permit any changes
+        errors.add :state, "invalid change from #{self.state_was} to #{self.state}"
+        return false
+      elsif self.state_was == Running and not self.state.in? [Complete, Failed, Cancelled]
+        # From running, can only transition to a finished state
+        errors.add :state, "invalid change from #{self.state_was} to #{self.state}"
+        return false
+      end
+    end
+    true
+  end
 end