350 lines
10 KiB
C#
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);
|
|
}
|
|
} |