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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9aa20d7fa2c867942efc6c70f7ad56548e651192068c9505a2854310cbb95043
4
- data.tar.gz: 74606e227872a914c3a806227d74206d4b66a33b7160753cbbb48a15d48a7d9c
3
+ metadata.gz: fc5a8c616b4bf393b03fdc5d2bef57eb03e1a9b9dc06fe592f89b3d1238c2606
4
+ data.tar.gz: 40fe3028ce331e7ecbc6facb5829b845272ce66c14578cdd12b79a4495de85ce
5
5
  SHA512:
6
- metadata.gz: cf4238e6bc83f1447de5c846032aad268d904fb5f74c1462424c69ffed8e40ffe316c51c2d248b2a6b46b7fb96df9b572f028368cc9e8e65e4baf8bffa7175b5
7
- data.tar.gz: c93242ccd8501c5b337417f60633474104550360276c607a48308ed6bfcbcd5bd77aeafa9df7e6f0e6cb0f15094ffe4ada75ef13f2704e98c2fd31bc674fe791
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
- @jobs = Job.order(queued_at: :desc, created_at: :desc)
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.integer :lag
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
@@ -1,3 +1,3 @@
1
1
  module Postburner
2
- VERSION = '1.0.0.rc.3'
2
+ VERSION = '1.0.0.rc.4'
3
3
  end
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.3
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