mirror of
https://github.com/discourse/discourse.git
synced 2024-12-22 20:03:54 +08:00
4406bbb020
Under certain conditions, a recurring automation can end up in a state with no pending automation records, causing it to not execute again until manually triggered.
We use the `RRule` gem to calculate the next execution date and time for recurring automations. The gem takes the interval, frequency, start date, and a time range, and returns all dates/times within this range that meet the recurrence rule. For example:
```ruby
RRule::Rule
.new("FREQ=DAILY;INTERVAL=1", dtstart: Time.parse("2023-01-01 07:30:00 UTC"))
.between(Time.zone.now, Time.zone.now + 2.days)
# => [Sat, 14 Sep 2024 07:30:00.000000000 UTC +00:00, Sun, 15 Sep 2024 07:30:00.000000000 UTC +00:00]
```
However, if the time component of the first point provided to `.between()` is slightly ahead of the start date (e.g., `dtstart`), the first date/time returned by `RRule` can fall outside the specified range by the same subsecond amount. For instance:
```ruby
RRule::Rule
.new("FREQ=DAILY;INTERVAL=1", dtstart: Time.parse("2023-01-01 07:30:00 UTC"))
.between(Time.parse("2023-01-01 07:30:00.999 UTC"), Time.parse("2023-01-03 07:30:00 UTC"))
.first
# => Sun, 01 Jan 2023 07:30:00.000000000 UTC +00:00
```
Here, the start date/time given to `.between()` is 999 milliseconds after 07:30:00, but the first date returned is exactly 07:30:00 without the 999 milliseconds. This causes the next recurring date to fall into the past if the automation executes within a subsecond of the start time, leading to the automation stalling.
I'm not sure why `RRule` does this, but it seems intentional judging by the source of the `.between()` method:
b9911b7147/lib/rrule/rule.rb (L28-L32)
This commit fixes the issue by selecting the first date ahead of the current time from the list returned by `RRule`, rather than the first date directly.
Internal topic: t/138045.
154 lines
5.6 KiB
Ruby
154 lines
5.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DiscourseAutomation
|
|
module Triggers
|
|
module Recurring
|
|
RECURRENCE_CHOICES = [
|
|
{ id: "minute", name: "discourse_automation.triggerables.recurring.frequencies.minute" },
|
|
{ id: "hour", name: "discourse_automation.triggerables.recurring.frequencies.hour" },
|
|
{ id: "day", name: "discourse_automation.triggerables.recurring.frequencies.day" },
|
|
{ id: "weekday", name: "discourse_automation.triggerables.recurring.frequencies.weekday" },
|
|
{ id: "week", name: "discourse_automation.triggerables.recurring.frequencies.week" },
|
|
{ id: "month", name: "discourse_automation.triggerables.recurring.frequencies.month" },
|
|
{ id: "year", name: "discourse_automation.triggerables.recurring.frequencies.year" },
|
|
]
|
|
|
|
def self.setup_pending_automation(automation, fields, previous_fields)
|
|
start_date = fields.dig("start_date", "value")
|
|
interval = fields.dig("recurrence", "value", "interval")
|
|
frequency = fields.dig("recurrence", "value", "frequency")
|
|
|
|
# this case is not possible in practice but better be safe
|
|
if !start_date || !interval || !frequency
|
|
automation.pending_automations.destroy_all
|
|
return
|
|
end
|
|
|
|
previous_start_date = previous_fields&.dig("start_date", "value")
|
|
previous_interval = previous_fields&.dig("recurrence", "value", "interval")
|
|
previous_frequency = previous_fields&.dig("recurrence", "value", "frequency")
|
|
|
|
if previous_start_date != start_date || previous_interval != interval ||
|
|
previous_frequency != frequency
|
|
automation.pending_automations.destroy_all
|
|
elsif automation.pending_automations.present?
|
|
log_debugging_info(
|
|
id: automation.id,
|
|
start_date:,
|
|
interval:,
|
|
frequency:,
|
|
previous_start_date:,
|
|
previous_interval:,
|
|
previous_frequency:,
|
|
now: Time.zone.now,
|
|
)
|
|
return
|
|
end
|
|
|
|
start_date = Time.parse(start_date)
|
|
if start_date > Time.zone.now
|
|
automation.pending_automations.create!(execute_at: start_date)
|
|
return
|
|
end
|
|
|
|
byday = start_date.strftime("%A").upcase[0, 2]
|
|
interval = interval.to_i
|
|
interval_end = interval + 1
|
|
|
|
next_trigger_date =
|
|
case frequency
|
|
when "minute"
|
|
(Time.zone.now + interval.minute).beginning_of_minute
|
|
when "hour"
|
|
(Time.zone.now + interval.hour).beginning_of_hour
|
|
when "day"
|
|
RRule::Rule
|
|
.new("FREQ=DAILY;INTERVAL=#{interval}", dtstart: start_date)
|
|
.between(Time.now, interval_end.days.from_now)
|
|
.find { |date| date > Time.zone.now }
|
|
when "weekday"
|
|
max_weekends = (interval_end.to_f / 5).ceil
|
|
RRule::Rule
|
|
.new("FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", dtstart: start_date)
|
|
.between(Time.now.end_of_day, max_weekends.weeks.from_now)
|
|
.drop(interval - 1)
|
|
.find { |date| date > Time.zone.now }
|
|
when "week"
|
|
RRule::Rule
|
|
.new("FREQ=WEEKLY;INTERVAL=#{interval};BYDAY=#{byday}", dtstart: start_date)
|
|
.between(Time.now.end_of_week, interval_end.weeks.from_now)
|
|
.find { |date| date > Time.zone.now }
|
|
when "month"
|
|
count = 0
|
|
(start_date.beginning_of_month.to_date..start_date.end_of_month.to_date).each do |date|
|
|
count += 1 if date.strftime("%A") == start_date.strftime("%A")
|
|
break if date.day == start_date.day
|
|
end
|
|
RRule::Rule
|
|
.new("FREQ=MONTHLY;INTERVAL=#{interval};BYDAY=#{count}#{byday}", dtstart: start_date)
|
|
.between(Time.now, interval_end.months.from_now)
|
|
.find { |date| date > Time.zone.now }
|
|
when "year"
|
|
RRule::Rule
|
|
.new("FREQ=YEARLY;INTERVAL=#{interval}", dtstart: start_date)
|
|
.between(Time.now, interval_end.years.from_now)
|
|
.find { |date| date > Time.zone.now }
|
|
end
|
|
|
|
if next_trigger_date
|
|
automation.pending_automations.create!(execute_at: next_trigger_date)
|
|
else
|
|
log_debugging_info(
|
|
id: automation.id,
|
|
start_date:,
|
|
interval:,
|
|
frequency:,
|
|
previous_start_date:,
|
|
previous_interval:,
|
|
previous_frequency:,
|
|
byday:,
|
|
interval_end:,
|
|
next_trigger_date:,
|
|
now: Time.zone.now,
|
|
)
|
|
nil
|
|
end
|
|
end
|
|
|
|
def self.log_debugging_info(context)
|
|
return if !SiteSetting.discourse_automation_enable_recurring_debug
|
|
str = "[automation] scheduling recurring automation debug: "
|
|
str += context.map { |k, v| "#{k}=#{v.inspect}" }.join(", ")
|
|
Rails.logger.warn(str)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::RECURRING) do
|
|
field :recurrence,
|
|
component: :period,
|
|
extra: {
|
|
content: DiscourseAutomation::Triggers::Recurring::RECURRENCE_CHOICES,
|
|
},
|
|
required: true
|
|
field :start_date, component: :date_time, required: true
|
|
|
|
on_update do |automation, fields, previous_fields|
|
|
DiscourseAutomation::Triggers::Recurring.setup_pending_automation(
|
|
automation,
|
|
fields,
|
|
previous_fields,
|
|
)
|
|
end
|
|
on_call do |automation, fields, previous_fields|
|
|
DiscourseAutomation::Triggers::Recurring.setup_pending_automation(
|
|
automation,
|
|
fields,
|
|
previous_fields,
|
|
)
|
|
end
|
|
|
|
enable_manual_trigger
|
|
end
|