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}