rust-backend

📁 windmill-labs/windmill 📅 14 days ago
55
总安装量
15
周安装量
#7132
全站排名
安装命令
npx skills add https://github.com/windmill-labs/windmill --skill rust-backend

Agent 安装分布

codex 14
opencode 14
gemini-cli 13
github-copilot 11
kimi-cli 11
amp 10

Skill 文档

Rust Backend Coding Guidelines

Apply these patterns when writing or modifying Rust code in the backend/ directory.

Data Structure Design

Choose between struct, enum, or newtype based on domain needs:

  • Use enum for state machines instead of boolean flags or loosely related fields
  • Model invariants explicitly using types (e.g., NonZeroU32, Duration, custom enums)
  • Consider ownership of each field:
    • Use &str vs String, slices vs vectors
    • Use Arc<T> when sharing across threads
    • Use Cow<'a, T> for flexible ownership
// State machine with enum
enum JobState {
    Pending { scheduled_for: DateTime<Utc> },
    Running { started_at: DateTime<Utc>, worker: String },
    Completed { result: JobResult, duration_ms: i64 },
    Failed { error: String, retries: u32 },
}

// Avoid multiple booleans
struct Job {
    is_pending: bool,   // Don't do this
    is_running: bool,
    is_completed: bool,
}

Impl Block Organization

Place impl blocks immediately below the struct/enum they modify. Group methods logically:

struct JobQueue {
    jobs: Vec<Job>,
    capacity: usize,
}

impl JobQueue {
    // Constructors first
    pub fn new(capacity: usize) -> Self { ... }
    pub fn with_jobs(jobs: Vec<Job>) -> Self { ... }

    // Getters
    pub fn len(&self) -> usize { ... }
    pub fn is_empty(&self) -> bool { ... }

    // Mutation methods
    pub fn push(&mut self, job: Job) -> Result<()> { ... }
    pub fn pop(&mut self) -> Option<Job> { ... }

    // Domain logic
    pub fn next_scheduled(&self) -> Option<&Job> { ... }
}

Iterator Chains Over For-Loops

Prefer functional iterator chains (.filter().map().collect()) over imperative for-loops:

// Preferred
let results: Vec<_> = items
    .iter()
    .filter(|item| item.is_valid())
    .map(|item| item.transform())
    .collect();

// Avoid
let mut results = Vec::new();
for item in items.iter() {
    if item.is_valid() {
        results.push(item.transform());
    }
}

Error Handling

Use the Error type from windmill_common::error. Return Result<T, Error> or JsonResult<T> for fallible functions:

use windmill_common::error::{Error, Result};

// Use ? operator for propagation
pub async fn get_job(db: &DB, id: Uuid) -> Result<Job> {
    let job = sqlx::query_as!(Job, "SELECT ... WHERE id = $1", id)
        .fetch_optional(db)
        .await?
        .ok_or_else(|| Error::NotFound("job not found".to_string()))?;
    Ok(job)
}

Prefer if let for optional handling. Use let...else when early return makes code clearer:

let Some(config) = get_config() else {
    return Err(Error::MissingConfig);
};

Never panic in library code. Reserve .unwrap() for cases with compile-time guarantees. Keep functions short to help lifetime inference and clarity.

Early Returns

Return early to avoid deep nesting. Handle error cases and edge conditions first:

// Preferred - early returns
fn process_job(job: Option<Job>) -> Result<Output> {
    let Some(job) = job else {
        return Ok(Output::default());
    };

    if !job.is_valid() {
        return Err(Error::InvalidJob);
    }

    if job.is_cached() {
        return Ok(job.cached_result());
    }

    // Main logic at the end, not nested
    execute_job(job)
}

// Avoid - deep nesting
fn process_job(job: Option<Job>) -> Result<Output> {
    if let Some(job) = job {
        if job.is_valid() {
            if !job.is_cached() {
                execute_job(job)
            } else {
                Ok(job.cached_result())
            }
        } else {
            Err(Error::InvalidJob)
        }
    } else {
        Ok(Output::default())
    }
}

