postburner 1.0.0.rc.3 → 1.0.0.rc.4
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/CHANGELOG.md +68 -0
- data/app/controllers/postburner/jobs_controller.rb +30 -1
- data/app/models/postburner/job.rb +33 -0
- data/app/models/postburner/orphaned_job.rb +107 -0
- data/app/models/postburner/schedule.rb +5 -0
- data/lib/generators/postburner/install/templates/migrations/create_postburner_jobs.rb.erb +17 -1
- data/lib/postburner/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc5a8c616b4bf393b03fdc5d2bef57eb03e1a9b9dc06fe592f89b3d1238c2606
|
|
4
|
+
data.tar.gz: 40fe3028ce331e7ecbc6facb5829b845272ce66c14578cdd12b79a4495de85ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e3095d5efd3761db840b44461f42f127269608a6070ead2e9b5751054e3875e11098d34ac9232237baa27350ec46fbf8413a80adde99feae0115bedd9b08e68
|
|
7
|
+
data.tar.gz: 3a38ad87ba8692bae49462dbf6ac8e0013cb64209200c44201a25e5cb5d2c73ea14719753f279da4ce7da016d1af581a446bec7ad97af035f4884d645c12fefe
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.0.0.rc.4 - 2026-06-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Postburner::OrphanedJob` — inert STI placeholder loaded when a row's `type` column references a deleted or renamed class. Prevents `ActiveRecord::SubclassNotFound` from crashing callers (e.g. the admin queued-jobs index). `perform` and `queue!`/`requeue!` raise `Postburner::Job::OrphanedJobError`. `readonly?` returns `true` to prevent Rails' `ensure_proper_type` from overwriting the original `type` string. `remove!` (soft-delete) and `destroy` still work. No migration required.
|
|
7
|
+
- `Postburner::Job::OrphanedJobError` — raised by `OrphanedJob#perform` and `#queue!`/`#requeue!`.
|
|
8
|
+
- `Postburner::Job#orphaned?` — predicate; always `false` on the base class, `true` on `OrphanedJob`.
|
|
9
|
+
- `Postburner::Job.find_sti_class` override — rescues `ActiveRecord::SubclassNotFound` and returns `Postburner::OrphanedJob` instead.
|
|
10
|
+
- Partial indexes on `postburner_jobs` (`idx_pbjobs_active_run_at`, `idx_pbjobs_processed`, `idx_pbjobs_unqueued`) tuned to the admin UI's default queries, keeping the jobs list fast on large tables.
|
|
11
|
+
- `JobsController#index` now accepts a `scope` param (`active`, `processed`, `unqueued`, `all`) and defaults to `active`, so the default page load matches the `idx_pbjobs_active_run_at` predicate.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `postburner_jobs.lag` is now `bigint` instead of `integer`, to avoid overflow on jobs that sit in the queue long enough to exceed the 32-bit range.
|
|
15
|
+
|
|
16
|
+
### Upgrade notes
|
|
17
|
+
- This is a **template mutation**, not an additive migration. Existing rc.x installs will need to either re-run `rails generate postburner:install` (and merge the migration by hand) or manually add an equivalent migration that creates the three partial indexes. Failing to do either is **non-catastrophic** — the admin UI will simply remain slow on large `postburner_jobs` tables until the indexes are in place.
|
|
18
|
+
- The `lag` column type change (`integer` → `bigint`) is also a template mutation. Existing installs should add a migration along the lines of `change_column :postburner_jobs, :lag, :bigint` to avoid overflow once lag values exceed the 32-bit range.
|
|
19
|
+
|
|
20
|
+
Example migration covering both the `lag` widening and the three partial indexes:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
class UpgradePostburnerJobsForRc4 < ActiveRecord::Migration[7.2]
|
|
24
|
+
disable_ddl_transaction!
|
|
25
|
+
|
|
26
|
+
def up
|
|
27
|
+
# Widen lag to bigint to avoid 32-bit overflow on long-queued jobs.
|
|
28
|
+
# ~24.9 days -> ~292.5 million years
|
|
29
|
+
change_column :postburner_jobs, :lag, :bigint
|
|
30
|
+
|
|
31
|
+
# Partial indexes tuned to the admin UI's default queries.
|
|
32
|
+
add_index :postburner_jobs, :run_at,
|
|
33
|
+
name: 'idx_pbjobs_active_run_at',
|
|
34
|
+
where: '(processed_at IS NULL AND removed_at IS NULL AND queued_at IS NOT NULL)',
|
|
35
|
+
algorithm: :concurrently
|
|
36
|
+
|
|
37
|
+
add_index :postburner_jobs, :processed_at,
|
|
38
|
+
name: 'idx_pbjobs_processed',
|
|
39
|
+
order: { processed_at: :desc },
|
|
40
|
+
where: '(processed_at IS NOT NULL)',
|
|
41
|
+
algorithm: :concurrently
|
|
42
|
+
|
|
43
|
+
add_index :postburner_jobs, [:removed_at, :created_at],
|
|
44
|
+
name: 'idx_pbjobs_unqueued',
|
|
45
|
+
order: { created_at: :desc },
|
|
46
|
+
where: '(queued_at IS NULL AND removed_at IS NULL)',
|
|
47
|
+
algorithm: :concurrently
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def down
|
|
51
|
+
remove_index :postburner_jobs, name: 'idx_pbjobs_unqueued', algorithm: :concurrently
|
|
52
|
+
remove_index :postburner_jobs, name: 'idx_pbjobs_processed', algorithm: :concurrently
|
|
53
|
+
remove_index :postburner_jobs, name: 'idx_pbjobs_active_run_at', algorithm: :concurrently
|
|
54
|
+
|
|
55
|
+
change_column :postburner_jobs, :lag, :integer
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Notes:
|
|
61
|
+
- `disable_ddl_transaction!` + `algorithm: :concurrently` is recommended for production tables of any size — it builds the indexes without holding a long write lock. Drop both if you're on a small dev table and want a single transactional migration.
|
|
62
|
+
- `change_column ... :bigint` on PostgreSQL is an in-place type widening for `integer → bigint` and is fast (no full table rewrite), but it does take a brief `ACCESS EXCLUSIVE` lock — schedule accordingly on hot tables.
|
|
63
|
+
- Adjust `ActiveRecord::Migration[7.2]` to match your app's Rails version.
|
|
64
|
+
|
|
65
|
+
## v1.0.0.rc.3
|
|
66
|
+
|
|
67
|
+
## v1.0.0.rc.2
|
|
68
|
+
|
|
69
|
+
## v1.0.0.rc.1
|
|
70
|
+
|
|
3
71
|
## v0.7.0 - 2022-10-24
|
|
4
72
|
|
|
5
73
|
### Added
|
|
@@ -2,12 +2,41 @@ require_dependency "postburner/application_controller"
|
|
|
2
2
|
|
|
3
3
|
module Postburner
|
|
4
4
|
class JobsController < ApplicationController
|
|
5
|
+
# Scopes align with the partial indexes defined in the install
|
|
6
|
+
# migration. Keeping the controller's default query matched to an
|
|
7
|
+
# index predicate keeps the admin UI fast on large tables.
|
|
8
|
+
SCOPES = %w[ active processed unqueued all ].freeze
|
|
9
|
+
DEFAULT_SCOPE = 'active'.freeze
|
|
10
|
+
|
|
5
11
|
def index
|
|
6
|
-
@
|
|
12
|
+
@scope = SCOPES.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE
|
|
13
|
+
@jobs = scoped_jobs(@scope)
|
|
7
14
|
end
|
|
8
15
|
|
|
9
16
|
def show
|
|
10
17
|
@job = Job.find(params[:id])
|
|
11
18
|
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def scoped_jobs(scope)
|
|
23
|
+
case scope
|
|
24
|
+
when 'active'
|
|
25
|
+
# Uses idx_pbjobs_active_run_at
|
|
26
|
+
Job.where(processed_at: nil, removed_at: nil)
|
|
27
|
+
.where.not(queued_at: nil)
|
|
28
|
+
.order(run_at: :asc)
|
|
29
|
+
when 'processed'
|
|
30
|
+
# Uses idx_pbjobs_processed
|
|
31
|
+
Job.where.not(processed_at: nil)
|
|
32
|
+
.order(processed_at: :desc)
|
|
33
|
+
when 'unqueued'
|
|
34
|
+
# Uses idx_pbjobs_unqueued
|
|
35
|
+
Job.where(queued_at: nil, removed_at: nil)
|
|
36
|
+
.order(created_at: :desc)
|
|
37
|
+
else
|
|
38
|
+
Job.order(queued_at: :desc, created_at: :desc)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
12
41
|
end
|
|
13
42
|
end
|
|
@@ -88,6 +88,30 @@ module Postburner
|
|
|
88
88
|
|
|
89
89
|
validates :sid, presence: {strict: true}
|
|
90
90
|
|
|
91
|
+
# Resolves an STI type name to a class, falling back to OrphanedJob when the
|
|
92
|
+
# original class no longer exists. This tolerates rows whose `type` column
|
|
93
|
+
# references a deleted or renamed job class, preventing
|
|
94
|
+
# ActiveRecord::SubclassNotFound from crashing callers (e.g. the admin
|
|
95
|
+
# queued-jobs page).
|
|
96
|
+
#
|
|
97
|
+
# @param type_name [String] The value stored in the `type` column
|
|
98
|
+
# @return [Class] The resolved class, or {Postburner::OrphanedJob} if missing
|
|
99
|
+
#
|
|
100
|
+
def self.find_sti_class(type_name)
|
|
101
|
+
super
|
|
102
|
+
rescue ActiveRecord::SubclassNotFound
|
|
103
|
+
Postburner::OrphanedJob
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns whether this job instance is an orphaned placeholder.
|
|
107
|
+
#
|
|
108
|
+
# Always false on the base class. Overridden to return true in
|
|
109
|
+
# {Postburner::OrphanedJob}.
|
|
110
|
+
#
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
#
|
|
113
|
+
def orphaned? = false
|
|
114
|
+
|
|
91
115
|
# Abstract method to be implemented by subclasses.
|
|
92
116
|
#
|
|
93
117
|
# This method contains the actual work logic for the job. It is called
|
|
@@ -220,6 +244,15 @@ module Postburner
|
|
|
220
244
|
#
|
|
221
245
|
class AlreadyProcessed < StandardError; end
|
|
222
246
|
|
|
247
|
+
# Exception raised when attempting to perform or enqueue an orphaned job whose
|
|
248
|
+
# original class no longer exists in the application.
|
|
249
|
+
#
|
|
250
|
+
# @example
|
|
251
|
+
# job = Postburner::Job.find(id) # loads as OrphanedJob if class is gone
|
|
252
|
+
# job.perform # => raises OrphanedJobError
|
|
253
|
+
#
|
|
254
|
+
class OrphanedJobError < StandardError; end
|
|
255
|
+
|
|
223
256
|
# Exception raised when a job is executed before its scheduled run_at time.
|
|
224
257
|
#
|
|
225
258
|
# In production (Queue/NiceQueue), this is handled by re-inserting with delay.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postburner
|
|
4
|
+
# Inert STI placeholder loaded when a Postburner::Job row's `type` column
|
|
5
|
+
# references a class that no longer exists in the application.
|
|
6
|
+
#
|
|
7
|
+
# ## Why readonly?
|
|
8
|
+
#
|
|
9
|
+
# Rails' `ensure_proper_type` (called via `save`/`update`) would overwrite the
|
|
10
|
+
# original `type` value with `'Postburner::OrphanedJob'`, permanently destroying
|
|
11
|
+
# the record of which class was missing. `readonly?` returning true prevents all
|
|
12
|
+
# `save`/`update` paths and therefore blocks `ensure_proper_type` from running.
|
|
13
|
+
#
|
|
14
|
+
# Rails' `instantiate` reads the `type` column verbatim when loading records from
|
|
15
|
+
# the database and does NOT call `ensure_proper_type`, so the original class name
|
|
16
|
+
# is preserved on load. This class is only ever instantiated via the
|
|
17
|
+
# `find_sti_class` fallback in {Postburner::Job}, never created directly.
|
|
18
|
+
#
|
|
19
|
+
# ## Why remove! is overridden here
|
|
20
|
+
#
|
|
21
|
+
# In Rails 8.1+, `update_column` respects `readonly?` and raises
|
|
22
|
+
# `ActiveRecord::ReadOnlyRecord`. The base `Commands#remove!` calls
|
|
23
|
+
# `update_column(:removed_at, ...)` — so it would be blocked too. This subclass
|
|
24
|
+
# overrides `remove!` to use `self.class.where(id: self.id).update_all(...)` which
|
|
25
|
+
# operates at the relation level (bypasses both `readonly?` and all instance
|
|
26
|
+
# callbacks) while still soft-deleting the row. Admins can therefore safely
|
|
27
|
+
# soft-remove orphaned rows without restoring the missing class.
|
|
28
|
+
#
|
|
29
|
+
# ## Usage
|
|
30
|
+
#
|
|
31
|
+
# This class is loaded automatically by {Postburner::Job.find_sti_class} when a
|
|
32
|
+
# row's type is unresolvable. You should never instantiate it directly in
|
|
33
|
+
# application code.
|
|
34
|
+
#
|
|
35
|
+
# @example Typical lifecycle
|
|
36
|
+
# # A DB row exists with type: 'Flex::TriggerNoShowDeliveriesJob' (class deleted)
|
|
37
|
+
# job = Postburner::Job.find(42)
|
|
38
|
+
# job.class # => Postburner::OrphanedJob
|
|
39
|
+
# job.type # => 'Flex::TriggerNoShowDeliveriesJob'
|
|
40
|
+
# job.orphaned? # => true
|
|
41
|
+
# job.missing_class_name # => 'Flex::TriggerNoShowDeliveriesJob'
|
|
42
|
+
# job.perform # => raises Postburner::Job::OrphanedJobError
|
|
43
|
+
# job.remove! # soft-deletes fine despite readonly?
|
|
44
|
+
#
|
|
45
|
+
class OrphanedJob < Job
|
|
46
|
+
# Returns the original class name stored in the `type` column — the deleted
|
|
47
|
+
# or renamed class that this placeholder stands in for.
|
|
48
|
+
#
|
|
49
|
+
# @return [String]
|
|
50
|
+
#
|
|
51
|
+
def missing_class_name
|
|
52
|
+
self.type.to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Always true; identifies this instance as an orphaned placeholder.
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
#
|
|
59
|
+
def orphaned? = true
|
|
60
|
+
|
|
61
|
+
# Raises {Postburner::Job::OrphanedJobError} because the original job class
|
|
62
|
+
# no longer exists and there is no implementation to run.
|
|
63
|
+
#
|
|
64
|
+
# @raise [Postburner::Job::OrphanedJobError] always
|
|
65
|
+
#
|
|
66
|
+
def perform(*)
|
|
67
|
+
raise Postburner::Job::OrphanedJobError,
|
|
68
|
+
"Cannot perform orphaned job ##{self.id}: class '#{self.missing_class_name}' no longer exists"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Raises {Postburner::Job::OrphanedJobError} because re-enqueuing a job whose
|
|
72
|
+
# class is gone would only produce another unperformable entry.
|
|
73
|
+
#
|
|
74
|
+
# @raise [Postburner::Job::OrphanedJobError] always
|
|
75
|
+
#
|
|
76
|
+
def queue!(*)
|
|
77
|
+
raise Postburner::Job::OrphanedJobError,
|
|
78
|
+
"Cannot enqueue orphaned job ##{self.id}: class '#{self.missing_class_name}' no longer exists"
|
|
79
|
+
end
|
|
80
|
+
alias_method :requeue!, :queue!
|
|
81
|
+
|
|
82
|
+
# Soft-deletes this orphaned row by setting `removed_at`, bypassing the
|
|
83
|
+
# `readonly?` guard via a relation-level `update_all` call (which does not go
|
|
84
|
+
# through instance save/update and therefore does not trigger
|
|
85
|
+
# `ensure_proper_type` either).
|
|
86
|
+
#
|
|
87
|
+
# Idempotent: does nothing if already removed.
|
|
88
|
+
#
|
|
89
|
+
# @return [void]
|
|
90
|
+
#
|
|
91
|
+
def remove!
|
|
92
|
+
return if self.removed_at
|
|
93
|
+
|
|
94
|
+
self.delete!
|
|
95
|
+
now = Time.current
|
|
96
|
+
self.class.where(id: self.id).update_all(removed_at: now)
|
|
97
|
+
self.removed_at = now
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Prevents saves that would allow Rails' ensure_proper_type to overwrite the
|
|
101
|
+
# original `type` value with 'Postburner::OrphanedJob'.
|
|
102
|
+
#
|
|
103
|
+
# @return [Boolean] always true
|
|
104
|
+
#
|
|
105
|
+
def readonly? = true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -366,6 +366,11 @@ module Postburner
|
|
|
366
366
|
|
|
367
367
|
execution.save!
|
|
368
368
|
|
|
369
|
+
# Reload so instrumented timestamps reflect persisted (microsecond)
|
|
370
|
+
# precision rather than the in-memory nanosecond values, matching what
|
|
371
|
+
# consumers see after a DB roundtrip (see #enqueue! reload below).
|
|
372
|
+
execution.reload
|
|
373
|
+
|
|
369
374
|
# Instrument execution creation
|
|
370
375
|
ActiveSupport::Notifications.instrument('create.schedule_execution.postburner', {
|
|
371
376
|
schedule: Postburner::Instrumentation.schedule_payload(self),
|
|
@@ -13,7 +13,7 @@ class CreatePostburnerJobs < ActiveRecord::Migration<%= migration_version %>
|
|
|
13
13
|
t.datetime :processing_at
|
|
14
14
|
t.datetime :processed_at
|
|
15
15
|
t.datetime :removed_at
|
|
16
|
-
t.
|
|
16
|
+
t.bigint :lag
|
|
17
17
|
t.integer :duration
|
|
18
18
|
t.integer :attempt_count
|
|
19
19
|
t.integer :error_count
|
|
@@ -37,6 +37,22 @@ class CreatePostburnerJobs < ActiveRecord::Migration<%= migration_version %>
|
|
|
37
37
|
t.index [ :error_count ]
|
|
38
38
|
t.index [ :log_count ]
|
|
39
39
|
|
|
40
|
+
# Partial indexes tuned to the admin UI's default queries. Kept
|
|
41
|
+
# alongside the single-column indexes above: the single-column
|
|
42
|
+
# indexes still serve ad-hoc queries, while these partials keep
|
|
43
|
+
# the hot-path listing queries fast on large tables.
|
|
44
|
+
t.index :run_at,
|
|
45
|
+
name: 'idx_pbjobs_active_run_at',
|
|
46
|
+
where: 'processed_at IS NULL AND removed_at IS NULL AND queued_at IS NOT NULL'
|
|
47
|
+
t.index :processed_at,
|
|
48
|
+
name: 'idx_pbjobs_processed',
|
|
49
|
+
order: { processed_at: :desc },
|
|
50
|
+
where: 'processed_at IS NOT NULL'
|
|
51
|
+
t.index [ :removed_at, :created_at ],
|
|
52
|
+
name: 'idx_pbjobs_unqueued',
|
|
53
|
+
order: { created_at: :desc },
|
|
54
|
+
where: 'queued_at IS NULL AND removed_at IS NULL'
|
|
55
|
+
|
|
40
56
|
# Add these or more depending on what you want to search for.
|
|
41
57
|
#t.index [ :attempts ], using: :gin
|
|
42
58
|
#t.index [ :errata ], using: :gin
|
data/lib/postburner/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: postburner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.0.rc.
|
|
4
|
+
version: 1.0.0.rc.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matt Smith
|
|
@@ -138,6 +138,7 @@ files:
|
|
|
138
138
|
- app/models/postburner/application_record.rb
|
|
139
139
|
- app/models/postburner/job.rb
|
|
140
140
|
- app/models/postburner/mailer.rb
|
|
141
|
+
- app/models/postburner/orphaned_job.rb
|
|
141
142
|
- app/models/postburner/schedule.rb
|
|
142
143
|
- app/models/postburner/schedule_execution.rb
|
|
143
144
|
- app/models/postburner/tracked_job.rb
|