schked 1.5.0 → 2.0.0

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: 4d392bfee50671c6bc87ab00b4cbdb29969e55544d35a6133ce23b24f1d3171e
4
- data.tar.gz: 196a09231bd9abb1c0a83f7d4d6d4ecb140cb3b305e1710aa775f21690ae3eb7
3
+ metadata.gz: de7c669767af942f29f4c3414334cc7e996d8d41c3e9375ef2a249cc7ccc3b22
4
+ data.tar.gz: a55cb9f96f1eb18d43452ad0e61ec1d722c51d316f4e6b6604d7627b82aaaa2c
5
5
  SHA512:
6
- metadata.gz: '02810b0f6a3717db6e4f5aa4cee9eee4644c74c7a583fbc1c0e6acae97d63193366c1ecbd0ee087106acbe542a49ace4e5cdfe209aba0b11c3d31938ef088638'
7
- data.tar.gz: b55de68f347b8be57cc32cbf13ef582cd4ac33c8cddb881bfcead7bc449454953147c20bd311a03bfaa38c444884e6bf45daa7b54d2bb4bcbad5e2207d50194e
6
+ metadata.gz: 5691b00d0cd7b9575e82a743b45c7e0210593f34648a2bf1d98f7b387538805a9acea000f63724f6a31b81ce6b21a7825919b975023de04e46285eb009788ea8
7
+ data.tar.gz: 9184dd5c9190dff9f43c3534487fb4d38b6355fdd93b66d61b859f30dfc9c95d4d469d1298bb2f7f314a824ae577943835cfc530d5ff4dd3646e239a6f75b1ff
data/README.md CHANGED
@@ -30,9 +30,9 @@ gem install schked
30
30
 
31
31
  ## Supported Ruby and Rails versions
32
32
 
33
- Schked requires **Ruby 2.7+**.
33
+ Schked requires **Ruby 3.0+**.
34
34
 
35
- The test matrix covers Ruby **2.7, 3.0, 3.1, 3.2, 3.3, 3.4, and 4.0**. Rails integration tests run on every Ruby; Rails 8 is only included on Ruby **3.2+**.
35
+ The test matrix covers Ruby **3.0, 3.1, 3.2, 3.3, 3.4, and 4.0**. Rails integration tests run on every Ruby; Rails 8 is only included on Ruby **3.2+**.
36
36
 
37
37
  ## Usage
38
38
 
@@ -80,7 +80,11 @@ bundle exec schked show
80
80
 
81
81
  ### Duplicate scheduling
82
82
 
83
- When you deploy your schedule to production, you want to start new instance before you shut down the current. And you don't want simultaneous working of both. To achieve a seamless transition, Schked is using Redis for locks.
83
+ Schked ships two coordination strategies for multi-instance deployments. Choose one via `Schked.config.job_run_store`:
84
+
85
+ #### Single-active-instance (default)
86
+
87
+ When you deploy your schedule to production, you want to start new instance before you shut down the current. And you don't want simultaneous working of both. To achieve a seamless transition, Schked uses Redis for a global lock.
84
88
 
85
89
  You can configure Redis client as the following:
86
90
 
@@ -88,6 +92,67 @@ You can configure Redis client as the following:
88
92
  Schked.config.redis = {url: ENV.fetch("REDIS_URL") }
