sqrtspace-dotnet/src/SqrtSpace.SpaceTime.AspNetCore/SpaceTimeStreamingExtensions.cs
2025-07-20 03:41:39 -04:00

350 lines
10 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using SqrtSpace.SpaceTime.Core;
namespace SqrtSpace.SpaceTime.AspNetCore;
/// <summary>
/// Extensions for streaming large responses with √n memory usage
/// </summary>
public static class SpaceTimeStreamingExtensions
{
/// <summary>
/// Writes a large enumerable as JSON stream with √n buffering
/// </summary>
public static async Task WriteAsJsonStreamAsync<T>(
this HttpResponse response,
IAsyncEnumerable<T> items,
JsonSerializerOptions? options = null,
CancellationToken cancellationToken = default)
{
response.ContentType = "application/json";
response.Headers.Add("X-SpaceTime-Streaming", "sqrtn");
await using var writer = new Utf8JsonWriter(response.Body, new JsonWriterOptions
{
Indented = options?.WriteIndented ?? false
});
writer.WriteStartArray();
var count = 0;
var bufferSize = SpaceTimeCalculator.CalculateSqrtInterval(100_000); // Estimate
var buffer = new List<T>(bufferSize);
await foreach (var item in items.WithCancellation(cancellationToken))
{
buffer.Add(item);
count++;
if (buffer.Count >= bufferSize)
{
await FlushBufferAsync(writer, buffer, options, cancellationToken);
buffer.Clear();
await response.Body.FlushAsync(cancellationToken);
}
}
// Flush remaining items
if (buffer.Count > 0)
{
await FlushBufferAsync(writer, buffer, options, cancellationToken);
}
writer.WriteEndArray();
await writer.FlushAsync(cancellationToken);
}
/// <summary>
/// Creates an async enumerable result with √n chunking
/// </summary>
public static IActionResult StreamWithSqrtNChunking<T>(
this ControllerBase controller,
IAsyncEnumerable<T> items,
int? estimatedCount = null)
{
return new SpaceTimeStreamResult<T>(items, estimatedCount);
}
private static async Task FlushBufferAsync<T>(
Utf8JsonWriter writer,
List<T> buffer,
JsonSerializerOptions? options,
CancellationToken cancellationToken)
{
foreach (var item in buffer)
{
JsonSerializer.Serialize(writer, item, options);
cancellationToken.ThrowIfCancellationRequested();
}
}
}
/// <summary>
/// Action result for streaming with SpaceTime optimizations
/// </summary>
public class SpaceTimeStreamResult<T> : IActionResult
{
private readonly IAsyncEnumerable<T> _items;
private readonly int? _estimatedCount;
public SpaceTimeStreamResult(IAsyncEnumerable<T> items, int? estimatedCount = null)
{
_items = items;
_estimatedCount = estimatedCount;
}
public async Task ExecuteResultAsync(ActionContext context)
{
var response = context.HttpContext.Response;
response.ContentType = "application/json";
response.Headers.Add("X-SpaceTime-Streaming", "chunked");
if (_estimatedCount.HasValue)
{
response.Headers.Add("X-Total-Count", _estimatedCount.Value.ToString());
}
await response.WriteAsJsonStreamAsync(_items, cancellationToken: context.HttpContext.RequestAborted);
}
}
/// <summary>
/// Attribute to configure streaming behavior
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class SpaceTimeStreamingAttribute : Attribute
{
/// <summary>
/// Chunk size strategy
/// </summary>
public ChunkStrategy ChunkStrategy { get; set; } = ChunkStrategy.SqrtN;
/// <summary>
/// Custom chunk size (if not using automatic strategies)
/// </summary>
public int? ChunkSize { get; set; }
/// <summary>
/// Whether to include progress headers
/// </summary>
public bool IncludeProgress { get; set; } = true;
}
/// <summary>
/// Strategies for determining chunk size
/// </summary>
public enum ChunkStrategy
{
/// <summary>Use √n of estimated total</summary>
SqrtN,
/// <summary>Fixed size chunks</summary>
Fixed,
/// <summary>Adaptive based on response time</summary>
Adaptive
}
/// <summary>
/// Extensions for streaming file downloads
/// </summary>
public static class FileStreamingExtensions
{
/// <summary>
/// Streams a file with √n buffer size
/// </summary>
public static async Task StreamFileWithSqrtNBufferAsync(
this HttpResponse response,
string filePath,
string? contentType = null,
CancellationToken cancellationToken = default)
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
{
response.StatusCode = 404;
return;
}
var bufferSize = (int)SpaceTimeCalculator.CalculateOptimalBufferSize(
fileInfo.Length,
4 * 1024 * 1024); // Max 4MB buffer
response.ContentType = contentType ?? "application/octet-stream";
response.ContentLength = fileInfo.Length;
response.Headers.Add("X-SpaceTime-Buffer-Size", bufferSize.ToString());
await using var fileStream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize,
useAsync: true);
await fileStream.CopyToAsync(response.Body, bufferSize, cancellationToken);
}
}
/// <summary>
/// Middleware for automatic response streaming optimization
/// </summary>
public class ResponseStreamingMiddleware
{
private readonly RequestDelegate _next;
private readonly ResponseStreamingOptions _options;
public ResponseStreamingMiddleware(RequestDelegate next, ResponseStreamingOptions options)
{
_next = next;
_options = options;
}
public async Task InvokeAsync(HttpContext context)
{
// Check if response should be streamed
if (_options.EnableAutoStreaming && IsLargeResponse(context))
{
// Replace response body with buffered stream
var originalBody = context.Response.Body;
using var bufferStream = new SqrtNBufferedStream(originalBody, _options.MaxBufferSize);
context.Response.Body = bufferStream;
try
{
await _next(context);
}
finally
{
context.Response.Body = originalBody;
}
}
else
{
await _next(context);
}
}
private bool IsLargeResponse(HttpContext context)
{
// Check endpoint metadata
var endpoint = context.GetEndpoint();
var streamingAttr = endpoint?.Metadata.GetMetadata<SpaceTimeStreamingAttribute>();
return streamingAttr != null;
}
}
/// <summary>
/// Options for response streaming middleware
/// </summary>
public class ResponseStreamingOptions
{
/// <summary>
/// Enable automatic streaming optimization
/// </summary>
public bool EnableAutoStreaming { get; set; } = true;
/// <summary>
/// Maximum buffer size in bytes
/// </summary>
public int MaxBufferSize { get; set; } = 4 * 1024 * 1024; // 4MB
}
/// <summary>
/// Stream that buffers using √n strategy
/// </summary>
internal class SqrtNBufferedStream : Stream
{
private readonly Stream _innerStream;
private readonly int _bufferSize;
private readonly byte[] _buffer;
private int _bufferPosition;
public SqrtNBufferedStream(Stream innerStream, int maxBufferSize)
{
_innerStream = innerStream;
_bufferSize = Math.Min(maxBufferSize, SpaceTimeCalculator.CalculateSqrtInterval(1_000_000) * 1024);
_buffer = new byte[_bufferSize];
}
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => _innerStream.CanWrite;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
if (_bufferPosition > 0)
{
_innerStream.Write(_buffer, 0, _bufferPosition);
_bufferPosition = 0;
}
_innerStream.Flush();
}
public override async Task FlushAsync(CancellationToken cancellationToken)
{
if (_bufferPosition > 0)
{
await _innerStream.WriteAsync(_buffer.AsMemory(0, _bufferPosition), cancellationToken);
_bufferPosition = 0;
}
await _innerStream.FlushAsync(cancellationToken);
}
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count)
{
while (count > 0)
{
var bytesToCopy = Math.Min(count, _bufferSize - _bufferPosition);
Buffer.BlockCopy(buffer, offset, _buffer, _bufferPosition, bytesToCopy);
_bufferPosition += bytesToCopy;
offset += bytesToCopy;
count -= bytesToCopy;
if (_bufferPosition >= _bufferSize)
{
Flush();
}
}
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
var remaining = buffer;
while (remaining.Length > 0)
{
var bytesToCopy = Math.Min(remaining.Length, _bufferSize - _bufferPosition);
remaining.Slice(0, bytesToCopy).CopyTo(_buffer.AsMemory(_bufferPosition));
_bufferPosition += bytesToCopy;
remaining = remaining.Slice(bytesToCopy);
if (_bufferPosition >= _bufferSize)
{
await FlushAsync(cancellationToken);
}
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Flush();
}
base.Dispose(disposing);
}
}