gush 2.1.0 → 4.0.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.
data/lib/gush/cli.rb CHANGED
@@ -70,9 +70,14 @@ module Gush
70
70
  client.destroy_workflow(workflow)
71
71
  end
72
72
 
73
- desc "list", "Lists all workflows with their statuses"
74
- def list
75
- workflows = client.all_workflows
73
+ desc "list START STOP", "Lists workflows from START index through STOP index with their statuses"
74
+ option :start, type: :numeric, default: nil
75
+ option :stop, type: :numeric, default: nil
76
+ def list(start=nil, stop=nil)
77
+ workflows = client.workflow_ids(start, stop).map do |id|
78
+ client.find_workflow(id)
79
+ end
80
+
76
81
  rows = workflows.map do |workflow|
77
82
  [workflow.id, (Time.at(workflow.started_at) if workflow.started_at), workflow.class, {alignment: :center, value: status_for(workflow)}]
78
83
  end
@@ -101,7 +106,7 @@ module Gush
101
106
  begin
102
107
  workflow = class_or_id.constantize.new
103
108
  rescue NameError => e
104
- STDERR.puts Paint["'#{class_or_id}' is not a valid workflow class or id", :red]
109
+ warn Paint["'#{class_or_id}' is not a valid workflow class or id", :red]
105
110
  exit 1
106
111
  end
107
112
  end
@@ -120,6 +125,24 @@ module Gush
120
125
  end
121
126
  end
122
127
 
128
+ desc "migrate", "Runs all unapplied migrations to Gush storage"
129
+ def migrate
130
+ Dir[File.join(__dir__, 'migrate', '*.rb')].each {|file| require file }
131
+
132
+ applied = Gush::Migration.subclasses.sort(&:version).count do |klass|
133
+ migration = klass.new
134
+ next if migration.migrated?
135
+
136
+ puts "Migrating to #{klass.name} (#{migration.version})"
137
+ migration.migrate
138
+ puts "== #{migration.version} #{klass.name}: migrated ==="
139
+
140
+ true
141
+ end
142
+
143
+ puts "#{applied} #{'migrations'.pluralize(applied)} applied"
144
+ end
145
+
123
146
  private
124
147
 
125
148
  def client
data/lib/gush/client.rb CHANGED
@@ -72,7 +72,7 @@ module Gush
72
72
  id = nil
73
73
  loop do
74
74
  id = SecureRandom.uuid
75
- available = !redis.exists?("gush.workflow.#{id}")
75
+ available = !redis.exists?("gush.workflows.#{id}")
76
76
 
77
77
  break if available
78
78
  end
@@ -80,6 +80,41 @@ module Gush
80
80
  id
81
81
  end
82
82
 
83
+ # Returns the specified range of workflow ids, sorted by created timestamp.
84
+ #
85
+ # @param start, stop [Integer] see https://redis.io/docs/latest/commands/zrange/#index-ranges
86
+ # for details on the start and stop parameters.
87
+ # @param by_ts [Boolean] if true, start and stop are treated as timestamps
88
+ # rather than as element indexes, which allows the workflows to be indexed
89
+ # by created timestamp
90
+ # @param order [Symbol] if :asc, finds ids in ascending created timestamp;
91
+ # if :desc, finds ids in descending created timestamp
92
+ # @returns [Array<String>] array of workflow ids
93
+ def workflow_ids(start=nil, stop=nil, by_ts: false, order: :asc)
94
+ start ||= 0
95
+ stop ||= 99
96
+
97
+ redis.zrange(
98
+ "gush.idx.workflows.created_at",
99
+ start,
100
+ stop,
101
+ by_score: by_ts,
102
+ rev: order&.to_sym == :desc
103
+ )
104
+ end
105
+
106
+ def workflows(start=nil, stop=nil, **kwargs)
107
+ workflow_ids(start, stop, **kwargs).map { |id| find_workflow(id) }
108
+ end
109
+
110
+ def workflows_count
111
+ redis.zcard('gush.idx.workflows.created_at')
112
+ end
113
+
114
+ # Deprecated.
115
+ #
116
+ # This method is not performant when there are a large number of workflows
117
+ # or when the redis keyspace is large. Use workflows instead with pagination.
83
118
  def all_workflows