89
93
  ```
90
94
 
95
+ This is the default — one instance runs all jobs; standby instances hold the global Redis lock and stay idle. This strategy will continue to be supported because it is the simplest and most predictable for many setups.
96
+
97
+ #### Per-job deduplication
98
+
99
+ When you want every scheduler instance to do useful work (and not require a global leader), opt into the per-job deduplication mode. Each recurring job claims its schedule interval atomically; only one instance wins each interval, so each job still runs exactly once across the cluster.
100
+
101
+ Pick a coordination store:
102
+
103
+ ```ruby
104
+ # Redis-backed (default Redis client from Schked.config.redis):
105
+ Schked.config.job_run_store = :redis
106
+
107
+ # Database-backed (no Redis required). The ActiveRecord or Sequel
108
+ # connection pool is auto-detected; override via:
109
+ Schked.config.job_run_store = :database
110
+ Schked.config.database_connection = conn # optional: Sequel::Database, ActiveRecord pool, or AR connection (PostgreSQL, Mysql2, or Trilogy)
111
+
112
+ # Custom store responding to #claim(job_name, window_start) and #cleanup(older_than):
113
+ Schked.config.job_run_store = my_store
114
+ ```
115
+
116
+ The database backend runs entirely through the ActiveRecord/Sequel connection pool: claims and the internal cleanup sweep check connections out per operation, so they are thread-safe and survive database restarts and failovers. MySQL 8.0+ is required for the MySQL DDL.
117
+
118
+ Additional tuning:
119
+
120
+ ```ruby
121
+ Schked.config.max_skew = 60 # max expected clock skew between instances (seconds)
122
+ ```
123
+
124
+ Schedule behavior in deduplication mode:
125
+
126
+ - `every` jobs are aligned to an absolute time grid so all instances share the same phase. The first firing is the next grid point relative to now — not relative to process start.
127
+ - `cron` jobs already align to absolute time natively and need no change.
128
+ - `at` / `in` (one-time) jobs are deduplicated too — the claim is kept for the store's retention period (the Redis TTL / the database sweep window).
129
+ - `interval` jobs **are not supported** and raise `Schked::ScheduleDSL::IntervalNotSupportedError` when scheduled in this mode. Their phase depends on job duration and cannot be grid-aligned. Use `every` or `cron` instead.
130
+
131
+ ##### Durability of the coordination store
132
+
133
+ Choosing between `:redis` and `:database` is also choosing how strong the exactly-once guarantee is:
134
+
135
+ - **`:database`** — claims are durable rows guarded by a UNIQUE constraint. They survive database restarts and failovers, and nothing removes them before the internal cleanup sweep (which keeps rows for 24 hours). Prefer this backend when a duplicate run is unacceptable.
136
+ - **`:redis`** — claims are keys with a TTL of `max(10 × max_skew, 1 hour)`, so the guarantee is only as strong as the Redis instance's durability:
137
+ - **Eviction policy** must be `noeviction` (or the instance must never reach `maxmemory`). Claim keys have a TTL, so both `allkeys-*` and `volatile-*` policies can evict them under memory pressure — the evicted window may then be claimed and executed by another instance.
138
+ - **Persistence**: a Redis restart without AOF/RDB loses in-flight claims. If it happens inside the contention window (the `max_skew`-wide interval during which instances race to claim the same slot), the job may run twice. Enable AOF, or use `:database` if you cannot accept that risk.
139
+
140
+ Failure semantics (both backends):
141
+
142
+ - The claim is taken **before** the job runs, so the semantics are *at-most-once per window*: if the winning instance crashes mid-run, that window's execution is lost and Schked does not retry it.
143
+ - The coordination store is a hard dependency: when it is unreachable, the claim fails and the firing is skipped (fail-closed) rather than risking a duplicate.
144
+
145
+ ##### Database store migration
146
+
147
+ Run the migration generator to print the `schked_job_runs` DDL:
148
+
149
+ ```sh
150
+ bundle exec schked generate-migration # Postgres
151
+ bundle exec schked generate-migration --flavor=mysql
152
+ ```
153
+
154
+ Copy the SQL into your application's migration and run it. The gem does not write migration files or run DDL on its own — it stays framework-agnostic.
155
+
91
156
  ### Callbacks
92
157
 
93
158
  Also, you can define callbacks for errors handling:
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Schked
6
+ # Adapter for ActiveRecord connection pools (PostgreSQL, Mysql2,
7
+ # Trilogy). Implements the +Schked::JobRunStore+ contract.
8
+ #
9
+ # The adapter wraps a connection *pool*, not a single connection: every
10
+ # operation checks a connection out via +with_connection+ and returns it
11
+ # afterwards. The lease guarantees exclusive use of the checked-out
12
+ # connection, so claims (scheduler thread) and the cleanup sweep (work
13
+ # threads) can safely share the pool, and the pool transparently
14
+ # replaces dead connections after a database restart or failover.
15
+ class Adapters
16
+ class ActiveRecord
17
+ include JobRunStore
18
+
19
+ SUPPORTED_ADAPTERS = %w[PostgreSQL Mysql2 Trilogy].freeze
20
+
21
+ attr_reader :adapter_name
22
+ attr_reader :logger
23
+
24
+ def initialize(pool, logger: Logger.new($stdout))
25
+ # Accept either a ConnectionPool or a concrete adapter connection
26
+ # (+ActiveRecord::Base.connection+) and normalize to the pool.
27
+ @pool = pool.respond_to?(:with_connection) ? pool : pool.pool
28
+ @logger = logger
29
+ validate_adapter!
30
+ end
31
+
32
+ def claim(job_name, window_start)
33
+ validate!(job_name, window_start)
34
+
35
+ ts = window_start.is_a?(Time) ? window_start.to_i : Integer(window_start)
36
+ run_at = Time.now.to_f
37
+ claimer = SecureRandom.uuid
38
+
39
+ @pool.with_connection do |connection|
40
+ if postgres?
41
+ postgres_claim(connection, job_name, ts, run_at, claimer)
42
+ else
43
+ mysql_claim(connection, job_name, ts, run_at, claimer)
44
+ end
45
+ end
46
+ rescue ArgumentError
47
+ raise
48
+ rescue => e
49
+ logger.error("Failed to claim AR job run with error: #{e.message}")
50
+ raise
51
+ end
52
+
53
+ def cleanup(older_than)
54
+ cutoff = older_than.is_a?(Time) ? older_than.to_i : Integer(older_than)
55
+ @pool.with_connection do |connection|
56
+ connection.execute("DELETE FROM #{TABLE} WHERE window_start < #{connection.quote(cutoff)}")
57
+ end
58
+ nil
59
+ rescue => e
60
+ logger.error("Failed to clean up AR job runs with error: #{e.message}")
61
+ raise
62
+ end
63
+
64
+ private
65
+
66
+ TABLE = "schked_job_runs"
67
+
68
+ def validate_adapter!
69
+ name = @pool.with_connection do |connection|
70
+ connection.respond_to?(:adapter_name) ? connection.adapter_name.to_s : ""
71
+ end
72
+ unless SUPPORTED_ADAPTERS.any? { |supported| supported.casecmp?(name) }
73
+ raise ArgumentError,
74
+ "Schked::Adapters::ActiveRecord supports #{SUPPORTED_ADAPTERS.join(", ")} " \
75
+ "connections only, got: #{name.inspect}"
76
+ end
77
+
78
+ # Cached: claim/cleanup dispatch on the dialect without extra
79
+ # adapter_name round-trips on every firing.
80
+ @adapter_name = name
81
+ end
82
+
83
+ def postgres?
84
+ adapter_name.include?("Postgre") || adapter_name.include?("Postgres")
85
+ end
86
+
87
+ # Postgres can decide atomically in a single statement: INSERT ...
88
+ # ON CONFLICT DO NOTHING RETURNING yields a row only for the winner.
89
+ def postgres_claim(connection, job_name, ts, run_at, claimer)
90
+ sql = "INSERT INTO #{TABLE} (job_name, window_start, run_at, claimer) " \
91
+ "VALUES (#{connection.quote(job_name)}, #{connection.quote(ts)}, " \
92
+ "#{connection.quote(run_at)}, #{connection.quote(claimer)}) " \
93
+ "ON CONFLICT (job_name, window_start) DO NOTHING RETURNING id"
94
+ Integer(connection.exec_query(sql, "Schked CLAIM").length).positive?
95
+ end
96
+
97
+ # Rails' mysql2 and trilogy adapters both connect with the
98
+ # CLIENT_FOUND_ROWS capability set unconditionally (see
99
+ # mysql2_adapter.rb / trilogy_adapter.rb), so affected_rows cannot
100
+ # distinguish a fresh insert from a matched duplicate. Instead every
101
+ # claimer writes a unique token and reads it back: the
102
+ # UNIQUE (job_name, window_start) constraint guarantees exactly one
103
+ # row survives, so only the claimer whose token is stored in that
104
+ # row won the window.
105
+ def mysql_claim(connection, job_name, ts, run_at, claimer)
106
+ insert = "INSERT IGNORE INTO #{TABLE} (job_name, window_start, run_at, claimer) " \
107
+ "VALUES (#{connection.quote(job_name)}, #{connection.quote(ts)}, " \
108
+ "#{connection.quote(run_at)}, #{connection.quote(claimer)})"
109
+ connection.execute(insert)
110
+
111
+ select = "SELECT claimer FROM #{TABLE} " \
112
+ "WHERE job_name = #{connection.quote(job_name)} AND window_start = #{connection.quote(ts)}"
113
+ row = connection.exec_query(select, "Schked CLAIM").first
114
+ !row.nil? && row["claimer"] == claimer
115
+ end
116
+
117
+ def validate!(job_name, window_start)
118
+ raise ArgumentError, "job_name must be a non-empty String" if job_name.to_s.empty?
119
+ raise ArgumentError, "window_start must not be nil" if window_start.nil?
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Schked
6
+ # Adapter for Sequel (Postgres or MySQL via +Sequel::Database+).
7
+ # Implements the +Schked::JobRunStore+ contract. Both operations go
8
+ # through Sequel's thread-safe connection pool, so claims (scheduler
9
+ # thread) and the cleanup sweep (work threads) can overlap freely.
10
+ class Adapters
11
+ class Sequel
12
+ include JobRunStore
13
+
14
+ SUPPORTED_DATABASE_TYPES = %w[postgres mysql].freeze
15
+
16
+ attr_reader :logger
17
+
18
+ def initialize(database, logger: Logger.new($stdout))
19
+ @database = database
20
+ @logger = logger
21
+ type = database.database_type.to_s
22
+ unless SUPPORTED_DATABASE_TYPES.include?(type)
23
+ raise ArgumentError,
24
+ "Schked::Adapters::Sequel supports Postgres and MySQL databases only, got: #{type.inspect}"
25
+ end
26
+ end
27
+
28
+ def claim(job_name, window_start)
29
+ validate!(job_name, window_start)
30
+
31
+ ts = window_start.is_a?(Time) ? window_start.to_i : Integer(window_start)
32
+ run_at = Time.now.to_f
33
+ claimer = SecureRandom.uuid
34
+
35
+ if postgres?
36
+ # Sequel's +insert_conflict+ is a PostgreSQL-only dataset method;
37
+ # it returns the new PK on success and +nil+ on conflict, so the
38
+ # winner is decided atomically in a single statement.
39
+ id = dataset
40
+ .insert_conflict(target: UNIQUE_COLUMNS)
41
+ .insert(job_name: job_name, window_start: ts, run_at: run_at, claimer: claimer)
42
+ !id.nil?
43
+ else
44
+ mysql_claim(job_name, ts, run_at, claimer)
45
+ end
46
+ rescue ArgumentError
47
+ raise
48
+ rescue => e
49
+ logger.error("Failed to claim sequel job run with error: #{e.message}")
50
+ raise
51
+ end
52
+
53
+ def cleanup(older_than)
54
+ cutoff = older_than.is_a?(Time) ? older_than.to_i : Integer(older_than)
55
+ dataset.where { window_start < cutoff }.delete
56
+ nil
57
+ rescue => e
58
+ logger.error("Failed to clean up sequel job runs with error: #{e.message}")
59
+ raise
60
+ end
61
+
62
+ def adapter_name
63
+ @database.adapter_scheme.to_s
64
+ end
65
+
66
+ private
67
+
68
+ TABLE = :schked_job_runs
69
+ UNIQUE_COLUMNS = %i[job_name window_start].freeze
70
+
71
+ def postgres?
72
+ @database.database_type == :postgres
73
+ end
74
+
75
+ # MySQL has no +RETURNING+, and affected-rows semantics depend on the
76
+ # CLIENT_FOUND_ROWS connection flag, so the winner cannot be derived
77
+ # from the insert alone. Instead every claimer writes a unique token
78
+ # and reads it back: the UNIQUE (job_name, window_start) constraint
79
+ # guarantees exactly one row survives, so only the claimer whose
80
+ # token is stored in that row won the window. +insert_ignore+ is a
81
+ # MySQL dataset method (+INSERT IGNORE+).
82
+ def mysql_claim(job_name, ts, run_at, claimer)
83
+ sql = dataset
84
+ .insert_ignore
85
+ .insert_sql(job_name: job_name, window_start: ts, run_at: run_at, claimer: claimer)
86
+
87
+ @database.synchronize do |conn|
88
+ conn.query(sql)
89
+ end
90
+
91
+ row = @database[TABLE]
92
+ .where(job_name: job_name, window_start: ts)
93
+ .select(:claimer)
94
+ .first
95
+ !row.nil? && row[:claimer] == claimer
96
+ end
97
+
98
+ def dataset
99
+ @database[TABLE]
100
+ end
101
+
102
+ def validate!(job_name, window_start)
103
+ raise ArgumentError, "job_name must be a non-empty String" if job_name.to_s.empty?
104
+ raise ArgumentError, "window_start must not be nil" if window_start.nil?
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schked
4
+ # Installs rufus-scheduler callbacks that translate rufus events into
5
+ # schked semantics: per-job deduplication claims via the configured
6
+ # +JobRunStore+, logging, and user-registered +Schked.config+ callbacks
7
+ # (+:before_start+, +:after_finish+, +:on_error+, +:around_job+).
8
+ #
9
+ # Extracted from +Schked::Worker+ so the worker stays focused on
10
+ # lifecycle and the callback wiring remains independently testable.
11
+ class Callbacks
12
+ # Marker prefix for schked's own internal jobs (cleanup, liveness
13
+ # heartbeat). These are exempt from dedup claims.
14
+ INTERNAL_JOB_PREFIX = "Schked::Worker#"
15
+
16
+ def initialize(config:, job_run_store: nil)
17
+ @config = config
18
+ @job_run_store = job_run_store
19
+ end
20
+
21
+ def install(scheduler)
22
+ cfg = @config
23
+ store = @job_run_store
24
+ internal = method(:internal_job?)
25
+ # Unlabeled jobs are skipped on every firing; logging the guidance at
26
+ # error level for each firing would spam operators (an `every 10s`
27
+ # job yields 8_640 messages per day). Remember which labels were
28
+ # already reported and downgrade repeat notices to debug.
29
+ missing_as_reported = {}
30
+
31
+ scheduler.define_singleton_method(:extract_job_name) do |job|
32
+ if job
33
+ job.opts[:as] || job.job_id
34
+ else
35
+ "unknown"
36
+ end
37
+ end
38
+
39
+ scheduler.define_singleton_method(:on_error) do |job, error|
40
+ cfg.logger.fatal("Task #{extract_job_name(job)} failed with error: #{error.message}")
41
+ cfg.logger.error(error.backtrace.join("\n")) if error.backtrace
42
+
43
+ cfg.fire_callback(:on_error, job, error)
44
+ end
45
+
46
+ scheduler.define_singleton_method(:on_pre_trigger) do |job, time|
47
+ job_name = extract_job_name(job).to_s
48
+
49
+ if store && internal.call(job_name)
50
+ # Internal schked jobs (cleanup sweep, liveness heartbeat) skip
51
+ # the dedup claim entirely so they always run on every instance.
52
+ elsif store
53
+ unless job.opts[:as]
54
+ # Without an explicit +as:+, the dedup key falls back to
55
+ # +job.job_id+, which encodes the Ruby +object_id+ of the
56
+ # +Rufus::Scheduler::Job+ and is unique per process. Claiming
57
+ # with that key would silently duplicate every run across the
58
+ # cluster. Refuse to claim; the operator must add +as:+ to
59
+ # their schedule for dedup to be correct.
60
+ if missing_as_reported.key?(job_name)
61
+ cfg.logger.debug("Skipping task #{job_name}: still no `as:` label (already reported).")
62
+ else
63
+ cfg.logger.error(
64
+ "Task #{job_name} has no `as:` label and cannot be deduplicated " \
65
+ "(each process generates a unique job_id). Add `as: \"my_job\"` to " \
66
+ "the schedule entry. Skipping this firing."
67
+ )
68
+ missing_as_reported[job_name] = true
69
+ end
70
+ next false
71
+ end
72
+
73
+ window_start = job.previous_time || job.scheduled_at
74
+ begin
75
+ claimed = store.claim(job_name, window_start)
76
+ rescue => e
77
+ # Fail closed, but say what actually happened: the task did not
78
+ # fail — the coordination store is unavailable, and firing is
79
+ # skipped to avoid a duplicate run.
80
+ cfg.logger.fatal("Skipped task #{job_name}: job run store unavailable: #{e.class} #{e.message}")
81
+ cfg.fire_callback(:on_error, job, e)
82
+ next false
83
+ end
84
+ unless claimed
85
+ cfg.logger.info("Skipped task: #{job_name} (already claimed for window_start=#{window_start.to_i})")
86
+ next false
87
+ end
88
+ end
89
+
90
+ cfg.logger.info("Started task: #{extract_job_name(job)}")
91
+ cfg.fire_callback(:before_start, job, time)
92
+ end
93
+
94
+ scheduler.define_singleton_method(:around_trigger) do |job, &block|
95
+ cfg.fire_around_callback(:around_job, job, &block)
96
+ end
97
+
98
+ scheduler.define_singleton_method(:on_post_trigger) do |job, time|
99
+ cfg.logger.info("Finished task: #{extract_job_name(job)}")
100
+
101
+ cfg.fire_callback(:after_finish, job, time)
102
+ end
103
+
104
+ scheduler
105
+ end
106
+
107
+ def internal_job?(job_name)
108
+ job_name.start_with?(INTERNAL_JOB_PREFIX)
109
+ end
110
+ end
111
+ end
data/lib/schked/cli.rb CHANGED
@@ -47,6 +47,12 @@ module Schked
47
47
  puts "====="
48
48
  end
49
49
 
50
+ desc "generate-migration", "Print DDL SQL for the schked_job_runs table to stdout"
51
+ option :flavor, type: :string, default: "postgres", desc: "DDL flavor (postgres or mysql)"
52
+ def generate_migration(flavor = nil)
53
+ puts MigrationGenerator.sql(flavor || options[:flavor])
54
+ end
55
+
50
56
  private
51
57
 
52
58
  def load_requires
data/lib/schked/config.rb CHANGED
@@ -4,10 +4,15 @@ require "logger"
4
4
 
5
5
  module Schked
6
6
  class Config
7
+ VALID_JOB_RUN_STORES = %i[redis database].freeze
8
+
7
9
  attr_writer :logger,
8
10
  :do_not_load_root_schedule,
9
11
  :redis,
10
- :standalone
12
+ :standalone,
13
+ :job_run_store,
14
+ :max_skew,
15
+ :database_connection
11
16
 
12
17
  def liveness_probe
13
18
  @liveness_probe ||= LivenessProbeConfig.new
@@ -81,8 +86,59 @@ module Schked
81
86
  !!@standalone
82
87
  end
83
88
 
89
+ attr_reader :job_run_store
90
+
91
+ def max_skew
92
+ @max_skew ||= 60
93
+ end
94
+
95
+ attr_reader :database_connection
96
+
97
+ def dedup_enabled?
98
+ !@job_run_store.nil?
99
+ end
100
+
101
+ # Validates all configuration options. Called by the worker during
102
+ # initialization so the worker can remain agnostic about which options
103
+ # exist and which combinations are legal.
104
+ def validate!
105
+ validate_job_run_store!
106
+ validate_max_skew!
107
+ end
108
+
84
109
  private
