Skip to content
davidtorciviaPublic

About

Crash-safe filesystem queue protocol

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

SteadQ

A brokerless filesystem queue for Linux. Jobs are immutable files whose pathnames encode ownership state. Ownership transfers through atomic no-overwrite rename operations.

Why

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.

Status

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.

Quick Start

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.sh

The 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.

Rust API

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.

Streaming Enqueue

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),
);

Verified Payload Reads

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.

C ABI

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);

How It Works

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).

Architecture

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

Testing

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=journal mounts), all passing

Performance

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.

Documentation

Requirements

  • Linux x86_64 or aarch64, glibc or musl
  • Rust 1.97.1
  • ext4, XFS, btrfs, f2fs, or ZFS filesystem

License

Apache-2.0

About

Crash-safe filesystem queue protocol

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages