Utility crate of composable building blocks for time handling, strings, binary helpers, collection ergonomics, and small async/Tokio primitives.
[dependencies]
rust-extensions = { tag = "${last_tag}", git = "https://github.com/MyJetTools/rust-extensions.git" }with-tokio— Tokio-backed helpers: timers, queues, events loop, task completions, application state tracking, sortable IDs.base64— Enable base64 encode/decode utilities.hex— Enable hex helpers.objects-pool— Object pooling.vec-maybe-stack— Stack-or-heap small-buffer optimization helpers.
Example:
rust-extensions = { version = "${last_tag}", features = ["with-tokio", "base64"] }- Time:
date_time,duration_utils,stop_watch,atomic_stop_watch,atomic_duration. - String ergonomics:
short_string,maybe_short_string,string_builder,str_utils,str_or_string,as_str. - Binary helpers:
binary_payload_builder,binary_search,uint32_variable_size, optionalbase64, optionalhex. - Collections & memory:
sorted_vec,sorted_ver_with_2_keys,grouped_data,auto_shrink,slice_or_vec,vec_maybe_stack(opt),objects_pool(opt),lazy,linq,array_of_bytes_iterator,slice_of_u8_utils. - Async/Tokio (feature
with-tokio):events_loop,background_executor,my_timer,task_completion,is_initialized,idempotency,tokio_queue,queue_to_save,queue_to_save_with_id,application_states,sortable_id. - IO & misc:
file_utils,remote_endpoint,logger,min_value,max_value,min_key_value,placeholders,maybe_short_string.
- Time point + interval keys:
date_time::DateTimeAsMicrosecondsfor UTC timestamps with µs precision.date_time::DateTimeAsMicrosecondsWithTimeZone(+TimeZone) to pair a UTC instant with an offset and render it as local wall-clock time.date_time::interval_key::*for rounding/grouping into year/month/week/day/hour (1h/2h/4h)/minute (1m/5m/15m/30m) buckets.
- High-performance strings:
ShortString(Pascal-style, single-byte length, max 255 bytes on stack) withDisplay,Serialize,Eq, hashing.
MaybeShortStringkeeps data inline asShortStringwhen length ≤ 255 bytes; seamlessly upgrades toStringwhen longer.StringBuilderfor incremental push/format operations.
- Binary payloads:
BinaryPayloadBuilderto append scalars, slices, and length-prefixed data into a singleVec<u8>.Uint32VariableSizefor compact integer encoding/decoding.
- Collections:
SortedVecfamily:SortedVec,SortedVecWithStrKey,SortedVecOfArc,SortedVecOfArcWithStrKey, andSortedVecWith2Keysmaintain order on insert and support efficient lookups.AutoShrinkVec/AutoShrinkVecDequeresize toward steady-state usage.SliceOrVectoggles between borrowed and owned buffers.
- Async/Tokio (enable
with-tokio):MyTimerfor tick-driven tasks,MyExactTimerfor ticks aligned to wall-clock marks,EventsLoopfor fan-out processing,BackgroundExecutorto offload bursty work onto a single background task,TaskCompletionfor awaiting completion handles,IsInitializedas a one-shot initialization gate many tasks can await,IdempotencyCacheto make a retried request execute at most once,TokioQueuefor bounded async queues,QueueToSavefor producer/consumer disk pipelines,ApplicationStatesfor async state transitions.
- File/IO:
file_utils::read_file_lines_iter,array_of_bytes_iterator::FileIterator,remote_endpointhelpers for host/port parsing.
DateTimeAsMicroseconds is a single-field UTC timestamp (unix_microseconds: i64) with serde support (see Serde format) and helpers to add/subtract durations, compare, format (RFC 3339/2822/5322/7231, compact), and convert to chrono::DateTime<Utc>.
Constructors include new(unix_microseconds), now(), create(...), from_str, parse_iso_string, and from_nanos(value: i64) — which converts a Unix nanoseconds timestamp to µs (valid range ~1677–2262).
From<i64> (let dt: DateTimeAsMicroseconds = value.into()) auto-detects the unit of a Unix timestamp by magnitude — seconds, milliseconds, microseconds, or nanoseconds — and normalizes it to microseconds.
The impls are hand-written and deliberately asymmetric. Do not "tidy" them into a symmetric pair, and do not restore #[serde(transparent)].
- Serialize → always RFC 3339, UTC,
Zsuffix, fixed microsecond precision:"2021-04-25T17:30:03.000000Z". Never a number. This is what OpenAPI/JSON Schemaformat: date-timepromises, and what protobuf JSON, the Google API Design Guide, and my-http-utils' schema + client writer all already speak. - Deserialize → tolerant, accepts both:
| JSON input | Read as |
|---|---|
"2021-04-25T17:30:03.000000Z" |
RFC 3339 (the format we now write) |
"2021-04-25T17:30:03+00:00" |
RFC 3339 with a numeric zero offset — what the older to_rfc3339() emits |
"1619371803000000" |
digits in a string → unix timestamp, unit sniffed by magnitude via From<i64> |
1619371803000000 |
number → unix timestamp, unit sniffed by magnitude via From<i64> |
Why asymmetric: changing the write format is not an API break the compiler can catch — it is a break of data already at rest. Across the monorepo DateTimeAsMicroseconds sits in MyNoSql entities, settings files, Service Bus messages, jsonb columns and caches, where history is written as 1704164645000000. A strict reader would make those unreadable, and rolling the deploy back would not heal them. Writing the new format while reading both (a tolerant reader) is the standard format-migration move.
A number carries no unit, so — quoted or bare — it always goes through From<i64>, which sniffs seconds / millis / micros / nanos by magnitude. One rule for numbers everywhere in the crate; deserialize_number_sniffs_the_unit pins it. Data written by the old #[serde(transparent)] impl reads back unchanged, because a real microseconds timestamp (1704164645000000 ≈ 1.7e15) sits in the microseconds band. The sniffing only reinterprets a bare number below ~4.7e9 — the first ~78 minutes after the epoch, where 0 maps to 0 regardless — so no realistic stored timestamp moves.
Deserialization uses deserialize_any, so it needs a self-describing format (JSON — what this monorepo uses). It would not work under bincode/postcard/rmp.
DateTimeAsMicroseconds::from_json_value_str(src: &str) -> Option<Self> takes a raw JSON value token, exactly as a parser hands it over — a quoted string keeps its quotes, a number arrives bare (this is what my-json's JsonValueRef::as_raw_str() returns). Use it from any hand-written deserializer (my-json, my-http-utils) so every reader accepts the same spellings:
DateTimeAsMicroseconds::from_json_value_str("\"2021-04-25T17:30:03.000000Z\""); // RFC 3339, Z
DateTimeAsMicroseconds::from_json_value_str("\"2021-04-25T17:30:03+00:00\""); // older to_rfc3339()
DateTimeAsMicroseconds::from_json_value_str("\"1619371803\""); // quoted number
DateTimeAsMicroseconds::from_json_value_str("1619371803000000"); // bare number
DateTimeAsMicroseconds::from_json_value_str("null"); // -> NoneThe rule is simply: quotes present → strip them; then parse the content. Quotes are packaging, not meaning — "1619371803" and 1619371803 land on the same instant, because either way the digits go to From<i64> for unit sniffing. Single quotes are accepted alongside double ones (matching my-json), escapes are not resolved (no RFC 3339 spelling contains one), and null / empty / garbage give None rather than a panic. If you have already stripped the quotes, from_str is equivalent.
serde does not call from_json_value_str, and cannot: a Deserializer hands the visitor an already-decoded value, having consumed the quotes. Instead both routes bottom out in the same primitives — from_str for a string, From<i64> for a number — and the test serde_and_from_json_value_str_agree pins them together across every spelling, so the two cannot drift.
Display, Debug and serde are three different renderings — don't reach for the wrong one:
let dt = DateTimeAsMicroseconds::parse_iso_string("2021-04-25T17:30:03.000Z").unwrap();
dt.to_string(); // Display -> "1619371803000000" (a number!)
format!("{:?}", dt); // Debug -> "'2021-04-25T17:30:03+00:00'"
serde_json::to_string(&dt).unwrap();// serde -> "\"2021-04-25T17:30:03.000000Z\""to_rfc3339() (chrono's default, renders the zero offset as +00:00) and to_rfc3339_utc() (Z suffix, fixed 6-digit fraction, the serde wire format) are both available; both parse back. Prefer to_rfc3339_utc() when the string gets stored or sorted — its fixed width makes lexicographic order match chronological order.
Note the parsers ignore a non-zero timezone offset: 2024-01-02T03:04:05+03:00 reads as 03:04:05 UTC, not 00:04:05. Z and +00:00 are unaffected.
Interval keys let you cut timestamps to buckets:
- Compile-time typed:
IntervalKey<YearKey | MonthKey | WeekMondayKey | WeekSundayKey | DayKey | HourKey | Hour2Key | Hour4Key | MinuteKey | Minute5Key | Minute15Key | Minute30Key>. - Runtime enum:
DateTimeInterval::{Year, Month, WeekMonday, WeekSunday, Day, Hour, Hour2, Hour4, Minute, Min5, Min15, Min30}. - Each key is encoded as an
i64whose numeric order matches chronological order within a given key type: calendar fields packed as digits (e.g.YYYYMMDDHHmm), with sub-hour/sub-day keys normalized to the slot start. Week keys encode theYYYYMMDDdate of the week start (Monday- or Sunday-based), so a week key shares theDayKeylayout — values of different key types are not mutually comparable. - Conversions are zero-cost wrappers over
i64values; you can go fromDateTimeAsMicrosecondsto an interval key and back.from_i64is unchecked, so pass only a value previously produced for the same key type.
Minimal example:
use rust_extensions::date_time::*;
use rust_extensions::date_time::interval_key::*;
use std::time::Duration;
let now = DateTimeAsMicroseconds::now();
let minute_key: IntervalKey<MinuteKey> = now.into();
let next_minute = minute_key.add(Duration::from_secs(60));
assert!(next_minute.to_i64() >= minute_key.to_i64());DateTimeAsMicroseconds is always UTC. When you need to render a moment back into the local wall-clock time it was captured in, pair it with a TimeZone:
pub struct DateTimeAsMicrosecondsWithTimeZone {
pub date_time: DateTimeAsMicroseconds, // the instant, kept in UTC
pub time_zone: TimeZone, // the offset to render it in
}TimeZone is a fixed offset from UTC stored in minutes — UTC+1 = 60, UTC-5 = -300, UTC+5:45 (Nepal) = 345:
TimeZone::utc()/TimeZone::from_minutes(i32)— construct directly.TimeZone::from_server_and_local_time(server_utc, local)— derive the offset aslocal - server, rounded to the nearest 15 minutes (15-minute steps exist in the wild, so+58m→+60,+50m→+45,+7m→0).localis the same moment as read on the local clock, encoded as a plainDateTimeAsMicroseconds.offset_in_minutes()/offset_in_seconds()/to_fixed_offset()— read it back;Debugrenders as+01:00.
Human-visible renderings (all apply the offset first):
to_local_date_time_struct() -> DateTimeStruct— localyear/month/day/time/dow, correct across midnight and for negative offsets.to_rfc3339()—2021-04-25T18:30:03.000000+01:00(numeric offset, fixed µs precision). This is also the serde wire format: it serializes to that one string and reads it back (a trailingZisUTC+0); a bare number or an offset-less string is rejected rather than silently misread.to_compact_string()—2021-04-25 18:30:03(local time, no zone suffix).
use rust_extensions::date_time::*;
let server = DateTimeAsMicroseconds::parse_iso_string("2021-04-25T17:30:03.000Z").unwrap();
// Client reported its wall clock as 18:30 for the same moment -> derive UTC+1.
let mut local = server;
local.add_minutes(58); // a noisy +58m rounds to +60
let dt = DateTimeAsMicrosecondsWithTimeZone::from_server_and_local_time(server, local);
assert_eq!(60, dt.time_zone.offset_in_minutes());
assert_eq!("2021-04-25 18:30:03", dt.to_compact_string());
assert_eq!("2021-04-25T18:30:03.000000+01:00", dt.to_rfc3339());ShortString: Pascal-style layout (length stored in the first byte) backed by[u8; 256], so the maximum encoded length is 255 bytes. Supports UTF-8 chars, checkedtry_push/try_push_strand panickingpush/push_str, serde, comparison, and case-insensitive helpers. Ideal for small IDs, headers, and keys without heap allocations.MaybeShortString: stores asShortStringwhile length ≤ 255 bytes; automatically promotes toStringonce it would overflow, so you can push without manual branching.StrOrString/SliceOrVecfor zero-copy borrow-or-own patterns.StringBuilder: push bytes/strings/char, drain toStringwithout realloc churn.
Example:
use rust_extensions::ShortString;
let mut s = ShortString::from_str("hi").unwrap();
assert!(s.try_push('-'));
s.push_str("there");
assert_eq!(s.as_str(), "hi-there");BinaryPayloadBuilder: append primitives (u8,u16,u32,u64, slices) and length-prefixed blobs; get the finalVec<u8>or borrowed slice.Uint32VariableSize: encode variable-lengthu32values for compact wire/storage formats.- Optional:
base64andhexmodules expose encode/decode helpers compatible with the rest of the crate.
Example:
use rust_extensions::binary_payload_builder::BinaryPayloadBuilder;
let mut builder = BinaryPayloadBuilder::new();
builder.write_u16(42);
builder.write_slice(b"ping");
let bytes = builder.finish();
assert_eq!(bytes.len(), 2 + 4);SortedVec<T>/SortedVecWith2Keys<K1, K2, V>keep elements ordered; provide binary search insertion and lookup APIs.GroupedDatato collect items by key with minimal allocations.AutoShrinkVec/AutoShrinkVecDequeshrink capacity after spikes.ObjectsPool(featureobjects-pool) for pooling reusable buffers/objects.VecMaybeStack(featurevec-maybe-stack) for small-buffer-optimized vectors.- Iteration helpers:
array_of_bytes_iterator::{SliceIterator, VecIterator, FileIterator},slice_of_u8_utilsfor safe chunking. Lazy<T>for deferred construction guarded byOnceLock-like behavior.
EventsLoop: single-consumer async message loop —sendis lock-free, the consumer runs in a dedicated Tokio task.BackgroundExecutor: offloads work from the caller onto a single background Tokio task —trigger()is lock-free in steady state and runs the registeredexecute()exactly once per call, never in parallel.MyTimer: tick-based scheduling with graceful stop.MyExactTimer: same tick model asMyTimer, but fires exactly on aligned wall-clock marks (:00, :05, :10 …) with no drift.TaskCompletion: create awaitable completion sources with error support.IsInitialized: one-shot initialization gate — any number of tasksawaituntil initialization happens, then every subsequent wait flies through a lock-free atomic flag.IdempotencyCache: de-duplicates retries of the same request — the first caller executes, concurrent retries park on the same execution, later retries get the memorized result.TokioQueue: bounded async queue with backpressure.QueueToSave: producer/consumer file-saving pipeline with retries.QueueToSaveWithId: same producer/consumer batching asQueueToSave, but each item implementsPersistObjectId<ID>. Re-enqueuing an item with an ID already in the queue overwrites the pending entry, so only the latest state per ID is flushed to the handler.IDmust beHash + Eq + Clone; the handler receives aVec<T>per tick. No ordering guarantee across IDs.ApplicationStates: async state machine with callbacks.SortableId: monotonic sortable IDs backed by time + randomness.
#[cfg(feature = "with-tokio")]
async fn example_queue() {
use rust_extensions::tokio_queue::TokioQueue;
let queue = TokioQueue::new(100);
queue.send("item").await.unwrap();
let item = queue.recv().await.unwrap();
assert_eq!(item, "item");
}EventsLoop is designed to live inside an AppCtx as a plain field (no outer Mutex / no mut access needed). The flow:
- Construct in
AppCtx::new— the channel is created immediately, sosendis available right away and is lock-free (no mutex on the hot path). - Register a callback (
EventsLoopTick) viaregister_event_loop— typically during app initialization, once dependencies are wired. - Start — spawns the background reader task which owns the receiver + callback and drives
started/tick/finished. - Send / stop —
send(msg)pushes a message;stop()sends a shutdown signal.
#[cfg(feature = "with-tokio")]
mod example {
use std::sync::Arc;
use rust_extensions::{
events_loop::{EventsLoop, EventsLoopTick},
ApplicationStates, Logger,
};
// 1. Define what each tick should do.
struct MyHandler;
#[async_trait::async_trait]
impl EventsLoopTick<String> for MyHandler {
async fn started(&self) {
println!("loop started");
}
async fn tick(&self, model: String) {
println!("got message: {model}");
}
async fn finished(&self) {
println!("loop finished");
}
}
// 2. Keep it inside AppCtx without any outer Mutex.
pub struct AppCtx {
pub events_loop: EventsLoop<String>,
}
impl AppCtx {
pub fn new() -> Self {
Self {
events_loop: EventsLoop::new("my-loop"),
}
}
}
// 3. Wire up at startup.
pub async fn bootstrap(
ctx: Arc<AppCtx>,
app_states: Arc<dyn ApplicationStates + Send + Sync + 'static>,
logger: Arc<dyn Logger + Send + Sync + 'static>,
) {
ctx.events_loop
.register_event_loop(Arc::new(MyHandler))
.await;
ctx.events_loop.start(app_states, logger).await;
}
// 4. Produce messages from anywhere — `send` takes `&self` and never locks.
pub fn produce(ctx: &AppCtx) {
ctx.events_loop.send("hello".to_string());
}
}For producers that don't need access to the whole EventsLoop (e.g. a background task, an HTTP handler held in its own struct), grab a cheap reference-counted publisher:
use std::sync::Arc;
use rust_extensions::events_loop::EventsLoopPublisher;
let publisher: Arc<EventsLoopPublisher<String>> = ctx.events_loop.get_publisher();
// Move/clone the Arc into other tasks; `send` / `stop` work the same way and stay lock-free.
tokio::spawn({
let publisher = publisher.clone();
async move {
publisher.send("from background task".into());
}
});get_publisher returns Arc<EventsLoopPublisher<TModel>> — every call hands out a clone of the same shared publisher (the Sender is created once in EventsLoop::new).
Key properties:
- Lock-free
send/stop— theSenderlives inside the sharedEventsLoopPublisher;.lock()is only ever taken inregister_event_loopandstart. - One-shot registration — a second
register_event_looppanics;startwithout a prior register panics. - Bounded lifecycle —
stopdeliversShutdownthrough the same channel, so in-flight messages ahead of it are processed first. - Per-tick timeout —
set_iteration_timeout(Duration)caps a singletickcall; overruns are logged via the providedLoggerand the loop keeps running.
BackgroundExecutor offloads work off the caller's thread and finishes it on a single background Tokio task. The caller just signals "there may be work to do" via trigger() and returns immediately; the registered job runs in the background, decides for itself whether anything actually needs doing, and so a trigger() is allowed to fire idly.
A typical example is on-demand persistence: callers mutate state and trigger(); the job locks the shared state, takes whatever is pending, and persists it. If nothing changed since the last run the job locks, sees an empty set, and returns — a cheap no-op.
- Construct —
BackgroundExecutor::new(name); the name is used in panic and log messages. - Register a job (
BackgroundJob) viaregister— one-shot; a second call panics. - Start —
start(logger)moves the registered job into the live state. Callingstartwithout a priorregisterpanics. - Trigger —
trigger()bumps an atomic counter; the first trigger (0 → 1) spawns the reader task, and further triggers while the reader is alive just increment the counter and stay lock-free.
#[cfg(feature = "with-tokio")]
mod example {
use std::sync::Arc;
use rust_extensions::{
background_executor::{BackgroundExecutor, BackgroundJob},
Logger,
};
// 1. Define the work (no payload — the job pulls what it needs itself).
struct FlushJob;
#[async_trait::async_trait]
impl BackgroundJob for FlushJob {
async fn execute(&self) {
// lock shared state, take what is pending, persist it (no-op if nothing changed).
}
}
// Keep it inside AppCtx as a plain field.
pub struct AppCtx {
pub flush: BackgroundExecutor,
}
impl AppCtx {
pub fn new() -> Self {
Self {
flush: BackgroundExecutor::new("flush"),
}
}
}
// 2 + 3. Wire up at startup.
pub fn bootstrap(ctx: &AppCtx, logger: Arc<dyn Logger + Send + Sync + 'static>) {
ctx.flush.register(Arc::new(FlushJob));
ctx.flush.start(logger);
}
// 4. Signal "there may be work" from anywhere — `trigger` takes `&self` and returns at once.
pub fn on_change(ctx: &AppCtx) {
ctx.flush.trigger();
}
}Key properties:
- Offloads the caller —
trigger()returns immediately; the job runs on a dedicated background task, so the calling thread is never blocked on the work. - Single consumer — at most one reader task is alive, so
execute()never runs concurrently with itself. - Idle triggers are fine —
execute()runs once pertrigger()and is expected to be cheap when there is nothing to do; the reader drains the counter back to zero before exiting. - Lock-free hot path —
trigger()only takes a lock on the 0 → 1 transition (to spawn the reader); subsequent triggers are a single atomic add. - Panic-safe — a panicking
execute()is caught, logged via the providedLogger, and the loop keeps draining. - One-shot lifecycle — a second
registerpanics,startwithout a priorregisterpanics, andtriggerbeforestartpanics.
IsInitialized is a one-shot initialization gate. Any number of tasks can await wait_until_initialized, and they all stay parked until initialization happens exactly once. It is designed to live inside an AppCtx as a plain field — all methods take &self, no outer Mutex needed.
The flow:
- Construct —
IsInitialized::new()(orDefault::default()); starts un-initialized. - Wait — callers
wait_until_initialized().await. While un-initialized, each subscribes (aTaskCompletionis parked in an internalVec); once initialized, the call returns instantly via anAtomicBoolfast path without touching the mutex. - Initialize —
initialized().awaitraises the flag and completes every parked awaiter, releasing them all at once. - Guard —
panic_if_not_initialized()panics with"Not Initialized"if called before initialization;wait_some_time_and_panic(duration)awaits initialization for at mostdurationand panics with"Not Initialized"if it does not arrive in time;is_initialized()returns the flag.
#[cfg(feature = "with-tokio")]
mod example {
use std::sync::Arc;
use rust_extensions::IsInitialized;
// Keep it inside AppCtx as a plain field.
pub struct AppCtx {
pub is_initialized: IsInitialized,
}
impl AppCtx {
pub fn new() -> Self {
Self {
is_initialized: IsInitialized::new(),
}
}
}
// Any number of tasks can park here until initialization happens.
pub async fn handle_request(ctx: Arc<AppCtx>) {
ctx.is_initialized.wait_until_initialized().await;
// ... proceed, the app is ready ...
}
// Called once, when bootstrap finished wiring everything up.
pub async fn on_bootstrap_finished(ctx: &AppCtx) {
ctx.is_initialized.initialized().await;
}
}Key properties:
- Lock-free fast path — once initialized,
wait_until_initializedonly reads anAtomicBooland returns; thetokio::Mutexis entered only while still un-initialized. - No lost wake-ups — the slow path re-checks the flag after taking the mutex, and
initializedraises the flag and drains the waiterVecunder that same mutex, so a caller either observes the flag or is guaranteed to be completed. - Idempotent
initialized— calling it again is a no-op (the waiterVecis already drained). - Cancel-safe — if a waiter's future is dropped before completion,
initializedskips the dead subscription without panicking.
IdempotencyCache makes a retried request execute at most once. A request is identified by an idempotency key (a String — typically the client's request id); the actual work lives behind the IdempotencyExecution trait. For a given key:
- first call — runs
IdempotencyExecution::executeinline and memorizes itsResult; - a retry that arrives while the first call is still running — executes nothing: it parks on a
TaskCompletionand is released with the very same result; - a retry that arrives after it finished — gets the memorized result immediately, the execution is not touched.
Like EventsLoop and BackgroundExecutor, it is designed to live inside an AppCtx as a plain field (all methods take &self), and the execution is registered separately so it is free to hold an Arc of the AppCtx that owns the cache.
#[cfg(feature = "with-tokio")]
mod example {
use std::sync::Arc;
use std::time::Duration;
use rust_extensions::{IdempotencyCache, IdempotencyExecution, DEFAULT_MAX_AMOUNT};
pub struct ChargeParams {
pub client_id: String,
pub amount: f64,
}
// 1. Define the work that must not happen twice.
struct ChargeExecution;
#[async_trait::async_trait]
impl IdempotencyExecution<ChargeParams, String, String> for ChargeExecution {
async fn execute(&self, params: ChargeParams) -> Result<String, String> {
// Runs exactly once per idempotency key.
Ok(format!("charged {} for {}", params.amount, params.client_id))
}
}
// 2. Keep it inside AppCtx as a plain field.
pub struct AppCtx {
pub charges: IdempotencyCache<ChargeParams, String, String>,
}
impl AppCtx {
pub fn new() -> Self {
Self {
charges: IdempotencyCache::new_with_max_amount("charges", DEFAULT_MAX_AMOUNT)
.set_execution_timeout(Duration::from_secs(5)),
}
}
}
// 3. Wire up at startup.
pub fn bootstrap(ctx: &AppCtx) {
ctx.charges.register_execution(Arc::new(ChargeExecution));
}
// 4. Handle a request — retrying it with the same key never charges twice.
pub async fn handle_request(ctx: &AppCtx, request_id: String, params: ChargeParams) {
match ctx.charges.execute(request_id, params).await {
Ok(receipt) => println!("{}", receipt.as_str()),
Err(err) => println!("failed: {}", err.as_str()),
}
}
}Key properties:
- At most one execution per key — concurrent retries park on the first one instead of starting their own; only the caller that actually executes consumes its
params, the others simply drop theirs. - Errors are memorized too — once a key produced an answer, every retry of that key gets that answer back,
OkorErralike. There is no "retry the failure for free": a genuinely new attempt needs a new key. - Shared as
Arc— the result is handed out asResult<Arc<TOk>, Arc<TErr>>(IdempotencyResult), so serving N retries costs N atomic increments, and neitherTOknorTErrhas to beClone. - Last N, FIFO — the last
max_amountresults are kept (newusesDEFAULT_MAX_AMOUNT= 1000,new_with_max_amountsets it), evicted oldest-completed-first; a cache hit does not refresh an entry.max_amount == 0is legal and means "de-duplicate concurrent retries, remember nothing afterwards". - One flat queue, no index — the whole state is a single
VecDeque: push new keys to the back, drop the oldest results from the front, look up by linear scan. There is no second structure that could drift out of sync with it. Eviction steps over in-flight entries rather than dropping the front blindly — anExecutingentry has awaiters parked on it — somax_amountcaps the memorized answers and in-flight executions sit on top of that. The linear scan is the right shape at these sizes, not for amax_amountin the hundreds of thousands. - Cancel-safe by design, loud about it — the first caller owns the execution, so if its future is dropped (HTTP timeout) or the execution panics, a drop-guard removes the entry — the next retry executes from scratch — and everybody parked on it gets the standard
TaskCompletiondrop behaviour: theirget_result()panics with"Task is dropped". Nothing is memorized in that case, because we do not know whether the side effect happened. - Bounded execution —
executeis wrapped in a timeout (DEFAULT_EXECUTION_TIMEOUT= 5s, builderset_execution_timeout). Overrunning it is simply the third way to not produce a result, so it is handled as a panic like the other two. Without it a hung execution would pin its key forever and every retry of that key would park forever, since anExecutingentry is never evicted. Needs a Tokio runtime with time enabled. - No lock held across
.await— aparking_lot::Mutexguards the map (get/insert/removeonly); the execution and every completion happen outside it.parking_lotis also what makes the synchronous cancellation drop-guard possible. - One-shot registration — a second
register_executionpanics, andexecutebefore registration panics (before it claims the key, so no entry is leaked). The registered handler lives in aOnceLock, so reading it on everyexecuteis a single atomic load that hands back a reference — the hot path never touches theArcrefcount.
MyExactTimer is the drift-free sibling of MyTimer. Where MyTimer sleeps interval between ticks (so ticks slowly drift and a slow tick pushes every later one back), MyExactTimer fires exactly on the aligned wall-clock marks of a fixed ExactTimerInterval — e.g. Every5Seconds fires at seconds :00, :05, :10, … :55, and Every5Minutes at minutes :00, :05, … :55.
It reuses the exact same MyTimerTick trait, so an existing tick can be registered on either timer — you only swap which timer you register it on.
use std::sync::Arc;
use rust_extensions::{MyExactTimer, ExactTimerInterval, MyTimerTick};
struct MyTick;
#[async_trait::async_trait]
impl MyTimerTick for MyTick {
async fn tick(&self) {
// runs at :00, :05, :10 … of every minute
}
}
pub fn bootstrap(
app_states: Arc<dyn rust_extensions::ApplicationStates + Send + Sync + 'static>,
logger: Arc<dyn rust_extensions::Logger + Send + Sync + 'static>,
) {
let mut timer = MyExactTimer::new(ExactTimerInterval::Every5Seconds);
timer.register_timer("my-tick", Arc::new(MyTick));
timer.start(app_states, logger);
}Available intervals: Every1Second, Every5Seconds, Every10Seconds, Every15Seconds, Every20Seconds, Every30Seconds, Every1Minute, Every5Minutes, Every10Minutes, Every15Minutes, Every20Minutes, Every30Minutes.
How it stays exact:
- Epoch-aligned marks — the next fire time is the next multiple of the interval since the Unix epoch. Because the epoch sits on a minute/hour boundary and every interval evenly divides a minute or an hour, those multiples land precisely on the natural wall-clock marks. No accumulated drift.
- Recomputed after every tick — the next mark is computed from the moment the tick finished, so a slow tick simply skips to the next mark instead of pushing the whole schedule back. Finishing exactly on a mark advances to the following one (never a double fire).
- Coarse-to-fine wait — the timer approaches the mark by sleeping in shrinking chunks (
10s → 5s → 1s), re-measuring each loop; once under one second remains it does a single exact sleep and wakes right on the mark. A long interval therefore still noticesis_shutting_down()within at most 10 seconds. - Same lifecycle as
MyTimer— waits foris_initialized()before the first tick, stops onis_shutting_down(), supports multiple registered ticks (fired together on each mark), a per-iteration timeout (new_with_execute_timeout/set_iteration_timeout, default 60s), and panic-catching that logs via the providedLogger.
file_utils: iterators over file lines and path helpers.logger: simple structured logger traits.remote_endpoint: parse/format endpoints (host:port), detect loopback.- Math/min-max helpers:
min_value,max_value,min_key_value,max_value. StopWatch/AtomicStopWatch/AtomicDurationfor timing.
- License: MIT (see
LICENSE.md). - Current crate version: 0.1.5.
- Contributions are welcome via pull requests.