Merge branch '3899-crunch-use-job-state' closes #3899
[arvados.git] / services / api / app / controllers / arvados / v1 / jobs_controller.rb
index 6fddba7a56c39a80dd94c6e46250f4bc39ae9361..b157de42fc8130fb8b1a33f694f0bdd71b58c049 100644 (file)
@@ -2,18 +2,16 @@ class Arvados::V1::JobsController < ApplicationController
   accept_attribute_as_json :script_parameters, Hash
   accept_attribute_as_json :runtime_constraints, Hash
   accept_attribute_as_json :tasks_summary, Hash
-  skip_before_filter :find_object_by_uuid, :only => :queue
-  skip_before_filter :render_404_if_no_object, :only => :queue
+  skip_before_filter :find_object_by_uuid, :only => [:queue, :queue_size]
+  skip_before_filter :render_404_if_no_object, :only => [:queue, :queue_size]
 
   def create
     [:repository, :script, :script_version, :script_parameters].each do |r|
       if !resource_attrs[r]
-        return render json: {
-          :errors => ["#{r} attribute must be specified"]
-        }, status: :unprocessable_entity
+        return send_error("#{r} attribute must be specified",
+                          status: :unprocessable_entity)
       end
     end
-    load_filters_param
 
     # We used to ask for the minimum_, exclude_, and no_reuse params
     # in the job resource. Now we advertise them as flags that alter
@@ -28,43 +26,39 @@ class Arvados::V1::JobsController < ApplicationController
     end
 
     if params[:find_or_create]
-      # Convert old special-purpose creation parameters to the new
-      # filters-based method.
-      minimum_script_version = params[:minimum_script_version]
-      exclude_script_versions = params.fetch(:exclude_script_versions, [])
-      @filters.select do |(col_name, operand, operator)|
-        case col_name
-        when "script_version"
-          case operand
-          when "in range"
-            minimum_script_version = operator
-            false
-          when "not in", "not in range"
-            begin
-              exclude_script_versions += operator
-            rescue TypeError
-              exclude_script_versions << operator
-            end
-            false
-          else
-            true
+      return if false.equal?(load_filters_param)
+      if @filters.empty?  # Translate older creation parameters into filters.
+        @filters =
+          [["repository", "=", resource_attrs[:repository]],
+           ["script", "=", resource_attrs[:script]],
+           ["script_version", "in git",
+            params[:minimum_script_version] || resource_attrs[:script_version]],
+           ["script_version", "not in git", params[:exclude_script_versions]],
+          ].reject { |filter| filter.last.nil? or filter.last.empty? }
+        if image_search = resource_attrs[:runtime_constraints].andand["docker_image"]
+          if image_tag = resource_attrs[:runtime_constraints]["docker_image_tag"]
+            image_search += ":#{image_tag}"
           end
+          @filters.append(["docker_image_locator", "in docker", image_search])
         else
-          true
+          @filters.append(["docker_image_locator", "=", nil])
+        end
+        begin
+          load_job_specific_filters
+        rescue ArgumentError => error
+          return send_error(error.message)
         end
       end
-      @filters.append(["script_version", "in",
-                       Commit.find_commit_range(current_user,
-                                                resource_attrs[:repository],
-                                                minimum_script_version,
-                                                resource_attrs[:script_version],
-                                                exclude_script_versions)])
-
-      # Set up default filters for specific parameters.
-      if @filters.select { |f| f.first == "script" }.empty?
-        @filters.append(["script", "=", resource_attrs[:script]])
+
+      # Check specified filters for some reasonableness.
+      filter_names = @filters.map { |f| f.first }.uniq
+      ["repository", "script"].each do |req_filter|
+        if not filter_names.include?(req_filter)
+          return send_error("#{req_filter} filter required")
+        end
       end
 
+      # Search for a reusable Job, and return it if found.
       @objects = Job.readable_by(current_user)
       apply_filters
       @object = nil
@@ -73,18 +67,20 @@ class Arvados::V1::JobsController < ApplicationController
         if j.nondeterministic != true and
             ((j.success == true and j.output != nil) or j.running == true) and
             j.script_parameters == resource_attrs[:script_parameters]
-          if j.running
+          if j.running && j.owner_uuid == current_user.uuid
             # We'll use this if we don't find a job that has completed
             incomplete_job ||= j
           else
