gemstack-jobs 0.2.5 → 0.3.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: 2a9ea7625bbd943d5ac2dbbd667891ac7cc4dc8996b1e6e33b6a69293c26cad5
4
- data.tar.gz: 0e61406bf477845383fae879f02e10644b19fcc0a4a07bc5f98c53405869264d
3
+ metadata.gz: 571a14f3229d36bcbe59738ded3e3a567c2256bba62c0574d42b3ce3ffcda90f
4
+ data.tar.gz: 24613a20c5bd70c2dd3b26f3f8a8c02b184bb97caef9451be425d8db0f929aa4
5
5
  SHA512:
6
- metadata.gz: a63cf76507048995846a0686d2c279617552bfd9cad934c4d958ab7333235b328294af5005b10be990440d0f53c1f8032aebfb08d98eda7a35dc4166d8d527a7
7
- data.tar.gz: 564e3e47843614f45c88695a6affcb6ec5c457112fff16d2037cc1552b12a2975177f024acf450b633cc4305967b7b73dfcc80ccd08343e393cff3e8c233132c
6
+ metadata.gz: 8ac25db8af60e5e26415285882a4835795b295abafae84703a7c7d5795605886c33214268292c22504ac64ee9096fd131919d0a3134ee4973d86994af72f6f86
7
+ data.tar.gz: 6caaa45b5f641271fab6d935e6fb159d067a0eccdcff5fb9b1cc508aafd92a4f32a4b41d60bec8a9621828440d3dd97451001b64b51da43cfa2ef560cf1da1b2
data/CHANGELOG.md CHANGED
@@ -1,29 +1,5 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.5
3
+ ## 0.3.0
4
4
 
5
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
-
7
- ## 0.2.4
8
-
9
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
10
-
11
- ## 0.2.3
12
-
13
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
14
-
15
- ## 0.2.2
16
-
17
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
18
-
19
- ## 0.2.1
20
-
21
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
22
-
23
- ## 0.2.0
24
-
25
- See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
26
-
27
- ## 0.1.0
28
-
29
- First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
5
+ Merged into the gemstack gem; this version is a transition shim. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -1,27 +1,13 @@
1
1
  # gemstack-jobs
2
2
 
3
- GemStack background jobs: a queue in the app's database by default, swappable adapters.
3
+ **Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
4
4
 
5
- Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
7
- version.
5
+ This version is a transition shim: it depends on `gemstack` and loads `gemstack/jobs`, so
6
+ Gemfiles that still list `gemstack-jobs` keep working. To finish upgrading, remove `gem "gemstack-jobs"`
7
+ from your Gemfile and make sure `config/app.rb` has `require "gemstack/jobs"` (apps created with 0.3.0 do).
8
8
 
