Module: Wurk::Cron

Defined in:
lib/wurk/cron.rb

Overview

Sidekiq Enterprise periodic jobs. Pure leader-driven cron — only the elected leader enqueues per tick; followers run nothing. No backfill on restart. DST-aware via per-loop timezone. In-tree crontab parser (no fugit dependency) supporting 5-field expressions plus the standard @hourly / @daily / @weekly / @monthly / @yearly aliases.

Spec: docs/target/sidekiq-ent.md §2.

Layout:

* `Cron::Parser` — crontab → wall-clock match + `next_fire_at`. Walks
forward minute-by-minute in the loop's TZ; DST gaps are skipped
naturally because the wall-clock components advance past them.
* `Cron::Loop` — one registered job. Identity = SHA1(schedule+klass+opts)
so a re-registration of the same loop is idempotent.
* `Cron::Manager` — registration DSL. `mgr.register(cron, klass, **opts)`
with `tz=` mass-setter. Writes to Redis (`periodic` SET + `loops:{lid}`
HASH) and prunes superseded loops for the classes it registers.
* `Cron::LoopSet` — Enumerable view (`each`/`size`/`fetch(lid)`).
* `Cron::ConfigTester` — boot-time validator. Verifies cron syntax and
that every worker class constant resolves.
* `Cron::Poller` — once-per-minute tick loop. Only the cluster leader
(`Component#leader?` / `dear-leader`) enqueues; non-leaders return
early without iterating loops.

Wire-compat: periodic, loops:{lid}, loop-history:{lid} per docs/target/sidekiq-ent.md §2.7. Periodic enqueue is gated by the single cluster leader (§6, Component#leader?) rather than a separate cron-leader lock — see the §2.7 divergence note.

Defined Under Namespace

Classes: ConfigTester, Loop, LoopSet, Manager, Parser, Poller

Constant Summary collapse

PERIODIC_KEY =
'periodic'
LOOP_PREFIX =
'loops:'
HISTORY_PREFIX =
'loop-history:'
HISTORY_CAP =
25
DEFAULT_TICK_SECONDS =
60
MISSED_TICK_THRESHOLD =
90

Class Method Summary collapse

Class Method Details

.fire!(lid) ⇒ Object

Test/ops helper: fire one registered loop immediately, ignoring the leader gate and the schedule due-check. Records history + advances the fire marks just like a leader tick, so specs can assert on the enqueue and history deterministically without waiting on wall-clock or stubbing leadership. Returns the enqueued jid, or nil for an unknown lid. Aliased as Sidekiq::Periodic.fire!.



758
759
760
761
762
763
# File 'lib/wurk/cron.rb', line 758

def fire!(lid)
  loop_obj = LoopSet.new.fetch(lid)
  return nil if loop_obj.nil?

  Poller.new(Wurk.configuration).fire(loop_obj)
end

.jobsObject



748
749
750
# File 'lib/wurk/cron.rb', line 748

def jobs
  LoopSet.new
end

.lid(schedule, klass, options) ⇒ Object

Stable 16-hex lid from (schedule, klass, options). Re-registering the same triple no-ops because the Redis writes overwrite under the same key.



674
675
676
677
# File 'lib/wurk/cron.rb', line 674

def lid(schedule, klass, options)
  opts = options.is_a?(Hash) ? options : {}
  ::Digest::SHA1.hexdigest("#{schedule}|#{klass}|#{JSON.dump(opts.sort.to_h)}")[0, 16]
end

.persist(loop_obj) ⇒ Object

paused is written with HSETNX, not HSET: a code-declared paused: option is only the initial value at first registration, after which the field belongs to the runtime (Web UI pause/unpause). Writing it unconditionally un-paused a dashboard-paused loop on the next boot.



693
694
695
696
697
698
699
700
701
702
703
# File 'lib/wurk/cron.rb', line 693

def persist(loop_obj)
  fields = loop_obj.to_redis_hash
  paused = fields.delete('paused')
  key = "#{LOOP_PREFIX}#{loop_obj.lid}"
  Wurk.redis do |c|
    c.call('SADD', PERIODIC_KEY, loop_obj.lid)
    c.call('HSET', key, *fields.flatten)
    c.call('HSETNX', key, 'paused', paused)
  end
  loop_obj
end

.prune_superseded(klass, keep_lids) ⇒ Object

Drop every registered loop for klass except keep_lids — the loops a booting process just registered for that class. Editing a schedule or any option changes the lid, so without this the pre-edit loop stays in periodic and keeps firing next to its replacement, forever.

Scoped to one class deliberately: the registry is fleet-wide, so a process that registers a subset of the fleet's loops (or none at all, e.g. a client-only app) must never delete loops for classes it doesn't register. Idempotent and safe to race — two processes booting the same code compute the same candidate set, and SREM/DEL repeat harmlessly.



715
716
717
718
719
720
721
722
723
# File 'lib/wurk/cron.rb', line 715

def prune_superseded(klass, keep_lids)
  keep = Array(keep_lids)
  stale = Wurk.redis do |c|
    candidates = c.call('SMEMBERS', PERIODIC_KEY) - keep
    candidates.select { |lid| c.call('HGET', "#{LOOP_PREFIX}#{lid}", 'klass') == klass }
  end
  stale.each { |lid| unregister(lid) }
  stale
end

.register(name, cron, worker_class, args = [], **opts) ⇒ Object

Task-stated convenience signature. name is treated as a label; the lid is still derived from (schedule, klass, opts) so the call is idempotent. Callers that want the Sidekiq DSL should use Manager#register via config.periodic { |mgr| ... }.



683
684
685
686
687
# File 'lib/wurk/cron.rb', line 683

def register(name, cron, worker_class, args = [], **opts)
  merged = opts.merge(args: args)
  merged[:label] = name if name
  Loop.new(schedule: cron, klass: worker_class.to_s, options: merged).tap { |lp| persist(lp) }
end

.reset!Object

Test helper: wipe every Cron Redis key. Production code must not call this — it removes every registered loop in the cluster.



738
739
740
741
742
743
744
745
746
# File 'lib/wurk/cron.rb', line 738

def reset!
  Wurk.redis do |c|
    lids = c.call('SMEMBERS', PERIODIC_KEY)
    lids.each do |lid|
      c.call('DEL', "#{LOOP_PREFIX}#{lid}", "#{HISTORY_PREFIX}#{lid}")
    end
    c.call('DEL', PERIODIC_KEY)
  end
end

.unregister(lid) ⇒ Object

Drop a loop entirely: registry membership, hash, and fire history. Public API for retiring a loop whose register(...) line is gone (no process registers its class any more, so prune_superseded cannot see it); also the primitive behind pruning and ConfigTester rollback.



729
730
731
732
733
734
# File 'lib/wurk/cron.rb', line 729

def unregister(lid)
  Wurk.redis do |c|
    c.call('SREM', PERIODIC_KEY, lid)
    c.call('DEL', "#{LOOP_PREFIX}#{lid}", "#{HISTORY_PREFIX}#{lid}")
  end
end