-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathProgram.cs
243 lines (198 loc) · 9.55 KB
/
Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
using System.IO.Compression;
using Microsoft.AspNetCore.Http.Features;
using System.Threading;
var builder = WebApplication.CreateBuilder(args);
// Configure Kestrel for container environment
builder.WebHost.ConfigureKestrel(options =>
{
// Disable request size limit
options.Limits.MaxRequestBodySize = null;
// Disable minimum response rate requirements - prevents automatic client disconnection on slow networks
options.Limits.MinResponseDataRate = null;
// Limit the number of concurrent connections to prevent resource exhaustion in K8s pod
options.Limits.MaxConcurrentConnections = 100;
// Allow synchronous IO operations
options.AllowSynchronousIO = true;
});
var app = builder.Build();
// Configure minimal pipeline for performance
app.UseRouting();
// Endpoint to serve folder as zip download
app.MapGet("/download/{folderName}", async (string folderName, HttpContext context, bool calculateLength = true) =>
{
try
{
// Base directory - customize this path as needed
string baseDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Folders");
string folderPath = Path.Combine(baseDirectory, folderName);
// Validate path and check if directory exists
if (!Directory.Exists(folderPath) ||
!Path.GetFullPath(folderPath).StartsWith(Path.GetFullPath(baseDirectory)))
{
return Results.NotFound($"Folder '{folderName}' not found or access denied.");
}
// Set response headers for optimal download handling
context.Response.ContentType = "application/zip";
context.Response.Headers.ContentDisposition = $"attachment; filename=\"{folderName}.zip\"";
context.Response.Headers.CacheControl = "no-cache, no-store";
context.Response.Headers.Pragma = "no-cache";
// Explicitly disable response buffering for Kestrel
var responseBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
if (responseBodyFeature != null)
{
responseBodyFeature.DisableBuffering();
}
// Set a cancellation token that combines client disconnects with a reasonable timeout
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromHours(1));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, timeoutCts.Token);
var cancellationToken = linkedCts.Token;
// Calculate content length only if requested and feasible
if (calculateLength)
{
try
{
// Use timeout for content length calculation to prevent hanging (20 seconds)
using var contentLengthCts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
using var contentLengthLinkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, contentLengthCts.Token);
long contentLength = await CalculateZipContentLengthAsync(folderPath, contentLengthLinkedCts.Token);
context.Response.Headers.ContentLength = contentLength;
}
catch (OperationCanceledException)
{
// Return specific error for timeout
Console.Error.WriteLine($"Content length calculation timed out for folder: {folderName}");
return Results.Problem(
title: "Processing Error",
detail: "The folder is too large to process in a reasonable time",
statusCode: 413); // 413 = Payload Too Large
}
catch (Exception ex)
{
// Log general errors but don't expose details to client
Console.Error.WriteLine($"Error calculating content length: {ex.Message}");
return Results.Problem(
title: "Processing Error",
statusCode: 500);
}
}
// Stream the zip archive directly to the client
await StreamZipArchiveAsync(folderPath, context.Response.Body, cancellationToken);
// Ensure final flush
await context.Response.Body.FlushAsync(cancellationToken);
return Results.Empty;
}
catch (OperationCanceledException)
{
// Client disconnected - we'll return right away
context.Abort();
return Results.Empty;
}
catch (Exception ex)
{
// Log the error, but don't expose details to client
Console.Error.WriteLine($"Error serving zip: {ex.Message}");
return Results.Problem("An error occurred while generating the download", statusCode: 500);
}
});
app.Run();
// Content length calculation with timeout
static async Task<long> CalculateZipContentLengthAsync(string folderPath, CancellationToken cancellationToken)
{
var countingStream = new CountingStream();
await StreamZipArchiveAsync(folderPath, countingStream, cancellationToken);
return countingStream.Length;
}
// Optimized zip streaming for Kubernetes container environment
static async Task StreamZipArchiveAsync(string folderPath, Stream outputStream, CancellationToken cancellationToken)
{
using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
var folder = new DirectoryInfo(folderPath);
var baseOffset = folder.FullName.Length + 1;
// Get directory info for file enumeration
// Using EnumerateFiles to avoid loading all paths into memory at once
var allFiles = Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories);
// Optimization: Don't create more buffers than necessary
const int bufferSize = 65536; // 64KB seems to be a sweet spot for most environments
byte[] buffer = new byte[bufferSize];
int filesProcessed = 0;
long totalBytesWritten = 0;
DateTime lastResourceCheck = DateTime.UtcNow;
foreach (var file in allFiles)
{
cancellationToken.ThrowIfCancellationRequested();
var fileInfo = new FileInfo(file);
var entryName = file.Substring(baseOffset);
var entry = archive.CreateEntry(entryName, CompressionLevel.Fastest);
using var entryStream = entry.Open();
using var fileStream = new FileStream(
file,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
int bytesRead;
int unflushedBytes = 0;
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0)
{
await entryStream.WriteAsync(buffer, 0, bytesRead, cancellationToken);
unflushedBytes += bytesRead;
totalBytesWritten += bytesRead;
// Flush more frequently with smaller files, less frequently with large files
// Small files: flush after about 512KB
// Large files (>500MB): flush after about 4MB
int flushThreshold = fileInfo.Length > 500_000_000 ? 4_194_304 : 524_288;
if (unflushedBytes >= flushThreshold)
{
await entryStream.FlushAsync(cancellationToken);
unflushedBytes = 0;
// Also monitor total bytes between output stream flushes
if (totalBytesWritten >= 20_971_520) // 20MB
{
await outputStream.FlushAsync(cancellationToken);
totalBytesWritten = 0;
// After significant output is flushed, give a small yield for system to process
await Task.Delay(1, cancellationToken);
}
}
// Periodically check system resources (roughly every 100MB processed)
if (totalBytesWritten > 104_857_600 && (DateTime.UtcNow - lastResourceCheck).TotalSeconds > 5)
{
// If available memory is getting low, force GC
if (Environment.WorkingSet > 200_000_000) // 200MB working set as a threshold
{
GC.Collect(0, GCCollectionMode.Optimized, false);
}
lastResourceCheck = DateTime.UtcNow;
}
}
// Always flush after each file
await entryStream.FlushAsync(cancellationToken);
filesProcessed++;
if (filesProcessed % 20 == 0) // Every 20 files
{
await outputStream.FlushAsync(cancellationToken);
}
}
}
// CountingStream to measure size without storing data
public class CountingStream : Stream
{
private long _length;
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => _length;
public override long Position { get => _length; set => throw new NotSupportedException(); }
public override void Flush() { }
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) => _length += count;
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
_length += count;
return Task.CompletedTask;
}
}