9
- ## Installation
10
-
11
- Optional module — added by `gemstack new`:
12
-
13
- ```ruby
14
- gem "gemstack-jobs", "~> 0.1"
15
- ```
16
-
17
- ## Documentation
18
-
19
- - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/background-jobs.md)
20
- - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
21
- [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
22
-
23
- Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
24
- (this gem lives in `gems/gemstack-jobs`).
9
+ GemStack is a modular Ruby API framework for Next.js applications by
10
+ [Adware Technologies](https://www.adwaretech.com) — [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack).
25
11
 
26
12
  ## License
27
13
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # gemstack-jobs is part of the gemstack gem since 0.3.0. This shim keeps Gemfiles
4
+ # that still list gemstack-jobs working; remove the line from your Gemfile.
5
+ require "gemstack/jobs"
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.2.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adware Technologies
@@ -11,19 +11,28 @@ cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: gemstack-core
14
+ name: gemstack
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '='
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.2.5
19
+ version: 0.3.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '1.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - '='
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 0.3.0
30
+ - - "<"
25
31
  - !ruby/object:Gem::Version
26
- version: 0.2.5
32
+ version: '1.0'
33
+ description: Since GemStack 0.3.0, gemstack-jobs is part of the gemstack gem. This
34
+ version only depends on gemstack and loads gemstack/jobs, so Gemfiles that still
35
+ list it keep working.
27
36
  email:
28
37
  - gemstack26@gmail.com
29
38
  executables: []
@@ -33,25 +42,19 @@ files:
33
42
  - CHANGELOG.md
34
43
  - LICENSE.txt
35
44
  - README.md
36
- - lib/gemstack/job.rb
37
- - lib/gemstack/jobs.rb
38
- - lib/gemstack/jobs/adapters/async.rb
39
- - lib/gemstack/jobs/adapters/database.rb
40
- - lib/gemstack/jobs/adapters/inline.rb
41
- - lib/gemstack/jobs/adapters/sidekiq.rb
42
- - lib/gemstack/jobs/adapters/test.rb
43
- - lib/gemstack/jobs/executor.rb
44
- - lib/gemstack/jobs/testing.rb
45
- - lib/gemstack/jobs/worker.rb
45
+ - lib/gemstack-jobs.rb
46
46
  homepage: https://github.com/gemstack-rb/gemstack
47
47
  licenses:
48
48
  - MIT
49
49
  metadata:
50
50
  rubygems_mfa_required: 'true'
51
- source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-jobs
52
- changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-jobs/CHANGELOG.md
51
+ source_code_uri: https://github.com/gemstack-rb/gemstack
52
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
53
53
  bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
54
54
  documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
55
+ post_install_message: gemstack-jobs is now part of the gemstack gem. Remove `gem "gemstack-jobs"`
56
+ from your Gemfile and make sure `config/app.rb` has `require "gemstack/jobs"` (apps
57
+ created with 0.3.0 do).
55
58
  rdoc_options: []
56
59
  require_paths:
57
60
  - lib
@@ -68,6 +71,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
71
  requirements: []
69
72
  rubygems_version: 4.0.20
70
73
  specification_version: 4
71
- summary: 'GemStack background jobs: a queue in the app''s database by default, swappable
72
- adapters'
74
+ summary: Merged into the gemstack gem — remove gemstack-jobs from your Gemfile
73
75
  test_files: []
data/lib/gemstack/job.rb DELETED
@@ -1,154 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- # Base class for background jobs. See GemStack::Jobs.
5
- #
6
- # class ImportProducts < GemStack::Job
7
- # queue :imports
8
- # priority 10 # lower runs first (default 100)
9
- # retry_on Faraday::TimeoutError, attempts: 5, wait: :exponential
10
- # discard_on GemStack::NotFound # the record is gone; nothing to do
11
- #
12
- # def perform(import_id, options = {})
13
- # ...
14
- # end
15
- # end
16
- #
17
- # ImportProducts.perform_later(import.id) # => job id
18
- # ImportProducts.set(wait: 300, queue: "slow").perform_later(1) # in 5 minutes
19
- # ImportProducts.set(at: Time.now + 3600).perform_later(1)
20
- # ImportProducts.perform_now(1) # synchronously
21
- #
22
- # Arguments must be JSON values (strings, numbers, booleans, nil, arrays,
23
- # hashes with string or symbol keys). Pass ids, not records. Hash keys come
24
- # back as strings.
25
- class Job
26
- RetryRule = Struct.new(:classes, :attempts, :wait)
27
-
28
- class << self
29
- def queue(name = nil)
30
- @queue = name.to_s if name
31
- @queue || (superclass.respond_to?(:queue) ? superclass.queue : Jobs.config.default_queue)
32
- end
33
-
34
- def priority(value = nil)
35
- @priority = Integer(value) if value
36
- @priority || (superclass.respond_to?(:priority) ? superclass.priority : Jobs.config.default_priority)
37
- end
38
-
39
- # retry_on Net::ReadTimeout, attempts: 5, wait: 30 # fixed seconds
40
- # retry_on StandardError, wait: :exponential # default
41
- # retry_on Api::RateLimited, wait: ->(attempt) { attempt * 60 }
42
- # The first matching rule (most recently declared first) decides.
43
- def retry_on(*classes, attempts: nil, wait: :exponential)
44
- retry_rules.unshift(RetryRule.new(classes, attempts, wait))
45
- end
46
-
47
- # Errors that mean the job should simply be dropped (logged, not retried).
48
- def discard_on(*classes)
49
- discard_classes.concat(classes)
50
- end
51
-
52
- def retry_rules = @retry_rules ||= superclass.respond_to?(:retry_rules) ? superclass.retry_rules.dup : []
53
-
54
- def discard_classes
55
- @discard_classes ||= superclass.respond_to?(:discard_classes) ? superclass.discard_classes.dup : []
56
- end
57
-
58
- def perform_later(*) = Enqueuer.new(self).perform_later(*)
59
- def set(**) = Enqueuer.new(self, **)
60
-
61
- def perform_now(*args)
62
- new.perform(*Jobs::Arguments.load(Jobs::Arguments.dump(args)))
63
- end
64
-
65
- # [max attempts, seconds to wait before the next attempt] for an error
66
- # raised on attempt number `attempt` (1-based); nil wait = give up.
67
- def retry_decision(error, attempt)
68
- rule = retry_rules.find { |r| r.classes.any? { |klass| error.is_a?(klass) } }
69
- max = rule&.attempts || Jobs.config.default_max_attempts
70
- return [max, nil] if attempt >= max
71
-
72
- [max, backoff(rule&.wait || :exponential, attempt)]
73
- end
74
-
75
- def discard?(error) = discard_classes.any? { |klass| error.is_a?(klass) }
76
-
77
- private
78
-
79
- # Sidekiq's curve: 16s, 31s, 96s, 271s, … ≈ 4 hours over 10 attempts.
80
- def backoff(wait, attempt)
81
- case wait
82
- when :exponential then (attempt**4) + 15 + (rand(10) * attempt)
83
- when Proc then Float(wait.call(attempt))
84
- else Float(wait)
85
- end
86
- end
87
- end
88
-
89
- # Builds the payload for a (possibly customised) enqueue.
90
- class Enqueuer
91
- def initialize(job_class, wait: nil, at: nil, queue: nil, priority: nil)
92
- @job_class = job_class
93
- @run_at = at || (wait && (Time.now + Float(wait)))
94
- @queue = queue&.to_s
95
- @priority = priority
96
- end
97
-
98
- def perform_later(*args)
99
- payload = {
100
- "job_class" => @job_class.name,
101
- "queue" => @queue || @job_class.queue,
102
- "priority" => @priority || @job_class.priority,
103
- "args" => Jobs::Arguments.dump(args),
104
- "run_at" => @run_at
105
- }
106
- raise ArgumentError, "anonymous job classes can't be enqueued" unless payload["job_class"]
107
-
108
- id = Jobs.adapter.enqueue(payload)
109
- Jobs.instrument(:enqueued, job_class: payload["job_class"], job_id: id, queue: payload["queue"],
110
- run_at: payload["run_at"])
111
- id
112
- end
113
- end
114
-
115
- # Subclasses implement this.
116
- def perform(*)
117
- raise NotImplementedError, "#{self.class.name}#perform is not implemented"
118
- end
119
-
120
- # Set by the executor for the running job.
121
- attr_accessor :job_id, :attempt
122
- end
123
-
124
- module Jobs
125
- # Validates and converts arguments to/from their JSON form.
126
- module Arguments
127
- module_function
128
-
129
- def dump(args) = args.map { |arg| dump_value(arg, "argument") }
130
-
131
- def load(json) = json
132
-
133
- def dump_value(value, path)
134
- case value
135
- when String, Integer, true, false, nil then value
136
- when Float then value.finite? ? value : invalid(value, path)
137
- when Symbol then value.name
138
- when Array then value.each_with_index.map { |v, i| dump_value(v, "#{path}[#{i}]") }
139
- when Hash
140
- value.to_h do |key, v|
141
- invalid(key, "#{path} key") unless key.is_a?(String) || key.is_a?(Symbol)
142
- [key.to_s, dump_value(v, "#{path}[#{key.inspect}]")]
143
- end
144
- else invalid(value, path)
145
- end
146
- end
147
-
148
- def invalid(value, path)
149
- hint = value.respond_to?(:pk) || value.respond_to?(:id) ? " — pass its id instead" : ""
150
- raise SerializationError, "#{path} #{value.class} can't be serialized to JSON#{hint}"
151
- end
152
- end
153
- end
154
- end
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Jobs
5
- module Adapters
6
- # An in-process thread pool with an in-memory schedule (retries and
7
- # `set(wait:)` work). Jobs are lost when the process exits, so it is
8
- # meant for development and apps without a database — use :postgres
9
- # or :sidekiq in production.
10
- class Async
11
- include AfterCommit
12
-
13
- Entry = Struct.new(:run_at, :sequence, :payload)
14
-
15
- def initialize(concurrency: Jobs.config.concurrency)
16
- @concurrency = concurrency
17
- @entries = []
18
- @mutex = Mutex.new
19
- @available = ConditionVariable.new
20
- @sequence = 0
21
- @running = 0
22
- @threads = nil
23
- end
24
-
25
- def enqueue(payload)
26
- id = SecureRandom.uuid
27
- after_commit { schedule(payload.merge("id" => id, "attempts" => 0), payload["run_at"] || Time.now) }
28
- id
29
- end
30
-
31
- # Blocks until no jobs are queued or running (mainly for tests).
32
- def drain(timeout: 10)
33
- deadline = monotonic + timeout
34
- sleep 0.01 until @mutex.synchronize { @entries.empty? && @running.zero? } || monotonic > deadline
35
- end
36
-
37
- def shutdown
38
- @mutex.synchronize do
39
- @stopping = true
40
- @available.broadcast
41
- end
42
- @threads&.each { |thread| thread.join(Jobs.config.shutdown_timeout) }
43
- @threads = nil
44
- end
45
-
46
- private
47
-
48
- def schedule(payload, run_at)
49
- worker_threads
50
- @mutex.synchronize do
51
- @sequence += 1
52
- @entries << Entry.new(run_at, @sequence, payload)
53
- @entries.sort_by! { |e| [e.run_at, e.payload["priority"] || 100, e.sequence] }
54
- @available.broadcast
55
- end
56
- end
57
-
58
- # Started lazily on the first job; shutdown joins them.
59
- def worker_threads
60
- @mutex.synchronize do
61
- @threads ||= Array.new(@concurrency) { Thread.new { work } } # rubocop:disable Naming/MemoizedInstanceVariableName
62
- end
63
- end
64
-
65
- def work
66
- loop do
67
- entry = next_entry or break
68
- outcome = Executor.execute(entry.payload)
69
- schedule(entry.payload.merge("attempts" => outcome.attempt), outcome.run_at) if outcome.status == :retry
70
- ensure
71
- @mutex.synchronize { @running -= 1 } if entry
72
- end
73
- end
74
-
75
- def next_entry
76
- @mutex.synchronize do
77
- loop do
78
- return nil if @stopping
79
-
80
- first = @entries.first
81
- if first && first.run_at <= Time.now
82
- @running += 1
83
- return @entries.shift
84
- end
85
- @available.wait(@mutex, first ? [first.run_at - Time.now, 0.01].max : nil)
86
- end
87
- end
88
- end
89
-
90
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
- end
92
- end
93
- end
94
- end
@@ -1,188 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Jobs
5
- # The migration new apps get (and `gemstack jobs:install` writes). Kept
6
- # as source text so the app owns a plain, readable migration while tests
7
- # and the generator share one definition.
8
- module Migration
9
- SOURCE = <<~RUBY
10
- # frozen_string_literal: true
11
-
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.
15
- Sequel.migration 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") } : {}
19
- create_table(:gemstack_jobs) do
20
- primary_key :id, type: :Bignum
21
- String :queue, null: false, default: "default"
22
- Integer :priority, null: false, default: 100
23
- String :job_class, null: false
24
- column :args, :jsonb, null: false # json on MySQL and SQLite
25
- column :run_at, :timestamptz, null: false
26
- Integer :attempts, null: false, default: 0
27
- String :last_error, text: true
28
- column :locked_at, :timestamptz
29
- String :locked_by
30
- column :failed_at, :timestamptz
31
- column :created_at, :timestamptz, null: false
32
-
33
- # The fetch query: ready jobs by queue, in priority/run_at order.
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
37
- end
38
- end
39
-
40
- down do
41
- drop_table(:gemstack_jobs)
42
- end
43
- end
44
- RUBY
45
-
46
- def self.apply(db, direction = :up)
47
- Sequel.extension :migration
48
- eval(SOURCE, TOPLEVEL_BINDING, "gemstack_jobs_migration.rb").apply(db, direction) # rubocop:disable Security/Eval
49
- end
50
- end
51
-
52
- module Adapters
53
- # The default adapter: jobs are rows in the application's database
54
- # — PostgreSQL, MySQL or SQLite.
55
- #
56
- # - Enqueueing is an INSERT on the current connection, so inside
57
- # GemStack.transaction a job exists only if the transaction commits.
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
66
- CHANNEL = "gemstack_jobs"
67
-
68
- def initialize(db: nil, table: Jobs.config.table)
69
- @db = db
70
- @table = table
71
- end
72
-
73
- def db
74
- @db || begin
75
- require "gemstack/db"
76
- GemStack::DB.connection
77
- end
78
- end
79
-
80
- def dataset = db[@table]
81
- def postgres? = db.database_type == :postgres
82
-
83
- def enqueue(payload)
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?
89
- id
90
- end
91
-
92
- # Claims the next ready job for these queues ("*" = all). Returns a
93
- # payload Hash or nil.
94
- def claim(queues, worker)
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
105
- end
106
-
107
- def complete(id) = dataset.where(id: id).delete
108
-
109
- def reschedule(id, run_at:, attempts:, error:)
110
- dataset.where(id: id).update(locked_at: nil, locked_by: nil, run_at: run_at, attempts: attempts,
111
- last_error: describe(error))
112
- end
113
-
114
- def fail(id, attempts:, error:)
115
- return complete(id) unless Jobs.config.keep_failed
116
-
117
- dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at: Time.now,
118
- attempts: attempts, last_error: describe(error))
119
- end
120
-
121
- # Releases this worker's claimed jobs (graceful shutdown).
122
- def release(worker) = dataset.where(locked_by: worker).update(locked_at: nil, locked_by: nil)
123
-
124
- # Releases jobs locked longer than `timeout` seconds (their worker died).
125
- def release_stale(timeout)
126
- cutoff = Time.now - Float(timeout)
127
- dataset.where { locked_at < cutoff }.update(locked_at: nil, locked_by: nil)
128
- end
129
-
130
- # Failed jobs back to the queue: all, or the given ids.
131
- def retry_failed(ids = nil)
132
- failed = dataset.exclude(failed_at: nil)
133
- failed = failed.where(id: ids) if ids
134
- failed.update(failed_at: nil, attempts: 0, run_at: Time.now, last_error: nil)
135
- end
136
-
137
- def discard_failed(ids = nil)
138
- failed = dataset.exclude(failed_at: nil)
139
- failed = failed.where(id: ids) if ids
140
- failed.delete
141
- end
142
-
143
- def failed(limit: 20)
144
- dataset.exclude(failed_at: nil).order(Sequel.desc(:failed_at)).limit(limit)
145
- .select(:id, :queue, :job_class, :attempts, :failed_at, :last_error).all
146
- end
147
-
148
- # { "default" => { ready:, scheduled:, running:, failed: }, ... }
149
- def stats
150
- now = Time.now
151
- states = Sequel.case(
152
- [[Sequel.~(failed_at: nil), "failed"], [Sequel.~(locked_at: nil), "running"],
153
- [Sequel[:run_at] > now, "scheduled"]], "ready"
154
- )
155
- dataset.group_and_count(:queue, states.as(:state)).all.each_with_object({}) do |row, result|
156
- (result[row[:queue]] ||= { ready: 0, scheduled: 0, running: 0, failed: 0 })[row[:state].to_sym] =
157
- row[:count]
158
- end
159
- end
160
-
161
- private
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
-
167
- # args as text, parsed with the json gem: jobs receive plain Hash/Array
168
- # values rather than Sequel's JSONB wrappers. (Lazy: Sequel may not be loaded.)
169
- def returned_columns
170
- @returned_columns ||= [:id, :job_class, :queue, :priority, :attempts,
171
- postgres? ? Sequel.cast(:args, :text).as(:args_json) : Sequel.as(:args, :args_json)]
172
- end
173
-
174
- def payload_for(row)
175
- { "id" => row[:id], "job_class" => row[:job_class], "queue" => row[:queue],
176
- "priority" => row[:priority], "args" => JSON.parse(row[:args_json].to_s), "attempts" => row[:attempts] }
177
- end
178
-
179
- def describe(error)
180
- "#{error.class}: #{error.message}\n#{Array(error.backtrace).first(20).join("\n")}"[0, 10_000]
181
- end
182
- end
183
-
184
- # The adapter's name before it supported MySQL and SQLite; `adapter: :postgres` still works.
185
- Postgres = Database
186
- end
187
- end
188
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Jobs
5
- module Adapters
6
- # Runs jobs immediately, in the caller's thread, ignoring schedules and
7
- # retries. Errors propagate, so nothing is silently swallowed. Handy for
8
- # scripts and debugging.
9
- class Inline
10
- def enqueue(payload)
11
- id = SecureRandom.uuid
12
- job = Executor.resolve(payload["job_class"]).new
13
- job.job_id = id
14
- job.attempt = 1
15
- job.perform(*Arguments.load(payload["args"]))
16
- id
17
- end
18
- end
19
-
20
- # Defers a block until the surrounding database transaction commits
21
- # (when gemstack-db is in use), so a job never runs before — or without —
22
- # the data it depends on. The :postgres adapter gets this for free by
23
- # inserting into the same transaction.
24
- module AfterCommit
25
- def after_commit(&)
26
- db = defined?(GemStack::DB) && GemStack::DB.connected? ? GemStack::DB.connection : nil
27
- return yield unless db&.in_transaction?
28
-
29
- db.after_commit(&)
30
- end
31
- end
32
- end
33
- end
34
- end
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Jobs
5
- module Adapters
6
- # Runs GemStack jobs on Sidekiq (for teams already running Redis +
7
- # Sidekiq). Add `gem "sidekiq"`, set `config.jobs.adapter = :sidekiq`,
8
- # and run `bundle exec sidekiq -r ./config/sidekiq.rb` (see docs/background-jobs.md).
9
- #
10
- # GemStack's executor still decides retries and discards, so job
11
- # classes behave identically on every adapter; Sidekiq's own retries
12
- # are off and exhausted jobs go to Sidekiq's Dead set.
13
- class Sidekiq
14
- include AfterCommit
15
-
16
- def initialize
17
- require "sidekiq"
18
- Runner.define!
19
- rescue LoadError
20
- raise ConfigurationError, 'the :sidekiq job adapter needs `gem "sidekiq"` in the Gemfile'
21
- end
22
-
23
- def enqueue(payload)
24
- jid = SecureRandom.hex(12)
25
- after_commit { Runner.push(payload.merge("attempts" => 0), jid: jid) }
26
- jid
27
- end
28
-
29
- # The Sidekiq job class that executes GemStack payloads.
30
- module Runner
31
- module_function
32
-
33
- def define!
34
- return if defined?(GemStack::Jobs::SidekiqRunner)
35
-
36
- klass = Class.new do
37
- include ::Sidekiq::Job
38
-
39
- sidekiq_options retry: 0 # exhausted jobs → Dead set; GemStack schedules retries itself
40
-
41
- def perform(payload)
42
- payload = payload.merge("id" => jid)
43
- outcome = Executor.execute(payload)
44
- case outcome.status
45
- when :retry then Runner.push(payload.merge("attempts" => outcome.attempt), at: outcome.run_at)
46
- when :failed then raise outcome.error
47
- end
48
- end
49
- end
50
- GemStack::Jobs.const_set(:SidekiqRunner, klass)
51
- end
52
-
53
- def push(payload, at: nil, jid: nil)
54
- item = { "class" => GemStack::Jobs::SidekiqRunner, "queue" => payload["queue"],
55
- "args" => [payload.except("run_at", "id")] }
56
- item["jid"] = jid if jid
57
- run_at = at || payload["run_at"]
58
- item["at"] = run_at.to_f if run_at
59
- ::Sidekiq::Client.push(item)
60
- end
61
- end
62
- end
63
- end
64
- end
65
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Jobs
5
- module Adapters
6
- # Records jobs instead of running them (the default in tests). See
7
- # GemStack::Jobs::Testing for assertions and perform_enqueued_jobs.
8
- class Test
9
- attr_reader :enqueued, :performed
10
-
11
- def initialize
12
- @enqueued = []
13
- @performed = []
14
- @mutex = Mutex.new
15
- @sequence = 0
16
- end
17
-
18
- def enqueue(payload)
19
- @mutex.synchronize do
20
- @sequence += 1
21
- @enqueued << payload.merge("id" => @sequence, "attempts" => 0)
22
- @sequence
23
- end
24
- end
25
-
26
- # Runs enqueued jobs (and jobs they enqueue) until none are left, or
27
- # only those matching `only`. An error a job raises is re-raised (unless
28
- # the job discards it), so failing jobs fail the test. Returns the outcomes.
29
- def perform_enqueued(only: nil, except_ids: [])
30
- names = only && Array(only).map(&:to_s)
31
- outcomes = []
32
- loop do
33
- payload = @mutex.synchronize do
34
- index = @enqueued.index do |p|
35
- (names.nil? || names.include?(p["job_class"])) && !except_ids.include?(p["id"])
36
- end
37
- index && @enqueued.delete_at(index)
38
- end
39
- break unless payload
40
-
41
- outcome = Executor.execute(payload)
42
- raise outcome.error if outcome.error && outcome.status != :discarded
43
-
44
- @performed << payload
45
- outcomes << outcome
46
- end
47
- outcomes
48
- end
49
-
50
- def clear
51
- @mutex.synchronize do
52
- @enqueued.clear
53
- @performed.clear
54
- end
55
- end
56
- end
57
- end
58
- end
59
- end
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GemStack
4
- module Jobs
5
- # Runs one job payload and decides what happens next. Shared by every
6
- # adapter, so retries, discards, failures and instrumentation behave the
7
- # same everywhere.
8
- #
9
- # Returns an Outcome: :performed, :retry (with run_at), :discarded or :failed.
10
- module Executor
11
- Outcome = Struct.new(:status, :error, :run_at, :attempt, keyword_init: true)
12
-
13
- module_function
14
-
15
- # payload: "job_class", "args", "queue", "attempts" (runs so far), "id".
16
- def execute(payload)
17
- attempt = Integer(payload["attempts"] || 0) + 1
18
- fields = { job_class: payload["job_class"], job_id: payload["id"], queue: payload["queue"], attempt: attempt }
19
- job_class = resolve(payload["job_class"])
20
- started = monotonic
21
- run(job_class, payload, attempt)
22
- Jobs.instrument(:performed, **fields, duration_ms: elapsed(started))
23
- Outcome.new(status: :performed, attempt: attempt)
24
- rescue UnknownJob => e
25
- Jobs.instrument(:failed, **fields, error: e)
26
- Outcome.new(status: :failed, error: e, attempt: attempt)
27
- rescue StandardError => e
28
- failure(job_class, e, attempt, fields.merge(duration_ms: elapsed(started)))
29
- end
30
-
31
- def run(job_class, payload, attempt)
32
- job = job_class.new
33
- job.job_id = payload["id"]
34
- job.attempt = attempt
35
- job.perform(*Arguments.load(payload["args"] || []))
36
- end
37
-
38
- def failure(job_class, error, attempt, fields)
39
- if job_class.discard?(error)
40
- Jobs.instrument(:discarded, **fields, error: error)
41
- return Outcome.new(status: :discarded, error: error, attempt: attempt)
42
- end
43
-
44
- _max, wait = job_class.retry_decision(error, attempt)
45
- if wait
46
- run_at = Time.now + wait
47
- Jobs.instrument(:retried, **fields, error: error, run_at: run_at)
48
- Outcome.new(status: :retry, error: error, run_at: run_at, attempt: attempt)
49
- else
50
- Jobs.instrument(:failed, **fields, error: error)
51
- Outcome.new(status: :failed, error: error, attempt: attempt)
52
- end
53
- end
54
-
55
- # Only GemStack::Job subclasses can run — a queue row naming another
56
- # constant is never instantiated.
57
- def resolve(name)
58
- klass = Object.const_get(name.to_s)
59
- raise UnknownJob, "#{name} is not a GemStack::Job" unless klass.is_a?(Class) && klass < Job
60
-
61
- klass
62
- rescue NameError
63
- raise UnknownJob, "unknown job class #{name.inspect}"
64
- end
65
-
66
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
- def elapsed(started) = started && ((monotonic - started) * 1000).round(2)
68
- end
69
- end
70
- end
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "gemstack/jobs"
4
-
5
- module GemStack
6
- module Jobs
7
- # Test helpers (included into GemStack::TestCase by the generated test helper):
8
- #
9
- # def test_signup_sends_welcome_email
10
- # post_json "/api/signups", { email: "a@b.c" }
11
- #
12
- # assert_enqueued SendWelcomeEmail, args: [User.last.id]
13
- # perform_enqueued_jobs
14
- # assert_equal 1, Mailer.deliveries.size
15
- # end
16
- module Testing
17
- def self.included(base)
18
- base.class_eval do
19
- def before_setup
20
- super
21
- GemStack::Jobs.adapter = GemStack::Jobs::Adapters::Test.new
22
- end
23
- end
24
- end
25
-
26
- def enqueued_jobs = Jobs.adapter.enqueued
27
-
28
- # Jobs of job_class (optionally with these args / on this queue) were enqueued.
29
- def assert_enqueued(job_class, args: nil, queue: nil, count: nil)
30
- matching = enqueued_jobs.select do |job|
31
- job["job_class"] == job_class.name && (args.nil? || job["args"] == Arguments.dump(args)) &&
32
- (queue.nil? || job["queue"] == queue.to_s)
33
- end
34
- message = "Expected #{job_class.name}#{" with #{args.inspect}" if args} to be enqueued; " \
35
- "enqueued: #{enqueued_jobs.map { |j| [j["job_class"], j["args"]] }.inspect}"
36
- count ? assert_equal(count, matching.size, message) : assert(!matching.empty?, message)
37
- end
38
-
39
- def refute_enqueued(job_class)
40
- assert(enqueued_jobs.none? { |job| job["job_class"] == job_class.name },
41
- "Expected no #{job_class.name} to be enqueued")
42
- end
43
-
44
- # Runs enqueued jobs (and any they enqueue). With a block: only jobs
45
- # enqueued inside it. Errors raised by jobs propagate.
46
- def perform_enqueued_jobs(only: nil)
47
- return Jobs.adapter.perform_enqueued(only: only) unless block_given?
48
-
49
- before = enqueued_jobs.map { |job| job["id"] }
50
- yield
51
- Jobs.adapter.perform_enqueued(only: only, except_ids: before)
52
- end
53
- end
54
- end
55
- end
@@ -1,137 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
-
5
- module GemStack
6
- module Jobs
7
- # Processes jobs from the PostgreSQL queue (`gemstack jobs`).
8
- #
9
- # - `concurrency` threads each claim and run one job at a time.
10
- # - A listener thread LISTENs for NOTIFY and wakes idle threads at once;
11
- # idle threads also re-check every poll_interval.
12
- # - A reaper releases jobs whose lock is older than lock_timeout (a worker
13
- # crashed mid-job), so they run again (at-least-once delivery).
14
- # - stop (SIGINT/SIGTERM) lets running jobs finish for shutdown_timeout,
15
- # then releases the rest back to the queue.
16
- #
17
- # Needs concurrency + 2 database connections (the CLI sizes the pool).
18
- class Worker
19
- attr_reader :id
20
-
21
- def initialize(store: Adapters::Database.new, queues: Jobs.config.queues, concurrency: Jobs.config.concurrency,
22
- poll_interval: Jobs.config.poll_interval, lock_timeout: Jobs.config.lock_timeout,
23
- shutdown_timeout: Jobs.config.shutdown_timeout)
24
- @store = store
25
- @queues = Array(queues).map(&:to_s)
26
- @concurrency = concurrency
27
- @poll_interval = poll_interval || (store.respond_to?(:postgres?) && store.postgres? ? 5 : 1)
28
- @lock_timeout = lock_timeout
29
- @shutdown_timeout = shutdown_timeout
30
- @id = "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(3)}"
31
- @mutex = Mutex.new
32
- @wakeup = ConditionVariable.new
33
- @running = false
34
- @threads = []
35
- end
36
-
37
- def running? = @running
38
-
39
- # Starts the threads and returns immediately.
40
- def start
41
- @running = true
42
- GemStack.logger.info("jobs worker started", worker: @id, queues: @queues.join(","), concurrency: @concurrency)
43
- @threads = Array.new(@concurrency) { |i| Thread.new { work_loop(i) } }
44
- @listener = Thread.new { listen_loop } if @store.respond_to?(:postgres?) && @store.postgres?
45
- @reaper = Thread.new { reap_loop }
46
- self
47
- end
48
-
49
- # Blocks until SIGINT/SIGTERM, then shuts down gracefully.
50
- def run
51
- %w[INT TERM].each { |signal| trap(signal) { @running = false } }
52
- start
53
- sleep 0.2 while @running
54
- shutdown
55
- end
56
-
57
- def stop
58
- @running = false
59
- wake
60
- end
61
-
62
- def shutdown
63
- stop
64
- deadline = monotonic + @shutdown_timeout
65
- @threads.each { |thread| thread.join([deadline - monotonic, 0].max) }
66
- unfinished = @threads.count(&:alive?)
67
- @threads.each(&:kill)
68
- released = @store.release(@id)
69
- [@listener, @reaper].compact.each { |thread| thread.kill.join(1) }
70
- GemStack.logger.info("jobs worker stopped", worker: @id, released: released, unfinished: unfinished)
71
- end
72
-
73
- # Wakes idle threads (NOTIFY arrived, or shutting down).
74
- def wake = @mutex.synchronize { @wakeup.broadcast }
75
-
76
- # Runs one job if one is ready. Returns the outcome, or nil when idle.
77
- def work_once
78
- payload = @store.claim(@queues, @id) or return nil
79
-
80
- outcome = Executor.execute(payload)
81
- settle(payload, outcome)
82
- outcome
83
- end
84
-
85
- private
86
-
87
- def work_loop(_index)
88
- while @running
89
- begin
90
- idle(@poll_interval) unless work_once
91
- rescue Sequel::DatabaseConnectionError, Sequel::PoolTimeout => e
92
- GemStack.logger.warn("jobs worker: database unavailable, retrying", error: e.message)
93
- idle(@poll_interval)
94
- rescue StandardError => e # a bug in the worker itself; keep the thread alive
95
- GemStack.logger.error("jobs worker error", error: e, backtrace: Array(e.backtrace).first(10))
96
- idle(@poll_interval)
97
- end
98
- end
99
- end
100
-
101
- def settle(payload, outcome)
102
- case outcome.status
103
- when :performed, :discarded then @store.complete(payload["id"])
104
- when :retry
105
- @store.reschedule(payload["id"], run_at: outcome.run_at, attempts: outcome.attempt, error: outcome.error)
106
- when :failed then @store.fail(payload["id"], attempts: outcome.attempt, error: outcome.error)
107
- end
108
- end
109
-
110
- def idle(seconds)
111
- @mutex.synchronize { @wakeup.wait(@mutex, seconds) if @running }
112
- end
113
-
114
- def listen_loop
115
- @store.db.listen(Adapters::Database::CHANNEL, loop: ->(_conn) { throw :stop unless @running },
116
- timeout: @poll_interval) { wake }
117
- rescue Sequel::DatabaseConnectionError => e
118
- GemStack.logger.warn("jobs worker: LISTEN failed, falling back to polling", error: e.message)
119
- end
120
-
121
- def reap_loop
122
- interval = [@lock_timeout / 4.0, 1].max
123
- while @running
124
- begin
125
- released = @store.release_stale(@lock_timeout)
126
- GemStack.logger.warn("jobs: released stale locks", count: released) if released.positive?
127
- rescue Sequel::Error => e
128
- GemStack.logger.warn("jobs reaper error", error: e.message)
129
- end
130
- sleep interval
131
- end
132
- end
133
-
134
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
- end
136
- end
137
- end
data/lib/gemstack/jobs.rb DELETED
@@ -1,144 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "securerandom"
5
- require "gemstack/core"
6
-
7
- module GemStack
8
- # Background jobs (ARCHITECTURE §9).
9
- #
10
- # class SendWelcomeEmail < GemStack::Job
11
- # queue :mailers
12
- # retry_on Net::ReadTimeout, attempts: 5
13
- # def perform(user_id) = Mailer.welcome(User.find(user_id))
14
- # end
15
- #
16
- # SendWelcomeEmail.perform_later(user.id)
17
- # SendWelcomeEmail.set(wait: 600).perform_later(user.id)
18
- #
19
- # Work is only ever asynchronous when you ask for it with perform_later.
20
- # Delivery is at-least-once: make perform idempotent.
21
- module Jobs
22
- class Config < Settings
23
- # :database (default with gemstack-db: the app's PostgreSQL, MySQL or
24
- # SQLite; :postgres is an alias), :async (in-process threads),
25
- # :inline (run immediately), :test (record only; default in tests),
26
- # :sidekiq, or an adapter object responding to #enqueue(payload).
27
- setting :adapter, default: lambda {
28
- if GemStack.env.test? then :test
29
- elsif defined?(GemStack::DB) then :database
30
- else :async
31
- end
32
- }
33
- setting :default_queue, default: "default"
34
- setting :default_priority, default: 100 # lower runs first
35
- setting :default_max_attempts, default: 10
36
- # Worker settings (`gemstack jobs`).
37
- setting :queues, default: -> { ENV.fetch("GEMSTACK_JOB_QUEUES", "*").split(",").map(&:strip) }
38
- setting :concurrency, default: -> { Integer(ENV.fetch("GEMSTACK_JOB_CONCURRENCY", 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
42
- # A job locked longer than this is assumed abandoned (worker crashed) and is released.
43
- # Jobs that legitimately run longer must raise it.
44
- setting :lock_timeout, default: 30 * 60
45
- # How long a stopping worker waits for running jobs before releasing them.
46
- setting :shutdown_timeout, default: 25
47
- setting :table, default: :gemstack_jobs
48
- # Keep exhausted jobs (failed_at set) for inspection and `gemstack jobs:retry`.
49
- setting :keep_failed, default: true
50
- end
51
-
52
- autoload :Worker, "gemstack/jobs/worker"
53
- autoload :Testing, "gemstack/jobs/testing"
54
-
55
- # Raised for arguments that can't round-trip through JSON.
56
- class SerializationError < Error; end
57
-
58
- # A job's name didn't resolve to a GemStack::Job subclass when it ran.
59
- class UnknownJob < Error; end
60
-
61
- Event = Struct.new(:name, :job_class, :job_id, :queue, :attempt, :duration_ms, :error, :run_at, keyword_init: true)
62
-
63
- @subscribers = Hash.new { |hash, key| hash[key] = [] }
64
- @mutex = Mutex.new
65
-
66
- class << self
67
- def config = GemStack.config.jobs
68
-
69
- def adapter
70
- @adapter || @mutex.synchronize { @adapter ||= build_adapter(config.adapter) }
71
- end
72
-
73
- attr_writer :adapter
74
-
75
- def build_adapter(setting)
76
- case setting
77
- when :database, "database", :postgres, "postgres" then Adapters::Database.new
78
- when :async, "async" then Adapters::Async.new
79
- when :inline, "inline" then Adapters::Inline.new
80
- when :test, "test" then Adapters::Test.new
81
- when :sidekiq, "sidekiq" then Adapters::Sidekiq.new
82
- else
83
- raise ConfigurationError, "a job adapter must respond to #enqueue" unless setting.respond_to?(:enqueue)
84
-
85
- setting
86
- end
87
- end
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
-
92
- # Instrumentation for metrics/monitoring:
93
- # GemStack::Jobs.subscribe(:failed) { |event| Sentry.capture_message(...) }
94
- # Events: :enqueued, :performed, :retried, :failed, :discarded.
95
- def subscribe(name = :all, &block)
96
- @mutex.synchronize { @subscribers[name.to_sym] << block }
97
- block
98
- end
99
-
100
- def unsubscribe(block)
101
- @mutex.synchronize { @subscribers.each_value { |list| list.delete(block) } }
102
- end
103
-
104
- def instrument(name, **fields)
105
- event = Event.new(name: name, **fields)
106
- log(event)
107
- (@subscribers[name] + @subscribers[:all]).each do |subscriber|
108
- subscriber.call(event)
109
- rescue StandardError => e
110
- GemStack.logger.error("job subscriber failed", error: e)
111
- end
112
- event
113
- end
114
-
115
- def reset!
116
- @adapter = nil
117
- end
118
-
119
- private
120
-
121
- def log(event)
122
- fields = { job: event.job_class, id: event.job_id, queue: event.queue, attempt: event.attempt,
123
- ms: event.duration_ms, run_at: event.run_at&.utc&.iso8601 }.compact
124
- fields[:error] = "#{event.error.class}: #{event.error.message}" if event.error
125
- level = { failed: :error, retried: :warn }.fetch(event.name, :info)
126
- GemStack.logger.public_send(level, "job.#{event.name}", **fields)
127
- end
128
- end
129
- end
130
-
131
- class << self
132
- def jobs = Jobs.adapter
133
- end
134
- end
135
-
136
- require_relative "job"
137
- require_relative "jobs/executor"
138
- require_relative "jobs/adapters/inline"
139
- require_relative "jobs/adapters/test"
140
- require_relative "jobs/adapters/async"
141
- require_relative "jobs/adapters/database"
142
- require_relative "jobs/adapters/sidekiq"
143
-
144
- GemStack::Config.namespace(:jobs, GemStack::Jobs::Config)