Rullst Core ⚙️
rullst-core contains Rullst’s runtime primitives, Axum-compatible routing,
state management, health probes, process telemetry, queues, and configuration
helpers.
Core is runtime-only by default. Enable orm for ORM bootstrap/artisan and
database-backed feature flags, and queue-sqlite for the SQLite queue driver.
orm adds no SQLx driver: pair it with drivers-all or one strict-*
backend. The umbrella rullst crate enables none of them by default (up to
13.0.0-alpha.2 its defaults were orm, drivers-all and queue-sqlite), and
domain crates opt in only when they actually use them.
Queue monitoring capabilities are driver-specific. The trait defaults for
listing all jobs, retrying failures and purging failures return
QueueError::Unsupported; they never fabricate an empty snapshot or successful
mutation. purge_failed_jobs is the canonical facade method. The deprecated
purge_completed_jobs name is retained only as a source-compatibility alias for
the historical operation, which actually removed failed jobs. The Redis driver
implements all three: list_all_jobs returns at most 1,000 rows (failed jobs
and dead letters newest first, then processing, pending and scheduled jobs, read
without one atomic snapshot; created_at is empty because Redis does not record
it), retry_failed_job moves a failed job to the tail of the pending list while
keeping its attempt counter, and purge_failed_jobs deletes every failed job
and dead letter.
The v13 list_job_previews(limit, max_field_bytes) (on
QueueDriver and the Queue facade) lists the same records as
QueuedJobPreviews whose payload and error hold at most max_field_bytes
bytes, cut on a UTF-8 boundary, with payload_truncated/error_truncated
flags. SQLite cuts the values in SQL and Redis in one atomic Lua script that
decodes at most limit envelopes, so complete payloads never leave the store.
Custom drivers inherit a default that projects their list_all_jobs (and
therefore still loads complete records).
Cache diagnostics are driver-specific too. Cache::inspect(limit) accepts
1–200 and returns sorted logical-key, UTF-8 value-length and remaining-TTL
metadata for Memory and Redis without the value. Exact keys are still
application data and CacheEntryMetadata::logical_key() belongs only inside an
authorized diagnostic boundary; its Debug output redacts the key. Custom
drivers return CacheError::InspectionUnsupported unless they implement the
bounded method. The live Redis CI/release contract checks metadata, TTL and
non-disclosure; it does not prove cluster/failover or operator authorization.
The Redis cache and queue drivers each keep one lazily opened multiplexed
connection for all operations and replace it after a connection-level failure;
each queue command waits up to 10 seconds for its reply.
The memory cache stores a TTL too large for the monotonic clock (such as
u64::MAX) as non-expiring instead of panicking. A read that finds an expired
entry removes the key only while it still holds that expired value, so a
concurrent refill is kept.
SQLite deletes successful jobs by default. Applications that need a real
Studio/operations history can opt in with
Queue::sqlite_with_completed_history(database_url, retained_jobs). The
validated limit is 1–100,000 records; status transition and pruning commit in
one transaction, and purge_completed_history removes the retained successes.
Rows still contain the original payload, so access control and retention policy
belong to the host. Redis/custom drivers do not inherit this policy implicitly.
The Redis driver bounds its own failure state instead: failed jobs (with
payloads) and dead letters are each retained up to 10,000 entries by default,
the oldest evicted atomically, and
RedisDriver::try_with_failure_retention(failed_jobs, dead_letters) accepts
1–100,000 for each. Failures recorded before that bound existed are not
indexed, so they are not listed and never evicted automatically.
Queue::dispatch and dispatch_at reject an empty job name or one longer
than 256 bytes with QueueError::InvalidConfiguration.
Queue::dispatch_at persists a due timestamp for at most 366 days through the
built-in SQLite and Redis drivers. SQLite filters claims by local wall-clock
milliseconds and claims in due-time order, so a due scheduled or handed-back
job does not wait behind later immediate jobs; Redis atomically promotes
bounded batches using Redis server time. Neither backend claims a scheduled job
early. Execution starts on the first worker poll after it becomes due and
retains the queue’s at-least-once semantics.
Worker drives each pop to completion instead of racing it against
completions or shutdown, because both built-in claims commit before the future
resolves. A job claimed after graceful shutdown was requested is requeued.
When a handler finishes while its timeout or a graceful shutdown is being
processed, the worker records the handler’s own result: only a handler that
was actually cancelled is failed as timed out or requeued, so a success is
never reported as a timeout or run again. Worker::run rejects a zero
max_concurrency, poll_interval or job_timeout and a stalled_after that
does not exceed job_timeout with QueueError::InvalidConfiguration.
Stalled-lease recovery runs when a worker starts and then every
min(stalled_after, 60 s), and it is queue-wide: it returns every processing
lease in the shared SQLite table or Redis namespace that is older than the
recovering worker’s stalled_after, including leases of other workers. Since
v13, workers claim through QueueDriver::pop_with_lease
with their own stalled_after; SQLite and Redis store that lease with the
claim and recovery honours it whatever age the recovering worker uses, so a
pool with a short stalled_after no longer requeues a slower pool’s running
job. Claims without a lease (older workers, custom drivers or direct pop
calls) still stall after the recovering worker’s age: while any exist, every
worker that shares a queue must use a stalled_after longer than the longest
job_timeout of any of them.
A job that crashes, aborts or hangs its worker would otherwise be recovered and
claimed forever, so the SQLite and Redis drivers count stalled leases per job
and fail the job, instead of requeuing it, when its fifth lease stalls. The
failure is listed and retryable like any other failed job, and
retry_failed_job restarts the count. Since v13,
SqliteDriver::try_with_max_stalled_leases and
RedisDriver::try_with_max_stalled_leases change the ceiling (1–1,000, default
DEFAULT_MAX_STALLED_LEASES = 5).
Worker transitions are fenced by the claim’s attempt number. The SQLite and
Redis drivers complete, fail or requeue a job only while it is still processing
under the attempt that pop returned, so a worker whose lease was recovered and
claimed again receives a StateTransition error instead of finishing, failing
or deleting the newer claim. The new QueueDriver::mark_complete_attempt,
mark_failed_attempt and requeue_attempt methods default to the unfenced
methods, so custom drivers keep their behaviour until they override them. The
stale handler may still have run its side effects (delivery stays
at-least-once). SQLite and Redis retry_failed_job keep the attempt counter,
so the fence also holds across a manual retry.
A worker that claims a job whose name it has no handler for hands the claim
back instead of failing it. SQLite and Redis make the job claimable again after
five seconds, behind jobs that are already due, so a worker that registered the
name (for example a newer version during a rolling deploy) can run it; the
delay keeps the claiming worker out of a hot loop, and it still reports
HandlerNotFound each time. From the 720th claim attempt (at least an hour of
hand-backs) the job is failed with HandlerNotFound instead, so a job that no
running worker can handle becomes visible and purgeable; since a retry keeps
the attempt counter, deploy a handler before retrying it.
Custom drivers that do not implement QueueDriver::requeue_attempt_after keep
the previous behaviour and fail the job.
ValidatedForm/ValidatedJson failures keep REST status codes (400, 413,
415 or 422 JSON) for other clients, but an HTMX request receives its
escaped HTML fragment with 200 OK and an X-Rullst-Validation-Status header
carrying that status, because htmx swaps only successful responses by default.
Scheduler::task takes a POSIX five-field expression (minute hour day-of-month month day-of-week) evaluated in UTC. Day-of-week accepts 0-7
(0 and 7 are Sunday, 1 is Monday) and names, so 0 9 * * 1-5 runs Monday to
Friday. When both day fields are restricted, a day matching either one runs
the task (0 0 1 * 1 is the 1st plus every Monday); a field starting with *
keeps the intersection. Earlier releases passed the fields to the cron
crate unchanged, where 1 was Sunday and 0 was rejected. Messaging’s durable
recurring publications keep their documented cron-crate projection.
See time zones for scheduled tasks to
match the fields against a local clock instead of UTC.
WorkerHandle and SchedulerHandle buffer at most 256 undrained errors. Once
the buffer is full, newer errors are dropped, counted by dropped_errors() and
emitted as tracing warnings, so a handle that is kept alive but never drained
does not grow memory. Drain next_error (for example from a supervising task)
to observe every failure. A scheduler attached with Server::schedule is
drained by the server: each task failure is logged as a tracing error on the
rullst::scheduler target when reported, and a past task failure no longer
turns a clean shutdown into Err(ServerError::Scheduler); only a failed
scheduler loop does.
Custom drivers return QueueError::Unsupported for future timestamps unless
they explicitly implement durable scheduling.
ApplicationLifecycle supplies an opt-in process-local startup/readiness/drain
contract. Up to 32 immutable validated component labels can gate readiness and
application admission; /ready publishes only aggregate counts, not labels or
dependency errors. Server::with_lifecycle marks the phase ready after binding,
begins draining before Axum’s graceful wait, and marks it stopped on completion
or startup failure. run_with_shutdown accepts a caller-owned trigger for
embedded supervisors and deterministic tests. The lifecycle does not run
dependency probes, coordinate replicas, authorize users, or guarantee load
balancer propagation.
Since v13, the drain retains admission through the response body, including streaming/trailers, error and cancellation. It does not track upgraded connections or prove client receipt. The two-replica deployment contract exercises this boundary through a real proxy; its hosted workspace and installed-archive checks passed in PR #224, and the final release campaign remains separate.
✨ Core Features & Subsystems
- Axum-compatible routing:
rullst::Routerwraps and converts to/fromaxum::Router; application latency depends on handlers, middleware, build profile, and deployment. - Typed server functions: concrete async
#[server_function]items share owned Serde arguments/results through the versionedrullst.clientv1 envelope. The generated native router and Wasm caller enforce same-origin paths, bounded bodies, correlation and redacted errors; hosts still own identity, tenant, authorization, idempotency and rate-limit policy. - Rullst Radar (
rullst::radar): Collects process RSS/CPU where an OS probe is supported, Tokio task/yield observations when a runtime is available, and process uptime. Unsupported probes returnNone: RSS and CPU have probes on Linux and Windows only, so macOS reports neither. On Linux, RSS comes fromVmRSSin/proc/self/status(correct on 16/64 KiB page kernels), and CPU percent is process CPU time over wall time: the host-wide/proc/statdelta is scaled by its host CPU count, not by the cgroup-limitedavailable_parallelism, so a container saturating a 2-CPU quota reports about 200%. - Prometheus
/metricsExporter: Text-format metrics served atGET /metrics; formatting and collection have bounded runtime cost. - Kubernetes probe routes (
rullst::health): the simplehealth_routerreports process availability and uptime.Serverrecords the health and Radar uptime origin when it starts, unlessinit_health_boot_timeorinit_radarwas called earlier; without aServer, call them yourself. The opt-inhealth_router_with_lifecyclereturns readiness from the same bounded state that gates Server request admission; the application still performs and times out its own dependency checks. Theversionfield of both payloads is the Rullst framework version. To report your application’s version too, usehealth_router_with_version(env!("CARGO_PKG_VERSION"))orhealth_router_with_lifecycle_and_version(lifecycle, env!("CARGO_PKG_VERSION"))(v13), which add it asapp_version; any string works, such as a commit hash read at startup. - Interactive Scalar API Docs (
rullst::scalar): OpenAPI documentation UI mounted at/docs, with a pinned CDN asset and a status-only fallback. A missing or malformedopenapi.jsonreturns503. - Typed framework errors: startup, queues, validation, scheduling, storage,
and other subsystems expose their own typed errors. Applications may compose
those into an application-owned
AppError; Core does not define one global application error type. - Redacted configuration
Debug:DatabaseConfig(and thereforeRullstConfig) prints only the database URL scheme, such aspostgres://<redacted>;db::ReplicationConfigredactsauth_tokenand prints only thesync_urlscheme. Fields stay public and unchanged. - Durable scheduled queues: SQLite and Redis persist bounded due timestamps; the live Redis CI contract proves that an immediate job remains claimable while a future job stays unavailable.
- Opt-in completed-job monitoring: SQLite can retain and atomically prune a configured number of successful jobs; the privacy-safe default remains immediate deletion.
- Bounded token-bucket rate limiter:
RateLimiterkeys IPv4 peers per address and IPv6 peers per /64 by default. It tracks at most 100,000 keys, drops fully refilled buckets and evicts the least recently used ones beyond that cap; a key longer than 128 bytes (for example from a custom extractor returning a token header) is stored as its SHA-256 digest, so the map stays bounded in bytes too. State is process-local, not a distributed limit. When attached toServer, the limiter and the Traffic Shield let exactGET/HEAD /healthand/readyprobes through, so load shedding or an exhausted bucket cannot fail a liveness probe. In a debug Development server withcargo rullst devreloading, its/_rullst/dev-generationpoll,/_rullst/dev-reload.jsand the/_rullst/dev-telemetrypoll ofcargo rullst dashalso bypass both and are not access-logged. In staging and production, successful/healthand/readyprobes are not access-logged either (failing ones are);RULLST_LOG_HEALTH_PROBES=trueprints them again. WithoutRUST_LOG, the default tracing filter isinfo,sqlx=warn; see the telemetry guide. - Development dashboard telemetry (v13): under the same conditions
ServerservesGET /_rullst/dev-telemetryto loopback clients only, over HTTP/1.1 or newer and without proxy forwarding headers: bounded request counters and recent requests (method, path without query string, status, duration), ORM operation counts and slow operations fromrullst.orm.queryspans, and the pending count of a queue passed toServer::with_dev_queue. Bodies, headers, cookies, query strings, SQL and bindings are never recorded. See the telemetry guide. - Production monitoring endpoint (v13, 13.0.0-alpha.3):
Server::with_monitor(rullst_core::monitor::Monitor::new())servesGET /_rullst/monitor/v1onceRULLST_MONITOR_TOKENS(orMonitor::with_token, or an applicationMonitorAuthorizer) provides a credential; without one nothing is mounted. It reports request rate, latency and status classes per route pattern, registered queues, N+1 fingerprints and the radar, never request data or configuration, and requires TLS evidence in staging and production. See the production monitoring guide. - Trusted-proxy client resolution (v13):
Server::trusted_proxiesmountssecurity::TrustedProxyLayeroutside every other framework layer. Only a socket peer inside the listed networks may report the client throughX-Forwarded-Foror RFC 7239Forwarded; the resolved address replacesConnectInfo, so existing rate limiters and lockouts use it unchanged. See Running behind a reverse proxy. - Bounded database flag cache:
DbFeatureDrivercaches a found flag, a flag without a row and a failed or timed-out lookup (missing table, unavailable database) for its TTL, so an undefined flag does not query the database on every evaluation. A failed refresh keeps serving the last value read; one lookup waits at most two seconds and each driver caches at most 4,096 flag names. - Feature flag buckets: percentage rollouts and A/B variants in the Env,
TOML, Memory and DB drivers use
calculate_hash_bucket, a versioned SHA-256 hash over a domain tag, the length-prefixed flag and the identifier. It gives every toolchain, platform and replica the same assignment. Earlier releases usedstd’s unspecifiedDefaultHasher, so upgrading reassigns users to buckets once; percentages and variant weights are kept.TomlFeatureDriver::reloadparses into a new map and swaps it in at once, so concurrent evaluations never see a flag as unset mid-reload. It reads[features]with a TOML parser, so quoted flag names ("checkout.v2"),#inside strings and any valid header spelling work, and dotted keys or[features.<group>]tables become dotted flag names; a file that is not valid TOML falls back to the earlier line reader. When an A/B split’s weights sum to less than 100, an identifier outside them gets the variant"disabled"from the driver that defines the flag; it no longer falls through to a lower-priority driver’s split. Every driver evaluates an A/B flag for an identifier to its variant name, soenabled_foris true only for identifiers assigned a variant named"enabled", andenabled(no identifier) is false for an A/B flag in every driver, including aDbFeatureDriverrow without a rollout percentage; gate code onvariant. The v13FeatureManager::overrides()returns the first-priorityMemoryFeatureDriverofFeatureManager::default()(and of the globalfeature::manager()when it uses the default pipeline), so programmatic overrides reach it. - Project settings (v13):
config::project_setting(name)(alsorullst::config::project_setting) reads an application setting from the process environment first and then./.env, which never overrides the environment and is never loaded into it, byServeror by Artisan commands (db:*,studio); read.envvalues in migrations and seeders with it rather thanstd::env::var. It returnsOk(None)when neither defines the name and fails withConfigError::Read/Parsefor an unreadable or malformed.env; errors never contain.envcontent. Nexusbasic_from_envand generated billing code (BILLING_*) use it. The#[doc(hidden)]server::ProjectSettingsand asyncserver::read_project_settingapply the same precedence for first-party crates such asrullst-mail, andProjectSettings::environmentapplies theServerprecedence forRULLST_ENV/APP_ENV/[app].env; they are not a stable extension point. - Bounded cache metadata: Memory and Redis expose value length and TTL for at most 200 sorted entries, never cached values. Rullst Studio renders keyed opaque identifiers and one-entry invalidation rather than exact keys or bulk flush.
🚀 Usage
Most applications can use the re-exports provided by the umbrella rullst
crate instead of depending on rullst-core directly.
Mounting lifecycle-aware Health Probes & Prometheus Metrics
These optional surfaces are application-owned and must be mounted explicitly.
Protect or isolate /metrics when its operational data should not be public.
use rullst_core::{
ApplicationLifecycle, Router, Server,
health::health_router_with_lifecycle,
radar::radar_metrics_router,
scalar::scalar_docs_router,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let lifecycle = ApplicationLifecycle::new();
let app = Router::new()
.merge_axum(health_router_with_lifecycle(lifecycle.clone()))
.merge_axum(radar_metrics_router()) // GET /metrics (Prometheus)
.merge_axum(scalar_docs_router("/openapi.json")); // GET /docs
Server::new(app)
.with_lifecycle(lifecycle)
.run(3000)
.await?;
Ok(())
}
Running behind a reverse proxy
Behind a load balancer or TLS terminator every connection comes from the proxy, so limits and lockouts would treat all clients as one. List the networks your own proxies connect from, and nothing broader:
use rullst_core::{Router, Server, security::TrustedProxyConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let proxies = TrustedProxyConfig::new(["10.0.0.0/8", "fd00::/8"])?
.trust_forwarded_proto(true);
Server::new(Router::new())
.trusted_proxies(proxies)
.run(3000)
.await?;
Ok(())
}
Or configure the same policy in Rullst.toml; a builder policy replaces it:
[security]
trusted_proxies = ["10.0.0.0/8", "fd00::/8"]
trusted_proxy_header = "x-forwarded-for" # or "forwarded" (RFC 7239)
trust_forwarded_proto = true
Forwarding headers from any other peer are ignored. The chain is read right to
left, skipping trusted hops, and the first untrusted address becomes the
client: ConnectInfo<SocketAddr> is replaced by that IP with port 0, and a
ClientAddr extension records the client, the original socket peer and whether
a proxy supplied it. A missing or malformed header from a trusted proxy keeps the
proxy address and logs one tracing event without header contents. Enable
trust_forwarded_proto only when your proxies overwrite X-Forwarded-Proto
(or set proto=); ClientAddr::forwarded_proto then reports the scheme, and
Nexus accepts an HTTPS report as its TLS evidence. Any host inside a listed
network can choose the client address, so never list client-reachable ranges.
Deferrable jobs and time windows
Opt-in v13 API. Work that need not run immediately (reports,
exports, re-indexing) can be dispatched with a Deferral: a deadline
(run_by) and up to 24 daily TimeWindows, each with an optional fixed UTC
offset (daylight-saving changes are not applied, except for the windows of
Scheduler::deferrable_task_in_tz; an end earlier than the start wraps
midnight). Queue::dispatch_deferred makes the job claimable at the
start of the next allowed window (immediately inside an open window), never
after the deadline; when no window opens before the deadline, it becomes
claimable at the deadline. Without windows, any time before the deadline is
allowed, so the job is due immediately.
Queue::dispatch_deferred_with takes a CarbonAwarePlanner around an
application-provided CarbonIntensitySource (an async trait returning
forecast slots with their unit; the source has a name). The job is placed at
the start of the lowest-value slot that overlaps its windows before the
deadline (ties go to the earliest slot). When the source returns an error or
exceeds the planner’s timeout (2 s by default), the time-window rule is used
and one warning is logged per outage. Core ships no network source; the
deterministic FixedIntensitySource exists for tests and documentation.
The planner only chooses a time from the values the source reports; Rullst
measures nothing and makes no claim about emissions.
Placement is expressed through dispatch_at, so SQLite and Redis store a
deferred job as an ordinary scheduled job: no new column or key, and rows
written by 12.x keep working unchanged. Custom drivers without durable
scheduling reject a future placement with QueueError::Unsupported. The
returned DeferredJob carries the DeferralPlan (run_at, reason of
window, intensity or deadline, the source name, and the source’s value
and unit for an intensity placement), and a rullst.queue.deferral tracing
span records the same fields with the job id. Deadlines are limited to 366
days, like dispatch_at, and execution still starts on the first worker poll
after the job becomes due.
#![allow(unused)]
fn main() {
use rullst_core::queue::{
CarbonAwarePlanner, Deferral, FixedIntensitySource, Queue, QueueError, TimeWindow,
};
use std::time::{Duration, SystemTime};
async fn defer_reports(queue: &Queue) -> Result<(), QueueError> {
// Claimable between 00:00 and 06:00 at UTC-03:00, at the latest in 24 hours.
let night = TimeWindow::daily(0, 0, 6, 0)?.with_utc_offset_minutes(-180)?;
let deferral = Deferral::within(Duration::from_secs(24 * 3_600))?.window(night)?;
let job = queue
.dispatch_deferred("monthly_report", serde_json::json!({"month": "2026-09"}), &deferral)
.await?;
println!("{} due at {:?} ({})", job.id, job.plan.run_at, job.plan.reason.as_str());
// Fixed example values; applications implement CarbonIntensitySource.
let now = SystemTime::now();
let hour = Duration::from_secs(3_600);
let source = FixedIntensitySource::new("example-fixed", "gCO2eq/kWh")
.slot(now, now + hour, 300.0)
.slot(now + hour, now + 2 * hour, 120.0);
let planner = CarbonAwarePlanner::new(source).with_source_timeout(Duration::from_secs(2));
let reindex = Deferral::within(4 * hour)?;
let job = queue
.dispatch_deferred_with(&planner, "reindex", serde_json::json!({}), &reindex)
.await?;
println!("{:?} from {:?}", job.plan.reason, job.plan.source);
Ok(())
}
}
Scheduler::deferrable_task(cron, ScheduleDeferral::within(max_delay)?.window(...)?, handler) applies the same window and deadline rules to each tick of a
recurring task, with the tick as the start and tick + max_delay as the
deadline. Ticks that pass while a deferred run waits are skipped, as for a slow
run. Scheduled tasks do not consult an intensity source; a task can enqueue a
job with dispatch_deferred_with instead.
Time zones for scheduled tasks
Scheduler::task matches its five fields against UTC. With the optional
scheduler-tz feature (v13; rullst and rullst-core both have it),
Scheduler::task_in_tz matches them against the wall clock of a named IANA
time zone, so “07:00 in Brasília” is written as it is said:
use rullst::Scheduler;
let scheduler = Scheduler::new()
// 07:00 São Paulo time, which is 10:00 UTC.
.task_in_tz("0 7 * * *", "America/Sao_Paulo", || async {
// send the morning digest
})?
// Every ten minutes; the zone does not matter for this one.
.task("*/10 * * * *", || async {})?;
An unknown zone name, such as Brasilia, is rejected when the task is
registered with SchedulerError::InvalidTimeZone { zone, message }; a bad
expression still returns SchedulerError::InvalidCron. The day-of-month and
day-of-week fields also follow the local date: 30 22 * * 5 in São Paulo runs
on Friday evening local time, which is Saturday 01:30 UTC.
In zones with daylight saving, one rule applies:
- A wall time that a gap skips (clocks move forward) runs at the first
instant after the gap. In
America/New_York,30 2 * * *runs at 03:00 EDT on the day clocks spring forward, and*/15 * * * *runs once at 03:00 for the skipped 02:00 to 02:45 and then continues at 03:15. - A wall time that a fold repeats (clocks move back) runs once, at its first
occurrence.
30 1 * * *runs at 01:30 EDT and not again at 01:30 EST, and an hourly task runs at 01:00 EDT and next at 02:00 EST.
The zone rules come from the system database (/usr/share/zoneinfo) when it
has the zone, so operating-system updates refresh them; otherwise the copy
bundled with the jiff dependency is used, which keeps minimal container
images without tzdata working. A task’s label in scheduler errors is the
expression followed by the zone, such as 0 7 * * * [America/Sao_Paulo].
Scheduler::deferrable_task_in_tz(expression, zone, deferral, handler)
(added in 13.0.0-alpha.3) is the zoned form of deferrable_task: its ticks follow
task_in_tz, and its TimeWindows are wall times of the same zone, so
TimeWindow::daily(22, 0, 6, 0) stays 22:00–06:00 local time across a
daylight-saving change. Window boundaries follow the same rule as ticks: one
that a gap skips moves to the first instant after the gap, and one that a fold
repeats is its first occurrence; a window that a gap skips entirely does not
open that day. max_delay is elapsed time after the tick. A window of such a
task must not set with_utc_offset_minutes, which registration rejects with
SchedulerError::InvalidConfiguration. deferrable_task keeps UTC ticks
and fixed-offset windows.
use rullst::Scheduler;
use rullst::queue::{ScheduleDeferral, TimeWindow};
use std::time::Duration;
// The 18:00 report may wait up to 12 hours for 22:00–06:00 in São Paulo.
let night = ScheduleDeferral::within(Duration::from_secs(12 * 3_600))?
.window(TimeWindow::daily(22, 0, 6, 0)?)?;
let scheduler = Scheduler::new().deferrable_task_in_tz(
"0 18 * * *",
"America/Sao_Paulo",
night,
|| async { /* build the report */ },
)?;
Axum First-Class Escape Hatches & Tower Interoperability
rullst::Router provides bidirectional conversion with axum::Router and
accepts compatible tower::Layer values:
#![allow(unused)]
fn main() {
use rullst::Router;
use axum::routing::get;
use tower_http::cors::CorsLayer;
async fn handler() -> &'static str { "ok" }
let mut router = Router::new()
.route("/hello", get(handler))
.fallback(|| async { (axum::http::StatusCode::NOT_FOUND, "not found") })
.layer(CorsLayer::permissive());
// Direct conversion to raw axum::Router
let axum_app: axum::Router = router.into();
// Or wrap an existing Axum router
let rullst_app: Router = axum_app.into();
}
🛡️ Security baseline
In staging and production, Server mounts the runtime security baseline from
rullst_core::security: secure headers with a per-request CSP nonce, an exact
CORS allowlist, a bounded WAF, double-submit CSRF and, when
enable_pii_masking is set, response PII masking. rullst-security adds
opt-in layers that overlap some of these. See
which security layer to use, and when before you
combine them.
The secure headers include Strict-Transport-Security: max-age=31536000; includeSubDomains. [security] hsts_max_age_seconds,
hsts_include_subdomains and hsts_preload change it; preload is opt-in
and requires includeSubDomains and a max-age of at least one year, or the
server refuses to start (see
Strict-Transport-Security).
The WAF refuses requests only in staging and production; in development it
logs a warn event with the rule and path of each request it would refuse.
[security] waf_body_exempt_paths lists exact paths whose bodies it does
not inspect, for routes that accept text such as code samples (see
seeing and accepting WAF false positives).
TLS connections use aws-lc-rs as the rustls crypto provider; ring is only
a primitives library (HMAC, digests, randomness, signatures). With the orm,
storage-s3 or telemetry feature, Server start-up installs aws-lc-rs as the
rustls process default before telemetry and the database pool build TLS
clients, and so does the OTLP exporter before it builds its HTTP client. An
application that installs its own provider first keeps it. See
cryptography providers.
🔐 Security Audit & Reliability
Repository workflows exercise Core with unit and integration tests on every change, and with fuzz and Miri campaigns started on demand, within their declared scopes. Consult the exact workflow run and commit for evidence; these tools do not prove the absence of every panic, leak, or vulnerability.