Step-by-step guides for common Fila use cases. Each tutorial assumes a running broker.
Goal: Prevent a noisy tenant from starving other tenants in a shared queue.
fila queue create orders \
--on-enqueue 'function on_enqueue(msg)
return { fairness_key = msg.headers["tenant_id"] }
end'The on_enqueue hook extracts a tenant_id header and uses it as the fairness key. Each unique tenant gets its own DRR scheduling group.
let client = FilaClient::connect("localhost:5555").await?;
let producer = client.producer();
// Noisy tenant sends 1000 messages
for i in 0..1000 {
producer.send(
Message::new("orders", format!("order-{i}"))
.header("tenant_id", "noisy-corp")
).await?;
}
// Other tenants send a few each
for tenant in ["acme", "globex", "initech"] {
for i in 0..10 {
producer.send(
Message::new("orders", format!("{tenant}-order-{i}"))
.header("tenant_id", tenant)
).await?;
}
}let mut orders = client.consumer().subscribe("orders").await?;
while let Some(delivery) = orders.next().await {
let delivery = delivery?;
println!("tenant={} id={}", delivery.fairness_key(), delivery.id());
delivery.ack().await?;
}Without Fila, all 1000 noisy-corp messages would be delivered first. With DRR scheduling, each tenant gets interleaved delivery — acme, globex, and initech messages arrive alongside noisy-corp's, not after.
fila queue inspect ordersThe per-key breakdown shows each tenant's pending count and current DRR deficit.
Give premium tenants more bandwidth by setting weights:
fila queue create orders \
--on-enqueue 'function on_enqueue(msg)
local weights = { premium = 3, standard = 1 }
local tier = msg.headers["tier"] or "standard"
return {
fairness_key = msg.headers["tenant_id"],
weight = weights[tier] or 1
}
end'A premium tenant with weight=3 gets 3x the delivery bandwidth of a standard tenant with weight=1.
Goal: Keep calls to an external API within its rate limit, without the worker fetching jobs it can't perform yet.
fila queue create charges \
--on-enqueue 'function on_enqueue(msg)
return { fairness_key = msg.headers["tenant"] }
end'The queue knows nothing about Stripe. Rate limits belong to the worker that calls it.
let producer = client.producer();
let charges: Vec<Message> = (0..500)
.map(|i| {
Message::new("charges", format!("charge-{i}"))
.header("tenant", "acme")
.header("customer", format!("cus_{}", i % 20))
})
.collect();
producer.send_batch(charges).await?;Producers don't mention Stripe or any limit.
let mut charges = client
.consumer()
.subscribe("charges")
// Stripe allows 100 requests/second; leave some headroom
.throttle(Throttle::named("stripe").limit(90, Duration::from_secs(1)))
// and no customer above 10 in any second
.throttle(
Throttle::named("stripe-per-customer")
.key([Key::header("customer")])
.limit(10, Duration::from_secs(1)),
)
.await?;
while let Some(delivery) = charges.next().await {
let delivery = delivery?;
// Already within both limits. No client-side rate checking, no re-enqueue loop.
stripe.charge(delivery.payload()).await?;
delivery.ack().await?;
}A message is delivered only when both stripe and its customer's
stripe-per-customer bucket have room. Until then it stays in the broker — no lease, no
attempt counted.
Headroom matters because the limit is on deliveries, not on the calls Stripe receives: workers call some time after receiving a message, and any Stripe traffic that doesn't go through Fila counts against the same account.
A refunds worker on a different queue also calls Stripe. It declares the same name:
let mut refunds = client
.consumer()
.subscribe("refunds")
.throttle(Throttle::named("stripe").limit(90, Duration::from_secs(1)))
.await?;Charges and refunds together stay within 90 in any second, on one node or across a cluster.
A limit lives in the worker's code, so changing it is a deploy. When declarations with the same name disagree, every declared limit applies:
- Lowering a limit takes effect as soon as the first updated worker subscribes.
- Raising a limit takes effect once no worker declares the old, lower one.
Both directions are safe during a rolling deploy: the limit never rises above what some running worker asked for.
Goal: Retry failed messages with increasing delays, then dead-letter after max attempts.
A fixed limit with exponential backoff is what every queue's retry policy does already,
with no script (see concepts). This tutorial uses on_failure
to make the limit changeable at runtime and different per job type.
fila queue create jobs \
--on-enqueue 'function on_enqueue(msg)
return { fairness_key = msg.headers["job_type"] }
end' \
--on-failure 'function on_failure(msg)
local max_attempts = tonumber(fila.get("max_retries") or "5")
if msg.attempts >= max_attempts then
return { action = "dlq" }
end
-- Exponential backoff: 1s, 2s, 4s, 8s, 16s...
local delay = math.min(1000 * (2 ^ (msg.attempts - 1)), 60000)
return { action = "retry", delay_ms = delay }
end' \
--visibility-timeout 30000fila config set max_retries 3The on_failure hook reads this with fila.get("max_retries"). Change it without redeploying.
let mut jobs = client.consumer().subscribe("jobs").await?;
while let Some(delivery) = jobs.next().await {
let delivery = delivery?;
match process_job(delivery.payload()).await {
Ok(()) => delivery.ack().await?,
// Nack runs the on_failure hook — the broker decides retry vs. DLQ
Err(e) => delivery.nack(&e.to_string()).await?,
}
}If the client already knows how long to wait, it can say so directly instead of deferring to the hook's delay decision:
Err(e) if e.is_rate_limited() => {
delivery.retry_after(e.retry_after()).await?;
}# Check how many messages ended up in the DLQ
fila queue inspect jobs.dlq
# After fixing the root cause, redrive them
fila redrive jobs.dlq --count 0 # 0 = all messagesRead config to customize behavior per job type:
function on_failure(msg)
-- Different retry strategies per job type
local job_type = msg.headers["job_type"] or "default"
local max = tonumber(fila.get("max_retries:" .. job_type) or "5")
if msg.attempts >= max then
return { action = "dlq" }
end
-- Critical jobs: shorter delays, more retries
-- Batch jobs: longer delays, fewer retries
local base_ms = tonumber(fila.get("retry_base_ms:" .. job_type) or "1000")
local delay = math.min(base_ms * (2 ^ (msg.attempts - 1)), 300000)
return { action = "retry", delay_ms = delay }
endfila config set max_retries:payment 10
fila config set retry_base_ms:payment 500
fila config set max_retries:report 3
fila config set retry_base_ms:report 5000