rust_on_background 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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +338 -0
- data/Rakefile +4 -0
- data/lib/generators/install/install_generator.rb +174 -0
- data/lib/generators/install/templates/Cargo.toml.erb +20 -0
- data/lib/generators/install/templates/config.toml.erb +40 -0
- data/lib/generators/install/templates/jobs/mod.rs +20 -0
- data/lib/generators/install/templates/main.rs +144 -0
- data/lib/generators/install/templates/rust_on_background.rake +215 -0
- data/lib/generators/install/templates/scheduler.rs +135 -0
- data/lib/generators/install/templates/worker.rs +140 -0
- data/lib/generators/job/job_generator.rb +141 -0
- data/lib/generators/job/templates/job.rs.erb +37 -0
- data/lib/rust_on_background/railtie.rb +10 -0
- data/lib/rust_on_background/version.rb +5 -0
- data/lib/rust_on_background.rb +113 -0
- metadata +106 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
mod worker;
|
|
2
|
+
mod jobs;
|
|
3
|
+
mod scheduler;
|
|
4
|
+
|
|
5
|
+
use std::sync::Arc;
|
|
6
|
+
use clap::Parser;
|
|
7
|
+
use serde::Deserialize;
|
|
8
|
+
use tokio::sync::watch;
|
|
9
|
+
|
|
10
|
+
#[derive(Parser, Debug)]
|
|
11
|
+
#[command(author, version, about)]
|
|
12
|
+
struct Args {
|
|
13
|
+
#[arg(short, long)]
|
|
14
|
+
config: String,
|
|
15
|
+
|
|
16
|
+
#[arg(short, long)]
|
|
17
|
+
workers: usize,
|
|
18
|
+
|
|
19
|
+
#[arg(short, long)]
|
|
20
|
+
queue: String,
|
|
21
|
+
|
|
22
|
+
#[arg(long, default_value = "false")]
|
|
23
|
+
scheduler: bool,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[derive(Debug, Deserialize)]
|
|
27
|
+
struct Config {
|
|
28
|
+
database: DatabaseConfig,
|
|
29
|
+
redis: RedisConfig,
|
|
30
|
+
#[serde(default)]
|
|
31
|
+
scheduler: SchedulerConfig,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[derive(Debug, Deserialize)]
|
|
35
|
+
struct DatabaseConfig {
|
|
36
|
+
url: String,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Deserialize)]
|
|
40
|
+
struct RedisConfig {
|
|
41
|
+
url: String,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
45
|
+
pub struct SchedulerConfig {
|
|
46
|
+
#[serde(default = "default_poll_interval")]
|
|
47
|
+
pub poll_interval_ms: u64,
|
|
48
|
+
#[serde(default = "default_batch_size")]
|
|
49
|
+
pub batch_size: i64,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn default_poll_interval() -> u64 { 1000 }
|
|
53
|
+
fn default_batch_size() -> i64 { 100 }
|
|
54
|
+
|
|
55
|
+
impl Default for SchedulerConfig {
|
|
56
|
+
fn default() -> Self {
|
|
57
|
+
Self {
|
|
58
|
+
poll_interval_ms: default_poll_interval(),
|
|
59
|
+
batch_size: default_batch_size(),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
impl Config {
|
|
65
|
+
fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
|
66
|
+
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
|
67
|
+
panic!(
|
|
68
|
+
"\n\nFailed to load config file: {}\nError: {}\n\nMake sure the config file exists. Run: rails generate rust_on_background:install\n",
|
|
69
|
+
path, e
|
|
70
|
+
)
|
|
71
|
+
});
|
|
72
|
+
let config: Config = toml::from_str(&content)?;
|
|
73
|
+
Ok(config)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[tokio::main]
|
|
78
|
+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
79
|
+
tracing_subscriber::fmt()
|
|
80
|
+
.with_env_filter(
|
|
81
|
+
tracing_subscriber::EnvFilter::from_default_env().add_directive("rust_on_background=info".parse()?)
|
|
82
|
+
).init();
|
|
83
|
+
|
|
84
|
+
let args = Args::parse();
|
|
85
|
+
let config = Config::load(&args.config)?;
|
|
86
|
+
|
|
87
|
+
let worker_count = args.workers;
|
|
88
|
+
let queue_name = args.queue;
|
|
89
|
+
|
|
90
|
+
let redis_url = Arc::new(config.redis.url.clone());
|
|
91
|
+
let database_url = Arc::new(config.database.url.clone());
|
|
92
|
+
let queue_name = Arc::new(format!("queue:{}", queue_name));
|
|
93
|
+
|
|
94
|
+
tracing::info!("Starting {} workers on {}", worker_count, queue_name);
|
|
95
|
+
|
|
96
|
+
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
97
|
+
let mut handles = Vec::new();
|
|
98
|
+
|
|
99
|
+
// Spawn scheduler if enabled (only one instance should run per Redis)
|
|
100
|
+
if args.scheduler {
|
|
101
|
+
let redis_url = Arc::clone(&redis_url);
|
|
102
|
+
let scheduler_config = config.scheduler.clone();
|
|
103
|
+
let shutdown_rx = shutdown_rx.clone();
|
|
104
|
+
|
|
105
|
+
let handle = tokio::spawn(async move {
|
|
106
|
+
tracing::info!("Starting scheduler");
|
|
107
|
+
if let Err(e) = scheduler::run(redis_url, scheduler_config, shutdown_rx).await {
|
|
108
|
+
tracing::error!("Scheduler error: {}", e);
|
|
109
|
+
}
|
|
110
|
+
tracing::info!("Scheduler stopped");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
handles.push(handle);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for i in 0..worker_count {
|
|
117
|
+
let redis_url = Arc::clone(&redis_url);
|
|
118
|
+
let database_url = Arc::clone(&database_url);
|
|
119
|
+
let queue_name = Arc::clone(&queue_name);
|
|
120
|
+
let shutdown_rx = shutdown_rx.clone();
|
|
121
|
+
|
|
122
|
+
let handle = tokio::spawn(async move {
|
|
123
|
+
tracing::info!("Worker {} started", i);
|
|
124
|
+
if let Err(e) = worker::run(i, redis_url, database_url, queue_name, shutdown_rx).await {
|
|
125
|
+
tracing::error!("Worker {} error: {}", i, e);
|
|
126
|
+
}
|
|
127
|
+
tracing::info!("Worker {} stopped", i);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
handles.push(handle);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
tokio::signal::ctrl_c().await?;
|
|
134
|
+
tracing::info!("Shutdown signal received, stopping workers.");
|
|
135
|
+
|
|
136
|
+
shutdown_tx.send(true)?;
|
|
137
|
+
|
|
138
|
+
for handle in handles {
|
|
139
|
+
let _ = handle.await;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
tracing::info!("All workers stopped");
|
|
143
|
+
Ok(())
|
|
144
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "toml-rb"
|
|
4
|
+
|
|
5
|
+
namespace :rust_on_background do
|
|
6
|
+
RUST_DIR = Rails.root.join("app/jobs/rust")
|
|
7
|
+
PID_DIR = Rails.root.join("tmp/pids")
|
|
8
|
+
|
|
9
|
+
def config_file
|
|
10
|
+
RUST_DIR.join("config.#{Rails.env}.toml")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def config
|
|
14
|
+
@config ||= TomlRB.load_file(config_file)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def queues
|
|
18
|
+
config["queues"] || [{ "name" => "default", "workers" => 4 }]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def log_file
|
|
22
|
+
path = config.dig("logging", "path")
|
|
23
|
+
return "/dev/null" if path.nil? || path.empty?
|
|
24
|
+
|
|
25
|
+
file = Rails.root.join(path)
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
27
|
+
file
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def pid_file(queue)
|
|
31
|
+
PID_DIR.join("#{queue}_#{Rails.env}.pid")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def running?(queue)
|
|
35
|
+
file = pid_file(queue)
|
|
36
|
+
return false unless File.exist?(file)
|
|
37
|
+
pid = File.read(file).strip.to_i
|
|
38
|
+
Process.kill(0, pid)
|
|
39
|
+
true
|
|
40
|
+
rescue Errno::ESRCH, Errno::ENOENT
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def running_queues
|
|
45
|
+
pattern = "*_#{Rails.env}.pid"
|
|
46
|
+
Dir.glob(PID_DIR.join(pattern)).map do |f|
|
|
47
|
+
File.basename(f, ".pid").sub("_#{Rails.env}", "")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "Build Rust worker (release mode)"
|
|
52
|
+
task :build do
|
|
53
|
+
Dir.chdir(RUST_DIR) do
|
|
54
|
+
puts "Building Rust worker..."
|
|
55
|
+
system("cargo build --release") || abort("Build failed")
|
|
56
|
+
puts "Build complete"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
desc "Start Rust workers"
|
|
61
|
+
task :start, [:queue] do |_t, args|
|
|
62
|
+
binary = RUST_DIR.join("target/release/rust_on_background")
|
|
63
|
+
abort "Worker not built. Run: rake rust_on_background:build" unless File.exist?(binary)
|
|
64
|
+
|
|
65
|
+
FileUtils.mkdir_p(PID_DIR)
|
|
66
|
+
|
|
67
|
+
to_start = args[:queue] ? [queues.find { |q| q["name"] == args[:queue] }].compact : queues
|
|
68
|
+
abort "Queue '#{args[:queue]}' not found in #{config_file}" if to_start.empty?
|
|
69
|
+
|
|
70
|
+
to_start.each do |q|
|
|
71
|
+
queue = q["name"]
|
|
72
|
+
workers = q["workers"]
|
|
73
|
+
|
|
74
|
+
if running?(queue)
|
|
75
|
+
puts "Queue '#{queue}': already running (PID: #{File.read(pid_file(queue)).strip})"
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Dir.chdir(RUST_DIR) do
|
|
80
|
+
cmd = [binary.to_s, "--config", config_file.to_s, "--queue", queue, "--workers", workers.to_s]
|
|
81
|
+
log = File.open(log_file, "a")
|
|
82
|
+
pid = spawn(*cmd, out: log, err: log)
|
|
83
|
+
Process.detach(pid)
|
|
84
|
+
File.write(pid_file(queue), pid.to_s)
|
|
85
|
+
puts "Queue '#{queue}': started (PID: #{pid}, workers: #{workers})"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
desc "Stop Rust workers"
|
|
91
|
+
task :stop, [:queue] do |_t, args|
|
|
92
|
+
to_stop = args[:queue] ? [args[:queue]] : running_queues
|
|
93
|
+
|
|
94
|
+
if to_stop.empty?
|
|
95
|
+
puts "No workers running"
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
to_stop.each do |queue|
|
|
100
|
+
file = pid_file(queue)
|
|
101
|
+
unless File.exist?(file)
|
|
102
|
+
puts "Queue '#{queue}': not running"
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
pid = File.read(file).strip.to_i
|
|
107
|
+
begin
|
|
108
|
+
Process.kill("TERM", pid)
|
|
109
|
+
File.delete(file)
|
|
110
|
+
puts "Queue '#{queue}': stopped, PID: #{pid}"
|
|
111
|
+
rescue Errno::ESRCH
|
|
112
|
+
File.delete(file)
|
|
113
|
+
puts "Queue '#{queue}': was not running (stale PID removed)"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
desc "Restart Rust workers"
|
|
119
|
+
task :restart, [:queue] do |_t, args|
|
|
120
|
+
Rake::Task["rust_on_background:stop"].invoke(args[:queue])
|
|
121
|
+
Rake::Task["rust_on_background:stop"].reenable
|
|
122
|
+
Rake::Task["rust_on_background:start"].invoke(args[:queue])
|
|
123
|
+
Rake::Task["rust_on_background:start"].reenable
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
desc "Build and start Rust workers"
|
|
127
|
+
task :run do
|
|
128
|
+
Rake::Task["rust_on_background:build"].invoke
|
|
129
|
+
Rake::Task["rust_on_background:start"].invoke
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
desc "Stop, rebuild and start Rust workers"
|
|
133
|
+
task :rebuild do
|
|
134
|
+
Rake::Task["rust_on_background:stop"].invoke
|
|
135
|
+
Rake::Task["rust_on_background:build"].invoke
|
|
136
|
+
Rake::Task["rust_on_background:start"].invoke
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc "Check Rust worker status"
|
|
140
|
+
task :status do
|
|
141
|
+
binary = RUST_DIR.join("target/release/rust_on_background")
|
|
142
|
+
|
|
143
|
+
puts "Environment: #{Rails.env}"
|
|
144
|
+
puts "Config: #{config_file}"
|
|
145
|
+
|
|
146
|
+
if File.exist?(binary)
|
|
147
|
+
puts "Binary: built (#{File.mtime(binary).strftime('%Y-%m-%d %H:%M')})"
|
|
148
|
+
else
|
|
149
|
+
puts "Binary: not built"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
puts ""
|
|
153
|
+
puts "Scheduler:"
|
|
154
|
+
scheduler_status = running?("scheduler") ? "running PID: #{File.read(pid_file("scheduler")).strip}" : "stopped"
|
|
155
|
+
puts "#{scheduler_status}"
|
|
156
|
+
|
|
157
|
+
puts ""
|
|
158
|
+
puts "Configured queues:"
|
|
159
|
+
queues.each do |q|
|
|
160
|
+
status = running?(q["name"]) ? "running (PID: #{File.read(pid_file(q["name"])).strip})" : "stopped"
|
|
161
|
+
puts " #{q["name"]}: #{q["workers"]} workers - #{status}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
namespace :scheduler do
|
|
166
|
+
desc "Start the scheduler (only run one instance per Redis)"
|
|
167
|
+
task :start do
|
|
168
|
+
binary = RUST_DIR.join("target/release/rust_on_background")
|
|
169
|
+
abort "Worker not built. Run: rake rust_on_background:build" unless File.exist?(binary)
|
|
170
|
+
|
|
171
|
+
FileUtils.mkdir_p(PID_DIR)
|
|
172
|
+
|
|
173
|
+
if running?("scheduler")
|
|
174
|
+
puts "Scheduler already running (PID: #{File.read(pid_file("scheduler")).strip})"
|
|
175
|
+
next
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
Dir.chdir(RUST_DIR) do
|
|
179
|
+
cmd = [binary.to_s, "--config", config_file.to_s, "--queue", "default", "--workers", "0", "--scheduler"]
|
|
180
|
+
log = File.open(log_file, "a")
|
|
181
|
+
pid = spawn(*cmd, out: log, err: log)
|
|
182
|
+
Process.detach(pid)
|
|
183
|
+
File.write(pid_file("scheduler"), pid.to_s)
|
|
184
|
+
puts "Scheduler started (PID: #{pid})"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
desc "Stop the scheduler"
|
|
189
|
+
task :stop do
|
|
190
|
+
file = pid_file("scheduler")
|
|
191
|
+
unless File.exist?(file)
|
|
192
|
+
puts "Scheduler not running"
|
|
193
|
+
next
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
pid = File.read(file).strip.to_i
|
|
197
|
+
begin
|
|
198
|
+
Process.kill("TERM", pid)
|
|
199
|
+
File.delete(file)
|
|
200
|
+
puts "Scheduler stopped, PID: #{pid}"
|
|
201
|
+
rescue Errno::ESRCH
|
|
202
|
+
File.delete(file)
|
|
203
|
+
puts "Scheduler was not running"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
desc "Restart the scheduler"
|
|
208
|
+
task :restart do
|
|
209
|
+
Rake::Task["rust_on_background:scheduler:stop"].invoke
|
|
210
|
+
Rake::Task["rust_on_background:scheduler:stop"].reenable
|
|
211
|
+
Rake::Task["rust_on_background:scheduler:start"].invoke
|
|
212
|
+
Rake::Task["rust_on_background:scheduler:start"].reenable
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
use std::sync::Arc;
|
|
2
|
+
use tokio::sync::watch;
|
|
3
|
+
use redis::AsyncCommands;
|
|
4
|
+
use serde::Deserialize;
|
|
5
|
+
use thiserror::Error;
|
|
6
|
+
use crate::SchedulerConfig;
|
|
7
|
+
|
|
8
|
+
#[derive(Error, Debug)]
|
|
9
|
+
pub enum SchedulerError {
|
|
10
|
+
#[error("Redis error: {0}")]
|
|
11
|
+
Redis(#[from] redis::RedisError),
|
|
12
|
+
|
|
13
|
+
#[error("JSON error: {0}")]
|
|
14
|
+
Json(#[from] serde_json::Error),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
18
|
+
struct ScheduledJob {
|
|
19
|
+
job: String,
|
|
20
|
+
args: serde_json::Value,
|
|
21
|
+
#[serde(default = "default_retry")]
|
|
22
|
+
retry: u32,
|
|
23
|
+
created_at: i64,
|
|
24
|
+
queue: String,
|
|
25
|
+
#[serde(default)]
|
|
26
|
+
scheduled_at: Option<i64>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn default_retry() -> u32 { 3 }
|
|
30
|
+
|
|
31
|
+
const SCHEDULE_KEY: &str = "rust_on_background:schedule";
|
|
32
|
+
|
|
33
|
+
// Lua script for atomic remove-and-enqueue
|
|
34
|
+
const ENQUEUE_SCRIPT: &str = r#"
|
|
35
|
+
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
|
|
36
|
+
if removed == 1 then
|
|
37
|
+
redis.call('LPUSH', KEYS[2], ARGV[2])
|
|
38
|
+
return 1
|
|
39
|
+
end
|
|
40
|
+
return 0
|
|
41
|
+
"#;
|
|
42
|
+
|
|
43
|
+
pub async fn run(redis_url: Arc<String>, config: SchedulerConfig, mut shutdown_rx: watch::Receiver<bool>) -> Result<(), SchedulerError> {
|
|
44
|
+
let client = redis::Client::open(redis_url.as_str())?;
|
|
45
|
+
let mut conn = client.get_multiplexed_async_connection().await?;
|
|
46
|
+
|
|
47
|
+
tracing::info!("Scheduler started, polling {} every {}ms (batch size: {})", SCHEDULE_KEY, config.poll_interval_ms, config.batch_size);
|
|
48
|
+
|
|
49
|
+
loop {
|
|
50
|
+
tokio::select! {
|
|
51
|
+
result = poll_and_enqueue(&mut conn, config.batch_size) => {
|
|
52
|
+
if let Err(e) = result {
|
|
53
|
+
tracing::error!("Scheduler error: {}", e);
|
|
54
|
+
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
_ = shutdown_rx.changed() => {
|
|
58
|
+
tracing::info!("Scheduler shutting down");
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
tokio::time::sleep(tokio::time::Duration::from_millis(config.poll_interval_ms)).await;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Ok(())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async fn poll_and_enqueue(
|
|
70
|
+
conn: &mut redis::aio::MultiplexedConnection,
|
|
71
|
+
batch_size: i64,
|
|
72
|
+
) -> Result<(), SchedulerError> {
|
|
73
|
+
let now = std::time::SystemTime::now()
|
|
74
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
75
|
+
.unwrap()
|
|
76
|
+
.as_secs_f64();
|
|
77
|
+
|
|
78
|
+
// Get all due jobs
|
|
79
|
+
let jobs: Vec<String> = redis::cmd("ZRANGEBYSCORE")
|
|
80
|
+
.arg(SCHEDULE_KEY)
|
|
81
|
+
.arg("-inf")
|
|
82
|
+
.arg(now)
|
|
83
|
+
.arg("LIMIT")
|
|
84
|
+
.arg(0)
|
|
85
|
+
.arg(batch_size)
|
|
86
|
+
.query_async(conn)
|
|
87
|
+
.await?;
|
|
88
|
+
|
|
89
|
+
if jobs.is_empty() {
|
|
90
|
+
return Ok(());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
tracing::debug!("Found {} scheduled jobs ready for processing", jobs.len());
|
|
94
|
+
|
|
95
|
+
let script = redis::Script::new(ENQUEUE_SCRIPT);
|
|
96
|
+
|
|
97
|
+
for job_json in jobs {
|
|
98
|
+
let scheduled_job: ScheduledJob = match serde_json::from_str(&job_json) {
|
|
99
|
+
Ok(job) => job,
|
|
100
|
+
Err(e) => { // Remove malformed job from schedule
|
|
101
|
+
tracing::error!("Failed to parse scheduled job: {}", e);
|
|
102
|
+
let _: () = conn.zrem(SCHEDULE_KEY, &job_json).await?;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let queue_key = format!("queue:{}", scheduled_job.queue);
|
|
108
|
+
|
|
109
|
+
// Convert to regular job payload - remove scheduling fields
|
|
110
|
+
let job_payload = serde_json::json!({
|
|
111
|
+
"job": scheduled_job.job,
|
|
112
|
+
"args": scheduled_job.args,
|
|
113
|
+
"retry": scheduled_job.retry,
|
|
114
|
+
"created_at": scheduled_job.created_at,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let result: i32 = script
|
|
118
|
+
.key(SCHEDULE_KEY)
|
|
119
|
+
.key(&queue_key)
|
|
120
|
+
.arg(&job_json)
|
|
121
|
+
.arg(job_payload.to_string())
|
|
122
|
+
.invoke_async(conn)
|
|
123
|
+
.await?;
|
|
124
|
+
|
|
125
|
+
if result == 1 {
|
|
126
|
+
tracing::info!(
|
|
127
|
+
"Enqueued scheduled job '{}' to queue '{}'",
|
|
128
|
+
scheduled_job.job,
|
|
129
|
+
scheduled_job.queue
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Ok(())
|
|
135
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
use std::sync::Arc;
|
|
2
|
+
use tokio::sync::watch;
|
|
3
|
+
use redis::AsyncCommands;
|
|
4
|
+
use serde::{Deserialize, Serialize};
|
|
5
|
+
use thiserror::Error;
|
|
6
|
+
|
|
7
|
+
#[derive(Error, Debug)]
|
|
8
|
+
pub enum WorkerError {
|
|
9
|
+
#[error("Redis error: {0}")]
|
|
10
|
+
Redis(#[from] redis::RedisError),
|
|
11
|
+
|
|
12
|
+
#[error("JSON error: {0}")]
|
|
13
|
+
Json(#[from] serde_json::Error),
|
|
14
|
+
|
|
15
|
+
#[error("Job error: {0}")]
|
|
16
|
+
Job(String),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
20
|
+
pub struct Job {
|
|
21
|
+
pub job: String,
|
|
22
|
+
pub args: serde_json::Value,
|
|
23
|
+
#[serde(default = "default_retry")]
|
|
24
|
+
pub retry: u32,
|
|
25
|
+
pub created_at: i64,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fn default_retry() -> u32 { 3 }
|
|
29
|
+
|
|
30
|
+
#[derive(Debug)]
|
|
31
|
+
pub enum JobResult {
|
|
32
|
+
Success,
|
|
33
|
+
Retry(String),
|
|
34
|
+
Failed(String),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub async fn run(
|
|
38
|
+
worker_id: usize,
|
|
39
|
+
redis_url: Arc<String>,
|
|
40
|
+
database_url: Arc<String>,
|
|
41
|
+
queue_name: Arc<String>,
|
|
42
|
+
mut shutdown_rx: watch::Receiver<bool>,
|
|
43
|
+
) -> Result<(), WorkerError> {
|
|
44
|
+
let client = redis::Client::open(redis_url.as_str())?;
|
|
45
|
+
let mut conn = client.get_multiplexed_async_connection().await?;
|
|
46
|
+
|
|
47
|
+
tracing::info!("Worker {} connected to Redis, listening on queue: {}", worker_id, queue_name);
|
|
48
|
+
|
|
49
|
+
loop {
|
|
50
|
+
if *shutdown_rx.borrow() {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
tokio::select! {
|
|
55
|
+
result = pop_job(&mut conn, &queue_name) => {
|
|
56
|
+
match result {
|
|
57
|
+
Ok(Some(job_json)) => {
|
|
58
|
+
process_job(&job_json, &database_url, &queue_name, &mut conn).await;
|
|
59
|
+
}
|
|
60
|
+
Ok(None) => {}
|
|
61
|
+
Err(e) => {
|
|
62
|
+
tracing::error!("Worker {} Redis error: {}", worker_id, e);
|
|
63
|
+
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
_ = shutdown_rx.changed() => {
|
|
68
|
+
tracing::info!("Worker {} received shutdown signal", worker_id);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Ok(())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async fn pop_job(
|
|
78
|
+
conn: &mut redis::aio::MultiplexedConnection,
|
|
79
|
+
queue_name: &str,
|
|
80
|
+
) -> Result<Option<String>, WorkerError> {
|
|
81
|
+
let result: Option<(String, String)> = redis::cmd("BLPOP").arg(queue_name).arg(1.0).query_async(conn).await?;
|
|
82
|
+
|
|
83
|
+
Ok(result.map(|(_, value)| value))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async fn process_job(
|
|
87
|
+
job_json: &str,
|
|
88
|
+
database_url: &str,
|
|
89
|
+
queue_name: &str,
|
|
90
|
+
conn: &mut redis::aio::MultiplexedConnection,
|
|
91
|
+
) {
|
|
92
|
+
let failed_queue = format!("{}:failed", queue_name);
|
|
93
|
+
|
|
94
|
+
let job: Job = match serde_json::from_str(job_json) {
|
|
95
|
+
Ok(job) => job,
|
|
96
|
+
Err(e) => {
|
|
97
|
+
tracing::error!("Failed to parse job JSON: {}", e);
|
|
98
|
+
let _ = push_to_queue(conn, &failed_queue, job_json).await;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
tracing::info!("Processing job: {} (retries left: {})", job.job, job.retry);
|
|
104
|
+
|
|
105
|
+
let result = crate::jobs::dispatch(&job, database_url).await;
|
|
106
|
+
|
|
107
|
+
match result {
|
|
108
|
+
JobResult::Success => {
|
|
109
|
+
tracing::info!("Job {} completed successfully", job.job);
|
|
110
|
+
}
|
|
111
|
+
JobResult::Retry(reason) => {
|
|
112
|
+
if job.retry > 0 {
|
|
113
|
+
tracing::warn!("Job {} will retry: {}", job.job, reason);
|
|
114
|
+
let retry_job = Job {
|
|
115
|
+
retry: job.retry - 1,
|
|
116
|
+
..job
|
|
117
|
+
};
|
|
118
|
+
if let Ok(retry_json) = serde_json::to_string(&retry_job) {
|
|
119
|
+
let _ = push_to_queue(conn, queue_name, &retry_json).await;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
tracing::error!("Job {} failed after all retries: {}", job.job, reason);
|
|
123
|
+
let _ = push_to_queue(conn, &failed_queue, job_json).await;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
JobResult::Failed(reason) => {
|
|
127
|
+
tracing::error!("Job {} failed: {}", job.job, reason);
|
|
128
|
+
let _ = push_to_queue(conn, &failed_queue, job_json).await;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async fn push_to_queue(
|
|
134
|
+
conn: &mut redis::aio::MultiplexedConnection,
|
|
135
|
+
queue_name: &str,
|
|
136
|
+
job_json: &str,
|
|
137
|
+
) -> Result<(), WorkerError> {
|
|
138
|
+
conn.lpush::<_, _, ()>(queue_name, job_json).await?;
|
|
139
|
+
Ok(())
|
|
140
|
+
}
|