resque-scheduler 2.5.5 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of resque-scheduler might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +12 -6
  3. data/.rubocop.yml +18 -113
  4. data/.rubocop_todo.yml +29 -0
  5. data/.simplecov +3 -1
  6. data/.travis.yml +12 -4
  7. data/.vagrant-provision-as-vagrant.sh +15 -0
  8. data/.vagrant-provision.sh +23 -0
  9. data/.vagrant-skel/bash_profile +7 -0
  10. data/.vagrant-skel/bashrc +7 -0
  11. data/AUTHORS.md +5 -0
  12. data/Gemfile +1 -2
  13. data/HISTORY.md +18 -0
  14. data/README.md +39 -11
  15. data/ROADMAP.md +0 -6
  16. data/Rakefile +11 -19
  17. data/Vagrantfile +14 -0
  18. data/bin/resque-scheduler +2 -2
  19. data/examples/Rakefile +1 -1
  20. data/examples/config/initializers/resque-web.rb +2 -2
  21. data/examples/dynamic-scheduling/app/jobs/fix_schedules_job.rb +2 -4
  22. data/examples/dynamic-scheduling/app/jobs/send_email_job.rb +1 -1
  23. data/examples/dynamic-scheduling/app/models/user.rb +2 -2
  24. data/examples/dynamic-scheduling/lib/tasks/resque.rake +2 -2
  25. data/lib/resque-scheduler.rb +3 -1
  26. data/lib/resque/scheduler.rb +112 -168
  27. data/lib/resque/scheduler/cli.rb +144 -0
  28. data/lib/resque/scheduler/configuration.rb +73 -0
  29. data/lib/resque/scheduler/delaying_extensions.rb +278 -0
  30. data/lib/resque/scheduler/env.rb +61 -0
  31. data/lib/resque/scheduler/extension.rb +13 -0
  32. data/lib/resque/scheduler/lock.rb +2 -1
  33. data/lib/resque/scheduler/lock/base.rb +6 -2
  34. data/lib/resque/scheduler/lock/basic.rb +4 -5
  35. data/lib/resque/scheduler/lock/resilient.rb +30 -37
  36. data/lib/resque/scheduler/locking.rb +94 -0
  37. data/lib/resque/scheduler/logger_builder.rb +72 -0
  38. data/lib/resque/scheduler/plugin.rb +31 -0
  39. data/lib/resque/scheduler/scheduling_extensions.rb +150 -0
  40. data/lib/resque/scheduler/server.rb +246 -0
  41. data/lib/{resque_scheduler → resque/scheduler}/server/views/delayed.erb +2 -1
  42. data/lib/{resque_scheduler → resque/scheduler}/server/views/delayed_schedules.erb +0 -0
  43. data/lib/{resque_scheduler → resque/scheduler}/server/views/delayed_timestamp.erb +0 -0
  44. data/lib/{resque_scheduler → resque/scheduler}/server/views/requeue-params.erb +0 -0
  45. data/lib/{resque_scheduler → resque/scheduler}/server/views/scheduler.erb +16 -1
  46. data/lib/{resque_scheduler → resque/scheduler}/server/views/search.erb +2 -1
  47. data/lib/{resque_scheduler → resque/scheduler}/server/views/search_form.erb +0 -0
  48. data/lib/resque/scheduler/signal_handling.rb +40 -0
  49. data/lib/{resque_scheduler → resque/scheduler}/tasks.rb +3 -5
  50. data/lib/resque/scheduler/util.rb +41 -0
  51. data/lib/resque/scheduler/version.rb +7 -0
  52. data/resque-scheduler.gemspec +21 -19
  53. data/script/migrate_to_timestamps_set.rb +5 -3
  54. data/tasks/resque_scheduler.rake +1 -1
  55. data/test/cli_test.rb +26 -69
  56. data/test/delayed_queue_test.rb +262 -169
  57. data/test/env_test.rb +41 -0
  58. data/test/resque-web_test.rb +169 -48
  59. data/test/scheduler_args_test.rb +73 -41
  60. data/test/scheduler_hooks_test.rb +9 -8
  61. data/test/scheduler_locking_test.rb +55 -36
  62. data/test/scheduler_setup_test.rb +52 -15
  63. data/test/scheduler_task_test.rb +15 -10
  64. data/test/scheduler_test.rb +215 -114
  65. data/test/support/redis_instance.rb +32 -33
  66. data/test/test_helper.rb +33 -36
  67. data/test/util_test.rb +11 -0
  68. metadata +113 -57
  69. data/lib/resque/scheduler_locking.rb +0 -91
  70. data/lib/resque_scheduler.rb +0 -386
  71. data/lib/resque_scheduler/cli.rb +0 -160
  72. data/lib/resque_scheduler/logger_builder.rb +0 -72
  73. data/lib/resque_scheduler/plugin.rb +0 -28
  74. data/lib/resque_scheduler/server.rb +0 -183
  75. data/lib/resque_scheduler/util.rb +0 -34
  76. data/lib/resque_scheduler/version.rb +0 -5
  77. data/test/redis-test.conf +0 -108
