joblin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +1 -0
- data/app/models/joblin/background_task/api_access.rb +148 -0
- data/app/models/joblin/background_task/attachments.rb +47 -0
- data/app/models/joblin/background_task/executor.rb +63 -0
- data/app/models/joblin/background_task/options.rb +75 -0
- data/app/models/joblin/background_task/retention_policy.rb +28 -0
- data/app/models/joblin/background_task.rb +72 -0
- data/app/models/joblin/concerns/job_working_dirs.rb +21 -0
- data/db/migrate/20250903184852_create_background_tasks.rb +12 -0
- data/joblin.gemspec +35 -0
- data/lib/joblin/batching/batch.rb +537 -0
- data/lib/joblin/batching/callback.rb +135 -0
- data/lib/joblin/batching/chain_builder.rb +247 -0
- data/lib/joblin/batching/compat/active_job.rb +108 -0
- data/lib/joblin/batching/compat/sidekiq/web/batches_assets/css/styles.less +182 -0
- data/lib/joblin/batching/compat/sidekiq/web/batches_assets/js/batch_tree.js +108 -0
- data/lib/joblin/batching/compat/sidekiq/web/batches_assets/js/util.js +2 -0
- data/lib/joblin/batching/compat/sidekiq/web/helpers.rb +41 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/_batch_tree.erb +6 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/_batches_table.erb +44 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/_common.erb +13 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/_jobs_table.erb +21 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/_pagination.erb +26 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/batch.erb +81 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/batches.erb +23 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/pool.erb +137 -0
- data/lib/joblin/batching/compat/sidekiq/web/views/pools.erb +47 -0
- data/lib/joblin/batching/compat/sidekiq/web.rb +218 -0
- data/lib/joblin/batching/compat/sidekiq.rb +149 -0
- data/lib/joblin/batching/compat.rb +20 -0
- data/lib/joblin/batching/context_hash.rb +157 -0
- data/lib/joblin/batching/hier_batch_ids.lua +25 -0
- data/lib/joblin/batching/jobs/base_job.rb +7 -0
- data/lib/joblin/batching/jobs/concurrent_batch_job.rb +20 -0
- data/lib/joblin/batching/jobs/managed_batch_job.rb +175 -0
- data/lib/joblin/batching/jobs/serial_batch_job.rb +20 -0
- data/lib/joblin/batching/pool.rb +254 -0
- data/lib/joblin/batching/pool_refill.lua +47 -0
- data/lib/joblin/batching/schedule_callback.lua +14 -0
- data/lib/joblin/batching/status.rb +89 -0
- data/lib/joblin/engine.rb +15 -0
- data/lib/joblin/lazy_access.rb +72 -0
- data/lib/joblin/uniqueness/compat/active_job.rb +75 -0
- data/lib/joblin/uniqueness/compat/sidekiq.rb +135 -0
- data/lib/joblin/uniqueness/compat.rb +20 -0
- data/lib/joblin/uniqueness/configuration.rb +25 -0
- data/lib/joblin/uniqueness/job_uniqueness.rb +49 -0
- data/lib/joblin/uniqueness/lock_context.rb +199 -0
- data/lib/joblin/uniqueness/locksmith.rb +92 -0
- data/lib/joblin/uniqueness/on_conflict/base.rb +32 -0
- data/lib/joblin/uniqueness/on_conflict/log.rb +13 -0
- data/lib/joblin/uniqueness/on_conflict/null_strategy.rb +9 -0
- data/lib/joblin/uniqueness/on_conflict/raise.rb +11 -0
- data/lib/joblin/uniqueness/on_conflict/reject.rb +21 -0
- data/lib/joblin/uniqueness/on_conflict/reschedule.rb +20 -0
- data/lib/joblin/uniqueness/on_conflict.rb +62 -0
- data/lib/joblin/uniqueness/strategy/base.rb +107 -0
- data/lib/joblin/uniqueness/strategy/until_and_while_executing.rb +35 -0
- data/lib/joblin/uniqueness/strategy/until_executed.rb +20 -0
- data/lib/joblin/uniqueness/strategy/until_executing.rb +20 -0
- data/lib/joblin/uniqueness/strategy/until_expired.rb +16 -0
- data/lib/joblin/uniqueness/strategy/while_executing.rb +26 -0
- data/lib/joblin/uniqueness/strategy.rb +27 -0
- data/lib/joblin/uniqueness/unique_job_common.rb +79 -0
- data/lib/joblin/version.rb +3 -0
- data/lib/joblin.rb +37 -0
- data/spec/batching/batch_spec.rb +493 -0
- data/spec/batching/callback_spec.rb +38 -0
- data/spec/batching/compat/active_job_spec.rb +107 -0
- data/spec/batching/compat/sidekiq_spec.rb +127 -0
- data/spec/batching/context_hash_spec.rb +54 -0
- data/spec/batching/flow_spec.rb +82 -0
- data/spec/batching/integration/fail_then_succeed.rb +42 -0
- data/spec/batching/integration/integration.rb +57 -0
- data/spec/batching/integration/nested.rb +88 -0
- data/spec/batching/integration/simple.rb +47 -0
- data/spec/batching/integration/workflow.rb +134 -0
- data/spec/batching/integration_helper.rb +50 -0
- data/spec/batching/pool_spec.rb +161 -0
- data/spec/batching/status_spec.rb +76 -0
- data/spec/batching/support/base_job.rb +19 -0
- data/spec/batching/support/sample_callback.rb +2 -0
- data/spec/internal/config/database.yml +5 -0
- data/spec/internal/config/routes.rb +5 -0
- data/spec/internal/config/storage.yml +3 -0
- data/spec/internal/db/combustion_test.sqlite +0 -0
- data/spec/internal/db/schema.rb +6 -0
- data/spec/internal/log/test.log +48200 -0
- data/spec/internal/public/favicon.ico +0 -0
- data/spec/models/background_task_spec.rb +41 -0
- data/spec/spec_helper.rb +29 -0
- data/spec/uniqueness/compat/active_job_spec.rb +49 -0
- data/spec/uniqueness/compat/sidekiq_spec.rb +68 -0
- data/spec/uniqueness/lock_context_spec.rb +106 -0
- data/spec/uniqueness/on_conflict/log_spec.rb +11 -0
- data/spec/uniqueness/on_conflict/raise_spec.rb +10 -0
- data/spec/uniqueness/on_conflict/reschedule_spec.rb +63 -0
- data/spec/uniqueness/on_conflict_spec.rb +16 -0
- data/spec/uniqueness/spec_helper.rb +19 -0
- data/spec/uniqueness/strategy/base_spec.rb +100 -0
- data/spec/uniqueness/strategy/until_and_while_executing_spec.rb +48 -0
- data/spec/uniqueness/strategy/until_executed_spec.rb +23 -0
- data/spec/uniqueness/strategy/until_executing_spec.rb +23 -0
- data/spec/uniqueness/strategy/until_expired_spec.rb +23 -0
- data/spec/uniqueness/strategy/while_executing_spec.rb +33 -0
- data/spec/uniqueness/support/lock_strategy.rb +28 -0
- data/spec/uniqueness/support/on_conflict.rb +24 -0
- data/spec/uniqueness/support/test_worker.rb +19 -0
- data/spec/uniqueness/unique_job_common_spec.rb +45 -0
- metadata +308 -0
| @@ -0,0 +1,247 @@ | |
| 1 | 
            +
            module Joblin::Batching
         | 
