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    /// The fork name is empty, all-whitespace, too long, or contains
233    /// control characters. Names flow into the registry key and on-disk
234    /// catalog, so they are validated before any state is created.
235    #[error("Invalid fork name: {reason}")]
236    ForkNameInvalid { reason: String },
237
238    /// Phase-1 gate: writes through `forked_session.tx()` are blocked
239    /// until Phase 2 lands. Reads, `locy()`, and admin paths work.
240    #[error(
241        "Writes on a forked session are not yet supported (Phase 2); reads, locy, and admin paths work"
242    )]
243    ForkWritesNotYetSupported,
244
245    /// Drop refused because forked sessions are still alive on the fork.
246    #[error("Fork '{name}' is held by {holder_count} live session(s); drop refused")]
247    ForkInUse { name: String, holder_count: usize },
248
249    /// Drop refused because a transaction has uncommitted mutations on the
250    /// fork. Commit or roll back the transaction first, then retry drop.
251    #[error("Fork '{name}' has uncommitted transaction state; commit or rollback first")]
252    ForkInflightTx { name: String },
253
254    /// Drop refused because the fork has pending async flushes that did
255    /// not drain within `UniConfig::drop_fork_drain_timeout`. Either retry
256    /// later (the streams will eventually complete) or raise the timeout.
257    #[error("Fork '{name}' has pending flushes that did not drain within timeout")]
258    PendingFlushTimeout { name: String },
259
260    /// Registry on disk is malformed (corrupt JSON, missing required field, etc.).
261    #[error("Fork registry is corrupt: {message}")]
262    ForkCorruptRegistry { message: String },
263
264    /// Drop refused because this fork has nested children. Use
265    /// `drop_fork_cascade` to remove the whole subtree, or drop the
266    /// children individually first.
267    #[error(
268        "Fork '{name}' has nested children {children:?}; use drop_fork_cascade or drop them first"
269    )]
270    ForkHasChildren { name: String, children: Vec<String> },
271
272    /// `drop_fork_cascade` refused because at least one fork in the
273    /// subtree has live sessions or in-flight transactions. No branch
274    /// has been deleted yet — the cascade is atomic at the validation
275    /// step. Resolve the blockers and retry.
276    #[error("Fork subtree cannot be dropped: {blockers:?}")]
277    ForkSubtreeInUse { blockers: Vec<String> },
278
279    /// `Session::fork(name)` refused because the configured `max_forks`
280    /// budget is at capacity. Drop existing forks (or wait for the
281    /// sweeper to reap expired ones) and retry. Counts include Active,
282    /// Pending, and Tombstoned entries.
283    #[error("Fork budget exceeded: {current}/{max} forks; drop one or raise UniConfig::max_forks")]
284    ForkBudgetExceeded { current: usize, max: usize },
285
286    /// 2PC step on a fork lifecycle operation failed.
287    ///
288    /// `stage` names the step (`registry_pending`, `create_branch`,
289    /// `registry_active`, `tombstone`, `delete_branch`, `registry_clear`,
290    /// `backend_unsupported`, `recovery`) so recovery and humans can
291    /// triage without parsing prose.
292    #[error("Fork '{name}' lifecycle failed at stage '{stage}': {source}")]
293    ForkLifecycle {
294        name: String,
295        stage: &'static str,
296        #[source]
297        source: Box<dyn std::error::Error + Send + Sync>,
298    },
299}
300
301impl UniError {
302    /// Returns `true` when retrying the failed operation from scratch may succeed.
303    ///
304    /// Distinguishes transient contention failures — optimistic-concurrency
305    /// aborts and lock/commit timeouts, which a fresh transaction can win — from
306    /// deterministic failures (bad query, schema or type violation) that would
307    /// fail identically on retry. This is the signal
308    /// [`Session::transact_with_retry`](../../../uni_db/api/session/struct.Session.html)
309    /// uses to decide whether to re-run a transaction closure.
310    ///
311    /// `TransactionExpired` is deliberately *not* retriable here: a fresh
312    /// transaction gets a new deadline, but the helper treats deadline expiry as
313    /// a caller-set budget, not a contention signal. A plain `Timeout` is
314    /// likewise *not* retriable — re-running the same slow operation would just
315    /// time out again; only `CommitTimeout` (lock contention at the commit point)
316    /// and `LockTimeout` (a contended `FOR UPDATE` row lock / deadlock) signal
317    /// retriable contention.
318    ///
319    /// # Examples
320    /// ```
321    /// use uni_common::UniError;
322    ///
323    /// assert!(UniError::SerializationConflict { message: "lost update".into() }.is_retriable());
324    /// assert!(!UniError::Schema { message: "no such label".into() }.is_retriable());
325    /// ```
326    #[must_use]
327    pub fn is_retriable(&self) -> bool {
328        matches!(
329            self,
330            UniError::SerializationConflict { .. }
331                | UniError::ConstraintConflict { .. }
332                | UniError::TransactionConflict { .. }
333                | UniError::CommitTimeout { .. }
334                | UniError::LockTimeout { .. }
335        )
336    }
337}
338
339pub type Result<T> = std::result::Result<T, UniError>;
340
341/// Why a Locy evaluation stopped before reaching its least fixed point.
342///
343/// A wall-clock timeout and a non-convergence failure are both *incomplete*
344/// outcomes, but they call for different remedies (raise the timeout / fix a
345/// slow rule vs. raise `max_iterations` / fix a non-monotone rule), so they are
346/// reported distinctly rather than collapsed into one flag.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum LocyIncompleteReason {
349    /// The wall-clock `timeout` budget was exhausted mid-evaluation.
350    Timeout,
351    /// A recursive stratum hit `max_iterations` without converging.
352    IterationLimit,
353}
354
355impl LocyIncompleteReason {
356    /// Returns a stable machine-readable tag (`"timeout"` / `"iteration_limit"`).
357    ///
358    /// Used as the discriminator surfaced to non-Rust callers (e.g. the Python
359    /// bindings), where matching on a Rust enum is not available.
360    #[must_use]
361    pub fn as_str(self) -> &'static str {
362        match self {
363            LocyIncompleteReason::Timeout => "timeout",
364            LocyIncompleteReason::IterationLimit => "iteration_limit",
365        }
366    }
367}
368
369impl std::fmt::Display for LocyIncompleteReason {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        f.write_str(self.as_str())
372    }
373}
374
375/// Diagnostics describing a Locy evaluation that stopped before completing.
376///
377/// Returned (boxed) inside [`UniError::LocyIncomplete`] when a program exceeds
378/// its time or iteration budget, and also attached to a `LocyResult` when the
379/// caller opts into partial results. The rule lists exist so a caller can tell
380/// "not evaluated" apart from "genuinely empty": any rule named in
381/// `incomplete_rules` or `skipped_rules` may be missing facts purely because
382/// evaluation was cut short, so a zero-row count for it is not authoritative.
383///
384/// # Examples
385/// ```
386/// use uni_common::{LocyIncomplete, LocyIncompleteReason};
387///
388/// let detail = LocyIncomplete {
389///     reason: LocyIncompleteReason::Timeout,
390///     elapsed_ms: 305_000,
391///     limit_ms: 300_000,
392///     max_iterations: 1000,
393///     completed_strata: 2,
394///     total_strata: 4,
395///     incomplete_rules: vec!["upstream_reaches".into()],
396///     skipped_rules: vec!["healthy_assets".into()],
397///     complement_rules_affected: vec!["healthy_assets".into()],
398/// };
399/// assert!(detail.to_string().contains("timeout"));
400/// assert!(detail.to_string().contains("UNSOUND"));
401/// ```
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct LocyIncomplete {
404    /// Why evaluation stopped.
405    pub reason: LocyIncompleteReason,
406    /// Wall-clock time elapsed when evaluation was cut short, in milliseconds.
407    pub elapsed_ms: u64,
408    /// The configured wall-clock `timeout`, in milliseconds.
409    pub limit_ms: u64,
410    /// The configured `max_iterations` cap for recursive strata.
411    pub max_iterations: usize,
412    /// Number of strata fully evaluated before the cutoff.
413    pub completed_strata: usize,
414    /// Total number of strata in the program.
415    pub total_strata: usize,
416    /// Rules in the stratum that was interrupted mid-evaluation. Their facts may
417    /// be a partial fixpoint rather than the least fixed point.
418    pub incomplete_rules: Vec<String>,
419    /// Rules in strata that were never reached. They derived no facts solely
420    /// because evaluation stopped first, not because their result is empty.
421    pub skipped_rules: Vec<String>,
422    /// Subset of the incomplete/skipped rules that use an `IS NOT` complement.
423    /// Stratified negation over a partial relation is unsound, so these results
424    /// must not be trusted at all — surfaced separately for emphasis.
425    pub complement_rules_affected: Vec<String>,
426}
427
428impl std::fmt::Display for LocyIncomplete {
429    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430        write!(
431            f,
432            "{reason} after {elapsed_ms}ms (limit {limit_ms}ms, max_iterations {max_iters}); \
433             evaluated {done}/{total} strata, {n_incomplete} rule(s) incomplete, \
434             {n_skipped} rule(s) skipped",
435            reason = self.reason,
436            elapsed_ms = self.elapsed_ms,
437            limit_ms = self.limit_ms,
438            max_iters = self.max_iterations,
439            done = self.completed_strata,
440            total = self.total_strata,
441            n_incomplete = self.incomplete_rules.len(),
442            n_skipped = self.skipped_rules.len(),
443        )?;
444        if !self.complement_rules_affected.is_empty() {
445            write!(
446                f,
447                "; UNSOUND complement rule(s) affected: {:?}",
448                self.complement_rules_affected
449            )?;
450        }
451        Ok(())
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn retriable_errors_are_contention_failures() {
461        let s = String::new;
462        let retriable = [
463            UniError::SerializationConflict { message: s() },
464            UniError::ConstraintConflict { message: s() },
465            UniError::TransactionConflict { message: s() },
466            UniError::CommitTimeout {
467                tx_id: s(),
468                hint: "",
469            },
470            // A contended FOR UPDATE row lock / deadlock clears when the holder
471            // releases; a fresh transaction can retry and win it.
472            UniError::LockTimeout { timeout_ms: 10_000 },
473        ];
474        for e in &retriable {
475            assert!(e.is_retriable(), "{e:?} should be retriable");
476        }
477    }
478
479    #[test]
480    fn deterministic_errors_are_not_retriable() {
481        let s = String::new;
482        let terminal = [
483            UniError::Parse {
484                message: s(),
485                position: None,
486                line: None,
487                column: None,
488                context: None,
489            },
490            UniError::Query {
491                message: s(),
492                query: None,
493            },
494            UniError::Schema { message: s() },
495            UniError::Constraint { message: s() },
496            UniError::InvalidArgument {
497                arg: s(),
498                message: s(),
499            },
500            // A caller-set deadline is not a contention signal.
501            UniError::TransactionExpired {
502                tx_id: s(),
503                hint: "",
504            },
505            // Re-running the same slow operation would just time out again.
506            UniError::Timeout { timeout_ms: 1 },
507        ];
508        for e in &terminal {
509            assert!(!e.is_retriable(), "{e:?} should not be retriable");
510        }
511    }
512}