Variable Shadowing

Shadow variables instead of creating new names with prefixes:

// Preferred
let data = fetch_raw_data();
let data = parse(data);
let data = validate(data)?;

// Avoid
let raw_data = fetch_raw_data();
let parsed_data = parse(raw_data);
let validated_data = validate(parsed_data)?;

Minimal Comments

  • No inline comments explaining obvious code
  • No TODO/FIXME comments in committed code
  • Doc comments (///) only on public items
  • Let code be self-documenting through clear naming

Type Safety

Use enums over boolean flags for clarity:

// Preferred
enum JobStatus {
    Pending,
    Running,
    Completed,
}

// Avoid
struct Job {
    is_running: bool,
    is_completed: bool,
}

Pattern Matching

Prefer explicit matching. Use wildcards strategically for fallback cases or ignored fields:

// Explicit matching preferred
match status {
    JobStatus::Pending => handle_pending(),
    JobStatus::Running => handle_running(),
    JobStatus::Completed => handle_completed(),
}

// Wildcards OK for fallback
match result {
    Ok(value) => process(value),
    Err(_) => return default_value(),
}

// Wildcards OK for ignoring fields in destructuring
let Point { x, y, .. } = point;

Destructuring in Function Signatures

Destructure structs directly in function parameters:

// Preferred
async fn process_job(
    Extension(db): Extension<DB>,
    Path((workspace, job_id)): Path<(String, Uuid)>,
    Query(pagination): Query<Pagination>,
) -> Result<Json<Job>> {
    // ...
}

// Avoid
async fn process_job(
    db_ext: Extension<DB>,
    path: Path<(String, Uuid)>,
    query: Query<Pagination>,
) -> Result<Json<Job>> {
    let Extension(db) = db_ext;
    let Path((workspace, job_id)) = path;
    // ...
}

Trait Implementations

Use standard trait implementations to simplify conversions and reduce boilerplate:

// Implement From/Into for type conversions
impl From<DbJob> for ApiJob {
    fn from(db: DbJob) -> Self {
        ApiJob {
            id: db.id,
            status: db.status.into(),
        }
    }
}

// Use TryFrom for fallible conversions
impl TryFrom<String> for JobKind {
    type Error = Error;
    fn try_from(s: String) -> Result<Self, Self::Error> { ... }
}

Apply derive macros to reduce boilerplate:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job { ... }

Module Structure

  • Use pub(crate) instead of pub when possible; expose only what needs exposing
  • Keep APIs small and expressive; avoid leaking internal types
  • Organize code into modules reflecting ownership and domain boundaries
// Prefer restricted visibility
pub(crate) fn internal_helper() { ... }

// Only pub for external API
pub fn create_job(...) -> Result<Job> { ... }

Code Navigation

Always use rust-analyzer LSP for:

  • Go to definition
  • Find references
  • Type information
  • Import resolution

Do not guess at module paths or type definitions.

JSON Handling

Prefer Box<serde_json::value::RawValue> over serde_json::Value when:

  • Storing JSON in the database (JSONB columns)
  • Passing JSON through without modification
  • The JSON structure doesn’t need inspection
// Preferred - avoids parsing/serialization overhead
pub struct Job {
    pub id: Uuid,
    pub args: Option<Box<serde_json::value::RawValue>>,
}

// Only use Value when you need to inspect/modify JSON
let value: serde_json::Value = serde_json::from_str(&json)?;
if let Some(field) = value.get("field") {
    // modify or inspect
}

Serde Optimizations

Use serde attributes to optimize serialization:

#[derive(Serialize, Deserialize)]
pub struct Job {
    #[serde(rename = "jobId")]
    pub id: Uuid,

    #[serde(default)]
    pub priority: i32,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_job: Option<Uuid>,

    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
}

Prefer borrowing for zero-copy deserialization when lifetimes allow:

#[derive(Deserialize)]
pub struct JobInput<'a> {
    #[serde(borrow)]
    pub workspace_id: Cow<'a, str>,

    #[serde(borrow)]
    pub script_path: &'a str,
}

