ductwork 1.2.0 → 1.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: 402e7de6f5703214be13a443e54a5b74f113500a22ad42727db969a7bcb39b69
4
- data.tar.gz: 9889dd598476068548da0f6cf9e63cf94f72e6e124b837d3abe2994de6a2770f
3
+ metadata.gz: 94c5c2250e4a96fee3aaeac379ec1ee58ac85e6aa5c2f0ccffc67c9e66d4499d
4
+ data.tar.gz: ac6aac4135803b6d0712db4b4491202b64280a506a3101baba6563f549e5ee9d
5
5
  SHA512:
6
- metadata.gz: 2eb520b852ff56484b7afd3f89a525f4e444ba2a04a03ff3d97152bdad625cf5d393a2500a72bcdd2ca8a4122f726caaadcd8aa4c626e70e8ba6397ec84bd5df
7
- data.tar.gz: 8667ad5225297be4b5c98383fbe0ad3766e18a33391912d110e9aae94bcaa5c9949089fb2c8f4eb0d6f0f17c8588626e82e6c7abe90aa0293a2081dffe8aef82
6
+ metadata.gz: d7305c2c9a26693cfa92dce562016420aafe4d745cb3efb4450b5192a8c036d7db5d96023b71979bd15c817059c2f8f2933f974a441066ec891cb2132d802c99
7
+ data.tar.gz: 54dfdadfcafe3e1e4a214f5d11599e87bc3b114387702b426aad5c146ec7ca2042c42e0fa001cd0d598e11b515a342f8f01d5ac3b96a4f6b982d6579f93b50a7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Ductwork Changelog
2
2
 
3
+ ## [1.3.0]
4
+
5
+ - feat: add more `MigrationHelper` methods for swapping and updating the availability claim index
6
+ - fix: resolve the database adapter from the configuration (`Ductwork::Record.adapter`) instead of `Ductwork::Record.connection.adapter_name`, and take the database clock's reading through `connection_pool.with_connection` - `ActiveRecord::Base#connection` is soft deprecated and takes a *sticky* lease, so `ConnectionPool#with_connection` skips its own checkin (it releases only `unless lease.sticky`) and every advancer or worker thread that touched the claim hot path pinned a pool slot for its lifetime; the Rails executor checked those back in per unit of work so long-running processes did not leak, but a host app setting `config.active_record.permanent_connection_checkout = :disallowed` would have raised, and `:deprecated` emitted a warning per claim
7
+ - fix: `ExecutionClaim` now selects row locking on Oracle as it always intended - the predicate tests `/oracle_enhanced/i` but was matched against `connection.adapter_name.downcase`, which renders as `oracleenhanced` with no underscore, so Oracle silently fell through to optimistic locking; it is matched against the configured adapter (`oracle_enhanced`) now. Oracle is not covered by CI
8
+ - fix: make `Ductwork::Processes::ProcessSupervisor#shutdown` idempotent and serialize it on a mutex - `run` calls `shutdown` itself once its loop sees the running context go down, so a caller that stops the supervisor from another thread raced the supervisor's own call and both ran `reap_own_process_record!` at the same time
9
+
3
10
  ## [1.2.0]
4
11
 
5
12
  - feat: add `add_availability_claim_index` and `remove_availability_claim_index` to `Ductwork::MigrationHelper`, which own the adapter-specific shape and the name of the availability claim index
@@ -26,7 +26,7 @@ module Ductwork
26
26
  first_set_at: Time.current,
27
27
  last_set_at: Time.current,
28
28
  }
29
- opts = if Ductwork::Tuple.connection.adapter_name.match?(/mysql|trilogy/i)
29
+ opts = if Ductwork::Tuple.adapter.match?(/mysql|trilogy/i)
30
30
  {}
31
31
  else
32
32
  { unique_by: %i[run_id key] }
@@ -1,10 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- # NOTE: these are SQL fragments that resolve against the database server's
5
- # clock instead of the calling Ruby process's clock. use them in `WHERE`
6
- # clauses that compare between a stored timestamp and "now" so that NTP
7
- # drift between hosts cannot make healthy work look stale (or vice versa)
8
4
  class DatabaseClock
