hurray_ffi/file/writer.rs
1//! Blocking file writes over a caller-supplied transport (ADR-041 §§ 3, 7, 8).
2//!
3//! A file is written in one forward pass — header, tensors, footer — so the writer needs
4//! nothing from the vtable that a stream writer does not: no seek, no size, just `write`.
5//! The footer index is accumulated as tensors go out and emitted last, which is why no
6//! offset is ever patched.
7
8use hurray_io::file::{
9 flatten_composite, FileCompositeNode, FileWriteOp, FileWriterCore, FileWriterOptions,
10};
11use hurray_io::stream::{Unusable, WriteStep};
12
13use crate::{
14 composite::{file_tree, HurrayCompositeNode, TreeArena},
15 descriptor::HurrayDescriptor,
16 file::kv::{kv_from_c, HurrayKvEntry},
17 panic::{catch, null_check},
18 status::{
19 status_from_io_error, HurrayStatus, HURRAY_ERR_INTERNAL, HURRAY_ERR_NULL_POINTER,
20 HURRAY_ERR_SINK_CLOSED, HURRAY_OK,
21 },
22 stream::{transport::Transport, HurrayIoSlice, HurrayTransport},
23};
24
25// ── Options ───────────────────────────────────────────────────────────────────
26
27/// Container options for a file writer.
28///
29/// Pass null to [`hurray_file_writer_new`] for the defaults: 4096-byte data buffer
30/// alignment, index in write order.
31#[repr(C)]
32pub struct HurrayFileWriterOptions {
33 /// Alignment applied to every data buffer in the file. MUST be a power of two in
34 /// `[4096, 2097152]`. Default: 4096, which is page-aligned on every common OS and so
35 /// lets a consumer mmap a buffer directly.
36 pub data_buffer_alignment: u32,
37 /// Sort the footer index by tensor name instead of leaving it in write order, so a
38 /// reader can binary-search it. Default: false.
39 pub sorted_index: bool,
40}
41
42// ── HurrayFileWriter ──────────────────────────────────────────────────────────
43
44/// Opaque handle to a blocking file writer.
45///
46/// Not `#[repr(C)]`; the layout is an implementation detail. Created by
47/// [`hurray_file_writer_new`] and released exactly once by
48/// [`hurray_file_writer_destroy`].
49///
50/// One handle, one thread at a time.
51pub struct HurrayFileWriter {
52 core: FileWriterCore,
53 transport: Transport,
54}
55
56// ── Construction / destruction ────────────────────────────────────────────────
57
58/// Creates a file writer over `transport` and writes the 64-byte file header.
59///
60/// The header goes out here, not on the first tensor, which is what makes every later
61/// offset in the file knowable without seeking back.
62///
63/// # Arguments
64///
65/// - `transport` — Non-null; its `write` callback MUST be non-null. `read_at` and `size`
66/// are not used: a file writer never reads what it has written.
67/// - `options` — MAY be null for [`HurrayFileWriterOptions`]' documented defaults.
68/// - `out_handle` — Non-null pointer to a `*mut HurrayFileWriter`; set on success.
69///
70/// Returns [`HURRAY_ERR_INVALID_FILE`][crate::HURRAY_ERR_INVALID_FILE] if
71/// `data_buffer_alignment` is not a power of two in range.
72///
73/// # Safety
74///
75/// - `transport` MUST point to a readable [`HurrayTransport`] for the duration of the call.
76/// - `options`, if non-null, MUST point to a readable [`HurrayFileWriterOptions`].
77/// - `out_handle` MUST be a valid, non-null, writable pointer.
78#[no_mangle]
79pub unsafe extern "C" fn hurray_file_writer_new(
80 transport: *const HurrayTransport,
81 options: *const HurrayFileWriterOptions,
82 out_handle: *mut *mut HurrayFileWriter,
83) -> HurrayStatus {
84 catch(|| {
85 // options may be null: that is the request for defaults.
86 null_check!(transport, out_handle);
87
88 // SAFETY: caller guarantees transport is readable for this call.
89 let transport = Transport::from_c(transport);
90 if !transport.can_write() {
91 return HURRAY_ERR_NULL_POINTER;
92 }
93
94 let io_options = if options.is_null() {
95 FileWriterOptions::default()
96 } else {
97 // SAFETY: non-null and caller-guaranteed readable.
98 FileWriterOptions {
99 data_buffer_alignment: (*options).data_buffer_alignment,
100 sorted_index: (*options).sorted_index,
101 }
102 };
103
104 let mut core = match FileWriterCore::with_options(io_options) {
105 Ok(core) => core,
106 Err(e) => return status_from_io_error(&e),
107 };
108
109 let mut header = match core.header() {
110 Ok(op) => op,
111 Err(e) => return status_from_io_error(&e),
112 };
113 let status = drain(&mut header, &transport);
114 drop(header);
115 if status != HURRAY_OK {
116 return status;
117 }
118
119 // SAFETY: Box::into_raw transfers ownership to the caller, who must call
120 // hurray_file_writer_destroy exactly once.
121 *out_handle = Box::into_raw(Box::new(HurrayFileWriter { core, transport }));
122 HURRAY_OK
123 })
124}
125
126/// Destroys a writer, releases its transport context, and writes null through `handle`.
127///
128/// Destroying a writer that was never finished leaves a file with no footer, which no
129/// reader can open — the bytes are not a container until
130/// [`hurray_file_writer_finish`] has written its index and trailer.
131///
132/// # Safety
133///
134/// - `handle` MUST be a valid, non-null pointer to a `*mut HurrayFileWriter` from
135/// [`hurray_file_writer_new`].
136/// - That writer MUST NOT have been destroyed already.
137#[no_mangle]
138pub unsafe extern "C" fn hurray_file_writer_destroy(
139 handle: *mut *mut HurrayFileWriter,
140) -> HurrayStatus {
141 catch(|| {
142 null_check!(handle);
143 let writer = *handle;
144 if writer.is_null() {
145 return HURRAY_ERR_INTERNAL;
146 }
147 // SAFETY: created by hurray_file_writer_new via Box::into_raw; the caller
148 // guarantees this is the first and only destroy call. Dropping it runs the
149 // transport's release callback.
150 drop(Box::from_raw(writer));
151 *handle = std::ptr::null_mut();
152 HURRAY_OK
153 })
154}
155
156// ── Writing ───────────────────────────────────────────────────────────────────
157
158/// Writes one tensor under `name`: its descriptor, its padding, then its data buffers.
159///
160/// `payloads` MUST have one entry per buffer in the descriptor's buffer table, in that
161/// order, each with the declared `byte_size`. The whole tensor is validated before a byte
162/// reaches the sink. The name is recorded in the footer index, so it is what
163/// [`hurray_file_reader_read_tensor`][crate::file::hurray_file_reader_read_tensor] will
164/// ask for; it MUST be non-empty, at most 65 535 bytes, and unique within the file.
165///
166/// Returns:
167///
168/// - [`HURRAY_OK`] — the tensor is entirely written and the payload pointers are no
169/// longer held.
170/// - [`HURRAY_ERR_INVALID_FILE`][crate::HURRAY_ERR_INVALID_FILE] — the name is empty, too
171/// long, or already used in this file. Refused before a byte goes out, so the file is
172/// still completable and another name may be used.
173/// - [`HURRAY_ERR_SINK_CLOSED`] — the sink stopped accepting bytes, or the write callback
174/// failed, once part of this tensor was already out. A torn tensor is in the file and a
175/// forward-only writer cannot get back to its start, so the writer is poisoned: every
176/// later call fails, including the footer, and the bytes on the sink are not a container.
177/// - Whatever status the write callback returned, if it failed **before any byte of this
178/// tensor reached the sink**. Nothing was emitted, so the call MAY be retried — under the
179/// same name, which the failed attempt did not consume — and the file is still
180/// completable (ADR-041 § 6b).
181///
182/// A composite is written with [`hurray_file_writer_write_composite`]; a composite head
183/// offered here is refused, because a head alone is not a valid tensor.
184///
185/// # Safety
186///
187/// - `handle` MUST point to a live [`HurrayFileWriter`] and `descriptor` to a live
188/// [`HurrayDescriptor`].
189/// - `name` MUST be readable for `name_len` bytes.
190/// - If `count` is non-zero, `payloads` MUST point to `count` readable [`HurrayIoSlice`]
191/// values, each describing memory readable for its own `len` bytes.
192/// - All of that memory MUST stay valid and unmodified until this call returns.
193#[no_mangle]
194pub unsafe extern "C" fn hurray_file_writer_write_tensor(
195 handle: *mut HurrayFileWriter,
196 name: *const u8,
197 name_len: usize,
198 descriptor: *const HurrayDescriptor,
199 payloads: *const HurrayIoSlice,
200 count: usize,
201) -> HurrayStatus {
202 catch(|| {
203 null_check!(handle, descriptor);
204 if count > 0 {
205 null_check!(payloads);
206 }
207
208 // SAFETY: caller guarantees `name_len` readable bytes at `name`.
209 let name = match crate::file::kv::str_from_c(name, name_len) {
210 Ok(name) => name,
211 Err(status) => return status,
212 };
213
214 // SAFETY: non-null and caller-guaranteed to describe `count` readable slices.
215 let slices = if count == 0 {
216 &[][..]
217 } else {
218 std::slice::from_raw_parts(payloads, count)
219 };
220
221 let mut borrowed: Vec<&[u8]> = Vec::with_capacity(count);
222 for slice in slices {
223 // SAFETY: caller guarantees `len` readable bytes at `ptr`, unmodified for the
224 // duration of this call; a zero length needs no pointer.
225 match crate::file::kv::bytes_from_c(slice.ptr, slice.len) {
226 Ok(bytes) => borrowed.push(bytes),
227 Err(status) => return status,
228 }
229 }
230
231 // SAFETY: both pointers are non-null and point to live handles, which the C ABI's
232 // one-handle-one-thread rule says nothing else is touching.
233 let HurrayFileWriter { core, transport } = &mut *handle;
234 let descriptor = (*descriptor).inner();
235
236 let mut op = match core.tensor(name, descriptor, &borrowed) {
237 Ok(op) => op,
238 Err(e) => return status_from_io_error(&e),
239 };
240 drain(&mut op, transport)
241 })
242}
243
244/// Writes a composite: its head, then every member in order, each with its own name and
245/// its own footer-index entry.
246///
247/// `nodes` is the composite in preorder: `nodes[0]` is the head, and each node declares
248/// how many of the nodes after it are its direct members. [`crate::composite`] has the
249/// shape and an example layout. Unlike the stream surface, **every node's name is
250/// required** — a reader recovers membership from the head's `member_count` and the file
251/// order of the entries after it, so each tensor needs an entry to be found by.
252///
253/// The whole tree is validated and flattened into write order before a byte goes out, so
254/// an invalid composite never reaches the sink.
255///
256/// Returns:
257///
258/// - [`HURRAY_OK`] — the composite is entirely written and the payload pointers are no
259/// longer held.
260/// - [`HURRAY_ERR_NOT_COMPOSITE`][crate::HURRAY_ERR_NOT_COMPOSITE] — `count` is `0`, or
261/// `nodes[0]` is not a composite head. Use [`hurray_file_writer_write_tensor`] for a
262/// tensor.
263/// - [`HURRAY_ERR_INDEX_OUT_OF_BOUNDS`][crate::HURRAY_ERR_INDEX_OUT_OF_BOUNDS] — the
264/// member counts do not describe the array: a head declares members the array does not
265/// carry, or nodes trail after the head's subtree is complete.
266/// - [`HURRAY_ERR_INVALID_FILE`][crate::HURRAY_ERR_INVALID_FILE] — a name is empty, too
267/// long, or already used in this file. The head's name is checked before any byte goes
268/// out, so the file is still completable; a **member's** name is checked when that
269/// member is submitted, by which point the head is on the sink, so the writer is
270/// poisoned — the group can no longer be completed and no index may be written over it.
271/// Check names before calling if that distinction matters.
272/// - [`HURRAY_ERR_INVALID_LAYOUT`][crate::HURRAY_ERR_INVALID_LAYOUT] — the tree is not a
273/// valid composite: a member count that disagrees with its head, a partition that does
274/// not cover its index space, an overlay whose base is not first.
275/// - [`HURRAY_ERR_SINK_CLOSED`] — the sink stopped accepting bytes, or a write callback
276/// failed, once part of the group was already out. The writer is poisoned: a head with
277/// fewer members after it than it declares is not a file any reader may open, and a
278/// forward-only writer cannot go back to fix it.
279/// - Whatever status the write callback returned, if it failed **before any byte of the
280/// group reached the sink**. Nothing was emitted, so the call MAY be retried and the
281/// file is still completable (ADR-041 § 6b).
282///
283/// # Safety
284///
285/// - `handle` MUST point to a live [`HurrayFileWriter`].
286/// - If `count` is non-zero, `nodes` MUST point to `count` readable
287/// [`HurrayCompositeNode`] values, each satisfying that type's own pointer contract.
288/// - All of that memory MUST stay valid and unmodified until this call returns.
289#[no_mangle]
290pub unsafe extern "C" fn hurray_file_writer_write_composite(
291 handle: *mut HurrayFileWriter,
292 nodes: *const HurrayCompositeNode,
293 count: usize,
294) -> HurrayStatus {
295 catch(|| {
296 null_check!(handle);
297 if count > 0 {
298 null_check!(nodes);
299 }
300
301 // SAFETY: non-null and caller-guaranteed to describe `count` readable nodes.
302 let nodes = if count == 0 {
303 &[][..]
304 } else {
305 std::slice::from_raw_parts(nodes, count)
306 };
307
308 // Outlives the ops below: the borrowed tree points into it.
309 let mut arena: TreeArena<FileCompositeNode<'static>> = TreeArena::default();
310 // SAFETY: delegated to this function's contract on `nodes`.
311 let (head_name, head, members) = match file_tree(&mut arena, nodes) {
312 Ok(tree) => tree,
313 Err(status) => return status,
314 };
315
316 // SAFETY: handle is non-null and points to a live HurrayFileWriter.
317 let HurrayFileWriter { core, transport } = &mut *handle;
318
319 // Validated and flattened first, so a torn or invalid composite never reaches the
320 // sink. One op per tensor, because a file item is one tensor — the grouping lives
321 // in the index, not in a frame.
322 let order = match flatten_composite(head_name, head, members) {
323 Ok(order) => order,
324 Err(e) => return status_from_io_error(&e),
325 };
326 for (index, node) in order.into_iter().enumerate() {
327 // The op is bound to a local and dropped before the poison below: it borrows
328 // the core mutably for its whole life.
329 let status = match core.tensor(node.name, node.descriptor, node.buffers) {
330 Ok(mut op) => drain(&mut op, transport),
331 Err(e) => status_from_io_error(&e),
332 };
333 if status != HURRAY_OK {
334 // A group that began and did not finish leaves a head with fewer than
335 // `member_count` members after it, which `file-format.md § Composite
336 // Tensors` calls invalid. No `finish` can rescue that, so the writer must
337 // not go on to write a clean index over it. `index > 0` is exactly "an
338 // earlier node completed": the loop returns on the first failure.
339 if index > 0 {
340 core.poison(Unusable::IncompleteOutput);
341 }
342 // The status says what actually happened — a torn item is already
343 // `SINK_CLOSED` from `drain`, and a refused submission keeps its own
344 // reason. Relabelling the second as the first would hide which it was.
345 return status;
346 }
347 }
348 HURRAY_OK
349 })
350}
351
352/// Writes the KV section, the footer index and the 40-byte trailer, completing the file.
353///
354/// Until this returns [`HURRAY_OK`] the bytes on the sink are not a container: the index
355/// a reader opens from is written here. Calling it twice *after it returned*
356/// [`HURRAY_OK`] fails.
357///
358/// A footer that failed **before any of its bytes reached the sink** may be written again:
359/// nothing was emitted, so the file is still completable (ADR-041 § 6b). This is the one
360/// retry worth having here — the data is already on the sink, and only the index is
361/// missing. A footer that failed after its first byte poisons the writer like any other
362/// torn item.
363///
364/// `kv` MAY be null with a `kv_count` of `0` for a file with no metadata section. Keys
365/// MUST be non-empty, at most 65 535 bytes, and unique. Entries read out of another file
366/// with [`hurray_file_reader_kv_entry`][crate::file::hurray_file_reader_kv_entry] can be
367/// passed straight in.
368///
369/// The writer handle stays alive and MUST still be destroyed with
370/// [`hurray_file_writer_destroy`]; any flush or close belongs to the transport's release
371/// callback, because only the caller knows what its sink is.
372///
373/// # Safety
374///
375/// - `handle` MUST point to a live [`HurrayFileWriter`].
376/// - If `kv_count` is non-zero, `kv` MUST point to `kv_count` readable
377/// [`HurrayKvEntry`] values whose own pointers are readable for their declared lengths,
378/// all unmodified until this call returns.
379#[no_mangle]
380pub unsafe extern "C" fn hurray_file_writer_finish(
381 handle: *mut HurrayFileWriter,
382 kv: *const HurrayKvEntry,
383 kv_count: usize,
384) -> HurrayStatus {
385 catch(|| {
386 null_check!(handle);
387 if kv_count > 0 {
388 null_check!(kv);
389 }
390
391 let entries = if kv_count == 0 {
392 Vec::new()
393 } else {
394 // SAFETY: caller guarantees `kv_count` readable entries at `kv`.
395 match kv_from_c(kv, kv_count) {
396 Ok(entries) => entries,
397 Err(status) => return status,
398 }
399 };
400
401 // SAFETY: handle is non-null and points to a live HurrayFileWriter.
402 let HurrayFileWriter { core, transport } = &mut *handle;
403
404 let mut op = match core.finish(entries) {
405 Ok(op) => op,
406 Err(e) => return status_from_io_error(&e),
407 };
408 drain(&mut op, transport)
409 })
410}
411
412// ── The driver ────────────────────────────────────────────────────────────────
413
414/// Writes every byte of one submitted item — header, tensor or footer — to the transport.
415///
416/// Any transport failure poisons the writer: see
417/// [`hurray_file_writer_write_tensor`] for why a file write is not retryable where a
418/// stream write is.
419///
420/// Not shared with the stream writer's loop: `FileWriteOp` and the stream's `WriteOp` are
421/// distinct types with no common trait upstream, and a trait defined here to join them
422/// would be more surface than the twenty lines it saves.
423fn drain(op: &mut FileWriteOp<'_>, transport: &Transport) -> HurrayStatus {
424 loop {
425 match op.step() {
426 Err(e) => return status_from_io_error(&e),
427 Ok(WriteStep::ItemWritten) => return HURRAY_OK,
428 Ok(WriteStep::Output { .. }) => {
429 let src = op.output_src();
430 match transport.write(src) {
431 Ok(0) => {
432 // § 4: zero accepted bytes is never progress.
433 let e = op.report_sink_closed();
434 return status_from_io_error(&e);
435 }
436 Ok(n) => {
437 if let Err(e) = op.report_written(n) {
438 return status_from_io_error(&e);
439 }
440 }
441 Err(status) => {
442 // No `report_sink_closed` here: the core poisoned itself at this
443 // item's first accepted byte (§ 6b), and calling it would overwrite
444 // that with `LostWriteCount` — claiming the count is unknown when
445 // it is known exactly. It would also make the footer retry
446 // unreachable, which is what § 6b exists to allow.
447 if op.emitted() > 0 {
448 // A torn item: the transport's own status would understate it.
449 return HURRAY_ERR_SINK_CLOSED;
450 }
451 // Nothing of this item is on the sink, so the callback's status is
452 // the more useful of the two — it says why the file stopped.
453 return status;
454 }
455 }
456 }
457 }
458 }
459}