84
119
  redis.scan_each(match: "gush.workflows.*").map do |key|
85
120
  id = key.sub("gush.workflows.", "")
@@ -92,10 +127,16 @@ module Gush
92
127
 
93
128
  unless data.nil?
94
129
  hash = Gush::JSON.decode(data, symbolize_keys: true)
95
- keys = redis.scan_each(match: "gush.jobs.#{id}.*")
130
+
131
+ if hash[:job_klasses]
132
+ keys = hash[:job_klasses].map { |klass| "gush.jobs.#{id}.#{klass}" }
133
+ else
134
+ # For backwards compatibility, get job keys via a full keyspace scan
135
+ keys = redis.scan_each(match: "gush.jobs.#{id}.*")
136
+ end
96
137
 
97
138
  nodes = keys.each_with_object([]) do |key, array|
98
- array.concat redis.hvals(key).map { |json| Gush::JSON.decode(json, symbolize_keys: true) }
139
+ array.concat(redis.hvals(key).map { |json| Gush::JSON.decode(json, symbolize_keys: true) })
99
140
  end
100
141
 
101
142
  workflow_from_hash(hash, nodes)
@@ -105,15 +146,25 @@ module Gush
105
146
  end
106
147
 
107
148
  def persist_workflow(workflow)
149
+ created_at = Time.now.to_f
150
+ added = redis.zadd("gush.idx.workflows.created_at", created_at, workflow.id, nx: true)
151
+
152
+ if added && configuration.ttl&.positive?
153
+ expires_at = created_at + configuration.ttl
154
+ redis.zadd("gush.idx.workflows.expires_at", expires_at, workflow.id, nx: true)
155
+ end
156
+
108
157
  redis.set("gush.workflows.#{workflow.id}", workflow.to_json)
109
158
 
110
- workflow.jobs.each {|job| persist_job(workflow.id, job) }
159
+ workflow.jobs.each {|job| persist_job(workflow.id, job, expires_at: expires_at) }
111
160
  workflow.mark_as_persisted
112
161
 
113
162
  true
114
163
  end
115
164
 
116
- def persist_job(workflow_id, job)
165
+ def persist_job(workflow_id, job, expires_at: nil)
166
+ redis.zadd("gush.idx.jobs.expires_at", expires_at, "#{workflow_id}.#{job.klass}", nx: true) if expires_at
167
+
117
168
  redis.hset("gush.jobs.#{workflow_id}.#{job.klass}", job.id, job.to_json)
118
169
  end
119
170
 
@@ -134,30 +185,67 @@ module Gush
134
185
 
135
186
  def destroy_workflow(workflow)
136
187
  redis.del("gush.workflows.#{workflow.id}")
188
+ redis.zrem("gush.idx.workflows.created_at", workflow.id)
189
+ redis.zrem("gush.idx.workflows.expires_at", workflow.id)
137
190
  workflow.jobs.each {|job| destroy_job(workflow.id, job) }
138
191
  end
139
192
 
140
193
  def destroy_job(workflow_id, job)
141
194
  redis.del("gush.jobs.#{workflow_id}.#{job.klass}")
195
+ redis.zrem("gush.idx.jobs.expires_at", "#{workflow_id}.#{job.klass}")
196
+ end
197
+
198
+ def expire_workflows(expires_at=nil)
199
+ expires_at ||= Time.now.to_f
200
+
201
+ ids = redis.zrange("gush.idx.workflows.expires_at", "-inf", expires_at, by_score: true)
202
+ return if ids.empty?
203
+
204
+ redis.del(ids.map { |id| "gush.workflows.#{id}" })
205
+ redis.zrem("gush.idx.workflows.created_at", ids)
206
+ redis.zrem("gush.idx.workflows.expires_at", ids)
207
+
208
+ expire_jobs(expires_at)
209
+ end
210
+
211
+ def expire_jobs(expires_at=nil)
212
+ expires_at ||= Time.now.to_f
213
+
214
+ keys = redis.zrange("gush.idx.jobs.expires_at", "-inf", expires_at, by_score: true)
215
+ return if keys.empty?
216
+
217
+ redis.del(keys.map { |key| "gush.jobs.#{key}" })
218
+ redis.zrem("gush.idx.jobs.expires_at", keys)
142
219
  end
143
220
 
