Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/DryDB/Internal/ReadOnlyMemoryStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace DryDB.Internal;

/// <summary>
/// Seekable read-only <see cref="Stream"/> view over a <see cref="ReadOnlyMemory{T}"/>,
/// used to feed the stream-based catalog parser without copying the caller's buffer.
/// </summary>
sealed class ReadOnlyMemoryStream(ReadOnlyMemory<byte> memory) : Stream
{
int position;

public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => memory.Length;

public override long Position
{
get => position;
set
{
if (value < 0 || value > memory.Length)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
position = (int)value;
}
}

public override int Read(Span<byte> buffer)
{
var length = Math.Min(buffer.Length, memory.Length - position);
memory.Span.Slice(position, length).CopyTo(buffer);
position += length;
return length;
}

public override int Read(byte[] buffer, int offset, int count) =>
Read(buffer.AsSpan(offset, count));

public override int ReadByte() =>
position < memory.Length ? memory.Span[position++] : -1;

public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
cancellationToken.IsCancellationRequested
? new ValueTask<int>(Task.FromCanceled<int>(cancellationToken))
: new ValueTask<int>(Read(buffer.Span));

public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();

public override long Seek(long offset, SeekOrigin origin)
{
Position = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => position + offset,
SeekOrigin.End => memory.Length + offset,
_ => throw new ArgumentOutOfRangeException(nameof(origin)),
};
return position;
}

public override void Flush()
{
}

public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
19 changes: 18 additions & 1 deletion src/DryDB/ReadOnlyDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ public record DatabaseLoadOptions

if (stream is MemoryStream ms)
{
return new InMemoryPageLoader(ms.ToArray());
// Read directly from the stream's buffer when it is exposable: the
// loader then aliases the live buffer, so the stream must not be
// written to while the database is open.
return ms.TryGetBuffer(out var buffer)
? new InMemoryPageLoader(buffer)
: new InMemoryPageLoader(ms.ToArray());
}

throw new NotSupportedException($"unsupported stream type: {stream.GetType().Name}");
Expand All @@ -51,6 +56,18 @@ public static async ValueTask<ReadOnlyDatabase> OpenAsync(Stream stream, Databas
return new ReadOnlyDatabase(catalog, storage, options);
}

/// <summary>
/// Opens a database held entirely in memory without copying it. The memory is
/// read for the lifetime of the database, so it must not change until the
/// database is disposed.
/// </summary>
public static async ValueTask<ReadOnlyDatabase> OpenAsync(ReadOnlyMemory<byte> data, DatabaseLoadOptions? options = null, CancellationToken cancellationToken = default)
{
options ??= DatabaseLoadOptions.Default;
var catalog = await DryDBCodec.ParseCatalogAsync(new ReadOnlyMemoryStream(data), cancellationToken);
return new ReadOnlyDatabase(catalog, new InMemoryPageLoader(data), options);
}

public Catalog Catalog { get; }
readonly IPageLoader pageLoader;
readonly PageCache pageCache;
Expand Down
2 changes: 1 addition & 1 deletion src/DryDB/Storages/InMemoryPageLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

namespace DryDB.Storages;

public class InMemoryPageLoader(Memory<byte> memory) : IPageLoader
public class InMemoryPageLoader(ReadOnlyMemory<byte> memory) : IPageLoader
{
public void Dispose()
{
Expand Down
69 changes: 69 additions & 0 deletions tests/DryDB.Tests/ReadOnlyDatabaseTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Threading.Tasks;

namespace DryDB.Tests;

[TestFixture]
public class ReadOnlyDatabaseTest
{
[Test]
public async Task OpenFromMemory()
{
var bytes = await BuildDatabaseBytesAsync();

using var database = await ReadOnlyDatabase.OpenAsync((ReadOnlyMemory<byte>)bytes);
var table = database.GetTable("items");

var result = await table.GetAsync("item2"u8.ToArray());
Assert.That(result.Value.Span.SequenceEqual("value2"u8), Is.True);
}

[Test]
public async Task OpenFromExposableMemoryStream()
{
var memoryStream = new MemoryStream();
var builder = CreateBuilder();
await builder.BuildToStreamAsync(memoryStream);
memoryStream.Seek(0, SeekOrigin.Begin);

// TryGetBuffer succeeds here: the loader reads the stream's own buffer.
using var database = await ReadOnlyDatabase.OpenAsync(memoryStream);
var table = database.GetTable("items");

var result = await table.GetAsync("item1"u8.ToArray());
Assert.That(result.Value.Span.SequenceEqual("value1"u8), Is.True);
}

[Test]
public async Task OpenFromNonExposableMemoryStream()
{
var bytes = await BuildDatabaseBytesAsync();

// A byte[]-wrapping MemoryStream does not expose its buffer;
// this exercises the ToArray fallback.
using var database = await ReadOnlyDatabase.OpenAsync(new MemoryStream(bytes));
var table = database.GetTable("items");

var result = await table.GetAsync("item3"u8.ToArray());
Assert.That(result.Value.Span.SequenceEqual("value3"u8), Is.True);
}

static async ValueTask<byte[]> BuildDatabaseBytesAsync()
{
var memoryStream = new MemoryStream();
var builder = CreateBuilder();
await builder.BuildToStreamAsync(memoryStream);
return memoryStream.ToArray();
}

static DatabaseBuilder CreateBuilder()
{
var builder = new DatabaseBuilder();
var itemsTable = builder.CreateTable("items");
itemsTable.Append("item1"u8.ToArray(), "value1"u8.ToArray());
itemsTable.Append("item2"u8.ToArray(), "value2"u8.ToArray());
itemsTable.Append("item3"u8.ToArray(), "value3"u8.ToArray());
return builder;
}
}
Loading