Skip to main content

hayson_engine/queue/
write_queue.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::fmt;
3use std::io;
4
5use serde_json::Value;
6
7use crate::errors::{
8    HaysonCipherError, HaysonPartitionError, HaysonPendingConflictError, HaysonUniqueViolationError,
9    HaysonValidationError,
10};
11use crate::registry::composite_index::unique_key_of;
12use crate::registry::table_registry::TableRegistry;
13use crate::schema::{
14    partition_of, primary_key_of, validate_record, HaysonRecord, TableSchema, ValidateOptions, UNPARTITIONED,
15};
16use crate::storage::{serialize_entry, LogFile, LogMeta, LogOp, LogSet, Slot};
17
18use super::pending_map::{PendingMap, ALL_FIELDS};
19
20pub trait LogAppender {
21    fn append_lines(&mut self, lines: &[String]) -> io::Result<Vec<Slot>>;
22
23    /// Appends into one named partition. Defaults to [`LogAppender::append_lines`], which is right
24    /// for anything holding a single log file: the only partition such a target has is the
25    /// [`UNPARTITIONED`] one every unpartitioned record already names.
26    fn append_lines_in(&mut self, part: &str, lines: &[String]) -> io::Result<Vec<Slot>> {
27        let _ = part;
28        self.append_lines(lines)
29    }
30
31    fn sync(&mut self) -> io::Result<()>;
32}
33
34impl LogAppender for LogFile {
35    fn append_lines(&mut self, lines: &[String]) -> io::Result<Vec<Slot>> {
36        self.append(lines)
37    }
38
39    fn sync(&mut self) -> io::Result<()> {
40        LogFile::sync(self)
41    }
42}
43
44impl LogAppender for LogSet {
45    fn append_lines(&mut self, lines: &[String]) -> io::Result<Vec<Slot>> {
46        self.append(UNPARTITIONED, lines)
47    }
48
49    fn append_lines_in(&mut self, part: &str, lines: &[String]) -> io::Result<Vec<Slot>> {
50        self.append(part, lines)
51    }
52
53    fn sync(&mut self) -> io::Result<()> {
54        LogSet::sync(self)
55    }
56}
57
58#[derive(Debug, Clone, PartialEq)]
59pub enum StageError {
60    Validation(HaysonValidationError),
61    Unique(HaysonUniqueViolationError),
62    Conflict(HaysonPendingConflictError),
63    Partition(HaysonPartitionError),
64}
65
66impl fmt::Display for StageError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            StageError::Validation(e) => e.fmt(f),
70            StageError::Unique(e) => e.fmt(f),
71            StageError::Conflict(e) => e.fmt(f),
72            StageError::Partition(e) => e.fmt(f),
73        }
74    }
75}
76
77impl std::error::Error for StageError {}
78
79/// `WriteQueue::commit_sealed`'s failure: an I/O error appending to the log, or the `seal`
80/// closure refusing a `cypher` field (no key to write it with).
81#[derive(Debug)]
82pub enum CommitError {
83    Io(io::Error),
84    Cipher(HaysonCipherError),
85}
86
87impl fmt::Display for CommitError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            CommitError::Io(e) => e.fmt(f),
91            CommitError::Cipher(e) => e.fmt(f),
92        }
93    }
94}
95
96impl std::error::Error for CommitError {}
97
98impl From<io::Error> for CommitError {
99    fn from(value: io::Error) -> Self {
100        CommitError::Io(value)
101    }
102}
103
104impl From<HaysonCipherError> for CommitError {
105    fn from(value: HaysonCipherError) -> Self {
106        CommitError::Cipher(value)
107    }
108}
109
110impl From<HaysonValidationError> for StageError {
111    fn from(value: HaysonValidationError) -> Self {
112        StageError::Validation(value)
113    }
114}
115
116impl From<HaysonUniqueViolationError> for StageError {
117    fn from(value: HaysonUniqueViolationError) -> Self {
118        StageError::Unique(value)
119    }
120}
121
122impl From<HaysonPartitionError> for StageError {
123    fn from(value: HaysonPartitionError) -> Self {
124        StageError::Partition(value)
125    }
126}
127
128impl From<HaysonPendingConflictError> for StageError {
129    fn from(value: HaysonPendingConflictError) -> Self {
130        StageError::Conflict(value)
131    }
132}
133
134#[derive(Debug, Clone, PartialEq)]
135pub enum WriteOutcome {
136    Put(HaysonRecord),
137    Deleted(bool),
138}
139
140/// What [`WriteQueue::commit_sealed`] puts every queued `put` through on its way to a log line: the
141/// entry's key, its plaintext record, and the field names that entry was staged as already sealed
142/// with.
143pub type Seal<'a> = dyn Fn(&str, &HaysonRecord, &[String]) -> Result<HaysonRecord, HaysonCipherError> + 'a;
144
145enum EntryKind {
146    Put(HaysonRecord),
147    Delete,
148}
149
150struct QueuedEntry {
151    key: String,
152    fields: Vec<String>,
153    kind: EntryKind,
154    /// Field names this entry's caller declared to already hold what the `seal` closure of
155    /// [`WriteQueue::commit_sealed`] would otherwise produce. Opaque here: the queue neither reads
156    /// them nor knows what sealing is, it only keeps them tied to the entry they were staged with,
157    /// so a second write in the same window cannot inherit them.
158    sealed: Vec<String>,
159    /// The log file this entry lands in — [`UNPARTITIONED`] on a table with no `partitionBy`.
160    part: String,
161}
162
163/// Batches put/patch/delete operations across one commit cycle into a single `LogAppender` call,
164/// same shape as the TS `WriteQueue`: a key claim (`PendingMap`) guards every queued entry until the
165/// batch that carries it is committed and the registry reflects it.
166pub struct WriteQueue {
167    table: String,
168    max_batch: usize,
169    pending: PendingMap,
170    queued: VecDeque<QueuedEntry>,
171    /// The keys of every entry a refused [`WriteQueue::commit_sealed`] threw away, until the
172    /// caller collects them through [`WriteQueue::take_abandoned_keys`].
173    abandoned: Vec<String>,
174    /// Keys the image file names of this table's writes; set for a table of an encrypted database.
175    image_key: Option<[u8; 32]>,
176}
177
178impl WriteQueue {
179    pub fn new(table: impl Into<String>) -> Self {
180        Self::with_max_batch(table, 256)
181    }
182
183    /// Names the image files of every entry staged from now on with `key` — see
184    /// [`crate::schema::ValidateOptions::image_key`].
185    pub fn set_image_key(&mut self, key: Option<[u8; 32]>) {
186        self.image_key = key;
187    }
188
189    pub fn with_max_batch(table: impl Into<String>, max_batch: usize) -> Self {
190        WriteQueue {
191            table: table.into(),
192            max_batch: max_batch.max(1),
193            pending: PendingMap::new(),
194            queued: VecDeque::new(),
195            abandoned: Vec::new(),
196            image_key: None,
197        }
198    }
199
200    pub fn depth(&self) -> usize {
201        self.queued.len()
202    }
203
204    pub fn pending(&self, key: &str) -> HashSet<String> {
205        self.pending.pending(key)
206    }
207
208    /// The keys the last refused commit abandoned — see [`WriteQueue::commit_sealed`] — handed over
209    /// once, in staging order, so the caller can drop whatever it staged beside the queue for them.
210    pub fn take_abandoned_keys(&mut self) -> Vec<String> {
211        std::mem::take(&mut self.abandoned)
212    }
213
214    /// Every key with a write still queued, in staging order — what a caller holding a cache of
215    /// committed records has to drop before [`WriteQueue::commit_sealed`] moves the registry, and
216    /// the only place that list exists before the commit drains it.
217    pub fn queued_keys(&self) -> impl Iterator<Item = &str> {
218        self.queued.iter().map(|entry| entry.key.as_str())
219    }
220
221    /// Validates `document` against `schema`, checks its unique fields against `registry`, resolves
222    /// the partition it belongs to, then reserves the record's fields in the pending map. The key
223    /// returned is the one the caller should use for `pending()`/patch chaining.
224    pub fn stage_put(
225        &mut self,
226        schema: &TableSchema,
227        registry: &TableRegistry,
228        document: &Value,
229        force: bool,
230    ) -> Result<String, StageError> {
231        self.stage_entry(schema, registry, document, None, Vec::new(), force)
232    }
233
234    /// [`WriteQueue::stage_put`] for a record whose fields named in `sealed` must reach the log as
235    /// they stand, without the `seal` closure of [`WriteQueue::commit_sealed`] touching them. The
236    /// names ride with this entry alone — see [`QueuedEntry::sealed`] — and the queue makes no
237    /// judgement about them; whoever hands them over has already vouched for them.
238    pub fn stage_put_carrying(
239        &mut self,
240        schema: &TableSchema,
241        registry: &TableRegistry,
242        document: &Value,
243        sealed: Vec<String>,
244        force: bool,
245    ) -> Result<String, StageError> {
246        self.stage_entry(schema, registry, document, None, sealed, force)
247    }
248
249    /// [`WriteQueue::stage_put`] with the claim named by the caller instead of taken from the whole
250    /// record — what a patch needs, since it only changes the fields it was given and claiming the
251    /// rest would refuse a second patch touching a field this one never mentioned.
252    ///
253    /// `claim` is only what the pending map guards; the entry queued is still the whole record.
254    pub fn stage_put_claiming(
255        &mut self,
256        schema: &TableSchema,
257        registry: &TableRegistry,
258        document: &Value,
259        claim: &[String],
260        force: bool,
261    ) -> Result<String, StageError> {
262        self.stage_entry(schema, registry, document, Some(claim), Vec::new(), force)
263    }
264
265    fn stage_entry(
266        &mut self,
267        schema: &TableSchema,
268        registry: &TableRegistry,
269        document: &Value,
270        claim: Option<&[String]>,
271        sealed: Vec<String>,
272        force: bool,
273    ) -> Result<String, StageError> {
274        let record = validate_record(
275            schema,
276            document,
277            ValidateOptions {
278                image_key: self.image_key,
279                ..ValidateOptions::default()
280            },
281        )?;
282        let key = primary_key_of(schema, &record)?;
283        assert_unique(schema, registry, &key, &record)?;
284        let part = partition_of(schema, &record)?;
285        assert_partition_stable(schema, registry, &key, &part)?;
286        let fields: Vec<String> = match claim {
287            Some(claim) => claim.to_vec(),
288            None => record.keys().cloned().collect(),
289        };
290        self.reserve(&key, &fields, force)?;
291        self.queued.push_back(QueuedEntry {
292            key: key.clone(),
293            fields,
294            kind: EntryKind::Put(record),
295            sealed,
296            part,
297        });
298        Ok(key)
299    }
300
301    /// The record of the entry queued last — how the caller of [`WriteQueue::stage_put`] reads back
302    /// what was validated and defaulted, without paying for a second `validate_record`. `None` when
303    /// nothing is queued or the last entry queued is a delete.
304    pub fn last_put_record(&self) -> Option<&HaysonRecord> {
305        match self.queued.back() {
306            Some(QueuedEntry {
307                kind: EntryKind::Put(record),
308                ..
309            }) => Some(record),
310            _ => None,
311        }
312    }
313
314    /// Queues a tombstone into `part` — the partition the key currently lives in, which the caller
315    /// reads off the registry, since a tombstone has to land in the same file as the record it
316    /// retires.
317    pub fn stage_delete(
318        &mut self,
319        key: impl Into<String>,
320        force: bool,
321        part: &str,
322    ) -> Result<(), HaysonPendingConflictError> {
323        let key = key.into();
324        let fields = vec![ALL_FIELDS.to_string()];
325        self.reserve(&key, &fields, force)?;
326        self.queued.push_back(QueuedEntry {
327            key,
328            fields,
329            kind: EntryKind::Delete,
330            sealed: Vec::new(),
331            part: part.to_string(),
332        });
333        Ok(())
334    }
335
336    fn reserve(&mut self, key: &str, fields: &[String], force: bool) -> Result<(), HaysonPendingConflictError> {
337        let conflicts = self.pending.conflicts_of(key, fields);
338        if !conflicts.is_empty() && !force {
339            return Err(HaysonPendingConflictError {
340                table: self.table.clone(),
341                key: key.to_string(),
342                fields: conflicts,
343            });
344        }
345        self.pending.claim(key, fields);
346        Ok(())
347    }
348
349    /// Commits every entry queued since the last call: one `append_lines` (plus one `sync` when
350    /// requested) per batch of at most `max_batch` entries, then updates `registry` and releases
351    /// the pending claims for each committed key, in the order the entries were staged. Writes no
352    /// `cypher` field, so it fails if any queued record carries one — callers with a schema that
353    /// declares `cypher` fields must go through [`WriteQueue::commit_sealed`] instead.
354    #[allow(dead_code)]
355    pub fn commit<L: LogAppender>(
356        &mut self,
357        log: &mut L,
358        registry: &mut TableRegistry,
359        seq: &mut u64,
360        sync: bool,
361    ) -> io::Result<Vec<WriteOutcome>> {
362        self.commit_sealed(log, registry, seq, sync, &|_key, record, _sealed| Ok(record.clone()))
363            .map_err(|error| match error {
364                CommitError::Io(error) => error,
365                CommitError::Cipher(error) => io::Error::other(error.to_string()),
366            })
367    }
368
369    /// Same as [`WriteQueue::commit`], but every queued `put` is passed through `seal` before it is
370    /// serialized into a log line — the plaintext record still goes to `registry` and the returned
371    /// [`WriteOutcome`], only the line written to `log` carries whatever `seal` returns. `seal` is
372    /// invoked once per `put` entry with its key, its plaintext record and the field names that
373    /// entry was staged as already sealed with (empty for anything staged through
374    /// [`WriteQueue::stage_put`]).
375    ///
376    /// A batch leaves the queue only once its append has landed. A refusal — the log turning the
377    /// append down, `seal` finding no key, the `sync` failing — abandons that batch and every one
378    /// still behind it: their claims are released and their keys kept for
379    /// [`WriteQueue::take_abandoned_keys`], so nothing of a write that never reached the log
380    /// survives as a pending claim a retry would conflict with. The batches that landed before it
381    /// stay committed; the `_seq` values the refused batch was stamped with stay burned.
382    pub fn commit_sealed<L: LogAppender>(
383        &mut self,
384        log: &mut L,
385        registry: &mut TableRegistry,
386        seq: &mut u64,
387        sync: bool,
388        seal: &Seal<'_>,
389    ) -> Result<Vec<WriteOutcome>, CommitError> {
390        let mut results = Vec::with_capacity(self.queued.len());
391        while !self.queued.is_empty() {
392            let take = self.max_batch.min(self.queued.len());
393            let now = chrono::Utc::now().timestamp_millis();
394
395            let landed = self.stamp_batch(take, seq, now, seal).and_then(|(lines, stamps)| {
396                let batch = &self.queued.make_contiguous()[..take];
397                let slots = append_by_part(log, batch, &lines)?;
398                if sync {
399                    log.sync()?;
400                }
401                Ok((slots, stamps))
402            });
403            let (slots, stamps) = match landed {
404                Ok(landed) => landed,
405                Err(error) => {
406                    self.abandon_queued();
407                    return Err(error);
408                }
409            };
410
411            for ((entry, slot), commit_seq) in self.queued.drain(..take).zip(slots).zip(stamps) {
412                match entry.kind {
413                    EntryKind::Put(record) => {
414                        registry.apply_put_in(&entry.key, &record, slot, commit_seq, &entry.part);
415                        results.push(WriteOutcome::Put(record));
416                    }
417                    EntryKind::Delete => {
418                        let existed = registry.slot_of(&entry.key).is_some();
419                        registry.apply_delete_in(&entry.key, slot.length, commit_seq, &entry.part);
420                        results.push(WriteOutcome::Deleted(existed));
421                    }
422                }
423                self.pending.release(&entry.key, &entry.fields);
424            }
425        }
426        Ok(results)
427    }
428
429    /// Serializes the first `take` queued entries into log lines, stamping each with the next
430    /// `_seq` — the half of a commit that touches nothing on disk.
431    fn stamp_batch(
432        &self,
433        take: usize,
434        seq: &mut u64,
435        now: i64,
436        seal: &Seal<'_>,
437    ) -> Result<(Vec<String>, Vec<u64>), CommitError> {
438        let mut lines = Vec::with_capacity(take);
439        let mut stamps = Vec::with_capacity(take);
440        for entry in self.queued.iter().take(take) {
441            *seq += 1;
442            stamps.push(*seq);
443            let line = match &entry.kind {
444                EntryKind::Put(record) => {
445                    let sealed = seal(&entry.key, record, &entry.sealed)?;
446                    serialize_entry(
447                        &LogMeta {
448                            key: entry.key.clone(),
449                            op: LogOp::Put,
450                            seq: *seq,
451                            ts: now,
452                        },
453                        Some(&sealed),
454                    )
455                }
456                EntryKind::Delete => serialize_entry(
457                    &LogMeta {
458                        key: entry.key.clone(),
459                        op: LogOp::Del,
460                        seq: *seq,
461                        ts: now,
462                    },
463                    None,
464                ),
465            };
466            lines.push(line);
467        }
468        Ok((lines, stamps))
469    }
470
471    /// Throws away everything still queued after a batch was refused, releasing each claim and
472    /// keeping each key for [`WriteQueue::take_abandoned_keys`]. The batches behind the refused one
473    /// go too: they would be refused the same way, or land after the caller has already heard that
474    /// its flush failed.
475    fn abandon_queued(&mut self) {
476        for entry in self.queued.drain(..) {
477            self.pending.release(&entry.key, &entry.fields);
478            self.abandoned.push(entry.key);
479        }
480    }
481}
482
483/// Appends one batch, one `append_lines_in` call per distinct partition it touches, and hands the
484/// slots back in the batch's own order. A batch that names a single partition — every batch of an
485/// unpartitioned table, and most batches of a partitioned one — makes exactly one append call, the
486/// same shape this queue had before partitions existed.
487fn append_by_part<L: LogAppender + ?Sized>(
488    log: &mut L,
489    batch: &[QueuedEntry],
490    lines: &[String],
491) -> io::Result<Vec<Slot>> {
492    let mut grouped: Vec<(&str, Vec<usize>)> = Vec::new();
493    for (position, entry) in batch.iter().enumerate() {
494        match grouped.iter_mut().find(|(part, _)| *part == entry.part) {
495            Some((_, positions)) => positions.push(position),
496            None => grouped.push((&entry.part, vec![position])),
497        }
498    }
499
500    let mut slots: Vec<Option<Slot>> = vec![None; batch.len()];
501    for (part, positions) in grouped {
502        let payload: Vec<String> = positions.iter().map(|position| lines[*position].clone()).collect();
503        let appended = log.append_lines_in(part, &payload)?;
504        for (position, slot) in positions.into_iter().zip(appended) {
505            slots[position] = Some(slot);
506        }
507    }
508    slots
509        .into_iter()
510        .map(|slot| slot.ok_or_else(|| io::Error::other("the log returned fewer slots than lines appended")))
511        .collect()
512}
513
514/// Refuses a write that would move an existing key out of the partition it already lives in — the
515/// record's old line stays where it is, so the two files would both claim the key. Mirrors the TS
516/// `Table.#assertPartitionStable`: delete the record first, then write it into the new partition.
517pub(crate) fn assert_partition_stable(
518    schema: &TableSchema,
519    registry: &TableRegistry,
520    key: &str,
521    part: &str,
522) -> Result<(), HaysonPartitionError> {
523    if part == UNPARTITIONED {
524        return Ok(());
525    }
526    let Some(held) = registry.part_of(key) else {
527        return Ok(());
528    };
529    if held == part {
530        return Ok(());
531    }
532    Err(HaysonPartitionError::new(
533        schema.table.clone(),
534        schema.partition_by.clone().unwrap_or_default(),
535        format!("key \"{key}\" already lives in partition \"{held}\" — delete it before writing it into \"{part}\""),
536    ))
537}
538
539/// What a batch of operations has already made of each key it named, as seen by an operation still
540/// being validated against the same batch: `Some(record)` for a key a put or patch will leave
541/// behind, `None` for one a delete will take away. Empty for a write that stands alone.
542pub(crate) type UnitShadow = HashMap<String, Option<HaysonRecord>>;
543
544pub(crate) fn assert_unique(
545    schema: &TableSchema,
546    registry: &TableRegistry,
547    key: &str,
548    record: &HaysonRecord,
549) -> Result<(), HaysonUniqueViolationError> {
550    assert_unique_in_unit(schema, registry, key, record, &UnitShadow::new())
551}
552
553fn virtual_path_of<'a>(record: &'a HaysonRecord, field: &str) -> Option<&'a str> {
554    record.get(field)?.as_object()?.get("virtualPath")?.as_str()
555}
556
557fn violation(schema: &TableSchema, field: String, holder: String) -> HaysonUniqueViolationError {
558    HaysonUniqueViolationError {
559        table: schema.table.clone(),
560        field,
561        holder,
562    }
563}
564
565/// True when the committed `holder` of a value has already been rewritten by this batch into
566/// something that no longer holds it — the committed index still names it, but the batch it belongs
567/// to will release it before anything lands, so it may not refuse `mine`.
568fn shadow_released(
569    shadow: &UnitShadow,
570    holder: &str,
571    mine: &str,
572    of: impl Fn(&HaysonRecord) -> Option<String>,
573) -> bool {
574    match shadow.get(holder) {
575        None => false,
576        Some(None) => true,
577        Some(Some(record)) => of(record).as_deref() != Some(mine),
578    }
579}
580
581/// Every uniqueness rule the table enforces, checked against the committed registry **and** against
582/// `shadow` — the records earlier operations of the same batch will leave behind. A unit of work
583/// appends nothing until it commits, so without `shadow` two operations of one unit can each pass
584/// against the committed state and still duplicate each other (ADR-3h3r).
585pub(crate) fn assert_unique_in_unit(
586    schema: &TableSchema,
587    registry: &TableRegistry,
588    key: &str,
589    record: &HaysonRecord,
590    shadow: &UnitShadow,
591) -> Result<(), HaysonUniqueViolationError> {
592    for index in registry.indexes() {
593        if !index.unique {
594            continue;
595        }
596        if index.fields.len() == 1 && index.fields[0] == schema.primary_key {
597            continue;
598        }
599        let unique_key = |candidate: &HaysonRecord| unique_key_of(candidate, &index.fields);
600        let Some(mine) = unique_key(record) else { continue };
601        for (other_key, other) in shadow {
602            if other_key == key {
603                continue;
604            }
605            let Some(other) = other else { continue };
606            if unique_key(other).as_deref() == Some(mine.as_str()) {
607                return Err(violation(schema, index.fields.join(", "), other_key.clone()));
608            }
609        }
610        if let Some(holder) = registry.unique_holder_of(index, record) {
611            if holder != key && !shadow_released(shadow, &holder, &mine, unique_key) {
612                return Err(violation(schema, index.fields.join(", "), holder));
613            }
614        }
615    }
616    for field in registry.virtual_path_fields() {
617        let path_of = |candidate: &HaysonRecord| virtual_path_of(candidate, field).map(str::to_string);
618        let Some(mine) = virtual_path_of(record, field) else {
619            continue;
620        };
621        for (other_key, other) in shadow {
622            if other_key == key {
623                continue;
624            }
625            let Some(other) = other else { continue };
626            if virtual_path_of(other, field) == Some(mine) {
627                return Err(violation(schema, field.clone(), other_key.clone()));
628            }
629        }
630        if let Some(holder) = registry.virtual_path_holder(field, mine) {
631            if holder != key && !shadow_released(shadow, &holder, mine, path_of) {
632                return Err(violation(schema, field.clone(), holder));
633            }
634        }
635    }
636    Ok(())
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::schema::parse_schema;
643    use serde_json::json;
644
645    fn users_schema() -> TableSchema {
646        parse_schema(&json!({
647            "schemaVersion": 1,
648            "table": "users",
649            "primaryKey": "id",
650            "fields": {
651                "id": { "type": "string" },
652                "name": { "type": "string", "required": true },
653                "email": { "type": "string", "unique": true },
654            },
655        }))
656        .unwrap()
657    }
658
659    struct CountingLog {
660        calls: usize,
661        synced: usize,
662        appended: Vec<String>,
663    }
664
665    impl CountingLog {
666        fn new() -> Self {
667            CountingLog {
668                calls: 0,
669                synced: 0,
670                appended: Vec::new(),
671            }
672        }
673    }
674
675    impl LogAppender for CountingLog {
676        fn append_lines(&mut self, lines: &[String]) -> io::Result<Vec<Slot>> {
677            self.calls += 1;
678            let start = self.appended.len() as u64;
679            let slots = lines
680                .iter()
681                .enumerate()
682                .map(|(i, line)| Slot {
683                    offset: start + i as u64,
684                    length: line.len() as u64,
685                })
686                .collect();
687            self.appended.extend(lines.iter().cloned());
688            Ok(slots)
689        }
690
691        fn sync(&mut self) -> io::Result<()> {
692            self.synced += 1;
693            Ok(())
694        }
695    }
696
697    #[test]
698    fn commits_every_entry_queued_since_the_last_call_in_one_append() {
699        let schema = users_schema();
700        let mut registry = TableRegistry::new(schema.clone());
701        let mut queue = WriteQueue::new("users");
702        let mut log = CountingLog::new();
703        let mut seq = 0u64;
704
705        queue
706            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Ania" }), false)
707            .unwrap();
708        queue
709            .stage_put(&schema, &registry, &json!({ "id": "u2", "name": "Piotr" }), false)
710            .unwrap();
711        assert_eq!(queue.depth(), 2);
712
713        let results = queue.commit(&mut log, &mut registry, &mut seq, true).unwrap();
714
715        assert_eq!(log.calls, 1);
716        assert_eq!(log.synced, 1);
717        assert_eq!(results.len(), 2);
718        assert_eq!(queue.depth(), 0);
719        assert_eq!(registry.records(), 2);
720    }
721
722    #[test]
723    fn a_second_write_to_the_same_field_conflicts_unless_forced() {
724        let schema = users_schema();
725        let registry = TableRegistry::new(schema.clone());
726        let mut queue = WriteQueue::new("users");
727
728        queue
729            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Ania" }), false)
730            .unwrap();
731        let err = queue
732            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Zosia" }), false)
733            .unwrap_err();
734        assert!(matches!(err, StageError::Conflict(_)));
735
736        queue
737            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Zosia" }), true)
738            .unwrap();
739        assert_eq!(queue.depth(), 2);
740    }
741
742    #[test]
743    fn a_delete_claims_every_field_and_conflicts_with_a_write_on_any_of_them() {
744        let schema = users_schema();
745        let registry = TableRegistry::new(schema.clone());
746        let mut queue = WriteQueue::new("users");
747
748        queue.stage_delete("u1", false, "").unwrap();
749        let err = queue
750            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Ania" }), false)
751            .unwrap_err();
752        assert!(matches!(err, StageError::Conflict(_)));
753    }
754
755    #[test]
756    fn releases_the_claim_only_after_the_batch_commits() {
757        let schema = users_schema();
758        let mut registry = TableRegistry::new(schema.clone());
759        let mut queue = WriteQueue::new("users");
760        let mut log = CountingLog::new();
761        let mut seq = 0u64;
762
763        queue
764            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Ania" }), false)
765            .unwrap();
766        assert_eq!(queue.pending("u1"), HashSet::from(["id".into(), "name".into()]));
767
768        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap();
769        assert!(queue.pending("u1").is_empty());
770    }
771
772    /// A log that refuses every append from the `failing_from`-th call on — a disk that filled up,
773    /// a file swapped out under the handle.
774    struct RefusingLog {
775        inner: CountingLog,
776        failing_from: usize,
777    }
778
779    impl LogAppender for RefusingLog {
780        fn append_lines(&mut self, lines: &[String]) -> io::Result<Vec<Slot>> {
781            if self.inner.calls + 1 >= self.failing_from {
782                self.inner.calls += 1;
783                return Err(io::Error::other("refused"));
784            }
785            self.inner.append_lines(lines)
786        }
787
788        fn sync(&mut self) -> io::Result<()> {
789            self.inner.sync()
790        }
791    }
792
793    #[test]
794    fn a_refused_append_leaves_nothing_queued_and_nothing_claimed() {
795        let schema = users_schema();
796        let mut registry = TableRegistry::new(schema.clone());
797        let mut queue = WriteQueue::new("users");
798        let mut log = RefusingLog {
799            inner: CountingLog::new(),
800            failing_from: 1,
801        };
802        let mut seq = 0u64;
803        queue
804            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Ania" }), false)
805            .unwrap();
806
807        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap_err();
808
809        assert_eq!(queue.depth(), 0, "the refused entry stayed queued");
810        assert!(queue.pending("u1").is_empty(), "the refused entry kept its claim");
811        assert_eq!(queue.take_abandoned_keys(), vec!["u1".to_string()]);
812        assert!(queue.take_abandoned_keys().is_empty(), "the keys are handed over once");
813        assert_eq!(registry.records(), 0);
814        queue
815            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Ania" }), false)
816            .expect("the same write can be staged again without force");
817    }
818
819    #[test]
820    fn a_refusal_mid_way_keeps_the_batches_that_landed_and_abandons_the_rest() {
821        let schema = users_schema();
822        let mut registry = TableRegistry::new(schema.clone());
823        let mut queue = WriteQueue::with_max_batch("users", 1);
824        let mut log = RefusingLog {
825            inner: CountingLog::new(),
826            failing_from: 2,
827        };
828        let mut seq = 0u64;
829        for (id, name) in [("u1", "Ania"), ("u2", "Piotr"), ("u3", "Zosia")] {
830            queue
831                .stage_put(&schema, &registry, &json!({ "id": id, "name": name }), false)
832                .unwrap();
833        }
834
835        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap_err();
836
837        assert_eq!(
838            registry.records(),
839            1,
840            "the batch that landed before the refusal is committed"
841        );
842        assert_eq!(queue.depth(), 0);
843        assert_eq!(queue.take_abandoned_keys(), vec!["u2".to_string(), "u3".to_string()]);
844        for key in ["u1", "u2", "u3"] {
845            assert!(queue.pending(key).is_empty(), "{key} kept its claim");
846        }
847    }
848
849    #[test]
850    fn rejects_a_second_record_claiming_a_unique_field_already_held() {
851        let schema = users_schema();
852        let mut registry = TableRegistry::new(schema.clone());
853        let mut queue = WriteQueue::new("users");
854        let mut log = CountingLog::new();
855        let mut seq = 0u64;
856
857        queue
858            .stage_put(
859                &schema,
860                &registry,
861                &json!({ "id": "u1", "name": "Ania", "email": "a@x.io" }),
862                false,
863            )
864            .unwrap();
865        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap();
866
867        let err = queue
868            .stage_put(
869                &schema,
870                &registry,
871                &json!({ "id": "u2", "name": "Zosia", "email": "a@x.io" }),
872                false,
873            )
874            .unwrap_err();
875        assert!(matches!(err, StageError::Unique(_)));
876    }
877
878    fn docs_schema() -> TableSchema {
879        parse_schema(&json!({
880            "schemaVersion": 1,
881            "table": "docs",
882            "primaryKey": "id",
883            "fields": {
884                "id": { "type": "string" },
885                "page": { "type": "markdown" },
886            },
887        }))
888        .unwrap()
889    }
890
891    #[test]
892    fn rejects_a_second_record_claiming_an_already_taken_markdown_virtual_path() {
893        let schema = docs_schema();
894        let mut registry = TableRegistry::new(schema.clone());
895        let mut queue = WriteQueue::new("docs");
896        let mut log = CountingLog::new();
897        let mut seq = 0u64;
898
899        queue
900            .stage_put(
901                &schema,
902                &registry,
903                &json!({ "id": "d1", "page": { "content": "a", "virtualPath": "/docs/a.md" } }),
904                false,
905            )
906            .unwrap();
907        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap();
908
909        let err = queue
910            .stage_put(
911                &schema,
912                &registry,
913                &json!({ "id": "d2", "page": { "content": "b", "virtualPath": "/docs/a.md" } }),
914                false,
915            )
916            .unwrap_err();
917        assert!(matches!(err, StageError::Unique(_)));
918    }
919
920    #[test]
921    fn dead_bytes_grow_when_an_existing_key_is_overwritten_or_removed() {
922        let schema = users_schema();
923        let mut registry = TableRegistry::new(schema.clone());
924        let mut queue = WriteQueue::new("users");
925        let mut log = CountingLog::new();
926        let mut seq = 0u64;
927
928        queue
929            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Ania" }), false)
930            .unwrap();
931        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap();
932        assert_eq!(registry.stats().dead_bytes, 0);
933
934        queue
935            .stage_put(&schema, &registry, &json!({ "id": "u1", "name": "Zosia" }), false)
936            .unwrap();
937        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap();
938        let after_overwrite = registry.stats().dead_bytes;
939        assert!(after_overwrite > 0);
940
941        queue.stage_delete("u1", false, "").unwrap();
942        queue.commit(&mut log, &mut registry, &mut seq, true).unwrap();
943        assert!(registry.stats().dead_bytes > after_overwrite);
944        assert_eq!(registry.records(), 0);
945    }
946
947    #[test]
948    fn splits_a_batch_larger_than_max_batch_into_several_appends() {
949        let schema = users_schema();
950        let mut registry = TableRegistry::new(schema.clone());
951        let mut queue = WriteQueue::with_max_batch("users", 2);
952        let mut log = CountingLog::new();
953        let mut seq = 0u64;
954
955        for i in 0..5 {
956            queue
957                .stage_put(
958                    &schema,
959                    &registry,
960                    &json!({ "id": format!("u{i}"), "name": "x" }),
961                    false,
962                )
963                .unwrap();
964        }
965        let results = queue.commit(&mut log, &mut registry, &mut seq, false).unwrap();
966        assert_eq!(results.len(), 5);
967        assert_eq!(log.calls, 3);
968        assert_eq!(log.synced, 0);
969    }
970}