postburner 1.0.0.rc.4 → 1.0.0.rc.6

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: fc5a8c616b4bf393b03fdc5d2bef57eb03e1a9b9dc06fe592f89b3d1238c2606
4
- data.tar.gz: 40fe3028ce331e7ecbc6facb5829b845272ce66c14578cdd12b79a4495de85ce
3
+ metadata.gz: b48c13a76dd9b1b6e54f825bbc50d95574c0decd4a57b8b6ba3d3fc8c5ea369c
4
+ data.tar.gz: 5a867eef5d36cce234b2874b5c18ff95d0ddb648fd1576e71c0a8c5946ddc12c
5
5
  SHA512:
6
- metadata.gz: 2e3095d5efd3761db840b44461f42f127269608a6070ead2e9b5751054e3875e11098d34ac9232237baa27350ec46fbf8413a80adde99feae0115bedd9b08e68
7
- data.tar.gz: 3a38ad87ba8692bae49462dbf6ac8e0013cb64209200c44201a25e5cb5d2c73ea14719753f279da4ce7da016d1af581a446bec7ad97af035f4884d645c12fefe
6
+ metadata.gz: 82e60e8af9d983550d7a07dac46b1c5611eb11245944e2ddac5a7411cf2d0b63a59697cab1a83965379bb92c274c1abd33a1a475498f981cbcb8589d520ac61c
7
+ data.tar.gz: 430829a786da22122f8ce4a47e8e966feb524877539f7a0e4086db912731235064c6888d0662d67c438e0c2e2d590a2a4496e33a56658b3022f70fbbb58ca050
data/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.0.rc.6 - 2026-06-27
4
+
5
+ ### Highlights
6
+
7
+ - `Postburner::Schedule#reconcile!` — guarantees exactly one live future execution per enabled schedule and zero future executions for a disabled schedule. The watchdog delegates to it.
8
+ - `Postburner::Schedule#enable!` / `#disable!` — convenience wrappers that drive reconciliation.
9
+ - Auto-reconcile on schedule edits — an `after_update_commit` hook reconciles whenever a scheduling/snapshot attribute changes.
10
+ - `Postburner::ScheduleExecution#supersede!` — tears down an execution (Beanstalkd job + `Postburner::Job` AR row) and marks it `superseded`.
11
+ - `superseded` status (enum value `201`) on `Postburner::ScheduleExecution`, distinct from `skipped`. Neither counts as a live future execution.
12
+ - `live` and `future_live` scopes on `Postburner::ScheduleExecution` (`pending`/`scheduled` only; `future_live` adds `run_at > now`).
13
+ - Instrumentation events `supersede.schedule_execution.postburner` and `reconcile.schedule.postburner`.
14
+ - **`ScheduleExecution#skip!` now skips one occurrence, then resumes -- tearing down down the associated `Postburner::Job` **and** the Beanstalkd job. The skipped row is retained as history.
15
+ - **`schedule.destroy` now tears down each execution's Beanstalkd job and `Postburner::Job` (no orphans)
16
+ - The watchdog sweeps **disabled** schedules with a lingering live future execution and supersedes them.
17
+
18
+ ### Fixed
19
+
20
+ - Scheduled **tracked ActiveJob** executions had a `nil` `bkid`: the adapter stored the entire Beanstalkd put response hash (`{status:, id:}`) instead of the id, which cast to `nil` in the `bigint` column. Scheduled **non-tracked ActiveJob** executions discarded the Beanstalkd id entirely (`beanstalk_job_id` was always `nil`), so they could not be cancelled. Both now capture the real id via the adapter's `provider_job_id`.
21
+ - Scheduled **`Postburner::Job`** executions stored the wrong value in `ScheduleExecution#beanstalk_job_id` (the return value of `queue!`, not the Beanstalkd id). It now holds the real `job.bkid`.
22
+ - `Postburner::OrphanedJob#destroy` can be soft-removed (`remove!`) and destroyed (`destroy`), with Beanstalkd job, while `readonly?` still blocks changes.
23
+
24
+ ### Upgrade notes
25
+
26
+ - This is a **template mutation** (like rc.4), not an additive migration. Existing installs must add a migration that:
27
+ 1. Replaces the total unique index on `(schedule_id, run_at)` with a **live-only partial** unique index (`WHERE status IN (0, 11)`), so a superseded/skipped row can coexist with a freshly recreated live execution at the same `run_at`.
28
+ 2. Drops `ON DELETE CASCADE` on the `schedule_id` foreign key (teardown is now owned by `dependent: :destroy` + `before_destroy`).
29
+ - **Behavior change — deleting a schedule.** With `ON DELETE CASCADE` removed, deleting a schedule by any path other than ActiveRecord `destroy` — raw SQL, `Schedule.delete`, `where(...).delete_all` — now raises a foreign-key violation when the schedule has executions, instead of silently cascading. Always use `schedule.destroy` (which runs `dependent: :destroy` + the `before_destroy` teardown). This is intentional: it prevents orphaned Beanstalkd jobs and `Postburner::Job` rows.
30
+
31
+ Standalone upgrade migration (written for zero-downtime on large `postburner_schedule_executions` tables — builds the index `CONCURRENTLY` and adds the FK unvalidated then validates it, neither of which blocks writes; requires `disable_ddl_transaction!`):
32
+
33
+ ```ruby
34
+ class UpgradePostburnerSchedulesForReconcile < ActiveRecord::Migration[7.2]
35
+ disable_ddl_transaction!
36
+
37
+ # Existing installs created the unique index via the generator with Rails'
38
+ # default name. Adjust if your install named it differently.
39
+ OLD_INDEX = 'index_postburner_schedule_executions_on_schedule_id_and_run_at'
40
+ NEW_INDEX = 'index_pb_sched_exec_live_schedule_run_at'
41
+
42
+ def up
43
+ # 1. Add the live-only partial unique index CONCURRENTLY (no write lock) so a
44
+ # superseded/skipped row can coexist with a live recreate at the same run_at.
45
+ # Narrowing uniqueness can't conflict with existing data (the old total
46
+ # index was stricter), so this build cannot fail on valid rows.
47
+ add_index :postburner_schedule_executions, [:schedule_id, :run_at],
48
+ unique: true, where: 'status IN (0, 11)',
49
+ name: NEW_INDEX, algorithm: :concurrently, if_not_exists: true
50
+
51
+ # Drop the old total unique index. Target by NAME: with the new index added,
52
+ # two indexes now match the (schedule_id, run_at) columns.
53
+ remove_index :postburner_schedule_executions,
54
+ name: OLD_INDEX, algorithm: :concurrently, if_exists: true
55
+
56
+ # 2. Replace the cascading FK with a plain one. Add unvalidated first (brief
57
+ # metadata-only lock), then validate separately (does not block writes).
58
+ # dependent: :destroy + before_destroy now own teardown.
59
+ remove_foreign_key :postburner_schedule_executions, column: :schedule_id
60
+ add_foreign_key :postburner_schedule_executions, :postburner_schedules,
61
+ column: :schedule_id, validate: false
62
+ validate_foreign_key :postburner_schedule_executions, column: :schedule_id
63
+ end
64
+
65
+ def down
66
+ remove_foreign_key :postburner_schedule_executions, column: :schedule_id
67
+ add_foreign_key :postburner_schedule_executions, :postburner_schedules,
68
+ column: :schedule_id, on_delete: :cascade, validate: false
69
+ validate_foreign_key :postburner_schedule_executions, column: :schedule_id
70
+
71
+ add_index :postburner_schedule_executions, [:schedule_id, :run_at],
72
+ unique: true, name: OLD_INDEX, algorithm: :concurrently, if_not_exists: true
73
+ remove_index :postburner_schedule_executions,
74
+ name: NEW_INDEX, algorithm: :concurrently, if_exists: true
75
+ end
76
+ end
77
+ ```
78
+
79
+ ## v1.0.0.rc.5 - 2026-06-26
80
+
81
+ ### Fixed
82
+ - `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.
83
+
3
84
  ## v1.0.0.rc.4 - 2026-06-10
4
85
 
5
86
  ### Added
data/README.md CHANGED
@@ -126,7 +126,7 @@ Postburner [beanstalkd](https://beanstalkd.github.io/) is used with PostgreSQL t
126
126
 
127
127
  ```ruby