@@ -0,0 +1,61 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ module Resque
4
+ module Scheduler
5
+ class Env
6
+ def initialize(options)
7
+ @options = options
8
+ end
9
+
10
+ def setup
11
+ require 'resque'
12
+ require 'resque/scheduler'
13
+
14
+ setup_backgrounding
15
+ setup_pid_file
16
+ setup_scheduler_configuration
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :options
22
+
23
+ def setup_backgrounding
24
+ # Need to set this here for conditional Process.daemon redirect of
25
+ # stderr/stdout to /dev/null
26
+ Resque::Scheduler.quiet = !!options[:quiet]
27
+
28
+ if options[:background]
29
+ unless Process.respond_to?('daemon')
30
+ abort 'background option is set, which requires ruby >= 1.9'
31
+ end
32
+
33
+ Process.daemon(true, !Resque::Scheduler.quiet)
34
+ Resque.redis.client.reconnect
35
+ end
36
+ end
37
+
38
+ def setup_pid_file
39
+ File.open(options[:pidfile], 'w') do |f|
40
+ f.puts $PROCESS_ID
41
+ end if options[:pidfile]
42
+ end
43
+
44
+ def setup_scheduler_configuration
45
+ Resque::Scheduler.configure do |c|
46
+ # These settings are somewhat redundant given the defaults present
47
+ # in the attr reader methods. They are left here for clarity and
48
+ # to serve as an example of how to use `.configure`.
49
+
50
+ c.app_name = options[:app_name]
51
+ c.dynamic = !!options[:dynamic]
52
+ c.env = options[:env]
53
+ c.logfile = options[:logfile]
54
+ c.logformat = options[:logformat]
55
+ c.poll_sleep_amount = Float(options[:poll_sleep_amount] || '5')
56
+ c.verbose = !!options[:verbose]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,13 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ require_relative 'scheduling_extensions'
4
+ require_relative 'delaying_extensions'
5
+
6
+ module Resque
7
+ module Scheduler
8
+ module Extension
9
+ include SchedulingExtensions
10
+ include DelayingExtensions
11
+ end
12
+ end
13
+ end
@@ -1,3 +1,4 @@
1
- %w[base basic resilient].each do |file|
1
+ # vim:fileencoding=utf-8
2
+ %w(base basic resilient).each do |file|
2
3
  require "resque/scheduler/lock/#{file}"
3
4
  end
@@ -1,5 +1,7 @@
1
+ # vim:fileencoding=utf-8
2
+
1
3
  module Resque
2
- class Scheduler
4
+ module Scheduler
3
5
  module Lock
4
6
  class Base
5
7
  attr_reader :key
@@ -40,7 +42,9 @@ module Resque
40
42
 
41
43
  def hostname
42
44
  local_hostname = Socket.gethostname
43
- Socket.gethostbyname(local_hostname).first rescue local_hostname
45
+ Socket.gethostbyname(local_hostname).first
46
+ rescue
47
+ local_hostname
44
48
  end
45
49
 
46
50
  def process_id
@@ -1,7 +1,8 @@
1
- require 'resque/scheduler/lock/base'
1
+ # vim:fileencoding=utf-8
2
+ require_relative 'base'
2
3
 
