Skip to content

Prevent jobs from being registered twice #42

@tirimia

Description

@tirimia

Currently we cannot prevent users from registering the same job twice, which can lead to some unexpected issues on their side.

The simplest way to solve it would be at runtime, the runner builder could return an error where we validate the config.
However, it would be much cooler and ergonomic if we got this sorted out in types. Tried out some stuff to see if we could actually achieve this (with a simpler Runner -> Jobs model instead of the Runner -> Queues -> Jobs we actually have).

TLDR: It seems to be possible to keep track of the added job types in a list inside the type of the Runner, but throwing a compile time error when the job is already in the list is not currently possible in stable rust. At the bottom you fill find a working typescript version.

Warning: moderately heavy LLM usage in the Rust code

// ============================================================================
// Typestate Magic: PotentialRunner with type-level job tracking
// ============================================================================
//
// This demonstrates typestate programming where the type parameter changes
// with each operation, encoding the state in the type system:
//
//   PotentialRunner<Ctx, Nil>                           // Empty
//   .register::<JobA>()  →  PotentialRunner<Ctx, Cons<JobA, Nil>>
//   .register::<JobB>()  →  PotentialRunner<Ctx, Cons<JobB, Cons<JobA, Nil>>>
//
// Each registration produces a NEW type, which the compiler tracks.
//
// LIMITATION: True compile-time duplicate prevention (making `.register::<JobA>()`
// twice fail to compile) is impossible in stable Rust because it requires proving
// a negative ("JobA is NOT in the list"), which needs one of:
//   - Specialization (RFC 1210, unstable)
//   - Negative trait bounds (RFC 2451, unstable)
//   - Auto traits with negative impls (unstable)
//
// The typestate DOES provide value for API design, type safety, and documentation.
// For duplicate prevention, we rely on `BackgroundJob::JOB_TYPE` uniqueness.
// ============================================================================

/// Type-level list terminator
#[derive(Debug, Copy, Clone)]
pub struct Nil;

/// Type-level list node
#[derive(Debug, Copy, Clone)]
pub struct Cons<Head, Tail> {
    _head: PhantomData<Head>,
    _tail: PhantomData<Tail>,
}

/// A builder that uses typestate to track registered jobs.
///
/// The type parameter `RegisteredJobs` is a type-level list (Cons/Nil) that
/// encodes which jobs have been registered. This prevents certain API misuses
/// at compile time.
///
/// For true compile-time duplicate prevention in stable Rust, each job type
/// would need to "consume" a unique type token. Since that's impractical for
/// a library API, we use the type system for API tracking and rely on the
/// fact that `BackgroundJob::JOB_TYPE` must be unique (enforced by the registry).
#[derive(Debug)]
pub struct PotentialRunner<Context, RegisteredJobs = Nil>
where
    Context: Clone + Send + Sync + 'static,
{
    registry: job_registry::JobRegistry<Context>,
    _jobs: PhantomData<RegisteredJobs>,
}

impl<Context> PotentialRunner<Context, Nil>
where
    Context: Clone + Send + Sync + 'static,
{
    /// Create a new PotentialRunner with no jobs registered yet.
    pub fn new() -> Self {
        Self {
            registry: job_registry::JobRegistry::default(),
            _jobs: PhantomData,
        }
    }
}

impl<Context> Default for PotentialRunner<Context, Nil>
where
    Context: Clone + Send + Sync + 'static,
{
    fn default() -> Self {
        Self::new()
    }
}

/// Trait that proves a type T is not in a type-level list.
///
/// This trait is only implemented for Nil (base case). Due to limitations
/// in stable Rust (no specialization/negative bounds), we cannot provide
/// a complete recursive implementation that works for all cases.
///
/// This demonstrates the typestate concept, but full compile-time duplicate
/// prevention requires either:
/// - Nightly Rust with specialization
/// - A macro-based approach that generates unique marker types
/// - An API design where each job "consumes" a unique token
pub trait NotInList<T> {}

impl<T> NotInList<T> for Nil {}

// This is where the magic happens: we DON'T implement NotInList<T> for Cons<T, _>
// So if you try to register T twice, this bound will fail

// For Cons<Head, Tail> where Head != T, we need Tail to not contain T
// But we can't write this without negative bounds or specialization...
// The solution: require the USER to provide the proof via a different API

