dynflow 1.9.3 → 2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/bats.yml +50 -0
  3. data/.github/workflows/release.yml +1 -1
  4. data/.github/workflows/ruby.yml +27 -46
  5. data/.gitignore +2 -0
  6. data/.rubocop.yml +3 -0
  7. data/Dockerfile +1 -1
  8. data/Gemfile +7 -8
  9. data/README.md +4 -4
  10. data/doc/pages/source/documentation/index.md +4 -4
  11. data/dynflow.gemspec +2 -3
  12. data/examples/example_helper.rb +1 -1
  13. data/examples/execution_plan_chaining.rb +56 -0
  14. data/examples/remote_executor.rb +5 -8
  15. data/lib/dynflow/action/format.rb +4 -33
  16. data/lib/dynflow/debug/telemetry/persistence.rb +1 -1
  17. data/lib/dynflow/delayed_executors/abstract_core.rb +1 -1
  18. data/lib/dynflow/delayed_plan.rb +6 -0
  19. data/lib/dynflow/director.rb +9 -1
  20. data/lib/dynflow/executors/sidekiq/core.rb +1 -1
  21. data/lib/dynflow/executors/sidekiq/redis_locking.rb +10 -3
  22. data/lib/dynflow/extensions/msgpack.rb +4 -0
  23. data/lib/dynflow/persistence.rb +14 -2
  24. data/lib/dynflow/persistence_adapters/abstract.rb +9 -1
  25. data/lib/dynflow/persistence_adapters/sequel.rb +91 -48
  26. data/lib/dynflow/persistence_adapters/sequel_migrations/025_create_execution_plan_dependencies.rb +22 -0
  27. data/lib/dynflow/rails/daemon.rb +16 -7
  28. data/lib/dynflow/testing.rb +1 -1
  29. data/lib/dynflow/version.rb +1 -1
  30. data/lib/dynflow/world.rb +34 -13
  31. data/lib/dynflow.rb +0 -1
  32. data/test/action_test.rb +3 -3
  33. data/test/bats/helpers/common.bash +67 -0
  34. data/test/bats/helpers/containers.bash +146 -0
  35. data/test/bats/setup_suite.bash +46 -0
  36. data/test/bats/sidekiq-orchestrator.bats +178 -0
  37. data/test/bats/teardown_suite.bash +16 -0
  38. data/test/concurrency_control_test.rb +0 -1
  39. data/test/daemon_test.rb +21 -2
  40. data/test/extensions_test.rb +3 -3
  41. data/test/future_execution_test.rb +150 -3
  42. data/test/persistence_test.rb +70 -3
  43. data/test/support/dummy_example.rb +4 -0
  44. data/test/test_helper.rb +19 -4
  45. data/web/views/show.erb +24 -0
  46. metadata +15 -17
  47. data/.github/install_dependencies.sh +0 -35
@@ -39,7 +39,8 @@ module Dynflow
39
39
  envelope: %w(receiver_id),
40
40
  coordinator_record: %w(id owner_id class),
41
41
  delayed: %w(execution_plan_uuid start_at start_before args_serializer frozen),
42
- output_chunk: %w(execution_plan_uuid action_id kind timestamp) }
42
+ output_chunk: %w(execution_plan_uuid action_id kind timestamp),
43
+ execution_plan_dependency: %w(execution_plan_uuid blocked_by_uuid) }
43
44
 
44
45
  SERIALIZABLE_COLUMNS = { action: %w(input output),
45
46
  delayed: %w(serialized_args),
@@ -71,20 +72,23 @@ module Dynflow
71
72
  paginate(table(table_name), options),
72
73
  options),
73
74
  options[:filters])
74
- data_set.all.map { |record| execution_plan_column_map(load_data(record, table_name)) }
75
+ records = with_retry { data_set.all }
76
+ records.map { |record| execution_plan_column_map(load_data(record, table_name)) }
75
77
  end
76
78
 
77
79
  def find_execution_plan_counts(options = {})
78
- filter(:execution_plan, table(:execution_plan), options[:filters]).count
80
+ with_retry { filter(:execution_plan, table(:execution_plan), options[:filters]).count }
79
81
  end
80
82
 
81
83
  def find_execution_plan_counts_after(timestamp, options = {})
82
- filter(:execution_plan, table(:execution_plan), options[:filters]).filter(::Sequel.lit('ended_at >= ?', timestamp)).count
84
+ with_retry { filter(:execution_plan, table(:execution_plan), options[:filters]).filter(::Sequel.lit('ended_at >= ?', timestamp)).count }
83
85
  end
84
86
 
85
87
  def find_execution_plan_statuses(options)
86
- plans = filter(:execution_plan, table(:execution_plan), options[:filters])
87
- .select(:uuid, :state, :result)
88
+ plans = with_retry do
89
+ filter(:execution_plan, table(:execution_plan), options[:filters])
90
+ .select(:uuid, :state, :result)
91
+ end
88
92
 
89
93
  plans.each_with_object({}) do |current, acc|
90
94
  uuid = current.delete(:uuid)
@@ -94,27 +98,29 @@ module Dynflow
94
98
 
95
99
  def delete_execution_plans(filters, batch_size = 1000, backup_dir = nil)
96
100
  count = 0
97
- filter(:execution_plan, table(:execution_plan), filters).each_slice(batch_size) do |plans|
98
- uuids = plans.map { |p| p.fetch(:uuid) }
99
- @db.transaction do
100
- table(:delayed).where(execution_plan_uuid: uuids).delete
101
+ with_retry do
102
+ filter(:execution_plan, table(:execution_plan), filters).each_slice(batch_size) do |plans|
103
+ uuids = plans.map { |p| p.fetch(:uuid) }
104
+ @db.transaction do
105
+ table(:delayed).where(execution_plan_uuid: uuids).delete
101
106
 
102
- steps = table(:step).where(execution_plan_uuid: uuids)
103
- backup_to_csv(:step, steps, backup_dir, 'steps.csv') if backup_dir
104
- steps.delete
107
+ steps = table(:step).where(execution_plan_uuid: uuids)
108
+ backup_to_csv(:step, steps, backup_dir, 'steps.csv') if backup_dir
109
+ steps.delete
105
110
 
106
- table(:output_chunk).where(execution_plan_uuid: uuids).delete
111
+ output_chunks = table(:output_chunk).where(execution_plan_uuid: uuids).delete
107
112
 
108
- actions = table(:action).where(execution_plan_uuid: uuids)
109
- backup_to_csv(:action, actions, backup_dir, 'actions.csv') if backup_dir
110
- actions.delete
113
+ actions = table(:action).where(execution_plan_uuid: uuids)
114
+ backup_to_csv(:action, actions, backup_dir, 'actions.csv') if backup_dir
115
+ actions.delete
111
116
 
112
- execution_plans = table(:execution_plan).where(uuid: uuids)
113
- backup_to_csv(:execution_plan, execution_plans, backup_dir, 'execution_plans.csv') if backup_dir
114
- count += execution_plans.delete
117
+ execution_plans = table(:execution_plan).where(uuid: uuids)
118
+ backup_to_csv(:execution_plan, execution_plans, backup_dir, 'execution_plans.csv') if backup_dir
119
+ count += execution_plans.delete
120
+ end
115
121
  end
122
+ return count
116
123
  end
117
- return count
118
124
  end
119
125
 
120
126
  def load_execution_plan(execution_plan_id)
@@ -127,10 +133,12 @@ module Dynflow
127
133
 
128
134
  def delete_delayed_plans(filters, batch_size = 1000)
129
135
  count = 0
130
- filter(:delayed, table(:delayed), filters).each_slice(batch_size) do |plans|
131
- uuids = plans.map { |p| p.fetch(:execution_plan_uuid) }
132
- @db.transaction do
133
- count += table(:delayed).where(execution_plan_uuid: uuids).delete
136
+ with_retry do
137
+ filter(:delayed, table(:delayed), filters).each_slice(batch_size) do |plans|
138
+ uuids = plans.map { |p| p.fetch(:execution_plan_uuid) }
139
+ @db.transaction do
140
+ count += table(:delayed).where(execution_plan_uuid: uuids).delete
141
+ end
134
142
  end
135
143
  end
136
144
  count
@@ -138,19 +146,43 @@ module Dynflow
138
146
 
139
147
  def find_old_execution_plans(age)
140
148
  table_name = :execution_plan
141
- table(table_name)
142
- .where(::Sequel.lit('ended_at <= ? AND state = ?', age, 'stopped'))
143
- .all.map { |plan| execution_plan_column_map(load_data plan, table_name) }
149
+ records = with_retry do
150
+ table(table_name)
151
+ .where(::Sequel.lit('ended_at <= ? AND state = ?', age, 'stopped'))
152
+ .all
153
+ end
154
+ records.map { |plan| execution_plan_column_map(load_data plan, table_name) }
155
+ end
156
+
157
+ def find_execution_plan_dependencies(execution_plan_id)
158
+ table(:execution_plan_dependency)
159
+ .where(execution_plan_uuid: execution_plan_id)
160
+ .select_map(:blocked_by_uuid)
161
+ end
162
+
163
+ def find_blocked_execution_plans(execution_plan_id)
164
+ table(:execution_plan_dependency)
165
+ .where(blocked_by_uuid: execution_plan_id)
166
+ .select_map(:execution_plan_uuid)
144
167
  end
145
168
 
146
- def find_past_delayed_plans(time)
169
+ def find_ready_delayed_plans(time)
147
170
  table_name = :delayed
148
- table(table_name)
149
- .where(::Sequel.lit('start_at <= ? OR (start_before IS NOT NULL AND start_before <= ?)', time, time))
150
- .where(:frozen => false)
151
- .order_by(:start_at)
152
- .all
153
- .map { |plan| load_data(plan, table_name) }
171
+ # Subquery to find delayed plans that have at least one non-stopped dependency
172
+ plans_with_unfinished_deps = table(:execution_plan_dependency)
173
+ .join(TABLES[:execution_plan], uuid: :blocked_by_uuid)
174
+ .where(::Sequel.~(state: 'stopped'))
175
+ .select(:execution_plan_uuid)
176
+
177
+ records = with_retry do
178
+ table(table_name)
179
+ .where(::Sequel.lit('start_at IS NULL OR (start_at <= ? OR (start_before IS NOT NULL AND start_before <= ?))', time, time))
180
+ .where(:frozen => false)
181
+ .exclude(execution_plan_uuid: plans_with_unfinished_deps)
182
+ .order_by(:start_at)
183
+ .all
184
+ end
185
+ records.map { |plan| load_data(plan, table_name) }
154
186
  end
155
187
 
156
188
  def load_delayed_plan(execution_plan_id)
@@ -163,6 +195,10 @@ module Dynflow
163
195
  save :delayed, { execution_plan_uuid: execution_plan_id }, value, with_data: false
164
196
  end
165
197
 
198
+ def chain_execution_plan(first, second)
199
+ save :execution_plan_dependency, {}, { execution_plan_uuid: second, blocked_by_uuid: first }, with_data: false
200
+ end
201
+
166
202
  def load_step(execution_plan_id, step_id)
167
203
  load :step, execution_plan_uuid: execution_plan_id, id: step_id
168
204
  end
@@ -205,7 +241,9 @@ module Dynflow
205
241
  end
206
242
 
207
243
  def delete_output_chunks(execution_plan_id, action_id)
208
- filter(:output_chunk, table(:output_chunk), { execution_plan_uuid: execution_plan_id, action_id: action_id }).delete
244
+ with_retry do
245
+ filter(:output_chunk, table(:output_chunk), { execution_plan_uuid: execution_plan_id, action_id: action_id }).delete
246
+ end
209
247
  end
210
248
 
211
249
  def connector_feature!
@@ -221,28 +259,30 @@ module Dynflow
221
259
 
222
260
  def pull_envelopes(receiver_id)
223
261
  connector_feature!
224
- db.transaction do
225
- data_set = table(:envelope).where(receiver_id: receiver_id).all
226
- envelopes = data_set.map { |record| load_data(record) }
262
+ with_retry do
263
+ db.transaction do
264
+ data_set = table(:envelope).where(receiver_id: receiver_id).all
265
+ envelopes = data_set.map { |record| load_data(record) }
227
266
 
228
- table(:envelope).where(id: data_set.map { |d| d[:id] }).delete
229
- return envelopes
267
+ table(:envelope).where(id: data_set.map { |d| d[:id] }).delete
268
+ return envelopes
269
+ end
230
270
  end
231
271
  end
232
272
 
233
273
  def push_envelope(envelope)
234
274
  connector_feature!
235
- table(:envelope).insert(prepare_record(:envelope, envelope))
275
+ with_retry { table(:envelope).insert(prepare_record(:envelope, envelope)) }
236
276
  end
237
277
 
238
278
  def prune_envelopes(receiver_ids)
239
279
  connector_feature!
240
- table(:envelope).where(receiver_id: receiver_ids).delete
280
+ with_retry { table(:envelope).where(receiver_id: receiver_ids).delete }
241
281
  end
242
282
 
243
283
  def prune_undeliverable_envelopes
244
284
  connector_feature!
245
- table(:envelope).where(receiver_id: table(:coordinator_record).select(:id)).invert.delete
285
+ with_retry { table(:envelope).where(receiver_id: table(:coordinator_record).select(:id)).invert.delete }
246
286
  end
247
287
 
248
288
  def coordinator_feature!
@@ -263,7 +303,7 @@ module Dynflow
263
303
 
264
304
  def delete_coordinator_record(class_name, record_id)
265
305
  coordinator_feature!
266
- table(:coordinator_record).where(class: class_name, id: record_id).delete
306
+ with_retry { table(:coordinator_record).where(class: class_name, id: record_id).delete }
267
307
  end
268
308
 
269
309
  def find_coordinator_records(options)
@@ -275,7 +315,9 @@ module Dynflow
275
315
  if exclude_owner_id
276
316
  data_set = data_set.exclude(:owner_id => exclude_owner_id)
277
317
  end
278
- data_set.all.map { |record| load_data(record) }
318
+ with_retry do
319
+ data_set.all.map { |record| load_data(record) }
320
+ end
279
321
  end
280
322
 
281
323
  def to_hash
@@ -301,7 +343,8 @@ module Dynflow
301
343
  envelope: :dynflow_envelopes,
302
344
  coordinator_record: :dynflow_coordinator_records,
303
345
  delayed: :dynflow_delayed_plans,
304
- output_chunk: :dynflow_output_chunks }
346
+ output_chunk: :dynflow_output_chunks,
347
+ execution_plan_dependency: :dynflow_execution_plan_dependencies }
305
348
 
306
349
  def table(which)
307
350
  db[TABLES.fetch(which)]
@@ -444,7 +487,7 @@ module Dynflow
444
487
  end
445
488
 
446
489
  def delete(what, condition)
447
- table(what).where(Utils.symbolize_keys(condition)).delete
490
+ with_retry { table(what).where(Utils.symbolize_keys(condition)).delete }
448
491
  end
449
492
 
450
493
  def extract_metadata(what, value)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ type = database_type