| 2 | 
            +
              class ChainBuilder
         | 
| 3 | 
            +
                VALID_PLACEMENT_PARAMETERS = %i[before after with].freeze
         | 
| 4 | 
            +
             | 
| 5 | 
            +
                attr_reader :base_job
         | 
| 6 | 
            +
             | 
| 7 | 
            +
                def initialize(base_type = SerialBatchJob)
         | 
| 8 | 
            +
                  if base_type.is_a?(Hash)
         | 
| 9 | 
            +
                    @base_job = base_type
         | 
| 10 | 
            +
                    @base_job[:args] ||= @base_job[:parameters] || []
         | 
| 11 | 
            +
                    @base_job[:kwargs] ||= {}
         | 
| 12 | 
            +
                  else
         | 
| 13 | 
            +
                    @base_job = build_job_hash(base_type)
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  self.class.get_chain_parameter(base_job)
         | 
| 17 | 
            +
                end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                def process!
         | 
| 20 | 
            +
                  normalize!
         | 
| 21 | 
            +
                  self.class.enqueue_job(base_job)
         | 
| 22 | 
            +
                end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                def [](key)
         | 
| 25 | 
            +
                  if key.is_a?(Class)
         | 
| 26 | 
            +
                    get_sub_chain(key)
         | 
| 27 | 
            +
                  else
         | 
| 28 | 
            +
                    # Legacy Support
         | 
| 29 | 
            +
                    key = :args if key == :parameters
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                    @base_job[key]
         | 
| 32 | 
            +
                  end
         | 
| 33 | 
            +
                end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                def args; return self[:args]; end
         | 
| 36 | 
            +
                def kwargs; return self[:kwargs]; end
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                def <<(new_job)
         | 
| 39 | 
            +
                  insert_at(-1, new_job)
         | 
| 40 | 
            +
                end
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                def insert_at(position, new_jobs, *args, **kwargs, &blk)
         | 
| 43 | 
            +
                  chain = self.class.get_chain_parameter(base_job)
         | 
| 44 | 
            +
                  if new_jobs.is_a?(Class) || new_jobs.is_a?(String)
         | 
| 45 | 
            +
                    new_jobs = build_job_hash(new_jobs, args: args, kwargs: kwargs, &blk)
         | 
| 46 | 
            +
                  elsif args.count > 0 || kwargs.count > 0
         | 
| 47 | 
            +
                    raise "Unexpected number of arguments"
         | 
| 48 | 
            +
                  end
         | 
| 49 | 
            +
                  new_jobs = [new_jobs] unless new_jobs.is_a?(Array)
         | 
| 50 | 
            +
                  chain.insert(position, *new_jobs)
         | 
| 51 | 
            +
                end
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                def insert(new_jobs, *args, **kwargs, &blk)
         | 
| 54 | 
            +
                  if new_jobs.is_a?(Class) || new_jobs.is_a?(String)
         | 
| 55 | 
            +
                    job_kwargs = kwargs.except(*VALID_PLACEMENT_PARAMETERS)
         | 
| 56 | 
            +
                    new_jobs = build_job_hash(new_jobs, args: args, kwargs: job_kwargs, &blk)
         | 
| 57 | 
            +
                    kwargs = kwargs.slice(*VALID_PLACEMENT_PARAMETERS)
         | 
| 58 | 
            +
                  else
         | 
| 59 | 
            +
                    invalid_params = kwargs.keys - VALID_PLACEMENT_PARAMETERS
         | 
| 60 | 
            +
                    raise "Invalid placement parameters: #{invalid_params.map(&:to_s).join(', ')}" if invalid_params.present?
         | 
| 61 | 
            +
                    raise "At most one placement parameter may be provided" if kwargs.values.compact.length > 1
         | 
| 62 | 
            +
                    raise "Unexpected number of arguments" if args.length > 0
         | 
| 63 | 
            +
                  end
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                  new_jobs = [new_jobs] unless new_jobs.is_a?(Array)
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                  if !kwargs.present?
         | 
