desiru 0.1.0 → 0.1.1
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 +4 -4
- data/.env.example +34 -0
- data/.rubocop.yml +7 -4
- data/.ruby-version +1 -0
- data/CLAUDE.md +4 -0
- data/Gemfile +21 -2
- data/Gemfile.lock +87 -12
- data/README.md +295 -2
- data/Rakefile +1 -0
- data/db/migrations/001_create_initial_tables.rb +96 -0
- data/db/migrations/002_create_job_results.rb +39 -0
- data/desiru.db +0 -0
- data/desiru.gemspec +2 -5
- data/docs/background_processing_roadmap.md +87 -0
- data/docs/job_scheduling.md +167 -0
- data/dspy-analysis-swarm.yml +60 -0
- data/dspy-feature-analysis.md +121 -0
- data/examples/README.md +69 -0
- data/examples/api_with_persistence.rb +122 -0
- data/examples/assertions_example.rb +232 -0
- data/examples/async_processing.rb +2 -0
- data/examples/few_shot_learning.rb +1 -2
- data/examples/graphql_api.rb +4 -2
- data/examples/graphql_integration.rb +3 -3
- data/examples/graphql_optimization_summary.md +143 -0
- data/examples/graphql_performance_benchmark.rb +247 -0
- data/examples/persistence_example.rb +102 -0
- data/examples/react_agent.rb +203 -0
- data/examples/rest_api.rb +173 -0
- data/examples/rest_api_advanced.rb +333 -0
- data/examples/scheduled_job_example.rb +116 -0
- data/examples/simple_qa.rb +1 -2
- data/examples/sinatra_api.rb +109 -0
- data/examples/typed_signatures.rb +1 -2
- data/graphql_optimization_summary.md +53 -0
- data/lib/desiru/api/grape_integration.rb +284 -0
- data/lib/desiru/api/persistence_middleware.rb +148 -0
- data/lib/desiru/api/sinatra_integration.rb +217 -0
- data/lib/desiru/api.rb +42 -0
- data/lib/desiru/assertions.rb +74 -0
- data/lib/desiru/async_status.rb +65 -0
- data/lib/desiru/cache.rb +1 -1
- data/lib/desiru/configuration.rb +2 -1
- data/lib/desiru/errors.rb +160 -0
- data/lib/desiru/field.rb +17 -14
- data/lib/desiru/graphql/batch_loader.rb +85 -0
- data/lib/desiru/graphql/data_loader.rb +242 -75
- data/lib/desiru/graphql/enum_builder.rb +75 -0
- data/lib/desiru/graphql/executor.rb +37 -4
- data/lib/desiru/graphql/schema_generator.rb +62 -158
- data/lib/desiru/graphql/type_builder.rb +138 -0
- data/lib/desiru/graphql/type_cache_warmer.rb +91 -0
- data/lib/desiru/jobs/async_predict.rb +1 -1
- data/lib/desiru/jobs/base.rb +67 -0
- data/lib/desiru/jobs/batch_processor.rb +6 -6
- data/lib/desiru/jobs/retriable.rb +119 -0
- data/lib/desiru/jobs/retry_strategies.rb +169 -0
- data/lib/desiru/jobs/scheduler.rb +219 -0
- data/lib/desiru/jobs/webhook_notifier.rb +242 -0
- data/lib/desiru/models/anthropic.rb +164 -0
- data/lib/desiru/models/base.rb +37 -3
- data/lib/desiru/models/open_ai.rb +151 -0
- data/lib/desiru/models/open_router.rb +161 -0
- data/lib/desiru/module.rb +59 -9
- data/lib/desiru/modules/chain_of_thought.rb +3 -3
- data/lib/desiru/modules/majority.rb +51 -0
- data/lib/desiru/modules/multi_chain_comparison.rb +204 -0
- data/lib/desiru/modules/predict.rb +8 -1
- data/lib/desiru/modules/program_of_thought.rb +139 -0
- data/lib/desiru/modules/react.rb +273 -0
- data/lib/desiru/modules/retrieve.rb +4 -2
- data/lib/desiru/optimizers/base.rb +2 -4
- data/lib/desiru/optimizers/bootstrap_few_shot.rb +2 -2
- data/lib/desiru/optimizers/copro.rb +268 -0
- data/lib/desiru/optimizers/knn_few_shot.rb +185 -0
- data/lib/desiru/persistence/database.rb +71 -0
- data/lib/desiru/persistence/models/api_request.rb +38 -0
- data/lib/desiru/persistence/models/job_result.rb +138 -0
- data/lib/desiru/persistence/models/module_execution.rb +37 -0
- data/lib/desiru/persistence/models/optimization_result.rb +28 -0
- data/lib/desiru/persistence/models/training_example.rb +25 -0
- data/lib/desiru/persistence/models.rb +11 -0
- data/lib/desiru/persistence/repositories/api_request_repository.rb +98 -0
- data/lib/desiru/persistence/repositories/base_repository.rb +77 -0
- data/lib/desiru/persistence/repositories/job_result_repository.rb +116 -0
- data/lib/desiru/persistence/repositories/module_execution_repository.rb +85 -0
- data/lib/desiru/persistence/repositories/optimization_result_repository.rb +67 -0
- data/lib/desiru/persistence/repositories/training_example_repository.rb +102 -0
- data/lib/desiru/persistence/repository.rb +29 -0
- data/lib/desiru/persistence/setup.rb +77 -0
- data/lib/desiru/persistence.rb +49 -0
- data/lib/desiru/registry.rb +3 -5
- data/lib/desiru/signature.rb +91 -24
- data/lib/desiru/version.rb +1 -1
- data/lib/desiru.rb +23 -8
- data/missing-features-analysis.md +192 -0
- metadata +63 -45
- data/lib/desiru/models/raix_adapter.rb +0 -210
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'retry_strategies'
|
4
|
+
|
5
|
+
module Desiru
|
6
|
+
module Jobs
|
7
|
+
# Mixin for adding advanced retry capabilities to jobs
|
8
|
+
module Retriable
|
9
|
+
def self.included(base)
|
10
|
+
base.extend(ClassMethods)
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
# Configure retry policy for the job class
|
15
|
+
def retry_policy(policy = nil)
|
16
|
+
if policy
|
17
|
+
@retry_policy = policy
|
18
|
+
else
|
19
|
+
@retry_policy ||= RetryStrategies::RetryPolicy.new
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# DSL for configuring retry policy
|
24
|
+
def configure_retries(max_retries: nil, strategy: nil, retriable: nil, non_retriable: nil)
|
25
|
+
policy_options = {}
|
26
|
+
policy_options[:max_retries] = max_retries if max_retries
|
27
|
+
policy_options[:retry_strategy] = strategy if strategy
|
28
|
+
policy_options[:retriable_errors] = retriable if retriable
|
29
|
+
policy_options[:non_retriable_errors] = non_retriable if non_retriable
|
30
|
+
|
31
|
+
@retry_policy = RetryStrategies::RetryPolicy.new(**policy_options)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Wrap job execution with retry logic
|
36
|
+
def perform_with_retries(*args)
|
37
|
+
retry_count = 0
|
38
|
+
job_id = args.first if args.first.is_a?(String)
|
39
|
+
|
40
|
+
begin
|
41
|
+
# Track retry count in job result if persistence is enabled
|
42
|
+
if job_id && respond_to?(:persistence_enabled?) && persistence_enabled?
|
43
|
+
update_retry_count(job_id, retry_count)
|
44
|
+
end
|
45
|
+
|
46
|
+
perform_without_retries(*args)
|
47
|
+
rescue StandardError => e
|
48
|
+
policy = self.class.retry_policy
|
49
|
+
|
50
|
+
if policy.should_retry?(retry_count, e)
|
51
|
+
retry_count += 1
|
52
|
+
delay = policy.retry_delay(retry_count)
|
53
|
+
|
54
|
+
log_retry(e, retry_count, delay)
|
55
|
+
|
56
|
+
# Schedule retry with delay
|
57
|
+
self.class.perform_in(delay, *args)
|
58
|
+
|
59
|
+
# Don't re-raise - we've scheduled a retry
|
60
|
+
nil
|
61
|
+
else
|
62
|
+
# Max retries exceeded or non-retriable error
|
63
|
+
log_retry_failure(e, retry_count)
|
64
|
+
|
65
|
+
# Mark job as failed if persistence is enabled
|
66
|
+
persist_error_to_db(job_id, e, e.backtrace) if job_id && respond_to?(:persist_error_to_db)
|
67
|
+
|
68
|
+
# Re-raise to let Sidekiq handle it
|
69
|
+
raise
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def update_retry_count(job_id, count)
|
77
|
+
return unless respond_to?(:job_repo) && job_repo
|
78
|
+
|
79
|
+
job_result = job_repo.find_by_job_id(job_id)
|
80
|
+
job_result&.update(retry_count: count)
|
81
|
+
rescue StandardError => e
|
82
|
+
Desiru.logger.warn("Failed to update retry count: #{e.message}")
|
83
|
+
end
|
84
|
+
|
85
|
+
def log_retry(error, retry_count, delay)
|
86
|
+
Desiru.logger.warn(
|
87
|
+
"Retrying #{self.class.name} after error: #{error.message}. " \
|
88
|
+
"Retry #{retry_count}, waiting #{delay.round(2)}s"
|
89
|
+
)
|
90
|
+
end
|
91
|
+
|
92
|
+
def log_retry_failure(error, retry_count)
|
93
|
+
Desiru.logger.error(
|
94
|
+
"#{self.class.name} failed after #{retry_count} retries: #{error.message}"
|
95
|
+
)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Enhanced base job with retry capabilities
|
100
|
+
class RetriableJob < Base
|
101
|
+
include Retriable
|
102
|
+
|
103
|
+
# Alias the original perform method
|
104
|
+
alias perform_without_retries perform
|
105
|
+
alias perform perform_with_retries
|
106
|
+
|
107
|
+
# Default configuration with exponential backoff
|
108
|
+
configure_retries(
|
109
|
+
max_retries: 5,
|
110
|
+
strategy: RetryStrategies::ExponentialBackoff.new,
|
111
|
+
non_retriable: [
|
112
|
+
ArgumentError,
|
113
|
+
NoMethodError,
|
114
|
+
SyntaxError
|
115
|
+
]
|
116
|
+
)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Desiru
|
4
|
+
module Jobs
|
5
|
+
# Advanced retry strategies for background jobs
|
6
|
+
module RetryStrategies
|
7
|
+
# Exponential backoff with jitter
|
8
|
+
class ExponentialBackoff
|
9
|
+
attr_reader :base_delay, :max_delay, :multiplier, :jitter
|
10
|
+
|
11
|
+
def initialize(base_delay: 1, max_delay: 300, multiplier: 2, jitter: true)
|
12
|
+
@base_delay = base_delay
|
13
|
+
@max_delay = max_delay
|
14
|
+
@multiplier = multiplier
|
15
|
+
@jitter = jitter
|
16
|
+
end
|
17
|
+
|
18
|
+
# Calculate delay for the given retry attempt
|
19
|
+
def delay_for(retry_count)
|
20
|
+
delay = [base_delay * (multiplier**retry_count), max_delay].min
|
21
|
+
|
22
|
+
if jitter
|
23
|
+
# Add random jitter (±25%) to prevent thundering herd
|
24
|
+
jitter_amount = delay * 0.25
|
25
|
+
jittered_delay = delay + ((rand * 2 * jitter_amount) - jitter_amount)
|
26
|
+
# Ensure we don't exceed max_delay even with jitter
|
27
|
+
[jittered_delay, max_delay].min
|
28
|
+
else
|
29
|
+
delay
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Linear backoff strategy
|
35
|
+
class LinearBackoff
|
36
|
+
attr_reader :base_delay, :max_delay, :increment
|
37
|
+
|
38
|
+
def initialize(base_delay: 1, max_delay: 60, increment: 5)
|
39
|
+
@base_delay = base_delay
|
40
|
+
@max_delay = max_delay
|
41
|
+
@increment = increment
|
42
|
+
end
|
43
|
+
|
44
|
+
def delay_for(retry_count)
|
45
|
+
[base_delay + (increment * retry_count), max_delay].min
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Fixed delay strategy
|
50
|
+
class FixedDelay
|
51
|
+
attr_reader :delay
|
52
|
+
|
53
|
+
def initialize(delay: 5)
|
54
|
+
@delay = delay
|
55
|
+
end
|
56
|
+
|
57
|
+
def delay_for(_retry_count)
|
58
|
+
delay
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Custom retry policy
|
63
|
+
class RetryPolicy
|
64
|
+
attr_reader :max_retries, :retry_strategy, :retriable_errors, :non_retriable_errors
|
65
|
+
|
66
|
+
def initialize(
|
67
|
+
max_retries: 5,
|
68
|
+
retry_strategy: ExponentialBackoff.new,
|
69
|
+
retriable_errors: nil,
|
70
|
+
non_retriable_errors: nil
|
71
|
+
)
|
72
|
+
@max_retries = max_retries
|
73
|
+
@retry_strategy = retry_strategy
|
74
|
+
@retriable_errors = Array(retriable_errors) if retriable_errors
|
75
|
+
@non_retriable_errors = Array(non_retriable_errors) if non_retriable_errors
|
76
|
+
end
|
77
|
+
|
78
|
+
# Check if error is retriable
|
79
|
+
def retriable?(error)
|
80
|
+
# If non-retriable errors are specified, check those first
|
81
|
+
return false if non_retriable_errors&.any? { |klass| error.is_a?(klass) }
|
82
|
+
|
83
|
+
# If retriable errors are specified, only retry those
|
84
|
+
if retriable_errors
|
85
|
+
# Only retry if the error matches one of the specified retriable errors
|
86
|
+
retriable_errors.any? { |klass| error.is_a?(klass) }
|
87
|
+
else
|
88
|
+
# By default, retry all errors except non-retriable ones
|
89
|
+
true
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Check if we should retry based on count
|
94
|
+
def should_retry?(retry_count, error)
|
95
|
+
retry_count < max_retries && retriable?(error)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Get delay for the current retry
|
99
|
+
def retry_delay(retry_count)
|
100
|
+
retry_strategy.delay_for(retry_count)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Circuit breaker pattern
|
105
|
+
class CircuitBreaker
|
106
|
+
attr_reader :failure_threshold, :timeout, :half_open_requests
|
107
|
+
|
108
|
+
def initialize(failure_threshold: 5, timeout: 60, half_open_requests: 1)
|
109
|
+
@failure_threshold = failure_threshold
|
110
|
+
@timeout = timeout
|
111
|
+
@half_open_requests = half_open_requests
|
112
|
+
@state = :closed
|
113
|
+
@failure_count = 0
|
114
|
+
@last_failure_time = nil
|
115
|
+
@half_open_count = 0
|
116
|
+
end
|
117
|
+
|
118
|
+
def call
|
119
|
+
case @state
|
120
|
+
when :open
|
121
|
+
raise CircuitOpenError, "Circuit breaker is open" unless Time.now - @last_failure_time >= timeout
|
122
|
+
|
123
|
+
@state = :half_open
|
124
|
+
@half_open_count = 0
|
125
|
+
|
126
|
+
end
|
127
|
+
|
128
|
+
begin
|
129
|
+
result = yield
|
130
|
+
on_success
|
131
|
+
result
|
132
|
+
rescue StandardError => e
|
133
|
+
on_failure
|
134
|
+
raise e
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def on_success
|
141
|
+
case @state
|
142
|
+
when :half_open
|
143
|
+
@half_open_count += 1
|
144
|
+
if @half_open_count >= half_open_requests
|
145
|
+
@state = :closed
|
146
|
+
@failure_count = 0
|
147
|
+
end
|
148
|
+
when :closed
|
149
|
+
@failure_count = 0
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def on_failure
|
154
|
+
@failure_count += 1
|
155
|
+
@last_failure_time = Time.now
|
156
|
+
|
157
|
+
case @state
|
158
|
+
when :closed
|
159
|
+
@state = :open if @failure_count >= failure_threshold
|
160
|
+
when :half_open
|
161
|
+
@state = :open
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
class CircuitOpenError < StandardError; end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module Desiru
|
6
|
+
module Jobs
|
7
|
+
# Simple cron-like scheduler for Sidekiq jobs
|
8
|
+
class Scheduler
|
9
|
+
include Singleton
|
10
|
+
|
11
|
+
attr_reader :jobs
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@jobs = {}
|
15
|
+
@running = false
|
16
|
+
@thread = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
# Schedule a job to run periodically
|
20
|
+
# @param name [String] unique name for the scheduled job
|
21
|
+
# @param job_class [Class] the job class to execute
|
22
|
+
# @param cron [String] cron expression or simple interval
|
23
|
+
# @param args [Array] arguments to pass to the job
|
24
|
+
# @param options [Hash] additional options
|
25
|
+
def schedule(name, job_class:, cron:, args: [], **options)
|
26
|
+
@jobs[name] = {
|
27
|
+
job_class: job_class,
|
28
|
+
cron: cron,
|
29
|
+
args: args,
|
30
|
+
options: options,
|
31
|
+
last_run: nil,
|
32
|
+
next_run: calculate_next_run(cron, nil)
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
# Remove a scheduled job
|
37
|
+
def unschedule(name)
|
38
|
+
@jobs.delete(name)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Start the scheduler
|
42
|
+
def start
|
43
|
+
return if @running
|
44
|
+
|
45
|
+
@running = true
|
46
|
+
@thread = Thread.new do
|
47
|
+
while @running
|
48
|
+
check_and_run_jobs
|
49
|
+
sleep 1 # Check every second
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Stop the scheduler
|
55
|
+
def stop
|
56
|
+
@running = false
|
57
|
+
@thread&.join
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check if scheduler is running
|
61
|
+
def running?
|
62
|
+
@running
|
63
|
+
end
|
64
|
+
|
65
|
+
# Clear all scheduled jobs
|
66
|
+
def clear
|
67
|
+
@jobs.clear
|
68
|
+
end
|
69
|
+
|
70
|
+
# Get information about a scheduled job
|
71
|
+
def job_info(name)
|
72
|
+
@jobs[name]
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def check_and_run_jobs
|
78
|
+
current_time = Time.now
|
79
|
+
|
80
|
+
@jobs.each do |name, job_config|
|
81
|
+
next unless should_run?(job_config, current_time)
|
82
|
+
|
83
|
+
run_job(name, job_config)
|
84
|
+
job_config[:last_run] = current_time
|
85
|
+
job_config[:next_run] = calculate_next_run(job_config[:cron], current_time)
|
86
|
+
end
|
87
|
+
rescue StandardError => e
|
88
|
+
Desiru.logger.error("Scheduler error: #{e.message}")
|
89
|
+
end
|
90
|
+
|
91
|
+
def should_run?(job_config, current_time)
|
92
|
+
job_config[:next_run] && current_time >= job_config[:next_run]
|
93
|
+
end
|
94
|
+
|
95
|
+
def run_job(name, job_config)
|
96
|
+
job_class = job_config[:job_class]
|
97
|
+
args = job_config[:args]
|
98
|
+
|
99
|
+
# Generate unique job ID for scheduled jobs
|
100
|
+
job_id = "scheduled-#{name}-#{Time.now.to_i}"
|
101
|
+
|
102
|
+
# Enqueue the job
|
103
|
+
if job_class.respond_to?(:perform_async)
|
104
|
+
if args.empty?
|
105
|
+
job_class.perform_async(job_id)
|
106
|
+
else
|
107
|
+
job_class.perform_async(job_id, *args)
|
108
|
+
end
|
109
|
+
|
110
|
+
Desiru.logger.info("Scheduled job #{name} enqueued with ID: #{job_id}")
|
111
|
+
else
|
112
|
+
Desiru.logger.error("Job class #{job_class} does not respond to perform_async")
|
113
|
+
end
|
114
|
+
rescue StandardError => e
|
115
|
+
Desiru.logger.error("Failed to enqueue scheduled job #{name}: #{e.message}")
|
116
|
+
end
|
117
|
+
|
118
|
+
def calculate_next_run(cron_expression, last_run)
|
119
|
+
case cron_expression
|
120
|
+
when /^\d+$/ # Simple interval in seconds
|
121
|
+
interval = cron_expression.to_i
|
122
|
+
base_time = last_run || Time.now
|
123
|
+
base_time + interval
|
124
|
+
when /^every (\d+) (second|minute|hour|day)s?$/i
|
125
|
+
# Handle simple interval expressions like "every 5 minutes"
|
126
|
+
amount = ::Regexp.last_match(1).to_i
|
127
|
+
unit = ::Regexp.last_match(2).downcase
|
128
|
+
|
129
|
+
interval = case unit
|
130
|
+
when 'second' then amount
|
131
|
+
when 'minute' then amount * 60
|
132
|
+
when 'hour' then amount * 3600
|
133
|
+
when 'day' then amount * 86_400
|
134
|
+
end
|
135
|
+
|
136
|
+
base_time = last_run || Time.now
|
137
|
+
base_time + interval
|
138
|
+
else
|
139
|
+
# For now, we'll support simple cron patterns
|
140
|
+
parse_cron_expression(cron_expression, last_run)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def parse_cron_expression(cron_expression, last_run)
|
145
|
+
# Simple cron parser for common patterns
|
146
|
+
# Format: minute hour day month weekday
|
147
|
+
parts = cron_expression.split
|
148
|
+
|
149
|
+
case parts.length
|
150
|
+
when 5
|
151
|
+
# Full cron expression - for now, just support simple patterns
|
152
|
+
minute, hour, = parts
|
153
|
+
|
154
|
+
if minute == '*' && hour == '*'
|
155
|
+
# Every minute
|
156
|
+
(last_run || Time.now) + 60
|
157
|
+
elsif minute =~ /^\d+$/ && hour == '*'
|
158
|
+
# Every hour at specific minute
|
159
|
+
next_time = last_run || Time.now
|
160
|
+
next_time + (((60 - next_time.min + minute.to_i) % 60) * 60)
|
161
|
+
|
162
|
+
elsif minute =~ /^\d+$/ && hour =~ /^\d+$/
|
163
|
+
# Daily at specific time
|
164
|
+
target_hour = hour.to_i
|
165
|
+
target_minute = minute.to_i
|
166
|
+
|
167
|
+
next_time = Time.now
|
168
|
+
next_time = Time.new(next_time.year, next_time.month, next_time.day, target_hour, target_minute, 0)
|
169
|
+
|
170
|
+
# If we've already passed this time today, schedule for tomorrow
|
171
|
+
if next_time <= Time.now
|
172
|
+
next_time += 86_400 # Add one day
|
173
|
+
end
|
174
|
+
|
175
|
+
next_time
|
176
|
+
else
|
177
|
+
# Unsupported pattern, default to hourly
|
178
|
+
(last_run || Time.now) + 3600
|
179
|
+
end
|
180
|
+
else
|
181
|
+
# Invalid cron expression, default to hourly
|
182
|
+
Desiru.logger.warn("Invalid cron expression: #{cron_expression}, defaulting to hourly")
|
183
|
+
(last_run || Time.now) + 3600
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Mixin for making jobs schedulable
|
189
|
+
module Schedulable
|
190
|
+
def self.included(base)
|
191
|
+
base.extend(ClassMethods)
|
192
|
+
end
|
193
|
+
|
194
|
+
module ClassMethods
|
195
|
+
# Schedule this job to run periodically
|
196
|
+
def schedule(cron:, name: nil, args: [], **)
|
197
|
+
job_name = name || self.name
|
198
|
+
Scheduler.instance.schedule(job_name,
|
199
|
+
job_class: self,
|
200
|
+
cron: cron,
|
201
|
+
args: args,
|
202
|
+
**)
|
203
|
+
end
|
204
|
+
|
205
|
+
# Remove this job from the schedule
|
206
|
+
def unschedule(name: nil)
|
207
|
+
job_name = name || self.name
|
208
|
+
Scheduler.instance.unschedule(job_name)
|
209
|
+
end
|
210
|
+
|
211
|
+
# Check if this job is scheduled
|
212
|
+
def scheduled?(name: nil)
|
213
|
+
job_name = name || self.name
|
214
|
+
Scheduler.instance.job_info(job_name) != nil
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|