Asynchronous batches#
Batches are the engine’s asynchronous submission API (TENT F08/F10). They work for every transport. This page also covers the opt-in admission controls that sit in front of all data submissions: the runtime queue, QoS contracts and receiver credits.
Ownership and API#
Arc<TransferEngine>::allocate_batch(capacity) reserves descriptor slots and an
engine-scoped, increasing BatchId. Capacity is positive and at most
max_batch_requests. max_allocated_batches and max_allocated_requests bound
all live reservations, including finished batches not yet freed and dropped
handles whose workers are still running.
Batch::submit(operation, peer, requests, notification, timeout) appends a group
and returns its stable task range. Addresses, permissions, bytes and source
authority are validated before enqueue; a rejected group adds no tasks, and
accepted groups are never replayed or rolled back. Groups in one batch run FIFO,
with at most one worker per batch; different batches progress concurrently.
A small host-file group can complete on its submitting caller when a CQE is
already ready. Pending I/O moves to that same batch’s worker with its ring,
loans and charges intact; see file submission.
On a file target, SQEs within the group complete independently. Each task
reports its own bytes, including a short prefix. See file submission.
seal() stops appends. status(), task_status(), task_statuses() and
submissions() expose published state. A status poll can also decide a pending transport retry as described below. Before seal the overall state is
Open; after seal it becomes Completed, Failed or Cancelled once every
group exits, and terminal states never change. wait(timeout) requires seal;
its timeout neither cancels nor releases work. Each submission’s deadline starts
at submit and covers validation, queueing, transfer and the attached
notification.
By default, an explicit free() on a busy batch returns BatchBusy and keeps the
handle valid. Setting EngineConfig::enable_progress_worker also opts in to
deferred free. A busy free then fences the ID and returns immediately. Dropping
a handle in either mode detaches accepted work. Actual registrations, staging
chunks, file loans and admission stay with their owners until physical drain.
EngineStats::deferred_batches counts retired IDs waiting for that proof.
quarantined_batches counts retired IDs without a safe reclaim path. Both
consume allocation capacity. submit_async allocates, submits one group and
seals it. See the async example.
Physical owners and public tasks#
Every public request keeps its task ID and length. task_status(i).physical_owner
is the absolute public ID of the first task carried by that physical transfer.
All aliases see the same running, cancellation and terminal state. Each reports
only bytes within its own range. Cancelling any alias addresses the owner.
Cancelling one memory owner leaves independent successors eligible. A queued
cancellation removes the owner before payload I/O. A running RDMA
owner cancels unposted slices and drains every accepted post before retiring
its backing.
Memory requests merge by default. Both addresses must be adjacent inside the
same local and remote registrations, with matching hint, resolved route, QoS,
priority, staging mode, intent, named policy and deadline. Both peers’ transfer
size limits apply. Shuffled slices can share an owner. The payload starts at the
lowest address and the owner executes at its first public position. Overlapping
destinations and self-transfer read/write dependencies fence reordering.
CUDA transfers also keep their position across a change of payload path. Set
EngineConfig::merge_requests to false for one physical transfer per request.
File segments have no remote registered-buffer boundary in TENT and retain
independent physical requests.
A submission report counts the successful public prefix. Its failure span includes later aliases whose owner ran before that failure. Read individual task results to distinguish confirmed bytes, failures and skipped neighbours. An attached notification covers the original public interval and fires only after all of its owners succeed.
Transport attempts#
A physical owner keeps its original epoch, registration, deadline and permits
across attempts. Its public aliases report the same transport and
failover_count. EngineConfig::max_failover_attempts defaults to three
additional attempts. Zero pins the first route. A clean submit rejection can
advance directly to the next eligible candidate in the original policy. A
failed proxy session first tries the same route without remote staging.
Completion-stage retry requires a backend release receipt. A READ can retry
after its last possible write to the destination has drained. A WRITE that
might have posted reports RemoteEffect::Unknown. An expired deadline,
accepted cancel or uncertain teardown ends retry. A short file completion
keeps its own byte prefix. A vectored file READ can retry only before it
exposes its first byte. A vectored WRITE has one attempt. Within a native
file batch, only eligible failed
owners can retry after the group drains. If unregister closes a cuFile
registration during preparation, an eligible io_uring route keeps its file lease.
enable_auto_failover_on_poll defaults to true. A task, vector or overall
status poll can advance one drained failure. With this setting disabled, a poll
that observes the failure makes it terminal. Explicit progress_batch,
wait and synchronous transfer can advance an unobserved failure. The batch
worker executes the authorized attempt with its original budget. Cancellation
through any alias fences that owner before the next handoff.
Explicit progress#
engine.progress_batch(id)? dispatches at most one FIFO group or notification
retry, or publishes one completion. It returns the batch status. The caller
can also probe a retired native submission for a late release receipt. The
existing per-batch executor owns the blocking socket, native calls and copies.
Independent batch IDs use separate locks. A freed or foreign ID returns
BatchClosed. Ordinary observation does no backend I/O. A pending failover is decided under the batch lock.
The optional progress worker coalesces wakes by batch ID and shares a single
runtime-queue wake. The wake set fits max_allocated_batches. Enabling the
runtime queue starts this worker too, while keeping busy-on-free unless
enable_progress_worker was requested. The default fallback interval is
50 ms. It applies only while the runtime queue owns work. Set
progress_fallback_interval to zero for event-only progress. Without either
option, the batch executor continues to make progress autonomously.
For caller-driven polling, wait_before_next_poll(count) yields for polls 0
through 127. Later delays start at 1 µs and double up to 200 µs.
next_poll_delay(count) returns the duration without sleeping. Increment the
count with saturating arithmetic.
Completion notifications#
submit_with_notification_targets attaches one message to a memory submission
and accepts a list of destination segments. The batch worker tries self first
and sends to each distinct destination after payload success. A later explicit
progress turn can retry a definitely unposted destination until the original
deadline. Delivered destinations are removed. An invalid request or uncertain
receipt ends attempts for that destination. Inspect submissions().notification
for the outcome independently of the data status. Pending hooks share the
engine’s outbound target and message-byte limits, sized by
max_pending_notifications and max_pending_notification_bytes. A retry holds
requester admission through its final attempt, deadline or free. Free closes
retry admission. TCP and RPC notifications describes
fallback and acknowledgements.
Runtime admission and dispatch window#
EngineConfig::runtime_queue defaults to None. Enable it to limit dispatch
independently of the retained-owner budgets:
use orbite::{EngineConfig, RuntimeQueueConfig, TransferEngine};
let engine = TransferEngine::new({
let mut config = EngineConfig::default();
config.max_inflight_batches = 128;
config.max_inflight_bytes = 256 << 20;
config.runtime_queue = Some({
let mut config = RuntimeQueueConfig::default();
config.max_dispatch_owners = 8;
config.max_dispatch_bytes = 32 << 20;
config.deadline_aware = true;
config
});
config
})?;
# Ok::<(), orbite::Error>(())
The queue admits all physical owners of a submit together. Defaults allow
1,024 outstanding owners and 1 GiB, with 64 owners and 64 MiB in the dispatch
window. Public task capacity is checked separately. A single owner larger
than the dispatch window returns ResourceExhausted before any task is
published. Notification bytes count once on the first owner. Requester-wide
max_inflight_batches still bounds submissions.
staging_owner_reserve and staging_byte_reserve default to zero. Ordinary
admission can consume only the total minus each reserve. Internal staging can
use available capacity up to both totals. OrbiTE reserves once for the
validated ProxyManager session that runs PRE/CROSS/POST. Its child chunks
share that charge. A public staging intent alone cannot spend the reserve.
FIFO orders eligible owners by admission. deadline_aware selects EDF with
FIFO ties. A submission’s eligible prefix can fill the window. A blocked owner
keeps its successors from taking the resources it needs, and a later append
waits for the earlier submit to drain. Independent batches can progress.
with_deadline(instant) may shorten an owner’s absolute submission deadline.
promotion_slack_ns promotes future deadlines strictly inside that slack
and preserves EDF within the promoted set. The default, zero, disables promotion.
With EDF and a positive mlu_local_threshold, dispatch can cancel an owner
whose predicted transfer time divided by its remaining time reaches the
threshold. The estimate includes eligible payload bytes already dispatched
on that transport plus the owner’s payload. A known RDMA rate and an expired
deadline yield an infeasible prediction. TCP, NVLink, files and proxy staging
currently have no independent bandwidth estimate. An unknown rate leaves the
owner eligible for ordinary dispatch. Ordinary execution timeouts still apply.
RDMA rail cooldown gates new slices within the compiled device mask. A masked owner with no eligible pair receives a typed unavailable/policy error. A paused or half-open rail retains in-flight native work until its real receipt. A timeout or an accepted cancel cannot make such an owner reusable. See rail health.
A drop removes queue accounting before I/O, publishes Error::DeadlineInfeasible
to its aliases and increments RuntimeQueueStats::deadline_infeasible_drops
once. Other eligible owners can run, including later tasks in the same submit.
A later WRITE in a failed report’s span can therefore have an unknown effect,
including when an earlier owner was cancelled.
The engine exposes this decision through status and stats. It installs no
local-decoding or compression callback. See
RDMA deadline prediction
and the verification controls.
QoS contracts#
EngineConfig::qos defaults to None. Some(QosConfig) enables version-1
contracts in specificity order: global defaults, tenant defaults, then the
tenant’s intent override. Each field overrides independently; None inherits,
an effective None means no cap, and Some(0) is a hard zero. Blank identities
resolve to default/unspec. Names are trimmed and lowercased, at most 256
bytes and unique; at most 4096 contracts compile.
use orbite::{
EngineConfig, Priority, QosConfig, QosPolicyFields, QosRequestContext, QosTenantContract,
TransferEngine,
};
let requester = TransferEngine::new({
let mut config = EngineConfig::default();
config.qos = Some({
let mut config = QosConfig::default();
config.defaults = {
let mut config = QosPolicyFields::default();
config.max_inflight_bytes = Some(64 << 20);
config.max_inflight_requests = Some(128);
config
};
config.tenants = vec![{
let mut config = QosTenantContract::default();
config.name = "tenant-a".into();
config.intents = [("foreground_get".into(), {
let mut config = QosPolicyFields::default();
config.priority = Some(Priority::High);
config.max_inflight_bytes = Some(128 << 20);
config
})]
.into();
config
}];
config
});
config
})?;
let policy = requester.resolve_qos(&{
let mut config = QosRequestContext::default();
config.tenant_id = "tenant-a".into();
config.intent = "foreground_get".into();
config.requested_priority = Priority::Low;
config
})?;
assert_eq!(policy.effective_priority(), Priority::High);
// Use request.with_qos(policy.clone()) for repeated submissions.
# Ok::<(), orbite::Error>(())
EffectiveQosPolicy is an immutable, engine-bound handle; attach it with
request.with_qos(policy). Its priority replaces the request’s own. A request
without a handle uses the precompiled default/unspec contract, so it cannot
bypass a configured default budget. A handle from another engine returns
ForeignQosPolicy. The effective priority feeds
transport selection and RDMA dispatch; TCP, HP-TCP and NVLink
treat it as advisory. Caps apply to every data transport.
Each resolved contract has one budget: an unknown tenant shares the global
default, an unknown intent shares its tenant’s default, and an explicit intent
has its own bucket. Unknown names cannot create new counters, and an intent
budget is not also charged to its tenant default. max_inflight_bytes counts
physical payload bytes and max_inflight_requests counts physical owners. In the default
reject mode, a group over its cap returns ResourceExhausted before any charge.
Accepted work keeps its charge until its prepared owner is actually released;
Unquiesced keeps it. Identities are application-supplied, not authentication.
policy.fields(), matched_contract() and effective_priority() explain
resolution, and policy.usage() reports current owners, bytes and requests.
Submission uses numeric slots with no per-slice lookup. TENT’s
QosContractResolver only resolves policy (diagnostic_scope=resolution_only);
OrbiTE also enforces priority and caps. QoS actions fallback_transport,
local_recompute and compress fail configuration. Each matched contract
has its own local cap. See
qualification.
QoS admission actions#
allowed_degraded_actions overlays like other fields (an explicit list, even
empty, replaces the inherited one). Typed Rust uses QosDegradedActions with
delay and reject flags; JSON uses an array of names.
Effective actions |
On contract quota pressure |
|---|---|
Absent, |
Return |
|
Wait until capacity frees or the original deadline passes |
Any, with a physical owner larger than its cap |
Reject during preparation |
Delay requires EngineConfig::runtime_queue; otherwise engine creation returns
InvalidConfig:
use orbite::{EngineConfig, QosConfig, RuntimeQueueConfig, TransferEngine};
let engine = TransferEngine::new({
let mut config = EngineConfig::default();
config.runtime_queue = Some(RuntimeQueueConfig::default());
config.qos = QosConfig::from_json(
r#"{"qos":{"defaults":{
"max_inflight_bytes":"64MiB", "max_inflight_requests":128,
"allowed_degraded_actions":["delay","reject"]
}}}"#,
)?;
config
})?;
# Ok::<(), orbite::Error>(())
When delay is enabled anywhere, every capped contract acquires capacity at its batch head, together with receiver credit and the dispatch window, with no partial charge while waiting; independent batches can pass a blocked head. If a reject-only slot fills after enqueue, that owner fails at dispatch with no remote effect and its later tasks are skipped. Waiting never extends the deadline. TENT parses these action names but does not execute them; this behavior is OrbiTE’s extension. See qualification.
JSON QoS configuration#
QosConfig::from_json(&str) and QosConfig::from_json_file(path) read the qos
section of an OrbiTE JSON object and return Result<Option<QosConfig>>
for EngineConfig::qos. Unknown wrapper keys are errors; use
EngineConfig::from_json for a complete engine document.
{
"qos": {
"version": 1,
"defaults": {
"priority": "medium",
"max_inflight_bytes": "64MiB",
"max_inflight_requests": 128
},
"tenants": [
{
"name": "tenant-a",
"defaults": {"max_inflight_bytes": "128MiB"},
"intents": {
"foreground_get": {"priority": "high"},
"background_prefetch": {"priority": "low", "max_inflight_requests": 16}
}
}
]
}
}
use orbite::{EngineConfig, QosConfig, TransferEngine};
let engine = TransferEngine::new({
let mut config = EngineConfig::default();
config.qos = QosConfig::from_json_file("transfer-engine.json")?;
config
})?;
# Ok::<(), orbite::Error>(())
Field |
Accepted values |
|---|---|
|
0/1/2, or |
|
Integer, or integer string with B, KiB, MiB, GiB or TiB (KB/MB/GB/TB also multiply by 1024, as in TENT) |
|
Nonnegative integer |
|
Array of |
Only version 1 is accepted. Missing or null qos, {} and a version-only object
return None, matching TENT’s disabled resolver. Duplicate or unknown fields,
duplicate normalized names and unsupported actions (fallback_transport,
local_recompute, compress) are errors reported as Error::InvalidQosConfig.
Input is limited to 1 MiB and parsed once before engine creation. See
qualification.
Receiver credit gating#
EngineConfig::max_receiver_credit_sessions defaults to zero (disabled). A
positive value (at most 4096) requires every nonempty outgoing data submission
to hold a credit binding for its peer endpoint and source epoch; a missing
binding fails during preparation. READ and WRITE both consume grants. Credits
authorize requests; they do not prove remote buffer capacity or limit rate.
In manual mode, call activate_receiver_credit(peer, epoch, qos_class) and
deliver ReceiverCreditUpdateV1 through apply_receiver_credit (a local typed
API, not a TENT wire message). Totals are cumulative, not increments:
Charge |
Meaning |
|---|---|
|
Sum of payload lengths, excluding notification bytes |
|
One per physical owner |
|
Manual grants are observable by tag. On an automatic binding the receiver charges the actual Pin staging and session owners separately. |
Decreasing totals or repeated resources reject the update; old or duplicate sequences are ignored. A credit-starved head waits without reserving the dispatch window, so other peers proceed. Credit is reserved with dispatch eligibility, refunded if the owner is cancelled before its transport handoff, and fully committed at that handoff, even if the transfer later fails. Completion does not return credit; the issuer grants new cumulative totals.
freshness_ttl_ms gates new reservations only. deactivate_receiver_credit
fences new and uncommitted work and returns BatchBusy while a permit is live
or a committed charge has no completion receipt. A lost WRITE acknowledgement
keeps that epoch fenced even after local drain. A fresh epoch cannot replace
it. ReceiverCreditStats::resource(tag) exposes each grant, consumption
and currently available balance. TENT’s
SenderCreditLedger provides this accounting model but is not wired into its
admission path; OrbiTE consumes it per physical owner. See
qualification.
Automatic receiver windows#
Configure EngineConfig::receiver_credit = Some(ReceiverCreditConfig) on the
receiving peer and max_receiver_credit_sessions on the requester, then call
connect_receiver_credit(peer, qos_class, timeout) once. All transports then
share that credit owner with no manual update loop:
use orbite::{EngineConfig, ReceiverCreditConfig, TransferEngine};
use std::time::Duration;
let source = TransferEngine::new({
let mut config = EngineConfig::default();
config.receiver_credit = Some({
let mut config = ReceiverCreditConfig::default();
config.max_sessions = 128;
config.max_bytes = 256 << 20;
config.max_requests = 1024;
config.bytes_per_sender = 64 << 20;
config.requests_per_sender = 256;
config
});
config
})?;
let requester = TransferEngine::new({
let mut config = EngineConfig::default();
config.max_receiver_credit_sessions = 32;
config
})?;
let peer = requester.open_segment(
source.endpoint(),
source.source_epoch(),
Duration::from_secs(3),
)?;
requester.connect_receiver_credit(&peer, 0, Duration::from_secs(3))?;
// Register memory and submit through the ordinary APIs.
// After every accepted data owner has completed and dropped:
requester.disconnect_receiver_credit(&peer, Duration::from_secs(3))?;
# Ok::<(), orbite::Error>(())
The receiver reserves a fixed byte/request window per sender engine;
max_bytes and max_requests bound the sum, and each physical owner must fit its
window. Defaults are 128 sessions, 64 MiB / 1024 aggregate and 1 MiB / 16
requests per sender. A receiver epoch can have only one endpoint binding per
requester, and a lost connect reply is recovered by retrying the same peer.
Grants are window + completed_total. The sender reports only successfully
completed owners, published when the physical permit drops. Failed, cancelled
or uncertain owners earn nothing. When the next owner lacks credit, its
existing worker performs one cumulative sync, shared by concurrent waiters, with
no background thread or per-slice RPC. disconnect_receiver_credit returns
BatchBusy until every committed owner has completed, then returns the window
and leaves a tombstone. Sender crashes are not reclaimed automatically.
receiver_credit_issuer_stats() reports sender and window counts.
TENT has no receiver issuer or network consumer; the CreditSync and
CreditClose frames and this issuer are OrbiTE extensions. See
qualification.
Staging and consumer owner credits#
With --features cuda, a source can also set max_staging_slots,
max_consumer_slots, staging_slots_per_sender and consumer_slots_per_sender
(all default to zero, disabled). Connecting a sender then reserves these windows
too.
Resource |
Source owner |
Returned when |
|---|---|---|
|
One negotiated remote Pin ring slot |
Its lease returns after CUDA and private transport cleanup |
|
One active remote Pin session |
Every child slot has returned and the session’s owners have finished |
The source acquires the negotiated depth plus one consumer atomically before
construction, and rejects a Pin with a missing, stale or closing binding before
allocation. Uncertain cleanup keeps both charges. These credits authorize remote
Pin owners; the shared staging pool can still return ResourceExhausted
independently. TENT defines these resource tags but does not connect them to
admission. See qualification.
Results, cancellation and quiescence#
Group reports count the fully successful prefix in request order and sum only
its bytes. request_index names the first unsuccessful request.
affected_requests covers the consecutive span that may have executed,
including later file SQEs submitted concurrently. Each file task retains its
own result. A short first READ can report three bytes and the next task can
complete, even when the group reports zero completed requests and zero bytes.
A failed remote staging request can show completed READ chunks while reporting
zero completed bytes. Each group’s NotificationStatus is separate.
cancel(task) and cancel_all() stop work that has not started. Running ordinary TCP, NVLink and remote staging return Error::Unavailable for a cancel. HP-TCP cancellation closes the active frame and joins every lane slice. A WRITE with a missing ACK retains an unknown remote effect. Independently dispatched HP-TCP tasks keep their own results.
RDMA wakes its posting workers. Slices already posted can still move bytes and
complete normally. File operations keep their backend cancellation and drain
rules. cancel_all() still attempts each queued owner if a running one cannot
cancel.
File requests wait for missing CQEs until 30 seconds after the submission
deadline. Expiry returns Error::Unquiesced and keeps unresolved owners and
admission charges retained.
locally_drained means the requester’s access to its backing has ended. It does
not prove remote quiescence: a lost WRITE acknowledgement leaves
RemoteEffect::Unknown, forbids replay and still requires source-side drain
before remote memory is reused.
close_requester(timeout) permanently rejects new outgoing work and waits for
accepted work; call it again after a timeout. close_source gates source
registration and handlers separately. Whole-engine close(timeout) fences both
directions, waits against one deadline and then closes shared RDMA resources; see
the device owner contract.
Mixed GPU direct and staged submissions#
One submission can alternate GPU Direct RDMA requests and GPU-over-TCP staging
requests, executed in input order. Address-adjacent GPU ranges on either side
of a staged request remain separate owners. Within the staged leg, adjacent
compatible ranges can still share one physical owner. Its aliases observe the
same completion or failure and keep their individual byte counts. Each RDMA operation releases its range loan
before the next CUDA copy, and each TCP copy drains its event before the next RDMA
request. RDMA readiness is checked once per submission as a preflight; actual
dispatch rechecks context, connection and quota. A failed copy keeps the earlier
completed prefix and skips later tasks; uncertain completion keeps the GPU
backing, pinned chunk and admission with locally_drained = false.
Per-request remote staging can join the same
batch. TENT’s commitPreparedSubmit gives each physical owner a transport and
staging plan; OrbiTE keeps that split with ordered batch execution. See
qualification.
TENT correspondence and limits#
TENT’s
TransferEngineImpl
allocates batch capacity, appends tasks and defers freeing pending batches.
OrbiTE uses one lock per batch and keeps the caller’s status reads observational.
TENT can drive hooks and queue progress while reading status. Here the existing
batch executor publishes transport receipts and explicit progress can dispatch a
pending hook. Seal fixes the terminal task set. When free or an abandoned stage
cannot confirm completion, its physical owners stay charged. The free-list sweep
continues past a stuck ID. Per-batch reclaim failures on a recoverable cuFile
operation are capped at 4,096 consecutive errors. A pending reply resets the
count. Uncertain destruction is parked immediately. The shared CPU trace
compares async allocate, append, status and free. Transport failover remains open.