Skip to main content

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}