128
128
  # Gemfile
129
- gem 'postburner', '~> 1.0.0.pre.18'
129
+ gem 'postburner', '~> 1.0.0.rc.6'
130
130
 
131
131
  # config/application.rb
132
132
  config.active_job.queue_adapter = :postburner
@@ -499,8 +499,7 @@ The scheduler uses **immediate enqueue** combined with a **watchdog safety net**
499
499
  ```
500
500
  4. When a worker reserves the watchdog, it instantiates `Postburner::Scheduler` which:
501
501
  - Acquires a PostgreSQL advisory lock for coordination
502
- - Auto-bootstraps any unstarted schedules
503
- - Ensures each schedule has a future execution queued
502
+ - Reconciles every schedule (see [Editing & Pausing Schedules](#editing--pausing-schedules)) auto-bootstrapping unstarted schedules, ensuring each enabled schedule has exactly one future execution queued, and tearing down lingering futures on disabled schedules
504
503
  - Re-queues a new watchdog with delay for the next interval
505
504
 
506
505
  NOTE: The watchdog is ephemeral data in Beanstalkd, not a database record. `Postburner::Scheduler` is the handler class that does the work. This design requires no dedicated scheduler process - existing workers handle everything.
@@ -687,8 +686,9 @@ Postburner::Schedule.create!(
687
686
  schedule = Postburner::Schedule.find_by(name: 'daily_cleanup')
688
687
  Postburner::Schedule.enabled # All enabled schedules
689
688
 
690
- # Disable temporarily
691
- schedule.update!(enabled: false)
689
+ # Pause / resume (preferred — see "Editing & Pausing Schedules" below)
690
+ schedule.disable! # Tears down the queued future execution
691
+ schedule.enable! # Resumes from the next grid point after now
692
692
 
693
693
  # Change catch-up policy
694
694
  schedule.update!(catch_up: true)
@@ -700,11 +700,68 @@ schedule.next_run_at # => 2025-01-02 09:00:00 -0500
700
700
  schedule.next_run_at_times(5) # Next 5 run times
701
701
 
702
702
  # View executions
703
+ schedule.executions.live # pending + scheduled (real queued work)
704
+ schedule.executions.future_live # the live future execution(s)
703
705
  schedule.executions.pending
704
706
  schedule.executions.scheduled
705
- schedule.executions.skipped
707
+ schedule.executions.skipped # cancelled occurrences (history)
708
+ schedule.executions.superseded # replaced by a recreate (history)
709
+
710
+ # Delete a schedule (ALWAYS use destroy — see note below)
711
+ schedule.destroy # Tears down every execution's Beanstalkd job + Postburner::Job row
706
712
  ```
