Class: Postburner::OrphanedJob

Inherits:
Job show all
Defined in:
app/models/postburner/orphaned_job.rb

Overview

Inert STI placeholder loaded when a Postburner::Job row’s ‘type` column references a class that no longer exists in the application.

## Why readonly?

Rails’ ‘ensure_proper_type` (called via `save`/`update`) would overwrite the original `type` value with `’Postburner::OrphanedJob’‘, permanently destroying the record of which class was missing. `readonly?` returning true prevents all `save`/`update` paths and therefore blocks `ensure_proper_type` from running.

Rails’ ‘instantiate` reads the `type` column verbatim when loading records from the database and does NOT call `ensure_proper_type`, so the original class name is preserved on load. This class is only ever instantiated via the `find_sti_class` fallback in Job, never created directly.

## Why remove! is overridden here

In Rails 8.1+, ‘update_column` respects `readonly?` and raises `ActiveRecord::ReadOnlyRecord`. The base `Commands#remove!` calls `update_column(:removed_at, …)` — so it would be blocked too. This subclass overrides `remove!` to use `self.class.where(id: self.id).update_all(…)` which operates at the relation level (bypasses both `readonly?` and all instance callbacks) while still soft-deleting the row. Admins can therefore safely soft-remove orphaned rows without restoring the missing class.

## Usage

This class is loaded automatically by Job.find_sti_class when a row’s type is unresolvable. You should never instantiate it directly in application code.

Examples:

Typical lifecycle

# A DB row exists with type: 'Flex::TriggerNoShowDeliveriesJob' (class deleted)
job = Postburner::Job.find(42)
job.class         # => Postburner::OrphanedJob
job.type          # => 'Flex::TriggerNoShowDeliveriesJob'
job.orphaned?     # => true
job.missing_class_name  # => 'Flex::TriggerNoShowDeliveriesJob'
job.perform             # => raises Postburner::Job::OrphanedJobError
job.remove!             # soft-deletes fine despite readonly?

Instance Attribute Summary

Attributes inherited from Job

#args, #attempt_count, #attempting_at, #attempts, #bkid, #duration, #errata, #error_count, #id, #lag, #log_count, #logs, #processed_at, #processing_at, #queued_at, #removed_at, #run_at, #sid, #type

Instance Method Summary collapse

Methods inherited from Job

#bk, #bk!, #bk=, #destroy!, find_sti_class

Methods included from Statistics

#elapsed_ms, #intended_at, #stats

Methods included from Execution

#perform!

Methods included from Insertion

#will_insert?

Methods included from Commands

#delete!, #extend!, #kick!

Methods included from Logging

#log, #log!, #log_exception, #log_exception!

Methods included from Properties

#priority, #queue_name, #retry_delay_for_attempt, #should_retry?, #ttr, #tube_name

Instance Method Details

#destroyself

Hard-deletes this orphaned row, removing BOTH its Beanstalkd job and its AR row, while keeping ‘readonly?` true for saves/updates.

The base ActiveRecord#destroy raises ReadOnlyRecord on a readonly record, which would make ScheduleExecution#teardown_job! (which calls ‘job.destroy` uniformly across job shapes) fail for an execution whose job class was deleted/renamed. This override mirrors #remove!: it removes the Beanstalkd job via Commands#delete! and deletes the row at the relation level — using the STI base class so the missing `type` value doesn’t scope the row out — bypassing both ‘readonly?` and instance callbacks. The row is genuinely removed (not a soft-delete), matching destroy semantics.

Returns:

  • (self)

    frozen, with destroyed? == true



118
119
120
121
122
123
# File 'app/models/postburner/orphaned_job.rb', line 118

def destroy
  self.delete!
  self.class.base_class.where(id: self.id).delete_all
  @destroyed = true
  freeze
end

#missing_class_nameString

Returns the original class name stored in the ‘type` column — the deleted or renamed class that this placeholder stands in for.

Returns:

  • (String)


51
52
53
# File 'app/models/postburner/orphaned_job.rb', line 51

def missing_class_name
  self.type.to_s
end

#orphaned?Boolean

Always true; identifies this instance as an orphaned placeholder.

Returns:

  • (Boolean)


59
# File 'app/models/postburner/orphaned_job.rb', line 59

def orphaned? = true

#performObject

Raises Job::OrphanedJobError because the original job class no longer exists and there is no implementation to run.

Raises:



66
67
68
69
# File 'app/models/postburner/orphaned_job.rb', line 66

def perform(*)
  raise Postburner::Job::OrphanedJobError,
    "Cannot perform orphaned job ##{self.id}: class '#{self.missing_class_name}' no longer exists"
end

#queue!Object Also known as: requeue!

Raises Job::OrphanedJobError because re-enqueuing a job whose class is gone would only produce another unperformable entry.

Raises:



76
77
78
79
# File 'app/models/postburner/orphaned_job.rb', line 76

def queue!(*)
  raise Postburner::Job::OrphanedJobError,
    "Cannot enqueue orphaned job ##{self.id}: class '#{self.missing_class_name}' no longer exists"
end

#readonly?Boolean

Prevents saves that would allow Rails’ ensure_proper_type to overwrite the original ‘type` value with ’Postburner::OrphanedJob’.

Returns:

  • (Boolean)

    always true



130
# File 'app/models/postburner/orphaned_job.rb', line 130

def readonly? = true

#remove!void

This method returns an undefined value.

Soft-deletes this orphaned row by setting ‘removed_at`, bypassing the `readonly?` guard via a relation-level `update_all` call (which does not go through instance save/update and therefore does not trigger `ensure_proper_type` either).

Uses the STI base class for the relation so the row’s missing ‘type` value (which differs from OrphanedJob’s sti_name) does not scope it out — a ‘self.class.where` would match zero rows and silently fail to persist.

Idempotent: does nothing if already removed.



95
96
97
98
99
100
101
102
# File 'app/models/postburner/orphaned_job.rb', line 95

def remove!
  return if self.removed_at

  self.delete!
  now = Time.current
  self.class.base_class.where(id: self.id).update_all(removed_at: now)
  self.removed_at = now
end