Skip to content

Commit 304e80c

Browse files
Cappu7inoSTARGAZER
andauthored
feat: expose typed V3 native error codes (#19)
Co-authored-by: STARGAZER <stargazer@XingdeMacBook-Air.local>
1 parent ff91614 commit 304e80c

5 files changed

Lines changed: 195 additions & 28 deletions

File tree

docs/architecture/native-interop.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,6 @@ Multiple V3 clients share the same process-wide Tokio runtime. This reduces thre
8585

8686
## Error Handling
8787

88-
Native failures are surfaced as managed exceptions that include operation context and the native last-error message when available. Agents should preserve these messages in diagnostics and not replace them with generic errors.
88+
Native failures are surfaced as managed exceptions that include operation context, the native last-error message, and the native error code when available. Agents should preserve these messages in diagnostics and not replace them with generic errors.
8989

90-
The native ABI also exposes stable integer error codes for engine-level last errors and async operation failures. `0` means success/no error; non-zero values distinguish invalid requests, missing tables, Delta/DataFusion/Arrow/JSON failures, internal failures, and cancellation. Managed code still includes the native message in exceptions, but cancellation detection should prefer the typed async error code and use message matching only as a compatibility fallback.
90+
The native ABI exposes stable integer error codes for engine-level last errors and async operation failures. `0` means success/no error; non-zero values distinguish invalid requests, missing tables, Delta/DataFusion/Arrow/JSON failures, internal failures, and cancellation. Managed code maps these values to an internal `NativeServiceErrorCode` enum for diagnostics and control flow. Cancellation detection uses the typed async error code first and message matching only as a compatibility fallback.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
6+
namespace DeltaLakeSharp.Client.Internal.Native
7+
{
8+
internal readonly struct NativeErrorInfo
9+
{
10+
public NativeErrorInfo(int rawCode, string? message)
11+
{
12+
RawCode = rawCode;
13+
Code = Enum.IsDefined(typeof(NativeServiceErrorCode), rawCode)
14+
? (NativeServiceErrorCode)rawCode
15+
: NativeServiceErrorCode.Internal;
16+
Message = message;
17+
}
18+
19+
public int RawCode { get; }
20+
21+
public NativeServiceErrorCode Code { get; }
22+
23+
public string? Message { get; }
24+
25+
public bool HasError => RawCode != (int)NativeServiceErrorCode.Ok || !string.IsNullOrWhiteSpace(Message);
26+
27+
public bool HasKnownCode => Enum.IsDefined(typeof(NativeServiceErrorCode), RawCode);
28+
}
29+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
namespace DeltaLakeSharp.Client.Internal.Native
5+
{
6+
/// <summary>
7+
/// Stable native service error codes exposed by the Rust V3 ABI.
8+
/// </summary>
9+
/// <remarks>
10+
/// Numeric values must match ServiceErrorCode in src/DeltaLakeSharp.Server/v3/src/error.rs.
11+
/// </remarks>
12+
internal enum NativeServiceErrorCode
13+
{
14+
Ok = 0,
15+
InvalidRequest = 1,
16+
TableNotFound = 2,
17+
Delta = 3,
18+
DataFusion = 4,
19+
Arrow = 5,
20+
Json = 6,
21+
Internal = 7,
22+
Cancelled = 8,
23+
}
24+
}

src/DeltaLakeSharp.Client/Internal/NativeRustBackend.cs

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ internal sealed class NativeRustBackend : IDeltaLakeBackend
3030
private const int NativeAsyncOperationSucceeded = 1;
3131
private const int NativeAsyncOperationFailed = 2;
3232
private const int NativeAsyncOperationCancelled = 3;
33-
private const int NativeServiceErrorOk = 0;
34-
private const int NativeServiceErrorCancelled = 8;
3533

3634
private static readonly NativeMethods.NativeAsyncOperationCompletedCallback NativeAsyncOperationCompleted = OnNativeAsyncOperationCompleted;
3735

@@ -612,18 +610,17 @@ public void Dispose()
612610

613611
private InvalidOperationException CreateNativeOperationFailedException(string operation)
614612
{
615-
int lastErrorCode = NativeMethods.GetLastErrorCode(_engine.DangerousGetHandle());
616-
string? lastError = GetLastErrorMessage();
613+
NativeErrorInfo error = GetLastError();
617614
string message = $"Native V3 backend operation '{operation}' failed.";
618615

619-
if (lastErrorCode != NativeServiceErrorOk)
616+
if (error.RawCode != (int)NativeServiceErrorCode.Ok)
620617
{
621-
message += $" Native error code: {lastErrorCode}.";
618+
message += $" Native error code: {FormatNativeErrorCode(error)}.";
622619
}
623620

624-
if (!string.IsNullOrWhiteSpace(lastError))
621+
if (!string.IsNullOrWhiteSpace(error.Message))
625622
{
626-
message += $" Native error: {lastError}";
623+
message += $" Native error: {error.Message}";
627624
}
628625

629626
return new InvalidOperationException(message);
@@ -633,24 +630,44 @@ private static InvalidOperationException CreateNativeAsyncOperationFailedExcepti
633630
IntPtr operation,
634631
string operationName)
635632
{
636-
int errorCode = NativeMethods.AsyncOperationGetErrorCode(operation);
637-
IntPtr errorPtr = NativeMethods.AsyncOperationGetError(operation);
638-
string? error = NativeMethods.PtrToStringUtf8(errorPtr);
633+
NativeErrorInfo error = GetAsyncOperationError(operation);
639634
string message = $"Native V3 backend operation '{operationName}' failed.";
640635

641-
if (errorCode != NativeServiceErrorOk)
636+
if (error.RawCode != (int)NativeServiceErrorCode.Ok)
642637
{
643-
message += $" Native error code: {errorCode}.";
638+
message += $" Native error code: {FormatNativeErrorCode(error)}.";
644639
}
645640

646-
if (!string.IsNullOrWhiteSpace(error))
641+
if (!string.IsNullOrWhiteSpace(error.Message))
647642
{
648-
message += $" Native error: {error}";
643+
message += $" Native error: {error.Message}";
649644
}
650645

651646
return new InvalidOperationException(message);
652647
}
653648

649+
private NativeErrorInfo GetLastError()
650+
{
651+
IntPtr engine = _engine.DangerousGetHandle();
652+
return new NativeErrorInfo(
653+
NativeMethods.GetLastErrorCode(engine),
654+
NativeMethods.PtrToStringUtf8(NativeMethods.GetLastError(engine)));
655+
}
656+
657+
private static NativeErrorInfo GetAsyncOperationError(IntPtr operation)
658+
{
659+
return new NativeErrorInfo(
660+
NativeMethods.AsyncOperationGetErrorCode(operation),
661+
NativeMethods.PtrToStringUtf8(NativeMethods.AsyncOperationGetError(operation)));
662+
}
663+
664+
private static string FormatNativeErrorCode(NativeErrorInfo error)
665+
{
666+
return error.HasKnownCode
667+
? $"{error.RawCode} ({error.Code})"
668+
: $"{error.RawCode} (Unknown)";
669+
}
670+
654671
private static void OnNativeAsyncOperationCompleted(IntPtr operation, IntPtr userData)
655672
{
656673
if (userData == IntPtr.Zero)
@@ -849,12 +866,6 @@ private static unsafe Schema TakeNativeAsyncSchemaResult(
849866
}
850867
}
851868

852-
private string? GetLastErrorMessage()
853-
{
854-
return NativeMethods.PtrToStringUtf8(
855-
NativeMethods.GetLastError(_engine.DangerousGetHandle()));
856-
}
857-
858869
/// <summary>
859870
/// Exports a managed Arrow stream to the native Rust backend using the
860871
/// Arrow C Stream interface.
@@ -1424,15 +1435,14 @@ private void Cancel()
14241435

14251436
private static bool IsNativeCancellationFailure(IntPtr operation)
14261437
{
1427-
int errorCode = NativeMethods.AsyncOperationGetErrorCode(operation);
1428-
if (errorCode == NativeServiceErrorCancelled)
1438+
NativeErrorInfo errorInfo = GetAsyncOperationError(operation);
1439+
if (errorInfo.Code == NativeServiceErrorCode.Cancelled)
14291440
{
14301441
return true;
14311442
}
14321443

1433-
string? error = NativeMethods.PtrToStringUtf8(NativeMethods.AsyncOperationGetError(operation));
1434-
return error != null
1435-
&& error.IndexOf("cancel", StringComparison.OrdinalIgnoreCase) >= 0;
1444+
return errorInfo.Message != null
1445+
&& errorInfo.Message.IndexOf("cancel", StringComparison.OrdinalIgnoreCase) >= 0;
14361446
}
14371447
}
14381448

tests/DeltaLakeSharp.Tests/NativeRustBackendTests.cs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Apache.Arrow.Types;
1515
using DeltaLakeSharp.Client;
1616
using DeltaLakeSharp.Client.Internal;
17+
using DeltaLakeSharp.Client.Internal.Native;
1718
using DeltaLakeSharp.Client.Models;
1819
using Microsoft.VisualStudio.TestTools.UnitTesting;
1920

@@ -29,6 +30,12 @@ namespace DeltaLakeSharp.Tests
2930
[TestClass]
3031
public class NativeRustBackendTests
3132
{
33+
private const int NativeAsyncOperationPending = 0;
34+
private const int NativeAsyncOperationFailed = 2;
35+
36+
private static readonly NativeMethods.NativeAsyncOperationCompletedCallback NoOpNativeAsyncOperationCompleted =
37+
static (_, _) => { };
38+
3239
/// <summary>
3340
/// Creates a unique local directory for a native Delta table test.
3441
/// Each test gets its own isolated folder so cleanup can safely remove
@@ -49,6 +56,99 @@ private static void CleanupTablePath(string tablePath)
4956
}
5057
}
5158

