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}