postburner 1.0.0.rc.2 → 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: 87f24f24d68086fe55b2fe315d36bc3ec9104a242066d6a78f5b61e2633f673d
4
- data.tar.gz: 9ed74afc5b63a477ee254e4812f1db19fd7d9c94064afa7084d5ec6d62ee7af9
3
+ metadata.gz: fc5a8c616b4bf393b03fdc5d2bef57eb03e1a9b9dc06fe592f89b3d1238c2606
4
+ data.tar.gz: 40fe3028ce331e7ecbc6facb5829b845272ce66c14578cdd12b79a4495de85ce
5
5
  SHA512:
6
- metadata.gz: a1a9bca8fe3cc955cb9f60c6f459cf3514bfe127806c7dda710cdd0e89822b1d398887b72d23c1f414deb5575621d829e7496f51ac6491485e9444190e70f8f6
7
- data.tar.gz: d916eaaa41debc35e5460b9360e1bb5717bc93a1b16d180d774dd2a49b3fd106ffb1a80ee0c74595eb9b42939669273e623fac460e75d5c1706b2ccab23b834f
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
data/README.md CHANGED
@@ -85,7 +85,7 @@ bundle exec rake postburner:work WORKER=default
85
85
 
86
86
  ## Table of Contents
87
87
 
88
- - [Why](#why)
88
+ - [Why Postburner?](#why-postburner)
89
89
  - [Quick Start](#quick-start)
90
90
  - [Usage](#usage)
91
91
  - [Standard Jobs](#standard-jobs)
@@ -106,7 +106,7 @@ bundle exec rake postburner:work WORKER=default
106
106
  - [Installation](#installation)
107
107
  - [Web UI - v2 Coming Soon](#web-ui)
108
108
 
109
- ## Why
109
+ ## Why Postburner?
110
110
 
111
111
  Postburner supports Async, Queues, Delayed, Priorities, Timeouts, and Retries from the [Backend Matrix](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters.html). But uniquely, priorities are per job, in addition to the class level. Timeouts are per job and class level as well, and can be extended dynamically. Postburner also supports scheduling jobs at fixed intervals, cron expressions, and calendar-aware anchor points.
112
112
 
@@ -927,6 +927,53 @@ test "tracked job logs execution" do
927
927
  end
928
928
  ```
929
929
 
930
+ ### Testing Emails
931
+
932
+ Standard ActiveJob test assertions (`assert_enqueued_emails`, `assert_enqueued_jobs`) work when using the `:test` ActiveJob adapter in your test environment, which is the Rails default:
933
+
934
+ ```ruby
935
+ class EmailTest < ActiveSupport::TestCase
936
+ # assert_enqueued_emails works — Rails' :test adapter tracks the enqueue
937
+ test "welcome email is enqueued" do
938
+ assert_enqueued_emails 1 do
939
+ UserMailer.welcome(user).deliver_later
940
+ end
941
+ end
942
+
943
+ # assert_emails works — the job executes inline, delivering the email
944
+ test "welcome email is sent" do
945
+ assert_emails 1 do
946
+ SendWelcomeEmail.perform_later(user.id)
947
+ end
948
+ end
949
+ end
950
+ ```
951
+
952
+ **How this works:** `assert_enqueued_emails` and `assert_enqueued_jobs` are powered by Rails' `:test` ActiveJob adapter, which tracks enqueued jobs in an in-memory array. Postburner's queue strategies (`InlineTestQueue`, etc.) control `Postburner::Job` subclasses separately. The two systems coexist — ActiveJob assertions use the `:test` adapter, `Postburner::Job` assertions use database queries.
953
+
954
+ By default, Rails uses the `:test` adapter in test mode unless you override it. Since you set `:postburner` as your adapter in `config/application.rb`, you should set it back to `:test` for your test environment:
955
+
956
+ ```ruby
957
+ # config/environments/test.rb
958
+ config.active_job.queue_adapter = :test
959
+ ```
960
+
961
+ This is standard practice for any ActiveJob adapter (Sidekiq, SolidQueue, etc.) — the production adapter handles real execution, and the `:test` adapter enables Rails' built-in assertion helpers. Without this, `assert_enqueued_emails` will not work because Postburner's adapter executes jobs rather than tracking them in an array.
962
+
963
+ **`Postburner::Mailer`** bypasses ActiveJob entirely, so `assert_enqueued_emails` cannot see these jobs regardless of adapter. Assert on side effects instead:
964
+
965
+ ```ruby
966
+ test "mailer job sends email" do
967
+ job = Postburner::Mailer.delivery(UserMailer, :welcome).with(user_id: 1)
968
+
969
+ assert_emails 1 do
970
+ job.queue! # Executes inline in test mode, delivering the email
971
+ end
972
+
973
+ assert job.reload.processed_at
974
+ end
975
+ ```
976
+
930
977
  ### Switching Queue Strategies
931
978
 
932
979
  For tests requiring specific queue behaviors, use `switch_queue_strategy!` and `restore_queue_strategy!`:
@@ -2031,13 +2078,15 @@ Postburner uses [Beaneater](https://github.com/beanstalkd/beaneater) as the Ruby
2031
2078
 
2032
2079
  ### Connection Methods
2033
2080
 
2081
+ Postburner uses **thread-local connections** — each thread gets its own Beanstalkd socket, matching the pattern used by worker threads. This is safe under multi-threaded servers like Puma.
2082
+
2034
2083
  ```ruby
2035
- # Get a cached Beaneater connection (returns Beaneater instance)
2084
+ # Get the thread-local Beaneater connection (returns Beaneater instance)
2036
2085
  conn = Postburner.connection
2037
2086
  conn.tubes.to_a # List all tubes
2038
2087
  conn.beanstalk.stats # Server statistics
2039
2088
 
2040
- # Block form - yields connection, recommended for one-off operations
2089
+ # Block form - yields thread-local connection
2041
2090
  Postburner.connected do |conn|
2042
2091
  conn.tubes.to_a # List all tubes
2043
2092
  conn.tubes['postburner.production.critical'].stats
@@ -2045,6 +2094,19 @@ Postburner.connected do |conn|
2045
2094
  end
2046
2095
  ```
2047
2096
 
2097
+ **Shutdown cleanup:**
2098
+
2099
+ On process shutdown, call `disconnect_all!` to close every thread's connection cleanly:
2100
+
2101
+ ```ruby
2102
+ # config/puma.rb
2103
+ on_worker_shutdown do
2104
+ Postburner.disconnect_all!
2105
+ end
2106
+ ```
2107
+
2108
+ This is optional — Ruby closes sockets on process exit — but gives you clean logs and explicit teardown.
2109
+
2048
2110
  ### Job-Level Access
2049
2111
 
2050
2112
  ```ruby
@@ -2248,7 +2310,7 @@ beanstalkd -l 127.0.0.1 -p 11300 -b /var/lib/beanstalkd
2248
2310
 
2249
2311
  ```ruby
2250
2312
  # Gemfile
2251
- gem 'postburner', '~> 1.0.0.pre.18'
2313
+ gem 'postburner', '~> 1.0.0.rc.2
2252
2314
  ```
2253
2315
 
2254
2316
  ```bash
@@ -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.
@@ -50,6 +50,20 @@ module Postburner
50
50
  # @example Queue to specific queue
51
51
  # Postburner::Mailer.delivery(UserMailer, :welcome).with(user_id: 1).queue!(queue: 'bulk_mailers')
52
52
  #
53
+ # == Testing
54
+ #
55
+ # Because Postburner::Mailer bypasses ActiveJob, Rails' +assert_enqueued_emails+
56
+ # will not detect these jobs. Use +assert_emails+ (which checks actual deliveries)
57
+ # or assert on the job record:
58
+ #
59
+ # # Assert email was delivered (inline in test mode)
60
+ # assert_emails 1 do
61
+ # Postburner::Mailer.delivery(UserMailer, :welcome).with(user_id: 1).queue!
62
+ # end
63
+ #
64
+ # # Assert job was processed
65
+ # assert job.reload.processed_at
66
+ #
53
67
  # @see Postburner::Job Base class for tracked jobs
54
68
  # @see Postburner::Configuration#default_mailer_queue Configuration option
55
69
  class Mailer < Job
@@ -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),
@@ -1,3 +1,5 @@
1
+ require 'time'
2
+
1
3
  class Postburner::InstallGenerator < Rails::Generators::Base
2
4
  source_root File.expand_path('templates', __dir__)
3
5
  include Rails::Generators::Migration
@@ -28,11 +30,15 @@ class Postburner::InstallGenerator < Rails::Generators::Base
28
30
  _max = timestamps.max || timestamp[-2..-1].to_i
29
31
  _next = _max + 1
30
32
  raise "MISSING NEXT" if _next.blank?
31
- migration_number = "#{stem}#{_next}"
32
- if migration_number.length != 14
33
- raise "INCORRECT LENGTH stem=#{stem} _next=#{_next} _max=#{_max}"
33
+
34
+ # When seconds overflow past 99, roll forward by 1 minute and reset.
35
+ if _next > 99
36
+ rolled = Time.parse("#{stem}00 UTC") + 60
37
+ stem = rolled.strftime('%Y%m%d%H%M')
38
+ _next = 0
34
39
  end
35
- migration_number
40
+
41
+ "#{stem}#{_next.to_s.rjust(2, '0')}"
36
42
  end
37
43
 
38
44
  private
@@ -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.2'
2
+ VERSION = '1.0.0.rc.4'
3
3
  end
data/lib/postburner.rb CHANGED
@@ -319,31 +319,31 @@ module Postburner
319
319
  # conn = Postburner.connection
320
320
  # conn.tubes['my.tube'].stats
321
321
  #
322
- # @note Connection is cached in @_connection instance variable
322
+ # @note Connection is cached in Thread.current[:postburner_connection] (thread-local)
323
323
  # @note Automatically reconnects if connection is not active
324
324
  #
325
325
  # @see #connected
326
326
  #
327
327
  def self.connection
328
- @_connection ||= Postburner::Connection.new
329
- @_connection.reconnect! unless @_connection.connected?
330
- @_connection
328
+ Thread.current[:postburner_connection] ||= Postburner::Connection.new
329
+ conn = Thread.current[:postburner_connection]
330
+ conn.reconnect! unless conn.connected?
331
+ conn
331
332
  end
332
333
 
333
334
  # Yields a Beanstalkd connection or returns cached connection.
334
335
  #
335
- # When called with a block, yields the connection and ensures it's closed
336
- # after the block completes. When called without a block, returns the
337
- # cached connection.
336
+ # When called with a block, yields the thread-local connection.
337
+ # When called without a block, returns the thread-local connection.
338
338
  #
339
339
  # @overload connected
340
340
  # Returns the cached Beanstalkd connection.
341
341
  # @return [Postburner::Connection] Beanstalkd connection
342
342
  #
343
343
  # @overload connected {|conn| ... }
344
- # Yields connection and ensures cleanup.
344
+ # Yields connection for the duration of the block.
345
345
  # @yieldparam conn [Postburner::Connection] Beanstalkd connection
346
- # @return [void]
346
+ # @return [Object] return value of the block
347
347
  #
348
348
  # @example With block (recommended)
349
349
  # Postburner.connected do |conn|
@@ -351,7 +351,6 @@ module Postburner
351
351
  # puts tube.name
352
352
  # end
353
353
  # end
354
- # # Connection automatically closed
355
354
  #
356
355
  # @example Without block
357
356
  # conn = Postburner.connected
@@ -364,19 +363,44 @@ module Postburner
364
363
  # end
365
364
  #
366
365
  # @see #connection
366
+ # @see #disconnect_all!
367
367
  #
368
368
  def self.connected
369
369
  if block_given?
370
- begin
371
- yield connection
372
- ensure
373
- connection.close if connection
374
- end
370
+ yield connection
375
371
  else
376
372
  connection
377
373
  end
378
374
  end
379
375
 
376
+ # Closes and clears Beanstalkd connections on all known threads.
377
+ #
378
+ # Call this on worker shutdown to release sockets cleanly. Because connections
379
+ # are thread-local, a single `connection.close` call from the main thread would
380
+ # only close that thread's socket. This method iterates every live thread and
381
+ # closes each thread's connection, if any.
382
+ #
383
+ # @return [void]
384
+ #
385
+ # @example Puma worker shutdown (config/puma.rb)
386
+ # on_worker_shutdown do
387
+ # Postburner.disconnect_all!
388
+ # end
389
+ #
390
+ def self.disconnect_all!
391
+ Thread.list.each do |thread|
392
+ if (conn = thread[:postburner_connection])
393
+ begin
394
+ conn.close if conn.respond_to?(:close) && conn.connected?
395
+ rescue => e
396
+ warn "Postburner: failed to close connection on shutdown: #{e.message}" if $VERBOSE
397
+ ensure
398
+ thread[:postburner_connection] = nil
399
+ end
400
+ end
401
+ end
402
+ end
403
+
380
404
  # Clears jobs from specified tubes or shows stats for all tubes.
381
405
  #
382
406
  # High-level method with formatted output. Delegates to Connection#clear_tubes!
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.2
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