Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Unique Jobs

A unique key prevents duplicate jobs. When a job carries a UniqueKey, the server rejects a new enqueue whose key matches an existing job within the key’s scope — returning the existing job instead of creating a second one.

Note

Unique jobs require a Pro license on the server.

This solves problems like “only one rebuild_search_index job should be queued at a time”, or “don’t email the same user twice for the same event”.

Deriving a unique key

The #[zizq(unique(...))] attribute on #[derive(JobKind)] generates a unique_key implementation that hashes the payload (or a subset of it) and attaches it to every enqueue automatically.

The bare form hashes the entire payload, tagged with the job type name:

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::JobKind;
#[derive(Serialize, Deserialize, JobKind)]
#[zizq(name = "rebuild_index", unique)]
struct RebuildIndex;
}

For a job type with no fields, “hash the whole payload” is a stable per-type key — perfect for the “only one queued at a time” case.

Selecting a subset

only and except narrow the hashed subset via jq-compatible paths:

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::JobKind;
// Two jobs collide when they share `user_id` and `campaign_id`,
// regardless of what `body` contains.
#[derive(Serialize, Deserialize, JobKind)]
#[zizq(name = "send_email", unique(only = [".user_id", ".campaign_id"]))]
struct SendEmail {
    user_id: u64,
    campaign_id: u64,
    body: String,
}
}

except is the inverse — hash everything but the listed paths. only and except are mutually exclusive; setting both is a compile error.

Paths are validated at compile time. An unknown or malformed path (e.g. user_id without the leading .) surfaces as a compile error with a caret on the offending string literal.

Important

Paths address the serialized field names, not the Rust field identifiers. If your struct uses #[serde(rename = "...")] or #[serde(rename_all = "...")] to change the wire form, the paths must match the wire form too. This is because the payload is hashed after serialisation — the derive doesn’t rewrite paths to match Rust names.

For example, a struct that serialises to camelCase must use camelCase paths:

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::JobKind;
#[derive(Serialize, Deserialize, JobKind)]
#[serde(rename_all = "camelCase")]
#[zizq(name = "send_email", unique(only = [".userId", ".campaignId"]))]
struct SendEmail {
    user_id: u64,       // serialises as "userId"
    campaign_id: u64,   // serialises as "campaignId"
    body: String,
}
}

Using [".user_id"] here would silently match nothing (jq’s behaviour for missing keys), which would collapse the picked subset to {} and give every enqueue the same key — probably not what you want. If you’re using serde renames, keep the two forms in sync.

Scope

The scope field selects the lifecycle window during which duplicates are rejected:

ScopeA duplicate is rejected while an existing job is…
"queued" waiting to run (ready or scheduled) — the default.
"active" queued or currently being processed.
"exists" present in any state at all, including completed or dead (until reaped by retention).

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::JobKind;
#[derive(Serialize, Deserialize, JobKind)]
#[zizq(name = "reindex", unique(scope = "active"))]
struct Reindex;
}

Dropping the type-name prefix

By default the hash is prefixed with the job type name, so two different job types can’t collide on identical payload data. Set prefix = false to opt out — useful when you want two job types to share a uniqueness namespace (for example, an insert-or-update pattern where either job type dedups against the other):

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::JobKind;
#[derive(Serialize, Deserialize, JobKind)]
#[zizq(name = "sync_user", unique(only = [".user_id"], prefix = false))]
struct SyncUser { user_id: u64 }
}

Per-enqueue override

EnqueueBuilder::unique_key supplies a key for a single call, overriding whatever the JobKind would produce:

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::{Client, JobKind, UniqueKey};
#[derive(Serialize, Deserialize, JobKind)]
#[zizq(name = "rebuild_index")]
struct RebuildIndex;
async fn run(client: &Client) -> Result<(), zizq::ZizqError> {
client
    .enqueue(RebuildIndex)
    .unique_key(UniqueKey::raw("rebuild_index"))
    .await?;
Ok(()) }
}

Chain .scope(...) on a UniqueKey to change the scope:

Rust:

#![allow(unused)]
fn main() {
use zizq::{UniqueKey, UniqueScope};

let key = UniqueKey::raw("rebuild_index").scope(UniqueScope::Active);
}

Detecting a duplicate

When an enqueue collides with an existing unique job, the call still succeeds — it returns the existing job, with its duplicate field set to Some(true):

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::{Client, JobKind, UniqueKey};
#[derive(Serialize, Deserialize, JobKind)]
#[zizq(name = "rebuild_index")]
struct RebuildIndex;
async fn run(client: &Client) -> Result<(), zizq::ZizqError> {
let job = client
    .enqueue(RebuildIndex)
    .unique_key(UniqueKey::raw("rebuild_index"))
    .await?;

if job.duplicate == Some(true) {
    println!("a rebuild was already queued — job {}", job.id);
}
Ok(()) }
}

Manual unique_key impl

The derive covers hashing-based keys — the 90% case. Reach for a hand-written fn unique_key(&self) when the key needs computation the attribute grammar can’t express: reaching into &self in non-obvious ways, combining runtime state, or producing a raw string key without hashing.

UniqueKey::tagged_hash_of is the same helper the derive uses under the hood; feed it whatever identifies the job — the whole payload (self), a single field (&self.field), or a tuple of fields:

Rust:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use zizq::{JobKind, UniqueKey};

#[derive(Serialize, Deserialize)]
struct SendWelcomeEmail {
    user_id: u64,
    campaign: String,
}

impl JobKind for SendWelcomeEmail {
    const NAME: &'static str = "send_welcome_email";

    fn unique_key(&self) -> Option<UniqueKey> {
        // Two elements — a tuple serialises to a JSON array, so
        // order matters and must stay stable.
        Some(UniqueKey::tagged_hash_of(
            Self::NAME,
            (&self.user_id, &self.campaign),
        ))
    }
}
}

UniqueKey::hash_of is the same without the tag prefix (equivalent to unique(prefix = false) on the derive). UniqueKey::raw(key) wraps a literal string verbatim — no hashing or transformation applied.

Chain .scope(UniqueScope::Active | Exists) on the returned UniqueKey to match the derive’s scope = "active" | "exists".