85
110
 
111
+ def validate_max_skew!
112
+ return if @max_skew.nil?
113
+
114
+ begin
115
+ skew = Integer(@max_skew)
116
+ rescue ArgumentError, TypeError
117
+ raise ArgumentError,
118
+ "Schked `max_skew` must be a positive number of seconds, got: #{@max_skew.inspect}"
119
+ end
120
+
121
+ return if skew.positive?
122
+
123
+ raise ArgumentError,
124
+ "Schked `max_skew` must be a positive number of seconds, got: #{@max_skew.inspect}"
125
+ end
126
+
127
+ def validate_job_run_store!
128
+ return if @job_run_store.nil?
129
+
130
+ message = "Schked `job_run_store` must be one of #{VALID_JOB_RUN_STORES.inspect}, " \
131
+ "a Symbol, or an object responding to #claim and #cleanup; got: #{@job_run_store.inspect}"
132
+
133
+ valid = if @job_run_store.is_a?(Symbol)
134
+ VALID_JOB_RUN_STORES.include?(@job_run_store)
135
+ else
136
+ @job_run_store.respond_to?(:claim) && @job_run_store.respond_to?(:cleanup)
137
+ end
138
+
139
+ raise ArgumentError, message unless valid
140
+ end
141
+
86
142
  def callbacks