9
5
  def self.ago_sql(column, interval)
10
6
  new(column).ago_sql(interval)
@@ -15,7 +11,7 @@ module Ductwork
15
11
  end
16
12
 
17
13
  def self.now
18
- adapter = Ductwork::Record.connection.adapter_name.downcase
14
+ adapter = Ductwork::Record.adapter
19
15
  sql =
20
16
  case adapter
21
17
  when /postgresql|cockroach/i
@@ -29,7 +25,9 @@ module Ductwork
29
25
  else
30
26
  raise NotImplementedError, "Database clock does not support adapter #{adapter}"
31
27
  end
32
- raw = Ductwork::Record.connection.select_value(sql)
28
+ raw = Ductwork::Record.connection_pool.with_connection do |conn|
29
+ conn.select_value(sql)
30
+ end
33
31
 
34
32
  case raw
35
33
  when ::Time, ::DateTime
@@ -41,7 +39,7 @@ module Ductwork
41
39
  end
42
40
 
43
41
  def initialize(column)
44
- @adapter = Ductwork::Record.connection.adapter_name.downcase
42
+ @adapter = Ductwork::Record.adapter
45
43
  @column = column
46
44
  end
47
45
 
@@ -5,7 +5,7 @@ module Ductwork
5
5
  def initialize(klass, owner_process_id)
6
6
  @klass = klass
7
7
  @owner_process_id = owner_process_id
8
- @adapter = Ductwork::Record.connection.adapter_name.downcase
8
+ @adapter = Ductwork::Record.adapter
9
9
  end
10
10
 
11
11
  def latest
@@ -2,29 +2,113 @@
2
2
 
3
3
  module Ductwork
4
4
  module MigrationHelper
5
+ class OpenTransactionError < StandardError; end
6
+
5
7
  AVAILABILITY_CLAIM_INDEX_NAME = "index_ductwork_availabilities_on_claim_latest"
8
+ AVAILABILITY_CLAIM_INDEX_NAME_V2 = "index_ductwork_availabilities_on_claim_latest_v2"
9
+ AVAILABILITY_CLAIM_INDEX_NAMES = [
10
+ AVAILABILITY_CLAIM_INDEX_NAME,
11
+ AVAILABILITY_CLAIM_INDEX_NAME_V2,
12
+ ].freeze
6
13
 
7
- def add_availability_claim_index(extra_columns: [], where_extra: nil)
14
+ def add_availability_claim_index(
15
+ extra_columns: [],
16
+ where_extra: nil,
17
+ name: AVAILABILITY_CLAIM_INDEX_NAME,
18
+ online: false
19
+ )
8
20
  if mysql?
9
- add_index :ductwork_availabilities,
10
- [:pipeline_klass, :completed_at, *extra_columns, :started_at],
11
- name: AVAILABILITY_CLAIM_INDEX_NAME
21
+ columns = [:pipeline_klass, :completed_at, *extra_columns, :started_at]
22
+ options = { name: }
12
23
  else
13
- predicate = ["completed_at IS NULL", where_extra].compact.join(" AND ")
24
+ columns = %i[pipeline_klass started_at]
25
+ options = {
26
+ name: name,
27
+ where: ["completed_at IS NULL", where_extra].compact.join(" AND "),
28
+ }
29
+ end
14
30
 
15
- add_index :ductwork_availabilities,
16
- %i[pipeline_klass started_at],
17
- name: AVAILABILITY_CLAIM_INDEX_NAME,
18
- where: predicate
31
+ if online
32
+ add_index_online :ductwork_availabilities, columns, **options
33
+ else
34
+ add_index :ductwork_availabilities, columns, **options
19
35
  end
20
36
  end
21
37
 
