Tasks can expire if not processed within a deadline. Expired tasks are skipped
by workers and marked with Expired status, freeing resources without
counting as failures.
| Principle | How This Aligns |
|---|---|
| S3 as only dependency | Uses existing available_at pattern, just adds expires_at |
| Simple, boring, reliable | One new field, simple timestamp comparison |
| No coordination services | Workers check expiration on claim, no external TTL service |
| Crash-safe | Expiration time persisted with task |
| Debuggable | buquet status shows expiration, clear why task wasn't processed |
- Time-sensitive tasks become useless after a deadline
- Stale tasks accumulate, wasting resources
- Currently: tasks wait forever or require manual cleanup
- Examples:
- Send a reminder email (useless if sent days late)
- Process a real-time event (stale after minutes)
- Generate a report for a meeting (useless after meeting ends)
Add expires_at: Option<DateTime> field. Workers skip expired tasks. Monitor
marks them as Expired during sweeps.
enum TaskStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
Expired, // NEW
Archived,
}from buquet import connect
from datetime import datetime, timedelta
queue = await connect(bucket="my-queue")
# Submit with absolute expiration
task = await queue.submit(
"send_reminder",
{"user_id": "123"},
expires_at=datetime(2026, 1, 28, 17, 0, 0), # 5pm today
)
# Submit with relative TTL
task = await queue.submit(
"process_event",
{"event_id": "456"},
ttl=timedelta(hours=1), # Expires in 1 hour
)
# Submit with TTL in seconds
task = await queue.submit(
"quick_task",
{"data": "..."},
ttl_seconds=300, # Expires in 5 minutes
)
# Check if expired
task = await queue.get(task_id)
if task.status == TaskStatus.Expired:
print(f"Task expired at {task.expires_at}")
# Check remaining time
now = await queue.now()
if task.expires_at:
remaining = task.expires_at - now
print(f"Expires in {remaining}")use buquet::Queue;
use chrono::{Duration, Utc};
let queue = Queue::connect("my-queue").await?;
// Submit with absolute expiration
let task = queue
.submit("send_reminder")
.input(json!({"user_id": "123"}))
.expires_at(Utc::now() + Duration::hours(2))
.send()
.await?;
// Submit with TTL
let task = queue
.submit("process_event")
.input(json!({"event_id": "456"}))
.ttl(Duration::minutes(30))
.send()
.await?;# Submit with absolute expiration
buquet submit -t send_reminder -i '{}' --expires-at "2026-01-28T17:00:00Z"
# Submit with TTL
buquet submit -t process_event -i '{}' --ttl 1h
buquet submit -t quick_task -i '{}' --ttl 5m
buquet submit -t batch_job -i '{}' --ttl 7d
# Check task expiration
buquet status abc123
# Output includes: expires_at: 2026-01-28T17:00:00Z (in 2h 30m)
# List expired tasks
buquet list --status expired{
"id": "abc123",
"task_type": "send_reminder",
"status": "pending",
"input": {"user_id": "123"},
"expires_at": "2026-01-28T17:00:00Z",
"expired_at": null,
...
}| Field | Type | Description |
|---|---|---|
expires_at |
Option<DateTime> |
When task expires (null = never) |
expired_at |
Option<DateTime> |
When task was marked expired |
Worker claim attempt:
│
├── Is task expired? (expires_at < now)
│ │
│ ├── Yes: Skip task, mark as Expired (best-effort)
│ │
│ └── No: Proceed with claim
│
▼
Normal claim flow...
Workers check expiration before claiming:
// In worker claim loop
let (task, etag) = queue.get(task_id).await?;
let now = queue.now().await?;
// Check expiration
if let Some(expires_at) = task.expires_at {
if now >= expires_at {
// Task expired, mark it (best-effort)
queue.mark_expired(task_id).await.ok();
continue;
}
}
// Check availability
if !task.is_available_at(now) {
continue;
}
// Proceed with claim...Monitor sweeps for expired tasks during regular checks:
// In monitor sweep
for task_id in ready_tasks {
let task = queue.get(task_id).await?;
let now = queue.now().await?;
if let Some(expires_at) = task.expires_at {
if now >= expires_at && task.status == TaskStatus::Pending {
queue.mark_expired(task_id).await?;
metrics.expired_tasks.inc();
}
}
}pub async fn mark_expired(&self, task_id: Uuid) -> Result<Task> {
let (task, etag) = self.get(task_id).await?.ok_or(NotFound)?;
if task.status != TaskStatus::Pending {
return Err(CannotExpire { status: task.status });
}
let now = self.now().await?;
let updated = Task {
status: TaskStatus::Expired,
expired_at: Some(now),
updated_at: now,
..task
};
self.put_task_if_match(&updated, &etag).await?;
self.delete_ready_index(task_id).await.ok(); // best-effort
Ok(updated)
}If task expires while running, it continues to completion. Expiration only affects pending tasks.
Rationale: Once work starts, let it finish. Handler can check expiration internally if needed.
@worker.task("process")
async def handle(input, context):
# Optional: check if task is about to expire
task = context.task
now = await context.queue.now()
if task.expires_at and (task.expires_at - now).total_seconds() < 60:
# Less than 1 minute left, abort early
raise PermanentError("Task about to expire, aborting")
# Normal processing...When a task fails and retries:
available_atis set to backoff timeexpires_atremains unchanged
If available_at > expires_at, task will expire before becoming available.
This is intentional - the task had its chance.
When RescheduleError is raised:
- Worker can optionally extend expiration
# Extend expiration when rescheduling
raise RescheduleError(
delay_seconds=60,
extend_expiration_seconds=120, # Add 2 minutes to expires_at
)ttl=0 means "expire immediately" - task is submitted already expired.
Use case: testing expiration handling.
expires_at=None means task never expires. This is the default.
# Task scheduled for 5pm, expires at 6pm
task = await queue.submit(
"meeting_reminder",
data,
schedule_at=datetime(2026, 1, 28, 17, 0), # Run at 5pm
expires_at=datetime(2026, 1, 28, 18, 0), # Expire at 6pm
)| Aspect | Cancellation | Expiration |
|---|---|---|
| Trigger | Explicit user action | Time-based automatic |
| Status | Cancelled |
Expired |
| Running tasks | Cooperative cancel | Continues to completion |
| Retry | Can retry cancelled | Cannot retry expired |
buquet_tasks_expired_total{task_type}
buquet_task_ttl_seconds{task_type} # Histogram of TTL values
buquet_tasks_near_expiration{task_type} # Gauge of tasks expiring soon
Expiration appears in task history:
$ buquet history abc123
Version 1: pending (created, expires_at: 2026-01-28T17:00:00Z)
Version 2: expired (expired_at: 2026-01-28T17:00:05Z)Expired tasks have their ready index removed:
DELETE ready/{shard}/{bucket}/{task_id}
crates/buquet/src/models/task.rs- AddExpiredstatus,expires_at,expired_atfieldscrates/buquet/src/queue/ops.rs- Addmark_expired(), TTL handling in submitcrates/buquet/src/queue/submit.rs- Addexpires_at,ttltoSubmitOptionscrates/buquet/src/worker/runner.rs- Check expiration before claimcrates/buquet/src/worker/monitor.rs- Sweep for expired taskscrates/buquet/src/python/queue.rs- Python bindingscrates/buquet/src/cli/commands.rs- Add--expires-at,--ttlflags
| Component | Lines |
|---|---|
| Task model | ~15 |
| Submit with TTL | ~25 |
| Mark expired | ~30 |
| Worker check | ~15 |
| Monitor sweep | ~25 |
| Python bindings | ~30 |
| CLI | ~25 |
| Tests | ~80 |
| Total | ~245 |
All core features from this spec are fully implemented:
Expiredstatus in TaskStatus enum- Task fields:
expires_at,expired_at queue.submit(..., ttl_seconds=N)- submit with TTL in secondsqueue.submit(..., expires_at=datetime)- submit with absolute expirationqueue.submit_many(..., ttl_seconds=N)- bulk submit with TTLqueue.submit_many(..., expires_at=datetime)- bulk submit with expirationtask.is_expired_at(timestamp)- check if task is expired at a given timequeue.mark_expired(task_id)- manually mark a task as expired- Workers check expiration before claiming and mark expired tasks
- Monitor/sweeper marks expired tasks during index sweeps
- Expiration works with scheduled tasks
- Python bindings with full support
- Rust core implementation
- CLI flags for
buquet submit:--ttl 1h/--ttl 30m/--ttl 7d- relative TTL--expires-at 2026-01-28T17:00:00Z- absolute expiration
RescheduleErrorwithextend_expiration_seconds(optional feature)
- Automatic retry of expired tasks: Expired means "too late"
- Sliding expiration: Expiration doesn't extend on claim
- Per-type default TTL: Configure in application code
- Expiration warnings: Use metrics/alerts externally