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    /// A GraphCompute invocation stopped before producing a complete result
104    /// because it exceeded its wall-clock deadline, its convergence-iteration
105    /// cap, or its native-work budget.
106    ///
107    /// Like [`UniError::LocyIncomplete`], this is the *default* outcome of an
108    /// over-budget run: partial output is never returned silently (GraphCompute
109    /// proposal §5.2). The boxed [`GraphComputeIncomplete`] distinguishes
110    /// `Timeout` (too slow) from `IterationLimit` (did not converge) from
111    /// `Exhausted` (hit the native-work meter), so a caller can pick the right
112    /// remedy. To recover an anytime partial result, re-run with `allow_partial`.
113    #[error("GraphCompute invocation incomplete: {detail}")]
114    GraphComputeIncomplete { detail: Box<GraphComputeIncomplete> },
115
116    #[error("Type error: expected {expected}, got {actual}")]
117    Type { expected: String, actual: String },
118
119    #[error("Constraint violation: {message}")]
120    Constraint { message: String },
121
122    /// A transaction was aborted at commit because a concurrent transaction
123    /// committed a conflicting write since this transaction began (optimistic
124    /// concurrency control). The transaction may be safely retried.
125    #[error("Serialization conflict: {message}")]
126    SerializationConflict { message: String },
127
128    /// A transaction was aborted at commit because a concurrent transaction
129    /// committed a row with the same unique key (serializable MERGE). The
130    /// transaction may be safely retried, which will observe the existing row.
131    #[error("Constraint conflict: {message}")]
132    ConstraintConflict { message: String },
133
134    #[error("Storage error: {message}")]
135    Storage {
136        message: String,
137        #[source]
138        source: Option<Box<dyn std::error::Error + Send + Sync>>,
139    },
140
141    #[error("IO error: {0}")]
142    Io(#[from] std::io::Error),
143
144    #[error("Internal error: {0}")]
145    Internal(#[from] anyhow::Error),
146
147    #[error("Invalid identifier '{name}': {reason}")]
148    InvalidIdentifier { name: String, reason: String },
149
150    #[error("Label '{label}' already exists")]
151    LabelAlreadyExists { label: String },
152
153    #[error("Edge type '{edge_type}' already exists")]
154    EdgeTypeAlreadyExists { edge_type: String },
155
156    #[error("Permission denied: {action}")]
157    PermissionDenied { action: String },
158
159    #[error("Argument '{arg}' is invalid: {message}")]
160    InvalidArgument { arg: String, message: String },
161
162    /// Write context (transaction, bulk writer, or appender) is already active on session.
163    #[error("A write context is already active on session '{session_id}'")]
164    WriteContextAlreadyActive {
165        session_id: String,
166        hint: &'static str,
167    },
168
169    /// Transaction commit timed out waiting for the global writer lock.
170    #[error("Transaction '{tx_id}' commit timed out")]
171    CommitTimeout { tx_id: String, hint: &'static str },
172
173    /// A `FOR UPDATE` pessimistic row lock could not be acquired within the
174    /// deadline — the holder is another live transaction (contention or a
175    /// lock-ordering deadlock). Unlike a plain [`UniError::Timeout`] (a slow
176    /// operation that would just time out again), this is transient: a fresh
177    /// transaction can retry and win the lock once the holder releases it, so
178    /// it is classified retriable. See `is_retriable`.
179    #[error("FOR UPDATE lock acquisition timed out after {timeout_ms}ms")]
180    LockTimeout { timeout_ms: u64 },
181
182    /// Transaction exceeded its deadline.
183    #[error("Transaction '{tx_id}' expired")]
184    TransactionExpired { tx_id: String, hint: &'static str },
185
186    /// Operation was cancelled via a cancellation token.
187    #[error("Operation cancelled")]
188    Cancelled,
189
190    /// Derived facts are stale relative to the current database version.
191    #[error("Derived facts are stale: version gap is {version_gap}")]
192    StaleDerivedFacts { version_gap: u64 },
193
194    /// A Locy rule conflict was detected during transaction commit rule promotion.
195    #[error("Rule conflict: rule '{rule_name}' conflicts during promotion")]
196    RuleConflict { rule_name: String },
197
198    /// A session hook rejected the operation.
199    #[error("Hook rejected: {message}")]
200    HookRejected { message: String },
201
202    /// A synchronous trigger returned `TriggerOutcome::Reject` (or `Err`)
203    /// during a `BeforeMutation` / `BeforeCommit` phase, aborting commit.
204    #[error("Trigger '{trigger}' rejected commit: {reason}")]
205    TriggerRejected { trigger: String, reason: String },
206
207    /// Authentication failed (M5i). Raised when
208    /// `Uni::session_with_credentials` cannot find a matching
209    /// `AuthProvider` or the matched provider rejects the credentials.
210    #[error("Authentication failed: {reason}")]
211    AuthenticationFailed {
212        /// Human-readable failure reason.
213        reason: String,
214    },
215
216    /// An `AuthzPolicy::check` returned `Decision::Deny` for the
217    /// current principal (M5i).
218    #[error("Authorization denied: {reason}")]
219    AuthorizationDenied {
220        /// Reason from the deciding policy.
221        reason: String,
222    },
223
224    /// A write was attempted against an ephemeral (transient, in-query)
225    /// node or edge — i.e. one whose `Vid` / `Eid` has the
226    /// `EPHEMERAL_BIT` set. Ephemeral entities are return-only
227    /// projections; SET / DELETE / MERGE against them must fail before
228    /// they reach storage (M5g / proposal §4.13.1).
229    #[error("Cannot mutate ephemeral {kind} {id}: ephemeral entities are return-only")]
230    EphemeralWriteAttempt {
231        /// `"node"` or `"edge"`.
232        kind: &'static str,
233        /// Transient id (bottom 63 bits) for diagnostic output.
234        id: u64,
235    },
236
237    /// Fork with the given name does not exist in the registry.
238    #[error("Fork '{name}' not found")]
239    ForkNotFound { name: String },
240
241    /// `session.fork(name).new_()` was called against an existing fork.
242    #[error("Fork '{name}' already exists")]
243    ForkAlreadyExists { name: String },
244
245    /// The fork name is empty, all-whitespace, too long, or contains
246    /// control characters. Names flow into the registry key and on-disk
247    /// catalog, so they are validated before any state is created.
248    #[error("Invalid fork name: {reason}")]
249    ForkNameInvalid { reason: String },
250
251    /// Phase-1 gate: writes through `forked_session.tx()` are blocked
252    /// until Phase 2 lands. Reads, `locy()`, and admin paths work.
253    #[error(
254        "Writes on a forked session are not yet supported (Phase 2); reads, locy, and admin paths work"
255    )]
256    ForkWritesNotYetSupported,
257
258    /// Drop refused because forked sessions are still alive on the fork.
259    #[error("Fork '{name}' is held by {holder_count} live session(s); drop refused")]
260    ForkInUse { name: String, holder_count: usize },
261
262    /// Drop refused because a transaction has uncommitted mutations on the
263    /// fork. Commit or roll back the transaction first, then retry drop.
264    #[error("Fork '{name}' has uncommitted transaction state; commit or rollback first")]
265    ForkInflightTx { name: String },
266
267    /// Drop refused because the fork has pending async flushes that did
268    /// not drain within `UniConfig::drop_fork_drain_timeout`. Either retry
269    /// later (the streams will eventually complete) or raise the timeout.
270    #[error("Fork '{name}' has pending flushes that did not drain within timeout")]
271    PendingFlushTimeout { name: String },
272
273    /// Registry on disk is malformed (corrupt JSON, missing required field, etc.).
274    #[error("Fork registry is corrupt: {message}")]
275    ForkCorruptRegistry { message: String },
276
277    /// Drop refused because this fork has nested children. Use
278    /// `drop_fork_cascade` to remove the whole subtree, or drop the
279    /// children individually first.
280    #[error(
281        "Fork '{name}' has nested children {children:?}; use drop_fork_cascade or drop them first"
282    )]
283    ForkHasChildren { name: String, children: Vec<String> },
284
285    /// `drop_fork_cascade` refused because at least one fork in the
286    /// subtree has live sessions or in-flight transactions. No branch
287    /// has been deleted yet — the cascade is atomic at the validation
288    /// step. Resolve the blockers and retry.
289    #[error("Fork subtree cannot be dropped: {blockers:?}")]
290    ForkSubtreeInUse { blockers: Vec<String> },
291
292    /// `Session::fork(name)` refused because the configured `max_forks`
293    /// budget is at capacity. Drop existing forks (or wait for the
294    /// sweeper to reap expired ones) and retry. Counts include Active,
295    /// Pending, and Tombstoned entries.
296    #[error("Fork budget exceeded: {current}/{max} forks; drop one or raise UniConfig::max_forks")]
297    ForkBudgetExceeded { current: usize, max: usize },
298
299    /// 2PC step on a fork lifecycle operation failed.
300    ///
301    /// `stage` names the step (`registry_pending`, `create_branch`,
302    /// `registry_active`, `tombstone`, `delete_branch`, `registry_clear`,
303    /// `backend_unsupported`, `recovery`) so recovery and humans can
304    /// triage without parsing prose.
305    #[error("Fork '{name}' lifecycle failed at stage '{stage}': {source}")]
306    ForkLifecycle {
307        name: String,
308        stage: &'static str,
309        #[source]
310        source: Box<dyn std::error::Error + Send + Sync>,
311    },
312}
313
314impl UniError {
315    /// Returns `true` when retrying the failed operation from scratch may succeed.
316    ///
317    /// Distinguishes transient contention failures — optimistic-concurrency
318    /// aborts and lock/commit timeouts, which a fresh transaction can win — from
319    /// deterministic failures (bad query, schema or type violation) that would
320    /// fail identically on retry. This is the signal
321    /// [`Session::transact_with_retry`](../../../uni_db/api/session/struct.Session.html)
322    /// uses to decide whether to re-run a transaction closure.
323    ///
324    /// `TransactionExpired` is deliberately *not* retriable here: a fresh
325    /// transaction gets a new deadline, but the helper treats deadline expiry as
326    /// a caller-set budget, not a contention signal. A plain `Timeout` is
327    /// likewise *not* retriable — re-running the same slow operation would just
328    /// time out again; only `CommitTimeout` (lock contention at the commit point)
329    /// and `LockTimeout` (a contended `FOR UPDATE` row lock / deadlock) signal
330    /// retriable contention.
331    ///
332    /// # Examples
333    /// ```
334    /// use uni_common::UniError;
335    ///
336    /// assert!(UniError::SerializationConflict { message: "lost update".into() }.is_retriable());
337    /// assert!(!UniError::Schema { message: "no such label".into() }.is_retriable());
338    /// ```
339    #[must_use]
340    pub fn is_retriable(&self) -> bool {
341        matches!(
342            self,
343            UniError::SerializationConflict { .. }
344                | UniError::ConstraintConflict { .. }
345                | UniError::TransactionConflict { .. }
346                | UniError::CommitTimeout { .. }
347                | UniError::LockTimeout { .. }
348        )
349    }
350}
351
352pub type Result<T> = std::result::Result<T, UniError>;
353
354/// Why a Locy evaluation stopped before reaching its least fixed point.
355///
356/// A wall-clock timeout and a non-convergence failure are both *incomplete*
357/// outcomes, but they call for different remedies (raise the timeout / fix a
358/// slow rule vs. raise `max_iterations` / fix a non-monotone rule), so they are
359/// reported distinctly rather than collapsed into one flag.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum LocyIncompleteReason {
362    /// The wall-clock `timeout` budget was exhausted mid-evaluation.
363    Timeout,
364    /// A recursive stratum hit `max_iterations` without converging.
365    IterationLimit,
366}
367
368impl LocyIncompleteReason {
369    /// Returns a stable machine-readable tag (`"timeout"` / `"iteration_limit"`).
370    ///
371    /// Used as the discriminator surfaced to non-Rust callers (e.g. the Python
372    /// bindings), where matching on a Rust enum is not available.
373    #[must_use]
374    pub fn as_str(self) -> &'static str {
375        match self {
376            LocyIncompleteReason::Timeout => "timeout",
377            LocyIncompleteReason::IterationLimit => "iteration_limit",
378        }
379    }
380}
381
382impl std::fmt::Display for LocyIncompleteReason {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        f.write_str(self.as_str())
385    }
386}
387
388/// Diagnostics describing a Locy evaluation that stopped before completing.
389///
390/// Returned (boxed) inside [`UniError::LocyIncomplete`] when a program exceeds
391/// its time or iteration budget, and also attached to a `LocyResult` when the
392/// caller opts into partial results. The rule lists exist so a caller can tell
393/// "not evaluated" apart from "genuinely empty": any rule named in
394/// `incomplete_rules` or `skipped_rules` may be missing facts purely because
395/// evaluation was cut short, so a zero-row count for it is not authoritative.
396///
397/// # Examples
398/// ```
399/// use uni_common::{LocyIncomplete, LocyIncompleteReason};
400///
401/// let detail = LocyIncomplete {
402///     reason: LocyIncompleteReason::Timeout,
403///     elapsed_ms: 305_000,
404///     limit_ms: 300_000,
405///     max_iterations: 1000,
406///     completed_strata: 2,
407///     total_strata: 4,
408///     incomplete_rules: vec!["upstream_reaches".into()],
409///     skipped_rules: vec!["healthy_assets".into()],
410///     complement_rules_affected: vec!["healthy_assets".into()],
411/// };
412/// assert!(detail.to_string().contains("timeout"));
413/// assert!(detail.to_string().contains("UNSOUND"));
414/// ```
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct LocyIncomplete {
417    /// Why evaluation stopped.
418    pub reason: LocyIncompleteReason,
419    /// Wall-clock time elapsed when evaluation was cut short, in milliseconds.
420    pub elapsed_ms: u64,
421    /// The configured wall-clock `timeout`, in milliseconds.
422    pub limit_ms: u64,
423    /// The configured `max_iterations` cap for recursive strata.
424    pub max_iterations: usize,
425    /// Number of strata fully evaluated before the cutoff.
426    pub completed_strata: usize,
427    /// Total number of strata in the program.
428    pub total_strata: usize,
429    /// Rules in the stratum that was interrupted mid-evaluation. Their facts may
430    /// be a partial fixpoint rather than the least fixed point.
431    pub incomplete_rules: Vec<String>,
432    /// Rules in strata that were never reached. They derived no facts solely
433    /// because evaluation stopped first, not because their result is empty.
434    pub skipped_rules: Vec<String>,
435    /// Subset of the incomplete/skipped rules that use an `IS NOT` complement.
436    /// Stratified negation over a partial relation is unsound, so these results
437    /// must not be trusted at all — surfaced separately for emphasis.
438    pub complement_rules_affected: Vec<String>,
439}
440
441impl std::fmt::Display for LocyIncomplete {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        write!(
444            f,
445            "{reason} after {elapsed_ms}ms (limit {limit_ms}ms, max_iterations {max_iters}); \
446             evaluated {done}/{total} strata, {n_incomplete} rule(s) incomplete, \
447             {n_skipped} rule(s) skipped",
448            reason = self.reason,
449            elapsed_ms = self.elapsed_ms,
450            limit_ms = self.limit_ms,
451            max_iters = self.max_iterations,
452            done = self.completed_strata,
453            total = self.total_strata,
454            n_incomplete = self.incomplete_rules.len(),
455            n_skipped = self.skipped_rules.len(),
456        )?;
457        if !self.complement_rules_affected.is_empty() {
458            write!(
459                f,
460                "; UNSOUND complement rule(s) affected: {:?}",
461                self.complement_rules_affected
462            )?;
463        }
464        Ok(())
465    }
466}
467
468/// Why a GraphCompute invocation stopped before producing a complete result.
469///
470/// The three outcomes call for different remedies — raise the deadline, raise
471/// the iteration cap, or raise the native-work budget — so they are reported
472/// distinctly rather than collapsed into a single "incomplete" flag
473/// (GraphCompute proposal §5.2, error codes `0x865`–`0x867`).
474#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
475pub enum GraphComputeIncompleteReason {
476    /// The native-work budget (a multiple of `|E|` plus an absolute ceiling)
477    /// was drained by kernel work before the algorithm finished. Maps to `0x865`.
478    Exhausted,
479    /// A convergence loop reached its superstep/iteration cap without settling.
480    /// Maps to `0x866`.
481    IterationLimit,
482    /// The wall-clock deadline elapsed mid-invocation. Maps to `0x867`.
483    Timeout,
484}
485
486impl GraphComputeIncompleteReason {
487    /// Returns a stable machine-readable tag for non-Rust callers.
488    ///
489    /// One of `"exhausted"`, `"iteration_limit"`, or `"timeout"` — surfaced to
490    /// callers (e.g. the Python bindings) that cannot match on a Rust enum.
491    #[must_use]
492    pub fn as_str(self) -> &'static str {
493        match self {
494            GraphComputeIncompleteReason::Exhausted => "exhausted",
495            GraphComputeIncompleteReason::IterationLimit => "iteration_limit",
496            GraphComputeIncompleteReason::Timeout => "timeout",
497        }
498    }
499
500    /// Returns the GraphCompute error code (`0x865`–`0x867`) for this reason.
501    ///
502    /// Lets the loader shims map an incomplete outcome onto the pinned error
503    /// block (proposal §12) without re-deriving the mapping per loader.
504    #[must_use]
505    pub fn error_code(self) -> u32 {
506        match self {
507            GraphComputeIncompleteReason::Exhausted => 0x865,
508            GraphComputeIncompleteReason::IterationLimit => 0x866,
509            GraphComputeIncompleteReason::Timeout => 0x867,
510        }
511    }
512
513    /// Maps a GraphCompute error code (`0x865`–`0x867`) back to its reason.
514    ///
515    /// The inverse of [`error_code`](Self::error_code): the provider boundary
516    /// receives a typed `FnError` carrying one of these codes and reconstructs
517    /// the reason for the user-visible [`UniError::GraphComputeIncomplete`].
518    /// Any other code is not an incomplete outcome and yields `None`.
519    #[must_use]
520    pub fn from_error_code(code: u32) -> Option<Self> {
521        match code {
522            0x865 => Some(GraphComputeIncompleteReason::Exhausted),
523            0x866 => Some(GraphComputeIncompleteReason::IterationLimit),
524            0x867 => Some(GraphComputeIncompleteReason::Timeout),
525            _ => None,
526        }
527    }
528}
529
530impl std::fmt::Display for GraphComputeIncompleteReason {
531    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532        f.write_str(self.as_str())
533    }
534}
535
536/// Diagnostics describing a GraphCompute invocation that stopped early.
537///
538/// Returned (boxed) inside [`UniError::GraphComputeIncomplete`] when an
539/// invocation exceeds its native-work budget, iteration cap, or wall-clock
540/// deadline. The counters let a caller tell "too slow" apart from "did not
541/// converge" apart from "did too much work", and size a retry accordingly.
542///
543/// # Examples
544/// ```
545/// use uni_common::{GraphComputeIncomplete, GraphComputeIncompleteReason};
546///
547/// let detail = GraphComputeIncomplete {
548///     reason: GraphComputeIncompleteReason::Exhausted,
549///     algorithm: "guest.ppr".into(),
550///     elapsed_ms: 120,
551///     iterations: 7,
552///     work_charged: 1_000_000_000,
553///     work_budget: 1_000_000_000,
554/// };
555/// assert!(detail.to_string().contains("exhausted"));
556/// ```
557#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
558pub struct GraphComputeIncomplete {
559    /// Why the invocation stopped.
560    pub reason: GraphComputeIncompleteReason,
561    /// Qualified name of the algorithm being run, for the diagnostic message.
562    pub algorithm: String,
563    /// Wall-clock time elapsed when the invocation was cut short, in milliseconds.
564    pub elapsed_ms: u64,
565    /// Number of guest control-loop iterations completed before the cutoff.
566    pub iterations: u64,
567    /// Native work units charged when the invocation stopped (`0` if untracked).
568    pub work_charged: u64,
569    /// The configured native-work budget in the same units as `work_charged`.
570    pub work_budget: u64,
571}
572
573impl std::fmt::Display for GraphComputeIncomplete {
574    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575        write!(
576            f,
577            "{reason} in algorithm `{algo}` after {elapsed_ms}ms, {iters} iteration(s); \
578             charged {charged}/{budget} native-work units",
579            reason = self.reason,
580            algo = self.algorithm,
581            elapsed_ms = self.elapsed_ms,
582            iters = self.iterations,
583            charged = self.work_charged,
584            budget = self.work_budget,
585        )
586    }
587}
588
589/// Stable marker prefixing a serialized [`GraphComputeIncomplete`] in an error
590/// message, so the structured reason survives the DataFusion error channel.
591///
592/// The GraphCompute `CALL` runs through generic DataFusion machinery whose only
593/// egress is a stringified error (there is no bespoke execution node to carry an
594/// out-of-band slot, as Locy uses for `LocyIncomplete`). The provider serializes
595/// the diagnostics behind this tag; the query API boundary
596/// ([`GraphComputeIncomplete::from_tagged_message`]) recovers them into the typed
597/// [`UniError::GraphComputeIncomplete`]. This mirrors the codebase's existing
598/// message-classification boundary (e.g. `TypeError:`, `ConstraintVerificationFailed:`).
599pub const GRAPH_COMPUTE_INCOMPLETE_TAG: &str = "GraphComputeIncomplete:";
600
601impl GraphComputeIncomplete {
602    /// Serializes this diagnostic behind [`GRAPH_COMPUTE_INCOMPLETE_TAG`].
603    ///
604    /// The provider boundary returns the result as a `DataFusionError` message;
605    /// the query API recovers the struct with
606    /// [`from_tagged_message`](Self::from_tagged_message). Serialization cannot
607    /// fail for this plain-data struct, so a marshalling error degrades to the
608    /// bare tag rather than panicking.
609    #[must_use]
610    pub fn to_tagged_message(&self) -> String {
611        let json = serde_json::to_string(self).unwrap_or_default();
612        format!("{GRAPH_COMPUTE_INCOMPLETE_TAG}{json}")
613    }
614
615    /// Recovers a [`GraphComputeIncomplete`] from a tagged error message.
616    ///
617    /// Locates [`GRAPH_COMPUTE_INCOMPLETE_TAG`] anywhere in `msg` (upstream
618    /// layers prepend context such as `Algorithm 'x': `) and deserializes the
619    /// single JSON object that follows, ignoring any trailing text a later
620    /// error-wrapping layer may append. Returns `None` when the tag is absent or
621    /// the payload does not parse.
622    #[must_use]
623    pub fn from_tagged_message(msg: &str) -> Option<Self> {
624        let start = msg.find(GRAPH_COMPUTE_INCOMPLETE_TAG)? + GRAPH_COMPUTE_INCOMPLETE_TAG.len();
625        let payload = &msg[start..];
626        // A streaming deserializer reads exactly one JSON value and stops, so any
627        // suffix appended by an outer error wrapper is harmless.
628        let mut stream =
629            serde_json::Deserializer::from_str(payload).into_iter::<GraphComputeIncomplete>();
630        stream.next()?.ok()
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn graph_compute_incomplete_tag_round_trips() {
640        let original = GraphComputeIncomplete {
641            reason: GraphComputeIncompleteReason::IterationLimit,
642            // A qname with dots/quotes must survive the round trip intact.
643            algorithm: "guest.author's.ppr".into(),
644            elapsed_ms: 42,
645            iterations: 200,
646            work_charged: 1_234,
647            work_budget: 5_678,
648        };
649        let tagged = original.to_tagged_message();
650        assert!(tagged.starts_with(GRAPH_COMPUTE_INCOMPLETE_TAG));
651
652        // Recovered verbatim even when an outer layer wraps the message with a
653        // prefix and a trailing suffix (as the DataFusion channel does).
654        let wrapped = format!("Algorithm 'x': {tagged}\ncaused by: stream closed");
655        let recovered =
656            GraphComputeIncomplete::from_tagged_message(&wrapped).expect("tag must be recovered");
657        assert_eq!(recovered, original);
658    }
659
660    #[test]
661    fn graph_compute_incomplete_reason_code_round_trips() {
662        for reason in [
663            GraphComputeIncompleteReason::Exhausted,
664            GraphComputeIncompleteReason::IterationLimit,
665            GraphComputeIncompleteReason::Timeout,
666        ] {
667            assert_eq!(
668                GraphComputeIncompleteReason::from_error_code(reason.error_code()),
669                Some(reason)
670            );
671        }
672        // Codes outside the 0x865-0x867 incomplete block are not incomplete.
673        assert_eq!(GraphComputeIncompleteReason::from_error_code(0x860), None);
674    }
675
676    #[test]
677    fn untagged_message_yields_no_incomplete() {
678        assert!(GraphComputeIncomplete::from_tagged_message("Execution error: boom").is_none());
679    }
680
681    #[test]
682    fn retriable_errors_are_contention_failures() {
683        let s = String::new;
684        let retriable = [
685            UniError::SerializationConflict { message: s() },
686            UniError::ConstraintConflict { message: s() },
687            UniError::TransactionConflict { message: s() },
688            UniError::CommitTimeout {
689                tx_id: s(),
690                hint: "",
691            },
692            // A contended FOR UPDATE row lock / deadlock clears when the holder
693            // releases; a fresh transaction can retry and win it.
694            UniError::LockTimeout { timeout_ms: 10_000 },
695        ];
696        for e in &retriable {
697            assert!(e.is_retriable(), "{e:?} should be retriable");
698        }
699    }
700
701    #[test]
702    fn deterministic_errors_are_not_retriable() {
703        let s = String::new;
704        let terminal = [
705            UniError::Parse {
706                message: s(),
707                position: None,
708                line: None,
709                column: None,
710                context: None,
711            },
712            UniError::Query {
713                message: s(),
714                query: None,
715            },
716            UniError::Schema { message: s() },
717            UniError::Constraint { message: s() },
718            UniError::InvalidArgument {
719                arg: s(),
720                message: s(),
721            },
722            // A caller-set deadline is not a contention signal.
723            UniError::TransactionExpired {
724                tx_id: s(),
725                hint: "",
726            },
727            // Re-running the same slow operation would just time out again.
728            UniError::Timeout { timeout_ms: 1 },
729        ];
730        for e in &terminal {
731            assert!(!e.is_retriable(), "{e:?} should not be retriable");
732        }
733    }
734}