144
221
  def expire_workflow(workflow, ttl=nil)
145
- ttl = ttl || configuration.ttl
146
- redis.expire("gush.workflows.#{workflow.id}", ttl)
222
+ ttl ||= configuration.ttl
223
+
224
+ if ttl&.positive?
225
+ redis.zadd("gush.idx.workflows.expires_at", Time.now.to_f + ttl, workflow.id)
226
+ else
227
+ redis.zrem("gush.idx.workflows.expires_at", workflow.id)
228
+ end
229
+
147
230
  workflow.jobs.each {|job| expire_job(workflow.id, job, ttl) }
148
231
  end
149
232
 
150
233
  def expire_job(workflow_id, job, ttl=nil)
151
- ttl = ttl || configuration.ttl
152
- redis.expire("gush.jobs.#{workflow_id}.#{job.klass}", ttl)
234
+ ttl ||= configuration.ttl
235
+
236
+ if ttl&.positive?
237
+ redis.zadd("gush.idx.jobs.expires_at", Time.now.to_f + ttl, "#{workflow_id}.#{job.klass}")
238
+ else
239
+ redis.zrem("gush.idx.jobs.expires_at", "#{workflow_id}.#{job.klass}")
240
+ end
153
241
  end
154
242
 
155
243
  def enqueue_job(workflow_id, job)
156
244
  job.enqueue!
157
245
  persist_job(workflow_id, job)
158
- queue = job.queue || configuration.namespace
159
246
 
160
- Gush::Worker.set(queue: queue).perform_later(*[workflow_id, job.name])
247
+ options = { queue: configuration.namespace }.merge(job.worker_options)
248
+ job.enqueue_worker!(options)
161
249
  end
162
250
 
163
251
  private
@@ -178,16 +266,25 @@ module Gush
178
266
  end
179
267
 
180
268
  def workflow_from_hash(hash, nodes = [])
181
- flow = hash[:klass].constantize.new(*hash[:arguments])
182
- flow.jobs = []
183
- flow.stopped = hash.fetch(:stopped, false)
184
- flow.id = hash[:id]
185
-
186
- flow.jobs = nodes.map do |node|
269
+ jobs = nodes.map do |node|
187
270
  Gush::Job.from_hash(node)
188
271
  end
189
272
 
190
- flow
273
+ internal_state = {
274
+ persisted: true,
275
+ jobs: jobs,
276
+ # For backwards compatibility, setup can only be skipped for a persisted
277
+ # workflow if there is no data missing from the persistence.
278
+ # 2024-07-23: dependencies added to persistence
279
+ skip_setup: !hash[:dependencies].nil?
280
+ }.merge(hash)
281
+
282
+ hash[:klass].constantize.new(
283
+ *hash[:arguments],
284
+ **hash[:kwargs],
285
+ globals: hash[:globals],
286
+ internal_state: internal_state
287
+ )
191
288
  end
192
289
 
193
290
  def redis
data/lib/gush/graph.rb CHANGED
@@ -4,7 +4,7 @@ require 'tmpdir'
4
4
 
5
5
  module Gush
6
6
  class Graph
7
- attr_reader :workflow, :filename, :path, :start_node, :end_node
7
+ attr_reader :workflow, :filename, :start_node, :end_node
8
8
 
9
9
  def initialize(workflow, options = {})
10
10
  @workflow = workflow
@@ -32,7 +32,7 @@ module Gush
32
32
  file_format = path.split('.')[-1]
33
33
  format = file_format if file_format.length == 3
34
34
 
35
- Graphviz::output(@graph, path: path, format: format)
35
+ Graphviz.output(@graph, path: path, format: format)
36
36
  end
37
37
 
38
38
  def path
data/lib/gush/job.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  module Gush
2
2
  class Job
3
3
  attr_accessor :workflow_id, :incoming, :outgoing, :params,
4
- :finished_at, :failed_at, :started_at, :enqueued_at, :payloads, :klass, :queue
5
- attr_reader :id, :klass, :output_payload, :params
4
+ :finished_at, :failed_at, :started_at, :enqueued_at, :payloads,
5
+ :klass, :queue, :wait
6
+ attr_reader :id, :output_payload
6
7
 
7
8
  def initialize(opts = {})
8
9
  options = opts.dup
