selene_graph/shared/builder.rs
1//! Shared graph construction helpers.
2
3use std::path::Path;
4use std::sync::Arc;
5
6use selene_core::GraphId;
7use selene_persist::{AuditLog, SyncPolicy, WalConfig, WalWriter};
8
9use super::SharedGraph;
10use crate::committer_batch::CommitBatching;
11use crate::error::{GraphError, GraphResult};
12use crate::graph::SeleneGraph;
13use crate::graph_types::GraphTypeDef;
14use crate::index_provider::IndexProvider;
15
16/// Builder for a [`SharedGraph`] and its fixed provider registry.
17pub struct SharedGraphBuilder {
18 graph: SeleneGraph,
19 providers: Vec<Arc<dyn IndexProvider>>,
20 wal_writer: Option<WalWriter>,
21 audit_log: Option<AuditLog>,
22 commit_batching: CommitBatching,
23}
24
25impl SharedGraphBuilder {
26 /// Construct a builder for an empty graph.
27 pub(super) fn new(graph_id: GraphId) -> Self {
28 Self {
29 graph: SeleneGraph::new(graph_id),
30 providers: Vec::new(),
31 wal_writer: None,
32 audit_log: None,
33 commit_batching: CommitBatching::Off,
34 }
35 }
36
37 /// Register an index provider.
38 ///
39 /// Providers are retained in registration order, which is the order used
40 /// for committed mutation delivery.
41 #[must_use]
42 pub fn with_provider(mut self, provider: Arc<dyn IndexProvider>) -> Self {
43 self.providers.push(provider);
44 self
45 }
46
47 /// Open a WAL file and route commits through the CORE durable provider.
48 ///
49 /// The path is the WAL file path, not a directory. Callers using the
50 /// conventional layout should pass `dir.join(selene_persist::DEFAULT_WAL_FILE_NAME)`.
51 ///
52 /// # SyncPolicy is OVERRIDDEN (v1.2 BRIEF 2 — read this)
53 ///
54 /// The single per-graph committer thread is the **sole fsync caller** for the
55 /// committer-managed WAL: it appends a contiguous run of commits with fsync
56 /// deferred, then issues exactly one [`WalWriter::flush`] per run (the R1
57 /// fsync-before-publish barrier). To make that the *only* fsync path, this
58 /// method **forces `config.sync_policy` to [`SyncPolicy::OnFlushOnly`]**
59 /// before opening the WAL — **whatever policy you pass is discarded.** The
60 /// fsync cadence is instead controlled by [`Self::with_commit_batching`]:
61 /// [`CommitBatching::Off`] (the default) fsyncs once per commit (behaviorally
62 /// identical to the old `EveryN(1)`), and [`CommitBatching::On`] coalesces a
63 /// contiguous run into one fsync. `config.snapshot_seq` is passed through
64 /// verbatim. Durability is unchanged: the committer always flushes before it
65 /// publishes or acks, so a commit is durable before it is ever visible.
66 ///
67 /// # Errors
68 ///
69 /// Returns [`GraphError::Persist`] when the WAL cannot be opened, including
70 /// when another writer already holds the file lock.
71 pub fn with_wal(mut self, path: impl AsRef<Path>, mut config: WalConfig) -> GraphResult<Self> {
72 // BRIEF 2: the committer owns fsync. Force OnFlushOnly before opening so
73 // the committer's group flush is the single durability barrier. Done
74 // before WalWriter::open so open-error timing (e.g. WriterLockHeld) is
75 // unchanged for existing .unwrap() call sites.
76 config.sync_policy = SyncPolicy::OnFlushOnly;
77 self.wal_writer = Some(WalWriter::open(path.as_ref(), config)?);
78 Ok(self)
79 }
80
81 /// Set the group-commit batching policy for the committer-managed WAL
82 /// (v1.2 BRIEF 2). Default [`CommitBatching::Off`].
83 ///
84 /// With [`CommitBatching::Off`] the committer fsyncs once per commit
85 /// (behaviorally identical to BRIEF 1). With [`CommitBatching::On`] it
86 /// coalesces up to `max_commits` (capped by aggregate `max_bytes`) contiguous
87 /// commits into one fsync — higher throughput + lower tail latency under
88 /// fan-in, at the cost of grouping several commits behind one barrier (all
89 /// still durable before any of them is acked or published). Has no effect
90 /// without [`Self::with_wal`] (no durable provider to flush).
91 #[must_use]
92 pub fn with_commit_batching(mut self, batching: CommitBatching) -> Self {
93 self.commit_batching = batching;
94 self
95 }
96
97 /// Attach a durable audit log at `path` (conventionally
98 /// `dir.join(selene_persist::DEFAULT_AUDIT_FILE_NAME)`).
99 ///
100 /// Engine-owned audit events committed through this graph are mirrored to
101 /// the audit log so they survive WAL-archive pruning (Item 7 / Seam D, D24).
102 /// Requires [`Self::with_wal`]: audit mirroring is part of the durable
103 /// commit path, so [`Self::build`] errors if an audit log is configured
104 /// without a WAL.
105 ///
106 /// # Errors
107 ///
108 /// Returns [`GraphError::Persist`] when the audit log cannot be opened.
109 pub fn with_audit_log(mut self, path: impl AsRef<Path>) -> GraphResult<Self> {
110 self.audit_log = Some(AuditLog::open(path.as_ref()).map_err(GraphError::Persist)?);
111 Ok(self)
112 }
113
114 /// Bind this graph to `type_def` at construction time.
115 ///
116 /// # Errors
117 ///
118 /// Returns [`GraphError::Inconsistent`] when the builder is already bound
119 /// or when `type_def` fails self-consistency validation.
120 pub fn bound_to(mut self, type_def: GraphTypeDef) -> GraphResult<Self> {
121 if self.graph.meta.bound_type.is_some() {
122 return Err(GraphError::Inconsistent {
123 reason: "graph builder is already bound to a graph type".to_owned(),
124 });
125 }
126 self.graph.meta.bound_type = Some(Arc::new(type_def.validate()?));
127 Ok(self)
128 }
129
130 /// Build shared graph state and validate provider registration.
131 ///
132 /// # Errors
133 ///
134 /// Returns [`GraphError::Provider`] when provider tags are duplicated.
135 pub fn build(self) -> GraphResult<SharedGraph> {
136 SharedGraph::from_graph_with_core_and_durables(
137 self.graph,
138 self.providers,
139 Vec::new(),
140 self.wal_writer,
141 self.audit_log,
142 self.commit_batching,
143 )
144 }
145}