gemstack-jobs 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f76474daac16429de13f512235ca7eb314b8981ea0f700fedba334b5f8242efa
4
- data.tar.gz: 2791f654094f13f0e0c7745106f5bff32b0027be4a69e7c166c9a44c0ef31fcb
3
+ metadata.gz: '0802dfa203bae021ece72db2d1a796c66e7a3b34fd157aa731d1812ccdda10c4'
4
+ data.tar.gz: 9e8c843b6018c6a33b594f739e1cb985a54f0ca54e0aafafd855e16a79e4629f
5
5
  SHA512:
6
- metadata.gz: d7ccc72bc94799b9883013b5a1a2761dc9757f77290604a0aa210cdac0a3d62dd829564a38a1fa4a556451aa33202644dcb6255477612debfe4e36607a9c86e4
7
- data.tar.gz: a3a95da5e5ef21f12d292b38228e25ff9a9a561ef42179c155dfbe61b17c000421852b3d7970f705bfbcd7055681b96d619bf384020fab2f0b52f73bae05f99e
6
+ metadata.gz: 65afed0b493a60301ecc39b8750d6b9ba812b511873ce76dc6a634768e60fec703ff91da8b5d476da8d960f40ed39424820db0e0a357c0f6d68e011fad2c5261
7
+ data.tar.gz: e7de052e354fd15863d5a3df6cf7aa64f45abba4fb1b08a13141862d141376026547a7874303556ab3f7a28b1e60db70512d4c2fcff86463d3d335f37f15eed8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
+
3
7
  ## 0.1.0
4
8
 
5
9
  First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # gemstack-jobs
2
2
 
3
- GemStack background jobs: a PostgreSQL queue by default, swappable adapters.
3
+ GemStack background jobs: a queue in the app's database by default, swappable adapters.
4
4
 
5
5
  Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
6
  applications. All GemStack gems are developed together in that repository and released with the same
@@ -9,31 +9,37 @@ module GemStack
9
9
  SOURCE = <<~RUBY
10
10
  # frozen_string_literal: true
11
11
 
12
- # The GemStack job queue (docs/background-jobs.md). Workers claim rows with
13
- # FOR UPDATE SKIP LOCKED; finished jobs are deleted, exhausted ones keep failed_at.
12
+ # The GemStack job queue (docs/background-jobs.md), for PostgreSQL, MySQL and
13
+ # SQLite. Workers claim rows one at a time (FOR UPDATE SKIP LOCKED where the
14
+ # database has it); finished jobs are deleted, exhausted ones keep failed_at.
14
15
  Sequel.migration do
15
- change do
16
+ up do
17
+ partial = database_type != :mysql # MySQL has no partial indexes
18
+ ready = partial ? { where: Sequel.lit("failed_at IS NULL AND locked_at IS NULL") } : {}
16
19
  create_table(:gemstack_jobs) do
17
20
  primary_key :id, type: :Bignum
18
21
  String :queue, null: false, default: "default"
19
22
  Integer :priority, null: false, default: 100
20
23
  String :job_class, null: false
21
- column :args, :jsonb, null: false, default: Sequel.lit("'[]'::jsonb")
22
- column :run_at, :timestamptz, null: false, default: Sequel::CURRENT_TIMESTAMP
24
+ column :args, :jsonb, null: false # json on MySQL and SQLite
25
+ column :run_at, :timestamptz, null: false
23
26
  Integer :attempts, null: false, default: 0
24
27
  String :last_error, text: true
25
28
  column :locked_at, :timestamptz
26
29
  String :locked_by
27
30
  column :failed_at, :timestamptz
28
- column :created_at, :timestamptz, null: false, default: Sequel::CURRENT_TIMESTAMP
31
+ column :created_at, :timestamptz, null: false
29
32
 
30
33
  # The fetch query: ready jobs by queue, in priority/run_at order.
31
- index %i[queue priority run_at id], name: :gemstack_jobs_ready,
32
- where: Sequel.lit("failed_at IS NULL AND locked_at IS NULL")
33
- index :locked_at, name: :gemstack_jobs_locked, where: Sequel.lit("locked_at IS NOT NULL")
34
- index :failed_at, name: :gemstack_jobs_failed, where: Sequel.lit("failed_at IS NOT NULL")
34
+ index %i[queue priority run_at id], name: :gemstack_jobs_ready, **ready
35
+ index :locked_at, name: :gemstack_jobs_locked
36
+ index :failed_at, name: :gemstack_jobs_failed
35
37
  end
36
38
  end
39
+
40
+ down do
41
+ drop_table(:gemstack_jobs)
42
+ end
37
43
  end
38
44
  RUBY
39
45
 
@@ -44,15 +50,19 @@ module GemStack
44
50
  end
45
51
 
46
52
  module Adapters
47
- # The default adapter: jobs are rows in PostgreSQL (DECISIONS D-040).
53
+ # The default adapter: jobs are rows in the application's database
54
+ # (DECISIONS D-040, D-063) — PostgreSQL, MySQL or SQLite.
48
55
  #
49
56
  # - Enqueueing is an INSERT on the current connection, so inside
50
57
  # GemStack.transaction a job exists only if the transaction commits.
51
- # - NOTIFY (also transactional) wakes idle workers immediately; polling
52
- # every config.jobs.poll_interval is the safety net.
53
- # - Workers claim one job at a time with FOR UPDATE SKIP LOCKED, so
54
- # workers never block each other or run the same job twice at once.
55
- class Postgres
58
+ # - Workers claim one job at a time in a short transaction: with
59
+ # FOR UPDATE SKIP LOCKED on PostgreSQL and MySQL 8, so workers never
60
+ # block each other; on SQLite by taking the write lock up front.
61
+ # - PostgreSQL also NOTIFYs idle workers at once; elsewhere (and as a
62
+ # safety net) workers poll every config.jobs.poll_interval.
63
+ # - Times are set from Ruby, in UTC, so the database clock and time zone
64
+ # never matter.
65
+ class Database
56
66
  CHANNEL = "gemstack_jobs"
57
67
 
58
68
  def initialize(db: nil, table: Jobs.config.table)
@@ -68,25 +78,30 @@ module GemStack
68
78
  end
69
79
 
70
80
  def dataset = db[@table]
81
+ def postgres? = db.database_type == :postgres
71
82
 
72
83
  def enqueue(payload)
73
- id = dataset.insert(
74
- job_class: payload["job_class"], queue: payload["queue"], priority: payload["priority"],
75
- args: Sequel.pg_jsonb_wrap(payload["args"]), run_at: payload["run_at"] || Sequel::CURRENT_TIMESTAMP
76
- )
77
- db.notify(CHANNEL, payload: payload["queue"])
84
+ now = Time.now
85
+ args = postgres? ? Sequel.pg_jsonb_wrap(payload["args"]) : JSON.generate(payload["args"])
86
+ id = dataset.insert(job_class: payload["job_class"], queue: payload["queue"], priority: payload["priority"],
87
+ args: args, run_at: payload["run_at"] || now, created_at: now)
88
+ db.notify(CHANNEL, payload: payload["queue"]) if postgres?
78
89
  id
79
90
  end
80
91
 
81
92
  # Claims the next ready job for these queues ("*" = all). Returns a
82
93
  # payload Hash or nil.
83
94
  def claim(queues, worker)
84
- ready = dataset.where(failed_at: nil, locked_at: nil).where { run_at <= Sequel::CURRENT_TIMESTAMP }
85
- ready = ready.where(queue: queues) unless queues.include?("*")
86
- next_id = ready.order(:priority, :run_at, :id).limit(1).for_update.skip_locked.select(:id)
87
- row = dataset.where(id: next_id).returning(*returned_columns)
88
- .update(locked_at: Sequel::CURRENT_TIMESTAMP, locked_by: worker).first
89
- row && payload_for(row)
95
+ now = Time.now
96
+ db.transaction(**claim_options) do
97
+ ready = dataset.where(failed_at: nil, locked_at: nil).where { run_at <= now }
98
+ ready = ready.where(queue: queues) unless queues.include?("*")
99
+ ready = ready.order(:priority, :run_at, :id).limit(1)
100
+ ready = ready.for_update.skip_locked if ready.supports_skip_locked?
101
+ row = ready.select(*returned_columns).first
102
+ dataset.where(id: row[:id]).update(locked_at: now, locked_by: worker) if row
103
+ row && payload_for(row)
104
+ end
90
105
  end
91
106
 
92
107
  def complete(id) = dataset.where(id: id).delete
@@ -99,7 +114,7 @@ module GemStack
99
114
  def fail(id, attempts:, error:)
100
115
  return complete(id) unless Jobs.config.keep_failed
101
116
 
102
- dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at: Sequel::CURRENT_TIMESTAMP,
117
+ dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at: Time.now,
103
118
  attempts: attempts, last_error: describe(error))
104
119
  end
105
120
 
@@ -108,7 +123,7 @@ module GemStack
108
123
 
109
124
  # Releases jobs locked longer than `timeout` seconds (their worker died).
110
125
  def release_stale(timeout)
111
- cutoff = Sequel.lit("CURRENT_TIMESTAMP - make_interval(secs => ?)", Float(timeout))
126
+ cutoff = Time.now - Float(timeout)
112
127
  dataset.where { locked_at < cutoff }.update(locked_at: nil, locked_by: nil)
113
128
  end
114
129
 
@@ -116,7 +131,7 @@ module GemStack
116
131
  def retry_failed(ids = nil)
117
132
  failed = dataset.exclude(failed_at: nil)
118
133
  failed = failed.where(id: ids) if ids
119
- failed.update(failed_at: nil, attempts: 0, run_at: Sequel::CURRENT_TIMESTAMP, last_error: nil)
134
+ failed.update(failed_at: nil, attempts: 0, run_at: Time.now, last_error: nil)
120
135
  end
121
136
 
122
137
  def discard_failed(ids = nil)
@@ -132,7 +147,7 @@ module GemStack
132
147
 
133
148
  # { "default" => { ready:, scheduled:, running:, failed: }, ... }
134
149
  def stats
135
- now = Sequel::CURRENT_TIMESTAMP
150
+ now = Time.now
136
151
  states = Sequel.case(
137
152
  [[Sequel.~(failed_at: nil), "failed"], [Sequel.~(locked_at: nil), "running"],
138
153
  [Sequel[:run_at] > now, "scheduled"]], "ready"
@@ -145,22 +160,29 @@ module GemStack
145
160
 
146
161
  private
147
162
 
163
+ # SQLite: take the write lock when the transaction starts, so two
164
+ # workers can't both read the same ready row.
165
+ def claim_options = db.database_type == :sqlite ? { mode: :immediate } : {}
166
+
148
167
  # args as text, parsed with the json gem: jobs receive plain Hash/Array
149
168
  # values rather than Sequel's JSONB wrappers. (Lazy: Sequel may not be loaded.)
150
169
  def returned_columns
151
170
  @returned_columns ||= [:id, :job_class, :queue, :priority, :attempts,
152
- Sequel.cast(:args, :text).as(:args_json)].freeze
171
+ postgres? ? Sequel.cast(:args, :text).as(:args_json) : Sequel.as(:args, :args_json)]
153
172
  end
154
173
 
155
174
  def payload_for(row)
156
175
  { "id" => row[:id], "job_class" => row[:job_class], "queue" => row[:queue],
157
- "priority" => row[:priority], "args" => JSON.parse(row[:args_json]), "attempts" => row[:attempts] }
176
+ "priority" => row[:priority], "args" => JSON.parse(row[:args_json].to_s), "attempts" => row[:attempts] }
158
177
  end
159
178
 
160
179
  def describe(error)
161
180
  "#{error.class}: #{error.message}\n#{Array(error.backtrace).first(20).join("\n")}"[0, 10_000]
162
181
  end
163
182
  end
183
+
184
+ # The adapter's name before it supported MySQL and SQLite; `adapter: :postgres` still works.
185
+ Postgres = Database
164
186
  end
165
187
  end
166
188
  end
@@ -18,13 +18,13 @@ module GemStack
18
18
  class Worker
19
19
  attr_reader :id
20
20
 