-            # Record the first job in the list
-            if !@object
-              @object = j
-            end
-            # Ensure that all candidate jobs actually did produce the same output
-            if @object.output != j.output
-              @object = nil
-              break
+            if Collection.readable_by(current_user).find_by_portable_data_hash(j.output)
+              # Record the first job in the list
+              if !@object
+                @object = j
+              end
+              # Ensure that all candidate jobs actually did produce the same output
+              if @object.output != j.output
+                @object = nil
+                break
+              end
             end
           end
         end
@@ -155,18 +151,109 @@ class Arvados::V1::JobsController < ApplicationController
     params[:order] ||= ['priority desc', 'created_at']
     load_limit_offset_order_params
     load_where_param
-    @where.merge!({
-                    started_at: nil,
-                    is_locked_by_uuid: nil,
-                    cancelled_at: nil,
-                    success: nil
-                  })
-    load_filters_param
+    @where.merge!({state: Job::Queued})
+    return if false.equal?(load_filters_param)
     find_objects_for_index
     index
   end
 
+  def queue_size
+    # Users may not be allowed to see all the jobs in the queue, so provide a
+    # method to get just the queue size in order to get a gist of how busy the
+    # cluster is.
+    render :json => {:queue_size => Job.queue.size}
+  end
+
+  def self._create_requires_parameters
+    (super rescue {}).
+      merge({
+              find_or_create: {
+                type: 'boolean', required: false, default: false
+              },
+              filters: {
+                type: 'array', required: false
+              },
+              minimum_script_version: {
+                type: 'string', required: false
+              },
+              exclude_script_versions: {
+                type: 'array', required: false
+              },
+            })
+  end
+
   def self._queue_requires_parameters
     self._index_requires_parameters
   end
+
+  protected
+
+  def load_job_specific_filters
+    # Convert Job-specific @filters entries into general SQL filters.
+    script_info = {"repository" => nil, "script" => nil}
+    script_range = {"exclude_versions" => []}
+    @filters.select! do |filter|
+      if (script_info.has_key? filter[0]) and (filter[1] == "=")
+        if script_info[filter[0]].nil?
+          script_info[filter[0]] = filter[2]
+        elsif script_info[filter[0]] != filter[2]
+          raise ArgumentError.new("incompatible #{filter[0]} filters")
+        end
+      end
+      case filter[0..1]
+      when ["script_version", "in git"]
+        script_range["min_version"] = filter.last
+        false
+      when ["script_version", "not in git"]
+        begin
+          script_range["exclude_versions"] += filter.last
+        rescue TypeError
+          script_range["exclude_versions"] << filter.last
+        end
+        false
+      when ["docker_image_locator", "in docker"], ["docker_image_locator", "not in docker"]
+        filter[1].sub!(/ docker$/, '')
+        search_list = filter[2].is_a?(Enumerable) ? filter[2] : [filter[2]]
+        filter[2] = search_list.flat_map do |search_term|
+          image_search, image_tag = search_term.split(':', 2)
+          Collection.find_all_for_docker_image(image_search, image_tag, @read_users).map(&:portable_data_hash)
+        end
+        true
+      else
+        true
+      end
+    end
+
+    # Build a real script_version filter from any "not? in git" filters.
+    if (script_range.size > 1) or script_range["exclude_versions"].any?
+      script_info.each_pair do |key, value|
+        if value.nil?
+          raise ArgumentError.new("script_version filter needs #{key} filter")
+        end
+      end
+      last_version = begin resource_attrs[:script_version] rescue "HEAD" end
+      version_range = Commit.find_commit_range(current_user,
+                                               script_info["repository"],
+                                               script_range["min_version"],
+                                               last_version,
+                                               script_range["exclude_versions"])
+      if version_range.nil?
+        raise ArgumentError.
+          new(["error searching #{script_info['repository']} from",
+               "'#{script_range['min_version']}' to '#{last_version}',",
+               "excluding #{script_range['exclude_versions']}"].join(" "))
+      end
+      @filters.append(["script_version", "in", version_range])
+    end
+  end
+
+  def load_filters_param
+    begin
+      super
+      load_job_specific_filters
+    rescue ArgumentError => error
+      send_error(error.message)
+      false
+    end
+  end
 end