87
143
  @callbacks ||= Hash.new { |hsh, key| hsh[key] = [] }
88
144
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schked
4
+ # Auto-detects which +Schked::Adapters::*+ class to use for the
5
+ # database-backed coordination store. Returns a concrete +JobRunStore+
6
+ # instance — the caller should not need to know which backend is in play.
7
+ #
8
+ # Resolution order:
9
+ # 1. An explicit connection/pool if provided.
10
+ # 2. +ActiveRecord::Base.connection_pool+ when ActiveRecord is loaded
11
+ # and configured (the pool, not a single connection, so the adapter
12
+ # can check out per operation).
13
+ # 3. Sequel's first available +Sequel::DATABASES+ database.
14
+ # 4. Otherwise raises a clear +NotFoundError+ telling the operator to
15
+ # set +database_connection+ explicitly.
16
+ module DatabaseConnection
17
+ class NotFoundError < StandardError; end
18
+
19
+ module_function
20
+
21
+ def detect(connection: nil, logger: Logger.new($stdout))
22
+ raw = connection || auto_detect_connection
23
+ wrap(raw, logger: logger)
24
+ end
25
+
26
+ def wrap(raw, logger: Logger.new($stdout))
27
+ case raw
28
+ when Adapters::Sequel, Adapters::ActiveRecord
29
+ raw
30
+ else
31
+ build_for(raw, logger: logger)
32
+ end
33
+ end
34
+
35
+ def build_for(raw, logger: Logger.new($stdout))
36
+ if defined?(::Sequel::Database) && raw.is_a?(::Sequel::Database)
37
+ Adapters::Sequel.new(raw, logger: logger)
38
+ elsif raw.respond_to?(:with_connection)
39
+ # An ActiveRecord connection pool.
40
+ Adapters::ActiveRecord.new(raw, logger: logger)
41
+ elsif raw.respond_to?(:pool) && raw.respond_to?(:adapter_name)
42
+ # A concrete ActiveRecord adapter connection — normalize to its pool.
43
+ Adapters::ActiveRecord.new(raw.pool, logger: logger)
44
+ elsif raw.respond_to?(:claim) && raw.respond_to?(:cleanup)
45
+ # Already a store: a pre-built adapter or a custom object.
46
+ raw
47
+ else
48
+ raise NotFoundError,
49
+ "Schked could not detect an adapter for: #{raw.class}. " \
50
+ "Provide a Sequel::Database, an ActiveRecord connection pool, " \
51
+ "or an ActiveRecord connection (PostgreSQL, Mysql2, or Trilogy)."
52
+ end
53
+ end
54
+
55
+ def auto_detect_connection
56
+ if defined?(ActiveRecord) && ActiveRecord.const_defined?(:Base)
57
+ begin
58
+ return ActiveRecord::Base.connection_pool
59
+ rescue
60
+ # ActiveRecord is loaded but not configured (or the pool cannot
61
+ # be resolved) — fall through to Sequel detection below.
62
+ end
63
+ end
64
+
65
+ if defined?(Sequel) && Sequel.respond_to?(:DATABASES) && Sequel::DATABASES.any?
66
+ return Sequel::DATABASES.first
67
+ end
68
+
69
+ raise NotFoundError,
70
+ "Schked could not detect a database connection for the database-backed " \
71
+ "job run store. Load ActiveRecord or Sequel, or set `Schked.config.database_connection` " \
72
+ "explicitly."
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schked
4
+ # Abstract interface for the per-job coordination store used by the
5
+ # deduplication mode. A store records which `(job_name, window_start)` pairs
6
+ # have been claimed, so two scheduler instances cannot execute the same job
7
+ # in the same interval.
8
+ #
9
+ # Two built-in backends are provided: Redis (see +RedisJobRunStore+) and
10
+ # database (see +DatabaseJobRunStore+). An operator may also supply any
11
+ # custom object that responds to +#claim+ and +#cleanup+.
12
+ module JobRunStore
13
+ # Atomically records that +job_name+ was claimed for the interval starting
14
+ # at +window_start+ (a Time/Integer epoch).
15
+ #
16
+ # Returns +true+ if this caller won the claim (the job may run) and +false+
17
+ # if the interval is already claimed by another instance (the job must be
18
+ # skipped). Raises +ArgumentError+ for invalid arguments.
19
+ def claim(job_name, window_start)
20
+ raise NotImplementedError, "#{self.class} must implement #claim(job_name, window_start)"
21
+ end
22
+
23
+ # Removes records whose window_start is older than +older_than+. The
24
+ # Redis backend relies on native TTL and provides a no-op; the database
25
+ # backend deletes matching rows.
26
+ def cleanup(older_than)
27
+ raise NotImplementedError, "#{self.class} must implement #cleanup(older_than)"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schked
4
+ # Generates the DDL for the +schked_job_runs+ table used by the
5
+ # database-backed coordination store. Operators copy the SQL into their own
6
+ # migration; schked does not write migration files or run DDL on its own.
7
+ module MigrationGenerator
8
+ module_function
9
+
10
+ DDL_POSTGRES = <<~SQL
11
+ CREATE TABLE schked_job_runs (
12
+ id BIGSERIAL PRIMARY KEY,
13
+ job_name TEXT NOT NULL,
14
+ window_start BIGINT NOT NULL,
15
+ run_at DOUBLE PRECISION NOT NULL,
16
+ claimer TEXT NOT NULL,
17
+ CONSTRAINT schked_job_runs_unique UNIQUE (job_name, window_start)
18
+ );
19
+
20
+ CREATE INDEX schked_job_runs_window_start_idx ON schked_job_runs (window_start);
21
+ SQL
22
+
23
+ DDL_MYSQL = <<~SQL
24
+ CREATE TABLE schked_job_runs (
25
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
26
+ job_name VARCHAR(255) NOT NULL,
27
+ window_start BIGINT NOT NULL,
28
+ run_at DOUBLE NOT NULL,
29
+ claimer VARCHAR(255) NOT NULL,
30
+ UNIQUE KEY schked_job_runs_unique (job_name, window_start),
31
+ KEY schked_job_runs_window_start_idx (window_start)
32
+ );
33
+ SQL
34
+
35
+ def sql(flavor = "postgres")
36
+ case flavor.to_s.downcase
37
+ when "mysql" then DDL_MYSQL
38
+ else DDL_POSTGRES
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schked
4
+ # Redis-backed implementation of the per-job coordination store.
5
+ # Uses +SET key 1 NX EX <ttl>+ so the first caller wins and TTL handles
6
+ # expiration; #cleanup is a no-op because native TTL covers retention.
7
+ class RedisJobRunStore
8
+ include JobRunStore
9
+
10
+ KEY_PREFIX = "schked:job_run"
11
+
12
+ attr_reader :redis_client, :logger, :max_skew_seconds
13
+
14
+ def initialize(redis_client:, logger: Logger.new($stdout), max_skew_seconds: 60)
15
+ @redis_client = redis_client
16
+ @logger = logger
17
+ @max_skew_seconds = Integer(max_skew_seconds)
18
+ end
19
+
20
+ def claim(job_name, window_start)
21
+ validate!(job_name, window_start)
22
+
23
+ key = build_key(job_name, window_start)
24
+ ttl = default_ttl
25
+
26
+ # +SET ... NX EX+ is atomic on a single Redis instance and is the
27
+ # idiomatic primitive for "claim this slot for at most N seconds".
28
+ # Transport errors (connection refused, timeout, ...) raise out of
29
+ # this method so the caller knows the store is unavailable — silently
30
+ # returning +false+ would skip every job while Redis is down.
31
+ #
32
+ # Why not Redlock? Redlock (the algorithm used by +RedisLocker+) is
33
+ # designed for cluster-wide consensus across multiple Redis masters.
34
+ # For this dedup, a single +SET NX EX+ is already atomic per
35
+ # instance and sufficient for exactly-once across the cluster of
36
+ # *schedulers* — the scheduler cluster itself uses one Redis (or a
37
+ # single master with replicas).
38
+ redis_client.call("SET", key, "1", "NX", "EX", ttl) == "OK"
39
+ end
40
+
41
+ def cleanup(_older_than)
42
+ # Native Redis TTL handles expiration; nothing to do here.
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ def default_ttl
49
+ # The key only needs to outlive the contention window — the interval
50
+ # (up to +max_skew+, plus scheduling jitter) during which instances
51
+ # race to claim the same slot. Expiry between windows is harmless:
52
+ # each window gets its own key. The floor keeps a minimum protective
53
+ # period for one-shot +at+/+in+ claims against instances that boot
54
+ # with a delay.
55
+ [10 * @max_skew_seconds, 3600].max
56
+ end
57
+
58
+ def build_key(job_name, window_start)
59
+ ts = window_start.is_a?(Time) ? window_start.to_i : Integer(window_start)
60
+ "#{KEY_PREFIX}:#{job_name}:#{ts}"
61
+ end
62
+
63
+ def validate!(job_name, window_start)
64
+ raise ArgumentError, "job_name must be a non-empty String" if job_name.to_s.empty?
65
+ raise ArgumentError, "window_start must not be nil" if window_start.nil?
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rufus/scheduler"
4
+
5
+ module Schked
6
+ # Wraps the rufus-scheduler DSL so deduplication mode (+job_run_store+ is
7
+ # configured) makes +every+ jobs grid-aligned (via injected +first_at+) and
8
+ # rejects +interval+ jobs (their phase drifts with job duration and cannot
9
+ # be deduplicated).
10
+ #
11
+ # In the default (non-dedup) mode the wrapper is transparent and forwards
12
+ # every call to the underlying scheduler.
13
+ class ScheduleDSL
14
+ class IntervalNotSupportedError < StandardError; end
15
+
16
+ attr_reader :scheduler
17
+
18
+ def initialize(scheduler:, dedup_enabled:, max_skew_seconds: 60, logger: Logger.new(File::NULL))
19
+ @scheduler = scheduler
20
+ @dedup_enabled = dedup_enabled
21
+ @max_skew_seconds = Integer(max_skew_seconds)
22
+ @logger = logger
23
+ end
24
+
25
+ def respond_to_missing?(name, include_private = false)
26
+ @scheduler.respond_to?(name, include_private) || super
27
+ end
28
+
29
+ def method_missing(name, *args, **kwargs, &block)
30
+ case name
31
+ when :every
32
+ every_with_alignment(*args, **kwargs, &block)
33
+ when :interval
34
+ raise IntervalNotSupportedError, interval_error_message if @dedup_enabled
35
+
36
+ @scheduler.interval(*args, **kwargs, &block)
37
+ else
38
+ @scheduler.public_send(name, *args, **kwargs, &block)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def every_with_alignment(duration, *args, **kwargs, &block)
45
+ if @dedup_enabled
46
+ seconds = Rufus::Scheduler.parse_duration(duration)
47
+ # Shift "now" backward by +max_skew+ so that two instances whose
48
+ # clocks differ by up to +max_skew+ still land on the same grid
49
+ # point. Without this shift, an instance that is half a skew
50
+ # ahead could pick a different slot than one that is half a skew
51
+ # behind, and both would claim the job.
52
+ first_at = Time.at(next_grid_point_epoch(seconds, Time.now.to_f - @max_skew_seconds))
53
+ if kwargs.key?(:first_at)
54
+ @logger.warn(
55
+ "Schked: ignoring `first_at: #{kwargs[:first_at].inspect}` for `every` job " \
56
+ "in deduplication mode — grid alignment is required for claims"
57
+ )
58
+ end
59
+ kwargs = kwargs.merge(first_at: first_at)
60
+ end
61
+
62
+ @scheduler.every(duration, *args, **kwargs, &block)
63
+ end
64
+
65
+ def next_grid_point_epoch(seconds, shifted_now)
66
+ grid_point = (shifted_now / seconds).ceil * seconds
67
+ # When the interval is comparable to or smaller than +max_skew+, the
68
+ # grid point closest to "now − max_skew" may still lie in the past,
69
+ # and rufus-scheduler rejects a past +first_at+. Advance by whole
70
+ # periods until the slot is strictly in the future: every step keeps
71
+ # the value on the absolute (epoch-multiple) grid, so all instances
72
+ # stay phase-aligned regardless of how many steps they take.
73
+ grid_point += seconds while grid_point <= Time.now.to_f
74
+ grid_point
75
+ end
76
+
77
+ def interval_error_message
78
+ "`interval` jobs are not supported when `Schked.config.job_run_store` is configured. " \
79
+ "Their phase depends on job duration and cannot be aligned to a stable grid. " \
80
+ "Use `every` or `cron` instead."
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Schked
4
- VERSION = "1.5.0"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/schked/worker.rb CHANGED
@@ -4,17 +4,24 @@ require "rufus/scheduler"
4
4
 
