postburner 1.0.0.rc.3 → 1.0.0.rc.5

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: 62187fb2fd681e87e1028c52cbd6f3756b32c895c05390bffca05d51e1287ec7
4
+ data.tar.gz: 31ab083d1e9d00bfb7dc6fabbf09d7065f5062d73a6cd738835a0b76f54426b6
5
5
  SHA512:
6
- metadata.gz: cf4238e6bc83f1447de5c846032aad268d904fb5f74c1462424c69ffed8e40ffe316c51c2d248b2a6b46b7fb96df9b572f028368cc9e8e65e4baf8bffa7175b5
7
- data.tar.gz: c93242ccd8501c5b337417f60633474104550360276c607a48308ed6bfcbcd5bd77aeafa9df7e6f0e6cb0f15094ffe4ada75ef13f2704e98c2fd31bc674fe791
6
+ metadata.gz: d2e7a422e53852490b5e2ec8502f3dd69a34f17975ee808f463658f20f2ca228c5467d149987caaf7e905433b364b30c7f92142e07e7f7da3bbd7763d99c6284
7
+ data.tar.gz: aa6ce11d8f045be5b259a1869339117ddf40fca8e6d3579ced469355d2c89fbd7f19da96903154dac11a0f0efed57db840c1581ba9512c2665300d58d0453e32
data/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.0.rc.5 - 2026-06-26
4
+
5
+ ### Fixed
6
+ - `Postburner::Scheduler#ensure_future_execution!` no longer accumulates duplicate future `Postburner::ScheduleExecution` rows. The watchdog (every `scheduler_interval`, default 300s) created and enqueued one extra future execution on **every** cycle, building up dozens or hundreds of Beanstalkd delayed jobs that all eventually fire.
7
+
8
+ ## v1.0.0.rc.4 - 2026-06-10
9
+
10
+ ### Added
11
+ - `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.
12
+ - `Postburner::Job::OrphanedJobError` — raised by `OrphanedJob#perform` and `#queue!`/`#requeue!`.
13
+ - `Postburner::Job#orphaned?` — predicate; always `false` on the base class, `true` on `OrphanedJob`.
14
+ - `Postburner::Job.find_sti_class` override — rescues `ActiveRecord::SubclassNotFound` and returns `Postburner::OrphanedJob` instead.
15
+ - 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.
16
+ - `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.
17
+
18
+ ### Changed
19
+ - `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.
20
+
21
+ ### Upgrade notes
22
+ - 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.
23
+ - 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.
24
+
25
+ Example migration covering both the `lag` widening and the three partial indexes:
26
+
27
+ ```ruby
28
+ class UpgradePostburnerJobsForRc4 < ActiveRecord::Migration[7.2]
29
+ disable_ddl_transaction!
30
+
31
+ def up
32
+ # Widen lag to bigint to avoid 32-bit overflow on long-queued jobs.
33
+ # ~24.9 days -> ~292.5 million years
34
+ change_column :postburner_jobs, :lag, :bigint
35
+
36
+ # Partial indexes tuned to the admin UI's default queries.
37
+ add_index :postburner_jobs, :run_at,
38
+ name: 'idx_pbjobs_active_run_at',
39
+ where: '(processed_at IS NULL AND removed_at IS NULL AND queued_at IS NOT NULL)',
40
+ algorithm: :concurrently
41
+
42
+ add_index :postburner_jobs, :processed_at,
43
+ name: 'idx_pbjobs_processed',
44
+ order: { processed_at: :desc },
45
+ where: '(processed_at IS NOT NULL)',
46
+ algorithm: :concurrently
47
+
48
+ add_index :postburner_jobs, [:removed_at, :created_at],
49
+ name: 'idx_pbjobs_unqueued',
50
+ order: { created_at: :desc },
51
+ where: '(queued_at IS NULL AND removed_at IS NULL)',
52
+ algorithm: :concurrently
53
+ end
54
+
55
+ def down
56
+ remove_index :postburner_jobs, name: 'idx_pbjobs_unqueued', algorithm: :concurrently
57
+ remove_index :postburner_jobs, name: 'idx_pbjobs_processed', algorithm: :concurrently
58
+ remove_index :postburner_jobs, name: 'idx_pbjobs_active_run_at', algorithm: :concurrently
59
+
60
+ change_column :postburner_jobs, :lag, :integer
61
+ end
62
+ end
63
+ ```
64
+
65
+ Notes:
66
+ - `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.
67
+ - `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.
68
+ - Adjust `ActiveRecord::Migration[7.2]` to match your app's Rails version.
69
+
70
+ ## v1.0.0.rc.3
71
+
72
+ ## v1.0.0.rc.2
73
+
74
+ ## v1.0.0.rc.1
75
+
3
76
  ## v0.7.0 - 2022-10-24
4
77
 
5
78
  ### 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,45 @@ module Postburner
88
88
 
89
89
  validates :sid, presence: {strict: true}
90
90
 
91
+ # Jobs that have been picked up by a worker but have not yet completed or
92
+ # been removed, i.e. currently in-flight.
93
+ #
94
+ # @note A job whose worker crashed mid-run can remain in this scope even
95
+ # though nothing is executing. Beanstalkd's reserved count
96
+ # ({Postburner.stats}) is the ground truth for what is actually running.
97
+ #
98
+ # @example
99
+ # Postburner::Job.processing # all in-flight tracked jobs
100
+ # Postburner::TrackedJob.processing # in-flight tracked ActiveJobs
101
+ #
102
+ scope :processing, -> {
103
+ where.not(processing_at: nil).where(processed_at: nil, removed_at: nil)
104
+ }
105
+
106
+ # Resolves an STI type name to a class, falling back to OrphanedJob when the
107
+ # original class no longer exists. This tolerates rows whose `type` column
108
+ # references a deleted or renamed job class, preventing
109
+ # ActiveRecord::SubclassNotFound from crashing callers (e.g. the admin
110
+ # queued-jobs page).
111
+ #
112
+ # @param type_name [String] The value stored in the `type` column
113
+ # @return [Class] The resolved class, or {Postburner::OrphanedJob} if missing
114
+ #
115
+ def self.find_sti_class(type_name)
116
+ super
117
+ rescue ActiveRecord::SubclassNotFound
118
+ Postburner::OrphanedJob
119
+ end
120
+
121
+ # Returns whether this job instance is an orphaned placeholder.
122
+ #
123
+ # Always false on the base class. Overridden to return true in
124
+ # {Postburner::OrphanedJob}.
125
+ #
126
+ # @return [Boolean]
127
+ #
128
+ def orphaned? = false
129
+
91
130
  # Abstract method to be implemented by subclasses.
92
131
  #
93
132
  # This method contains the actual work logic for the job. It is called
@@ -220,6 +259,15 @@ module Postburner
220
259
  #
221
260
  class AlreadyProcessed < StandardError; end
222
261
 
262
+ # Exception raised when attempting to perform or enqueue an orphaned job whose
263
+ # original class no longer exists in the application.
264
+ #
265
+ # @example
266
+ # job = Postburner::Job.find(id) # loads as OrphanedJob if class is gone
267
+ # job.perform # => raises OrphanedJobError
268
+ #
269
+ class OrphanedJobError < StandardError; end
270
+
223
271
  # Exception raised when a job is executed before its scheduled run_at time.
224
272
  #
225
273
  # 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
@@ -484,7 +484,32 @@ module Postburner
484
484
  return true
485
485
  end
486
486
 
487
- # Delegate to Schedule - it knows whether a future execution is needed
487
+ # GUARD: Only create a new future execution if none exists yet (relative to now).
488
+ #
489
+ # This guard intentionally uses Time.current, while the inner guard inside
490
+ # create_next_execution! (schedule.rb:199-200) uses after.run_at. They answer
491
+ # DIFFERENT questions:
492
+ #
493
+ # Watchdog (here): "Does ANY execution exist in the future?"
494
+ # before_attempt callback: "Does an execution exist AFTER the one now running?"
495
+ #
496
+ # The before_attempt callback (job.rb:380) passes `after: current_execution` so
497
+ # create_next_execution!'s check_time ≈ Time.current — it correctly fires when
498
+ # the watchdog has already queued a successor. But when this method calls
499
+ # create_next_execution!(after: last_execution), check_time becomes
500
+ # last_execution.run_at — the highest run_at in the table by definition — so
501
+ # the inner guard can never find an execution beyond it and would always create
502
+ # another, accumulating one extra future execution per watchdog cycle.
503
+ #
504
+ # Note: > (not >=) is intentional. A just-fired execution at run_at == Time.current
505
+ # must NOT block creation of its successor.
506
+ #
507
+ # The inner guard in create_next_execution! is STILL REQUIRED for the
508
+ # before_attempt callback path and must NOT be removed even though it is
509
+ # effectively bypassed on the watchdog path now that this outer guard fires first.
510
+ return false if schedule.executions.where('run_at > ?', Time.current).exists?
511
+
512
+ # Delegate to Schedule - it knows how to calculate the next run time
488
513
  execution = schedule.create_next_execution!(after: last_execution)
489
514
 
490
515
  if execution
@@ -1,3 +1,3 @@
1
1
  module Postburner
2
- VERSION = '1.0.0.rc.3'
2
+ VERSION = '1.0.0.rc.5'
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.5
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