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 +4 -4
- data/.github/workflows/ruby.yml +137 -1
- data/CHANGELOG.md +48 -0
- data/Gemfile +16 -1
- data/README.md +31 -16
- data/VERSION +1 -1
- data/lib/generators/workhorse/templates/create_table_jobs.rb +19 -4
- data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +14 -1
- data/lib/workhorse/daemon/shell_handler.rb +4 -1
- data/lib/workhorse/daemon.rb +5 -1
- data/lib/workhorse/db_job.rb +40 -1
- data/lib/workhorse/performer.rb +1 -2
- data/lib/workhorse/poller.rb +55 -15
- data/lib/workhorse/worker.rb +25 -4
- data/test/lib/db_schema.rb +19 -6
- data/test/lib/test_helper.rb +107 -9
- data/test/workhorse/db_job_test.rb +1 -3
- data/test/workhorse/notifier_test.rb +0 -13
- data/test/workhorse/performer_test.rb +7 -9
- data/test/workhorse/poller_test.rb +95 -25
- data/test/workhorse/schedule_test.rb +1 -1
- data/test/workhorse/worker_test.rb +109 -76
- data/workhorse.gemspec +3 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84d0f28bfeb67292f3de373115ada4ef7c2cff27b29e847af29326733ba89841
|
|
4
|
+
data.tar.gz: f69f1d82c2c9c15f47f87d8f8390c31e655596636383b9cadefc8c240d8f43b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 00c490f9e4aa2b57066b8b974e38286509cb156cb27f79dc7d7a355c9797338eaa8dcf33a5dc9c07e2f3bbe37878d0be5e82b7f098da9e45194ad0fe5b064d6f
|
|
7
|
+
data.tar.gz: e0116907c47ae31d9fa5a5626e331a503286667966aa974255e8d9f816c363fe3a9e55406e5f79c9a3da7e82abe4b0648b505de3e279992a58e1864d847edaef
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -40,10 +40,146 @@ jobs:
|
|
|
40
40
|
- name: Run rake tests
|
|
41
41
|
uses: nick-fields/retry@v2
|
|
42
42
|
with:
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
```
|
|
96
|
+
GRANT execute ON DBMS_LOCK TO <schema-name>;
|
|
97
|
+
```
|
|
89
98
|
|
|
90
|
-
|
|
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.
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/workhorse/daemon.rb
CHANGED
|
@@ -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
|
|
data/lib/workhorse/db_job.rb
CHANGED
|
@@ -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(
|
|
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
|
#
|
data/lib/workhorse/performer.rb
CHANGED
|
@@ -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
|
data/lib/workhorse/poller.rb
CHANGED
|
@@ -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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|