postburner 1.0.0.pre.11 → 1.0.0.pre.12

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +961 -555
  3. data/app/concerns/postburner/commands.rb +1 -1
  4. data/app/concerns/postburner/execution.rb +11 -11
  5. data/app/concerns/postburner/insertion.rb +1 -1
  6. data/app/concerns/postburner/logging.rb +2 -2
  7. data/app/concerns/postburner/statistics.rb +1 -1
  8. data/app/models/postburner/job.rb +27 -4
  9. data/app/models/postburner/mailer.rb +1 -1
  10. data/app/models/postburner/schedule.rb +703 -0
  11. data/app/models/postburner/schedule_execution.rb +353 -0
  12. data/app/views/postburner/jobs/show.html.haml +3 -3
  13. data/lib/generators/postburner/install/install_generator.rb +1 -0
  14. data/lib/generators/postburner/install/templates/config/postburner.yml +15 -6
  15. data/lib/generators/postburner/install/templates/migrations/create_postburner_schedules.rb.erb +71 -0
  16. data/lib/postburner/active_job/adapter.rb +3 -3
  17. data/lib/postburner/active_job/payload.rb +5 -0
  18. data/lib/postburner/advisory_lock.rb +123 -0
  19. data/lib/postburner/configuration.rb +43 -7
  20. data/lib/postburner/connection.rb +7 -6
  21. data/lib/postburner/runner.rb +26 -3
  22. data/lib/postburner/scheduler.rb +427 -0
  23. data/lib/postburner/strategies/immediate_test_queue.rb +24 -7
  24. data/lib/postburner/strategies/nice_queue.rb +1 -1
  25. data/lib/postburner/strategies/null_queue.rb +2 -2
  26. data/lib/postburner/strategies/test_queue.rb +2 -2
  27. data/lib/postburner/time_helpers.rb +4 -2
  28. data/lib/postburner/tube.rb +9 -1
  29. data/lib/postburner/version.rb +1 -1
  30. data/lib/postburner/worker.rb +684 -0
  31. data/lib/postburner.rb +32 -13
  32. metadata +7 -3
  33. data/lib/postburner/workers/base.rb +0 -205
  34. data/lib/postburner/workers/worker.rb +0 -396