707
713
 
714
+ **Deleting schedules — always use `destroy`.** `schedule.destroy` runs `dependent: :destroy` plus a `before_destroy` teardown that removes each execution's Beanstalkd job and any `Postburner::Job` row, so nothing is orphaned. Deleting a schedule by any other path — raw SQL, `Schedule.delete`, `where(...).delete_all` — raises a foreign-key violation when the schedule has executions, by design. Reach for `schedule.destroy` (or `disable!` to keep the record and history).
715
+
716
+ #### Editing & Pausing Schedules
717
+
718
+ Postburner keeps a schedule's queued work in sync with its configuration through a single, idempotent convergence path: **reconciliation**. The guarantee is simple:
719
+
720
+ - An **enabled** schedule always has **exactly one** live future execution, sitting on the current grid with a snapshot matching the live config.
721
+ - A **disabled** schedule has **zero** future executions.
722
+
723
+ You rarely call reconciliation directly (`schedule.reconcile!` exists if you need it). Instead it runs for you in three places: the watchdog reconciles every schedule on each pass, schedule edits trigger it automatically, and `enable!` / `disable!` drive it.
724
+
725
+ ##### Pausing & resuming
726
+
727
+ Use `disable!` / `enable!` rather than `update!(enabled: ...)` (both work, but these read better and make intent obvious):
728
+
729
+ ```ruby
730
+ schedule.disable! # Supersedes the queued future execution → zero future executions
731
+ schedule.enable! # Recreates a future execution at the next grid point after now
732
+ ```
733
+
734
+ Disabling now actually tears down the queued future execution in Beanstalkd — it doesn't just stop creating new ones. Enabling resumes from the **next grid point after now**; it does **not** backfill the gap while the schedule was paused (`catch_up` only governs the running-job chaining path, not resume).
735
+
736
+ ##### Editing a schedule
737
+
738
+ Editing a scheduling/snapshot attribute — `args`, `queue`, `priority`, the grid (`anchor`, `interval`, `interval_unit`, `cron`), `timezone`, `enabled`, or `name` — automatically supersedes the stale queued execution and recreates it from the current config (via an `after_update_commit` hook):
739
+
740
+ ```ruby
741
+ # Payload-only edit: recreates the future execution at the SAME run_at
742
+ schedule.update!(args: { report_type: 'weekly' })
743
+
744
+ # Grid edit: recreates the future execution at the NEW grid point
745
+ schedule.update!(interval: 2, interval_unit: 'hours')
746
+ ```
747
+
748
+ > **Best-effort, not synchronous.** This inline reconcile is non-blocking: it *tries* the global scheduler advisory lock without waiting, so it never holds up a web request. If the watchdog happens to hold the lock mid-sweep, the inline reconcile is skipped and the watchdog converges on its next pass. For UI-managed schedules, **don't assume the next run is updated immediately** — read it back after the watchdog interval (`scheduler_interval`) rather than right after the save.
749
+
750
+ ##### Skipping a single occurrence
751
+
752
+ `skip!` cancels **one** upcoming occurrence and then resumes at the next grid point after the skipped slot. It tears down both the Beanstalkd job and the associated `Postburner::Job` row, marks the execution `skipped` (kept as history), and triggers a best-effort reconcile. The skipped occurrence does not run and is not recreated:
753
+
754
+ ```ruby
755
+ execution = schedule.executions.future_live.first
756
+ execution.skip! # This one won't run; the schedule continues at the next grid point
757
+ ```
758
+
759
+ To pause every upcoming run instead of just one, use `disable!`.
760
+
761
+ ##### Superseding (internal)
762
+
763
+ `ScheduleExecution#supersede!` performs the same teardown as `skip!` but records a `superseded` status, meaning the execution was replaced by a recreate (config/grid drift resolved by reconciliation) rather than cancelled by an operator. Reconciliation uses it internally; you'll mostly encounter `superseded` rows when reading execution history. Like skipped rows, superseded rows are excluded from `live` / `future_live`.
764
+
708
765
  #### Starting Schedules