59+
private static int WaitForNativeAsyncOperation(IntPtr operation)
60+
{
61+
for (int i = 0; i < 200; i++)
62+
{
63+
int status = NativeMethods.AsyncOperationStatus(operation);
64+
if (status != NativeAsyncOperationPending)
65+
{
66+
return status;
67+
}
68+
69+
Thread.Sleep(TimeSpan.FromMilliseconds(10));
70+
}
71+
72+
return NativeMethods.AsyncOperationStatus(operation);
73+
}
74+
75+
[TestMethod]
76+
public void NativeServiceErrorCode_NumericValuesMatchNativeAbi()
77+
{
78+
Assert.AreEqual(0, (int)NativeServiceErrorCode.Ok);
79+
Assert.AreEqual(1, (int)NativeServiceErrorCode.InvalidRequest);
80+
Assert.AreEqual(2, (int)NativeServiceErrorCode.TableNotFound);
81+
Assert.AreEqual(3, (int)NativeServiceErrorCode.Delta);
82+
Assert.AreEqual(4, (int)NativeServiceErrorCode.DataFusion);
83+
Assert.AreEqual(5, (int)NativeServiceErrorCode.Arrow);
84+
Assert.AreEqual(6, (int)NativeServiceErrorCode.Json);
85+
Assert.AreEqual(7, (int)NativeServiceErrorCode.Internal);
86+
Assert.AreEqual(8, (int)NativeServiceErrorCode.Cancelled);
87+
}
88+
89+
[TestMethod]
90+
public void NativeErrorInfo_KnownCodesPreserveTypedClassification()
91+
{
92+
foreach (NativeServiceErrorCode code in Enum.GetValues(typeof(NativeServiceErrorCode)))
93+
{
94+
var error = new NativeErrorInfo(
95+
(int)code,
96+
code == NativeServiceErrorCode.Ok ? null : code.ToString());
97+
98+
Assert.AreEqual(code, error.Code);
99+
Assert.AreEqual((int)code, error.RawCode);
100+
Assert.IsTrue(error.HasKnownCode);
101+
Assert.AreEqual(code != NativeServiceErrorCode.Ok, error.HasError);
102+
}
103+
}
104+
105+
[TestMethod]
106+
public void NativeErrorInfo_UnknownCodePreservesRawValueAndMapsToInternal()
107+
{
108+
var error = new NativeErrorInfo(999, "future native code");
109+
110+
Assert.AreEqual(999, error.RawCode);
111+
Assert.AreEqual(NativeServiceErrorCode.Internal, error.Code);
112+
Assert.IsFalse(error.HasKnownCode);
113+
Assert.IsTrue(error.HasError);
114+
}
115+
116+
[TestMethod]
117+
public void NativeAsyncOperation_MalformedCommandReturnsJsonErrorCode()
118+
{
119+
NativeMethods.EnsureLoaded();
120+
IntPtr engine = NativeMethods.CreateEngine();
121+
Assert.AreNotEqual(IntPtr.Zero, engine);
122+
123+
IntPtr operation = IntPtr.Zero;
124+
try
125+
{
126+
operation = NativeMethods.GetSchemaAsyncWithCallback(
127+
engine,
128+
"{",
129+
NoOpNativeAsyncOperationCompleted,
130+
IntPtr.Zero);
131+
Assert.AreNotEqual(IntPtr.Zero, operation);
132+
133+
Assert.AreEqual(NativeAsyncOperationFailed, WaitForNativeAsyncOperation(operation));
134+
Assert.AreEqual(
135+
(int)NativeServiceErrorCode.Json,
136+
NativeMethods.AsyncOperationGetErrorCode(operation));
137+
138+
string? error = NativeMethods.PtrToStringUtf8(NativeMethods.AsyncOperationGetError(operation));
139+
StringAssert.Contains(error ?? string.Empty, "json");
140+
}
141+
finally
142+
{
143+
if (operation != IntPtr.Zero)
144+
{
145+
NativeMethods.AsyncOperationDestroy(operation);
146+
}
147+
148+
NativeMethods.DestroyEngine(engine);
149+
}
150+
}
151+
52152
[TestMethod]
53153
public async Task NativeBackend_HealthCheck_ReturnsTrue()
54154
{
@@ -460,6 +560,10 @@ public async Task NativeBackend_GetArrowSchemaAsync_MissingTableThrowsNativeErro
460560

461561
StringAssert.Contains(ex.Message, "GetArrowSchemaAsync");
462562
StringAssert.Contains(ex.Message, "Native error code:");
563+
Assert.IsTrue(
564+
ex.Message.Contains("(Delta)", StringComparison.Ordinal)
565+
|| ex.Message.Contains("(InvalidRequest)", StringComparison.Ordinal),
566+
ex.Message);
463567
StringAssert.Contains(ex.Message, "Native error");
464568
}
465569

0 commit comments

Comments
 (0)