3
4
  module Resque
4
- class Scheduler
5
+ module Scheduler
5
6
  module Lock
6
7
  class Basic < Base
7
8
  def acquire!
@@ -15,9 +16,7 @@ module Resque
15
16
  if Resque.redis.get(key) == value
16
17
  extend_lock!
17
18
 
18
- if Resque.redis.get(key) == value
19
- return true
20
- end
19
+ return true if Resque.redis.get(key) == value
21
20
  end
22
21
 
23
22
  false
@@ -1,22 +1,23 @@
1
- require 'resque/scheduler/lock/base'
1
+ # vim:fileencoding=utf-8
2
+ require_relative 'base'
2
3
 
3
4
  module Resque
4
- class Scheduler
5
+ module Scheduler
5
6
  module Lock
6
- class Resilient < Base # rubocop:disable TrailingComma
7
+ class Resilient < Base
7
8
  def acquire!
8
9
  Resque.redis.evalsha(
9
10
  acquire_sha,
10
- :keys => [key],
11
- :argv => [value]
11
+ keys: [key],
12
+ argv: [value]
12
13
  ).to_i == 1
13
14
  end
14
15
 
15
16
  def locked?
16
17
  Resque.redis.evalsha(
17
18
  locked_sha,
18
- :keys => [key],
19
- :argv => [value]
19
+ keys: [key],
20
+ argv: [value]
20
21
  ).to_i == 1
21
22
  end
22
23
 
@@ -34,43 +35,35 @@ module Resque
34
35
  def locked_sha(refresh = false)
35
36
  @locked_sha = nil if refresh
36
37
 
