Skip to main content

haematite_cli/serve/storage/
mutations.rs

1use haematite::branch::history::Key as HistoryKey;
2use haematite::branch::{
3    CommitDurability, CommitRequest, ForkWithChanges, ForkWrite, RefRecord, current_timestamp,
4    open as open_branch,
5};
6use haematite::db::branches::ForkKeptError;
7use haematite::tree::Hash;
8
9use super::{
10    Captured, Collection, DocumentRevision, Failure, Receipt, Storage, branch_name, collection_key,
11    counts, document_key, identifier, reads, receipt_key, revision_key, version,
12};
13use crate::serve::contracts::attribution::Row;
14use crate::serve::contracts::{Caller, local_owner};
15use crate::serve::protocol::{Change, DocumentImport, Mutation, ReceiptQuery, Selection, Version};
16use crate::serve::schema::Collection as CollectionSchema;
17
18pub(super) type PreparedWrite = (Vec<u8>, Option<Vec<u8>>);
19
20impl Storage {
21    /// Reconcile a receipt and retry only its reference fence, not the write.
22    ///
23    /// # Errors
24    ///
25    /// Propagates any error from `identifier`, `get_bytes`, `from_slice` or `publish_record`.
26    pub fn receipt(&mut self, query: &ReceiptQuery) -> Result<Option<Receipt>, Failure> {
27        identifier(&query.request_id)?;
28        identifier(&query.branch)?;
29        if let Some(receipt) = self.discard_receipt(query)? {
30            return Ok(Some(receipt));
31        }
32        if self.pending_discards.contains(&branch_name(&query.branch)) {
33            return Err(Failure::new(
34                "discard_pending",
35                "branch removal requires receipt reconciliation",
36            ));
37        }
38        // Reconciliation deliberately sees an installed but not yet published
39        // head. Ordinary readers use only the separately confirmed catalogue.
40        let record = self
41            .refs
42            .get(&branch_name(&query.branch))
43            .ok_or_else(|| Failure::new("branch_missing", &query.branch))?;
44        let at = Captured {
45            branch: query.branch.clone(),
46            version: version(record),
47            shards: record.shards.clone(),
48        };
49        let Some(bytes) = reads::get_bytes(
50            &self.reader,
51            &at,
52            &receipt_key(&query.branch, at.version.generation, &query.request_id),
53        )?
54        else {
55            return Ok(None);
56        };
57        let receipt: Receipt = serde_json::from_slice(&bytes).map_err(Failure::storage)?;
58        if receipt.request_id != query.request_id
59            || receipt.branch != query.branch
60            || receipt.version.generation != at.version.generation
61        {
62            return Err(Failure::new(
63                "receipt_corrupt",
64                "receipt identity does not match its stored key",
65            ));
66        }
67        self.refs
68            .confirm_version(
69                &branch_name(&query.branch),
70                at.version.generation,
71                at.version.sequence,
72            )
73            .map_err(|error| Failure::new("durability_unknown", error.to_string()))?;
74        self.publish_record(&branch_name(&query.branch))?;
75        Ok(Some(receipt))
76    }
77
78    /// One durable request identity, validation and effect within admission.
79    ///
80    /// # Errors
81    ///
82    /// Propagates any error from `identifier`, `to_vec`, `replay` or `prepare_change`.
83    pub fn mutate(&mut self, request: &Mutation) -> Result<Receipt, Failure> {
84        let author = local_owner()?;
85        self.mutate_with_import_bound(request, &author, None, super::AppendPruneLimits::default())
86    }
87
88    /// Admit imports up to sixty-four pages, preserving receipt-first replay.
89    ///
90    /// # Errors
91    /// Propagates mutation preparation, admission, replay and durable commit refusals without changing their outcome classification.
92    pub fn mutate_bounded(
93        &mut self,
94        request: &Mutation,
95        max_page: std::num::NonZeroU32,
96    ) -> Result<Receipt, Failure> {
97        let author = local_owner()?;
98        self.mutate_with_import_bound(
99            request,
100            &author,
101            Some(max_page),
102            super::AppendPruneLimits::default(),
103        )
104    }
105
106    /// Admit a mutation with the configured retention and import bounds.
107    ///
108    /// # Errors
109    /// Preserves receipt-first replay and propagates preparation or commit refusals.
110    pub fn mutate_with_append_prune_limits(
111        &mut self,
112        request: &Mutation,
113        max_page: std::num::NonZeroU32,
114        limits: super::AppendPruneLimits,
115    ) -> Result<Receipt, Failure> {
116        let author = local_owner()?;
117        self.mutate_with_import_bound(request, &author, Some(max_page), limits)
118    }
119
120    /// Admit a mutation made by `author`, the caller the service itself
121    /// established for the request (ACCESS-003 R4): its author row is written
122    /// in the same commit as the change and its receipt, so it costs no
123    /// flush of its own.
124    ///
125    /// # Errors
126    /// As [`Storage::mutate_with_append_prune_limits`], and the author row's
127    /// own refusal when it cannot be written within its bound.
128    pub fn mutate_by(
129        &mut self,
130        request: &Mutation,
131        author: &Caller,
132        max_page: std::num::NonZeroU32,
133        limits: super::AppendPruneLimits,
134    ) -> Result<Receipt, Failure> {
135        self.mutate_with_import_bound(request, author, Some(max_page), limits)
136    }
137
138    /// The live record a mutation names, at exactly the version it expects.
139    fn expected_record(&self, request: &Mutation) -> Result<RefRecord, Failure> {
140        let record = self
141            .refs
142            .get(&branch_name(&request.branch))
143            .ok_or_else(|| Failure::new("branch_missing", &request.branch))?;
144        if version(record) != request.expected {
145            return Err(Failure::new(
146                "version_conflict",
147                "expected branch generation or sequence differs",
148            ));
149        }
150        if self.pending_discards.contains(&record.name) {
151            return Err(Failure::new(
152                "discard_pending",
153                "branch removal requires receipt reconciliation",
154            ));
155        }
156        Ok(record.clone())
157    }
158
159    fn mutate_with_import_bound(
160        &mut self,
161        request: &Mutation,
162        author: &Caller,
163        import_max_page: Option<std::num::NonZeroU32>,
164        retention_limits: super::AppendPruneLimits,
165    ) -> Result<Receipt, Failure> {
166        identifier(&request.request_id)?;
167        identifier(&request.branch)?;
168        let destination = match &request.change {
169            Change::Fork(fork) => &fork.name,
170            _ => &request.branch,
171        };
172        identifier(destination)?;
173        let fingerprint =
174            Hash::of(&serde_json::to_vec(request).map_err(Failure::storage)?).to_string();
175        if let Some(receipt) = self.replay(request, destination, &fingerprint)? {
176            return Ok(receipt);
177        }
178        validate_import_bound(&request.change, import_max_page)?;
179        let record = self.expected_record(request)?;
180        let at = Captured {
181            branch: request.branch.clone(),
182            version: request.expected,
183            shards: record.shards.clone(),
184        };
185        if let Change::Fork(fork) = &request.change {
186            return self.fork_request(request, author, &fingerprint, &fork.name, fork.from);
187        }
188        if let Change::Discard(discard) = &request.change {
189            return self.discard_request(request, &fingerprint, &discard.name, &record);
190        }
191        let next = Version {
192            generation: record.created,
193            sequence: record.seq.checked_add(1).ok_or_else(|| {
194                Failure::new("sequence_exhausted", "branch sequence cannot advance")
195            })?,
196        };
197        let (mut writes, retention) = match &request.change {
198            Change::AppendPrune(write) => {
199                let (writes, result) =
200                    self.prepare_append_prune(&at, write, next, retention_limits)?;
201                (writes, Some(result))
202            }
203            change => (self.prepare_change(&at, change, next)?, None),
204        };
205        counts::append(&self.reader, &at, &request.change, &mut writes)?;
206        // Made before anything is written, so a row over its bound refuses
207        // the change rather than leaving it without its author.
208        let authored = author_write(request, author, next)?;
209        // A version the advance drops from the kept window has its row first
210        // (STOR-003 MD5a).
211        self.rows_before_advance(&request.branch, &record)?;
212        #[cfg(test)]
213        super::history::after_rows::reached()?;
214        let receipt = Receipt {
215            request_id: request.request_id.clone(),
216            fingerprint,
217            branch: request.branch.clone(),
218            version: next,
219            retention,
220        };
221        // The receipt, then the author's row, last, in the change's own commit.
222        let key = receipt_key(&request.branch, next.generation, &request.request_id);
223        writes.push((
224            key,
225            Some(serde_json::to_vec(&receipt).map_err(Failure::storage)?),
226        ));
227        writes.push((authored.0, Some(authored.1)));
228        let branch = open_branch(&record.name, &self.refs, &self.registry)
229            .map_err(Failure::branch_commit)?;
230        for (key, value) in writes {
231            let shard_id = self.writer.shard_for(&key);
232            match value {
233                Some(value) => branch.put(shard_id, key, value),
234                None => branch.delete(shard_id, key),
235            }
236            .map_err(Failure::branch)?;
237        }
238        let result = self.writer.commit(
239            &branch,
240            &self.registry,
241            CommitRequest {
242                durability: CommitDurability::Durable {
243                    refs: &mut self.refs,
244                },
245                extra_parents: &[],
246                timestamp: current_timestamp(),
247            },
248        );
249        if let Err(error) = result {
250            let installed = self
251                .refs
252                .get(&record.name)
253                .is_some_and(|now| version(now) != request.expected);
254            let mut failure = Failure::branch_commit(error);
255            let code = if installed {
256                "durability_unknown"
257            } else {
258                "write_refused"
259            };
260            code.clone_into(&mut failure.code);
261            return Err(failure);
262        }
263        #[cfg(test)]
264        lose_mutation_answer()?;
265        #[cfg(test)]
266        publish_pause_point();
267        self.publish_record(&record.name)?;
268        Ok(receipt)
269    }
270
271    fn replay(
272        &mut self,
273        request: &Mutation,
274        destination: &str,
275        fingerprint: &str,
276    ) -> Result<Option<Receipt>, Failure> {
277        // A fork receipt lives in its destination and is absent before creation.
278        if matches!(&request.change, Change::Discard(_))
279            || self.refs.get(&branch_name(destination)).is_some()
280        {
281            if let Some(receipt) = self.receipt(&ReceiptQuery {
282                branch: destination.to_owned(),
283                request_id: request.request_id.clone(),
284            })? {
285                return if receipt.fingerprint == fingerprint {
286                    Ok(Some(receipt))
287                } else {
288                    Err(Failure::new(
289                        "request_conflict",
290                        "request identity was used with different content",
291                    ))
292                };
293            }
294            if matches!(&request.change, Change::Fork(_)) {
295                return Err(Failure::new(
296                    "branch_exists",
297                    "destination exists without this fork receipt",
298                ));
299            }
300        }
301        Ok(None)
302    }
303
304    pub(super) fn prepare_change(
305        &self,
306        at: &Captured,
307        change: &Change,
308        next: Version,
309    ) -> Result<Vec<PreparedWrite>, Failure> {
310        match change {
311            Change::Collection(write) => {
312                identifier(&write.collection)?;
313                if let Some(schema) = &write.schema {
314                    CollectionSchema::compile(schema).map_err(schema_failure)?;
315                }
316                let collection = Collection {
317                    name: write.collection.clone(),
318                    schema: write.schema.clone(),
319                    changed_in: at.branch.clone(),
320                    changed_at: next,
321                };
322                Ok(vec![(
323                    collection_key(&write.collection),
324                    Some(serde_json::to_vec(&collection).map_err(Failure::storage)?),
325                )])
326            }
327            Change::Put(write) => {
328                identifier(&write.document_id)?;
329                let collection = reads::collection(&self.reader, at, &write.collection)?
330                    .ok_or_else(|| Failure::new("collection_missing", &write.collection))?;
331                if let Some(schema) = &collection.schema {
332                    CollectionSchema::compile(schema)
333                        .map_err(schema_failure)?
334                        .validate(
335                            &serde_json::from_str(write.document.get())
336                                .map_err(Failure::storage)?,
337                        )
338                        .map_err(schema_failure)?;
339                }
340                let revision = DocumentRevision {
341                    collection_branch: collection.changed_in,
342                    collection_version: collection.changed_at,
343                    written_in: at.branch.clone(),
344                    written_at: next,
345                };
346                Ok(vec![
347                    (
348                        document_key(&write.collection, &write.document_id),
349                        Some(write.document.get().as_bytes().to_vec()),
350                    ),
351                    (
352                        revision_key(&write.collection, &write.document_id),
353                        Some(serde_json::to_vec(&revision).map_err(Failure::storage)?),
354                    ),
355                ])
356            }
357            Change::AppendPrune(_) => Err(Failure::new(
358                "append_prune_preparation_required",
359                "retention uses bounded mutation preparation",
360            )),
361            Change::Delete(write) => {
362                identifier(&write.collection)?;
363                identifier(&write.document_id)?;
364                // A deleted document takes its edges, in both directions, and its
365                // vectors with it in the same version (D7).
366                let mut writes = vec![
367                    (document_key(&write.collection, &write.document_id), None),
368                    (revision_key(&write.collection, &write.document_id), None),
369                ];
370                writes.extend(super::edges::removals_for_document(
371                    &self.reader,
372                    at,
373                    &write.collection,
374                    &write.document_id,
375                )?);
376                writes.extend(super::vectors::removals_for_document(
377                    &self.reader,
378                    at,
379                    &write.collection,
380                    &write.document_id,
381                )?);
382                Ok(writes)
383            }
384            Change::DeleteMany(delete) => self.prepare_delete_many(at, delete, next),
385            Change::Merge(merge) => self.merge_writes(at, merge),
386            Change::Fork(_) | Change::Discard(_) => Err(Failure::new(
387                "invalid_operation",
388                "branch control uses the publication path",
389            )),
390            Change::EdgePut(edge) => super::edges::put(&self.reader, at, edge),
391            Change::EdgeDelete(edge) => super::edges::delete(edge),
392            Change::EdgePutMany(import) => super::edges::put_many(&self.reader, at, import),
393            Change::IndexDeclare(field) => {
394                super::field_index::declare(&self.reader, at, field, next)
395            }
396            Change::VectorDeclare(field) => super::vectors::declare(&self.reader, at, field),
397            Change::VectorPut(vector) => super::vectors::put(&self.reader, at, vector),
398            Change::VectorPutMany(import) => {
399                super::vector_import::prepare(&self.reader, at, import)
400            }
401            Change::VectorDelete(vector) => super::vectors::delete(vector),
402            Change::PutMany(import) => self.prepare_import(at, import, next),
403            Change::RelationPut(relation) => super::relations::put(&self.reader, at, relation),
404            Change::RelationDelete(relation) => super::relations::delete(relation),
405        }
406    }
407
408    fn prepare_import(
409        &self,
410        at: &Captured,
411        import: &DocumentImport,
412        next: Version,
413    ) -> Result<Vec<PreparedWrite>, Failure> {
414        if import.documents.is_empty() {
415            return Err(Failure {
416                code: "import_empty".to_owned(),
417                message: "an import must contain at least one document".to_owned(),
418                path: Some("/documents".to_owned()),
419                cause: None,
420            });
421        }
422        let collection = reads::collection(&self.reader, at, &import.collection)
423            .map_err(|mut failure| {
424                failure.path = Some("/collection".to_owned());
425                failure
426            })?
427            .ok_or_else(|| Failure {
428                code: "collection_missing".to_owned(),
429                message: import.collection.clone(),
430                path: Some("/collection".to_owned()),
431                cause: None,
432            })?;
433        let schema = collection
434            .schema
435            .as_ref()
436            .map(CollectionSchema::compile)
437            .transpose()
438            .map_err(schema_failure)?;
439        validate_import_rows(import, schema.as_ref())?;
440        let revision = serde_json::to_vec(&DocumentRevision {
441            collection_branch: collection.changed_in,
442            collection_version: collection.changed_at,
443            written_in: at.branch.clone(),
444            written_at: next,
445        })
446        .map_err(Failure::storage)?;
447        let mut writes = Vec::new();
448        for document in &import.documents {
449            writes.push((
450                document_key(&import.collection, &document.document_id),
451                Some(document.document.get().as_bytes().to_vec()),
452            ));
453            writes.push((
454                revision_key(&import.collection, &document.document_id),
455                Some(revision.clone()),
456            ));
457        }
458        Ok(writes)
459    }
460
461    fn fork_request(
462        &mut self,
463        request: &Mutation,
464        author: &Caller,
465        fingerprint: &str,
466        destination: &str,
467        from: Version,
468    ) -> Result<Receipt, Failure> {
469        if self.pending_discards.contains(&branch_name(destination)) {
470            return Err(Failure::new(
471                "discard_pending",
472                "destination removal requires receipt reconciliation",
473            ));
474        }
475        self.capture(&Selection {
476            branch: request.branch.clone(),
477            version: Some(from),
478        })?;
479        let current = self
480            .refs
481            .get(&branch_name(&request.branch))
482            .ok_or_else(|| Failure::new("branch_missing", &request.branch))?
483            .clone();
484        // `fork_kept` reads the row of the version forked from: the head's is
485        // written only when the head is that version (STOR-003 MD5b).
486        if from == version(&current) {
487            self.retain_current(&request.branch, &current)?;
488        }
489        // A kept version forked from has its row before `fork_kept` reads it
490        // (STOR-003 MD5a).
491        self.row_for_kept(&request.branch, &current, from)?;
492        #[cfg(test)]
493        super::history::after_rows::reached()?;
494        let service =
495            open_branch(super::SERVICE, &self.refs, &self.registry).map_err(|source| {
496                fork_kept_failure(&ForkKeptError::ServiceBindingStale { source }, false)
497            })?;
498        let history_key = HistoryKey {
499            branch: Hash::of(request.branch.as_bytes()),
500            generation: from.generation,
501            sequence: from.sequence,
502        };
503        let created = current_timestamp();
504        let receipt = Receipt {
505            request_id: request.request_id.clone(),
506            fingerprint: fingerprint.to_owned(),
507            branch: destination.to_owned(),
508            version: Version {
509                generation: created,
510                sequence: 0,
511            },
512            retention: None,
513        };
514        let key = receipt_key(destination, created, &request.request_id);
515        let (author_key, row) = author_write(request, author, receipt.version)?;
516        let writes = [
517            ForkWrite {
518                shard_id: self.writer.shard_for(&key),
519                key,
520                value: Some(serde_json::to_vec(&receipt).map_err(Failure::storage)?),
521            },
522            super::branches::provenance_write(&self.writer, &request.branch, from)?,
523            ForkWrite {
524                shard_id: self.writer.shard_for(&author_key),
525                key: author_key,
526                value: Some(row),
527            },
528        ];
529        let name = branch_name(destination);
530        self.writer
531            .fork_kept(
532                &service,
533                history_key,
534                &mut self.refs,
535                &self.registry,
536                ForkWithChanges {
537                    name: &name,
538                    timestamp: created,
539                    writes: &writes,
540                },
541            )
542            .map_err(|error| fork_kept_failure(&error, self.refs.get(&name).is_some()))?;
543        #[cfg(test)]
544        lose_mutation_answer()?;
545        self.publish_record(&name)?;
546        Ok(receipt)
547    }
548
549    /// Stop this thread's next committed mutation before it publishes.
550    ///
551    /// The pause point signals on `paused` once the store commit has returned,
552    /// then waits on `release` while still holding the mutation admission.
553    #[cfg(test)]
554    pub fn arm_publish_pause(
555        paused: std::sync::mpsc::Sender<()>,
556        release: std::sync::mpsc::Receiver<()>,
557    ) {
558        PUBLISH_PAUSE.with(|slot| {
559            *slot.borrow_mut() = Some(PublishPause { paused, release });
560        });
561    }
562
563    #[cfg(test)]
564    pub(crate) fn lose_next_mutation_answer() {
565        Self::lose_next_mutation_answer_with_message(
566            "injected lost answer after reference commit".to_owned(),
567        );
568    }
569
570    #[cfg(test)]
571    pub(crate) fn lose_next_mutation_answer_with_message(message: String) {
572        LOST_MUTATION_ANSWER.with(|slot| {
573            *slot.borrow_mut() = Some(message);
574        });
575    }
576}
577
578#[cfg(test)]
579thread_local! {
580    static LOST_MUTATION_ANSWER: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
581}
582
583#[cfg(test)]
584fn lose_mutation_answer() -> Result<(), Failure> {
585    if let Some(message) = LOST_MUTATION_ANSWER.with(|slot| slot.borrow_mut().take()) {
586        return Err(Failure::new("durability_unknown", message));
587    }
588    Ok(())
589}
590
591/// The author row of `request`, made by `author` and written at `written_at`:
592/// its key and its bytes (serve/contracts/attribution.rs). It names who made
593/// the request and never what the change touched, so its size does not grow
594/// with the change.
595///
596/// # Errors
597/// `invalid_identifier` for an invalid request id.
598pub(in crate::serve) fn author_write(
599    request: &Mutation,
600    author: &Caller,
601    written_at: Version,
602) -> Result<(Vec<u8>, Vec<u8>), Failure> {
603    let row = Row::by(&request.request_id, author, request.expected, written_at)?;
604    Ok((row.key()?, row.encode()?))
605}
606
607fn fork_kept_failure(error: &ForkKeptError, installed: bool) -> Failure {
608    let code = match error {
609        ForkKeptError::ServiceBindingStale { .. } => "kept_service_binding_stale",
610        ForkKeptError::ServiceInvalid { .. } => "kept_service_invalid",
611        ForkKeptError::ShardSetIncomplete { .. } => "kept_shard_set_incomplete",
612        ForkKeptError::RowMissing { .. } => "kept_row_missing",
613        ForkKeptError::RowInvalid { .. } => "kept_row_invalid",
614        ForkKeptError::RowRead { .. } => "kept_row_read",
615        ForkKeptError::RootUnavailable { .. } => "kept_root_unavailable",
616        ForkKeptError::Fork(_) if installed => "durability_unknown",
617        ForkKeptError::Fork(_) => "write_refused",
618    };
619    Failure::new(code, error.to_string())
620}
621
622#[cfg(test)]
623#[path = "mutations_kept_tests.rs"]
624mod kept_tests;
625
626fn validate_import_rows(
627    import: &DocumentImport,
628    schema: Option<&CollectionSchema>,
629) -> Result<(), Failure> {
630    let mut ids = std::collections::BTreeSet::new();
631    for (index, document) in import.documents.iter().enumerate() {
632        identifier(&document.document_id).map_err(|mut failure| {
633            failure.path = Some(format!("/documents/{index}/document_id"));
634            failure
635        })?;
636        if !ids.insert(document.document_id.as_str()) {
637            return Err(Failure {
638                code: "import_duplicate".to_owned(),
639                message: format!("repeated document_id {}", document.document_id),
640                path: Some(format!("/documents/{index}/document_id")),
641                cause: None,
642            });
643        }
644        if let Some(schema) = schema {
645            let value = serde_json::from_str(document.document.get()).map_err(|error| Failure {
646                code: "invalid_document".to_owned(),
647                message: error.to_string(),
648                path: Some(format!("/documents/{index}/document")),
649                cause: None,
650            })?;
651            schema.validate(&value).map_err(|error| Failure {
652                code: error.code().to_owned(),
653                message: error.reason,
654                path: Some(format!("/documents/{index}/document{}", error.path)),
655                cause: None,
656            })?;
657        }
658    }
659    Ok(())
660}
661
662fn validate_import_bound(
663    change: &Change,
664    max_page: Option<std::num::NonZeroU32>,
665) -> Result<(), Failure> {
666    let (rows, kind) = match change {
667        Change::PutMany(import) => (import.documents.len(), "documents"),
668        Change::DeleteMany(delete) => (delete.document_ids.len(), "document_ids"),
669        Change::EdgePutMany(import) => (import.edges.len(), "edges"),
670        Change::VectorPutMany(import) => (import.vectors.len(), "vectors"),
671        _ => return Ok(()),
672    };
673    let max_page = max_page.ok_or_else(|| {
674        Failure::new(
675            "import_limit_required",
676            "an import requires the service page bound",
677        )
678    })?;
679    let bound = u64::from(max_page.get())
680        .checked_mul(64)
681        .ok_or_else(|| Failure::new("import_limit", "import row bound exceeds u64"))?;
682    if u64::try_from(rows).map_err(Failure::storage)? > bound {
683        return Err(Failure {
684            code: "import_limit".to_owned(),
685            message: format!("maximum import size is {bound} {kind}"),
686            path: Some(format!("/{kind}/{bound}")),
687            cause: None,
688        });
689    }
690    Ok(())
691}
692
693fn schema_failure(error: crate::serve::schema::Violation) -> Failure {
694    Failure {
695        code: error.code().to_owned(),
696        message: error.reason,
697        path: Some(error.path),
698        cause: None,
699    }
700}
701
702#[cfg(test)]
703struct PublishPause {
704    paused: std::sync::mpsc::Sender<()>,
705    release: std::sync::mpsc::Receiver<()>,
706}
707
708#[cfg(test)]
709thread_local! {
710    static PUBLISH_PAUSE: std::cell::RefCell<Option<PublishPause>> =
711        const { std::cell::RefCell::new(None) };
712}
713
714/// Between the durable store commit and the catalogue publication. The ten
715/// second bound turns a lost signal into a named failure, not a hung test.
716#[cfg(test)]
717fn publish_pause_point() {
718    let Some(pause) = PUBLISH_PAUSE.with(|slot| slot.borrow_mut().take()) else {
719        return;
720    };
721    assert!(
722        pause.paused.send(()).is_ok(),
723        "paused signal receiver was dropped"
724    );
725    let wait = std::time::Duration::from_secs(10);
726    let released = pause.release.recv_timeout(wait);
727    assert!(released.is_ok(), "release signal not received within 10 s");
728}