5
5
  module Schked
6
6
  class Worker
7
+ DEFAULT_CLEANUP_INTERVAL = "60s"
8
+ DEFAULT_CLEANUP_RETENTION = 24 * 60 * 60 # seconds before cutoff
9
+
7
10
  def initialize(config:)
8
11
  @config = config
9
12
  @liveness_probe = nil
10
13
 
11
- @locker = RedisLocker.new(config.redis, lock_ttl: 40_000, logger: config.logger) unless config.standalone?
14
+ config.validate!
15
+ @job_run_store = build_job_run_store
16
+ @locker = build_locker
12
17
 
13
- @scheduler = Rufus::Scheduler.new(trigger_lock: locker)
18
+ scheduler_opts = {trigger_lock: locker}.compact
19
+ @scheduler = Rufus::Scheduler.new(**scheduler_opts)
14
20
 
15
21
  watch_signals
16
- define_callbacks
17
- define_extend_lock unless config.standalone?
22
+ Callbacks.new(config: config, job_run_store: @job_run_store).install(@scheduler)
23
+ define_extend_lock if locker
24
+ define_cleanup_job if database_backed_store?
18
25
  load_schedule
19
26
  start_liveness_probe
20
27
  end
@@ -47,41 +54,33 @@ module Schked
47
54
 
