mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-13 11:17:50 -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>
324 lines
9.7 KiB
C#
324 lines
9.7 KiB
C#
namespace SpacetimeDB.Internal;
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using SpacetimeDB.BSATN;
|
|
|
|
public abstract class IndexBase<Row>
|
|
where Row : IStructuralReadWrite, new()
|
|
{
|
|
internal readonly FFI.IndexId indexId;
|
|
|
|
public IndexBase(string name)
|
|
{
|
|
var name_bytes = System.Text.Encoding.UTF8.GetBytes(name);
|
|
FFI.index_id_from_name(name_bytes, (uint)name_bytes.Length, out indexId);
|
|
}
|
|
|
|
private static void ToParams<Bounds>(
|
|
Bounds bounds,
|
|
out FFI.ColId prefixElems,
|
|
out ReadOnlySpan<byte> prefix,
|
|
out ReadOnlySpan<byte> rstart,
|
|
out ReadOnlySpan<byte> rend
|
|
)
|
|
where Bounds : IBTreeIndexBounds
|
|
{
|
|
prefixElems = new FFI.ColId(bounds.PrefixElems);
|
|
|
|
using var s = new MemoryStream();
|
|
using var w = new BinaryWriter(s);
|
|
bounds.Prefix(w);
|
|
var prefix_idx = (int)s.Length;
|
|
bounds.RStart(w);
|
|
var rstart_idx = (int)s.Length;
|
|
bounds.REnd(w);
|
|
var rend_idx = (int)s.Length;
|
|
|
|
var bytes = s.GetBuffer().AsSpan();
|
|
prefix = bytes[..prefix_idx];
|
|
rstart = bytes[prefix_idx..rstart_idx];
|
|
rend = bytes[rstart_idx..rend_idx];
|
|
}
|
|
|
|
protected IEnumerable<Row> DoFilter<Bounds>(Bounds bounds)
|
|
where Bounds : IBTreeIndexBounds => new RawTableIter<Bounds>(indexId, bounds).Parse();
|
|
|
|
protected uint DoDelete<Bounds>(Bounds bounds)
|
|
where Bounds : IBTreeIndexBounds
|
|
{
|
|
ToParams(bounds, out var prefixElems, out var prefix, out var rstart, out var rend);
|
|
FFI.datastore_delete_by_index_scan_range_bsatn(
|
|
indexId,
|
|
prefix,
|
|
(uint)prefix.Length,
|
|
prefixElems,
|
|
rstart,
|
|
(uint)rstart.Length,
|
|
rend,
|
|
(uint)rend.Length,
|
|
out var out_
|
|
);
|
|
return out_;
|
|
}
|
|
|
|
private class RawTableIter<Bounds>(FFI.IndexId indexId, Bounds bounds) : RawTableIterBase<Row>
|
|
where Bounds : IBTreeIndexBounds
|
|
{
|
|
protected override void IterStart(out FFI.RowIter handle)
|
|
{
|
|
ToParams(bounds, out var prefixElems, out var prefix, out var rstart, out var rend);
|
|
FFI.datastore_index_scan_range_bsatn(
|
|
indexId,
|
|
prefix,
|
|
(uint)prefix.Length,
|
|
prefixElems,
|
|
rstart,
|
|
(uint)rstart.Length,
|
|
rend,
|
|
(uint)rend.Length,
|
|
out handle
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
public abstract class ReadOnlyIndexBase<Row>(string name) : IndexBase<Row>(name)
|
|
where Row : IStructuralReadWrite, new()
|
|
{
|
|
protected IEnumerable<Row> Filter<Bounds>(Bounds bounds)
|
|
where Bounds : IBTreeIndexBounds => DoFilter(bounds);
|
|
}
|
|
|
|
public abstract class UniqueIndex<Handle, Row, T, RW>(string name) : IndexBase<Row>(name)
|
|
where Handle : ITableView<Handle, Row>
|
|
where Row : struct, IStructuralReadWrite
|
|
where RW : struct, BSATN.IReadWrite<T>
|
|
{
|
|
private static BTreeIndexBounds<T, RW> ToBounds(T key) => new(key);
|
|
|
|
private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase<Row>
|
|
{
|
|
protected override void IterStart(out FFI.RowIter handle) =>
|
|
FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle);
|
|
}
|
|
|
|
protected IEnumerable<Row> DoFilter(T key) => DoFilter(ToBounds(key));
|
|
|
|
public bool Delete(T key)
|
|
{
|
|
using var s = new MemoryStream();
|
|
using var w = new BinaryWriter(s);
|
|
new RW().Write(w, key);
|
|
var point = s.ToArray();
|
|
FFI.datastore_delete_by_index_scan_point_bsatn(
|
|
indexId,
|
|
point,
|
|
(uint)point.Length,
|
|
out var numDeleted
|
|
);
|
|
return numDeleted > 0;
|
|
}
|
|
|
|
protected Row? FindSingle(T key)
|
|
{
|
|
using var s = new MemoryStream();
|
|
using var w = new BinaryWriter(s);
|
|
new RW().Write(w, key);
|
|
var point = s.ToArray();
|
|
|
|
using var e = new RawPointIter(indexId, point).Parse().GetEnumerator();
|
|
if (!e.MoveNext())
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var row = e.Current;
|
|
if (e.MoveNext())
|
|
{
|
|
throw new InvalidOperationException("Unique index point scan returned >1 rows");
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
protected Row DoUpdate(Row row)
|
|
{
|
|
// Insert the row.
|
|
var bytes = IStructuralReadWrite.ToBytes(row);
|
|
var bytes_len = (uint)bytes.Length;
|
|
FFI.datastore_update_bsatn(ITableView<Handle, Row>.tableId, indexId, bytes, ref bytes_len);
|
|
|
|
return ITableView<Handle, Row>.IntegrateGeneratedColumns(row, bytes, bytes_len);
|
|
}
|
|
}
|
|
|
|
public abstract class RefUniqueIndex<Handle, Row, T, RW>(string name) : IndexBase<Row>(name)
|
|
where Handle : ITableView<Handle, Row>
|
|
where Row : class, IStructuralReadWrite, new()
|
|
where RW : struct, BSATN.IReadWrite<T>
|
|
{
|
|
private static BTreeIndexBounds<T, RW> ToBounds(T key) => new(key);
|
|
|
|
private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase<Row>
|
|
{
|
|
protected override void IterStart(out FFI.RowIter handle) =>
|
|
FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle);
|
|
}
|
|
|
|
protected IEnumerable<Row> DoFilter(T key) => DoFilter(ToBounds(key));
|
|
|
|
public bool Delete(T key)
|
|
{
|
|
using var s = new MemoryStream();
|
|
using var w = new BinaryWriter(s);
|
|
new RW().Write(w, key);
|
|
var point = s.ToArray();
|
|
FFI.datastore_delete_by_index_scan_point_bsatn(
|
|
indexId,
|
|
point,
|
|
(uint)point.Length,
|
|
out var numDeleted
|
|
);
|
|
return numDeleted > 0;
|
|
}
|
|
|
|
protected Row? FindSingle(T key)
|
|
{
|
|
using var s = new MemoryStream();
|
|
using var w = new BinaryWriter(s);
|
|
new RW().Write(w, key);
|
|
var point = s.ToArray();
|
|
|
|
using var e = new RawPointIter(indexId, point).Parse().GetEnumerator();
|
|
if (!e.MoveNext())
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var row = e.Current;
|
|
if (e.MoveNext())
|
|
{
|
|
throw new InvalidOperationException("Unique index point scan returned >1 rows");
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
protected Row DoUpdate(Row row)
|
|
{
|
|
// Insert the row.
|
|
var bytes = IStructuralReadWrite.ToBytes(row);
|
|
var bytes_len = (uint)bytes.Length;
|
|
FFI.datastore_update_bsatn(ITableView<Handle, Row>.tableId, indexId, bytes, ref bytes_len);
|
|
|
|
return ITableView<Handle, Row>.IntegrateGeneratedColumns(row, bytes, bytes_len);
|
|
}
|
|
}
|
|
|
|
public abstract class ReadOnlyUniqueIndex<Handle, Row, T, RW>(string name)
|
|
: ReadOnlyIndexBase<Row>(name)
|
|
where Handle : ReadOnlyTableView<Row>
|
|
where Row : struct, IStructuralReadWrite
|
|
where RW : struct, BSATN.IReadWrite<T>
|
|
{
|
|
private static BTreeIndexBounds<T, RW> ToBounds(T key) => new(key);
|
|
|
|
private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase<Row>
|
|
{
|
|
protected override void IterStart(out FFI.RowIter handle) =>
|
|
FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle);
|
|
}
|
|
|
|
protected IEnumerable<Row> Filter(T key) => Filter(ToBounds(key));
|
|
|
|
protected Row? FindSingle(T key)
|
|
{
|
|
using var s = new MemoryStream();
|
|
using var w = new BinaryWriter(s);
|
|
new RW().Write(w, key);
|
|
var point = s.ToArray();
|
|
|
|
using var e = new RawPointIter(indexId, point).Parse().GetEnumerator();
|
|
if (!e.MoveNext())
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var row = e.Current;
|
|
if (e.MoveNext())
|
|
{
|
|
throw new InvalidOperationException("Unique index point scan returned >1 rows");
|
|
}
|
|
|
|
return row;
|
|
}
|
|
}
|
|
|
|
public abstract class ReadOnlyRefUniqueIndex<Handle, Row, T, RW>(string name)
|
|
: ReadOnlyIndexBase<Row>(name)
|
|
where Handle : ReadOnlyTableView<Row>
|
|
where Row : class, IStructuralReadWrite, new()
|
|
where RW : struct, BSATN.IReadWrite<T>
|
|
{
|
|
private static BTreeIndexBounds<T, RW> ToBounds(T key) => new(key);
|
|
|
|
private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase<Row>
|
|
{
|
|
protected override void IterStart(out FFI.RowIter handle) =>
|
|
FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle);
|
|
}
|
|
|
|
protected IEnumerable<Row> Filter(T key) => Filter(ToBounds(key));
|
|
|
|
protected Row? FindSingle(T key)
|
|
{
|
|
using var s = new MemoryStream();
|
|
using var w = new BinaryWriter(s);
|
|
new RW().Write(w, key);
|
|
var point = s.ToArray();
|
|
|
|
using var e = new RawPointIter(indexId, point).Parse().GetEnumerator();
|
|
if (!e.MoveNext())
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var row = e.Current;
|
|
if (e.MoveNext())
|
|
{
|
|
throw new InvalidOperationException("Unique index point scan returned >1 rows");
|
|
}
|
|
|
|
return row;
|
|
}
|
|
}
|
|
|
|
public abstract class ReadOnlyTableView<Row>
|
|
where Row : IStructuralReadWrite, new()
|
|
{
|
|
private readonly FFI.TableId tableId;
|
|
|
|
private sealed class TableIter(FFI.TableId tableId) : RawTableIterBase<Row>
|
|
{
|
|
protected override void IterStart(out FFI.RowIter handle) =>
|
|
FFI.datastore_table_scan_bsatn(tableId, out handle);
|
|
}
|
|
|
|
protected ReadOnlyTableView(string tableName)
|
|
{
|
|
var nameBytes = Encoding.UTF8.GetBytes(tableName);
|
|
FFI.table_id_from_name(nameBytes, (uint)nameBytes.Length, out tableId);
|
|
}
|
|
|
|
protected ulong DoCount()
|
|
{
|
|
FFI.datastore_table_row_count(tableId, out var count);
|
|
return count;
|
|
}
|
|
|
|
protected IEnumerable<Row> DoIter() => new TableIter(tableId).Parse();
|
|
}
|