workhorse 2.0.0.rc0 → 2.0.0.rc1

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: 69859406636c3463da70d9e274f023e5b0838720e428f77b8f0babad2e15baf9
4
- data.tar.gz: 6af9c55d5f9066be6801898d3786311d3a0f5c33723baf3a5e4d464c107ace85
3
+ metadata.gz: 84d0f28bfeb67292f3de373115ada4ef7c2cff27b29e847af29326733ba89841
4
+ data.tar.gz: f69f1d82c2c9c15f47f87d8f8390c31e655596636383b9cadefc8c240d8f43b6
5
5
  SHA512:
6
- metadata.gz: b674430cc63891334e49f4ce52b1cc407dea1fdd5575beab8792b82c4f12729596bfb16829ebd859bfefc2881a701e75ff85d8bca8e1e479a081b50e1bc7d0e0
7
- data.tar.gz: 3db6d62ef9a9dbd950dcb40ac6399a6d28ab166bc75a8b592153de315672423f76b2a6518e58d219de789edf1cc2faa81368f568c637585e5d2a05247b626e14
6
+ metadata.gz: 00c490f9e4aa2b57066b8b974e38286509cb156cb27f79dc7d7a355c9797338eaa8dcf33a5dc9c07e2f3bbe37878d0be5e82b7f098da9e45194ad0fe5b064d6f
7
+ data.tar.gz: e0116907c47ae31d9fa5a5626e331a503286667966aa974255e8d9f816c363fe3a9e55406e5f79c9a3da7e82abe4b0648b505de3e279992a58e1864d847edaef
@@ -40,10 +40,146 @@ jobs:
40
40
  - name: Run rake tests
41
41
  uses: nick-fields/retry@v2
42
42
  with:
43
- timeout_seconds: 120
43
+ # Not much above what one run takes on a loaded runner.
44
+ timeout_seconds: 300
44
45
  retry_on: any
45
46
  max_attempts: 3
46
47
  command: bundle exec rake test TESTOPTS='--verbose'