@@ -22,7 +23,8 @@ module Gush
22
23
  failed_at: failed_at,
23
24
  params: params,
24
25
  workflow_id: workflow_id,
25
- output_payload: output_payload
26
+ output_payload: output_payload,
27
+ wait: wait
26
28
  }
27
29
  end
28
30
 
@@ -57,6 +59,14 @@ module Gush
57
59
  @failed_at = nil
58
60
  end
59
61
 
62
+ def enqueue_worker!(options = {})
63
+ Gush::Worker.set(options).perform_later(workflow_id, name)
64
+ end
65
+
66
+ def worker_options
67
+ { queue: queue, wait: wait }.compact
68
+ end
69
+
60
70
  def finish!
61
71
  @finished_at = current_timestamp
62
72
  end
@@ -126,6 +136,7 @@ module Gush
126
136
  @output_payload = opts[:output_payload]
127
137
  @workflow_id = opts[:workflow_id]
128
138
  @queue = opts[:queue]
139
+ @wait = opts[:wait]
129
140
  end
130
141
  end
131
142
  end
@@ -0,0 +1,21 @@
1
+ module Gush
2
+ class IndexWorkflowsByCreatedAtAndExpiresAt < Gush::Migration
3
+ def self.version
4
+ 1
5
+ end
6
+
7
+ def up
8
+ redis.scan_each(match: "gush.workflows.*").map do |key|
9
+ id = key.sub("gush.workflows.", "")
10
+ workflow = client.find_workflow(id)
11
+
12
+ ttl = redis.ttl(key)
13
+ redis.persist(key)
14
+ workflow.jobs.each { |job| redis.persist("gush.jobs.#{id}.#{job.klass}") }
15
+
16
+ client.persist_workflow(workflow)
17
+ client.expire_workflow(workflow, ttl.positive? ? ttl : -1)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ module Gush
2
+ class Migration
3
+ def migrate
4
+ return if migrated?
5
+
6
+ up
7
+ migrated!
8
+ end
9
+
10
+ def up
11
+ # subclass responsibility
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def version
16
+ self.class.version
17
+ end
18
+
19
+ def migrated?
20
+ redis.sismember("gush.migration.schema_migrations", version)
21
+ end
22
+
23
+ private
24
+
25
+ def migrated!
26
+ redis.sadd("gush.migration.schema_migrations", version)
27
+ end
28
+
29
+ def client
30
+ @client ||= Client.new
31
+ end
32
+
33
+ def redis
34
+ Gush::Client.redis_connection(client.configuration)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module Gush
2
+ VERSION = '4.0.0'.freeze
3
+ end
data/lib/gush/worker.rb CHANGED
@@ -30,7 +30,7 @@ module Gush
30
30
 
31
31
  private
32
32
 
33
- attr_reader :client, :workflow_id, :job, :configuration
33
+ attr_reader :workflow_id, :job
34
34
 
35
35
  def client
36
36
  @client ||= Gush::Client.new(Gush.configuration)
data/lib/gush/workflow.rb CHANGED
@@ -2,25 +2,33 @@ require 'securerandom'
2
2
 
3
3
  module Gush
4
4
  class Workflow
5
- attr_accessor :id, :jobs, :stopped, :persisted, :arguments
5
+ attr_accessor :jobs, :dependencies, :stopped, :persisted, :arguments, :kwargs, :globals
6
+ attr_writer :id
6
7
 
7
- def initialize(*args)
8
- @id = id
9
- @jobs = []
10
- @dependencies = []
11
- @persisted = false
12
- @stopped = false
8
+ def initialize(*args, globals: nil, internal_state: {}, **kwargs)
13
9
  @arguments = args
10
+ @kwargs = kwargs
11
+ @globals = globals || {}
12
+
13
+ @id = internal_state[:id] || id
14
+ @jobs = internal_state[:jobs] || []
15
+ @dependencies = internal_state[:dependencies] || []
16
+ @persisted = internal_state[:persisted] || false
17
+ @stopped = internal_state[:stopped] || false
14
18
 
15
- setup
19
+ setup unless internal_state[:skip_setup]
16
20
  end
17
21
 
18
22
  def self.find(id)
19
23
  Gush::Client.new.find_workflow(id)
20
24
  end
21
25
 
