Skip to main content

a3s_vec/
collection.rs

1//! Thread-safe collection handle and transaction coordinator.
2
3mod checkpoint;
4mod configuration;
5mod index_api;
6mod maintenance;
7mod mutation;
8mod query_api;
9mod query_contract;
10mod query_engine;
11mod resource;
12mod validation;
13
14#[cfg(feature = "async")]
15mod async_api;
16
17use crate::config::{ConfigBuilder, IoBackend};
18use crate::doc::{Doc, DocumentMap};
19use crate::error::{Error, Result};
20use crate::index::IndexRegistry;
21use crate::schema::{AddColumnOption, AlterColumnOption, CollectionSchema, FieldSchema};
22use crate::stats::{assess_collection_health, CollectionHealthInput, StatsRegistry, StatsSnapshot};
23pub use crate::stats::{CollectionHealth, CollectionHealthStatus, IndexStat};
24use crate::storage::StorageHandle;
25use crate::storage_ceilings::StorageCeilings;
26use checkpoint::{
27    append_prepared_schema_change, persist_index_cache, publish_prepared_schema_change,
28};
29pub use configuration::CollectionOptions;
30use configuration::{options_config, resolved_storage_ceilings};
31pub use maintenance::{
32    CollectionMaintenanceHealth, CollectionMaintenanceOptions, CollectionMaintenancePhase,
33    CollectionMaintenanceRuntime,
34};
35pub use mutation::{DocWriteResult, WriteResult};
36use rayon::prelude::*;
37pub use resource::CollectionResourceLimits;
38use resource::ResourceUsage;
39use serde::{Deserialize, Serialize};
40use std::path::{Path, PathBuf};
41use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
42use std::sync::{Arc, Mutex, RwLock};
43use validation::{normalize_doc, parse_default_expression, validate_doc};
44
45/// Hard ceiling for a schema-evolution worker pool. Schema changes are
46/// collection-local maintenance work; allowing an untrusted `u32` directly
47/// into Rayon could otherwise reserve thousands of workers for a small batch.
48const MAX_SCHEMA_WORKERS: usize = 256;
49
50/// Public collection statistics (the fields used by the official SDK are kept
51/// first; additional counters are additive).
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct CollectionStats {
54    pub doc_count: u64,
55    pub indexes: Vec<IndexStat>,
56    pub revision: u64,
57    #[serde(default)]
58    pub index_cache_hit: bool,
59    /// Resolved sidecar backend for this collection handle. A cache miss may
60    /// rebuild indexes in memory without exercising the configured backend.
61    #[serde(default)]
62    pub io_backend: IoBackend,
63    pub read_only: bool,
64    pub wal_active_seq: u64,
65    pub wal_checkpoint_seq: u64,
66    pub wal_ops_since_checkpoint: u64,
67    pub wal_bytes_since_checkpoint: u64,
68    /// Deterministic serialized size of the authoritative document map.
69    #[serde(default)]
70    pub accounted_document_bytes: u64,
71    /// Sum of deterministic derived-index payload estimates.
72    #[serde(default)]
73    pub estimated_index_bytes: u64,
74    /// Authoritative document accounting plus derived-index estimates.
75    #[serde(default)]
76    pub accounted_bytes: u64,
77    /// Collection-local limits captured when this handle was opened.
78    #[serde(default)]
79    pub resource_limits: CollectionResourceLimits,
80    /// Persistence `DoS` ceilings captured when this handle was opened.
81    #[serde(default)]
82    pub storage_ceilings: StorageCeilings,
83    /// Operations rejected by this handle's resource policy.
84    #[serde(default)]
85    pub resource_limit_rejections: u64,
86}
87
88#[derive(Debug, Clone)]
89struct CollectionState {
90    path: PathBuf,
91    schema: CollectionSchema,
92    docs: Arc<DocumentMap>,
93    revision: u64,
94    options: CollectionOptions,
95    config: ConfigBuilder,
96    stats: Arc<StatsRegistry>,
97    indexes: Arc<IndexRegistry>,
98    index_cache_hit: bool,
99    resource_usage: ResourceUsage,
100}
101
102#[derive(Debug, Clone)]
103struct CollectionSnapshot {
104    schema: CollectionSchema,
105    docs: Arc<DocumentMap>,
106    revision: u64,
107    stats: Arc<StatsRegistry>,
108    indexes: Arc<IndexRegistry>,
109    resource_limits: CollectionResourceLimits,
110}
111
112#[derive(Debug)]
113struct CollectionInner {
114    state: RwLock<CollectionState>,
115    storage: Mutex<StorageHandle>,
116    writer: Mutex<()>,
117    closed: AtomicBool,
118    maintenance_claimed: AtomicBool,
119}
120
121/// Cheap, cloneable, thread-safe handle to one collection.
122#[derive(Clone, Debug)]
123pub struct Collection {
124    inner: Arc<CollectionInner>,
125}
126
127impl Collection {
128    pub fn create_and_open(
129        path: &str,
130        schema: &CollectionSchema,
131        options: Option<&CollectionOptions>,
132    ) -> Result<Self> {
133        let options = options.cloned().unwrap_or_default();
134        let config = options_config(&options);
135        let ceilings = resolved_storage_ceilings(&options);
136        let root = Path::new(path);
137        schema.validate()?;
138        let docs = DocumentMap::new();
139        let indexes = IndexRegistry::build(schema, &docs, 0)?;
140        let resource_usage = options
141            .resource_limits
142            .enforce_state(schema, &docs, &indexes)?;
143        let storage = StorageHandle::create(root, schema, options.read_only, ceilings)?;
144        let state = CollectionState {
145            path: root.to_path_buf(),
146            schema: schema.clone(),
147            docs: Arc::new(docs),
148            revision: 0,
149            options,
150            config,
151            stats: Arc::new(StatsRegistry::default()),
152            indexes: Arc::new(indexes),
153            index_cache_hit: false,
154            resource_usage,
155        };
156        Ok(Self {
157            inner: Arc::new(CollectionInner {
158                state: RwLock::new(state),
159                storage: Mutex::new(storage),
160                writer: Mutex::new(()),
161                closed: AtomicBool::new(false),
162                maintenance_claimed: AtomicBool::new(false),
163            }),
164        })
165    }
166
167    pub fn create(
168        path: &str,
169        schema: &CollectionSchema,
170        options: Option<&CollectionOptions>,
171    ) -> Result<Self> {
172        Self::create_and_open(path, schema, options)
173    }
174
175    pub fn open(path: &str, options: Option<&CollectionOptions>) -> Result<Self> {
176        let options = options.cloned().unwrap_or_default();
177        let config = options_config(&options);
178        let ceilings = resolved_storage_ceilings(&options);
179        let (storage, schema, docs) =
180            StorageHandle::open(Path::new(path), options.read_only, ceilings)?;
181        if schema.name.trim().is_empty() {
182            return Err(Error::internal("persisted collection has an empty name"));
183        }
184        let revision = storage.manifest.revision;
185        let mut recovered_docs = DocumentMap::new();
186        for doc in docs {
187            let doc = normalize_doc(&schema, &doc).map_err(|error| {
188                Error::internal(format!(
189                    "persisted document cannot be normalized: {}",
190                    error.message
191                ))
192            })?;
193            validate_doc(&schema, &doc, true).map_err(|error| {
194                Error::internal(format!("persisted document is invalid: {}", error.message))
195            })?;
196            let id = doc
197                .get_pk()
198                .ok_or_else(|| Error::internal("persisted document has no primary key"))?
199                .to_string();
200            if recovered_docs.insert(id.clone(), Arc::new(doc)).is_some() {
201                return Err(Error::internal(format!(
202                    "persisted collection contains duplicate primary key '{id}'"
203                )));
204            }
205        }
206        let docs = recovered_docs;
207        let cached_indexes = storage.read_index_cache().ok().flatten().and_then(|bytes| {
208            let diskann_file = storage.open_diskann_file().ok().flatten();
209            IndexRegistry::restore_cache(
210                &bytes,
211                diskann_file,
212                config.io_backend,
213                &schema,
214                &docs,
215                revision,
216                &storage.index_cache_identity(),
217                storage.ceilings,
218            )
219        });
220        let index_cache_hit = cached_indexes.is_some();
221        let indexes = cached_indexes.map_or_else(
222            || {
223                IndexRegistry::build(&schema, &docs, revision).map_err(|error| {
224                    Error::internal(format!(
225                        "rebuild persisted indexes at revision {revision}: {}",
226                        error.message
227                    ))
228                })
229            },
230            Ok,
231        )?;
232        let resource_usage = options
233            .resource_limits
234            .enforce_state(&schema, &docs, &indexes)?;
235        if !index_cache_hit && !options.read_only {
236            persist_index_cache(&storage, &schema, &indexes, revision, false);
237        }
238        let state = CollectionState {
239            path: PathBuf::from(path),
240            schema: schema.clone(),
241            docs: Arc::new(docs),
242            revision,
243            options,
244            config,
245            stats: Arc::new(StatsRegistry::default()),
246            indexes: Arc::new(indexes),
247            index_cache_hit,
248            resource_usage,
249        };
250        Ok(Self {
251            inner: Arc::new(CollectionInner {
252                state: RwLock::new(state),
253                storage: Mutex::new(storage),
254                writer: Mutex::new(()),
255                closed: AtomicBool::new(false),
256                maintenance_claimed: AtomicBool::new(false),
257            }),
258        })
259    }
260
261    pub fn path(&self) -> PathBuf {
262        self.inner
263            .state
264            .read()
265            .map(|state| state.path.clone())
266            .unwrap_or_default()
267    }
268
269    pub fn is_open(&self) -> bool {
270        !self.inner.closed.load(AtomicOrdering::Acquire)
271    }
272
273    pub fn flush(&self) -> Result<()> {
274        self.ensure_open()?;
275        let _writer = self
276            .inner
277            .writer
278            .lock()
279            .map_err(|_| Error::internal("writer lock poisoned"))?;
280        let (schema, docs, indexes, revision) = {
281            let state = self
282                .inner
283                .state
284                .read()
285                .map_err(|_| Error::internal("collection state lock poisoned"))?;
286            (
287                state.schema.clone(),
288                Arc::clone(&state.docs),
289                Arc::clone(&state.indexes),
290                state.revision,
291            )
292        };
293        let mut storage = self
294            .inner
295            .storage
296            .lock()
297            .map_err(|_| Error::internal("storage lock poisoned"))?;
298        storage.checkpoint(&schema, docs.as_ref(), revision, true)?;
299        persist_index_cache(&storage, &schema, &indexes, revision, true);
300        Ok(())
301    }
302
303    pub fn close(self) -> Result<()> {
304        if self.is_open() {
305            let read_only = self
306                .inner
307                .state
308                .read()
309                .map_err(|_| Error::internal("collection state lock poisoned"))?
310                .options
311                .read_only;
312            if !read_only {
313                self.flush()?;
314            }
315            self.inner.closed.store(true, AtomicOrdering::Release);
316        }
317        Ok(())
318    }
319
320    pub fn destroy(self) -> Result<()> {
321        let path = self.path();
322        self.close()?;
323        if path.exists() {
324            std::fs::remove_dir_all(&path)
325                .map_err(|e| Error::internal(format!("destroy collection: {e}")))?;
326        }
327        Ok(())
328    }
329
330    pub fn schema(&self) -> Result<CollectionSchema> {
331        self.ensure_open()?;
332        self.inner
333            .state
334            .read()
335            .map(|state| state.schema.clone())
336            .map_err(|_| Error::internal("collection state lock poisoned"))
337    }
338
339    pub fn stats(&self) -> Result<CollectionStats> {
340        self.ensure_open()?;
341        self.collect_stats().map(|(stats, _)| stats)
342    }
343
344    /// Assesses authoritative revision agreement and derived-index readiness.
345    ///
346    /// A pending WAL is reported but remains healthy because interval/manual
347    /// durability intentionally permits checkpoint lag. Unlike other data
348    /// methods, health remains observable after the shared handle is closed.
349    pub fn health(&self) -> Result<CollectionHealth> {
350        let (stats, storage_revision) = self.collect_stats()?;
351        Ok(assess_collection_health(CollectionHealthInput {
352            is_open: self.is_open(),
353            revision: stats.revision,
354            storage_revision,
355            doc_count: stats.doc_count,
356            indexes: &stats.indexes,
357            read_only: stats.read_only,
358            wal_ops_since_checkpoint: stats.wal_ops_since_checkpoint,
359            wal_bytes_since_checkpoint: stats.wal_bytes_since_checkpoint,
360            maintenance_active: self.inner.maintenance_claimed.load(AtomicOrdering::Acquire),
361        }))
362    }
363
364    fn collect_stats(&self) -> Result<(CollectionStats, u64)> {
365        let state = self
366            .inner
367            .state
368            .read()
369            .map_err(|_| Error::internal("collection state lock poisoned"))?;
370        let storage = self
371            .inner
372            .storage
373            .lock()
374            .map_err(|_| Error::internal("storage lock poisoned"))?;
375        let mut indexes = state
376            .indexes
377            .stats(&state.schema, &state.docs, state.revision);
378        indexes.sort_by(|left, right| left.name.cmp(&right.name));
379        let usage = state.resource_usage;
380        Ok((
381            CollectionStats {
382                doc_count: state.docs.len() as u64,
383                indexes,
384                revision: state.revision,
385                index_cache_hit: state.index_cache_hit,
386                io_backend: state.config.io_backend,
387                read_only: state.options.read_only,
388                wal_active_seq: storage.manifest.wal_active_seq,
389                wal_checkpoint_seq: storage.manifest.wal_checkpoint_seq,
390                wal_ops_since_checkpoint: storage.manifest.wal_ops_since_checkpoint,
391                wal_bytes_since_checkpoint: storage.manifest.wal_bytes_since_checkpoint,
392                accounted_document_bytes: usage.documents,
393                estimated_index_bytes: usage.indexes,
394                accounted_bytes: usage.total,
395                resource_limits: state.options.resource_limits,
396                storage_ceilings: storage.ceilings,
397                resource_limit_rejections: state
398                    .stats
399                    .resource_limit_rejections
400                    .load(AtomicOrdering::Relaxed),
401            },
402            storage.manifest.revision,
403        ))
404    }
405
406    pub fn stats_snapshot(&self) -> Result<StatsSnapshot> {
407        let basic = self.stats()?;
408        let state = self
409            .inner
410            .state
411            .read()
412            .map_err(|_| Error::internal("collection state lock poisoned"))?;
413        let registry = Arc::clone(&state.stats);
414        Ok(StatsSnapshot {
415            collection_name: state.schema.name.clone(),
416            revision: basic.revision,
417            doc_count: basic.doc_count,
418            query_count: registry.query_count.load(AtomicOrdering::Relaxed),
419            fts_query_count: registry.fts_query_count.load(AtomicOrdering::Relaxed),
420            fts_index_query_count: registry.fts_index_query_count.load(AtomicOrdering::Relaxed),
421            ann_query_count: registry.ann_query_count.load(AtomicOrdering::Relaxed),
422            diskann_query_count: registry.diskann_query_count.load(AtomicOrdering::Relaxed),
423            diskann_mmap_query_count: registry
424                .diskann_mmap_query_count
425                .load(AtomicOrdering::Relaxed),
426            diskann_sector_read_count: registry
427                .diskann_sector_read_count
428                .load(AtomicOrdering::Relaxed),
429            exact_query_count: registry.exact_query_count.load(AtomicOrdering::Relaxed),
430            filtered_query_count: registry.filtered_query_count.load(AtomicOrdering::Relaxed),
431            scalar_index_query_count: registry
432                .scalar_index_query_count
433                .load(AtomicOrdering::Relaxed),
434            radius_query_count: registry.radius_query_count.load(AtomicOrdering::Relaxed),
435            candidates_scanned: registry.candidates_scanned.load(AtomicOrdering::Relaxed),
436            indexed_field_count: basic.indexes.len(),
437            indexes: basic.indexes,
438            index_cache_hit: basic.index_cache_hit,
439            io_backend: basic.io_backend,
440            read_only: basic.read_only,
441            wal_active_seq: basic.wal_active_seq,
442            wal_checkpoint_seq: basic.wal_checkpoint_seq,
443            wal_ops_since_checkpoint: basic.wal_ops_since_checkpoint,
444            wal_bytes_since_checkpoint: basic.wal_bytes_since_checkpoint,
445            accounted_document_bytes: basic.accounted_document_bytes,
446            estimated_index_bytes: basic.estimated_index_bytes,
447            accounted_bytes: basic.accounted_bytes,
448            resource_limits: basic.resource_limits,
449            resource_limit_rejections: basic.resource_limit_rejections,
450        })
451    }
452
453    pub fn count(&self) -> Result<usize> {
454        self.ensure_open()?;
455        self.inner
456            .state
457            .read()
458            .map(|state| state.docs.len())
459            .map_err(|_| Error::internal("collection state lock poisoned"))
460    }
461
462    // ---------------------------------------------------------------------
463    // Index and schema management
464    // ---------------------------------------------------------------------
465
466    pub fn add_column(&self, field_schema: &FieldSchema, default_expr: Option<&str>) -> Result<()> {
467        self.add_column_with_options(field_schema, default_expr, AddColumnOption::default())
468    }
469
470    pub fn add_column_with_options(
471        &self,
472        field_schema: &FieldSchema,
473        default_expr: Option<&str>,
474        option: AddColumnOption,
475    ) -> Result<()> {
476        self.ensure_open()?;
477        let _writer = self
478            .inner
479            .writer
480            .lock()
481            .map_err(|_| Error::internal("writer lock poisoned"))?;
482        let state = self
483            .inner
484            .state
485            .write()
486            .map_err(|_| Error::internal("collection state lock poisoned"))?;
487        ensure_writable(&state.options)?;
488        let mut next = state.clone();
489        next.schema.add_field(field_schema)?;
490        let default = default_expr
491            .map(|expression| parse_default_expression(expression, field_schema.data_type))
492            .transpose()?;
493        if let Some(value) = default {
494            next.docs = Arc::new(transform_documents_with_concurrency(
495                &next.docs,
496                option.concurrency,
497                |doc| doc.set_field_value(&field_schema.name, value.clone()),
498            )?);
499        }
500        validate_documents_with_concurrency(&next.schema, &next.docs, option.concurrency)?;
501        let config = state.config.clone();
502        let previous_docs = Arc::clone(&state.docs);
503        let previous_revision = state.revision;
504        let previous_schema = state.schema.clone();
505        drop(state);
506        finish_schema_commit(
507            self,
508            &previous_docs,
509            previous_revision,
510            &previous_schema,
511            next,
512            &config,
513        )
514    }
515
516    pub fn drop_column(&self, name: &str) -> Result<()> {
517        self.ensure_open()?;
518        let _writer = self
519            .inner
520            .writer
521            .lock()
522            .map_err(|_| Error::internal("writer lock poisoned"))?;
523        let state = self
524            .inner
525            .state
526            .write()
527            .map_err(|_| Error::internal("collection state lock poisoned"))?;
528        ensure_writable(&state.options)?;
529        let mut next = state.clone();
530        next.schema.drop_field(name)?;
531        next.docs = Arc::new(transform_documents(&next.docs, |doc| {
532            doc.remove_field(name)
533        })?);
534        let config = state.config.clone();
535        let previous_docs = Arc::clone(&state.docs);
536        let previous_revision = state.revision;
537        let previous_schema = state.schema.clone();
538        drop(state);
539        finish_schema_commit(
540            self,
541            &previous_docs,
542            previous_revision,
543            &previous_schema,
544            next,
545            &config,
546        )
547    }
548
549    pub fn rename_column(&self, old_name: &str, new_name: &str) -> Result<()> {
550        if old_name.trim().is_empty() || old_name.contains('\0') {
551            return Err(Error::invalid_argument("old field name is invalid"));
552        }
553        if new_name.trim().is_empty() || new_name.contains('\0') {
554            return Err(Error::invalid_argument("new field name is invalid"));
555        }
556        if old_name == new_name {
557            return Ok(());
558        }
559        self.ensure_open()?;
560        let _writer = self
561            .inner
562            .writer
563            .lock()
564            .map_err(|_| Error::internal("writer lock poisoned"))?;
565        let state = self
566            .inner
567            .state
568            .write()
569            .map_err(|_| Error::internal("collection state lock poisoned"))?;
570        ensure_writable(&state.options)?;
571        let mut next = state.clone();
572        if next.schema.has_field(new_name) {
573            return Err(Error::already_exists(format!(
574                "field '{new_name}' already exists"
575            )));
576        }
577        if let Some(field) = next
578            .schema
579            .fields
580            .iter_mut()
581            .find(|field| field.name == old_name)
582        {
583            field.name = new_name.to_string();
584        } else if let Some(field) = next
585            .schema
586            .vectors
587            .iter_mut()
588            .find(|field| field.name == old_name)
589        {
590            field.name = new_name.to_string();
591        } else {
592            return Err(Error::not_found(format!("field '{old_name}' not found")));
593        }
594        next.docs = Arc::new(transform_documents(&next.docs, |doc| {
595            if let Some(value) = doc.field(old_name).cloned() {
596                doc.remove_field(old_name)?;
597                doc.set_field_value(new_name, value)?;
598            } else if let Some(value) = doc.vector(old_name).cloned() {
599                doc.remove_field(old_name)?;
600                doc.set_vector_value(new_name, value)?;
601            }
602            Ok(())
603        })?);
604        let config = state.config.clone();
605        let previous_docs = Arc::clone(&state.docs);
606        let previous_revision = state.revision;
607        let previous_schema = state.schema.clone();
608        drop(state);
609        finish_schema_commit(
610            self,
611            &previous_docs,
612            previous_revision,
613            &previous_schema,
614            next,
615            &config,
616        )
617    }
618
619    pub fn alter_column(
620        &self,
621        field_schema: &FieldSchema,
622        option: AlterColumnOption,
623    ) -> Result<()> {
624        self.ensure_open()?;
625        let _writer = self
626            .inner
627            .writer
628            .lock()
629            .map_err(|_| Error::internal("writer lock poisoned"))?;
630        let state = self
631            .inner
632            .state
633            .write()
634            .map_err(|_| Error::internal("collection state lock poisoned"))?;
635        ensure_writable(&state.options)?;
636        let mut next = state.clone();
637        let target = next
638            .schema
639            .fields
640            .iter_mut()
641            .find(|field| field.name == field_schema.name)
642            .ok_or_else(|| Error::not_found(format!("field '{}' not found", field_schema.name)))?;
643        if target.data_type != field_schema.data_type || target.dimension != field_schema.dimension
644        {
645            return Err(Error::invalid_argument(
646                "altering a field's data type or dimension would invalidate existing data",
647            ));
648        }
649        *target = field_schema.clone();
650        next.schema.validate()?;
651        validate_documents_with_concurrency(&next.schema, &next.docs, option.concurrency)?;
652        let config = state.config.clone();
653        let previous_docs = Arc::clone(&state.docs);
654        let previous_revision = state.revision;
655        let previous_schema = state.schema.clone();
656        drop(state);
657        finish_schema_commit(
658            self,
659            &previous_docs,
660            previous_revision,
661            &previous_schema,
662            next,
663            &config,
664        )
665    }
666
667    fn snapshot_state(&self) -> Result<CollectionSnapshot> {
668        let state = self
669            .inner
670            .state
671            .read()
672            .map_err(|_| Error::internal("collection state lock poisoned"))?;
673        Ok(CollectionSnapshot {
674            schema: state.schema.clone(),
675            docs: state.docs.clone(),
676            revision: state.revision,
677            stats: Arc::clone(&state.stats),
678            indexes: state.indexes.clone(),
679            resource_limits: state.options.resource_limits,
680        })
681    }
682
683    fn ensure_open(&self) -> Result<()> {
684        if self.inner.closed.load(AtomicOrdering::Acquire) {
685            Err(Error::failed_precondition("collection is closed"))
686        } else {
687            Ok(())
688        }
689    }
690
691    #[cfg(test)]
692    pub(crate) fn test_arm_wal_sync_stall(&self) -> crate::storage::StallGate {
693        let storage = self.inner.storage.lock().expect("storage lock poisoned");
694        storage.arm_wal_sync_stall()
695    }
696
697    #[cfg(test)]
698    pub(crate) fn test_arm_diskann_write_fault(&self) {
699        let storage = self.inner.storage.lock().expect("storage lock poisoned");
700        storage.arm_diskann_write_fault();
701    }
702
703    #[cfg(test)]
704    pub(crate) fn test_diskann_write_fault_fired(&self) -> bool {
705        let storage = self.inner.storage.lock().expect("storage lock poisoned");
706        storage.diskann_write_fault_fired()
707    }
708}
709
710fn ensure_writable(options: &CollectionOptions) -> Result<()> {
711    if options.read_only {
712        Err(Error::permission_denied("collection is read-only"))
713    } else {
714        Ok(())
715    }
716}
717
718fn ensure_same_generation(current: &CollectionState, expected: &CollectionState) -> Result<()> {
719    if current.revision == expected.revision && current.schema == expected.schema {
720        Ok(())
721    } else {
722        Err(Error::failed_precondition(
723            "collection generation changed during index construction",
724        ))
725    }
726}
727
728fn transform_documents(
729    docs: &DocumentMap,
730    transform: impl Fn(&mut Doc) -> Result<()> + Send + Sync,
731) -> Result<DocumentMap> {
732    transform_documents_with_concurrency(docs, 0, transform)
733}
734
735/// Applies a schema backfill using an optional, collection-local Rayon pool.
736///
737/// The input `OrdMap` is first materialized in its deterministic key order and
738/// the transformed results are collected in that same order before rebuilding
739/// the persistent map.  This keeps revision contents and error selection
740/// deterministic while allowing callers to bound the worker count explicitly.
741fn transform_documents_with_concurrency(
742    docs: &DocumentMap,
743    concurrency: u32,
744    transform: impl Fn(&mut Doc) -> Result<()> + Send + Sync,
745) -> Result<DocumentMap> {
746    let entries: Vec<(String, Arc<Doc>)> = docs
747        .iter()
748        .map(|(id, doc)| (id.clone(), Arc::clone(doc)))
749        .collect();
750    let transform_one = |(id, doc): &(String, Arc<Doc>)| {
751        let mut next = doc.as_ref().clone();
752        let result = transform(&mut next).map(|()| next);
753        (id.clone(), result)
754    };
755    let transformed: Vec<(String, Result<Doc>)> =
756        if let Some(threads) = schema_worker_count(concurrency, entries.len())? {
757            let pool = rayon::ThreadPoolBuilder::new()
758                .num_threads(threads)
759                .build()
760                .map_err(|error| {
761                    Error::resource_exhausted(format!("build schema worker pool: {error}"))
762                })?;
763            pool.install(|| entries.par_iter().map(transform_one).collect())
764        } else {
765            entries.iter().map(transform_one).collect()
766        };
767    let mut output = DocumentMap::new();
768    for (id, result) in transformed {
769        output.insert(id, Arc::new(result?));
770    }
771    Ok(output)
772}
773
774/// Validates every document against a candidate schema, optionally in a
775/// bounded local pool.  Results are reduced in input order so callers receive
776/// stable errors even when validation runs concurrently.
777fn validate_documents_with_concurrency(
778    schema: &CollectionSchema,
779    docs: &DocumentMap,
780    concurrency: u32,
781) -> Result<()> {
782    let entries: Vec<Arc<Doc>> = docs.values().cloned().collect();
783    let validate_one = |doc: &Arc<Doc>| validate_doc(schema, doc, true);
784    let Some(threads) = schema_worker_count(concurrency, entries.len())? else {
785        for doc in &entries {
786            validate_one(doc)?;
787        }
788        return Ok(());
789    };
790    let pool = rayon::ThreadPoolBuilder::new()
791        .num_threads(threads)
792        .build()
793        .map_err(|error| Error::resource_exhausted(format!("build schema worker pool: {error}")))?;
794    let results: Vec<Result<()>> = pool.install(|| entries.par_iter().map(validate_one).collect());
795    for result in results {
796        result?;
797    }
798    Ok(())
799}
800
801/// Resolves a requested schema worker count without allowing the public `u32`
802/// option to turn into an unbounded process-level thread request. A zero
803/// request retains the serial path; small collections and single-core hosts
804/// also avoid creating a private pool. The effective count is bounded by the
805/// amount of work, host parallelism, and a conservative engine ceiling.
806fn schema_worker_count(concurrency: u32, work_items: usize) -> Result<Option<usize>> {
807    if concurrency == 0 || work_items < 2 {
808        return Ok(None);
809    }
810    let requested = usize::try_from(concurrency)
811        .map_err(|_| Error::resource_exhausted("schema concurrency exceeds this platform"))?;
812    let available = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
813    let threads = requested
814        .min(work_items)
815        .min(available)
816        .min(MAX_SCHEMA_WORKERS);
817    Ok((threads > 1).then_some(threads))
818}
819
820fn finish_schema_commit(
821    collection: &Collection,
822    previous_docs: &Arc<DocumentMap>,
823    previous_revision: u64,
824    previous_schema: &CollectionSchema,
825    next: CollectionState,
826    config: &ConfigBuilder,
827) -> Result<()> {
828    let next = prepare_schema_change(next)?;
829    {
830        let mut storage = collection
831            .inner
832            .storage
833            .lock()
834            .map_err(|_| Error::internal("storage lock poisoned"))?;
835        append_prepared_schema_change(
836            &mut storage,
837            previous_docs,
838            previous_revision,
839            &next,
840            config,
841        )?;
842    }
843    let mut state = collection
844        .inner
845        .state
846        .write()
847        .map_err(|_| Error::internal("collection state lock poisoned"))?;
848    if state.revision != previous_revision || state.schema != *previous_schema {
849        return Err(Error::failed_precondition(
850            "collection generation changed during index construction",
851        ));
852    }
853    let mut storage = collection
854        .inner
855        .storage
856        .lock()
857        .map_err(|_| Error::internal("storage lock poisoned"))?;
858    publish_prepared_schema_change(&mut storage, &mut state, next, config)
859}
860
861fn prepare_schema_change(mut next: CollectionState) -> Result<CollectionState> {
862    let revision = next_revision(next.revision)?;
863    next.revision = revision;
864    next.indexes = Arc::new(IndexRegistry::build(&next.schema, &next.docs, revision)?);
865    next.resource_usage =
866        match next
867            .options
868            .resource_limits
869            .enforce_state(&next.schema, &next.docs, &next.indexes)
870        {
871            Ok(usage) => usage,
872            Err(error) => {
873                next.stats.record_resource_limit_rejection();
874                return Err(error);
875            }
876        };
877    Ok(next)
878}
879
880fn next_revision(current: u64) -> Result<u64> {
881    current
882        .checked_add(1)
883        .ok_or_else(|| Error::resource_exhausted("collection revision overflow"))
884}
885
886#[cfg(test)]
887mod ga_contract;
888#[cfg(test)]
889mod tests;