37
- @locked_sha ||= begin
38
- Resque.redis.script(
39
- :load,
40
- <<-EOF
41
- if redis.call('GET', KEYS[1]) == ARGV[1]
42
- then
43
- redis.call('EXPIRE', KEYS[1], #{timeout})
38
+ @locked_sha ||=
39
+ Resque.redis.script(:load, <<-EOF.gsub(/^ {14}/, ''))
40
+ if redis.call('GET', KEYS[1]) == ARGV[1]
41
+ then
42
+ redis.call('EXPIRE', KEYS[1], #{timeout})
44
43
 
45
- if redis.call('GET', KEYS[1]) == ARGV[1]
46
- then
47
- return 1
48
- end
49
- end
44
+ if redis.call('GET', KEYS[1]) == ARGV[1]
45
+ then
46
+ return 1
47
+ end
48
+ end
50
49
 
51
- return 0
52
- EOF
53
- )
54
- end
50
+ return 0
51
+ EOF
55
52
  end
56
53
 
57
54
  def acquire_sha(refresh = false)
58
55
  @acquire_sha = nil if refresh
59
56
 
60
- @acquire_sha ||= begin
61
- Resque.redis.script(
62
- :load,
63
- <<-EOF
64
- if redis.call('SETNX', KEYS[1], ARGV[1]) == 1
65
- then
66
- redis.call('EXPIRE', KEYS[1], #{timeout})
67
- return 1
68
- else
69
- return 0
70
- end
71
- EOF
72
- )
73
- end
57
+ @acquire_sha ||=
58
+ Resque.redis.script(:load, <<-EOF.gsub(/^ {14}/, ''))
59
+ if redis.call('SETNX', KEYS[1], ARGV[1]) == 1
60
+ then
61
+ redis.call('EXPIRE', KEYS[1], #{timeout})
62
+ return 1
63
+ else
64
+ return 0
65
+ end
66
+ EOF
74
67
  end
75
68
  end
76
69
  end
@@ -0,0 +1,94 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ # ### Locking the scheduler process
4
+ #
5
+ # There are two places in resque-scheduler that need to be synchonized in order
6
+ # to be able to run redundant scheduler processes while ensuring jobs don't get
7
+ # queued multiple times when the master process changes.
8
+ #
9
+ # 1) Processing the delayed queues (jobs that are created from
10
+ # enqueue_at/enqueue_in, etc) 2) Processing the scheduled (cron-like) jobs from
11
+ # rufus-scheduler
12
+ #
13
+ # Protecting the delayed queues (#1) is relatively easy. A simple SETNX in
14
+ # redis would suffice. However, protecting the scheduled jobs is trickier
15
+ # because the clocks on machines could be slightly off or actual firing times
16
+ # could vary slightly due to load. If scheduler A's clock is slightly ahead of
17
+ # scheduler B's clock (since they are on different machines), when scheduler A
18
+ # dies, we need to ensure that scheduler B doesn't queue jobs that A already
19
+ # queued before it's death. (This all assumes that it is better to miss a few
20
+ # scheduled jobs than it is to run them multiple times for the same iteration.)
21
+ #
22
+ # To avoid queuing multiple jobs in the case of master fail-over, the master
23
+ # should remain the master as long as it can rather than a simple SETNX which
24
+ # would result in the master roll being passed around frequently.
25
+ #
26
+ # Locking Scheme: Each resque-scheduler process attempts to get the master lock
27
+ # via SETNX. Once obtained, it sets the expiration for 3 minutes
28
+ # (configurable). The master process continually updates the timeout on the
29
+ # lock key to be 3 minutes in the future in it's loop(s) (see `run`) and when
30
+ # jobs come out of rufus-scheduler (see `load_schedule_job`). That ensures
31
+ # that a minimum of 3 minutes must pass since the last queuing operation before
32
+ # a new master is chosen. If, for whatever reason, the master fails to update
33
+ # the expiration for 3 minutes, the key expires and the lock is up for grabs.
34
+ # If miraculously the original master comes back to life, it will realize it is
35
+ # no longer the master and stop processing jobs.
36
+ #
37
+ # The clocks on the scheduler machines can then be up to 3 minutes off from
38
+ # each other without the risk of queueing the same scheduled job twice during a
39
+ # master change. The catch is, in the event of a master change, no scheduled
40
+ # jobs will be queued during those 3 minutes. So, there is a trade off: the
41
+ # higher the timeout, the less likely scheduled jobs will be fired twice but
42
+ # greater chances of missing scheduled jobs. The lower the timeout, less
43
+ # likely jobs will be missed, greater the chances of jobs firing twice. If you
44
+ # don't care about jobs firing twice or are certain your machines' clocks are
45
+ # well in sync, a lower timeout is preferable. One thing to keep in mind: this
46
+ # only effects *scheduled* jobs - delayed jobs will never be lost or skipped
47
+ # since eventually a master will come online and it will process everything
48
+ # that is ready (no matter how old it is). Scheduled jobs work like cron - if
49
+ # you stop cron, no jobs fire while it's stopped and it doesn't fire jobs that
50
+ # were missed when it starts up again.
51
+
52
+ require_relative 'lock'
53
+
54
+ module Resque
55
+ module Scheduler
56
+ module Locking
57
+ def master_lock
58
+ @master_lock ||= build_master_lock
59
+ end
60
+
61
+ def supports_lua?
62
+ redis_master_version >= 2.5
63
+ end
64
+
65
+ def master?
66
+ master_lock.acquire! || master_lock.locked?
67
+ end
68
+
69
+ def release_master_lock!
70
+ master_lock.release!
71
+ end
72
+
73
+ private
74
+
75
+ def build_master_lock
76
+ if supports_lua?
77
+ Resque::Scheduler::Lock::Resilient.new(master_lock_key)
78
+ else
79
+ Resque::Scheduler::Lock::Basic.new(master_lock_key)
80
+ end
81
+ end
82
+
83
+ def master_lock_key
84
+ lock_prefix = ENV['RESQUE_SCHEDULER_MASTER_LOCK_PREFIX'] || ''
85
+ lock_prefix += ':' if lock_prefix != ''
86
+ "#{Resque.redis.namespace}:#{lock_prefix}resque_scheduler_master_lock"
87
+ end
88
+
89
+ def redis_master_version
90
+ Resque.redis.info['redis_version'].to_f
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,72 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ require 'mono_logger'
4
+
5
+ module Resque
6
+ module Scheduler
7
+ # Just builds a logger, with specified verbosity and destination.
8
+ # The simplest example:
9
+ #
10
+ # Resque::Scheduler::LoggerBuilder.new.build
11
+ class LoggerBuilder
12
+ # Initializes new instance of the builder
13
+ #
14
+ # Pass :opts Hash with
15
+ # - :quiet if logger needs to be silent for all levels. Default - false
16
+ # - :verbose if there is a need in debug messages. Default - false
17
+ # - :log_dev to output logs into a desired file. Default - STDOUT
18
+ # - :format log format, either 'text' or 'json'. Default - 'text'
19
+ #
20
+ # Example:
21
+ #
22
+ # LoggerBuilder.new(
23
+ # :quiet => false, :verbose => true, :log_dev => 'log/scheduler.log'
24
+ # )
25
+ def initialize(opts = {})
26
+ @quiet = !!opts[:quiet]
27
+ @verbose = !!opts[:verbose]
28
+ @log_dev = opts[:log_dev] || $stdout
29
+ @format = opts[:format] || 'text'
30
+ end
31
+
32
+ # Returns an instance of MonoLogger
33
+ def build
34
+ logger = MonoLogger.new(@log_dev)
35
+ logger.level = level
36
+ logger.formatter = send(:"#{@format}_formatter")
37
+ logger
38
+ end
39
+
40
+ private
41
+
42
+ def level
43
+ if @verbose && !@quiet
44
+ MonoLogger::DEBUG
45
+ elsif !@quiet
46
+ MonoLogger::INFO
47
+ else
48
+ MonoLogger::FATAL
49
+ end
50
+ end
51
+
52
+ def text_formatter
53
+ proc do |severity, datetime, _progname, msg|
54
+ "resque-scheduler: [#{severity}] #{datetime.iso8601}: #{msg}\n"
55
+ end
56
+ end
57
+
58
+ def json_formatter
59
+ proc do |severity, datetime, progname, msg|
60
+ require 'json'
61
+ JSON.dump(
62
+ name: 'resque-scheduler',
63
+ progname: progname,
64
+ level: severity,
65
+ timestamp: datetime.iso8601,
66
+ msg: msg
67
+ ) + "\n"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,31 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ module Resque
4
+ module Scheduler
5
+ module Plugin
6
+ def self.hooks(job, pattern)
7
+ job.methods.grep(/^#{pattern}/).sort
8
+ end
9
+
10
+ def self.run_hooks(job, pattern, *args)
11
+ results = hooks(job, pattern).map do |hook|
12
+ job.send(hook, *args)
13
+ end
14
+
15
+ results.all? { |result| result != false }
16
+ end
17
+
18
+ def self.run_before_delayed_enqueue_hooks(klass, *args)
19
+ run_hooks(klass, 'before_delayed_enqueue', *args)
20
+ end
21
+
22
+ def self.run_before_schedule_hooks(klass, *args)
23
+ run_hooks(klass, 'before_schedule', *args)
24
+ end
25
+
26
+ def self.run_after_schedule_hooks(klass, *args)
27
+ run_hooks(klass, 'after_schedule', *args)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,150 @@
1
+ # vim:fileencoding=utf-8
2
+
3
+ module Resque
4
+ module Scheduler
5
+ module SchedulingExtensions
6
+ # Accepts a new schedule configuration of the form:
7
+ #
8
+ # {
9
+ # "MakeTea" => {
10
+ # "every" => "1m" },
11
+ # "some_name" => {
12
+ # "cron" => "5/* * * *",
13
+ # "class" => "DoSomeWork",
14
+ # "args" => "work on this string",
15
+ # "description" => "this thing works it"s butter off" },
16
+ # ...
17
+ # }
18
+ #
19
+ # Hash keys can be anything and are used to describe and reference
20
+ # the scheduled job. If the "class" argument is missing, the key
21
+ # is used implicitly as "class" argument - in the "MakeTea" example,
22
+ # "MakeTea" is used both as job name and resque worker class.
23
+ #
24
+ # Any jobs that were in the old schedule, but are not
25
+ # present in the new schedule, will be removed.
26
+ #
27
+ # :cron can be any cron scheduling string
28
+ #
29
+ # :every can be used in lieu of :cron. see rufus-scheduler's 'every'
30
+ # usage for valid syntax. If :cron is present it will take precedence
31
+ # over :every.
32
+ #
33
+ # :class must be a resque worker class. If it is missing, the job name
34
+ # (hash key) will be used as :class.
35
+ #
36
+ # :args can be any yaml which will be converted to a ruby literal and
37
+ # passed in a params. (optional)
38
+ #
39
+ # :rails_envs is the list of envs where the job gets loaded. Envs are
40
+ # comma separated (optional)
41
+ #
42
+ # :description is just that, a description of the job (optional). If
43
+ # params is an array, each element in the array is passed as a separate
44
+ # param, otherwise params is passed in as the only parameter to
45
+ # perform.
46
+ def schedule=(schedule_hash)
47
+ # clean the schedules as it exists in redis
48
+ clean_schedules
49
+
50
+ schedule_hash = prepare_schedule(schedule_hash)
51
+
52
+ # store all schedules in redis, so we can retrieve them back
53
+ # everywhere.
54
+ schedule_hash.each do |name, job_spec|
55
+ set_schedule(name, job_spec)
56
+ end
57
+
58
+ # ensure only return the successfully saved data!
59
+ reload_schedule!
60
+ end
61
+
62
+ # Returns the schedule hash
63
+ def schedule
64
+ @schedule ||= all_schedules
65
+ @schedule || {}
66
+ end
67
+
68
+ # reloads the schedule from redis
69
+ def reload_schedule!
70
+ @schedule = all_schedules
71
+ end
72
+
73
+ # gets the schedules as it exists in redis
74
+ def all_schedules
75
+ return nil unless redis.exists(:schedules)
76
+
77
+ redis.hgetall(:schedules).tap do |h|
78
+ h.each do |name, config|
79
+ h[name] = decode(config)
80
+ end
81
+ end
82
+ end
83
+
84
+ # clean the schedules as it exists in redis, useful for first setup?
85
+ def clean_schedules
86
+ if redis.exists(:schedules)
87
+ redis.hkeys(:schedules).each do |key|
88
+ remove_schedule(key) unless schedule_persisted?(key)
89
+ end
90
+ end
91
+ @schedule = nil
92
+ true
93
+ end
94
+
95
+ # Create or update a schedule with the provided name and configuration.
96
+ #
97
+ # Note: values for class and custom_job_class need to be strings,
98
+ # not constants.
99
+ #
100
+ # Resque.set_schedule('some_job', {:class => 'SomeJob',
101
+ # :every => '15mins',
102
+ # :queue => 'high',
103
+ # :args => '/tmp/poop'})
104
+ def set_schedule(name, config)
105
+ existing_config = fetch_schedule(name)
106
+ persist = config.delete(:persist) || config.delete('persist')
107
+ unless existing_config && existing_config == config
108
+ redis.pipelined do
109
+ redis.hset(:schedules, name, encode(config))
110
+ redis.sadd(:schedules_changed, name)
111
+ redis.sadd(:persisted_schedules, name) if persist
112
+ end
113
+ end
114
+ config
115
+ end
116
+
117
+ # retrive the schedule configuration for the given name
118
+ def fetch_schedule(name)
119
+ decode(redis.hget(:schedules, name))
120
+ end
121
+
122
+ def schedule_persisted?(name)
123
+ redis.sismember(:persisted_schedules, name)
124
+ end
125
+
126
+ # remove a given schedule by name
127
+ def remove_schedule(name)
128
+ redis.pipelined do
129
+ redis.hdel(:schedules, name)
130
+ redis.srem(:persisted_schedules, name)
131
+ redis.sadd(:schedules_changed, name)
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def prepare_schedule(schedule_hash)
138
+ prepared_hash = {}
139
+ schedule_hash.each do |name, job_spec|
140
+ job_spec = job_spec.dup
141
+ unless job_spec.key?('class') || job_spec.key?(:class)
142
+ job_spec['class'] = name
143
+ end
144
+ prepared_hash[name] = job_spec
145
+ end
146
+ prepared_hash
147
+ end
148
+ end
149
+ end
150
+ end