stampeded 0.3.1

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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stampeded
4
+ module QueuePolicy
5
+ module_function
6
+
7
+ def set(queue, max_running: nil, max_wait: nil)
8
+ raise Error, "a queue policy needs a queue name" if queue.to_s.strip.empty?
9
+
10
+ Connection.translating_errors do
11
+ Connection.with do |c|
12
+ c.exec_params(<<~SQL, [queue.to_s, max_running&.to_i, max_wait&.to_i])
13
+ INSERT INTO stampeded_queue_policies (queue, max_running, max_wait_secs)
14
+ VALUES ($1, $2, $3)
15
+ ON CONFLICT (queue) DO UPDATE SET
16
+ max_running = EXCLUDED.max_running, max_wait_secs = EXCLUDED.max_wait_secs,
17
+ updated_at = now()
18
+ SQL
19
+ end
20
+ end
21
+ queue.to_s
22
+ end
23
+
24
+ def remove(queue)
25
+ Connection.with { |c| c.exec_params("DELETE FROM stampeded_queue_policies WHERE queue = $1", [queue.to_s]) }
26
+ end
27
+
28
+ def all
29
+ Connection.translating_errors do
30
+ Connection.with do |c|
31
+ c.exec(<<~SQL).to_a
32
+ SELECT queue, max_running, max_wait_secs, verdict, reason, decided_at
33
+ FROM stampeded_queue_policies ORDER BY queue
34
+ SQL
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Stampeded
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("tasks/stampeded.rake", __dir__)
9
+ end
10
+
11
+ initializer "stampeded.active_job", before: "active_job.set_configs" do
12
+ ActiveSupport.on_load(:active_job) do
13
+ require "active_job/queue_adapters/stampeded_adapter"
14
+ require "stampeded/active_job_ext"
15
+ require "stampeded/active_job_worker"
16
+ include Stampeded::ActiveJobExt
17
+ end
18
+ end
19
+
20
+ initializer "stampeded.config" do
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Stampeded
6
+ module Schedule
7
+ module_function
8
+
9
+ def recurring(key:, job:, cron:, args: [], queue: nil, timezone: "UTC",
10
+ dst_policy: nil, priority: nil)
11
+ raise Error, "a schedule needs a key" if key.to_s.strip.empty?
12
+
13
+ suggested = check_cron!(cron, timezone)
14
+ dst_policy ||= suggested
15
+ job_class = job.is_a?(Class) ? job : job.class
16
+ queue ||= resolve_queue_name(job_class) || Stampeded.config.default_queue
17
+
18
+ task, standard_args, meta = envelope(job_class, args, queue, priority)
19
+
20
+ Connection.with do |c|
21
+ c.exec_params(<<~SQL, [key, queue, task, JSON.generate(standard_args), JSON.generate(meta), cron, timezone, dst_policy, priority])
22
+ INSERT INTO stampeded_schedules
23
+ (key, queue, task, args, meta, cron_expr, timezone, dst_policy, priority, next_run_at)
24
+ VALUES ($1, $2, $3, $4, $5, $6, $7,
25
+ $8, coalesce($9, 0), now())
26
+ ON CONFLICT (key) DO UPDATE SET
27
+ queue = EXCLUDED.queue, task = EXCLUDED.task, args = EXCLUDED.args, meta = EXCLUDED.meta,
28
+ cron_expr = EXCLUDED.cron_expr, interval_secs = NULL,
29
+ timezone = EXCLUDED.timezone, dst_policy = EXCLUDED.dst_policy,
30
+ priority = EXCLUDED.priority, updated_at = now()
31
+ SQL
32
+ end
33
+ key
34
+ end
35
+
36
+ def remove(key)
37
+ Connection.with { |c| c.exec_params("DELETE FROM stampeded_schedules WHERE key = $1", [key]) }
38
+ end
39
+
40
+ def pause(key, paused: true)
41
+ Connection.with do |c|
42
+ c.exec_params("UPDATE stampeded_schedules SET paused = $2, updated_at = now() WHERE key = $1",
43
+ [key, paused])
44
+ end
45
+ end
46
+
47
+ def all
48
+ Connection.with do |c|
49
+ c.exec("SELECT key, queue, task, cron_expr, timezone, dst_policy, paused, next_run_at, last_run_at
50
+ FROM stampeded_schedules ORDER BY next_run_at").to_a
51
+ end
52
+ end
53
+
54
+ def check_cron!(expr, timezone)
55
+ require "stampeded/native"
56
+ Native.cron_check(expr, timezone)
57
+ rescue Error => e
58
+ raise Error, e.message unless e.message.include?("cron")
59
+
60
+ raise Error, <<~MSG
61
+ #{expr.inspect}: #{e.message}
62
+
63
+ Five fields are required: minute hour day-of-month month day-of-week
64
+
65
+ "0 3 * * *" every day at 03:00
66
+ "*/15 * * * *" every fifteen minutes
67
+ "0 9-17 * * mon-fri"
68
+ MSG
69
+ end
70
+
71
+ def resolve_queue_name(job_class)
72
+ return nil unless job_class.respond_to?(:queue_name)
73
+
74
+ name = job_class.queue_name
75
+ name = job_class.new.queue_name if name.respond_to?(:call)
76
+ name&.to_s
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ def envelope(job_class, args, queue, priority)
82
+ task = job_class.try(:stampeded_task)
83
+ context = {
84
+ "job_class" => job_class.name,
85
+ "queue_name" => queue,
86
+ "priority" => priority,
87
+ "locale" => defined?(I18n) ? I18n.locale.to_s : "en",
88
+ "timezone" => defined?(Time.zone) && Time.zone ? Time.zone.name : "UTC"
89
+ }
90
+
91
+ kwargs = args.is_a?(Hash) ? args : ({} if Array(args).empty?)
92
+ if kwargs && Envelope.plain?(kwargs)
93
+ [task || job_class.name, JSON.parse(JSON.generate(kwargs)), { "activejob" => context }]
94
+ elsif task
95
+ raise PortableArgumentsError.new(job_class.name, task)
96
+ else
97
+ context["arguments"] = ActiveJob::Arguments.serialize(Array(args))
98
+ [job_class.name, {}, { "activejob" => context }]
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stampeded
4
+ module Schema
5
+ FILES = %w[schema.sql].freeze
6
+
7
+ module_function
8
+
9
+ def root
10
+ candidates = [
11
+ File.expand_path("../../schema", __dir__),
12
+ File.expand_path("../../../schema", __dir__)
13
+ ]
14
+ candidates.find { |d| File.directory?(d) } ||
15
+ raise(Error, "cannot find schema/*.sql (looked in #{candidates.join(", ")})")
16
+ end
17
+
18
+ def sql
19
+ FILES.map { |f| File.read(File.join(root, f)) }.join("\n")
20
+ end
21
+
22
+ def install!(conn = nil)
23
+ run = lambda do |c|
24
+ c.exec("BEGIN")
25
+ begin
26
+ c.exec(sql)
27
+ c.exec("COMMIT")
28
+ rescue StandardError
29
+ c.exec("ROLLBACK")
30
+ raise
31
+ end
32
+ end
33
+
34
+ conn ? run.call(conn) : Connection.with(&run)
35
+ end
36
+
37
+ def drop!(conn = nil)
38
+ run = lambda do |c|
39
+ c.exec(<<~SQL)
40
+ DROP TABLE IF EXISTS stampeded_cron_occurrences, stampeded_schedules,
41
+ stampeded_queue_policies, stampeded_queue_counts,
42
+ stampeded_jobs, stampeded_workers CASCADE;
43
+ DO $$
44
+ DECLARE r record;
45
+ BEGIN
46
+ FOR r IN
47
+ SELECT p.oid::regprocedure AS sig
48
+ FROM pg_proc p
49
+ JOIN pg_namespace n ON n.oid = p.pronamespace
50
+ WHERE p.proname LIKE 'stampeded\_%'
51
+ AND n.nspname = current_schema()
52
+ LOOP
53
+ EXECUTE 'DROP FUNCTION IF EXISTS ' || r.sig || ' CASCADE';
54
+ END LOOP;
55
+ END $$;
56
+ SQL
57
+ end
58
+ conn ? run.call(conn) : Connection.with(&run)
59
+ end
60
+
61
+ def installed?(conn = nil)
62
+ check = lambda do |c|
63
+ Stampeded.truthy?(c.exec("SELECT to_regclass('stampeded_jobs') IS NOT NULL AS ok")[0]["ok"])
64
+ end
65
+ conn ? check.call(conn) : Connection.with(&check)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ module Stampeded
7
+ class Steps
8
+ class DuplicateStep < Error; end
9
+ class ResultTooLarge < Error; end
10
+
11
+ def initialize(context)
12
+ require "stampeded/native"
13
+ @ctx = context
14
+ @record = Native::Steps.new(context.checkpoint)
15
+ @seen = Set.new
16
+ end
17
+
18
+ def step(name, cursor: nil, &block)
19
+ name = name.to_s
20
+ raise DuplicateStep, "step #{name.inspect} is declared twice in this job" unless @seen.add?(name)
21
+ return @record.value(name) if @record.completed?(name)
22
+
23
+ result = cursor.nil? ? block.call : run_cursor_step(name, cursor, &block)
24
+ complete(name, result)
25
+ persist!
26
+ result
27
+ end
28
+
29
+ def completed?(name) = @record.completed?(name)
30
+
31
+ def completed_steps = @record.to_h["completed"]
32
+
33
+ private
34
+
35
+ def run_cursor_step(name, initial, &block)
36
+ saved = @record.cursor(name)
37
+ block.call(Cursor.new(saved.nil? ? initial : saved, self, name))
38
+ end
39
+
40
+ def advance(name, value)
41
+ @record.set_cursor(name, JSON.generate(value))
42
+ persist!
43
+ end
44
+
45
+ def complete(name, result)
46
+ json =
47
+ begin
48
+ result.nil? ? nil : JSON.generate(result)
49
+ rescue JSON::GeneratorError => e
50
+ raise ResultTooLarge, "step #{name.inspect} returned something not JSON-serialisable: #{e.message}"
51
+ end
52
+ @record.complete(name, json)
53
+ rescue ResultTooLarge
54
+ raise
55
+ rescue Error => e
56
+ raise ResultTooLarge, e.message
57
+ end
58
+
59
+ def persist! = @ctx.checkpoint!(@record.to_h)
60
+
61
+ class Cursor
62
+ attr_reader :value
63
+
64
+ alias cursor value
65
+
66
+ def initialize(value, steps, name)
67
+ @value = value
68
+ @steps = steps
69
+ @name = name
70
+ @since_write = 0
71
+ end
72
+
73
+ def advance!(value, every: 1)
74
+ @value = value
75
+ @since_write += 1
76
+ return value if every > 1 && @since_write < every
77
+
78
+ @since_write = 0
79
+ @steps.send(:advance, @name, value)
80
+ value
81
+ end
82
+
83
+ def flush!
84
+ return @value if @since_write.zero?
85
+
86
+ @since_write = 0
87
+ @steps.send(:advance, @name, @value)
88
+ @value
89
+ end
90
+
91
+ def to_s = @value.to_s
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :stampeded do
4
+ desc "Install the Stampeded schema (Postgres 18+)"
5
+ task install: :environment do
6
+ require "stampeded"
7
+ Stampeded::Schema.install!
8
+ puts "schema installed"
9
+ end
10
+
11
+ desc "Run an ActiveJob worker (QUEUES=default,mail or QUEUES=* QUEUES_EXCEPT=dlq; CONCURRENCY=5)"
12
+ task work: :environment do
13
+ require "stampeded/active_job_worker"
14
+ Rails.application.eager_load! unless Rails.application.config.eager_load
15
+
16
+ worker = Stampeded::ActiveJobWorker.new(
17
+ queues: ENV["QUEUES"] || ENV.fetch("QUEUE", nil),
18
+ except: ENV.fetch("QUEUES_EXCEPT", nil),
19
+ concurrency: ENV.fetch("CONCURRENCY", Stampeded.config.concurrency).to_i
20
+ )
21
+ puts "stampeded worker: queues=#{worker.all_queues? ? "*" : worker.queues.join(",")} pid=#{Process.pid}"
22
+ worker.run
23
+ end
24
+
25
+ desc "Run the daemon (scheduler, reaper, pruner, dashboard). Not the worker."
26
+ task daemon: :environment do
27
+ require "stampeded/daemon"
28
+ args = ["--database-url", Stampeded.database_url_for_daemon, "--no-worker"]
29
+ args += ENV["STAMPEDED_ARGS"].to_s.split
30
+ Stampeded::Daemon.exec!(*args)
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stampeded
4
+ VERSION = "0.3.1" # x-release-please-version
5
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Stampeded
6
+ class Worker
7
+ attr_reader :queues, :handlers
8
+
9
+ def initialize(queue: nil, queues: nil, except: nil, concurrency: nil, batch_size: nil, poll_interval: nil)
10
+ requested = queues || queue || Stampeded.config.queues || Stampeded.config.default_queue
11
+ @all_queues = wildcard?(requested)
12
+ raise ArgumentError, "except: applies only to queues: :all" if except && !@all_queues
13
+
14
+ @except = @all_queues ? Array(except || Stampeded.config.except_queues).flat_map { it.to_s.split(",") }.map(&:strip) : []
15
+ @queues = @all_queues ? [] : queue_list!(requested)
16
+ @concurrency = positive!(concurrency || Stampeded.config.concurrency, "concurrency")
17
+ @batch_size = positive!(batch_size || Stampeded.config.batch_size, "batch_size")
18
+ @poll_interval = poll_interval || Stampeded.config.poll_interval
19
+ @handlers = {}
20
+ @stopping = false
21
+ @wake_reader, @wake_writer = IO.pipe
22
+ end
23
+
24
+ def queue = @queues.first
25
+
26
+ def all_queues? = @all_queues
27
+ def register(task, &block)
28
+ @handlers[task.to_s] = block
29
+ self
30
+ end
31
+
32
+ def stop
33
+ @stopping = true
34
+ Stampeded.stopping = true
35
+ @wake_writer.write_nonblock(".", exception: false)
36
+ rescue IOError
37
+ nil
38
+ end
39
+
40
+ def stopping? = @stopping
41
+ def run
42
+ require "stampeded/native"
43
+ Native.log_init(library_log_level)
44
+ Stampeded.stopping = false
45
+
46
+ puller = Connection.translating_errors { start_puller }
47
+ log "registered #{puller.worker_id} on #{description}"
48
+
49
+ @threads = Array.new(@concurrency) { worker_thread(puller) }
50
+ install_signal_handlers
51
+ @wake_reader.wait_readable until stopping?
52
+
53
+ deadline = now_monotonic + Stampeded.config.shutdown_timeout
54
+ puller.stop(Stampeded.config.shutdown_timeout)
55
+ stopped = true
56
+ @threads.each { |t| t.join([deadline - now_monotonic, 0].max) }
57
+
58
+ stragglers = @threads.count(&:alive?)
59
+ if stragglers.positive?
60
+ log "#{stragglers} job(s) did not finish in #{Stampeded.config.shutdown_timeout}s; " \
61
+ "the reaper will reclaim them"
62
+ end
63
+ log "stopped"
64
+ ensure
65
+ @stopping = true
66
+ begin
67
+ puller.stop(0) if puller && !stopped
68
+ rescue Error => e
69
+ log "could not stop: #{e.message}"
70
+ end
71
+ @threads&.each { |t| t.join(@poll_interval * 2) }
72
+ puller&.close if @threads.nil? || @threads.none?(&:alive?)
73
+ @wake_reader.close
74
+ @wake_writer.close
75
+ end
76
+
77
+ private
78
+
79
+ def start_puller
80
+ Native::Puller.new(
81
+ url: Connection.url,
82
+ queues: @all_queues ? "*" : @queues.join(","),
83
+ except: @except.any? ? @except.join(",") : nil,
84
+ max_leases: @concurrency,
85
+ batch_size: @batch_size,
86
+ poll_interval: @poll_interval,
87
+ heartbeat_interval: Stampeded.config.heartbeat_interval
88
+ )
89
+ rescue Error => e
90
+ raise SchemaMissing, e if e.message.include?("stampeded_") && e.message.include?("does not exist")
91
+
92
+ raise
93
+ end
94
+
95
+ def description
96
+ return @queues.join(", ") unless @all_queues
97
+
98
+ "every queue#{" except #{@except.join(", ")}" if @except.any?}"
99
+ end
100
+
101
+ def worker_thread(puller)
102
+ Thread.new do
103
+ until stopping?
104
+ lease =
105
+ begin
106
+ puller.next(@poll_interval)
107
+ rescue Error => e
108
+ log "claim failed: #{e.message}"
109
+ sleep @poll_interval
110
+ next
111
+ end
112
+ execute(lease, puller.worker_id) if lease
113
+ end
114
+ end
115
+ end
116
+
117
+ def execute(lease, worker_id)
118
+ job = Job.new(lease.row, decoded: true)
119
+ started = now_monotonic
120
+ error = nil
121
+
122
+ begin
123
+ handler = @handlers[job.task]
124
+ raise UnknownTask, "no handler registered for task #{job.task.inspect}" if handler.nil?
125
+
126
+ with_job_timeout(job) { handler.call(JobContext.new(job, worker_id)) }
127
+ rescue Exception => e
128
+ error = e
129
+ end
130
+
131
+ report(lease, error)
132
+ log format("%s %s in %.3fs", job.task, job.id, now_monotonic - started)
133
+ raise error if error.is_a?(SignalException) || error.is_a?(SystemExit)
134
+ ensure
135
+ lease.release!
136
+ end
137
+
138
+ def report(lease, error)
139
+ return lease.requeue! if error.is_a?(Requeue)
140
+
141
+ message = error && error.message.to_s[0, 4000]
142
+ case error && failure_kind_for(error)
143
+ when nil then lease.complete!
144
+ when "unrecoverable" then lease.discard!(message)
145
+ when "timed_out" then lease.timed_out!(message)
146
+ else lease.fail!(message, retry_in_ms: error.is_a?(RetryIn) ? error.delay_ms : nil)
147
+ end
148
+ rescue Error => e
149
+ log "could not report #{lease.row["id"]}: #{e.message}"
150
+ end
151
+
152
+ def with_job_timeout(job, &block)
153
+ limit = Stampeded.config.job_timeout
154
+ return yield if limit.nil? || limit <= 0
155
+
156
+ expire = (job.row["expire_seconds"] || 3600).to_i
157
+ if limit >= expire
158
+ log "job_timeout (#{limit}s) is not shorter than #{job.task}'s expire_seconds (#{expire}s): it may run twice"
159
+ end
160
+
161
+ Timeout.timeout(limit, Timeout::Error, "job exceeded job_timeout of #{limit}s", &block)
162
+ end
163
+
164
+ def failure_kind_for(error)
165
+ case error
166
+ when UnknownTask then "unrecoverable"
167
+ when Timeout::Error then "timed_out"
168
+ else "raised"
169
+ end
170
+ end
171
+
172
+ def wildcard?(value)
173
+ return true if value == :all || value.to_s.strip == "*"
174
+
175
+ names = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.split(",")
176
+ raise ArgumentError, "\"*\" cannot be mixed with queue names; use except: to leave queues out" if names.map(&:strip).include?("*")
177
+
178
+ false
179
+ end
180
+
181
+ def queue_list!(value)
182
+ names = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.split(",", -1)
183
+ names = names.map(&:strip)
184
+ raise ArgumentError, "queues must name at least one queue, got #{value.inspect}" if names.empty?
185
+ raise ArgumentError, "queues has a blank entry: #{value.inspect}" if names.any?(&:empty?)
186
+
187
+ names.uniq
188
+ end
189
+
190
+ def positive!(value, name)
191
+ n = value.to_i
192
+ raise ArgumentError, "#{name} must be at least 1, got #{value.inspect}" if n < 1
193
+
194
+ n
195
+ end
196
+
197
+ def install_signal_handlers
198
+ %w[INT TERM].each do |sig|
199
+ trap(sig) { stop }
200
+ rescue ArgumentError
201
+ nil
202
+ end
203
+ end
204
+
205
+ def now_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+
207
+ def log? = $stdout.tty? || ENV.key?("STAMPEDED_LOG")
208
+
209
+ LOG_LEVELS = %w[trace debug info warn error].freeze
210
+
211
+ def library_log_level
212
+ level = ENV.fetch("STAMPEDED_LOG", nil)&.downcase
213
+ return level if LOG_LEVELS.include?(level)
214
+
215
+ log? ? "info" : "warn"
216
+ end
217
+
218
+ def log(message)
219
+ return unless log?
220
+
221
+ warn "[stampeded] #{message}"
222
+ end
223
+ end
224
+ end
data/lib/stampeded.rb ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stampeded/version"
4
+ require_relative "stampeded/errors"
5
+ require_relative "stampeded/configuration"
6
+ require_relative "stampeded/connection"
7
+ require "forwardable"
8
+ require_relative "stampeded/lifecycle"
9
+ require_relative "stampeded/job"
10
+ require_relative "stampeded/job_context"
11
+ require_relative "stampeded/current"
12
+ require_relative "stampeded/steps"
13
+ require_relative "stampeded/client"
14
+ require_relative "stampeded/envelope"
15
+ require_relative "stampeded/schema"
16
+ require_relative "stampeded/schedule"
17
+ require_relative "stampeded/queue_policy"
18
+ require_relative "stampeded/worker"
19
+ require_relative "stampeded/daemon"
20
+
21
+ module Stampeded
22
+ autoload :ActiveJobWorker, File.expand_path("stampeded/active_job_worker", __dir__)
23
+ end
24
+
25
+ require_relative "stampeded/railtie" if defined?(Rails::Railtie)
26
+
27
+ module Stampeded
28
+ def self.truthy?(value)
29
+ value == true || value == "t" || value == "true"
30
+ end
31
+
32
+ class << self
33
+ def config
34
+ @config ||= Configuration.new
35
+ end
36
+
37
+ def configure
38
+ yield config
39
+ end
40
+
41
+ attr_writer :stopping
42
+
43
+ def stopping? = @stopping == true
44
+
45
+ def enqueue(queue:, task:, **opts)
46
+ Client.enqueue(queue: queue, task: task, **opts)
47
+ end
48
+
49
+ def database_url_for_daemon
50
+ params = Connection.connection_params
51
+ return params if params.is_a?(String)
52
+
53
+ user = [params[:user], params[:password]].compact.join(":")
54
+ host = [params[:host], params[:port]].compact.join(":")
55
+ "postgres://#{user}#{"@" unless user.empty?}#{host}/#{params[:dbname]}"
56
+ end
57
+
58
+ def create_queue(name)
59
+ Connection.with { |c| c.exec_params("SELECT stampeded_create_queue($1)", [name]) }
60
+ name
61
+ end
62
+
63
+ def depth(queue)
64
+ Connection.with do |c|
65
+ c.exec_params(
66
+ "SELECT state, n FROM stampeded_queue_counts WHERE queue = $1 AND n <> 0 ORDER BY state",
67
+ [queue]
68
+ ).each_with_object({}) { |r, h| h[r["state"]] = r["n"].to_i }
69
+ end
70
+ end
71
+ end
72
+ end