Skip to main content

uni_common/api/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use std::path::PathBuf;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum UniError {
10    #[error("Database not found: {path}")]
11    NotFound { path: PathBuf },
12
13    #[error("Schema error: {message}")]
14    Schema { message: String },
15
16    #[error("Parse error: {message}")]
17    Parse {
18        message: String,
19        position: Option<usize>,
20        line: Option<usize>,
21        column: Option<usize>,
22        context: Option<String>,
23    },
24
25    #[error("Query error: {message}")]
26    Query {
27        message: String,
28        query: Option<String>,
29    },
30
31    #[error("Transaction error: {message}")]
32    Transaction { message: String },
33
34    #[error("Transaction conflict: {message}")]
35    TransactionConflict { message: String },
36
37    #[error("Transaction already completed")]
38    TransactionAlreadyCompleted,
39
40    /// A previous statement in this transaction failed, marking it rollback-only.
41    ///
42    /// Once any statement returns an error, the transaction is poisoned: it has
43    /// possibly half-applied rows in its private buffer, so it is no longer
44    /// committable (Neo4j-style rollback-only semantics). All further statements
45    /// and `commit()` are rejected with this error; only `rollback()` (or drop)
46    /// succeeds, discarding the partial writes. Start a fresh transaction to
47    /// retry.
48    #[error(
49        "Transaction is rollback-only: a previous statement failed; the transaction can no longer be committed and must be rolled back"
50    )]
51    TransactionRollbackOnly,
52
53    /// Operation not supported on read-only database
54    #[error("Operation '{operation}' not supported on read-only database")]
55    ReadOnly { operation: String },
56
57    /// Label not found in schema
58    #[error("Label '{label}' not found in schema")]
59    LabelNotFound { label: String },
60
61    /// Edge type not found in schema
62    #[error("Edge type '{edge_type}' not found in schema")]
63    EdgeTypeNotFound { edge_type: String },
64
65    /// Property not found on node/edge
66    #[error("Property '{property}' not found on {entity_type} with label '{label}'")]
67    PropertyNotFound {
68        property: String,
69        entity_type: String, // "node" or "edge"
70        label: String,
71    },
72
73    /// Index not found
74    #[error("Index '{index}' not found")]
75    IndexNotFound { index: String },
76
77    /// Snapshot not found
78    #[error("Snapshot '{snapshot_id}' not found")]
79    SnapshotNotFound { snapshot_id: String },
80
81    /// Query memory limit exceeded
82    #[error("Query exceeded memory limit of {limit_bytes} bytes")]
83    MemoryLimitExceeded { limit_bytes: usize },
84
85    #[error("Database is locked by another process")]
86    DatabaseLocked,
87
88    #[error("Operation timed out after {timeout_ms}ms")]
89    Timeout { timeout_ms: u64 },
90
91    /// A Locy program stopped before reaching its least fixed point because it
92    /// exceeded its wall-clock `timeout` or its `max_iterations` cap.
93    ///
94    /// This is the default outcome of an over-budget evaluation: partial results
95    /// are *not* returned silently. The boxed [`LocyIncomplete`] carries the
96    /// diagnostics (which rules were skipped, which complement rules are now
97    /// unsound, how far evaluation got). The partial facts themselves are not
98    /// embedded here — to recover them, re-run with `allow_partial` set, which
99    /// returns `Ok` with the partial result instead of this error.
100    #[error("Locy evaluation incomplete: {detail}")]
101    LocyIncomplete { detail: Box<LocyIncomplete> },
102
103    #[error("Type error: expected {expected}, got {actual}")]
104    Type { expected: String, actual: String },
105
106    #[error("Constraint violation: {message}")]
107    Constraint { message: String },
108
109    /// A transaction was aborted at commit because a concurrent transaction
110    /// committed a conflicting write since this transaction began (optimistic
111    /// concurrency control). The transaction may be safely retried.
112    #[error("Serialization conflict: {message}")]
113    SerializationConflict { message: String },
114
115    /// A transaction was aborted at commit because a concurrent transaction
116    /// committed a row with the same unique key (serializable MERGE). The
117    /// transaction may be safely retried, which will observe the existing row.
118    #[error("Constraint conflict: {message}")]
119    ConstraintConflict { message: String },
120
121    #[error("Storage error: {message}")]
122    Storage {
123        message: String,
124        #[source]
125        source: Option<Box<dyn std::error::Error + Send + Sync>>,
126    },
127
128    #[error("IO error: {0}")]
129    Io(#[from] std::io::Error),
130
131    #[error("Internal error: {0}")]
132    Internal(#[from] anyhow::Error),
133
134    #[error("Invalid identifier '{name}': {reason}")]
135    InvalidIdentifier { name: String, reason: String },
136
137    #[error("Label '{label}' already exists")]
138    LabelAlreadyExists { label: String },
139
140    #[error("Edge type '{edge_type}' already exists")]
141    EdgeTypeAlreadyExists { edge_type: String },
142
143    #[error("Permission denied: {action}")]
144    PermissionDenied { action: String },
145
146    #[error("Argument '{arg}' is invalid: {message}")]
147    InvalidArgument { arg: String, message: String },
148
149    /// Write context (transaction, bulk writer, or appender) is already active on session.
150    #[error("A write context is already active on session '{session_id}'")]
151    WriteContextAlreadyActive {
152        session_id: String,
153        hint: &'static str,
154    },
155
156    /// Transaction commit timed out waiting for the global writer lock.
157    #[error("Transaction '{tx_id}' commit timed out")]
158    CommitTimeout { tx_id: String, hint: &'static str },
159
160    /// A `FOR UPDATE` pessimistic row lock could not be acquired within the
161    /// deadline — the holder is another live transaction (contention or a
162    /// lock-ordering deadlock). Unlike a plain [`UniError::Timeout`] (a slow
163    /// operation that would just time out again), this is transient: a fresh
164    /// transaction can retry and win the lock once the holder releases it, so
165    /// it is classified retriable. See `is_retriable`.
166    #[error("FOR UPDATE lock acquisition timed out after {timeout_ms}ms")]
167    LockTimeout { timeout_ms: u64 },
168
169    /// Transaction exceeded its deadline.
170    #[error("Transaction '{tx_id}' expired")]
171    TransactionExpired { tx_id: String, hint: &'static str },
172
173    /// Operation was cancelled via a cancellation token.
174    #[error("Operation cancelled")]
175    Cancelled,
176
177    /// Derived facts are stale relative to the current database version.
178    #[error("Derived facts are stale: version gap is {version_gap}")]
179    StaleDerivedFacts { version_gap: u64 },
180
181    /// A Locy rule conflict was detected during transaction commit rule promotion.
182    #[error("Rule conflict: rule '{rule_name}' conflicts during promotion")]
183    RuleConflict { rule_name: String },
184
185    /// A session hook rejected the operation.
186    #[error("Hook rejected: {message}")]
187    HookRejected { message: String },
188
189    /// A synchronous trigger returned `TriggerOutcome::Reject` (or `Err`)
190    /// during a `BeforeMutation` / `BeforeCommit` phase, aborting commit.
191    #[error("Trigger '{trigger}' rejected commit: {reason}")]
192    TriggerRejected { trigger: String, reason: String },
193
194    /// Authentication failed (M5i). Raised when
195    /// `Uni::session_with_credentials` cannot find a matching
196    /// `AuthProvider` or the matched provider rejects the credentials.
197    #[error("Authentication failed: {reason}")]
198    AuthenticationFailed {
199        /// Human-readable failure reason.
200        reason: String,
201    },
202
203    /// An `AuthzPolicy::check` returned `Decision::Deny` for the
204    /// current principal (M5i).
205    #[error("Authorization denied: {reason}")]
206    AuthorizationDenied {
207        /// Reason from the deciding policy.
208        reason: String,
209    },
210
211    /// A write was attempted against an ephemeral (transient, in-query)
212    /// node or edge — i.e. one whose `Vid` / `Eid` has the
213    /// `EPHEMERAL_BIT` set. Ephemeral entities are return-only
214    /// projections; SET / DELETE / MERGE against them must fail before
215    /// they reach storage (M5g / proposal §4.13.1).
216    #[error("Cannot mutate ephemeral {kind} {id}: ephemeral entities are return-only")]
217    EphemeralWriteAttempt {
218        /// `"node"` or `"edge"`.
219        kind: &'static str,
220        /// Transient id (bottom 63 bits) for diagnostic output.
221        id: u64,
222    },
223
224    /// Fork with the given name does not exist in the registry.
225    #[error("Fork '{name}' not found")]
226    ForkNotFound { name: String },
227
228    /// `session.fork(name).new_()` was called against an existing fork.
229    #[error("Fork '{name}' already exists")]
230    ForkAlreadyExists { name: String },
231
232    /// Phase-1 gate: writes through `forked_session.tx()` are blocked
233    /// until Phase 2 lands. Reads, `locy()`, and admin paths work.
234    #[error(
235        "Writes on a forked session are not yet supported (Phase 2); reads, locy, and admin paths work"
236    )]
237    ForkWritesNotYetSupported,
238
239    /// Drop refused because forked sessions are still alive on the fork.
240    #[error("Fork '{name}' is held by {holder_count} live session(s); drop refused")]
241    ForkInUse { name: String, holder_count: usize },
242
243    /// Drop refused because a transaction has uncommitted mutations on the
244    /// fork. Commit or roll back the transaction first, then retry drop.
245    #[error("Fork '{name}' has uncommitted transaction state; commit or rollback first")]
246    ForkInflightTx { name: String },
247
248    /// Drop refused because the fork has pending async flushes that did
249    /// not drain within `UniConfig::drop_fork_drain_timeout`. Either retry
250    /// later (the streams will eventually complete) or raise the timeout.
251    #[error("Fork '{name}' has pending flushes that did not drain within timeout")]
252    PendingFlushTimeout { name: String },
253
254    /// Registry on disk is malformed (corrupt JSON, missing required field, etc.).
255    #[error("Fork registry is corrupt: {message}")]
256    ForkCorruptRegistry { message: String },
257
258    /// Drop refused because this fork has nested children. Use
259    /// `drop_fork_cascade` to remove the whole subtree, or drop the
260    /// children individually first.
261    #[error(
262        "Fork '{name}' has nested children {children:?}; use drop_fork_cascade or drop them first"
263    )]
264    ForkHasChildren { name: String, children: Vec<String> },
265
266    /// `drop_fork_cascade` refused because at least one fork in the
267    /// subtree has live sessions or in-flight transactions. No branch
268    /// has been deleted yet — the cascade is atomic at the validation
269    /// step. Resolve the blockers and retry.
270    #[error("Fork subtree cannot be dropped: {blockers:?}")]
271    ForkSubtreeInUse { blockers: Vec<String> },
272
273    /// `Session::fork(name)` refused because the configured `max_forks`
274    /// budget is at capacity. Drop existing forks (or wait for the
275    /// sweeper to reap expired ones) and retry. Counts include Active,
276    /// Pending, and Tombstoned entries.
277    #[error("Fork budget exceeded: {current}/{max} forks; drop one or raise UniConfig::max_forks")]
278    ForkBudgetExceeded { current: usize, max: usize },
279
280    /// 2PC step on a fork lifecycle operation failed.
281    ///
282    /// `stage` names the step (`registry_pending`, `create_branch`,
283    /// `registry_active`, `tombstone`, `delete_branch`, `registry_clear`,
284    /// `backend_unsupported`, `recovery`) so recovery and humans can
285    /// triage without parsing prose.
286    #[error("Fork '{name}' lifecycle failed at stage '{stage}': {source}")]
287    ForkLifecycle {
288        name: String,
289        stage: &'static str,
290        #[source]
291        source: Box<dyn std::error::Error + Send + Sync>,
292    },
293}
294
295impl UniError {
296    /// Returns `true` when retrying the failed operation from scratch may succeed.
297    ///
298    /// Distinguishes transient contention failures — optimistic-concurrency
299    /// aborts and lock/commit timeouts, which a fresh transaction can win — from
300    /// deterministic failures (bad query, schema or type violation) that would
301    /// fail identically on retry. This is the signal
302    /// [`Session::transact_with_retry`](../../../uni_db/api/session/struct.Session.html)
303    /// uses to decide whether to re-run a transaction closure.
304    ///
305    /// `TransactionExpired` is deliberately *not* retriable here: a fresh
306    /// transaction gets a new deadline, but the helper treats deadline expiry as
307    /// a caller-set budget, not a contention signal. A plain `Timeout` is
308    /// likewise *not* retriable — re-running the same slow operation would just
309    /// time out again; only `CommitTimeout` (lock contention at the commit point)
310    /// and `LockTimeout` (a contended `FOR UPDATE` row lock / deadlock) signal
311    /// retriable contention.
312    ///
313    /// # Examples
314    /// ```
315    /// use uni_common::UniError;
316    ///
317    /// assert!(UniError::SerializationConflict { message: "lost update".into() }.is_retriable());
318    /// assert!(!UniError::Schema { message: "no such label".into() }.is_retriable());
319    /// ```
320    #[must_use]
321    pub fn is_retriable(&self) -> bool {
322        matches!(
323            self,
324            UniError::SerializationConflict { .. }
325                | UniError::ConstraintConflict { .. }
326                | UniError::TransactionConflict { .. }
327                | UniError::CommitTimeout { .. }
328                | UniError::LockTimeout { .. }
329        )
330    }
331}
332
333pub type Result<T> = std::result::Result<T, UniError>;
334
335/// Why a Locy evaluation stopped before reaching its least fixed point.
336///
337/// A wall-clock timeout and a non-convergence failure are both *incomplete*
338/// outcomes, but they call for different remedies (raise the timeout / fix a
339/// slow rule vs. raise `max_iterations` / fix a non-monotone rule), so they are
340/// reported distinctly rather than collapsed into one flag.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum LocyIncompleteReason {
343    /// The wall-clock `timeout` budget was exhausted mid-evaluation.
344    Timeout,
345    /// A recursive stratum hit `max_iterations` without converging.
346    IterationLimit,
347}
348
349impl LocyIncompleteReason {
350    /// Returns a stable machine-readable tag (`"timeout"` / `"iteration_limit"`).
351    ///
352    /// Used as the discriminator surfaced to non-Rust callers (e.g. the Python
353    /// bindings), where matching on a Rust enum is not available.
354    #[must_use]
355    pub fn as_str(self) -> &'static str {
356        match self {
357            LocyIncompleteReason::Timeout => "timeout",
358            LocyIncompleteReason::IterationLimit => "iteration_limit",
359        }
360    }
361}
362
363impl std::fmt::Display for LocyIncompleteReason {
364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365        f.write_str(self.as_str())
366    }
367}
368
369/// Diagnostics describing a Locy evaluation that stopped before completing.
370///
371/// Returned (boxed) inside [`UniError::LocyIncomplete`] when a program exceeds
372/// its time or iteration budget, and also attached to a `LocyResult` when the
373/// caller opts into partial results. The rule lists exist so a caller can tell
374/// "not evaluated" apart from "genuinely empty": any rule named in
375/// `incomplete_rules` or `skipped_rules` may be missing facts purely because
376/// evaluation was cut short, so a zero-row count for it is not authoritative.
377///
378/// # Examples
379/// ```
380/// use uni_common::{LocyIncomplete, LocyIncompleteReason};
381///
382/// let detail = LocyIncomplete {
383///     reason: LocyIncompleteReason::Timeout,
384///     elapsed_ms: 305_000,
385///     limit_ms: 300_000,
386///     max_iterations: 1000,
387///     completed_strata: 2,
388///     total_strata: 4,
389///     incomplete_rules: vec!["upstream_reaches".into()],
390///     skipped_rules: vec!["healthy_assets".into()],
391///     complement_rules_affected: vec!["healthy_assets".into()],
392/// };
393/// assert!(detail.to_string().contains("timeout"));
394/// assert!(detail.to_string().contains("UNSOUND"));
395/// ```
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct LocyIncomplete {
398    /// Why evaluation stopped.
399    pub reason: LocyIncompleteReason,
400    /// Wall-clock time elapsed when evaluation was cut short, in milliseconds.
401    pub elapsed_ms: u64,
402    /// The configured wall-clock `timeout`, in milliseconds.
403    pub limit_ms: u64,
404    /// The configured `max_iterations` cap for recursive strata.
405    pub max_iterations: usize,
406    /// Number of strata fully evaluated before the cutoff.
407    pub completed_strata: usize,
408    /// Total number of strata in the program.
409    pub total_strata: usize,
410    /// Rules in the stratum that was interrupted mid-evaluation. Their facts may
411    /// be a partial fixpoint rather than the least fixed point.
412    pub incomplete_rules: Vec<String>,
413    /// Rules in strata that were never reached. They derived no facts solely
414    /// because evaluation stopped first, not because their result is empty.
415    pub skipped_rules: Vec<String>,
416    /// Subset of the incomplete/skipped rules that use an `IS NOT` complement.
417    /// Stratified negation over a partial relation is unsound, so these results
418    /// must not be trusted at all — surfaced separately for emphasis.
419    pub complement_rules_affected: Vec<String>,
420}
421
422impl std::fmt::Display for LocyIncomplete {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        write!(
425            f,
426            "{reason} after {elapsed_ms}ms (limit {limit_ms}ms, max_iterations {max_iters}); \
427             evaluated {done}/{total} strata, {n_incomplete} rule(s) incomplete, \
428             {n_skipped} rule(s) skipped",
429            reason = self.reason,
430            elapsed_ms = self.elapsed_ms,
431            limit_ms = self.limit_ms,
432            max_iters = self.max_iterations,
433            done = self.completed_strata,
434            total = self.total_strata,
435            n_incomplete = self.incomplete_rules.len(),
436            n_skipped = self.skipped_rules.len(),
437        )?;
438        if !self.complement_rules_affected.is_empty() {
439            write!(
440                f,
441                "; UNSOUND complement rule(s) affected: {:?}",
442                self.complement_rules_affected
443            )?;
444        }
445        Ok(())
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn retriable_errors_are_contention_failures() {
455        let s = String::new;
456        let retriable = [
457            UniError::SerializationConflict { message: s() },
458            UniError::ConstraintConflict { message: s() },
459            UniError::TransactionConflict { message: s() },
460            UniError::CommitTimeout {
461                tx_id: s(),
462                hint: "",
463            },
464            // A contended FOR UPDATE row lock / deadlock clears when the holder
465            // releases; a fresh transaction can retry and win it.
466            UniError::LockTimeout { timeout_ms: 10_000 },
467        ];
468        for e in &retriable {
469            assert!(e.is_retriable(), "{e:?} should be retriable");
470        }
471    }
472
473    #[test]
474    fn deterministic_errors_are_not_retriable() {
475        let s = String::new;
476        let terminal = [
477            UniError::Parse {
478                message: s(),
479                position: None,
480                line: None,
481                column: None,
482                context: None,
483            },
484            UniError::Query {
485                message: s(),
486                query: None,
487            },
488            UniError::Schema { message: s() },
489            UniError::Constraint { message: s() },
490            UniError::InvalidArgument {
491                arg: s(),
492                message: s(),
493            },
494            // A caller-set deadline is not a contention signal.
495            UniError::TransactionExpired {
496                tx_id: s(),
497                hint: "",
498            },
499            // Re-running the same slow operation would just time out again.
500            UniError::Timeout { timeout_ms: 1 },
501        ];
502        for e in &terminal {
503            assert!(!e.is_retriable(), "{e:?} should not be retriable");
504        }
505    }
506}