48
+ # A timed-out attempt can leave the suite and its daemon workers
49
+ # running - the workers call setsid, so killing the attempt does not
50
+ # reach them - and the next attempt then runs against them. KILL, as
51
+ # a worker traps TERM. Clear their pidfiles as well.
52
+ on_retry_command: |
53
+ pkill -KILL -f "^Workhorse .*: $GITHUB_WORKSPACE" || true
54
+ pkill -KILL -f "[r]ake_test_loader" || true
55
+ sleep 1
56
+ rm -f tmp/pids/*
57
+
58
+ test-oracle:
59
+ runs-on: ubuntu-latest
60
+
61
+ # Oracle gets a job of its own rather than a matrix entry: it needs a
62
+ # service container and the Instant Client, and it is run against a single
63
+ # Ruby version, as what is being covered is the database dialect rather
64
+ # than the interpreter.
65
+ services:
66
+ oracle:
67
+ image: gvenzl/oracle-free:23-slim
68
+ env:
69
+ ORACLE_PASSWORD: oracle
70
+ APP_USER: workhorse
71
+ APP_USER_PASSWORD: workhorse
72
+ ports:
73
+ - 1521:1521
74
+ options: >-
75
+ --health-cmd healthcheck.sh
76
+ --health-interval 20s
77
+ --health-timeout 10s
78
+ --health-retries 30
79
+
80
+ env:
81
+ DB_ADAPTER: oracle_enhanced
82
+ DB_NAME: FREEPDB1
83
+ DB_USERNAME: workhorse
84
+ DB_PASSWORD: workhorse
85
+ DB_HOST: localhost
86
+ DB_PORT: 1521
87
+ LD_LIBRARY_PATH: /opt/oracle/instantclient
88
+
89
+ steps:
90
+ - uses: actions/checkout@v2
91
+
92
+ # ruby-oci8 compiles against the Instant Client, so both the runtime
93
+ # library and the SDK headers are needed; basiclite alone does not carry
94
+ # oci.h.
95
+ - name: Install Oracle Instant Client
96
+ run: |
97
+ sudo apt-get update
98
+ # The package was renamed in Ubuntu 24.04, which `ubuntu-latest` now
99
+ # is; keep working on both.
100
+ sudo apt-get install -y unzip
101
+ sudo apt-get install -y libaio1 || sudo apt-get install -y libaio1t64
102
+ # The rename came with a soname change to libaio.so.1t64, while the
103
+ # Oracle client still links against libaio.so.1. Without this the
104
+ # client library cannot be linked, and ruby-oci8's version probe
105
+ # fails with a TypeError that says nothing about the cause.
106
+ if ! ldconfig -p | grep -qE 'libaio\.so\.1$|libaio\.so\.1 '; then
107
+ t64=$(ls /usr/lib/*/libaio.so.1t64 2>/dev/null | head -1)
108
+ if [ -n "$t64" ]; then sudo ln -sfn "$t64" "$(dirname "$t64")/libaio.so.1"; fi
109
+ fi
110
+ sudo mkdir -p /opt/oracle
111
+ cd /tmp
112
+ # Pinned to 21.13 rather than taking the current release: ruby-oci8
113
+ # 2.2 fails its `OCI_MAJOR_VERSION` probe against an Instant Client
114
+ # 23 header, after finding the library and the SDK correctly. A 21
115
+ # client talks to the 23 server used below.
116
+ base=https://download.oracle.com/otn_software/linux/instantclient/2113000
117
+ curl -sSLO $base/instantclient-basiclite-linux.x64-21.13.0.0.0dbru.zip
118
+ curl -sSLO $base/instantclient-sdk-linux.x64-21.13.0.0.0dbru.zip
119
+ sudo unzip -oq 'instantclient-basiclite-linux.x64-*.zip' -d /opt/oracle
120
+ sudo unzip -oq 'instantclient-sdk-linux.x64-*.zip' -d /opt/oracle
121
+ # The archives unpack into a versioned directory, whose name changes
122
+ # with every release; the fixed symlink is what LD_LIBRARY_PATH and
123
+ # the ruby-oci8 build both point at.
124
+ sudo ln -sfn "$(find /opt/oracle -maxdepth 1 -type d -name 'instantclient_*' | head -1)" /opt/oracle/instantclient
125
+ # The basiclite package ships only the versioned libraries, and
126
+ # linking a conftest against -lclntsh needs the unversioned name.
127
+ cd /opt/oracle/instantclient
128
+ for lib in libclntsh libocci; do
129
+ versioned=$(ls $lib.so.* 2>/dev/null | head -1)
130
+ if [ -n "$versioned" ]; then sudo ln -sfn "$versioned" "$lib.so"; fi
131
+ done
132
+ echo /opt/oracle/instantclient | sudo tee /etc/ld.so.conf.d/oracle-instantclient.conf
133
+ sudo ldconfig
134
+
135
+ - name: Set up Ruby
136
+ uses: ruby/setup-ruby@v1
137
+ with:
138
+ ruby-version: '3.2.1'
139
+
140
+ - name: Install bundle including the oracle group
141
+ run: |
142
+ bundle config set --local with oracle
143
+ bundle install --jobs 4 --retry 3
144
+
145
+ # extconf.rb only says to consult mkmf.log, which is otherwise lost with
146
+ # the runner.
147
+ - name: Show the native extension build log
148
+ if: failure()
149
+ run: find / -name mkmf.log -path '*ruby-oci8*' -exec cat {} +
150
+
151
+ # The global lock is DBMS_LOCK on Oracle, and execute permission on that
152
+ # package is not granted to a new schema by default. SYS owns the
153
+ # package, and only it can grant it, so this connects as SYSDBA.
154
+ - name: Grant DBMS_LOCK to the test schema
155
+ run: |
156
+ bundle exec ruby -e '
157
+ require "active_record"
158
+ require "active_record/connection_adapters/oracle_enhanced_adapter"
159
+ ActiveRecord::Base.establish_connection(
160
+ adapter: "oracle_enhanced", database: "FREEPDB1",
161
+ username: "sys", password: "oracle", privilege: "SYSDBA",
162
+ host: "localhost", port: 1521
163
+ )
164
+ ActiveRecord::Base.connection.execute("GRANT EXECUTE ON DBMS_LOCK TO workhorse")
165
+ '
166
+
167
+ - name: Run rake tests
168
+ uses: nick-fields/retry@v2
169
+ with:
170
+ timeout_seconds: 600
171
+ retry_on: any
172
+ max_attempts: 3
173
+ command: bundle exec rake test TESTOPTS='--verbose'
174
+ # A timed-out attempt can leave the suite and its daemon workers
175
+ # running - the workers call setsid, so killing the attempt does not
176
+ # reach them - and the next attempt then runs against them. KILL, as
177
+ # a worker traps TERM. Clear their pidfiles as well.
178
+ on_retry_command: |
179
+ pkill -KILL -f "^Workhorse .*: $GITHUB_WORKSPACE" || true
180
+ pkill -KILL -f "[r]ake_test_loader" || true
181
+ sleep 1
182
+ rm -f tmp/pids/*
47
183
 
48
184
  linters:
49
185
  runs-on: ubuntu-latest
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Workhorse Changelog
2
2
 
3
+ ## 2.0.0.rc1 - 2026-09-30
4
+
5
+ Sitrox reference: #154443.
6
+
7
+ ### Added
8
+
9
+ * Oracle 12c or later is supported again, and is now covered by CI against
10
+ `activerecord-oracle_enhanced-adapter` rather than only by hand. The global
11
+ lock uses `DBMS_LOCK`, and the generated migrations leave out the index
12
+ prefix lengths Oracle rejects while naming every index within the 30
13
+ characters it allows before 12.2. See
14
+ [Database support](README.md#database-support).
15
+
16
+ 2.0.0.rc0 dropped Oracle; anyone who took that release up and needs it can
17
+ move straight to this one. Grant the schema execute permission on
18
+ `DBMS_LOCK`, as described under Database support.
19
+
20
+ ### Fixed
21
+
22
+ * On Oracle, jobs are picked up in priority order. 1.x limited the rows it
23
+ selected by filtering on `ROWNUM`, which Oracle numbers before sorting, so
24
+ both the job taken from each queue and the jobs a poll picked up were an
25
+ arbitrary subset regardless of `priority`. Limiting now uses
26
+ `FETCH FIRST … ROWS ONLY`, which applies after the sort and is why 12c is
27
+ now the minimum.
28
+
29
+ * A poll that finds the global lock taken now waits for it with a polling
30
+ interval below half a second. The lock timeout follows the interval, and
31
+ MySQL and Oracle take it as whole seconds, so they rounded it down to not
32
+ waiting at all - only MariaDB honours a fraction. Each such poll gave up
33
+ at once and counted towards `Workhorse.max_global_lock_fails`, so several
34
+ workers contending for the lock could trigger its alarm about a crashed
35
+ worker. The timeout is now rounded up to whole seconds.
36
+
37
+ * A soft restart (`USR1`) no longer wedges a worker whose `tmp/pids` does not
38
+ exist yet, as on a fresh checkout or deployment. The worker touched its
39
+ shutdown file there after it had stopped accepting jobs, so failing on it
40
+ left a worker that took no jobs and never exited. The directory is now
41
+ created when missing, and a shutdown file that still cannot be written no
42
+ longer holds up the shutdown. The heartbeat and the memory-limit shutdown,
43
+ which write to the same directory, create it as well.
44
+
45
+ * `Workhorse.clean_stuck_jobs` works on Oracle. It is built on splitting
46
+ `locked_by` into host, PID and random component, which was written with
47
+ MySQL's `substring_index` and so raised on Oracle for as long as the option
48
+ has existed — visible only to an installation that turned it on, as it is
49
+ off by default.
50
+
3
51
  ## 2.0.0.rc0 - 2026-09-29
4
52
 
5
53
  Sitrox reference: #154443.
data/Gemfile CHANGED
@@ -11,5 +11,20 @@ gem 'minitest'
11
11
  gem 'mysql2'
12
12
  gem 'pry'
13
13
  gem 'rake'
14
- gem 'rubocop', '~> 1.60'
14
+
15
+ # Pinned to a patch range rather than given a floor: Gemfile.lock is not
16
+ # checked in, so CI resolves the newest version matching this line while a
17
+ # checkout keeps whatever it installed. With a floor, every rubocop release
18
+ # that adds a cop turns the build red for a reason nobody can reproduce
19
+ # locally.
20
+ gem 'rubocop', '~> 1.91.0'
15
21
  gem 'trilogy'
22
+
23
+ # Only needed to run the suite against Oracle, which additionally requires the
24
+ # Oracle Instant Client to be installed - building ruby-oci8 fails without it.
25
+ # The group is optional, so a plain `bundle install` skips it; enable it with
26
+ # `bundle config set --local with oracle`.
27
+ group :oracle, optional: true do
28
+ gem 'activerecord-oracle_enhanced-adapter', '~> 7.1'
29
+ gem 'ruby-oci8'
30
+ end
data/README.md CHANGED
@@ -35,8 +35,8 @@ What it does not do:
35
35
 
36
36
  * Ruby `>= 3.0` (may work with earlier versions but is untested)
37
37
  * Rails `>= 7.0`
38
- * MySQL or MariaDB with InnoDB. No other database is supported, see
39
- [Database support](#database-support).
38
+ * One of the supported databases (see [Database support](#database-support)):
39
+ MySQL / MariaDB with InnoDB, or Oracle. **PostgreSQL is not supported.**
40
40
  * If you are planning on using the daemons handler:
41
41
  * An operating system and file system that supports file locking.
42
42
  * MRI Ruby (aka "CRuby") as jRuby does not support `fork`. See the
@@ -70,24 +70,35 @@ What it does not do:
70
70
 
71
71
  ### Database support
72
72
 
73
- **MySQL and MariaDB are the only supported databases.** Workhorse serialises
74
- job pickup with `GET_LOCK`, a MySQL advisory lock, which is emitted on every
75
- poll; a database that does not provide it fails on the first poll. InnoDB is
76
- required, as MyISAM supports neither transactions nor row-level locking. Both
77
- the `mysql2` and the `trilogy` adapter are covered by CI.
73
+ Workhorse serialises job pickup using a database-level lock, which is
74
+ necessarily written against a specific database's dialect. Two families are
75
+ implemented:
78
76
 
79
- Oracle was supported until 2.0.0 and is not any more — see the changelog entry
80
- for that release. PostgreSQL has never been supported, despite the
81
- requirements once listing it; supporting it would mean an advisory-lock
82
- dialect of its own (`pg_advisory_lock`) and is not currently planned.
77
+ | Database | Supported | Lock used | Covered by CI |
78
+ |-------------------|-----------|----------------------|---------------|
79
+ | MySQL / MariaDB | Yes | `GET_LOCK` | Yes, against both the `mysql2` and the `trilogy` adapter |
80
+ | Oracle 12c+ | Yes | `DBMS_LOCK` | Yes, against `activerecord-oracle_enhanced-adapter` |
81
+ | PostgreSQL | **No** | — | — |
82
+ | Everything else | **No** | — | — |
83
83
 
84
- ## Upgrading from 1.x
84
+ There is no PostgreSQL implementation: workers emit `GET_LOCK` on every poll,
85
+ which PostgreSQL does not provide, so a worker fails on its first poll.
86
+ Supporting it would mean an advisory-lock dialect of its own
87
+ (`pg_advisory_lock`) and is not currently planned. Note that InnoDB is required
88
+ on MySQL / MariaDB, as MyISAM supports neither transactions nor row-level
89
+ locking.
90
+
91
+ Oracle 12c is the minimum, as job selection limits its rows with
92
+ `FETCH FIRST … ROWS ONLY`. When using Oracle, make sure your schema has access
93
+ to the package `DBMS_LOCK`:
85
94
 
86
- Workhorse 2.0 drops support for Oracle, see
87
- [Database support](#database-support). An Oracle installation has no upgrade
88
- path and should stay on 1.x.
95
+ ```
96
+ GRANT execute ON DBMS_LOCK TO <schema-name>;
97
+ ```
89
98
 
90
- Otherwise nothing breaks without migrating: [scheduling](#scheduling) is inert
99
+ ## Upgrading from 1.x
100
+
101
+ Nothing breaks without migrating: [scheduling](#scheduling) is inert
91
102
  until a schedule is declared, [notifications](#notifications) are off until a
92
103
  notifier is selected, and the new job columns are only written when used. To
93
104
  take the new features up, add this migration:
@@ -130,6 +141,10 @@ class UpgradeWorkhorseToV2 < ActiveRecord::Migration[7.1]
130
141
  end
131
142
  ```
132
143
 
144
+ On Oracle, drop every `length:` option above — it indexes the whole column and
145
+ rejects a prefix length. The generated migrations do this for you; this one is
146
+ written out by hand.
147
+
133
148
  Two behaviour changes worth knowing about, both described in the changelog: a
134
149
  forked daemon worker no longer runs `at_exit` handlers registered by the
135
150
  process that started it, and `Workhorse::Jobs::CleanupSucceededJobs` now also
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0.rc0
1
+ 2.0.0.rc1
@@ -29,10 +29,25 @@ class CreateTableJobs < ActiveRecord::Migration[7.1]
29
29
  t.timestamps null: false
30
30
  end
31
31
 
32
- add_index :jobs, :queue, length: 191
33
- add_index :jobs, %i[state perform_at], length: { state: 191 }, name: 'idx_jobs_state_perform_at'
34
- add_index :jobs, %i[state priority created_at], length: { state: 191 }, name: 'idx_jobs_state_prio_created'
35
- add_index :jobs, %i[state expires_at], length: { state: 191 }, name: 'idx_jobs_state_expires_at'
32
+ # The index names are given explicitly because the ones Rails would derive
33
+ # exceed the 30 characters Oracle allows before 12.2.
34
+ if oracle?
35
+ add_index :jobs, :queue
36
+ add_index :jobs, %i[state perform_at], name: 'idx_jobs_state_perform_at'
37
+ add_index :jobs, %i[state priority created_at], name: 'idx_jobs_state_prio_created'
38
+ add_index :jobs, %i[state expires_at], name: 'idx_jobs_state_expires_at'
39
+ else
40
+ add_index :jobs, :queue, length: 191
41
+ add_index :jobs, %i[state perform_at], length: { state: 191 }, name: 'idx_jobs_state_perform_at'
42
+ add_index :jobs, %i[state priority created_at], length: { state: 191 }, name: 'idx_jobs_state_prio_created'
43
+ add_index :jobs, %i[state expires_at], length: { state: 191 }, name: 'idx_jobs_state_expires_at'
44
+ end
36
45
  add_index :jobs, :perform_at
37
46
  end
47
+
48
+ private
49
+
50
+ def oracle?
51
+ ActiveRecord::Base.connection.adapter_name == 'OracleEnhanced'
52
+ end
38
53
  end
@@ -23,7 +23,20 @@ class CreateTableWorkhorseSchedules < ActiveRecord::Migration[7.1]
23
23
  t.timestamps null: false
24
24
  end
25
25
 
26
- add_index :workhorse_schedules, :key, unique: true, length: 191, name: 'idx_wh_schedules_key'
26
+ # The index names are given explicitly because the ones Rails would derive
27
+ # exceed the 30 characters Oracle allows before 12.2.
28
+ if oracle?
29
+ add_index :workhorse_schedules, :key, unique: true, name: 'idx_wh_schedules_key'
30
+ else
31
+ add_index :workhorse_schedules, :key, unique: true, length: 191, name: 'idx_wh_schedules_key'
32
+ end
33
+
27
34
  add_index :workhorse_schedules, %i[enabled next_at], name: 'idx_wh_schedules_due'
28
35
  end
36
+
37
+ private
38
+
39
+ def oracle?
40
+ ActiveRecord::Base.connection.adapter_name == 'OracleEnhanced'
41
+ end
29
42
  end
@@ -146,7 +146,10 @@ module Workhorse
146
146
 
147
147
  def self.acquire_lock(lockfile_path, flags)
148
148
  if Workhorse.lock_shell_commands
149
- lockfile = File.open(lockfile_path, 'a') # rubocop:disable Style/FileOpen
149
+ # Not the block form: the lockfile is returned to the caller, which
150
+ # holds the flock for as long as the command runs.
151
+ # rubocop:disable-next Style/FileOpen
152
+ lockfile = File.open(lockfile_path, 'a')
150
153
  result = lockfile.flock(flags)
151
154
 
152
155
  if result == false
@@ -311,9 +311,13 @@ module Workhorse
311
311
  $0 = process_name(worker)
312
312
  # Close inherited lockfile fd to prevent holding the flock after parent exits
313
313
  @lockfile&.close
314
- # Reopen pipes to prevent #107576
314
+ # Reopen pipes to prevent #107576. Not the block form: the descriptor
315
+ # has to outlive this call, as stdout and stderr keep pointing at it
316
+ # for the lifetime of the worker.
317
+ # rubocop:disable Style/FileOpen
315
318
  $stdin.reopen File.open(File::NULL, 'r')
316
319
  null_out = File.open(File::NULL, 'w')
320
+ # rubocop:enable Style/FileOpen
317
321
  $stdout.reopen null_out
318
322
  $stderr.reopen null_out
319
323
 
@@ -101,10 +101,25 @@ module Workhorse
101
101
  # Returns a relation with split locked_by field for easier querying.
102
102
  # Extracts host, PID, and random string components from locked_by.
103
103
  #
104
+ # `locked_by` is "<host>.<pid>.<random>", and the host may itself contain
105
+ # dots, so both dialects below work backwards from the last two.
106
+ #
104
107
  # @return [ActiveRecord::Relation] Relation with additional computed columns
105
108
  # @private
106
109
  def self.with_split_locked_by
107
- select(<<~SQL)
110
+ return select(oracle? ? split_locked_by_sql_oracle : split_locked_by_sql_mysql)
111
+ end
112
+
113
+ # @return [Boolean] Whether the connection speaks Oracle
114
+ # @private
115
+ def self.oracle?
116
+ return connection.adapter_name == 'OracleEnhanced'
117
+ end
118
+
119
+ # @return [String]
120
+ # @private
121
+ def self.split_locked_by_sql_mysql
122
+ return <<~SQL
108
123
  #{table_name}.*,
109
124
 
110
125
  -- random string
@@ -127,6 +142,30 @@ module Workhorse
127
142
  SQL
128
143
  end
129
144
 
145
+ # Oracle has no `substring_index`. `instr` with a negative position counts
146
+ # occurrences from the right, which gives the two delimiters directly.
147
+ #
148
+ # @return [String]
149
+ # @private
150
+ def self.split_locked_by_sql_oracle
151
+ return <<~SQL
152
+ #{table_name}.*,
153
+
154
+ -- random string
155
+ substr(locked_by, instr(locked_by, '.', -1, 1) + 1) as locked_by_rnd,
156
+
157
+ -- pid
158
+ substr(
159
+ locked_by,
160
+ instr(locked_by, '.', -1, 2) + 1,
161
+ instr(locked_by, '.', -1, 1) - instr(locked_by, '.', -1, 2) - 1
162
+ ) as locked_by_pid,
163
+
164
+ -- get host
165
+ substr(locked_by, 1, instr(locked_by, '.', -1, 2) - 1) as locked_by_host
166
+ SQL
167
+ end
168
+
130
169
  # Resets job to state "waiting" and clears all meta fields
131
170
  # set by workhorse in course of processing this job.
132
171
  #
@@ -165,9 +165,8 @@ module Workhorse
165
165
  def deserialized_job
166
166
  # The source is safe as long as jobs are always enqueued using
167
167
  # Workhorse::Enqueuer so it is ok to use Marshal.load.
168
- # rubocop: disable Security/MarshalLoad
168
+ # rubocop: disable-next Security/MarshalLoad
169
169
  Marshal.load(@db_job.handler)
170
- # rubocop: enable Security/MarshalLoad
171
170
  end
172
171
  end
173
172
  end
@@ -1,6 +1,7 @@
1
1
  module Workhorse
2
2
  # Database poller that discovers and locks jobs for execution.
3
3
  # Handles job querying, global locking, and job distribution to workers.
4
+ # Supports both MySQL and Oracle databases with database-specific optimizations.
4
5
  #
5
6
  # @example Basic usage (typically used internally)
6
7
  # poller = Workhorse::Poller.new(worker, proc { true })
@@ -17,6 +18,9 @@ module Workhorse
17
18
  # responsive to shutdown, to instant repolling and to notifications.
18
19
  SLEEP_SLICE = 0.1
19
20
 
21
+ ORACLE_LOCK_MODE = 6 # X_MODE (exclusive)
22
+ ORACLE_LOCK_HANDLE = 478_564_848 # Randomly chosen number
23
+
20
24
  # @return [Workhorse::Worker] The worker this poller serves
21
25
  attr_reader :worker
22
26
 
@@ -31,6 +35,7 @@ module Workhorse
31
35
  @worker = worker
32
36
  @running = false
33
37
  @table = Workhorse::DbJob.arel_table
38
+ @is_oracle = ActiveRecord::Base.connection.adapter_name == 'OracleEnhanced'
34
39
  @instant_repoll = Concurrent::AtomicBoolean.new(false)
35
40
  @global_lock_fails = 0
36
41
  @max_global_lock_fails_reached = false
@@ -303,6 +308,7 @@ module Workhorse
303
308
  end
304
309
 
305
310
  # Executes a block with a global database lock.
311
+ # Supports both MySQL GET_LOCK and Oracle DBMS_LOCK.
306
312
  #
307
313
  # @param name [Symbol] Lock name identifier
308
314
  # @param timeout [Integer] Lock timeout in seconds
@@ -312,11 +318,25 @@ module Workhorse
312
318
  # @return [void]
313
319
  # @private
314
320
  def with_global_lock(name: :workhorse, timeout: 2, count_failures: true, &_block)
315
- begin # rubocop:disable Style/RedundantBegin
316
- result = Workhorse::DbJob.connection.select_all(
317
- "SELECT GET_LOCK(CONCAT(DATABASE(), '_#{name}'), #{timeout})"
318
- ).first.values.last
319
- success = result == 1
321
+ # Whole seconds, rounded up: MySQL and Oracle both take the timeout as an
322
+ # integer and round a fraction of a second down to not waiting at all -
323
+ # only MariaDB honours one. A poll finding the lock taken would then give
324
+ # up at once, and count towards max_global_lock_fails for mere contention.
325
+ timeout = timeout.ceil
326
+
327
+ begin
328
+ if @is_oracle
329
+ result = Workhorse::DbJob.connection.select_all(
330
+ "SELECT DBMS_LOCK.REQUEST(#{ORACLE_LOCK_HANDLE}, #{ORACLE_LOCK_MODE}, #{timeout}) FROM DUAL"
331
+ ).first.values.last
332
+
333
+ success = result == 0
334
+ else
335
+ result = Workhorse::DbJob.connection.select_all(
336
+ "SELECT GET_LOCK(CONCAT(DATABASE(), '_#{name}'), #{timeout})"
337
+ ).first.values.last
338
+ success = result == 1
339
+ end
320
340
 
321
341
  if success
322
342
  @global_lock_fails = 0
@@ -362,7 +382,11 @@ module Workhorse
362
382
  yield
363
383
  ensure
364
384
  if success
365
- Workhorse::DbJob.connection.execute("SELECT RELEASE_LOCK(CONCAT(DATABASE(), '_#{name}'))")
385
+ if @is_oracle
386
+ Workhorse::DbJob.connection.execute("SELECT DBMS_LOCK.RELEASE(#{ORACLE_LOCK_HANDLE}) FROM DUAL")
387
+ else
388
+ Workhorse::DbJob.connection.execute("SELECT RELEASE_LOCK(CONCAT(DATABASE(), '_#{name}'))")
389
+ end
366
390
  end
367
391
  end
368
392
  end
@@ -572,7 +596,7 @@ module Workhorse
572
596
  # any presumptions on the order.
573
597
  record_number = queue.nil? ? limit : 1
574
598
 
575
- union_parts << select.take(record_number)
599
+ union_parts << limited_sql(select, record_number)
576
600
  end
577
601
 
578
602
  return [] if union_parts.empty?
@@ -583,10 +607,13 @@ module Workhorse
583
607
  # contained within.
584
608
  # Additionally, each of the subselects and the final union select is given
585
609
  # an alias to comply with MySQL requirements.
610
+ # These aliases are added directly instead of using Arel `as`, because it
611
+ # uses the keyword 'AS' in SQL generated for Oracle, which is invalid for
612
+ # table aliases.
586
613
  union_query_sql = '('
587
- union_query_sql += "SELECT * FROM (#{union_parts.shift.to_sql}) union_0"
614
+ union_query_sql += "SELECT * FROM (#{union_parts.shift}) union_0"
588
615
  union_parts.each_with_index do |part, idx|
589
- union_query_sql += " UNION SELECT * FROM (#{part.to_sql}) union_#{idx + 1}"
616
+ union_query_sql += " UNION SELECT * FROM (#{part}) union_#{idx + 1}"
590
617
  end
591
618
  union_query_sql += ') subselect'
592
619
 
@@ -599,10 +626,7 @@ module Workhorse
599
626
  select = table.project(Arel.star).where(table[:id].in(select.project(:id)))
600
627
  select = order(select)
601
628
 
602
- # Limit number of records
603
- select = select.take(limit)
604
-
605
- return Workhorse::DbJob.find_by_sql(select.to_sql).to_a
629
+ return Workhorse::DbJob.find_by_sql(limited_sql(select, limit)).to_a
606
630
  end
607
631
 
608
632
  # Returns a fresh Arel select manager containing the id of all waiting jobs.
@@ -652,6 +676,21 @@ module Workhorse
652
676
  select.order(Arel.sql('priority').asc).order(Arel.sql('created_at').asc)
653
677
  end
654
678
 
679
+ # Returns the SQL of a select, limited to the given number of records.
680
+ #
681
+ # On Oracle this is `FETCH FIRST`, which applies after `ORDER BY`. Filtering
682
+ # on `ROWNUM` instead - the only option before 12c - numbers the rows
683
+ # before they are sorted, so it returned an arbitrary subset and ignored
684
+ # the priority order altogether.
685
+ #
686
+ # @param select [Arel::SelectManager] the select manager to limit
687
+ # @param number [Integer] the maximum number of records to return
688
+ # @return [String] the resultant SQL
689
+ def limited_sql(select, number)
690
+ return "#{select.to_sql} FETCH FIRST #{Integer(number)} ROWS ONLY" if @is_oracle
691
+ return select.take(number).to_sql
692
+ end
693
+
655
694
  # Returns an Array of queue names for which a job may be posted
656
695
  #
657
696
  # This is done in multiple steps. First, all queues with jobs that are in
@@ -696,9 +735,10 @@ module Workhorse
696
735
  queues = select.project(:queue)
697
736
 
698
737
  # Note that `select_values` is used here on purpose: `execute` does not
699
- # return a result set on every adapter, while `select_values` is
738
+ # return a result set on every adapter (the Oracle enhanced adapter, for
739
+ # instance, returns `true` for queries), while `select_values` is
700
740
  # implemented in terms of `exec_query` and thus behaves the same on the
701
- # mysql2 and trilogy adapters.
741
+ # mysql2, trilogy and Oracle enhanced adapters.
702
742
  return Workhorse::DbJob.connection.select_values(queues.distinct.to_sql)
703
743
  end
704
744
  end