SQLx Patterns

Never use SELECT * – always list columns explicitly. This is critical for backwards compatibility when workers run behind the API server version:

// Preferred - explicit columns
sqlx::query_as!(
    Job,
    "SELECT id, workspace_id, path, created_at FROM v2_job WHERE id = $1",
    job_id
)

// Avoid - breaks when columns are added
sqlx::query_as!(Job, "SELECT * FROM v2_job WHERE id = $1", job_id)

Use batch operations to minimize round trips:

// Preferred - single query with multiple values
sqlx::query!(
    "INSERT INTO job_logs (job_id, logs) VALUES ($1, $2), ($3, $4)",
    id1, log1, id2, log2
)

// Avoid N+1 queries
for id in ids {
    sqlx::query!("SELECT ... WHERE id = $1", id).fetch_one(db).await?;
}

// Preferred - single query with IN clause
sqlx::query!("SELECT ... WHERE id = ANY($1)", &ids[..]).fetch_all(db).await?

Use transactions for multi-step operations and parameterize all queries.

Async & Tokio Patterns

Never block the async runtime. Use spawn_blocking for CPU-intensive or blocking I/O:

// Preferred - offload blocking work
let result = tokio::task::spawn_blocking(move || {
    expensive_computation(&data)
}).await?;

// Avoid - blocks the runtime
let result = expensive_computation(&data);  // Don't do this in async

Use tokio primitives for sleep and channels:

use tokio::sync::mpsc;
use tokio::time::sleep;

// Avoid in async contexts
use std::thread::sleep; // Blocks the runtime

Use bounded channels for backpressure:

// Preferred - bounded channel prevents overwhelming
let (tx, rx) = tokio::sync::mpsc::channel(100);

// Be careful with unbounded
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();

Mutex Selection in Async Code

Prefer std::sync::Mutex (or parking_lot::Mutex) over tokio::sync::Mutex for protecting data in async code. The async mutex is more expensive and only needed when holding locks across .await points.

// Preferred for data protection - std mutex is faster
use std::sync::Mutex;

struct Cache {
    data: Mutex<HashMap<String, Value>>,
}

impl Cache {
    fn get(&self, key: &str) -> Option<Value> {
        self.data.lock().unwrap().get(key).cloned()
    }

    fn insert(&self, key: String, value: Value) {
        self.data.lock().unwrap().insert(key, value);
    }
}

Use tokio::sync::Mutex only when you must hold the lock across .await points, typically for IO resources like database connections:

use tokio::sync::Mutex;
use std::sync::Arc;

// Async mutex for IO resources held across await points
let conn = Arc::new(Mutex::new(db_connection));

async fn execute_query(conn: Arc<Mutex<DbConn>>, query: &str) {
    let mut lock = conn.lock().await;
    lock.execute(query).await;  // Lock held across .await
}

Common pattern: Wrap Arc<Mutex<...>> in a struct with non-async methods that lock internally, keeping lock scope minimal:

struct SharedState {
    inner: std::sync::Mutex<StateInner>,
}

impl SharedState {
    fn update(&self, value: i32) {
        self.inner.lock().unwrap().value = value;
    }

    fn get(&self) -> i32 {
        self.inner.lock().unwrap().value
    }
}

Alternative for IO resources: Spawn a dedicated task to manage the resource and communicate via message passing:

let (tx, mut rx) = tokio::sync::mpsc::channel(32);

tokio::spawn(async move {
    while let Some(cmd) = rx.recv().await {
        handle_io_command(&mut resource, cmd).await;
    }
});

Build & Tooling

Build speed tips:

  • Use cargo check during rapid iteration over cargo build
  • Minimize unnecessary dependencies and feature flags