Skip to content

Index exception times for constant-time lookup - #572

Open
xulkrnan wants to merge 3 commits into
ice-cube-ruby:masterfrom
xulkrnan:exception-time-index
Open

xulkrnan wants to merge 3 commits into
ice-cube-ruby:masterfrom
xulkrnan:exception-time-index

Conversation

@xulkrnan

@xulkrnan xulkrnan commented Oct 26, 2026 •

Copy link
Copy Markdown

Schedules with many exception times get slow fast. A typical case is blocking out every slot of a recurring schedule for a few weeks, e.g. one add_exception_time per 15-minute slot, which comes to a few thousand exception times.

Two things make it quadratic:

  • add_exception_rule de-duplicates with Array#include?, and Rule#== builds to_hash for every comparison, so adding n exception times (directly, or through from_yaml / from_hash) is O(n²).
  • exception_time? checks every candidate occurrence against every exception rule.

This PR keeps single exception times in two hashes:

  • exact time values (time.to_r) for de-duplication, which is the same equality Rule#== gives SingleOccurrenceRule
  • whole seconds (time.to_i) for lookup, which is the same equality SingleOccurrenceRule#on? uses

Adding and checking an exception time is now O(1). Recurring exception rules keep the existing include? and on? checks. The index is created with the schedule and rebuilt in place on remove_exception_rule / remove_exception_time. Updating it in place means dup/clone copies share it the same way they already share the rule arrays. Schedules loaded from Marshal dumps made by older versions build it on first use. Nothing in the public API changes.

Benchmark

A 15-minute schedule over 27 days with 2,400 exception times (Ruby 2.7):

master this PR
build 5.49s 0.03s
from_yaml 5.51s 0.04s
all_occurrences 0.97s 0.04s
Script
require "benchmark"
require "ice_cube"

start = Time.utc(2026, 1, 1)
finish = start + 27 * 86_400
schedule = nil
build = Benchmark.realtime do
  schedule = IceCube::Schedule.new(start) do |s|
    s.add_recurrence_rule(IceCube::Rule.minutely(15).until(finish))
    t = start + 86_400
    while t < finish - 86_400
      s.add_exception_time(t)
      t += 900
    end
  end
end
yaml = schedule.to_yaml
load = Benchmark.realtime { IceCube::Schedule.from_yaml(yaml) }
enum = Benchmark.realtime { schedule.all_occurrences }
puts format("build=%.2fs from_yaml=%.2fs all_occurrences=%.2fs", build, load, enum)

Behaviour

I compared master and this branch over a set of schedules: UTC, local, ActiveSupport::TimeWithZone and subsecond start times; until, count and hour_of_day rules; and duplicate exception times given in other zones or with fractional seconds, mixed with recurring exception rules. For each I checked occurrences, occurs_at?, occurring_between?, next_occurrences and previous_occurrence, to_ical, YAML, hash and Marshal round trips, and removing and re-adding exceptions. The output was identical.

Specs

The new specs in schedule_spec.rb cover:

  • de-duplication, including times that differ by a fraction of a second
  • exception times given in another zone
  • exception times mixed with exception rules
  • removing an exception time or rule, and re-adding one
  • a dup copy and its original staying consistent with their exception times
  • a schedule with 2,880 exception times, which exceeds the suite's 1s per-example timeout on master

Each add_exception_rule de-duplicated with Array#include?, which builds
Rule#to_hash for every comparison, and exception_time? checked every
candidate occurrence against every exception rule. Building, loading and
enumerating a schedule with n exception times was therefore O(n^2).

Single exception times are now kept in sets: exact time values for
de-duplication (the same equality Rule#== uses) and whole seconds for
lookup (the same equality SingleOccurrenceRule#on? uses). Recurring
exception rules keep the existing check. The index is rebuilt lazily
after an exception is removed.
Set needs an explicit require before Ruby 3.2, which standardrb flags as
redundant on newer Rubies.
Object#dup shares the rule arrays between a schedule and its copy, but a
copy that removed an exception reset only its own index, so the original
kept skipping the removed time. The index is now created with the
schedule and only updated in place, like the arrays it mirrors.
zulqarnain-hh pushed a commit to hungryhub-team/ice_cube that referenced this pull request Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant