postburner 1.0.0.rc.5 → 1.0.0.rc.7

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: 62187fb2fd681e87e1028c52cbd6f3756b32c895c05390bffca05d51e1287ec7
4
- data.tar.gz: 31ab083d1e9d00bfb7dc6fabbf09d7065f5062d73a6cd738835a0b76f54426b6
3
+ metadata.gz: 41b3c95f8abde17bb19b725dd0c39ce65b97a53e73afb77c7237e2e51f37d7e9
4
+ data.tar.gz: 7e5de5424a027d59c9552624bd555ae38da4f30823f3ffc28ef86601c96301e0
5
5
  SHA512:
6
- metadata.gz: d2e7a422e53852490b5e2ec8502f3dd69a34f17975ee808f463658f20f2ca228c5467d149987caaf7e905433b364b30c7f92142e07e7f7da3bbd7763d99c6284
7
- data.tar.gz: aa6ce11d8f045be5b259a1869339117ddf40fca8e6d3579ced469355d2c89fbd7f19da96903154dac11a0f0efed57db840c1581ba9512c2665300d58d0453e32
6
+ metadata.gz: d2f90b3d590adfc16247f285f8ea27cef03d9dd72af5671bc92117d2418442dcf6f331c68d5380a2917687ea2353524f972fdd7edeca4fe9a8e1b6b965dff9ef
7
+ data.tar.gz: 06accd7ecd5c01aa83532e7f8cd69e2aca53459e7b8f8f8c0a290163555a2f24f47df8ecb4407e39d7401680547b7f016b9111221688e5dc10c2fea43eb49ba6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.0.0.rc.7 - 2026-08-03
4
+
5
+ ### Fixed
6
+
7
+ - **Delayed jobs can release before their `run_at`.** Beanstalkd delays are whole seconds, so a fractional delay has to round. Insertions are now computed as `(at - now).ceil`.
8
+ - **Premature re-insert could spin.** `NiceQueue`/`DefaultQueue` re-inserted a premature job with a fractional second value. Re-insert delay is now `[early.ceil, 1].max`.
9
+
10
+ ### Changed
11
+
12
+ - Premature-job messages now report how early the job arrived: the `PREMATURE; RE-INSERTED` audit log entry and the `Postburner::Job::PrematurePerform` message both include the delta in seconds, which makes sub-second scheduling drift visible instead of silent.
13
+
14
+ ## v1.0.0.rc.6 - 2026-06-27
15
+
16
+ ### Highlights
17
+
18
+ - `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.
19
+ - `Postburner::Schedule#enable!` / `#disable!` — convenience wrappers that drive reconciliation.
20
+ - Auto-reconcile on schedule edits — an `after_update_commit` hook reconciles whenever a scheduling/snapshot attribute changes.
21
+ - `Postburner::ScheduleExecution#supersede!` — tears down an execution (Beanstalkd job + `Postburner::Job` AR row) and marks it `superseded`.
22
+ - `superseded` status (enum value `201`) on `Postburner::ScheduleExecution`, distinct from `skipped`. Neither counts as a live future execution.
23
+ - `live` and `future_live` scopes on `Postburner::ScheduleExecution` (`pending`/`scheduled` only; `future_live` adds `run_at > now`).
24
+ - Instrumentation events `supersede.schedule_execution.postburner` and `reconcile.schedule.postburner`.
25
+ - **`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.
26
+ - **`schedule.destroy` now tears down each execution's Beanstalkd job and `Postburner::Job` (no orphans)
27
+ - The watchdog sweeps **disabled** schedules with a lingering live future execution and supersedes them.
28
+
29
+ ### Fixed
30
+
31
+ - 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`.
32
+ - 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`.
33
+ - `Postburner::OrphanedJob#destroy` can be soft-removed (`remove!`) and destroyed (`destroy`), with Beanstalkd job, while `readonly?` still blocks changes.
34
+
35
+ ### Upgrade notes
36
+
37
+ - This is a **template mutation** (like rc.4), not an additive migration. Existing installs must add a migration that:
38
+ 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`.
39
+ 2. Drops `ON DELETE CASCADE` on the `schedule_id` foreign key (teardown is now owned by `dependent: :destroy` + `before_destroy`).
40
+ - **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.
41
+
42
+ 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!`):
43
+
44
+ ```ruby
45
+ class UpgradePostburnerSchedulesForReconcile < ActiveRecord::Migration[7.2]
46
+ disable_ddl_transaction!
47
+
48
+ # Existing installs created the unique index via the generator with Rails'
49
+ # default name. Adjust if your install named it differently.
50
+ OLD_INDEX = 'index_postburner_schedule_executions_on_schedule_id_and_run_at'
51
+ NEW_INDEX = 'index_pb_sched_exec_live_schedule_run_at'
52
+
53
+ def up
54
+ # 1. Add the live-only partial unique index CONCURRENTLY (no write lock) so a
55
+ # superseded/skipped row can coexist with a live recreate at the same run_at.
56
+ # Narrowing uniqueness can't conflict with existing data (the old total
57
+ # index was stricter), so this build cannot fail on valid rows.
58
+ add_index :postburner_schedule_executions, [:schedule_id, :run_at],
59
+ unique: true, where: 'status IN (0, 11)',
60
+ name: NEW_INDEX, algorithm: :concurrently, if_not_exists: true
61
+
62
+ # Drop the old total unique index. Target by NAME: with the new index added,
63
+ # two indexes now match the (schedule_id, run_at) columns.
64
+ remove_index :postburner_schedule_executions,
65
+ name: OLD_INDEX, algorithm: :concurrently, if_exists: true
66
+
67
+ # 2. Replace the cascading FK with a plain one. Add unvalidated first (brief
68
+ # metadata-only lock), then validate separately (does not block writes).
69
+ # dependent: :destroy + before_destroy now own teardown.
70
+ remove_foreign_key :postburner_schedule_executions, column: :schedule_id
71
+ add_foreign_key :postburner_schedule_executions, :postburner_schedules,
72
+ column: :schedule_id, validate: false
73
+ validate_foreign_key :postburner_schedule_executions, column: :schedule_id
74
+ end
75
+
76
+ def down
77
+ remove_foreign_key :postburner_schedule_executions, column: :schedule_id
78
+ add_foreign_key :postburner_schedule_executions, :postburner_schedules,
79
+ column: :schedule_id, on_delete: :cascade, validate: false
80
+ validate_foreign_key :postburner_schedule_executions, column: :schedule_id
81
+
82
+ add_index :postburner_schedule_executions, [:schedule_id, :run_at],
83
+ unique: true, name: OLD_INDEX, algorithm: :concurrently, if_not_exists: true
84
+ remove_index :postburner_schedule_executions,
85
+ name: NEW_INDEX, algorithm: :concurrently, if_exists: true
86
+ end
87
+ end
88
+ ```
89
+
3
90
  ## v1.0.0.rc.5 - 2026-06-26