6
+ create_table(:dynflow_execution_plan_dependencies) do
7
+ column_properties = if type.to_s.include?('postgres')
8
+ { type: :uuid }
9
+ else
10
+ { type: String, size: 36, fixed: true, null: false }
11
+ end
12
+ foreign_key :execution_plan_uuid, :dynflow_execution_plans, on_delete: :cascade, **column_properties
13
+ foreign_key :blocked_by_uuid, :dynflow_execution_plans, on_delete: :cascade, **column_properties
14
+ index :blocked_by_uuid
15
+ index :execution_plan_uuid
16
+ end
17
+ end
18
+
19
+ down do
20
+ drop_table(:dynflow_execution_plan_dependencies)
21
+ end
22
+ end
@@ -23,23 +23,32 @@ module Dynflow
23
23
  @daemons_class || ::Daemons
24
24
  end
25
25
 
26
+ def stdout
27
+ STDOUT
28
+ end
29
+
30
+ def stderr
31
+ STDERR
32
+ end
33
+
26
34
  # Load the Rails environment and initialize the executor in this thread.
27
35
  def run(rails_root = Dir.pwd, options = {})
28
- STDOUT.puts('Starting Rails environment')
36
+ stdout.puts('Starting Rails environment')
29
37
  rails_env_file = File.expand_path('./config/environment.rb', rails_root)
30
38
  unless File.exist?(rails_env_file)
31
39
  raise "#{rails_root} doesn't seem to be a Rails root directory"
32
40
  end
33
41
 
34
- STDERR.puts("Starting dynflow with the following options: #{options}")
42
+ stderr.puts("Starting dynflow with the following options: #{options}")
35
43
 
36
44
  ::Rails.application.dynflow.executor!
37
45
 
38
46
  if options[:memory_limit] && options[:memory_limit].to_i > 0
39
47
  ::Rails.application.dynflow.config.on_init do |world|
48
+ stdout_cap = stdout
40
49
  memory_watcher = initialize_memory_watcher(world, options[:memory_limit], options)
41
50
  world.terminated.on_resolution do
42
- STDOUT.puts("World has been terminated")
51
+ stdout_cap.puts("World has been terminated")
43
52
  memory_watcher = nil # the object can be disposed
44
53
  end
45
54
  end
@@ -48,10 +57,10 @@ module Dynflow
48
57
  require rails_env_file
49
58
  ::Rails.application.dynflow.initialize!
50
59
  world_id = ::Rails.application.dynflow.world.id
51
- STDOUT.puts("Everything ready for world: #{world_id}")
60
+ stdout.puts("Everything ready for world: #{world_id}")
52
61
  sleep
53
62
  ensure
54
- STDOUT.puts('Exiting')
63
+ stdout.puts('Exiting')
55
64
  end
56
65
 
57
66
  # run the executor as a daemon
@@ -68,7 +77,7 @@ module Dynflow
68
77
  raise "Command exptected to be 'start', 'stop', 'restart', 'run', was #{command.inspect}"
69
78
  end
70
79
 
71
- STDOUT.puts("Dynflow Executor: #{command} in progress")
80
+ stdout.puts("Dynflow Executor: #{command} in progress")
72
81
 
73
82
  options[:executors_count].times do
