Object placement and locality-aware task API
Status: Phase C object creation, typed calls, leases, roots, weak upgrades, transfer, resolver routing, and colocation implemented; Phase D manual migration implemented; Phase E object-aware algorithms remain future work
Tracking: hataori-rs#14
Parent design: distributed-runtime.md
1. Purpose
Hataori must support keeping a remote object in memory on a selected locality and sending work to that object instead of repeatedly transferring its state. It must also support using an object’s current location as a task-placement preference.
This document specifies the high-level API and its lowering contract. It uses logical LocalityId and DomainId values; MPI ranks and TCP endpoints remain transport-backend details.
“Long-lived” means retained for part or all of one running Hataori runtime. It does not mean durable storage across process or runtime restart. Disk persistence, replication, and recovery from loss of the owning process are not part of the initial API.
2. Required semantics
The API distinguishes three operations:
- create an object at a required initial
Place; - invoke an object action at the object’s authoritative current location;
- schedule an independent task using an object location as a hard or soft placement constraint.
Placement preference is affinity, not execution priority. A preferred task is offered to the selected locality/domain before its fallback is considered. It does not jump ahead of already-admitted work or bypass queue/admission limits.
3. Places and placement policies
A place is transport-independent:
pub struct Place {
locality: LocalityId,
domain: DomainId,
}The target policy is conceptually:
pub enum PlacementTarget {
Place(Place),
Colocated(ObjectId),
}
pub enum PlacementFallback {
Any,
Place(Place),
Reject,
}
pub enum TaskPlacement {
Any,
Require(PlacementTarget),
Prefer {
target: PlacementTarget,
fallback: PlacementFallback,
},
}The exact enum/builder spelling remains an implementation decision. The following semantics are fixed:
Anylets the bounded scheduler select an eligible domain.Require(Place)must execute at that exact locality/domain or return a typed placement error.Require(Colocated(object))follows the object’s authoritative location and executes there. Migration races redirect and retry within the call deadline.Prefertries the target under a bounded preference/admission budget, then applies its explicit fallback. It never waits indefinitely for affinity.Rejectturns inability to satisfy the preference into a typed error.
There is no initial multi-object constraint solver, topology cost model, data replication planner, or automatic global rebalance. When a task depends on several objects, the caller selects one primary affinity or expresses the work as an action on the object that owns the primary mutable state.
4. Creating and retaining an object
Illustrative target syntax follows. It is design syntax, not a currently compilable example:
let locality = runtime.localities().get(LocalityId::new(2))?;
let place = locality.place(DomainId::DEFAULT)?;
let cache: Remote<ShardCache> = runtime
.objects()
.create(ShardCache::new(shard))
.at(place)
.mobility(Mobility::Pinned)
.await?;Creation at a remote place is one registered object-factory action. The initial logical state is encoded once and restored into a prepared object slot at the destination. Same-locality creation moves the state directly and performs no serialization or transport operation.
The returned Remote<T> owns a locality lease. Local clones share that lease. As long as a strong handle, runtime-owned root, in-flight call, transfer pin, or migration pin exists, the object is not collectible.
An explicit runtime-owned root retains an object even when no client handle is kept:
let (cache, root): (Remote<ShardCache>, ObjectRoot<ShardCache>) = runtime
.objects()
.create(ShardCache::new(shard))
.at(place)
.mobility(Mobility::Pinned)
.rooted()
.await?;ObjectRoot<T> is an RAII token backed by directory durable-root accounting. Dropping or explicitly releasing it removes that root. It is not freely serializable and is not an immortal global registry entry. Runtime shutdown releases runtime-owned roots and reports any unexpected retained object.
Mobility::Pinned means the object remains at the required place for its lifetime. Mobility::Migratable and Mobility::Reconstructible treat .at() as the initial location; later explicit migration may move the object without changing its ObjectId or lifetime accounting.
5. Object actions
The most direct way to execute where an object lives is an object action:
let value = cache.call(CacheLookup { key }).await?;The action is registered with stable object/action IDs and is executed against the resident object state. The initial object concurrency contract is one bounded serial mailbox per object: an admitted action receives exclusive mutable access to its logical state. Read-parallel or application-defined concurrency may be added only after a concrete need; it is not implicit in the first API.
Remote<T>::call has hard colocation semantics:
- resolve
ObjectIdtoObjectLocation { locality, slot, generation, epoch }; - send the typed object call with the observed epoch;
- validate epoch and acquire an in-flight object pin before execution;
- execute in the object’s domain and release the pin on every completion path;
- on
Moved, refresh the resolver cache and retry with the sameRequestIdwithin the deadline and deduplication contract.
Object calls do not serialize or transfer the stored object state. A same-locality call uses the object store and domain scheduler directly, without transport framing.
5.2 User-owned synchronization
An object type may contain its own Mutex, RwLock, atomics, or sharded locks for application-specific fine-grained synchronization. This is most useful for pinned or reconstructible objects. Such a type must satisfy the concurrency mode’s Send/Sync bounds and must define a snapshot that serializes logical state rather than lock implementation state.
User locks do not replace Hataori admission or lifetime pins. Lock ordering is the object’s responsibility, and no guard may cross an .await or remote call. The initial public API does not expose raw Arc<Mutex<T>> storage pointers or allow another locality to lock process memory directly.
6. Colocated independent tasks
An independent task that benefits from data locality but is not an object method can use explicit placement:
let exact = runtime
.spawn_colocated(&cache, BuildIndex { range })
.await?;
let preferred = runtime
.task(PrefetchNeighbors { range })
.prefer_colocated(&cache)
.fallback_any()
.spawn()
.await?;spawn_colocated is hard affinity. Before admission, the runtime resolves the object and acquires a short placement pin/ticket tying the admission decision to the observed location epoch. Migration waits for that ticket to be released or the task fails its admission deadline. Once admitted, the task stays on the selected domain; it is not stolen to another locality.
prefer_colocated is soft affinity. It tries the current object locality first under a bounded preference budget. A stale epoch causes at most the bounded resolver/redirect path; saturation then applies the explicit fallback. Soft affinity does not pin an object for the entire task, because the task does not own direct object access.
If a task must read or mutate the object consistently, it uses Remote::call rather than relying on physical colocation. General colocated tasks never receive raw pointers or unchecked references into the object store.
Convenience entry points lower to the same canonical operation:
runtime.spawn_at(place, action)
runtime.spawn_colocated(&object, action)
runtime.task(action).placement(policy).spawn()The convenience layer adds no second scheduler or queue.
7. pmap placement
The current-style sugar facade can apply one affinity policy to a complete pmap operation:
let output = hataori::pmap(
&runtime,
domain,
PmapOptions::default()
.batch_size(16)?
.placement(TaskPlacement::prefer_colocated(&cache).fallback_any()),
inputs,
PROCESS_BATCH,
)
.await?;The policy is applied per batch, not per item. This preserves one pending promise, placement decision, and transport sequence per in-flight batch. It does not create a Remote<T>, directory lookup, or lease for every input.
Per-item affinity is deferred until a real workload needs it. If introduced, the canonical representation will group items by compact ObjectId/Place keys before scheduling so it cannot accidentally turn one batch into one allocation, hash, resolver lookup, or parcel per item.
8. Placement and migration races
Hard object calls and hard colocation follow the authoritative directory epoch. The initial protocol uses these rules:
- a stale target returns
Moved { location, epoch }rather than executing; - a placement ticket acquired before migration either completes admission before freeze or blocks the migration transition within a bounded deadline;
- directory commit invalidates older tickets and cached locations;
- redirects and forwarding entries are bounded by TTL and count;
- retry reuses the same
RequestIdand bounded deduplication state; - deadline expiry releases waiters, placement tickets, and pins mechanically.
Soft preference may fall back instead of following migration indefinitely. Migration does not change task semantics after a task has been admitted to a domain unless that task performs a separate object call.
9. Failure and resource behavior
Placement failures are typed and distinguish at least:
- unknown locality/domain;
- domain unavailable or saturated;
- object collected or unavailable;
- stale location/redirect exhaustion;
- pinned object migration request;
- placement/admission deadline;
- runtime or peer failure.
Creation, rooting, calls, and placement use the existing runtime limits. They do not create unbounded affinity queues. Object mailbox capacity, per-domain action capacity, pending calls, placement tickets, resolver entries, redirects, and retained bytes are observable and bounded.
Loss of a process owning an unreplicated object makes that object unavailable and may fail the run under the initial transport contract. The API does not claim transparent recovery.
10. Performance contract
Placement lookup is O(1) expected time through the bounded resolver cache and does not scan all localities or objects. Same-locality creation, calls, and colocated dispatch avoid codec and transport work. Remote object calls encode only the action input/output, not resident object state.
The high-level API must add no extra scheduler hop over the canonical placement operation. Instrumentation and benchmarks cover:
- exact versus preferred/fallback dispatch;
- cache-hit and redirect resolver paths;
- same-locality zero serialization/messages;
- repeated calls to a large resident object compared with retransmitting its state;
- placement-ticket and migration contention;
- bounded memory under saturated affinity queues.
These cases join the legacy-continuity and sugar-transparency gates in the parent design before Phase E is accepted.
11. Deployment assumptions
The initial runtime serves a fixed set of trusted participants launched for one configured run. It does not add TLS, authentication, authorization, ACLs, capability security, tenant isolation, sandboxing, or hostile-peer defenses.
Protocol versions, run IDs, registry hashes, lengths, counts, and checked allocation limits remain mandatory for correctness, memory safety, accidental cross-run detection, and bounded resource use. They are not presented as a security subsystem.
12. Acceptance criteria
13. Provenance
The identity/location split and object-colocated action model are conceptually informed by HPX components, actions, localities, and AGAS as described in HPX’s public documentation. Hataori’s API and implementation are independently designed in Rust; no HPX implementation source is copied or translated by this design.