709
766
 
710
767
  When you create a schedule, it won't run until the first execution is created. You have two options:
@@ -748,19 +805,27 @@ Each scheduled run creates an execution record for tracking:
748
805
  ```ruby
749
806
  execution = Postburner::ScheduleExecution.find(123)
750
807
 
751
- execution.status # pending, scheduled, skipped
808
+ execution.status # pending, scheduled, skipped, superseded
752
809
  execution.run_at # Scheduled time
753
810
  execution.enqueued_at # When job was queued
754
811
  execution.beanstalk_job_id # Beanstalkd job ID
755
812
  execution.job_id # Postburner::Job ID (if using Postburner::Job)
756
813
  ```
757
814
 
815
+ **Status values:**
816
+ - `pending` — created, not yet enqueued to Beanstalkd
817
+ - `scheduled` — enqueued to Beanstalkd, waiting for `run_at`
818
+ - `skipped` — one occurrence cancelled by an operator (`skip!`); retained as history
819
+ - `superseded` — replaced by a recreate during reconciliation (`supersede!`); retained as history
820
+
821
+ Only `pending` and `scheduled` count as a live future execution (the `live` / `future_live` scopes). `skipped` and `superseded` are inert history.
822
+
758
823
  **Execution lifecycle:**
759
824
  1. Execution created with `pending` status and immediately enqueued to Beanstalkd
760
825
  2. Status changes to `scheduled` once enqueued
761
826
  3. At `run_at` time, Beanstalkd releases job to worker
762
827
  4. For `Postburner::Job` (and `ActiveJob` with `Postburner::Tracked`) schedules: `before_attempt` callback creates next execution
763
- 5. Watchdog periodically verifies future executions exist (safety net)
828
+ 5. Watchdog periodically reconciles each schedule, guaranteeing future executions exist (safety net)
764
829
 
765
830
  #### Timezone Handling
766
831
 
@@ -1572,7 +1637,8 @@ Postburner emits ActiveSupport::Notifications events following Rails conventions
1572
1637
  |-------|------|--------------|
1573
1638
  | `create.schedule.postburner` | When schedule is created | `:schedule` |