22
- def self.create(*args)
23
- flow = new(*args)
26
+ def self.page(start=0, stop=99, order: :asc)
27
+ Gush::Client.new.workflows(start, stop, order: order)
28
+ end
29
+
30
+ def self.create(*args, **kwargs)
31
+ flow = new(*args, **kwargs)
24
32
  flow.save
25
33
  flow
26
34
  end
@@ -38,7 +46,7 @@ module Gush
38
46
  persist!
39
47
  end
40
48
 
41
- def configure(*args)
49
+ def configure(*args, **kwargs)
42
50
  end
43
51
 
44
52
  def mark_as_stopped
@@ -53,7 +61,7 @@ module Gush
53
61
  client.persist_workflow(self)
54
62
  end
55
63
 
56
- def expire! (ttl=nil)
64
+ def expire!(ttl=nil)
57
65
  client.expire_workflow(self, ttl)
58
66
  end
59
67
 
@@ -111,8 +119,9 @@ module Gush
111
119
  node = klass.new({
112
120
  workflow_id: id,
113
121
  id: client.next_free_job_id(id, klass.to_s),
114
- params: opts.fetch(:params, {}),
115
- queue: opts[:queue]
122
+ params: (@globals || {}).merge(opts.fetch(:params, {})),
123
+ queue: opts[:queue],
124
+ wait: opts[:wait]
116
125
  })
117
126
 
118
127
  jobs << node
@@ -156,7 +165,7 @@ module Gush
156
165
  when stopped?
157
166
  :stopped
158
167
  else
159
- :running
168
+ :pending
160
169
  end
161
170
  end
162
171
 
@@ -174,9 +183,13 @@ module Gush
174
183
  name: name,
175
184
  id: id,
176
185
  arguments: @arguments,
186
+ kwargs: @kwargs,
187
+ globals: @globals,
188
+ dependencies: @dependencies,
177
189
  total: jobs.count,
178
190
  finished: jobs.count(&:finished?),
179
191
  klass: name,
192
+ job_klasses: jobs.map(&:class).map(&:to_s).uniq,
180
193
  status: status,
181
194
  stopped: stopped,
182
195
  started_at: started_at,
@@ -199,7 +212,7 @@ module Gush
199
212
  private
200
213
 
201
214
  def setup
202
- configure(*@arguments)
215
+ configure(*@arguments, **@kwargs)
203
216
  resolve_dependencies
204
217
  end
205
218
 
data/lib/gush.rb CHANGED
@@ -15,6 +15,7 @@ require "gush/client"
15
15
  require "gush/configuration"
16
16
  require "gush/errors"
17
17
  require "gush/job"
18
+ require "gush/migration"
18
19
  require "gush/worker"
19
20
  require "gush/workflow"
20
21
 
@@ -5,9 +5,10 @@ describe "Workflows" do
5
5
  context "when all jobs finish successfuly" do
6
6
  it "marks workflow as completed" do
7
7
  flow = TestWorkflow.create
8
- perform_enqueued_jobs do
9
- flow.start!
10
- end
8
+
9
+ ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true
10
+ flow.start!
11
+ ActiveJob::Base.queue_adapter.perform_enqueued_jobs = false
11
12
 
12
13
  flow = flow.reload
13
14
  expect(flow).to be_finished
@@ -135,7 +136,7 @@ describe "Workflows" do
135
136
 
136
137
  class SummaryJob < Gush::Job
137
138
  def perform
138
- output payloads.map { |payload| payload[:output] }
139
+ output(payloads.map { |payload| payload[:output] })
139
140
  end
140
141
  end
141
142
 
@@ -168,8 +169,8 @@ describe "Workflows" do
168
169
  INTERNAL_CONFIGURE_SPY = double('configure spy')
169
170
  expect(INTERNAL_SPY).to receive(:some_method).exactly(110).times
170
171
 
171
- # One time when persisting, second time when reloading in the spec
172
- expect(INTERNAL_CONFIGURE_SPY).to receive(:some_method).exactly(2).times
172
+ # One time when persisting; reloading does not call configure again
173
+ expect(INTERNAL_CONFIGURE_SPY).to receive(:some_method).exactly(1).time
173
174
 
174
175
  class SimpleJob < Gush::Job
175
176
  def perform