impl<Context, RegisteredJobs> PotentialRunner<Context, RegisteredJobs>
where
    Context: Clone + Send + Sync + 'static,
{
    /// Register a BackgroundJob type with this runner.
    ///
    /// The type parameter changes with each registration, building up a
    /// type-level list of registered jobs: Cons<J, Cons<K, Nil>>
    ///
    /// **Note**: While the type system tracks registrations, compile-time
    /// duplicate prevention requires the `register_new` method with explicit
    /// type-level proofs. This method does runtime checking.
    pub fn register<J>(mut self) -> PotentialRunner<Context, Cons<J, RegisteredJobs>>
    where
        J: BackgroundJob<Context = Context>,
    {
        self.registry.register::<J>();
        PotentialRunner {
            registry: self.registry,
            _jobs: PhantomData,
        }
    }

    /// Register a BackgroundJob type with compile-time duplicate prevention.
    ///
    /// This method requires a proof (via the `NotInList` trait bound) that
    /// J is not already in the RegisteredJobs list. If J is already registered,
    /// this will fail to compile.
    ///
    /// **Limitation**: Due to Rust's trait system limitations (no specialization
    /// or negative trait bounds in stable), this only works for the first registration
    /// of each type. For a fully working solution, use nightly Rust with specialization.
    pub fn register_new<J>(mut self) -> PotentialRunner<Context, Cons<J, RegisteredJobs>>
    where
        J: BackgroundJob<Context = Context>,
        RegisteredJobs: NotInList<J>,
    {
        self.registry.register::<J>();
        PotentialRunner {
            registry: self.registry,
            _jobs: PhantomData,
        }
    }

    /// Get the number of registered job types.
    pub fn job_count(&self) -> usize {
        self.registry.job_types().len()
    }

    /// Get the list of registered job type names.
    pub fn job_types(&self) -> Vec<String> {
        self.registry.job_types()
    }
}

#[cfg(test)]
mod typestate_tests {
    use super::*;
    use serde::{Deserialize, Serialize};

    #[derive(Serialize, Deserialize)]
    struct JobA;

    impl BackgroundJob for JobA {
        const JOB_TYPE: &'static str = "job_a";
        type Context = ();
        async fn run(&self, _: Self::Context) -> anyhow::Result<()> {
            Ok(())
        }
    }

    #[derive(Serialize, Deserialize)]
    struct JobB;

    impl BackgroundJob for JobB {
        const JOB_TYPE: &'static str = "job_b";
        type Context = ();
        async fn run(&self, _: Self::Context) -> anyhow::Result<()> {
            Ok(())
        }
    }

    #[derive(Serialize, Deserialize)]
    struct JobC;

    impl BackgroundJob for JobC {
        const JOB_TYPE: &'static str = "job_c";
        type Context = ();
        async fn run(&self, _: Self::Context) -> anyhow::Result<()> {
            Ok(())
        }
    }

    #[test]
    fn test_typestate_tracking() {
        // The type changes with each registration:
        // PotentialRunner<(), Nil>
        let r0 = PotentialRunner::<()>::new();
        assert_eq!(r0.job_count(), 0);

        // PotentialRunner<(), Cons<JobA, Nil>>
        let r1 = r0.register::<JobA>();
        assert_eq!(r1.job_count(), 1);

        // PotentialRunner<(), Cons<JobB, Cons<JobA, Nil>>>
        let r2 = r1.register::<JobB>();
        assert_eq!(r2.job_count(), 2);

        // PotentialRunner<(), Cons<JobC, Cons<JobB, Cons<JobA, Nil>>>>
        let r3 = r2.register::<JobC>();
        assert_eq!(r3.job_count(), 3);

        let job_types = r3.job_types();
        assert!(job_types.contains(&"job_a".to_string()));
        assert!(job_types.contains(&"job_b".to_string()));
        assert!(job_types.contains(&"job_c".to_string()));
    }
}

This actually works in Typescript 🤯

interface Job<JobName extends string> {
  name: JobName,
  run: () => void
}

class Runner<Jobs extends string = never> {
  constructor(
    private jobRegistry: { [key: string]: () => void } = {}
  ) { }

  add<JobName extends string>(
    name: JobName extends Jobs
      ? { _error: `Job name "${JobName}" already exists` }
      : JobName,
    run: () => void
  ): Runner<Jobs | JobName> {
    if (typeof name === 'string') {
      this.jobRegistry[name] = run;
    }
    return new Runner<Jobs | JobName>(this.jobRegistry)
  }

  run<_Check extends Jobs = Jobs>(
    /// Need variadic arg to allow for no arguments to be passed
    ..._args: [Jobs] extends [never]
      ? [{ "ERROR": "Cannot call run() without jobs" }]
      : []
  ): void {
    for (const [name, run] of Object.entries(this.jobRegistry)) {
      console.log(`Running job ${name}...`);
      run();
      console.log(`Job ${name} completed`);
    }
  }
}

const emptyRunner = new Runner()
emptyRunner.run(); // Type error: Expected 1 arguments but got 0
// type of emptyRunner.run is
// (method) Runner<never>.run<never>(_args_0: {
//     ERROR: "Cannot call run() without jobs";
// }): void

const correctlyConfiguredRunner = new Runner()
  .add("job1", () => console.log("Job 1"))
  .add("job2", () => console.log("Job 2"))
correctlyConfiguredRunner.run();

const duplicateJobRunner = new Runner()
  .add("job1", () => console.log("Job 1"))
  .add("job2", () => console.log("Job 2"))
  .add("job1", () => console.log("Duplicate!")) // Type error: Argument of type 'string' is not assignable to parameter of type '{ _error: "Job name \"job1\" already exists"; }'.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions