mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-14 11:48:28 -04:00
8a0cd87c4f
# Description of Changes To resolve #3875, we added exact-match unique index point lookup support to the C# bindings by introducing and using `datastore_index_scan_point_bsatn`. Previously, generated unique index `Find()` was (in at least one codepath) implemented as: * A range scan (`datastore_index_scan_range_bsatn`) over a BTree bound, then * `SingleOrDefault()` to collapse the results into a single row. When the scan is empty, `SingleOrDefault()` returns `default(T)`. For value-type rows this can manifest as a default-initialized row instead of “missing”, which is what surfaced as “default-ish row” behavior in views. Using `datastore_index_scan_point_bsatn` makes the C# implementation match Rust semantics more closely by performing an exact point lookup and returning: * `null` when no rows are found * the row when exactly one row is found * (defensively) an error if >1 row is returned (unique index invariant violation) Similarly, `datastore_delete_by_index_scan_point_bsatn` was added and used so deletes-by-unique-key are also exact-match point operations rather than range deletes. Runtime updates were made to utilize point scan in `FindSingle(key)` and in both mutable/read-only unique-index paths. To keep this non-breaking for existing modules, codegen now detects whether the table row is a struct or a class and chooses the appropriate base type: * Struct rows: `Find()` returns `Row?` (`Nullable<Row>`). * Class rows: `Find()` returns `Row?` (nullable reference, `null` on miss). # API and ABI breaking changes This change is non-breaking with respect to row type kinds, because class/record table rows continue to work via RefUniqueIndex/ReadOnlyRefUniqueIndex while struct rows use UniqueIndex/ReadOnlyUniqueIndex. API surface changes: * Generated `Find()` return type is now nullable (`Row?`) to correctly represent “missing”. ABI/runtime: * Requires the point-scan hostcall import (`datastore_index_scan_point_bsatn`) to be available; the runtime uses point-scan for unique lookup (and point delete for unique delete). # Expected complexity level and risk Low 2 # Testing - [X] Local testing: repro module + client validate view and direct Find() behavior --------- Signed-off-by: rekhoff <r.ekhoff@clockworklabs.io>
383 lines
12 KiB
C#
383 lines
12 KiB
C#
namespace SpacetimeDB.Internal;
|
|
|
|
using System.Runtime.InteropServices;
|
|
using System.Runtime.InteropServices.Marshalling;
|
|
|
|
// This type is outside of the hidden `FFI` class because for now we need to do some public
|
|
// forwarding in the codegen for `__describe_module__` and `__call_reducer__` exports which both
|
|
// use this type.
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly record struct BytesSource(uint Handle)
|
|
{
|
|
public static readonly BytesSource INVALID = new(0);
|
|
}
|
|
|
|
// This type is outside of the hidden `FFI` class because for now we need to do some public
|
|
// forwarding in the codegen for `__describe_module__` and `__call_reducer__` exports which both
|
|
// use this type.
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly record struct BytesSink(uint Handle) { }
|
|
|
|
public enum Errno : short
|
|
{
|
|
EXHAUSTED = -1,
|
|
OK = 0,
|
|
HOST_CALL_FAILURE = 1,
|
|
NOT_IN_TRANSACTION = 2,
|
|
BSATN_DECODE_ERROR = 3,
|
|
NO_SUCH_TABLE = 4,
|
|
NO_SUCH_INDEX = 5,
|
|
NO_SUCH_ITER = 6,
|
|
NO_SUCH_CONSOLE_TIMER = 7,
|
|
NO_SUCH_BYTES = 8,
|
|
NO_SPACE = 9,
|
|
BUFFER_TOO_SMALL = 11,
|
|
UNIQUE_ALREADY_EXISTS = 12,
|
|
SCHEDULE_AT_DELAY_TOO_LONG = 13,
|
|
INDEX_NOT_UNIQUE = 14,
|
|
NO_SUCH_ROW = 15,
|
|
AUTO_INC_OVERFLOW = 16,
|
|
WOULD_BLOCK_TRANSACTION = 17,
|
|
TRANSACTION_NOT_ANONYMOUS = 18,
|
|
TRANSACTION_IS_READ_ONLY = 19,
|
|
TRANSACTION_IS_MUT = 20,
|
|
}
|
|
|
|
#pragma warning disable IDE1006 // Naming Styles - Not applicable to FFI stuff.
|
|
internal static partial class FFI
|
|
{
|
|
// For now this must match the name of the `.c` file (`bindings.c`).
|
|
// In the future C# will allow to specify Wasm import namespace in
|
|
// `LibraryImport` directly.
|
|
const string StdbNamespace10_0 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.0"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_1 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.1"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_2 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.2"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_3 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.3"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
const string StdbNamespace10_4 =
|
|
#if EXPERIMENTAL_WASM_AOT
|
|
"spacetime_10.4"
|
|
#else
|
|
"bindings"
|
|
#endif
|
|
;
|
|
|
|
[NativeMarshalling(typeof(Marshaller))]
|
|
public struct CheckedStatus
|
|
{
|
|
// This custom marshaller takes care of checking the status code
|
|
// returned from the host and throwing an exception if it's not 0.
|
|
// The only reason it doesn't return `void` is because the C# compiler
|
|
// doesn't treat `void` as a real type and doesn't allow it to be returned
|
|
// from custom marshallers, so we resort to an empty struct instead.
|
|
[CustomMarshaller(
|
|
typeof(CheckedStatus),
|
|
MarshalMode.ManagedToUnmanagedOut,
|
|
typeof(Marshaller)
|
|
)]
|
|
internal static class Marshaller
|
|
{
|
|
public static CheckedStatus ConvertToManaged(Errno status)
|
|
{
|
|
ErrnoHelpers.ThrowIfError(status);
|
|
return default;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class ErrnoHelpers
|
|
{
|
|
public static void ThrowIfError(Errno status)
|
|
{
|
|
if (status == Errno.OK)
|
|
{
|
|
return;
|
|
}
|
|
|
|
throw ToException(status);
|
|
}
|
|
|
|
public static Exception ToException(Errno status) =>
|
|
status switch
|
|
{
|
|
Errno.NOT_IN_TRANSACTION => new NotInTransactionException(),
|
|
Errno.BSATN_DECODE_ERROR => new BsatnDecodeException(),
|
|
Errno.NO_SUCH_TABLE => new NoSuchTableException(),
|
|
Errno.NO_SUCH_INDEX => new NoSuchIndexException(),
|
|
Errno.NO_SUCH_ITER => new NoSuchIterException(),
|
|
Errno.NO_SUCH_CONSOLE_TIMER => new NoSuchLogStopwatch(),
|
|
Errno.NO_SUCH_BYTES => new NoSuchBytesException(),
|
|
Errno.NO_SPACE => new NoSpaceException(),
|
|
Errno.BUFFER_TOO_SMALL => new BufferTooSmallException(),
|
|
Errno.UNIQUE_ALREADY_EXISTS => new UniqueConstraintViolationException(),
|
|
Errno.INDEX_NOT_UNIQUE => new IndexNotUniqueException(),
|
|
Errno.NO_SUCH_ROW => new NoSuchRowException(),
|
|
Errno.AUTO_INC_OVERFLOW => new AutoIncOverflowException(),
|
|
Errno.WOULD_BLOCK_TRANSACTION => new TransactionWouldBlockException(),
|
|
Errno.TRANSACTION_NOT_ANONYMOUS => new TransactionNotAnonymousException(),
|
|
Errno.TRANSACTION_IS_READ_ONLY => new TransactionIsReadOnlyException(),
|
|
Errno.TRANSACTION_IS_MUT => new TransactionIsMutableException(),
|
|
_ => new UnknownException(status),
|
|
};
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct TableId
|
|
{
|
|
private readonly uint table_id;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct IndexId
|
|
{
|
|
private readonly uint index_id;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct ColId(ushort col_id)
|
|
{
|
|
private readonly ushort col_id = col_id;
|
|
|
|
public static explicit operator ushort(ColId col_id) => col_id.col_id;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct IndexType
|
|
{
|
|
private readonly byte index_type;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly record struct RowIter(uint Handle)
|
|
{
|
|
public static readonly RowIter INVALID = new(0);
|
|
}
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus table_id_from_name(
|
|
[In] byte[] name,
|
|
uint name_len,
|
|
out TableId out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus index_id_from_name(
|
|
[In] byte[] name,
|
|
uint name_len,
|
|
out IndexId out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_table_row_count(TableId table_id, out ulong out_);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_table_scan_bsatn(
|
|
TableId table_id,
|
|
out RowIter out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_4)]
|
|
public static partial CheckedStatus datastore_index_scan_point_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> point,
|
|
uint point_len,
|
|
out RowIter out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_4)]
|
|
public static partial CheckedStatus datastore_delete_by_index_scan_point_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> point,
|
|
uint point_len,
|
|
out uint out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_index_scan_range_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> prefix,
|
|
uint prefix_len,
|
|
ColId prefix_elems,
|
|
ReadOnlySpan<byte> rstart,
|
|
uint rstart_len,
|
|
ReadOnlySpan<byte> rend,
|
|
uint rend_len,
|
|
out RowIter out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial Errno row_iter_bsatn_advance(
|
|
RowIter iter_handle,
|
|
[MarshalUsing(CountElementName = nameof(buffer_len))] [Out] byte[] buffer,
|
|
ref uint buffer_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus row_iter_bsatn_close(RowIter iter_handle);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_insert_bsatn(
|
|
TableId table_id,
|
|
Span<byte> row,
|
|
ref uint row_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_update_bsatn(
|
|
TableId table_id,
|
|
IndexId index_id,
|
|
Span<byte> row,
|
|
ref uint row_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_delete_by_index_scan_range_bsatn(
|
|
IndexId index_id,
|
|
ReadOnlySpan<byte> prefix,
|
|
uint prefix_len,
|
|
ColId prefix_elems,
|
|
ReadOnlySpan<byte> rstart,
|
|
uint rstart_len,
|
|
ReadOnlySpan<byte> rend,
|
|
uint rend_len,
|
|
out uint out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus datastore_delete_all_by_eq_bsatn(
|
|
TableId table_id,
|
|
[In] byte[] relation,
|
|
uint relation_len,
|
|
out uint out_
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial Errno bytes_source_read(
|
|
BytesSource source,
|
|
Span<byte> buffer,
|
|
ref uint buffer_len
|
|
);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus bytes_sink_write(
|
|
BytesSink sink,
|
|
ReadOnlySpan<byte> buffer,
|
|
ref uint buffer_len
|
|
);
|
|
|
|
public enum LogLevel : byte
|
|
{
|
|
Error = 0,
|
|
Warn = 1,
|
|
Info = 2,
|
|
Debug = 3,
|
|
Trace = 4,
|
|
Panic = 5,
|
|
}
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial void console_log(
|
|
LogLevel level,
|
|
[In] byte[] target,
|
|
uint target_len,
|
|
[In] byte[] filename,
|
|
uint filename_len,
|
|
uint line_number,
|
|
[In] byte[] message,
|
|
uint message_len
|
|
);
|
|
|
|
[NativeMarshalling(typeof(ConsoleTimerIdMarshaller))]
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
public readonly struct ConsoleTimerId
|
|
{
|
|
private readonly uint timer_id;
|
|
|
|
private ConsoleTimerId(uint id)
|
|
{
|
|
timer_id = id;
|
|
}
|
|
|
|
//LayoutKind.Sequential is apparently not enough for this struct to be returnable in PInvoke, so we need a custom marshaller unfortunately
|
|
[CustomMarshaller(
|
|
typeof(ConsoleTimerId),
|
|
MarshalMode.Default,
|
|
typeof(ConsoleTimerIdMarshaller)
|
|
)]
|
|
internal static class ConsoleTimerIdMarshaller
|
|
{
|
|
public static ConsoleTimerId ConvertToManaged(uint id) => new ConsoleTimerId(id);
|
|
|
|
public static uint ConvertToUnmanaged(ConsoleTimerId id) => id.timer_id;
|
|
}
|
|
}
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial ConsoleTimerId console_timer_start([In] byte[] name, uint name_len);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial CheckedStatus console_timer_end(ConsoleTimerId stopwatch_id);
|
|
|
|
[LibraryImport(StdbNamespace10_0)]
|
|
public static partial void volatile_nonatomic_schedule_immediate(
|
|
[In] byte[] name,
|
|
uint name_len,
|
|
[In] byte[] args,
|
|
uint args_len
|
|
);
|
|
|
|
// Note #1: our Identity type has the same layout as a fixed-size 32-byte little-endian buffer,
|
|
// so instead of working around C#'s lack of fixed-size arrays, we just accept the pointer to
|
|
// the Identity itself. In this regard it's different from Rust declaration, but is still
|
|
// functionally the same.
|
|
// Note #2: we can't use `LibraryImport` here due to https://github.com/dotnet/runtime/issues/98616
|
|
// which prevents source-generated PInvokes from working with types from other assemblies, and
|
|
// `Identity` lives in another assembly (`BSATN.Runtime`). Luckily, `DllImport` is enough here.
|
|
#pragma warning disable SYSLIB1054 // Suppress "Use 'LibraryImportAttribute' instead of 'DllImportAttribute'" warning.
|
|
[DllImport(StdbNamespace10_0)]
|
|
public static extern void identity(out Identity dest);
|
|
#pragma warning restore SYSLIB1054
|
|
|
|
[DllImport(StdbNamespace10_1)]
|
|
public static extern Errno bytes_source_remaining_length(BytesSource source, ref uint len);
|
|
|
|
[DllImport(StdbNamespace10_2)]
|
|
public static extern Errno get_jwt(ref ConnectionId connectionId, out BytesSource source);
|
|
|
|
[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_start_mut_tx")]
|
|
public static partial Errno procedure_start_mut_tx(out long micros);
|
|
|
|
[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_commit_mut_tx")]
|
|
public static partial Errno procedure_commit_mut_tx();
|
|
|
|
[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_abort_mut_tx")]
|
|
public static partial Errno procedure_abort_mut_tx();
|
|
}
|