21
- def initialize(store: Adapters::Postgres.new, queues: Jobs.config.queues, concurrency: Jobs.config.concurrency,
21
+ def initialize(store: Adapters::Database.new, queues: Jobs.config.queues, concurrency: Jobs.config.concurrency,
22
22
  poll_interval: Jobs.config.poll_interval, lock_timeout: Jobs.config.lock_timeout,
23
23
  shutdown_timeout: Jobs.config.shutdown_timeout)
24
24
  @store = store
25
25
  @queues = Array(queues).map(&:to_s)
26
26
  @concurrency = concurrency
27
- @poll_interval = poll_interval
27
+ @poll_interval = poll_interval || (store.respond_to?(:postgres?) && store.postgres? ? 5 : 1)
28
28
  @lock_timeout = lock_timeout
29
29
  @shutdown_timeout = shutdown_timeout
30
30
  @id = "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(3)}"
@@ -41,7 +41,7 @@ module GemStack
41
41
  @running = true
42
42
  GemStack.logger.info("jobs worker started", worker: @id, queues: @queues.join(","), concurrency: @concurrency)
43
43
  @threads = Array.new(@concurrency) { |i| Thread.new { work_loop(i) } }
44
- @listener = Thread.new { listen_loop }
44
+ @listener = Thread.new { listen_loop } if @store.respond_to?(:postgres?) && @store.postgres?
45
45
  @reaper = Thread.new { reap_loop }
46
46
  self
47
47
  end
@@ -112,7 +112,7 @@ module GemStack
112
112
  end
113
113
 
114
114
  def listen_loop
115
- @store.db.listen(Adapters::Postgres::CHANNEL, loop: ->(_conn) { throw :stop unless @running },
115
+ @store.db.listen(Adapters::Database::CHANNEL, loop: ->(_conn) { throw :stop unless @running },
116
116
  timeout: @poll_interval) { wake }
117
117
  rescue Sequel::DatabaseConnectionError => e
118
118
  GemStack.logger.warn("jobs worker: LISTEN failed, falling back to polling", error: e.message)
data/lib/gemstack/jobs.rb CHANGED
@@ -20,12 +20,13 @@ module GemStack
20
20
  # Delivery is at-least-once: make perform idempotent.
21
21
  module Jobs
22
22
  class Config < Settings
23
- # :postgres (default with gemstack-db), :async (in-process threads),
23
+ # :database (default with gemstack-db: the app's PostgreSQL, MySQL or
24
+ # SQLite; :postgres is an alias), :async (in-process threads),
24
25
  # :inline (run immediately), :test (record only; default in tests),
25
26
  # :sidekiq, or an adapter object responding to #enqueue(payload).
26
27
  setting :adapter, default: lambda {
27
28
  if GemStack.env.test? then :test
28
- elsif defined?(GemStack::DB) then :postgres
29
+ elsif defined?(GemStack::DB) then :database
29
30
  else :async
30
31
  end
31
32
  }
@@ -35,8 +36,9 @@ module GemStack
35
36
  # Worker settings (`gemstack jobs`).
36
37
  setting :queues, default: -> { ENV.fetch("GEMSTACK_JOB_QUEUES", "*").split(",").map(&:strip) }
37
38
  setting :concurrency, default: -> { Integer(ENV.fetch("GEMSTACK_JOB_CONCURRENCY", 5)) }
38
- # Seconds between polls when no NOTIFY arrives (a safety net; NOTIFY wakes workers instantly).
39
- setting :poll_interval, default: 5
39
+ # Seconds between polls. PostgreSQL NOTIFY wakes workers instantly, so
40
+ # there polling is only a safety net (5 s); MySQL and SQLite rely on it (1 s).
41
+ setting :poll_interval, default: nil
40
42
  # A job locked longer than this is assumed abandoned (worker crashed) and is released.
41
43
  # Jobs that legitimately run longer must raise it.
42
44
  setting :lock_timeout, default: 30 * 60
@@ -72,7 +74,7 @@ module GemStack
72
74
 
73
75
  def build_adapter(setting)
74
76
  case setting
75
- when :postgres, "postgres" then Adapters::Postgres.new
77
+ when :database, "database", :postgres, "postgres" then Adapters::Database.new
76
78
  when :async, "async" then Adapters::Async.new
77
79
  when :inline, "inline" then Adapters::Inline.new
78
80
  when :test, "test" then Adapters::Test.new
@@ -84,6 +86,9 @@ module GemStack
84
86
  end
85
87
  end
86
88
 
89
+ # True when jobs are rows in the application's database (and need a worker).
90
+ def database_queue?(setting = config.adapter) = %w[database postgres].include?(setting.to_s)
91
+
87
92
  # Instrumentation for metrics/monitoring:
88
93
  # GemStack::Jobs.subscribe(:failed) { |event| Sentry.capture_message(...) }
89
94
  # Events: :enqueued, :performed, :retried, :failed, :discarded.
@@ -133,7 +138,7 @@ require_relative "jobs/executor"
133
138
  require_relative "jobs/adapters/inline"
134
139
  require_relative "jobs/adapters/test"
135
140
  require_relative "jobs/adapters/async"
136
- require_relative "jobs/adapters/postgres"
141
+ require_relative "jobs/adapters/database"
137
142
  require_relative "jobs/adapters/sidekiq"
138
143
 
139
144
  GemStack::Config.namespace(:jobs, GemStack::Jobs::Config)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstack-jobs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shoaib Malik
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0
18
+ version: 0.2.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.1.0
25
+ version: 0.2.0
26
26
  email:
27
27
  - gemstack26@gmail.com
28
28
  executables: []
@@ -35,8 +35,8 @@ files:
35
35
  - lib/gemstack/job.rb
36
36
  - lib/gemstack/jobs.rb
37
37
  - lib/gemstack/jobs/adapters/async.rb
38
+ - lib/gemstack/jobs/adapters/database.rb
38
39
  - lib/gemstack/jobs/adapters/inline.rb
39
- - lib/gemstack/jobs/adapters/postgres.rb
40
40
  - lib/gemstack/jobs/adapters/sidekiq.rb
41
41
  - lib/gemstack/jobs/adapters/test.rb
42
42
  - lib/gemstack/jobs/executor.rb
@@ -67,5 +67,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
67
  requirements: []
68
68
  rubygems_version: 4.0.20
69
69
  specification_version: 4
70
- summary: 'GemStack background jobs: a PostgreSQL queue by default, swappable adapters'
70
+ summary: 'GemStack background jobs: a queue in the app''s database by default, swappable
71
+ adapters'
71
72
  test_files: []