22
- def remove_availability_claim_index
23
- remove_index :ductwork_availabilities,
24
- name: AVAILABILITY_CLAIM_INDEX_NAME,
38
+ def remove_availability_claim_index(name: AVAILABILITY_CLAIM_INDEX_NAME)
39
+ remove_index :ductwork_availabilities, name: name, if_exists: true
40
+ end
41
+
42
+ def swap_availability_claim_index(to:, extra_columns: [], where_extra: nil)
43
+ unless AVAILABILITY_CLAIM_INDEX_NAMES.include?(to)
44
+ raise ArgumentError, "unrecognized availability claim index name: #{to}"
45
+ end
46
+
47
+ add_availability_claim_index(
48
+ extra_columns: extra_columns,
49
+ where_extra: where_extra,
50
+ name: to,
51
+ online: true
52
+ )
53
+
54
+ (AVAILABILITY_CLAIM_INDEX_NAMES - [to]).each do |stale|
55
+ remove_index_online :ductwork_availabilities, name: stale
56
+ end
57
+ end
58
+
59
+ def add_index_online(table_name, column_names, **options)
60
+ ensure_ddl_transaction_disabled!
61
+
62
+ name = options[:name] || connection.index_name(table_name, column_names)
63
+
64
+ drop_invalid_index(table_name, name)
65
+
66
+ return if connection.index_name_exists?(table_name, name)
67
+
68
+ full_options = options.merge(name:)
69
+ full_options[:algorithm] = :concurrently if postgresql?
70
+
71
+ add_index table_name, column_names, **full_options
72
+ end
73
+
74
+ def remove_index_online(table_name, name:)
75
+ ensure_ddl_transaction_disabled!
76
+
77
+ return unless connection.index_name_exists?(table_name, name)
78
+
79
+ options = { name: name, if_exists: true }
80
+ options[:algorithm] = :concurrently if postgresql?
81
+
82
+ remove_index table_name, **options
83
+ end
84
+
85
+ def drop_invalid_index(table_name, index_name)
86
+ return unless postgresql?
87
+
88
+ invalid = connection.select_value(<<~SQL.squish)
89
+ SELECT 1 FROM pg_index
90
+ WHERE indexrelid = to_regclass(#{connection.quote(index_name)})
91
+ AND NOT indisvalid
92
+ SQL
93
+
94
+ return if invalid.blank?
95
+
96
+ remove_index table_name,
97
+ name: index_name,
98
+ algorithm: :concurrently,
25
99
  if_exists: true
26
100
  end
27
101
 
102
+ def ensure_ddl_transaction_disabled!
103
+ return unless postgresql?
104
+ return unless connection.transaction_open?
105
+
106
+ raise OpenTransactionError, <<~MESSAGE.squish
107
+ online index changes cannot run inside a transaction on PostgreSQL;
108
+ declare `disable_ddl_transaction!` in the migration
109
+ MESSAGE
110
+ end
111
+
28
112
  def create_ductwork_table(table_name, &block)
29
113
  if postgresql?
30
114
  create_table table_name, id: :uuid, &block
@@ -14,6 +14,10 @@ module Ductwork
14
14
  "ductwork_"
15
15
  end
16
16
 
17
+ def self.adapter
18
+ connection_db_config.adapter.to_s.downcase
19
+ end
20
+
17
21
  private
18
22
 
19
23
  def generate_uuid_v7
@@ -118,7 +118,7 @@ module Ductwork
118
118
  # relies on the advancer retrying a deadlock victim; SQLite has no row-level
119
119
  # locks, so `lock!` is a no-op there and the deadlock cannot occur.
120
120
  def lock_for_terminal_resolution!
121
- if Ductwork::Record.connection.adapter_name.downcase.match?(/postgresql|cockroach/)
121
+ if Ductwork::Record.adapter.match?(/postgresql|cockroach/)
122
122
  lock!("FOR NO KEY UPDATE")
123
123
  else
124
124
  lock!
@@ -3,8 +3,6 @@
3
3
  module Ductwork
4
4
  module Processes
5
5
  class ProcessSupervisor # rubocop:todo Metrics/ClassLength
6
- # Upper bound on how long sig_kill_process will block the supervisor
7
- # loop waiting for a KILL'd child to be reaped (see sig_kill_process).
8
6
  KILL_REAP_TIMEOUT = 5 # seconds
9
7
  KILL_REAP_INTERVAL = 0.1
10
8
 
@@ -13,6 +11,8 @@ module Ductwork
13
11
  def initialize
14
12
  @running_context = Ductwork::RunningContext.new
15
13
  @workers = []
14
+ @shutdown_mutex = Mutex.new
15
+ @shutdown_complete = false
16
16
 
17
17
  run_hooks_for(:start)
18
18
 
@@ -53,22 +53,28 @@ module Ductwork
53
53
  end
54
54
 
55
55
  def shutdown
56
- running_context.shutdown!
57
- Ductwork.logger.debug(
58
- msg: "Beginning shutdown",
59
- role: :process_supervisor
60
- )
56
+ shutdown_mutex.synchronize do
57
+ next if shutdown_complete
58
+
59
+ running_context.shutdown!
60
+ Ductwork.logger.debug(
61
+ msg: "Beginning shutdown",
62
+ role: :process_supervisor
63
+ )
61
64
 
62
- terminate_gracefully
63
- wait_for_workers_to_exit
64
- terminate_immediately
65
- reap_own_process_record!
66
- run_hooks_for(:stop)
65
+ terminate_gracefully
66
+ wait_for_workers_to_exit
67
+ terminate_immediately
68
+ reap_own_process_record!
69
+ run_hooks_for(:stop)
70
+
71
+ @shutdown_complete = true
72
+ end
67
73
  end
68
74
 
69
75
  private
70
76
 
71
- attr_reader :running_context
77
+ attr_reader :running_context, :shutdown_mutex, :shutdown_complete
72
78
 
73
79
  def adopt_or_create_process!
74
80
  Ductwork.wrap_with_app_executor do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ductwork
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -10,113 +10,113 @@ module Ductwork
10
10
  source_root File.expand_path("templates", __dir__)
11
11
 
12
12
  def create_files
13
- connection = Ductwork::Record.connection
14
-
15
- if Ductwork::Availability.column_names.exclude?("pipeline_klass")
16
- migration_template "db/denormalize_pipeline_klass_on_availabilities.rb",
17
- "db/migrate/denormalize_pipeline_klass_on_availabilities.rb"
18
- end
19
-
20
- if Ductwork::Pipeline.column_for_attribute("id").type != :uuid
21
- migration_template "db/migrate_tables_to_uuid_primary_key.rb",
22
- "db/migrate/migrate_tables_to_uuid_primary_key.rb"
23
- end
24
-
25
- if !connection.table_exists?(:ductwork_branches)
26
- migration_template "db/create_ductwork_branches.rb",
27
- "db/migrate/create_ductwork_branches.rb"
28
- end
29
-
30
- if !connection.table_exists?(:ductwork_branch_links)
31
- migration_template "db/create_ductwork_branch_links.rb",
32
- "db/migrate/create_ductwork_branch_links.rb"
33
- end
34
-
35
- if Ductwork::Step.column_names.exclude?("branch_id")
36
- migration_template "db/associate_steps_to_branches.rb",
37
- "db/migrate/associate_steps_to_branches.rb"
38
- migration_template "db/backfill_branch_ids_on_steps.rb",
39
- "db/migrate/backfill_branch_ids_on_steps.rb"
40
- end
41
-
42
- if !connection.table_exists?(:ductwork_transitions)
43
- migration_template "db/create_ductwork_transitions.rb",
44
- "db/migrate/create_ductwork_transitions.rb"
45
- end
46
-
47
- if !connection.table_exists?(:ductwork_advancements)
48
- migration_template "db/create_ductwork_advancements.rb",
49
- "db/migrate/create_ductwork_advancements.rb"
50
- end
51
-
52
- if Ductwork::Execution.column_names.include?("process_id")
53
- migration_template "db/update_process_associations.rb",
54
- "db/migrate/update_process_associations.rb"
55
- end
56
-
57
- if connection.table_exists?(:ductwork_runs)
58
- migration_template "db/rename_runs_to_attempts.rb",
59
- "db/migrate/rename_runs_to_attempts.rb"
60
- end
61
-
62
- if !connection.table_exists?(:ductwork_runs)
63
- migration_template "db/create_ductwork_runs.rb",
64
- "db/migrate/create_ductwork_runs.rb"
65
- end
66
-
67
- if Ductwork::Branch.column_names.include?("pipeline_id")
68
- migration_template "db/associate_branches_to_runs.rb",
69
- "db/migrate/associate_branches_to_runs.rb"
70
- end
71
-
72
- if Ductwork::Step.column_names.include?("pipeline_id")
73
- migration_template "db/associate_steps_to_runs.rb",
74
- "db/migrate/associate_steps_to_runs.rb"
75
- end
76
-
77
- if Ductwork::Tuple.column_names.include?("pipeline_id")
78
- migration_template "db/associate_tuples_to_runs.rb",
79
- "db/migrate/associate_tuples_to_runs.rb"
80
- end
81
-
82
- if Ductwork::Execution.column_names.exclude?("crash_count")
83
- migration_template "db/add_crash_count_to_ductwork_executions.rb",
84
- "db/migrate/add_crash_count_to_ductwork_executions.rb"
85
- end
86
-
87
- if Ductwork::Advancement.column_names.exclude?("crash_count")
88
- migration_template "db/add_crash_count_to_ductwork_advancements.rb",
89
- "db/migrate/add_crash_count_to_ductwork_advancements.rb"
90
- end
91
-
92
- if Ductwork::Execution.column_names.exclude?("process_id")
93
- migration_template "db/add_process_id_to_ductwork_executions.rb",
94
- "db/migrate/add_process_id_to_ductwork_executions.rb"
95
- end
96
-
97
- if Ductwork::Process.column_names.exclude?("role")
98
- migration_template "db/add_role_to_ductwork_processes.rb",
99
- "db/migrate/add_role_to_ductwork_processes.rb"
100
- end
101
-
102
- if !connection.index_exists?(:ductwork_results, %i[result_type created_at])
103
- migration_template "db/add_indexes_to_ductwork_results.rb",
104
- "db/migrate/add_indexes_to_ductwork_results.rb"
105
- end
106
-
107
- if !connection.index_exists?(:ductwork_runs, :started_at)
108
- migration_template "db/add_indexes_to_ductwork_runs.rb",
109
- "db/migrate/add_indexes_to_ductwork_runs.rb"
110
- end
111
-
112
- if !connection.index_exists?(:ductwork_runs, %i[pipeline_id started_at])
113
- migration_template "db/add_pipeline_started_index_to_ductwork_runs.rb",
114
- "db/migrate/add_pipeline_started_index_to_ductwork_runs.rb"
115
- end
116
-
117
- if !connection.index_exists?(:ductwork_transitions, name: "index_ductwork_transitions_on_latest_open")
118
- migration_template "db/add_indexes_to_ductwork_transitions.rb",
119
- "db/migrate/add_indexes_to_ductwork_transitions.rb"
13
+ Ductwork::Record.connection_pool.with_connection do |connection|
14
+ if Ductwork::Availability.column_names.exclude?("pipeline_klass")
15
+ migration_template "db/denormalize_pipeline_klass_on_availabilities.rb",
16
+ "db/migrate/denormalize_pipeline_klass_on_availabilities.rb"
17
+ end
18
+
19
+ if Ductwork::Pipeline.column_for_attribute("id").type != :uuid
20
+ migration_template "db/migrate_tables_to_uuid_primary_key.rb",
21
+ "db/migrate/migrate_tables_to_uuid_primary_key.rb"
22
+ end
23
+
24
+ if !connection.table_exists?(:ductwork_branches)
25
+ migration_template "db/create_ductwork_branches.rb",
26
+ "db/migrate/create_ductwork_branches.rb"
27
+ end
28
+
29
+ if !connection.table_exists?(:ductwork_branch_links)
30
+ migration_template "db/create_ductwork_branch_links.rb",
31
+ "db/migrate/create_ductwork_branch_links.rb"
32
+ end
33
+
34
+ if Ductwork::Step.column_names.exclude?("branch_id")
35
+ migration_template "db/associate_steps_to_branches.rb",
36
+ "db/migrate/associate_steps_to_branches.rb"
37
+ migration_template "db/backfill_branch_ids_on_steps.rb",
38
+ "db/migrate/backfill_branch_ids_on_steps.rb"
39
+ end
40
+
41
+ if !connection.table_exists?(:ductwork_transitions)
42
+ migration_template "db/create_ductwork_transitions.rb",
43
+ "db/migrate/create_ductwork_transitions.rb"
44
+ end
45
+
46
+ if !connection.table_exists?(:ductwork_advancements)
47
+ migration_template "db/create_ductwork_advancements.rb",
48
+ "db/migrate/create_ductwork_advancements.rb"
49
+ end
50
+
51
+ if Ductwork::Execution.column_names.include?("process_id")
52
+ migration_template "db/update_process_associations.rb",
53
+ "db/migrate/update_process_associations.rb"
54
+ end
55
+
56
+ if connection.table_exists?(:ductwork_runs)
57
+ migration_template "db/rename_runs_to_attempts.rb",
58
+ "db/migrate/rename_runs_to_attempts.rb"
59
+ end
60
+
61
+ if !connection.table_exists?(:ductwork_runs)
62
+ migration_template "db/create_ductwork_runs.rb",
63
+ "db/migrate/create_ductwork_runs.rb"
64
+ end
65
+
66
+ if Ductwork::Branch.column_names.include?("pipeline_id")
67
+ migration_template "db/associate_branches_to_runs.rb",
68
+ "db/migrate/associate_branches_to_runs.rb"
69
+ end
70
+
71
+ if Ductwork::Step.column_names.include?("pipeline_id")
72
+ migration_template "db/associate_steps_to_runs.rb",
73
+ "db/migrate/associate_steps_to_runs.rb"
74
+ end
75
+
76
+ if Ductwork::Tuple.column_names.include?("pipeline_id")
77
+ migration_template "db/associate_tuples_to_runs.rb",
78
+ "db/migrate/associate_tuples_to_runs.rb"
79
+ end
80
+
81
+ if Ductwork::Execution.column_names.exclude?("crash_count")
82
+ migration_template "db/add_crash_count_to_ductwork_executions.rb",
83
+ "db/migrate/add_crash_count_to_ductwork_executions.rb"
84
+ end
85
+
86
+ if Ductwork::Advancement.column_names.exclude?("crash_count")
87
+ migration_template "db/add_crash_count_to_ductwork_advancements.rb",
88
+ "db/migrate/add_crash_count_to_ductwork_advancements.rb"
89
+ end
90
+
91
+ if Ductwork::Execution.column_names.exclude?("process_id")
92
+ migration_template "db/add_process_id_to_ductwork_executions.rb",
93
+ "db/migrate/add_process_id_to_ductwork_executions.rb"
94
+ end
95
+
96
+ if Ductwork::Process.column_names.exclude?("role")
97
+ migration_template "db/add_role_to_ductwork_processes.rb",
98
+ "db/migrate/add_role_to_ductwork_processes.rb"
99
+ end
100
+
101
+ if !connection.index_exists?(:ductwork_results, %i[result_type created_at])
102
+ migration_template "db/add_indexes_to_ductwork_results.rb",
103
+ "db/migrate/add_indexes_to_ductwork_results.rb"
104
+ end
105
+
106
+ if !connection.index_exists?(:ductwork_runs, :started_at)
107
+ migration_template "db/add_indexes_to_ductwork_runs.rb",
108
+ "db/migrate/add_indexes_to_ductwork_runs.rb"
109
+ end
110
+
111
+ if !connection.index_exists?(:ductwork_runs, %i[pipeline_id started_at])
112
+ migration_template "db/add_pipeline_started_index_to_ductwork_runs.rb",
113
+ "db/migrate/add_pipeline_started_index_to_ductwork_runs.rb"
114
+ end
115
+
116
+ if !connection.index_exists?(:ductwork_transitions, name: "index_ductwork_transitions_on_latest_open")
117
+ migration_template "db/add_indexes_to_ductwork_transitions.rb",
118
+ "db/migrate/add_indexes_to_ductwork_transitions.rb"
119
+ end
120
120
  end
121
121
  end
122
122
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ductwork
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Ewing