74
83
  daemons_class.run_proc(
@@ -79,7 +88,7 @@ module Dynflow
79
88
  ::Logging.reopen
80
89
  run(options[:rails_root], options)
81
90
  rescue => e
82
- STDERR.puts e.message
91
+ stderr.puts e.message
83
92
  ::Rails.logger.fatal('Failed running Dynflow daemon')
84
93
  ::Rails.logger.fatal(e)
85
94
  exit 1
@@ -5,7 +5,7 @@ module Dynflow
5
5
  extend Algebrick::TypeCheck
6
6
 
7
7
  def self.logger_adapter
8
- @logger_adapter || LoggerAdapters::Simple.new($stdout, 1)
8
+ @logger_adapter || LoggerAdapters::Simple.new('test.log', 1)
9
9
  end
10
10
 
11
11
  def self.logger_adapter=(adapter)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dynflow
4
- VERSION = '1.9.3'
4
+ VERSION = '2.0.0'
5
5
  end
data/lib/dynflow/world.rb CHANGED
@@ -202,6 +202,16 @@ module Dynflow
202
202
  Scheduled[execution_plan.id]
203
203
  end
204
204
 
205
+ def chain(plan_uuids, action_class, *args)
206
+ plan_uuids = [plan_uuids] unless plan_uuids.is_a? Array
207
+ result = delay_with_options(action_class: action_class, args: args, delay_options: { frozen: true })
208
+ plan_uuids.each do |plan_uuid|
209
+ persistence.chain_execution_plan(plan_uuid, result.execution_plan_id)
210
+ end
211
+ persistence.set_delayed_plan_frozen(result.execution_plan_id, false)
212
+ result
213
+ end
214
+
205
215
  def plan_elsewhere(action_class, *args)
206
216
  execution_plan = ExecutionPlan.new(self, nil)
207
217
  execution_plan.delay(nil, action_class, {}, *args)
@@ -325,17 +335,7 @@ module Dynflow
325
335
  logger.info "start terminating throttle_limiter..."
326
336
  throttle_limiter.terminate.wait(termination_timeout)
327
337
 
328
- if executor
329
- connector.stop_receiving_new_work(self, termination_timeout)
330
-
331
- logger.info "start terminating executor..."
332
- executor.terminate.wait(termination_timeout)
333
-
334
- logger.info "start terminating executor dispatcher..."
335
- executor_dispatcher_terminated = Concurrent::Promises.resolvable_future
336
- executor_dispatcher.ask([:start_termination, executor_dispatcher_terminated])
337
- executor_dispatcher_terminated.wait(termination_timeout)
338
- end
338
+ terminate_executor
339
339
 
340
340
  logger.info "start terminating client dispatcher..."
341
341
  client_dispatcher_terminated = Concurrent::Promises.resolvable_future
@@ -350,7 +350,11 @@ module Dynflow
350
350
  clock.ask(:terminate!).wait(termination_timeout)
351
351
  end
352
352
 
353
- coordinator.delete_world(registered_world, true)
353
+ begin
354
+ coordinator.delete_world(registered_world, true)
355
+ rescue Dynflow::Errors::FatalPersistenceError => e
356
+ nil
357
+ end
354
358
  @terminated.resolve
355
359
  true
356
360
  rescue => e
@@ -361,7 +365,10 @@ module Dynflow
361
365
  termination_future.wait(termination_timeout)
362
366
  end.on_resolution do
363
367
  @terminated.resolve
364
- Thread.new { Kernel.exit } if @exit_on_terminate.true?
368
+ Thread.new do
369
+ logger.info 'World terminated, exiting.'
370
+ Kernel.exit if @exit_on_terminate.true?
371
+ end
365
372
  end
366
373
  end
367
374
  end
@@ -395,6 +402,20 @@ module Dynflow
395
402
  initialized.wait
396
403
  return actor
397
404
  end
405
+
406
+ def terminate_executor
407
+ return unless executor
408
+
409
+ connector.stop_receiving_new_work(self, termination_timeout)
410
+
411
+ logger.info "start terminating executor..."
412
+ executor.terminate.wait(termination_timeout)
413
+
414
+ logger.info "start terminating executor dispatcher..."
415
+ executor_dispatcher_terminated = Concurrent::Promises.resolvable_future
416
+ executor_dispatcher.ask([:start_termination, executor_dispatcher_terminated])
417
+ executor_dispatcher_terminated.wait(termination_timeout)
418
+ end
398
419
  end
399
420
  # rubocop:enable Metrics/ClassLength
400
421
  end
data/lib/dynflow.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'apipie-params'
4
3
  require 'algebrick'
5
4
  require 'set'
6
5
  require 'base64'
data/test/action_test.rb CHANGED
@@ -135,7 +135,7 @@ module Dynflow
135
135
  plan = create_and_plan_action(PlanEventedAction, { time: 0.5 })
136
136
  action = run_action plan
137
137
 
138
- _(action.output[:status]).must_equal nil
138
+ assert_nil action.output[:status]
139
139
  _(action.world.clock.pending_pings.first).wont_be_nil
140
140
  _(action.state).must_equal :suspended
141
141
 
@@ -150,7 +150,7 @@ module Dynflow
150
150
  plan = create_and_plan_action(PlanEventedAction, { time: nil })
151
151
  action = run_action plan
152
152
 
153
- _(action.output[:status]).must_equal nil
153
+ assert_nil action.output[:status]
154
154
  _(action.world.clock.pending_pings.first).must_be_nil
155
155
  _(action.world.executor.events_to_process.first).wont_be_nil
156
156
  _(action.state).must_equal :suspended
@@ -905,7 +905,7 @@ module Dynflow
905
905
 
906
906
  it 'collects and drops output chunks' do
907
907
  action = create_and_plan_action(OutputChunkAction)
908
- _(action.pending_output_chunks).must_equal nil
908
+ assert_nil action.pending_output_chunks
909
909
 
910
910
  action = run_action(action)
911
911
  _(action.pending_output_chunks.count).must_equal 1
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # Common helper functions for bats tests
3
+
4
+ # Get the project root directory
5
+ get_project_root() {
6
+ local dir="${BATS_TEST_DIRNAME}"
7
+ while [ "${dir}" != "/" ]; do
8
+ if [ -f "${dir}/dynflow.gemspec" ]; then
9
+ echo "${dir}"
10
+ return 0
11
+ fi
12
+ dir="$(dirname "${dir}")"
13
+ done
14
+ echo "ERROR: Could not find project root" >&2
15
+ return 1
16
+ }
17
+
18
+ # Setup test environment variables
19
+ setup_test_env() {
20
+ export PROJECT_ROOT="$(get_project_root)"
21
+ export BUNDLE_GEMFILE="${PROJECT_ROOT}/Gemfile"
22
+
23
+ # Set database URLs for tests
24
+ export DATABASE_URL="$(get_postgres_url)"
25
+ export REDIS_URL="$(get_redis_url)"
26
+ export DB_CONN_STRING="$DATABASE_URL"
27
+
28
+ # Test directories
29
+ export TEST_PIDDIR="${BATS_TEST_TMPDIR}/pids"
30
+ }
31
+
32
+ run_background() {
33
+ local label="$1"
34
+ shift
35
+
36
+ local log_file="$(bg_output_file "$label")"
37
+ mkdir -p "$TEST_PIDDIR"
38
+ (
39
+ "$@" 2>&1 &
40
+ echo $! >"${TEST_PIDDIR}/${label}.pid"
41
+ ) | tee "$log_file" | sed "s/^/${label}: /" &
42
+ }
43
+
44
+ bg_output_file() {
45
+ local label="$1"
46
+
47
+ echo "${BATS_TEST_TMPDIR}/${label}.log"
48
+ }
49
+
50
+ # A function that polls a given command until it succeeds or until it runs out
51
+ wait_for() {
52
+ local timeout="$1"
53
+ local interval="$2"
54
+ shift 2
55
+
56
+ local elapsed=0
57
+ while [ "$elapsed" -lt "$timeout" ]; do
58
+ if "$@" >/dev/null 2>&1; then
59
+ return 0
60
+ fi
61
+ sleep "$interval"
62
+ elapsed=$((elapsed + interval))
63
+ done
64
+
65
+ echo "Timeout after ${timeout}s waiting for: $*" >&2
66
+ return 1
67
+ }