| 68 | 
            +
                    insert_at(-1, new_jobs)
         | 
| 69 | 
            +
                  else
         | 
| 70 | 
            +
                    placement = kwargs.keys[0]
         | 
| 71 | 
            +
                    relative_to = kwargs.values[0]
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                    matching_jobs = find_matching_jobs(relative_to).to_a
         | 
| 74 | 
            +
                    raise "Could not find a \"#{relative_to}\" job in the chain" if matching_jobs.count == 0
         | 
| 75 | 
            +
                    raise "Found multiple \"#{relative_to}\" jobs in the chain" if matching_jobs.count > 1
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                    relative_job, parent_job, sub_index = matching_jobs[0]
         | 
| 78 | 
            +
                    needed_parent_type = placement == :with ? ConcurrentBatchJob : SerialBatchJob
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                    chain = self.class.get_chain_parameter(parent_job)
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                    if parent_job[:job] != needed_parent_type
         | 
| 83 | 
            +
                      old_job = chain[sub_index]
         | 
| 84 | 
            +
                      parent_job = chain[sub_index] = {
         | 
| 85 | 
            +
                        job: needed_parent_type,
         | 
| 86 | 
            +
                        parameters: [],
         | 
| 87 | 
            +
                      }
         | 
| 88 | 
            +
                      sub_index = 0
         | 
| 89 | 
            +
                      chain = self.class.get_chain_parameter(parent_job)
         | 
| 90 | 
            +
                      chain << old_job
         | 
| 91 | 
            +
                    end
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                    if placement == :with
         | 
| 94 | 
            +
                      chain.insert(-1, *new_jobs)
         | 
| 95 | 
            +
                    else
         | 
| 96 | 
            +
                      sub_index += 1 if placement == :after
         | 
| 97 | 
            +
                      chain.insert(sub_index, *new_jobs)
         | 
| 98 | 
            +
                    end
         | 
| 99 | 
            +
                  end
         | 
| 100 | 
            +
                end
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                def empty?
         | 
| 103 | 
            +
                  self.class.get_chain_parameter(self).empty?
         | 
| 104 | 
            +
                end
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                def get_sub_chain(sub_type)
         | 
| 107 | 
            +
                  matching_jobs = find_matching_jobs(sub_type).to_a
         | 
| 108 | 
            +
                  raise "Found multiple \"#{sub_type}\" jobs in the chain" if matching_jobs.count > 1
         | 
| 109 | 
            +
                  return nil if matching_jobs.count == 0
         | 
| 110 | 
            +
             | 
| 111 | 
            +
                  job = matching_jobs[0][0]
         | 
| 112 | 
            +
                  job = self.class.new(job) unless job.is_a?(ChainBuilder)
         | 
| 113 | 
            +
                  job
         | 
| 114 | 
            +
                end
         | 
| 115 | 
            +
             | 
| 116 | 
            +
                def normalize!(job_def = self.base_job)
         | 
| 117 | 
            +
                  if job_def.is_a?(ChainBuilder)
         | 
| 118 | 
            +
                    job_def.normalize!
         | 
| 119 | 
            +
                  else
         | 
| 120 | 
            +
                    job_def[:job] = job_def[:job].to_s
         | 
| 121 | 
            +
                    if (chain = self.class.get_chain_parameter(job_def, raise_error: false)).present?
         | 
| 122 | 
            +
                      chain.map! { |sub_job| normalize!(sub_job) }
         | 
| 123 | 
            +
                    end
         | 
| 124 | 
            +
                    job_def
         | 
| 125 | 
            +
                  end
         | 
| 126 | 
            +
                end
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                def apply_block(&blk)
         | 
| 129 | 
            +
                  return unless blk.present?
         | 
| 130 | 
            +
                  instance_exec(&blk)
         | 
| 131 | 
            +
                end
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                private
         | 
| 134 | 
            +
             | 
| 135 | 
            +
                def build_job_hash(job, args: [], kwargs: {}, &blk)
         | 
| 136 | 
            +
                  hsh = {
         | 
| 137 | 
            +
                    job: job,
         | 
| 138 | 
            +
                    args: args,
         | 
| 139 | 
            +
                    kwargs: kwargs,
         | 
| 140 | 
            +
                  }
         | 
| 141 | 
            +
                  self.class.new(hsh).apply_block(&blk) if blk.present?
         | 
| 142 | 
            +
                  hsh
         | 
| 143 | 
            +
                end
         | 
| 144 | 
            +
             | 
| 145 | 
            +
                def find_matching_jobs(search_job, parent_job = self.base_job)
         | 
| 146 | 
            +
                  return to_enum(:find_matching_jobs, search_job, parent_job) unless block_given?
         | 
| 147 | 
            +
             | 
| 148 | 
            +
                  sub_jobs = self.class.get_chain_parameter(parent_job)
         | 
| 149 | 
            +
                  sub_jobs.each_with_index do |sub_job, i|
         | 
| 150 | 
            +
                    if sub_job[:job].to_s == search_job.to_s
         | 
| 151 | 
            +
                      yield [sub_job, parent_job, i]
         | 
| 152 | 
            +
                    elsif self.class._job_type_definitions[sub_job[:job].to_s]
         | 
| 153 | 
            +
                      find_matching_jobs(search_job, sub_job) { |item| yield item }
         | 
| 154 | 
            +
                    end
         | 
| 155 | 
            +
                  end
         | 
| 156 | 
            +
                end
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                def find_parent_job(job_def)
         | 
| 159 | 
            +
                  iterate_job_tree do |job, path|
         | 
| 160 | 
            +
                    return path[-1] if job == job_def
         | 
| 161 | 
            +
                  end
         | 
| 162 | 
            +
                  nil
         | 