@@ -0,0 +1,703 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # Schedule model for recurring job execution with fixed-rate, grid-aligned scheduling.
5
+ #
6
+ # Postburner's scheduler provides predictable execution times with no drift, like
7
+ # subscription billing. Executions are calculated as anchor + N*interval, maintaining
8
+ # alignment to the grid regardless of actual execution times.
9
+ #
10
+ # ## Architecture
11
+ #
12
+ # The scheduler uses **immediate enqueue** combined with a **watchdog safety net**:
13
+ #
14
+ # 1. When an execution is created, it's immediately enqueued to Beanstalkd's delayed
15
+ # queue with the appropriate delay until run_at
16
+ # 2. For Postburner::Job-based schedules, a before_attempt callback creates the next
17
+ # execution when the current job runs - providing immediate pickup without waiting
18
+ # 3. A lightweight watchdog in the 'scheduler' tube acts as a safety net, ensuring
19
+ # every schedule has a future execution queued
20
+ #
21
+ # This design requires no dedicated scheduler process - existing workers handle everything.
22
+ #
23
+ # ## Scheduling Modes
24
+ #
25
+ # **Anchor-based (recommended):** Define a start time, interval, and unit (like subscriptions)
26
+ # - Supports: seconds, minutes, hours, days, weeks, months, years
27
+ # - Grid-aligned: Always snaps to anchor + N*interval, never drifts
28
+ # - Example: Daily at 9:00 AM, weekly on Saturday, monthly on the 1st
29
+ #
30
+ # **Cron-based:** Use standard cron expressions (requires fugit gem)
31
+ # - Power user feature for complex schedules
32
+ # - Example: Weekdays at 8 AM, every 15 minutes, etc.
33
+ #
34
+ # ## Database Fields
35
+ #
36
+ # - `name` - Unique identifier for the schedule
37
+ # - `job_class` - ActiveJob or Postburner::Job class name
38
+ # - `anchor` - Start time for interval calculation (anchor mode)
39
+ # - `interval` - Number of interval units (anchor mode)
40
+ # - `interval_unit` - Unit type: seconds/minutes/hours/days/weeks/months/years
41
+ # - `cron` - Cron expression (cron mode)
42
+ # - `timezone` - Timezone for calculations (default: UTC)
43
+ # - `args` - JSONB arguments passed to each job execution
44
+ # - `queue` - Override default queue name
45
+ # - `priority` - Override default Beanstalkd priority
46
+ # - `enabled` - Enable/disable schedule
47
+ # - `catch_up` - Skip missed executions (false) or run all (true)
48
+ # - `last_audit_at` - Last time watchdog processed this schedule
49
+ #
50
+ # ## Catch-Up Policy
51
+ #
52
+ # The catch_up attribute controls behavior when worker is down:
53
+ # - `catch_up: false` (default) - Skip missed executions, resume from next future time
54
+ # - `catch_up: true` - Run all missed executions when worker restarts
55
+ #
56
+ # ## Configuration
57
+ #
58
+ # Add to config/postburner.yml:
59
+ # production:
60
+ # default_scheduler_interval: 300 # Check every 5 minutes
61
+ # default_scheduler_priority: 100 # Watchdog priority
62
+ #
63
+ # ## Starting Schedules
64
+ #
65
+ # **Explicit start (immediate):**
66
+ # schedule.start! # Creates and enqueues first execution to Beanstalkd
67
+ #
68
+ # **Auto-bootstrap (eventual):**
69
+ # # Watchdog auto-bootstraps on next run (adds up to one interval of delay)
70
+ #
71
+ # @example Anchor-based schedule (daily at 9:30 AM)
72
+ # schedule = Postburner::Schedule.create!(
73
+ # name: 'daily_cleanup',
74
+ # job_class: 'CleanupJob',
75
+ # anchor: Time.zone.parse('2025-01-01 09:30:00'),
76
+ # interval: 1,
77
+ # interval_unit: 'days',
78
+ # timezone: 'America/New_York',
79
+ # args: { report_type: 'daily' }
80
+ # )
81
+ # schedule.start! # Optional: immediate pickup
82
+ #
83
+ # @example Cron-based schedule (weekdays at 8 AM)
84
+ # schedule = Postburner::Schedule.create!(
85
+ # name: 'weekday_standup',
86
+ # job_class: 'StandupReminderJob',
87
+ # cron: '0 8 * * 1-5',
88
+ # timezone: 'America/Chicago'
89
+ # )
90
+ #
91
+ # @example With catch-up enabled
92
+ # schedule = Postburner::Schedule.create!(
93
+ # name: 'billing_job',
94
+ # job_class: 'BillingJob',
95
+ # interval: 1,
96
+ # interval_unit: 'hours',
97
+ # catch_up: true # Run all missed hours if worker was down
98
+ # )
99
+ #
100
+ # @see Postburner::ScheduleExecution
101
+ # @see Postburner::Scheduler
102
+ #
103
+ class Schedule < ApplicationRecord
104
+ has_many :executions,
105
+ class_name: 'Postburner::ScheduleExecution',
106
+ dependent: :destroy
107
+
108
+ # Validations
109
+ validates :name, presence: true, uniqueness: true
110
+ validates :job_class, presence: true
111
+ validates :timezone, presence: true
112
+ validate :validate_scheduling_mode!
113
+ validate :validate_job_class_exists!
114
+ validate :validate_cron_expression!, if: :cron?
115
+
116
+ # Scopes
117
+ scope :enabled, -> { where(enabled: true) }
118
+ scope :disabled, -> { where(enabled: false) }
119
+ scope :anchor_based, -> { where.not(anchor: nil) }
120
+ scope :cron_based, -> { where.not(cron: nil) }
121
+
122
+ # Start the schedule by creating the first execution.
123
+ #
124
+ # Use this when you need the schedule to be picked up immediately
125
+ # (within the next scheduler interval). Without calling start!, the
126
+ # scheduler will auto-bootstrap the schedule on its next run, but
127
+ # that adds an extra interval of delay.
128
+ #
129
+ # This is an idempotent operation - calling it multiple times will
130
+ # only create the first execution once.
131
+ #
132
+ # @return [ScheduleExecution, nil] The created execution, or nil if already started
133
+ # @raise [ActiveRecord::RecordInvalid] If execution creation fails
134
+ #
135
+ # @example Start a new schedule
136
+ # schedule = Postburner::Schedule.create!(
137
+ # name: 'daily_cleanup',
138
+ # job_class: 'CleanupJob',
139
+ # anchor: Time.zone.now,
140
+ # interval: 1,
141
+ # interval_unit: 'days',
142
+ # timezone: 'UTC'
143
+ # )
144
+ # schedule.start! # Creates and enqueues first execution
145
+ #
146
+ def start!
147
+ return nil if started?
148
+
149
+ create_execution!
150
+ end
151
+
152
+ # Create the next execution if one doesn't already exist.
153
+ #
154
+ # Idempotent method that ensures exactly one future execution is scheduled.
155
+ # Used by Postburner::Job callback to provide immediate pickup without waiting
156
+ # for the scheduler watchdog. Safe to call multiple times - will only create
157
+ # an execution if none exists in the future.
158
+ #
159
+ # The catch_up attribute controls behavior when worker is down:
160
+ # - catch_up: true -> calculates from last execution (may be in past, runs immediately)
161
+ # - catch_up: false -> calculates from Time.current (skips missed executions)
162
+ #
163
+ # @param after [ScheduleExecution, Time, nil] The execution or time to calculate next from.
164
+ # If nil, behavior depends on catch_up setting. If ScheduleExecution, uses its run_at.
165
+ # @return [ScheduleExecution, nil] The created execution, or nil if one already exists
166
+ # or if a race condition occurred
167
+ #
168
+ # @example Create next execution after current
169
+ # last_execution = schedule.executions.last
170
+ # schedule.create_next_execution!(after: last_execution)
171
+ #
172
+ # @example With catch_up disabled (default)
173
+ # schedule.catch_up = false
174
+ # schedule.create_next_execution! # Calculates from Time.current
175
+ #
176
+ # @note This method handles race conditions gracefully - if two threads/processes
177
+ # try to create an execution simultaneously, one will succeed and the other
178
+ # will return nil with a warning logged.
179
+ #
180
+ def create_next_execution!(after: nil)
181
+ # Check if a future execution already exists (any status - including skipped).
182
+ #
183
+ # TIME PRECISION: When called from a job callback during time travel
184
+ # (e.g., ImmediateTestQueue), Time.current and execution.run_at can differ
185
+ # by microseconds:
186
+ #
187
+ # Time.current = 2025-12-29 06:54:58.000000 UTC (traveled, truncated)
188
+ # execution.run_at = 2025-12-29 06:54:58.185940 UTC (database precision)
189
+ #
190
+ # Using Time.current would incorrectly find the CURRENT execution as "future"
191
+ # (since 185940µs > 0µs), causing this method to return nil. By using
192
+ # after.run_at when an execution is provided, we correctly exclude the
193
+ # current execution from the future check.
194
+ check_time = after.is_a?(ScheduleExecution) ? after.run_at : Time.current
195
+ return nil if executions.where('run_at > ?', check_time).exists?
196
+
197
+ # Determine base time for calculating next execution.
198
+ #
199
+ # TIME PRECISION: When after is a current/future ScheduleExecution, we
200
+ # must use its run_at for calculation. If we used Time.current instead
201
+ # (which may be microseconds behind), next_run_at() could return the SAME
202
+ # time as the current execution, causing a duplicate key error.
203
+ #
204
+ # For past executions, respect the catch_up setting:
205
+ # catch_up: true -> calculate from last execution (runs missed jobs)
206
+ # catch_up: false -> calculate from Time.current (skips missed jobs)
207
+ after_time = if after.is_a?(ScheduleExecution) && after.run_at >= Time.current
208
+ after.run_at
209
+ elsif catch_up
210
+ after.is_a?(ScheduleExecution) ? after.run_at : after
211
+ else
212
+ Time.current
213
+ end
214
+
215
+ create_execution!(after: after_time)
216
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
217
+ # Race condition - another process/thread created it between our check and insert
218
+ # RecordNotUnique: PostgreSQL constraint violation
219
+ # RecordInvalid: Rails validation error (uniqueness validation catches it first)
220
+ Rails.logger.warn "[Postburner::Schedule] Expected race condition creating next execution for '#{name}' (job: #{id}): #{e.message}"
221
+ nil
222
+ end
223
+
224
+ # Calculate next N run times.
225
+ #
226
+ # Uses either cron or anchor-based calculation depending on schedule mode.
227
+ # All times are calculated in the schedule's timezone and returned as Time objects.
228
+ #
229
+ # @param after [Time, nil] Calculate times after this time (default: Time.current)
230
+ # @param count [Integer] Number of times to calculate (default: 1)
231
+ # @return [Array<Time>] Array of future run times in schedule's timezone
232
+ #
233
+ # @example Preview next 5 runs
234
+ # schedule.next_run_at_times(count: 5)
235
+ # # => [2025-12-29 09:00:00 UTC, 2025-12-30 09:00:00 UTC, ...]
236
+ #
237
+ # @example Calculate from specific time
238
+ # schedule.next_run_at_times(after: 1.week.from_now, count: 3)
239
+ #
240
+ def next_run_at_times(after: nil, count: 1)
241
+ after ||= Time.current
242
+ times = []
243
+
244
+ if cron?
245
+ # Cron-based calculation
246
+ times = calculate_cron_times(after: after, count: count)
247
+ else
248
+ # Anchor-based calculation
249
+ times = calculate_anchor_times(after: after, count: count)
250
+ end
251
+
252
+ times
253
+ end
254
+
255
+ # Calculate the single next run time.
256
+ #
257
+ # Convenience method that returns only the next run time instead of an array.
258
+ # Equivalent to calling next_run_at_times(after: after, count: 1).first
259
+ #
260
+ # @param after [Time, nil] Calculate time after this (default: Time.current)
261
+ # @return [Time, nil] Next run time, or nil if no more runs
262
+ #
263
+ # @example Get next run time
264
+ # schedule.next_run_at
265
+ # # => 2025-12-29 09:00:00 UTC
266
+ #
267
+ def next_run_at(after: nil)
268
+ next_run_at_times(after: after, count: 1).first
269
+ end
270
+
271
+ # Check if the schedule has been started.
272
+ #
273
+ # A schedule is considered started if it has any executions
274
+ # (pending, scheduled, or skipped).
275
+ #
276
+ # @return [Boolean] true if schedule has any executions, false otherwise
277
+ #
278
+ # @example Check if schedule is started
279
+ # schedule.started? # => false
280
+ # schedule.start!
281
+ # schedule.started? # => true
282
+ #
283
+ def started?
284
+ executions.exists?
285
+ end
286
+
287
+ # Check if schedule uses cron mode.
288
+ #
289
+ # @return [Boolean] true if cron expression is set, false otherwise
290
+ def cron?
291
+ cron.present?
292
+ end
293
+
294
+ # Check if schedule uses anchor mode.
295
+ #
296
+ # @return [Boolean] true if anchor time is set, false otherwise
297
+ def anchor?
298
+ anchor.present?
299
+ end
300
+
301
+ # Get timezone object.
302
+ #
303
+ # Returns an ActiveSupport::TimeZone instance for the schedule's timezone.
304
+ # The timezone object is cached in an instance variable.
305
+ #
306
+ # @return [ActiveSupport::TimeZone] The timezone object
307
+ #
308
+ # @example
309
+ # schedule.timezone = 'America/New_York'
310
+ # schedule.tz # => #<ActiveSupport::TimeZone:0x... @name="America/New_York">
311
+ #
312
+ def tz
313
+ @tz ||= Time.find_zone(timezone)
314
+ end
315
+
316
+ # Attributes to cache in schedule executions.
317
+ #
318
+ # Returns a hash of schedule attributes that are cached in each ScheduleExecution's
319
+ # cached_schedule column. This allows executions to run even if the schedule is
320
+ # modified or deleted after creation.
321
+ #
322
+ # @return [Hash] Hash of schedule attributes to cache
323
+ #
324
+ # @api private
325
+ #
326
+ def cacheable_attributes
327
+ {
328
+ name: name,
329
+ job_class: job_class,
330
+ args: args,
331
+ queue: queue,
332
+ priority: priority,
333
+ timezone: timezone,
334
+ anchor: anchor,
335
+ interval: interval,
336
+ interval_unit: interval_unit,
337
+ cron: cron,
338
+ catch_up: catch_up
339
+ }
340
+ end
341
+
342
+ private
343
+
344
+ # Create and save an execution, then enqueue it to Beanstalkd.
345
+ #
346
+ # The execution is enqueued immediately with appropriate delay - Beanstalkd's
347
+ # delayed queue will hold it until run_at. This ensures all future executions
348
+ # are already queued and ready to go.
349
+ #
350
+ # @param after [Time, ScheduleExecution, nil] Calculate after this time/execution
351
+ # @return [ScheduleExecution, nil] The created execution, or nil if no more runs
352
+ #
353
+ # @api private
354
+ #
355
+ def create_execution!(after: nil)
356
+ execution = build_execution(after: after)
357
+ return nil if execution.nil?
358
+
359
+ execution.save!
360
+ execution.enqueue!
361
+ execution
362
+ end
363
+
364
+ # Build an execution record (does not save).
365
+ #
366
+ # Calculates the next two run times and builds an execution with run_at
367
+ # and next_run_at set. The execution is not persisted to the database.
368
+ #
369
+ # @param after [Time, ScheduleExecution, nil] Calculate after this time/execution
370
+ # @return [ScheduleExecution, nil] The built execution, or nil if schedule has no more runs
371
+ #
372
+ # @api private
373
+ #
374
+ def build_execution(after: nil)
375
+ after_time = after.is_a?(ScheduleExecution) ? after.run_at : after
376
+ times = next_run_at_times(after: after_time, count: 2)
377
+
378
+ return nil if times.empty?
379
+
380
+ run_at = times[0]
381
+ next_run_at = times[1] # May be nil if count is 1 or no more runs
382
+
383
+ executions.build(
384
+ run_at: run_at,
385
+ next_run_at: next_run_at,
386
+ cached_schedule: cacheable_attributes
387
+ )
388
+ end
389
+
390
+ # Validate that exactly one scheduling mode is configured.
391
+ #
392
+ # Ensures either anchor+interval+interval_unit OR cron is set, but not both.
393
+ # Also validates that anchor mode has all required fields and interval_unit is valid.
394
+ #
395
+ # @return [void]
396
+ #
397
+ # @api private
398
+ #
399
+ def validate_scheduling_mode!
400
+ if anchor.blank? && cron.blank?
401
+ errors.add(:base, 'Must specify either anchor+interval+interval_unit OR cron')
402
+ end
403
+
404
+ if anchor.present? && cron.present?
405
+ errors.add(:base, 'Cannot specify both anchor and cron modes')
406
+ end
407
+
408
+ if anchor.present?
409
+ if interval.blank?
410
+ errors.add(:interval, 'must be present when using anchor mode')
411
+ end
412
+
413
+ if interval_unit.blank?
414
+ errors.add(:interval_unit, 'must be present when using anchor mode')
415
+ end
416
+
417
+ unless valid_interval_unit?
418
+ errors.add(:interval_unit, 'must be one of: seconds, minutes, hours, days, weeks, months, years')
419
+ end
420
+ end
421
+ end
422
+
423
+ # Validate job class exists.
424
+ #
425
+ # Checks that job_class can be constantized and is either an ActiveJob::Base
426
+ # or Postburner::Job subclass.
427
+ #
428
+ # @return [void]
429
+ #
430
+ # @api private
431
+ #
432
+ def validate_job_class_exists!
433
+ return if job_class.blank?
434
+
435
+ # Try to constantize - if it fails, we'll add an error
436
+ klass = job_class.safe_constantize
437
+
438
+ if klass.nil?
439
+ errors.add(:job_class, "class '#{job_class}' does not exist")
440
+ return
441
+ end
442
+
443
+ # Check if it's a valid job class
444
+ # Use <= to include the class itself, not just subclasses
445
+ unless klass <= ::ActiveJob::Base || klass <= Postburner::Job
446
+ errors.add(:job_class, 'must be an ActiveJob or Postburner::Job subclass')
447
+ end
448
+ end
449
+
450
+ # Validate cron expression syntax.
451
+ #
452
+ # Uses the fugit gem to parse and validate cron expressions.
453
+ # Logs a warning if fugit is not available.
454
+ #
455
+ # @return [void]
456
+ #
457
+ # @api private
458
+ #
459
+ def validate_cron_expression!
460
+ return if cron.blank?
461
+
462
+ begin
463
+ require 'fugit'
464
+ parsed = Fugit::Cron.parse(cron)
465
+
466
+ if parsed.nil?
467
+ errors.add(:cron, 'is not a valid cron expression')
468
+ end
469
+ rescue LoadError
470
+ # fugit gem not loaded, skip validation
471
+ Rails.logger.warn "[Postburner::Schedule] fugit gem not available for cron validation"
472
+ rescue => e
473
+ errors.add(:cron, "validation error: #{e.message}")
474
+ end
475
+ end
476
+
477
+ # Check if interval_unit is valid.
478
+ #
479
+ # @return [Boolean] true if interval_unit is blank or one of the valid units
480
+ #
481
+ # @api private
482
+ #
483
+ def valid_interval_unit?
484
+ return true if interval_unit.blank?
485
+
486
+ %w[seconds minutes hours days weeks months years].include?(interval_unit)
487
+ end
488
+
489
+ # Calculate next run times using cron expression.
490
+ #
491
+ # Uses fugit gem to parse cron expression and calculate future run times.
492
+ # All calculations are performed in the schedule's timezone.
493
+ #
494
+ # @param after [Time] Calculate times after this time
495
+ # @param count [Integer] Number of times to calculate
496
+ # @return [Array<Time>] Array of future run times
497
+ # @raise [RuntimeError] If cron expression is invalid or fugit gem is not available
498
+ #
499
+ # @api private
500
+ #
501
+ def calculate_cron_times(after:, count:)
502
+ require 'fugit'
503
+
504
+ cron_parser = Fugit::Cron.parse(cron)
505
+ raise "Invalid cron expression: #{cron}" if cron_parser.nil?
506
+
507
+ times = []
508
+ current_time = tz.at(after.to_f)
509
+
510
+ count.times do
511
+ next_time = cron_parser.next_time(current_time)
512
+ break if next_time.nil?
513
+
514
+ times << next_time.to_time
515
+ current_time = next_time
516
+ end
517
+
518
+ times
519
+ rescue LoadError
520
+ raise "fugit gem is required for cron-based schedules. Add 'gem \"fugit\"' to your Gemfile"
521
+ end
522
+
523
+ # Calculate next run times using anchor and interval.
524
+ #
525
+ # Grid-aligned scheduling that maintains fixed intervals from anchor point.
526
+ # Never drifts - always calculates N*interval from anchor.
527
+ #
528
+ # @param after [Time] Calculate times after this time
529
+ # @param count [Integer] Number of times to calculate
530
+ # @return [Array<Time>] Array of future run times
531
+ #
532
+ # @api private
533
+ #
534
+ def calculate_anchor_times(after:, count:)
535
+ times = []
536
+ current_time = after
537
+
538
+ count.times do
539
+ next_time = calculate_next_anchor_time(current_time)
540
+ break if next_time.nil?
541
+
542
+ times << next_time
543
+ current_time = next_time
544
+ end
545
+
546
+ times
547
+ end
548
+
549
+ # Calculate next grid-aligned time from anchor.
550
+ #
551
+ # Snaps to the grid defined by anchor + N*interval, never drifts.
552
+ # This is like subscription billing - always on the anchor date/time.
553
+ #
554
+ # Delegates to specific calculation methods based on interval_unit:
555
+ # - Fixed intervals (seconds-weeks): calculate_next_fixed_interval_time
556
+ # - Months: calculate_next_month_time
557
+ # - Years: calculate_next_year_time
558
+ #
559
+ # @param from_time [Time] Calculate next time after this
560
+ # @return [Time] Next grid-aligned time
561
+ # @raise [RuntimeError] If interval_unit is unsupported
562
+ #
563
+ # @api private
564
+ #
565
+ def calculate_next_anchor_time(from_time)
566
+ from_time ||= tz.at(anchor.to_f)
567
+ anchor_time = tz.at(anchor.to_f)
568
+
569
+ case interval_unit
570
+ when 'seconds', 'minutes', 'hours', 'days', 'weeks'
571
+ calculate_next_fixed_interval_time(from_time, anchor_time)
572
+ when 'months'
573
+ calculate_next_month_time(from_time, anchor_time)
574
+ when 'years'
575
+ calculate_next_year_time(from_time, anchor_time)
576
+ else
577
+ raise "Unsupported interval unit: #{interval_unit}"
578
+ end
579
+ end
580
+
581
+ # Calculate next time for fixed-duration intervals (seconds through weeks).
582
+ #
583
+ # Uses integer arithmetic to avoid floating point precision issues.
584
+ # Finds the smallest N such that anchor + N*interval > from_time.
585
+ #
586
+ # @param from_time [Time] Calculate next time after this
587
+ # @param anchor_time [Time] The anchor point for grid alignment
588
+ # @return [Time] Next grid-aligned time
589
+ #
590
+ # @api private
591
+ #
592
+ def calculate_next_fixed_interval_time(from_time, anchor_time)
593
+ interval_seconds = case interval_unit
594
+ when 'seconds' then interval
595
+ when 'minutes' then interval * 60
596
+ when 'hours' then interval * 3600
597
+ when 'days' then interval * 86400
598
+ when 'weeks' then interval * 604800
599
+ end
600
+
601
+ # Use integer seconds to avoid floating point precision issues
602
+ elapsed = (from_time.to_i - anchor_time.to_i)
603
+
604
+ # Find the next grid point strictly AFTER from_time
605
+ # We need to find the smallest N such that anchor + N*interval > from_time
606
+ # That means N > elapsed/interval, so N = floor(elapsed/interval) + 1
607
+ intervals_passed = (elapsed / interval_seconds) + 1
608
+
609
+ # Handle case where from_time is before anchor
610
+ intervals_passed = 1 if intervals_passed < 1
611
+
612
+ anchor_time + (intervals_passed * interval_seconds)
613
+ end
614
+
615
+ # Calculate next time for month-based intervals.
616
+ #
617
+ # Handles variable month lengths like Flex subscription billing.
618
+ # Always snaps to the anchor's day-of-month (or end of month if shorter).
619
+ #
620
+ # Uses Rails Duration for month arithmetic with day adjustment.
621
+ # This ensures "snap back" behavior - e.g., Jan 31 -> Feb 28 -> Mar 31.
622
+ #
623
+ # @param from_time [Time] Calculate next time after this
624
+ # @param anchor_time [Time] The anchor point for grid alignment
625
+ # @return [Time] Next grid-aligned time
626
+ #
627
+ # @example Snap back behavior
628
+ # anchor = Time.zone.parse('2025-01-31 09:00:00')
629
+ # # Next runs: Jan 31, Feb 28 (or 29), Mar 31, Apr 30, May 31, ...
630
+ #
631
+ # @api private
632
+ #
633
+ def calculate_next_month_time(from_time, anchor_time)
634
+ requested_day = anchor_time.day
635
+
636
+ # Estimate starting interval based on months difference
637
+ months_diff = (from_time.year - anchor_time.year) * 12 + (from_time.month - anchor_time.month)
638
+ n = (months_diff.to_f / interval).floor
639
+ n = 0 if n < 0
640
+
641
+ # Find the first grid point strictly AFTER from_time
642
+ loop do
643
+ grid_point = month_grid_point_at(anchor_time, n, requested_day)
644
+ return grid_point if grid_point > from_time
645
+ n += 1
646
+ end
647
+ end
648
+
649
+ # Calculate the monthly grid point for a specific interval number.
650
+ #
651
+ # Uses Rails Duration for month arithmetic and adjusts day to match anchor.
652
+ # This ensures "snap back" behavior - e.g., Jan 31 -> Feb 28 -> Mar 31.
653
+ #
654
+ # @param anchor_time [Time] The anchor time
655
+ # @param n [Integer] Number of intervals from anchor
656
+ # @param requested_day [Integer] The day of month from anchor
657
+ # @return [Time] The grid point time
658
+ #
659
+ # @api private
660
+ #
661
+ def month_grid_point_at(anchor_time, n, requested_day)
662
+ # Use Rails Duration for month arithmetic (handles variable month lengths)
663
+ next_time = anchor_time + (n * interval).months
664
+
665
+ # Adjust day to match anchor's day (or end of month if shorter)
666
+ # This is the Flex pattern for "snap back" behavior
667
+ if next_time.end_of_month.day >= requested_day
668
+ next_time.change(day: requested_day)
669
+ else
670
+ next_time.change(day: next_time.end_of_month.day)
671
+ end
672
+ end
673
+
674
+ # Calculate next time for year-based intervals.
675
+ #
676
+ # Uses Rails Duration for year arithmetic (handles leap years automatically).
677
+ # Finds the smallest N such that anchor + N*interval > from_time.
678
+ #
679
+ # @param from_time [Time] Calculate next time after this
680
+ # @param anchor_time [Time] The anchor point for grid alignment
681
+ # @return [Time] Next grid-aligned time
682
+ #
683
+ # @example Leap year handling
684
+ # anchor = Time.zone.parse('2024-02-29 09:00:00') # Leap year
685
+ # # Next run: 2025-02-28 (Rails Duration handles Feb 29 -> Feb 28)
686
+ #
687
+ # @api private
688
+ #
689
+ def calculate_next_year_time(from_time, anchor_time)
690
+ years_diff = from_time.year - anchor_time.year
691
+ n = (years_diff.to_f / interval).floor
692
+ n = 0 if n < 0
693
+
694
+ # Find the first grid point strictly AFTER from_time
695
+ loop do
696
+ # Rails Duration handles Feb 29 -> Feb 28 automatically
697
+ grid_point = anchor_time + (n * interval).years
698
+ return grid_point if grid_point > from_time
699
+ n += 1
700
+ end
701
+ end
702
+ end
703
+ end