48
55
  private
49
56
 
50
- attr_reader :config, :scheduler, :locker, :liveness_probe
51
-
52
- def define_callbacks
53
- cfg = config
54
-
55
- scheduler.define_singleton_method(:extract_job_name) do |job|
56
- if job
57
- job.opts[:as] || job.job_id
58
- else
59
- "unknown"
60
- end
61
- end
62
-
63
- scheduler.define_singleton_method(:on_error) do |job, error|
64
- cfg.logger.fatal("Task #{extract_job_name(job)} failed with error: #{error.message}")
65
- cfg.logger.error(error.backtrace.join("\n")) if error.backtrace
66
-
67
- cfg.fire_callback(:on_error, job, error)
68
- end
69
-
70
- scheduler.define_singleton_method(:on_pre_trigger) do |job, time|
71
- cfg.logger.info("Started task: #{extract_job_name(job)}")
72
-
73
- cfg.fire_callback(:before_start, job, time)
74
- end
75
-
76
- scheduler.define_singleton_method(:around_trigger) do |job, &block|
77
- cfg.fire_around_callback(:around_job, job, &block)
57
+ attr_reader :config, :scheduler, :locker, :liveness_probe, :job_run_store
58
+
59
+ def build_job_run_store
60
+ return nil unless config.dedup_enabled?
61
+
62
+ case config.job_run_store
63
+ when :redis
64
+ RedisJobRunStore.new(
65
+ redis_client: RedisClientFactory.build(config.redis),
66
+ logger: config.logger,
67
+ max_skew_seconds: config.max_skew
68
+ )
69
+ when :database
70
+ DatabaseConnection.detect(
71
+ connection: config.database_connection,
72
+ logger: config.logger
73
+ )
74
+ else
75
+ config.job_run_store
78
76
  end
77
+ end
79
78
 
80
- scheduler.define_singleton_method(:on_post_trigger) do |job, time|
81
- cfg.logger.info("Finished task: #{extract_job_name(job)}")
79
+ def build_locker
80
+ return nil if config.standalone?
81
+ return nil if config.dedup_enabled?
82
82
 
83
- cfg.fire_callback(:after_finish, job, time)
84
- end
83
+ RedisLocker.new(config.redis, lock_ttl: 40_000, logger: config.logger)
85
84
  end
86
85
 
87
86
  def watch_signals
@@ -112,8 +111,36 @@ module Schked
112
111
  end
113
112
  end
114
113
 
114
+ def define_cleanup_job
115
+ store = @job_run_store
116
+ logger = config.logger
117
+
118
+ scheduler.every(DEFAULT_CLEANUP_INTERVAL, as: "Schked::Worker#cleanup_job_runs", overlap: false) do
119
+ cutoff = Time.now.to_i - (DEFAULT_CLEANUP_RETENTION + config.max_skew)
120
+ logger.debug("Cleaning up database job runs older than #{cutoff}")
121
+ store.cleanup(cutoff)
122
+ rescue => e
123
+ logger.error("Failed to clean up database job runs: #{e.message}")
124
+ end
125
+ end
126
+
127
+ # Only stores whose +#cleanup+ actually deletes rows need the sweep.
128
+ # +RedisJobRunStore#cleanup+ is a no-op (native TTL handles retention),
129
+ # so scheduling it would just log noise on every instance. Custom
130
+ # stores keep the sweep because they implemented +#cleanup+ for it.
131
+ # Returns +false+ when no store is configured (default mode).
132
+ def database_backed_store?
133
+ !job_run_store.nil? && !job_run_store.is_a?(RedisJobRunStore)
134
+ end
135
+
115
136
  def load_schedule
116
- scheduler.instance_eval(schedule)
137
+ dsl = ScheduleDSL.new(
138
+ scheduler: scheduler,
139
+ dedup_enabled: config.dedup_enabled?,
140
+ max_skew_seconds: config.max_skew,
141
+ logger: config.logger
142
+ )
143
+ dsl.instance_eval(schedule)
117
144
  end
118
145
 
119
146
  def start_liveness_probe
data/lib/schked.rb CHANGED
@@ -6,6 +6,14 @@ require "redlock"
6
6
  require "schked/version"
7
7
  require "schked/liveness_probe"
8
8
  require "schked/config"
9
+ require "schked/job_run_store"
10
+ require "schked/redis_job_run_store"
11
+ require "schked/adapters/sequel"
12
+ require "schked/adapters/active_record"
13
+ require "schked/database_connection"
14
+ require "schked/schedule_dsl"
15
+ require "schked/callbacks"
16
+ require "schked/migration_generator"
9
17
  require "schked/worker"
10
18
  require "schked/redis_locker"
11
19
  require "schked/redis_client_factory"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schked
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Misha Merkushin
@@ -209,12 +209,20 @@ files:
209
209
  - README.md
210
210
  - exe/schked
211
211
  - lib/schked.rb
212
+ - lib/schked/adapters/active_record.rb
213
+ - lib/schked/adapters/sequel.rb
214
+ - lib/schked/callbacks.rb
212
215
  - lib/schked/cli.rb
213
216
  - lib/schked/config.rb
217
+ - lib/schked/database_connection.rb
218
+ - lib/schked/job_run_store.rb
214
219
  - lib/schked/liveness_probe.rb
220
+ - lib/schked/migration_generator.rb
215
221
  - lib/schked/railtie.rb
216
222
  - lib/schked/redis_client_factory.rb
223
+ - lib/schked/redis_job_run_store.rb
217
224
  - lib/schked/redis_locker.rb
225
+ - lib/schked/schedule_dsl.rb
218
226
  - lib/schked/version.rb
219
227
  - lib/schked/worker.rb
220
228
  homepage: https://github.com/bibendi/schked
@@ -229,7 +237,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
229
237
  requirements:
230
238
  - - ">="
231
239
  - !ruby/object:Gem::Version
232
- version: '2.7'
240
+ version: '3.0'
233
241
  required_rubygems_version: !ruby/object:Gem::Requirement
234
242
  requirements:
235
243
  - - ">="