Ownership and Moves for AI Payloads
AI applications move expensive values: prompts, images, decoded audio, video packets, tensors, embeddings, tool observations, and traces. Rust’s ownership model forces the program to say where each value lives, who may use it, and when it can be released.
Those are not compiler trivia. They determine peak memory, concurrency safety, stale-state bugs, and whether a two-hour recording gets copied by accident.
Learning objectives
Section titled “Learning objectives”After this chapter, you will be able to:
- explain ownership, moves, copies, drops, and clones using a heap-backed payload;
- read a function signature as a data-flow and invalidation contract;
- choose owned values at durable boundaries and borrowed inputs inside local work;
- distinguish moving
Vec<T>from cloning its allocation; - share immutable bytes with
Arc<[u8]>without introducing shared mutation; - identify accidental copies in multimodal and async pipelines;
- design a bounded, non-panicking byte-range API; and
- run and modify the companion
EvidenceBlobimplementation.
Prerequisites and downstream use
Section titled “Prerequisites and downstream use”This chapter depends on structs, enums, Option, and Result. It introduces borrowed references
only as function inputs; the next chapter derives borrowing, slices, and lifetime relationships in
depth.
Ownership decisions made here recur throughout the book:
HTTP upload ──owns──▶ queued job ──owns──▶ decoder ──moves──▶ normalized artifact │ └──shares Arc──▶ OCR └──▶ embeddingThe production chapters add async tasks and channels. The media chapters add memory-mapped files, frame pools, tensors, and native runtimes. Harness chapters add owned run state and borrowed views of stable context.
The one-owner rule
Section titled “The one-owner rule”Every Rust value has one owner. When the owner leaves scope, Rust runs Drop and recursively
releases owned resources. The compiler tracks this without a garbage collector.
fn summarize() { let prompt = String::from("summarize the deployment evidence"); send(prompt);} // prompt was moved, so this scope no longer drops its buffer
fn send(prompt: String) { println!("{prompt}");} // send owns and drops the StringString contains a pointer, length, and capacity. The UTF-8 bytes live on the heap. Passing the
String by value moves the small descriptor to send; it does not copy the heap bytes.
Rust prevents summarize from using prompt after the move. Two owners dropping the same
allocation would be a double free, so the invalidation is essential.
Move, copy, and clone are different
Section titled “Move, copy, and clone are different”- A move transfers ownership. For heap containers it usually copies only the descriptor.
- A copy duplicates bits implicitly for types implementing
Copy, such as most small numbers. - A clone explicitly asks the type to produce another owned value. Its cost depends on the type.
let attempts: u32 = 2;let copied = attempts; // small implicit Copy
let prompt = String::from("go");let duplicated = prompt.clone(); // allocates and copies UTF-8 bytes
let moved = prompt; // transfers the original allocation// println!("{prompt}"); // compile error: value was movedThe compiler guarantees what operation occurred, not whether a particular clone is cheap enough
for your workload.
Read signatures as ownership contracts
Section titled “Read signatures as ownership contracts”These three signatures communicate different data flow:
fn inspect(bytes: &[u8]) -> MediaFacts;fn normalize(bytes: Vec<u8>) -> Result<NormalizedMedia, NormalizeError>;fn cache(bytes: Arc<[u8]>) -> CacheKey;inspect temporarily reads someone else’s bytes. normalize takes responsibility for a vector and
may reuse its allocation. cache takes one shared-ownership handle; other handles may keep the
bytes alive.
When reviewing a pipeline, sketch who owns each representation:
Upload(Vec<u8>) │ move ▼DecodedMedia { samples: Vec<f32> } │ move; decoder buffer can be released ▼NormalizedMedia { samples: Arc<[f32]> } ├─ cheap Arc clone ─▶ transcription └─ cheap Arc clone ─▶ audio-event detectionAn API that accepts &[u8] cannot retain those bytes beyond the borrow without copying. An API that
accepts Vec<u8> is allowed to consume, mutate, store, or release it. That is useful information
before reading the implementation.
Own at durable boundaries, borrow inside local work
Section titled “Own at durable boundaries, borrow inside local work”API requests, queued jobs, database records, spawned tasks, and cached artifacts normally need owned values because their lifetimes are independent of the caller’s stack frame.
Small synchronous helpers should usually borrow:
fn normalize_text(input: &str) -> String;fn inspect_image(pixels: &[u8], width: u32, height: u32) -> ImageStats;fn rms(samples: &[f32]) -> f32;This is a practical default:
Own when data crosses time, task, process, or persistence boundaries. Borrow while the lifetime is local and obvious.
Do not contort a durable object into a web of references merely to avoid one measured-small
allocation. Conversely, do not require every helper to take an owned String; that forces callers
to give up or duplicate data unnecessarily.
Input convenience with impl Into<T>
Section titled “Input convenience with impl Into<T>”Constructors may accept owned or convertible inputs:
fn new_model_id(value: impl Into<String>) -> ModelId { ModelId(value.into())}This is convenient at construction boundaries. Avoid scattering broad generic conversions across
every internal function; concrete &str, String, &[u8], and Vec<u8> signatures are often
clearer and compile faster.
Moving large containers is usually cheap
Section titled “Moving large containers is usually cheap”A Vec<u8> descriptor is small even when its allocation is hundreds of megabytes. Therefore an
owned pipeline can be both safe and efficient:
fn decode(upload: Upload) -> Result<DecodedMedia, DecodeError>;fn normalize(media: DecodedMedia) -> Result<NormalizedMedia, NormalizeError>;fn segment(media: NormalizedMedia) -> Result<Vec<Segment>, SegmentError>;Each stage consumes the previous representation. That:
- makes stale representations unavailable;
- gives the next stage freedom to reuse buffers;
- releases abandoned state deterministically;
- documents phase transitions in the type signature.
Moving does not make the payload physically move in RAM. Cloning the container would allocate and copy its elements.
Partial moves
Section titled “Partial moves”You can move fields out of a struct:
let Upload { request_id, bytes, metadata,} = upload;After moving non-Copy fields, the original upload cannot be used as a whole. Destructuring is
often clearer than cloning fields merely to keep an object superficially intact.
Shared immutable data with Arc
Section titled “Shared immutable data with Arc”If several independently owned tasks need the same immutable allocation, use atomic reference counting:
use std::sync::Arc;
#[derive(Clone)]struct Evidence { id: String, bytes: Arc<[u8]>,}Cloning Evidence still deep-clones id, but only increments the reference count for bytes.
Cloning the final Arc handle is cheap relative to copying the entire payload:
let second_reader = Arc::clone(&evidence.bytes);assert!(Arc::ptr_eq(&evidence.bytes, &second_reader));The allocation is dropped after the last strong handle disappears. This can retain large data
longer than expected, so traces and caches should not casually hold extra Arc handles.
Why Arc<[u8]> instead of Arc<Vec<u8>>?
Section titled “Why Arc<[u8]> instead of Arc<Vec<u8>>?”Arc<[u8]> says the shared value is a fixed slice. Consumers cannot rely on vector capacity or
resize it. Arc<Vec<u8>> can still be valid when vector-specific APIs matter, but exposes a larger
semantic surface.
Arc provides shared ownership, not automatic interior mutation. Arc<T> allows shared read access
when T is safe to share. It does not make unsafe data races possible.
Shared mutation is a separate decision
Section titled “Shared mutation is a separate decision”Arc<Mutex<T>> combines:
- shared ownership;
- interior mutability;
- mutual exclusion;
- possible contention and poisoning/error policy.
Do not reach for it merely because multiple tasks exist. Prefer:
- one owner receiving commands through a channel;
- immutable snapshots replaced atomically;
- per-run state instead of one global map;
- partitioned locks when measurement proves shared mutation is necessary.
A single mutex around every trace, cache, and model route can serialize the service. It also creates unclear cancellation behavior: what happens if a task exits while holding or waiting for the lock?
The later concurrency chapter derives these tradeoffs with Tokio primitives.
Rc versus Arc
Section titled “Rc versus Arc”Rc<T> is reference counting for one thread and is cheaper because its counter is not atomic.
Arc<T> supports cross-thread sharing when T satisfies the required Send and Sync bounds.
Use Rc for single-threaded ownership graphs such as a local parser tree when it materially
simplifies design. Use Arc at multi-threaded runtime boundaries. Do not choose Arc everywhere
“just in case”; it obscures whether cross-thread sharing is actually intended.
Box for indirection and ownership
Section titled “Box for indirection and ownership”Box<T> owns one heap allocation. Common uses in this book include:
- recursive data structures;
- large or dynamically sized values;
- trait objects such as
Box<dyn Model>; - stable indirection around native runtime handles.
Boxing is not a general performance optimization. It adds an allocation and pointer indirection. Use it when the type relationship requires indirection or when measurement supports it.
Drop timing and resource budgets
Section titled “Drop timing and resource budgets”Deterministic destruction is operationally useful. File handles, temporary directories, permits, and native contexts can release resources when their owners leave scope.
Memory does not always return to the operating system immediately because allocators may retain pages for reuse. “Dropped” means Rust has run destruction and returned the allocation to its allocator—not necessarily that a dashboard instantly shows lower resident memory.
You can narrow a scope to release a large intermediate before inference:
let normalized = { let decoded = decode(upload)?; normalize(decoded)?}; // decoded is no longer needed here
infer(normalized).awaitCalling drop(value) can be explicit and useful, but a well-structured scope usually communicates
lifetime better.
Clone is a cost annotation
Section titled “Clone is a cost annotation”Some clones are intentionally cheap:
- identifiers and small configuration;
Archandles;- small enums and metadata;
- values duplicated once at a durable boundary.
Others deserve scrutiny:
- full prompts assembled repeatedly;
- decoded images and frame batches;
- tensors or embedding matrices;
- large trace vectors;
- nested JSON values.
During review, ask:
- Is this a handle clone or a deep clone?
- Is ownership genuinely needed by both consumers?
- Would a borrow, move,
Arc,Cow, or stream express the lifetime better? - Does cloning retain a scarce accelerator or file mapping?
- Is the cost material beside network and model inference?
Measure. Removing every clone can create brittle lifetime coupling for insignificant savings.
Runnable companion implementation
Section titled “Runnable companion implementation”The companion example is
crates/mosaic-domain/examples/ownership.rs. Run:
cd rust/rust-ai-engineeringcargo run -q -p mosaic-domain --example ownershipcargo test --workspace --examplesExpected output:
17 bytes are shared; borrowed slice: evidenceThe implementation has:
- an owned MIME type and provenance enum;
- bytes stored as
Arc<[u8]>; - a
viewmethod returning a borrowed slice without copying; - checked range access using
slice::get, not indexing that can panic; - a test for the out-of-bounds failure; and
Arc::ptr_eqevidence that cloning the blob shares the byte allocation.
The borrowed EvidenceSlice cannot outlive EvidenceBlob; the next chapter explains how the
compiler derives that relationship.
Failure investigation: memory doubles during fan-out
Section titled “Failure investigation: memory doubles during fan-out”Symptom: feeding one decoded recording to transcription and event detection nearly doubles resident memory.
Hypothesis: the fan-out code clones Vec<f32> for each task.
Investigation:
- find
.clone()near the fan-out boundary; - inspect the cloned type—
Vec<f32>is a deep element copy; - add tracing fields for sample count and allocation bytes;
- reproduce with a fixed long recording;
- compare allocation profiles before and after a shared immutable buffer;
- verify both consumers only require read access.
Possible fix:
let samples: Arc<[f32]> = decoded_samples.into();let for_speech = Arc::clone(&samples);let for_events = Arc::clone(&samples);Do not apply blindly: a native library may require owned, aligned, mutable storage. In that case, change the integration boundary, use a buffer pool, or accept a measured copy. Record the reason.
Common failure modes
Section titled “Common failure modes”Cloning to satisfy the borrow checker
Section titled “Cloning to satisfy the borrow checker”This often indicates an unclear ownership boundary. First ask which component should own the value. Clone only when two owners are semantically required.
Storing references in queued jobs
Section titled “Storing references in queued jobs”The source request may end before the worker runs. Store owned IDs, paths, hashes, or reference-counted artifacts at the queue boundary.
One giant shared mutable state
Section titled “One giant shared mutable state”Arc<Mutex<AppState>> makes unrelated operations contend and complicates cancellation. Split state
by ownership and capability.
Confusing move cost with payload size
Section titled “Confusing move cost with payload size”Moving Vec<T> does not copy its elements. Benchmark the right operation.
Retaining buffers through trace objects
Section titled “Retaining buffers through trace objects”A small trace record that holds an Arc to a full video keeps the full video alive. Store a content
hash, artifact ID, and bounded preview instead.
Panicking range access
Section titled “Panicking range access”Model-produced offsets are untrusted. &bytes[start..end] panics when invalid. Use get and return
a typed violation.
Security and performance implications
Section titled “Security and performance implications”Ownership prevents use-after-free and data races in safe Rust, but it does not enforce authorization or memory budgets.
Security rules:
- do not let a broad state object confer capabilities merely because it is cloneable;
- keep secret-bearing types out of
Debugor redact them; - validate all model-produced ranges before slicing;
- avoid retaining sensitive buffers longer than necessary;
- overwrite secrets with a dedicated, reviewed approach when the threat model requires it—ordinary
dropdoes not guarantee memory erasure.
Performance rules:
- bound the number and size of simultaneously owned chunks;
- use shared immutable buffers only when lifetimes overlap;
- stream media rather than owning the whole decoded universe;
- measure clone count, allocation bytes, peak resident memory, and queue residence time;
- remember that reference counting adds atomic operations and can delay reclamation.
The fastest copy is sometimes the one that simplifies a hot loop or lets a native runtime own the correct alignment. The goal is explicit, measured ownership—not zero allocation as ideology.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- Why does moving a
Vec<u8>not copy all bytes? - What is the semantic difference between
Arc<[u8]>andArc<Mutex<Vec<u8>>>? - When does Rust run
Drop? - Why can an extra
Arcin a trace record matter?
Implementation
Section titled “Implementation”- Add a non-empty
ContentHashnewtype toEvidenceBlob. - Add
len,is_empty, andoriginmethods without exposing mutable fields. - Add a checked
split_atoperation returning two borrowed views. - Change the example to fan out a one-megabyte buffer to four readers and prove they share one allocation.
- Add a
Fileorigin carrying an owned path. Explain whyPathBufis appropriate at this boundary.
Design
Section titled “Design”Design the ownership graph for a video upload that feeds:
- content hashing;
- virus scanning;
- metadata extraction;
- adaptive frame sampling;
- audio transcription;
- long-term object storage.
State which component owns the upload at each step, which work can borrow, where immutable sharing is needed, how cancellation releases resources, and the maximum bytes simultaneously resident.
Measurement
Section titled “Measurement”Benchmark:
- cloning a 100 MiB
Vec<u8>; - moving it through a function;
- cloning
Arc<[u8]>; - borrowing
&[u8].
Use a release build, prevent the optimizer from deleting the work, repeat samples, and report both time and peak memory. Do not infer production throughput from one microbenchmark.
Solution guidance
Section titled “Solution guidance”For ContentHash, keep the algorithm and digest explicit, for example sha256:<hex>. Validate the
length and alphabet at ingestion; do not claim content addressing if the digest is merely supplied
by an untrusted client.
split_at should use checked slice operations and return Result, because the offset may come from
a model. Both result views borrow the same blob.
For the fan-out exercise, convert the vector into Arc<[u8]> once. Clone the handles and use
Arc::ptr_eq or compare as_ptr addresses. Do not mutate through unsafe code merely to prove
sharing.
For the ownership graph, streaming hashing/scanning before durable acceptance usually minimizes trust. Object storage can become the durable owner; later jobs carry a content-addressed artifact ID rather than the entire body.
Check your understanding
Section titled “Check your understanding”- After
let b = a;, when canastill be used? - Why can consuming a phase type prevent stale-state reuse?
- Which is likely a deep clone:
String,Vec<f32>,Arc<[u8]>, or all three? - When should a helper take
&strrather thanString? - Does
Arcmake the inner value mutable or thread-safe by itself? - Why might
Arc<[u8]>be a clearer contract thanArc<Vec<u8>>? - What would you inspect first when memory scales with the number of fan-out branches?
Completion checklist
Section titled “Completion checklist”- I can identify the owner of every large value in a pipeline.
- I can distinguish move,
Copy, andClone. - I know when an owned boundary is more robust than a borrowed one.
- I can share immutable bytes without adding a global mutex.
- I use checked access for model-produced offsets.
- I can explain when the last
Arcreleases its allocation. - I ran the ownership example and its failure test.
- I measured or justified every deep clone in my exercise pipeline.