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.
@@ -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
+ }