1574
1639
  | `update.schedule.postburner` | When schedule is updated | `:schedule`, `:changes` |
1575
- | `audit.schedule.postburner` | When scheduler audits a schedule | `:schedule` |
1640
+ | `audit.schedule.postburner` | When scheduler audits a schedule | `:schedule`, `:execution_created`, `:orphans_enqueued` |
1641
+ | `reconcile.schedule.postburner` | When a schedule is reconciled (executions converged to config) | `:schedule`, `:superseded`, `:created` |
1576
1642
 
1577
1643
  **Schedule Payload Structure:**
1578
1644
 
@@ -1595,7 +1661,8 @@ Postburner emits ActiveSupport::Notifications events following Rails conventions
1595
1661
  |-------|------|--------------|
1596
1662
  | `create.schedule_execution.postburner` | When execution is created | `:schedule`, `:execution` |
1597
1663
  | `enqueue.schedule_execution.postburner` | When execution is enqueued to Beanstalkd | `:schedule`, `:execution`, `:beanstalk_job_id` |
1598
- | `skip.schedule_execution.postburner` | When execution is skipped | `:schedule`, `:execution` |
1664
+ | `skip.schedule_execution.postburner` | When an operator skips one occurrence | `:schedule`, `:execution` |
1665
+ | `supersede.schedule_execution.postburner` | When an execution is superseded (replaced by a recreate during reconciliation) | `:schedule`, `:execution` |
1599
1666
 
1600
1667
  **Execution Payload Structure:**
1601
1668
 
@@ -2368,7 +2435,7 @@ Key changes in v1.0:
2368
2435
 
2369
2436
  1. **Update Gemfile:**
2370
2437
  ```ruby
2371
- gem 'postburner', '~> 1.0.0.pre.18'
2438
+ gem 'postburner', '~> 1.0.0.rc.6'
2372
2439
  ```
2373
2440
 
2374
2441
  2. **Remove Backburner config:**
@@ -88,6 +88,21 @@ 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
+
91
106
  # Resolves an STI type name to a class, falling back to OrphanedJob when the
92
107
  # original class no longer exists. This tolerates rows whose `type` column
93
108
  # references a deleted or renamed job class, preventing
@@ -84,6 +84,10 @@ module Postburner
84
84
  # through instance save/update and therefore does not trigger
85
85
  # `ensure_proper_type` either).
86
86
  #
87
+ # Uses the STI base class for the relation so the row's missing `type` value
88
+ # (which differs from OrphanedJob's sti_name) does not scope it out — a
89
+ # `self.class.where` would match zero rows and silently fail to persist.
90
+ #
87
91
  # Idempotent: does nothing if already removed.
88
92
  #
89
93
  # @return [void]
@@ -93,10 +97,31 @@ module Postburner
93
97
 
94
98
  self.delete!
95
99
  now = Time.current
96
- self.class.where(id: self.id).update_all(removed_at: now)
100
+ self.class.base_class.where(id: self.id).update_all(removed_at: now)
97
101
  self.removed_at = now
98
102
  end
99
103
 
104
+ # Hard-deletes this orphaned row, removing BOTH its Beanstalkd job and its AR
105
+ # row, while keeping `readonly?` true for saves/updates.
106
+ #
107
+ # The base ActiveRecord#destroy raises ReadOnlyRecord on a readonly record,
108
+ # which would make ScheduleExecution#teardown_job! (which calls `job.destroy`
109
+ # uniformly across job shapes) fail for an execution whose job class was
110
+ # deleted/renamed. This override mirrors {#remove!}: it removes the Beanstalkd
111
+ # job via {#delete!} and deletes the row at the relation level — using the STI
112
+ # base class so the missing `type` value doesn't scope the row out — bypassing
113
+ # both `readonly?` and instance callbacks. The row is genuinely removed (not a
114
+ # soft-delete), matching destroy semantics.
115
+ #
116
+ # @return [self] frozen, with destroyed? == true
117
+ #
118
+ def destroy
119
+ self.delete!
120
+ self.class.base_class.where(id: self.id).delete_all
121
+ @destroyed = true
122
+ freeze
123
+ end
124
+
100
125
  # Prevents saves that would allow Rails' ensure_proper_type to overwrite the
101
126
  # original `type` value with 'Postburner::OrphanedJob'.
102
127
  #