4
91
 
5
92
  ### Fixed
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:**
@@ -85,7 +85,15 @@ module Postburner
85
85
  self.run_at = case
86
86
  when at.present?
87
87
  # this is rudimentary, add error handling
88
- options[:delay] ||= at.to_i - now.to_i
88
+ #
89
+ # Ceil, don't floor. Beanstalkd delays are whole seconds,
90
+ # so some rounding is unavoidable -- but it must round
91
+ # *away* from now. Flooring both sides (`at.to_i -
92
+ # now.to_i`) drops frac(at) and frac(now) independently,
93
+ # releasing the job early by frac(at) - frac(now)
94
+ # whenever that is positive. Consumers then see a job
95
+ # arrive before its own run_at.
96
+ options[:delay] ||= [(at.to_time - now).ceil, 0].max
89
97
  at
90
98
  when options[:delay].present?
91
99
  now + options[:delay].seconds
@@ -33,12 +33,12 @@ module Postburner
33
33
  # application code.
34
34
  #
35
35
  # @example Typical lifecycle
36
- # # A DB row exists with type: 'Flex::TriggerNoShowDeliveriesJob' (class deleted)
36
+ # # A DB row exists with type: 'MyApp::RemovedJob' (class deleted)
37
37
  # job = Postburner::Job.find(42)
38
38
  # job.class # => Postburner::OrphanedJob
39
- # job.type # => 'Flex::TriggerNoShowDeliveriesJob'
39
+ # job.type # => 'MyApp::RemovedJob'
40
40
  # job.orphaned? # => true
41
- # job.missing_class_name # => 'Flex::TriggerNoShowDeliveriesJob'
41
+ # job.missing_class_name # => 'MyApp::RemovedJob'
42
42
  # job.perform # => raises Postburner::Job::OrphanedJobError
43
43
  # job.remove! # soft-deletes fine despite readonly?
44
44
  #
@@ -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
  #