A brokerless filesystem queue for Linux. Jobs are immutable files whose pathnames encode ownership state. Ownership transfers through atomic no-overwrite rename operations.
SteadQ needs no daemon, no leader, no mutable index, and no network. A queue is a directory tree. Producers and consumers operate by opening files and renaming them. Durability comes from fsync, not from a write-ahead log. Recovery is a bounded directory scan that resumes from where it left off.
This makes SteadQ suitable for single-node workloads where simplicity and crash safety matter more than horizontal scalability: job schedulers, CI task queues, durable notification pipelines, and local work dispatch on machines that already have a POSIX filesystem.
Experimental. The protocol is fully implemented and tested but has not been deployed in production. Do not use it for workloads where job loss would cause harm.
The queue lifecycle works: enqueue, lease, acknowledge, retry with exponential backoff, bury, renew, recover, inspect. Payload integrity is verified by SHA-256 at every transition. The wall clock watermark prevents early delivery after a clock rollback. Recovery is resumable with bounded work guarantees.
cargo build --release
./target/release/steadq init /tmp/myqueue
echo "hello world" | ./target/release/steadq put /tmp/myqueue - --content-type text/plain
./target/release/steadq lease /tmp/myqueue --duration-seconds 30 --handle-file /tmp/handle.json
./target/release/steadq ack /tmp/myqueue --handle-file /tmp/handle.json
./target/release/steadq inspect /tmp/myqueue <job_id>Without --handle-file, steadq lease prints the handle JSON on stdout and the job summary on stderr, so steadq lease /tmp/myqueue > handle.json works too. ack, retry, bury, and lease take --ticket-out PATH to save the transition ticket when they exit 2 (outcome unknown); pass it to steadq resolve --result-file PATH. bury --reason takes a code 0-4 or its name from spec/reasons.md. The global --json flag is honored by stats and doctor only.
steadq work runs a command per job:
./target/release/steadq work /tmp/myqueue --concurrency 4 -- ./handle-job.shThe payload arrives on stdin, and STEADQ_JOB_ID and STEADQ_ATTEMPT are set in the environment. Exit 0 acks; nonzero requeues. The lease is renewed at half its duration and retried every min(duration/8, 1 s) after a failed renewal. The command is checked before any lease (missing: exit 127), the child is killed if the worker dies, and SIGTERM or SIGINT stops leasing, forwards SIGTERM to running children, and exits 0 once they finish. --once runs one job and exits with the child's code (128+N for signal N), which can overlap steadq's own exit codes. steadq bench refuses a queue that holds jobs unless given --allow-nonempty, since it acks everything it leases.
use steadq_core::{Queue, EnqueueInput};
let mut queue = Queue::open(std::path::Path::new("/tmp/myqueue"), &Default::default())?;
queue.enqueue(EnqueueInput {
maximum_attempts: 3,
content_type: "text/plain".into(),
payload: b"hello world".to_vec(),
..Default::default()
});
match queue.lease(0, 30_000_000_000) {
steadq_core::LeaseOutcome::Leased(lease) => {
queue.verify_lease_payload(&lease)?;
queue.ack(&lease);
}
_ => {}
}lease(max_wait_ns, lease_duration_ns) scans once when max_wait_ns is zero. A
positive wait retries empty scans and transient watermark-lock contention with
bounded backoff until the monotonic deadline.
Deferred directory sync is opt-in through OpenOptions::deferred_dir_sync.
Enqueue returns EnqueueOutcome::Deferred(ticket), not Committed; a successful
queue.sync() then establishes durability for pending tickets. Strict mode
remains the default.
For payloads that should not be buffered in memory:
queue.enqueue_streaming(
3, "text/plain".into(),
Default::default(), None, None, None,
std::io::Cursor::new(large_payload),
);Read payload bytes without re-hashing on every access:
let reader = queue.open_verified_payload_reader(&lease)?.unwrap();
let mut buf = vec![0u8; 4096];
let n = reader.read_at(&mut buf, 0)?;lease_with_payload returns the same reader from the claim, which has already
verified the payload, so delivery skips the second hash. Ack still re-verifies.
The steadq-capi crate exposes the full lifecycle through opaque handles: init, open, enqueue, lease, renew, verified reads, ack, retry, bury, recover, and resolve. The header is generated by cbindgen, documents each function's return codes, and is checked for drift in CI.
SteadqQueue *q = steadq_init("/tmp/myqueue", 64);
SteadqJobId job_id;
steadq_enqueue(q, payload, len, "text/plain", 3, &job_id);
SteadqLease *lease = NULL;
steadq_lease(q, 30000000000ULL, &lease);
SteadqPayloadReader *reader = NULL;
steadq_lease_open_reader(q, lease, &reader);
size_t bytes_read;
uint8_t buf[4096];
steadq_reader_read(reader, buf, sizeof(buf), 0, &bytes_read);
steadq_renew(q, lease, 30000000000ULL); /* updates the lease in place */
steadq_ack(q, lease);
steadq_reader_free(reader);
steadq_lease_free(lease);
steadq_close(q);A queue lives in a directory on a local Linux filesystem (ext4, XFS, btrfs, f2fs, or ZFS). The layout is:
queue/
FORMAT Queue identity and configuration
control/
maintenance.lock OFD write lock for recovery
recovery.lock OFD write lock for exclusive recovery
wall-watermark Authenticated wall-time floor
ready/<shard>/ Ready jobs; leased jobs stay here with boot id in the filename
leased/<boot>/<bucket>/<shard>/ Previous-layout leases, still scanned on recovery;
drained directories of earlier boots are removed
delayed/<bucket>/<shard>/ Jobs waiting for scheduled delivery
dead/<bucket>/<shard>/ Jobs that exhausted retries
receipts/<bucket>/<shard>/ Acknowledgment records
quarantine/ Corrupt objects isolated for inspection
tmp/<boot>/<shard>/ Temporary staging for publication; drained directories
of earlier boots are removed
Every job filename encodes its identity: queue ID, job ID, generation, attempt count, lease token, content digest, and an integrity tag. State transitions are no-overwrite renames through a phase-aware executor that classifies every failure as either not-committed (before the rename) or outcome-unknown (after the rename).
| Crate | Responsibility |
|---|---|
| steadq-format | Binary encoding: FORMAT record, job header, receipt, watermark, CBOR extension |
| steadq-names | Canonical filename parsing, integrity tags, shard computation |
| steadq-math | Bucket arithmetic, retry jitter, checked conversions |
| steadq-fs-linux | Linux syscalls: openat, renameat2, linkat, O_TMPFILE, fsync, OFD locks |
| steadq-core | Queue state machine: all operations route through a phase-aware executor |
| steadq-cli | Command-line interface |
| steadq-capi | C ABI with opaque handles and generated header |
| steadq-testkit | Conformance harness, logical oracle, filesystem simulator, differential driver |
SteadQ has 871 tests across unit, integration, conformance, and formal model checking:
- Unit tests cover every binary format, filename, shard computation, retry policy, and syscall wrapper
- Fault injection tests inject I/O errors at every syscall boundary and verify error classification
- Stateful differential tests run random operation sequences through the production API and verify results against a pure logical oracle at every step
- TLA+ model checking verifies invariants for lease tokens, capability tokens, namespace durability, scheduling, and receipt evidence across six model configurations
- Mutation testing (cargo-mutants) runs on every pull request, scoped to the changed lines
- Fuzz testing covers format parsing, filename parsing, CBOR decoding, and arithmetic
- Crash lab replays persistence-barrier crash states on real filesystem images: 761 states on kernel 6.8.0-137 and 793 states on kernel 7.0.0-28, all five profiles passing on both hosts (docs/crash-lab.md)
- dm-flakey power cuts on ext4 under a concurrent workload: 1,350 cut points (dropped and failed writes, default and
data=journalmounts), all passing
SteadQ sustains more than 10,000 completed jobs per second with strict durability on the reference system.
| Mode | Parallelism | Batch size | Completed jobs/sec |
|---|---|---|---|
| Individual commits | 1 worker | 1 | 2,988 |
| Individual commits | 4 workers | 1 | 5,795 |
| Individual commits | 8 workers | 1 | 8,212 |
| Group commit | 1 worker | 256 | 5,479 |
| Group commit | 8 workers | 64 | 9,321 |
| Group commit | 8 workers | 128 | 9,958 |
| Group commit | 8 workers | 256 | 10,116 |
| Production pipeline | 9 producers, 15 consumers | - | 11,619 median |
A completed job includes enqueue, lease, payload verification, and acknowledgment. The production pipeline ranged from 11,164 to 12,118 jobs per second across five 10-second runs.
The Criterion results use a release build, a 64-shard queue, 64 B payloads, strict durability, a 2-second warm-up, a 10-second measurement, and 30 samples. The reference system has an Intel Core i5-13500 CPU and an Intel SSDPEK1A118GA NVMe drive formatted as ext4.
Batch operations remain pending until commit() completes. Larger batches
increase throughput but also increase latency and the number of operations
affected by a failed commit. Use batches of 64 to 128 for balanced workloads.
Use 256 when throughput matters more than latency.
spec/contract.md- Assumptions, guarantees, and terminologyspec/format.md- Binary record layouts with offsets and digest formulasspec/filenames.abnf- Normative filename grammarspec/reasons.md- Dead and quarantine reason registriesdocs/compatibility-policy.md- Target platforms and versioningdocs/release-checklist.md- Requirements for a stable releasedocs/formal-evidence-scope.md- What the formal models prove and their limitationsdocs/filesystem-profiles.md- Filesystem certification requirementsdocs/crash-lab.md- Storage crash testing tooling
- Linux x86_64 or aarch64, glibc or musl
- Rust 1.97.1
- ext4, XFS, btrfs, f2fs, or ZFS filesystem
Apache-2.0