| 163 | 
            +
                end
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                def iterate_job_tree(root: self.base_job, path: [], &blk)
         | 
| 166 | 
            +
                  blk.call(root, path)
         | 
| 167 | 
            +
             | 
| 168 | 
            +
                  if self.class._job_type_definitions[root[:job]]
         | 
| 169 | 
            +
                    sub_jobs = self.class.get_chain_parameter(root)
         | 
| 170 | 
            +
                    sub_jobs.each_with_index do |sub_job, i|
         | 
| 171 | 
            +
                      iterate_job_tree(root: sub_job, path: [*path, root], &blk)
         | 
| 172 | 
            +
                    end
         | 
| 173 | 
            +
                  end
         | 
| 174 | 
            +
                end
         | 
| 175 | 
            +
             | 
| 176 | 
            +
                class << self
         | 
| 177 | 
            +
                  # Support builder syntaxt/DSL
         | 
| 178 | 
            +
                  # Chain.build(ConcurrentBatchJob) do
         | 
| 179 | 
            +
                  #   insert(SomeJob, arg1, kwarg: 1)
         | 
| 180 | 
            +
                  #   insert(SerialBatchJob) do
         | 
| 181 | 
            +
                  #     insert(SomeJob, arg1, kwarg: 1)
         | 
| 182 | 
            +
                  #   end
         | 
| 183 | 
            +
                  # end
         | 
| 184 | 
            +
                  def build(job, *args, **kwargs, &blk)
         | 
| 185 | 
            +
                    new(job).tap do |ch|
         | 
| 186 | 
            +
                      ch.base_job[:args] = args
         | 
| 187 | 
            +
                      ch.base_job[:kwargs] = kwargs
         | 
| 188 | 
            +
                      ch.apply_block(&blk)
         | 
| 189 | 
            +
                    end
         | 
| 190 | 
            +
                  end
         | 
| 191 | 
            +
             | 
| 192 | 
            +
                  def _job_type_definitions
         | 
| 193 | 
            +
                    @job_type_definitions ||= {}
         | 
| 194 | 
            +
                  end
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                  def register_chain_job(job_class, chain_parameter, **options)
         | 
| 197 | 
            +
                    _job_type_definitions[job_class.to_s] = {
         | 
| 198 | 
            +
                      **options,
         | 
| 199 | 
            +
                      chain_parameter: chain_parameter,
         | 
| 200 | 
            +
                    }
         | 
| 201 | 
            +
                  end
         | 
| 202 | 
            +
             | 
| 203 | 
            +
                  def get_chain_parameter(job_def, raise_error: true)
         | 
| 204 | 
            +
                    unless _job_type_definitions[job_def[:job].to_s].present?
         | 
| 205 | 
            +
                      raise "Job Type #{job_def[:job].to_s} does not accept a sub-chain" if raise_error
         | 
| 206 | 
            +
                      return nil
         | 
| 207 | 
            +
                    end
         | 
| 208 | 
            +
             | 
| 209 | 
            +
                    key = _job_type_definitions[job_def[:job].to_s][:chain_parameter]
         | 
| 210 | 
            +
                    if key.is_a?(Numeric)
         | 
| 211 | 
            +
                      job_def[:args][key] ||= []
         | 
| 212 | 
            +
                    else
         | 
| 213 | 
            +
                      job_def[:kwargs][key] ||= []
         | 
| 214 | 
            +
                    end
         | 
| 215 | 
            +
                  end
         | 
| 216 | 
            +
             | 
| 217 | 
            +
                  # TODO: Add a Chain progress web View
         | 
| 218 | 
            +
                  # Augment Batch tree-view with Chain data
         | 
| 219 | 
            +
                  #   > [DONE] Tree view w/o Chain will only show Parent/Current batches and Job Counts
         | 
| 220 | 
            +
                  #   > If augmented with Chain data, the above will be annotated with Chain-related info and will be able to show Jobs defined in the Chain
         | 
| 221 | 
            +
                  #     > Chain-jobs will be supplied chain_id and chain_step_id metadata
         | 
| 222 | 
            +
                  #     > Using server-middleware, if a Chain-job (has chain_id and chain_step_id) creates a Batch, tag the Batch w/ the chain_id and chain_step_id
         | 
| 223 | 
            +
                  #     > UI will map Batches to Chain-steps using the chain_step_id. UI will add entries for any Chain-steps that were not tied to a Batch
         | 
| 224 | 
            +
                  #   > [DONE] Use a Lua script to find child batch IDs. Support max_depth, items_per_depth, top_depth_slice parameters
         | 
| 225 | 
            +
                  def enqueue_job(job_def)
         | 
| 226 | 
            +
                    job_class = job_def[:job].constantize
         | 
| 227 | 
            +
                    job_args = job_def[:args] || job_def[:parameters] || []
         | 
| 228 | 
            +
                    job_kwargs = job_def[:kwargs] || {}
         | 
| 229 | 
            +
             | 
| 230 | 
            +
                    # Legacy Support
         | 
| 231 | 
            +
                    if job_def[:options]
         | 
| 232 | 
            +
                      job_args << {} unless job_args[-1].is_a?(Hash)
         | 
| 233 | 
            +
                      job_args[-1].merge!(job_def[:options])
         | 
| 234 | 
            +
                    end
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                    if job_class.respond_to? :perform_async
         | 
| 237 | 
            +
                      job_class.perform_async(*job_args, **job_kwargs)
         | 
| 238 | 
            +
                    else
         | 
| 239 | 
            +
                      job_class.perform_later(*job_args, **job_kwargs)
         | 
| 240 | 
            +
                    end
         | 
| 241 | 
            +
                  end
         | 
| 242 | 
            +
                end
         | 
| 243 | 
            +
              end
         | 
| 244 | 
            +
             | 
| 245 | 
            +
              ChainBuilder.register_chain_job(ConcurrentBatchJob, 0)
         | 
| 246 | 
            +
              ChainBuilder.register_chain_job(SerialBatchJob, 0)
         | 
| 247 | 
            +
            end
         | 
| @@ -0,0 +1,108 @@ | |
| 1 | 
            +
             | 
| 2 | 
            +
            module Joblin::Batching
         | 
| 3 | 
            +
              module Compat
         | 
| 4 | 
            +
                module ActiveJob
         | 
| 5 | 
            +
                  module BatchAwareJob
         | 
| 6 | 
            +
                    extend ActiveSupport::Concern
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                    included do
         | 
| 9 | 
            +
                      around_perform do |job, block|
         | 
| 10 | 
            +
                        if (@bid) # This _must_ be @bid - not just bid
         | 
| 11 | 
            +
                          prev_batch = Thread.current[CURRENT_BATCH_THREAD_KEY]
         | 
| 12 | 
            +
                          begin
         | 
| 13 | 
            +
                            Thread.current[CURRENT_BATCH_THREAD_KEY] = Batch.new(@bid)
         | 
| 14 | 
            +
                            block.call
         | 
| 15 | 
            +
                            Thread.current[CURRENT_BATCH_THREAD_KEY].save_context_changes
         | 
| 16 | 
            +
                            Batch.process_successful_job(@bid, job_id)
         | 
| 17 | 
            +
                          rescue
         | 
| 18 | 
            +
                            Batch.process_failed_job(@bid, job_id)
         | 
| 19 | 
            +
                            raise
         | 
| 20 | 
            +
                          ensure
         | 
| 21 | 
            +
                            Thread.current[CURRENT_BATCH_THREAD_KEY] = prev_batch
         | 
| 22 | 
            +
                          end
         | 
| 23 | 
            +
                        else
         | 
| 24 | 
            +
                          block.call
         | 
| 25 | 
            +
                        end
         | 
| 26 | 
            +
                      end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                      around_enqueue do |job, block|
         | 
| 29 | 
            +
                        if (batch = Thread.current[CURRENT_BATCH_THREAD_KEY])
         | 
| 30 | 
            +
                          @bid = batch.bid
         | 
| 31 | 
            +
                          batch.append_jobs(job_id) if @bid
         | 
| 32 | 
            +
                        end
         | 
| 33 | 
            +
                        block.call
         | 
| 34 | 
            +
                      end
         | 
| 35 | 
            +
                    end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                    def bid
         | 
| 38 | 
            +
                      @bid || Thread.current[CURRENT_BATCH_THREAD_KEY]&.bid
         | 
| 39 | 
            +
                    end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                    def batch
         | 
| 42 | 
            +
                      Thread.current[CURRENT_BATCH_THREAD_KEY]
         | 
| 43 | 
            +
                    end
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                    def batch_context
         | 
| 46 | 
            +
                      batch&.context || {}
         | 
| 47 | 
            +
                    end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                    def valid_within_batch?
         | 
| 50 | 
            +
                      batch.valid?
         | 
| 51 | 
            +
                    end
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                    def serialize
         | 
| 54 | 
            +
                      super.tap do |data|
         | 
| 55 | 
            +
                        data['batch_id'] = @bid # This _must_ be @bid - not just bid
         | 
| 56 | 
            +
                        data
         | 
| 57 | 
            +
                      end
         | 
| 58 | 
            +
                    end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                    def deserialize(data)
         | 
| 61 | 
            +
                      super
         | 
| 62 | 
            +
                      @bid = data['batch_id']
         | 
| 63 | 
            +
                    end
         | 
| 64 | 
            +
                  end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                  class ActiveJobCallbackWorker < ::ActiveJob::Base
         | 
| 67 | 
            +
                    include Batch::Callback::CallbackWorkerCommon
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                    def self.enqueue_all(args, queue)
         | 
| 70 | 
            +
                      args.each do |arg_set|
         | 
| 71 | 
            +
                        set(queue: queue).perform_later(*arg_set)
         | 
| 72 | 
            +
                      end
         | 
| 73 | 
            +
                    end
         | 
| 74 | 
            +
                  end
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                  def self.handle_job_death(job, error = nil)
         | 
| 77 | 
            +
                    if job.is_a?(Array)
         | 
| 78 | 
            +
                      event = ActiveSupport::Notifications::Event.new(*job)
         | 
| 79 | 
            +
                      payload = event.payload
         | 
| 80 | 
            +
                      job = payload[:job].serialize
         | 
| 81 | 
            +
                      error = payload[:error]
         | 
| 82 | 
            +
                    end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                    if job["job_id"].present? && job["batch_id"].present?
         | 
| 85 | 
            +
                      Joblin::Batching::Batch.process_dead_job(job['batch_id'], job['job_id'])
         | 
| 86 | 
            +
                    end
         | 
| 87 | 
            +
                  end
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                  def self.configure
         | 
| 90 | 
            +
                    ::ActiveJob::Base.include BatchAwareJob
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                    begin
         | 
| 93 | 
            +
                      ActiveSupport::Notifications.subscribe "discard.active_job" do |*args|
         | 
| 94 | 
            +
                        handle_job_death(args)
         | 
| 95 | 
            +
                      end
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                      ActiveSupport::Notifications.subscribe "retry_stopped.active_job" do |*args|
         | 
| 98 | 
            +
                        handle_job_death(args)
         | 
| 99 | 
            +
                      end
         | 
| 100 | 
            +
                    rescue => err
         | 
| 101 | 
            +
                      Rails.logger.warn(err)
         | 
| 102 | 
            +
                    end
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                    Batch::Callback.worker_class ||= ActiveJobCallbackWorker
         | 
| 105 | 
            +
                  end
         | 
| 106 | 
            +
                end
         | 
| 107 | 
            +
              end
         | 
| 108 | 
            +
            end
         | 
| @@ -0,0 +1,182 @@ | |
| 1 | 
            +
             | 
| 2 | 
            +
            @color-green: #25c766;
         | 
| 3 | 
            +
            @color-darkred: #8f0000;
         | 
| 4 | 
            +
            @color-red: #e03963;
         | 
| 5 | 
            +
            @color-yellow: #c4c725;
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            .code-wrap.batch-context .args-extended {
         | 
| 8 | 
            +
                white-space: pre;
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                .key {
         | 
| 11 | 
            +
                    white-space: pre-wrap;
         | 
| 12 | 
            +
                    margin-left: 2em;
         | 
| 13 | 
            +
                    text-indent: -2em;
         | 
| 14 | 
            +
                    display: inline-block;
         | 
| 15 | 
            +
                }
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                .own {
         | 
| 18 | 
            +
                    color: @color-green;
         | 
| 19 | 
            +
                }
         | 
| 20 | 
            +
            }
         | 
| 21 | 
            +
             | 
| 22 | 
            +
             | 
| 23 | 
            +
            .batch-tree {
         | 
| 24 | 
            +
                .status-block {
         | 
| 25 | 
            +
                    .tree-stat {
         | 
| 26 | 
            +
                        margin: 0 4px;
         | 
| 27 | 
            +
                
         | 
| 28 | 
            +
                        &.pending {
         | 
| 29 | 
            +
                            color: @color-yellow;
         | 
| 30 | 
            +
                        }
         | 
| 31 | 
            +
                        &.failed {
         | 
| 32 | 
            +
                            color: @color-red;
         | 
| 33 | 
            +
                        }
         | 
| 34 | 
            +
                        &.dead {
         | 
| 35 | 
            +
                            color: @color-darkred;
         | 
| 36 | 
            +
                        }
         | 
| 37 | 
            +
                        &.success {
         | 
| 38 | 
            +
                            color: @color-green;
         | 
| 39 | 
            +
                        }
         | 
| 40 | 
            +
                        &.total {
         | 
| 41 | 
            +
                
         | 
| 42 | 
            +
                        }
         | 
| 43 | 
            +
                    }
         | 
| 44 | 
            +
                }
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                .text-inactive {
         | 
| 47 | 
            +
                    color: darken(#fff, 35%);
         | 
| 48 | 
            +
                    font-size: 80%;
         | 
| 49 | 
            +
                }
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                .tree-header {
         | 
| 52 | 
            +
                    position: relative;
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                    .status-block {
         | 
| 55 | 
            +
                        position: absolute;
         | 
| 56 | 
            +
                        bottom: 0;
         | 
| 57 | 
            +
                        width: 100%;
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                        margin-right: 8px;
         | 
| 60 | 
            +
                        font-size: 90%;
         | 
| 61 | 
            +
                        text-align: right;
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                        .tree-stat {
         | 
| 64 | 
            +
                            font-style: italic;
         | 
| 65 | 
            +
                        }
         | 
| 66 | 
            +
                    }
         | 
| 67 | 
            +
                }
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                .tree-entry {
         | 
| 70 | 
            +
                    > .header {
         | 
| 71 | 
            +
                        display: flex;
         | 
| 72 | 
            +
                        align-items: center;
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                        .header-inner {
         | 
| 75 | 
            +
                            padding: 4px 0;
         | 
| 76 | 
            +
                            border-bottom: 1px dashed white;
         | 
| 77 | 
            +
                            display: flex;
         | 
| 78 | 
            +
                            align-items: center;
         | 
| 79 | 
            +
                            flex: 1;
         | 
| 80 | 
            +
                        }
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                        &:hover {
         | 
| 83 | 
            +
                            background-color: rgba(0,0,0,0.20);
         | 
| 84 | 
            +
                            border-radius: 3px;
         | 
| 85 | 
            +
                        }
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                        .row-toggle {
         | 
| 88 | 
            +
                            width: 16px;
         | 
| 89 | 
            +
                            height: 16px;
         | 
| 90 | 
            +
                            text-align: center;
         | 
| 91 | 
            +
                            align-self: center;
         | 
| 92 | 
            +
                            border-radius: 50%;
         | 
| 93 | 
            +
                            border: 1px solid #999;
         | 
| 94 | 
            +
                            text-decoration: none;
         | 
| 95 | 
            +
                            margin: 0 4px;
         | 
| 96 | 
            +
                            font-size: 16px;
         | 
| 97 | 
            +
                            line-height: 15px;
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                            &.not_applicable {
         | 
| 100 | 
            +
                                opacity: 0;
         | 
| 101 | 
            +
                                pointer-events: none;
         | 
| 102 | 
            +
                            }
         | 
| 103 | 
            +
                        }
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                        .main {
         | 
| 106 | 
            +
                            flex: 1;
         | 
| 107 | 
            +
                            display: flex;
         | 
| 108 | 
            +
                            align-items: baseline;
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                            .bid {
         | 
| 111 | 
            +
                                font-family: monospace;
         | 
| 112 | 
            +
                                padding: 3px 6px;
         | 
| 113 | 
            +
                                background: rgba(0,0,0,0.2);
         | 
| 114 | 
            +
                                border-radius: 3px;
         | 
| 115 | 
            +
                                font-size: 12px;
         | 
| 116 | 
            +
                                margin: 0 12px 0 0;
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                                &:hover {
         | 
| 119 | 
            +
                                    .bid-goto {
         | 
| 120 | 
            +
                                        display: inline-block;
         | 
| 121 | 
            +
                                        padding: 0 0 0 4px;
         | 
| 122 | 
            +
                                        font-size: 200%;
         | 
| 123 | 
            +
                                        line-height: 10px;
         | 
| 124 | 
            +
                                        vertical-align: sub;
         | 
| 125 | 
            +
                                        text-decoration: dotted;
         | 
| 126 | 
            +
                                    }
         | 
| 127 | 
            +
                                }
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                                .bid-goto {
         | 
| 130 | 
            +
                                    display: none;
         | 
| 131 | 
            +
                                }
         | 
| 132 | 
            +
                            }
         | 
| 133 | 
            +
                        }
         | 
| 134 | 
            +
             | 
| 135 | 
            +
                        .goto-link {
         | 
| 136 | 
            +
                            margin: 0 8px;
         | 
| 137 | 
            +
                            display: inline-block;
         | 
| 138 | 
            +
                            height: 16px;
         | 
| 139 | 
            +
                            font-size: 90%;
         | 
| 140 | 
            +
                            border-bottom: 1px dotted white;
         | 
| 141 | 
            +
                        }
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                        .status-label {
         | 
| 144 | 
            +
                            font-family: monospace;
         | 
| 145 | 
            +
                            padding: 3px 6px;
         | 
| 146 | 
            +
                            background: rgba(0,0,0,0.2);
         | 
| 147 | 
            +
                            border-radius: 3px;
         | 
| 148 | 
            +
                            font-size: 12px;
         | 
| 149 | 
            +
                            margin: 0 12px 0 0;
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                            &.deleted {
         | 
| 152 | 
            +
                                background: #99999933;
         | 
| 153 | 
            +
                            }
         | 
| 154 | 
            +
                            &.failed, &.complete {
         | 
| 155 | 
            +
                                background: #99000033;
         | 
| 156 | 
            +
                            }
         | 
| 157 | 
            +
                            &.success {
         | 
| 158 | 
            +
                                background: #00990033;
         | 
| 159 | 
            +
                            }
         | 
| 160 | 
            +
                        }
         | 
| 161 | 
            +
             | 
| 162 | 
            +
                        .status-block {
         | 
| 163 | 
            +
                            width: 12em;
         | 
| 164 | 
            +
                            text-align: center;
         | 
| 165 | 
            +
                        }
         | 
| 166 | 
            +
                    }
         | 
| 167 | 
            +
             | 
| 168 | 
            +
                    > .subitems {
         | 
| 169 | 
            +
                        padding-left: 16px;
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                        >.load-more {
         | 
| 172 | 
            +
                            padding: 4px 0;
         | 
| 173 | 
            +
                            text-align: center;
         | 
| 174 | 
            +
                            border-bottom: 1px dashed white;
         | 
| 175 | 
            +
                            a {
         | 
| 176 | 
            +
                                border-bottom: 1px dotted white;
         | 
| 177 | 
            +
                                text-decoration: none;
         | 
| 178 | 
            +
                            }
         | 
| 179 | 
            +
                        }
         | 
| 180 | 
            +
                    }
         | 
| 181 | 
            +
                }
         | 
| 182 | 
            +
            }
         | 
| @@ -0,0 +1,108 @@ | |
| 1 | 
            +
            import { h, Component, render } from 'https://esm.sh/preact';
         | 
| 2 | 
            +
            import htm from 'https://esm.sh/htm';
         | 
| 3 | 
            +
            import { root_url } from './util.js';
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            // Initialize htm with Preact
         | 
| 6 | 
            +
            const html = htm.bind(h);
         | 
| 7 | 
            +
             | 
| 8 | 
            +
            const StatusBlock = (props) => html`
         | 
| 9 | 
            +
              <div class="status-block ${props.class || ''}">
         | 
| 10 | 
            +
                ${props.title && props.title + ':'}
         | 
| 11 | 
            +
                <span class="tree-stat pending">${props.pending_count}</span>
         | 
| 12 | 
            +
                |
         | 
| 13 | 
            +
                <span class="tree-stat failed">${props.failed_count}</span>
         | 
| 14 | 
            +
                |
         | 
| 15 | 
            +
                <span class="tree-stat dead">${props.dead_count}</span>
         | 
| 16 | 
            +
                |
         | 
| 17 | 
            +
                <span class="tree-stat success">${props.successful_count}</span>
         | 
| 18 | 
            +
                /
         | 
| 19 | 
            +
                <span class="tree-stat total">${props.total_count}</span>
         | 
| 20 | 
            +
              </div>
         | 
| 21 | 
            +
            `
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            class TreeLevel extends Component {
         | 
| 24 | 
            +
              get bid() {
         | 
| 25 | 
            +
                return this.props.data.bid;
         | 
| 26 | 
            +
              }
         | 
| 27 | 
            +
             | 
| 28 | 
            +
              get batch() {
         | 
| 29 | 
            +
                return this.props.data;
         | 
| 30 | 
            +
              }
         | 
| 31 | 
            +
             | 
| 32 | 
            +
              load_more = async (event) => {
         | 
| 33 | 
            +
                event.preventDefault();
         | 
| 34 | 
            +
                const l = this.batch.batches.items.length;
         | 
| 35 | 
            +
                const resp = await fetch(`${root_url}batches/${this.bid}/tree?slice=${l}:${l + 5 - 1}`)
         | 
| 36 | 
            +
                const result = await resp.json()
         | 
| 37 | 
            +
                const newEntries = result.batches.items;
         | 
| 38 | 
            +
                for (let ent of newEntries) {
         | 
| 39 | 
            +
                  this.batch.batches.items.push(ent)
         | 
| 40 | 
            +
                }
         | 
| 41 | 
            +
                this.forceUpdate()
         | 
| 42 | 
            +
              }
         | 
| 43 | 
            +
             | 
| 44 | 
            +
              toggle = (event) => {
         | 
| 45 | 
            +
                event.preventDefault();
         | 
| 46 | 
            +
                this.setState({ collapsed: !this.state.collapsed })
         | 
| 47 | 
            +
              }
         | 
| 48 | 
            +
             | 
| 49 | 
            +
              render() {
         | 
| 50 | 
            +
                const { data: bd } = this.props;
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                let sub_entries = [];
         | 
| 53 | 
            +
                let sub_batches = bd.batches.items;
         | 
| 54 | 
            +
                for (let b of sub_batches) {
         | 
| 55 | 
            +
                  sub_entries.push(html`<${TreeLevel} data=${b} />`)
         | 
| 56 | 
            +
                }
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                let fully_loaded = !(sub_batches.length < bd.batches.total_count);
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                const load_more_elem = html`<div class="load-more">
         | 
| 61 | 
            +
                  ${sub_entries.length} / ${bd.batches.total_count} Items Loaded - <a href="#" onClick=${this.load_more}>Load More</a>
         | 
| 62 | 
            +
                </div>`
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                return html`<div class="tree-entry tree-batch">
         | 
| 65 | 
            +
                  <div class="header">
         | 
| 66 | 
            +
                    <a class="row-toggle ${!bd.batches.total_count && 'not_applicable'}" onClick=${this.toggle} href="#">
         | 
| 67 | 
            +
                      ${this.state.collapsed ? '+' : '-'}
         | 
| 68 | 
            +
                    </a>
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                    <div class="header-inner">
         | 
| 71 | 
            +
                      <div class="main">
         | 
| 72 | 
            +
                        <span class="bid">
         | 
| 73 | 
            +
                          ${bd.bid}
         | 
| 74 | 
            +
                          <a class="bid-goto" href="${root_url}batches/${bd.bid}">⇢</a>
         | 
| 75 | 
            +
                        </span>
         | 
| 76 | 
            +
                        ${bd.description || (bd.status == 'deleted' && html`<i class="text-inactive">Deleted</i>`) || html`<i class="text-inactive">No Description</i>`}
         | 
| 77 | 
            +
                      </div>
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                      <span class="status-label ${bd.status}">${bd.status}</span>
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                      ${bd.status != 'deleted' && html`
         | 
| 82 | 
            +
                        <${StatusBlock} class="job-status" title="Jobs" ...${bd.jobs} />
         | 
| 83 | 
            +
                        <${StatusBlock} class="batch-status" title="Batches" ...${bd.batches} />
         | 
| 84 | 
            +
                      `}
         | 
| 85 | 
            +
                    </div>
         | 
| 86 | 
            +
                  </div>
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                  <div class="subitems ${this.state.collapsed ? 'hidden' : ''}">
         | 
| 89 | 
            +
                    ${sub_entries}
         | 
| 90 | 
            +
                    ${!fully_loaded && load_more_elem}
         | 
| 91 | 
            +
                  </div>
         | 
| 92 | 
            +
                </div>`
         | 
| 93 | 
            +
              }
         | 
| 94 | 
            +
            }
         | 
| 95 | 
            +
             | 
| 96 | 
            +
            class TreeRoot extends Component {
         | 
| 97 | 
            +
              render() {
         | 
| 98 | 
            +
                const tree_data = JSON.parse(document.querySelector('#batch-tree #initial-data').innerHTML);
         | 
| 99 | 
            +
                return html`
         | 
| 100 | 
            +
                  <div class="tree-header">
         | 
| 101 | 
            +
                    <${StatusBlock} pending_count="pending" failed_count="failed" dead_count="dead" successful_count="successful" total_count="total" />
         | 
| 102 | 
            +
                  </div>
         | 
| 103 | 
            +
                  <${TreeLevel} data=${tree_data} />
         | 
| 104 | 
            +
                `;
         | 
| 105 | 
            +
              }
         | 
| 106 | 
            +
            }
         | 
| 107 | 
            +
             | 
| 108 | 
            +
            render(html`<${TreeRoot} />`, document.querySelector('#batch-tree'));
         | 
| @@ -0,0 +1,41 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Joblin::Batching::Compat::Sidekiq
         | 
| 4 | 
            +
              module Web
         | 
| 5 | 
            +
                module Helpers
         | 
| 6 | 
            +
                  VIEW_PATH    = File.expand_path("../web/views", __dir__)
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  module_function
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def get_template(name)
         | 
| 11 | 
            +
                    File.open(File.join(VIEW_PATH, "#{name}.erb")).read
         | 
| 12 | 
            +
                  end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  def drain_zset(key)
         | 
| 15 | 
            +
                    items, _ = Joblin::Batching::Batch.redis do |r|
         | 
| 16 | 
            +
                      r.multi do |r|
         | 
| 17 | 
            +
                        r.zrange(key, 0, -1)
         | 
| 18 | 
            +
                        r.zremrangebyrank(key, 0, -1)
         | 
| 19 | 
            +
                      end
         | 
| 20 | 
            +
                    end
         | 
| 21 | 
            +
                    yield items
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  def safe_relative_time(time)
         | 
| 25 | 
            +
                    time = parse_time(time)
         | 
| 26 | 
            +
                    relative_time(time)
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                  def parse_time(time)
         | 
| 30 | 
            +
                    case time
         | 
| 31 | 
            +
                    when Time
         | 
| 32 | 
            +
                      time
         | 
| 33 | 
            +
                    when Integer, Float
         | 
| 34 | 
            +
                      Time.at(time)
         | 
| 35 | 
            +
                    else
         | 
| 36 | 
            +
                      Time.parse(time.to_s)
         | 
| 37 | 
            +
                    end
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
                end
         | 
| 40 | 
            +
              end
         | 
| 41 | 
            +
            end
         |